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-22 21:30:44 +02:00
committed by GitHub
parent 10da5ca169
commit de69b2a2af
8 changed files with 181 additions and 13 deletions

View File

@@ -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),
}),
);
}

View File

@@ -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),
}),
);
}

View File

@@ -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)),
});
}

View File

@@ -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)),
});
}

View File

@@ -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: [],

View File

@@ -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" }),
]),
);
});
});

View File

@@ -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");
});
});

View File

@@ -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;