utils/claudeSubscription.ts
Rama main · réplica de lectura del repositorio original.
import { resolveModelSlug } from "../models.ts";
/**
* identity block Anthropic's OAuth gate validates on `/v1/messages` calls
* authenticated with a Claude Code OAuth token. haiku is currently exempt
* but sending it costs nothing and survives the exemption being removed.
*/
const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
// fallback probe model for runs with no explicit model (claude-code picks its
// own default there, so per-model accuracy is moot — only whether the
// subscription answers at all). registry-resolved so the models-bump cron
// keeps the id fresh. read per call, so a registry run-context served applies.
function fallbackProbeModel(): string {
const resolve = resolveModelSlug("anthropic/claude-haiku");
if (!resolve) {
throw new Error("claudeSubscription preflight: anthropic/claude-haiku missing from registry");
}
return resolve.slice(resolve.indexOf("/") + 1);
}
/**
* `status` is the HTTP status Anthropic answered with, or `undefined` when we
* never got an answer at all (network failure / timeout). Callers that need to
* tell "the credential is dead" from "we couldn't ask" — the paste-time check
* in `credentialCheck.ts` — read it; `usable` alone conflates the two, because
* fail-open makes both `usable: true`.
*/
export type SubscriptionPreflight =
| { usable: true; status: number | undefined }
| { usable: false; status: number; reason: string; resetAt: Date | undefined };
/**
* preflight a Claude subscription OAuth token (`CLAUDE_CODE_OAUTH_TOKEN`)
* with a 1-token Messages call, so the agent can fall back to
* `ANTHROPIC_API_KEY` when the subscription is exhausted or revoked instead
* of failing the whole run at its first model call. rides the same de-facto
* OAuth surface Claude Code itself uses: Bearer auth + the
* `claude-code-20250219,oauth-2025-04-20` betas + the identity system prompt.
*
* probes the run's own model when known — subscription limits can be
* per-model ("You've hit your Opus limit"), so a cheaper stand-in could pass
* preflight and still leave the run dead on arrival.
*
* fail-open by design: only 401 (revoked/expired token), 403 (Anthropic
* `permission_error` — the credential isn't entitled to serve this run, e.g.
* an org disabled Claude Code subscription access, #1072) and 429
* (session/weekly/per-model limit) mark the token unusable. network errors,
* 5xx, and request-shape drift (400) all keep today's subscription-first
* behavior, so the preflight can never fail a run that would have worked —
* the worst wrong answer is a run that bills the API key instead of the
* subscription.
*/
export async function preflightClaudeSubscription(params: {
token: string;
/** bare Anthropic model id the run will use (e.g. "claude-fable-5") */
model: string | undefined;
}): Promise<SubscriptionPreflight> {
const model = params.model ?? fallbackProbeModel();
let res: Response;
try {
res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
authorization: `Bearer ${params.token}`,
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
"anthropic-version": "2023-06-01",
"content-type": "application/json",
"x-app": "cli",
},
body: JSON.stringify({
model,
max_tokens: 1,
system: CLAUDE_CODE_IDENTITY,
messages: [{ role: "user", content: "ok" }],
}),
signal: AbortSignal.timeout(10_000),
});
} catch {
// network failure / timeout says nothing about the credential
return { usable: true, status: undefined };
}
if (res.status !== 401 && res.status !== 403 && res.status !== 429)
return { usable: true, status: res.status };
const body = await res.text().catch(() => "");
// epoch seconds, sent on every subscription response — the moment a capped
// window opens again, which a 429's own message only gives in prose.
const reset = Number(res.headers.get("anthropic-ratelimit-unified-reset"));
return {
usable: false,
status: res.status,
reason: `${res.status}: ${extractApiErrorMessage(body)}`,
resetAt: res.status === 429 && reset > 0 ? new Date(reset * 1000) : undefined,
};
}
/** pull `error.message` out of an Anthropic error payload, else a raw excerpt */
function extractApiErrorMessage(body: string): string {
try {
const parsed: unknown = JSON.parse(body);
if (
typeof parsed === "object" &&
parsed !== null &&
"error" in parsed &&
typeof parsed.error === "object" &&
parsed.error !== null &&
"message" in parsed.error &&
typeof parsed.error.message === "string"
) {
return parsed.error.message;
}
} catch {
// not json — fall through to the raw excerpt
}
return body.slice(0, 200);
}