| author | |
| committer | |
| log | 7b09ac7e0022769425471fad1c0d152c03d5954f |
| tree | 5ec88cf8b8144e1ab54d25dc3dd2c6812966978a |
| parent | 472ea7ec5f67fae660dc606b82698d15a2bc850f |
| signature | Signed by PGP key B5690EEEBB952194 |
8 files changed, 358 insertions(+), 21 deletions(-)
apps/web/src/components/chat/ModelListRow.tsx+2-3| ... | @@ -3,6 +3,7 @@ import { memo } from "react"; | ... | @@ -3,6 +3,7 @@ import { memo } from "react"; |
| 3 | import { CheckIcon, StarIcon } from "lucide-react"; | 3 | import { CheckIcon, StarIcon } from "lucide-react"; |
| 4 | import { | 4 | import { |
| 5 | getDisplayModelName, | 5 | getDisplayModelName, |
| 6 | getProviderRowLabel, | ||
| 6 | getTriggerDisplayModelLabel, | 7 | getTriggerDisplayModelLabel, |
| 7 | type ModelEsque, | 8 | type ModelEsque, |
| 8 | } from "./providerIconUtils"; | 9 | } from "./providerIconUtils"; |
| ... | @@ -43,9 +44,7 @@ export const ModelListRow = memo(function ModelListRow(props: { | ... | @@ -43,9 +44,7 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 43 | disabledReason?: string | null; | 44 | disabledReason?: string | null; |
| 44 | onToggleFavorite: () => void; | 45 | onToggleFavorite: () => void; |
| 45 | }) { | 46 | }) { |
| 46 | const providerLabel = props.model.subProvider | 47 | const providerLabel = getProviderRowLabel(props.providerDisplayName, props.model.subProvider); |
| 47 | ? `${props.providerDisplayName} · ${props.model.subProvider}` | ||
| 48 | : props.providerDisplayName; | ||
| 49 | 48 | ||
| 50 | const row = ( | 49 | const row = ( |
| 51 | <ComboboxItem | 50 | <ComboboxItem |
apps/web/src/components/chat/providerIconUtils.test.ts created+34| ... | @@ -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+21| ... | @@ -32,6 +32,27 @@ export function getDisplayModelName( | ... | @@ -32,6 +32,27 @@ export function getDisplayModelName( |
| 32 | return stripLeadingQualifier(name, model.subProvider); | 32 | return stripLeadingQualifier(name, model.subProvider); |
| 33 | } | 33 | } |
| 34 | 34 | ||
| 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 | |||
| 35 | export function getTriggerDisplayModelName(model: ModelEsque): string { | 56 | export function getTriggerDisplayModelName(model: ModelEsque): string { |
| 36 | return getDisplayModelName(model, { preferShortName: true }); | 57 | return getDisplayModelName(model, { preferShortName: true }); |
| 37 | } | 58 | } |
packages/provider-opencode/src/server/driver.ts+3-12| ... | @@ -36,6 +36,7 @@ import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textG | ... | @@ -36,6 +36,7 @@ import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textG |
| 36 | import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; | 36 | import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; |
| 37 | import { openCodeUsageReader, type OpenCodeUsageReaderEnv } from "./usage.ts"; | 37 | import { openCodeUsageReader, type OpenCodeUsageReaderEnv } from "./usage.ts"; |
| 38 | import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; | 38 | import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; |
| 39 | import { loadOpenCode2Catalog } from "./openCode2Catalog.ts"; | ||
| 39 | import { | 40 | import { |
| 40 | checkOpenCodeProviderStatus, | 41 | checkOpenCodeProviderStatus, |
| 41 | loadOpenCode2Workspace, | 42 | loadOpenCode2Workspace, |
| ... | @@ -305,18 +306,8 @@ export const OpenCodeDriver: ProviderDriver< | ... | @@ -305,18 +306,8 @@ export const OpenCodeDriver: ProviderDriver< |
| 305 | ), | 306 | ), |
| 306 | }); | 307 | }); |
| 307 | const loadOpenCode2Models = yield* makeOpenCode2ModelLoader( | 308 | const loadOpenCode2Models = yield* makeOpenCode2ModelLoader( |
| 308 | openCode2Server.withConnection((connection) => | 309 | openCode2Server.withConnection(({ client }) => |
| 309 | connection.client.model.list({ location: { directory: host.paths.cwd } }).pipe( | 310 | loadOpenCode2Catalog(client, host.paths.cwd), |
| 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 | ), | ||
| 320 | ), | 311 | ), |
| 321 | ); | 312 | ); |
| 322 | // A 2.x server lists skills and commands per directory, so one server | 313 | // 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 @@ | ||
| 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 | }); | ||
packages/provider-opencode/src/server/openCode2Catalog.ts created+74| ... | @@ -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+41| ... | @@ -700,6 +700,47 @@ it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (i | ... | @@ -700,6 +700,47 @@ it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (i |
| 700 | }), | 700 | }), |
| 701 | ); | 701 | ); |
| 702 | 702 | ||
| 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 | |||
| 703 | it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () => | 744 | it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () => |
| 704 | Effect.gen(function* () { | 745 | Effect.gen(function* () { |
| 705 | const snapshot = yield* checkProvider( | 746 | const snapshot = yield* checkProvider( |
packages/provider-opencode/src/server/status.ts+34-6| ... | @@ -383,9 +383,33 @@ export interface OpenCode2Model { | ... | @@ -383,9 +383,33 @@ export interface OpenCode2Model { |
| 383 | readonly providerID: string; | 383 | readonly providerID: string; |
| 384 | readonly id: string; | 384 | readonly id: string; |
| 385 | readonly name: string; | 385 | 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; | ||
| 386 | readonly variants: ReadonlyArray<{ readonly id: string }>; | 392 | readonly variants: ReadonlyArray<{ readonly id: string }>; |
| 387 | } | 393 | } |
| 388 | 394 | ||
| 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 | */ | ||
| 403 | function 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 | |||
| 389 | /** | 413 | /** |
| 390 | * A server loads its catalog lazily and lists nothing for its first few | 414 | * A server loads its catalog lazily and lists nothing for its first few |
| 391 | * hundred milliseconds, and a local instance starts a fresh server for each | 415 | * hundred milliseconds, and a local instance starts a fresh server for each |
| ... | @@ -541,12 +565,16 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( | ... | @@ -541,12 +565,16 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( |
| 541 | } | 565 | } |
| 542 | const models = providerModelsFromSettings( | 566 | const models = providerModelsFromSettings( |
| 543 | result.value | 567 | result.value |
| 544 | .map((model) => ({ | 568 | .map((model) => { |
| 545 | slug: `${model.providerID}/${model.id}`, | 569 | const subProvider = subProviderLabelFor(model); |
| 546 | name: model.name, | 570 | return { |
| 547 | isCustom: false, | 571 | slug: `${model.providerID}/${model.id}`, |
| 548 | capabilities: openCode2ModelCapabilities(model), | 572 | name: model.name, |
| 549 | })) | 573 | ...(subProvider ? { subProvider } : {}), |
| 574 | isCustom: false, | ||
| 575 | capabilities: openCode2ModelCapabilities(model), | ||
| 576 | }; | ||
| 577 | }) | ||
| 550 | .toSorted((left, right) => left.name.localeCompare(right.name)), | 578 | .toSorted((left, right) => left.name.localeCompare(right.name)), |
| 551 | settings.customModels, | 579 | settings.customModels, |
| 552 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | 580 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, |