ddbc610569
* review prompt: friendly green callouts + per-section severity emojis - Replace `[!NOTE]` informational tier and the no-callout minor-suggestions tier with friendly green blockquotes (`> ✅` / `> 💡`). The two loud tiers (`[!CAUTION]` / `[!IMPORTANT]`) keep their GitHub admonitions. - Add a per-`##`-section severity-emoji rule (🚨/⚠️/💡/ℹ️) for cross-cutting review concerns that don't anchor to a line and would otherwise be buried in summary content. - Drop the `<br/>` between summary sections — heading + blank line carries enough visual spacing. - Skip the post-run learnings-reflection turn for `IncrementalReview`. It's the lowest-novelty mode (delta review against existing PR with prior summary already loaded) and almost never produces durable learnings — reflection there costs ~$0.50-0.80/run for nothing. - Surface real error info on `agent-browser` skill install failures (exit code + stdout + stderr + spawn error). The skills CLI uses a TUI that prints errors to stdout, so the prior stderr-only logging silently swallowed every failure. * review prompt: per-bullet severity emoji + bullets-only sections Section headings are plain again (no leading severity emoji). Severity moves to individual bullets so a section that mixes a 🚨 and a 💡 isn't mislabeled by either. Section bodies are now bullets only — paragraph prose under a heading is harder to scan and tends to bury the actionable point. Bullets can carry indented continuation content (sub-bullets, code fences, blockquotes) by indenting two spaces under the parent. * review prompt: cap section length + identifier discipline Bound each summary section to at most 4 bullets at most 2 lines each, and explicitly call out identifier-heavy prose as an anti-pattern. The reader is often a manager or non-author; identifier-dense paragraphs ('foo calls bar.fetch which dispatches to baz via qux...') are unreadable for them. Default to plain-language behavior descriptions, name an identifier only when it's the subject of an actionable concern or a public surface a reader would recognize, target 2-3 backtick tokens per bullet. Move the deep-explanation pattern from open blockquote to a default- collapsed details/summary so depth doesn't dominate the visible body. * review prompt: hard cap on bullet identifier density + worked rewrite example Soft 'aim for 2-3 tokens' guidance was ignored — first big-PR e2e showed 12 of 19 actionable bullets exceeded the target (avg 4.8 tokens, several over 8). Promote to a hard cap of 3 backticked tokens per bullet and pair with a concrete bad/good rewrite the agent can pattern- match against. Also tighten the per-bullet length cap from ~240 to ~200 chars and explicitly call it 'hard cap, not target'. * review prompt: tighten bullet length cap to 160 chars, dramatize the worked example V2 e2e test: token discipline improved (4.8 -> 3.3 avg, 12/19 -> 6/14 violations) but length got worse (235 -> 286 chars, 13/14 over the 200 cap). The agent compensated for fewer identifiers with more prose. Two changes: (1) tighten the cap from ~200 chars to 160 chars / 1 visual line and call out wrap-to-multiple-lines as the failure mode; (2) rewrite the worked example so the good version is genuinely half the length of the bad one, not just lower token count. The example was the thing the agent pattern-matches against; making the good version ~130 chars vs the bad version's ~290 chars sets the right shape. * review prompt: drop fixed bullet-count cap, keep length + identifier caps Per user feedback — section length should be governed by content, not an arbitrary count. Soft guidance ('past ~6, ask whether to split') is fine; the hard '≤ 4 bullets per section' rule was the wrong shape. Length cap (160c) and identifier cap (3 backtick tokens) stay; those target the actual scanability problem. * review prompt: drop ## subsystem sections, flat 'Issues found' list Per-section structure forced every concern into a subsystem frame and made the body read like a series of mini-essays. Replace with two parts: (1) TL;DR + Key changes as the dispassionate overview, (2) flat '### Issues found' list ordered by severity, intermixed across files and subsystems. Per-bullet rules (≤160c, ≤3 backtick tokens, severity emoji prefix, optional indented continuation) carry over unchanged. * review prompt: full v6 structure — preamble + cross-cutting H3s + nitpicks Replaces the flat 'Issues found' bullet list with the iterated v6 shape: - Preamble is a bolded inline 'Reviewed changes' lead-in plus bullets plus a collapsed 'Review metadata' block (mode/files/commits/refs/ reviewed commits list/prior pullfrog review/staleness note). - Each cross-cutting concern gets a '### emoji Title' section. The visible problem write-up is human-friendly and DESCRIBES THE PROBLEM ONLY — no asks, no suggested fixes, no 'the right thing to do is'. - Each section carries a collapsed 'Technical details' block wrapped in a 4-backtick markdown fence (so it can hold its own 3-tick code fences cleanly, agent-readable, one-click copyable). Standard four inner sections: Affected sites, Required outcome, optional Suggested approach, optional Open questions for the human. - '### ℹ️ Nitpicks' at the bottom for body-only nits that don't inline; simple bullets, no technical-details collapse. - Anti-paragraph-wall rule: never two successive plain paragraphs in visible '### ' sections; alternate prose with structure. - Inline-vs-body discipline: anything that anchors to a single line goes inline, body is for cross-cutting only. - Drops legacy '### Key changes', '### Issues found', '<b>TL;DR</b>', and the '<sub>Summary</sub>' line. * model effort: bump Gemini + GPT to high effort; drop Gemini Pro→Flash subagent E2E review eval against a substantive billing-module diff surfaced two related quality gaps: 1. Gemini Pro at thinkingLevel=medium (#663's CI-timeout fix) reviewed the diff only, took the 0-lens path, and missed a catastrophic camelCase/snake_case service-vs-schema mismatch. Bumping back to high — review work is exactly the wrong shape for the medium/high tradeoff #663 was optimizing for; the per-turn TTFT cost is worth paying when reasoning IS the value. 2. GPT had no reasoningEffort override, defaulting to upstream medium. Same diff, similar shallow result vs Claude. Adding reasoningEffort: high for the curated direct-OpenAI slugs, mirroring the Gemini pattern (Anthropic separately uses --effort high via the Claude Code CLI flag in claude.ts). 3. Gemini Pro's subagentModel was 'gemini-flash' — but Google has no in-between tier between Pro and Flash, and Flash is a meaningful capability cliff for review work. Dropping the override so subagents inherit Pro. Cost stays reasonable since Gemini Pro is already the cheapest of the flagship trio. Other providers unchanged: Anthropic opus→sonnet and OpenAI gpt→gpt-5.4 remain (each is a one-tier drop to a still-capable sibling). * model effort: revert orchestrator override, set explicit high on reviewfrog subagent Reshape the effort design after eval: - Drop the explicit Gemini and GPT model-level overrides — orchestrators now run at upstream defaults (Gemini high, GPT-5.x medium). Gemini's upstream IS high, so this is a no-op there; GPT goes back to upstream medium for orchestrator-level routing work. - Add explicit 'high' on the reviewfrog subagent via agent.options. OpenCode merge order is base ← model.options ← agent.options ← variant per session/llm.ts:141, so the subagent always runs at high regardless of which orchestrator dispatched it. Both thinkingConfig.thinkingLevel (Gemini) and reasoningEffort (GPT) keys included; irrelevant keys are ignored per provider. - Bump providers-live timeouts (12min job / 10min step, from 8/6) to budget for Gemini's TTFT variance at high effort. #663's 4min timeout was sized for the medium-effort override that's now removed. * model effort: restore Gemini explicit high override (no-override path breaks) Bare 'rely on upstream default' for Gemini failed in e2e — removing the model-level provider config produced 'Function call is missing a thought_signature' API errors on every gemini-pro run. Even though upstream opencode's options() returns the same thinkingLevel: high we were explicitly setting, opencode's resolution path differs subtly between the two cases. v2's explicit override worked; v3's removal broke. Reproducible across two consecutive runs. Restoring the explicit Gemini override (back to v2 design). GPT orchestrator stays UN-overridden — at upstream default (medium) — since removing that override didn't trigger the same failure pattern and the reviewfrog subagent agent.options high override compensates for the extra depth GPT loses at medium. * diag: remove reviewfrog agent.options to isolate Gemini thought_signature failure v3 (no Gemini orch override) failed with thought_signature error. v4 (restored Gemini orch override at v2-equivalent) ALSO failed, even though the orchestrator config matches v2. The variable between v2 (working) and v4 (failing) is the new reviewfrog agent.options block. Removing it to confirm — if Gemini works again, the agent.options addition is the culprit and we need a different shape for it. * opencode-ai: bump 1.1.56 → 1.15.0 + clean up gemini effort config opencode-ai@1.1.56 was published 2026-02-10 (3 months old). The Google API tightened thought_signature validation 24-48h ago (per https://discuss.ai.google.dev/t/gemini-thought-signature-patch/122555), and the bug class hits opencode's session→prompt serializer for MCP tool-call parts (anomalyco/opencode#4832, #8321). Latest stable bumps us through ~3 months of fixes; needed for Gemini-direct to stop dying with 'thought_signature is missing' on every multi-turn run. Companion cleanup: the gemini provider override in opencode.ts had 30-line block of comments, four unused constants, and a 6-line Object.fromEntries map for two entries. Replaced with one source-of- truth helper that loops modelAliases, filters provider==='google', strips the 'google/' prefix, and returns the override map. Adding any future Google alias to the registry now flows through automatically. Test added: action/agents/opencode.test.ts asserts the helper covers every direct-Google alias, strips the prefix correctly, and pins every entry to thinkingLevel high — catches drift in helper logic without hardcoding the API ids the test would have to update in lockstep with the registry. * fix(workflow): tolerate listJobsForWorkflowRun 404 in resolveRun PR #750 (docker testing rewrite) replaced the per-call env allowlist with full process.env passthrough into the test container. That now leaks GITHUB_RUN_ID + GITHUB_JOB into runs whose MCP token is scoped to a DIFFERENT repo (e.g. providers-live smoke runs the action against pullfrog/test-repo with pullfrog/app's run ID). The unconditional listJobsForWorkflowRun call 404s and crashes the entire run, breaking every providers-live job on main since #750 landed. jobId is purely cosmetic (deep-links 'View workflow run' footer to a specific job vs the run-level URL). Wrapping the API call in try/catch so a 404 logs a debug message and falls through to undefined jobId is the right fix — the failure mode is exactly what graceful degradation is for, and the alternative (filter the env vars at the docker boundary) re-introduces the kind of allowlist #750 was getting rid of. * opencode-ai: pin 1.14.51 instead of 1.15.0 (effect refactor breaks JSON output) opencode 1.15.0 (May 15) ships a major architectural refactor onto @effect — the run command boots an in-process server via @opencode-ai/sdk/v2 and the JSON event emission path through that SDK client doesn't surface on stdout the way our parser expects (CI run on 1.15.0 produced 0 stdout events but the agent still completed). Local invocation also hangs at the in-process server boot. The Gemini thought_signature fixes (the original reason for bumping) landed earlier in the 1.14.x line, so 1.14.51 (May 14) gets us the upstream fix without the Effect rewrite. Defer the 1.15.x bump until we're ready to rewire our parser/spawn around the new SDK. * opencode-ai: revert to 1.1.56; gha: filter outer-CI workflow-run vars at the docker boundary Two related changes for the docker testing harness's ergonomics: 1. Revert opencode-ai 1.14.51 → 1.1.56. The 1.14+ line ships an Effect refactor (the SDK-v2 client + in-process server architecture) that our --format json parser doesn't speak — even the 1.14.51 release, pre-dating the 1.15.0 Effect rename, produced 0 stdout events on our skill-invoke smoke. There's no clean pre-Effect version that ships the Gemini thought_signature fix; that fix needs a separate workstream once we're ready to rewire the parser onto SDK v2. 2. Filter outer-CI workflow-run identifiers (GITHUB_RUN_ID, GITHUB_JOB, GITHUB_WORKFLOW, GITHUB_ACTION, GITHUB_REF, GITHUB_SHA, etc.) from gha.ts's --env-file passthrough. PR #750's full-process.env design leaks pullfrog/app's CI run identifiers into runs that act against a different repo (e.g. pullfrog/test-repo); any code path inside the action that uses them as keys (most notably resolveRun's listJobsForWorkflowRun lookup) 404s. Filtering them here means the action sees undefined and skips the lookup, complementing the defensive try/catch in resolveRun (commit addc76d4). GITHUB_REPOSITORY and GITHUB_TOKEN are NOT filtered — those are genuinely needed. Companion to addc76d4 (resolveRun 404 tolerance). The two together make this class of bug 'either fix would have caught it' rather than 'silently breaks the entire test matrix'. * fix(deps): sync pnpm-lock.yaml with opencode-ai 1.1.56 manifest revert Forgot to refresh the lockfile after reverting the manifest in 02c6d8c1. CI's frozen-lockfile install was failing with 'lockfile: 1.14.51, manifest: 1.1.56' mismatch.
623 lines
24 KiB
TypeScript
623 lines
24 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 ──────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 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`). enterprise Bedrock 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` entry is a routing slug, not a model alias: the
|
|
* harness reads `BEDROCK_MODEL_ID` and routes to claude-code (when the ID
|
|
* contains "anthropic") or opencode (everything else, with an
|
|
* `amazon-bedrock/` prefix).
|
|
*/
|
|
export type ModelRouting = "bedrock";
|
|
|
|
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". 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 is free and requires no API key */
|
|
isFree: boolean;
|
|
/** slug of a replacement model — presence implies this model is deprecated */
|
|
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;
|
|
/** 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;
|
|
/** 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;
|
|
/** 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,
|
|
// 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.
|
|
},
|
|
"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,
|
|
},
|
|
// 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.3 for both direct-key and OpenRouter users.
|
|
"grok-fast": {
|
|
displayName: "Grok Fast",
|
|
resolve: "xai/grok-4-1-fast",
|
|
openRouterResolve: "openrouter/x-ai/grok-4.3",
|
|
fallback: "xai/grok",
|
|
},
|
|
"grok-code-fast": {
|
|
displayName: "Grok Code Fast",
|
|
resolve: "xai/grok-code-fast-1",
|
|
openRouterResolve: "openrouter/x-ai/grok-4.3",
|
|
fallback: "xai/grok",
|
|
},
|
|
},
|
|
}),
|
|
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",
|
|
// Inherit — see google/gemini-pro for rationale.
|
|
},
|
|
"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,
|
|
},
|
|
},
|
|
}),
|
|
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",
|
|
},
|
|
},
|
|
}),
|
|
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",
|
|
// Inherit — see google/gemini-pro for rationale.
|
|
},
|
|
"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,
|
|
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,
|
|
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;
|
|
}
|
|
|
|
// ── bedrock routing ────────────────────────────────────────────────────────────
|
|
|
|
/** env var that supplies the Bedrock model ID for the `bedrock/byok` slug. */
|
|
export const BEDROCK_MODEL_ID_ENV = "BEDROCK_MODEL_ID";
|
|
|
|
/**
|
|
* the Bedrock model ID passed to claude-code or opencode is whatever the
|
|
* user set in `BEDROCK_MODEL_ID` — Pullfrog never resolves or upgrades it.
|
|
* we route by checking whether the ID names an Anthropic model: claude-code
|
|
* handles Anthropic-on-Bedrock natively (with `CLAUDE_CODE_USE_BEDROCK=1`),
|
|
* everything else goes through opencode's `amazon-bedrock` provider.
|
|
*
|
|
* AWS Bedrock IDs come in two shapes:
|
|
* - dotted foundation IDs: `us.anthropic.claude-opus-4-7`,
|
|
* `anthropic.claude-haiku-4-5-20251001-v1:0`, `amazon.nova-pro-v1:0`,
|
|
* `meta.llama4-scout-17b-instruct-v1:0`. AWS-published, lowercase, the
|
|
* foundation provider always appears as a discrete dot-segment.
|
|
* - inference-profile ARNs: `arn:aws:bedrock:us-east-2:<acct>:application-inference-profile/<user-name>`.
|
|
* `<user-name>` is operator-chosen, so a naive substring check is fragile
|
|
* in both directions (Anthropic profile named without "anthropic" → routes
|
|
* to opencode and misses CLAUDE_CODE_USE_BEDROCK; non-Anthropic profile
|
|
* whose name happens to contain "anthropic" → routes to claude-code).
|
|
*
|
|
* we anchor on a discrete dot-segment match (case-insensitive). this catches
|
|
* every published foundation ID and is conservative for ARN-form IDs: ARN
|
|
* names that don't include "anthropic" as their own dot-segment route to
|
|
* opencode by default. operators using ARN-form IDs whose backing model is
|
|
* Anthropic should set `PULLFROG_AGENT=claude` to force the right route, or
|
|
* include the foundation segment in the profile name.
|
|
*/
|
|
export function isBedrockAnthropicId(bedrockModelId: string): boolean {
|
|
// split on `.`, `/`, and `:` so the check works for both dotted foundation
|
|
// IDs (anthropic.* / us.anthropic.*) and ARN-form IDs (where the relevant
|
|
// foundation segment sits between `/` and `.` inside the resource name).
|
|
return bedrockModelId.toLowerCase().split(/[./:]/).includes("anthropic");
|
|
}
|