Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
usageFreetier,
usageStats,
usageSummary,
usageTokenPlan,
pipelineRun,
pipelineValidate,
advisorRecommend,
Expand Down Expand Up @@ -163,6 +164,7 @@ export const commands: Record<string, AnyCommand> = {
"usage freetier": usageFreetier,
"usage stats": usageStats,
"usage summary": usageSummary,
"usage token-plan": usageTokenPlan,
"pipeline run": pipelineRun,
"pipeline validate": pipelineValidate,
"advisor recommend": advisorRecommend,
Expand Down
181 changes: 181 additions & 0 deletions packages/commands/src/commands/usage/token-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { BailianError, ExitCode, defineCommand, unwrapResponse } from "bailian-cli-core";
import { ansi, displayWidth, emitResult, type TextStyle } from "bailian-cli-runtime";

const TOKEN_PLAN_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
const BOX_WIDTH = 76;
const PROGRESS_WIDTH = 32;

interface TokenPlanUsage {
per5HourPercentage?: number;
per5HourResetTime?: number;
per1WeekPercentage?: number;
per1WeekResetTime?: number;
}

function readUsage(result: unknown): TokenPlanUsage {
const response = unwrapResponse(result as Record<string, unknown>);
const usage = {
per5HourPercentage: response.per5HourPercentage,
per5HourResetTime: response.per5HourResetTime,
per1WeekPercentage: response.per1WeekPercentage,
per1WeekResetTime: response.per1WeekResetTime,
};

const quotas = [
[usage.per5HourPercentage, usage.per5HourResetTime],
[usage.per1WeekPercentage, usage.per1WeekResetTime],
];
const hasValidQuotas = quotas.every(
([percentage, resetTime]) =>
(percentage === undefined && resetTime === undefined) ||
(typeof percentage === "number" &&
Number.isFinite(percentage) &&
((percentage === 0 && resetTime === undefined) ||
(typeof resetTime === "number" && Number.isFinite(resetTime)))),
);

if (!hasValidQuotas) {
throw new BailianError("Token Plan usage response has an unexpected format.", ExitCode.GENERAL);
}

return usage as TokenPlanUsage;
}

function formatPercentage(ratio: number): string {
return `${(ratio * 100).toFixed(2)}%`;
}

function formatDateTime(timestamp: number): string {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hour = String(date.getHours()).padStart(2, "0");
const minute = String(date.getMinutes()).padStart(2, "0");
const second = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}

function formatRemainingTime(resetTime: number, now: number): string {
const remainingMs = Math.max(0, resetTime - now);
const totalMinutes = Math.floor(remainingMs / 60_000);
if (totalMinutes === 0) return "now";

const days = Math.floor(totalMinutes / (24 * 60));
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
const minutes = totalMinutes % 60;
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
return parts.join(" ");
}

function progressBar(ratio: number): string {
const clampedRatio = Math.min(1, Math.max(0, ratio));
const filled = Math.round(clampedRatio * PROGRESS_WIDTH);
return `[${"█".repeat(filled)}${"░".repeat(PROGRESS_WIDTH - filled)}]`;
}

function progressStyle(
percentage: number,
green: TextStyle,
yellow: TextStyle,
red: TextStyle,
): TextStyle {
if (percentage >= 0.9) return red;
if (percentage >= 0.75) return yellow;
return green;
}

function printView(usage: TokenPlanUsage, generatedAt: number): void {
const color = ansi(process.stdout);
const writeLine = (content = "", visibleContent = content) => {
const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${visibleContent}`));
process.stdout.write(`│ ${content}${" ".repeat(padding)}│\n`);
};
const writeQuota = (
label: string,
unlimitedMessage: string,
percentage: number | undefined,
resetTime: number | undefined,
) => {
writeLine(color.bold(label), label);
if (percentage === undefined) {
writeLine(color.dim(unlimitedMessage), unlimitedMessage);
return;
}

const percentageText = formatPercentage(percentage);
const bar = progressBar(percentage);
const style = progressStyle(percentage, color.green, color.yellow, color.red);
writeLine(`${percentageText} used ${style(bar)}`, `${percentageText} used ${bar}`);
if (resetTime === undefined) {
writeLine(
color.dim("Resets: not applicable (no usage yet)"),
"Resets: not applicable (no usage yet)",
);
return;
}

const resetText = `Resets: ${formatDateTime(resetTime)} (in ${formatRemainingTime(resetTime, generatedAt)})`;
writeLine(color.dim(resetText), resetText);
};

process.stdout.write(`┌${"─".repeat(BOX_WIDTH)}┐\n`);
writeLine(color.cyan("Token Plan Usage"), "Token Plan Usage");
const generatedAtText = `Generated at: ${formatDateTime(generatedAt)} (local time)`;
writeLine(color.dim(generatedAtText), generatedAtText);
process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`);
writeQuota(
"5-hour quota",
"5小时限额当前可能无限制,请到百炼 Token Plan 控制台核实。",
usage.per5HourPercentage,
usage.per5HourResetTime,
);
process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`);
writeQuota(
"1-week quota",
"1周限额当前可能无限制,请到百炼 Token Plan 控制台核实。",
usage.per1WeekPercentage,
usage.per1WeekResetTime,
);
process.stdout.write(`└${"─".repeat(BOX_WIDTH)}┘\n`);
}

export default defineCommand({
description: "Show Token Plan quota usage as core JSON or a human-readable view",
auth: "console",
usageArgs: "<--json | --view> [flags]",
flags: {
json: {
type: "switch",
description: "Output only the four core usage fields as JSON",
},
view: {
type: "switch",
description: "Render a compact human-readable quota view",
},
},
exampleArgs: ["--json", "--view"],
validate: (flags) =>
flags.json === flags.view ? "Choose exactly one of --json or --view." : undefined,
async run(ctx) {
const { flags, settings } = ctx;

if (settings.dryRun) {
emitResult({ api: TOKEN_PLAN_USAGE_API, data: {} }, "json");
return;
}

const result = await ctx.client.console(TOKEN_PLAN_USAGE_API, {});
const usage = readUsage(result);

if (flags.json) {
emitResult(usage, "json");
return;
}

printView(usage, Date.now());
},
});
1 change: 1 addition & 0 deletions packages/commands/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export { default as usageFree } from "./commands/usage/free.ts";
export { default as usageFreetier } from "./commands/usage/freetier.ts";
export { default as usageStats } from "./commands/usage/stats.ts";
export { default as usageSummary } from "./commands/usage/summary.ts";
export { default as usageTokenPlan } from "./commands/usage/token-plan.ts";
export { default as pipelineRun } from "./commands/pipeline/run.ts";
export { default as pipelineValidate } from "./commands/pipeline/validate.ts";
export { default as advisorRecommend } from "./commands/advisor/recommend.ts";
Expand Down
1 change: 1 addition & 0 deletions packages/commands/tests/e2e/topic-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const USAGE_ROUTES: E2eRouteExports = {
"usage free": "usageFree",
"usage freetier": "usageFreetier",
"usage stats": "usageStats",
"usage token-plan": "usageTokenPlan",
};

export const DEPLOY_ROUTES: E2eRouteExports = {
Expand Down
90 changes: 90 additions & 0 deletions packages/commands/tests/e2e/usage-token-plan.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, test } from "vite-plus/test";
import {
isConsoleAuthFailure,
isConsoleE2EReady,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
import { USAGE_ROUTES } from "./topic-routes.ts";

describe("e2e: usage token-plan", () => {
test("usage token-plan --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--json|--view|Token Plan/i);
});

test("usage token-plan 未选择输出形式时退出为用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("Choose exactly one of --json or --view.");
});

test("usage token-plan 同时选择两种输出形式时退出为用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--json",
"--view",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("Choose exactly one of --json or --view.");
});
});

describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () => {
test("usage token-plan --json --dry-run 输出网关请求计划", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--json",
"--dry-run",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ api?: string; data?: Record<string, unknown> }>(stdout);
expect(data.api).toBe("zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage");
expect(data.data).toEqual({});
});

test("usage token-plan --json 返回可用的额度字段", async () => {
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
const data = parseStdoutJson<{
per5HourPercentage?: number;
per5HourResetTime?: number;
per1WeekPercentage?: number;
per1WeekResetTime?: number;
}>(result.stdout);
const quotas = [
[data.per5HourPercentage, data.per5HourResetTime],
[data.per1WeekPercentage, data.per1WeekResetTime],
];
for (const [percentage, resetTime] of quotas) {
if (percentage === undefined) expect(resetTime).toBeUndefined();
else if (percentage === 0) expect(resetTime).toBeUndefined();
else {
expect(percentage).toBeTypeOf("number");
expect(resetTime).toBeTypeOf("number");
}
}
});

test("usage token-plan --view 渲染生成时间与两个额度窗口", async () => {
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--view"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout).toContain("Generated at:");
expect(result.stdout).toContain("5-hour quota");
expect(result.stdout).toContain("1-week quota");
});
});
Loading