utils/credentialCheck.ts
Rama main · réplica de lectura del repositorio original.
import { preflightClaudeSubscription } from "./claudeSubscription.ts";
/**
* What the provider itself says about a credential. `unknown` is the safe
* default and covers every outcome that isn't the provider explicitly
* rejecting it — no probe for this env var, a network failure, a timeout, a
* 404, a 5xx. Callers must treat `unknown` as "carry on as before": only
* `dead` is positive evidence, and acting on anything weaker would let a
* provider outage rewrite a working account's configuration.
*/
export type CredentialVerdict = "alive" | "dead" | "unknown";
type Probe = {
/** built from the credential so key-in-query providers (Gemini) fit too. */
request: (value: string) => { url: string; headers: Record<string, string> };
/**
* providers that answer a bad credential with something other than 401/403
* declare their own tell. Gemini returns 400 `API_KEY_INVALID`, which is
* indistinguishable from a malformed request by status alone.
*/
deadWhen?: (status: number, body: string) => boolean;
/**
* the credential can legitimately be aimed at a host other than the one this
* probe knows, so the probe only speaks for callers that can see where it is
* pointed. `ANTHROPIC_BASE_URL` re-points Claude Code at a gateway, and a
* gateway token is 401 at api.anthropic.com while working perfectly at the
* gateway — so a caller with no view of the base URL must not ask at all.
*/
hostConfigurable?: true;
};
/**
* Only providers whose live-key 200 and bad-key rejection have both been
* measured. An unlisted credential returns `unknown`, which is exactly the
* behaviour that existed before this file — so the table grows by adding a
* verified entry, never by guessing at one. `CODEX_AUTH_JSON` is deliberately
* absent: it is an OAuth blob whose only check is a token refresh, which would
* mutate the very credential being probed. `OPENAI_API_KEY` is absent for a
* different reason — `GET /v1/models` needs the `Models: Read` scope, so a
* restricted inference-only key 401s there while working perfectly for real
* requests, and refusing it at paste time would be worse than not checking.
*/
const PROBES: Record<string, Probe> = {
ANTHROPIC_API_KEY: {
request: (value) => ({
url: "https://api.anthropic.com/v1/models",
headers: { "x-api-key": value, "anthropic-version": "2023-06-01" },
}),
hostConfigurable: true,
},
OPENROUTER_API_KEY: {
request: (value) => ({
url: "https://openrouter.ai/api/v1/key",
headers: { authorization: `Bearer ${value}` },
}),
},
GEMINI_API_KEY: {
request: (value) => ({
url: `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(value)}`,
headers: {},
}),
deadWhen: (status, body) => status === 400 && body.includes("API_KEY_INVALID"),
},
};
PROBES.GOOGLE_GENERATIVE_AI_API_KEY = PROBES.GEMINI_API_KEY;
/**
* The Pro/Max subscription token rides an OAuth surface that a plain
* `/v1/models` call doesn't exercise, and `preflightClaudeSubscription` already
* speaks it — so delegate rather than keep a second Anthropic OAuth probe that
* could drift from the first. The interpretation differs from the run-time one
* on purpose: a 429 means the subscription is at its limit, which makes it
* unusable for a run but does NOT make it an invalid credential to store.
*/
async function verifyClaudeSubscription(value: string): Promise<CredentialVerdict> {
const preflight = await preflightClaudeSubscription({ token: value, model: undefined });
if (preflight.status === undefined) return "unknown";
return preflight.status === 401 ? "dead" : "alive";
}
/**
* Whether asking the provider about this credential would tell us anything —
* the paste-time gate, where the caller stores a secret and cannot see the run
* env. A `hostConfigurable` credential is excluded here and stays probeable at
* run time, because only the runner knows whether a gateway is in play: the
* base URL usually lives in the workflow file, which the console never reads.
*/
export function isCredentialProbeable(envVar: string): boolean {
if (envVar === "CLAUDE_CODE_OAUTH_TOKEN") return true;
const probe = PROBES[envVar];
return probe !== undefined && !probe.hostConfigurable;
}
/**
* Ask the provider whether it still accepts this credential. Used at the two
* points that were each getting it wrong on their own: the run-time fallback
* decision, and the console/CLI paste flow that previously stored a dead token
* without comment.
*/
export async function verifyCredential(params: {
envVar: string;
value: string;
}): Promise<CredentialVerdict> {
// trimmed, not `sanitizeSecret`d: this module runs on a Vercel lambda and in
// the CLI as well as on a runner, and `sanitizeSecret` reaches for
// `@actions/core` to register a GHA log mask — which is meaningless off a
// runner and drags node builtins into the console's client bundle. the
// masking still happens where it matters, at the run-time env injection.
const value = params.value.trim();
if (value.length === 0) return "dead";
if (params.envVar === "CLAUDE_CODE_OAUTH_TOKEN") return verifyClaudeSubscription(value);
const probe = PROBES[params.envVar];
if (!probe) return "unknown";
const request = probe.request(value);
try {
const response = await fetch(request.url, {
headers: request.headers,
signal: AbortSignal.timeout(5000),
});
if (response.ok) return "alive";
if (probe.deadWhen) {
return probe.deadWhen(response.status, await response.text()) ? "dead" : "unknown";
}
// 401 ONLY. a 403 is nearly always a SCOPE answer — "this key is real but
// may not touch this endpoint/model" — and reading it as a dead credential
// would refuse a working key at paste time, a worse failure than the one
// this exists to catch. 429/404/5xx are about the moment or the endpoint.
return response.status === 401 ? "dead" : "unknown";
} catch {
return "unknown";
}
}