Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions backend/cli/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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]) => [
Expand Down
3 changes: 3 additions & 0 deletions backend/cli/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})
Expand Down
14 changes: 9 additions & 5 deletions frontend/workspace/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2040,24 +2040,28 @@ export const PromptInput: Component<PromptInputProps> = (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()}
</Button>
</TooltipKeybind>
</Show>
<Show when={local.model.tier.list().length > 1}>
<Show when={local.model.tier.list().includes("fast")}>
<TooltipKeybind
placement="top"
title={language.t("command.tier.cycle")}
keybind={command.keybind("tier.cycle")}
>
<Button
data-action="model-tier-cycle"
aria-label={`${language.t("command.tier.cycle")}: ${local.model.tier.current()}`}
aria-label={`Fast mode: ${local.model.tier.current() === "fast" ? "on" : "off"}`}
aria-pressed={local.model.tier.current() === "fast"}
variant="ghost"
class="text-text-base group-hover/prompt-input:inline-block capitalize"
onClick={() => local.model.tier.cycle()}
classList={{
"bg-surface-raised-base-active text-text-strong": local.model.tier.current() === "fast",
}}
onClick={() => local.model.tier.set(local.model.tier.current() === "fast" ? "standard" : "fast")}
>
{local.model.tier.current()}
Fast
</Button>
</TooltipKeybind>
</Show>
Expand Down
56 changes: 41 additions & 15 deletions frontend/workspace/src/context/model-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,18 +100,34 @@ 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}`
}

type CatalogModel = {
id: string
provider: { id: string }
modes?: Record<string, unknown>
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)
Expand All @@ -120,15 +136,9 @@ export function foldedRouteMode(model: ModelKey, target: CatalogModel): string |
}

export function preferredModels<T extends CatalogModel>(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)
Expand All @@ -137,14 +147,30 @@ export function preferredModels<T extends CatalogModel>(models: T[]): T[] {
)
})

const seen = new Set<string>()
return routed.filter((model) => {
const result: T[] = []
const seen = new Map<string, number>()
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<T extends CatalogModel>(models: T[], key: ModelKey): T | undefined {
Expand Down
63 changes: 53 additions & 10 deletions frontend/workspace/src/context/models-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
displayProviderForModel,
foldedRouteMode,
FRONTIER_MODELS,
isChatModel,
isFrontier,
preferredModel,
preferredModels,
Expand All @@ -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)
})
Expand All @@ -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",
Expand All @@ -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)
Expand Down Expand Up @@ -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") }
Expand All @@ -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()
})
})
12 changes: 7 additions & 5 deletions frontend/workspace/src/context/models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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),
),
),
)
Expand Down
4 changes: 2 additions & 2 deletions frontend/workspace/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down