mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -14,6 +14,7 @@ import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
|||||||
import {
|
import {
|
||||||
ApiNumberSchema,
|
ApiNumberSchema,
|
||||||
fetchProviderApi,
|
fetchProviderApi,
|
||||||
|
toneFromUsedPct,
|
||||||
unavailableUsage,
|
unavailableUsage,
|
||||||
windowFromUsedPct,
|
windowFromUsedPct,
|
||||||
} from "../usage.js";
|
} from "../usage.js";
|
||||||
@@ -156,7 +157,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
|
|||||||
label: "Session",
|
label: "Session",
|
||||||
utilizationPct: resp.five_hour.utilization,
|
utilizationPct: resp.five_hour.utilization,
|
||||||
resetsAt: resp.five_hour.resets_at ?? null,
|
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",
|
label: "Weekly",
|
||||||
utilizationPct: resp.seven_day.utilization,
|
utilizationPct: resp.seven_day.utilization,
|
||||||
resetsAt: resp.seven_day.resets_at ?? null,
|
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",
|
label: "Weekly · Opus",
|
||||||
utilizationPct: resp.seven_day_opus.utilization,
|
utilizationPct: resp.seven_day_opus.utilization,
|
||||||
resetsAt: resp.seven_day_opus.resets_at ?? null,
|
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",
|
label: "Weekly · Omelette",
|
||||||
utilizationPct: resp.seven_day_omelette.utilization,
|
utilizationPct: resp.seven_day_omelette.utilization,
|
||||||
resetsAt: resp.seven_day_omelette.resets_at ?? null,
|
resetsAt: resp.seven_day_omelette.resets_at ?? null,
|
||||||
tone: "ok",
|
tone: toneFromUsedPct(resp.seven_day_omelette.utilization),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
|||||||
import {
|
import {
|
||||||
ApiNumberSchema,
|
ApiNumberSchema,
|
||||||
balanceToneFromRemaining,
|
balanceToneFromRemaining,
|
||||||
|
toneFromUsedPct,
|
||||||
fetchProviderApi,
|
fetchProviderApi,
|
||||||
unavailableUsage,
|
unavailableUsage,
|
||||||
windowFromUsedPct,
|
windowFromUsedPct,
|
||||||
@@ -143,7 +144,7 @@ export class CodexQuotaProvider implements ProviderUsageFetcher {
|
|||||||
label: "Session",
|
label: "Session",
|
||||||
utilizationPct: session.usedPct,
|
utilizationPct: session.usedPct,
|
||||||
resetsAt: session.resetsAt,
|
resetsAt: session.resetsAt,
|
||||||
tone: "ok",
|
tone: toneFromUsedPct(session.usedPct),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -154,7 +155,7 @@ export class CodexQuotaProvider implements ProviderUsageFetcher {
|
|||||||
label: "Weekly",
|
label: "Weekly",
|
||||||
utilizationPct: weekly.usedPct,
|
utilizationPct: weekly.usedPct,
|
||||||
resetsAt: weekly.resetsAt,
|
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",
|
label: "Code review",
|
||||||
utilizationPct: codeReview.usedPct,
|
utilizationPct: codeReview.usedPct,
|
||||||
resetsAt: codeReview.resetsAt,
|
resetsAt: codeReview.resetsAt,
|
||||||
tone: codeReview.usedPct >= 70 ? "warning" : "ok",
|
tone: toneFromUsedPct(codeReview.usedPct),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messag
|
|||||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||||
import {
|
import {
|
||||||
ApiNullableNumberSchema,
|
ApiNullableNumberSchema,
|
||||||
balanceToneFromRemaining,
|
toneFromUsedPct,
|
||||||
|
usedPctOf,
|
||||||
fetchProviderApi,
|
fetchProviderApi,
|
||||||
toIsoStringOrNull,
|
toIsoStringOrNull,
|
||||||
unavailableUsage,
|
unavailableUsage,
|
||||||
@@ -160,7 +161,7 @@ export class CursorQuotaProvider implements ProviderUsageFetcher {
|
|||||||
limit,
|
limit,
|
||||||
unit: "usd",
|
unit: "usd",
|
||||||
resetsAt: billingCycleEnd,
|
resetsAt: billingCycleEnd,
|
||||||
tone: balanceToneFromRemaining(remaining),
|
tone: toneFromUsedPct(usedPctOf(totalSpend, limit)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messag
|
|||||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||||
import {
|
import {
|
||||||
ApiNumberSchema,
|
ApiNumberSchema,
|
||||||
balanceToneFromRemaining,
|
toneFromUsedPct,
|
||||||
|
usedPctOf,
|
||||||
fetchProviderApi,
|
fetchProviderApi,
|
||||||
unavailableUsage,
|
unavailableUsage,
|
||||||
} from "../usage.js";
|
} from "../usage.js";
|
||||||
@@ -89,7 +90,7 @@ export class GrokQuotaProvider implements ProviderUsageFetcher {
|
|||||||
remaining,
|
remaining,
|
||||||
limit: monthlyLimit,
|
limit: monthlyLimit,
|
||||||
unit: "credits",
|
unit: "credits",
|
||||||
tone: balanceToneFromRemaining(remaining),
|
tone: toneFromUsedPct(usedPctOf(creditUsage, monthlyLimit)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import type { Logger } from "pino";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { ProviderUsage } from "../../../server/messages.js";
|
import type { ProviderUsage } from "../../../server/messages.js";
|
||||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.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({
|
const KimiUsageResponseSchema = z.object({
|
||||||
usage: z
|
usage: z
|
||||||
@@ -81,7 +86,7 @@ export class KimiQuotaProvider implements ProviderUsageFetcher {
|
|||||||
usedPct,
|
usedPct,
|
||||||
remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct),
|
remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct),
|
||||||
resetsAt: resp.usage?.resetTime ?? null,
|
resetsAt: resp.usage?.resetTime ?? null,
|
||||||
tone: "ok",
|
tone: toneFromUsedPct(usedPct),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
balances: [],
|
balances: [],
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
51
packages/server/src/services/quota-fetcher/usage.test.ts
Normal file
51
packages/server/src/services/quota-fetcher/usage.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
import type {
|
import type {
|
||||||
ProviderUsage,
|
ProviderUsage,
|
||||||
ProviderUsageBalance,
|
ProviderUsageBalance,
|
||||||
|
ProviderUsageTone,
|
||||||
ProviderUsageWindow,
|
ProviderUsageWindow,
|
||||||
} from "../../server/messages.js";
|
} from "../../server/messages.js";
|
||||||
import type { ProviderApiFetch } from "./provider.js";
|
import type { ProviderApiFetch } from "./provider.js";
|
||||||
@@ -67,6 +68,26 @@ export function windowFromUsedPct(input: {
|
|||||||
return window;
|
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(
|
export function balanceToneFromRemaining(
|
||||||
remaining: number | null | undefined,
|
remaining: number | null | undefined,
|
||||||
): ProviderUsageBalance["tone"] {
|
): ProviderUsageBalance["tone"] {
|
||||||
@@ -75,6 +96,15 @@ export function balanceToneFromRemaining(
|
|||||||
return "ok";
|
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 {
|
export function toIsoStringOrNull(timestampMs: number): string | null {
|
||||||
const date = new Date(timestampMs);
|
const date = new Date(timestampMs);
|
||||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||||
|
|||||||
Reference in New Issue
Block a user