forked from farhoodlabs/paperclip
fix: Anthropic subscription quota always shows 100% used (#3589)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The Costs > Providers tab displays live subscription quota from each adapter (Claude, Codex) > - The Claude adapter fetches utilization from the Anthropic OAuth usage API and converts it to a 0-100 percent via `toPercent()` > - The API changed to return utilization as 0-100 percentages (e.g. `34.0` = 34%), but `toPercent()` assumed 0-1 fractions and multiplied by 100 > - After `Math.min(100, ...)` clamping, every quota window displayed as 100% used regardless of actual usage > - Additionally, `extra_usage.used_credits` and `monthly_limit` are returned in cents but were formatted as dollars, showing $6,793 instead of $67.93 > - This PR applies the same `< 1` heuristic already proven in the Codex adapter and fixes the cents-to-dollars conversion > - The benefit is accurate quota display matching what users see on claude.ai/settings/usage ## What Changed - `toPercent()`: apply `< 1` heuristic to handle both legacy 0-1 fractions and current 0-100 percentage API responses (consistent with Codex adapter's `normalizeCodexUsedPercent()`) - `formatExtraUsageLabel()`: divide `used_credits` and `monthly_limit` by 100 to convert cents to dollars before formatting - Updated all `toPercent` and `fetchClaudeQuota` tests to use current API format (0-100 range) - Added backward-compatibility test for legacy 0-1 fraction values - Added test for enabled extra usage with utilization and cents-to-dollars conversion ## Verification - `toPercent(34.0)` → `34` (was `100`) - `toPercent(91.0)` → `91` (was `100`) - `toPercent(0.5)` → `50` (legacy format still works) - Extra usage `used_credits: 6793, monthly_limit: 14000` → `$67.93 / $140.00` (was `$6,793.00 / $14,000.00`) - Verified on a live instance with Claude Max subscription — Costs > Providers tab now shows correct percentages matching claude.ai/settings/usage ## Risks Low risk. The `< 1` heuristic is already battle-tested in the Codex adapter. The only edge case is a true utilization of exactly `1.0` which maps to `1%` instead of `100%` — this is consistent with the Codex adapter behavior and is an acceptable trade-off since 1% and 100% are distinguishable in practice (100% would be returned as `100.0` by the API). ## Model Used Claude Opus 4.6 (1M context) via Claude Code CLI — tool use, code analysis, and code generation ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Closes #2188 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,24 +39,34 @@ describe("toPercent", () => {
|
||||
expect(toPercent(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("converts 0.5 to 50", () => {
|
||||
it("treats values < 1 as fraction and multiplies by 100 (0.5 → 50%)", () => {
|
||||
expect(toPercent(0.5)).toBe(50);
|
||||
});
|
||||
|
||||
it("converts 1.0 to 100", () => {
|
||||
expect(toPercent(1.0)).toBe(100);
|
||||
it("treats values >= 1 as already-percentage (34 → 34%)", () => {
|
||||
expect(toPercent(34.0)).toBe(34);
|
||||
expect(toPercent(91.0)).toBe(91);
|
||||
});
|
||||
|
||||
it("treats value exactly 1.0 as 1% (not 100%) — the < 1 heuristic boundary", () => {
|
||||
// 1.0 is NOT < 1, so it is treated as already-percentage → 1%
|
||||
expect(toPercent(1.0)).toBe(1);
|
||||
});
|
||||
|
||||
it("clamps overshoot to 100", () => {
|
||||
// floating-point utilization can slightly exceed 1.0
|
||||
expect(toPercent(1.001)).toBe(100);
|
||||
expect(toPercent(1.01)).toBe(100);
|
||||
expect(toPercent(105)).toBe(100);
|
||||
expect(toPercent(101)).toBe(100);
|
||||
});
|
||||
|
||||
it("rounds to nearest integer", () => {
|
||||
it("rounds to nearest integer for fractions", () => {
|
||||
expect(toPercent(0.333)).toBe(33);
|
||||
expect(toPercent(0.666)).toBe(67);
|
||||
});
|
||||
|
||||
it("rounds to nearest integer for percentages", () => {
|
||||
expect(toPercent(48.52)).toBe(49);
|
||||
expect(toPercent(23.4)).toBe(23);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -516,37 +526,48 @@ describe("fetchClaudeQuota", () => {
|
||||
expect(windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses five_hour window", async () => {
|
||||
mockFetch({ five_hour: { utilization: 0.4, resets_at: "2026-01-01T00:00:00Z" } });
|
||||
it("parses five_hour window with percentage-range utilization", async () => {
|
||||
mockFetch({ five_hour: { utilization: 34.0, resets_at: "2026-01-01T00:00:00Z" } });
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({
|
||||
label: "Current session",
|
||||
usedPercent: 40,
|
||||
usedPercent: 34,
|
||||
resetsAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses seven_day window", async () => {
|
||||
mockFetch({ seven_day: { utilization: 0.75, resets_at: null } });
|
||||
it("parses seven_day window with percentage-range utilization", async () => {
|
||||
mockFetch({ seven_day: { utilization: 91.0, resets_at: null } });
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({
|
||||
label: "Current week (all models)",
|
||||
usedPercent: 75,
|
||||
usedPercent: 91,
|
||||
resetsAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("still handles legacy 0-1 fraction utilization", async () => {
|
||||
mockFetch({ five_hour: { utilization: 0.4, resets_at: null } });
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows[0]).toMatchObject({
|
||||
label: "Current session",
|
||||
usedPercent: 40,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses seven_day_sonnet and seven_day_opus windows", async () => {
|
||||
mockFetch({
|
||||
seven_day_sonnet: { utilization: 0.2, resets_at: null },
|
||||
seven_day_opus: { utilization: 0.9, resets_at: null },
|
||||
seven_day_sonnet: { utilization: 23.0, resets_at: null },
|
||||
seven_day_opus: { utilization: 85.0, resets_at: null },
|
||||
});
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0]!.label).toBe("Current week (Sonnet only)");
|
||||
expect(windows[0]!.usedPercent).toBe(23);
|
||||
expect(windows[1]!.label).toBe("Current week (Opus only)");
|
||||
expect(windows[1]!.usedPercent).toBe(85);
|
||||
});
|
||||
|
||||
it("sets usedPercent to null when utilization is absent", async () => {
|
||||
@@ -557,10 +578,10 @@ describe("fetchClaudeQuota", () => {
|
||||
|
||||
it("includes all four windows when all are present", async () => {
|
||||
mockFetch({
|
||||
five_hour: { utilization: 0.1, resets_at: null },
|
||||
seven_day: { utilization: 0.2, resets_at: null },
|
||||
seven_day_sonnet: { utilization: 0.3, resets_at: null },
|
||||
seven_day_opus: { utilization: 0.4, resets_at: null },
|
||||
five_hour: { utilization: 10.0, resets_at: null },
|
||||
seven_day: { utilization: 20.0, resets_at: null },
|
||||
seven_day_sonnet: { utilization: 30.0, resets_at: null },
|
||||
seven_day_opus: { utilization: 40.0, resets_at: null },
|
||||
});
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(4);
|
||||
@@ -571,6 +592,7 @@ describe("fetchClaudeQuota", () => {
|
||||
"Current week (Sonnet only)",
|
||||
"Current week (Opus only)",
|
||||
]);
|
||||
expect(windows.map((w: QuotaWindow) => w.usedPercent)).toEqual([10, 20, 30, 40]);
|
||||
});
|
||||
|
||||
it("parses extra usage when the OAuth response includes it", async () => {
|
||||
@@ -591,6 +613,25 @@ describe("fetchClaudeQuota", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats extra usage credits from cents to dollars", async () => {
|
||||
mockFetch({
|
||||
extra_usage: {
|
||||
is_enabled: true,
|
||||
monthly_limit: 14000,
|
||||
used_credits: 6793,
|
||||
utilization: 48.52,
|
||||
},
|
||||
});
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({
|
||||
label: "Extra usage",
|
||||
usedPercent: 49,
|
||||
valueLabel: "$67.93 / $140.00",
|
||||
detail: "Monthly extra usage pool",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user