Skip to content

Latest commit

 

History

History
151 lines (123 loc) · 7.85 KB

File metadata and controls

151 lines (123 loc) · 7.85 KB

Claude Code usage limits — undocumented API

How src/usage.ts reads your own Claude Code usage limits: the same data the interactive /usage panel shows. Verified working 2026-08-06 against Claude Code v2.1.223.

There is no CLI flag and no public documentation for this. The official answer is that usage is visible only through the interactive /usage panel, your account settings on claude.ai, or — for API-key customers — the Analytics API in the Console. The endpoint below is what Claude Code itself calls.

The endpoint

GET https://api.anthropic.com/api/oauth/usage
Authorization: Bearer <oauth access token>
anthropic-beta: oauth-2025-04-20

Both the /api/oauth/ path prefix and the beta header are required. Do not guess the path/v1/usage and /account/usage both return 404, which makes it look like no such endpoint exists.

Credentials

Claude Code stores an OAuth token that this endpoint accepts.

  • macOS: Keychain, service Claude Code-credentials. Read with security find-generic-password -s "Claude Code-credentials" -w.
  • Elsewhere: ~/.claude/.credentials.json (or $CLAUDE_CONFIG_DIR).

The token lives at claudeAiOauth.accessToken, with expiry at claudeAiOauth.expiresAt.

Three things make naive lookups fail, all learned from ai-token-monitor (a sibling local project, not part of this repo) — its src-tauri/src/oauth_usage.rs is the reference implementation this port follows:

  • One service can hold several items. Some carry only mcpOAuth and no usable token. Try multiple account candidates and accept only an item that actually yields claudeAiOauth — otherwise the lookup lands on a tokenless item and reports failure.
  • Claude Code v2.1.52+ uses a hashed service name, Claude Code-credentials-{hash}. Discover it rather than assuming the legacy name. Discovery here means security dump-keychain — a metadata walk of the entire keychain (slow on large ones, and broader than one service lookup), so run it only after the fixed legacy name yields nothing, and remember the item that answered for the process lifetime (src/usage.ts does both).
  • The Keychain payload may have a leading non-JSON byte. Strip before parsing.

Refresh is best delegated to the CLI — run claude auth status --json and re-read the stored credentials — rather than reimplementing the OAuth exchange with private client details.

Handling the token

It is a live credential. Never print it, never write it to disk, and never pass it as a command-line argument, where ps would expose it to every user on the machine. Read it, use it, keep it in memory.

Response shape

{
  "five_hour":  { "utilization": 7,  "resets_at": "2026-08-07T00:50:00Z" },
  "seven_day":  { "utilization": 6,  "resets_at": "2026-08-10T11:00:00Z" },
  "seven_day_sonnet": null,          // legacy per-model keys are null now
  "seven_day_opus":   null,
  "limits": [
    { "kind": "session",        "percent": 4, "is_active": false, "scope": null },
    { "kind": "weekly_all",     "percent": 6, "is_active": false, "scope": null },
    { "kind": "weekly_scoped",  "percent": 9, "is_active": true,
      "resets_at": "2026-08-10T10:59:59Z",
      "scope": { "model": { "display_name": "Fable" } } }
  ],
  "extra_usage": { "is_enabled": true, "monthly_limit": 17000, "used_credits": 0,
                   "currency": "EUR", "decimal_places": 2 },
  "spend": { "used": { "amount_minor": 0, "currency": "EUR", "exponent": 2 }, "…": "" }
}

Granularity is 1%. utilization and percent are integers — every observed value (2026-08-06/07, dozens of samples) has been whole, and no field carries a finer-grained alternative: the per-window limit_dollars/used_dollars/remaining_dollars fields and extra_usage.utilization are null on a subscription plan, and spend only moves once paid extra-usage credits are being consumed. One percent of the 5-hour window is ±3 minutes of time-budget; finer than that requires counting tokens locally (OTEL metrics or the transcript JSONL), which yields tokens but not percent-of-limit — the denominators are not disclosed. There is also no history: this endpoint is current-state only, and Claude Code's own "last N days" graphs are built from local transcript data — see USAGE-GRAPH.md. Windows also carry group and severity fields, and scope has a surface key (null so far).

Parsing notes, each of which has broken a real implementation:

  • resets_at is nullable. A window at 0% utilisation reports null. A non-optional type here fails the entire parse, which empties the cache and surfaces a misleading "usage unavailable" rather than a parse error.
  • Per-model limits moved. They now arrive as weekly_scoped entries in the limits array carrying scope.model.display_name; the dedicated seven_day_<model> keys return null.
  • is_active is not a render filter. It marks whichever single limit currently binds — observed across two days flipping from the Fable entry (at 9%, the highest utilization) to the session entry (tied at 10%, session wins ties) while all three entries stayed present. Filtering model windows on is_active: true makes them vanish whenever another limit overtakes them, which is exactly how it broke here. A window that stops applying disappears from the array instead.
  • limits may be absent on older payloads or some accounts. Default it to empty rather than failing.
  • Ignore unknown keys. The response carries several null-valued codename placeholders (tangelo, iguana_necktie, nimbus_quill, cinder_cove, amber_ladder, omelette_promotional, …) that come and go.
  • Money is in minor units. monthly_limit: 17000 with decimal_places: 2 is €170.00.
  • There is no window-start field. The monitor derives the 5-hour window's start as resets_at − 5h (the window opens at the session's first activity, so this holds by construction) — but if the API ever reports a shorter effective window, a pace marker derived this way drifts optimistic. Unverified against a real session boundary.

Rate limiting

The endpoint returns 429 with a Retry-After header, and it is easy to hit while iterating — repeated manual refreshes during testing triggered a 60-second back-off. Honour Retry-After in full rather than retrying on your normal interval, and gate any user-triggered refresh behind the same window so a button press cannot provoke it again.

Do not assume you are the endpoint's only caller when picking a cadence. The rate budget appears to be per-account, not per-client: a 5-minute poll — the cadence ai-token-monitor uses, and this monitor's original default — drew regular 429s in day-to-day use (observed 2026-08-09), plausibly because active Claude Code sessions hit the same endpoint. The monitor now polls every 10 minutes and doubles its wait on consecutive 429s (capped at 4× the interval), which is a better fit for a shared budget than retrying at full cadence the moment one Retry-After expires.

In-process, every consumer must share one deduplicated fetcher: dedupedFetchUsage() (src/usage.ts, wired in src/mbar.ts with a 30 s TTL). Two modules polling independently double the load on a per-account budget for identical data — and the deduper caches errors too, so a sibling module cannot immediately re-provoke a 429 the first one just backed off from. A new usage-reading module goes through it, not through fetchUsage() directly.

Example

Minimal read, token never leaving memory:

import { fetchUsage } from './src/usage';

const usage = await fetchUsage();
console.log(usage.fiveHour?.utilization, usage.fiveHour?.resetsAt);
for (const model of usage.models) console.log(model.model, model.utilization);

fetchUsage() throws NoCredentialsError when Claude Code is not signed in and RateLimitError (carrying retryAfterSeconds) on a 429.