Skip to content
Open
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
169 changes: 169 additions & 0 deletions apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/provider/Layers/OpenCodeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
OpenCodeRuntimeError,
openCodeQuestionId,
openCodeRuntimeErrorDetail,
parseOpenCodeModelSlug,
toOpenCodeRuntimeModel,
runOpenCodeSdk,
toOpenCodeFileParts,
toOpenCodePermissionReply,
Expand Down Expand Up @@ -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,
Expand Down
77 changes: 77 additions & 0 deletions apps/server/src/provider/Layers/OpenCodeProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
9 changes: 7 additions & 2 deletions apps/server/src/provider/Layers/OpenCodeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,20 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerPr
continue;
}

for (const model of Object.values(provider.models)) {
for (const [modelID, model] of Object.entries(provider.models)) {
const name = nonEmptyTrimmed(model.name);
if (!name) {
continue;
}

const runtimeModelID = nonEmptyTrimmed(modelID);
if (!runtimeModelID) {
continue;
}

const subProvider = nonEmptyTrimmed(provider.name);
models.push({
slug: `${provider.id}/${model.id}`,
slug: `${provider.id}/${runtimeModelID}`,
name,
...(subProvider ? { subProvider } : {}),
isCustom: false,
Expand Down
66 changes: 65 additions & 1 deletion apps/server/src/provider/opencodeRuntime.cliParsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import * as NodeAssert from "node:assert/strict";

import { describe, it } from "vite-plus/test";

import { parseModelsCliOutput, parseAgentListCliOutput } from "./opencodeRuntime.ts";
import {
parseAgentListCliOutput,
parseModelsCliOutput,
parseOpenCodeModelSlug,
toOpenCodeRuntimeModel,
} from "./opencodeRuntime.ts";

describe("parseModelsCliOutput", () => {
it("parses a single model from a single provider", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
Loading
Loading