feat(usage): add token plan usage view

This commit is contained in:
sonicg
2026-08-05 23:28:39 +08:00
parent 6338df36be
commit 80bdcb83f6
8 changed files with 328 additions and 7 deletions
+2
View File
@@ -45,6 +45,7 @@ import {
usageFreetier,
usageStats,
usageSummary,
usageTokenPlan,
pipelineRun,
pipelineValidate,
advisorRecommend,
@@ -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,
@@ -0,0 +1,144 @@
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,
};
if (!Object.values(usage).every((value) => typeof value === "number" && Number.isFinite(value))) {
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, percentage: number, resetTime: number) => {
const percentageText = formatPercentage(percentage);
const bar = progressBar(percentage);
const style = progressStyle(percentage, color.green, color.yellow, color.red);
writeLine(color.bold(label), label);
writeLine(`${percentageText} used ${style(bar)}`, `${percentageText} used ${bar}`);
writeLine(
color.dim(
`Resets: ${formatDateTime(resetTime)} (in ${formatRemainingTime(resetTime, generatedAt)})`,
),
`Resets: ${formatDateTime(resetTime)} (in ${formatRemainingTime(resetTime, generatedAt)})`,
);
};
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", usage.per5HourPercentage, usage.per5HourResetTime);
process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`);
writeQuota("1-week quota", 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
View File
@@ -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";
@@ -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 = {
@@ -0,0 +1,88 @@
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);
expect(data.per5HourPercentage).toBeTypeOf("number");
expect(data.per5HourResetTime).toBeTypeOf("number");
expect(data.per1WeekPercentage).toBeTypeOf("number");
expect(data.per1WeekResetTime).toBeTypeOf("number");
expect(Object.keys(data).sort()).toEqual([
"per1WeekPercentage",
"per1WeekResetTime",
"per5HourPercentage",
"per5HourResetTime",
]);
});
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");
});
});
@@ -0,0 +1,54 @@
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(percentage: number): Record<string, unknown> {
return {
data: {
DataV2: {
data: {
data: {
per5HourPercentage: percentage,
per5HourResetTime: 1_786_000_000_000,
per1WeekPercentage: percentage,
per1WeekResetTime: 1_786_100_000_000,
},
},
},
},
};
}
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[`);
});
});
+2 -1
View File
@@ -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
+36 -6
View File
@@ -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 <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
| `--console-site <site>` | string | no | Console site: domestic, international |
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
#### Examples
```bash
bl usage token-plan --json
```
```bash
bl usage token-plan --view
```