From 885fc6dae0357a25bb4af72025c5f700be2c4e90 Mon Sep 17 00:00:00 2001 From: "Ottavio (opencode)" Date: Sun, 2 Aug 2026 12:15:26 -0700 Subject: [PATCH] feat: add opencodeVariant passthrough for opencode-provider model calls --- README.md | 2 + src/config.ts | 9 +++ src/services/ai/opencode-provider.ts | 14 +++- src/services/auto-capture.ts | 1 + src/services/user-memory-learning.ts | 2 + src/services/user-profile/ai-cleanup.ts | 1 + .../user-profile/user-profile-manager.ts | 3 + tests/config-resolution.test.ts | 19 +++++ tests/opencode-provider.test.ts | 81 +++++++++++++++++++ 9 files changed, 130 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 073375a2..62d5bd4c 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,8 @@ Supported providers: any provider listed by `opencode providers list` (e.g. `ant If `opencodeProvider` and `opencodeModel` are set, they take precedence over the manual `memoryProvider` settings below. +**Model variant (optional):** set `"opencodeVariant"` to pass a model variant through to opencode's model object, e.g. `"opencodeVariant": "thinking-off"` for `deepseek-v4-flash` on a custom provider alias whose auth fails without the variant (401 `ProviderModelNotFoundError`). The value is forwarded verbatim as `model.variant` on every opencode structured-output request; leave it unset to keep the current behavior. + **Follow the session model:** set `"opencodeModel": "inherit"` to use a concrete OpenCode model at call time instead of a pinned id. For **auto-capture**, each prompt is recorded via the `chat.params` hook and the capture request reuses that prompt's provider/model. For **profile learning** and other structured-output paths (which are not tied to a single user message), `inherit` falls back to the most recent model in OpenCode's `model.json` recent list (preferring the configured `opencodeProvider`). Sending the literal model id `inherit` is never valid and previously caused `ProviderModelNotFoundError: Model not found: /inherit` on those paths. `opencodeProvider` is still required as the normal config gate. **Fallback:** Manual API configuration (if not using opencodeProvider): diff --git a/src/config.ts b/src/config.ts index 9247e7e9..a360235e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,6 +51,8 @@ interface OpenCodeMemConfig { memoryExtraParams?: Record; opencodeProvider?: string; opencodeModel?: string; + /** Optional model variant (e.g. "thinking-off") passed through to opencode. */ + opencodeVariant?: string; aiSessionRetentionDays?: number; webServerEnabled?: boolean; webServerPort?: number; @@ -111,6 +113,7 @@ const DEFAULTS: Required< | "memoryExtraParams" | "opencodeProvider" | "opencodeModel" + | "opencodeVariant" | "autoCaptureLanguage" | "userEmailOverride" | "userNameOverride" @@ -129,6 +132,7 @@ const DEFAULTS: Required< memoryExtraParams?: Record; opencodeProvider?: string; opencodeModel?: string; + opencodeVariant?: string; autoCaptureLanguage?: string; userEmailOverride?: string; userNameOverride?: string; @@ -329,6 +333,10 @@ const CONFIG_TEMPLATE = `{ // OpenAI (API key): "opencodeProvider": "openai", "opencodeModel": "gpt-4o-mini" // GitHub Copilot: "opencodeProvider": "github-copilot", "opencodeModel": "gpt-4o-mini" // + // Optional model variant (passed through to opencode's model object, e.g. + // "thinking-off" for deepseek-v4-flash when thinking is not needed): + // "opencodeVariant": "thinking-off" + // // "opencodeProvider": "anthropic", // "opencodeModel": "claude-haiku-4-5-20251001", @@ -615,6 +623,7 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { memoryExtraParams: fileConfig.memoryExtraParams, opencodeProvider: fileConfig.opencodeProvider, opencodeModel: fileConfig.opencodeModel, + opencodeVariant: fileConfig.opencodeVariant, autoCaptureProviderStatus: getAutoCaptureProviderStatus({ opencodeProvider: fileConfig.opencodeProvider, opencodeModel: fileConfig.opencodeModel, diff --git a/src/services/ai/opencode-provider.ts b/src/services/ai/opencode-provider.ts index 6df80988..8d74bd8e 100644 --- a/src/services/ai/opencode-provider.ts +++ b/src/services/ai/opencode-provider.ts @@ -137,13 +137,18 @@ function sessionCreateBody(): Record { function sessionPromptFields(args: { providerID: string; modelID: string; + variant?: string; systemPrompt: string; userPrompt: string; jsonSchema: Record; retryCount?: number; }): Record { return { - model: { providerID: args.providerID, modelID: args.modelID }, + model: { + providerID: args.providerID, + modelID: args.modelID, + ...(args.variant ? { variant: args.variant } : {}), + }, agent: STRUCTURED_OUTPUT_AGENT, system: args.systemPrompt, parts: [{ type: "text", text: args.userPrompt }], @@ -163,6 +168,7 @@ export interface StructuredOutputOptions { client: OpencodeClient; providerID: string; modelID: string; + variant?: string; systemPrompt: string; userPrompt: string; schema: z.ZodType; @@ -232,7 +238,7 @@ export async function generateStructuredOutput(opts: StructuredOutputOptions< providerID: opts.providerID, modelID: opts.modelID, }); - const { client, systemPrompt, userPrompt, schema, directory, retryCount } = opts; + const { client, systemPrompt, userPrompt, schema, directory, retryCount, variant } = opts; const { providerID, modelID } = resolved; const jsonSchema = @@ -246,6 +252,7 @@ export async function generateStructuredOutput(opts: StructuredOutputOptions< return generateViaSdkClient(client, { providerID, modelID, + variant, systemPrompt, userPrompt, directory, @@ -273,6 +280,7 @@ export async function generateStructuredOutput(opts: StructuredOutputOptions< directory, providerID, modelID, + variant, systemPrompt, userPrompt, jsonSchema, @@ -321,6 +329,7 @@ type V2SessionClient = { interface SdkStructuredOutputArgs { providerID: string; modelID: string; + variant?: string; systemPrompt: string; userPrompt: string; directory?: string; @@ -496,6 +505,7 @@ interface PromptSessionArgs { directory?: string; providerID: string; modelID: string; + variant?: string; systemPrompt: string; userPrompt: string; jsonSchema: Record; diff --git a/src/services/auto-capture.ts b/src/services/auto-capture.ts index d7c5188c..56dafc26 100644 --- a/src/services/auto-capture.ts +++ b/src/services/auto-capture.ts @@ -422,6 +422,7 @@ Analyze this conversation. If it contains technical work (code, bugs, features, client: v2Client, providerID, modelID, + variant: CONFIG.opencodeVariant, systemPrompt, userPrompt: aiPrompt, schema, diff --git a/src/services/user-memory-learning.ts b/src/services/user-memory-learning.ts index 76035463..79ab1b04 100644 --- a/src/services/user-memory-learning.ts +++ b/src/services/user-memory-learning.ts @@ -529,6 +529,7 @@ Use the update_user_profile tool to save the ${existingProfile ? "updated" : "ne client: v2Client, providerID: CONFIG.opencodeProvider, modelID: CONFIG.opencodeModel, + variant: CONFIG.opencodeVariant, systemPrompt, userPrompt: context, schema, @@ -722,6 +723,7 @@ If no clear chains, return { "paths": [] }.`; client: v2Client, providerID: CONFIG.opencodeProvider, modelID: CONFIG.opencodeModel, + variant: CONFIG.opencodeVariant, systemPrompt, userPrompt, schema: z.object({ diff --git a/src/services/user-profile/ai-cleanup.ts b/src/services/user-profile/ai-cleanup.ts index a7aff30e..4210fec8 100644 --- a/src/services/user-profile/ai-cleanup.ts +++ b/src/services/user-profile/ai-cleanup.ts @@ -333,6 +333,7 @@ async function callViaOpencodeWithClient( model: { providerID: CONFIG.opencodeProvider || "bs-aigw", modelID: CONFIG.opencodeModel || "deepseek-v4-flash", + ...(CONFIG.opencodeVariant ? { variant: CONFIG.opencodeVariant } : {}), }, system: systemPrompt, parts: [{ type: "text", text: prompt }], diff --git a/src/services/user-profile/user-profile-manager.ts b/src/services/user-profile/user-profile-manager.ts index 6e41e96e..cac81073 100644 --- a/src/services/user-profile/user-profile-manager.ts +++ b/src/services/user-profile/user-profile-manager.ts @@ -1378,6 +1378,7 @@ Answer JSON only: { "duplicate": true|false, "reason": "one sentence explanation client: v2Client, providerID: CONFIG.opencodeProvider, modelID: CONFIG.opencodeModel, + variant: CONFIG.opencodeVariant, systemPrompt: "You are a semantic duplicate detector. Output valid JSON.", userPrompt: prompt, schema: z.object({ duplicate: z.boolean(), reason: z.string() }), @@ -1578,6 +1579,7 @@ Answer JSON only: { "conflict": true|false, "reason": "one sentence explanation" client: v2Client, providerID: CONFIG.opencodeProvider, modelID: CONFIG.opencodeModel, + variant: CONFIG.opencodeVariant, systemPrompt: "You are a preference contradiction detector. Output valid JSON.", userPrompt: prompt, schema: z.object({ conflict: z.boolean(), reason: z.string() }), @@ -1842,6 +1844,7 @@ Generate a concise, abstract description of the user's general behavioral tenden client: v2Client, providerID: CONFIG.opencodeProvider!, modelID: CONFIG.opencodeModel!, + variant: CONFIG.opencodeVariant, systemPrompt, userPrompt, schema, diff --git a/tests/config-resolution.test.ts b/tests/config-resolution.test.ts index b944685a..29690b0b 100644 --- a/tests/config-resolution.test.ts +++ b/tests/config-resolution.test.ts @@ -68,4 +68,23 @@ describe("project-scoped config resolution", () => { expect(CONFIG.autoCaptureEnabled).toBe(true); // default value expect(CONFIG.opencodeProvider).toBeUndefined(); }); + + it("parses opencodeVariant next to opencodeProvider/opencodeModel", () => { + existsSpy = spyOn(fs, "existsSync").mockReturnValue(true); + readSpy = spyOn(fs, "readFileSync").mockImplementation((p) => { + const path = normalizePath(p); + if (path.includes(".opencode/opencode-mem")) { + return JSON.stringify({ + opencodeProvider: "opencode-go", + opencodeModel: "deepseek-v4-flash", + opencodeVariant: "thinking-off", + }) as any; + } + return JSON.stringify({}) as any; + }); + initConfig("/my/project"); + expect(CONFIG.opencodeProvider).toBe("opencode-go"); + expect(CONFIG.opencodeModel).toBe("deepseek-v4-flash"); + expect(CONFIG.opencodeVariant).toBe("thinking-off"); + }); }); diff --git a/tests/opencode-provider.test.ts b/tests/opencode-provider.test.ts index dc1fd8b8..67e28e10 100644 --- a/tests/opencode-provider.test.ts +++ b/tests/opencode-provider.test.ts @@ -180,6 +180,87 @@ describe("generateStructuredOutput", () => { expect(isInternalStructuredSession("ses_test_1")).toBe(false); }); + it("passes the variant through in the model object when provided", async () => { + mock = installFetchMock((call) => { + if (call.method === "POST" && call.url.endsWith("/session")) { + return { body: { id: "ses_test_2" } }; + } + if (call.method === "POST" && call.url.includes("/session/ses_test_2/message")) { + return { + body: { + info: { structured_output: { topic: "auth", count: 1 } }, + parts: [], + }, + }; + } + if (call.method === "DELETE") { + return { body: true }; + } + throw new Error(`unexpected fetch: ${call.method} ${call.url}`); + }); + + const client = createV2Client("http://127.0.0.1:9999"); + const result = await generateStructuredOutput({ + client, + providerID: "opencode-go", + modelID: "deepseek-v4-flash", + variant: "thinking-off", + systemPrompt: "system", + userPrompt: "user", + schema, + }); + + expect(result).toEqual({ topic: "auth", count: 1 }); + + const promptCall = mock.calls.find((c) => c.url.includes("/session/ses_test_2/message")); + expect(promptCall).toBeDefined(); + const promptBody = promptCall!.body as Record; + expect(promptBody.model).toEqual({ + providerID: "opencode-go", + modelID: "deepseek-v4-flash", + variant: "thinking-off", + }); + }); + + it("omits variant from the model object when not provided", async () => { + mock = installFetchMock((call) => { + if (call.method === "POST" && call.url.endsWith("/session")) { + return { body: { id: "ses_test_3" } }; + } + if (call.method === "POST" && call.url.includes("/session/ses_test_3/message")) { + return { + body: { + info: { structured_output: { topic: "auth", count: 2 } }, + parts: [], + }, + }; + } + if (call.method === "DELETE") { + return { body: true }; + } + throw new Error(`unexpected fetch: ${call.method} ${call.url}`); + }); + + const client = createV2Client("http://127.0.0.1:9999"); + await generateStructuredOutput({ + client, + providerID: "anthropic", + modelID: "claude-haiku-4-5", + systemPrompt: "system", + userPrompt: "user", + schema, + }); + + const promptCall = mock.calls.find((c) => c.url.includes("/session/ses_test_3/message")); + expect(promptCall).toBeDefined(); + const promptBody = promptCall!.body as Record; + expect(promptBody.model).toEqual({ + providerID: "anthropic", + modelID: "claude-haiku-4-5", + }); + expect(promptBody.model).not.toHaveProperty("variant"); + }); + it("rejects with full info.error details when opencode reports an assistant error", async () => { mock = installFetchMock((call) => { if (call.method === "POST" && call.url.endsWith("/session")) {