mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Merge pull request #155 from modelstudioai/feat/coding-plan-usage
feat: add coding plan usage
This commit is contained in:
@@ -46,6 +46,7 @@ import {
|
||||
usageStats,
|
||||
usageSummary,
|
||||
usageTokenPlan,
|
||||
usageCodingPlan,
|
||||
pipelineRun,
|
||||
pipelineValidate,
|
||||
advisorRecommend,
|
||||
@@ -166,6 +167,7 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"usage stats": usageStats,
|
||||
"usage summary": usageSummary,
|
||||
"usage token-plan": usageTokenPlan,
|
||||
"usage coding-plan": usageCodingPlan,
|
||||
"pipeline run": pipelineRun,
|
||||
"pipeline validate": pipelineValidate,
|
||||
"advisor recommend": advisorRecommend,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { printQuotaBox, readNumber, type QuotaSection } from "./quota-box.ts";
|
||||
import { formatNumber } from "./shared.ts";
|
||||
|
||||
const CODING_PLAN_USAGE_API =
|
||||
"zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2";
|
||||
|
||||
const COMMODITY_CODES: Record<string, string> = {
|
||||
domestic: "sfm_codingplan_public_cn",
|
||||
international: "sfm_codingplan_public_intl",
|
||||
};
|
||||
|
||||
interface CodingPlanWindow {
|
||||
usedQuota?: number;
|
||||
totalQuota?: number;
|
||||
/** Usage ratio in [0, 1]; absent when the window has no positive total or no used value. */
|
||||
percentage?: number;
|
||||
resetTime?: number;
|
||||
}
|
||||
|
||||
interface CodingPlanUsage {
|
||||
instanceType?: string;
|
||||
per5Hour: CodingPlanWindow;
|
||||
perWeek: CodingPlanWindow;
|
||||
perBillMonth: CodingPlanWindow;
|
||||
}
|
||||
|
||||
function readWindow(
|
||||
quotaInfo: Record<string, unknown> | undefined,
|
||||
fieldPrefix: string,
|
||||
): CodingPlanWindow {
|
||||
const window: CodingPlanWindow = {};
|
||||
if (!quotaInfo) return window;
|
||||
|
||||
const usedQuota = readNumber(quotaInfo[`${fieldPrefix}UsedQuota`]);
|
||||
if (usedQuota !== undefined) window.usedQuota = usedQuota;
|
||||
const totalQuota = readNumber(quotaInfo[`${fieldPrefix}TotalQuota`]);
|
||||
if (totalQuota !== undefined) window.totalQuota = totalQuota;
|
||||
const resetTime = readNumber(quotaInfo[`${fieldPrefix}QuotaNextRefreshTime`]);
|
||||
if (resetTime !== undefined) window.resetTime = resetTime;
|
||||
|
||||
// Console rule: the usage rate only exists with a positive total and a used value.
|
||||
if (usedQuota !== undefined && totalQuota !== undefined && totalQuota > 0) {
|
||||
window.percentage = usedQuota / totalQuota;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
/** Pick the first VALID instance's quota info, mirroring the Coding Plan console. */
|
||||
function readUsage(result: unknown): CodingPlanUsage | undefined {
|
||||
const response = unwrapResponse(result as Record<string, unknown>);
|
||||
const instances = Array.isArray(response.codingPlanInstanceInfos)
|
||||
? (response.codingPlanInstanceInfos as Record<string, unknown>[])
|
||||
: [];
|
||||
const validInstance = instances.find((instance) => instance.status === "VALID");
|
||||
if (!validInstance) return undefined;
|
||||
|
||||
const quotaInfo = validInstance.codingPlanQuotaInfo as Record<string, unknown> | undefined;
|
||||
const usage: CodingPlanUsage = {
|
||||
per5Hour: readWindow(quotaInfo, "per5Hour"),
|
||||
perWeek: readWindow(quotaInfo, "perWeek"),
|
||||
perBillMonth: readWindow(quotaInfo, "perBillMonth"),
|
||||
};
|
||||
if (typeof validInstance.instanceType === "string" && validInstance.instanceType) {
|
||||
usage.instanceType = validInstance.instanceType;
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
function toSection(label: string, window: CodingPlanWindow): QuotaSection {
|
||||
const section: QuotaSection = {
|
||||
label,
|
||||
emptyMessage: "No quota data for this window; verify in the Bailian Coding Plan console.",
|
||||
percentage: window.percentage,
|
||||
resetTime: window.resetTime,
|
||||
};
|
||||
if (window.usedQuota !== undefined && window.totalQuota !== undefined) {
|
||||
section.detail = `Used: ${formatNumber(window.usedQuota)} / ${formatNumber(window.totalQuota)}`;
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
function printView(usage: CodingPlanUsage, generatedAt: number): void {
|
||||
const planSuffix = usage.instanceType ? ` (${usage.instanceType})` : "";
|
||||
printQuotaBox(
|
||||
`Coding Plan Usage${planSuffix}`,
|
||||
[
|
||||
toSection("5-hour quota", usage.per5Hour),
|
||||
toSection("1-week quota", usage.perWeek),
|
||||
toSection("Monthly quota", usage.perBillMonth),
|
||||
],
|
||||
generatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Show Coding Plan quota usage",
|
||||
auth: "console",
|
||||
usageArgs: "[flags]",
|
||||
exampleArgs: ["", "--output json"],
|
||||
async run(ctx) {
|
||||
const { settings } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const requestData = {
|
||||
queryCodingPlanInstanceInfoRequest: {
|
||||
commodityCode: COMMODITY_CODES[settings.consoleSite ?? "domestic"],
|
||||
onlyLatestOne: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ api: CODING_PLAN_USAGE_API, data: requestData }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ctx.client.console(CODING_PLAN_USAGE_API, requestData);
|
||||
const usage = readUsage(result);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(usage ?? {}, format);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!usage) {
|
||||
process.stdout.write("No active Coding Plan subscription found.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
printView(usage, Date.now());
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
ansi,
|
||||
displayWidth,
|
||||
renderGauge,
|
||||
type GaugeCell,
|
||||
type TextStyle,
|
||||
} from "bailian-cli-runtime";
|
||||
import { formatDateTime } from "./shared.ts";
|
||||
|
||||
const BOX_WIDTH = 76;
|
||||
|
||||
/** One quota window rendered inside the box: a label + usage ratio + reset time. */
|
||||
export interface QuotaSection {
|
||||
label: string;
|
||||
/** Shown instead of the gauge when the usage ratio is absent. */
|
||||
emptyMessage: string;
|
||||
/** Usage ratio in [0, 1]; absent means no data (possibly unlimited). */
|
||||
percentage?: number;
|
||||
resetTime?: number;
|
||||
/** Optional dim line under the gauge, e.g. "Used: 38 / 100". */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Accept only finite numbers; anything else counts as absent (possibly unlimited). */
|
||||
export function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
/** Match the `usage free` gauge label style: 0.1% precision, no trailing zeros. */
|
||||
function formatPercentage(ratio: number): string {
|
||||
const percent = Math.round(ratio * 1000) / 10;
|
||||
return `${Number.isInteger(percent) ? percent : percent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
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(" ");
|
||||
}
|
||||
|
||||
/** Print a bordered quota box with a title line and one gauge per section. */
|
||||
export function printQuotaBox(title: string, sections: QuotaSection[], generatedAt: number): void {
|
||||
const color = ansi(process.stdout);
|
||||
const writeLine = (text = "", style?: TextStyle) => {
|
||||
const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${text}`));
|
||||
process.stdout.write(`│ ${style ? style(text) : text}${" ".repeat(padding)}│\n`);
|
||||
};
|
||||
// Pre-colored gauge cell: pad from the plain variant so ANSI escapes never shift the border.
|
||||
const writeGaugeLine = (cell: GaugeCell) => {
|
||||
const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${cell.plain}`));
|
||||
process.stdout.write(`│ ${cell.colored}${" ".repeat(padding)}│\n`);
|
||||
};
|
||||
const writeQuota = (section: QuotaSection) => {
|
||||
writeLine(section.label, color.bold);
|
||||
if (section.percentage === undefined) {
|
||||
writeLine(section.emptyMessage, color.dim);
|
||||
return;
|
||||
}
|
||||
|
||||
const gaugeLabel = `${formatPercentage(section.percentage)} used`;
|
||||
writeGaugeLine(renderGauge(section.percentage * 100, gaugeLabel));
|
||||
if (section.detail) {
|
||||
writeLine(section.detail, color.dim);
|
||||
}
|
||||
if (section.resetTime === undefined) {
|
||||
writeLine("Resets: not applicable (no usage yet)", color.dim);
|
||||
return;
|
||||
}
|
||||
|
||||
const resetText = `Resets: ${formatDateTime(section.resetTime)} (in ${formatRemainingTime(section.resetTime, generatedAt)})`;
|
||||
writeLine(resetText, color.dim);
|
||||
};
|
||||
|
||||
process.stdout.write(`┌${"─".repeat(BOX_WIDTH)}┐\n`);
|
||||
writeLine(title, color.cyan);
|
||||
writeLine(`Generated at: ${formatDateTime(generatedAt)} (local time)`, color.dim);
|
||||
for (const section of sections) {
|
||||
process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`);
|
||||
writeQuota(section);
|
||||
}
|
||||
process.stdout.write(`└${"─".repeat(BOX_WIDTH)}┘\n`);
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
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";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { printQuotaBox, readNumber } from "./quota-box.ts";
|
||||
|
||||
const TOKEN_PLAN_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
|
||||
const BOX_WIDTH = 76;
|
||||
const PROGRESS_WIDTH = 32;
|
||||
|
||||
interface TokenPlanUsage {
|
||||
per5HourPercentage?: number;
|
||||
@@ -19,16 +11,6 @@ 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: TokenPlanUsage = {};
|
||||
@@ -45,78 +27,27 @@ function readUsage(result: unknown): TokenPlanUsage {
|
||||
return usage;
|
||||
}
|
||||
|
||||
function formatPercentage(ratio: number): string {
|
||||
return `${(ratio * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
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, 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 = (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, window: QuotaWindow) => {
|
||||
writeLine(label, color.bold);
|
||||
if (window.percentage === undefined) {
|
||||
writeLine(unlimitedMessage, color.dim);
|
||||
return;
|
||||
}
|
||||
|
||||
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(window.resetTime)} (in ${formatRemainingTime(window.resetTime, generatedAt)})`;
|
||||
writeLine(resetText, color.dim);
|
||||
};
|
||||
|
||||
process.stdout.write(`┌${"─".repeat(BOX_WIDTH)}┐\n`);
|
||||
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",
|
||||
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
|
||||
{ percentage: usage.per5HourPercentage, resetTime: usage.per5HourResetTime },
|
||||
printQuotaBox(
|
||||
"Token Plan Usage",
|
||||
[
|
||||
{
|
||||
label: "5-hour quota",
|
||||
emptyMessage:
|
||||
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
|
||||
percentage: usage.per5HourPercentage,
|
||||
resetTime: usage.per5HourResetTime,
|
||||
},
|
||||
{
|
||||
label: "1-week quota",
|
||||
emptyMessage:
|
||||
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
|
||||
percentage: usage.per1WeekPercentage,
|
||||
resetTime: usage.per1WeekResetTime,
|
||||
},
|
||||
],
|
||||
generatedAt,
|
||||
);
|
||||
process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`);
|
||||
writeQuota(
|
||||
"1-week quota",
|
||||
"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({
|
||||
|
||||
@@ -49,6 +49,7 @@ 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 usageCodingPlan } from "./commands/usage/coding-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";
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
import codingPlanUsage from "../src/commands/usage/coding-plan.ts";
|
||||
|
||||
const originalNoColor = process.env.NO_COLOR;
|
||||
const originalForceColor = process.env.FORCE_COLOR;
|
||||
const originalIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNoColor === undefined) delete process.env.NO_COLOR;
|
||||
else process.env.NO_COLOR = originalNoColor;
|
||||
if (originalForceColor === undefined) delete process.env.FORCE_COLOR;
|
||||
else process.env.FORCE_COLOR = originalForceColor;
|
||||
if (originalIsTty) Object.defineProperty(process.stdout, "isTTY", originalIsTty);
|
||||
else delete (process.stdout as { isTTY?: boolean }).isTTY;
|
||||
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 runCodingPlan(response: Record<string, unknown>, output?: string): Promise<void> {
|
||||
await codingPlanUsage.run({
|
||||
client: { console: vi.fn().mockResolvedValue(response) },
|
||||
flags: {},
|
||||
settings: { dryRun: false, output },
|
||||
} as never);
|
||||
}
|
||||
|
||||
function wrapResponse(data: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
data: {
|
||||
DataV2: {
|
||||
data: {
|
||||
data,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeInstanceResponse(
|
||||
quotaInfo: Record<string, unknown>,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return wrapResponse({
|
||||
codingPlanInstanceInfos: [
|
||||
{ status: "VALID", instanceType: "pro", codingPlanQuotaInfo: quotaInfo, ...overrides },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const FULL_QUOTA_INFO = {
|
||||
per5HourUsedQuota: 38,
|
||||
per5HourTotalQuota: 100,
|
||||
per5HourQuotaNextRefreshTime: 1_786_000_000_000,
|
||||
perWeekUsedQuota: 500,
|
||||
perWeekTotalQuota: 1000,
|
||||
perWeekQuotaNextRefreshTime: 1_786_100_000_000,
|
||||
perBillMonthUsedQuota: 950,
|
||||
perBillMonthTotalQuota: 1000,
|
||||
perBillMonthQuotaNextRefreshTime: 1_786_200_000_000,
|
||||
};
|
||||
|
||||
describe("usage coding-plan view", () => {
|
||||
test("renders the three quota windows with usage rates and used/total details", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO));
|
||||
|
||||
const renderedOutput = output.join("");
|
||||
expect(renderedOutput).toContain("Coding Plan Usage (pro)");
|
||||
expect(renderedOutput).toContain("5-hour quota");
|
||||
expect(renderedOutput).toContain("1-week quota");
|
||||
expect(renderedOutput).toContain("Monthly quota");
|
||||
expect(renderedOutput).toContain("38% used");
|
||||
expect(renderedOutput).toContain("50% used");
|
||||
expect(renderedOutput).toContain("95% used");
|
||||
expect(renderedOutput).toContain("Used: 38 / 100");
|
||||
expect(renderedOutput).toContain("Used: 950 / 1,000");
|
||||
});
|
||||
|
||||
test("skips non-VALID instances when picking quota info", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(
|
||||
wrapResponse({
|
||||
codingPlanInstanceInfos: [
|
||||
{
|
||||
status: "EXPIRED",
|
||||
codingPlanQuotaInfo: { per5HourUsedQuota: 1, per5HourTotalQuota: 2 },
|
||||
},
|
||||
{ status: "VALID", codingPlanQuotaInfo: FULL_QUOTA_INFO },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output.join("")).toContain("38% used");
|
||||
});
|
||||
|
||||
test("renders windows without a positive total as missing quota data", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(
|
||||
makeInstanceResponse({
|
||||
per5HourUsedQuota: 38,
|
||||
per5HourTotalQuota: 0,
|
||||
perWeekUsedQuota: 500,
|
||||
perBillMonthUsedQuota: "not-a-number",
|
||||
perBillMonthTotalQuota: 1000,
|
||||
}),
|
||||
);
|
||||
|
||||
const renderedOutput = output.join("");
|
||||
const emptyMessageCount = renderedOutput.split(
|
||||
"No quota data for this window; verify in the Bailian Coding Plan console.",
|
||||
).length;
|
||||
expect(emptyMessageCount - 1).toBe(3);
|
||||
});
|
||||
|
||||
test("reports when there is no active subscription", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(wrapResponse({ codingPlanInstanceInfos: [] }));
|
||||
|
||||
expect(output.join("")).toContain("No active Coding Plan subscription found.");
|
||||
});
|
||||
|
||||
test("renders the gauge in the usage-free style: brand fill and proportional cells", async () => {
|
||||
delete process.env.NO_COLOR;
|
||||
process.env.FORCE_COLOR = "3";
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO));
|
||||
|
||||
// Brand-cyan fill cell, same as the `usage free` gauge column
|
||||
expect(output.join("")).toContain("\u001B[38;2;0;150;160m\u2588");
|
||||
});
|
||||
|
||||
test("fills gauge cells proportionally to the usage rate", async () => {
|
||||
process.env.NO_COLOR = "1";
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO));
|
||||
|
||||
const renderedOutput = output.join("");
|
||||
// 95% of the default 20-cell gauge → 19 filled cells + 1 track space
|
||||
expect(renderedOutput).toContain(`${"\u2588".repeat(19)} `);
|
||||
expect(renderedOutput).not.toContain("\u2588".repeat(20));
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage coding-plan json", () => {
|
||||
test("outputs the three windows with used/total/percentage/resetTime", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO), "json");
|
||||
|
||||
expect(JSON.parse(output.join(""))).toEqual({
|
||||
instanceType: "pro",
|
||||
per5Hour: {
|
||||
usedQuota: 38,
|
||||
totalQuota: 100,
|
||||
percentage: 0.38,
|
||||
resetTime: 1_786_000_000_000,
|
||||
},
|
||||
perWeek: {
|
||||
usedQuota: 500,
|
||||
totalQuota: 1000,
|
||||
percentage: 0.5,
|
||||
resetTime: 1_786_100_000_000,
|
||||
},
|
||||
perBillMonth: {
|
||||
usedQuota: 950,
|
||||
totalQuota: 1000,
|
||||
percentage: 0.95,
|
||||
resetTime: 1_786_200_000_000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns an empty JSON object when no VALID instance exists", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(wrapResponse({}), "json");
|
||||
|
||||
expect(output.join("").trim()).toBe("{}");
|
||||
});
|
||||
|
||||
test("omits non-numeric quota fields from the JSON output", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(
|
||||
makeInstanceResponse(
|
||||
{ per5HourUsedQuota: "not-a-number", per5HourTotalQuota: 100 },
|
||||
{ instanceType: undefined },
|
||||
),
|
||||
"json",
|
||||
);
|
||||
|
||||
expect(JSON.parse(output.join(""))).toEqual({
|
||||
per5Hour: { totalQuota: 100 },
|
||||
perWeek: {},
|
||||
perBillMonth: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,7 @@ export const USAGE_ROUTES: E2eRouteExports = {
|
||||
"usage freetier": "usageFreetier",
|
||||
"usage stats": "usageStats",
|
||||
"usage token-plan": "usageTokenPlan",
|
||||
"usage coding-plan": "usageCodingPlan",
|
||||
};
|
||||
|
||||
export const DEPLOY_ROUTES: E2eRouteExports = {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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 coding-plan", () => {
|
||||
test("usage coding-plan --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
|
||||
"usage",
|
||||
"coding-plan",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/Coding Plan|quota/i);
|
||||
});
|
||||
|
||||
test("usage coding-plan --help 包含 --output json 示例", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
|
||||
"usage",
|
||||
"coding-plan",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("bl usage coding-plan --output json");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isConsoleE2EReady())("e2e: usage coding-plan(Console)", () => {
|
||||
test("usage coding-plan --dry-run 输出网关请求计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
|
||||
"usage",
|
||||
"coding-plan",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
api?: string;
|
||||
data?: { queryCodingPlanInstanceInfoRequest?: Record<string, unknown> };
|
||||
}>(stdout);
|
||||
expect(data.api).toBe("zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2");
|
||||
expect(data.data?.queryCodingPlanInstanceInfoRequest).toEqual({
|
||||
commodityCode: "sfm_codingplan_public_cn",
|
||||
onlyLatestOne: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("usage coding-plan --output json 返回窗口结构", async () => {
|
||||
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "coding-plan", "--output", "json"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
per5Hour?: { percentage?: number };
|
||||
perWeek?: { percentage?: number };
|
||||
perBillMonth?: { percentage?: number };
|
||||
}>(result.stdout);
|
||||
// 无有效订阅返回 {};有订阅时三个窗口必须存在
|
||||
if (Object.keys(data).length > 0) {
|
||||
expect(data.per5Hour).toBeTypeOf("object");
|
||||
expect(data.perWeek).toBeTypeOf("object");
|
||||
expect(data.perBillMonth).toBeTypeOf("object");
|
||||
}
|
||||
});
|
||||
|
||||
test("usage coding-plan 默认渲染生成时间与额度窗口或无订阅提示", async () => {
|
||||
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "coding-plan"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
if (result.stdout.includes("No active Coding Plan subscription found.")) return;
|
||||
expect(result.stdout).toContain("Generated at:");
|
||||
expect(result.stdout).toContain("5-hour quota");
|
||||
expect(result.stdout).toContain("1-week quota");
|
||||
expect(result.stdout).toContain("Monthly quota");
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,14 @@ 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 originalForceColor = process.env.FORCE_COLOR;
|
||||
const originalIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNoColor === undefined) delete process.env.NO_COLOR;
|
||||
else process.env.NO_COLOR = originalNoColor;
|
||||
if (originalForceColor === undefined) delete process.env.FORCE_COLOR;
|
||||
else process.env.FORCE_COLOR = originalForceColor;
|
||||
if (originalIsTty) Object.defineProperty(process.stdout, "isTTY", originalIsTty);
|
||||
else delete (process.stdout as { isTTY?: boolean }).isTTY;
|
||||
vi.restoreAllMocks();
|
||||
@@ -59,18 +62,28 @@ function wrapResponse(usage: Record<string, unknown>): Record<string, unknown> {
|
||||
}
|
||||
|
||||
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) => {
|
||||
test("renders the gauge with the usage-free brand fill color", async () => {
|
||||
delete process.env.NO_COLOR;
|
||||
process.env.FORCE_COLOR = "3";
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
||||
const output = captureStdout();
|
||||
|
||||
await runTokenPlan(makeUsageResponse(percentage));
|
||||
await runTokenPlan(makeUsageResponse(0.5));
|
||||
|
||||
expect(output.join("")).toContain(`\u001B[${colorCode}m`);
|
||||
// Brand-cyan fill cell, same as the `usage free` gauge column
|
||||
expect(output.join("")).toContain("\u001B[38;2;0;150;160m\u2588");
|
||||
});
|
||||
|
||||
test("renders proportional gauge cells with a transparent track", async () => {
|
||||
process.env.NO_COLOR = "1";
|
||||
const output = captureStdout();
|
||||
|
||||
await runTokenPlan(makeUsageResponse(0.5));
|
||||
|
||||
const renderedOutput = output.join("");
|
||||
// 50% of the default 20-cell gauge → 10 filled cells + 10 track spaces
|
||||
expect(renderedOutput).toContain(`${"\u2588".repeat(10)}${" ".repeat(10)}`);
|
||||
expect(renderedOutput).not.toContain("\u2588".repeat(11));
|
||||
});
|
||||
|
||||
test("accepts missing reset times when the quota usage is zero", async () => {
|
||||
|
||||
@@ -42,7 +42,13 @@ export {
|
||||
// Output facilities consumed by commands
|
||||
export { emitResult, emitBare, emitRequestId } from "./output/output.ts";
|
||||
export { formatTable } from "./output/table.ts";
|
||||
export { renderBoxTable, type BoxTableOptions, type BarColumn } from "./output/box-table.ts";
|
||||
export {
|
||||
renderBoxTable,
|
||||
renderGauge,
|
||||
type BoxTableOptions,
|
||||
type BarColumn,
|
||||
type GaugeCell,
|
||||
} from "./output/box-table.ts";
|
||||
export { createSpinner, createProgressBar } from "./output/progress.ts";
|
||||
export { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
|
||||
export { maybeShowStatusBar } from "./output/status-bar.ts";
|
||||
|
||||
@@ -136,6 +136,26 @@ interface RenderedCell {
|
||||
colored: string;
|
||||
}
|
||||
|
||||
/** A standalone gauge cell (bar + label) for non-table layouts, e.g. the usage quota box. */
|
||||
export interface GaugeCell {
|
||||
plain: string;
|
||||
colored: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single gauge cell in the `usage free` table style: brand-cyan fill,
|
||||
* transparent track, and a light-blue label after the bar. `percent` is 0-100;
|
||||
* null renders an empty gauge with a neutral label.
|
||||
*/
|
||||
export function renderGauge(
|
||||
percent: number | null,
|
||||
label: string,
|
||||
width: number = DEFAULT_BAR_WIDTH,
|
||||
out: NodeJS.WriteStream = process.stdout,
|
||||
): GaugeCell {
|
||||
return buildBarCell(percent, label, width, colorLevel(out));
|
||||
}
|
||||
|
||||
function buildBarCell(
|
||||
percent: number | null,
|
||||
label: string,
|
||||
|
||||
@@ -72,6 +72,7 @@ Use this table only after the decision table in [`bailian-protocol`](../bailian-
|
||||
| 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 |
|
||||
| Bailian Coding Plan quota usage | `bl usage coding-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` |
|
||||
|
||||
@@ -62,6 +62,7 @@ Use this index for the skill-scoped quick index and global flags.
|
||||
| `bl token-plan create-key` | AK/SK | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan list-seats` | AK/SK | List Token Plan subscription seat details | [token-plan.md](token-plan.md) |
|
||||
| `bl update` | No Auth | Update the CLI to the latest or a specified version | [update.md](update.md) |
|
||||
| `bl usage coding-plan` | Console | Show Coding Plan quota usage | [usage.md](usage.md) |
|
||||
| `bl usage free` | Console | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) |
|
||||
| `bl usage freetier` | Console | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) |
|
||||
| `bl usage stats` | Console | Query model usage statistics | [usage.md](usage.md) |
|
||||
@@ -92,7 +93,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`, `token-plan` | [usage.md](usage.md) |
|
||||
| `usage` | `coding-plan`, `free`, `freetier`, `stats`, `summary`, `token-plan` | [usage.md](usage.md) |
|
||||
| `workspace` | `init`, `list` | [workspace.md](workspace.md) |
|
||||
|
||||
## Global flags
|
||||
|
||||
@@ -7,16 +7,45 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Authentication | Description |
|
||||
| --------------------- | -------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `bl usage free` | Console | Query free-tier quota for models (all models if --model is omitted) |
|
||||
| `bl usage freetier` | Console | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable |
|
||||
| `bl usage stats` | Console | Query model usage statistics |
|
||||
| `bl usage summary` | Console | Show a unified usage summary: free-tier quota and recent usage overview |
|
||||
| `bl usage token-plan` | Console | Show Token Plan quota usage |
|
||||
| Command | Authentication | Description |
|
||||
| ---------------------- | -------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `bl usage coding-plan` | Console | Show Coding Plan quota usage |
|
||||
| `bl usage free` | Console | Query free-tier quota for models (all models if --model is omitted) |
|
||||
| `bl usage freetier` | Console | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable |
|
||||
| `bl usage stats` | Console | Query model usage statistics |
|
||||
| `bl usage summary` | Console | Show a unified usage summary: free-tier quota and recent usage overview |
|
||||
| `bl usage token-plan` | Console | Show Token Plan quota usage |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl usage coding-plan`
|
||||
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------ |
|
||||
| **Name** | `usage coding-plan` |
|
||||
| **Description** | Show Coding Plan quota usage |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl usage coding-plan [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
|
||||
| `--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 coding-plan
|
||||
```
|
||||
|
||||
```bash
|
||||
bl usage coding-plan --output json
|
||||
```
|
||||
|
||||
### `bl usage free`
|
||||
|
||||
| Field | Value |
|
||||
|
||||
Reference in New Issue
Block a user