Ctrl K
ComunidadGuías…

utils/claudeSubscription.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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);
}