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
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -1493,6 +1493,12 @@ CLI headlessly (`claude -p`, `stream-json`) once per turn:
OpenCodex runs as, so every request routed through this row — from any client of the proxy —
spends that one Claude account. There is no per-client account, no pooling and no multiplexing;
giving several people their own Claude usage needs one proxy user per sign-in.
- **Subscription usage:** the Providers workspace shows the plan's 5-hour, weekly and model-scoped
windows from the last usage check OpenCodex recorded before a turn, for the account Claude Code is
signed in to now. Opening the page never probes or launches the CLI, and **Refresh all quotas**
re-reads the local sign-in and that recorded observation rather than Anthropic's endpoint. After a
sign-out or account switch the previous account's row disappears on **Refresh all quotas**, and
within a minute otherwise; a newly signed-in account shows nothing until its first Claude turn.
- **Input media:** the row publishes its models as text-only for v1. The CLI accepts an image frame
on its stream-json input, but no headless turn has been shown to hand those bytes to the model, so
an image sent straight to this provider is refused (`unsupported_input_modality`) instead of
Expand Down
13 changes: 13 additions & 0 deletions docs-site/src/content/docs/reference/management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,19 @@ When Antigravity's quota-summary endpoint has no usable response, quota reports
per-model Cloud Code Assist usage windows when the model-discovery response provides them. These
measurements are diagnostic; an absent model window does not establish that the model is unavailable.

For a row using the `claude-cli` adapter, the report is the last recorded Claude subscription
observation (`observed: true`) rather than an active probe, and only for the account Claude Code is
signed in to. `refresh=1` re-reads the local sign-in and that snapshot. Fetching this Claude CLI
report never spawns the CLI or calls Anthropic; other configured providers can still run their
normal quota probes. The row is absent until a usage check records quota windows for the signed-in
account. A sign-out or account switch clears it at once with `refresh=1` and within a minute (the
sign-in cache) on a cached read; a newer observation, including one without windows, replaces it
at once with `refresh=1` and otherwise when the report cache expires. `GET /api/quota-pool` reads no
sign-in of its own: it lists the Claude CLI account only for the sign-in this proxy last read, and
only when that account has a recorded snapshot. After an external sign-out or account switch it keeps
the previous account until the dashboard or a Claude turn reads the sign-in again; after a restart
the row is absent until such a read.

With `quota=1`, an Anthropic or Antigravity account row whose probe returned no quota carries
`quotaUnavailable: true` and, when the cause is known, a closed `quotaFailure` code:
`account_unavailable` (sign the account in again), `access_denied`, `rate_limited`, `timeout`,
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1437,7 +1437,7 @@
"provider-runtime-fetch.test.ts": "providers",
"provider-tls-profile.test.ts": "providers",
"provider-payload.test.ts": "gui",
"provider-quota-label-sanitize.test.ts": "providers",
"provider-quota-claude-cli.test.ts": "providers", "provider-quota-label-sanitize.test.ts": "providers",
"provider-quota-observed-marker.test.ts": "providers",
"provider-quota-ollama-cloud.test.ts": "providers",
"provider-quota.test.ts": "providers",
Expand Down
117 changes: 53 additions & 64 deletions src/adapters/claude-cli/usage-admission.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { getConfigDir } from "../../config/paths";
import { join } from "node:path";
import { isTestHomeGuardArmed } from "../../lib/test-home-guard";
import { detectClaudeCodeTokenAsync } from "../../oauth/local-token-detect";
import { readAnthropicUsageQuota } from "../../providers/claude-cli-usage";
import {
claudeUsageStatePath,
loadClaudeUsageSnapshot,
readClaudeUsageSnapshots,
recordClaudeSignedInIdentity,
saveClaudeUsageSnapshot,
type ClaudeUsageSnapshot,
} from "../../providers/claude-cli-usage-state";
import type { ProviderQuota } from "../../providers/quota-types";

export type ClaudeAdmission = {
Expand All @@ -14,7 +21,6 @@ export type ClaudeAdmission = {
resetAt?: number;
message?: string;
};
type Snapshot = { identity: string; checkedAt: number; quota?: ProviderQuota; refusalUntil?: number };
type Identity = { key: string; access: string };
export type AdmissionDeps = {
now?: () => number;
Expand All @@ -24,9 +30,11 @@ export type AdmissionDeps = {
statePath?: string;
};
const CACHE_MS = 60_000;
const inflight = new Map<string, Promise<Snapshot | null>>();
const memory = new Map<string, Snapshot>();
const inflight = new Map<string, Promise<ClaudeUsageSnapshot | null>>();
let identityCache: { scope: string; at: number; value: Promise<Identity | null> } | undefined;
let identityOverride: ((fresh?: boolean) => Identity | null | Promise<Identity | null>) | undefined;
let identityReads = 0;
let recordedIdentityRead = 0;

const nonEmpty = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;

Expand All @@ -50,49 +58,43 @@ async function readLocalIdentity(): Promise<Identity | null> {
/**
* The signed-in Claude Code account, read through the CLI's own secure storage. Cached briefly so
* a burst of turns costs one Keychain lookup; an account switch is noticed within the cache window.
* Every read is recorded, newest read wins, so a published quota row can tell it was superseded.
*/
function localIdentity(fresh = false): Promise<Identity | null> {
const scope = process.env.CLAUDE_CONFIG_DIR?.trim() ?? "";
const now = Date.now();
if (!fresh && identityCache && identityCache.scope === scope && now - identityCache.at < CACHE_MS) return identityCache.value;
const value = readLocalIdentity().catch(() => null);
const read = ++identityReads;
const override = identityOverride;
const value = (override ? Promise.resolve().then(() => override(fresh)) : readLocalIdentity())
.catch(() => null)
.then(identity => {
if (read > recordedIdentityRead) {
recordedIdentityRead = read;
recordClaudeSignedInIdentity(identity?.key ?? null);
}
return identity;
});
identityCache = { scope, at: now, value };
return value;
}
async function resolveIdentity(deps: AdmissionDeps, fresh = false): Promise<Identity | null> {
try { return await (deps.identity ?? localIdentity)(fresh); } catch { return null; }
}
function statePath(deps: AdmissionDeps): string {
return deps.statePath ?? join(getConfigDir(), "claude-usage-admission.json");

/** Test seam for the signed-in account; call with no argument to restore the real sign-in read. */
export function setClaudeIdentityForTests(fn?: (fresh?: boolean) => Identity | null | Promise<Identity | null>): void {
identityOverride = fn;
identityCache = undefined;
recordClaudeSignedInIdentity(null);
}
function load(path: string): Snapshot | undefined {
try {
const saved = JSON.parse(readFileSync(path, "utf8"));
if (typeof saved.identity !== "string" || !Number.isFinite(saved.checkedAt)) return;
if (saved.refusalUntil !== undefined && !finite(saved.refusalUntil)) return;
if (saved.quota !== undefined) {
const q = saved.quota;
if (!q || typeof q !== "object" || !finite(q.updatedAt)) return;
for (const field of ["fiveHourPercent", "fiveHourResetAt", "weeklyPercent", "weeklyResetAt"])
if (q[field] !== undefined && !finite(q[field])) return;
if (q.customWindows !== undefined && (!Array.isArray(q.customWindows) || !q.customWindows.every((w: unknown) => {
if (!w || typeof w !== "object") return false;
const row = w as Record<string, unknown>;
return typeof row.label === "string" && finite(row.percent) && (row.resetAt === undefined || finite(row.resetAt));
}))) return;
}
return saved;
} catch { /* Missing or malformed cache is unknown, never proof of capacity. */ }
return undefined;

/**
* The signed-in account's hashed key, or null when signed out or the sign-in cannot be read. Only
* the key leaves this module, never the access token. `fresh` bypasses the identity cache.
*/
export async function currentClaudeIdentityKey(fresh = false): Promise<string | null> {
return (await resolveIdentity({}, fresh))?.key ?? null;
}
function save(path: string, snapshot: Snapshot): void {
memory.set(path, snapshot);
try {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
const temp = `${path}.${process.pid}.tmp`;
writeFileSync(temp, JSON.stringify(snapshot) + "\n", { mode: 0o600 });
renameSync(temp, path);
} catch { /* Memory still suppresses launches; no secrets or upstream text are persisted. */ }
async function resolveIdentity(deps: AdmissionDeps, fresh = false): Promise<Identity | null> {
try { return await (deps.identity ?? localIdentity)(fresh); } catch { return null; }
}
function finite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); }
/** Windows that gate this model: the plan windows plus custom windows in scope for its family. */
Expand All @@ -111,7 +113,7 @@ export function formatClaudeReset(resetAt: number, timeZone?: string): string {
}).format(resetAt);
}
function full(w: { percent?: number }): boolean { return finite(w.percent) && w.percent >= 100; }
function admission(snapshot: Snapshot, model: string, now: number): ClaudeAdmission {
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));
if (finite(snapshot.refusalUntil) && snapshot.refusalUntil > now) exhausted.push({ percent: 100, resetAt: snapshot.refusalUntil });
Expand Down Expand Up @@ -140,10 +142,10 @@ export async function checkClaudeUsageAdmission(model = "", deps: AdmissionDeps
async function checkAdmission(model: string, deps: AdmissionDeps, now: number): Promise<ClaudeAdmission> {
const identity = await resolveIdentity(deps);
if (!identity) return { state: "unknown", checkedAt: now };
const path = statePath(deps);
const disk = load(path);
const local = memory.get(path);
const cached = [disk, local].filter((s): s is Snapshot => s?.identity === identity.key).sort((a, b) => b.checkedAt - a.checkedAt || (b.refusalUntil ?? 0) - (a.refusalUntil ?? 0))[0];
const path = claudeUsageStatePath(deps.statePath);
const cached = readClaudeUsageSnapshots(path)
.filter(s => s.identity === identity.key)
.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;
Expand All @@ -164,13 +166,13 @@ async function checkAdmission(model: string, deps: AdmissionDeps, now: number):
// Merge the newest refusal at commit time. Nothing awaits between this read and the write,
// so a refusal the CLI reported while the probe was in flight is never overwritten.
const at = (deps.now ?? Date.now)();
const refusals = [load(path), memory.get(path)]
.filter((s): s is Snapshot => s?.identity === identity.key)
const refusals = readClaudeUsageSnapshots(path)
.filter(s => s.identity === identity.key)
.map(s => s.refusalUntil)
.filter((until): until is number => finite(until) && until > at);
const refusalUntil = refusals.length ? Math.max(...refusals) : undefined;
const snapshot: Snapshot = { identity: identity.key, checkedAt: at, ...(quota ? { quota } : {}), ...(refusalUntil ? { refusalUntil } : {}) };
save(path, snapshot);
const snapshot: ClaudeUsageSnapshot = { identity: identity.key, checkedAt: at, ...(quota ? { quota } : {}), ...(refusalUntil ? { refusalUntil } : {}) };
saveClaudeUsageSnapshot(path, snapshot);
return snapshot;
})().finally(() => inflight.delete(requestKey));
inflight.set(requestKey, probe);
Expand All @@ -182,19 +184,6 @@ async function checkAdmission(model: string, deps: AdmissionDeps, now: number):

let preflightOverride: ((model: string) => Promise<ClaudeAdmission>) | undefined;

/** The last usage the admission recorded, as `/api/quota-pool` reports it. */
export type ClaudeUsageObservation = Readonly<Snapshot>;

/**
* The newest recorded usage snapshot, from memory or the admission file. Read-only: it resolves
* no sign-in (no Keychain read), runs no probe and writes nothing. `identity` is the admission's
* own SHA-256 account key, never the account uuid itself.
*/
export function peekClaudeUsageObservation(deps: Pick<AdmissionDeps, "statePath"> = {}): ClaudeUsageObservation | null {
const path = statePath(deps);
const rows = [load(path), memory.get(path)].filter((s): s is Snapshot => s !== undefined);
return rows.sort((a, b) => b.checkedAt - a.checkedAt)[0] ?? null;
}
/** Test seam for the pre-dispatch check in the Responses executor; call with no argument to restore. */
export function setClaudeUsagePreflightForTests(fn?: (model: string) => Promise<ClaudeAdmission>): void { preflightOverride = fn; }
/** The pre-dispatch check the Responses executor runs before a claude-cli turn. */
Expand Down Expand Up @@ -249,8 +238,8 @@ export async function recordClaudeUsageRefusal(message: string, deps: AdmissionD
if (!until) return;
const identity = await resolveIdentity(deps);
if (!identity) return;
const path = statePath(deps);
const old = load(path);
const path = claudeUsageStatePath(deps.statePath);
const old = loadClaudeUsageSnapshot(path);
const same = old?.identity === identity.key ? old : undefined;
save(path, { identity: identity.key, checkedAt: now, ...(same?.quota ? { quota: same.quota } : {}), refusalUntil: Math.max(until, same?.refusalUntil ?? 0) });
saveClaudeUsageSnapshot(path, { identity: identity.key, checkedAt: now, ...(same?.quota ? { quota: same.quota } : {}), refusalUntil: Math.max(until, same?.refusalUntil ?? 0) });
}
93 changes: 93 additions & 0 deletions src/providers/claude-cli-usage-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* The last Claude Code subscription observation, persisted beside the proxy's other state.
*
* The read model lives here rather than in `src/adapters/claude-cli/usage-admission.ts` because
* `/api/provider-quotas` reads it as well, and `src/providers/quota.ts` is statically reachable
* from `src/server/responses/core.ts`: importing the adapter would drag its token/Keychain and
* child-process layer into every install. The adapter owns identity resolution and gating; this
* module owns only the file.
*/
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { getConfigDir } from "../config/paths";
import type { ProviderQuota } from "./quota-types";

export type ClaudeUsageSnapshot = {
identity: string;
checkedAt: number;
quota?: ProviderQuota;
refusalUntil?: number;
};

export type ClaudeUsageObservation = Readonly<ClaudeUsageSnapshot>;

const memory = new Map<string, ClaudeUsageSnapshot>();
let signedInIdentity: string | null = null;

/** The adapter records every sign-in read here, so a published report can tell it was superseded. */
export function recordClaudeSignedInIdentity(identity: string | null): void { signedInIdentity = identity; }

/** The hashed key of the last sign-in read, or null when signed out, unreadable or not yet read. */
export function claudeSignedInIdentity(): string | null { return signedInIdentity; }

export function claudeUsageStatePath(statePath?: string): string {
return statePath ?? join(getConfigDir(), "claude-usage-admission.json");
}

function finite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); }

/**
* The admission file, or undefined when it is missing or any field fails validation. A rejected
* file reads as "never checked", so a corrupt cache can neither admit nor refuse a launch.
*/
export function loadClaudeUsageSnapshot(path: string): ClaudeUsageSnapshot | undefined {
try {
const saved = JSON.parse(readFileSync(path, "utf8"));
if (typeof saved.identity !== "string" || !Number.isFinite(saved.checkedAt)) return;
if (saved.refusalUntil !== undefined && !finite(saved.refusalUntil)) return;
if (saved.quota !== undefined) {
const q = saved.quota;
if (!q || typeof q !== "object" || !finite(q.updatedAt)) return;
for (const field of ["fiveHourPercent", "fiveHourResetAt", "weeklyPercent", "weeklyResetAt"])
if (q[field] !== undefined && !finite(q[field])) return;
if (q.customWindows !== undefined && (!Array.isArray(q.customWindows) || !q.customWindows.every((w: unknown) => {
if (!w || typeof w !== "object") return false;
const row = w as Record<string, unknown>;
return typeof row.label === "string" && finite(row.percent) && (row.resetAt === undefined || finite(row.resetAt));
}))) return;
}
return saved;
} catch { /* Missing or malformed cache is unknown, never proof of capacity. */ }
return undefined;
}

/**
* Records the snapshot in memory first, then atomically on disk (temp file plus rename, mode 0600).
* A failed disk write is swallowed: the in-memory copy still gates this process's launches.
*/
export function saveClaudeUsageSnapshot(path: string, snapshot: ClaudeUsageSnapshot): void {
memory.set(path, snapshot);
try {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
const temp = `${path}.${process.pid}.tmp`;
writeFileSync(temp, JSON.stringify(snapshot) + "\n", { mode: 0o600 });
renameSync(temp, path);
} catch { /* Memory still suppresses launches; no secrets or upstream text are persisted. */ }
}

/** Every snapshot this process can see for the path: the admission file and the in-memory write. */
export function readClaudeUsageSnapshots(path: string): ClaudeUsageSnapshot[] {
return [loadClaudeUsageSnapshot(path), memory.get(path)]
.filter((snapshot): snapshot is ClaudeUsageSnapshot => snapshot !== undefined);
}

/**
* The newest recorded usage snapshot, from memory or the admission file, optionally only for one
* account. Read-only: it resolves no sign-in (no Keychain read), runs no probe and writes nothing.
* `identity` is the admission's own SHA-256 account key, never the account uuid itself.
*/
export function peekClaudeUsageObservation(deps: { statePath?: string; identity?: string } = {}): ClaudeUsageObservation | null {
const rows = readClaudeUsageSnapshots(claudeUsageStatePath(deps.statePath))
.filter(row => deps.identity === undefined || row.identity === deps.identity);
return rows.sort((a, b) => b.checkedAt - a.checkedAt)[0] ?? null;
}
Loading
Loading