diff --git a/CHANGELOG.md b/CHANGELOG.md index 57307701a8..3b7814aea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### 🐛 Bug Fixes +- **fix(translator):** preserve a Gemini `functionResponse` co-located with other parts (another `functionCall`, or trailing `text`) in the same content when translating **Gemini → OpenAI**. `convertGeminiContent()` early-returned the tool message on the first `functionResponse` part, dropping any co-located parts; such contents are now pre-split (one tool message per `functionResponse`, emitted first, plus one message for the remaining parts). Regression guard: `tests/unit/gemini-to-openai-function-response.test.ts`. (thanks @warelik) - **fix(headroom):** detect a python interpreter managed by **mise / pyenv / asdf / conda**. Headroom's python probe (`src/lib/headroom/detect.ts`) searched a hardcoded `PATH`, but version managers expose their interpreters via shim dirs that only join `PATH` through interactive-shell activation — which the non-interactive server never runs, so a managed python (≥3.10) was invisible and Headroom reported it missing. The search path now prepends the well-known shim/bin dirs (`~/.local/share/mise/shims`, `~/.pyenv/shims`, `~/.asdf/shims`, `$CONDA_PREFIX/bin`, `~/.local/bin`, respecting `MISE_DATA_DIR`/`PYENV_ROOT`/`ASDF_DATA_DIR` when set), and a new `HEADROOM_PYTHON` env override lets operators point straight at their interpreter (mirroring `HEADROOM_URL`). Still shell-free (`execFileSync`). Regression guard: `tests/unit/headroom-detect.test.ts` (5). (thanks @loopyd) - **fix(executors):** strip the OpenAI-Codex/Claude-CLI `client_metadata` passthrough field for **NVIDIA** requests. NVIDIA's OpenAI-compatible wrapper rejects it with `400 Unsupported parameter`, the same class already handled for `cerebras`/`mistral`; `nvidia` (executor `default`) was missing from the strip allowlist so Codex/Claude-Code passthrough requests 400'd. Regression guard: `tests/unit/executor-default-strip-client-metadata.test.ts` (+nvidia case). (thanks @phidinhmanh) - **fix(translator):** strip the Claude-style `thinking` field for **NVIDIA `z-ai/glm-5.2`**. NVIDIA's OpenAI-compatible wrapper 400s on `thinking` (a Claude-format client routed here leaves a `thinking:{type:"adaptive"}`); the existing strip rule only dropped `reasoning`. Same class already handled for `minimax-m2.7`. Regression guard: `tests/unit/nvidia-minimax-thinking-strip.test.ts` (+glm-5.2 case). (thanks @phidinhmanh) diff --git a/open-sse/translator/request/gemini-to-openai.ts b/open-sse/translator/request/gemini-to-openai.ts index 4e968e2671..f073eaf288 100644 --- a/open-sse/translator/request/gemini-to-openai.ts +++ b/open-sse/translator/request/gemini-to-openai.ts @@ -46,7 +46,7 @@ export function geminiToOpenAIRequest(model, body, stream) { // Convert contents to messages if (body.contents && Array.isArray(body.contents)) { - for (const content of body.contents) { + for (const content of splitCoLocatedFunctionResponses(body.contents)) { const converted = convertGeminiContent(content); if (converted) { result.messages.push(converted); @@ -77,6 +77,38 @@ export function geminiToOpenAIRequest(model, body, stream) { } // Convert Gemini content to OpenAI message +// convertGeminiContent() early-returns the tool message on the first +// `functionResponse` part in a content, dropping any co-located parts +// (another functionCall, or trailing text). Gemini clients can send those +// co-located. Pre-split each such content into: one single-part content per +// functionResponse (each early-returns cleanly as a tool message, emitted +// first to keep tool-result-before-next-turn ordering) plus one content for +// the remaining non-functionResponse parts. +function splitCoLocatedFunctionResponses(contents) { + const out = []; + for (const content of contents) { + if (!content || !Array.isArray(content.parts)) { + out.push(content); + continue; + } + const hasFunctionResponse = content.parts.some((p) => p && p.functionResponse); + if (!hasFunctionResponse) { + out.push(content); + continue; + } + for (const part of content.parts) { + if (part && part.functionResponse) { + out.push({ ...content, parts: [part] }); + } + } + const nonFRParts = content.parts.filter((p) => !(p && p.functionResponse)); + if (nonFRParts.length > 0) { + out.push({ ...content, parts: nonFRParts }); + } + } + return out; +} + function convertGeminiContent(content) { const role = content.role === "user" ? "user" : "assistant"; diff --git a/tests/unit/gemini-to-openai-function-response.test.ts b/tests/unit/gemini-to-openai-function-response.test.ts new file mode 100644 index 0000000000..215b66c014 --- /dev/null +++ b/tests/unit/gemini-to-openai-function-response.test.ts @@ -0,0 +1,103 @@ +// Regression: open-sse/translator/request/gemini-to-openai.ts — a Gemini `content` +// whose `parts` array holds a `functionResponse` co-located with other parts +// (another functionCall, or trailing text) used to be dropped past the first +// functionResponse, because convertGeminiContent() early-returns the tool message +// on the first `functionResponse` part it finds. Gemini clients legitimately send +// functionResponse alongside functionCall (multi-tool turns) or text (a user +// follow-up next to a tool result). The fix pre-splits such contents so every +// co-located part is preserved (tool results emitted first to keep ordering). +// +// Tested against the exported geminiToOpenAIRequest — the function registered as +// the GEMINI→OPENAI request translator (translateRequest routes through it after +// its normalize pipeline, which strips *orphaned* tool results; a real multi-turn +// conversation carries the matching functionCall so the co-located parts reach this +// function intact). This mirrors OmniRoute's translator-gemini-to-openai.test.ts. +import test from "node:test"; +import assert from "node:assert/strict"; + +const { geminiToOpenAIRequest } = await import( + "../../open-sse/translator/request/gemini-to-openai.ts" +); + +test("preserves a functionCall co-located with a functionResponse in the same content", () => { + const body = { + contents: [ + { + role: "model", + parts: [ + { functionCall: { id: "call_a", name: "tool_a", args: {} } }, + { functionResponse: { id: "call_b", name: "tool_b", response: { result: "b done" } } }, + ], + }, + ], + }; + const result = geminiToOpenAIRequest("gemini-pro", body, false); + const toolMsg = result.messages.find((m) => m.role === "tool"); + const assistantMsg = result.messages.find((m) => m.role === "assistant" && m.tool_calls); + assert.ok(toolMsg, "tool result must be preserved"); + assert.ok(assistantMsg, "co-located functionCall must be preserved"); + assert.equal(assistantMsg.tool_calls[0].function.name, "tool_a"); +}); + +test("preserves multiple functionResponses in the same content", () => { + const body = { + contents: [ + { + role: "user", + parts: [ + { functionResponse: { id: "call_a", name: "tool_a", response: { result: "a done" } } }, + { functionResponse: { id: "call_b", name: "tool_b", response: { result: "b done" } } }, + ], + }, + ], + }; + const result = geminiToOpenAIRequest("gemini-pro", body, false); + const toolMsgs = result.messages.filter((m) => m.role === "tool"); + assert.equal(toolMsgs.length, 2); + assert.deepEqual( + toolMsgs.map((m) => m.tool_call_id).sort(), + ["call_a", "call_b"] + ); +}); + +test("preserves text co-located with a functionResponse, keeping the original turn role", () => { + const body = { + contents: [ + { + role: "user", + parts: [ + { functionResponse: { id: "call_a", name: "tool_a", response: { result: "a done" } } }, + { text: "also please summarize" }, + ], + }, + ], + }; + const result = geminiToOpenAIRequest("gemini-pro", body, false); + const toolMsg = result.messages.find((m) => m.role === "tool"); + const userMsg = result.messages.find( + (m) => m.role === "user" && m.content === "also please summarize" + ); + const asstWithText = result.messages.find( + (m) => m.role === "assistant" && m.content === "also please summarize" + ); + assert.ok(toolMsg, "tool result must be preserved"); + assert.ok(userMsg, "co-located text must be preserved with role:user"); + assert.ok(!asstWithText, "co-located user text must NOT be attributed to assistant"); +}); + +test("still works for a functionResponse alone (no regression)", () => { + const body = { + contents: [ + { + role: "user", + parts: [ + { functionResponse: { id: "call_a", name: "tool_a", response: { result: "a done" } } }, + ], + }, + ], + }; + const result = geminiToOpenAIRequest("gemini-pro", body, false); + const toolMsgs = result.messages.filter((m) => m.role === "tool"); + assert.equal(toolMsgs.length, 1); + assert.equal(toolMsgs[0].tool_call_id, "call_a"); +});