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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <provider>/inherit` on those paths. `opencodeProvider` is still required as the normal config gate.

**Fallback:** Manual API configuration (if not using opencodeProvider):
Expand Down
9 changes: 9 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ interface OpenCodeMemConfig {
memoryExtraParams?: Record<string, unknown>;
opencodeProvider?: string;
opencodeModel?: string;
/** Optional model variant (e.g. "thinking-off") passed through to opencode. */
opencodeVariant?: string;
aiSessionRetentionDays?: number;
webServerEnabled?: boolean;
webServerPort?: number;
Expand Down Expand Up @@ -111,6 +113,7 @@ const DEFAULTS: Required<
| "memoryExtraParams"
| "opencodeProvider"
| "opencodeModel"
| "opencodeVariant"
| "autoCaptureLanguage"
| "userEmailOverride"
| "userNameOverride"
Expand All @@ -129,6 +132,7 @@ const DEFAULTS: Required<
memoryExtraParams?: Record<string, unknown>;
opencodeProvider?: string;
opencodeModel?: string;
opencodeVariant?: string;
autoCaptureLanguage?: string;
userEmailOverride?: string;
userNameOverride?: string;
Expand Down Expand Up @@ -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",

Expand Down Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions src/services/ai/opencode-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,18 @@ function sessionCreateBody(): Record<string, unknown> {
function sessionPromptFields(args: {
providerID: string;
modelID: string;
variant?: string;
systemPrompt: string;
userPrompt: string;
jsonSchema: Record<string, unknown>;
retryCount?: number;
}): Record<string, unknown> {
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 }],
Expand All @@ -163,6 +168,7 @@ export interface StructuredOutputOptions<T> {
client: OpencodeClient;
providerID: string;
modelID: string;
variant?: string;
systemPrompt: string;
userPrompt: string;
schema: z.ZodType<T>;
Expand Down Expand Up @@ -232,7 +238,7 @@ export async function generateStructuredOutput<T>(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 =
Expand All @@ -246,6 +252,7 @@ export async function generateStructuredOutput<T>(opts: StructuredOutputOptions<
return generateViaSdkClient(client, {
providerID,
modelID,
variant,
systemPrompt,
userPrompt,
directory,
Expand Down Expand Up @@ -273,6 +280,7 @@ export async function generateStructuredOutput<T>(opts: StructuredOutputOptions<
directory,
providerID,
modelID,
variant,
systemPrompt,
userPrompt,
jsonSchema,
Expand Down Expand Up @@ -321,6 +329,7 @@ type V2SessionClient = {
interface SdkStructuredOutputArgs<T> {
providerID: string;
modelID: string;
variant?: string;
systemPrompt: string;
userPrompt: string;
directory?: string;
Expand Down Expand Up @@ -496,6 +505,7 @@ interface PromptSessionArgs {
directory?: string;
providerID: string;
modelID: string;
variant?: string;
systemPrompt: string;
userPrompt: string;
jsonSchema: Record<string, unknown>;
Expand Down
1 change: 1 addition & 0 deletions src/services/auto-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/services/user-memory-learning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions src/services/user-profile/ai-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand Down
3 changes: 3 additions & 0 deletions src/services/user-profile/user-profile-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }),
Expand Down Expand Up @@ -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() }),
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions tests/config-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
81 changes: 81 additions & 0 deletions tests/opencode-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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")) {
Expand Down