Skip to content

Commit b402f3e

Browse files
authored
Merge pull request #141 from sonicg83/codex/usage-token-plan-reset-times
fix(usage): handle missing Token Plan quota fields
2 parents daefc09 + bedd59d commit b402f3e

10 files changed

Lines changed: 512 additions & 66 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,
@@ -164,6 +165,7 @@ export const commands: Record<string, AnyCommand> = {
164165
"usage freetier": usageFreetier,
165166
"usage stats": usageStats,
166167
"usage summary": usageSummary,
168+
"usage token-plan": usageTokenPlan,
167169
"pipeline run": pipelineRun,
168170
"pipeline validate": pipelineValidate,
169171
"advisor recommend": advisorRecommend,

packages/commands/src/commands/usage/shared.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ export function formatDate(ts: number): string {
2424
return `${year}-${month}-${day}`;
2525
}
2626

27+
export function formatDateTime(ts: number): string {
28+
const date = new Date(ts);
29+
const hour = String(date.getHours()).padStart(2, "0");
30+
const minute = String(date.getMinutes()).padStart(2, "0");
31+
const second = String(date.getSeconds()).padStart(2, "0");
32+
return `${formatDate(ts)} ${hour}:${minute}:${second}`;
33+
}
34+
2735
export function requireWorkspaceId(settings: Settings, binName: string): string {
2836
if (settings.workspaceId) return settings.workspaceId;
2937

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
2+
import {
3+
ansi,
4+
displayWidth,
5+
emitResult,
6+
type AnsiStyles,
7+
type TextStyle,
8+
} from "bailian-cli-runtime";
9+
import { formatDateTime } from "./shared.ts";
10+
11+
const TOKEN_PLAN_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
12+
const BOX_WIDTH = 76;
13+
const PROGRESS_WIDTH = 32;
14+
15+
interface TokenPlanUsage {
16+
per5HourPercentage?: number;
17+
per5HourResetTime?: number;
18+
per1WeekPercentage?: number;
19+
per1WeekResetTime?: number;
20+
}
21+
22+
interface QuotaWindow {
23+
percentage?: number;
24+
resetTime?: number;
25+
}
26+
27+
/** Accept only finite numbers; anything else counts as absent (possibly unlimited). */
28+
function readNumber(value: unknown): number | undefined {
29+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
30+
}
31+
32+
function readUsage(result: unknown): TokenPlanUsage {
33+
const response = unwrapResponse(result as Record<string, unknown>);
34+
const usage: TokenPlanUsage = {};
35+
36+
const per5HourPercentage = readNumber(response.per5HourPercentage);
37+
if (per5HourPercentage !== undefined) usage.per5HourPercentage = per5HourPercentage;
38+
const per5HourResetTime = readNumber(response.per5HourResetTime);
39+
if (per5HourResetTime !== undefined) usage.per5HourResetTime = per5HourResetTime;
40+
const per1WeekPercentage = readNumber(response.per1WeekPercentage);
41+
if (per1WeekPercentage !== undefined) usage.per1WeekPercentage = per1WeekPercentage;
42+
const per1WeekResetTime = readNumber(response.per1WeekResetTime);
43+
if (per1WeekResetTime !== undefined) usage.per1WeekResetTime = per1WeekResetTime;
44+
45+
return usage;
46+
}
47+
48+
function formatPercentage(ratio: number): string {
49+
return `${(ratio * 100).toFixed(2)}%`;
50+
}
51+
52+
function formatRemainingTime(resetTime: number, now: number): string {
53+
const remainingMs = Math.max(0, resetTime - now);
54+
const totalMinutes = Math.floor(remainingMs / 60_000);
55+
if (totalMinutes === 0) return "now";
56+
57+
const days = Math.floor(totalMinutes / (24 * 60));
58+
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
59+
const minutes = totalMinutes % 60;
60+
const parts: string[] = [];
61+
if (days > 0) parts.push(`${days}d`);
62+
if (hours > 0) parts.push(`${hours}h`);
63+
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
64+
return parts.join(" ");
65+
}
66+
67+
function progressBar(ratio: number): string {
68+
const clampedRatio = Math.min(1, Math.max(0, ratio));
69+
const filled = Math.round(clampedRatio * PROGRESS_WIDTH);
70+
return `[${"█".repeat(filled)}${"░".repeat(PROGRESS_WIDTH - filled)}]`;
71+
}
72+
73+
function progressStyle(percentage: number, color: AnsiStyles): TextStyle {
74+
if (percentage >= 0.9) return color.red;
75+
if (percentage >= 0.75) return color.yellow;
76+
return color.green;
77+
}
78+
79+
function printView(usage: TokenPlanUsage, generatedAt: number): void {
80+
const color = ansi(process.stdout);
81+
const writeLine = (text = "", style?: TextStyle) => {
82+
const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${text}`));
83+
process.stdout.write(`│ ${style ? style(text) : text}${" ".repeat(padding)}│\n`);
84+
};
85+
const writeQuota = (label: string, unlimitedMessage: string, window: QuotaWindow) => {
86+
writeLine(label, color.bold);
87+
if (window.percentage === undefined) {
88+
writeLine(unlimitedMessage, color.dim);
89+
return;
90+
}
91+
92+
const percentageText = formatPercentage(window.percentage);
93+
const bar = progressBar(window.percentage);
94+
writeLine(`${percentageText} used ${bar}`, progressStyle(window.percentage, color));
95+
if (window.resetTime === undefined) {
96+
writeLine("Resets: not applicable (no usage yet)", color.dim);
97+
return;
98+
}
99+
100+
const resetText = `Resets: ${formatDateTime(window.resetTime)} (in ${formatRemainingTime(window.resetTime, generatedAt)})`;
101+
writeLine(resetText, color.dim);
102+
};
103+
104+
process.stdout.write(`┌${"─".repeat(BOX_WIDTH)}┐\n`);
105+
writeLine("Token Plan Usage", color.cyan);
106+
writeLine(`Generated at: ${formatDateTime(generatedAt)} (local time)`, color.dim);
107+
process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`);
108+
writeQuota(
109+
"5-hour quota",
110+
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
111+
{ percentage: usage.per5HourPercentage, resetTime: usage.per5HourResetTime },
112+
);
113+
process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`);
114+
writeQuota(
115+
"1-week quota",
116+
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
117+
{ percentage: usage.per1WeekPercentage, resetTime: usage.per1WeekResetTime },
118+
);
119+
process.stdout.write(`└${"─".repeat(BOX_WIDTH)}┘\n`);
120+
}
121+
122+
export default defineCommand({
123+
description: "Show Token Plan quota usage",
124+
auth: "console",
125+
usageArgs: "[flags]",
126+
exampleArgs: ["", "--output json"],
127+
async run(ctx) {
128+
const { settings } = ctx;
129+
const format = detectOutputFormat(settings.output);
130+
131+
if (settings.dryRun) {
132+
emitResult({ api: TOKEN_PLAN_USAGE_API, data: {} }, format);
133+
return;
134+
}
135+
136+
const result = await ctx.client.console(TOKEN_PLAN_USAGE_API, {});
137+
const usage = readUsage(result);
138+
139+
if (format === "json") {
140+
emitResult(usage, format);
141+
return;
142+
}
143+
144+
printView(usage, Date.now());
145+
},
146+
});

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: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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(/Token Plan|quota/i);
19+
});
20+
21+
test("usage token-plan --help 包含 --output json 示例", async () => {
22+
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
23+
"usage",
24+
"token-plan",
25+
"--help",
26+
]);
27+
expect(exitCode, stderr).toBe(0);
28+
expect(stderr).toContain("bl usage token-plan --output json");
29+
});
30+
});
31+
32+
describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () => {
33+
test("usage token-plan --dry-run 输出网关请求计划", async () => {
34+
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
35+
"usage",
36+
"token-plan",
37+
"--dry-run",
38+
"--output",
39+
"json",
40+
]);
41+
expect(exitCode, stderr).toBe(0);
42+
const data = parseStdoutJson<{ api?: string; data?: Record<string, unknown> }>(stdout);
43+
expect(data.api).toBe("zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage");
44+
expect(data.data).toEqual({});
45+
});
46+
47+
test("usage token-plan --output json 返回可用的额度字段", async () => {
48+
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--output", "json"]);
49+
if (isConsoleAuthFailure(result)) return;
50+
expect(result.exitCode, result.stderr).toBe(0);
51+
const data = parseStdoutJson<{
52+
per5HourPercentage?: number;
53+
per5HourResetTime?: number;
54+
per1WeekPercentage?: number;
55+
per1WeekResetTime?: number;
56+
}>(result.stdout);
57+
const fields = [
58+
data.per5HourPercentage,
59+
data.per5HourResetTime,
60+
data.per1WeekPercentage,
61+
data.per1WeekResetTime,
62+
];
63+
for (const field of fields) {
64+
if (field !== undefined) expect(field).toBeTypeOf("number");
65+
}
66+
});
67+
68+
test("usage token-plan 默认渲染生成时间与两个额度窗口", async () => {
69+
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan"]);
70+
if (isConsoleAuthFailure(result)) return;
71+
expect(result.exitCode, result.stderr).toBe(0);
72+
expect(result.stdout).toContain("Generated at:");
73+
expect(result.stdout).toContain("5-hour quota");
74+
expect(result.stdout).toContain("1-week quota");
75+
});
76+
});

0 commit comments

Comments
 (0)