From 3cc650af2aada47e991b529bf7a41132484c6d89 Mon Sep 17 00:00:00 2001 From: Nick Davis Date: Thu, 12 Nov 2026 17:01:59 +0000 Subject: [PATCH] fix(claude-cli): recognise paid usage and recheck stale plan limits --- .../src/content/docs/guides/providers.md | 16 ++-- src/adapters/claude-cli/usage-admission.ts | 9 +- src/providers/claude-cli-usage-state.ts | 1 + src/providers/claude-cli-usage.ts | 15 +++ src/providers/quota-types.ts | 2 + structure/providers/claude-cli-replay.md | 4 +- .../claude-cli-usage-admission.test.ts | 94 +++++++++++++++++++ 7 files changed, 131 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 678dba19638..e4769fd6838 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1600,12 +1600,16 @@ CLI headlessly (`claude -p`, `stream-json`) once per turn: only the client's selected tools (the same bridge CodeBuddy uses). The CLI captures the call and the turn ends there; approval, sandboxing, execution and the returned result stay with Codex, Pi, or another client. Built-in tools and every other MCP server remain disabled. -- **Subscription limits:** before each launch OpenCodex asks Anthropic's usage endpoint, with the - CLI's own sign-in read in memory, whether the plan's 5-hour, weekly or model-specific window is - full. An exhausted plan is refused locally with HTTP 429 `claude_subscription_cooldown` and a - `Retry-After` for the reset, instead of starting a CLI that would fail. The answer is cached for - a minute in `claude-usage-admission.json` under the OpenCodex home (percentages, reset times and - a hashed account key only). If the check cannot run, the turn proceeds. +- **Subscription limits and paid usage:** OpenCodex checks Anthropic's usage endpoint using + the CLI's own sign-in. Measured 5-hour, weekly and model-specific exhaustion is cached for at + most a minute, so a top-up or subscription reset can be detected before the old reset date. + When extra usage is explicitly enabled without an exhausted reported cap, the official CLI + decides whether the wallet and spending limit permit the request. OpenCodex does not infer a + funded balance or enable paid usage. Explicitly disabled/exhausted extra usage does not open + that path. A genuine limit returned by the CLI remains enforced until its recorded reset. + Otherwise an exhausted plan returns HTTP 429 `claude_subscription_cooldown` with `Retry-After`. + The private cache stores percentages, reset times, the closed extra-usage state and a hashed + account key, never credentials or wallet amounts. An unavailable check lets the CLI decide. - **Destination:** the canonical row names `https://api.anthropic.com` because that is where the subscription's traffic lands. OpenCodex never sends that request itself, and overriding the base URL fails closed rather than handing the turn to another environment. diff --git a/src/adapters/claude-cli/usage-admission.ts b/src/adapters/claude-cli/usage-admission.ts index a79fd2f167b..d39eae4a846 100644 --- a/src/adapters/claude-cli/usage-admission.ts +++ b/src/adapters/claude-cli/usage-admission.ts @@ -115,7 +115,9 @@ export function formatClaudeReset(resetAt: number, timeZone?: string): string { function full(w: { percent?: number }): boolean { return finite(w.percent) && w.percent >= 100; } function admission(snapshot: ClaudeUsageSnapshot, model: string, now: number): ClaudeAdmission { const quota = snapshot.quota; - const exhausted = applicableWindows(quota, model).filter(w => full(w) && (!finite(w.resetAt) || w.resetAt > now)); + const planExhausted = applicableWindows(quota, model).filter(w => full(w) && (!finite(w.resetAt) || w.resetAt > now)); + const extraEnabled = quota?.claudeExtraUsage === "enabled"; + const exhausted = extraEnabled ? [] : planExhausted; if (finite(snapshot.refusalUntil) && snapshot.refusalUntil > now) exhausted.push({ percent: 100, resetAt: snapshot.refusalUntil }); if (exhausted.length) { const resets = exhausted.map(w => w.resetAt).filter(finite); @@ -123,7 +125,8 @@ function admission(snapshot: ClaudeUsageSnapshot, model: string, now: number): C const resetAt = resets.length === exhausted.length ? Math.max(...resets) : Math.max(snapshot.checkedAt + CACHE_MS, ...resets); return { state: "exhausted", checkedAt: snapshot.checkedAt, resetAt, message: `Claude subscription limits are exhausted. Claude launches are paused until ${formatClaudeReset(resetAt)}, then usage will be checked again.` }; } - return { state: quota ? "available" : "unknown", checkedAt: snapshot.checkedAt }; + // Extra usage can outlive plan windows; the CLI still enforces the wallet and spend limit. + return { state: quota && !(extraEnabled && planExhausted.length) ? "available" : "unknown", checkedAt: snapshot.checkedAt }; } /** @@ -148,7 +151,7 @@ async function checkAdmission(model: string, deps: AdmissionDeps, now: number): .sort((a, b) => b.checkedAt - a.checkedAt || (b.refusalUntil ?? 0) - (a.refusalUntil ?? 0))[0]; if (cached) { const status = admission(cached, model, now); - if (status.state === "exhausted" && status.resetAt! > now) return status; + if (finite(cached.refusalUntil) && cached.refusalUntil > now) return status; // Same windows and threshold as admission(): only a full window in scope for this model forces a re-read. const expiredReset = [cached.refusalUntil, ...applicableWindows(cached.quota, model).filter(full).map(w => w.resetAt)] .some(reset => finite(reset) && reset <= now); diff --git a/src/providers/claude-cli-usage-state.ts b/src/providers/claude-cli-usage-state.ts index 123da673fdc..5b7c7c99177 100644 --- a/src/providers/claude-cli-usage-state.ts +++ b/src/providers/claude-cli-usage-state.ts @@ -48,6 +48,7 @@ export function loadClaudeUsageSnapshot(path: string): ClaudeUsageSnapshot | und if (saved.quota !== undefined) { const q = saved.quota; if (!q || typeof q !== "object" || !finite(q.updatedAt)) return; + if (q.claudeExtraUsage !== undefined && !["enabled", "disabled", "exhausted"].includes(q.claudeExtraUsage)) return; for (const field of ["fiveHourPercent", "fiveHourResetAt", "weeklyPercent", "weeklyResetAt"]) if (q[field] !== undefined && !finite(q[field])) return; // Display-only rows (an inactive model limit) reach the dashboard too, so they pass the same check. diff --git a/src/providers/claude-cli-usage.ts b/src/providers/claude-cli-usage.ts index 50a6f38f0f4..2eb9ee36c08 100644 --- a/src/providers/claude-cli-usage.ts +++ b/src/providers/claude-cli-usage.ts @@ -23,6 +23,19 @@ function familyWindow({ window, row, percentField, weeklyAllowancePercent }: Ant ...(weeklyAllowancePercent !== undefined ? { weeklyAllowancePercent } : {}) }; } +function extraUsage(value: unknown): ProviderQuota["claudeExtraUsage"] { + const row = asRecord(value); + if (typeof row?.is_enabled !== "boolean") return; + if (!row.is_enabled) return "disabled"; + const percent = normalizePercent(row.utilization); + const limit = row.monthly_limit, used = row.used_credits; + const capped = typeof limit === "number" && Number.isFinite(limit) && limit >= 0; + if ((percent !== undefined && percent >= 100) || (capped && (limit === 0 + || (typeof used === "number" && Number.isFinite(used) && used >= limit)))) return "exhausted"; + // A null/unreported cap and a remaining allowance say nothing about prepaid funds. + return "enabled"; +} + /** Read the Anthropic OAuth usage endpoint for an access token and return the parsed quota, or null on a non-OK or malformed response. */ export async function readAnthropicUsageQuota(accessToken: string): Promise { return (await probeAnthropicUsageQuota(accessToken)).quota; @@ -69,8 +82,10 @@ export async function probeAnthropicUsageQuota(accessToken: string): Promise familyWindow(bucket) ?? []); + const paidUsage = extraUsage(body.extra_usage); const quota: ProviderQuota = { ...shared, + ...(paidUsage !== undefined ? { claudeExtraUsage: paidUsage } : {}), ...(customWindows.length > 0 ? { customWindows } : {}), ...(displayWindows.length > 0 ? { displayWindows } : {}), updatedAt: Date.now(), diff --git a/src/providers/quota-types.ts b/src/providers/quota-types.ts index 74f712c4b1c..ee7cac16c7d 100644 --- a/src/providers/quota-types.ts +++ b/src/providers/quota-types.ts @@ -74,6 +74,8 @@ export interface ProviderQuotaCreditsUsd { export interface ProviderQuota { /** Display-only budget windows for which the upstream did not provide a usable entitlement. */ unknownBudgetWindows?: { label: string; resetAt?: number }[]; + /** Claude CLI extra-usage entitlement/cap only; enabled does not prove wallet funds. */ + claudeExtraUsage?: "enabled" | "disabled" | "exhausted"; /** Display-only subscription credits; not a routing or exhaustion window. */ workbuddyCredits?: import("./workbuddy-credit-balance").WorkBuddyCreditBalance; fiveHourPercent?: number; diff --git a/structure/providers/claude-cli-replay.md b/structure/providers/claude-cli-replay.md index 0c6362a7dee..6ac3cc0c444 100644 --- a/structure/providers/claude-cli-replay.md +++ b/structure/providers/claude-cli-replay.md @@ -18,7 +18,9 @@ The shared runner's `onCliOutputError` callback carries accepted CLI error-resul `src/adapters/claude-cli/usage-admission.ts` gates every `claude-cli` launch on the local Claude Code subscription quota. The adapter checks it before spawning the CLI, and the Responses executor checks it before dispatch so an exhausted plan returns 429 `claude_subscription_cooldown` with `Retry-After` instead of a generic 502; Chat Completions keeps that code. Admission and cache invalidation share one window selection (plan windows plus custom windows in scope for the requested model family) and one `>= 100` threshold, so an expired window of another family never forces a re-read. A limit the CLI itself reports with a parseable reset ("resets 3pm (Zone)") is remembered until that time. The requested model id is caller input the CLI can reflect inside its own error wording, so a model id that itself carries the reset-clock phrase makes the message unattributable - real limit or echoed model - and the turn records nothing: the error still reaches the client and recorded or probed limits still gate, but a genuine limit seen only on such a turn is not newly cached. -A probe re-reads the account before it commits: an account switch mid-probe discards the answer (`unknown`, nothing persisted), and the newest refusal is merged at commit time with no await between that read and the write, so a CLI-reported reset is never overwritten. The file has one writer per `OPENCODEX_HOME`, the same single-owner topology as the spend ledger. `src/providers/claude-cli-usage-state.ts` owns the snapshot path, validation (routed `customWindows` and display-only `displayWindows` pass the same row check) and in-memory copy so the quota facade can read it without importing the adapter. The check reads the CLI's own sign-in in memory (`detectClaudeCodeTokenAsync`: Keychain without blocking the event loop, or `/.credentials.json`), caches the resolved identity for one minute, and sends the access token only to Anthropic's usage endpoint through `src/providers/claude-cli-usage.ts`, the same reader the account-pool quota probe uses. `CLAUDE_CONFIG_DIR` is passed to the child so the CLI and the check read one profile. The cache file `/claude-usage-admission.json` stores only percentages, reset times and a hashed identity key. Every failure (no sign-in, malformed credential, unreachable endpoint) fails open as `unknown`, and a test process (`OCX_TEST_HOME_GUARD`) never resolves the real sign-in. If the executor's check throws or refuses, it releases the web-search sidecar's probe lease first; `tests/responses/responses-claude-preflight-lease.test.ts` and `tests/providers/claude-cli-usage-admission.test.ts` cover both. +Measured subscription exhaustion is cached for at most one minute, including before a future reset, so a top-up, reset or plan change can be noticed. The usage reader retains only the closed `claudeExtraUsage` entitlement/cap state: explicitly disabled, explicitly exhausted, or enabled with wallet capacity unproved. Enabled extra usage defers exhausted subscription windows to the official CLI as `unknown`; it never declares a prepaid balance available. An explicit zero/spent monthly cap or 100% extra-usage utilization does not open that path. A null/unreported cap does not prove funds. A genuine CLI-reported refusal still gates until its recorded reset and survives a concurrent quota probe. No balance, spend limit, account, credential or refusal state is cleared to refresh measured quota. `tests/providers/claude-cli-usage-admission.test.ts` covers the actual usage parser through persisted cache and admission, legacy and malformed metadata, account changes and refusal races. + +A probe re-reads the account before it commits: an account switch mid-probe discards the answer (`unknown`, nothing persisted), and the newest refusal is merged at commit time with no await between that read and the write, so a CLI-reported reset is never overwritten. The file has one writer per `OPENCODEX_HOME`, the same single-owner topology as the spend ledger. `src/providers/claude-cli-usage-state.ts` owns the snapshot path, validation (routed `customWindows` and display-only `displayWindows` pass the same row check) and in-memory copy so the quota facade can read it without importing the adapter. The check reads the CLI's own sign-in in memory (`detectClaudeCodeTokenAsync`: Keychain without blocking the event loop, or `/.credentials.json`), caches the resolved identity for one minute, and sends the access token only to Anthropic's usage endpoint through `src/providers/claude-cli-usage.ts`, the same reader the account-pool quota probe uses. `CLAUDE_CONFIG_DIR` is passed to the child so the CLI and the check read one profile. The cache file `/claude-usage-admission.json` stores only percentages, reset times, the closed extra-usage state and a hashed identity key. Every failure (no sign-in, malformed credential, unreachable endpoint) fails open as `unknown`, and a test process (`OCX_TEST_HOME_GUARD`) never resolves the real sign-in. If the executor's check throws or refuses, it releases the web-search sidecar's probe lease first; `tests/responses/responses-claude-preflight-lease.test.ts` and `tests/providers/claude-cli-usage-admission.test.ts` cover both. The same snapshot is what `/api/provider-quotas` publishes, as an `observed` row from `fetchClaudeCliObservedQuota` in `src/providers/quota/vendor-probes-oauth.ts`: fetching this row never spawns the CLI or probes upstream, and a forced quota refresh re-reads the local sign-in and this file rather than Anthropic's endpoint. Other providers retain their normal probes. The row is bound to the signed-in account: the reader lazily imports `currentClaudeIdentityKey` from the admission (keeping the Keychain reader off the core import path), publishes only that identity's snapshot, and returns authoritative empty when signed out, when the sign-in cannot be read, or when that account has no snapshot with windows, which clears any other account's cached report. Each sign-in read, a turn's admission included, records its hashed key through `recordClaudeSignedInIdentity` in `src/providers/claude-cli-usage-state.ts`; a cached row stops being current once a later read names another account or none. An unforced `fetchProviderQuotaReports` calls `refreshClaudeCliSignIn` (same file as the reader) before its cache fast path; with a `claude-cli` provider configured it re-reads the sign-in through the one-minute identity cache, so a plain dashboard poll drops a superseded row within a minute without forcing other providers to re-probe. `tests/providers/provider-quota-claude-cli.test.ts` covers sign-out and account switch on forced refresh, after a later sign-in read, and on a plain read past the identity cache, plus newer observation timestamps. diff --git a/tests/providers/claude-cli-usage-admission.test.ts b/tests/providers/claude-cli-usage-admission.test.ts index 9ad582302a5..689e8483a62 100644 --- a/tests/providers/claude-cli-usage-admission.test.ts +++ b/tests/providers/claude-cli-usage-admission.test.ts @@ -16,6 +16,7 @@ import { } from "../../src/adapters/claude-cli/usage-admission"; import { getAdapterDefinition } from "../../src/adapters/registry"; import { getConfigDir } from "../../src/config/paths"; +import { probeAnthropicUsageQuota } from "../../src/providers/claude-cli-usage"; import { handleResponses } from "../../src/server/responses"; import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; @@ -139,6 +140,99 @@ describe("Claude usage admission cache", () => { }); }); +describe("Claude paid usage", () => { + async function parsedQuota(extra: unknown, now: number) { + const previous = globalThis.fetch; + globalThis.fetch = (async () => Response.json({ + seven_day: { utilization: 100, resets_at: new Date(now + 86_400_000).toISOString() }, + extra_usage: extra, + })) as typeof fetch; + try { return (await probeAnthropicUsageQuota("synthetic-paid-usage-bearer")).quota; } + finally { globalThis.fetch = previous; } + } + + test("stale subscription exhaustion is rechecked before its future reset", async () => { + const now = Date.now(); + const statePath = seed({ ...freshQuota, weeklyPercent: 100 }, now - 120_000); + let probes = 0; + const status = await checkClaudeUsageAdmission("claude-opus-5-5", { + now: () => now, identity, statePath, + probe: async () => { probes++; return parsedQuota({ is_enabled: true, utilization: 12 }, now); }, + }); + expect(probes).toBe(1); + // Enabled extra usage is not proof of wallet funds: the official CLI decides. + expect(status.state).toBe("unknown"); + expect(JSON.parse(readFileSync(statePath, "utf8")).quota.claudeExtraUsage).toBe("enabled"); + const again = await checkClaudeUsageAdmission("claude-opus-5-5", { + now: () => now + 1000, identity, statePath, + probe: async () => { probes++; return null; }, + }); + expect(again.state).toBe("unknown"); + expect(probes).toBe(1); + }); + + for (const extra of [undefined, null, { is_enabled: "true" }, { is_enabled: false }, + { is_enabled: true, utilization: 100 }, { is_enabled: true, monthly_limit: 0 }, + { is_enabled: true, monthly_limit: 1000, used_credits: 1000 }]) { + test(`disabled or exhausted paid usage does not admit an exhausted plan: ${JSON.stringify(extra)}`, async () => { + const now = Date.now(); + const quota = await parsedQuota(extra, now); + const status = await checkClaudeUsageAdmission("claude-opus-5-5", { now: () => now, identity, + statePath: tempState(), probe: async () => quota }); + expect(status.state).toBe("exhausted"); + }); + } + + test("a null paid-usage cap leaves wallet enforcement to the official CLI", async () => { + const now = Date.now(); + const quota = await parsedQuota({ is_enabled: true, monthly_limit: null, utilization: null }, now); + expect((await checkClaudeUsageAdmission("claude-opus-5-5", { now: () => now, identity, + statePath: tempState(), probe: async () => quota })).state).toBe("unknown"); + }); + + test("included quota becoming available returns to ordinary admission even while extra usage remains enabled", async () => { + const now = Date.now(); + const paid = await parsedQuota({ is_enabled: true, utilization: 12 }, now); + const statePath = seed(paid, now - 120_000); + expect((await checkClaudeUsageAdmission("claude-opus-5-5", { now: () => now, identity, + statePath, probe: async () => ({ ...paid!, weeklyPercent: 0 }) })).state).toBe("available"); + expect(JSON.parse(readFileSync(statePath, "utf8")).quota.weeklyPercent).toBe(0); + }); + + test("an exhausted paid cap does not block remaining included usage", async () => { + const now = Date.now(); + const paid = await parsedQuota({ is_enabled: true, utilization: 100 }, now); + expect((await checkClaudeUsageAdmission("claude-opus-5-5", { now: () => now, identity, + statePath: tempState(), probe: async () => ({ ...paid!, weeklyPercent: 10 }) })).state).toBe("available"); + }); + + test("paid usage cannot erase a real CLI refusal racing its probe", async () => { + const now = Date.UTC(2026, 9, 8, 12, 0); + const statePath = tempState(); + let recorded = false; + const racing = async (fresh?: boolean) => { + if (fresh && !recorded) { + recorded = true; + await recordClaudeUsageRefusal("You've hit your limit ยท resets 3pm (UTC)", { now: () => now, identity, statePath }); + } + return identity(); + }; + const quota = await parsedQuota({ is_enabled: true, utilization: 12 }, now); + const status = await checkClaudeUsageAdmission("claude-opus-5-5", { now: () => now, identity: racing, + statePath, probe: async () => quota }); + expect(status).toMatchObject({ state: "exhausted", resetAt: Date.UTC(2026, 9, 8, 15, 0) }); + }); + + test("paid usage from an account switched during the probe is discarded", async () => { + const now = Date.now(), statePath = tempState(); + const quota = await parsedQuota({ is_enabled: true, utilization: 12 }, now); + const switching = (fresh?: boolean) => fresh ? { key: "account-b", access: "b" } : identity(); + expect((await checkClaudeUsageAdmission("claude-opus-5-5", { now: () => now, identity: switching, + statePath, probe: async () => quota })).state).toBe("unknown"); + expect(existsSync(statePath)).toBe(false); + }); +}); + describe("Claude-reported limits", () => { test("a parsed CLI reset pauses later launches until that time", async () => { const now = Date.UTC(2026, 9, 8, 12, 0);