Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ Troubleshooting:
- If auto-capture reports that a provider is not connected, confirm the provider name with `opencode providers list` and configure that provider in opencode first.
- If a proxy or custom provider returns plain text instead of structured/tool output, choose another model/provider or use one of the manual provider modes above.
- For models that reject `temperature`, add `"memoryTemperature": false` when using manual API configuration.
- For models that reject forced tool calls (`tool_choice: "required"`, e.g. some thinking modes), add `"forceToolChoice": false` when using `openai-chat` / `orcarouter`.
- **Unsupported platforms:** Intel Mac (`darwin/x64`) is not supported — `@tursodatabase/database` and fixed `onnxruntime-node` releases (pinned `1.30.0`) ship no x64 native binding. Use Apple Silicon, Linux, or Windows, or a remote embedding endpoint via `embeddingApiUrl` + `embeddingApiKey`. MLX is not supported.

## Public Subpath Exports
Expand Down
13 changes: 13 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ interface OpenCodeMemConfig {
memoryApiUrl?: string;
memoryApiKey?: string;
memoryTemperature?: number | false;
/**
* Force chat-completion providers to send `tool_choice: "required"`.
* Defaults to true when unset. Set to false for models that reject forced
* tool choice (e.g. some thinking/reasoning modes).
*/
forceToolChoice?: boolean;
memoryExtraParams?: Record<string, unknown>;
opencodeProvider?: string;
opencodeModel?: string;
Expand Down Expand Up @@ -131,6 +137,7 @@ const DEFAULTS: Required<
| "memoryApiKey"
| "memoryProvider"
| "memoryTemperature"
| "forceToolChoice"
| "memoryExtraParams"
| "opencodeProvider"
| "opencodeModel"
Expand All @@ -150,6 +157,7 @@ const DEFAULTS: Required<
memoryApiKey?: string;
memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter";
memoryTemperature?: number | false;
forceToolChoice?: boolean;
memoryExtraParams?: Record<string, unknown>;
opencodeProvider?: string;
opencodeModel?: string;
Expand Down Expand Up @@ -487,6 +495,10 @@ const CONFIG_TEMPLATE = `{
// Set to false and add "memoryTemperature": false in config when using such models
"memoryTemperature": 0.3,

// Force tool calls on openai-chat / orcarouter (tool_choice: "required"). Default true.
// Some thinking/reasoning models reject forced tool choice — set false to fall back to "auto":
// "forceToolChoice": false,

// Extra parameters to include in API request body
// Useful for local inference servers (e.g. llama-server with --jinja) that support
// additional parameters like disabling thinking/reasoning mode
Expand Down Expand Up @@ -723,6 +735,7 @@ function buildConfig(fileConfig: OpenCodeMemConfig) {
memoryApiUrl: fileConfig.memoryApiUrl,
memoryApiKey,
memoryTemperature: fileConfig.memoryTemperature,
forceToolChoice: fileConfig.forceToolChoice,
memoryExtraParams: fileConfig.memoryExtraParams,
opencodeProvider: fileConfig.opencodeProvider,
opencodeModel: fileConfig.opencodeModel,
Expand Down
2 changes: 2 additions & 0 deletions src/services/ai/provider-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface MemoryProviderRuntimeConfig {
memoryApiUrl?: string;
memoryApiKey?: string;
memoryTemperature?: number | false;
forceToolChoice?: boolean;
memoryExtraParams?: Record<string, unknown>;
autoCaptureMaxIterations?: number;
autoCaptureIterationTimeout?: number;
Expand Down Expand Up @@ -44,6 +45,7 @@ export function buildMemoryProviderConfig(
apiUrl: memoryApiUrl || "",
apiKey: memoryApiKey || "",
memoryTemperature: config.memoryTemperature,
forceToolChoice: config.forceToolChoice,
extraParams: config.memoryExtraParams,
maxIterations: overrides.maxIterations ?? config.autoCaptureMaxIterations,
iterationTimeout: overrides.iterationTimeout ?? config.autoCaptureIterationTimeout,
Expand Down
8 changes: 8 additions & 0 deletions src/services/ai/providers/base-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ export interface ProviderConfig {
maxTokens?: number;
memoryTemperature?: number | false;
extraParams?: Record<string, unknown>;
/**
* Force the model to emit a tool call instead of free text. Defaults to true
* on chat-completion providers: prompts already demand a tool call, and
* `tool_choice: "auto"` lets some models answer in prose, which silently
* drops the structured result (see tag migration). Set to false to opt out
* for providers that reject `tool_choice: "required"`.
*/
forceToolChoice?: boolean;
}

const PROTECTED_KEYS = new Set([
Expand Down
13 changes: 11 additions & 2 deletions src/services/ai/providers/openai-chat-completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type RequestBody = {
model: string;
messages: APIMessage[];
tools: ChatCompletionTool[];
tool_choice: "auto";
tool_choice: "auto" | "required";
temperature?: number;
[key: string]: unknown;
};
Expand Down Expand Up @@ -264,7 +264,7 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
model: this.resolveModel(),
messages,
tools: [toolSchema],
tool_choice: "auto",
tool_choice: this.config.forceToolChoice === false ? "auto" : "required",
};

if (this.config.memoryTemperature !== false) {
Expand Down Expand Up @@ -311,6 +311,15 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
) {
errorMessage =
'Your model does not support the temperature parameter. Add "memoryTemperature": false to your config file to disable it.';
} else if (
response.status === 400 &&
/tool_choice/i.test(errorText) &&
(/Thinking mode does not support/i.test(errorText) ||
/unsupported/i.test(errorText) ||
/not support/i.test(errorText))
) {
errorMessage =
'Your model does not support tool_choice "required". Add "forceToolChoice": false to your config file to fall back to "auto".';
}

return {
Expand Down
2 changes: 1 addition & 1 deletion src/services/api-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1495,7 +1495,7 @@ export async function handleRunTagMigrationBatch(
: [];

if (currentTags.length === 0) {
const prompt = `Generate 2-4 short technical tags for this memory content:\n\n${m.content}\n\nReturn ONLY a comma-separated list of tags.`;
const prompt = `Generate 2-4 short technical tags for this memory content. Call the save_tags tool with a "tags" array.\n\n${m.content}`;
const result = await provider.executeToolCall(
"You are a technical tagger.",
prompt,
Expand Down
13 changes: 13 additions & 0 deletions tests/ai-provider-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,23 @@ describe("AI provider config", () => {
apiUrl: "https://api.openai.com/v1",
apiKey: "sk-test",
memoryTemperature: false,
forceToolChoice: undefined,
maxIterations: 7,
iterationTimeout: 1234,
});
});

it("builds provider config with forceToolChoice from runtime config", () => {
const providerConfig = buildMemoryProviderConfig({
memoryModel: "deepseek-v4-flash",
memoryApiUrl: "https://openrouter.ai/api/v1",
memoryApiKey: "sk-test",
forceToolChoice: false,
});

expect(providerConfig.forceToolChoice).toBe(false);
});

it("rejects placeholder API keys before a provider request is built", () => {
expect(() =>
buildMemoryProviderConfig({
Expand Down Expand Up @@ -138,6 +150,7 @@ describe("AI provider config", () => {
model: "",
apiUrl: "",
apiKey: "sk-orca-test",
forceToolChoice: undefined,
maxIterations: undefined,
iterationTimeout: undefined,
});
Expand Down
95 changes: 94 additions & 1 deletion tests/openai-chat-completion-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ describe("OpenAIChatCompletionProvider", () => {
expect(capturedBody?.model).toBe("gpt-4o-mini");
expect(Array.isArray(capturedBody?.messages)).toBe(true);
expect(Array.isArray(capturedBody?.tools)).toBe(true);
expect(capturedBody?.tool_choice).toBe("auto");
expect(capturedBody?.tool_choice).toBe("required");
});

it("includes temperature 0.3 by default", async () => {
Expand Down Expand Up @@ -322,6 +322,25 @@ describe("OpenAIChatCompletionProvider", () => {
expect(result.error).toContain("memoryTemperature");
});

it("returns friendly message when thinking mode rejects tool_choice required", async () => {
globalThis.fetch = makeFetch({
ok: false,
status: 400,
body: "Thinking mode does not support this tool_choice",
});

const result = await makeProvider({ apiUrl: "https://api.openai.com/v1" }).executeToolCall(
"system",
"user",
toolSchema,
"session-id"
);

expect(result.success).toBe(false);
expect(result.error).toContain("forceToolChoice");
expect(result.error).toContain("false");
});

it("returns success: false when response has no choices", async () => {
globalThis.fetch = makeFetch({ ok: true, body: { choices: [] } } as any);

Expand Down Expand Up @@ -439,4 +458,78 @@ describe("OpenAIChatCompletionProvider", () => {

expect(result.success).toBe(false);
});

it("defaults to tool_choice=required so models cannot answer with prose", async () => {
let sentBody: any;
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
sentBody = JSON.parse(String(init?.body));
return {
ok: true,
status: 200,
statusText: "OK",
text: async () => "",
json: async () => ({
choices: [
{
message: {
content: null,
tool_calls: [
{
id: "call-1",
type: "function",
function: { name: "save_memories", arguments: "{}" },
},
],
},
},
],
}),
} as Response;
}) as typeof fetch;

await makeProvider({ apiUrl: "https://api.openai.com/v1" }).executeToolCall(
"system",
"user",
toolSchema,
"session-id"
);

expect(sentBody.tool_choice).toBe("required");
});

it("honors forceToolChoice=false to fall back to tool_choice=auto", async () => {
let sentBody: any;
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
sentBody = JSON.parse(String(init?.body));
return {
ok: true,
status: 200,
statusText: "OK",
text: async () => "",
json: async () => ({
choices: [
{
message: {
content: null,
tool_calls: [
{
id: "call-1",
type: "function",
function: { name: "save_memories", arguments: "{}" },
},
],
},
},
],
}),
} as Response;
}) as typeof fetch;

await makeProvider({
apiUrl: "https://api.openai.com/v1",
forceToolChoice: false,
}).executeToolCall("system", "user", toolSchema, "session-id");

expect(sentBody.tool_choice).toBe("auto");
});
});
2 changes: 1 addition & 1 deletion tests/orcarouter-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ describe("OrcaRouterProvider", () => {
await provider.executeToolCall("system", "user", toolSchema, "session-id");

expect(capturedBody?.model).toBe(ORCAROUTER_DEFAULT_MODEL);
expect(capturedBody?.tool_choice).toBe("auto");
expect(capturedBody?.tool_choice).toBe("required");
expect(Array.isArray(capturedBody?.messages)).toBe(true);
expect(Array.isArray(capturedBody?.tools)).toBe(true);
});
Expand Down
Loading