diff --git a/packages/commands/src/commands/quota/check.ts b/packages/commands/src/commands/quota/check.ts index f8b4277..1160a71 100644 --- a/packages/commands/src/commands/quota/check.ts +++ b/packages/commands/src/commands/quota/check.ts @@ -4,8 +4,7 @@ import { detectOutputFormat, type Client, } from "bailian-cli-core"; -import { ansi, emitResult } from "bailian-cli-runtime"; -import { displayWidth, padEnd } from "bailian-cli-runtime"; +import { ansi, emitResult, renderBoxTable } from "bailian-cli-runtime"; const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels"; const MONITOR_API = "zeldaEasy.bailian-telemetry.monitor.getMonitorData"; @@ -49,24 +48,6 @@ function calculateTPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): n return Math.floor((item.usage_limit * 60) / period); } -function formatNumber(num: number): string { - return num.toLocaleString("en-US"); -} - -function formatRatio(usage: number, limit: number): string { - if (limit <= 0) return "-"; - const pct = Math.round((usage / limit) * 100); - return `${formatNumber(usage)}/${formatNumber(limit)} (${pct}%)`; -} - -function getStatus(usage: number, limit: number): string { - if (limit <= 0) return "-"; - const pct = (usage / limit) * 100; - if (pct >= 100) return "Rate Limited"; - if (pct >= 80) return "Near limit"; - return "Normal"; -} - function getNestedRecord( obj: Record, key: string, @@ -157,7 +138,11 @@ async function fetchMonitorData( } return { rpm, tpm }; - } catch { + } catch (error) { + // Re-throw console authentication errors, but catch other errors + if (error instanceof Error && error.message?.includes("Console session")) { + throw error; + } return { rpm: -1, tpm: -1 }; } } @@ -168,16 +153,24 @@ interface CheckRow { rpmLimit: number; tpmUsage: number; tpmLimit: number; + rpmQuotaLeft: number | null; + tpmQuotaLeft: number | null; + rpmQuotaLabel: string | null; + tpmQuotaLabel: string | null; } function printTable(rows: CheckRow[]): void { const color = ansi(process.stdout); + const headers = ["Model", "RPM Used/Limit", "TPM Used/Limit", "Status", "RPM Left", "TPM Left"]; - const headers = ["Model", "RPM Usage/Limit", "TPM Usage/Limit", "Status"]; + const rpmPercents = rows.map((r) => r.rpmQuotaLeft); + const rpmLabels = rows.map((r) => r.rpmQuotaLabel); + const tpmPercents = rows.map((r) => r.tpmQuotaLeft); + const tpmLabels = rows.map((r) => r.tpmQuotaLabel); const tableRows = rows.map((r) => { - const rpmStr = r.rpmUsage < 0 ? "-" : formatRatio(r.rpmUsage, r.rpmLimit); - const tpmStr = r.tpmUsage < 0 ? "-" : formatRatio(r.tpmUsage, r.tpmLimit); + const rpmStr = r.rpmUsage < 0 ? "-" : `${r.rpmUsage}/${r.rpmLimit}`; + const tpmStr = r.tpmUsage < 0 ? "-" : `${r.tpmUsage}/${r.tpmLimit}`; const maxPct = Math.max( r.rpmLimit > 0 ? (r.rpmUsage / r.rpmLimit) * 100 : 0, r.tpmLimit > 0 ? (r.tpmUsage / r.tpmLimit) * 100 : 0, @@ -185,39 +178,34 @@ function printTable(rows: CheckRow[]): void { const status = r.rpmUsage < 0 ? "-" - : getStatus(Math.max(r.rpmUsage, r.tpmUsage), Math.max(r.rpmLimit, r.tpmLimit)); - return { cells: [r.model, rpmStr, tpmStr, status], maxPct }; + : maxPct >= 100 + ? "Rate Limited" + : maxPct >= 80 + ? "Near limit" + : "Normal"; + return [r.model, rpmStr, tpmStr, status, "", ""]; }); - if (tableRows.length === 0) { - process.stdout.write("No models found.\n"); - return; - } - - const widths = headers.map((label, col) => - Math.max(displayWidth(label), ...tableRows.map((r) => displayWidth(r.cells[col]))), - ); - - const headerLine = headers.map((label, col) => color.bold(padEnd(label, widths[col]))).join(" "); - const separator = widths.map((w) => color.dim("─".repeat(w))).join("──"); - - process.stdout.write(headerLine + "\n"); - process.stdout.write(separator + "\n"); - - const statusCol = 3; - for (const r of tableRows) { - const cells = r.cells.map((cell, col) => { - if (col === statusCol) { - if (cell === "Rate Limited") return color.red(padEnd(cell, widths[col])); - if (cell === "Near limit") return color.yellow(padEnd(cell, widths[col])); - if (cell === "Normal") return color.green(padEnd(cell, widths[col])); + const lines = renderBoxTable({ + headers, + rows: tableRows, + align: ["left", "right", "right", "left", "left", "left"], + barColumns: [ + { index: 4, percents: rpmPercents, labels: rpmLabels, width: 15 }, + { index: 5, percents: tpmPercents, labels: tpmLabels, width: 15 }, + ], + cellColor: (rowIndex, colIndex, value) => { + if (colIndex === 3) { + // Status 列着色 + if (value === "Rate Limited") return color.red(value); + if (value === "Near limit") return color.yellow(value); + if (value === "Normal") return color.green(value); } - return padEnd(cell, widths[col]); - }); - process.stdout.write(cells.join(" ") + "\n"); - } + return undefined; + }, + }); - process.stdout.write(color.dim(`\nTotal: ${rows.length} models`) + "\n"); + for (const line of lines) process.stdout.write(line + "\n"); } export default defineCommand({ @@ -295,12 +283,35 @@ export default defineCommand({ const tpmLimit = calculateTPM(userSpec, modelDefault?.usage_limit_period) || calculateTPM(modelDefault); + const rpmUsage = monitorResults[idx].rpm; + const tpmUsage = monitorResults[idx].tpm; + + // RPM Quota Left = 1 - (rpmUsage / rpmLimit) in percentage + let rpmQuotaPercent: number | null = null; + let rpmQuotaLabel: string | null = null; + if (rpmUsage >= 0 && rpmLimit > 0) { + rpmQuotaPercent = Math.max(0, 100 - (rpmUsage / rpmLimit) * 100); + rpmQuotaLabel = rpmQuotaPercent.toFixed(1) + "%"; + } + + // TPM Quota Left = 1 - (tpmUsage / tpmLimit) in percentage + let tpmQuotaPercent: number | null = null; + let tpmQuotaLabel: string | null = null; + if (tpmUsage >= 0 && tpmLimit > 0) { + tpmQuotaPercent = Math.max(0, 100 - (tpmUsage / tpmLimit) * 100); + tpmQuotaLabel = tpmQuotaPercent.toFixed(1) + "%"; + } + return { model: m.model, - rpmUsage: monitorResults[idx].rpm, + rpmUsage, rpmLimit, - tpmUsage: monitorResults[idx].tpm, + tpmUsage, tpmLimit, + rpmQuotaLeft: rpmQuotaPercent, + tpmQuotaLeft: tpmQuotaPercent, + rpmQuotaLabel, + tpmQuotaLabel, }; }); diff --git a/packages/commands/src/commands/quota/list.ts b/packages/commands/src/commands/quota/list.ts index db3be9e..1ebb8fd 100644 --- a/packages/commands/src/commands/quota/list.ts +++ b/packages/commands/src/commands/quota/list.ts @@ -1,8 +1,8 @@ import { defineCommand, BailianError, detectOutputFormat, type Client } from "bailian-cli-core"; -import { ansi, emitResult } from "bailian-cli-runtime"; -import { displayWidth, padEnd } from "bailian-cli-runtime"; +import { emitResult, renderBoxTable } from "bailian-cli-runtime"; const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels"; +const MONITOR_API = "zeldaEasy.bailian-telemetry.monitor.getMonitorData"; interface QpmInfoItem { count_limit: number; @@ -18,6 +18,17 @@ interface ModelWithQpm { qpmInfo?: Record; } +interface MonitorPoint { + value: number; + timestamp: number; +} + +interface MonitorMetric { + aggMethod: string; + metricName: string; + points: MonitorPoint[]; +} + function calculateRPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): number { if (!item) return 0; const period = item.count_limit_period || fallbackPeriod; @@ -36,6 +47,58 @@ function formatNumber(num: number): string { return num.toLocaleString("en-US"); } +async function fetchMonitorData( + client: Client, + modelName: string, + windowMinutes: number, +): Promise<{ rpm: number; tpm: number }> { + const now = Date.now(); + const startTime = now - windowMinutes * 60 * 1000; + + try { + const raw = await client.console(MONITOR_API, { + reqDTO: { + monitorType: "Advanced", + metricFilters: [ + { aggMethod: "sum_pm", metricName: "model_total_amount" }, + { aggMethod: "sum_pm", metricName: "model_call_count" }, + ], + labelFilters: { + resourceId: modelName, + resourceType: "model", + }, + startTime, + endTime: now, + }, + }); + + const resp = extractResponseData(raw as Record); + const metrics = (resp.data ?? resp) as MonitorMetric[] | Record; + if (!Array.isArray(metrics)) { + return { rpm: 0, tpm: 0 }; + } + + let rpm = 0; + let tpm = 0; + + for (const metric of metrics) { + if (metric.aggMethod !== "sum_pm" || !metric.points?.length) continue; + const lastValue = metric.points[metric.points.length - 1].value ?? 0; + if (metric.metricName === "model_call_count") rpm = Math.round(lastValue); + if (metric.metricName === "model_total_amount") tpm = Math.round(lastValue); + } + + return { rpm, tpm }; + } catch (error) { + // Re-throw console authentication errors, but catch other errors + if (error instanceof Error && error.message?.includes("Console session")) { + throw error; + } + // Return -1 to indicate no data (same as check.ts for consistency) + return { rpm: -1, tpm: -1 }; + } +} + function getNestedRecord( obj: Record, key: string, @@ -58,10 +121,7 @@ function extractResponseData(result: Record): Record { +async function fetchAllModelsWithQpm(client: Client): Promise { const allModels: ModelWithQpm[] = []; let pageNo = 1; @@ -72,10 +132,8 @@ async function fetchAllModelsWithQpm( group: false, queryQpmInfo: true, ignoreWorkspaceServiceSite: true, + supports: { selfServiceLimitIncrease: true }, }; - if (onlySelfService) { - input.supports = { selfServiceLimitIncrease: true }; - } const raw = await client.console(MODEL_LIST_API, { input }); @@ -91,50 +149,38 @@ async function fetchAllModelsWithQpm( return allModels; } -function printTable(models: ModelWithQpm[]): void { - const color = ansi(process.stdout); +interface ListRow { + model: string; + rpm: string; + tpm: string; + maxTpm: string; + rpmQuotaLeft: number | null; + tpmQuotaLeft: number | null; + rpmQuotaLabel: string | null; + tpmQuotaLabel: string | null; +} - const headers = ["Model", "Req/min", "Token/min", "Max TPM"]; +function printTable(rows: ListRow[]): void { + const headers = ["Model", "Req/min", "Token/min", "RPM Left", "TPM Left"]; - const rows = models.map((m) => { - const qpm = m.qpmInfo; - const modelDefault = qpm?.["model-default"]; - const userSpec = qpm?.["user-spec"]; + const rpmPercents = rows.map((r) => r.rpmQuotaLeft); + const rpmLabels = rows.map((r) => r.rpmQuotaLabel); + const tpmPercents = rows.map((r) => r.tpmQuotaLeft); + const tpmLabels = rows.map((r) => r.tpmQuotaLabel); - const defaultRPM = calculateRPM(modelDefault); - const defaultTPM = calculateTPM(modelDefault); - const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM; - const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM; - const maxTPM = defaultTPM * 2; + const tableRows = rows.map((r) => [r.model, r.rpm, r.tpm, "", ""]); - return [ - m.model, - currentRPM > 0 ? formatNumber(currentRPM) : "-", - currentTPM > 0 ? formatNumber(currentTPM) : "-", - maxTPM > 0 ? formatNumber(maxTPM) : "-", - ]; + const lines = renderBoxTable({ + headers, + rows: tableRows, + align: ["left", "right", "right", "left", "left"], + barColumns: [ + { index: 3, percents: rpmPercents, labels: rpmLabels, width: 15 }, + { index: 4, percents: tpmPercents, labels: tpmLabels, width: 15 }, + ], }); - if (rows.length === 0) { - process.stdout.write("No models found.\n"); - return; - } - - const widths = headers.map((label, col) => - Math.max(displayWidth(label), ...rows.map((row) => displayWidth(row[col]))), - ); - - const headerLine = headers.map((label, col) => color.bold(padEnd(label, widths[col]))).join(" "); - const separator = widths.map((w) => color.dim("─".repeat(w))).join("──"); - - process.stdout.write(headerLine + "\n"); - process.stdout.write(separator + "\n"); - - for (const row of rows) { - process.stdout.write(row.map((cell, col) => padEnd(cell, widths[col])).join(" ") + "\n"); - } - - process.stdout.write(color.dim(`\nTotal: ${models.length} models`) + "\n"); + for (const line of lines) process.stdout.write(line + "\n"); } export default defineCommand({ @@ -178,7 +224,7 @@ export default defineCommand({ return; } - let models = await fetchAllModelsWithQpm(ctx.client, !showAll); + let models = await fetchAllModelsWithQpm(ctx.client); if (modelFlag) { const names = new Set( @@ -216,6 +262,58 @@ export default defineCommand({ return; } - printTable(models); + // For text output with gauges, we need monitor data + const monitorResults = await Promise.all( + models.map((m) => fetchMonitorData(ctx.client, m.model, 2)), + ); + + const rows: ListRow[] = models.map((m, idx) => { + const qpm = m.qpmInfo; + const modelDefault = qpm?.["model-default"]; + const userSpec = qpm?.["user-spec"]; + + const defaultRPM = calculateRPM(modelDefault); + const defaultTPM = calculateTPM(modelDefault); + const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM; + const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM; + const maxTPM = defaultTPM * 2; + + const rpmUsage = monitorResults[idx].rpm; + const tpmUsage = monitorResults[idx].tpm; + + // RPM Quota Left = 1 - (rpmUsage / currentRPM) in percentage + let rpmQuotaPercent: number | null = null; + let rpmQuotaLabel: string | null = null; + if (rpmUsage >= 0 && currentRPM > 0) { + rpmQuotaPercent = Math.max(0, 100 - (rpmUsage / currentRPM) * 100); + rpmQuotaLabel = rpmQuotaPercent.toFixed(1) + "%"; + } + + // TPM Quota Left = 1 - (tpmUsage / currentTPM) in percentage + let tpmQuotaPercent: number | null = null; + let tpmQuotaLabel: string | null = null; + if (tpmUsage >= 0 && currentTPM > 0) { + tpmQuotaPercent = Math.max(0, 100 - (tpmUsage / currentTPM) * 100); + tpmQuotaLabel = tpmQuotaPercent.toFixed(1) + "%"; + } + + return { + model: m.model, + rpm: currentRPM > 0 ? formatNumber(currentRPM) : "-", + tpm: currentTPM > 0 ? formatNumber(currentTPM) : "-", + maxTpm: maxTPM > 0 ? formatNumber(maxTPM) : "-", + rpmQuotaLeft: rpmQuotaPercent, + tpmQuotaLeft: tpmQuotaPercent, + rpmQuotaLabel, + tpmQuotaLabel, + }; + }); + + if (rows.length === 0) { + process.stdout.write("No models found.\n"); + return; + } + + printTable(rows); }, }); diff --git a/packages/runtime/src/output/box-table.ts b/packages/runtime/src/output/box-table.ts index 0f16921..5fab69f 100644 --- a/packages/runtime/src/output/box-table.ts +++ b/packages/runtime/src/output/box-table.ts @@ -118,7 +118,10 @@ export interface BoxTableOptions { rows: string[][]; /** Per-column alignment; defaults to left. */ align?: ("left" | "right")[]; + /** Single gauge column (legacy; use barColumns for multiple). */ barColumn?: BarColumn; + /** Multiple gauge columns (overrides barColumn if provided). */ + barColumns?: BarColumn[]; /** Return a colored variant of a plain cell value (must keep the same visible width). */ cellColor?: (rowIndex: number, colIndex: number, value: string) => string | undefined; /** Stream used for color capability detection (default process.stdout). */ @@ -173,14 +176,19 @@ export function renderBoxTable(options: BoxTableOptions): string[] { const out = options.out ?? process.stdout; const level = colorLevel(out); const align = options.align ?? []; - const barCol = options.barColumn; - const barWidth = barCol?.width ?? DEFAULT_BAR_WIDTH; + + // Support both single barColumn (legacy) and multiple barColumns (new) + const barColumns = options.barColumns ?? (options.barColumn ? [options.barColumn] : []); const columnCount = options.headers.length; - // Precompute bar cells so widths use their plain representation. - const barCells: RenderedCell[] = []; - if (barCol) { + // Precompute bar cells for all gauge columns + type BarCellsMap = Record; + const allBarCells: BarCellsMap = {}; + + for (const barCol of barColumns) { + const barWidth = barCol.width ?? DEFAULT_BAR_WIDTH; + const barCells: RenderedCell[] = []; for (let rowIndex = 0; rowIndex < options.rows.length; rowIndex++) { const percent = barCol.percents[rowIndex] ?? null; const explicitLabel = barCol.labels?.[rowIndex]; @@ -192,10 +200,11 @@ export function renderBoxTable(options: BoxTableOptions): string[] { : `${Number.isInteger(percent) ? percent : percent.toFixed(1)}%`; barCells.push(buildBarCell(percent, label, barWidth, level)); } + allBarCells[barCol.index] = barCells; } const plainCell = (rowIndex: number, colIndex: number): string => { - if (barCol && colIndex === barCol.index) return barCells[rowIndex].plain; + if (allBarCells[colIndex]) return allBarCells[colIndex][rowIndex].plain; return options.rows[rowIndex][colIndex] ?? ""; }; @@ -236,8 +245,9 @@ export function renderBoxTable(options: BoxTableOptions): string[] { const cells: string[] = []; for (let colIndex = 0; colIndex < columnCount; colIndex++) { const width = widths[colIndex]; - if (barCol && colIndex === barCol.index) { - const bar = barCells[rowIndex]; + // Check if this column is a gauge column + if (allBarCells[colIndex]) { + const bar = allBarCells[colIndex][rowIndex]; const gap = width - displayWidth(bar.plain); cells.push(gap > 0 ? bar.colored + " ".repeat(gap) : bar.colored); continue;