diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index a59ee5237fb8..7a15f173693b 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -3,6 +3,7 @@ import { memo } from "react"; import { CheckIcon, StarIcon } from "lucide-react"; import { getDisplayModelName, + getProviderRowLabel, getTriggerDisplayModelLabel, type ModelEsque, } from "./providerIconUtils"; @@ -43,9 +44,7 @@ export const ModelListRow = memo(function ModelListRow(props: { disabledReason?: string | null; onToggleFavorite: () => void; }) { - const providerLabel = props.model.subProvider - ? `${props.providerDisplayName} · ${props.model.subProvider}` - : props.providerDisplayName; + const providerLabel = getProviderRowLabel(props.providerDisplayName, props.model.subProvider); const row = ( { + it("shows a sub-provider named after its provider on its own", () => { + expect(getProviderRowLabel("OpenCode", "OpenCode Zen")).toBe("OpenCode Zen"); + expect(getProviderRowLabel("OpenCode", "OpenCode Go")).toBe("OpenCode Go"); + }); + + it("keeps a provider id that merely starts like the provider name", () => { + expect(getProviderRowLabel("OpenCode", "opencode-go")).toBe("OpenCode · opencode-go"); + }); + + it("joins a sub-provider that names something else", () => { + expect(getProviderRowLabel("OpenCode", "GitHub Copilot")).toBe("OpenCode · GitHub Copilot"); + }); + + it("keeps the provider name when a model has no sub-provider", () => { + expect(getProviderRowLabel("Claude", undefined)).toBe("Claude"); + }); +}); + +describe("getDisplayModelName", () => { + it("drops a leading sub-provider qualifier from the model name", () => { + expect( + getDisplayModelName({ + slug: "a/b", + name: "OpenCode Zen: Step 5", + subProvider: "OpenCode Zen", + }), + ).toBe("Step 5"); + }); +}); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 557d62d742db..54c11b494bef 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -32,6 +32,27 @@ export function getDisplayModelName( return stripLeadingQualifier(name, model.subProvider); } +/** + * The second line of a model row, naming where the model comes from. A + * sub-provider whose name reads as its provider plus a qualifier ("OpenCode + * Zen" under "OpenCode") stands alone: joining the two shows "OpenCode · + * OpenCode Zen", and OpenCode's own picker shows the sub-provider by itself. + */ +export function getProviderRowLabel( + providerDisplayName: string, + subProvider: string | undefined, +): string { + const provider = providerDisplayName.trim(); + const sub = subProvider?.trim(); + if (!sub) { + return providerDisplayName; + } + if (provider && new RegExp(`^${escapeRegExp(provider)}\\s+\\S`, "i").test(sub)) { + return sub; + } + return `${providerDisplayName} · ${sub}`; +} + export function getTriggerDisplayModelName(model: ModelEsque): string { return getDisplayModelName(model, { preferShortName: true }); } diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 9c37ab2abd48..0da0ec56c272 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -34,6 +34,7 @@ import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAda import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; +import { loadOpenCode2Catalog } from "./openCode2Catalog.ts"; import { checkOpenCodeProviderStatus, loadOpenCode2Workspace, @@ -296,18 +297,8 @@ export const OpenCodeDriver: ProviderDriver ), }); const loadOpenCode2Models = yield* makeOpenCode2ModelLoader( - openCode2Server.withConnection((connection) => - connection.client.model.list({ location: { directory: host.paths.cwd } }).pipe( - Effect.map((models) => models.data), - Effect.mapError( - (cause) => - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "model.list", - detail: "The OpenCode server could not list its models.", - cause, - }), - ), - ), + openCode2Server.withConnection(({ client }) => + loadOpenCode2Catalog(client, host.paths.cwd), ), ); // A 2.x server lists skills and commands per directory, so one server diff --git a/packages/provider-opencode/src/server/openCode2Catalog.test.ts b/packages/provider-opencode/src/server/openCode2Catalog.test.ts new file mode 100644 index 000000000000..4bddbd88e633 --- /dev/null +++ b/packages/provider-opencode/src/server/openCode2Catalog.test.ts @@ -0,0 +1,149 @@ +import { assert, it } from "@effect/vitest"; +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, HttpClientResponse } from "effect/http"; +import { describe } from "vite-plus/test"; + +import { loadOpenCode2Catalog } from "./openCode2Catalog.ts"; +import * as OpenCode2Client from "./v2/OpenCode2Client.ts"; + +/** + * `GET /api/model` from an `opencode serve` 2.0.26 with credentials for both + * gateways, cut to the one model each of them lists. Step 5 Preview Free is + * the whole point: both providers list it, with the same name. + */ +const MODELS_BODY = `{"location":{"directory":"/project"},"data":[{"id":"step-5-preview-free","modelID":"step-5-preview-free","providerID":"opencode-go","name":"Step 5 Preview Free","compatibility":{"reasoningField":"reasoning_content"},"package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"sk-demo-not-real","baseURL":"http://127.0.0.1:48990/v1","provider":"opencode-go"},"capabilities":{"tools":true,"input":["text","image","video"],"output":["text"]},"variants":[{"id":"low","settings":{"reasoningEffort":"low"}},{"id":"medium","settings":{"reasoningEffort":"medium"}},{"id":"high","settings":{"reasoningEffort":"high"}}],"time":{"released":1789516800000},"cost":[{"input":0,"output":0,"cache":{"read":0,"write":0}}],"status":"active","enabled":true,"limit":{"context":1000000,"input":1000000,"output":65536}},{"id":"step-5-preview-free","modelID":"step-5-preview-free","providerID":"opencode","name":"Step 5 Preview Free","compatibility":{"reasoningField":"reasoning_content"},"package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"public","baseURL":"https://opencode.ai/zen/v1","provider":"opencode"},"capabilities":{"tools":true,"input":["text","image","video"],"output":["text"]},"variants":[{"id":"low","settings":{"reasoningEffort":"low"}},{"id":"medium","settings":{"reasoningEffort":"medium"}},{"id":"high","settings":{"reasoningEffort":"high"}}],"time":{"released":1789516800000},"cost":[{"input":0,"output":0,"cache":{"read":0,"write":0}}],"status":"active","enabled":true,"limit":{"context":1000000,"input":1000000,"output":65536}}]}`; + +/** `GET /api/provider` from the same server. */ +const PROVIDERS_BODY = `{"location":{"directory":"/project"},"data":[{"id":"opencode-go","integrationID":"opencode-go","name":"OpenCode Go","activation":"enabled","package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"sk-demo-not-real","baseURL":"http://127.0.0.1:48990/v1","provider":"opencode-go"}},{"id":"opencode","integrationID":"opencode","name":"OpenCode Zen","activation":"enabled","package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"public","baseURL":"https://opencode.ai/zen/v1"}}]}`; + +const layerServerReplying = ( + bodies: Readonly>, + hanging: ReadonlyArray = [], +) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.suspend(() => { + const path = new URL(request.url).pathname; + if (hanging.includes(path)) { + return Effect.never; + } + const body = bodies[path]; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(body ?? '{"_tag":"NotFoundError","message":"not recorded"}', { + status: body === undefined ? 404 : 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + }), + ), + ); + +/** + * Reads a catalog the way the driver does, through the client and a + * connection. `hanging` names paths the server accepts but never answers. + */ +const readCatalog = ( + bodies: Readonly>, + hanging: ReadonlyArray = [], +) => + Effect.gen(function* () { + const opencode = yield* OpenCode2Client.OpenCode2Client; + const { client } = yield* opencode.connect({ + baseUrl: "http://127.0.0.1:4096", + password: "secret", + }); + return yield* loadOpenCode2Catalog(client, "/project"); + }).pipe( + Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServerReplying(bodies, hanging)))), + ); + +describe("loadOpenCode2Catalog", () => { + it.effect("names the provider each listed model came from", () => + Effect.gen(function* () { + const models = yield* readCatalog({ + "/api/model": MODELS_BODY, + "/api/provider": PROVIDERS_BODY, + }); + + assert.deepStrictEqual( + models.map((model) => [`${model.providerID}/${model.id}`, model.providerName]), + [ + ["opencode-go/step-5-preview-free", "OpenCode Go"], + ["opencode/step-5-preview-free", "OpenCode Zen"], + ], + ); + }), + ); + + it.effect("keeps the variants a model advertised", () => + Effect.gen(function* () { + const models = yield* readCatalog({ + "/api/model": MODELS_BODY, + "/api/provider": PROVIDERS_BODY, + }); + + assert.deepStrictEqual( + models.map((model) => model.variants), + [ + [{ id: "low" }, { id: "medium" }, { id: "high" }], + [{ id: "low" }, { id: "medium" }, { id: "high" }], + ], + ); + }), + ); + + // A server that cannot name its providers still lists its models, and the + // status check labels those with the provider id instead. + it.effect("leaves a model unnamed when the provider list fails", () => + Effect.gen(function* () { + const models = yield* readCatalog({ "/api/model": MODELS_BODY }); + + assert.deepStrictEqual( + models.map((model) => [`${model.providerID}/${model.id}`, model.providerName]), + [ + ["opencode-go/step-5-preview-free", undefined], + ["opencode/step-5-preview-free", undefined], + ], + ); + }), + ); + + // Names are decoration: a provider read that never answers must not hold the + // models back, because the models are what the snapshot needs. + it.effect("keeps reading models while a provider list stays pending", () => + Effect.gen(function* () { + const reading = yield* readCatalog({ "/api/model": MODELS_BODY }, ["/api/provider"]).pipe( + Effect.forkChild, + ); + yield* TestClock.adjust("5 seconds"); + const models = yield* Fiber.join(reading); + + assert.deepStrictEqual( + models.map((model) => [`${model.providerID}/${model.id}`, model.providerName]), + [ + ["opencode-go/step-5-preview-free", undefined], + ["opencode/step-5-preview-free", undefined], + ], + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("fails the read when the model list fails", () => + Effect.gen(function* () { + const error = yield* readCatalog({ + "/api/model": '{"_tag":"InternalServerError","message":"catalog unavailable"}', + "/api/provider": PROVIDERS_BODY, + }).pipe(Effect.flip); + + assert.strictEqual(error.operation, "model.list"); + assert.match(error.detail, /could not list its models/i); + }), + ); +}); diff --git a/packages/provider-opencode/src/server/openCode2Catalog.ts b/packages/provider-opencode/src/server/openCode2Catalog.ts new file mode 100644 index 000000000000..ddf78f3684fc --- /dev/null +++ b/packages/provider-opencode/src/server/openCode2Catalog.ts @@ -0,0 +1,74 @@ +import type { OpenCodeClient } from "@opencode/client/effect"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import type { OpenCode2Model } from "./status.ts"; + +/** + * How long a provider read may take before the models are listed without it. + * A server loads its provider list with the rest of its catalog, which takes a + * couple of seconds on a cold start, and the model loader retries while the + * catalog is empty anyway. Past that, the names are not worth waiting for. + */ +const PROVIDER_NAME_TIMEOUT = "3 seconds"; + +/** + * Reads the model catalog of an OpenCode 2 server, and names the provider each + * model belongs to. + * + * One instance fronts several providers: OpenCode Zen, OpenCode Go, and + * whatever the user added themselves. Their catalogues overlap, so a model's + * name alone cannot say which provider listed it and two rows for it read the + * same. Reading the server's provider list in the same connection as its + * models is what tells them apart. + * + * A server that cannot name its providers still lists its models, so the + * provider read is optional: a model whose provider went unnamed keeps only + * its id, which the status check then uses as the label. The models carry the + * payload and the names are decoration, so a provider read is bounded as well + * as optional: one that never answers must not hold the models back. Both + * reads are issued at once, and only the slower of the two is waited on. + */ +export const loadOpenCode2Catalog = ( + client: OpenCodeClient, + directory: string, +): Effect.Effect, OpenCodeRuntime.OpenCodeRuntimeError> => { + const location = { directory }; + return Effect.all( + { + models: client.model.list({ location }), + providers: client.provider + .list({ location }) + .pipe(Effect.timeout(PROVIDER_NAME_TIMEOUT), Effect.option), + }, + { concurrency: "unbounded" }, + ).pipe( + Effect.map(({ models, providers }) => { + const providerNames = new Map( + (Option.getOrUndefined(providers)?.data ?? []).map((provider) => [ + provider.id, + provider.name, + ]), + ); + return models.data.map((model) => { + const providerName = providerNames.get(model.providerID); + return { + providerID: model.providerID, + id: model.id, + name: model.name, + ...(providerName ? { providerName } : {}), + variants: model.variants.map((variant) => ({ id: variant.id })), + }; + }); + }), + Effect.mapError( + (cause) => + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "model.list", + detail: "The OpenCode server could not list its models.", + cause, + }), + ), + ); +}; diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index f062330ad733..72d4a08ada09 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -699,6 +699,47 @@ it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (i }), ); + it.effect("labels each model with the sub-provider that listed it", () => + Effect.gen(function* () { + // Names come from the server's provider list; see openCode2Catalog.test.ts + // for reading that list itself. + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }), + process.cwd(), + undefined, + replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), + Effect.succeed([ + { + providerID: "openai", + id: "gpt-5.4", + name: "GPT-5.4", + providerName: "OpenAI", + variants: [], + }, + { + providerID: "custom", + id: "m1", + name: "Custom 1", + providerName: "OpenCode", + variants: [], + }, + { providerID: "anthropic", id: "claude", name: "Claude", variants: [] }, + ]), + ); + + const subProviderFor = (slug: string) => + snapshot.models.find((model) => model.slug === slug)?.subProvider; + NodeAssert.equal(subProviderFor("openai/gpt-5.4"), "OpenAI"); + // A provider called plain "OpenCode" would repeat the instance name. + NodeAssert.equal(subProviderFor("custom/m1"), undefined); + // The id stands in until the server names its providers. + NodeAssert.equal(subProviderFor("anthropic/claude"), "anthropic"); + }), + ); + it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () => Effect.gen(function* () { const snapshot = yield* checkProvider( diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index f89298af01ae..cb0892c5d9d6 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -383,9 +383,33 @@ export interface OpenCode2Model { readonly providerID: string; readonly id: string; readonly name: string; + /** + * Name of the provider the model belongs to, when the server names it. One + * OpenCode instance fronts several: OpenCode Zen, OpenCode Go, and whatever + * the user added themselves. + */ + readonly providerName?: string; readonly variants: ReadonlyArray<{ readonly id: string }>; } +/** + * One OpenCode instance fronts several providers, and their catalogues + * overlap: OpenCode Zen and OpenCode Go both list Step 5 Preview Free. Naming + * the sub-provider is what tells two such rows apart. The label only earns its + * place when it adds to the instance name the row already shows, which is why a + * provider called plain "OpenCode" keeps none, and the id stands in while the + * server has not named its providers yet. + */ +function subProviderLabelFor(input: { + readonly providerID: string; + readonly providerName?: string; +}): string | undefined { + const label = nonEmptyTrimmed(input.providerName) ?? input.providerID; + return label.toLowerCase() === OPENCODE_PRESENTATION.displayName.toLowerCase() + ? undefined + : label; +} + /** * A server loads its catalog lazily and lists nothing for its first few * hundred milliseconds, and a local instance starts a fresh server for each @@ -541,12 +565,16 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( } const models = providerModelsFromSettings( result.value - .map((model) => ({ - slug: `${model.providerID}/${model.id}`, - name: model.name, - isCustom: false, - capabilities: openCode2ModelCapabilities(model), - })) + .map((model) => { + const subProvider = subProviderLabelFor(model); + 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,