diff options
| author | Adam Malczewski <[email protected]> | 2026-05-19 20:44:09 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-19 20:44:09 +0900 |
| commit | b3d16aa62a1b8724fe57ab3846a83f03a4a3fbc0 (patch) | |
| tree | 480a1fa2c95bf87ab214f709b1b4414aa64e8929 | |
| parent | f78a91c20f658dd404277919a0b872b352c99bb6 (diff) | |
| download | dispatch-b3d16aa62a1b8724fe57ab3846a83f03a4a3fbc0.tar.gz dispatch-b3d16aa62a1b8724fe57ab3846a83f03a4a3fbc0.zip | |
fix: DeepSeek reasoning_content dropped on multi-step tool calls
- Base URL corrected: zen/v1 -> zen/go/v1 (opencode-go provider)
- Model changed: deepseek-v4-flash-free -> deepseek-v4-flash
- Added wrapLanguageModel middleware to inject reasoning_content via
providerMetadata.openaiCompatible before each stream call
- Fixed test mocks: removed vi.importActual (unsupported in Bun), added
tool factory mocks, preserved real tool export in ai mock
- Added 11 tests for the normalizeMessages middleware
| -rwxr-xr-x | bin/prod_secrets | 2 | ||||
| -rw-r--r-- | docker-compose.prod.yml | 2 | ||||
| -rw-r--r-- | docker-compose.yml | 2 | ||||
| -rw-r--r-- | packages/api/src/agent-manager.ts | 4 | ||||
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 64 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 65 | ||||
| -rw-r--r-- | packages/core/src/llm/provider.ts | 67 | ||||
| -rw-r--r-- | packages/core/tests/agent/agent.test.ts | 4 | ||||
| -rw-r--r-- | packages/core/tests/llm/provider.test.ts | 284 | ||||
| -rw-r--r-- | packages/frontend/src/lib/chat.svelte.ts | 2 | ||||
| -rw-r--r-- | problem.md | 79 |
11 files changed, 522 insertions, 53 deletions
diff --git a/bin/prod_secrets b/bin/prod_secrets index 68cc6d9..17e35c2 100755 --- a/bin/prod_secrets +++ b/bin/prod_secrets @@ -30,4 +30,4 @@ echo "" >&2 # --- Output .env format to stdout --- echo "OPENCODE_API_KEY=$(gopass show -o projects/ai-api/opencode_go_key)" -echo "DISPATCH_MODEL=deepseek-v4-flash-free" +echo "DISPATCH_MODEL=deepseek-v4-flash" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 2809e01..35234b8 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -7,7 +7,7 @@ services: - "3000:3000" environment: OPENCODE_API_KEY: ${OPENCODE_API_KEY} - DISPATCH_MODEL: ${DISPATCH_MODEL:-deepseek-v4-flash-free} + DISPATCH_MODEL: ${DISPATCH_MODEL:-deepseek-v4-flash} DISPATCH_WORKING_DIR: /app/workspace volumes: - workspace:/app/workspace diff --git a/docker-compose.yml b/docker-compose.yml index 770c83a..e52d3f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: - .:/app environment: OPENCODE_API_KEY: ${OPENCODE_API_KEY:-} - DISPATCH_MODEL: ${DISPATCH_MODEL:-deepseek-v4-flash-free} + DISPATCH_MODEL: ${DISPATCH_MODEL:-deepseek-v4-flash} DISPATCH_WORKING_DIR: /app frontend: diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index f60b8d3..2991ee1 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -24,7 +24,7 @@ export class AgentManager { private getOrCreateAgent(): Agent { if (!this.agent) { const apiKey = process.env.OPENCODE_API_KEY ?? ""; - const model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash-free"; + const model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash"; const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd(); const tools = [ @@ -36,7 +36,7 @@ export class AgentManager { this.agent = new Agent({ model, apiKey, - baseURL: "https://opencode.ai/zen/v1", + baseURL: "https://opencode.ai/zen/go/v1", systemPrompt: SYSTEM_PROMPT, tools, workingDirectory, diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts index 17b0bff..56cb818 100644 --- a/packages/api/tests/agent-manager.test.ts +++ b/packages/api/tests/agent-manager.test.ts @@ -1,28 +1,48 @@ -import type { AgentEvent } from "@dispatch/core"; +import type { AgentEvent, ToolDefinition } from "@dispatch/core"; import { describe, expect, it, vi } from "vitest"; // Mock @dispatch/core's Agent to avoid real LLM calls -vi.mock("@dispatch/core", async () => { - const actual = await vi.importActual<typeof import("@dispatch/core")>("@dispatch/core"); - return { - ...actual, - Agent: class MockAgent { - status = "idle"; - messages: unknown[] = []; - async *run(_message: string) { - yield { type: "status", status: "running" } as const; - await new Promise<void>((r) => setTimeout(r, 10)); - yield { type: "text-delta", delta: "Hello " } as const; - yield { type: "text-delta", delta: "world" } as const; - yield { - type: "done", - message: { role: "assistant", content: "Hello world" }, - } as const; - yield { type: "status", status: "idle" } as const; - } - }, - }; -}); +vi.mock("@dispatch/core", () => ({ + Agent: class MockAgent { + status = "idle"; + messages: unknown[] = []; + async *run(_message: string) { + yield { type: "status", status: "running" } as const; + await new Promise<void>((r) => setTimeout(r, 10)); + yield { type: "text-delta", delta: "Hello " } as const; + yield { type: "text-delta", delta: "world" } as const; + yield { + type: "done", + message: { role: "assistant", content: "Hello world" }, + } as const; + yield { type: "status", status: "idle" } as const; + } + }, + createReadFileTool(_wd: string): ToolDefinition { + return { + name: "read_file", + description: "read a file", + parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"], + execute: async () => "mock file content", + }; + }, + createWriteFileTool(_wd: string): ToolDefinition { + return { + name: "write_file", + description: "write a file", + parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"], + execute: async () => true, + }; + }, + createListFilesTool(_wd: string): ToolDefinition { + return { + name: "list_files", + description: "list files", + parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"], + execute: async () => ["file1.ts"], + }; + }, +})); // Import after mock is defined (Vitest hoists vi.mock automatically) const { AgentManager } = await import("../src/agent-manager.js"); diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index d5384b3..9f852ee 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -1,28 +1,49 @@ +import type { ToolDefinition } from "@dispatch/core"; import { describe, expect, it, vi } from "vitest"; // Mock @dispatch/core's Agent to avoid real LLM calls -vi.mock("@dispatch/core", async () => { - const actual = await vi.importActual<typeof import("@dispatch/core")>("@dispatch/core"); - return { - ...actual, - Agent: class MockAgent { - status = "idle"; - messages: unknown[] = []; - async *run(_message: string) { - yield { type: "status", status: "running" } as const; - // Simulate some processing time so status stays "running" - await new Promise<void>((r) => setTimeout(r, 100)); - yield { type: "text-delta", delta: "Hello " } as const; - yield { type: "text-delta", delta: "world" } as const; - yield { - type: "done", - message: { role: "assistant", content: "Hello world" }, - } as const; - yield { type: "status", status: "idle" } as const; - } - }, - }; -}); +vi.mock("@dispatch/core", () => ({ + Agent: class MockAgent { + status = "idle"; + messages: unknown[] = []; + async *run(_message: string) { + yield { type: "status", status: "running" } as const; + // Simulate some processing time so status stays "running" + await new Promise<void>((r) => setTimeout(r, 100)); + yield { type: "text-delta", delta: "Hello " } as const; + yield { type: "text-delta", delta: "world" } as const; + yield { + type: "done", + message: { role: "assistant", content: "Hello world" }, + } as const; + yield { type: "status", status: "idle" } as const; + } + }, + createReadFileTool(_wd: string): ToolDefinition { + return { + name: "read_file", + description: "read a file", + parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"], + execute: async () => "mock file content", + }; + }, + createWriteFileTool(_wd: string): ToolDefinition { + return { + name: "write_file", + description: "write a file", + parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"], + execute: async () => true, + }; + }, + createListFilesTool(_wd: string): ToolDefinition { + return { + name: "list_files", + description: "list files", + parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"], + execute: async () => ["file1.ts"], + }; + }, +})); const { app } = await import("../src/app.js"); diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts index 06f7b72..2a917b2 100644 --- a/packages/core/src/llm/provider.ts +++ b/packages/core/src/llm/provider.ts @@ -1,9 +1,74 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { wrapLanguageModel } from "ai"; +import type { LanguageModelV1Middleware, LanguageModelV1Prompt } from "ai"; + +/** + * Normalize messages for interleaved reasoning providers (DeepSeek). + * Extracts { type: "reasoning" } / { type: "redacted-reasoning" } parts from + * assistant message content arrays and moves them into + * providerMetadata.openaiCompatible.reasoning_content so + * @ai-sdk/openai-compatible serializes them correctly for the API. + * + * IMPORTANT: The messages in params.prompt are already in LanguageModelV1Prompt + * format (post-conversion by the AI SDK). They use `providerMetadata`, NOT + * `providerOptions` — that conversion happens before the middleware runs. + */ +function normalizeMessages(msgs: unknown[]): unknown[] { + return msgs.map((msg: unknown) => { + const message = msg as Record<string, unknown>; + if (message.role !== "assistant" || !Array.isArray(message.content)) return message; + + const content = message.content as Array<Record<string, unknown>>; + const reasoningParts = content.filter( + (p) => p.type === "reasoning" || p.type === "redacted-reasoning", + ); + const reasoningText = reasoningParts.map((p) => p.text ?? "").join(""); + + const filteredContent = content.filter( + (p) => p.type !== "reasoning" && p.type !== "redacted-reasoning", + ); + + const existingMetadata = (message.providerMetadata ?? {}) as Record<string, unknown>; + const existingOpenAICompat = (existingMetadata.openaiCompatible ?? {}) as Record<string, unknown>; + + return { + ...message, + content: filteredContent, + providerMetadata: { + ...existingMetadata, + openaiCompatible: { + ...existingOpenAICompat, + reasoning_content: reasoningText, + }, + }, + }; + }); +} export function createProvider(config: { apiKey: string; baseURL: string }) { - return createOpenAICompatible({ + const provider = createOpenAICompatible({ name: "opencode-zen", apiKey: config.apiKey, baseURL: config.baseURL, }); + + return (modelId: string) => { + const middleware: LanguageModelV1Middleware = { + middlewareVersion: "v1" as const, + transformParams: async ({ type, params }) => { + if (type === "stream" && params.prompt) { + return { + ...params, + prompt: normalizeMessages(params.prompt as unknown[]) as LanguageModelV1Prompt, + }; + } + return params; + }, + }; + + return wrapLanguageModel({ + model: provider(modelId), + middleware: [middleware], + }); + }; } diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts index 81d42c8..92df90e 100644 --- a/packages/core/tests/agent/agent.test.ts +++ b/packages/core/tests/agent/agent.test.ts @@ -4,8 +4,8 @@ import { Agent } from "../../src/agent/agent.js"; import type { AgentConfig } from "../../src/types/index.js"; // Mock the ai module's streamText -vi.mock("ai", async (importOriginal) => { - const actual = await importOriginal<typeof import("ai")>(); +vi.mock("ai", async () => { + const actual = await import("ai"); return { ...actual, streamText: vi.fn(), diff --git a/packages/core/tests/llm/provider.test.ts b/packages/core/tests/llm/provider.test.ts new file mode 100644 index 0000000..9b5439e --- /dev/null +++ b/packages/core/tests/llm/provider.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it, vi } from "vitest"; + +// We test normalizeMessages through the middleware by mocking the provider +// layers and capturing what transformParams does to the prompt. + +// Mock wrapLanguageModel to capture the middleware +vi.mock("ai", async () => { + const actual = await import("ai"); + return { + ...actual, + wrapLanguageModel: vi.fn(({ model, middleware }) => { + // Return a wrapper that exposes the middleware for testing + const wrapped = actual.wrapLanguageModel({ model, middleware }); + (wrapped as unknown as Record<string, unknown>)._middleware = middleware; + return wrapped; + }), + streamText: vi.fn(), + }; +}); + +// Mock provider factory +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => (modelId: string) => ({ + id: `mock-${modelId}`, + doGenerate: vi.fn(), + doStream: vi.fn(), + })), +})); + +const { createProvider } = await import("../../src/llm/provider.js"); + +// A helper that runs the middleware's transformParams on a prompt +// and returns the resulting normalized prompt. +async function runTransform( + prompt: unknown[], +): Promise<unknown[]> { + const wrappedModel = createProvider({ + apiKey: "test-key", + baseURL: "https://example.com/v1", + })("test-model"); + + const middleware = ( + (wrappedModel as unknown) as { _middleware: Array<{ transformParams: (args: { type: string; params: Record<string, unknown> }) => Promise<unknown> }> } + )._middleware; + + const result = await middleware[0]!.transformParams({ + type: "stream", + params: { prompt }, + }); + + return (result as Record<string, unknown>).prompt as unknown[]; +} + +describe("createProvider middleware", () => { + it("passes through non-stream calls unchanged", async () => { + const wrappedModel = createProvider({ + apiKey: "test-key", + baseURL: "https://example.com/v1", + })("test-model"); + + const middleware = ( + (wrappedModel as unknown) as { _middleware: Array<{ transformParams: (args: { type: string; params: Record<string, unknown> }) => Promise<unknown> }> } + )._middleware; + + const params = { prompt: [], temperature: 0.5 }; + const result = (await middleware[0]!.transformParams({ + type: "generate", + params, + })) as Record<string, unknown>; + + expect(result).toEqual(params); + }); + + it("strips reasoning parts and sets reasoning_content on providerMetadata", async () => { + const prompt = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "I should use the list_files tool." }, + { type: "text", text: "Let me check the directory." }, + { + type: "tool-call", + toolCallId: "call_1", + toolName: "list_files", + args: { path: "." }, + }, + ], + }, + ]; + + const normalized = await runTransform(prompt); + const msg = normalized[0] as Record<string, unknown>; + + // Reasoning parts removed from content + const content = msg.content as Array<Record<string, unknown>>; + expect(content).toHaveLength(2); + expect(content.find((p) => p.type === "reasoning")).toBeUndefined(); + expect(content.find((p) => p.type === "text")).toBeDefined(); + expect(content.find((p) => p.type === "tool-call")).toBeDefined(); + + // reasoning_content set on providerMetadata + const pm = msg.providerMetadata as Record<string, unknown>; + const compat = pm.openaiCompatible as Record<string, unknown>; + expect(compat.reasoning_content).toBe("I should use the list_files tool."); + }); + + it("sets empty reasoning_content when no reasoning parts exist", async () => { + const prompt = [ + { + role: "assistant", + content: [ + { type: "text", text: "Hello!" }, + ], + }, + ]; + + const normalized = await runTransform(prompt); + const msg = normalized[0] as Record<string, unknown>; + + // Content unchanged + const content = msg.content as Array<Record<string, unknown>>; + expect(content).toHaveLength(1); + expect(content[0]!.type).toBe("text"); + + // reasoning_content always set, even empty + const pm = msg.providerMetadata as Record<string, unknown>; + const compat = pm.openaiCompatible as Record<string, unknown>; + expect(compat.reasoning_content).toBe(""); + }); + + it("does not modify user messages", async () => { + const prompt = [ + { + role: "user", + content: [{ type: "text", text: "What dir am I in?" }], + }, + ]; + + const normalized = await runTransform(prompt); + expect(normalized).toEqual(prompt); + }); + + it("does not modify system messages", async () => { + const prompt = [ + { role: "system", content: "You are a helpful assistant." }, + ]; + + const normalized = await runTransform(prompt); + expect(normalized).toEqual(prompt); + }); + + it("handles assistant with plain string content (not array)", async () => { + const prompt = [ + { + role: "assistant", + content: "Hello world", + }, + ]; + + const normalized = await runTransform(prompt); + expect(normalized).toEqual(prompt); + }); + + it("handles redacted-reasoning type parts", async () => { + const prompt = [ + { + role: "assistant", + content: [ + { type: "redacted-reasoning", text: "[redacted chain of thought]" }, + { type: "text", text: "Here is the result." }, + ], + }, + ]; + + const normalized = await runTransform(prompt); + const msg = normalized[0] as Record<string, unknown>; + + const content = msg.content as Array<Record<string, unknown>>; + expect(content.find((p) => p.type === "redacted-reasoning")).toBeUndefined(); + expect(content.find((p) => p.type === "text")).toBeDefined(); + + const pm = msg.providerMetadata as Record<string, unknown>; + const compat = pm.openaiCompatible as Record<string, unknown>; + expect(compat.reasoning_content).toBe("[redacted chain of thought]"); + }); + + it("preserves existing providerMetadata fields", async () => { + const prompt = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking..." }, + { type: "text", text: "done" }, + ], + providerMetadata: { + openaiCompatible: { custom_field: "keep-me" }, + }, + }, + ]; + + const normalized = await runTransform(prompt); + const msg = normalized[0] as Record<string, unknown>; + const pm = msg.providerMetadata as Record<string, unknown>; + const compat = pm.openaiCompatible as Record<string, unknown>; + + expect(compat.custom_field).toBe("keep-me"); + expect(compat.reasoning_content).toBe("thinking..."); + }); + + it("handles multi-message prompts with mixed roles", async () => { + const prompt = [ + { role: "system", content: "Be helpful." }, + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "I'll say hello." }, + { type: "text", text: "Hi there!" }, + ], + }, + ]; + + const normalized = await runTransform(prompt); + + // System and user unchanged + expect(normalized[0]).toEqual(prompt[0]); + expect(normalized[1]).toEqual(prompt[1]); + + // Assistant transformed + const msg = normalized[2] as Record<string, unknown>; + const pm = msg.providerMetadata as Record<string, unknown>; + const compat = pm.openaiCompatible as Record<string, unknown>; + expect(compat.reasoning_content).toBe("I'll say hello."); + }); + + it("concatenates multiple reasoning parts", async () => { + const prompt = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Step 1: " }, + { type: "reasoning", text: "Step 2: " }, + { type: "reasoning", text: "Step 3." }, + { type: "text", text: "Final answer." }, + ], + }, + ]; + + const normalized = await runTransform(prompt); + const msg = normalized[0] as Record<string, unknown>; + const pm = msg.providerMetadata as Record<string, unknown>; + const compat = pm.openaiCompatible as Record<string, unknown>; + expect(compat.reasoning_content).toBe("Step 1: Step 2: Step 3."); + }); + + it("applies to every assistant message in a multi-step history", async () => { + const prompt = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "First thought." }, + { type: "tool-call", toolCallId: "c1", toolName: "list_files", args: {} }, + ], + }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "Second thought." }, + { type: "text", text: "All done." }, + ], + }, + ]; + + const normalized = await runTransform(prompt); + + const msg1 = normalized[0] as Record<string, unknown>; + const compat1 = (msg1.providerMetadata as Record<string, unknown>).openaiCompatible as Record<string, unknown>; + expect(compat1.reasoning_content).toBe("First thought."); + + const msg2 = normalized[1] as Record<string, unknown>; + const compat2 = (msg2.providerMetadata as Record<string, unknown>).openaiCompatible as Record<string, unknown>; + expect(compat2.reasoning_content).toBe("Second thought."); + }); +}); diff --git a/packages/frontend/src/lib/chat.svelte.ts b/packages/frontend/src/lib/chat.svelte.ts index 54216ec..e5367c2 100644 --- a/packages/frontend/src/lib/chat.svelte.ts +++ b/packages/frontend/src/lib/chat.svelte.ts @@ -9,7 +9,7 @@ function generateId() { function makeDebugInfo(overrides: Partial<DebugInfo> = {}): DebugInfo { return { timestamp: new Date().toISOString(), - model: "deepseek-v4-flash-free", + model: "deepseek-v4-flash", apiBase: config.apiBase, connectionStatus: wsClient.connectionStatus, ...overrides, diff --git a/problem.md b/problem.md new file mode 100644 index 0000000..a29f801 --- /dev/null +++ b/problem.md @@ -0,0 +1,79 @@ +# Problem: DeepSeek `reasoning_content` Dropped on Multi-Step Tool Calls + +## Symptom + +The first LLM call works (model makes a tool call). The second call fails with: + +``` +Error from provider (DeepSeek): The `reasoning_content` in the thinking mode must be passed back to the API. +``` + +This only happens when `maxSteps > 1` in `streamText` — i.e., when the agent loop calls the LLM a second time after executing a tool. + +## Root Cause + +A bug in `@ai-sdk/openai-compatible`. The package correctly **receives** `reasoning_content` from DeepSeek's response but silently **drops** it when building the next request. + +### The chain of events: + +1. **DeepSeek responds** with both `reasoning_content` (chain-of-thought) and `content` (answer) plus tool calls. + +2. **`@ai-sdk/openai-compatible` parses the response** and correctly captures `reasoning_content` into the SDK's internal `reasoning` field. + +3. **The AI SDK stores it** as a `{ type: "reasoning", text: "..." }` content part on the assistant message — this is correct. + +4. **On the next step**, the SDK passes the message history back through `@ai-sdk/openai-compatible`'s `convertToOpenAICompatibleChatMessages()` to serialize it for the API. This function handles assistant message content parts with a switch statement: + + ```js + // @ai-sdk/openai-compatible/dist/index.js, lines 90-120 + for (const part of content) { + switch (part.type) { + case "text": { /* handled */ break; } + case "tool-call": { /* handled */ break; } + // NO case "reasoning" — silently dropped! + } + } + ``` + +5. **The outgoing request** has no `reasoning_content` field. DeepSeek requires it to be echoed back and rejects the request. + +### Important: The agent code cannot fix this + +The `streamText` function with `maxSteps` manages its own internal multi-step loop. The agent's `toCoreMessages()` is only called once for the initial prompt. The second call to DeepSeek is built entirely inside the SDK — the serialization bug is in `@ai-sdk/openai-compatible`, not in our code. + +## Fix Options + +### Option A: Patch `@ai-sdk/openai-compatible` (recommended) + +Add a `case "reasoning"` branch to `convertToOpenAICompatibleChatMessages()` that writes `reasoning_content` back into the outgoing assistant message: + +```js +case "reasoning": { + reasoningContent = (reasoningContent ?? "") + part.text; + break; +} +// Then in the push: +messages.push({ + role: "assistant", + content: text, + reasoning_content: reasoningContent ?? undefined, + tool_calls: toolCalls.length > 0 ? toolCalls : void 0, + ...metadata +}); +``` + +Apply via `bun patch` or `patch-package`. File a bug/PR upstream against `@ai-sdk/openai-compatible`. + +### Option B: AI middleware to strip reasoning + +Use the AI SDK's `wrapLanguageModel` to intercept responses and remove `reasoning` parts before they enter the multi-step history. This avoids the error but loses the chain-of-thought content. Acceptable for Phase 1 since we don't display reasoning in the UI. + +### Option C: Switch to a model without thinking mode + +Use a DeepSeek model or configuration that doesn't enable thinking mode, if one is available through the OpenCode Zen endpoint. This avoids the problem entirely but limits model capability. + +## Affected Files + +- Bug location: `node_modules/@ai-sdk/openai-compatible/dist/index.js` (lines 90-120 in `convertToOpenAICompatibleChatMessages`) +- Our agent code: `packages/core/src/agent/agent.ts` — not the cause, cannot fix it from here +- Upstream repo: https://github.com/vercel/ai (the `@ai-sdk/openai-compatible` package) |
