Skip to content

Commit a0789b5

Browse files
committed
fix(provider-opencode): tell OpenCode Zen and Go models apart
One OpenCode instance fronts several providers whose catalogues overlap, and Step 5 Preview Free is listed by both OpenCode Zen and OpenCode Go. The 2.x status check dropped the provider name when it built the snapshot's model list, so every model reaches the composer with the instance's own display name and nothing beside it. Two rows for the same model from two different subscriptions then read the same, and a user cannot tell which one a turn bills (#17100). The status check now reads the server's provider list in the same connection as its model list and labels each model with the provider that listed it. Clients already render that label: web and desktop show it under the model name, mobile shows it as the row's subtitle, and the picker's search matches it. - A provider the server calls plain "OpenCode" gets no label, because that would repeat the instance name the row already shows. - The provider id stands in until the server names its providers, which it does lazily with the rest of its catalog. Because the id on its own says little, the row then reads "OpenCode · opencode-go" rather than "opencode-go". - A sub-provider whose name already carries the provider it runs under ("OpenCode Zen" under "OpenCode") reads on its own, so a row shows "OpenCode Zen" instead of "OpenCode · OpenCode Zen". That is how OpenCode's own picker lists them, and mobile already rendered it that way. - A server that cannot name its providers still lists its models, so a failed provider read degrades to those ids instead of failing the status check.
1 parent 43f8a8d commit a0789b5

8 files changed

Lines changed: 358 additions & 21 deletions

File tree

‎apps/web/src/components/chat/ModelListRow.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { memo } from "react";
33
import { CheckIcon, StarIcon } from "lucide-react";
44
import {
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
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
3+
import { getDisplayModelName, getProviderRowLabel } from "./providerIconUtils";
4+
5+
describe("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+
24+
describe("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‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
*/
41+
export 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+
3556
export function getTriggerDisplayModelName(model: ModelEsque): string {
3657
return getDisplayModelName(model, { preferShortName: true });
3758
}

‎packages/provider-opencode/src/server/driver.ts‎

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAda
3434
import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration";
3535
import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
3636
import { readOpenCodeGoUsageLimits } from "./usageLimits.ts";
37+
import { loadOpenCode2Catalog } from "./openCode2Catalog.ts";
3738
import {
3839
checkOpenCodeProviderStatus,
3940
loadOpenCode2Workspace,
@@ -296,18 +297,8 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
296297
),
297298
});
298299
const loadOpenCode2Models = yield* makeOpenCode2ModelLoader(
299-
openCode2Server.withConnection((connection) =>
300-
connection.client.model.list({ location: { directory: host.paths.cwd } }).pipe(
301-
Effect.map((models) => models.data),
302-
Effect.mapError(
303-
(cause) =>
304-
new OpenCodeRuntime.OpenCodeRuntimeError({
305-
operation: "model.list",
306-
detail: "The OpenCode server could not list its models.",
307-
cause,
308-
}),
309-
),
310-
),
300+
openCode2Server.withConnection(({ client }) =>
301+
loadOpenCode2Catalog(client, host.paths.cwd),
311302
),
312303
);
313304
// A 2.x server lists skills and commands per directory, so one server
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { assert, it } from "@effect/vitest";
2+
import * as Effect from "effect/Effect";
3+
import * as Fiber from "effect/Fiber";
4+
import * as Layer from "effect/Layer";
5+
import * as TestClock from "effect/testing/TestClock";
6+
import { HttpClient, HttpClientResponse } from "effect/http";
7+
import { describe } from "vite-plus/test";
8+
9+
import { loadOpenCode2Catalog } from "./openCode2Catalog.ts";
10+
import * 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+
*/
17+
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}}]}`;
18+
19+
/** `GET /api/provider` from the same server. */
20+
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"}}]}`;
21+
22+
const 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+
*/
52+
const 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+
67+
describe("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+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { OpenCodeClient } from "@opencode/client/effect";
2+
import * as Effect from "effect/Effect";
3+
import * as Option from "effect/Option";
4+
5+
import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
6+
import 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+
*/
14+
const 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+
*/
33+
export 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‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,47 @@ it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (i
699699
}),
700700
);
701701

702+
it.effect("labels each model with the sub-provider that listed it", () =>
703+
Effect.gen(function* () {
704+
// Names come from the server's provider list; see openCode2Catalog.test.ts
705+
// for reading that list itself.
706+
const snapshot = yield* checkProvider(
707+
makeOpenCodeSettings({
708+
serverUrl: "http://127.0.0.1:9999",
709+
serverPassword: "secret-password",
710+
}),
711+
process.cwd(),
712+
undefined,
713+
replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"),
714+
Effect.succeed([
715+
{
716+
providerID: "openai",
717+
id: "gpt-5.4",
718+
name: "GPT-5.4",
719+
providerName: "OpenAI",
720+
variants: [],
721+
},
722+
{
723+
providerID: "custom",
724+
id: "m1",
725+
name: "Custom 1",
726+
providerName: "OpenCode",
727+
variants: [],
728+
},
729+
{ providerID: "anthropic", id: "claude", name: "Claude", variants: [] },
730+
]),
731+
);
732+
733+
const subProviderFor = (slug: string) =>
734+
snapshot.models.find((model) => model.slug === slug)?.subProvider;
735+
NodeAssert.equal(subProviderFor("openai/gpt-5.4"), "OpenAI");
736+
// A provider called plain "OpenCode" would repeat the instance name.
737+
NodeAssert.equal(subProviderFor("custom/m1"), undefined);
738+
// The id stands in until the server names its providers.
739+
NodeAssert.equal(subProviderFor("anthropic/claude"), "anthropic");
740+
}),
741+
);
742+
702743
it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () =>
703744
Effect.gen(function* () {
704745
const snapshot = yield* checkProvider(

0 commit comments

Comments
 (0)