Ctrl K
ComunidadGuías…

utils/runErrorRenderer.ts

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

Archivo original ↗Preguntar a KiBot
/**
 * Classify + render the error thrown out of the main run try-block into the
 * markdown the user reads — the PR progress comment, and the same body under a
 * `### ❌ Pullfrog failed` banner in the GitHub Actions job summary.
 *
 * Every body names its cause, so none collapses to a bare "Run failed.": an
 * error nothing below recognizes is shown as-is under "An unrecognized error
 * occurred." The shape is `renderFailure`'s, so the copy rules hold for all of
 * them — see `failureBody.ts`.
 *
 * Classifications, in dispatch order (first match wins):
 *
 *   1. Trial credits — a trial key that ran out mid-run is the account's free
 *      credits running out, never the Router wallet the same wire text means
 *      on a Router run.
 *
 *   1a. `BillingError` — the agent runtime surfaced an OpenRouter "key budget
 *      exhausted" string mid-run on a Router run. Same copy as a proxy-token 402.
 *
 *   1b. Pre-rendered bodies — a throw site that already built the markdown
 *      (`MODEL_ACCESS_MARKER`, `SECRETS_UNAVAILABLE_MARKER`,
 *      `ROUTER_UNFUNDED_MARKER`, `TRIAL_SPENT_MARKER`,
 *      `CREDENTIAL_REJECTED_MARKER`, `CREDENTIAL_POOL_REFUSED_MARKER`,
 *      `KEYLESS_UNUSABLE_MARKER`) passes
 *      through verbatim.
 *
 *   2. BYOK provider billing-exhausted (#835) — DeepSeek "Insufficient
 *      Balance", Anthropic "credit balance is too low", OpenCode Zen
 *      `CreditsError`, Gemini "spending cap", a ChatGPT workspace out of
 *      credits. Checked before api-key auth because billing-exhausted responses
 *      often carry 401 status codes that `isApiKeyAuthError` would otherwise
 *      mis-classify.
 *
 *   2a. Provider usage limit (#1474) — a capped subscription window or a
 *      configured workspace limit; "wait or raise it", never "top up".
 *
 *   3. API-key auth error — `isApiKeyAuthError` sniffs the raw error string
 *      (or the activity-timeout hang body when present, since that's where
 *      the underlying provider error often lands). On a proxy run the key is
 *      Pullfrog's, so the body names Pullfrog instead of asking for a rotation.
 *
 *   4. Model not in OpenCode's catalog (opencode ≥1.18 words it `Model not
 *      found: <id>`, #1470), a provider declining the model (#1077), Zen's
 *      free-tier gate (#1377), context overflow (#1116), the run's time limit,
 *      the repo's stop script, and an agent that never posted its review.
 *
 *   4c. Transient upstream failure (#1173) — Anthropic `529 Overloaded`,
 *      OpenRouter `provider_unavailable` / `timeout`, Zen `Streaming response
 *      failed: [5xx]`, Codex's model at capacity. Last of the classified
 *      branches so every more specific one wins first.
 *
 *   5. Activity-timeout hang — `formatAgentHangBody` says whose side went
 *      quiet, with the agent's last output collapsed under it.
 *
 *   6. Unclassified provider error (#1474) — `errorMessage` starts with
 *      `provider error: `: the provider's own text, shown under a lead line.
 *
 *   7. Default — "An unrecognized error occurred." over the error itself.
 */

import type { AgentCredential } from "../agents/shared.ts";
import type { AgentDiagnostic } from "./agentHangReport.ts";
import { formatAgentHangBody } from "./agentHangReport.ts";
import {
  buildTrialSpentError,
  CREDENTIAL_POOL_REFUSED_MARKER,
  CREDENTIAL_REJECTED_MARKER,
  formatApiKeyErrorSummary,
  isApiKeyAuthError,
  isByokSetupError,
  KEYLESS_UNUSABLE_MARKER,
  pickModelLink,
  providerDisplayName,
  providerLink,
  ROUTER_UNFUNDED_MARKER,
  repoConsoleUrl,
  SECRETS_UNAVAILABLE_MARKER,
  subscriptionFor,
  TRIAL_SPENT_MARKER,
} from "./apiKeys.ts";
import { BillingError, formatBillingErrorSummary } from "./billingErrors.ts";
import { DISCORD_LINK, renderFailure } from "./failureBody.ts";
import { MODEL_ACCESS_MARKER } from "./modelAccess.ts";
import { PROVIDER_DASHBOARDS } from "./providerDashboards.ts";
import {
  extractProviderId,
  findAnthropicSpendCap,
  isOpenRouterKeyLimitExceeded,
  isProviderBillingExhausted,
  isProviderMissingCredential,
  isProviderNoRoutableEndpoints,
  isProviderUsageLimit,
  isRouterKeylimitExhaustedError,
  isTransientUpstreamError,
} from "./providerErrors.ts";

export type RenderedRunError = {
  summary: string;
  comment: string;
};

type RenderRunErrorInput = {
  errorMessage: string;
  repo: { owner: string; name: string };
  agentDiagnostic: AgentDiagnostic | undefined;
  /** the run is spending Pullfrog's Router wallet (a proxy key was minted), not
   * the user's own provider credential. `payload.proxyModel` at both call
   * sites. */
  routerActive: boolean;
  /** the model that ran: it names the provider, and the subscription when one is in play. */
  model?: string | undefined;
  credential?: AgentCredential | undefined;
  /** the run was served by the account's free trial credits. */
  trial?: boolean | undefined;
  /** why opencode's live model catalog could not be downloaded, leaving it on its build-time snapshot. */
  modelsCatalogFailure?: string | undefined;
};

/** who answered the request, as the customer knows them. */
type Account = {
  /** "OpenAI", "Codex / ChatGPT", or null when nothing names the provider. */
  name: string | null;
  /** "ChatGPT subscription", "OpenAI account" or "model provider account". */
  phrase: string;
  /** the account's billing page, when we know it. */
  billingUrl: string | undefined;
  subscription: boolean;
};

type RenderContext = RenderRunErrorInput & { account: Account };

/** opencode <1.18 names the error class; ≥1.18 only publishes `Model not found: <id>` (#1470). */
function isProviderModelNotFoundError(message: string): boolean {
  return /ProviderModelNotFoundError|\bModel not found: /.test(message);
}

/**
 * Terminal context-window overflow (#1116). The run did real work — 5-12
 * minutes of it — and then died with no review, so the two levers the user has
 * (a larger-context model, or a smaller PR) are the whole message. Covers the
 * Claude CLI (`Prompt is too long`), the OpenRouter/opencode endpoint rejection
 * (`maximum context length is N tokens`), and opencode's post-compaction give-up.
 */
function isContextOverflowError(message: string): boolean {
  return (
    /Prompt is too long/i.test(message) ||
    /Input exceeds context window/i.test(message) ||
    /maximum context length is \d+ tokens/i.test(message) ||
    /Session too large to compact/i.test(message)
  );
}

/**
 * OpenCode Zen's own server-side refusal (its `zen.api.error.noProviderAvailable`
 * string), not a CLI or catalog fault: the model is real and listed, but the
 * account's plan cannot be routed to it. Distinct from
 * `ProviderModelNotFoundError`, where the id is gone from the catalog entirely.
 * Reached most often by auto-select landing on a Zen free-tier model for an
 * account with no Zen relationship. See #1077.
 */
function isNoProviderAvailableError(message: string): boolean {
  return /\bNo provider available\b/i.test(message);
}

/**
 * OpenCode Zen's free-tier request-shape gate (`FreeTierError`): since
 * 2026-09-17 Zen refuses a free-tier request whose tool list is missing any of
 * the built-in tools it expects — measured: `bash`, `glob`, `grep` and `read`
 * each trip it, while `list`, `edit`, `write`, `task`, `todowrite`, `skill`,
 * `webfetch`, `websearch` and `question` do not. The harness keeps `bash`
 * listed (blocked by the gate plugin) so the main turn passes, but opencode's
 * auto-compaction request carries no tools and trips the same gate mid-run
 * (anomalyco/opencode#49587), and a repo-root `opencode.json` denying one of
 * the four strips it from the first call. See wiki/models-catalog.md.
 */
function isZenFreeTierGateError(message: string): boolean {
  return /free tier can only be used from within OpenCode/i.test(message);
}

/**
 * The run's own time limit (`main.ts`), not a hang: the activity watchdog
 * kills a silent turn within minutes, so reaching the limit almost always
 * means the agent was still working. 393 runs across 88 accounts in 30 days
 * ended here with a bare `Run failed.`, while the fix is one flag.
 */
const RUN_TIMEOUT_PATTERN = /^agent run timed out after (\S+)/;

/** `postRun`'s gate: the repo's stop script still failed after the agent's retries. */
const STOP_SCRIPT_PATTERN = /^stop hook failed \(exit code (-?\d+)\): ([\s\S]*)$/;

/** `postRun`'s gate: a review-mode run ended without submitting its review. */
const UNSUBMITTED_REVIEW_PATTERN = /^\w+ mode finished without calling /;

/**
 * `extractProviderId` only fires when the harness emits `providerID=...`
 * (OpenCode log shape). Direct-provider errors (e.g. Anthropic SDK throwing
 * `"Your credit balance is too low to access the Anthropic API"`) carry no
 * such tag, so map their distinctive copy to a provider id here so the
 * dashboard link is reachable.
 *
 * Pattern is intentionally tight (Anthropic-specific phrasing only) to
 * avoid mis-tagging non-Anthropic billing-exhausted errors that happen to
 * mention `"Anthropic API"` in passing — the broader phrase appears in
 * fallback-chain agent prompt text and OpenCode harness logs.
 */
function detectProviderId(message: string): string | null {
  const harnessId = extractProviderId(message);
  if (harnessId) return harnessId;
  if (/credit balance is too low/i.test(message)) return "anthropic";
  // xAI's exhaustion arrives via the opencode `session.error` path, which
  // carries no `providerID=` tag. its team-scoped phrasing is distinctive
  // enough to map on its own. see #1076.
  if (/used all available credits/i.test(message)) return "xai";
  // same gap, and it is the ONLY path #1135 is about: `providerID=` is folded in
  // by `withServerStderr`, which runs only on a `session.create` failure, so a
  // mid-run credit exhaustion arrives as bare wire text. without this the BYOK
  // fall-through lands on the linkless generic copy and the openrouter entry in
  // PROVIDER_DASHBOARDS is dead. see #1135.
  if (/requires more credits|Key limit exceeded \(total limit\)|openrouter\.ai/i.test(message)) {
    return "openrouter";
  }
  if (/platform\.openai\.com/i.test(message)) return "openai";
  return null;
}

/**
 * The error's own provider when it names one, else the model's. A subscription
 * run is named by its plan — "Your ChatGPT subscription", with the plan's
 * billing page — because the API account's dashboard is the wrong place for it.
 */
function resolveAccount(input: RenderRunErrorInput): Account {
  const subscription =
    input.credential === "subscription" ? subscriptionFor(input.model) : undefined;
  if (subscription) {
    const plan = Object.values(PROVIDER_DASHBOARDS).find(
      (dashboard) => dashboard.credential === subscription.env
    );
    return {
      name: plan?.name ?? null,
      phrase: subscription.label,
      billingUrl: plan?.billing,
      subscription: true,
    };
  }
  const id = detectProviderId(input.errorMessage) ?? input.model?.split("/")[0] ?? null;
  const dashboard = id ? PROVIDER_DASHBOARDS[id] : undefined;
  const name = dashboard?.name ?? providerDisplayName(id) ?? null;
  return {
    name,
    phrase: name ? `${name} account` : "model provider account",
    billingUrl: dashboard?.billing,
    subscription: false,
  };
}

/** the provider's own words, collapsed under the copy. */
function providerResponse(ctx: RenderContext) {
  return {
    summary: ctx.account.name ? `${ctx.account.name}'s response` : "The provider's response",
    text: ctx.errorMessage,
  };
}

function formatProviderBillingExhausted(ctx: RenderContext): string {
  // a per-key ceiling is not an empty wallet, and topping up alone does not
  // clear it — the affordable figure drifts run to run while the wallet holds
  // credit. name both levers, key limit first. see #1164.
  if (isOpenRouterKeyLimitExceeded(ctx.errorMessage)) {
    const openrouter = PROVIDER_DASHBOARDS.openrouter;
    return renderFailure({
      headline: "Your OpenRouter key's spending limit is too low for this request.",
      detail:
        "OpenRouter checks the key's own limit as well as your balance, so raise or remove the limit; adding credit alone may not be enough.",
      details: providerResponse(ctx),
      links: openrouter?.keys
        ? [
            { label: "Raise the key's limit", url: openrouter.keys },
            { label: "Add credit", url: openrouter.billing },
          ]
        : [],
    });
  }

  // a Console-set spend cap is not an empty wallet — topping up clears nothing,
  // and the provider already told us when access comes back. see #1208.
  const spendCap = findAnthropicSpendCap(ctx.errorMessage);
  if (spendCap) {
    const regains = spendCap.regainAt ? `Access returns on **${spendCap.regainAt}**. ` : "";
    const anthropic = PROVIDER_DASHBOARDS.anthropic;
    return renderFailure({
      headline: "Your Anthropic account has reached its API spend limit.",
      detail: `${regains}This is a limit set in your Anthropic Console, not an empty balance; raise or remove it to run before then.`,
      details: providerResponse(ctx),
      links: anthropic ? [{ label: "Anthropic billing settings", url: anthropic.billing }] : [],
    });
  }

  return renderFailure({
    headline: `Your ${ctx.account.phrase} is out of credit.`,
    detail: "Top it up, then re-trigger Pullfrog.",
    details: providerResponse(ctx),
    links: ctx.account.billingUrl
      ? [{ label: `Top up ${ctx.account.name}`, url: ctx.account.billingUrl }]
      : [],
  });
}

/**
 * A capped usage window or a configured usage limit, not an empty wallet: the
 * billing copy's "top up" clears nothing. Codex/ChatGPT and Go plans reset on
 * their own; a Zen pay-as-you-go workspace limit is one the user set.
 */
function formatProviderUsageLimit(ctx: RenderContext): string {
  // the Codex CLI names the reset in the runner's own clock: `try again at 7:58 AM`.
  const tryAgainAt = /try again at (\d{1,2}:\d{2}\s*[AP]M)/i.exec(ctx.errorMessage)?.[1];
  // opencode's Go cap: `It will reset in 4 days.`
  const resetIn = /It will reset in ([^.]+)\./i.exec(ctx.errorMessage)?.[1];
  const said = tryAgainAt ? `to try again at ${tryAgainAt}` : resetIn && `it resets in ${resetIn}`;
  const resets = said ? `${ctx.account.name ?? "The provider"} says ${said}. ` : "";
  return renderFailure({
    headline: `Your ${ctx.account.phrase} has hit its usage limit.`,
    detail: ctx.account.subscription
      ? `${resets}It resets on its own, so re-trigger after that, or connect an API key, which Pullfrog falls back to automatically.`
      : `${resets}Raise the limit with the provider, or re-trigger once it resets.`,
    details: providerResponse(ctx),
    links: [providerLink(ctx.repo.owner), pickModelLink(ctx.repo)],
  });
}

/**
 * A transient upstream failure — the model provider 5xx'd, dropped the stream,
 * or has no capacity for the model. Nothing the user configured is wrong, so
 * the only useful thing to say is which provider, and that re-triggering is the
 * remedy.
 */
function formatTransientUpstreamBody(ctx: RenderContext): string {
  if (/\bmodel is at capacity\b/i.test(ctx.errorMessage)) {
    return renderFailure({
      headline: `${ctx.account.name ? `${ctx.account.name}'s model` : "The model"} is at capacity right now.`,
      detail: "Re-trigger in a few minutes, or pick another model.",
      details: providerResponse(ctx),
      links: [pickModelLink(ctx.repo)],
    });
  }
  return renderFailure({
    headline: `${ctx.account.name ?? "The model provider"} had a temporary failure.`,
    detail:
      "Nothing on your side is at fault. Re-trigger to try again; if it keeps happening, check the provider's status page.",
    details: providerResponse(ctx),
  });
}

/**
 * OpenRouter has nowhere it is permitted to route the picked model. Nothing is
 * billed and no key is at fault, so neither the billing nor the api-key copy
 * fits — both would send the user to fix something that is not broken.
 */
function formatProviderNoRoutableEndpoints(ctx: RenderContext): string {
  return renderFailure({
    headline: "OpenRouter can't route this model anywhere your account allows.",
    detail:
      "Your OpenRouter data policy excludes every provider serving it, so nothing ran and nothing was billed. Allow more providers, or pick a model your policy covers.",
    details: providerResponse(ctx),
    links: [
      { label: "OpenRouter data policy", url: "https://openrouter.ai/settings/privacy" },
      pickModelLink(ctx.repo),
    ],
  });
}

function formatProviderMissingCredential(ctx: RenderContext): string {
  return renderFailure({
    headline: "No credential reached the model provider.",
    detail:
      "The run used a provider it had no key for, so nothing was billed and no key of yours was rejected. Connect a key for that provider, or pick a model you have one for.",
    details: providerResponse(ctx),
    links: [providerLink(ctx.repo.owner), pickModelLink(ctx.repo)],
  });
}

function formatProviderModelNotFound(ctx: RenderContext): string {
  const model = /Model not found: (\S+?)\.?(?:\s|$)/.exec(ctx.errorMessage)?.[1];
  const suggestions = /Did you mean: ([^?\n]+)\?/
    .exec(ctx.errorMessage)?.[1]
    ?.split(",")
    .map((id) => `\`${id.trim()}\``);
  const suggested = suggestions?.length
    ? ` OpenCode suggests ${suggestions.slice(0, -1).join(", ")}${suggestions.length > 1 ? " or " : ""}${suggestions.at(-1)}.`
    : "";
  if (ctx.modelsCatalogFailure) {
    return renderFailure({
      headline: model
        ? `OpenCode couldn't download its model list, so it didn't recognize \`${model}\`.`
        : "OpenCode couldn't download its model list, so it didn't recognize this repo's model.",
      detail: `It fell back to the copy built into it, which lacks models released since that build. Re-run once the runner can reach OpenCode's model catalog, or pick an older model.${suggested}`,
      details: {
        summary: "Why the download failed",
        text: `${ctx.modelsCatalogFailure}\n\n${ctx.errorMessage}`,
      },
      links: [pickModelLink(ctx.repo)],
    });
  }
  return renderFailure({
    headline: model
      ? `\`${model}\` isn't in OpenCode's catalog.`
      : "This repo's model isn't in OpenCode's catalog.",
    detail: `Pick another model wherever this repo sets it: the Pullfrog console, the workflow's \`model\` input or the \`PULLFROG_MODEL\` variable.${suggested}`,
    details: { summary: "OpenCode's response", text: ctx.errorMessage },
    links: [pickModelLink(ctx.repo)],
  });
}

function formatNoProviderAvailable(ctx: RenderContext): string {
  return renderFailure({
    headline: "The provider won't serve this model to this account.",
    detail:
      "It's in the catalog, but this account's plan can't reach it, which often happens when Pullfrog auto-selects it. Pin a model for this repo, or connect the provider that was picked.",
    details: providerResponse(ctx),
    links: [pickModelLink(ctx.repo), providerLink(ctx.repo.owner)],
  });
}

function formatZenFreeTierGate(ctx: RenderContext): string {
  return renderFailure({
    headline: "OpenCode Zen's free tier refused a request from this run.",
    detail:
      "Zen rejects free-model requests that don't list its built-in tools, and OpenCode's mid-run context compaction sends none ([upstream issue](https://github.com/anomalyco/opencode/issues/49587)). Pick a paid model, or split the PR so the run finishes before compaction. If this repo's `opencode.json` denies `bash`, `glob`, `grep` or `read`, remove that too.",
    details: { summary: "OpenCode Zen's response", text: ctx.errorMessage },
    links: [pickModelLink(ctx.repo)],
  });
}

/** the unclassified fallback, shown as-is: the error is the only explanation there is. */
function formatUnrecognized(ctx: RenderContext): string {
  return renderFailure({
    headline: "An unrecognized error occurred.",
    detail: "The error is below.",
    error: ctx.errorMessage,
    links: [DISCORD_LINK],
  });
}

function renderRunErrorBody(ctx: RenderContext): string {
  const message = ctx.errorMessage;

  // a trial key that ran out mid-run IS the account's trial credits running
  // out — the mint is gated on credits left and the key's limit is the whole
  // trial — so the Router copy below, about a wallet the account never had,
  // would be false. ahead of it for that reason.
  if (ctx.trial && isRouterKeylimitExhaustedError(message)) {
    return buildTrialSpentError({ owner: ctx.repo.owner });
  }

  // reclassify mid-run OpenRouter "key budget exhausted" as BillingError so
  // the user gets the same actionable copy as a /api/proxy-token 402.
  //
  // gated on `routerActive`: the identical wire text is emitted when a BYOK
  // customer's OWN OpenRouter wallet runs dry, and sending them to top up a
  // Pullfrog Router balance they don't use is advice that cannot work. on a BYOK
  // run this falls through to the provider-billing branch below, which now
  // matches the same shapes and links openrouter.ai instead. see #1135.
  if (ctx.routerActive && isRouterKeylimitExhaustedError(message)) {
    return formatBillingErrorSummary(
      new BillingError(message, { code: "router_keylimit_exhausted" }),
      ctx.repo.owner
    );
  }

  // a throw site that already rendered its body (model access, stored secrets
  // unavailable, unfunded Router, spent trial, a credential rejected before the
  // agent started, the credential pool's refusal). ahead of the api-key branch,
  // which rebuilds any body carrying provider wording into the generic "rotate
  // your key" copy — and the rejection bodies QUOTE the provider's wording.
  if (
    [
      MODEL_ACCESS_MARKER,
      SECRETS_UNAVAILABLE_MARKER,
      ROUTER_UNFUNDED_MARKER,
      TRIAL_SPENT_MARKER,
      CREDENTIAL_REJECTED_MARKER,
      CREDENTIAL_POOL_REFUSED_MARKER,
      KEYLESS_UNUSABLE_MARKER,
    ].some((marker) => message.includes(marker))
  ) {
    return message;
  }

  // gated on isHang because the harness sets `agentDiagnostic` on entry, so
  // any non-hang throw that hits the outer catch (e.g. post-success
  // output_schema validator, or a late cleanup throw after the run already
  // succeeded) would otherwise render a stall with a stale diagnostic and
  // silently drop the real errorMessage.
  const isHang =
    message.startsWith("activity timeout") || message.startsWith("agent still pending");
  const hangBody = isHang
    ? formatAgentHangBody({ diagnostic: ctx.agentDiagnostic, errorMessage: message })
    : null;

  // BYOK provider billing-exhausted. distinct from the Router billing branch
  // above — Router uses `BillingError`, this uses the agent log payload
  // classified by `isProviderBillingExhausted`. see #835.
  //
  // checked BEFORE api-key auth: providers commonly return 401 (DeepSeek,
  // Gemini) or include `"API Error: 401"` in the error body for billing
  // exhaustion, which `isApiKeyAuthError` would otherwise match — surfacing
  // a "rotate your key" CTA when the actual fix is "top up credits".
  if (isProviderBillingExhausted(message)) return formatProviderBillingExhausted(ctx);

  // routable-endpoint refusal, checked alongside the billing branch for the
  // same reason: it is user-fixable provider configuration.
  if (isProviderNoRoutableEndpoints(message)) return formatProviderNoRoutableEndpoints(ctx);

  // a usage window, not a wallet or a key — ahead of the api-key branch for the
  // same reason the billing branch is.
  if (isProviderUsageLimit(message)) return formatProviderUsageLimit(ctx);

  // an ABSENT credential, not a rejected one — the remedies share nothing.
  // checked ahead of the api-key branch so a later widening there cannot start
  // answering "rotate your key" to a run that never held one.
  if (isProviderMissingCredential(message)) return formatProviderMissingCredential(ctx);

  // both BYOK setup layers — `resolveSlug`'s routing-slug refusal and the
  // per-provider `validate*Setup` checks — already name what is missing, the
  // remedy and the setup guide.
  if (isByokSetupError(message)) {
    return renderFailure({
      headline: "This repo's model isn't fully set up yet.",
      detail: message,
    });
  }

  const apiKeySource = hangBody ?? message;
  // a proxy run's key is one Pullfrog minted, so the customer has nothing to rotate.
  if (ctx.routerActive && isApiKeyAuthError(apiKeySource)) {
    return renderFailure({
      headline: "Pullfrog's model credential for this run was refused.",
      detail:
        "This is on Pullfrog's side, not your key or balance. Re-trigger the run to try again.",
      details: providerResponse(ctx),
      links: [DISCORD_LINK],
    });
  }
  if (isApiKeyAuthError(apiKeySource)) {
    return formatApiKeyErrorSummary({
      owner: ctx.repo.owner,
      name: ctx.repo.name,
      raw: apiKeySource,
      model: ctx.model,
      credential: ctx.credential,
      output: hangBody ? undefined : message,
    });
  }

  if (isProviderModelNotFoundError(message)) return formatProviderModelNotFound(ctx);
  if (isNoProviderAvailableError(message)) return formatNoProviderAvailable(ctx);
  if (isZenFreeTierGateError(message)) return formatZenFreeTierGate(ctx);

  if (isContextOverflowError(message)) {
    return renderFailure({
      headline: "This run outgrew the model's context window.",
      detail:
        "Pick a model with a larger context window, or split the PR into smaller ones and re-trigger.",
      links: [pickModelLink(ctx.repo)],
    });
  }

  const timeout = RUN_TIMEOUT_PATTERN.exec(message)?.[1];
  if (timeout) {
    return renderFailure({
      headline: `This run hit its ${timeout} time limit while still working.`,
      detail:
        "Give runs longer with `--timeout=2h` (or `--notimeout`) in this repo's instructions, or raise the workflow's `timeout` input if it sets one, since that input wins. A smaller PR or a lower effort also finishes sooner.",
      links: [
        { label: "Repo settings", url: repoConsoleUrl(ctx.repo) },
        { label: "Flags", url: "https://docs.pullfrog.com/flags" },
      ],
    });
  }

  const stopScript = STOP_SCRIPT_PATTERN.exec(message);
  if (stopScript) {
    return renderFailure({
      headline: "This repo's stop script failed, so the run ended without finishing.",
      detail: `It exited with code ${stopScript[1]}. Fix the script in this repo's settings, or re-trigger if the failure was temporary.`,
      details: stopScript[2]?.trim()
        ? { summary: "Script output", text: stopScript[2].trim() }
        : undefined,
      links: [{ label: "Repo settings", url: repoConsoleUrl(ctx.repo) }],
    });
  }

  if (UNSUBMITTED_REVIEW_PATTERN.test(message)) {
    return renderFailure({
      headline: "The agent finished without posting its review.",
      detail: "It was reminded and still didn't, so the run stopped. Re-trigger to try again.",
    });
  }

  // an upstream blip is not ours, but the silence about it was: the provider
  // handed us accurate, actionable copy and the comment said `Run failed.`
  // while up to 38 tool calls of finished review work went in the bin (#1173).
  // last of the classified branches, so every specific one still wins.
  if (isTransientUpstreamError(message)) return formatTransientUpstreamBody(ctx);

  if (hangBody) return hangBody;

  // after every specific classifier, so it only catches the wording none of them
  // knows yet. the provider's text is the explanation, so it is shown open.
  if (message.startsWith("provider error: ")) {
    return renderFailure({
      headline: `${ctx.account.name ?? "The model provider"} returned an error, so this run stopped.`,
      detail:
        "If it names your key, plan or balance, fix that with the provider or pick another model.",
      error: message.slice("provider error: ".length),
      links: [pickModelLink(ctx.repo)],
    });
  }

  return formatUnrecognized(ctx);
}

export function renderRunError(input: RenderRunErrorInput): RenderedRunError {
  const body = renderRunErrorBody({ ...input, account: resolveAccount(input) });
  return { summary: `### ❌ Pullfrog failed\n\n${body}`, comment: body };
}