diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..aa70476ab171 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -388,6 +388,175 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("sends OpenCode Go models to the runtime as a bare modelID", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-go-deepseek"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode-go/deepseek-v4-pro", + ), + }); + + NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { + sessionID: "http://127.0.0.1:9999/session", + model: { + providerID: "opencode-go", + modelID: "deepseek-v4-pro", + }, + parts: [{ type: "text", text: "hello" }], + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("strips a duplicated OpenCode Go provider prefix before the runtime request", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-go-doubled"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode-go/opencode-go/deepseek-v4-pro", + ), + }); + + NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { + sessionID: "http://127.0.0.1:9999/session", + model: { + providerID: "opencode-go", + modelID: "deepseek-v4-pro", + }, + parts: [{ type: "text", text: "hello" }], + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps a catalog key that starts with the same provider id", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-huggingface-slash-key"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "huggingface/huggingface/CodeBERTa", + ), + }); + + NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { + sessionID: "http://127.0.0.1:9999/session", + model: { + providerID: "huggingface", + modelID: "huggingface/CodeBERTa", + }, + parts: [{ type: "text", text: "hello" }], + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects a bare OpenCode model id that is missing the provider namespace", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-bare-opencode-model"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const error = yield* adapter + .sendTurn({ + threadId, + input: "hello", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "deepseek-v4-pro", + ), + }) + .pipe(Effect.flip); + + NodeAssert.equal(error._tag, "ProviderAdapterValidationError"); + if (error._tag !== "ProviderAdapterValidationError") { + throw new Error("Unexpected error type"); + } + NodeAssert.equal( + error.issue, + "OpenCode model selection must use the 'provider/model' format.", + ); + NodeAssert.deepEqual(runtimeMock.state.promptCalls, []); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("normalizes a duplicated OpenCode Go slug on the resume sendTurn path", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-go-resume-model"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_persisted" }, + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode-go/opencode-go/kimi-k2.7-code", + ), + }); + + yield* adapter.sendTurn({ + threadId, + input: "continue", + }); + + NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { + sessionID: "ses_persisted", + model: { + providerID: "opencode-go", + modelID: "kimi-k2.7-code", + }, + parts: [{ type: "text", text: "continue" }], + }); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("falls back to a fresh session when the persisted session is gone", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7c..6dfddd30e15a 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -44,7 +44,7 @@ import { OpenCodeRuntimeError, openCodeQuestionId, openCodeRuntimeErrorDetail, - parseOpenCodeModelSlug, + toOpenCodeRuntimeModel, runOpenCodeSdk, toOpenCodeFileParts, toOpenCodePermissionReply, @@ -1446,7 +1446,7 @@ export function makeOpenCodeAdapter( issue: `OpenCode model selection is bound to instance '${modelSelection?.instanceId}', expected '${boundInstanceId}'.`, }); } - const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); + const parsedModel = toOpenCodeRuntimeModel(modelSelection?.model); if (!parsedModel) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 41454b48b314..54fbf8c46bb1 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -207,6 +207,83 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect("builds OpenCode Go slugs from the inventory map key, not a namespaced model.id", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["opencode-go", "anthropic"], + all: [ + { + id: "opencode-go", + name: "OpenCode Go", + models: { + "deepseek-v4-pro": { + id: "opencode-go/deepseek-v4-pro", + name: "DeepSeek V4 Pro", + }, + "kimi-k2.7-code": { + id: "kimi-k2.7-code", + name: "Kimi K2.7 Code", + }, + }, + }, + { + id: "anthropic", + name: "Anthropic", + models: { + "claude-sonnet-4-5": { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + }, + }, + }, + ], + default: {}, + }, + agents: [], + }; + + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const slugs = snapshot.models.map((entry) => entry.slug).toSorted(); + + NodeAssert.deepEqual(slugs, [ + "anthropic/claude-sonnet-4-5", + "opencode-go/deepseek-v4-pro", + "opencode-go/kimi-k2.7-code", + ]); + }), + ); + + it.effect("keeps a catalog map key that already starts with the provider id", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["huggingface"], + all: [ + { + id: "huggingface", + name: "Hugging Face", + models: { + "huggingface/CodeBERTa": { + id: "huggingface/CodeBERTa", + name: "CodeBERTa", + }, + }, + }, + ], + default: {}, + }, + agents: [], + }; + + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + NodeAssert.deepEqual( + snapshot.models.map((entry) => entry.slug), + ["huggingface/huggingface/CodeBERTa"], + ); + }), + ); + it.effect("does not spawn a local server for health check (uses CLI instead)", () => Effect.gen(function* () { yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 21014e33f08b..20d2693a1273 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -226,15 +226,20 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray { it("parses a single model from a single provider", () => { @@ -150,6 +155,32 @@ describe("parseModelsCliOutput", () => { NodeAssert.equal(model.id, "qwen/qwen3-coder"); NodeAssert.equal(model.providerID, "openrouter"); }); + + it("keys OpenCode Go models by the slug suffix even when JSON id is namespaced", () => { + const stdout = [ + "opencode-go/deepseek-v4-pro", + JSON.stringify({ + id: "opencode-go/deepseek-v4-pro", + providerID: "opencode-go", + name: "DeepSeek V4 Pro", + status: "active", + }), + "opencode-go/kimi-k2.7-code", + JSON.stringify({ + id: "kimi-k2.7-code", + providerID: "opencode-go", + name: "Kimi K2.7 Code", + status: "active", + }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + const provider = result.providers.get("opencode-go")!; + NodeAssert.ok(provider); + NodeAssert.deepEqual([...result.connected], ["opencode-go"]); + NodeAssert.equal(provider.models["deepseek-v4-pro"]?.id, "opencode-go/deepseek-v4-pro"); + NodeAssert.equal(provider.models["kimi-k2.7-code"]?.id, "kimi-k2.7-code"); + }); }); describe("parseAgentListCliOutput", () => { @@ -252,3 +283,36 @@ describe("parseAgentListCliOutput", () => { NodeAssert.equal(result[1]!.hidden, false); }); }); + +describe("toOpenCodeRuntimeModel", () => { + it("splits on the first slash without rewriting a well-formed slug", () => { + NodeAssert.deepEqual(toOpenCodeRuntimeModel("opencode-go/deepseek-v4-pro"), { + providerID: "opencode-go", + modelID: "deepseek-v4-pro", + }); + NodeAssert.deepEqual(toOpenCodeRuntimeModel("openrouter/qwen/qwen3-coder"), { + providerID: "openrouter", + modelID: "qwen/qwen3-coder", + }); + }); + + it("strips a duplicated hosted OpenCode prefix and leaves other catalogs intact", () => { + NodeAssert.deepEqual(toOpenCodeRuntimeModel("opencode-go/opencode-go/deepseek-v4-pro"), { + providerID: "opencode-go", + modelID: "deepseek-v4-pro", + }); + NodeAssert.deepEqual(toOpenCodeRuntimeModel("opencode/opencode/gpt-5.4"), { + providerID: "opencode", + modelID: "gpt-5.4", + }); + NodeAssert.deepEqual(toOpenCodeRuntimeModel("huggingface/huggingface/CodeBERTa"), { + providerID: "huggingface", + modelID: "huggingface/CodeBERTa", + }); + }); + + it("rejects a bare model id the same way parseOpenCodeModelSlug does", () => { + NodeAssert.equal(parseOpenCodeModelSlug("deepseek-v4-pro"), null); + NodeAssert.equal(toOpenCodeRuntimeModel("deepseek-v4-pro"), null); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 399e7d6b3b0f..514bbf902b20 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -308,6 +308,32 @@ export function parseOpenCodeModelSlug( }; } +/** + * Hosted OpenCode catalogs (Zen and Go) key models by bare ID. A T3 inventory + * slug built from `model.id` can repeat that provider (`opencode-go/opencode-go/…`). + * Other providers may legitimately use a key that starts with `provider/`. + */ +const OPENCODE_HOSTED_PROVIDER_IDS = new Set(["opencode", "opencode-go"]); + +export function toOpenCodeRuntimeModel( + slug: string | null | undefined, +): ParsedOpenCodeModelSlug | null { + const parsed = parseOpenCodeModelSlug(slug); + if (!parsed) { + return null; + } + if (!OPENCODE_HOSTED_PROVIDER_IDS.has(parsed.providerID)) { + return parsed; + } + + const prefix = `${parsed.providerID}/`; + let modelID = parsed.modelID; + while (modelID.startsWith(prefix) && modelID.length > prefix.length) { + modelID = modelID.slice(prefix.length); + } + return { providerID: parsed.providerID, modelID }; +} + export function openCodeQuestionId( index: number, question: QuestionRequest["questions"][number], diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index e09c3db2cffc..7c5993f35154 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -366,7 +366,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" readonly modelSelection: ModelSelection; readonly attachments?: ReadonlyArray | undefined; }) { - const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); + const parsedModel = OpenCodeRuntime.toOpenCodeRuntimeModel(input.modelSelection.model); if (!parsedModel) { return yield* new TextGenerationError({ operation: input.operation,