Ctrl K
ComunidadGuías…

utils/apiKeys.ts

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

Archivo original ↗Preguntar a KiBot
import {
  AZURE_API_KEY_ENV,
  AZURE_CONTEXT_ENV,
  AZURE_DEPLOYMENT_ENV,
  AZURE_MAX_OUTPUT_ENV,
  AZURE_PROVIDER,
  AZURE_RESOURCE_NAME_ENV,
  BEDROCK_MODEL_ID_ENV,
  getModelEnvVars,
  getModelManagedCredentials,
  getOpenCodeEnvVars,
  modelAliases,
  OPENAI_COMPATIBLE_PROVIDER,
  openAICompatibleEnv,
  providers,
  resolveDisplayAlias,
  TRIAL_FUNDED_USD_LIMIT,
  VERTEX_MODEL_ID_ENV,
} from "../models.ts";
import { getApiUrl } from "./apiUrl.ts";
import { PULLFROG_DATA_DIR, unhostedSubscriptions } from "./codexHome.ts";
import { DISCORD_LINK, type FailureLink, renderFailure } from "./failureBody.ts";
import { keylessRejection } from "./keylessAuth.ts";
import { getModelsFailure } from "./openCodeModels.ts";
import { PROVIDER_DASHBOARDS } from "./providerDashboards.ts";
import type { CredentialCandidate } from "./subscriptionCredentials.ts";
import type { ProbeVerdict } from "./subscriptionProbe.ts";
import {
  GOOGLE_CLOUD_PROJECT_ENV,
  readProjectIdFromVertexServiceAccountJson,
  VERTEX_LOCATION_ENV,
  VERTEX_SERVICE_ACCOUNT_JSON_ENV,
} from "./vertex.ts";

/** marker prefix on the throw message for the catch-side reclassification path */
const MISSING_KEY_MARKER = "No API key found";
/** shared sentence in the four `resolveSlug` routing-slug throws. */
export const BYOK_SLUG_MARKER = "must be set when the model is";
/** shared sentence in the four `validate*Setup` throws. */
export const BYOK_CONFIG_MARKER = "is missing required settings:";

/**
 * What an owned setup error looks like from character zero: an ALL-CAPS env var
 * name in a code span before the slug marker, or a capitalised provider name (up
 * to four words, for `Google Vertex AI`) before the config marker. Neither marker
 * contains a regex metacharacter, so both interpolate literally.
 */
const BYOK_SETUP_PATTERN = new RegExp(
  `^(?:\`[A-Z][A-Z0-9_]+\` ${BYOK_SLUG_MARKER}|[A-Z][\\w-]*(?: [\\w-]+){0,3} ${BYOK_CONFIG_MARKER})`
);

/**
 * A BYOK provider setup is incomplete, at either of the two points that catch it:
 * `resolveSlug` refusing a routing slug whose backing env var is unset, and the
 * per-provider `validate*Setup` check that runs once the slug resolves. All eight
 * messages already name what is missing, the remedy and a docs page, so the
 * renderer passes them through rather than rewriting them. Before this they
 * matched no branch at all, and `renderRunError` writes `errorMessage` to the job
 * summary but not to the comment — so the answer existed on every one of these
 * runs and the customer's PR comment read `Run failed.`
 *
 * Matched from character zero rather than searched anywhere in the text, because
 * a match publishes the WHOLE message verbatim to a customer's PR and
 * `errorMessage` is not always ours: `postRun` builds one out of a repo's own
 * stop-hook output, which is arbitrary text the customer's script printed. A
 * message that merely CONTAINS one of these errors is something quoting us, and
 * passing it through would republish everything around it.
 */
export function isByokSetupError(text: string): boolean {
  return BYOK_SETUP_PATTERN.test(text);
}

/**
 * marker for the distinct "run-context couldn't hand over your stored secrets"
 * body. surfaced verbatim by `runErrorRenderer` (same contract as
 * `MODEL_ACCESS_MARKER`) because blaming the user for a transient fetch failure
 * on our side is the wrong CTA — their key is configured and still stored.
 */
export const SECRETS_UNAVAILABLE_MARKER = "couldn't load your stored secrets";

/**
 * marker for the "your Router wallet is empty AND you have no provider key"
 * body. its own marker rather than the missing-key one because
 * `formatApiKeyErrorSummary` rebuilds any body carrying `MISSING_KEY_MARKER`
 * that doesn't start with it, which would silently discard this copy.
 */
export const ROUTER_UNFUNDED_MARKER = "Your Pullfrog Router balance is empty";

/**
 * marker for the body a trial account gets once its funded credits are spent:
 * at the late fallback, or when the funded key runs out mid-run. Without it the
 * first is told to add an API key and the second that a Router wallet it never
 * had went negative, and neither mentions the trial.
 */
export const TRIAL_SPENT_MARKER = "reached the limit of your free trial credits";

export function buildTrialSpentError(params: { owner: string }): string {
  return renderFailure({
    headline: `You've ${TRIAL_SPENT_MARKER}.`,
    detail: `Your $${TRIAL_FUNDED_USD_LIMIT} of trial usage credits have been used up, so you'll need to connect a provider to continue using Pullfrog.`,
    links: [providerLink(params.owner)],
  });
}

/** the repo's console page: its model, instructions and scripts. */
export function repoConsoleUrl(repo: { owner: string; name: string }): string {
  return `${getApiUrl()}/console/${repo.owner}/${repo.name}`;
}

export function pickModelLink(repo: { owner: string; name: string }): FailureLink {
  return { label: "Pick another model", url: repoConsoleUrl(repo) };
}

/** the account console's billing card: provider keys, subscriptions and Router credit. */
export function providerLink(owner: string): FailureLink {
  return { label: "Connect a provider", url: `${getApiUrl()}/console/${owner}#billing` };
}

/**
 * marker for a credential the provider explicitly REJECTED before the agent
 * started. needs a verbatim guard for the same reason as its three siblings,
 * and more sharply: the body quotes the provider's own wording, which is
 * exactly what `isApiKeyAuthError` sniffs — so an Anthropic error whose JSON
 * carries `authentication_error` would be rebuilt into the generic
 * "rotate the key, update the GitHub Actions secret" copy. That is #782
 * verbatim, on the code path built to stop it.
 *
 * Phrased distinctively for the same reason as its siblings: `runErrorRenderer`
 * short-circuits the whole rendering pipeline on a SUBSTRING match against
 * arbitrary agent output, so a marker short enough to occur by accident would
 * hand that agent the ability to suppress every other error body.
 */
export const CREDENTIAL_REJECTED_MARKER = "was rejected by its provider";

/**
 * marker for the credential pool's refusal: every configured credential for the
 * model was rejected or exhausted. verbatim for the same reason as
 * `CREDENTIAL_REJECTED_MARKER` — each bullet quotes the probe's verdict.
 */
export const CREDENTIAL_POOL_REFUSED_MARKER = "No configured credential can serve this run";

/** marker for keyless auth that nothing could stand in for. verbatim: its reason quotes the provider. */
export const KEYLESS_UNUSABLE_MARKER = "keyless auth could not be used";

/**
 * Three ways to arrive at "the runner has no key", each with a different CTA:
 * we couldn't read the key you stored, your Router wallet ran dry and you have
 * no key of your own, or you genuinely have no key. Every throw site picks by
 * these flags.
 */
/**
 * Nothing in the environment can pay for this run — as distinct from a
 * credential that IS configured but incompletely (the Bedrock/Vertex/Azure/
 * OpenAI-compatible setup validators), an agent that could not start
 * (`getModelsFailure`), or Pullfrog failing to hand its own secrets over.
 *
 * The trial fallback keys on this class, and that is the whole reason it
 * exists: every other failure in `validateAgentApiKey` means the user HAS
 * brought a key, so falling back would swap their model for a cheap one and
 * swallow the message telling them what to fix.
 */
export class NoUsableCredentialError extends Error {}

/**
 * The three ways a run reaches the agent with nothing to authenticate with.
 * Only two of them are the user's to fix, and only those two are a
 * `NoUsableCredentialError`: when `secretsUnavailable` is set we could not
 * READ their Pullfrog-stored credentials, so we do not know whether they have
 * one — and guessing costs them a downgraded run to cover our own outage.
 */
function throwKeyError(params: {
  owner: string;
  name: string;
  model?: string | undefined;
  secretsUnavailable?: boolean | undefined;
  routerUnfunded?: boolean | undefined;
}): never {
  // not a `NoUsableCredentialError`: the user brought a credential, it just failed
  const keyless = keylessRejection(params.model);
  if (keyless) throw new Error(buildKeylessRejectedError({ ...keyless, owner: params.owner }));
  if (params.secretsUnavailable) throw new Error(buildSecretsUnavailableError());
  if (params.routerUnfunded) throw new NoUsableCredentialError(buildRouterUnfundedError(params));
  throw new NoUsableCredentialError(buildMissingApiKeyError(params));
}

/**
 * The account is on the Router, its wallet hit zero, and run-context therefore
 * declined the mint — so the run fell through to BYOK and found nothing.
 * `router_requires_card` (the 402 with this copy) is only reachable on the one
 * run whose balance crosses zero; every run after it lands here, and before
 * this body they were all told to go add an `OPENAI_API_KEY`. Leads with the
 * funding remedy because that is the one that matches how the account is
 * actually configured.
 */
function buildRouterUnfundedError(params: { owner: string }): string {
  return renderFailure({
    headline: `${ROUTER_UNFUNDED_MARKER}, and this repo has no API key to fall back on.`,
    detail: "Add Router credit, or connect your own provider key or subscription.",
    links: [
      { label: "Billing and providers", url: `${getApiUrl()}/console/${params.owner}#billing` },
    ],
  });
}

export function buildSecretsUnavailableError(): string {
  return renderFailure({
    headline: `Pullfrog ${SECRETS_UNAVAILABLE_MARKER} for this run.`,
    detail:
      "They're still saved; this is a temporary failure on our side. Re-trigger to try again, and tell us in Discord if it keeps happening.",
    links: [DISCORD_LINK],
  });
}

/**
 * Markdown body used for both the thrown error and the formatted PR comment
 * summary. When the model is known, names it and the exact env var(s) it needs
 * so the user knows precisely what to fix; otherwise falls back to the generic
 * "any provider key" copy (auto-select path).
 *
 * The lead says "this run used" rather than "this repo is configured to use",
 * because the resolved model does not always come from the repo's config — a
 * trigger-time `--model=`, a `PULLFROG_MODEL` variable or a server-side canary
 * arm all land here too, and asserting provenance we do not have blames the
 * user's configuration for a model they never chose. A model canary did exactly
 * that to vite-hub/vitehub#1347 on 2026-09-08.
 */
function buildMissingApiKeyError(params: {
  owner: string;
  name: string;
  model?: string | undefined;
}): string {
  const envVars = params.model?.includes("/") ? getModelEnvVars(params.model) : [];
  const [primary, ...alternates] = envVars;
  const envVarList = primary
    ? `\`${primary}\`${alternates.length > 0 ? ` (or ${alternates.map((v) => `\`${v}\``).join(" / ")})` : ""}`
    : undefined;
  // the run passes the resolved id (`moonshotai/kimi-k2.7-code`); name the model as it was picked
  const alias = params.model
    ? (resolveDisplayAlias(params.model) ??
      modelAliases.find((a) => !a.fallback && a.resolve === params.model))
    : undefined;
  const modelName = params.model ? (alias?.displayName ?? `\`${params.model}\``) : undefined;

  const unhosted = params.model?.includes("/")
    ? getModelManagedCredentials(params.model).find((name) => unhostedSubscriptions.has(name))
    : undefined;
  const detail = unhosted
    ? `Your ${SUBSCRIPTION_CREDENTIALS[unhosted]?.label} is stored, but this runner has no passwordless \`sudo\`, which Pullfrog needs to keep it in \`${PULLFROG_DATA_DIR}\`, out of the agent's reach. Allow passwordless \`sudo\` for the runner user, run on a GitHub-hosted runner, or add an API key.`
    : envVarList
      ? `It needs ${envVarList}. Connect it in Pullfrog or add it as a GitHub Actions secret, or pick a model you already have a key for.`
      : "Pullfrog needs a key or subscription for at least one model provider. Connect one in Pullfrog, or add a key as a GitHub Actions secret.";

  return renderFailure({
    headline:
      envVarList && modelName
        ? `${MISSING_KEY_MARKER} for ${modelName}.`
        : `${MISSING_KEY_MARKER}.`,
    detail,
    links: [providerLink(params.owner), pickModelLink(params)],
  });
}

/** the setup copy's shared shape: what is missing, where to put it, the env block, a guide. */
function buildSetupError(params: {
  provider: string;
  missing: string[];
  where: string;
  env: string[];
  note?: string | undefined;
  guide: string;
}): string {
  const missing = params.missing.map((name) => `\`${name}\``).join(", ");
  return [
    `${params.provider} ${BYOK_CONFIG_MARKER} ${missing}. ${params.where}`,
    `\`\`\`yaml\n${params.env.join("\n")}\n\`\`\``,
    `${params.note ? `${params.note} ` : ""}[${params.provider} setup guide](${params.guide})`,
  ].join("\n\n");
}

function secretsUrl(params: { owner: string; name: string }): string {
  return `https://github.com/${params.owner}/${params.name}/settings/secrets/actions`;
}

function buildBedrockSetupError(params: {
  owner: string;
  name: string;
  missing: string[];
}): string {
  return buildSetupError({
    provider: "Amazon Bedrock",
    missing: params.missing,
    where: `Add them to [this repo's Actions secrets](${secretsUrl(params)}) and pass them in the workflow's \`env:\` block:`,
    env: [
      `AWS_BEARER_TOKEN_BEDROCK: \${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}`,
      `AWS_REGION: \${{ secrets.AWS_REGION }}`,
      `${BEDROCK_MODEL_ID_ENV}: \${{ secrets.${BEDROCK_MODEL_ID_ENV} }}`,
    ],
    note: "`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` (plus `AWS_SESSION_TOKEN` if you use one) can replace the bearer token.",
    guide: "https://docs.pullfrog.com/bedrock",
  });
}

function buildVertexSetupError(params: { owner: string; name: string; missing: string[] }): string {
  return buildSetupError({
    provider: "Google Vertex AI",
    missing: params.missing,
    where: `Add the service account to [this repo's Actions secrets](${secretsUrl(params)}) and set the rest in the workflow's \`env:\` block:`,
    env: [
      `${VERTEX_SERVICE_ACCOUNT_JSON_ENV}: \${{ secrets.${VERTEX_SERVICE_ACCOUNT_JSON_ENV} }}`,
      `${GOOGLE_CLOUD_PROJECT_ENV}: my-project`,
      `${VERTEX_LOCATION_ENV}: global`,
      `${VERTEX_MODEL_ID_ENV}: <vertex-model-id>`,
    ],
    guide: "https://docs.pullfrog.com/vertex",
  });
}

function buildOpenAICompatibleSetupError(params: {
  owner: string;
  name: string;
  missing: string[];
  env: ReturnType<typeof openAICompatibleEnv>;
}): string {
  return buildSetupError({
    provider: "OpenAI-compatible endpoint",
    missing: params.missing,
    where: `Set them on the OpenAI-compatible provider in [Pullfrog's provider settings](${getApiUrl()}/console/${params.owner}#billing), or in the workflow's \`env:\` block with the key as an [Actions secret](${secretsUrl(params)}):`,
    env: [
      `${params.env.baseUrl}: https://your-endpoint.example.com/v1`,
      `${params.env.apiKey}: \${{ secrets.${params.env.apiKey} }}`,
      `${params.env.model}: <model-id>`,
      `${params.env.context}: "128000"`,
      `${params.env.maxOutput}: "16384"`,
    ],
    note: "Use the real context and output limits of the model your endpoint serves: Pullfrog can't discover them, and without them long runs grow until the endpoint refuses them.",
    guide: "https://docs.pullfrog.com/openai-compatible",
  });
}

function buildAzureSetupError(params: { owner: string; name: string; missing: string[] }): string {
  return buildSetupError({
    provider: "Azure OpenAI",
    missing: params.missing,
    where: `Set them on the Azure OpenAI provider in [Pullfrog's provider settings](${getApiUrl()}/console/${params.owner}#billing), or in the workflow's \`env:\` block with the key as an [Actions secret](${secretsUrl(params)}):`,
    env: [
      `${AZURE_RESOURCE_NAME_ENV}: <name>`,
      `${AZURE_API_KEY_ENV}: \${{ secrets.${AZURE_API_KEY_ENV} }}`,
      `${AZURE_DEPLOYMENT_ENV}: <deployment-name>`,
      `${AZURE_CONTEXT_ENV}: "400000"`,
      `${AZURE_MAX_OUTPUT_ENV}: "128000"`,
    ],
    note: `\`${AZURE_RESOURCE_NAME_ENV}\` is the \`<name>\` in \`https://<name>.openai.azure.com\`, and \`${AZURE_DEPLOYMENT_ENV}\` is the deployment's name, not the model it serves. Use that model's real context and output limits: Pullfrog can't discover them, and without them long runs grow until Azure refuses them.`,
    guide: "https://docs.pullfrog.com/azure",
  });
}

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

/**
 * Whether this run actually holds a credential that can serve `model`.
 * Managed credentials count: `openai` carries `CODEX_AUTH_JSON`, so a run
 * authenticated by `pullfrog auth codex` has no `OPENAI_API_KEY` and would
 * otherwise read as unservable on every `openai/*` entry. A model with no
 * declared credential at all (off-registry provider) stays eligible rather
 * than being refused on our own ignorance.
 *
 * Private on purpose. `autoSelectModel`'s `servable` looks like a duplicate
 * but asks a stricter question — "may I PIN this alias?" — and must stay
 * conservative about managed credentials: Codex eligibility is a separate
 * upstream allow list that `opencode models` doesn't honor, so a
 * `CODEX_AUTH_JSON` run is better left to opencode's own pick than handed
 * `openai/gpt-6.1-sol`. Don't widen this to share it with the picker.
 */
function modelHasRuntimeAuth(model: string): boolean {
  const authVars = [...getOpenCodeEnvVars(model), ...getModelManagedCredentials(model)];
  return authVars.length === 0 || authVars.some(hasEnvVar);
}

/** the token limits are numbers, so presence isn't enough — `128k` must fail here. */
function hasPositiveNumberEnvVar(name: string): boolean {
  return Number(process.env[name]) > 0;
}

/** what the endpoint `set` (the bare variables when undefined) lacks in this process's env. */
export function openAICompatibleSetupMissing(set: string | undefined): string[] {
  const env = openAICompatibleEnv(set);
  const missing: string[] = [];
  if (!hasEnvVar(env.baseUrl)) missing.push(env.baseUrl);
  if (!hasEnvVar(env.apiKey)) missing.push(env.apiKey);
  if (!hasEnvVar(env.model)) missing.push(env.model);
  // required, not optional: opencode has no catalog metadata for a user-supplied
  // endpoint, and an undeclared limit both caps completions at 32000 and disables
  // auto-compaction for the whole run. see openAICompatibleLimit().
  if (!hasPositiveNumberEnvVar(env.context)) missing.push(env.context);
  if (!hasPositiveNumberEnvVar(env.maxOutput)) missing.push(env.maxOutput);
  return missing;
}

/** checks the run's endpoint under the names it is stored as: a named set's own variables
 * (`activateOpenAICompatibleSet` copied them into the bare ones), else the bare ones. */
function validateOpenAICompatibleSetup(params: {
  owner: string;
  name: string;
  set: string | undefined;
}): void {
  const missing = openAICompatibleSetupMissing(params.set);
  if (missing.length > 0) {
    const env = openAICompatibleEnv(params.set);
    throw new Error(
      buildOpenAICompatibleSetupError({ owner: params.owner, name: params.name, missing, env })
    );
  }
}

export function azureSetupMissing(): string[] {
  const missing: string[] = [];
  if (!hasEnvVar(AZURE_API_KEY_ENV)) missing.push(AZURE_API_KEY_ENV);
  if (!hasEnvVar(AZURE_RESOURCE_NAME_ENV)) missing.push(AZURE_RESOURCE_NAME_ENV);
  if (!hasEnvVar(AZURE_DEPLOYMENT_ENV)) missing.push(AZURE_DEPLOYMENT_ENV);
  // required for the same reason openai-compatible requires its pair: a deployment
  // name carries no catalog metadata, so `azureProvider()` has to declare the
  // limits itself or opencode falls back to 32000 max_tokens with no compaction.
  if (!hasPositiveNumberEnvVar(AZURE_CONTEXT_ENV)) missing.push(AZURE_CONTEXT_ENV);
  if (!hasPositiveNumberEnvVar(AZURE_MAX_OUTPUT_ENV)) missing.push(AZURE_MAX_OUTPUT_ENV);
  return missing;
}

function validateAzureSetup(params: { owner: string; name: string }): void {
  const missing = azureSetupMissing();
  if (missing.length > 0) {
    throw new Error(buildAzureSetupError({ owner: params.owner, name: params.name, missing }));
  }
}

export function bedrockSetupMissing(): string[] {
  const hasAuth =
    hasEnvVar("AWS_BEARER_TOKEN_BEDROCK") ||
    (hasEnvVar("AWS_ACCESS_KEY_ID") && hasEnvVar("AWS_SECRET_ACCESS_KEY"));

  const missing: string[] = [];
  if (!hasAuth)
    missing.push("AWS_BEARER_TOKEN_BEDROCK (or AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY)");
  if (!hasEnvVar("AWS_REGION")) missing.push("AWS_REGION");
  if (!hasEnvVar(BEDROCK_MODEL_ID_ENV)) missing.push(BEDROCK_MODEL_ID_ENV);
  return missing;
}

function validateBedrockSetup(params: { owner: string; name: string }): void {
  const missing = bedrockSetupMissing();
  if (missing.length > 0) {
    throw new Error(buildBedrockSetupError({ owner: params.owner, name: params.name, missing }));
  }
}

export function vertexSetupMissing(): string[] {
  const hasAuth = hasEnvVar(VERTEX_SERVICE_ACCOUNT_JSON_ENV);
  const hasProject =
    hasEnvVar(GOOGLE_CLOUD_PROJECT_ENV) ||
    readProjectIdFromVertexServiceAccountJson() !== undefined;

  const missing: string[] = [];
  if (!hasAuth) missing.push(VERTEX_SERVICE_ACCOUNT_JSON_ENV);
  if (!hasProject) missing.push(GOOGLE_CLOUD_PROJECT_ENV);
  if (!hasEnvVar(VERTEX_LOCATION_ENV)) missing.push(VERTEX_LOCATION_ENV);
  if (!hasEnvVar(VERTEX_MODEL_ID_ENV)) missing.push(VERTEX_MODEL_ID_ENV);
  return missing;
}

function validateVertexSetup(params: { owner: string; name: string }): void {
  const missing = vertexSetupMissing();
  if (missing.length > 0) {
    throw new Error(buildVertexSetupError({ owner: params.owner, name: params.name, missing }));
  }
}

/**
 * The single-vendor harnesses each accept one API key or one subscription
 * credential, so "can this agent authenticate at all" is a fixed-env-var check.
 * OpenCode is the exception and answers from its own model introspection.
 *
 * `ANTHROPIC_AUTH_TOKEN` is claude-code's gateway credential — the same value as
 * `ANTHROPIC_API_KEY` but sent as `Authorization: Bearer` instead of `x-api-key`,
 * and the one Anthropic tells gateway users to reach for first. It is recognised
 * HERE only, never in `providers.anthropic.envVars`: that list feeds
 * `getModelEnvVars`, so adding it there would wave an opencode run through
 * validation on a variable opencode cannot use.
 */
function hasSingleProviderAuth(agentName: string): boolean {
  if (agentName === "codex") {
    return hasEnvVar("OPENAI_API_KEY") || hasEnvVar("CODEX_AUTH_JSON");
  }
  return (
    hasEnvVar("ANTHROPIC_API_KEY") ||
    hasEnvVar("ANTHROPIC_AUTH_TOKEN") ||
    hasEnvVar("CLAUDE_CODE_OAUTH_TOKEN")
  );
}

/**
 * Validate that the resolved model can actually be served by the chosen
 * agent. For routing slugs (Bedrock / Vertex) the auth shape is multi-var
 * (auth + region/location + model-id) and `opencode models` doesn't catch
 * gaps in the latter two — keep dedicated setup validators. For the
 * opencode path, the authoritative answer comes from OpenCode's own model
 * introspection (`authorized` set captured in `openCodeModels.ts`). For
 * the claude path, fall back to the static check (`ANTHROPIC_API_KEY` /
 * `CLAUDE_CODE_OAUTH_TOKEN`).
 */
export function validateAgentApiKey(params: {
  agent: { name: string };
  model: string | undefined;
  authorized: Set<string>;
  owner: string;
  name: string;
  /** run-context couldn't hand over Pullfrog-stored secrets, so a missing key
   * says nothing about what the user actually configured. */
  secretsUnavailable?: boolean | undefined;
  routerUnfunded?: boolean | undefined;
  /** the named OpenAI-compatible set the run is on, from `activateOpenAICompatibleSet`. */
  openAICompatibleSet?: string | undefined;
}): void {
  if (params.model) {
    const alias = resolveDisplayAlias(params.model);
    if (alias?.routing === "bedrock") {
      validateBedrockSetup({ owner: params.owner, name: params.name });
      return;
    }
    if (alias?.routing === "vertex") {
      validateVertexSetup({ owner: params.owner, name: params.name });
      return;
    }

    // openai-compatible keeps its `openai-compatible/` provider prefix through
    // resolution (slug `openai-compatible/byok` → specifier
    // `openai-compatible/<OPENAI_COMPATIBLE_MODEL>`), so one prefix check covers
    // both forms. its custom provider isn't in the opencode `authorized`
    // snapshot (it's injected into OPENCODE_CONFIG_CONTENT only at run time), so
    // validate the env-supplied base URL / key / model directly.
    if (params.model.startsWith(`${OPENAI_COMPATIBLE_PROVIDER}/`)) {
      validateOpenAICompatibleSetup({
        owner: params.owner,
        name: params.name,
        set: params.openAICompatibleSet,
      });
      return;
    }

    // azure resolves to `azure/<deployment>`, and a deployment name that isn't
    // also a models.dev azure model id is absent from the `authorized` snapshot
    // however correct the credentials are — so the prefix check has to precede
    // it, same as openai-compatible above.
    if (params.model.startsWith(`${AZURE_PROVIDER}/`)) {
      validateAzureSetup({ owner: params.owner, name: params.name });
      return;
    }

    // raw backend model IDs (post-resolveModel for routing slugs) have no
    // `/`. discriminate by the env-var sentinel, then run the matching
    // setup validator — `opencode models` doesn't help here because the
    // Bedrock/Vertex provider plugins need region/location/model-id wired
    // through env regardless of CLI-side auth.
    // both sentinels are checked, because `resolveSlug` returns the env var's
    // value verbatim — so a slash-less model matching NEITHER did not come from
    // a routing slug at all. It is an unknown slug `resolveModel` passed through
    // (having already warned "agent will auto-select"), and answering that with
    // "Bedrock model selected but required configuration is missing" is advice
    // about a provider the user never picked. Falling through sends it to the
    // ordinary checks, where `getModelEnvVars` reports the actual `invalid
    // model slug`. That is still not a `NoUsableCredentialError`, so a trial
    // does not fund it — a typo'd slug is a fixable mistake, not a run nobody
    // can pay for.
    if (!params.model.includes("/")) {
      if (process.env[VERTEX_MODEL_ID_ENV]?.trim() === params.model) {
        validateVertexSetup({ owner: params.owner, name: params.name });
        return;
      }
      if (process.env[BEDROCK_MODEL_ID_ENV]?.trim() === params.model) {
        validateBedrockSetup({ owner: params.owner, name: params.name });
        return;
      }
    }

    if (params.agent.name === "opencode") {
      if (params.authorized.has(params.model)) return;
      // an empty authorized set means either "no auth" or "opencode couldn't
      // start" — an unloadable repo config being the usual second case. prefer
      // opencode's own reason over sending the user to their secrets page.
      const reason = getModelsFailure();
      if (reason) throw new Error(reason);
      // `opencode models` can exit 0 having printed only a prefix of its
      // catalog, so a missing entry is not proof the key is absent — a run
      // failed or passed purely on where its slug sorted against the cut. only
      // trust the absence when the model's own env var is unset too. the rescue
      // asks `getOpenCodeEnvVars` because it is rescuing an OPENCODE run: a
      // Claude subscription token would otherwise wave through the very model
      // opencode is about to reject.
      if (getOpenCodeEnvVars(params.model).some(hasEnvVar)) return;
      throwKeyError({
        owner: params.owner,
        name: params.name,
        model: params.model,
        secretsUnavailable: params.secretsUnavailable,
        routerUnfunded: params.routerUnfunded,
      });
    }

    // claude / codex: single-provider check on that vendor's auth shapes.
    if (hasSingleProviderAuth(params.agent.name)) return;
    throwKeyError({
      owner: params.owner,
      name: params.name,
      model: params.model,
      secretsUnavailable: params.secretsUnavailable,
      routerUnfunded: params.routerUnfunded,
    });
  }

  // no model configured (auto-select path).
  if (params.agent.name === "opencode") {
    // a non-empty catalog is NOT proof of a credential: opencode lists Zen's
    // free models with no key at all, so `size > 0` waved through exactly the
    // runs that had nothing to authenticate with, and `autoSelectModel` then
    // handed the pick to opencode, which died at the provider with a bare
    // `Missing Authentication header`. require what the configured-model branch
    // above requires — a model whose own env var is actually set. an
    // off-registry provider yields `[]` and stays eligible, so a key we don't
    // catalog still passes.
    if ([...params.authorized].some(modelHasRuntimeAuth)) return;
    // same reasoning as the configured-model branch above: an unloadable repo
    // config empties the model set, and it is the likelier cause here too.
    const reason = getModelsFailure();
    if (reason) throw new Error(reason);
    throwKeyError({
      owner: params.owner,
      name: params.name,
      secretsUnavailable: params.secretsUnavailable,
      routerUnfunded: params.routerUnfunded,
    });
  }
  if (hasSingleProviderAuth(params.agent.name)) return;
  throwKeyError({
    owner: params.owner,
    name: params.name,
    secretsUnavailable: params.secretsUnavailable,
    routerUnfunded: params.routerUnfunded,
  });
}

/**
 * Detect agent-runtime auth failures that should be reformatted as an actionable
 * key-fix CTA before being shown to the user. Covers the shapes we see:
 *   - missing key (validateAgentApiKey throw): contains MISSING_KEY_MARKER
 *   - revoked / invalid key (Claude CLI 401 surfaced via api_error_status):
 *     "Invalid API key · Fix external API key" + similar provider variants
 *   - direct-Anthropic 401 (`Failed to authenticate. API Error: 401 ...
 *     {"type":"error","error":{"type":"authentication_error", ...
 *     "Invalid bearer token"}}`) emitted by the Claude CLI for revoked /
 *     mistyped / rotated `ANTHROPIC_API_KEY`. see #782.
 *   - expired credentials (#931): Bedrock 403 `Failed to authenticate. API
 *     Error: 403 {"Message":"*** has expired"}` (short-lived bearer tokens),
 *     OpenAI OAuth "Your authentication token has expired", and Codex
 *     "Token refresh failed: 401". the Bedrock pattern is anchored to the
 *     Claude CLI emission ("Failed to authenticate. API Error:") so generic
 *     auth chatter in agent stderr can't misclassify a hang as a key error.
 *   - DeepSeek invalid key (#960): `Authentication Fails, Your api key:
 *     ****XXXX is invalid`. anchored to "Your api key: ... is invalid" so it
 *     can't collide with DeepSeek's already-handled `Insufficient balance`
 *     billing shape (which routes to formatProviderBillingExhausted).
 *   - org-disabled Claude subscription (#1072): `Your organization has
 *     disabled Claude subscription access for Claude Code`, an entitlement
 *     denial with its own remedy — see isClaudeSubscriptionDisabledError.
 *   - OpenAI rejected key: `Incorrect API key provided: sk-proj-***`, OpenAI's
 *     own wording for a revoked, rotated or wrong-project key. Absent from this
 *     list every such run rendered the generic one-line "Run failed." with no
 *     cause at all — measured on two accounts sitting at 100% failure, one of
 *     them for a week. Matched on the full phrase rather than "Incorrect API
 *     key" alone for the reason the markers above are phrased distinctively:
 *     this predicate short-circuits the whole rendering pipeline on a substring
 *     of arbitrary agent output.
 */
export function isApiKeyAuthError(text: string): boolean {
  if (!text) return false;
  return (
    text.includes(MISSING_KEY_MARKER) ||
    /Invalid API key/i.test(text) ||
    /Incorrect API key provided/i.test(text) ||
    /\bUser not found\b/i.test(text) ||
    /\bInvalid authentication\b/i.test(text) ||
    /authentication_error/i.test(text) ||
    /Invalid bearer token/i.test(text) ||
    /api_error_status\s*=\s*401/i.test(text) ||
    /API Error:\s*401/i.test(text) ||
    /Failed to authenticate\. API Error:/i.test(text) ||
    /Your api key:.*is invalid/i.test(text) ||
    isMalformedKeyError(text) ||
    isClaudeSubscriptionDisabledError(text) ||
    isKimiPlanTierError(text) ||
    isClaudeSessionLimitError(text) ||
    isOAuthCredentialExpiredError(text)
  );
}

/**
 * Dead OAuth-connection credential — the provider is telling us the token
 * itself is finished (expired, invalidated, revoked, or unrecognised), and the
 * only fix is to re-authenticate the connection (`pullfrog auth <provider>`),
 * never to rotate a repo-secret API key. `formatApiKeyErrorSummary` renders
 * distinct copy for the whole class.
 *
 * Match the SHAPE, not the sentence. Four prior issues (#931, #1041, #1072,
 * #1122) each landed one more literal and the next provider rewording walked
 * straight through, so these patterns are parameterised over the two things
 * that vary — which token noun the provider uses, and which terminal state it
 * reports. Still deliberately narrow on the noun ("authentication token" /
 * "OAuth access token", never bare "token") so a GitHub installation-token
 * expiry can't be misread as an LLM credential problem.
 */
export function isOAuthCredentialExpiredError(text: string): boolean {
  return (
    // the copula is parameterised for the same reason the noun and the state
    // are: OpenAI writes `Provided authentication token IS expired.` (#1180).
    // the noun list stays narrow so a GitHub installation-token expiry can
    // still never be misread as an LLM credential problem.
    /(?:authentication|OAuth access) token (?:has |is |was )?(?:expired|been (?:invalidated|revoked))/i.test(
      text
    ) ||
    // the provider no longer recognises the token at all (#1086) — same dead
    // credential, phrased as a lookup miss rather than a state transition.
    /Could not find the appropriate key in your authentication token/i.test(text) ||
    // the reversed word order: `Encountered invalidated oauth token for user` (#1474).
    /(?:expired|invalidated|revoked) (?:OAuth|authentication) token/i.test(text) ||
    /Token refresh failed/i.test(text)
  );
}

/**
 * The key itself is unsendable, not wrong: a line-wrapped paste leaves an
 * interior newline, and the provider SDK refuses to serialize the header
 * (`API Error: Header '14' has invalid value`). The only member of the
 * credential-error family whose remedy is NOT "rotate the key" — re-saving the
 * same value on one line fixes it. See #1162.
 */
export function isMalformedKeyError(text: string): boolean {
  return /Header '\d+' has invalid value/i.test(text);
}

/**
 * Anthropic entitlement denial for Claude Pro/Max subscription auth (#1072):
 * an org admin turned off Claude Code subscription access. no credential the
 * user controls fixes it, so it gets its own copy instead of the re-auth CTA.
 * The probe sees the same denial as a 403, `OAuth authentication is currently
 * not allowed for this organization`, which used to be answered "sign in again".
 */
export function isClaudeSubscriptionDisabledError(text: string): boolean {
  return (
    /disabled Claude subscription access/i.test(text) ||
    /OAuth authentication is currently not allowed for this organization/i.test(text)
  );
}

/**
 * Kimi Code entitlement denial — the key is valid and the membership is live,
 * but the tier doesn't include the model or the context window the run asked
 * for (`k3` and `k3-256k` need Moderato, `kimi-for-coding-highspeed` and K3's
 * 1M window need Allegretto). Kimi answers all of them with **401**, the same
 * status as a revoked key, so nothing but the sentence tells them apart — and
 * without that the run renders "rotate the key in your provider dashboard",
 * which names three things that will not fix it.
 *
 * Both published shapes, parameterised over the clause that varies rather than
 * the model id inside it, per `isOAuthCredentialExpiredError`. Listed in
 * `isApiKeyAuthError` below so the branch is reachable whatever `type` Kimi
 * puts in the payload. See wiki/kimi-code.md.
 */
export function isKimiPlanTierError(text: string): boolean {
  return /Your current (?:subscription does not have access to|plan supports only)\b/i.test(text);
}

/**
 * Claude Pro/Max usage cap (#1104): `You've hit your session limit · resets
 * 9:20pm (UTC)`. A quota condition, not a credential one — the subscription is
 * healthy and will work again at the reset.
 *
 * The rungs are ENUMERATED rather than matched with a wildcard. A `[A-Za-z]+`
 * slot also swallows OpenAI Codex's `You've hit your usage limit.`, and the copy
 * this gates hardcodes "Your Claude subscription" and an `ANTHROPIC_API_KEY`
 * remedy — so a ChatGPT-subscription run (a supported configuration; see
 * `installCodexAuth`) would be told to fix a subscription it does not have.
 * That is the exact wrong-CTA defect this classifier exists to prevent.
 *
 * Capture group 1 is the reset stamp when present, bounded by quote/newline
 * because the message often arrives inside a JSON dump — a greedy `.+` there
 * renders `9:20pm (UTC)","type":...` into the user-facing copy.
 */
const CLAUDE_SESSION_LIMIT_PATTERN =
  /hit your (?:session|weekly|five-hour|Opus|Sonnet|Haiku)\s+limit(?:\s*·\s*resets\s+([^"\n]+))?/i;

export function isClaudeSessionLimitError(text: string): boolean {
  return CLAUDE_SESSION_LIMIT_PATTERN.test(text);
}

/**
 * Subscription credentials are re-authenticated, never rotated: there is no
 * provider dashboard to visit and no API key to paste. Telling their owner to
 * "rotate the key in your provider dashboard, then update the matching GitHub
 * Actions secret" names three things that do not exist for them — which is what
 * a revoked `CLAUDE_CODE_OAUTH_TOKEN` was answered with 47 times in a row
 * before this existed, because Anthropic's rejection reads `Invalid bearer
 * token` and that is also what a rotated `ANTHROPIC_API_KEY` reads (#782).
 * The credential in play is knowable, so it decides the copy — never the prose.
 */
const SUBSCRIPTION_CREDENTIALS: Record<
  string,
  { label: string; command: string; provider: string }
> = {
  CLAUDE_CODE_OAUTH_TOKEN: {
    label: "Claude subscription",
    command: "pullfrog auth claude",
    provider: "anthropic",
  },
  CODEX_AUTH_JSON: {
    label: "ChatGPT subscription",
    command: "pullfrog auth codex",
    provider: "openai",
  },
  GROK_AUTH_JSON: {
    label: "Grok subscription",
    command: "pullfrog auth grok",
    provider: "xai",
  },
};

/** the subscription that serves `model`'s provider, named by the env var that carries it. */
export function subscriptionFor(model: string | undefined) {
  const provider = model?.split("/")[0];
  for (const [env, subscription] of Object.entries(SUBSCRIPTION_CREDENTIALS)) {
    if (subscription.provider === provider) return { env, ...subscription };
  }
  return undefined;
}

const SIGN_IN_AGAIN = (command: string) => `Sign in again by running \`${command}\` in a terminal`;

const CLAUDE_ORG_DISABLED = {
  headline: "Your Claude organization doesn't allow subscription sign-in for Claude Code.",
  detail:
    "Ask its admin to allow it in the Claude Console, or connect an Anthropic API key instead.",
};

/** a provider id's display name, e.g. `openai` → OpenAI. */
export function providerDisplayName(id: string | null | undefined): string | undefined {
  for (const [key, provider] of Object.entries(providers)) {
    if (key === id) return provider.displayName;
  }
  return undefined;
}

/**
 * The provider rejected a credential we checked before the agent started, and
 * nothing else on the account can serve this run. Names the credential and the
 * place it actually lives, rather than guessing from the provider's wording.
 */
export function buildRejectedCredentialError(params: {
  credential: string;
  /** the provider's own message, when it gave one worth quoting. */
  reason: string | undefined;
  owner: string;
  name: string;
  /** stored in Pullfrog (console / `pullfrog auth`) rather than a GitHub Actions secret. */
  inPullfrogStore: boolean;
}): string {
  const subscription = SUBSCRIPTION_CREDENTIALS[params.credential];
  const details = params.reason
    ? { summary: "The provider's response", text: params.reason }
    : undefined;

  if (subscription) {
    const orgDisabled =
      params.reason !== undefined && isClaudeSubscriptionDisabledError(params.reason);
    return renderFailure({
      headline: `Your ${subscription.label} ${CREDENTIAL_REJECTED_MARKER}.`,
      detail: orgDisabled
        ? `${CLAUDE_ORG_DISABLED.headline} ${CLAUDE_ORG_DISABLED.detail}`
        : `${SIGN_IN_AGAIN(subscription.command)}, or pick a model you have an API key for.`,
      details,
      links: [orgDisabled ? providerLink(params.owner) : pickModelLink(params)],
    });
  }

  const provider = credentialProvider(params.credential);
  const keysUrl = provider ? PROVIDER_DASHBOARDS[provider.id]?.keys : undefined;
  const update: FailureLink = params.inPullfrogStore
    ? {
        label: "Update it in Pullfrog",
        url: `${getApiUrl()}/console/${params.owner}/${params.name}`,
      }
    : { label: "Update the Actions secret", url: secretsUrl(params) };
  return renderFailure({
    headline: `Your ${provider ? `${provider.displayName} key` : "key"} \`${params.credential}\` ${CREDENTIAL_REJECTED_MARKER}.`,
    detail: `Issue a new key and update the copy ${params.inPullfrogStore ? "stored in Pullfrog" : "in this repo's GitHub Actions secrets"}.`,
    details,
    links: keysUrl ? [{ label: "Issue a new key", url: keysUrl }, update] : [update],
  });
}

/** keyless auth was set up for the provider, could not pay (refused, or set up incompletely), and nothing else could. */
function buildKeylessRejectedError(params: {
  provider: string;
  name: string;
  reason: string;
  owner: string;
}): string {
  const fix: FailureLink = {
    label: "Check keyless auth",
    url: `${getApiUrl()}/console/${params.owner}#billing`,
  };
  return renderFailure({
    headline: `Your ${params.name} ${KEYLESS_UNUSABLE_MARKER}.`,
    detail: `No other ${params.name} credential is set up to fall back to. If the provider refused the exchange, check that its rule matches this repo's Pullfrog runs.`,
    details: { summary: "Why", text: params.reason },
    links:
      params.provider === "anthropic"
        ? [
            {
              label: "Open the authentication history",
              url: "https://platform.claude.com/settings/workload-identity-federation?tab=history",
            },
            fix,
          ]
        : [fix],
  });
}

/**
 * The provider that owns this env var. First match wins, which only matters for
 * `OPENCODE_API_KEY` — Zen and Go share it, and both rows name the same keys
 * page, so the order is not load-bearing for `keys`.
 */
function credentialProvider(credential: string) {
  for (const [id, provider] of Object.entries(providers)) {
    if (provider.envVars.includes(credential)) return { id, displayName: provider.displayName };
  }
  return undefined;
}

export type RefusedCredential = {
  name: string;
  source: CredentialCandidate["source"] | "workflow";
  verdict: Extract<ProbeVerdict, { status: "rejected" | "exhausted" }>;
};

/**
 * The credential pool probed every credential configured for the model and
 * none can serve it. One sentence per credential, from its kind and status —
 * never inferred from the prose — with the probe's own verdicts collapsed below.
 */
export function buildCredentialPoolRefusedError(params: {
  model: string;
  refused: RefusedCredential[];
  owner: string;
  name: string;
}): string {
  const [first = "", second, ...rest] = params.refused.map((credential) =>
    describeRefusal({ credential, owner: params.owner, name: params.name })
  );
  return renderFailure({
    headline: `${CREDENTIAL_POOL_REFUSED_MARKER}.`,
    detail:
      second === undefined ? first : `Every credential for \`${params.model}\` was turned down:`,
    list: second === undefined ? undefined : [first, second, ...rest],
    details: {
      summary: "The providers' responses",
      text: params.refused
        .map((credential) => `${credential.name}: ${credential.verdict.detail}`)
        .join("\n"),
    },
    links: [pickModelLink(params)],
  });
}

function describeRefusal(params: { credential: RefusedCredential; owner: string; name: string }) {
  const credential = params.credential;
  const verdict = credential.verdict;
  const subscription = SUBSCRIPTION_CREDENTIALS[credential.name];
  const dashboard = PROVIDER_DASHBOARDS[credentialProvider(credential.name)?.id ?? ""];
  const what = subscription ? `Your ${subscription.label}` : `Your \`${credential.name}\``;
  if (verdict.status === "exhausted" && subscription) {
    const reset = verdict.resetAt
      ? `, which resets at **${verdict.resetAt.toISOString().slice(0, 16).replace("T", " ")} UTC**`
      : "";
    return `${what} has hit its usage limit${reset}. Re-trigger after it resets, or add an API key for this provider, which Pullfrog falls back to automatically.`;
  }
  if (verdict.status === "exhausted") {
    return `${what} is out of credit or quota. ${dashboard ? `[Add credit](${dashboard.billing})` : "Add credit"} with the provider, or raise its quota.`;
  }
  if (subscription && isClaudeSubscriptionDisabledError(verdict.detail)) {
    return `${CLAUDE_ORG_DISABLED.headline} ${CLAUDE_ORG_DISABLED.detail}`;
  }
  if (subscription) return `${what} was rejected. ${SIGN_IN_AGAIN(subscription.command)}.`;
  const issue = dashboard?.keys ? `[Issue a new key](${dashboard.keys})` : "Issue a new key";
  const where =
    credential.source === "workflow"
      ? `in [this repo's GitHub Actions secrets](${secretsUrl(params)})`
      : `in [Pullfrog](${getApiUrl()}/console/${params.owner}#billing)`;
  return `${what} was rejected. ${issue} and update it ${where}.`;
}

/**
 * Friendly Markdown summary for both the missing-key and invalid-key cases.
 * Used in the catch / result-failure paths in `main.ts` to overwrite the raw
 * agent error before it's posted to the PR progress comment. `model` names the
 * subscription in play — guessing it from the env once told a GPT run its Claude
 * subscription was rejected — and `output`, the provider's own words, is
 * collapsed under the copy.
 */
export function formatApiKeyErrorSummary(params: {
  owner: string;
  name: string;
  raw: string;
  model?: string | undefined;
  credential?: string | undefined;
  output?: string | undefined;
}): string {
  if (params.raw.includes(MISSING_KEY_MARKER)) {
    // a verbatim validateAgentApiKey throw is already the full rendered body
    // (model-specific copy included) — pass it through. only rebuild the
    // generic copy when the marker is embedded in surrounding noise (e.g. a
    // hang body that swallowed the original message).
    if (params.raw.startsWith(`**${MISSING_KEY_MARKER}`)) return params.raw;
    return buildMissingApiKeyError({ owner: params.owner, name: params.name });
  }

  const details = params.output
    ? { summary: "The provider's response", text: params.output }
    : undefined;
  const subscription = subscriptionFor(params.model);

  // the subscription hit its usage cap. checked before every credential branch
  // below: nothing is wrong with the token, so a "rotate your key" CTA would
  // send the user to fix something that isn't broken. lead with the reset time
  // — it's the only thing that stops them re-triggering into the same wall.
  if (isClaudeSessionLimitError(params.raw)) {
    const reset = CLAUDE_SESSION_LIMIT_PATTERN.exec(params.raw)?.[1]?.trim();
    return renderFailure({
      headline: "Your Claude subscription has hit its usage limit.",
      detail: `${reset ? `It resets at **${reset}**. ` : ""}Re-trigger after that, or connect an Anthropic API key, which Pullfrog falls back to automatically.`,
      details,
      links: [providerLink(params.owner)],
    });
  }

  // the value is unsendable rather than wrong, so every "rotate the key" CTA
  // below would send the user to replace a key that is probably fine.
  if (isMalformedKeyError(params.raw)) {
    return renderFailure({
      headline: "Your stored API key can't be sent as an HTTP header.",
      detail:
        "It contains a line break or control character, usually from a key pasted across several lines. Re-save it as a single line; the key itself is likely fine.",
      links: [
        {
          label: "Pullfrog provider settings",
          url: `${getApiUrl()}/console/${params.owner}#billing`,
        },
        { label: "GitHub Actions secrets", url: secretsUrl(params) },
      ],
    });
  }

  // an org admin disabled Claude Code subscription access — re-authenticating
  // can't clear an entitlement flag, so name the two remedies the provider's
  // own message spells out.
  if (isClaudeSubscriptionDisabledError(params.raw)) {
    return renderFailure({
      ...CLAUDE_ORG_DISABLED,
      details,
      links: [providerLink(params.owner)],
    });
  }

  // the Kimi membership doesn't cover the model that was asked for. same 401 a
  // revoked key returns, so it has to be checked before every rotate-the-key
  // branch below — the key is fine and there is nothing to re-issue.
  if (isKimiPlanTierError(params.raw)) {
    return renderFailure({
      headline: "Your Kimi membership doesn't include the model this run asked for.",
      detail:
        "Kimi K3 needs a Moderato plan or above, and K3's 1M context and Kimi K2 HighSpeed need Allegretto or above. Upgrade the membership, or switch this repo to **Kimi K2**, which every tier includes.",
      details,
      links: [
        { label: "Kimi plans", url: "https://www.kimi.com/membership/pricing" },
        pickModelLink(params),
      ],
    });
  }

  // OAuth-connection credentials (Codex / provider OAuth) aren't repo
  // secrets — "rotate the key, update the GitHub secret" is wrong advice.
  if (isOAuthCredentialExpiredError(params.raw)) {
    return renderFailure({
      headline: subscription
        ? `Your ${subscription.label}'s sign-in has expired or been revoked.`
        : "Your provider sign-in has expired or been revoked.",
      detail: subscription
        ? `${SIGN_IN_AGAIN(subscription.command)}, then re-trigger.`
        : "Sign in again with `pullfrog auth claude`, `pullfrog auth codex` or `pullfrog auth grok` in a terminal, whichever this repo uses, then re-trigger.",
      details,
      links: [pickModelLink(params)],
    });
  }

  // the provider's wording can't tell a revoked subscription token from a
  // rotated API key — both read `Invalid bearer token` — so fall back to the
  // credential actually in this run before assuming there is a key to rotate.
  // only reachable when a credential dies mid-run: a dead one is deleted from
  // env by `checkConfiguredCredentials` before the agent starts.
  if (subscription && hasEnvVar(subscription.env) && params.credential !== "api_key") {
    return renderFailure({
      headline: `Your ${subscription.label} was rejected during this run.`,
      detail: `${SIGN_IN_AGAIN(subscription.command)}, then re-trigger.`,
      details,
      links: [pickModelLink(params)],
    });
  }

  // unlike `buildRejectedCredentialError`, which probed a named credential, the
  // mid-run path only has the provider's wording — so it can't know which store
  // the rejected key came from. naming one sent a customer whose only copy lives
  // in Pullfrog's store to an empty GitHub Actions settings page.
  return renderFailure({
    headline: `Your ${providerDisplayName(params.model?.split("/")[0]) ?? "model provider"} API key was rejected.`,
    detail:
      "Issue a new key with the provider, then update the copy Pullfrog uses, in Pullfrog or in this repo's GitHub Actions secrets.",
    details,
    links: [
      {
        label: "Pullfrog provider settings",
        url: `${getApiUrl()}/console/${params.owner}#billing`,
      },
      { label: "GitHub Actions secrets", url: secretsUrl(params) },
    ],
  });
}