refactor(usage): align token-plan with --output convention and tolerant quota reading

This commit is contained in:
故璃
2026-08-13 19:43:11 +08:00
parent 4d84af614b
commit 39a488181e
7 changed files with 197 additions and 206 deletions
@@ -24,6 +24,14 @@ export function formatDate(ts: number): string {
return `${year}-${month}-${day}`;
}
export function formatDateTime(ts: number): string {
const date = new Date(ts);
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 `${formatDate(ts)} ${hour}:${minute}:${second}`;
}
export function requireWorkspaceId(settings: Settings, binName: string): string {
if (settings.workspaceId) return settings.workspaceId;
@@ -1,5 +1,12 @@
import { BailianError, ExitCode, defineCommand, unwrapResponse } from "bailian-cli-core";
import { ansi, displayWidth, emitResult, type TextStyle } from "bailian-cli-runtime";
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
import {
ansi,
displayWidth,
emitResult,
type AnsiStyles,
type TextStyle,
} from "bailian-cli-runtime";
import { formatDateTime } from "./shared.ts";
const TOKEN_PLAN_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
const BOX_WIDTH = 76;
@@ -12,50 +19,36 @@ interface TokenPlanUsage {
per1WeekResetTime?: number;
}
interface QuotaWindow {
percentage?: number;
resetTime?: number;
}
/** Accept only finite numbers; anything else counts as absent (possibly unlimited). */
function readNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
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 usage: TokenPlanUsage = {};
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)))),
);
const per5HourPercentage = readNumber(response.per5HourPercentage);
if (per5HourPercentage !== undefined) usage.per5HourPercentage = per5HourPercentage;
const per5HourResetTime = readNumber(response.per5HourResetTime);
if (per5HourResetTime !== undefined) usage.per5HourResetTime = per5HourResetTime;
const per1WeekPercentage = readNumber(response.per1WeekPercentage);
if (per1WeekPercentage !== undefined) usage.per1WeekPercentage = per1WeekPercentage;
const per1WeekResetTime = readNumber(response.per1WeekResetTime);
if (per1WeekResetTime !== undefined) usage.per1WeekResetTime = per1WeekResetTime;
if (!hasValidQuotas) {
throw new BailianError("Token Plan usage response has an unexpected format.", ExitCode.GENERAL);
}
return usage as TokenPlanUsage;
return usage;
}
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);
@@ -77,102 +70,74 @@ function progressBar(ratio: number): string {
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 progressStyle(percentage: number, color: AnsiStyles): TextStyle {
if (percentage >= 0.9) return color.red;
if (percentage >= 0.75) return color.yellow;
return color.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 writeLine = (text = "", style?: TextStyle) => {
const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${text}`));
process.stdout.write(`│ ${style ? style(text) : text}${" ".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);
const writeQuota = (label: string, unlimitedMessage: string, window: QuotaWindow) => {
writeLine(label, color.bold);
if (window.percentage === undefined) {
writeLine(unlimitedMessage, color.dim);
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)",
);
const percentageText = formatPercentage(window.percentage);
const bar = progressBar(window.percentage);
writeLine(`${percentageText} used ${bar}`, progressStyle(window.percentage, color));
if (window.resetTime === undefined) {
writeLine("Resets: not applicable (no usage yet)", color.dim);
return;
}
const resetText = `Resets: ${formatDateTime(resetTime)} (in ${formatRemainingTime(resetTime, generatedAt)})`;
writeLine(color.dim(resetText), resetText);
const resetText = `Resets: ${formatDateTime(window.resetTime)} (in ${formatRemainingTime(window.resetTime, generatedAt)})`;
writeLine(resetText, color.dim);
};
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);
writeLine("Token Plan Usage", color.cyan);
writeLine(`Generated at: ${formatDateTime(generatedAt)} (local time)`, color.dim);
process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`);
writeQuota(
"5-hour quota",
"5小时限额当前可能无限制,请到百炼 Token Plan 控制台核实。",
usage.per5HourPercentage,
usage.per5HourResetTime,
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
{ percentage: usage.per5HourPercentage, resetTime: usage.per5HourResetTime },
);
process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`);
writeQuota(
"1-week quota",
"1周限额当前可能无限制,请到百炼 Token Plan 控制台核实。",
usage.per1WeekPercentage,
usage.per1WeekResetTime,
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
{ percentage: usage.per1WeekPercentage, resetTime: 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",
description: "Show Token Plan quota usage",
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,
usageArgs: "[flags]",
exampleArgs: ["", "--output json"],
async run(ctx) {
const { flags, settings } = ctx;
const { settings } = ctx;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ api: TOKEN_PLAN_USAGE_API, data: {} }, "json");
emitResult({ api: TOKEN_PLAN_USAGE_API, data: {} }, format);
return;
}
const result = await ctx.client.console(TOKEN_PLAN_USAGE_API, {});
const usage = readUsage(result);
if (flags.json) {
emitResult(usage, "json");
if (format === "json") {
emitResult(usage, format);
return;
}
@@ -15,39 +15,28 @@ describe("e2e: usage token-plan", () => {
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--json|--view|Token Plan/i);
expect(stderr).toMatch(/Token Plan|quota/i);
});
test("usage token-plan 未选择输出形式时退出为用法错误", async () => {
test("usage token-plan --help 包含 --output json 示例", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--quiet",
"--help",
]);
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.");
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl usage token-plan --output json");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () => {
test("usage token-plan --json --dry-run 输出网关请求计划", async () => {
test("usage token-plan --dry-run 输出网关请求计划", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--json",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ api?: string; data?: Record<string, unknown> }>(stdout);
@@ -55,8 +44,8 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () =
expect(data.data).toEqual({});
});
test("usage token-plan --json 返回可用的额度字段", async () => {
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--json"]);
test("usage token-plan --output json 返回可用的额度字段", async () => {
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
const data = parseStdoutJson<{
@@ -65,22 +54,19 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () =
per1WeekPercentage?: number;
per1WeekResetTime?: number;
}>(result.stdout);
const quotas = [
[data.per5HourPercentage, data.per5HourResetTime],
[data.per1WeekPercentage, data.per1WeekResetTime],
const fields = [
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");
}
for (const field of fields) {
if (field !== undefined) expect(field).toBeTypeOf("number");
}
});
test("usage token-plan --view 渲染生成时间与两个额度窗口", async () => {
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--view"]);
test("usage token-plan 默认渲染生成时间与两个额度窗口", async () => {
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout).toContain("Generated at:");
@@ -12,6 +12,23 @@ afterEach(() => {
vi.restoreAllMocks();
});
function captureStdout(): string[] {
const output: string[] = [];
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
output.push(String(chunk));
return true;
});
return output;
}
async function runTokenPlan(response: Record<string, unknown>, output?: string): Promise<void> {
await tokenPlanUsage.run({
client: { console: vi.fn().mockResolvedValue(response) },
flags: {},
settings: { dryRun: false, output },
} as never);
}
function makeUsageResponse(
per5HourPercentage?: number,
per1WeekPercentage = per5HourPercentage,
@@ -26,6 +43,10 @@ function makeUsageResponse(
if (per1WeekPercentage !== 0) usage.per1WeekResetTime = 1_786_100_000_000;
}
return wrapResponse(usage);
}
function wrapResponse(usage: Record<string, unknown>): Record<string, unknown> {
return {
data: {
DataV2: {
@@ -45,49 +66,25 @@ describe("usage token-plan view", () => {
])("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;
});
const output = captureStdout();
await tokenPlanUsage.run({
client: { console: vi.fn().mockResolvedValue(makeUsageResponse(percentage)) },
flags: { json: false, view: true },
settings: { dryRun: false },
} as never);
await runTokenPlan(makeUsageResponse(percentage));
expect(output.join("")).toContain(`\u001B[${colorCode}m[`);
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;
});
const output = captureStdout();
await tokenPlanUsage.run({
client: { console: vi.fn().mockResolvedValue(makeUsageResponse(0)) },
flags: { json: false, view: true },
settings: { dryRun: false },
} as never);
await runTokenPlan(makeUsageResponse(0));
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;
});
const output = captureStdout();
await tokenPlanUsage.run({
client: { console: vi.fn().mockResolvedValue(makeUsageResponse(0, 0.5)) },
flags: { json: false, view: true },
settings: { dryRun: false },
} as never);
await runTokenPlan(makeUsageResponse(0, 0.5));
const renderedOutput = output.join("");
expect(renderedOutput).toContain("Resets: not applicable (no usage yet)");
@@ -95,55 +92,91 @@ describe("usage token-plan view", () => {
});
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;
});
const output = captureStdout();
await tokenPlanUsage.run({
client: { console: vi.fn().mockResolvedValue(makeUsageResponse()) },
flags: { json: false, view: true },
settings: { dryRun: false },
} as never);
await runTokenPlan(makeUsageResponse());
const renderedOutput = output.join("");
expect(renderedOutput).toContain("5小时限额当前可能无限制,请到百炼 Token Plan 控制台核实。");
expect(renderedOutput).toContain("1周限额当前可能无限制,请到百炼 Token Plan 控制台核实。");
expect(renderedOutput).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
expect(renderedOutput).toContain(
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
);
});
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;
});
const output = captureStdout();
await tokenPlanUsage.run({
client: { console: vi.fn().mockResolvedValue(makeUsageResponse(undefined, 0.5)) },
flags: { json: false, view: true },
settings: { dryRun: false },
} as never);
await runTokenPlan(makeUsageResponse(undefined, 0.5));
const renderedOutput = output.join("");
expect(renderedOutput).toContain("5小时限额当前可能无限制,请到百炼 Token Plan 控制台核实。");
expect(renderedOutput).not.toContain("1周限额当前可能无限制,请到百炼 Token Plan 控制台核实。");
expect(renderedOutput).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
expect(renderedOutput).not.toContain(
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
);
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;
});
test("renders a window with a missing percentage as possibly unlimited even when its reset time is present", async () => {
const output = captureStdout();
await tokenPlanUsage.run({
client: { console: vi.fn().mockResolvedValue(makeUsageResponse()) },
flags: { json: true, view: false },
settings: { dryRun: false },
} as never);
await runTokenPlan(wrapResponse({ per5HourResetTime: 1_786_000_000_000 }));
expect(output.join("")).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
});
test("treats non-numeric quota fields as absent instead of failing", async () => {
const output = captureStdout();
await runTokenPlan(
wrapResponse({ per5HourPercentage: "not-a-number", per1WeekPercentage: Number.NaN }),
);
const renderedOutput = output.join("");
expect(renderedOutput).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
expect(renderedOutput).toContain(
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
);
});
});
describe("usage token-plan json", () => {
test("outputs the four core usage fields with --output json", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0.5, 0.25), "json");
expect(JSON.parse(output.join(""))).toEqual({
per5HourPercentage: 0.5,
per5HourResetTime: 1_786_000_000_000,
per1WeekPercentage: 0.25,
per1WeekResetTime: 1_786_100_000_000,
});
});
test("returns an empty JSON object when no quota fields are available", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(), "json");
expect(output.join("").trim()).toBe("{}");
});
test("omits non-numeric quota fields from the JSON output", async () => {
const output = captureStdout();
await runTokenPlan(
wrapResponse({ per5HourPercentage: "not-a-number", per1WeekPercentage: 0 }),
"json",
);
expect(JSON.parse(output.join(""))).toEqual({ per1WeekPercentage: 0 });
});
});
+1
View File
@@ -71,6 +71,7 @@ Use this table only after the decision table in [`bailian-protocol`](../bailian-
| Bailian pipeline workflow (a step in a bl flow) | `bl pipeline run` / `validate` | JSON/YAML workflow definitions |
| Bailian rate limits / quota | `bl quota list` / `check` / `request` | Console auth; class 2 — ask which product first if unnamed |
| Bailian free tier / usage stats | `bl usage free` / `stats` / `freetier` | Console auth; class 2 — ask which product first if unnamed |
| Bailian Token Plan quota usage | `bl usage token-plan` | Console auth; class 2 — ask which product first if unnamed |
| Console API (advanced) | `bl console call` | Console auth |
| Bailian workspace listing | `bl workspace list` | Console auth |
| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` |
+1 -1
View File
@@ -66,7 +66,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 usage token-plan` | Show Token Plan quota usage | [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) |
+8 -10
View File
@@ -13,7 +13,7 @@ Index: [index.md](index.md)
| `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 |
| `bl usage token-plan` | Show Token Plan quota usage |
## Command details
@@ -203,18 +203,16 @@ 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]` |
| Field | Value |
| --------------- | ----------------------------- |
| **Name** | `usage token-plan` |
| **Description** | Show Token Plan quota usage |
| **Usage** | `bl usage token-plan [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 |
@@ -223,9 +221,9 @@ bl usage summary --output json
#### Examples
```bash
bl usage token-plan --json
bl usage token-plan
```
```bash
bl usage token-plan --view
bl usage token-plan --output json
```