fix(translator): preserve co-located functionResponse parts in gemini→openai (#6376)

preserve co-located functionResponse parts (port PR #2394) (net +1/-0, test OK). Integrated into release/v3.8.46.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-06 19:24:43 -03:00
committed by GitHub
parent 259d9b0f38
commit 2ecaae7c40
3 changed files with 137 additions and 1 deletions

View File

@@ -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)

View File

@@ -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";

View File

@@ -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");
});