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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions apps/web/src/components/chat/ModelListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { memo } from "react";
import { CheckIcon, StarIcon } from "lucide-react";
import {
getDisplayModelName,
getProviderRowLabel,
getTriggerDisplayModelLabel,
type ModelEsque,
} from "./providerIconUtils";
Expand Down Expand Up @@ -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 = (
<ComboboxItem
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/components/chat/providerIconUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vite-plus/test";

import { getDisplayModelName, getProviderRowLabel } from "./providerIconUtils";

describe("getProviderRowLabel", () => {
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");
});
});
21 changes: 21 additions & 0 deletions apps/web/src/components/chat/providerIconUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
15 changes: 3 additions & 12 deletions packages/provider-opencode/src/server/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -296,18 +297,8 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
),
});
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
Expand Down
149 changes: 149 additions & 0 deletions packages/provider-opencode/src/server/openCode2Catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>,
hanging: ReadonlyArray<string> = [],
) =>
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<Record<string, string>>,
hanging: ReadonlyArray<string> = [],
) =>
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);
}),
);
});
74 changes: 74 additions & 0 deletions packages/provider-opencode/src/server/openCode2Catalog.ts
Original file line number Diff line number Diff line change
@@ -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<ReadonlyArray<OpenCode2Model>, 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,
}),
),
);
};
41 changes: 41 additions & 0 deletions packages/provider-opencode/src/server/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading