fix(server): tone usage bars by how full they are (#2322)

A usage bar in Settings -> Usage stayed green all the way to 100%, so
the one moment the meter matters was the moment it said nothing.

The client already computes this. `window-bar.tsx` reads
`window.tone ?? deriveTone(usedPct)`, and `deriveTone` escalates past
70% and 90%. But `usage.ts` only assigns `window.tone` when a provider
sends one, so any provider that sends a tone opts out of the thresholds
entirely. Claude and Kimi hardcoded `tone: "ok"`, and Codex escalated to
`warning` past 70% but could never reach `danger`.

Providers now derive tone from the percentage they already have, through
one shared `toneFromUsedPct` whose thresholds match the client's.
Claude, Codex and Kimi windows all escalate correctly.

Balances had the same gap by a different route: `balance-bar.tsx` has no
`deriveTone` fallback, and `balanceToneFromRemaining` only escalates
once a balance is completely spent, so a credits bar at 99% was green.
Cursor and Grok know their limit, so they now tone by used percentage.
Codex credits report only a remaining balance with no limit, so they
keep the remaining-based tone, documented as the no-limit case.

MiniMax is unchanged: it maps a status its own API supplies rather than
hardcoding a tone.

Verified against the live Anthropic API with a session window at 83%,
which now reports `warning` where it previously reported `ok`.

Closes #2320

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Christoph Leiter
2026-07-23 03:30:44 +08:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 10da5ca169
commit de69b2a2af
8 changed files with 181 additions and 13 deletions
@@ -947,3 +947,81 @@ describe("real provider usage fetchers", () => {
});
});
});
// Regression for #2320: providers hardcoded `tone: "ok"`, which suppressed the client's
// own thresholds (window-bar.tsx reads `window.tone ?? deriveTone(usedPct)`), so a bar
// stayed green at 99%. Codex escalated to "warning" but could never reach "danger".
describe("usage bars escalate as they fill", () => {
let claudeHome: string;
let codexHome: string;
beforeEach(() => {
claudeHome = mkdtempSync(join(tmpdir(), "paseo-tone-claude-"));
codexHome = mkdtempSync(join(tmpdir(), "paseo-tone-codex-"));
});
afterEach(() => {
rmSync(claudeHome, { recursive: true, force: true });
rmSync(codexHome, { recursive: true, force: true });
});
function claudeAt(utilization: number) {
writeClaudeCredentials(claudeHome, "at_valid");
return new ClaudeQuotaProvider({
logger: createLogger(),
claudeHome,
claudeKeychainReader: async () => null,
fetch: mockFetch(
new Map([
[
"https://api.anthropic.com/api/oauth/usage",
() =>
jsonResponse({
seven_day: { utilization, resets_at: "2026-06-04T00:00:00Z" },
}),
],
]),
),
}).fetchUsage();
}
it.each([
[10, "ok"],
[75, "warning"],
[99, "danger"],
])("a Claude window at %s%% is %s", async (utilization, tone) => {
const usage = await claudeAt(utilization);
expect(usage.windows).toEqual([expect.objectContaining({ id: "weekly", tone })]);
});
it("a Codex window can reach danger, not just warning", async () => {
writeCodexAuth(codexHome, "at_codex");
const usage = await new CodexQuotaProvider({
logger: createLogger(),
codexHome,
fetch: mockFetch(
new Map([
[
"https://chatgpt.com/backend-api/wham/usage",
() =>
jsonResponse(
makeCodexResponse({
rate_limit: {
primary_window: { used_percent: 12, reset_at: 1_748_812_800 },
secondary_window: { used_percent: 96, reset_at: 1_749_072_000 },
},
}),
),
],
]),
),
}).fetchUsage();
expect(usage.windows).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "session", tone: "ok" }),
expect.objectContaining({ id: "weekly", tone: "danger" }),
]),
);
});
});