diff --git a/src/commands/quota/show.ts b/src/commands/quota/show.ts index e921068..8dbf4d1 100644 --- a/src/commands/quota/show.ts +++ b/src/commands/quota/show.ts @@ -3,6 +3,7 @@ import { requestJson } from '../../client/http'; import { quotaEndpoint, usageEndpoint } from '../../client/endpoints'; import { formatOutput, detectOutputFormat } from '../../output/formatter'; import { renderUsage } from '../../output/usage'; +import { resolveQuotaCounts } from '../../utils/quota'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; import type { AccountBalanceResponse, QuotaModelRemain } from '../../types/api'; @@ -50,8 +51,22 @@ export default defineCommand({ if (config.quiet) { for (const m of models) { - const remaining = m.current_interval_total_count - m.current_interval_usage_count; - console.log(`${m.model_name}\t${m.current_interval_usage_count}\t${m.current_interval_total_count}\t${remaining}`); + const counts = resolveQuotaCounts( + m.current_interval_usage_count, + m.current_interval_total_count, + m.current_interval_remaining_percent, + ); + + if (counts) { + console.log(`${m.model_name}\t${counts.used}\t${counts.total}\t${counts.remaining}`); + continue; + } + + const remaining = m.current_interval_remaining_percent === undefined + || m.current_interval_remaining_percent === null + ? '-' + : `${m.current_interval_remaining_percent}%`; + console.log(`${m.model_name}\t-\t${m.current_interval_total_count}\t${remaining}`); } return; } diff --git a/src/output/quota-table.ts b/src/output/quota-table.ts index 80b1bfa..439e678 100644 --- a/src/output/quota-table.ts +++ b/src/output/quota-table.ts @@ -1,3 +1,4 @@ +import { resolveQuotaCounts } from '../utils/quota'; import type { Config } from '../config/schema'; import type { QuotaModelRemain } from '../types/api'; @@ -151,7 +152,7 @@ const UNLIMITED_LABEL_EN = 'unlimited'; function renderMetric( label: string, - remaining: number, + reportedCount: number, total: number, percent: number | undefined | null, color: boolean, @@ -169,10 +170,11 @@ function renderMetric( const bar = `[${'█'.repeat(COMPACT_BAR_WIDTH)}]`; return `${label} ${bar} ${ulStr}`; } - const pct = remainingPct(percent, remaining, total, boostPermille); - const bar = renderBar(pct, color, COMPACT_BAR_WIDTH, total <= 0); - if (total > 0) { - const count = `${remaining.toLocaleString()} / ${total.toLocaleString()}`; + const pct = remainingPct(percent, reportedCount, total, boostPermille); + const counts = resolveQuotaCounts(reportedCount, total, percent); + const bar = renderBar(pct, color, COMPACT_BAR_WIDTH, counts === undefined); + if (counts) { + const count = `${counts.remaining.toLocaleString()} / ${counts.total.toLocaleString()}`; return color ? `${D}${label}${R} ${bar} ${remainingColors(pct)[0]}${count}${R}` : `${label} ${bar} ${count}`; } return `${label} ${bar}`; diff --git a/src/utils/quota.ts b/src/utils/quota.ts new file mode 100644 index 0000000..3cd0d6d --- /dev/null +++ b/src/utils/quota.ts @@ -0,0 +1,51 @@ +export interface ResolvedQuotaCounts { + used: number; + remaining: number; + total: number; +} + +const PERCENT_MATCH_TOLERANCE = 1; + +/** + * Resolve the ambiguous `*_usage_count` fields returned by the quota API. + * + * Older responses use the fields as remaining counts, while newer responses + * may use them as consumed counts. When the server also returns an explicit + * remaining percentage, use it to select the interpretation that agrees with + * the authoritative percentage. Without a percentage, preserve the legacy + * remaining-count interpretation. + */ +export function resolveQuotaCounts( + reportedCount: number, + total: number, + remainingPercent?: number | null, +): ResolvedQuotaCounts | undefined { + if (!Number.isFinite(reportedCount) + || !Number.isFinite(total) + || total <= 0 + || reportedCount < 0 + || reportedCount > total) { + return undefined; + } + + let remaining = reportedCount; + + if (remainingPercent !== undefined + && remainingPercent !== null + && Number.isFinite(remainingPercent)) { + const reportedAsRemaining = (reportedCount / total) * 100; + const reportedAsUsed = ((total - reportedCount) / total) * 100; + const remainingDistance = Math.abs(reportedAsRemaining - remainingPercent); + const usedDistance = Math.abs(reportedAsUsed - remainingPercent); + const closestDistance = Math.min(remainingDistance, usedDistance); + + if (closestDistance > PERCENT_MATCH_TOLERANCE) return undefined; + if (usedDistance < remainingDistance) remaining = total - reportedCount; + } + + return { + used: total - remaining, + remaining, + total, + }; +} diff --git a/test/commands/quota/show.test.ts b/test/commands/quota/show.test.ts index 35b7f8a..6a96ff7 100644 --- a/test/commands/quota/show.test.ts +++ b/test/commands/quota/show.test.ts @@ -108,4 +108,56 @@ describe('quota show command', () => { } }); + it('normalizes ambiguous quota counts in quiet output', async () => { + server = createMockServer({ + routes: { + '/v1/token_plan/remains': () => jsonResponse({ + model_remains: [ + { + model_name: 'legacy-video', + current_interval_total_count: 3, + current_interval_usage_count: 3, + current_interval_remaining_percent: 100, + }, + { + model_name: 'current-video', + current_interval_total_count: 5, + current_interval_usage_count: 0, + current_interval_remaining_percent: 100, + }, + { + model_name: 'general', + current_interval_total_count: 0, + current_interval_usage_count: 0, + current_interval_remaining_percent: 99, + }, + ], + }), + }, + }); + + const output: string[] = []; + const origLog = console.log; + console.log = (msg: string) => { output.push(msg); }; + + try { + await showCommand.execute( + { + ...baseConfig, + baseUrl: server.url, + quiet: true, + }, + { ...baseFlags, quiet: true }, + ); + } finally { + console.log = origLog; + } + + expect(output).toEqual([ + 'legacy-video\t0\t3\t3', + 'current-video\t0\t5\t5', + 'general\t-\t0\t99%', + ]); + }); + }); diff --git a/test/output/quota-table.test.ts b/test/output/quota-table.test.ts index 61e3a02..b69067e 100644 --- a/test/output/quota-table.test.ts +++ b/test/output/quota-table.test.ts @@ -130,6 +130,71 @@ describe('renderQuotaTable', () => { expect(output).not.toContain('0 / 3'); }); + it('uses remaining percent to disambiguate newer used-count responses', () => { + const lines: string[] = []; + const originalLog = console.log; + + console.log = (message?: unknown) => { + lines.push(String(message ?? '')); + }; + + try { + renderQuotaTable( + [ + { + ...createModel(), + model_name: 'video', + current_interval_total_count: 5, + current_interval_usage_count: 0, + current_interval_remaining_percent: 100, + current_weekly_total_count: 35, + current_weekly_usage_count: 0, + current_weekly_remaining_percent: 100, + }, + ], + { ...createConfig(), noColor: true }, + ); + } finally { + console.log = originalLog; + } + + const output = lines.join('\n'); + expect(output).toContain('5 / 5'); + expect(output).toContain('35 / 35'); + expect(output).not.toContain('0 / 5'); + expect(output).not.toContain('0 / 35'); + }); + + it('falls back to the authoritative percent when counts cannot be reconciled', () => { + const lines: string[] = []; + const originalLog = console.log; + + console.log = (message?: unknown) => { + lines.push(String(message ?? '')); + }; + + try { + renderQuotaTable( + [ + { + ...createModel(), + current_interval_total_count: 10, + current_interval_usage_count: 7, + current_interval_remaining_percent: 40, + }, + ], + { ...createConfig(), noColor: true }, + ); + } finally { + console.log = originalLog; + } + + const output = lines.join('\n'); + expect(output).toContain('Left [████......] 40%'); + expect(output).not.toContain('7 / 10'); + expect(output).not.toContain('3 / 10'); + }); + it('renders the reset countdown in a boxed column with the window duration tag', () => { const lines: string[] = []; const originalLog = console.log; diff --git a/test/utils/quota.test.ts b/test/utils/quota.test.ts new file mode 100644 index 0000000..339ecbc --- /dev/null +++ b/test/utils/quota.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'bun:test'; +import { resolveQuotaCounts } from '../../src/utils/quota'; + +describe('resolveQuotaCounts', () => { + it('preserves legacy responses where usage_count means remaining', () => { + expect(resolveQuotaCounts(3, 3, 100)).toEqual({ + used: 0, + remaining: 3, + total: 3, + }); + }); + + it('supports newer responses where usage_count means used', () => { + expect(resolveQuotaCounts(0, 5, 100)).toEqual({ + used: 0, + remaining: 5, + total: 5, + }); + }); + + it('preserves legacy semantics when no percentage is available', () => { + expect(resolveQuotaCounts(4, 10)).toEqual({ + used: 6, + remaining: 4, + total: 10, + }); + }); + + it('returns undefined when neither interpretation matches the percentage', () => { + expect(resolveQuotaCounts(7, 10, 40)).toBeUndefined(); + }); + + it('returns undefined for zero-sized or invalid buckets', () => { + expect(resolveQuotaCounts(0, 0, 99)).toBeUndefined(); + expect(resolveQuotaCounts(6, 5, 0)).toBeUndefined(); + }); +});