diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 2cbda30d..6b130caa 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -45,6 +45,7 @@ import { usageFreetier, usageStats, usageSummary, + usageTokenPlan, pipelineRun, pipelineValidate, advisorRecommend, @@ -163,6 +164,7 @@ export const commands: Record = { "usage freetier": usageFreetier, "usage stats": usageStats, "usage summary": usageSummary, + "usage token-plan": usageTokenPlan, "pipeline run": pipelineRun, "pipeline validate": pipelineValidate, "advisor recommend": advisorRecommend, diff --git a/packages/commands/src/commands/usage/token-plan.ts b/packages/commands/src/commands/usage/token-plan.ts new file mode 100644 index 00000000..76c48767 --- /dev/null +++ b/packages/commands/src/commands/usage/token-plan.ts @@ -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); + 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()); + }, +}); diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index cba8b321..70485b18 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -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"; diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index 35497843..48fd7591 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -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 = { diff --git a/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts b/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts new file mode 100644 index 00000000..1acf052f --- /dev/null +++ b/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts @@ -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 }>(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"); + }); +}); diff --git a/packages/commands/tests/token-plan-usage.test.ts b/packages/commands/tests/token-plan-usage.test.ts new file mode 100644 index 00000000..9a1596c3 --- /dev/null +++ b/packages/commands/tests/token-plan-usage.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import tokenPlanUsage from "../src/commands/usage/token-plan.ts"; + +const originalNoColor = process.env.NO_COLOR; +const originalIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + +afterEach(() => { + if (originalNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = originalNoColor; + if (originalIsTty) Object.defineProperty(process.stdout, "isTTY", originalIsTty); + else delete (process.stdout as { isTTY?: boolean }).isTTY; + vi.restoreAllMocks(); +}); + +function makeUsageResponse( + per5HourPercentage?: number, + per1WeekPercentage = per5HourPercentage, +): Record { + const usage: Record = {}; + if (per5HourPercentage !== undefined) { + usage.per5HourPercentage = per5HourPercentage; + if (per5HourPercentage !== 0) usage.per5HourResetTime = 1_786_000_000_000; + } + if (per1WeekPercentage !== undefined) { + usage.per1WeekPercentage = per1WeekPercentage; + if (per1WeekPercentage !== 0) usage.per1WeekResetTime = 1_786_100_000_000; + } + + return { + data: { + DataV2: { + data: { + data: usage, + }, + }, + }, + }; +} + +describe("usage token-plan view", () => { + test.each([ + [0.7499, "32"], + [0.75, "33"], + [0.9, "31"], + ])("uses ANSI color %s for %s", async (percentage, colorCode) => { + delete process.env.NO_COLOR; + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await tokenPlanUsage.run({ + client: { console: vi.fn().mockResolvedValue(makeUsageResponse(percentage)) }, + flags: { json: false, view: true }, + settings: { dryRun: false }, + } as never); + + expect(output.join("")).toContain(`\u001B[${colorCode}m[`); + }); + + test("accepts missing reset times when the quota usage is zero", async () => { + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await tokenPlanUsage.run({ + client: { console: vi.fn().mockResolvedValue(makeUsageResponse(0)) }, + flags: { json: false, view: true }, + settings: { dryRun: false }, + } as never); + + expect(output.join("")).toContain("Resets: not applicable (no usage yet)"); + }); + + test("allows one unused quota window without masking another reset time", async () => { + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await tokenPlanUsage.run({ + client: { console: vi.fn().mockResolvedValue(makeUsageResponse(0, 0.5)) }, + flags: { json: false, view: true }, + settings: { dryRun: false }, + } as never); + + const renderedOutput = output.join(""); + expect(renderedOutput).toContain("Resets: not applicable (no usage yet)"); + expect(renderedOutput).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/); + }); + + test("renders missing quota windows as possibly unlimited", async () => { + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await tokenPlanUsage.run({ + client: { console: vi.fn().mockResolvedValue(makeUsageResponse()) }, + flags: { json: false, view: true }, + settings: { dryRun: false }, + } as never); + + const renderedOutput = output.join(""); + expect(renderedOutput).toContain("5小时限额当前可能无限制,请到百炼 Token Plan 控制台核实。"); + expect(renderedOutput).toContain("1周限额当前可能无限制,请到百炼 Token Plan 控制台核实。"); + }); + + test("renders only the missing quota window as possibly unlimited", async () => { + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await tokenPlanUsage.run({ + client: { console: vi.fn().mockResolvedValue(makeUsageResponse(undefined, 0.5)) }, + flags: { json: false, view: true }, + settings: { dryRun: false }, + } as never); + + const renderedOutput = output.join(""); + expect(renderedOutput).toContain("5小时限额当前可能无限制,请到百炼 Token Plan 控制台核实。"); + expect(renderedOutput).not.toContain("1周限额当前可能无限制,请到百炼 Token Plan 控制台核实。"); + expect(renderedOutput).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/); + }); + + test("returns an empty JSON object when no quota fields are available", async () => { + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await tokenPlanUsage.run({ + client: { console: vi.fn().mockResolvedValue(makeUsageResponse()) }, + flags: { json: true, view: false }, + settings: { dryRun: false }, + } as never); + + expect(output.join("").trim()).toBe("{}"); + }); +}); diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 31bc4c73..39d04ce5 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -65,6 +65,7 @@ Use this index for the skill-scoped quick index and global flags. | `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) | | `bl usage stats` | Query model usage statistics | [usage.md](usage.md) | | `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | [usage.md](usage.md) | +| `bl usage token-plan` | Show Token Plan quota usage as core JSON or a human-readable view | [usage.md](usage.md) | | `bl workspace init` | Initialize Bailian workspace and activate postpaid services | [workspace.md](workspace.md) | | `bl workspace list` | List all workspaces | [workspace.md](workspace.md) | @@ -90,7 +91,7 @@ Use this index for the skill-scoped quick index and global flags. | `text` | `chat` | [text.md](text.md) | | `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) | | `update` | `(root)` | [update.md](update.md) | -| `usage` | `free`, `freetier`, `stats`, `summary` | [usage.md](usage.md) | +| `usage` | `free`, `freetier`, `stats`, `summary`, `token-plan` | [usage.md](usage.md) | | `workspace` | `init`, `list` | [workspace.md](workspace.md) | ## Global flags diff --git a/skills/bailian-cli/reference/usage.md b/skills/bailian-cli/reference/usage.md index dec5d7ea..f391cf2b 100644 --- a/skills/bailian-cli/reference/usage.md +++ b/skills/bailian-cli/reference/usage.md @@ -7,12 +7,13 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Description | -| ------------------- | ------------------------------------------------------------------------------------------ | -| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | -| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | -| `bl usage stats` | Query model usage statistics | -| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | +| Command | Description | +| --------------------- | ------------------------------------------------------------------------------------------ | +| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | +| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | +| `bl usage stats` | Query model usage statistics | +| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | +| `bl usage token-plan` | Show Token Plan quota usage as core JSON or a human-readable view | ## Command details @@ -199,3 +200,32 @@ bl usage summary --days 30 ```bash bl usage summary --output json ``` + +### `bl usage token-plan` + +| Field | Value | +| --------------- | ----------------------------------------------------------------- | +| **Name** | `usage token-plan` | +| **Description** | Show Token Plan quota usage as core JSON or a human-readable view | +| **Usage** | `bl usage token-plan <--json \| --view> [flags]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | -------------------------------------------------------- | +| `--json` | switch | no | Output only the four core usage fields as JSON | +| `--view` | switch | no | Render a compact human-readable quota view | +| `--console-region ` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) | +| `--console-site ` | string | no | Console site: domestic, international | +| `--console-switch-agent ` | number | no | Switch agent UID for delegated access | +| `--workspace-id ` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) | + +#### Examples + +```bash +bl usage token-plan --json +``` + +```bash +bl usage token-plan --view +```