From 8e8fa1e0dc7c2e181d6d123b067f9fc9758f41b4 Mon Sep 17 00:00:00 2001 From: fyzanshaik Date: Sun, 4 Oct 2026 02:42:03 +0530 Subject: [PATCH] fix(opencode): distinguish configured model providers --- .../SettingsScheduledTasksRouteScreen.tsx | 18 +- .../features/threads/NewTaskDraftScreen.tsx | 7 +- .../src/features/threads/ThreadComposer.tsx | 7 +- apps/mobile/src/lib/modelOptions.test.ts | 55 +++++ apps/mobile/src/lib/modelOptions.ts | 17 +- .../provider/Drivers/OpenCodeDriver.test.ts | 70 ++++++- .../src/provider/Drivers/OpenCodeDriver.ts | 22 ++ .../provider/Layers/OpenCodeProvider.test.ts | 188 +++++++++++++++++- .../src/provider/Layers/OpenCodeProvider.ts | 57 +++++- .../opencodeRuntime.cliParsers.test.ts | 3 +- .../opencodeRuntime.inventory.test.ts | 6 + apps/server/src/provider/opencodeRuntime.ts | 23 ++- apps/web/src/components/Sidebar.tsx | 4 +- apps/web/src/components/chat/ModelListRow.tsx | 11 +- .../components/chat/ModelPickerContent.tsx | 38 ++-- .../components/chat/ProviderModelPicker.tsx | 12 +- .../components/chat/modelPickerSearch.test.ts | 75 ++++++- .../src/components/chat/modelPickerSearch.ts | 60 +++++- .../src/components/chat/providerIconUtils.ts | 15 +- packages/shared/src/model.test.ts | 95 +++++++++ packages/shared/src/model.ts | 59 ++++++ 21 files changed, 777 insertions(+), 65 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 91b1d0923e6c..fcd607424a6a 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -38,7 +38,7 @@ import type { ComposerEditorSelection } from "../../components/ComposerEditor"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { SegmentedControl } from "../../components/SegmentedControl"; import { ThemedSwitch } from "../../components/ThemedSwitch"; -import { buildModelOptions } from "../../lib/modelOptions"; +import { buildModelOptions, selectedModelOptionLabel } from "../../lib/modelOptions"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useEnvironmentServerConfig } from "../../state/entities"; import { useEnvironmentQuery } from "../../state/query"; @@ -553,6 +553,11 @@ function TaskForm({ const projects = useProjects().filter((project) => project.environmentId === environmentId); const config = useEnvironmentServerConfig(environmentId); const modelOptions = useMemo(() => buildModelOptions(config, null), [config]); + const selectedModelOption = modelOptions.find( + (option) => + option.selection.instanceId === draft.modelSelection?.instanceId && + option.selection.model === draft.modelSelection?.model, + ); const upsert = useAtomCommand(serverEnvironment.upsertScheduledTask, { label: "scheduled task upsert", reportFailure: false, @@ -720,13 +725,10 @@ function TaskForm({ label="Model" borderTop value={ - modelOptions.find( - (option) => - option.selection.instanceId === draft.modelSelection?.instanceId && - option.selection.model === draft.modelSelection?.model, - )?.label ?? - draft.modelSelection?.model ?? - (modelOptions.length ? "Choose model" : "No models available") + selectedModelOption + ? selectedModelOptionLabel(selectedModelOption) + : (draft.modelSelection?.model ?? + (modelOptions.length ? "Choose model" : "No models available")) } onPress={() => navigation.navigate("SettingsScheduledTaskModel")} disabled={saving || dictationPending || environmentUnavailable} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 1ee4e9bc4e85..32748fc9fa11 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -112,6 +112,7 @@ import { ProjectCloneBanner } from "../../components/ProjectCloneBanner"; import { isModelSelectionUnavailable, resolveSelectableModelSelection, + selectedModelOptionLabel, } from "../../lib/modelOptions"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; @@ -1727,7 +1728,11 @@ export function NewTaskDraftScreen(props: { size={size} /> )} - label={flow.selectedModelOption?.label ?? "Choose model"} + label={ + flow.selectedModelOption + ? selectedModelOptionLabel(flow.selectedModelOption) + : "Choose model" + } maxWidth="100%" onPress={settingsSheetPresentation.open} /> diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 16b09e7c1b1a..b38ddb6748bb 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -86,6 +86,7 @@ import { buildModelOptions, groupByProvider, isModelSelectionUnavailable, + selectedModelOptionLabel, } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; @@ -1123,7 +1124,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer size={size} /> )} - label={currentModelOption?.label ?? currentModelSelection.model} + label={ + currentModelOption + ? selectedModelOptionLabel(currentModelOption) + : currentModelSelection.model + } maxWidth="100%" onPress={openSettings} /> diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 463ff5cc3662..fdb4b7df8ce8 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -9,6 +9,7 @@ import { resolveDefaultableModelSelection, resolveNewTaskModelSelection, resolveSelectableModelSelection, + selectedModelOptionLabel, type ModelOption, } from "./modelOptions"; @@ -141,6 +142,60 @@ describe("mobile model options", () => { ]); }); + it("derives an upstream provider subtitle from the slug when the catalog omits one", () => { + const config = { + providers: [ + { + instanceId: "opencode_work", + driver: "opencode", + displayName: "OpenCode Work", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { + slug: "deepseek/deepseek-v4.1-flash", + name: "DeepSeek V4.1 Flash", + isCustom: false, + capabilities: null, + }, + { + slug: "opencode-go/deepseek-v4.1-flash", + name: "DeepSeek V4.1 Flash", + isCustom: false, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + + const options = buildModelOptions(config, null); + + // Same label, distinct upstream providers; exact slugs still route. + expect(options.map((option) => option.label)).toEqual([ + "DeepSeek V4.1 Flash", + "DeepSeek V4.1 Flash", + ]); + expect(options.map((option) => option.subtitle)).toEqual(["DeepSeek", "OpenCode Go"]); + expect(options.map((option) => option.selection.model)).toEqual([ + "deepseek/deepseek-v4.1-flash", + "opencode-go/deepseek-v4.1-flash", + ]); + }); + + it("appends the upstream provider to the selected-model trigger only for OpenCode", () => { + const openCode = { + providerDriver: "opencode", + label: "DeepSeek V4.1 Flash", + subtitle: "OpenCode Go", + } as unknown as ModelOption; + expect(selectedModelOptionLabel(openCode)).toBe("DeepSeek V4.1 Flash · OpenCode Go"); + + const codex = { ...openCode, providerDriver: "codex", subtitle: "OpenAI" }; + expect(selectedModelOptionLabel(codex)).toBe("DeepSeek V4.1 Flash"); + }); + it("does not materialize catalog defaults for missing stored options", () => { const config = { providers: [ diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 4469becee1a2..384a54ff0e8e 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -8,6 +8,7 @@ import type { import { buildExplicitProviderOptionSelectionsFromDescriptors, getProviderOptionDescriptors, + resolveModelProviderLabel, } from "@t3tools/shared/model"; export type ModelOption = { @@ -32,6 +33,16 @@ export type ProviderGroup = { readonly models: ReadonlyArray; }; +/** + * Composer trigger label for the selected model. Only OpenCode crowds one + * instance with same-named models from different upstreams, so only it + * appends the upstream provider; other providers keep their plain label. + */ +export function selectedModelOptionLabel(option: ModelOption): string { + const upstream = option.providerDriver === "opencode" ? option.subtitle : ""; + return [option.label, upstream].filter(Boolean).join(" · "); +} + function providerDisplayLabel(provider: { readonly displayName?: string | undefined; readonly driver: string; @@ -179,7 +190,7 @@ export function buildModelOptions( options.set(key, { key, label: model.name, - subtitle: model.subProvider ?? "", + subtitle: resolveModelProviderLabel({ ...model, driverKind: provider.driver }) ?? "", providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, @@ -233,7 +244,9 @@ export function buildModelOptions( options.set(key, { key, label: model?.name ?? fallbackModelSelection.model, - subtitle: model?.subProvider ?? "", + subtitle: model + ? (resolveModelProviderLabel({ ...model, driverKind: provider?.driver }) ?? "") + : "", providerKey: fallbackModelSelection.instanceId, providerLabel, providerDriver, diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts index 48100991e71c..b772592dbe63 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts @@ -11,7 +11,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient } from "effect/unstable/http"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; @@ -115,6 +115,74 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { }).pipe(Effect.scoped), ); + it.effect("labels OpenCode 2 models with their provider's name from the provider list", () => + Effect.gen(function* () { + const directory = process.cwd(); + const http = HttpClient.make((request) => { + const path = new URL(request.url).pathname; + const json = (body: unknown) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json(body, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + if (path === "/api/info") { + return json({ version: "2.0.18", pid: 4242, urls: [], paths: { tmp: "/tmp" } }); + } + if (path === "/api/model") { + return json({ + location: { directory }, + data: [ + { + id: "qwen/qwen3-coder", + modelID: "qwen/qwen3-coder", + providerID: "openrouter", + family: "qwen", + name: "Qwen3 Coder", + package: "@opencode/ai/providers/openrouter", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 1760659200000 }, + cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }], + status: "active", + enabled: true, + limit: { context: 200000, input: 160000, output: 32000 }, + }, + ], + }); + } + if (path === "/api/provider") { + return json({ + location: { directory }, + data: [ + { + id: "openrouter", + name: "My OpenRouter Proxy", + activation: "enabled", + package: "@opencode/ai/providers/openrouter", + }, + ], + }); + } + return json({}); + }); + const instance = yield* create( + { serverUrl: "http://127.0.0.1:4096", serverPassword: "secret" }, + http, + ); + + const snapshot = yield* instance.snapshot.refresh; + const model = snapshot.models.find((entry) => entry.slug === "openrouter/qwen/qwen3-coder"); + + assert.ok(model); + assert.strictEqual(model.subProvider, "My OpenRouter Proxy"); + }).pipe(Effect.scoped), + ); + it.effect("answers capability reads without waiting on an unreachable server", () => Effect.gen(function* () { const hang = HttpClient.make(() => Effect.never); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index a16516c40a27..0cca04341125 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -313,6 +313,27 @@ export const OpenCodeDriver: ProviderDriver ), ), ); + // A 2.x model row carries no provider display name; the provider list + // does. `checkOpenCode2` treats it as optional, so a failed or missing + // lookup only costs the label, never the catalog. + const loadOpenCode2ProviderNames = openCode2Server.withConnection((connection) => + connection.client.provider.list({ location: { directory: serverConfig.cwd } }).pipe( + Effect.map( + (providers) => + new Map( + providers.data.map((provider): [string, string] => [provider.id, provider.name]), + ), + ), + Effect.mapError( + (cause) => + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "provider.list", + detail: "The OpenCode server could not list its providers.", + cause, + }), + ), + ), + ); // A 2.x server lists skills and commands per directory, so one server // answers every workspace. Its event stream says when a directory it had // not served yet finished scanning. @@ -374,6 +395,7 @@ export const OpenCodeDriver: ProviderDriver serverConfig.cwd, runtimeProbe.refresh, loadOpenCode2Models, + loadOpenCode2ProviderNames, ), usageLimits: readOpenCodeGoUsageLimits({ enabled: effectiveConfig.enabled, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index e60515694ba5..ded2ec0d6ec3 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -338,6 +338,10 @@ const checkProvider = Effect.fn("checkProvider")(function* ( ReadonlyArray, OpenCodeRuntime.OpenCodeRuntimeError > = Effect.succeed([]), + openCode2ProviderNames: Effect.Effect< + ReadonlyMap, + OpenCodeRuntime.OpenCodeRuntimeError + > = Effect.succeed(new Map()), ) { return yield* Effect.scoped( Effect.gen(function* () { @@ -351,9 +355,13 @@ const checkProvider = Effect.fn("checkProvider")(function* ( Effect.provideService(HttpClient.HttpClient, server), Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), ); - return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe( - Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), - ); + return yield* checkOpenCodeProviderStatus( + settings, + cwd, + probe, + openCode2Models, + openCode2ProviderNames, + ).pipe(Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner)); }), ); }); @@ -524,6 +532,99 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect("labels v1 models with their provider's configured display name", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["acme-internal"], + all: [ + { + id: "acme-internal", + // A user-chosen name that no allowlist knows about must survive. + name: "Acme Internal Gateway", + models: { + "custom-model": { id: "custom-model", name: "Custom Model", variants: {} }, + }, + }, + ], + default: {}, + }, + agents: [], + skills: [], + }; + + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + const model = snapshot.models.find((entry) => entry.slug === "acme-internal/custom-model"); + + NodeAssert.ok(model); + NodeAssert.equal(model.subProvider, "Acme Internal Gateway"); + }), + ); + + it.effect("omits the upstream label when a provider has no display name", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["openai"], + all: [ + { + id: "openai", + // What the CLI inventory reports: an id, never a display name. + name: "", + models: { "gpt-5.4": { id: "gpt-5.4", name: "GPT-5.4", variants: {} } }, + }, + ], + default: {}, + }, + agents: [], + skills: [], + }; + + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); + + NodeAssert.ok(model); + NodeAssert.equal(model.subProvider, undefined); + }), + ); + + it.effect("keeps same-named models from two providers distinct by their slugs", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["alpha", "beta"], + all: [ + { + id: "alpha", + name: "Alpha Endpoint", + models: { shared: { id: "shared", name: "Shared Model", variants: {} } }, + }, + { + id: "beta", + name: "Beta Endpoint", + models: { shared: { id: "shared", name: "Shared Model", variants: {} } }, + }, + ], + default: {}, + }, + agents: [], + skills: [], + }; + + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + + NodeAssert.deepEqual( + snapshot.models + .filter((entry) => entry.name === "Shared Model") + .map((entry) => [entry.slug, entry.subProvider]), + [ + ["alpha/shared", "Alpha Endpoint"], + ["beta/shared", "Beta Endpoint"], + ], + ); + }), + ); + it.effect("loads local inventory from a scoped OpenCode server", () => Effect.gen(function* () { yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" })); @@ -587,6 +688,87 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect("labels OpenCode 2 models with the provider list's display name", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode v2.0.18\n"; + const snapshot = yield* checkProvider( + makeOpenCodeSettings(), + process.cwd(), + undefined, + undefined, + Effect.succeed([ + { + // A nested model id keeps the provider id as its routing qualifier. + providerID: "openrouter", + id: "qwen/qwen3-coder", + name: "Qwen3 Coder", + variants: [], + }, + ]), + // A user-chosen name no allowlist knows about must survive verbatim. + Effect.succeed(new Map([["openrouter", "My OpenRouter Proxy"]])), + ); + + const model = snapshot.models.find((entry) => entry.slug === "openrouter/qwen/qwen3-coder"); + + NodeAssert.ok(model); + NodeAssert.equal(model.subProvider, "My OpenRouter Proxy"); + }), + ); + + it.effect("keeps the OpenCode 2 catalog when the provider list fails", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode v2.0.18\n"; + const snapshot = yield* checkProvider( + makeOpenCodeSettings(), + process.cwd(), + undefined, + undefined, + Effect.succeed([ + { providerID: "openrouter", id: "big-pickle", name: "Big Pickle", variants: [] }, + ]), + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "provider.list", + detail: "status=500 body=unavailable", + }), + ), + ); + + NodeAssert.equal(snapshot.status, "ready"); + const model = snapshot.models.find((entry) => entry.slug === "openrouter/big-pickle"); + NodeAssert.ok(model); + NodeAssert.equal(model.subProvider, undefined); + }), + ); + + it.effect("labels only the OpenCode 2 providers the provider list names", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode v2.0.18\n"; + const snapshot = yield* checkProvider( + makeOpenCodeSettings(), + process.cwd(), + undefined, + undefined, + Effect.succeed([ + { providerID: "alpha", id: "shared", name: "Shared Model", variants: [] }, + { providerID: "beta", id: "shared", name: "Shared Model", variants: [] }, + ]), + Effect.succeed(new Map([["alpha", "Alpha Endpoint"]])), + ); + + NodeAssert.deepEqual( + snapshot.models + .filter((entry) => entry.name === "Shared Model") + .map((entry) => [entry.slug, entry.subProvider]), + [ + ["alpha/shared", "Alpha Endpoint"], + ["beta/shared", undefined], + ], + ); + }), + ); + it.effect("reports a failed OpenCode 2 model list without the server's response", () => Effect.gen(function* () { runtimeMock.state.versionStdout = "opencode v2.0.18\n"; diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 96d8bf41bcd7..c52d30bf33c9 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -9,6 +9,7 @@ import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; import { createModelCapabilities } from "@t3tools/shared/model"; @@ -508,11 +509,33 @@ function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities { }); } +/** + * The display name per provider id, or empty when the server cannot report + * them. A failed or slow lookup only costs the labels, never the catalog, so + * it degrades to slug-derived labels instead of failing the status check. + */ +const loadOpenCode2ProviderNamesOrEmpty = ( + load: Effect.Effect, OpenCodeRuntime.OpenCodeRuntimeError>, +): Effect.Effect> => + load.pipe( + Effect.timeoutOption("5 seconds"), + Effect.map((loaded) => (Option.isSome(loaded) ? loaded.value : new Map())), + Effect.catch((cause) => + Effect.logWarning("OpenCode 2 provider list failed", cause).pipe( + Effect.as(new Map()), + ), + ), + ); + const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( settings: OpenCodeSettings, version: string, checkedAt: string, loadModels: Effect.Effect, OpenCodeRuntime.OpenCodeRuntimeError>, + loadProviderNames: Effect.Effect< + ReadonlyMap, + OpenCodeRuntime.OpenCodeRuntimeError + >, ) { const result = yield* Effect.exit(loadModels); const probe = (status: "ready" | "warning" | "error", message: string) => ({ @@ -539,14 +562,24 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( probe: probe("error", "OpenCode could not load its model list."), }); } + // With no models there is nothing to label, so skip the provider lookup and + // the request (and timeout) it would cost. + let providerNames: ReadonlyMap = new Map(); + if (result.value.length > 0) { + providerNames = yield* loadOpenCode2ProviderNamesOrEmpty(loadProviderNames); + } const models = providerModelsFromSettings( result.value - .map((model) => ({ - slug: `${model.providerID}/${model.id}`, - name: model.name, - isCustom: false, - capabilities: openCode2ModelCapabilities(model), - })) + .map((model) => { + const subProvider = nonEmptyTrimmed(providerNames.get(model.providerID)); + return { + slug: `${model.providerID}/${model.id}`, + name: model.name, + ...(subProvider ? { subProvider } : {}), + isCustom: false, + capabilities: openCode2ModelCapabilities(model), + }; + }) .toSorted((left, right) => left.name.localeCompare(right.name)), settings.customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, @@ -581,6 +614,10 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu ReadonlyArray, OpenCodeRuntime.OpenCodeRuntimeError >, + loadOpenCode2ProviderNames: Effect.Effect< + ReadonlyMap, + OpenCodeRuntime.OpenCodeRuntimeError + >, ): Effect.fn.Return< ServerProviderDraft, never, @@ -650,7 +687,13 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause)); const probed = probedExit.value; if (probed.generation === "v2") { - return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models); + return yield* checkOpenCode2( + openCodeSettings, + probed.version, + checkedAt, + loadOpenCode2Models, + loadOpenCode2ProviderNames, + ); } let version: string | null = probed.version; if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) { diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index c66a2f36a207..bf702392fbed 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -35,7 +35,8 @@ describe("parseModelsCliOutput", () => { const provider = result.providers.get("anthropic")!; NodeAssert.ok(provider); NodeAssert.equal(provider.id, "anthropic"); - NodeAssert.equal(provider.name, "anthropic"); + // The CLI output carries only the routing id, never a display name. + NodeAssert.equal("name" in provider, false); NodeAssert.equal(Object.keys(provider.models).length, 1); const model = provider.models["claude-sonnet-4-5"]!; diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts index 61bc82464049..c2d75d1474db 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/apps/server/src/provider/opencodeRuntime.inventory.test.ts @@ -264,6 +264,12 @@ it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { }); NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); + // `opencode models --verbose` reports only the provider id, so the CLI + // inventory must not dress it up as a configured display name. + NodeAssert.deepEqual( + inventory.providerList.all.map((provider) => [provider.id, provider.name]), + [["openai", ""]], + ); NodeAssert.equal(inventory.skills.length, 0); }), ); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index f14a93257486..b892cb0258f4 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -306,18 +306,22 @@ const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; // definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); -/** @internal */ +/** + * Parse `opencode models --verbose`. The output names each model by a bare + * `provider/model` slug plus its JSON body; it never carries a provider + * display name, only the provider id used for routing. So the parsed provider + * exposes the id alone — callers must not present it as configured metadata. + * + * @internal + */ export function parseModelsCliOutput(stdout: string): { readonly providers: ReadonlyMap< string, - { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } + { readonly id: string; readonly models: { [key: string]: Model } } >; readonly connected: ReadonlyArray; } { - const providers = new Map< - string, - { id: string; name: string; models: { [key: string]: Model } } - >(); + const providers = new Map(); const lines = stdout.split("\n"); let currentSlug: string | null = null; const jsonLines: Array = []; @@ -334,7 +338,7 @@ export function parseModelsCliOutput(stdout: string): { const modelID = currentSlug.slice(separator + 1); let provider = providers.get(providerID); if (!provider) { - provider = { id: providerID, name: providerID, models: {} }; + provider = { id: providerID, models: {} }; providers.set(providerID, provider); } provider.models[modelID] = model; @@ -1044,7 +1048,10 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( (provider) => ({ id: provider.id, - name: provider.name, + // The CLI exposes only provider ids. An empty name keeps the id from + // being read as a configured display name; the client falls back to + // formatting the slug's provider id. + name: "", source: "config" as const, env: [], options: {}, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index eb0499b0d93f..cd1c8d2ed3e0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1333,7 +1333,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { (model) => model.slug === thread.modelSelection.model, ); const modelLabel = selectedModel - ? getTriggerDisplayModelLabel(selectedModel) + ? getTriggerDisplayModelLabel(selectedModel, providerEntry?.driverKind) : thread.modelSelection.model; // The local environment is "this machine" and needs no marker; every other @@ -2188,7 +2188,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { (model) => model.slug === thread.modelSelection.model, ); const modelLabel = selectedModel - ? getTriggerDisplayModelLabel(selectedModel) + ? getTriggerDisplayModelLabel(selectedModel, providerEntry?.driverKind) : thread.modelSelection.model; const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index a59ee5237fb8..51ecaf85fd93 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -1,4 +1,5 @@ import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; +import { resolveModelProviderLabel } from "@t3tools/shared/model"; import { memo } from "react"; import { CheckIcon, StarIcon } from "lucide-react"; import { @@ -43,8 +44,12 @@ export const ModelListRow = memo(function ModelListRow(props: { disabledReason?: string | null; onToggleFavorite: () => void; }) { - const providerLabel = props.model.subProvider - ? `${props.providerDisplayName} · ${props.model.subProvider}` + const upstreamProvider = resolveModelProviderLabel({ + ...props.model, + driverKind: props.driverKind, + }); + const providerLabel = upstreamProvider + ? `${props.providerDisplayName} · ${upstreamProvider}` : props.providerDisplayName; const row = ( @@ -63,7 +68,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
{props.useTriggerLabel - ? getTriggerDisplayModelLabel(props.model) + ? getTriggerDisplayModelLabel(props.model, props.driverKind) : getDisplayModelName( props.model, props.preferShortName ? { preferShortName: true } : undefined, diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 70181947bc4a..bb5c09cc8fe9 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -18,7 +18,11 @@ import { parseModelPickerLegacySectionKey, parseModelPickerModelKey, } from "./modelPickerKeys"; -import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; +import { + buildModelPickerSearchModel, + buildModelPickerSearchText, + scoreModelPickerSearch, +} from "./modelPickerSearch"; import { Combobox, ComboboxEmpty, @@ -443,28 +447,26 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { // Apply tokenized fuzzy search across the combined provider/model search fields. if (searchQuery.trim()) { const rankedMatches = result - .map((model) => ({ - model, - score: scoreModelPickerSearch( - { - name: model.name, - ...(model.shortName ? { shortName: model.shortName } : {}), - ...(model.subProvider ? { subProvider: model.subProvider } : {}), - driverKind: model.driverKind, - providerDisplayName: model.instanceDisplayName, - isFavorite: favoritesSet.has(providerModelKey(model.instanceId, model.slug)), - }, - searchQuery, - ), - isFavorite: favoritesSet.has(providerModelKey(model.instanceId, model.slug)), - tieBreaker: buildModelPickerSearchText({ + .map((model) => { + // The row shows a resolved upstream label (the catalog's own name, or + // OpenCode's decoded slug), so index both that label and the raw + // provider id the slug routes by. + const searchableModel = buildModelPickerSearchModel({ + slug: model.slug, name: model.name, ...(model.shortName ? { shortName: model.shortName } : {}), ...(model.subProvider ? { subProvider: model.subProvider } : {}), driverKind: model.driverKind, providerDisplayName: model.instanceDisplayName, - }), - })) + }); + const isFavorite = favoritesSet.has(providerModelKey(model.instanceId, model.slug)); + return { + model, + score: scoreModelPickerSearch({ ...searchableModel, isFavorite }, searchQuery), + isFavorite, + tieBreaker: buildModelPickerSearchText(searchableModel), + }; + }) .filter( ( rankedModel, diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index c28b6aec0ace..c05cf1b293ee 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -89,8 +89,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { : props.model === ANTIGRAVITY_DEFAULT_MODEL ? "Choose model" : props.model || "Choose model"; - const triggerLabel = selectedModel - ? `${getTriggerDisplayModelLabel(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}` + const activeModelLabel = selectedModel + ? `${getTriggerDisplayModelLabel(selectedModel, activeEntry?.driverKind)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}` : triggerTitle; const showInstanceBadge = activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); @@ -172,7 +172,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ...selection, entry, label: model - ? `${getTriggerDisplayModelName(model)}${model.isUnavailable ? " (Unavailable)" : ""}` + ? `${getTriggerDisplayModelLabel(model, entry?.driverKind)}${model.isUnavailable ? " (Unavailable)" : ""}` : selection.model, }; }); @@ -188,8 +188,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ? selectedEntries.map((selection) => selection.label).join(", ") || "Choose models" : undefined; const triggerTooltipContent = shortcutLabel - ? `${props.triggerLabel ?? allModelNames ?? triggerLabel} · ${shortcutLabel}` - : (props.triggerLabel ?? allModelNames ?? triggerLabel); + ? `${props.triggerLabel ?? allModelNames ?? activeModelLabel} · ${shortcutLabel}` + : (props.triggerLabel ?? allModelNames ?? activeModelLabel); return ( } > - {props.triggerLabel ?? multipleLabel ?? triggerTitle} + {props.triggerLabel ?? multipleLabel ?? activeModelLabel} {triggerTooltipContent} diff --git a/apps/web/src/components/chat/modelPickerSearch.test.ts b/apps/web/src/components/chat/modelPickerSearch.test.ts index 57a7142c6b9a..16782143521d 100644 --- a/apps/web/src/components/chat/modelPickerSearch.test.ts +++ b/apps/web/src/components/chat/modelPickerSearch.test.ts @@ -1,6 +1,11 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; +import { + buildModelPickerSearchModel, + buildModelPickerSearchText, + scoreModelPickerSearch, +} from "./modelPickerSearch"; describe("buildModelPickerSearchText", () => { it("builds provider-agnostic search text from generic fields", () => { @@ -128,4 +133,72 @@ describe("scoreModelPickerSearch", () => { ), ).not.toBeNull(); }); + + it("matches the raw upstream provider ID even when the display name omits it", () => { + const model = { + driverKind: "opencode", + providerDisplayName: "OpenCode", + name: "DeepSeek V4.1 Flash", + providerId: "opencode-go", + }; + + expect(buildModelPickerSearchText(model)).toContain("opencode-go"); + expect(scoreModelPickerSearch(model, "opencode-go")).not.toBeNull(); + expect(scoreModelPickerSearch(model, "opencode")).not.toBeNull(); + }); +}); + +describe("buildModelPickerSearchModel", () => { + it("indexes the OpenCode Zen label its row shows, plus the raw id", () => { + const model = buildModelPickerSearchModel({ + slug: "opencode/big-pickle", + name: "Big Pickle", + driverKind: ProviderDriverKind.make("opencode"), + providerDisplayName: "OpenCode", + }); + + expect(model.subProvider).toBe("OpenCode Zen"); + expect(model.providerId).toBe("opencode"); + expect(scoreModelPickerSearch(model, "zen")).not.toBeNull(); + expect(scoreModelPickerSearch(model, "opencode")).not.toBeNull(); + }); + + it("humanizes an arbitrary custom upstream but keeps its raw id searchable", () => { + const model = buildModelPickerSearchModel({ + slug: "my-llm-gateway/llama-3.3", + name: "Llama 3.3", + driverKind: ProviderDriverKind.make("opencode"), + providerDisplayName: "OpenCode", + }); + + expect(model.subProvider).toBe("My Llm Gateway"); + expect(scoreModelPickerSearch(model, "my llm gateway")).not.toBeNull(); + expect(scoreModelPickerSearch(model, "my-llm-gateway")).not.toBeNull(); + }); + + it("keeps a catalog-reported upstream name verbatim", () => { + const model = buildModelPickerSearchModel({ + slug: "acme/custom-model", + name: "Custom Model", + subProvider: "Acme Internal Gateway", + driverKind: ProviderDriverKind.make("opencode"), + providerDisplayName: "OpenCode", + }); + + expect(model.subProvider).toBe("Acme Internal Gateway"); + expect(scoreModelPickerSearch(model, "internal gateway")).not.toBeNull(); + expect(scoreModelPickerSearch(model, "acme")).not.toBeNull(); + }); + + it("leaves slash-qualified slugs alone for other drivers", () => { + const model = buildModelPickerSearchModel({ + slug: "meta-llama/Llama-3.3", + name: "Llama 3.3", + driverKind: ProviderDriverKind.make("codex"), + providerDisplayName: "Codex", + }); + + expect(model.subProvider).toBeUndefined(); + expect(model.providerId).toBeUndefined(); + }); }); diff --git a/apps/web/src/components/chat/modelPickerSearch.ts b/apps/web/src/components/chat/modelPickerSearch.ts index ff265e4a65a5..03d40344fa6d 100644 --- a/apps/web/src/components/chat/modelPickerSearch.ts +++ b/apps/web/src/components/chat/modelPickerSearch.ts @@ -1,3 +1,5 @@ +import { type ProviderDriverKind } from "@t3tools/contracts"; +import { modelProviderId, resolveModelProviderLabel } from "@t3tools/shared/model"; import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; type ModelPickerSearchableModel = { @@ -11,10 +13,58 @@ type ModelPickerSearchableModel = { providerDisplayName: string; name: string; shortName?: string; + /** + * The upstream provider as the row shows it: the catalog's own + * `subProvider` when it has one, otherwise OpenCode's decoded slug segment + * (e.g. "OpenCode Zen" for `opencode/big-pickle`). Indexed so searching the + * visible name finds the model. + */ subProvider?: string; + /** + * The raw upstream provider segment of the model's slug (e.g. `opencode-go` + * for `opencode-go/deepseek-flash`). Indexed alongside `subProvider` so the + * un-humanized id stays searchable. + */ + providerId?: string; isFavorite?: boolean; }; +type ModelPickerSearchModelInput = { + readonly slug: string; + readonly name: string; + readonly shortName?: string | undefined; + readonly subProvider?: string | undefined; + readonly driverKind: ProviderDriverKind; + readonly providerDisplayName: string; +}; + +/** + * Maps a picker model to the fields search ranks over. The picker row labels + * the upstream provider via `resolveModelProviderLabel`, so the searchable + * `subProvider` carries that resolved label rather than the raw catalog value; + * the raw provider id is kept alongside it. Both feed score and tiebreaker. + */ +export function buildModelPickerSearchModel( + model: ModelPickerSearchModelInput, +): ModelPickerSearchableModel { + const upstreamProvider = resolveModelProviderLabel({ + slug: model.slug, + subProvider: model.subProvider, + driverKind: model.driverKind, + }); + // Other drivers use slashes for their own namespacing, so only OpenCode's + // slug segment is an upstream provider worth indexing by raw id. + const providerId = model.driverKind === "opencode" ? modelProviderId(model.slug) : undefined; + return { + name: model.name, + ...(model.shortName ? { shortName: model.shortName } : {}), + ...(upstreamProvider ? { subProvider: upstreamProvider } : {}), + ...(providerId ? { providerId } : {}), + driverKind: model.driverKind, + providerDisplayName: model.providerDisplayName, + }; +} + const MODEL_PICKER_FAVORITE_SCORE_BOOST = 24; function getModelPickerSearchFields(model: ModelPickerSearchableModel): string[] { @@ -22,6 +72,7 @@ function getModelPickerSearchFields(model: ModelPickerSearchableModel): string[] normalizeSearchQuery(model.name), ...(model.shortName ? [normalizeSearchQuery(model.shortName)] : []), ...(model.subProvider ? [normalizeSearchQuery(model.subProvider)] : []), + ...(model.providerId ? [normalizeSearchQuery(model.providerId)] : []), normalizeSearchQuery(model.driverKind), normalizeSearchQuery(model.providerDisplayName), buildModelPickerSearchText(model), @@ -46,7 +97,14 @@ function scoreModelPickerSearchToken( export function buildModelPickerSearchText(model: ModelPickerSearchableModel): string { return normalizeSearchQuery( - [model.name, model.shortName, model.subProvider, model.driverKind, model.providerDisplayName] + [ + model.name, + model.shortName, + model.subProvider, + model.providerId, + model.driverKind, + model.providerDisplayName, + ] .filter((value): value is string => typeof value === "string" && value.length > 0) .join(" "), ); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 557d62d742db..36a57fec0ddb 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,3 +1,6 @@ +import { type ProviderDriverKind } from "@t3tools/contracts"; +import { resolveModelProviderLabel } from "@t3tools/shared/model"; + export type ModelEsque = { slug: string; name: string; @@ -36,6 +39,14 @@ export function getTriggerDisplayModelName(model: ModelEsque): string { return getDisplayModelName(model, { preferShortName: true }); } -export function getTriggerDisplayModelLabel(model: ModelEsque): string { - return getTriggerDisplayModelName(model); +export function getTriggerDisplayModelLabel( + model: ModelEsque, + driverKind?: ProviderDriverKind | undefined, +): string { + const name = getTriggerDisplayModelName(model); + // Only OpenCode stacks many upstreams under one instance, so only its + // trigger earns the upstream suffix; every other driver keeps its label. + const upstreamProvider = + driverKind === "opencode" ? resolveModelProviderLabel({ ...model, driverKind }) : undefined; + return upstreamProvider ? `${name} · ${upstreamProvider}` : name; } diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 32691d242e6e..d1ddf6e05763 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -14,11 +14,13 @@ import { getModelSelectionStringOptionValue, getProviderOptionDescriptors, readCustomModelEntries, + resolveModelProviderLabel, toCustomModelSetting, getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, normalizeCustomModelSlug, normalizeModelSlug, + modelProviderId, modelSelectionsEqual, } from "./model.ts"; @@ -363,3 +365,96 @@ describe("provider-reported option display", () => { expect(getProviderOptionCurrentLabel(descriptor, selected, reported)).toBe("Unknown"); }); }); + +describe("model provider identity", () => { + it("reads the upstream provider segment from a slash-qualified slug", () => { + expect(modelProviderId("deepseek/deepseek-flash")).toBe("deepseek"); + expect(modelProviderId("opencode-go/deepseek-v4.1-flash")).toBe("opencode-go"); + expect(modelProviderId("gpt-5.6-sol")).toBeUndefined(); + expect(modelProviderId("/leading")).toBeUndefined(); + expect(modelProviderId("trailing/")).toBe("trailing"); + }); + + it("labels the same OpenCode model by its upstream provider so duplicates stay distinct", () => { + const official = resolveModelProviderLabel({ + slug: "deepseek/deepseek-flash", + driverKind: ProviderDriverKind.make("opencode"), + }); + const go = resolveModelProviderLabel({ + slug: "opencode-go/deepseek-v4.1-flash", + driverKind: ProviderDriverKind.make("opencode"), + }); + expect(official).not.toBe(go); + expect(official).toBe("DeepSeek"); + expect(go).toBe("OpenCode Go"); + }); + + it("uses the product casing for known OpenCode upstreams", () => { + const label = (slug: string) => + resolveModelProviderLabel({ slug, driverKind: ProviderDriverKind.make("opencode") }); + expect(label("deepseek/deepseek-flash")).toBe("DeepSeek"); + expect(label("opencode/claude-fable-5")).toBe("OpenCode Zen"); + expect(label("opencode-go/deepseek-v4.1-flash")).toBe("OpenCode Go"); + expect(label("openai/gpt-5.4")).toBe("OpenAI"); + expect(label("openrouter/deepseek-flash")).toBe("OpenRouter"); + expect(label("github-copilot/claude-fable-5")).toBe("GitHub Copilot"); + }); + + it("does not decode slash-qualified slugs for other drivers", () => { + expect( + resolveModelProviderLabel({ + slug: "meta-llama/Llama-3", + driverKind: ProviderDriverKind.make("codex"), + }), + ).toBeUndefined(); + expect(resolveModelProviderLabel({ slug: "deepseek/deepseek-flash" })).toBeUndefined(); + }); + + it("prefers a catalog-reported custom upstream name over the slug", () => { + expect( + resolveModelProviderLabel({ + slug: "github-copilot/claude-fable-5", + subProvider: "GitHub Copilot", + driverKind: ProviderDriverKind.make("opencode"), + }), + ).toBe("GitHub Copilot"); + expect(resolveModelProviderLabel({ slug: "gpt-5.6-sol", subProvider: "OpenAI" })).toBe( + "OpenAI", + ); + }); + + it("leaves plain built-in models unlabeled", () => { + expect( + resolveModelProviderLabel({ + slug: "gpt-5.6-sol", + driverKind: ProviderDriverKind.make("codex"), + }), + ).toBeUndefined(); + expect( + resolveModelProviderLabel({ slug: "claude-sonnet-5", subProvider: " " }), + ).toBeUndefined(); + }); + + it("humanizes separator- and camel-case provider IDs", () => { + const label = (slug: string) => + resolveModelProviderLabel({ slug, driverKind: ProviderDriverKind.make("opencode") }); + expect(label("some-upstream/deepseek-flash")).toBe("Some Upstream"); + expect(label("github_copilot/x")).toBe("GitHub Copilot"); + expect(label("openCode/x")).toBe("Open Code"); + }); + + it("treats Object prototype member names as ordinary custom providers", () => { + const label = (slug: string) => + resolveModelProviderLabel({ slug, driverKind: ProviderDriverKind.make("opencode") }); + expect(label("constructor/x")).toBe("Constructor"); + expect(label("hasOwnProperty/x")).toBe("Has Own Property"); + expect(label("valueOf/x")).toBe("Value Of"); + }); + + it("falls back to the raw custom provider ID when humanizing yields no label", () => { + const label = (slug: string) => + resolveModelProviderLabel({ slug, driverKind: ProviderDriverKind.make("opencode") }); + expect(label("___/x")).toBe("___"); + expect(label(".../x")).toBe("..."); + }); +}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 0e05fb6926cf..fb97582f1605 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -328,6 +328,65 @@ export function formatModelSlugName(slug: string): string { ); } +/** + * The upstream provider segment of a slash-qualified model slug. OpenCode + * catalogs many upstreams under one instance, so `deepseek/deepseek-flash` + * names the official `deepseek` upstream while `opencode-go/deepseek-flash` + * names the OpenCode Go upstream. Other runtimes also use slashes for their + * own namespacing (a HuggingFace `meta-llama/…` id, a native catalog's + * qualified model), so callers gate this to OpenCode rather than decoding + * every slash-qualified slug. + */ +export function modelProviderId(slug: string): string | undefined { + const separator = slug.indexOf("/"); + if (separator <= 0) return undefined; + const providerId = slug.slice(0, separator).trim(); + return providerId.length > 0 ? providerId : undefined; +} + +/** OpenCode upstreams whose product casing differs from a title-cased id. */ +const OPENCODE_PROVIDER_LABELS: Readonly> = { + deepseek: "DeepSeek", + opencode: "OpenCode Zen", + "opencode-go": "OpenCode Go", + openai: "OpenAI", + openrouter: "OpenRouter", + "github-copilot": "GitHub Copilot", +}; + +function formatUpstreamProviderLabel(providerId: string): string { + const normalizedId = providerId.replace(/[_.\s]+/gu, "-"); + const known = Object.hasOwn(OPENCODE_PROVIDER_LABELS, normalizedId) + ? OPENCODE_PROVIDER_LABELS[normalizedId] + : undefined; + if (known) return known; + const formatted = providerId + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(/[-_.\s]+/u) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + return formatted || providerId; +} + +/** + * A model's upstream provider as a display label. The catalog's own + * `subProvider` wins verbatim so custom upstream names survive. Otherwise + * OpenCode's slash-qualified slug is decoded; every other driver returns + * undefined so its presentation is unchanged. + */ +export function resolveModelProviderLabel(model: { + readonly slug: string; + readonly subProvider?: string | null | undefined; + readonly driverKind?: ProviderDriverKind | undefined; +}): string | undefined { + const reported = model.subProvider?.trim(); + if (reported) return reported; + if (model.driverKind !== "opencode") return undefined; + const providerId = modelProviderId(model.slug); + return providerId ? formatUpstreamProviderLabel(providerId) : undefined; +} + export function normalizeModelSlug( model: string | null | undefined, provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND,