Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 6 additions & 3 deletions src/adapters/claude-cli/usage-admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,15 +115,18 @@ 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);
// Unknown reset windows require another read after the short cache, not an invented reset date.
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 };
}

/**
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/providers/claude-cli-usage-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions src/providers/claude-cli-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderQuota | null> {
return (await probeAnthropicUsageQuota(accessToken)).quota;
Expand Down Expand Up @@ -69,8 +82,10 @@ export async function probeAnthropicUsageQuota(accessToken: string): Promise<Ant
}
}
const displayWindows = displayBuckets.flatMap(bucket => 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(),
Expand Down
2 changes: 2 additions & 0 deletions src/providers/quota-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion structure/providers/claude-cli-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<CLAUDE_CONFIG_DIR>/.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 `<OPENCODEX_HOME>/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 `<CLAUDE_CONFIG_DIR>/.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 `<OPENCODEX_HOME>/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.

Expand Down
94 changes: 94 additions & 0 deletions tests/providers/claude-cli-usage-admission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
Loading