Skip to content

Commit 80bdcb8

Browse files
author
sonicg
committed
feat(usage): add token plan usage view
1 parent 6338df3 commit 80bdcb8

8 files changed

Lines changed: 328 additions & 7 deletions

File tree

packages/cli/src/commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
usageFreetier,
4646
usageStats,
4747
usageSummary,
48+
usageTokenPlan,
4849
pipelineRun,
4950
pipelineValidate,
5051
advisorRecommend,
@@ -163,6 +164,7 @@ export const commands: Record<string, AnyCommand> = {
163164
"usage freetier": usageFreetier,
164165
"usage stats": usageStats,
165166
"usage summary": usageSummary,
167+
"usage token-plan": usageTokenPlan,
166168
"pipeline run": pipelineRun,
167169
"pipeline validate": pipelineValidate,
168170
"advisor recommend": advisorRecommend,
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { BailianError, ExitCode, defineCommand, unwrapResponse } from "bailian-cli-core";
2+
import { ansi, displayWidth, emitResult, type TextStyle } from "bailian-cli-runtime";
3+
4+
const TOKEN_PLAN_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
5+
const BOX_WIDTH = 76;
6+
const PROGRESS_WIDTH = 32;
7+
8+
interface TokenPlanUsage {
9+
per5HourPercentage: number;
10+
per5HourResetTime: number;
11+
per1WeekPercentage: number;
12+
per1WeekResetTime: number;
13+
}
14+
15+
function readUsage(result: unknown): TokenPlanUsage {
16+
const response = unwrapResponse(result as Record<string, unknown>);
17+
const usage = {
18+
per5HourPercentage: response.per5HourPercentage,
19+
per5HourResetTime: response.per5HourResetTime,
20+
per1WeekPercentage: response.per1WeekPercentage,
21+
per1WeekResetTime: response.per1WeekResetTime,
22+
};
23+
24+
if (!Object.values(usage).every((value) => typeof value === "number" && Number.isFinite(value))) {
25+
throw new BailianError("Token Plan usage response has an unexpected format.", ExitCode.GENERAL);
26+
}
27+
28+
return usage as TokenPlanUsage;
29+
}
30+
31+
function formatPercentage(ratio: number): string {
32+
return `${(ratio * 100).toFixed(2)}%`;
33+
}
34+
35+
function formatDateTime(timestamp: number): string {
36+
const date = new Date(timestamp);
37+
const year = date.getFullYear();
38+
const month = String(date.getMonth() + 1).padStart(2, "0");
39+
const day = String(date.getDate()).padStart(2, "0");
40+
const hour = String(date.getHours()).padStart(2, "0");
41+
const minute = String(date.getMinutes()).padStart(2, "0");
42+
const second = String(date.getSeconds()).padStart(2, "0");
43+
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
44+
}
45+
46+
function formatRemainingTime(resetTime: number, now: number): string {
47+
const remainingMs = Math.max(0, resetTime - now);
48+
const totalMinutes = Math.floor(remainingMs / 60_000);
49+
if (totalMinutes === 0) return "now";
50+
51+
const days = Math.floor(totalMinutes / (24 * 60));
52+
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
53+
const minutes = totalMinutes % 60;
54+
const parts: string[] = [];
55+
if (days > 0) parts.push(`${days}d`);
56+
if (hours > 0) parts.push(`${hours}h`);
57+
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
58+
return parts.join(" ");
59+
}
60+
61+
function progressBar(ratio: number): string {
62+
const clampedRatio = Math.min(1, Math.max(0, ratio));
63+
const filled = Math.round(clampedRatio * PROGRESS_WIDTH);
64+
return `[${"█".repeat(filled)}${"░".repeat(PROGRESS_WIDTH - filled)}]`;
65+
}
66+
67+
function progressStyle(
68+
percentage: number,
69+
green: TextStyle,
70+
yellow: TextStyle,
71+
red: TextStyle,
72+
): TextStyle {
73+
if (percentage >= 0.9) return red;
74+
if (percentage >= 0.75) return yellow;
75+
return green;
76+
}
77+
78+
function printView(usage: TokenPlanUsage, generatedAt: number): void {
79+
const color = ansi(process.stdout);
80+
const writeLine = (content = "", visibleContent = content) => {
81+
const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${visibleContent}`));
82+
process.stdout.write(`│ ${content}${" ".repeat(padding)}│\n`);
83+
};
84+
const writeQuota = (label: string, percentage: number, resetTime: number) => {
85+
const percentageText = formatPercentage(percentage);
86+
const bar = progressBar(percentage);
87+
const style = progressStyle(percentage, color.green, color.yellow, color.red);
88+
writeLine(color.bold(label), label);
89+
writeLine(`${percentageText} used ${style(bar)}`, `${percentageText} used ${bar}`);
90+
writeLine(
91+
color.dim(
92+
`Resets: ${formatDateTime(resetTime)} (in ${formatRemainingTime(resetTime, generatedAt)})`,
93+
),
94+
`Resets: ${formatDateTime(resetTime)} (in ${formatRemainingTime(resetTime, generatedAt)})`,
95+
);
96+
};
97+
98+
process.stdout.write(`┌${"─".repeat(BOX_WIDTH)}┐\n`);
99+
writeLine(color.cyan("Token Plan Usage"), "Token Plan Usage");
100+
const generatedAtText = `Generated at: ${formatDateTime(generatedAt)} (local time)`;
101+
writeLine(color.dim(generatedAtText), generatedAtText);
102+
process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`);
103+
writeQuota("5-hour quota", usage.per5HourPercentage, usage.per5HourResetTime);
104+
process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`);
105+
writeQuota("1-week quota", usage.per1WeekPercentage, usage.per1WeekResetTime);
106+
process.stdout.write(`└${"─".repeat(BOX_WIDTH)}┘\n`);
107+
}
108+
109+
export default defineCommand({
110+
description: "Show Token Plan quota usage as core JSON or a human-readable view",
111+
auth: "console",
112+
usageArgs: "<--json | --view> [flags]",
113+
flags: {
114+
json: {
115+
type: "switch",
116+
description: "Output only the four core usage fields as JSON",
117+
},
118+
view: {
119+
type: "switch",
120+
description: "Render a compact human-readable quota view",
121+
},
122+
},
123+
exampleArgs: ["--json", "--view"],
124+
validate: (flags) =>
125+
flags.json === flags.view ? "Choose exactly one of --json or --view." : undefined,
126+
async run(ctx) {
127+
const { flags, settings } = ctx;
128+
129+
if (settings.dryRun) {
130+
emitResult({ api: TOKEN_PLAN_USAGE_API, data: {} }, "json");
131+
return;
132+
}
133+
134+
const result = await ctx.client.console(TOKEN_PLAN_USAGE_API, {});
135+
const usage = readUsage(result);
136+
137+
if (flags.json) {
138+
emitResult(usage, "json");
139+
return;
140+
}
141+
142+
printView(usage, Date.now());
143+
},
144+
});

packages/commands/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export { default as usageFree } from "./commands/usage/free.ts";
4848
export { default as usageFreetier } from "./commands/usage/freetier.ts";
4949
export { default as usageStats } from "./commands/usage/stats.ts";
5050
export { default as usageSummary } from "./commands/usage/summary.ts";
51+
export { default as usageTokenPlan } from "./commands/usage/token-plan.ts";
5152
export { default as pipelineRun } from "./commands/pipeline/run.ts";
5253
export { default as pipelineValidate } from "./commands/pipeline/validate.ts";
5354
export { default as advisorRecommend } from "./commands/advisor/recommend.ts";

packages/commands/tests/e2e/topic-routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export const USAGE_ROUTES: E2eRouteExports = {
109109
"usage free": "usageFree",
110110
"usage freetier": "usageFreetier",
111111
"usage stats": "usageStats",
112+
"usage token-plan": "usageTokenPlan",
112113
};
113114

114115
export const DEPLOY_ROUTES: E2eRouteExports = {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, test } from "vite-plus/test";
2+
import {
3+
isConsoleAuthFailure,
4+
isConsoleE2EReady,
5+
parseStdoutJson,
6+
runCommandE2e,
7+
} from "./helpers.ts";
8+
import { USAGE_ROUTES } from "./topic-routes.ts";
9+
10+
describe("e2e: usage token-plan", () => {
11+
test("usage token-plan --help 正常退出", async () => {
12+
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
13+
"usage",
14+
"token-plan",
15+
"--help",
16+
]);
17+
expect(exitCode, stderr).toBe(0);
18+
expect(stderr).toMatch(/--json|--view|Token Plan/i);
19+
});
20+
21+
test("usage token-plan 未选择输出形式时退出为用法错误", async () => {
22+
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
23+
"usage",
24+
"token-plan",
25+
"--quiet",
26+
]);
27+
expect(exitCode).toBe(2);
28+
expect(stderr).toContain("Choose exactly one of --json or --view.");
29+
});
30+
31+
test("usage token-plan 同时选择两种输出形式时退出为用法错误", async () => {
32+
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
33+
"usage",
34+
"token-plan",
35+
"--json",
36+
"--view",
37+
"--quiet",
38+
]);
39+
expect(exitCode).toBe(2);
40+
expect(stderr).toContain("Choose exactly one of --json or --view.");
41+
});
42+
});
43+
44+
describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () => {
45+
test("usage token-plan --json --dry-run 输出网关请求计划", async () => {
46+
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
47+
"usage",
48+
"token-plan",
49+
"--json",
50+
"--dry-run",
51+
]);
52+
expect(exitCode, stderr).toBe(0);
53+
const data = parseStdoutJson<{ api?: string; data?: Record<string, unknown> }>(stdout);
54+
expect(data.api).toBe("zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage");
55+
expect(data.data).toEqual({});
56+
});
57+
58+
test("usage token-plan --json 返回四个核心字段", async () => {
59+
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--json"]);
60+
if (isConsoleAuthFailure(result)) return;
61+
expect(result.exitCode, result.stderr).toBe(0);
62+
const data = parseStdoutJson<{
63+
per5HourPercentage?: number;
64+
per5HourResetTime?: number;
65+
per1WeekPercentage?: number;
66+
per1WeekResetTime?: number;
67+
}>(result.stdout);
68+
expect(data.per5HourPercentage).toBeTypeOf("number");
69+
expect(data.per5HourResetTime).toBeTypeOf("number");
70+
expect(data.per1WeekPercentage).toBeTypeOf("number");
71+
expect(data.per1WeekResetTime).toBeTypeOf("number");
72+
expect(Object.keys(data).sort()).toEqual([
73+
"per1WeekPercentage",
74+
"per1WeekResetTime",
75+
"per5HourPercentage",
76+
"per5HourResetTime",
77+
]);
78+
});
79+
80+
test("usage token-plan --view 渲染生成时间与两个额度窗口", async () => {
81+
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--view"]);
82+
if (isConsoleAuthFailure(result)) return;
83+
expect(result.exitCode, result.stderr).toBe(0);
84+
expect(result.stdout).toContain("Generated at:");
85+
expect(result.stdout).toContain("5-hour quota");
86+
expect(result.stdout).toContain("1-week quota");
87+
});
88+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
2+
import tokenPlanUsage from "../src/commands/usage/token-plan.ts";
3+
4+
const originalNoColor = process.env.NO_COLOR;
5+
const originalIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
6+
7+
afterEach(() => {
8+
if (originalNoColor === undefined) delete process.env.NO_COLOR;
9+
else process.env.NO_COLOR = originalNoColor;
10+
if (originalIsTty) Object.defineProperty(process.stdout, "isTTY", originalIsTty);
11+
else delete (process.stdout as { isTTY?: boolean }).isTTY;
12+
vi.restoreAllMocks();
13+
});
14+
15+
function makeUsageResponse(percentage: number): Record<string, unknown> {
16+
return {
17+
data: {
18+
DataV2: {
19+
data: {
20+
data: {
21+
per5HourPercentage: percentage,
22+
per5HourResetTime: 1_786_000_000_000,
23+
per1WeekPercentage: percentage,
24+
per1WeekResetTime: 1_786_100_000_000,
25+
},
26+
},
27+
},
28+
},
29+
};
30+
}
31+
32+
describe("usage token-plan view", () => {
33+
test.each([
34+
[0.7499, "32"],
35+
[0.75, "33"],
36+
[0.9, "31"],
37+
])("uses ANSI color %s for %s", async (percentage, colorCode) => {
38+
delete process.env.NO_COLOR;
39+
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
40+
const output: string[] = [];
41+
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
42+
output.push(String(chunk));
43+
return true;
44+
});
45+
46+
await tokenPlanUsage.run({
47+
client: { console: vi.fn().mockResolvedValue(makeUsageResponse(percentage)) },
48+
flags: { json: false, view: true },
49+
settings: { dryRun: false },
50+
} as never);
51+
52+
expect(output.join("")).toContain(`\u001B[${colorCode}m[`);
53+
});
54+
});

skills/bailian-cli/reference/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ Use this index for the skill-scoped quick index and global flags.
6565
| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) |
6666
| `bl usage stats` | Query model usage statistics | [usage.md](usage.md) |
6767
| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | [usage.md](usage.md) |
68+
| `bl usage token-plan` | Show Token Plan quota usage as core JSON or a human-readable view | [usage.md](usage.md) |
6869
| `bl workspace init` | Initialize Bailian workspace and activate postpaid services | [workspace.md](workspace.md) |
6970
| `bl workspace list` | List all workspaces | [workspace.md](workspace.md) |
7071

@@ -90,7 +91,7 @@ Use this index for the skill-scoped quick index and global flags.
9091
| `text` | `chat` | [text.md](text.md) |
9192
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) |
9293
| `update` | `(root)` | [update.md](update.md) |
93-
| `usage` | `free`, `freetier`, `stats`, `summary` | [usage.md](usage.md) |
94+
| `usage` | `free`, `freetier`, `stats`, `summary`, `token-plan` | [usage.md](usage.md) |
9495
| `workspace` | `init`, `list` | [workspace.md](workspace.md) |
9596

9697
## Global flags

skills/bailian-cli/reference/usage.md

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ Index: [index.md](index.md)
77

88
## Commands in this group
99

10-
| Command | Description |
11-
| ------------------- | ------------------------------------------------------------------------------------------ |
12-
| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) |
13-
| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable |
14-
| `bl usage stats` | Query model usage statistics |
15-
| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview |
10+
| Command | Description |
11+
| --------------------- | ------------------------------------------------------------------------------------------ |
12+
| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) |
13+
| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable |
14+
| `bl usage stats` | Query model usage statistics |
15+
| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview |
16+
| `bl usage token-plan` | Show Token Plan quota usage as core JSON or a human-readable view |
1617

1718
## Command details
1819

@@ -199,3 +200,32 @@ bl usage summary --days 30
199200
```bash
200201
bl usage summary --output json
201202
```
203+
204+
### `bl usage token-plan`
205+
206+
| Field | Value |
207+
| --------------- | ----------------------------------------------------------------- |
208+
| **Name** | `usage token-plan` |
209+
| **Description** | Show Token Plan quota usage as core JSON or a human-readable view |
210+
| **Usage** | `bl usage token-plan <--json \| --view> [flags]` |
211+
212+
#### Flags
213+
214+
| Flag | Type | Required | Description |
215+
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
216+
| `--json` | switch | no | Output only the four core usage fields as JSON |
217+
| `--view` | switch | no | Render a compact human-readable quota view |
218+
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
219+
| `--console-site <site>` | string | no | Console site: domestic, international |
220+
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
221+
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
222+
223+
#### Examples
224+
225+
```bash
226+
bl usage token-plan --json
227+
```
228+
229+
```bash
230+
bl usage token-plan --view
231+
```

0 commit comments

Comments
 (0)