| 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 | 3 | import { CheckIcon, StarIcon } from "lucide-react"; |
| 4 | 4 | import { |
| 5 | 5 | getDisplayModelName, |
| 6 | getProviderRowLabel, | |
| 6 | 7 | getTriggerDisplayModelLabel, |
| 7 | 8 | type ModelEsque, |
| 8 | 9 | } from "./providerIconUtils"; |
| ... | ... | @@ -43,9 +44,7 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 43 | 44 | disabledReason?: string | null; |
| 44 | 45 | onToggleFavorite: () => void; |
| 45 | 46 | }) { |
| 46 | const providerLabel = props.model.subProvider | |
| 47 | ? `${props.providerDisplayName} · ${props.model.subProvider}` | |
| 48 | : props.providerDisplayName; | |
| 47 | const providerLabel = getProviderRowLabel(props.providerDisplayName, props.model.subProvider); | |
| 49 | 48 | |
| 50 | 49 | const row = ( |
| 51 | 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 | 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 | 56 | export function getTriggerDisplayModelName(model: ModelEsque): string { |
| 36 | 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 | 36 | import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; |
| 37 | 37 | import { openCodeUsageReader, type OpenCodeUsageReaderEnv } from "./usage.ts"; |
| 38 | 38 | import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; |
| 39 | import { loadOpenCode2Catalog } from "./openCode2Catalog.ts"; | |
| 39 | 40 | import { |
| 40 | 41 | checkOpenCodeProviderStatus, |
| 41 | 42 | loadOpenCode2Workspace, |
| ... | ... | @@ -305,18 +306,8 @@ export const OpenCodeDriver: ProviderDriver< |
| 305 | 306 | ), |
| 306 | 307 | }); |
| 307 | 308 | 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), | |
| 320 | 311 | ), |
| 321 | 312 | ); |
| 322 | 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 | 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 | 744 | it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () => |
| 704 | 745 | Effect.gen(function* () { |
| 705 | 746 | const snapshot = yield* checkProvider( |
packages/provider-opencode/src/server/status.ts+34-6| ... | ... | @@ -383,9 +383,33 @@ export interface OpenCode2Model { |
| 383 | 383 | readonly providerID: string; |
| 384 | 384 | readonly id: string; |
| 385 | 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 | 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 | 414 | * A server loads its catalog lazily and lists nothing for its first few |
| 391 | 415 | * hundred milliseconds, and a local instance starts a fresh server for each |
| ... | ... | @@ -541,12 +565,16 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( |
| 541 | 565 | } |
| 542 | 566 | const models = providerModelsFromSettings( |
| 543 | 567 | 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 | }) | |
| 550 | 578 | .toSorted((left, right) => left.name.localeCompare(right.name)), |
| 551 | 579 | settings.customModels, |
| 552 | 580 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, |