Files
shockbot/models.ts
T
Colin McDonnell 5caeb75344 review: 0-or-2+ lens rule, parallel-or-bust, downshifted subagent models (#710)
* review: 0-or-2+ lens rule, parallel-or-bust, downshifted subagent models

PR review wall-time was dominated by two failure modes: orchestrator
serial-dispatching subagents (despite prompt asking for parallel) and
running every lens on the same Opus tier as the orchestrator. Sample of
recent runs showed 25-60min reviews on small PRs, with 8-10min idle
gaps between subagent dispatches.

Three changes:

1. `action/modes.ts` — replace the soft "1 trivial / 2-3 typical /
   4-5 high-stakes" lens calibration with a binary 0-or-2+ rule. Default
   is 0 lenses (orchestrator handles review solo with optional cheap
   tracerfrog dispatches). 2+ parallel lenses only fire for substantive
   PRs (>5 files AND >200 lines) or high-stakes-subsystem touches. Never
   exactly one. Both Review and IncrementalReview prompts get loud
   ALL-CAPS framing on parallel dispatch — emit ALL Task tool_use blocks
   in a single assistant turn before reading any result. Drop the
   "do NOT lens-review the diff yourself" advice; orchestrator pulls
   context aggressively, in parallel with the lens fan-out.

2. New `tracerfrog` subagent for mechanical code tracing ("where is X
   used / who calls Y / what depends on Z"). Pure read+grep+report with
   no judgment — orchestrator can dispatch many tracers cheaply in
   parallel. Defined in `action/agents/reviewer.ts`. Wired into both
   claude.ts (`--agents` JSON) and opencode.ts (`agent` config block).

3. Per-subagent model downshifts via `deriveSubagentModels`:
   - Anthropic: reviewfrog → Sonnet, tracerfrog → Haiku
   - OpenAI: both → gpt-5.4-mini
   - other providers (xai, deepseek, gemini, etc.): inherit (no
     standard tier triplet to downshift to)

Claude Code path always runs Anthropic so the downshift is hardcoded
inline in claude.ts. OpenCode uses the helper since orchestrator
provider varies.

Both runtimes' subagent-definition formats verified directly against
their source: `--agents` JSON `model` field (claude-code's
`AgentJsonSchema` accepts model+effort+maxTurns+more) and OpenCode's
`agent.{name}.model` config field (parsed via Provider.parseModel,
applied per-task in tool/task.ts line 92). Parallel dispatch is
infra-supported in both — only the orchestrator model's tool_use
emission pattern was the bottleneck.

Tests: subagentModels.test.ts (14 tests covering provider matrix),
subagentRegistration.test.ts (6 source-asserts catching shape
regressions in buildAgentsJson / buildReviewerAgentConfig).

* subagentModels: add openrouter routes (proxy/router mode)

Initial helper missed the openrouter prefix used by Pullfrog's router
proxy. preview-710 e2e showed the OpenCode + openrouter path receiving
no downshift — orchestrator and lenses both ran on opus-4.7 because
'openrouter/anthropic/claude-opus-4.7' didn't match any of the
anthropic/openai prefixes the helper checked.

Add explicit branches for 'openrouter/anthropic/...' (uses dot notation:
claude-sonnet-4.6 / claude-haiku-4.5) and 'openrouter/openai/...'
(gpt-5.4-mini for both reviewer and tracer). Same opus->sonnet,
sonnet->keep-but-haiku-tracer, haiku->no-op semantics as the direct
anthropic path.

* opencode: log resolved subagent models at startup

So we can verify per-subagent model overrides actually take effect at
runtime. Prints once per run alongside the existing model/effort log
lines.

* drop tracerfrog: keep reviewfrog only, LSP-powered tracer planned later

Removes the cheap-haiku-tracer subagent (TRACER_AGENT_NAME +
TRACER_SYSTEM_PROMPT, registrations in claude.ts/opencode.ts, dispatch
guidance in modes.ts). The mechanical-tracing use case will be served
better by an LSP-powered tool than by a separately-prompted subagent.

deriveSubagentModels collapses to a single { reviewer } shape; the
reviewfrog-on-Sonnet downshift stays. Same source-assert + provider-
matrix tests, minus the tracer-specific cases.

modes.ts wording: drop the 'subagent type cheat sheet' bullet, drop
the parenthetical 'often better served by tracerfrog than reviewfrog'
on the impact lens, drop tracerfrog from the same-turn-context-pulling
hint. The 0-or-2+ rule and ALL-CAPS parallel emphasis are unchanged.

* subagentModels: broader downshift coverage (gpt-pro, gemini-pro, grok); drop gpt-mini target

Scanned every resolved orchestrator slug in action/models.ts against
models.dev pricing data. Identified five clear cases where the
orchestrator is meaningfully expensive AND has a cheaper sibling that
remains capable enough for review-style judgment work.

Changes:
- Anthropic: opus → sonnet  (kept; -40%)
- OpenAI: gpt → gpt-5.4 (was: gpt-mini; -54% instead of -85% but
  preserves review-quality judgment — gpt-mini was too dumb)
- OpenAI: gpt-pro → gpt   (NEW; -93%, biggest single unlock —
  gpt-5.5-pro is $30/Mtok in vs gpt-5.5 at $5)
- Google: gemini-pro → gemini-flash  (NEW; -75%)
- xAI: grok-4.3 → grok-4-1-fast  (NEW; -80%)

Every branch handles the three routes in use: direct provider slug,
opencode-vendored, and openrouter-proxied. Variants below the downshift
target (mini/nano/flash/fast/sonnet/haiku) inherit (no further drop).

Skipped:
- DeepSeek: v4-flash ($0.14/Mtok) is too far below review judgment
  threshold; v4-pro orchestrator already cheap ($0.55 blended).
- Moonshot: kimi-k2-thinking would only save 32% and slug stability on
  OpenRouter is uncertain; revisit if cost matters.
- o3: already mid-tier in OpenAI's reasoning family; no clean target.

* models: hoist subagent downshift into the registry, add hidden flag

The downshift relationship now lives next to each alias's resolve /
openRouterResolve as a sibling field. Two new ModelDef fields:

- subagentModel?: string — alias key (within same provider) of the
  cheaper sibling reviewfrog should use as a lens-fanout subagent.
  e.g. claude-opus → 'claude-sonnet'.
- hidden?: boolean — exclude from selectable lists (UI dropdown,
  CLI init picker). Does NOT affect resolution; for that use
  fallback. Used so internal-only subagent targets like openai/gpt-5.4
  exist in the registry but never appear as a user-facing pick.

Wiring:
- anthropic.claude-opus → claude-sonnet (-40%)
- openai.gpt-pro → gpt (-93%, biggest unlock)
- openai.gpt → gpt-5.4 (-54%); gpt-5.4 added with hidden:true
- google.gemini-pro → gemini-flash (-75%)
- mirrored across opencode + openrouter providers (each provider
  declares its own three-route data so the downshift declaration
  is colocated with the rest of the alias definition).

deriveSubagentModels collapses from ~85 lines of prefix-matching to
a ~15-line registry reverse-lookup: find the alias whose resolve OR
openRouterResolve matches the orchestrator's spec, follow its
subagentModel pointer, return the matching field of the target alias.

Filter sites updated:
- components/ModelSelector.tsx: !a.fallback && !a.hidden
- action/commands/init.ts:       same

Tests rewritten to exercise the registry through the public surface;
the matrix collapses to one assertion per (provider × route) pair.

* TEMP: log per-step cost+tokens for subagent model verification (PR #710)

* TEMP: also log SUBAGENT step_finish from bus envelope handler

* remove temporary per-step diagnostic logs (verification done)

Verified subagent model downshift takes effect end-to-end on the OpenCode
+ openrouter path. PR #8 in pullfrog/preview-710-review-perf dispatched
3 lenses (billing-subsystem / security / correctness) on the orchestrator's
opus-4.7 session, and per-subagent step_finish events showed actual cost
exactly matching Sonnet pricing rates (60% of what Opus would have cost):

  session       n  actual    if-Opus   if-Sonnet  match
  T3VrUuF...    5  $0.2425   $0.4042   $0.2425    Sonnet ✓
  93ZZR7E...    4  $0.2253   $0.3754   $0.2253    Sonnet ✓
  Fb1Kr7b...    4  $0.2495   $0.4158   $0.2495    Sonnet ✓

The startup '» subagent models: reviewfrog=...' line stays — useful
permanent diagnostic showing the resolved subagent model per-run.

* TEMP: log per-event model from claude.ts assistant handler

* remove temporary per-event model log (claude.ts verification done)

Verified subagent model downshift takes effect end-to-end on the Claude
Code path. PR #9 in pullfrog/preview-710-review-perf dispatched 2 lenses
on an opus-4-7 orchestrator. Per-assistant-event model field from the
SDK's stream-json output, partitioned by parent_tool_use_id:

  ORCH (parent_tool_use_id=null):  17 events all model=claude-opus-4-7
  SUBAGENT lens:billing-subsystem: 17 events all model=claude-sonnet-4-6
  SUBAGENT lens:security:          21 events all model=claude-sonnet-4-6

Zero leakage to opus from either subagent session. The per-subagent
'model' field in --agents JSON is honored by claude-code at the SDK
level, identical to the OpenCode path verified earlier.

* opencode: bump per-call output cap 5K → 16K to unblock large reviews

The 5K cap (added in #616 to lower OpenRouter upfront budget reservation
for low-wallet runs) was capping the entire response of a single LLM call,
not just the budget reservation. A single tool_use response — like a
`create_pull_request_review` with many inline comments — would truncate
mid-stream past 5K output tokens, leave the JSON unparseable, and the tool
would never actually invoke. We hit this on PR #710's verify-downshift PR:
review aggregated from 3 lenses had 11 inline comments + a long body,
truncated at out=5000 on every retry attempt, action exited with 'Review
mode finished without calling create_pull_request_review after 3 retry
attempts'.

Investigated whether OpenCode (or OpenAI/Anthropic/OpenRouter directly)
exposes a separate budget-reservation parameter that could stay small
while letting the response exceed it. They don't — `max_tokens` /
`max_completion_tokens` is the single value all four use for both the
upfront reservation and the hard output ceiling. No way to decouple them
at the API surface.

Bumped to 16K as a middle ground: 8× the prior cap (handles every review
shape we've observed plus headroom), still half of OpenCode's 32K default
so the wallet-burn benefit for low-balance accounts is preserved, just
smaller. For Opus 4.7 a typical ~50K-input call now reserves roughly
$0.65 instead of the prior $0.38.

Updated the constant comment to spell out the trade-off clearly so this
doesn't happen again.

* opencode: drop OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX override entirely

Verified the original rationale for the override is obsolete. From #616
the cap shrunk OpenRouter's per-call upfront budget reservation so a
single call's reservation wouldn't exceed the per-run key cap
(`ROUTER_PER_RUN_LIMIT_USD = 25`) and lock low-balance accounts out of
starting a run.

That per-run gate is gone. `app/api/proxy-token/route.ts` ~line 422
explicitly says: 'No upper cap (the old ROUTER_PER_RUN_LIMIT_USD = 25 is
gone). The natural ceiling is whatever the user has + their buffer.'
Router now mints keys with `keyLimitCents = balance + buffer` ($50 for
autoreload+card, $5 for card-only, $0 for no-card). A single call's
upfront reservation fits comfortably within that — no separate per-call
gate to fail past.

The cap had a real downside as a hard per-call output truncation. A
single `create_pull_request_review` tool_use with many inline comments
would truncate mid-stream past 5K output tokens, the JSON would be
unparseable, and the tool never invoked. Hit on PR #710's
verify-downshift PR.

Removing the override entirely; OpenCode falls back to its 32K default.
Left an explanatory note above the env-var assignment site so the next
person doesn't unknowingly re-add it.
2026-05-13 21:05:52 +00:00

540 lines
19 KiB
TypeScript

/**
* 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.
*/
// ── types ──────────────────────────────────────────────────────────────────────
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;
/** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */
resolve: string;
/** full models.dev specifier for the OpenRouter equivalent (undefined for free models) */
openRouterResolve: string | undefined;
/** top-tier pick for this provider — preferred during auto-select */
preferred: boolean;
/** whether this alias is free and requires no API key */
isFree: boolean;
/** slug of a replacement model — presence implies this model is deprecated */
fallback: string | 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;
/** hide from selectable lists (UI dropdowns, CLI pickers). does NOT affect
* resolution — for that use `fallback`. used for internal-only tier targets
* (e.g. gpt-5.4 as a subagent target without exposing it to users). */
hidden: boolean;
}
interface ModelDef {
displayName: 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;
preferred?: boolean;
envVars?: readonly string[];
isFree?: boolean;
/** slug of a replacement model — presence implies this model is deprecated */
fallback?: string;
/** 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;
/** hide from selectable lists. does NOT affect resolution; for that use `fallback`. */
hidden?: boolean;
}
export interface ProviderConfig {
displayName: string;
envVars: readonly string[];
models: Record<string, ModelDef>;
}
// ── provider + model definitions ────────────────────────────────────────────────
function provider(config: ProviderConfig): ProviderConfig {
return config;
}
export const providers = {
anthropic: provider({
displayName: "Anthropic",
envVars: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
models: {
"claude-opus": {
displayName: "Claude Opus",
resolve: "anthropic/claude-opus-4-7",
openRouterResolve: "openrouter/anthropic/claude-opus-4.7",
preferred: true,
subagentModel: "claude-sonnet",
},
"claude-sonnet": {
displayName: "Claude Sonnet",
resolve: "anthropic/claude-sonnet-4-6",
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6",
},
"claude-haiku": {
displayName: "Claude Haiku",
resolve: "anthropic/claude-haiku-4-5",
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5",
},
},
}),
openai: provider({
displayName: "OpenAI",
envVars: ["OPENAI_API_KEY"],
models: {
gpt: {
displayName: "GPT",
resolve: "openai/gpt-5.5",
openRouterResolve: "openrouter/openai/gpt-5.5",
preferred: true,
subagentModel: "gpt-5.4",
},
"gpt-pro": {
displayName: "GPT Pro",
resolve: "openai/gpt-5.5-pro",
openRouterResolve: "openrouter/openai/gpt-5.5-pro",
subagentModel: "gpt",
},
// hidden subagent target — `gpt` lenses run against this. surfacing
// it in the picker would just confuse users (it's the prior-flagship,
// and they already have `gpt` and `gpt-mini` to choose from).
"gpt-5.4": {
displayName: "GPT 5.4",
resolve: "openai/gpt-5.4",
openRouterResolve: "openrouter/openai/gpt-5.4",
hidden: true,
},
"gpt-mini": {
displayName: "GPT Mini",
resolve: "openai/gpt-5.4-mini",
openRouterResolve: "openrouter/openai/gpt-5.4-mini",
},
// 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" / "GPT Mini", 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",
},
"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-mini",
},
o3: {
displayName: "O3",
resolve: "openai/o3",
},
},
}),
google: provider({
displayName: "Google",
envVars: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
models: {
"gemini-pro": {
displayName: "Gemini Pro",
resolve: "google/gemini-3.1-pro-preview",
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
preferred: true,
subagentModel: "gemini-flash",
},
"gemini-flash": {
displayName: "Gemini Flash",
resolve: "google/gemini-3-flash-preview",
openRouterResolve: "openrouter/google/gemini-3-flash-preview",
},
},
}),
xai: provider({
displayName: "xAI",
envVars: ["XAI_API_KEY"],
models: {
grok: {
displayName: "Grok",
resolve: "xai/grok-4.3",
openRouterResolve: "openrouter/x-ai/grok-4.3",
preferred: true,
},
"grok-fast": {
displayName: "Grok Fast",
resolve: "xai/grok-4-1-fast",
openRouterResolve: "openrouter/x-ai/grok-4.1-fast",
},
"grok-code-fast": {
displayName: "Grok Code Fast",
resolve: "xai/grok-code-fast-1",
openRouterResolve: "openrouter/x-ai/grok-code-fast-1",
},
},
}),
deepseek: provider({
displayName: "DeepSeek",
envVars: ["DEEPSEEK_API_KEY"],
models: {
"deepseek-pro": {
displayName: "DeepSeek Pro",
resolve: "deepseek/deepseek-v4-pro",
openRouterResolve: "openrouter/deepseek/deepseek-v4-pro",
preferred: true,
},
"deepseek-flash": {
displayName: "DeepSeek Flash",
resolve: "deepseek/deepseek-v4-flash",
openRouterResolve: "openrouter/deepseek/deepseek-v4-flash",
},
// 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: {
"kimi-k2": {
displayName: "Kimi K2",
resolve: "moonshotai/kimi-k2.6",
openRouterResolve: "openrouter/moonshotai/kimi-k2.6",
preferred: true,
},
},
}),
opencode: provider({
displayName: "OpenCode",
envVars: ["OPENCODE_API_KEY"],
models: {
"big-pickle": {
displayName: "Big Pickle",
resolve: "opencode/big-pickle",
preferred: true,
envVars: [],
isFree: true,
},
"claude-opus": {
displayName: "Claude Opus",
resolve: "opencode/claude-opus-4-7",
openRouterResolve: "openrouter/anthropic/claude-opus-4.7",
subagentModel: "claude-sonnet",
},
"claude-sonnet": {
displayName: "Claude Sonnet",
resolve: "opencode/claude-sonnet-4-6",
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6",
},
"claude-haiku": {
displayName: "Claude Haiku",
resolve: "opencode/claude-haiku-4-5",
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5",
},
gpt: {
displayName: "GPT",
resolve: "opencode/gpt-5.5",
openRouterResolve: "openrouter/openai/gpt-5.5",
subagentModel: "gpt-5.4",
},
"gpt-pro": {
displayName: "GPT Pro",
resolve: "opencode/gpt-5.5-pro",
openRouterResolve: "openrouter/openai/gpt-5.5-pro",
subagentModel: "gpt",
},
// hidden subagent target — see openai provider above for context.
"gpt-5.4": {
displayName: "GPT 5.4",
resolve: "opencode/gpt-5.4",
openRouterResolve: "openrouter/openai/gpt-5.4",
hidden: true,
},
"gpt-mini": {
displayName: "GPT Mini",
resolve: "opencode/gpt-5.4-mini",
openRouterResolve: "openrouter/openai/gpt-5.4-mini",
},
// 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",
},
"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-mini",
},
"gemini-pro": {
displayName: "Gemini Pro",
resolve: "opencode/gemini-3.1-pro",
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
subagentModel: "gemini-flash",
},
"gemini-flash": {
displayName: "Gemini Flash",
resolve: "opencode/gemini-3-flash",
openRouterResolve: "openrouter/google/gemini-3-flash-preview",
},
"kimi-k2": {
displayName: "Kimi K2",
resolve: "opencode/kimi-k2.6",
openRouterResolve: "openrouter/moonshotai/kimi-k2.6",
},
"gpt-5-nano": {
displayName: "GPT Nano",
resolve: "opencode/gpt-5-nano",
openRouterResolve: "openrouter/openai/gpt-5-nano",
},
"mimo-v2-pro-free": {
displayName: "MiMo V2 Pro",
resolve: "opencode/mimo-v2-pro-free",
envVars: [],
isFree: true,
fallback: "opencode/big-pickle",
},
"minimax-m2.5-free": {
displayName: "MiniMax M2.5",
resolve: "opencode/minimax-m2.5-free",
envVars: [],
isFree: true,
},
},
}),
openrouter: provider({
displayName: "OpenRouter",
envVars: ["OPENROUTER_API_KEY"],
models: {
"claude-opus": {
displayName: "Claude Opus",
resolve: "openrouter/anthropic/claude-opus-4.7",
openRouterResolve: "openrouter/anthropic/claude-opus-4.7",
preferred: true,
subagentModel: "claude-sonnet",
},
"claude-sonnet": {
displayName: "Claude Sonnet",
resolve: "openrouter/anthropic/claude-sonnet-4.6",
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6",
},
"claude-haiku": {
displayName: "Claude Haiku",
resolve: "openrouter/anthropic/claude-haiku-4.5",
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5",
},
gpt: {
displayName: "GPT",
resolve: "openrouter/openai/gpt-5.5",
openRouterResolve: "openrouter/openai/gpt-5.5",
subagentModel: "gpt-5.4",
},
"gpt-pro": {
displayName: "GPT Pro",
resolve: "openrouter/openai/gpt-5.5-pro",
openRouterResolve: "openrouter/openai/gpt-5.5-pro",
subagentModel: "gpt",
},
// hidden subagent target — see openai provider above for context.
"gpt-5.4": {
displayName: "GPT 5.4",
resolve: "openrouter/openai/gpt-5.4",
openRouterResolve: "openrouter/openai/gpt-5.4",
hidden: true,
},
"gpt-mini": {
displayName: "GPT Mini",
resolve: "openrouter/openai/gpt-5.4-mini",
openRouterResolve: "openrouter/openai/gpt-5.4-mini",
},
// 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",
},
"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-mini",
},
"o4-mini": {
displayName: "O4 Mini",
resolve: "openrouter/openai/o4-mini",
openRouterResolve: "openrouter/openai/o4-mini",
},
"gemini-pro": {
displayName: "Gemini Pro",
resolve: "openrouter/google/gemini-3.1-pro-preview",
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
subagentModel: "gemini-flash",
},
"gemini-flash": {
displayName: "Gemini Flash",
resolve: "openrouter/google/gemini-3-flash-preview",
openRouterResolve: "openrouter/google/gemini-3-flash-preview",
},
grok: {
displayName: "Grok",
resolve: "openrouter/x-ai/grok-4.3",
openRouterResolve: "openrouter/x-ai/grok-4.3",
},
"deepseek-pro": {
displayName: "DeepSeek Pro",
resolve: "openrouter/deepseek/deepseek-v4-pro",
openRouterResolve: "openrouter/deepseek/deepseek-v4-pro",
},
"deepseek-flash": {
displayName: "DeepSeek Flash",
resolve: "openrouter/deepseek/deepseek-v4-flash",
openRouterResolve: "openrouter/deepseek/deepseek-v4-flash",
},
// 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.6",
openRouterResolve: "openrouter/moonshotai/kimi-k2.6",
},
},
}),
} satisfies Record<string, ProviderConfig>;
export type ModelProvider = keyof typeof providers;
// ── slug parsing ───────────────────────────────────────────────────────────────
export function parseModel(slug: string): { provider: string; model: string } {
const slashIdx = slug.indexOf("/");
if (slashIdx === -1) {
throw new Error(`invalid model slug "${slug}" — expected "provider/model"`);
}
return { provider: slug.slice(0, slashIdx), model: slug.slice(slashIdx + 1) };
}
export function getModelProvider(slug: string): string {
return parseModel(slug).provider;
}
export function getProviderDisplayName(slug: string): string | undefined {
const parsed = parseModel(slug);
return (providers as Record<string, ProviderConfig>)[parsed.provider]?.displayName;
}
export function getModelEnvVars(slug: string): string[] {
const parsed = parseModel(slug);
const providerConfig = (providers as Record<string, ProviderConfig>)[parsed.provider];
if (!providerConfig) {
return [];
}
const modelConfig = providerConfig.models[parsed.model];
if (modelConfig?.envVars) {
return modelConfig.envVars.slice();
}
return providerConfig.envVars.slice();
}
// ── derived flat list ──────────────────────────────────────────────────────────
export const modelAliases: ModelAlias[] = Object.entries(providers).flatMap(
([providerKey, config]) =>
Object.entries(config.models).map(([modelId, def]) => ({
slug: `${providerKey}/${modelId}`,
provider: providerKey,
displayName: def.displayName,
resolve: def.resolve,
openRouterResolve: def.openRouterResolve,
preferred: def.preferred ?? false,
isFree: def.isFree ?? false,
fallback: def.fallback,
// 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,
hidden: def.hidden ?? false,
}))
);
// ── 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 {
let current = slug;
const visited = new Set<string>();
for (let i = 0; i < MAX_FALLBACK_DEPTH; i++) {
if (visited.has(current)) return undefined;
visited.add(current);
const alias = modelAliases.find((a) => a.slug === current);
if (!alias) return undefined;
if (!alias.fallback) return alias;
current = alias.fallback;
}
return undefined;
}
/**
* resolve a model slug to the CLI-ready model string, following the fallback
* chain when a model is deprecated. returns the first non-deprecated resolve
* target, or undefined if the chain is exhausted or broken.
*/
export function resolveCliModel(slug: string): string | undefined {
return resolveDisplayAlias(slug)?.resolve;
}
/**
* resolve a model slug to the OpenRouter-ready model string, following the
* fallback chain when a model is deprecated. returns undefined if the chain
* is exhausted/broken or the terminal alias has no openrouter equivalent
* (e.g. free opencode models).
*/
export function resolveOpenRouterModel(slug: string): string | undefined {
return resolveDisplayAlias(slug)?.openRouterResolve;
}