diff --git a/commands/init.ts b/commands/init.ts index 2cb07b8..fd3ad0d 100644 --- a/commands/init.ts +++ b/commands/init.ts @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import * as p from "@clack/prompts"; import arg from "arg"; import pc from "picocolors"; -import { modelAliases, type ProviderConfig, providers } from "../models.ts"; +import { modelAliases, type ProviderConfig, providers, resolveDisplayAlias } from "../models.ts"; const PULLFROG_API_URL = (process.env.PULLFROG_API_URL || "https://pullfrog.com").replace( /\/+$/, @@ -24,7 +24,7 @@ function buildProviders(): CliProvider[] { return Object.entries(providers) .filter(([key]) => key !== "opencode" && key !== "openrouter") .map(([key, config]: [string, ProviderConfig]) => { - const aliases = modelAliases.filter((a) => a.provider === key); + const aliases = modelAliases.filter((a) => a.provider === key && !a.fallback); const recommended = aliases.find((a) => a.preferred); const sorted = [...aliases].sort((a, b) => { if (a.preferred && !b.preferred) return -1; @@ -796,8 +796,12 @@ async function main() { const resolved = resolveModelProvider(secrets.model); if (!resolved) bail(`unknown model provider: ${secrets.model}`); provider = resolved; + // walk the fallback chain so a deprecated stored slug shows the model + // the run will actually execute against (e.g. "GPT", not "GPT Codex"). + const displayAlias = resolveDisplayAlias(secrets.model); + const label = displayAlias ? displayAlias.displayName : secrets.model; spin.start(""); - spin.stop(`using model ${pc.cyan(secrets.model)}`); + spin.stop(`using model ${pc.cyan(label)}`); } else { const providerId = await p.select({ message: "select your preferred model provider", diff --git a/external.ts b/external.ts index 01ba4d2..869f4a1 100644 --- a/external.ts +++ b/external.ts @@ -36,7 +36,9 @@ export { parseModel, providers, resolveCliModel, + resolveDisplayAlias, resolveModelSlug, + resolveOpenRouterModel, } from "./models.ts"; // tool permission types shared with server dispatch diff --git a/internal/index.ts b/internal/index.ts index 76a5e7f..46ea930 100644 --- a/internal/index.ts +++ b/internal/index.ts @@ -24,7 +24,9 @@ export { providers, pullfrogMcpName, resolveCliModel, + resolveDisplayAlias, resolveModelSlug, + resolveOpenRouterModel, } from "../external.ts"; export type { Mode } from "../modes.ts"; export { modes } from "../modes.ts"; diff --git a/models.test.ts b/models.test.ts index af4f7e3..e9d6053 100644 --- a/models.test.ts +++ b/models.test.ts @@ -6,7 +6,9 @@ import { parseModel, providers, resolveCliModel, + resolveDisplayAlias, resolveModelSlug, + resolveOpenRouterModel, } from "./models.ts"; describe("parseModel", () => { @@ -28,7 +30,7 @@ describe("parseModel", () => { describe("getModelProvider", () => { it("extracts provider from slug", () => { expect(getModelProvider("anthropic/claude-opus")).toBe("anthropic"); - expect(getModelProvider("openai/gpt-codex")).toBe("openai"); + expect(getModelProvider("openai/gpt")).toBe("openai"); expect(getModelProvider("google/gemini-pro")).toBe("google"); }); }); @@ -71,8 +73,12 @@ describe("resolveModelSlug", () => { }); it("resolves openai alias", () => { - const resolved = resolveModelSlug("openai/gpt-codex"); - expect(resolved).toBe("openai/gpt-5.3-codex"); + const resolved = resolveModelSlug("openai/gpt"); + expect(resolved).toBe("openai/gpt-5.5"); + }); + + it("returns the raw resolve for deprecated aliases (does not walk fallback)", () => { + expect(resolveModelSlug("openai/gpt-codex")).toBe("openai/gpt-5.3-codex"); }); it("returns undefined for unknown slug", () => { @@ -89,6 +95,75 @@ describe("resolveCliModel", () => { it("returns undefined for unknown slug", () => { expect(resolveCliModel("bogus/nope")).toBeUndefined(); }); + + it("walks fallback chain for deprecated deepseek aliases", () => { + expect(resolveCliModel("deepseek/deepseek-reasoner")).toBe("deepseek/deepseek-v4-pro"); + expect(resolveCliModel("deepseek/deepseek-chat")).toBe("deepseek/deepseek-v4-flash"); + }); + + it("walks fallback chain for deprecated openai codex aliases", () => { + expect(resolveCliModel("openai/gpt-codex")).toBe("openai/gpt-5.5"); + expect(resolveCliModel("openai/gpt-codex-mini")).toBe("openai/gpt-5.4-mini"); + expect(resolveCliModel("opencode/gpt-codex")).toBe("opencode/gpt-5.5"); + expect(resolveCliModel("openrouter/gpt-codex")).toBe("openrouter/openai/gpt-5.5"); + }); +}); + +describe("resolveDisplayAlias", () => { + it("returns the alias itself for a non-deprecated slug", () => { + const alias = resolveDisplayAlias("anthropic/claude-opus"); + expect(alias?.slug).toBe("anthropic/claude-opus"); + expect(alias?.displayName).toBe("Claude Opus"); + }); + + it("walks fallback chain to terminal alias for deprecated slug", () => { + const alias = resolveDisplayAlias("openai/gpt-codex"); + expect(alias?.slug).toBe("openai/gpt"); + expect(alias?.displayName).toBe("GPT"); + }); + + it("walks fallback chain for deepseek-reasoner -> deepseek-pro", () => { + const alias = resolveDisplayAlias("deepseek/deepseek-reasoner"); + expect(alias?.slug).toBe("deepseek/deepseek-pro"); + expect(alias?.displayName).toBe("DeepSeek Pro"); + }); + + it("returns undefined for unknown slug", () => { + expect(resolveDisplayAlias("bogus/nope")).toBeUndefined(); + }); +}); + +describe("resolveOpenRouterModel", () => { + it("returns the openrouter specifier for a non-deprecated alias", () => { + expect(resolveOpenRouterModel("anthropic/claude-opus")).toBe( + "openrouter/anthropic/claude-opus-4.7" + ); + }); + + it("walks fallback chain for deprecated deepseek aliases", () => { + expect(resolveOpenRouterModel("deepseek/deepseek-reasoner")).toBe( + "openrouter/deepseek/deepseek-v4-pro" + ); + expect(resolveOpenRouterModel("deepseek/deepseek-chat")).toBe( + "openrouter/deepseek/deepseek-v4-flash" + ); + expect(resolveOpenRouterModel("openrouter/deepseek-chat")).toBe( + "openrouter/deepseek/deepseek-v4-flash" + ); + }); + + it("walks fallback chain for deprecated openai codex aliases", () => { + expect(resolveOpenRouterModel("openai/gpt-codex")).toBe("openrouter/openai/gpt-5.5"); + expect(resolveOpenRouterModel("openai/gpt-codex-mini")).toBe("openrouter/openai/gpt-5.4-mini"); + }); + + it("returns undefined for free opencode models with no openrouter equivalent", () => { + expect(resolveOpenRouterModel("opencode/big-pickle")).toBeUndefined(); + }); + + it("returns undefined for unknown slug", () => { + expect(resolveOpenRouterModel("bogus/nope")).toBeUndefined(); + }); }); describe("modelAliases registry", () => { diff --git a/models.ts b/models.ts index d9688c4..ecba51e 100644 --- a/models.ts +++ b/models.ts @@ -59,7 +59,7 @@ export const providers = { "claude-opus": { displayName: "Claude Opus", resolve: "anthropic/claude-opus-4-7", - openRouterResolve: "openrouter/anthropic/claude-opus-4.6", + openRouterResolve: "openrouter/anthropic/claude-opus-4.7", preferred: true, }, "claude-sonnet": { @@ -78,16 +78,38 @@ export const providers = { displayName: "OpenAI", envVars: ["OPENAI_API_KEY"], models: { + gpt: { + displayName: "GPT", + resolve: "openai/gpt-5.5", + openRouterResolve: "openrouter/openai/gpt-5.5", + preferred: true, + }, + "gpt-pro": { + displayName: "GPT Pro", + resolve: "openai/gpt-5.5-pro", + openRouterResolve: "openrouter/openai/gpt-5.5-pro", + }, + "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", - preferred: true, + 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", @@ -138,16 +160,30 @@ export const providers = { 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", - preferred: true, + fallback: "deepseek/deepseek-pro", }, "deepseek-chat": { displayName: "DeepSeek Chat", resolve: "deepseek/deepseek-chat", openRouterResolve: "openrouter/deepseek/deepseek-v3.2", + fallback: "deepseek/deepseek-flash", }, }, }), @@ -157,8 +193,8 @@ export const providers = { models: { "kimi-k2": { displayName: "Kimi K2", - resolve: "moonshotai/kimi-k2.5", - openRouterResolve: "openrouter/moonshotai/kimi-k2.5", + resolve: "moonshotai/kimi-k2.6", + openRouterResolve: "openrouter/moonshotai/kimi-k2.6", preferred: true, }, }, @@ -177,7 +213,7 @@ export const providers = { "claude-opus": { displayName: "Claude Opus", resolve: "opencode/claude-opus-4-7", - openRouterResolve: "openrouter/anthropic/claude-opus-4.6", + openRouterResolve: "openrouter/anthropic/claude-opus-4.7", }, "claude-sonnet": { displayName: "Claude Sonnet", @@ -189,15 +225,33 @@ export const providers = { 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", + }, + "gpt-pro": { + displayName: "GPT Pro", + resolve: "opencode/gpt-5.5-pro", + openRouterResolve: "openrouter/openai/gpt-5.5-pro", + }, + "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", @@ -211,8 +265,8 @@ export const providers = { }, "kimi-k2": { displayName: "Kimi K2", - resolve: "opencode/kimi-k2.5", - openRouterResolve: "openrouter/moonshotai/kimi-k2.5", + resolve: "opencode/kimi-k2.6", + openRouterResolve: "openrouter/moonshotai/kimi-k2.6", }, "gpt-5-nano": { displayName: "GPT Nano", @@ -247,8 +301,8 @@ export const providers = { models: { "claude-opus": { displayName: "Claude Opus", - resolve: "openrouter/anthropic/claude-opus-4.6", - openRouterResolve: "openrouter/anthropic/claude-opus-4.6", + resolve: "openrouter/anthropic/claude-opus-4.7", + openRouterResolve: "openrouter/anthropic/claude-opus-4.7", preferred: true, }, "claude-sonnet": { @@ -261,15 +315,33 @@ export const providers = { 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", + }, + "gpt-pro": { + displayName: "GPT Pro", + resolve: "openrouter/openai/gpt-5.5-pro", + openRouterResolve: "openrouter/openai/gpt-5.5-pro", + }, + "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", @@ -291,15 +363,28 @@ export const providers = { resolve: "openrouter/x-ai/grok-4", openRouterResolve: "openrouter/x-ai/grok-4", }, + "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.5", - openRouterResolve: "openrouter/moonshotai/kimi-k2.5", + resolve: "openrouter/moonshotai/kimi-k2.6", + openRouterResolve: "openrouter/moonshotai/kimi-k2.6", }, }, }), @@ -367,11 +452,15 @@ export function resolveModelSlug(slug: string): string | undefined { const MAX_FALLBACK_DEPTH = 10; /** - * 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. + * 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 aliases by filtering on `!a.fallback`. */ -export function resolveCliModel(slug: string): string | undefined { +export function resolveDisplayAlias(slug: string): ModelAlias | undefined { let current = slug; const visited = new Set(); for (let i = 0; i < MAX_FALLBACK_DEPTH; i++) { @@ -379,8 +468,27 @@ export function resolveCliModel(slug: string): string | undefined { visited.add(current); const alias = modelAliases.find((a) => a.slug === current); if (!alias) return undefined; - if (!alias.fallback) return alias.resolve; + 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; +} diff --git a/test/__snapshots__/models-catalog.main.test.ts.snap b/test/__snapshots__/models-catalog.main.test.ts.snap index 9b54625..6cf3d37 100644 --- a/test/__snapshots__/models-catalog.main.test.ts.snap +++ b/test/__snapshots__/models-catalog.main.test.ts.snap @@ -7,32 +7,32 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = ` "releaseDate": "2026-04-16", }, "deepseek": { - "modelId": "deepseek-reasoner", - "releaseDate": "2025-12-01", + "modelId": "deepseek-v4-pro", + "releaseDate": "2026-04-24", }, "google": { "modelId": "gemma-4-31b-it", "releaseDate": "2026-04-02", }, "moonshotai": { - "modelId": "kimi-k2.5", - "releaseDate": "2026-01", + "modelId": "kimi-k2.6", + "releaseDate": "2026-04-21", }, "openai": { - "modelId": "gpt-5.4-nano", - "releaseDate": "2026-03-17", + "modelId": "gpt-5.5-pro", + "releaseDate": "2026-04-23", }, "opencode": { - "modelId": "claude-opus-4-7", - "releaseDate": "2026-04-16", + "modelId": "gpt-5.5-pro", + "releaseDate": "2026-04-24", }, "openrouter": { - "modelId": "anthropic/claude-opus-4.7", - "releaseDate": "2026-04-16", + "modelId": "poolside/laguna-xs.2:free", + "releaseDate": "2026-04-28", }, "xai": { - "modelId": "grok-4.20-multi-agent-0309", - "releaseDate": "2026-03-09", + "modelId": "grok-4.3", + "releaseDate": "2026-05-01", }, } `; diff --git a/utils/buildPullfrogFooter.ts b/utils/buildPullfrogFooter.ts index 9c25b57..ee526fa 100644 --- a/utils/buildPullfrogFooter.ts +++ b/utils/buildPullfrogFooter.ts @@ -1,4 +1,4 @@ -import { modelAliases } from "../models.ts"; +import { resolveDisplayAlias } from "../models.ts"; export const PULLFROG_DIVIDER = ""; @@ -26,7 +26,9 @@ export interface BuildPullfrogFooterParams { } function formatModelLabel(slug: string): string { - const alias = modelAliases.find((a) => a.slug === slug); + // walk the fallback chain so a deprecated stored slug shows the model the + // run actually executed against (e.g. "GPT", not "GPT Codex"). + const alias = resolveDisplayAlias(slug); if (!alias) return `\`${slug}\``; return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``; }