authorgravatar for hello@mrkaran.devKaran Sharma <hello@mrkaran.dev> 2026-10-10 16:59:06+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 06:29:06-05:00
log7b09ac7e0022769425471fad1c0d152c03d5954f
tree5ec88cf8b8144e1ab54d25dc3dd2c6812966978a
parent472ea7ec5f67fae660dc606b82698d15a2bc850f
signature Signed by PGP key B5690EEEBB952194

fix(provider-opencode): tell OpenCode Zen and Go models apart (#17424)


8 files changed, 358 insertions(+), 21 deletions(-)

apps/web/src/components/chat/ModelListRow.tsx+2-3
......@@ -3,6 +3,7 @@ import { memo } from "react";
33import { CheckIcon, StarIcon } from "lucide-react";
44import {
55 getDisplayModelName,
6 getProviderRowLabel,
67 getTriggerDisplayModelLabel,
78 type ModelEsque,
89} from "./providerIconUtils";
......@@ -43,9 +44,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
4344 disabledReason?: string | null;
4445 onToggleFavorite: () => void;
4546}) {
46 const providerLabel = props.model.subProvider
47 ? `${props.providerDisplayName} · ${props.model.subProvider}`
48 : props.providerDisplayName;
47 const providerLabel = getProviderRowLabel(props.providerDisplayName, props.model.subProvider);
4948
5049 const row = (
5150 <ComboboxItem
apps/web/src/components/chat/providerIconUtils.test.ts created+34
......@@ -0,0 +1,34 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { getDisplayModelName, getProviderRowLabel } from "./providerIconUtils";
4
5describe("getProviderRowLabel", () => {
6 it("shows a sub-provider named after its provider on its own", () => {
7 expect(getProviderRowLabel("OpenCode", "OpenCode Zen")).toBe("OpenCode Zen");
8 expect(getProviderRowLabel("OpenCode", "OpenCode Go")).toBe("OpenCode Go");
9 });
10
11 it("keeps a provider id that merely starts like the provider name", () => {
12 expect(getProviderRowLabel("OpenCode", "opencode-go")).toBe("OpenCode · opencode-go");
13 });
14
15 it("joins a sub-provider that names something else", () => {
16 expect(getProviderRowLabel("OpenCode", "GitHub Copilot")).toBe("OpenCode · GitHub Copilot");
17 });
18
19 it("keeps the provider name when a model has no sub-provider", () => {
20 expect(getProviderRowLabel("Claude", undefined)).toBe("Claude");
21 });
22});
23
24describe("getDisplayModelName", () => {
25 it("drops a leading sub-provider qualifier from the model name", () => {
26 expect(
27 getDisplayModelName({
28 slug: "a/b",
29 name: "OpenCode Zen: Step 5",
30 subProvider: "OpenCode Zen",
31 }),
32 ).toBe("Step 5");
33 });
34});
apps/web/src/components/chat/providerIconUtils.ts+21
......@@ -32,6 +32,27 @@ export function getDisplayModelName(
3232 return stripLeadingQualifier(name, model.subProvider);
3333}
3434
35/**
36 * The second line of a model row, naming where the model comes from. A
37 * sub-provider whose name reads as its provider plus a qualifier ("OpenCode
38 * Zen" under "OpenCode") stands alone: joining the two shows "OpenCode ·
39 * OpenCode Zen", and OpenCode's own picker shows the sub-provider by itself.
40 */
41export function getProviderRowLabel(
42 providerDisplayName: string,
43 subProvider: string | undefined,
44): string {
45 const provider = providerDisplayName.trim();
46 const sub = subProvider?.trim();
47 if (!sub) {
48 return providerDisplayName;
49 }
50 if (provider && new RegExp(`^${escapeRegExp(provider)}\\s+\\S`, "i").test(sub)) {
51 return sub;
52 }
53 return `${providerDisplayName} · ${sub}`;
54}
55
3556export function getTriggerDisplayModelName(model: ModelEsque): string {
3657 return getDisplayModelName(model, { preferShortName: true });
3758}
packages/provider-opencode/src/server/driver.ts+3-12
......@@ -36,6 +36,7 @@ import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textG
3636import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
3737import { openCodeUsageReader, type OpenCodeUsageReaderEnv } from "./usage.ts";
3838import { readOpenCodeGoUsageLimits } from "./usageLimits.ts";
39import { loadOpenCode2Catalog } from "./openCode2Catalog.ts";
3940import {
4041 checkOpenCodeProviderStatus,
4142 loadOpenCode2Workspace,
......@@ -305,18 +306,8 @@ export const OpenCodeDriver: ProviderDriver<
305306 ),
306307 });
307308 const loadOpenCode2Models = yield* makeOpenCode2ModelLoader(
308 openCode2Server.withConnection((connection) =>
309 connection.client.model.list({ location: { directory: host.paths.cwd } }).pipe(
310 Effect.map((models) => models.data),
311 Effect.mapError(
312 (cause) =>
313 new OpenCodeRuntime.OpenCodeRuntimeError({
314 operation: "model.list",
315 detail: "The OpenCode server could not list its models.",
316 cause,
317 }),
318 ),
319 ),
309 openCode2Server.withConnection(({ client }) =>
310 loadOpenCode2Catalog(client, host.paths.cwd),
320311 ),
321312 );
322313 // A 2.x server lists skills and commands per directory, so one server
packages/provider-opencode/src/server/openCode2Catalog.test.ts created+149
......@@ -0,0 +1,149 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Fiber from "effect/Fiber";
4import * as Layer from "effect/Layer";
5import * as TestClock from "effect/testing/TestClock";
6import { HttpClient, HttpClientResponse } from "effect/http";
7import { describe } from "vite-plus/test";
8
9import { loadOpenCode2Catalog } from "./openCode2Catalog.ts";
10import * as OpenCode2Client from "./v2/OpenCode2Client.ts";
11
12/**
13 * `GET /api/model` from an `opencode serve` 2.0.26 with credentials for both
14 * gateways, cut to the one model each of them lists. Step 5 Preview Free is
15 * the whole point: both providers list it, with the same name.
16 */
17const 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}}]}`;
18
19/** `GET /api/provider` from the same server. */
20const 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"}}]}`;
21
22const layerServerReplying = (
23 bodies: Readonly<Record<string, string>>,
24 hanging: ReadonlyArray<string> = [],
25) =>
26 Layer.succeed(
27 HttpClient.HttpClient,
28 HttpClient.make((request) =>
29 Effect.suspend(() => {
30 const path = new URL(request.url).pathname;
31 if (hanging.includes(path)) {
32 return Effect.never;
33 }
34 const body = bodies[path];
35 return Effect.succeed(
36 HttpClientResponse.fromWeb(
37 request,
38 new Response(body ?? '{"_tag":"NotFoundError","message":"not recorded"}', {
39 status: body === undefined ? 404 : 200,
40 headers: { "content-type": "application/json" },
41 }),
42 ),
43 );
44 }),
45 ),
46 );
47
48/**
49 * Reads a catalog the way the driver does, through the client and a
50 * connection. `hanging` names paths the server accepts but never answers.
51 */
52const readCatalog = (
53 bodies: Readonly<Record<string, string>>,
54 hanging: ReadonlyArray<string> = [],
55) =>
56 Effect.gen(function* () {
57 const opencode = yield* OpenCode2Client.OpenCode2Client;
58 const { client } = yield* opencode.connect({
59 baseUrl: "http://127.0.0.1:4096",
60 password: "secret",
61 });
62 return yield* loadOpenCode2Catalog(client, "/project");
63 }).pipe(
64 Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServerReplying(bodies, hanging)))),
65 );
66
67describe("loadOpenCode2Catalog", () => {
68 it.effect("names the provider each listed model came from", () =>
69 Effect.gen(function* () {
70 const models = yield* readCatalog({
71 "/api/model": MODELS_BODY,
72 "/api/provider": PROVIDERS_BODY,
73 });
74
75 assert.deepStrictEqual(
76 models.map((model) => [`${model.providerID}/${model.id}`, model.providerName]),
77 [
78 ["opencode-go/step-5-preview-free", "OpenCode Go"],
79 ["opencode/step-5-preview-free", "OpenCode Zen"],
80 ],
81 );
82 }),
83 );
84
85 it.effect("keeps the variants a model advertised", () =>
86 Effect.gen(function* () {
87 const models = yield* readCatalog({
88 "/api/model": MODELS_BODY,
89 "/api/provider": PROVIDERS_BODY,
90 });
91
92 assert.deepStrictEqual(
93 models.map((model) => model.variants),
94 [
95 [{ id: "low" }, { id: "medium" }, { id: "high" }],
96 [{ id: "low" }, { id: "medium" }, { id: "high" }],
97 ],
98 );
99 }),
100 );
101
102 // A server that cannot name its providers still lists its models, and the
103 // status check labels those with the provider id instead.
104 it.effect("leaves a model unnamed when the provider list fails", () =>
105 Effect.gen(function* () {
106 const models = yield* readCatalog({ "/api/model": MODELS_BODY });
107
108 assert.deepStrictEqual(
109 models.map((model) => [`${model.providerID}/${model.id}`, model.providerName]),
110 [
111 ["opencode-go/step-5-preview-free", undefined],
112 ["opencode/step-5-preview-free", undefined],
113 ],
114 );
115 }),
116 );
117
118 // Names are decoration: a provider read that never answers must not hold the
119 // models back, because the models are what the snapshot needs.
120 it.effect("keeps reading models while a provider list stays pending", () =>
121 Effect.gen(function* () {
122 const reading = yield* readCatalog({ "/api/model": MODELS_BODY }, ["/api/provider"]).pipe(
123 Effect.forkChild,
124 );
125 yield* TestClock.adjust("5 seconds");
126 const models = yield* Fiber.join(reading);
127
128 assert.deepStrictEqual(
129 models.map((model) => [`${model.providerID}/${model.id}`, model.providerName]),
130 [
131 ["opencode-go/step-5-preview-free", undefined],
132 ["opencode/step-5-preview-free", undefined],
133 ],
134 );
135 }).pipe(Effect.provide(TestClock.layer())),
136 );
137
138 it.effect("fails the read when the model list fails", () =>
139 Effect.gen(function* () {
140 const error = yield* readCatalog({
141 "/api/model": '{"_tag":"InternalServerError","message":"catalog unavailable"}',
142 "/api/provider": PROVIDERS_BODY,
143 }).pipe(Effect.flip);
144
145 assert.strictEqual(error.operation, "model.list");
146 assert.match(error.detail, /could not list its models/i);
147 }),
148 );
149});
packages/provider-opencode/src/server/openCode2Catalog.ts created+74
......@@ -0,0 +1,74 @@
1import type { OpenCodeClient } from "@opencode/client/effect";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4
5import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
6import type { OpenCode2Model } from "./status.ts";
7
8/**
9 * How long a provider read may take before the models are listed without it.
10 * A server loads its provider list with the rest of its catalog, which takes a
11 * couple of seconds on a cold start, and the model loader retries while the
12 * catalog is empty anyway. Past that, the names are not worth waiting for.
13 */
14const PROVIDER_NAME_TIMEOUT = "3 seconds";
15
16/**
17 * Reads the model catalog of an OpenCode 2 server, and names the provider each
18 * model belongs to.
19 *
20 * One instance fronts several providers: OpenCode Zen, OpenCode Go, and
21 * whatever the user added themselves. Their catalogues overlap, so a model's
22 * name alone cannot say which provider listed it and two rows for it read the
23 * same. Reading the server's provider list in the same connection as its
24 * models is what tells them apart.
25 *
26 * A server that cannot name its providers still lists its models, so the
27 * provider read is optional: a model whose provider went unnamed keeps only
28 * its id, which the status check then uses as the label. The models carry the
29 * payload and the names are decoration, so a provider read is bounded as well
30 * as optional: one that never answers must not hold the models back. Both
31 * reads are issued at once, and only the slower of the two is waited on.
32 */
33export const loadOpenCode2Catalog = (
34 client: OpenCodeClient,
35 directory: string,
36): Effect.Effect<ReadonlyArray<OpenCode2Model>, OpenCodeRuntime.OpenCodeRuntimeError> => {
37 const location = { directory };
38 return Effect.all(
39 {
40 models: client.model.list({ location }),
41 providers: client.provider
42 .list({ location })
43 .pipe(Effect.timeout(PROVIDER_NAME_TIMEOUT), Effect.option),
44 },
45 { concurrency: "unbounded" },
46 ).pipe(
47 Effect.map(({ models, providers }) => {
48 const providerNames = new Map(
49 (Option.getOrUndefined(providers)?.data ?? []).map((provider) => [
50 provider.id,
51 provider.name,
52 ]),
53 );
54 return models.data.map((model) => {
55 const providerName = providerNames.get(model.providerID);
56 return {
57 providerID: model.providerID,
58 id: model.id,
59 name: model.name,
60 ...(providerName ? { providerName } : {}),
61 variants: model.variants.map((variant) => ({ id: variant.id })),
62 };
63 });
64 }),
65 Effect.mapError(
66 (cause) =>
67 new OpenCodeRuntime.OpenCodeRuntimeError({
68 operation: "model.list",
69 detail: "The OpenCode server could not list its models.",
70 cause,
71 }),
72 ),
73 );
74};
packages/provider-opencode/src/server/status.test.ts+41
......@@ -700,6 +700,47 @@ it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (i
700700 }),
701701 );
702702
703 it.effect("labels each model with the sub-provider that listed it", () =>
704 Effect.gen(function* () {
705 // Names come from the server's provider list; see openCode2Catalog.test.ts
706 // for reading that list itself.
707 const snapshot = yield* checkProvider(
708 makeOpenCodeSettings({
709 serverUrl: "http://127.0.0.1:9999",
710 serverPassword: "secret-password",
711 }),
712 process.cwd(),
713 undefined,
714 replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"),
715 Effect.succeed([
716 {
717 providerID: "openai",
718 id: "gpt-5.4",
719 name: "GPT-5.4",
720 providerName: "OpenAI",
721 variants: [],
722 },
723 {
724 providerID: "custom",
725 id: "m1",
726 name: "Custom 1",
727 providerName: "OpenCode",
728 variants: [],
729 },
730 { providerID: "anthropic", id: "claude", name: "Claude", variants: [] },
731 ]),
732 );
733
734 const subProviderFor = (slug: string) =>
735 snapshot.models.find((model) => model.slug === slug)?.subProvider;
736 NodeAssert.equal(subProviderFor("openai/gpt-5.4"), "OpenAI");
737 // A provider called plain "OpenCode" would repeat the instance name.
738 NodeAssert.equal(subProviderFor("custom/m1"), undefined);
739 // The id stands in until the server names its providers.
740 NodeAssert.equal(subProviderFor("anthropic/claude"), "anthropic");
741 }),
742 );
743
703744 it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () =>
704745 Effect.gen(function* () {
705746 const snapshot = yield* checkProvider(
packages/provider-opencode/src/server/status.ts+34-6
......@@ -383,9 +383,33 @@ export interface OpenCode2Model {
383383 readonly providerID: string;
384384 readonly id: string;
385385 readonly name: string;
386 /**
387 * Name of the provider the model belongs to, when the server names it. One
388 * OpenCode instance fronts several: OpenCode Zen, OpenCode Go, and whatever
389 * the user added themselves.
390 */
391 readonly providerName?: string;
386392 readonly variants: ReadonlyArray<{ readonly id: string }>;
387393}
388394
395/**
396 * One OpenCode instance fronts several providers, and their catalogues
397 * overlap: OpenCode Zen and OpenCode Go both list Step 5 Preview Free. Naming
398 * the sub-provider is what tells two such rows apart. The label only earns its
399 * place when it adds to the instance name the row already shows, which is why a
400 * provider called plain "OpenCode" keeps none, and the id stands in while the
401 * server has not named its providers yet.
402 */
403function subProviderLabelFor(input: {
404 readonly providerID: string;
405 readonly providerName?: string;
406}): string | undefined {
407 const label = nonEmptyTrimmed(input.providerName) ?? input.providerID;
408 return label.toLowerCase() === OPENCODE_PRESENTATION.displayName.toLowerCase()
409 ? undefined
410 : label;
411}
412
389413/**
390414 * A server loads its catalog lazily and lists nothing for its first few
391415 * hundred milliseconds, and a local instance starts a fresh server for each
......@@ -541,12 +565,16 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* (
541565 }
542566 const models = providerModelsFromSettings(
543567 result.value
544 .map((model) => ({
545 slug: `${model.providerID}/${model.id}`,
546 name: model.name,
547 isCustom: false,
548 capabilities: openCode2ModelCapabilities(model),
549 }))
568 .map((model) => {
569 const subProvider = subProviderLabelFor(model);
570 return {
571 slug: `${model.providerID}/${model.id}`,
572 name: model.name,
573 ...(subProvider ? { subProvider } : {}),
574 isCustom: false,
575 capabilities: openCode2ModelCapabilities(model),
576 };
577 })
550578 .toSorted((left, right) => left.name.localeCompare(right.name)),
551579 settings.customModels,
552580 DEFAULT_OPENCODE_MODEL_CAPABILITIES,