Ctrl K
ComunidadGuías…

utils/credentialFallback.ts

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

Archivo original ↗Preguntar a KiBot
import { getModelEnvVars, getProviderGatewayUrl, stripProviderPrefix } from "../models.ts";
import { preflightClaudeSubscription } from "./claudeSubscription.ts";
import { log } from "./cli.ts";
import { verifyCredential } from "./credentialCheck.ts";

/**
 * `ok` covers "the credential works", "we couldn't tell", and "something else
 * on this model still works" — the run proceeds as it did before this check
 * existed. `dead` is only reached when every credential the configured model
 * could use has been explicitly rejected by its own provider. A run never moves
 * to a different model: the pool of credentials for THIS provider is where a
 * rejected one is routed around (see wiki/subscription-credentials.md).
 */
export type CredentialOutcome =
  | { kind: "ok" }
  | { kind: "dead"; credential: string; reason: string | undefined };

/**
 * How badly a credential failed. `dead` is the credential itself (401) and is
 * the only verdict that earns the "re-authenticate / re-issue" copy. `unusable`
 * is 403 (not entitled) or 429 (at its limit) — the credential is real, so this
 * run routes around it if it can, but the remedy is the provider's own (wait
 * for the reset, ask an admin) and `formatApiKeyErrorSummary` already renders
 * both from the agent's own error. Collapsing the two would tell a rate-limited
 * subscriber to re-issue a perfectly healthy credential.
 */
type Failure = { severity: "dead" | "unusable"; reason: string | undefined };

function hasEnvVar(name: string): boolean {
  const value = process.env[name];
  return typeof value === "string" && value.length > 0;
}

/**
 * `getModelEnvVars` THROWS on a slugless model, and bedrock / vertex resolve to
 * a bare backend model id (`gemini-2.5-flash`, `us.anthropic.claude-opus-4-6-v1`)
 * rather than a `provider/model` specifier. Their credentials are multi-var and
 * have dedicated setup validators, and neither is in the probe table, so there
 * is nothing here for them anyway. Same guard `buildMissingApiKeyError` already
 * applies. (`openai-compatible` is NOT in this set — `resolveSlug` keeps its
 * `openai-compatible/` prefix, so it takes the normal path.)
 */
function envVarsFor(model: string): string[] {
  return model.includes("/") ? getModelEnvVars(model) : [];
}

/**
 * "Can this credential serve THIS run?" — the subscription token is probed
 * against the run's own model, since Anthropic's limits are per-model and a
 * cheaper stand-in would pass while the run still died.
 */
async function checkOne(params: { envVar: string; model: string }): Promise<Failure | null> {
  const value = process.env[params.envVar];
  if (!value) return null;

  // a gateway re-points where this credential is SENT, so the vendor's own host
  // cannot answer whether it is valid. keyed on the model's provider, so every
  // credential it accepts is covered at once. see wiki/credential-rejection.md.
  if (getProviderGatewayUrl(params.model)) return null;

  if (params.envVar === "CLAUDE_CODE_OAUTH_TOKEN") {
    const preflight = await preflightClaudeSubscription({
      token: value,
      model: stripProviderPrefix(params.model),
    });
    if (preflight.usable) return null;
    return { severity: preflight.status === 401 ? "dead" : "unusable", reason: preflight.reason };
  }

  // the /v1/models-style probes carry no message worth repeating — the copy
  // already says the provider refused it, so there is nothing to quote.
  const verdict = await verifyCredential({ envVar: params.envVar, value });
  return verdict === "dead" ? { severity: "dead", reason: undefined } : null;
}

/**
 * Ask the providers whether the credentials behind the configured model still
 * work, BEFORE the agent spawns — so a rejected one becomes either a run on
 * another credential for the same model or an accurate error, instead of the
 * agent 401ing three seconds in against a "rotate your GitHub Actions secret"
 * CTA naming a place the user never put a key.
 *
 * Failed credentials are deleted from `process.env` only when this run can
 * still proceed without them. Nothing downstream infers anything from that
 * deletion — `claude.ts` runs its own preflight regardless, because this check
 * cannot promise it probed any particular token (see its comment for the three
 * paths that reach it unprobed).
 *
 * Only providers in the `credentialCheck` probe table can be found dead; every
 * other credential is left exactly as it was.
 */
export async function checkConfiguredCredentials(params: {
  model: string | undefined;
}): Promise<CredentialOutcome> {
  if (!params.model) return { kind: "ok" };

  const present = envVarsFor(params.model).filter(hasEnvVar);
  // no credential at all is the missing-key path, which already has its own
  // error copy and its own CTA — this check has nothing to add to it.
  if (present.length === 0) return { kind: "ok" };

  const model = params.model;
  const checked = await Promise.all(
    present.map(async (envVar) => ({ envVar, failure: await checkOne({ envVar, model }) }))
  );
  const failed = checked.flatMap((entry) =>
    entry.failure ? [{ envVar: entry.envVar, failure: entry.failure }] : []
  );
  if (failed.length === 0) return { kind: "ok" };

  const drop = (entry: { envVar: string; failure: Failure }) => {
    const detail = entry.failure.reason;
    log.info(`» ${entry.envVar} rejected by its provider${detail ? ` (${detail})` : ""}`);
    delete process.env[entry.envVar];
  };

  // another credential still serves this model, so drop the bad ones and let
  // the agent use the good one — the same-provider fallback `claude.ts` used to
  // own (an exhausted subscription giving way to an API key).
  if (failed.length < checked.length) {
    failed.forEach(drop);
    return { kind: "ok" };
  }

  // every credential for this model failed, but not all of them are dead
  // CREDENTIALS — a 403 or 429 is the provider's own condition with its own
  // remedy, and the agent's error already renders it correctly. leave the env
  // untouched so that message survives rather than becoming "no API key found".
  if (failed.some((entry) => entry.failure.severity !== "dead")) return { kind: "ok" };

  failed.forEach(drop);
  const first = failed[0];
  if (!first) return { kind: "ok" };
  return { kind: "dead", credential: first.envVar, reason: first.failure.reason };
}