From de69b2a2af34e865af235bcb28ba94b897fce7ff Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Wed, 22 Jul 2026 21:30:44 +0200 Subject: [PATCH] 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 --- .../quota-fetcher/providers/claude.ts | 9 ++- .../services/quota-fetcher/providers/codex.ts | 7 +- .../quota-fetcher/providers/cursor.ts | 5 +- .../services/quota-fetcher/providers/grok.ts | 5 +- .../services/quota-fetcher/providers/kimi.ts | 9 ++- .../services/quota-fetcher/service.test.ts | 78 +++++++++++++++++++ .../src/services/quota-fetcher/usage.test.ts | 51 ++++++++++++ .../src/services/quota-fetcher/usage.ts | 30 +++++++ 8 files changed, 181 insertions(+), 13 deletions(-) create mode 100644 packages/server/src/services/quota-fetcher/usage.test.ts diff --git a/packages/server/src/services/quota-fetcher/providers/claude.ts b/packages/server/src/services/quota-fetcher/providers/claude.ts index 05624d481..1684f787a 100644 --- a/packages/server/src/services/quota-fetcher/providers/claude.ts +++ b/packages/server/src/services/quota-fetcher/providers/claude.ts @@ -14,6 +14,7 @@ import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; import { ApiNumberSchema, fetchProviderApi, + toneFromUsedPct, unavailableUsage, windowFromUsedPct, } from "../usage.js"; @@ -156,7 +157,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { label: "Session", utilizationPct: resp.five_hour.utilization, resetsAt: resp.five_hour.resets_at ?? null, - tone: "ok", + tone: toneFromUsedPct(resp.five_hour.utilization), }), ); } @@ -167,7 +168,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { label: "Weekly", utilizationPct: resp.seven_day.utilization, resetsAt: resp.seven_day.resets_at ?? null, - tone: "ok", + tone: toneFromUsedPct(resp.seven_day.utilization), }), ); } @@ -178,7 +179,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { label: "Weekly · Opus", utilizationPct: resp.seven_day_opus.utilization, resetsAt: resp.seven_day_opus.resets_at ?? null, - tone: "ok", + tone: toneFromUsedPct(resp.seven_day_opus.utilization), }), ); } @@ -189,7 +190,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { label: "Weekly · Omelette", utilizationPct: resp.seven_day_omelette.utilization, resetsAt: resp.seven_day_omelette.resets_at ?? null, - tone: "ok", + tone: toneFromUsedPct(resp.seven_day_omelette.utilization), }), ); } diff --git a/packages/server/src/services/quota-fetcher/providers/codex.ts b/packages/server/src/services/quota-fetcher/providers/codex.ts index 76adcb3f2..a0607fcf2 100644 --- a/packages/server/src/services/quota-fetcher/providers/codex.ts +++ b/packages/server/src/services/quota-fetcher/providers/codex.ts @@ -12,6 +12,7 @@ import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; import { ApiNumberSchema, balanceToneFromRemaining, + toneFromUsedPct, fetchProviderApi, unavailableUsage, windowFromUsedPct, @@ -143,7 +144,7 @@ export class CodexQuotaProvider implements ProviderUsageFetcher { label: "Session", utilizationPct: session.usedPct, resetsAt: session.resetsAt, - tone: "ok", + tone: toneFromUsedPct(session.usedPct), }), ); } @@ -154,7 +155,7 @@ export class CodexQuotaProvider implements ProviderUsageFetcher { label: "Weekly", utilizationPct: weekly.usedPct, resetsAt: weekly.resetsAt, - tone: weekly.usedPct >= 70 ? "warning" : "ok", + tone: toneFromUsedPct(weekly.usedPct), }), ); } @@ -165,7 +166,7 @@ export class CodexQuotaProvider implements ProviderUsageFetcher { label: "Code review", utilizationPct: codeReview.usedPct, resetsAt: codeReview.resetsAt, - tone: codeReview.usedPct >= 70 ? "warning" : "ok", + tone: toneFromUsedPct(codeReview.usedPct), }), ); } diff --git a/packages/server/src/services/quota-fetcher/providers/cursor.ts b/packages/server/src/services/quota-fetcher/providers/cursor.ts index 8831e2617..5f30cae5f 100644 --- a/packages/server/src/services/quota-fetcher/providers/cursor.ts +++ b/packages/server/src/services/quota-fetcher/providers/cursor.ts @@ -9,7 +9,8 @@ import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messag import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; import { ApiNullableNumberSchema, - balanceToneFromRemaining, + toneFromUsedPct, + usedPctOf, fetchProviderApi, toIsoStringOrNull, unavailableUsage, @@ -160,7 +161,7 @@ export class CursorQuotaProvider implements ProviderUsageFetcher { limit, unit: "usd", resetsAt: billingCycleEnd, - tone: balanceToneFromRemaining(remaining), + tone: toneFromUsedPct(usedPctOf(totalSpend, limit)), }); } diff --git a/packages/server/src/services/quota-fetcher/providers/grok.ts b/packages/server/src/services/quota-fetcher/providers/grok.ts index 3c4986a32..8d660c34c 100644 --- a/packages/server/src/services/quota-fetcher/providers/grok.ts +++ b/packages/server/src/services/quota-fetcher/providers/grok.ts @@ -7,7 +7,8 @@ import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messag import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; import { ApiNumberSchema, - balanceToneFromRemaining, + toneFromUsedPct, + usedPctOf, fetchProviderApi, unavailableUsage, } from "../usage.js"; @@ -89,7 +90,7 @@ export class GrokQuotaProvider implements ProviderUsageFetcher { remaining, limit: monthlyLimit, unit: "credits", - tone: balanceToneFromRemaining(remaining), + tone: toneFromUsedPct(usedPctOf(creditUsage, monthlyLimit)), }); } diff --git a/packages/server/src/services/quota-fetcher/providers/kimi.ts b/packages/server/src/services/quota-fetcher/providers/kimi.ts index 6b2b61f02..af3e6b560 100644 --- a/packages/server/src/services/quota-fetcher/providers/kimi.ts +++ b/packages/server/src/services/quota-fetcher/providers/kimi.ts @@ -5,7 +5,12 @@ import type { Logger } from "pino"; import { z } from "zod"; import type { ProviderUsage } from "../../../server/messages.js"; import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; -import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js"; +import { + ApiOptionalStringSchema, + fetchProviderApi, + toneFromUsedPct, + unavailableUsage, +} from "../usage.js"; const KimiUsageResponseSchema = z.object({ usage: z @@ -81,7 +86,7 @@ export class KimiQuotaProvider implements ProviderUsageFetcher { usedPct, remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct), resetsAt: resp.usage?.resetTime ?? null, - tone: "ok", + tone: toneFromUsedPct(usedPct), }, ], balances: [], diff --git a/packages/server/src/services/quota-fetcher/service.test.ts b/packages/server/src/services/quota-fetcher/service.test.ts index 71f371c34..5dfc18408 100644 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ b/packages/server/src/services/quota-fetcher/service.test.ts @@ -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" }), + ]), + ); + }); +}); diff --git a/packages/server/src/services/quota-fetcher/usage.test.ts b/packages/server/src/services/quota-fetcher/usage.test.ts new file mode 100644 index 000000000..8e37583e3 --- /dev/null +++ b/packages/server/src/services/quota-fetcher/usage.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { balanceToneFromRemaining, toneFromUsedPct, usedPctOf } from "./usage.js"; + +describe("toneFromUsedPct", () => { + // Thresholds must match deriveTone in the app's provider-usage/tone.ts, which is what + // the client applies when a window arrives without a tone. + it.each([ + [0, "ok"], + [69.9, "ok"], + [70, "warning"], + [90, "warning"], + [90.1, "danger"], + [100, "danger"], + [150, "danger"], + ])("%s%% used is %s", (usedPct, expected) => { + expect(toneFromUsedPct(usedPct)).toBe(expected); + }); + + it("is neutral when the percentage is unknown", () => { + expect(toneFromUsedPct(null)).toBe("default"); + expect(toneFromUsedPct(undefined)).toBe("default"); + }); +}); + +describe("usedPctOf", () => { + it("computes a percentage of the limit", () => { + expect(usedPctOf(15.79, 42.5)).toBeCloseTo(37.15, 2); + }); + + it("is unknown when either side is missing", () => { + expect(usedPctOf(null, 100)).toBeNull(); + expect(usedPctOf(50, null)).toBeNull(); + }); + + // A zero limit would divide to Infinity and render as a full red bar. + it("is unknown when the limit is zero or negative", () => { + expect(usedPctOf(50, 0)).toBeNull(); + expect(usedPctOf(50, -1)).toBeNull(); + }); +}); + +describe("balanceToneFromRemaining", () => { + // Kept for balances with no limit, where no percentage can be computed. It only + // escalates at exhaustion, which is why anything with a limit should use + // toneFromUsedPct instead. + it("stays ok until nothing is left", () => { + expect(balanceToneFromRemaining(0.01)).toBe("ok"); + expect(balanceToneFromRemaining(0)).toBe("danger"); + expect(balanceToneFromRemaining(null)).toBe("default"); + }); +}); diff --git a/packages/server/src/services/quota-fetcher/usage.ts b/packages/server/src/services/quota-fetcher/usage.ts index 148219960..fa766f577 100644 --- a/packages/server/src/services/quota-fetcher/usage.ts +++ b/packages/server/src/services/quota-fetcher/usage.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { ProviderUsage, ProviderUsageBalance, + ProviderUsageTone, ProviderUsageWindow, } from "../../server/messages.js"; import type { ProviderApiFetch } from "./provider.js"; @@ -67,6 +68,26 @@ export function windowFromUsedPct(input: { return window; } +/** + * The tone scale for anything measured against a known limit, windows and balances alike. + * + * Thresholds match `deriveTone` in the app's provider-usage/tone.ts, which is what the + * client falls back to when a window arrives without a tone. Healthy is "ok" rather than + * "default" because that is what every provider setting a tone has always sent, and it is + * what the bars render today below their thresholds. + */ +export function toneFromUsedPct(usedPct: number | null | undefined): ProviderUsageTone { + if (typeof usedPct !== "number") return "default"; + if (usedPct > 90) return "danger"; + if (usedPct >= 70) return "warning"; + return "ok"; +} + +/** + * Tone for a balance with no known limit, where a percentage cannot be computed and the + * only signal is whether anything is left. Prefer `toneFromUsedPct` when a limit exists: + * this one stays "ok" until the balance is completely spent. + */ export function balanceToneFromRemaining( remaining: number | null | undefined, ): ProviderUsageBalance["tone"] { @@ -75,6 +96,15 @@ export function balanceToneFromRemaining( return "ok"; } +/** Percentage of a limit consumed, or null when either side is unknown. */ +export function usedPctOf( + used: number | null | undefined, + limit: number | null | undefined, +): number | null { + if (typeof used !== "number" || typeof limit !== "number" || limit <= 0) return null; + return (used / limit) * 100; +} + export function toIsoStringOrNull(timestampMs: number): string | null { const date = new Date(timestampMs); return Number.isFinite(date.getTime()) ? date.toISOString() : null;