mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat(usage): add token plan usage view
This commit is contained in:
@@ -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());
|
||||
},
|
||||
});
|
||||
@@ -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[`);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user