Skip to content

Commit ed9de10

Browse files
committed
fix: normalize ambiguous quota counts
1 parent 11251ab commit ed9de10

6 files changed

Lines changed: 229 additions & 7 deletions

File tree

src/commands/quota/show.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { requestJson } from '../../client/http';
33
import { quotaEndpoint, usageEndpoint } from '../../client/endpoints';
44
import { formatOutput, detectOutputFormat } from '../../output/formatter';
55
import { renderUsage } from '../../output/usage';
6+
import { resolveQuotaCounts } from '../../utils/quota';
67
import type { Config } from '../../config/schema';
78
import type { GlobalFlags } from '../../types/flags';
89
import type { AccountBalanceResponse, QuotaModelRemain } from '../../types/api';
@@ -50,8 +51,22 @@ export default defineCommand({
5051

5152
if (config.quiet) {
5253
for (const m of models) {
53-
const remaining = m.current_interval_total_count - m.current_interval_usage_count;
54-
console.log(`${m.model_name}\t${m.current_interval_usage_count}\t${m.current_interval_total_count}\t${remaining}`);
54+
const counts = resolveQuotaCounts(
55+
m.current_interval_usage_count,
56+
m.current_interval_total_count,
57+
m.current_interval_remaining_percent,
58+
);
59+
60+
if (counts) {
61+
console.log(`${m.model_name}\t${counts.used}\t${counts.total}\t${counts.remaining}`);
62+
continue;
63+
}
64+
65+
const remaining = m.current_interval_remaining_percent === undefined
66+
|| m.current_interval_remaining_percent === null
67+
? '-'
68+
: `${m.current_interval_remaining_percent}%`;
69+
console.log(`${m.model_name}\t-\t${m.current_interval_total_count}\t${remaining}`);
5570
}
5671
return;
5772
}

src/output/quota-table.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { resolveQuotaCounts } from '../utils/quota';
12
import type { Config } from '../config/schema';
23
import type { QuotaModelRemain } from '../types/api';
34

@@ -151,7 +152,7 @@ const UNLIMITED_LABEL_EN = 'unlimited';
151152

152153
function renderMetric(
153154
label: string,
154-
remaining: number,
155+
reportedCount: number,
155156
total: number,
156157
percent: number | undefined | null,
157158
color: boolean,
@@ -169,10 +170,11 @@ function renderMetric(
169170
const bar = `[${'█'.repeat(COMPACT_BAR_WIDTH)}]`;
170171
return `${label} ${bar} ${ulStr}`;
171172
}
172-
const pct = remainingPct(percent, remaining, total, boostPermille);
173-
const bar = renderBar(pct, color, COMPACT_BAR_WIDTH, total <= 0);
174-
if (total > 0) {
175-
const count = `${remaining.toLocaleString()} / ${total.toLocaleString()}`;
173+
const pct = remainingPct(percent, reportedCount, total, boostPermille);
174+
const counts = resolveQuotaCounts(reportedCount, total, percent);
175+
const bar = renderBar(pct, color, COMPACT_BAR_WIDTH, counts === undefined);
176+
if (counts) {
177+
const count = `${counts.remaining.toLocaleString()} / ${counts.total.toLocaleString()}`;
176178
return color ? `${D}${label}${R} ${bar} ${remainingColors(pct)[0]}${count}${R}` : `${label} ${bar} ${count}`;
177179
}
178180
return `${label} ${bar}`;

src/utils/quota.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
export interface ResolvedQuotaCounts {
2+
used: number;
3+
remaining: number;
4+
total: number;
5+
}
6+
7+
const PERCENT_MATCH_TOLERANCE = 1;
8+
9+
/**
10+
* Resolve the ambiguous `*_usage_count` fields returned by the quota API.
11+
*
12+
* Older responses use the fields as remaining counts, while newer responses
13+
* may use them as consumed counts. When the server also returns an explicit
14+
* remaining percentage, use it to select the interpretation that agrees with
15+
* the authoritative percentage. Without a percentage, preserve the legacy
16+
* remaining-count interpretation.
17+
*/
18+
export function resolveQuotaCounts(
19+
reportedCount: number,
20+
total: number,
21+
remainingPercent?: number | null,
22+
): ResolvedQuotaCounts | undefined {
23+
if (!Number.isFinite(reportedCount)
24+
|| !Number.isFinite(total)
25+
|| total <= 0
26+
|| reportedCount < 0
27+
|| reportedCount > total) {
28+
return undefined;
29+
}
30+
31+
let remaining = reportedCount;
32+
33+
if (remainingPercent !== undefined
34+
&& remainingPercent !== null
35+
&& Number.isFinite(remainingPercent)) {
36+
const reportedAsRemaining = (reportedCount / total) * 100;
37+
const reportedAsUsed = ((total - reportedCount) / total) * 100;
38+
const remainingDistance = Math.abs(reportedAsRemaining - remainingPercent);
39+
const usedDistance = Math.abs(reportedAsUsed - remainingPercent);
40+
const closestDistance = Math.min(remainingDistance, usedDistance);
41+
42+
if (closestDistance > PERCENT_MATCH_TOLERANCE) return undefined;
43+
if (usedDistance < remainingDistance) remaining = total - reportedCount;
44+
}
45+
46+
return {
47+
used: total - remaining,
48+
remaining,
49+
total,
50+
};
51+
}

test/commands/quota/show.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,56 @@ describe('quota show command', () => {
108108
}
109109
});
110110

111+
it('normalizes ambiguous quota counts in quiet output', async () => {
112+
server = createMockServer({
113+
routes: {
114+
'/v1/token_plan/remains': () => jsonResponse({
115+
model_remains: [
116+
{
117+
model_name: 'legacy-video',
118+
current_interval_total_count: 3,
119+
current_interval_usage_count: 3,
120+
current_interval_remaining_percent: 100,
121+
},
122+
{
123+
model_name: 'current-video',
124+
current_interval_total_count: 5,
125+
current_interval_usage_count: 0,
126+
current_interval_remaining_percent: 100,
127+
},
128+
{
129+
model_name: 'general',
130+
current_interval_total_count: 0,
131+
current_interval_usage_count: 0,
132+
current_interval_remaining_percent: 99,
133+
},
134+
],
135+
}),
136+
},
137+
});
138+
139+
const output: string[] = [];
140+
const origLog = console.log;
141+
console.log = (msg: string) => { output.push(msg); };
142+
143+
try {
144+
await showCommand.execute(
145+
{
146+
...baseConfig,
147+
baseUrl: server.url,
148+
quiet: true,
149+
},
150+
{ ...baseFlags, quiet: true },
151+
);
152+
} finally {
153+
console.log = origLog;
154+
}
155+
156+
expect(output).toEqual([
157+
'legacy-video\t0\t3\t3',
158+
'current-video\t0\t5\t5',
159+
'general\t-\t0\t99%',
160+
]);
161+
});
162+
111163
});

test/output/quota-table.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,71 @@ describe('renderQuotaTable', () => {
130130
expect(output).not.toContain('0 / 3');
131131
});
132132

133+
it('uses remaining percent to disambiguate newer used-count responses', () => {
134+
const lines: string[] = [];
135+
const originalLog = console.log;
136+
137+
console.log = (message?: unknown) => {
138+
lines.push(String(message ?? ''));
139+
};
140+
141+
try {
142+
renderQuotaTable(
143+
[
144+
{
145+
...createModel(),
146+
model_name: 'video',
147+
current_interval_total_count: 5,
148+
current_interval_usage_count: 0,
149+
current_interval_remaining_percent: 100,
150+
current_weekly_total_count: 35,
151+
current_weekly_usage_count: 0,
152+
current_weekly_remaining_percent: 100,
153+
},
154+
],
155+
{ ...createConfig(), noColor: true },
156+
);
157+
} finally {
158+
console.log = originalLog;
159+
}
160+
161+
const output = lines.join('\n');
162+
expect(output).toContain('5 / 5');
163+
expect(output).toContain('35 / 35');
164+
expect(output).not.toContain('0 / 5');
165+
expect(output).not.toContain('0 / 35');
166+
});
167+
168+
it('falls back to the authoritative percent when counts cannot be reconciled', () => {
169+
const lines: string[] = [];
170+
const originalLog = console.log;
171+
172+
console.log = (message?: unknown) => {
173+
lines.push(String(message ?? ''));
174+
};
175+
176+
try {
177+
renderQuotaTable(
178+
[
179+
{
180+
...createModel(),
181+
current_interval_total_count: 10,
182+
current_interval_usage_count: 7,
183+
current_interval_remaining_percent: 40,
184+
},
185+
],
186+
{ ...createConfig(), noColor: true },
187+
);
188+
} finally {
189+
console.log = originalLog;
190+
}
191+
192+
const output = lines.join('\n');
193+
expect(output).toContain('Left [████......] 40%');
194+
expect(output).not.toContain('7 / 10');
195+
expect(output).not.toContain('3 / 10');
196+
});
197+
133198
it('renders the reset countdown in a boxed column with the window duration tag', () => {
134199
const lines: string[] = [];
135200
const originalLog = console.log;

test/utils/quota.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import { resolveQuotaCounts } from '../../src/utils/quota';
3+
4+
describe('resolveQuotaCounts', () => {
5+
it('preserves legacy responses where usage_count means remaining', () => {
6+
expect(resolveQuotaCounts(3, 3, 100)).toEqual({
7+
used: 0,
8+
remaining: 3,
9+
total: 3,
10+
});
11+
});
12+
13+
it('supports newer responses where usage_count means used', () => {
14+
expect(resolveQuotaCounts(0, 5, 100)).toEqual({
15+
used: 0,
16+
remaining: 5,
17+
total: 5,
18+
});
19+
});
20+
21+
it('preserves legacy semantics when no percentage is available', () => {
22+
expect(resolveQuotaCounts(4, 10)).toEqual({
23+
used: 6,
24+
remaining: 4,
25+
total: 10,
26+
});
27+
});
28+
29+
it('returns undefined when neither interpretation matches the percentage', () => {
30+
expect(resolveQuotaCounts(7, 10, 40)).toBeUndefined();
31+
});
32+
33+
it('returns undefined for zero-sized or invalid buckets', () => {
34+
expect(resolveQuotaCounts(0, 0, 99)).toBeUndefined();
35+
expect(resolveQuotaCounts(6, 5, 0)).toBeUndefined();
36+
});
37+
});

0 commit comments

Comments
 (0)