Ctrl K
ComunidadGuías…

models.ts

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

Archivo original ↗Preguntar a KiBot
/**
 * model alias registry.
 *
 * slugs use the format `provider/model-id` (e.g. "anthropic/claude-opus").
 * bump `resolve` when a new model generation ships — the alias (slug) stays stable.
 */

import { type EffortPosition, resolveRung } from "./effort.ts";

// ── types ──────────────────────────────────────────────────────────────────────

/**
 * routing discriminant for entries whose `resolve` is dynamic — looked up
 * from a separate env var at run time rather than fixed in the catalog.
 *
 * `"bedrock"` means the actual model ID comes from `BEDROCK_MODEL_ID`
 * (an AWS-canonical Bedrock model ID like `us.anthropic.claude-opus-4-7`
 * or `amazon.nova-pro-v1:0`). `"vertex"` means the actual model ID comes
 * from `VERTEX_MODEL_ID` (a Vertex AI model ID like
 * `claude-opus-4-1@20250805` or `gemini-2.5-pro`). enterprise hosted-model
 * customers self-select for version control — silent alias bumps would break
 * compliance review, model-access enrollment, and provisioned-throughput
 * contracts. so the single `bedrock/byok` and `vertex/byok` entries are
 * routing slugs, not model aliases: the harness reads the backend-specific
 * env var and routes to claude-code for Anthropic IDs or opencode for
 * everything else.
 *
 * `"azure"` is the same shape for a different reason. models.dev DOES carry an
 * Azure catalog, but the id it lists is sent to Azure as the DEPLOYMENT NAME,
 * and a deployment is named by whoever created it — so cataloging `azure/gpt-5`
 * would only work for customers who kept the portal's default name. the
 * deployment comes from `AZURE_DEPLOYMENT` instead. unlike bedrock/vertex it
 * resolves to a provider-PREFIXED specifier (`azure/<deployment>`), matching
 * openai-compatible, because `azure` is a real opencode provider id.
 */
export type ModelRouting = "bedrock" | "vertex" | "openai-compatible" | "azure";

export interface ModelAlias {
  /** stable alias stored in DB, e.g. "anthropic/claude-opus" */
  slug: string;
  /** provider key (matches providers keys) */
  provider: string;
  /** human-readable name shown in dropdowns */
  displayName: string;
  /** optional one-line picker sub-label clarifying what the alias is (e.g.
   * gpt-pro = "Maximum reasoning effort"). undefined for most aliases. */
  description: string | undefined;
  /** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6". sentinel for routing entries — never passed to a CLI directly. */
  resolve: string;
  /** full models.dev specifier for the OpenRouter equivalent (undefined for free models and routing entries) */
  openRouterResolve: string | undefined;
  /** top-tier pick for this provider — preferred during auto-select */
  preferred: boolean;
  /** whether this alias costs nothing to run. NOT the same as needing no
   * credential: Zen's free models still require `OPENCODE_API_KEY`, and reading
   * this as "keyless" is what let a keyless run boot onto one and die with
   * `No provider available` (#1077). used to skip the proxy mint. */
  isFree: boolean;
  /** slug of a replacement model to resolve through. presence means this alias
   * never runs as-is — resolution redirects to the replacement and it's hidden
   * from pickers. covers permanent deprecation AND riding out a temporarily
   * unavailable model: point it at a cheaper tier (downgrade) or a working
   * sibling/higher tier (upgrade), then clear it when the model returns. */
  fallback: string | undefined;
  /** dynamic-resolution discriminant — see ModelRouting docs */
  routing: ModelRouting | undefined;
  /** alias key (within same provider) of the cheaper sibling reviewfrog should
   * use as its lens-fanout subagent. e.g. claude-opus → "claude-sonnet". */
  subagentModel: string | undefined;
  /** reasoning-effort rungs this model accepts on the direct route, ascending.
   * mirrors models.dev `reasoning_options[type=effort].values`. undefined means
   * the model has no effort control and the setting is a documented no-op. */
  effort: readonly string[] | undefined;
  /** effort rungs on the OpenRouter route, when they differ from `effort` (the
   * DeepSeek/GLM top rung is `max` natively but `xhigh` via OpenRouter).
   * undefined falls back to `effort`; `[]` says the OpenRouter route publishes
   * no rungs at all, which undefined cannot express. */
  openRouterEffort: readonly string[] | undefined;
  /** hide from selectable lists (UI dropdowns, CLI pickers). does NOT affect
   * resolution — for that use `fallback`. used to keep a redundant alias out of
   * pickers (e.g. the free `minimax-m2.5-free` duplicate). */
  hidden: boolean;
}

interface ModelDef {
  displayName: string;
  /** optional one-line picker sub-label (e.g. "Maximum reasoning effort"). */
  description?: string;
  /** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */
  resolve: string;
  /** full models.dev specifier for the OpenRouter equivalent, e.g. "openrouter/anthropic/claude-opus-4.6" */
  openRouterResolve?: string | undefined;
  preferred?: boolean;
  envVars?: readonly string[];
  isFree?: boolean;
  /** slug of a replacement model to resolve through — permanent deprecation or
   * temporary unavailability (downgrade/upgrade until the model returns). see
   * ModelAlias.fallback. */
  fallback?: string;
  /** dynamic-resolution discriminant — see ModelRouting docs */
  routing?: ModelRouting;
  /** alias key (within same provider) of the cheaper sibling reviewfrog should
   * use as its lens-fanout subagent (e.g. claude-opus → "claude-sonnet"). */
  subagentModel?: string;
  /** effort rungs accepted on the direct route, ascending — see ModelAlias.effort */
  effort?: readonly string[] | undefined;
  /** effort rungs on the OpenRouter route when they differ — see ModelAlias.openRouterEffort */
  openRouterEffort?: readonly string[] | undefined;
  /** hide from selectable lists. does NOT affect resolution; for that use `fallback`. */
  hidden?: boolean;
  /** `resolve` and `openRouterResolve` are chosen by hand (a rolling `~…-latest` pointer, a `-pro`
   * id models.dev lacks, a dated or held-back build), so the server never looks them up by
   * models.dev's canonical id. see wiki/model-resolution.md. */
  pinned?: boolean;
}

export interface ProviderConfig {
  displayName: string;
  envVars: readonly string[];
  /** credentials authored only via `pullfrog auth <provider>` — never
   * user-facing in `init`, never documented as a manual GHA secret. counted
   * for hasAnyKey / log-redaction purposes but excluded from any prompt /
   * paste flow. CLI-managed magic. see wiki/codex-auth.md. */
  managedCredentials?: readonly string[];
  models: Record<string, ModelDef>;
}

// ── provider + model definitions ────────────────────────────────────────────────

function provider(config: ProviderConfig): ProviderConfig {
  return config;
}

/** this build's own registry. `providers` starts as this copy and `installProviders` replaces it. */
export const bundledProviders = {
  anthropic: provider({
    displayName: "Anthropic",
    envVars: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
    models: {
      // claude-fable-5 is selectable but not recommended/auto-selected — it's
      // moving to usage-credits-only billing and isn't broadly available yet, so
      // opus stays the universally-available flagship, the AUTO_INTELLIGENT tier
      // target, and the recommended pick. an explicit fable pick hits the real
      // API and is access-gated: it errors for accounts without access today and
      // just works once usage credits are live — no silent opus fallback, the
      // API is the source of truth (#959).
      "claude-fable": {
        displayName: "Claude Fable",
        resolve: "anthropic/claude-fable-5-2",
        effort: ["low", "medium", "high", "xhigh", "max"],
        // rolling alias: models.dev's OpenRouter mirror lags brand-new pinned
        // versions (claude-fable-5 was missing there when this was added), so track ~…-latest to
        // stay catalog-valid and auto-follow version bumps.
        openRouterResolve: "openrouter/~anthropic/claude-fable-latest",
        subagentModel: "claude-sonnet",
        pinned: true,
      },
      "claude-opus": {
        displayName: "Claude Opus",
        resolve: "anthropic/claude-opus-5-5",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/anthropic/claude-opus-5.5",
        preferred: true,
        subagentModel: "claude-sonnet",
      },
      "claude-sonnet": {
        displayName: "Claude Sonnet",
        resolve: "anthropic/claude-sonnet-5-5",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/anthropic/claude-sonnet-5.5",
      },
      "claude-haiku": {
        displayName: "Claude Haiku",
        resolve: "anthropic/claude-haiku-5-5",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterEffort: ["none", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/anthropic/claude-haiku-5.5",
      },
    },
  }),
  openai: provider({
    displayName: "OpenAI",
    envVars: ["OPENAI_API_KEY"],
    managedCredentials: ["CODEX_AUTH_JSON"],
    models: {
      // Sol/Terra/Luna are OpenAI's durable capability tiers, so they ARE the
      // brand-tier names the slug convention asks for — the pre-5.6 `gpt` /
      // `gpt-pro` / `gpt-mini` slugs are holdovers from the retired GPT / GPT Pro
      // / GPT Mini tiering and are carried below as deprecated aliases.
      "gpt-astra": {
        displayName: "GPT Astra",
        resolve: "openai/gpt-6-astra",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6-astra",
        subagentModel: "gpt-sol",
      },
      "gpt-sol": {
        displayName: "GPT Sol",
        resolve: "openai/gpt-6.1-sol",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6.1-sol",
        preferred: true,
        subagentModel: "gpt-terra",
      },
      // Sol served at reasoning.mode=pro — same $/token as Sol, just more tokens
      // burned; not a pricier premium tier. models.dev has no -pro id, so direct-key
      // (BYOK) resolves to plain Sol; only the Router/OpenRouter path gets sol-pro.
      "gpt-sol-pro": {
        displayName: "GPT Sol Pro",
        description: "Maximum reasoning effort",
        resolve: "openai/gpt-6.1-sol",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6.1-sol-pro",
        subagentModel: "gpt-sol",
        pinned: true,
      },
      // gpt-5.6's balanced mid-tier. selectable on its own and doubles as Sol's
      // cheaper lens-fanout subagent — replaces the old hidden gpt-5.4 target.
      "gpt-terra": {
        displayName: "GPT Terra",
        resolve: "openai/gpt-6.1-terra",
        effort: ["none", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6.1-terra",
      },
      "gpt-luna": {
        displayName: "GPT Luna",
        resolve: "openai/gpt-6-luna",
        effort: ["none", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6-luna",
      },
      // legacy aliases — openai unified the codex line into the main GPT family
      // and is shutting down every "-codex" snapshot on 2026-07-23. transparently
      // upgrade existing users via the fallback chain. UI display sites resolve
      // to the terminal alias's label (so dropdown trigger + PR footers show
      // "GPT Sol" / "GPT Luna", not the historical name).
      "gpt-codex": {
        displayName: "GPT Codex",
        resolve: "openai/gpt-5.3-codex",
        openRouterResolve: "openrouter/openai/gpt-5.3-codex",
        fallback: "openai/gpt-sol",
      },
      "gpt-codex-mini": {
        displayName: "GPT Codex Mini",
        resolve: "openai/gpt-5.1-codex-mini",
        openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
        fallback: "openai/gpt-luna",
      },
      // pre-5.6 tier slugs. the tiers themselves were renamed Sol/Sol Pro/Luna,
      // so these fold forward rather than describing anything that still runs.
      gpt: {
        displayName: "GPT",
        resolve: "openai/gpt-5.6-sol",
        openRouterResolve: "openrouter/openai/gpt-5.6-sol",
        fallback: "openai/gpt-sol",
      },
      "gpt-pro": {
        displayName: "GPT Pro",
        resolve: "openai/gpt-5.6-sol",
        openRouterResolve: "openrouter/openai/gpt-5.6-sol-pro",
        fallback: "openai/gpt-sol-pro",
      },
      "gpt-mini": {
        displayName: "GPT Mini",
        resolve: "openai/gpt-5.6-luna",
        openRouterResolve: "openrouter/openai/gpt-5.6-luna",
        fallback: "openai/gpt-luna",
      },
      // dropped hidden subagent tier — folds stored pins forward to Sol.
      "gpt-5.4": {
        displayName: "GPT 5.4",
        resolve: "openai/gpt-5.4",
        openRouterResolve: "openrouter/openai/gpt-5.4",
        fallback: "openai/gpt-sol",
      },
      o3: {
        displayName: "O3",
        resolve: "openai/o3",
        effort: ["low", "medium", "high"],
        openRouterResolve: "openrouter/openai/o3",
        // OpenRouter publishes a reasoning TOGGLE for o3 where direct OpenAI
        // publishes a ladder, so the route genuinely has no rungs. `[]` rather
        // than omitting the field: an absent openRouterEffort falls through to
        // `effort` and would send a rung this route rejects.
        openRouterEffort: [],
      },
    },
  }),
  google: provider({
    displayName: "Google",
    envVars: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
    models: {
      "gemini-pro": {
        displayName: "Gemini Pro",
        resolve: "google/gemini-4-argon",
        effort: ["low", "medium", "high"],
        openRouterResolve: "openrouter/google/gemini-4-argon",
        preferred: true,
        // Inherit (subagents stay on Pro). Google has no in-between tier;
        // dropping to Flash for review work was a meaningful capability cliff
        // (Flash missed the catastrophic camelCase/snake_case mismatch in
        // the v4 e2e test). Pro is cost-effective enough to use for both
        // orchestrator and lenses.
      },
      // an additional tier beside Argon, not its successor: Google keeps Argon as
      // the default for most work and positions Carbon for the hardest tasks.
      "gemini-carbon": {
        displayName: "Gemini Carbon",
        resolve: "google/gemini-4-carbon",
        effort: ["low", "medium", "high"],
        openRouterResolve: "openrouter/google/gemini-4-carbon",
        subagentModel: "gemini-pro",
      },
      "gemini-flash": {
        displayName: "Gemini Flash",
        resolve: "google/gemini-3.8-flash",
        effort: ["low", "medium", "high"],
        openRouterResolve: "openrouter/google/gemini-3.8-flash",
      },
    },
  }),
  xai: provider({
    displayName: "xAI",
    envVars: ["XAI_API_KEY"],
    // CLI-only, like CODEX_AUTH_JSON: a Grok subscription chain minted by
    // `pullfrog auth grok`. Excluded from every paste/prompt surface because
    // the refresh token rotates on every use, so a hand-pasted blob is stale
    // the moment it is saved. See wiki/grok-auth.md.
    managedCredentials: ["GROK_AUTH_JSON"],
    models: {
      grok: {
        displayName: "Grok",
        resolve: "xai/grok-4.7",
        effort: ["low", "medium", "high", "xhigh"],
        openRouterResolve: "openrouter/x-ai/grok-4.7",
        preferred: true,
      },
      // the Smart ladder's cheap rung: 1.25/2.5 against 4.6's 2/6, and a 0.2
      // cache read against 0.5 — the only live xAI model a rung can save on.
      "grok-4.3": {
        displayName: "Grok 4.3",
        resolve: "xai/grok-4.3",
        effort: ["none", "low", "medium", "high", "xhigh"],
        openRouterResolve: "openrouter/x-ai/grok-4.3",
        openRouterEffort: ["none", "low", "medium", "high"],
      },
      // legacy aliases — xAI retired the entire fast/code-fast line on
      // 2026-05-15 (https://docs.x.ai/developers/migration/may-15-deprecation)
      // and now redirects every deprecated text-model slug to grok-4.3 at
      // standard pricing. fall back to the live `xai/grok` so the alias
      // chain resolves to grok-4.7 for both direct-key and OpenRouter users.
      "grok-fast": {
        displayName: "Grok Fast",
        resolve: "xai/grok-4-1-fast",
        openRouterResolve: "openrouter/x-ai/grok-4.7",
        fallback: "xai/grok",
      },
      "grok-code-fast": {
        displayName: "Grok Code Fast",
        resolve: "xai/grok-code-fast-1",
        openRouterResolve: "openrouter/x-ai/grok-4.7",
        fallback: "xai/grok",
      },
    },
  }),
  deepseek: provider({
    displayName: "DeepSeek",
    envVars: ["DEEPSEEK_API_KEY"],
    models: {
      // same fork trap as `deepseek-flash` below, and Pro fell into it on
      // 2026-08-12: OpenRouter forked the 0813 release into its own id and left
      // the April preview live under the unversioned one at 2.7x the input and
      // 27x the CACHE-READ rate ($1.168/$2.336/$0.09855 vs
      // $0.435/$0.87/$0.003625). the direct route upgraded in place, so only the
      // OpenRouter side was stale — and that side is what OSS and Router runs
      // use. measured: $0.1719/run authoritative against the funded default's
      // $0.0405. there is no `~deepseek/*-pro-latest` pointer to track, so this
      // one has to be a dated pin; the `models-catalog` drift test is what
      // catches the next release.
      // from 2026-09-14 DeepSeek serves every direct `deepseek-v4-pro` call with
      // V4.1-Flash at Flash rates until V4.1-Pro ships. it is still the only Pro
      // id the vendor lists, and the OpenRouter 0813 pin is third-party-hosted
      // and unaffected — so no `fallback` to Flash, which would drag the Router
      // route down with it. the OSS program dropped Pro from its menu instead;
      // see `OSS_MODEL_ALLOWLIST`.
      "deepseek-pro": {
        displayName: "DeepSeek Pro",
        resolve: "deepseek/deepseek-v4-pro",
        // models.dev gave the direct id V4.1-Flash's ladder on 2026-09-21, which
        // is what DeepSeek now serves under it — and the same as the Router pin's.
        effort: ["low", "high", "max"],
        openRouterEffort: ["none", "low", "high", "max"],
        openRouterResolve: "openrouter/deepseek/deepseek-v4-pro-0813",
        preferred: true,
      },
      // V4.1-Flash (2026-09-10) retired `deepseek-v4-flash`; DeepSeek's /models
      // now lists the unversioned `deepseek-flash`, upgraded in place (1017 on
      // 2026-10-17). OpenRouter forks every snapshot into its own dated id, so the
      // OpenRouter target is NOT pinned: the served registry follows models.dev's
      // canonical id for `deepseek-flash` onto the matching OpenRouter id. this
      // target is the funded OSS default — see wiki/oss-model-allowlist.md. the
      // OSS effort floor pins `high` by name.
      "deepseek-flash": {
        displayName: "DeepSeek Flash",
        resolve: "deepseek/deepseek-flash",
        effort: ["low", "high", "max"],
        openRouterEffort: ["none", "low", "high", "max"],
        openRouterResolve: "openrouter/deepseek/deepseek-v4.1-flash-1017",
      },
      // legacy aliases — deepseek retires these on 2026-07-24; transparently
      // upgrade existing users to the v4 family via the fallback chain.
      "deepseek-reasoner": {
        displayName: "DeepSeek Reasoner",
        resolve: "deepseek/deepseek-reasoner",
        openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
        fallback: "deepseek/deepseek-pro",
      },
      "deepseek-chat": {
        displayName: "DeepSeek Chat",
        resolve: "deepseek/deepseek-chat",
        openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
        fallback: "deepseek/deepseek-flash",
      },
    },
  }),
  moonshotai: provider({
    displayName: "Moonshot AI",
    envVars: ["MOONSHOT_API_KEY"],
    models: {
      // Moonshot's premium 1M-context multimodal reasoning flagship — pricier
      // than K2.7-code, so it's the preferred BYOK pick but NOT the subsidized
      // efficient default (that stays K2 via AUTO_EFFICIENT). the slug tracks the
      // K3 line: K3.1 Pro is its post-trained successor, at $2/$10 against K3's $3/$15.
      "kimi-k3": {
        displayName: "Kimi K3",
        resolve: "moonshotai/kimi-k3.1-pro",
        effort: ["low", "high", "max"],
        openRouterEffort: ["none", "low", "high", "max"],
        openRouterResolve: "openrouter/moonshotai/kimi-k3.1-pro",
        preferred: true,
        subagentModel: "kimi-k2",
      },
      "kimi-k2": {
        displayName: "Kimi K2",
        resolve: "moonshotai/kimi-k2.7-code",
        openRouterResolve: "openrouter/moonshotai/kimi-k2.7-code",
      },
    },
  }),
  // the same models as `moonshotai` above, billed against a Kimi membership
  // instead of a Moonshot API balance — a separate endpoint, separate model
  // ids and a separate key, which is why it is a provider rather than a second
  // `envVars` entry. no provider injection is needed: models.dev carries the
  // provider (api `https://api.kimi.com/coding/v1`, npm
  // `@ai-sdk/openai-compatible`) and opencode's env loop enables it from a bare
  // `KIMI_API_KEY`, verified against our pinned opencode-ai@1.18.5 — all four
  // ids list in `opencode models` and a run reaches Kimi's own auth check.
  //
  // models.dev renamed that provider on 2026-09-18: `kimi-for-coding` became
  // `kimi-code-plan-cn` (api.kimi.com — where the console mints the key) and
  // `kimi-code-plan-global` (api.kimi.ai). opencode resolves providers from
  // its models.dev cache, so a `resolve` still spelling the old key names a
  // provider it does not know. the `resolve` targets track models.dev's key;
  // the provider KEY below is Pullfrog's slug and stays put. see
  // wiki/kimi-code.md.
  //
  // NO `openRouterResolve` ON PURPOSE. OpenRouter cannot serve a Kimi
  // membership, so a Router path here would silently bill the wallet for
  // Moonshot pay-as-you-go under a slug picked for the opposite reason. The
  // absence is load-bearing: `isCardGatedModel` reads it as "not Router-
  // resolvable", `decideModelAccess` answers an explicit pick with the
  // `router` copy ("add your own provider key"), and run-context clamps a
  // keyless Router pick to the default subsidy model with `noRouterPath`.
  "kimi-for-coding": provider({
    // "Kimi Code" is the product name on kimi.com/code and in the console the
    // key is minted from. the provider KEY stays `kimi-for-coding`: it is the
    // slug prefix every stored pick, `--model` flag and BYOK card spells, and
    // it is not what reaches opencode — `enabled_providers` is derived from
    // the `resolve` prefix (`action/agents/opencode.ts`), which is why the
    // two may differ here and nowhere else in this file.
    displayName: "Kimi Code",
    envVars: ["KIMI_API_KEY"],
    models: {
      // the tier each model needs is in `description` because Kimi answers an
      // out-of-tier request with 401 — indistinguishable from a bad key by
      // status alone, and only `isKimiPlanTierError` tells them apart after the
      // fact. saying it in the picker is the half that stops the run happening.
      "kimi-k3": {
        displayName: "Kimi K3",
        description: "1M context, but only on Allegretto and above · Moderato caps it at 256K",
        resolve: "kimi-code-plan-cn/k3",
        effort: ["none", "low", "high", "max"],
        subagentModel: "kimi-k2",
      },
      // `preferred` sits on the 256K variant rather than the 1M flagship, which
      // inverts the usual "top tier wins" rule for a reason specific to Kimi:
      // below Allegretto the two are the SAME 256K ceiling and `k3` charges
      // roughly double the quota for it, so on the median tier the flagship is
      // strictly dominated rather than merely pricier. This flag seeds the
      // onboarding pick (`getRecommendedSlug`) and wins key-only auto-select
      // (`autoSelectModel`), which are exactly the two moments nobody has
      // chosen yet.
      "kimi-k3-256k": {
        displayName: "Kimi K3 256K",
        description: "Same model at 256K for ~half the quota · Moderato or above",
        resolve: "kimi-code-plan-cn/k3-256k",
        effort: ["none", "low", "high", "max"],
        preferred: true,
        subagentModel: "kimi-k2",
      },
      "kimi-k2": {
        displayName: "Kimi K2",
        description: "Included in every Kimi membership tier",
        resolve: "kimi-code-plan-cn/kimi-for-coding",
        // models.dev published `low, high, max` on 2026-09-14 and added `none`
        // on 2026-10-19; the catalog gate mirrors it, so main was red until the
        // alias carried it too.
        effort: ["none", "low", "high", "max"],
      },
      "kimi-k2-highspeed": {
        displayName: "Kimi K2 HighSpeed",
        description: "~6x output speed for ~3x quota · needs Allegretto or above",
        resolve: "kimi-code-plan-cn/kimi-for-coding-highspeed",
      },
    },
  }),
  // Meta Model API — the direct route to Muse Spark. a registry entry, like
  // kimi-for-coding: models.dev carries `meta` (`https://api.meta.ai/v1`, npm
  // `@ai-sdk/openai`) and opencode 1.18.29 enables it from a bare
  // META_MODEL_API_KEY — all five ids list in `opencode models`, and a bogus key
  // reaches Meta's own auth check (`invalid_api_key`). see wiki/muse-spark.md.
  meta: provider({
    displayName: "Meta",
    envVars: ["META_MODEL_API_KEY"],
    models: {
      // the Standard tier. no `subagentModel`: the contributor tier below is
      // the same model, not a cheaper sibling.
      "muse-spark": {
        displayName: "Muse Spark",
        resolve: "meta/muse-spark-1.3",
        effort: ["minimal", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/meta/muse-spark-1.3",
        preferred: true,
      },
      // Meta's top tier (2026-10-12) and its cost-efficient tier (2026-11-09),
      // on the same four routes and API as Muse Spark.
      "muse-wonder": {
        displayName: "Muse Wonder",
        resolve: "meta/muse-wonder-1.0",
        effort: ["minimal", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/meta/muse-wonder",
      },
      "muse-ember": {
        displayName: "Muse Ember",
        resolve: "meta/muse-ember-1.0",
        effort: ["minimal", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/meta/muse-ember",
      },
      // the Contributor tier: the same model at $0.10 / $0.20 (cache read
      // $0.002) because Meta trains on the prompts and completions. an opt-in
      // pick on the OSS menu (public code, headless runs), never the default.
      // OpenRouter only serves it when the account allows paid-model training,
      // so the runtime opens data collection for this id alone — see
      // `openRouterProvider` in opencodeShared.ts.
      "muse-spark-contributor": {
        displayName: "Muse Spark Contributor",
        description: "Meta trains on prompts and completions",
        resolve: "meta/muse-spark-1.3-contributor",
        effort: ["minimal", "low", "medium", "high", "xhigh"],
        openRouterEffort: ["minimal", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/meta/muse-spark-1.3-contributor",
      },
    },
  }),
  opencode: provider({
    displayName: "OpenCode Zen",
    envVars: ["OPENCODE_API_KEY"],
    models: {
      "big-pickle": {
        displayName: "Big Pickle",
        resolve: "opencode/big-pickle",
        preferred: true,
        // free to RUN, but Zen still refuses it without a key — a keyless run
        // dies at session start with `No provider available`. the `envVars: []`
        // override that used to sit here shadowed the provider's own
        // `OPENCODE_API_KEY` and made every static gate wave it through (#1077).
        // `isFree` stays: it is what skips the proxy mint in run-context, and
        // the model does cost nothing.
        isFree: true,
      },
      // Zen meters Fable like any other model, so this route reaches it without
      // the Anthropic access grant `anthropic/claude-fable` still needs.
      "claude-fable": {
        displayName: "Claude Fable",
        resolve: "opencode/claude-fable-5-2",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/~anthropic/claude-fable-latest",
        subagentModel: "claude-sonnet",
        pinned: true,
      },
      "claude-opus": {
        displayName: "Claude Opus",
        resolve: "opencode/claude-opus-5-5",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/anthropic/claude-opus-5.5",
        subagentModel: "claude-sonnet",
        // fell back to Sonnet from 2026-08-25 to 2026-09-25: Zen listed
        // claude-opus-5 while `opencode run` on it emitted nothing for 240s. A
        // raw-endpoint 200 is not runtime availability — the fallback was cleared
        // only once `opencode run` completed on both claude-opus-5 and 5-5. see
        // wiki/opencode-silent-stall.md
      },
      "claude-sonnet": {
        displayName: "Claude Sonnet",
        resolve: "opencode/claude-sonnet-5-5",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/anthropic/claude-sonnet-5.5",
      },
      "claude-haiku": {
        displayName: "Claude Haiku",
        resolve: "opencode/claude-haiku-5-5",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterEffort: ["none", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/anthropic/claude-haiku-5.5",
      },
      "gpt-sol": {
        displayName: "GPT Sol",
        resolve: "opencode/gpt-6.1-sol",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6.1-sol",
        subagentModel: "gpt-terra",
      },
      "gpt-astra": {
        displayName: "GPT Astra",
        resolve: "opencode/gpt-6-astra",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6-astra",
        subagentModel: "gpt-sol",
      },
      // see openai/gpt-sol-pro — Zen has no -pro id, so direct resolves to plain Sol.
      "gpt-sol-pro": {
        displayName: "GPT Sol Pro",
        description: "Maximum reasoning effort",
        resolve: "opencode/gpt-6.1-sol",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6.1-sol-pro",
        subagentModel: "gpt-sol",
        pinned: true,
      },
      // gpt-5.6 balanced mid-tier — selectable + Sol's subagent. see openai above.
      "gpt-terra": {
        displayName: "GPT Terra",
        resolve: "opencode/gpt-5.6-terra",
        effort: ["none", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6.1-terra",
      },
      "gpt-luna": {
        displayName: "GPT Luna",
        resolve: "opencode/gpt-6-luna",
        effort: ["none", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6-luna",
      },
      // legacy aliases — see openai provider above for context.
      "gpt-codex": {
        displayName: "GPT Codex",
        resolve: "opencode/gpt-5.3-codex",
        openRouterResolve: "openrouter/openai/gpt-5.3-codex",
        fallback: "opencode/gpt-sol",
      },
      "gpt-codex-mini": {
        displayName: "GPT Codex Mini",
        resolve: "opencode/gpt-5.1-codex-mini",
        openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
        fallback: "opencode/gpt-luna",
      },
      // pre-5.6 tier slugs — see openai provider above.
      gpt: {
        displayName: "GPT",
        resolve: "opencode/gpt-5.6-sol",
        openRouterResolve: "openrouter/openai/gpt-5.6-sol",
        fallback: "opencode/gpt-sol",
      },
      "gpt-pro": {
        displayName: "GPT Pro",
        resolve: "opencode/gpt-5.6-sol",
        openRouterResolve: "openrouter/openai/gpt-5.6-sol-pro",
        fallback: "opencode/gpt-sol-pro",
      },
      "gpt-mini": {
        displayName: "GPT Mini",
        resolve: "opencode/gpt-5.6-luna",
        openRouterResolve: "openrouter/openai/gpt-5.6-luna",
        fallback: "opencode/gpt-luna",
      },
      // dropped hidden subagent tier — folds stored pins forward to Sol.
      "gpt-5.4": {
        displayName: "GPT 5.4",
        resolve: "opencode/gpt-5.4",
        openRouterResolve: "openrouter/openai/gpt-5.4",
        fallback: "opencode/gpt-sol",
      },
      "gemini-pro": {
        displayName: "Gemini Pro",
        resolve: "opencode/gemini-4-argon",
        effort: ["low", "medium", "high"],
        openRouterResolve: "openrouter/google/gemini-4-argon",
        // Inherit — see google/gemini-pro for rationale.
      },
      // see google/gemini-carbon.
      "gemini-carbon": {
        displayName: "Gemini Carbon",
        resolve: "opencode/gemini-4-carbon",
        effort: ["low", "medium", "high"],
        openRouterResolve: "openrouter/google/gemini-4-carbon",
        subagentModel: "gemini-pro",
      },
      "gemini-flash": {
        displayName: "Gemini Flash",
        resolve: "opencode/gemini-3.8-flash",
        effort: ["low", "medium", "high"],
        openRouterResolve: "openrouter/google/gemini-3.8-flash",
      },
      // Zen serves K3, but rule 7 can only move a mirror when its upstream moves,
      // so the K3 generation could never reach this block by bumping `kimi-k2`.
      "kimi-k3": {
        displayName: "Kimi K3",
        resolve: "opencode/kimi-k3",
        // Zen published a single rung (`max`) for K3 until 2026-10-23; it now matches OpenRouter.
        effort: ["low", "high", "max"],
        openRouterEffort: ["none", "low", "high", "max"],
        openRouterResolve: "openrouter/moonshotai/kimi-k3",
        subagentModel: "kimi-k2",
      },
      "kimi-k2": {
        displayName: "Kimi K2",
        // k2.7-code is UNDEPLOYED on Zen — still listed in /zen/v1/models, but
        // the endpoint answers 400 `[NOT_FOUND] Model not found, inaccessible,
        // and/or not deployed` (measured 2026-08-28; k2.6 200 on the same key).
        // opencode-go is no escape: its own kimi-k2 resolves to the SAME model
        // id. k2.6 is the newest build Zen will actually serve, and the mirror
        // guard permits the step down because k2.7-code is in ZEN_UNDEPLOYED.
        resolve: "opencode/kimi-k2.6",
        openRouterResolve: "openrouter/moonshotai/kimi-k2.7-code",
        pinned: true,
      },
      // M3 is its own line beside M2, not a bump of it — same reason `kimi-k3`
      // sits beside `kimi-k2` rather than replacing it.
      "minimax-m3": {
        displayName: "MiniMax M3",
        resolve: "opencode/minimax-m3",
        openRouterResolve: "openrouter/minimax/minimax-m3",
      },
      // slug pins the m2 line for DB stability; resolve tracks the current m2.7.
      "minimax-m2.5": {
        displayName: "MiniMax M2",
        resolve: "opencode/minimax-m2.7",
        openRouterResolve: "openrouter/minimax/minimax-m2.7",
      },
      // Z.ai and xAI reach Zen subscribers only here — before this, `opencode-go`
      // was the catalog's only GLM route, and Grok had no Zen route at all.
      glm: {
        displayName: "GLM",
        resolve: "opencode/glm-5.3",
        effort: ["low", "high", "max"],
        openRouterResolve: "openrouter/z-ai/glm-5.3",
      },
      grok: {
        displayName: "Grok",
        resolve: "opencode/grok-4.7",
        effort: ["low", "medium", "high", "xhigh"],
        openRouterResolve: "openrouter/x-ai/grok-4.7",
      },
      // Zen began listing Qwen's Max and Flash tiers in late 2026-09 (Max on
      // 09-25); until then `opencode-go` was the only Qwen route, so a Zen
      // subscriber without Go had none. Plus is still Go-only. Zen's Max entry
      // published a reasoning toggle but no effort rungs until 2026-10-23.
      "qwen-max": {
        displayName: "Qwen Max",
        resolve: "opencode/qwen3.8-max",
        effort: ["low", "medium", "xhigh"],
        openRouterEffort: ["minimal", "low", "medium", "high", "xhigh"],
        openRouterResolve: "openrouter/qwen/qwen3.8-max-0902",
      },
      "qwen-flash": {
        displayName: "Qwen Flash",
        resolve: "opencode/qwen3.8-flash",
        effort: ["low", "medium", "xhigh"],
        openRouterEffort: [],
        openRouterResolve: "openrouter/qwen/qwen3.8-flash",
      },
      // Zen lists Mistral Large 4 at half its list price for launch, level with
      // the routers.
      "mistral-large": {
        displayName: "Mistral Large",
        resolve: "opencode/mistral-large-4",
        effort: ["none", "high"],
        openRouterResolve: "openrouter/mistralai/mistral-large-4-0",
      },
      "gpt-5-nano": {
        displayName: "GPT Nano",
        resolve: "opencode/gpt-5.4-nano",
        effort: ["none", "low", "medium", "high", "xhigh"],
        openRouterResolve: "openrouter/openai/gpt-5.4-nano",
      },
      // Zen's PAID Muse Spark, priced like Meta's own Standard tier. answered
      // `OK` through the harness on 2026-09-12 once the workspace was funded.
      "muse-spark": {
        displayName: "Muse Spark",
        resolve: "opencode/muse-spark-1.3",
        // models.dev dropped `max` from Zen's ladder on 2026-09-19 while the
        // OpenRouter and direct `meta/` routes kept it; the catalog gate
        // mirrors what is published per route.
        effort: ["minimal", "low", "medium", "high", "xhigh"],
        openRouterResolve: "openrouter/meta/muse-spark-1.3",
        openRouterEffort: ["minimal", "low", "medium", "high", "xhigh", "max"],
      },
      "muse-wonder": {
        displayName: "Muse Wonder",
        resolve: "opencode/muse-wonder-1.0",
        effort: ["minimal", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/meta/muse-wonder",
      },
      "muse-ember": {
        displayName: "Muse Ember",
        resolve: "opencode/muse-ember-1.0",
        effort: ["minimal", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/meta/muse-ember",
      },
      // Zen's FREE contributor tier: the same model at $0 because Meta trains
      // on the prompts and completions. Zen served it to our key on 2026-09-12
      // without the DataPolicyError Go raises for its contributor id. see
      // wiki/muse-spark.md.
      "muse-spark-contributor": {
        displayName: "Muse Spark Contributor",
        description: "Meta trains on prompts and completions",
        resolve: "opencode/muse-spark-1.3-contributor-free",
        effort: ["minimal", "low", "medium", "high", "xhigh"],
        // free to run, still gated on the provider's own OPENCODE_API_KEY —
        // see the big-pickle note above (#1077).
        isFree: true,
        // Zen answers "Model muse-spark-1.3-contributor-free has been deprecated."
        // since 2026-10-17 and has no free Muse Spark left; stored picks land on
        // Big Pickle, as `exo` does, rather than on the paid `muse-spark`.
        fallback: "opencode/big-pickle",
      },
      // Zen's FREE stealth preview (listed 2026-09-16), the big-pickle shape: an
      // anonymous vendor's model at $0 for a limited time. OpenRouter's listing:
      // prompts and completions may be retained, not trained on.
      "union-alpha": {
        displayName: "Union Alpha",
        description: "Stealth preview; prompts may be retained",
        resolve: "opencode/union-alpha",
        // free to run, still gated on the provider's own OPENCODE_API_KEY —
        // see the big-pickle note above (#1077).
        isFree: true,
        // Zen dropped the id from `/v1/models` on 2026-09-18, two days after it
        // listed it, and answers a 500 for it. a stealth preview has no same-
        // family successor, so stored picks land on the other free stealth
        // model, as `minimax-m2.5-free` does.
        fallback: "opencode/big-pickle",
      },
      // Zen's FREE stealth preview (listed 2026-09-23), the third after
      // big-pickle and union-alpha. Zen's docs: zero retention, no training.
      // unlike the first two it publishes an effort ladder, so the gate
      // mirrors one here.
      "space-bunny": {
        displayName: "Space Bunny",
        description: "Stealth preview, free for a limited time",
        resolve: "opencode/space-bunny-free",
        effort: ["low", "medium", "high", "xhigh", "max"],
        // free to run, still gated on the provider's own OPENCODE_API_KEY —
        // see the big-pickle note above (#1077).
        isFree: true,
        // Zen dropped the free id from `/v1/models` and models.dev deprecated it
        // by 2026-10-19; stored picks land on Big Pickle, as `step-5-preview` does.
        fallback: "opencode/big-pickle",
      },
      // Zen's FREE Meituan preview (listed 2026-09-25). Zen's docs: zero
      // retention, no training. Go lists the same free id, but one key reaches
      // both, so the promo gets one row, here with the other free ones.
      "longcat-preview": {
        displayName: "LongCat Preview",
        description: "Meituan preview, free for a limited time",
        resolve: "opencode/longcat-2.5-preview-free",
        // free to run, still gated on the provider's own OPENCODE_API_KEY —
        // see the big-pickle note above (#1077).
        isFree: true,
        // Zen dropped the free id from `/v1/models` and models.dev deprecated it
        // by 2026-10-23, with no other LongCat on Zen; stored picks land on Big Pickle.
        fallback: "opencode/big-pickle",
      },
      // Zen's FREE stealth preview (listed 2026-10-01), the fifth promo. Zen's
      // docs: data may be used to improve the model during the free period.
      "fledge-alpha": {
        displayName: "Fledge Alpha",
        description: "Stealth preview; data may be used to improve it",
        resolve: "opencode/fledge-alpha-free",
        effort: ["low", "high", "max"],
        // free to run, still gated on the provider's own OPENCODE_API_KEY —
        // see the big-pickle note above (#1077).
        isFree: true,
        // Zen dropped the id from `/v1/models` and its docs on 2026-10-08 and
        // answers a 500 for it; stored picks land on the other free stealth
        // model, as `union-alpha` does.
        fallback: "opencode/big-pickle",
      },
      // Zen's FREE preview (listed 2026-10-06), the sixth promo, on Fledge
      // Alpha's terms.
      exo: {
        displayName: "Exo",
        description: "Preview; data may be used to improve it",
        resolve: "opencode/exo-free",
        // Zen publishes a single rung.
        effort: ["high"],
        // free to run, still gated on the provider's own OPENCODE_API_KEY —
        // see the big-pickle note above (#1077).
        isFree: true,
        // Zen answers "Model exo-free has been deprecated." on both lanes since
        // 2026-10-09; stored picks land on Big Pickle, as `fledge-alpha` does.
        fallback: "opencode/big-pickle",
      },
      // Zen's FREE StepFun preview (listed 2026-10-08), the seventh promo. Zen's
      // docs: zero retention, no training. Go lists the same free id; one key
      // reaches both.
      "step-5-preview": {
        displayName: "Step 5 Preview",
        description: "StepFun preview, free for a limited time",
        resolve: "opencode/step-5-preview-free",
        effort: ["low", "medium", "high"],
        // free to run, still gated on the provider's own OPENCODE_API_KEY —
        // see the big-pickle note above (#1077).
        isFree: true,
        // Zen dropped the free id from `/v1/models` and models.dev deprecated it
        // by 2026-10-15; stored picks land on Big Pickle, as `exo` does.
        fallback: "opencode/big-pickle",
      },
      // Zen's FREE open-weights preview (listed 2026-10-05), the eighth promo.
      // Zen's docs: no training, 30-day retention for safety. hidden until a
      // published release carries the alias; 0.1.102 is the first.
      beam: {
        displayName: "Beam",
        description: "Preview, free for a limited time",
        resolve: "opencode/beam-free",
        effort: ["low", "medium", "high", "xhigh", "max"],
        // free to run, still gated on the provider's own OPENCODE_API_KEY —
        // see the big-pickle note above (#1077).
        isFree: true,
      },
      // Zen's FREE Flint 40B (listed 2026-10-21), the ninth promo: Recursive's
      // open-weights MoE, 2.5B active. Zen's docs: free-period data may train
      // the model, kept 30 days.
      flint: {
        displayName: "Flint",
        description: "Free on Zen for a limited time",
        resolve: "opencode/flint-40b-free",
        effort: ["low", "medium", "high", "xhigh", "max"],
        // free to run, still gated on the provider's own OPENCODE_API_KEY —
        // see the big-pickle note above (#1077).
        isFree: true,
      },
      // Zen's FREE stealth preview (listed 2026-11-02), the tenth promo. Zen's
      // docs: free-period data may be retained and used to improve the model.
      "pulse-alpha": {
        displayName: "Pulse Alpha",
        description: "Stealth preview; data may be used to improve it",
        resolve: "opencode/pulse-alpha-free",
        effort: ["low", "high", "max"],
        // free to run, still gated on the provider's own OPENCODE_API_KEY —
        // see the big-pickle note above (#1077).
        isFree: true,
        // Zen dropped the free id from `/v1/models` and models.dev deprecated it
        // by 2026-11-07; stored picks land on Big Pickle, as `space-bunny` does.
        fallback: "opencode/big-pickle",
      },
      // Zen's live free MiMo, and the second free row in a menu that big-pickle
      // was alone in since `mimo-v2-pro-free` lost its model.
      mimo: {
        displayName: "MiMo",
        // models.dev deprecated `mimo-v2.5-free` on 2026-09-22. V2.6-Flash is
        // the only other MiMo Zen serves, so the slug follows it across the
        // tier rather than leaving stored picks on a dying id.
        resolve: "opencode/mimo-v2.6-flash-free",
        // free to run, still gated on the provider's own OPENCODE_API_KEY —
        // see the big-pickle note above (#1077).
        isFree: true,
      },
      "mimo-v2-pro-free": {
        displayName: "MiMo V2 Pro",
        resolve: "opencode/mimo-v2-pro-free",
        // free to run, still gated on the provider's own OPENCODE_API_KEY —
        // see the big-pickle note above (#1077).
        isFree: true,
        // the model id Zen dropped; land a stored MiMo pick back on MiMo rather
        // than on the unrelated model it had to settle for while none was live.
        fallback: "opencode/mimo",
      },
      "minimax-m2.5-free": {
        displayName: "MiniMax M2",
        resolve: "opencode/minimax-m2.5-free",
        // free to run, still gated on the provider's own OPENCODE_API_KEY —
        // see the big-pickle note above (#1077).
        isFree: true,
        fallback: "opencode/big-pickle",
        hidden: true,
      },
    },
  }),
  // OpenCode Go is a separate $10/mo subscription from Zen, served on its own
  // base URL (`https://opencode.ai/zen/go/v1`) but authenticated with the SAME
  // `OPENCODE_API_KEY`. it carries the open-weight coding models plus a couple
  // of frontier ones, four of which are served ONLY here — Zen's `/v1/models`
  // does not list qwen3.7-plus, mimo-v2.6-pro, longcat-2.0 or hy3/hy4. (it listed
  // no glm-5.3* either until 2026-09, which is why `opencode/glm` trailed on
  // 5.2, and no qwen3.8-* until late 2026-09.) so for a Go subscriber this
  // provider is not a duplicate route to Zen, it is the only route to a large
  // part of what they pay for.
  // like `opencode` and `openrouter` this is a ROUTER, not a vendor: slugs and
  // display names mirror the upstream brand tier, and the picker groups them
  // under the upstream vendor.
  "opencode-go": provider({
    displayName: "OpenCode Go",
    envVars: ["OPENCODE_API_KEY"],
    models: {
      // Z.ai — the plan's flagship coding family, and the only route the
      // catalog offers to GLM at all.
      glm: {
        displayName: "GLM",
        resolve: "opencode-go/glm-5.3",
        effort: ["low", "high", "max"],
        openRouterResolve: "openrouter/z-ai/glm-5.3",
        preferred: true,
        subagentModel: "glm-flash",
      },
      "glm-flash": {
        displayName: "GLM Flash",
        resolve: "opencode-go/glm-5.3-flash",
        // the Go endpoint intermittently rejects reasoning_effort ("native reasoning
        // control reasoning_effort is not allowed") mid-run, despite models.dev
        // advertising a ladder — so this route sends none.
        effort: [],
        openRouterEffort: ["low", "high", "max"],
        openRouterResolve: "openrouter/z-ai/glm-5.3-flash",
      },
      // legacy alias — the slug pinned a version instead of a brand tier and
      // was already resolving to 5.2 under a "GLM 5.2" label. folds forward to
      // the tier slug; 16 repos and 9 accounts still hold it.
      "glm-5.1": {
        displayName: "GLM 5.2",
        resolve: "opencode-go/glm-5.2",
        effort: ["high", "max"],
        openRouterEffort: ["high", "xhigh"],
        openRouterResolve: "openrouter/z-ai/glm-5.2",
        fallback: "opencode-go/glm",
      },
      // Moonshot — parity with moonshotai/* and openrouter/*.
      "kimi-k3": {
        displayName: "Kimi K3",
        resolve: "opencode-go/kimi-k3.1-pro",
        // Go published a single rung (`max`) for K3; K3.1 Pro publishes the
        // same three as the direct route.
        effort: ["low", "high", "max"],
        openRouterEffort: ["none", "low", "high", "max"],
        openRouterResolve: "openrouter/moonshotai/kimi-k3.1-pro",
        subagentModel: "kimi-k2",
      },
      "kimi-k2": {
        displayName: "Kimi K2",
        resolve: "opencode-go/kimi-k2.7-code",
        openRouterResolve: "openrouter/moonshotai/kimi-k2.7-code",
      },
      // DeepSeek and Muse Spark are deliberately ABSENT even though Go serves
      // them and prices DeepSeek Pro below Zen. each sits behind a per-workspace
      // opt-in that is off by default — measured, the run dies with
      // `RegionError` ("only available hosted in China") and `DataPolicyError`
      // ("collects data used to improve its quality"). that toggle lives on the
      // CUSTOMER's OpenCode workspace, so no change here can satisfy it, and a
      // picker row that fails for almost everyone is worse than none. DeepSeek
      // stays reachable ungated via `deepseek/*`, `opencode/*` and
      // `openrouter/*`; both remain runnable by full specifier once opted in.
      // Alibaba — Zen serves Max and Flash too (see `opencode/qwen-max`); Plus
      // is Go-only.
      "qwen-max": {
        displayName: "Qwen Max",
        resolve: "opencode-go/qwen3.8-max",
        // both routes publish rungs, and they are different sets rather than
        // different spellings of one — OpenRouter carries a `minimal` and a
        // `high` the Go route does not.
        effort: ["low", "medium", "xhigh"],
        openRouterEffort: ["minimal", "low", "medium", "high", "xhigh"],
        // OpenRouter retired the rolling `qwen3.8-max` for a dated snapshot, so
        // this side has to pin one; the Go route still carries the rolling id.
        openRouterResolve: "openrouter/qwen/qwen3.8-max-0902",
      },
      "qwen-plus": {
        displayName: "Qwen Plus",
        resolve: "opencode-go/qwen3.7-plus",
        openRouterResolve: "openrouter/qwen/qwen3.7-plus",
      },
      // the cheap rung Alibaba added under Plus (0.15/0.47 against 0.5/3), and
      // the only model either OpenCode plan has added since this block was written.
      "qwen-flash": {
        displayName: "Qwen Flash",
        resolve: "opencode-go/qwen3.8-flash",
        effort: ["low", "medium", "xhigh"],
        // the Go route publishes rungs where OpenRouter publishes none.
        openRouterEffort: [],
        openRouterResolve: "openrouter/qwen/qwen3.8-flash",
      },
      // MiniMax — parity with opencode/* and openrouter/*; the m2 slug pins the
      // line for DB stability while the resolve tracks the current m2.7.
      "minimax-m3": {
        displayName: "MiniMax M3",
        resolve: "opencode-go/minimax-m3",
        openRouterResolve: "openrouter/minimax/minimax-m3",
      },
      "minimax-m2.5": {
        displayName: "MiniMax M2",
        resolve: "opencode-go/minimax-m2.7",
        openRouterResolve: "openrouter/minimax/minimax-m2.7",
        // models.dev deprecated Go's m2.7 by 2026-10-15 and Go lists no other
        // M2; M3 is the same vendor at the same $0.30 input price.
        fallback: "opencode-go/minimax-m3",
      },
      // Xiaomi — Go-only; Zen serves the free `mimo-v2-pro-free` promo instead.
      "mimo-pro": {
        displayName: "MiMo Pro",
        resolve: "opencode-go/mimo-v2.6-pro",
        openRouterResolve: "openrouter/xiaomi/mimo-v2.6-pro",
      },
      // Meituan — Go-only.
      longcat: {
        displayName: "LongCat",
        resolve: "opencode-go/longcat-2.0",
        openRouterResolve: "openrouter/meituan/longcat-2.0",
      },
      // Tencent — Go-only (0.14/0.58). Hy3 succeeds the Hunyuan 2.0 line, so the
      // generation is part of the product name the way Kimi K2/K3 is.
      hy3: {
        displayName: "Hy3",
        resolve: "opencode-go/hy3",
        effort: ["none", "low", "high"],
        openRouterResolve: "openrouter/tencent/hy3",
      },
      // a sibling, not a bump of `hy3`: Hy4 (GA 2026-11-04) costs 4-6x as much on
      // Go (0.834/2.501), so moving `hy3` would reprice every repo pinned to it.
      hy4: {
        displayName: "Hy4",
        resolve: "opencode-go/hy4",
        effort: ["none", "high"],
        openRouterEffort: ["none", "low", "high"],
        openRouterResolve: "openrouter/tencent/hy4",
      },
      // xAI and OpenAI — the two non-open models on the plan. same list price as
      // Zen, but a Go subscription covers them where Zen meters them.
      // Go is the only route that has retired grok-4.5 (models.dev marks
      // `opencode-go/grok-4.5` deprecated while every other provider still
      // serves it), so this alias LEADS `xai/grok` by a generation. a mirror
      // that leads is safe; it is the trailing case that rots — see
      // wiki/models-catalog.md on `opencode/kimi-k2`.
      grok: {
        displayName: "Grok",
        resolve: "opencode-go/grok-4.7",
        effort: ["low", "medium", "high", "xhigh"],
        openRouterResolve: "openrouter/x-ai/grok-4.7",
      },
      "gpt-luna": {
        displayName: "GPT Luna",
        resolve: "opencode-go/gpt-6-luna",
        effort: ["none", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6-luna",
      },
    },
  }),
  bedrock: provider({
    displayName: "Amazon Bedrock",
    envVars: ["AWS_BEARER_TOKEN_BEDROCK", "AWS_REGION", "BEDROCK_MODEL_ID"],
    models: {
      // single routing entry — the actual Bedrock model ID is read from
      // BEDROCK_MODEL_ID at run time. see ModelRouting docs for why we
      // don't catalog individual Bedrock models.
      byok: {
        displayName: "Amazon Bedrock",
        resolve: "bedrock",
        routing: "bedrock",
      },
    },
  }),
  vertex: provider({
    displayName: "Google Vertex AI",
    envVars: [
      "VERTEX_SERVICE_ACCOUNT_JSON",
      "GOOGLE_CLOUD_PROJECT",
      "VERTEX_LOCATION",
      "VERTEX_MODEL_ID",
    ],
    models: {
      // single routing entry — the actual Vertex AI model ID is read from
      // VERTEX_MODEL_ID at run time. see ModelRouting docs for why we don't
      // catalog individual Vertex models.
      byok: {
        displayName: "Google Vertex AI",
        resolve: "vertex",
        routing: "vertex",
      },
    },
  }),
  azure: provider({
    displayName: "Azure OpenAI",
    // the resource name is half the endpoint URL and the deployment is the model
    // id, so both are as load-bearing as the key. only the key is sensitive.
    envVars: ["AZURE_API_KEY", "AZURE_RESOURCE_NAME", "AZURE_DEPLOYMENT"],
    models: {
      // single routing entry — the real model is the customer's deployment name,
      // read from AZURE_DEPLOYMENT at run time. see ModelRouting docs for why
      // Azure can't be cataloged even though models.dev lists it.
      byok: {
        displayName: "Azure OpenAI",
        resolve: "azure",
        routing: "azure",
      },
    },
  }),
  "openai-compatible": provider({
    // "Custom" is the picker group, "OpenAI-compatible" the entry under it, so the
    // menu reads `Custom › OpenAI-compatible` and a second custom backend (a
    // different wire format, say) slots in beside it without a rename. the
    // provider KEY stays `openai-compatible` — it's the stored slug and the
    // `OPENAI_COMPATIBLE_*` env prefix, so this is display-only.
    displayName: "Custom",
    // bring-your-own generic OpenAI-compatible endpoint — Cloudflare AI Gateway,
    // Alibaba DashScope, self-hosted vLLM, or any compatible gateway. base URL +
    // key + model ID are all supplied via env; nothing is cataloged or bumped.
    // the two token limits are deliberately absent: this list is the auth
    // heuristic (`hasPullfrogStoredAuthForModel`, `validateAgentApiKey`), and a
    // stored context number is config, not proof of a key. the console picks
    // them up from `PROVIDER_EXTRA_SECRET_NAMES` instead.
    envVars: ["OPENAI_COMPATIBLE_BASE_URL", "OPENAI_COMPATIBLE_API_KEY", "OPENAI_COMPATIBLE_MODEL"],
    models: {
      // single routing entry — the actual model ID is read from
      // OPENAI_COMPATIBLE_MODEL at run time and the provider is materialized
      // via `@ai-sdk/openai-compatible`.
      byok: {
        displayName: "OpenAI-compatible",
        resolve: "openai-compatible",
        routing: "openai-compatible",
      },
    },
  }),
  openrouter: provider({
    displayName: "OpenRouter",
    envVars: ["OPENROUTER_API_KEY"],
    models: {
      "claude-opus": {
        displayName: "Claude Opus",
        resolve: "openrouter/~anthropic/claude-opus-latest",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/~anthropic/claude-opus-latest",
        preferred: true,
        subagentModel: "claude-sonnet",
        pinned: true,
      },
      "claude-sonnet": {
        displayName: "Claude Sonnet",
        resolve: "openrouter/~anthropic/claude-sonnet-latest",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/~anthropic/claude-sonnet-latest",
        pinned: true,
      },
      "claude-haiku": {
        displayName: "Claude Haiku",
        resolve: "openrouter/~anthropic/claude-haiku-latest",
        // the rolling alias moved to Haiku 5.5 on 2026-10-07, which publishes
        // the ladder Haiku 4.5 did not.
        effort: ["none", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/~anthropic/claude-haiku-latest",
        pinned: true,
      },
      // pinned to the explicit Sol/Terra/Luna tiers (not the ~openai/gpt-latest
      // rolling alias): after that rename, ~gpt-mini-latest no longer maps
      // to Luna, so rolling aliases would silently diverge `gpt`/`gpt-mini` from
      // the chosen tiers across funding paths.
      "gpt-sol": {
        displayName: "GPT Sol",
        resolve: "openrouter/openai/gpt-6.1-sol",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6.1-sol",
        subagentModel: "gpt-terra",
      },
      "gpt-astra": {
        displayName: "GPT Astra",
        resolve: "openrouter/openai/gpt-6-astra",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6-astra",
        subagentModel: "gpt-sol",
      },
      // see openai/gpt-sol-pro. openrouter serves sol-pro directly on both routes.
      "gpt-sol-pro": {
        displayName: "GPT Sol Pro",
        description: "Maximum reasoning effort",
        resolve: "openrouter/openai/gpt-6.1-sol-pro",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6.1-sol-pro",
        subagentModel: "gpt-sol",
        pinned: true,
      },
      // gpt-5.6 balanced mid-tier — selectable + Sol's subagent. see openai above.
      "gpt-terra": {
        displayName: "GPT Terra",
        resolve: "openrouter/openai/gpt-6.1-terra",
        effort: ["none", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6.1-terra",
      },
      "gpt-luna": {
        displayName: "GPT Luna",
        resolve: "openrouter/openai/gpt-6-luna",
        effort: ["none", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/openai/gpt-6-luna",
      },
      // legacy aliases — see openai provider for context.
      "gpt-codex": {
        displayName: "GPT Codex",
        resolve: "openrouter/openai/gpt-5.3-codex",
        openRouterResolve: "openrouter/openai/gpt-5.3-codex",
        fallback: "openrouter/gpt-sol",
      },
      "gpt-codex-mini": {
        displayName: "GPT Codex Mini",
        resolve: "openrouter/openai/gpt-5.1-codex-mini",
        openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
        fallback: "openrouter/gpt-luna",
      },
      // pre-5.6 tier slugs — see openai provider above.
      gpt: {
        displayName: "GPT",
        resolve: "openrouter/openai/gpt-5.6-sol",
        openRouterResolve: "openrouter/openai/gpt-5.6-sol",
        fallback: "openrouter/gpt-sol",
      },
      "gpt-pro": {
        displayName: "GPT Pro",
        resolve: "openrouter/openai/gpt-5.6-sol-pro",
        openRouterResolve: "openrouter/openai/gpt-5.6-sol-pro",
        fallback: "openrouter/gpt-sol-pro",
      },
      "gpt-mini": {
        displayName: "GPT Mini",
        resolve: "openrouter/openai/gpt-5.6-luna",
        openRouterResolve: "openrouter/openai/gpt-5.6-luna",
        fallback: "openrouter/gpt-luna",
      },
      // dropped hidden subagent tier — folds stored pins forward to Sol.
      "gpt-5.4": {
        displayName: "GPT 5.4",
        resolve: "openrouter/openai/gpt-5.4",
        openRouterResolve: "openrouter/openai/gpt-5.4",
        fallback: "openrouter/gpt-sol",
      },
      // OpenRouter dropped `openai/o4-mini` on 2026-10-28 (only `:batch` remains) — folds stored pins to Luna.
      "o4-mini": {
        displayName: "O4 Mini",
        resolve: "openrouter/openai/o4-mini",
        openRouterResolve: "openrouter/openai/o4-mini",
        fallback: "openrouter/gpt-luna",
      },
      "gemini-pro": {
        displayName: "Gemini Pro",
        resolve: "openrouter/~google/gemini-pro-latest",
        effort: ["low", "medium", "high"],
        openRouterResolve: "openrouter/~google/gemini-pro-latest",
        // Inherit — see google/gemini-pro for rationale.
        pinned: true,
      },
      // see google/gemini-carbon. pinned to the id: the `~google/gemini-pro-latest`
      // pointer above tracks the default tier, not this one.
      "gemini-carbon": {
        displayName: "Gemini Carbon",
        resolve: "openrouter/google/gemini-4-carbon",
        effort: ["low", "medium", "high"],
        openRouterResolve: "openrouter/google/gemini-4-carbon",
        subagentModel: "gemini-pro",
      },
      "gemini-flash": {
        displayName: "Gemini Flash",
        resolve: "openrouter/~google/gemini-flash-latest",
        // a floating pointer, not a pinned id — it already serves 3.8, so this
        // entry tracks its siblings' generation bumps for free. the ladder still
        // mirrors exactly: claude-code hard-errors on an out-of-range `--effort`.
        effort: ["low", "medium", "high"],
        openRouterResolve: "openrouter/~google/gemini-flash-latest",
        pinned: true,
      },
      grok: {
        displayName: "Grok",
        resolve: "openrouter/x-ai/grok-4.7",
        effort: ["low", "medium", "high", "xhigh"],
        openRouterResolve: "openrouter/x-ai/grok-4.7",
      },
      // dated pin, not the bare id — see `deepseek/deepseek-pro` for why
      // OpenRouter's unversioned `deepseek-v4-pro` is the stale April preview.
      "deepseek-pro": {
        displayName: "DeepSeek Pro",
        resolve: "openrouter/deepseek/deepseek-v4-pro-0813",
        effort: ["none", "low", "high", "max"],
        openRouterResolve: "openrouter/deepseek/deepseek-v4-pro-0813",
      },
      // follows DeepSeek's current snapshot — see `deepseek/deepseek-flash`.
      "deepseek-flash": {
        displayName: "DeepSeek Flash",
        resolve: "openrouter/deepseek/deepseek-v4.1-flash-1017",
        effort: ["none", "low", "high", "max"],
        openRouterResolve: "openrouter/deepseek/deepseek-v4.1-flash-1017",
      },
      // legacy alias — deepseek retires this on 2026-07-24; transparently
      // upgrade existing users to the v4 family via the fallback chain.
      "deepseek-chat": {
        displayName: "DeepSeek Chat",
        resolve: "openrouter/deepseek/deepseek-v3.2",
        openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
        fallback: "openrouter/deepseek-flash",
      },
      "kimi-k2": {
        displayName: "Kimi K2",
        resolve: "openrouter/moonshotai/kimi-k2.7-code",
        openRouterResolve: "openrouter/moonshotai/kimi-k2.7-code",
      },
      "kimi-k3": {
        displayName: "Kimi K3",
        resolve: "openrouter/moonshotai/kimi-k3.1-pro",
        effort: ["none", "low", "high", "max"],
        openRouterResolve: "openrouter/moonshotai/kimi-k3.1-pro",
      },
      // slug pins the m2 line for DB stability; resolve tracks the current m2.7.
      // stored picks moved to M3.1 Flash on 2026-10-30, a quarter of m2.7's
      // price and newer: the family carries on there rather than on M2.
      "minimax-m2.5": {
        displayName: "MiniMax M2",
        resolve: "openrouter/minimax/minimax-m2.7",
        openRouterResolve: "openrouter/minimax/minimax-m2.7",
        fallback: "openrouter/minimax-flash",
      },
      "minimax-m3": {
        displayName: "MiniMax M3",
        resolve: "openrouter/minimax/minimax-m3",
        openRouterResolve: "openrouter/minimax/minimax-m3",
      },
      // the cheap rung beside M3 (0.075/0.30, cache 0.0075, against M3's 0.30/1.20).
      "minimax-flash": {
        displayName: "MiniMax Flash",
        resolve: "openrouter/minimax/minimax-m3.1-flash",
        effort: ["low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/minimax/minimax-m3.1-flash",
      },
      // OpenRouter gates Meta models on an ACCOUNT-level 18+ confirmation
      // (openrouter.ai/settings/preferences). Pullfrog's account has it, so the
      // Router path runs; a BYOK OpenRouter key without it refuses with that
      // link before the request reaches Meta. see wiki/muse-spark.md.
      "muse-spark": {
        displayName: "Muse Spark",
        resolve: "openrouter/meta/muse-spark-1.3",
        effort: ["minimal", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/meta/muse-spark-1.3",
      },
      "muse-wonder": {
        displayName: "Muse Wonder",
        resolve: "openrouter/meta/muse-wonder",
        effort: ["minimal", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/meta/muse-wonder",
      },
      "muse-ember": {
        displayName: "Muse Ember",
        resolve: "openrouter/meta/muse-ember",
        effort: ["minimal", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/meta/muse-ember",
      },
      "muse-spark-contributor": {
        displayName: "Muse Spark Contributor",
        description: "Meta trains on prompts and completions",
        resolve: "openrouter/meta/muse-spark-1.3-contributor",
        effort: ["minimal", "low", "medium", "high", "xhigh", "max"],
        openRouterResolve: "openrouter/meta/muse-spark-1.3-contributor",
      },
      // Mistral's flagship (public preview, 2026-10-06), on the routers and
      // Zen only: the catalog carries no direct Mistral provider.
      "mistral-large": {
        displayName: "Mistral Large",
        resolve: "openrouter/mistralai/mistral-large-4-0",
        effort: ["none", "high"],
        openRouterResolve: "openrouter/mistralai/mistral-large-4-0",
      },
    },
  }),
  vercel: provider({
    // Vercel AI Gateway — one key serves every model it fronts. model IDs use
    // the models.dev `vercel` catalog's own naming (dotted versions, e.g.
    // `anthropic/claude-opus-5.5`, `zai/glm-5.3`). effort ladders come from the
    // gateway's OWN `/v1/models`, not models.dev — the two disagree routinely
    // (on 2026-09-22 they disagreed on all ten entries that drifted), and the
    // drift test reads the gateway for that reason. deliberately no
    // `openRouterResolve`: a gateway pick is BYOK-only — silently rerouting it
    // over the Router proxy would bill the wallet for traffic the user pointed
    // at their own gateway.
    displayName: "Vercel AI Gateway",
    envVars: ["AI_GATEWAY_API_KEY"],
    models: {
      "claude-opus": {
        displayName: "Claude Opus",
        resolve: "vercel/anthropic/claude-opus-5.5",
        effort: ["low", "medium", "high", "xhigh", "max"],
        preferred: true,
        subagentModel: "claude-sonnet",
      },
      "claude-sonnet": {
        displayName: "Claude Sonnet",
        resolve: "vercel/anthropic/claude-sonnet-5.5",
        effort: ["low", "medium", "high", "xhigh", "max"],
      },
      "claude-haiku": {
        displayName: "Claude Haiku",
        resolve: "vercel/anthropic/claude-haiku-5.5",
        effort: ["none", "low", "medium", "high", "xhigh", "max"],
      },
      "gpt-sol": {
        displayName: "GPT Sol",
        resolve: "vercel/openai/gpt-6.1-sol",
        effort: ["low", "medium", "high", "xhigh", "max"],
        subagentModel: "gpt-terra",
      },
      "gpt-astra": {
        displayName: "GPT Astra",
        resolve: "vercel/openai/gpt-6-astra",
        effort: ["low", "medium", "high", "xhigh", "max"],
        subagentModel: "gpt-sol",
      },
      "gpt-terra": {
        displayName: "GPT Terra",
        resolve: "vercel/openai/gpt-6.1-terra",
        effort: ["none", "low", "medium", "high", "xhigh", "max"],
      },
      "gpt-luna": {
        displayName: "GPT Luna",
        resolve: "vercel/openai/gpt-6-luna",
        effort: ["none", "low", "medium", "high", "xhigh", "max"],
      },
      "gemini-pro": {
        displayName: "Gemini Pro",
        resolve: "vercel/google/gemini-4-argon",
        effort: ["low", "medium", "high"],
      },
      "gemini-carbon": {
        displayName: "Gemini Carbon",
        resolve: "vercel/google/gemini-4-carbon",
        effort: ["low", "medium", "high"],
        subagentModel: "gemini-pro",
      },
      "gemini-flash": {
        displayName: "Gemini Flash",
        resolve: "vercel/google/gemini-3.8-flash",
        effort: ["low", "high"],
      },
      "deepseek-pro": {
        displayName: "DeepSeek Pro",
        resolve: "vercel/deepseek/deepseek-v4-pro-0813",
        effort: ["none", "low", "high", "max"],
      },
      // the gateway's bare `deepseek-v4-flash` is the April preview — the same
      // fork trap as OpenRouter, with `deepseek-v4-flash-0731` beside it — so
      // this entry sat on the preview until V4.1.
      "deepseek-flash": {
        displayName: "DeepSeek Flash",
        resolve: "vercel/deepseek/deepseek-v4.1-flash-1017",
        effort: ["none", "low", "high", "max"],
      },
      glm: {
        displayName: "GLM",
        resolve: "vercel/zai/glm-5.3",
        effort: ["low", "high", "max"],
      },
      "kimi-k3": {
        displayName: "Kimi K3",
        resolve: "vercel/moonshotai/kimi-k3.1-pro",
        effort: ["low", "high", "max"],
      },
      "muse-spark": {
        displayName: "Muse Spark",
        resolve: "vercel/meta/muse-spark-1.3",
        effort: ["minimal", "low", "medium", "high", "xhigh", "max"],
      },
      "muse-wonder": {
        displayName: "Muse Wonder",
        resolve: "vercel/meta/muse-wonder-1.0",
        effort: ["minimal", "low", "medium", "high", "xhigh", "max"],
      },
      "muse-ember": {
        displayName: "Muse Ember",
        resolve: "vercel/meta/muse-ember",
        effort: ["minimal", "low", "medium", "high", "xhigh", "max"],
      },
      "mistral-large": {
        displayName: "Mistral Large",
        resolve: "vercel/mistral/mistral-large-4",
        effort: ["none", "high"],
      },
    },
  }),
} satisfies Record<string, ProviderConfig>;

/** the registry every lookup below reads: this file's copy, until `installProviders` swaps in the server's. */
export let providers: Record<string, ProviderConfig> = bundledProviders;

export type ModelProvider = keyof typeof bundledProviders;

// ── slug parsing ───────────────────────────────────────────────────────────────

export function parseModel(slug: string): { provider: string; model: string } {
  const slashIdx = slug.indexOf("/");
  if (slashIdx === -1) {
    throw new Error(`invalid model slug "${slug}" — expected "provider/model"`);
  }
  return { provider: slug.slice(0, slashIdx), model: slug.slice(slashIdx + 1) };
}

export function getModelProvider(slug: string): string {
  return parseModel(slug).provider;
}

export function getProviderDisplayName(slug: string): string | undefined {
  const parsed = parseModel(slug);
  return (providers as Record<string, ProviderConfig>)[parsed.provider]?.displayName;
}

/** provider-prefixed specifier → bare model id (`anthropic/claude-opus-5` → `claude-opus-5`). */
export function stripProviderPrefix(specifier: string): string {
  const slashIndex = specifier.indexOf("/");
  return slashIndex > 0 ? specifier.slice(slashIndex + 1) : specifier;
}

/** specifier → the model's own id, for display: `openrouter/~anthropic/claude-opus-latest` →
 * `claude-opus-latest`. names what an alias points at today, which its name does not. */
export function modelIdLabel(specifier: string): string {
  return specifier.slice(specifier.lastIndexOf("/") + 1).replace(/^~/, "");
}

/**
 * A provider by its slug prefix, or by the prefix its models resolve under: the action passes
 * resolved specifiers, and Kimi Code's (`kimi-code-plan-cn/…`) differ from its slugs
 * (`kimi-for-coding/…`). Without the second lookup every Kimi model read as needing no key.
 */
function getProviderConfig(prefix: string): ProviderConfig | undefined {
  const all: Record<string, ProviderConfig> = providers;
  return (
    all[prefix] ??
    Object.values(all).find((config) =>
      Object.values(config.models).some((model) => model.resolve.startsWith(`${prefix}/`))
    )
  );
}

export function getModelEnvVars(slug: string): string[] {
  const parsed = parseModel(slug);
  const providerConfig = getProviderConfig(parsed.provider);
  if (!providerConfig) {
    return [];
  }

  const modelConfig = providerConfig.models[parsed.model];
  if (modelConfig?.envVars) {
    return modelConfig.envVars.slice();
  }

  return providerConfig.envVars.slice();
}

/** managed credentials are authored only via `pullfrog auth <provider>` — they
 * count as "configured" for hasAnyKey-style UI checks but are never offered as
 * a manual-paste option in `init` or the AgentSettings env-var button row.
 * see `provider.managedCredentials` and wiki/codex-auth.md. */
export function getModelManagedCredentials(slug: string): string[] {
  const parsed = parseModel(slug);
  const providerConfig = getProviderConfig(parsed.provider);
  return providerConfig?.managedCredentials?.slice() ?? [];
}

/**
 * Anthropic credentials that ONLY claude-code can present. opencode's anthropic
 * provider authenticates from `ANTHROPIC_API_KEY` and nothing else (see
 * `packages/llm/src/providers/anthropic.ts` upstream), so holding one of these
 * is not evidence opencode can serve a Claude model.
 *
 * `ANTHROPIC_AUTH_TOKEN` (the gateway variable) is kept out of
 * `providers.anthropic.envVars` entirely. `CLAUDE_CODE_OAUTH_TOKEN` cannot be:
 * the console offers it as a paste target and `credentialFallback` preflights
 * it, both of which read `getModelEnvVars`. So it stays in `envVars` and is
 * subtracted here instead, via `getOpenCodeEnvVars` at every opencode-facing
 * gate. Leaving it un-subtracted let `autoSelectModel` pin
 * `anthropic/claude-opus-5` on a subscription-only account and hand it to
 * opencode, which died with `Model not found` before the first turn.
 */
export const CLAUDE_CODE_ONLY_CREDENTIALS = ["ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"];

/**
 * Credentials that can serve a provider's models without belonging in its
 * `envVars`, because only one harness understands them. An account holding one
 * DOES have a working Anthropic credential, and reading it as "no BYOK" routes
 * the run onto a billed proxy, which `resolveAgent` then hands to opencode,
 * bypassing the gateway entirely. Listing a credential that IS in `envVars`
 * (`CLAUDE_CODE_OAUTH_TOKEN`) is harmless — every consumer asks `.some()`.
 */
const HARNESS_ONLY_CREDENTIALS: Record<string, string[]> = {
  anthropic: CLAUDE_CODE_ONLY_CREDENTIALS,
};

/**
 * `getModelEnvVars` minus the credentials only claude-code can present — the
 * right question for every opencode-facing gate (auto-select, key validation).
 * `getModelEnvVars` answers "which credentials can serve this model", which is
 * the product's question; this answers "which can serve it UNDER OPENCODE",
 * which is the only one those gates may act on.
 */
export function getOpenCodeEnvVars(slug: string): string[] {
  return getModelEnvVars(slug).filter((v) => !CLAUDE_CODE_ONLY_CREDENTIALS.includes(v));
}

/**
 * Whether one of `secretNames` can run `model` — the Router opt-out predicate,
 * shared so every caller decides the same way. Auto-tier sentinels and
 * deprecated aliases resolve first; `getModelEnvVars("auto/intelligent")` is
 * `[]` otherwise.
 */
export function modelHasStoredAuth(params: { model: string; secretNames: string[] }): boolean {
  const slug = resolveDisplayAlias(params.model)?.slug ?? params.model;
  const authVars = [
    ...getModelEnvVars(slug),
    ...getModelManagedCredentials(slug),
    ...(HARNESS_ONLY_CREDENTIALS[getModelProvider(slug)] ?? []),
    ...(KEYLESS_AUTH_ENV[getModelProvider(slug)]?.required ?? []),
    // the default endpoint's vars above still count: a runner holding no such set runs the
    // slug as a raw model id on it
    ...openAICompatibleSetEnvVars(slug),
  ];
  return authVars.some((v) => params.secretNames.includes(v));
}

/**
 * `proxyOptOut`, spelled the way run-context spells it: a Router run skips the
 * mint when the pick is FREE or a Pullfrog-stored key covers it, and then runs
 * as-configured rather than clamping to the auto tier.
 *
 * one predicate for every reader — the console picker's `customLocked`, the
 * effort ladder's `resolveRunning` and `resolvePickModel`. they held two
 * copies and the free half was missing from both, which put the whole `opencode/*` free tier (`opencode/big-pickle`
 * is `preferred`, so the most-picked model there is) behind "add a payment
 * method to run it" on a card-less Router repo, against a server that ran it.
 */
export function routerProxyOptOut(ctx: { slug: string | null; secretNames: string[] }) {
  if (ctx.slug === null) return false;
  return (
    resolveDisplayAlias(ctx.slug)?.isFree === true ||
    modelHasStoredAuth({ model: ctx.slug, secretNames: ctx.secretNames })
  );
}

/** the specifier a pick runs as: its OpenRouter target on a proxied account (Router, the OSS
 * subsidy), which mints for every pick `routerProxyOptOut` does not exempt, else its direct one. */
export function resolvePickModel(params: {
  slug: string;
  proxied: boolean;
  secretNames: string[];
}): string | undefined {
  return params.proxied && !routerProxyOptOut(params)
    ? resolveOpenRouterModel(params.slug)
    : resolveCliModel(params.slug);
}

// ── derived flat list ──────────────────────────────────────────────────────────

function flattenAliases(source: Record<string, ProviderConfig>): ModelAlias[] {
  return Object.entries(source).flatMap(([providerKey, config]) =>
    Object.entries(config.models).map(([modelId, def]: [string, ModelDef]) => ({
      slug: `${providerKey}/${modelId}`,
      provider: providerKey,
      displayName: def.displayName,
      description: def.description,
      resolve: def.resolve,
      openRouterResolve: def.openRouterResolve,
      preferred: def.preferred ?? false,
      isFree: def.isFree ?? false,
      fallback: def.fallback,
      routing: def.routing,
      // subagentModel is stored as an alias key local to the provider; expand
      // here to a fully-qualified slug so callers can look up the target alias
      // directly without re-deriving the provider.
      subagentModel: def.subagentModel ? `${providerKey}/${def.subagentModel}` : undefined,
      effort: def.effort,
      openRouterEffort: def.openRouterEffort,
      hidden: def.hidden ?? false,
    }))
  );
}

export let modelAliases: ModelAlias[] = flattenAliases(providers);

// ── auto tiers ───────────────────────────────────────────────────────────────

/**
 * `repo.model` sentinels for the two managed auto tiers. stored verbatim in
 * the DB (they pass the `provider/model` shape check) and resolved to a
 * concrete alias by `resolveDisplayAlias` below, so every downstream consumer
 * (CLI resolve, OpenRouter resolve, footer label) handles them transparently.
 *
 * `efficient` mirrors the OSS/default subsidy model (DeepSeek Flash — the 0731
 * release is DeepSeek's first officially-shipped V4, where Pro is still the
 * April preview, and it costs ~0.45x Pro on our cache-heavy review workload);
 * `intelligent` is the frontier pick (Claude Opus). a `null` model means the
 * tier hasn't been pinned: callers default by card status via `defaultAutoTier`.
 */
export const AUTO_EFFICIENT = "auto/efficient";
export const AUTO_INTELLIGENT = "auto/intelligent";
export type AutoTier = typeof AUTO_EFFICIENT | typeof AUTO_INTELLIGENT;

const AUTO_TIER_TARGET: Record<AutoTier, string> = {
  [AUTO_EFFICIENT]: "deepseek/deepseek-flash",
  [AUTO_INTELLIGENT]: "anthropic/claude-opus",
};

export function isAutoTier(slug: string | null | undefined): slug is AutoTier {
  return slug === AUTO_EFFICIENT || slug === AUTO_INTELLIGENT;
}

/**
 * the tier to default to when the user hasn't pinned one. card on file →
 * intelligent (they've signalled willingness to pay for frontier reviews);
 * otherwise → efficient (safe, cheap). server (`run-context`) and the console
 * picker both call this so the displayed default and the runtime default agree.
 */
export function defaultAutoTier(hasCard: boolean): AutoTier {
  return hasCard ? AUTO_INTELLIGENT : AUTO_EFFICIENT;
}

/**
 * the auto tier that actually runs for an account, enforcing the card gate:
 * the intelligent tier (Opus) requires a card on file, so a no-card account is
 * always clamped to efficient (Kimi) — whether intelligent was the card-based
 * default or an explicit pick made while a card was once present. keeps a trial
 * balance from being torched on a premium model before the user has a card.
 * `model` may be null, an `auto/*` sentinel, or a concrete pick being
 * clamped — callers route concrete picks here only when `!hasCard` (carded
 * concrete picks resolve directly), so a concrete `model` always lands on the
 * efficient tier via the no-card early return.
 */
export function resolveAutoTier(params: { model: string | null; hasCard: boolean }): AutoTier {
  if (!params.hasCard) return AUTO_EFFICIENT;
  return isAutoTier(params.model) ? params.model : defaultAutoTier(params.hasCard);
}

// ── model router tiers ─────────────────────────────────────────────────────────

/**
 * the four rungs the model router scores a review into, ascending. `minimal` is
 * a PR with no behavioural surface, `deep` one whose defects are absences the
 * diff does not show. see wiki/router.md for how a tier is earned.
 */
export const ROUTER_TIERS = ["minimal", "light", "standard", "deep"] as const;
export type RouterTier = (typeof ROUTER_TIERS)[number];

export function isRouterTier(value: unknown): value is RouterTier {
  return typeof value === "string" && ROUTER_TIERS.some((tier) => tier === value);
}

type Ladder = Record<RouterTier, string>;

/**
 * the model each tier runs on, per provider, as alias slugs — so a rung follows
 * its alias's fallback chain and never names a retired model. only providers
 * where one credential serves every rung get a ladder: a repo pinned to a
 * provider absent here (bedrock, vertex, azure, openai-compatible, kimi-for-
 * coding) keeps its pin, because the sibling model may be unreachable on the
 * route the repo actually takes. openai uses all four durable tiers
 * (Luna/Terra/Sol/Astra): the console lists the whole ladder as what Auto runs
 * on, so every rung is a model the customer can see. anthropic doubles Opus at
 * `deep` because Fable is access-gated (see its alias) and an automatic rung
 * must run on every accepted credential, and doubles Sonnet at `minimal`
 * because Haiku 4.5 ends most reviews without ever submitting one (see
 * wiki/router.md); a two-model provider doubles up at the cheap end.
 */
const PROVIDER_LADDERS: Record<string, Ladder> = {
  anthropic: {
    minimal: "anthropic/claude-sonnet",
    light: "anthropic/claude-sonnet",
    standard: "anthropic/claude-opus",
    deep: "anthropic/claude-opus",
  },
  openai: {
    minimal: "openai/gpt-luna",
    light: "openai/gpt-terra",
    standard: "openai/gpt-sol",
    deep: "openai/gpt-astra",
  },
  google: {
    minimal: "google/gemini-flash",
    light: "google/gemini-flash",
    standard: "google/gemini-pro",
    deep: "google/gemini-pro",
  },
  deepseek: {
    minimal: "deepseek/deepseek-flash",
    light: "deepseek/deepseek-flash",
    standard: "deepseek/deepseek-pro",
    deep: "deepseek/deepseek-pro",
  },
  xai: {
    minimal: "xai/grok-4.3",
    light: "xai/grok-4.3",
    standard: "xai/grok",
    deep: "xai/grok",
  },
  moonshotai: {
    minimal: "moonshotai/kimi-k2",
    light: "moonshotai/kimi-k2",
    standard: "moonshotai/kimi-k3",
    deep: "moonshotai/kimi-k3",
  },
  openrouter: {
    minimal: "openrouter/deepseek-flash",
    light: "openrouter/gpt-luna",
    standard: "openrouter/gpt-sol",
    deep: "openrouter/claude-opus",
  },
  opencode: {
    minimal: "opencode/gpt-luna",
    light: "opencode/gpt-luna",
    standard: "opencode/gpt-sol",
    deep: "opencode/claude-opus",
  },
};

/**
 * the Router's own ladder for an account on `auto/router` with a card on
 * file. an account without a card is never routed onto this:
 * `resolveAutoTier` clamps it to the efficient default at every tier, exactly
 * as before the router existed.
 */
export const ROUTER_LADDER: Ladder = {
  minimal: "deepseek/deepseek-flash",
  light: "openai/gpt-luna",
  standard: "openai/gpt-sol",
  deep: "anthropic/claude-opus",
};

/**
 * the `repo.model` sentinel that turns the router on: `auto/<provider>` runs
 * each piece of work on that provider's ladder, `auto/router` on the Pullfrog
 * Router's. distinct from the two managed tiers above, which each name ONE
 * model. a Pro feature — outside Pro the sentinel simply runs its ladder's
 * `standard` rung, which is what `resolveDisplayAlias` resolves it to.
 */
export const AUTO_ROUTER = "auto/router";
const AUTO_PREFIX = "auto/";

/** the ladder an Auto sentinel routes on; undefined for a tier or a pin */
export function autoLadder(slug: string | null | undefined): Ladder | undefined {
  if (!slug || !slug.startsWith(AUTO_PREFIX) || isAutoTier(slug)) return undefined;
  const target = slug.slice(AUTO_PREFIX.length);
  return target === "router" ? ROUTER_LADDER : PROVIDER_LADDERS[target];
}

export function isAutoRouted(slug: string | null | undefined): boolean {
  return autoLadder(slug) !== undefined;
}

/** any `auto/*` sentinel — a managed tier or the router — as opposed to a concrete pin */
export function isAutoSlug(slug: string | null | undefined): boolean {
  return isAutoTier(slug) || isAutoRouted(slug);
}

export function autoRoutedSlug(provider: string): string {
  return `${AUTO_PREFIX}${provider}`;
}

/** the providers a BYOK repo can put on Auto — exactly the ones with a ladder */
export let ROUTED_PROVIDERS: readonly { key: string; displayName: string }[];

/**
 * a ladder's distinct models in ascending order, each with the tiers it
 * serves — the console's "what Auto runs on" list, where a model that covers
 * two tiers is one row.
 */
export function ladderRungs(ladder: Ladder): { slug: string; tiers: RouterTier[] }[] {
  const rungs: { slug: string; tiers: RouterTier[] }[] = [];
  for (const tier of ROUTER_TIERS) {
    const last = rungs[rungs.length - 1];
    if (last?.slug === ladder[tier]) last.tiers.push(tier);
    else rungs.push({ slug: ladder[tier], tiers: [tier] });
  }
  return rungs;
}

/**
 * the model a routed run takes: the tier's rung on the ladder the configured
 * Auto sentinel names. undefined for a pin — a pin opts out of routing.
 */
export function resolveRoutedModel(params: {
  configured: string | null | undefined;
  tier: RouterTier;
}): string | undefined {
  return autoLadder(params.configured)?.[params.tier];
}

/**
 * Router-resolvable — the set a card on file unlocks. custom (non-Auto) picks
 * are card-gated wholesale on the Router: the console locks the Custom tab
 * without a card and the server clamps any stored pick to the efficient tier.
 * a pick with no openRouterResolve (a model OpenRouter doesn't serve yet)
 * lands on the efficient default with or without a card, so "add a card to
 * run this model" messaging would be false for it — hence the predicate.
 * resolveOpenRouterModel walks display aliases, so auto sentinels and
 * deprecated slugs are judged by the model that actually runs.
 */
export function isCardGatedModel(slug: string): boolean {
  return resolveOpenRouterModel(slug) !== undefined;
}

// ── resolution ─────────────────────────────────────────────────────────────────

/** resolve a model slug to its concrete models.dev specifier (e.g. "anthropic/claude-opus-4-6") */
export function resolveModelSlug(slug: string): string | undefined {
  return modelAliases.find((a) => a.slug === slug)?.resolve;
}

const MAX_FALLBACK_DEPTH = 10;

/**
 * walk the fallback chain to the terminal (non-deprecated) alias.
 * returns undefined if the chain is broken, exhausted, or cyclic.
 *
 * use this in UI display sites (dropdown trigger labels, PR-comment footers,
 * etc.) so a deprecated stored slug renders as the model the user actually
 * runs against — not the historical name. selectable lists should still hide
 * deprecated and internal-only aliases by filtering on `!a.fallback && !a.hidden`.
 */
export function resolveDisplayAlias(slug: string): ModelAlias | undefined {
  // auto sentinels aren't real aliases — map them to their concrete target
  // first so CLI/OpenRouter resolution and display labels all work. a router
  // sentinel stands for its ladder's standard rung wherever no tier is known.
  let current = isAutoTier(slug) ? AUTO_TIER_TARGET[slug] : (autoLadder(slug)?.standard ?? slug);
  const visited = new Set<string>();
  for (let i = 0; i < MAX_FALLBACK_DEPTH; i++) {
    if (visited.has(current)) return undefined;
    visited.add(current);
    const alias = modelAliases.find((a) => a.slug === current);
    if (!alias) return undefined;
    if (!alias.fallback) return alias;
    current = alias.fallback;
  }
  return undefined;
}

/**
 * resolve a model slug to the CLI-ready model string, following the fallback
 * chain when a model is deprecated. returns the first non-deprecated resolve
 * target, or undefined if the chain is exhausted or broken.
 */
export function resolveCliModel(slug: string): string | undefined {
  return resolveDisplayAlias(slug)?.resolve;
}

/**
 * resolve a model slug to the OpenRouter-ready model string, following the
 * fallback chain when a model is deprecated. returns undefined if the chain
 * is exhausted/broken or the terminal alias has no openrouter equivalent
 * (e.g. free opencode models).
 */
export function resolveOpenRouterModel(slug: string): string | undefined {
  return resolveDisplayAlias(slug)?.openRouterResolve;
}

// ── effort ─────────────────────────────────────────────────────────────────────

/**
 * the effort rungs `slug` accepts on the route being used, or undefined when the
 * model has no effort control at all. walks the fallback chain, so a deprecated
 * slug is judged by the model that actually runs. drives both the console's
 * level list and the runtime clamp, so the two can't disagree.
 */
export function getModelEffortLevels(params: {
  slug: string;
  useOpenRouter: boolean;
}): readonly string[] | undefined {
  const alias = resolveDisplayAlias(params.slug);
  if (!alias) return undefined;
  if (params.useOpenRouter) return alias.openRouterEffort ?? alias.effort;
  return alias.effort;
}

/**
 * the rung a run actually sends: a position landed on this model's own published
 * ladder. the result is always a rung the model offers, so there is no arithmetic
 * anywhere that can produce a level it doesn't have. undefined means the model
 * publishes no rungs and the harness gets no flag.
 */
export function resolveModelRung(params: {
  slug: string;
  position: EffortPosition;
  useOpenRouter: boolean;
}): string | undefined {
  const published = getModelEffortLevels(params);
  if (!published) return undefined;
  return resolveRung({ position: params.position, published });
}

// ── default proxy model ──────────────────────────────────────────────────────────

/**
 * OpenRouter target when Router or OSS funding is active and `repo.model` is null.
 * resolved through the efficient tier's fallback chain, so if that tier's backing
 * model is given a `fallback` (e.g. to ride out a temporary outage) the OSS/Router
 * default follows the substitute instead of pinning the unavailable model.
 */
export let DEFAULT_PROXY_MODEL: string;
let defaultProxyDisplayName: string;

/**
 * what a Pullfrog-funded trial run falls back to. apart from `DEFAULT_PROXY_MODEL` because the
 * trial is capped at `TRIAL_FUNDED_USD_LIMIT` per account, so first-impression quality outranks price.
 * see wiki/trial-model-fallback.md.
 */
export let TRIAL_PROXY_MODEL: string;
export let TRIAL_PROXY_DISPLAY_NAME: string;
/** lifetime cap, in USD, on the model usage Pullfrog funds for an account's trial runs. */
export const TRIAL_FUNDED_USD_LIMIT = 10;

// every router ladder rung must be a live alias, for the same reason the proxy default is checked —
// and the rungs must reach two models, or the ladder routes nothing (a cheap rung whose alias
// falls back to the top rung passed the first check while every tier ran the same model)
function checkLadders(): void {
  for (const [name, ladder] of Object.entries({ ...PROVIDER_LADDERS, router: ROUTER_LADDER })) {
    const terminal = new Set<string>();
    for (const slug of Object.values(ladder)) {
      const alias = resolveDisplayAlias(slug);
      if (!alias) throw new Error(`${name} ladder names unknown alias ${slug}`);
      terminal.add(alias.slug);
    }
    if (terminal.size < 2) throw new Error(`${name} ladder runs every tier on ${[...terminal][0]}`);
  }
}

// ── OSS allowlist ──────────────────────────────────────────────────────────────

/**
 * the models Pullfrog is willing to fund on the OSS program. an accepted OSS
 * repo may pick any of these and run-context honors the pick; anything else
 * falls back to `DEFAULT_PROXY_MODEL`, so a subsidized run can never land on a
 * frontier model.
 *
 * membership is a spend decision, so it lives here as a literal rather than a
 * DB toggle — changing it is a deploy, which is the right friction. costs below
 * are modelled on the measured OSS token mix, where cache reads are ~87.5% of
 * input and list price therefore ranks models WRONG: Luna 0.39x, Flash 0.45x,
 * Pro 1.00x, MiniMax M2 1.30x.
 *
 * Kimi K2 was funded and then dropped: 13.2x the funded default as MEASURED
 * over 2,203 real runs ($0.4782 vs $0.0362), almost entirely on its cache-read
 * RATE — 8.5x Flash's. it also fails 2.9% of runs against Flash's 0.0%. best
 * achievable was 10.9x (cheapest OpenRouter endpoint), so no routing fixes it.
 *
 * DeepSeek Pro was funded and then dropped on 2026-09-11: 88% of OSS spend at
 * $0.3117/run authoritative over 4,795 runs (30d), against Flash's $0.0531 —
 * and DeepSeek's V4.1-Flash release says it beats V4-Pro and phases Pro out.
 * a subsidised Pro pick now runs V4.1 Flash; BYOK and Router Pro are untouched.
 * re-add it only after V4.1-Pro ships AND is priced on a real run.
 * see wiki/oss-model-allowlist.md.
 */
// MiniMax has no direct-vendor block (it ships only through the routers), so
// it is listed under `openrouter/` where every other entry uses its vendor.
//
// CHANGING THIS LIST CHANGES PUBLIC COPY. four surfaces promise maintainers a
// menu in prose, and a stale one is a promise we don't keep. two now read the
// names from here via `ossFundedModelNames` and follow an edit on their own —
// but the sentences AROUND them are hand-written, so re-read all four:
//   - `app/for-oss/page.tsx` (derived) — the pitch + the OG/meta blurb.
//   - `emails/ossAccepted.ts` (derived) — what an accepted maintainer holds us to.
//   - `docs/models.mdx` (hand-written) — the "Pullfrog for OSS" table.
//   - `app/blog/ModelUsageSection.tsx` (hand-written) — the pricing-post aside.
// the two derived ones are derived because #1190 updated the other two and
// missed these, leaving the application page selling one model the program had
// stopped defaulting to, for two allowlist edits running.
// ordered by Artificial Analysis Intelligence Index, highest first (2026-10-06:
// Muse Spark 1.3 48, DeepSeek V4.1 Flash 39, GPT-6 Luna 38; 2026-10-30:
// MiniMax M3.1 Flash 42), so every surface that lists the menu reads best-first.
export const OSS_MODEL_ALLOWLIST: readonly string[] = [
  // opt-in: 0.86x the old default on the measured mix, and Meta trains on the
  // traffic — fine for public code on headless runs, the human decided
  // 2026-09-11. unmeasured on a real run; not the default until it is.
  "meta/muse-spark-contributor",
  // replaced MiniMax M2 (M2.7, 3-4x the default) on 2026-10-30; no OSS repo held M2.
  "openrouter/minimax-flash",
  "deepseek/deepseek-flash",
  "openai/gpt-luna",
];

/** the pick the console badges. DERIVED from the efficient tier rather than
 * restated, so the badge cannot drift from the model that actually runs — a
 * second literal here would be an invariant nothing enforces. */
export const OSS_RECOMMENDED_MODEL = AUTO_TIER_TARGET[AUTO_EFFICIENT];

/**
 * display names of the funded set, in allowlist order. the single place every
 * surface that NAMES the menu in prose reads it from — the application page,
 * the acceptance email, the model-access error — so none of them can promise a
 * model the program stopped funding. callers format the list themselves; this
 * is a catalog, not copy.
 */
export function ossFundedModelNames(): string[] {
  return OSS_MODEL_ALLOWLIST.map((slug) => resolveDisplayAlias(slug)?.displayName ?? slug);
}

/**
 * the OpenRouter targets the allowlist admits. keying on the target rather than
 * the slug is what makes `openrouter/deepseek-flash` and `deepseek/deepseek-flash`
 * the same funded model — both spellings are stored in the wild.
 */
let ossAllowedTargets: Set<string>;

/** rebuilds everything read off `providers` at load, throwing where a registry would break a run */
function deriveFromProviders(): void {
  const efficient = resolveDisplayAlias(AUTO_EFFICIENT);
  if (!efficient?.openRouterResolve) {
    throw new Error(`DEFAULT_PROXY_MODEL: ${AUTO_EFFICIENT} has no openRouterResolve`);
  }
  const trial = resolveDisplayAlias("openai/gpt-luna");
  if (!trial?.openRouterResolve) {
    throw new Error("TRIAL_PROXY_MODEL: openai/gpt-luna has no openRouterResolve");
  }
  checkLadders();
  const allowed = OSS_MODEL_ALLOWLIST.map((slug) => {
    const target = resolveOpenRouterModel(slug);
    if (!target) throw new Error(`OSS_MODEL_ALLOWLIST: ${slug} has no openRouterResolve`);
    return target;
  });
  DEFAULT_PROXY_MODEL = efficient.openRouterResolve;
  defaultProxyDisplayName = efficient.displayName;
  TRIAL_PROXY_MODEL = trial.openRouterResolve;
  TRIAL_PROXY_DISPLAY_NAME = trial.displayName;
  ossAllowedTargets = new Set(allowed);
  ROUTED_PROVIDERS = Object.keys(PROVIDER_LADDERS).map((key) => ({
    key,
    displayName: providers[key]?.displayName ?? key,
  }));
}
deriveFromProviders();

/**
 * Swaps in the server's copy of `providers`, so a merged registry change reaches runs at deploy
 * rather than at the next release. A copy this build can't derive from (an alias its proxy
 * defaults, ladders or OSS allowlist need is gone) is refused, and the bundled one stays.
 */
export function installProviders(next: Record<string, ProviderConfig>): void {
  const previous = providers;
  try {
    providers = next;
    modelAliases = flattenAliases(next);
    deriveFromProviders();
  } catch (error) {
    providers = previous;
    modelAliases = flattenAliases(previous);
    deriveFromProviders();
    throw error;
  }
}

/**
 * whether an OSS repo's stored pick is one the program funds. resolves through
 * the fallback chain first, so `auto/*` sentinels and deprecated slugs are
 * judged by the model that actually runs.
 */
export function isOssAllowedModel(slug: string | null | undefined): slug is string {
  if (!slug) return false;
  const target = resolveOpenRouterModel(slug);
  return target !== undefined && ossAllowedTargets.has(target);
}

/** short label for the model auto-select picks today (console hint copy). */
export function getAutoSelectHintModel(): string {
  return defaultProxyDisplayName;
}

// ── bedrock routing ────────────────────────────────────────────────────────────

/** env var that supplies the Bedrock model ID for the `bedrock/byok` slug. */
export const BEDROCK_MODEL_ID_ENV = "BEDROCK_MODEL_ID";

/** env var that supplies the Vertex AI model ID for the `vertex/byok` slug. */
export const VERTEX_MODEL_ID_ENV = "VERTEX_MODEL_ID";

/** provider key + slug prefix for the Azure OpenAI BYOK backend. matches opencode's own provider id. */
export const AZURE_PROVIDER = "azure";
/** resource name in the endpoint `https://<name>.openai.azure.com` — plain config, not a credential. */
export const AZURE_RESOURCE_NAME_ENV = "AZURE_RESOURCE_NAME";
/** API key for the Azure OpenAI resource — the one sensitive value. */
export const AZURE_API_KEY_ENV = "AZURE_API_KEY";
/**
 * the customer's Azure deployment name, supplied for the `azure/byok` slug.
 * Azure takes the deployment name where every other provider takes a model id
 * (`@ai-sdk/azure` types the argument `deploymentId`), and the name is whatever
 * its creator typed — which is why Azure gets a routing slug instead of the
 * catalog entries models.dev publishes for it.
 */
export const AZURE_DEPLOYMENT_ENV = "AZURE_DEPLOYMENT";
/**
 * context-window size of the model behind the deployment. required for the same
 * reason as its `OPENAI_COMPATIBLE_*` twin: opencode holds no metadata for a
 * deployment name, and an undeclared limit both caps completions at 32000 and
 * disables auto-compaction. see `azureProvider()` in agents/opencodeShared.ts.
 */
export const AZURE_CONTEXT_ENV = "AZURE_CONTEXT";
/** max completion tokens the model behind the deployment accepts. required. */
export const AZURE_MAX_OUTPUT_ENV = "AZURE_MAX_OUTPUT";
/**
 * opt this deployment into Chat Completions. opencode's azure loader calls
 * `sdk.responses(modelID)` unless its `useCompletionUrls` option is set
 * (`selectAzureLanguageModel`), so a deployment serving a model that predates
 * Azure's Responses API — gpt-4, gpt-35-turbo — fails on the wire dialect
 * rather than on anything we control. optional, and the only value here that
 * isn't required.
 */
export const AZURE_USE_CHAT_COMPLETIONS_ENV = "AZURE_USE_CHAT_COMPLETIONS";

/** provider key + slug prefix for the generic OpenAI-compatible BYOK backend. */
export const OPENAI_COMPATIBLE_PROVIDER = "openai-compatible";
/** base URL of the user's OpenAI-compatible endpoint (e.g. a Cloudflare AI Gateway URL). */
export const OPENAI_COMPATIBLE_BASE_URL_ENV = "OPENAI_COMPATIBLE_BASE_URL";
/** API key/token for the user's OpenAI-compatible endpoint — the one sensitive secret. */
export const OPENAI_COMPATIBLE_API_KEY_ENV = "OPENAI_COMPATIBLE_API_KEY";
/** model ID served by the endpoint, supplied for the `openai-compatible/byok` slug. */
export const OPENAI_COMPATIBLE_MODEL_ENV = "OPENAI_COMPATIBLE_MODEL";
/**
 * context-window size of the endpoint's model. required — `validateOpenAICompatibleSetup`
 * rejects the run pre-agent when it's unset or non-numeric. it also gates
 * auto-compaction: opencode's `isOverflow` short-circuits when
 * `limit.context === 0`, which would otherwise let a long session grow until the
 * endpoint rejects it on context length.
 */
export const OPENAI_COMPATIBLE_CONTEXT_ENV = "OPENAI_COMPATIBLE_CONTEXT";
/**
 * max completion tokens the endpoint's model accepts. required — see
 * OPENAI_COMPATIBLE_CONTEXT_ENV. opencode has no models.dev metadata for a
 * user-supplied endpoint, and an undeclared limit makes it send
 * `max_tokens: 32000`, which most models reject outright (gpt-4o and gpt-4o-mini
 * cap at 16384, many open models at 4096/8192). opencode's `limit` requires
 * `context` + `output` together, so the pair is validated and emitted as a unit.
 */
export const OPENAI_COMPATIBLE_MAX_OUTPUT_ENV = "OPENAI_COMPATIBLE_MAX_OUTPUT";

/**
 * a NAMED OpenAI-compatible endpoint: the slug `openai-compatible/<name>` runs on the five
 * `OPENAI_COMPATIBLE_<NAME>_*` values, so one account holds several endpoints and each repo,
 * trigger or `--model=` picks one. `byok` stays the unnamed default on the bare names. 32 is
 * what keeps `OPENAI_COMPATIBLE_<NAME>_MAX_OUTPUT` under the 64-character secret-name cap.
 */
export function isOpenAICompatibleSetName(name: string): boolean {
  return name !== "byok" && name.length <= 32 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name);
}

/**
 * the set a slug names, by shape alone. `openai-compatible/<x>` is also a raw specifier (model
 * `x` on the default endpoint), so a runner treats it as a set only when it holds that set's values.
 */
export function openAICompatibleSetName(slug: string): string | undefined {
  const prefix = `${OPENAI_COMPATIBLE_PROVIDER}/`;
  if (!slug.startsWith(prefix)) return undefined;
  const name = slug.slice(prefix.length);
  return isOpenAICompatibleSetName(name) ? name : undefined;
}

export const OPENAI_COMPATIBLE_FIELDS = [
  "baseUrl",
  "apiKey",
  "model",
  "context",
  "maxOutput",
  "headers",
] as const;

/** the env var names one endpoint is configured by: a named set's, or the default's. */
export function openAICompatibleEnv(
  name?: string
): Record<(typeof OPENAI_COMPATIBLE_FIELDS)[number], string> {
  const prefix = `OPENAI_COMPATIBLE_${name ? `${name.toUpperCase().replaceAll("-", "_")}_` : ""}`;
  return {
    baseUrl: `${prefix}BASE_URL`,
    apiKey: `${prefix}API_KEY`,
    model: `${prefix}MODEL`,
    context: `${prefix}CONTEXT`,
    maxOutput: `${prefix}MAX_OUTPUT`,
    headers: `${prefix}HEADERS`,
  };
}

/** a named set's credential vars, its counterpart of the provider's `envVars`; `[]` for any other slug. */
function openAICompatibleSetEnvVars(slug: string): string[] {
  const set = openAICompatibleSetName(slug);
  if (!set) return [];
  const env = openAICompatibleEnv(set);
  return [env.baseUrl, env.apiKey, env.model];
}

/** the set a variable belongs to: `OPENAI_COMPATIBLE_VLLM_A_API_KEY` → `vllm-a`. */
export function openAICompatibleSetOf(envName: string): string | undefined {
  const match =
    /^OPENAI_COMPATIBLE_([A-Z0-9]+(?:_[A-Z0-9]+)*)_(?:BASE_URL|API_KEY|MODEL|CONTEXT|MAX_OUTPUT|HEADERS)$/.exec(
      envName
    );
  const name = match?.[1]?.toLowerCase().replaceAll("_", "-");
  return name && isOpenAICompatibleSetName(name) ? name : undefined;
}

/** the named endpoints `names` (stored secret or env var names) hold any value of. */
export function openAICompatibleSets(names: string[]): string[] {
  return [...new Set(names.flatMap((name) => openAICompatibleSetOf(name) ?? []))].sort();
}

/** a named endpoint as a picker entry: the default endpoint's routing alias under its own slug. */
export function openAICompatibleSetAlias(set: string): ModelAlias | undefined {
  const byok = resolveDisplayAlias(`${OPENAI_COMPATIBLE_PROVIDER}/byok`);
  return byok && { ...byok, slug: `${OPENAI_COMPATIBLE_PROVIDER}/${set}`, displayName: set };
}

// ── provider gateways ──────────────────────────────────────────────────────────

/**
 * Providers whose endpoint can be re-pointed at a customer gateway, and the env
 * var that does it. Deliberately a short explicit map rather than a derived
 * `<PROVIDER>_BASE_URL` rule: only a provider we have confirmed reachable
 * through a proxy belongs here, and three of them must never be.
 * `openai-compatible` already owns its endpoint via OPENAI_COMPATIBLE_BASE_URL,
 * `azure` derives one from AZURE_RESOURCE_NAME, and bedrock/vertex authenticate
 * against a cloud SDK rather than a URL.
 *
 * models.dev declares no base-URL env var for any catalogued provider (2 of 213
 * do, and neither is one of these), so nothing reads these names on its own —
 * `providerGatewayOverride` is what makes them mean anything to opencode and
 * `gatewayProvider` to codex, while claude-code reads ANTHROPIC_BASE_URL itself.
 *
 * Adding a provider that ALREADY has a block in `buildSecurityConfig` needs a
 * merge, not an entry here: that spread replaces the whole key, so listing
 * `openrouter` would silently drop `kimiOpenRouterProviderOverrides()`.
 */
export const PROVIDER_GATEWAY_URL_ENV: Record<string, string> = {
  anthropic: "ANTHROPIC_BASE_URL",
  openai: "OPENAI_BASE_URL",
};

/** the customer gateway a model's provider is re-pointed at, if any. */
export function getProviderGatewayUrl(specifier: string | undefined): string | undefined {
  const slashIndex = specifier?.indexOf("/") ?? -1;
  if (!specifier || slashIndex <= 0) return undefined;
  const envVar = PROVIDER_GATEWAY_URL_ENV[specifier.slice(0, slashIndex)];
  if (!envVar) return undefined;
  // a trailing slash doubles up against the path opencode joins onto it
  return process.env[envVar]?.trim().replace(/\/$/, "") || undefined;
}

/**
 * The extra request headers a re-pointed provider sends its gateway, scoped per endpoint like its
 * URL. A single variable for every provider would hand a gateway's credential to whichever provider
 * a run falls back to. `ANTHROPIC_CUSTOM_HEADERS` is Claude Code's own name, read natively there.
 */
export const PROVIDER_GATEWAY_HEADERS_ENV: Record<string, string> = {
  anthropic: "ANTHROPIC_CUSTOM_HEADERS",
  openai: "OPENAI_CUSTOM_HEADERS",
};

const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
/** what every harness sends: `fetch` throws on a control character, and Codex drops a header past visible ASCII. */
const HEADER_VALUE = /^[\t\x20-\x7e]*$/;

/**
 * `Name: Value` per line, the format Claude Code reads `ANTHROPIC_CUSTOM_HEADERS` in. `badLine` is
 * the first line that is neither blank nor a header, by number because its text may be a credential.
 */
export function parseCustomHeaders(raw: string): {
  headers: Record<string, string>;
  badLine: number | undefined;
} {
  const headers: Record<string, string> = {};
  for (const [index, line] of raw.split("\n").entries()) {
    if (!line.trim()) continue;
    const colon = line.indexOf(":");
    const name = line.slice(0, colon).trim();
    const value = line.slice(colon + 1).trim();
    if (colon < 0 || !HEADER_NAME.test(name) || !HEADER_VALUE.test(value)) {
      return { headers: {}, badLine: index + 1 };
    }
    headers[name] = value;
  }
  return { headers, badLine: undefined };
}

// ── keyless auth ───────────────────────────────────────────────────────────────

/**
 * The IDs a provider's keyless (workload identity federation) exchange takes,
 * under the provider's own variable names. They are identifiers, not secrets:
 * the credential is the run's GitHub OIDC token. Stored like any secret, and a
 * provider is keyless once every `required` name is set. `audience` is what the
 * GitHub token is minted for, which the provider's rule must match; OpenAI's
 * can be overridden with `OPENAI_WIF_AUDIENCE`. See wiki/keyless-auth.md.
 */
export const KEYLESS_AUTH_ENV: Record<
  string,
  { required: string[]; optional: string[]; audience: string }
> = {
  anthropic: {
    required: [
      "ANTHROPIC_FEDERATION_RULE_ID",
      "ANTHROPIC_ORGANIZATION_ID",
      "ANTHROPIC_SERVICE_ACCOUNT_ID",
    ],
    optional: ["ANTHROPIC_WORKSPACE_ID"],
    audience: "https://api.anthropic.com",
  },
  openai: {
    required: ["OPENAI_IDENTITY_PROVIDER_ID", "OPENAI_SERVICE_ACCOUNT_ID"],
    optional: ["OPENAI_WIF_AUDIENCE"],
    audience: "https://api.openai.com/v1",
  },
};

/**
 * the Bedrock model ID passed to claude-code or opencode is whatever the
 * user set in `BEDROCK_MODEL_ID` — Pullfrog never resolves or upgrades it.
 * we route by checking whether the ID names an Anthropic model: claude-code
 * handles Anthropic-on-Bedrock natively (with `CLAUDE_CODE_USE_BEDROCK=1`),
 * everything else goes through opencode's `amazon-bedrock` provider.
 *
 * AWS Bedrock IDs come in two shapes:
 *   - dotted foundation IDs: `us.anthropic.claude-opus-4-7`,
 *     `anthropic.claude-haiku-4-5-20251001-v1:0`, `amazon.nova-pro-v1:0`,
 *     `meta.llama4-scout-17b-instruct-v1:0`. AWS-published, lowercase, the
 *     foundation provider always appears as a discrete dot-segment.
 *   - inference-profile ARNs: `arn:aws:bedrock:us-east-2:<acct>:application-inference-profile/<user-name>`.
 *     `<user-name>` is operator-chosen, so a naive substring check is fragile
 *     in both directions (Anthropic profile named without "anthropic" → routes
 *     to opencode and misses CLAUDE_CODE_USE_BEDROCK; non-Anthropic profile
 *     whose name happens to contain "anthropic" → routes to claude-code).
 *
 * we anchor on a discrete dot-segment match (case-insensitive). this catches
 * every published foundation ID and is conservative for ARN-form IDs: ARN
 * names that don't include "anthropic" as their own dot-segment route to
 * opencode by default. operators using ARN-form IDs whose backing model is
 * Anthropic should set `PULLFROG_AGENT=claude` to force the right route, or
 * include the foundation segment in the profile name.
 */
export function isBedrockAnthropicId(bedrockModelId: string): boolean {
  // split on `.`, `/`, and `:` so the check works for both dotted foundation
  // IDs (anthropic.* / us.anthropic.*) and ARN-form IDs (where the relevant
  // foundation segment sits between `/` and `.` inside the resource name).
  return bedrockModelId.toLowerCase().split(/[./:]/).includes("anthropic");
}

/**
 * Vertex Anthropic model IDs start with the Claude family name, e.g.
 * `claude-opus-4-1@20250805`. partner-model resource paths can contain the
 * substring "anthropic" elsewhere, so the Bedrock segment check does not
 * transfer — anchor on the model ID prefix instead.
 */
export function isVertexAnthropicId(vertexModelId: string): boolean {
  return /^claude-/i.test(vertexModelId.trim());
}