diff --git a/backend/cli/src/provider/provider.ts b/backend/cli/src/provider/provider.ts index fbf3e50e..76b289aa 100644 --- a/backend/cli/src/provider/provider.ts +++ b/backend/cli/src/provider/provider.ts @@ -1048,6 +1048,7 @@ export namespace Provider { Object.entries(modes ?? {}) .filter(([key, mode]) => { if (!mode) return false + if (key === "pro" && /(^|\/)gpt-/.test(modelID)) return false if (providerID !== "anthropic" || key !== "fast") return true const id = modelID.toLowerCase().replaceAll(".", "-") return id.startsWith("claude-opus-5") || id.startsWith("claude-opus-4-8") @@ -1082,9 +1083,9 @@ export namespace Provider { function modelModes(provider: ModelsDev.Provider, model: ModelsDev.Model): Model["modes"] | undefined { const direct = directModes(provider.id, model.id, model.experimental) ?? {} const sibling = - provider.id === "openrouter" && !/-(?:fast|pro)$/.test(model.id) + provider.id === "openrouter" && !/-fast$/.test(model.id) ? Object.fromEntries( - ["fast", "pro"] + ["fast"] .map((key) => [key, provider.models[`${model.id}-${key}`]] as const) .filter((entry) => !!entry[1]) .map(([key, route]) => [ diff --git a/backend/cli/test/provider/provider.test.ts b/backend/cli/test/provider/provider.test.ts index a6389d32..31b9f2d1 100644 --- a/backend/cli/test/provider/provider.test.ts +++ b/backend/cli/test/provider/provider.test.ts @@ -178,6 +178,9 @@ test("current frontier models are routable from the seeded catalog", async () => expect((providers["openai"].models["gpt-5.4"] as any).modes?.fast?.provider?.body).toEqual({ service_tier: "priority", }) + expect(Object.keys(providers["openai"].models["gpt-5.6-sol"].modes ?? {})).toEqual(["fast"]) + expect(providers["openrouter"].models["openai/gpt-5.6-sol"].modes?.pro).toBeUndefined() + expect(providers["openrouter"].models["openai/gpt-5.6-sol-pro"]).toBeDefined() expect((providers["anthropic"].models["claude-opus-4-8"] as any).modes?.fast?.provider?.body).toEqual({ speed: "fast", }) diff --git a/frontend/workspace/src/components/prompt-input.tsx b/frontend/workspace/src/components/prompt-input.tsx index e669c749..9478b48c 100644 --- a/frontend/workspace/src/components/prompt-input.tsx +++ b/frontend/workspace/src/components/prompt-input.tsx @@ -2040,11 +2040,11 @@ export const PromptInput: Component = (props) => { class="text-text-base group-hover/prompt-input:inline-block capitalize" onClick={() => local.model.variant.cycle()} > - {local.model.variant.current()} + {local.model.variant.current() === "standard" ? "Thinking" : local.model.variant.current()} - 1}> + = (props) => { > diff --git a/frontend/workspace/src/context/model-catalog.ts b/frontend/workspace/src/context/model-catalog.ts index 84c58e91..b5a55fce 100644 --- a/frontend/workspace/src/context/model-catalog.ts +++ b/frontend/workspace/src/context/model-catalog.ts @@ -100,6 +100,7 @@ export function canonicalKey(providerID: string, modelID: string): string { if (vendor === "z-ai" || vendor === "zhipuai") vendor = "zai" if (vendor === "x-ai") vendor = "xai" base = base.replace(/^~/, "").toLowerCase().replace(/\./g, "-") + if (vendor === "anthropic") base = base.replace(/-\d{8}$/, "") return `${vendor}/${base}` } @@ -107,11 +108,26 @@ type CatalogModel = { id: string provider: { id: string } modes?: Record + capabilities?: { + output?: { + text?: boolean + audio?: boolean + image?: boolean + video?: boolean + } + } +} + +export function isChatModel(model: CatalogModel): boolean { + const output = model.capabilities?.output + if (!output) return true + if (output.text === false) return false + return !output.audio && !output.image && !output.video } export function foldedRouteMode(model: ModelKey, target: CatalogModel): string | undefined { if (model.providerID !== "openrouter" || target.provider.id !== model.providerID) return undefined - const match = model.modelID.match(/-(fast|pro)$/) + const match = model.modelID.match(/-(fast)$/) if (!match) return undefined const mode = match[1] const base = model.modelID.slice(0, -match[0].length) @@ -120,15 +136,9 @@ export function foldedRouteMode(model: ModelKey, target: CatalogModel): string | } export function preferredModels(models: T[]): T[] { - const native = new Set( - models - .filter((model) => model.provider.id !== "openrouter") - .map((model) => canonicalKey(model.provider.id, model.id)), - ) - const routed = models.filter((model) => { if (model.provider.id !== "openrouter") return true - const match = model.id.match(/-(fast|pro)$/) + const match = model.id.match(/-(fast)$/) if (!match) return true const mode = match[1] const base = model.id.slice(0, -match[0].length) @@ -137,14 +147,30 @@ export function preferredModels(models: T[]): T[] { ) }) - const seen = new Set() - return routed.filter((model) => { + const result: T[] = [] + const seen = new Map() + const score = (model: T) => { + // Synced managed routes are removed by the backend in explicit BYOK mode. + // When both survive in auto mode, prefer the managed OpenRouter route so a + // stale native key cannot shadow the healthy wallet route. + const route = model.provider.id === "openrouter" ? 2 : 0 + // Models.dev sometimes ships both a stable alias and its dated Anthropic + // snapshot under the same display name. Keep the stable id. + const stable = /-\d{8}$/.test(model.id) ? 0 : 1 + return route + stable + } + + for (const model of routed) { const key = canonicalKey(model.provider.id, model.id) - if (model.provider.id === "openrouter" && native.has(key)) return false - if (seen.has(key)) return false - seen.add(key) - return true - }) + const index = seen.get(key) + if (index === undefined) { + seen.set(key, result.length) + result.push(model) + continue + } + if (score(model) > score(result[index])) result[index] = model + } + return result } export function preferredModel(models: T[], key: ModelKey): T | undefined { diff --git a/frontend/workspace/src/context/models-catalog.test.ts b/frontend/workspace/src/context/models-catalog.test.ts index f85c688c..5eb08f1a 100644 --- a/frontend/workspace/src/context/models-catalog.test.ts +++ b/frontend/workspace/src/context/models-catalog.test.ts @@ -4,6 +4,7 @@ import { displayProviderForModel, foldedRouteMode, FRONTIER_MODELS, + isChatModel, isFrontier, preferredModel, preferredModels, @@ -25,6 +26,13 @@ describe("frontier model canonicalization", () => { expect(FRONTIER_MODELS.has(direct)).toBe(true) }) + test("dated Anthropic aliases collapse to their stable model ids", () => { + expect(canonicalKey("anthropic", "claude-opus-4-5-20251101")).toBe(canonicalKey("anthropic", "claude-opus-4-5")) + expect(canonicalKey("anthropic", "claude-sonnet-4-5-20250929")).toBe( + canonicalKey("openrouter", "anthropic/claude-sonnet-4.5"), + ) + }) + test("Muse Spark is part of the default frontier set", () => { expect(FRONTIER_MODELS.has(canonicalKey("meta", "muse-spark-1.1"))).toBe(true) }) @@ -51,7 +59,6 @@ describe("frontier model canonicalization", () => { { id: "openai/gpt-5.6-sol", provider: provider("openrouter"), - modes: { pro: { model: "openai/gpt-5.6-sol-pro" } }, }, { id: "openai/gpt-5.6-sol-pro", @@ -72,13 +79,40 @@ describe("frontier model canonicalization", () => { ]) expect(models.map((model) => `${model.provider.id}/${model.id}`)).toEqual([ + "openrouter/anthropic/claude-sonnet-5", "openrouter/openai/gpt-5.6-sol", + "openrouter/openai/gpt-5.6-sol-pro", "openrouter/meta/muse-spark-1.1", - "anthropic/claude-sonnet-5", "openai-codex/gpt-5.6-sol", ]) }) + test("chat picker excludes output-generation models", () => { + const provider = { id: "openrouter" } + expect( + isChatModel({ + id: "google/gemini-3-pro-image", + provider, + capabilities: { output: { text: true, image: true } }, + }), + ).toBe(false) + expect( + isChatModel({ + id: "openai/gpt-5.6-sol", + provider, + capabilities: { output: { text: true, image: false } }, + }), + ).toBe(true) + }) + + test("stable Anthropic aliases win over dated duplicates", () => { + const provider = { id: "anthropic" } + const dated = { id: "claude-opus-4-5-20251101", provider } + const stable = { id: "claude-opus-4-5", provider } + expect(preferredModels([dated, stable])).toEqual([stable]) + expect(preferredModels([stable, dated])).toEqual([stable]) + }) + test("shows ChatGPT subscription models with Fast mode in the default picker", () => { for (const modelID of ["gpt-5.4", "gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"]) { expect(isFrontier({ providerID: "openai-codex", modelID })).toBe(true) @@ -124,7 +158,7 @@ describe("frontier model canonicalization", () => { }) }) - test("persisted losing routes resolve to the preferred model", () => { + test("managed routes win logical duplicates and persisted native selections follow them", () => { const provider = (id: string) => ({ id, name: id }) const managed = { id: "anthropic/claude-sonnet-5", provider: provider("openrouter") } const native = { id: "claude-sonnet-5", provider: provider("anthropic") } @@ -134,23 +168,32 @@ describe("frontier model canonicalization", () => { [native, managed], ]) { const models = preferredModels(input) - expect(preferredModel(models, { providerID: "openrouter", modelID: managed.id })).toEqual(native) + expect(models).toEqual([managed]) + expect(preferredModel(models, { providerID: "anthropic", modelID: native.id })).toEqual(managed) } }) - test("stale service-route selections resolve only to a base with the matching mode", () => { + test("stale fast-route selections resolve only to a base with fast mode", () => { const provider = { id: "openrouter", name: "OpenRouter" } - const key = { providerID: "openrouter", modelID: "openai/gpt-5.6-sol-pro" } + const key = { providerID: "openrouter", modelID: "anthropic/claude-opus-5-fast" } const base = { - id: "openai/gpt-5.6-sol", + id: "anthropic/claude-opus-5", provider, - modes: { pro: { model: "openai/gpt-5.6-sol-pro" } }, + modes: { fast: { model: "anthropic/claude-opus-5-fast" } }, } - const unsupported = { id: "openai/gpt-5.6-sol", provider } + const unsupported = { id: "anthropic/claude-opus-5", provider } expect(preferredModel([base], key)).toBe(base) - expect(foldedRouteMode(key, base)).toBe("pro") + expect(foldedRouteMode(key, base)).toBe("fast") expect(preferredModel([unsupported], key)).toBeUndefined() expect(foldedRouteMode(key, unsupported)).toBeUndefined() }) + + test("Pro routes stay as independently selectable models", () => { + const provider = { id: "openrouter", name: "OpenRouter" } + const base = { id: "openai/gpt-5.6-sol", provider } + const pro = { id: "openai/gpt-5.6-sol-pro", provider } + expect(preferredModels([base, pro])).toEqual([base, pro]) + expect(foldedRouteMode({ providerID: "openrouter", modelID: pro.id }, base)).toBeUndefined() + }) }) diff --git a/frontend/workspace/src/context/models.tsx b/frontend/workspace/src/context/models.tsx index ffd8289e..6110ba4b 100644 --- a/frontend/workspace/src/context/models.tsx +++ b/frontend/workspace/src/context/models.tsx @@ -4,7 +4,7 @@ import { uniqueBy } from "remeda" import { createSimpleContext } from "@synsci/ui/context" import { useProviders } from "@/hooks/use-providers" import { Persist, persisted } from "@/utils/persist" -import { isFrontier, preferredModel, preferredModels, type ModelKey } from "./model-catalog" +import { isChatModel, isFrontier, preferredModel, preferredModels, type ModelKey } from "./model-catalog" export { canonicalKey, FRONTIER_MODELS, type ModelKey } from "./model-catalog" @@ -35,10 +35,12 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( const available = createMemo(() => preferredModels( providers.connected().flatMap((p) => - Object.values(p.models).map((m) => ({ - ...m, - provider: p, - })), + Object.values(p.models) + .map((m) => ({ + ...m, + provider: p, + })) + .filter(isChatModel), ), ), ) diff --git a/frontend/workspace/src/i18n/en.ts b/frontend/workspace/src/i18n/en.ts index 4708b9a1..b1dfe676 100644 --- a/frontend/workspace/src/i18n/en.ts +++ b/frontend/workspace/src/i18n/en.ts @@ -64,8 +64,8 @@ export const dict = { "command.agent.cycle.description": "switch to the next agent", "command.agent.cycle.reverse": "cycle agent backwards", "command.agent.cycle.reverse.description": "switch to the previous agent", - "command.tier.cycle": "cycle tier", - "command.tier.cycle.description": "switch between fast, pro, and ultra", + "command.tier.cycle": "toggle fast mode", + "command.tier.cycle.description": "toggle fast mode for supported models", "command.model.variant.cycle": "cycle thinking effort", "command.model.variant.cycle.description": "switch to the next effort level", "command.permissions.autoaccept.enable": "auto-accept edits",