diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index ddbbe4550b3..8841a212666 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1292,9 +1292,13 @@ export const REGISTRY: Record = { { id: "mimo-v2-omni", name: "MiMo-V2-Omni" }, { id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" }, { id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" }, - { id: "qwen3.7-max", name: "Qwen3.7 Max" }, - { id: "qwen3.6-plus", name: "Qwen3.6 Plus" }, - { id: "qwen3.5-plus", name: "Qwen3.5 Plus" }, + // Issue #2292: Qwen models on opencode-go reject oa-compat format + // ("Model qwen3.x-* is not supported for format oa-compat") — same + // upstream behavior already declared for opencode-zen. Route them + // through /messages with the Claude translator. + { id: "qwen3.7-max", name: "Qwen3.7 Max", targetFormat: "claude" }, + { id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude" }, + { id: "qwen3.5-plus", name: "Qwen3.5 Plus", targetFormat: "claude" }, { id: "hy3-preview", name: "Hunyuan3 Preview" }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, diff --git a/open-sse/translator/helpers/toolCallHelper.ts b/open-sse/translator/helpers/toolCallHelper.ts index ea8d21607c8..e9177fa3740 100644 --- a/open-sse/translator/helpers/toolCallHelper.ts +++ b/open-sse/translator/helpers/toolCallHelper.ts @@ -118,7 +118,9 @@ export function hasToolResults(msg, toolCallIds) { return false; } -// Fix missing tool responses - insert empty tool_result if assistant has tool_use but next message has no tool_result +// Fix missing tool responses - insert empty tool_result if assistant has tool_use but next message has no tool_result. +// Inserts in the same shape as the opening assistant message: OpenAI tool_calls → role:"tool"; +// Claude tool_use blocks → role:"user" with tool_result content blocks. export function fixMissingToolResponses(body) { if (!body.messages || !Array.isArray(body.messages)) return body; @@ -136,13 +138,23 @@ export function fixMissingToolResponses(body) { // Check if next message has tool_result if (nextMsg && !hasToolResults(nextMsg, toolCallIds)) { - // Insert tool responses for each tool_call - for (const id of toolCallIds) { - // OpenAI format: role = "tool" + const hasOpenAIToolCalls = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0; + if (hasOpenAIToolCalls) { + for (const id of toolCallIds) { + newMessages.push({ + role: "tool", + tool_call_id: id, + content: "", + }); + } + } else { newMessages.push({ - role: "tool", - tool_call_id: id, - content: "", + role: "user", + content: toolCallIds.map((id) => ({ + type: "tool_result", + tool_use_id: id, + content: "", + })), }); } } diff --git a/tests/unit/opencode-executor.test.ts b/tests/unit/opencode-executor.test.ts index d17243783a1..d7b42f14ed2 100644 --- a/tests/unit/opencode-executor.test.ts +++ b/tests/unit/opencode-executor.test.ts @@ -213,8 +213,6 @@ describe("OpencodeExecutor", () => { registerModel("opencode-go", { id: "kimi-k2.6", name: "Kimi K2.6" }); registerModel("opencode-go", { id: "mimo-v2-pro", name: "MiMo V2 Pro" }); registerModel("opencode-go", { id: "mimo-v2-omni", name: "MiMo V2 Omni" }); - registerModel("opencode-go", { id: "qwen3.6-plus", name: "Qwen 3.6 Plus" }); - registerModel("opencode-go", { id: "qwen3.5-plus", name: "Qwen 3.5 Plus" }); // glm-5.1 const glm51 = await goExecutor.execute(createInput("glm-5.1")); @@ -231,14 +229,20 @@ describe("OpencodeExecutor", () => { // mimo-v2-omni const mimoOmni = await goExecutor.execute(createInput("mimo-v2-omni")); assert.equal(mimoOmni.url, "https://opencode.ai/zen/go/v1/chat/completions"); + }); - // qwen3.6-plus - const qwen36 = await goExecutor.execute(createInput("qwen3.6-plus")); - assert.equal(qwen36.url, "https://opencode.ai/zen/go/v1/chat/completions"); + it("routes opencode-go qwen models to claude messages endpoint", async () => { + const qwen36 = await goExecutor.execute( + createInput("qwen3.6-plus", true, { apiKey: "claude-key" }) + ); + assert.equal(qwen36.url, "https://opencode.ai/zen/go/v1/messages"); + assert.equal(qwen36.headers["anthropic-version"], "2023-06-01"); - // qwen3.5-plus - const qwen35 = await goExecutor.execute(createInput("qwen3.5-plus")); - assert.equal(qwen35.url, "https://opencode.ai/zen/go/v1/chat/completions"); + const qwen35 = await goExecutor.execute( + createInput("qwen3.5-plus", true, { apiKey: "claude-key" }) + ); + assert.equal(qwen35.url, "https://opencode.ai/zen/go/v1/messages"); + assert.equal(qwen35.headers["anthropic-version"], "2023-06-01"); }); it("builds bearer auth headers for opencode-go openai models", async () => { diff --git a/tests/unit/translator-helper-branches.test.ts b/tests/unit/translator-helper-branches.test.ts index 397ee3424b2..d41a913eafe 100644 --- a/tests/unit/translator-helper-branches.test.ts +++ b/tests/unit/translator-helper-branches.test.ts @@ -489,6 +489,53 @@ test("toolCallHelper normalizes ids, links tool responses and inserts missing to assert.deepEqual(toolCallHelper.fixMissingToolResponses({ messages: null }), { messages: null }); }); +test("fixMissingToolResponses inserts Claude tool_result block when assistant uses Claude shape", () => { + const fixed = toolCallHelper.fixMissingToolResponses({ + messages: [ + { role: "user", content: [{ type: "text", text: "do it" }] }, + { + role: "assistant", + content: [ + { type: "tool_use", id: "tool_a", name: "bash", input: { cmd: "ls" } }, + { type: "tool_use", id: "tool_b", name: "bash", input: { cmd: "pwd" } }, + ], + }, + { role: "user", content: [{ type: "text", text: "continue" }] }, + ], + }); + + assert.equal(fixed.messages.length, 4); + const inserted = fixed.messages[2]; + assert.equal(inserted.role, "user"); + assert.ok(Array.isArray(inserted.content)); + assert.equal(inserted.content.length, 2); + assert.equal(inserted.content[0].type, "tool_result"); + assert.equal(inserted.content[0].tool_use_id, "tool_a"); + assert.equal(inserted.content[0].content, ""); + assert.equal(inserted.content[1].tool_use_id, "tool_b"); +}); + +test("fixMissingToolResponses keeps OpenAI role:tool when assistant uses OpenAI tool_calls", () => { + const fixed = toolCallHelper.fixMissingToolResponses({ + messages: [ + { + role: "assistant", + tool_calls: [ + { id: "call_a", type: "function", function: { name: "lookup", arguments: "{}" } }, + { id: "call_b", type: "function", function: { name: "search", arguments: "{}" } }, + ], + }, + { role: "user", content: "no tool result here" }, + ], + }); + + assert.equal(fixed.messages.length, 4); + assert.equal(fixed.messages[1].role, "tool"); + assert.equal(fixed.messages[1].tool_call_id, "call_a"); + assert.equal(fixed.messages[2].role, "tool"); + assert.equal(fixed.messages[2].tool_call_id, "call_b"); +}); + test("translateRequest replays cached DeepSeek reasoning messages without tool calls", () => { clearReasoningCacheAll(); cacheReasoningByKey(