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:
Knife.D
2026-04-15 20:44:26 +09:00
committed by GitHub
parent b816809a1e
commit f6ce976544
2 changed files with 65 additions and 22 deletions
@@ -187,13 +187,15 @@ function formatExtraUsageLabel(extraUsage: AnthropicExtraUsage): string | null {
) {
return null;
}
return `${formatCurrencyAmount(usedCredits, extraUsage.currency)} / ${formatCurrencyAmount(monthlyLimit, extraUsage.currency)}`;
// API returns values in cents — convert to dollars for display
return `${formatCurrencyAmount(usedCredits / 100, extraUsage.currency)} / ${formatCurrencyAmount(monthlyLimit / 100, extraUsage.currency)}`;
}
/** Convert a 0-1 utilization fraction to a 0-100 integer percent. Returns null for null/undefined input. */
/** Convert a utilization value to a 0-100 integer percent. Returns null for null/undefined input.
* Handles both 0-1 fractions (legacy) and 0-100 percentages (current API). */
export function toPercent(utilization: number | null | undefined): number | null {
if (utilization == null) return null;
return Math.min(100, Math.round(utilization * 100));
return Math.min(100, Math.round(utilization < 1 ? utilization * 100 : utilization));
}
/** fetch with an abort-based timeout so a hanging provider api doesn't block the response indefinitely */
+60 -19
View File
@@ -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",
});
});
});
// ---------------------------------------------------------------------------