/** * 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/`), 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 ` — 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; } // ── 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; /** the registry every lookup below reads: this file's copy, until `installProviders` swaps in the server's. */ export let providers: Record = 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)[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 = 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 ` — 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 = { 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): 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 = { [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; /** * 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 = { 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/` 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(); 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(); 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; /** 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): 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://.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/` runs on the five * `OPENAI_COMPATIBLE__*` 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__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/` 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 * `_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 = { 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 = { 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; badLine: number | undefined; } { const headers: Record = {}; 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::application-inference-profile/`. * `` 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()); }