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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(gemini):** stop sending Gemini a `400` on combos ("function call turn comes immediately after a user turn") when conversation history *opens* on a functionCall turn — after `mergeConsecutiveSameRoleContents`, `contents[]` alternation is guaranteed for every index ≥ 1, but the one remaining violation was a history whose very first turn is a functionCall. Prepends a synthetic leading user turn in that case ([#13573](https://github.com/diegosouzapw/OmniRoute/pull/13573)).
4 changes: 4 additions & 0 deletions open-sse/translator/request/claude-to-gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
buildChangedToolNameMap,
buildHistoricalToolResultContext,
mergeConsecutiveSameRoleContents,
ensureHistoryDoesNotOpenWithFunctionCall,
type GeminiContent,
} from "./openai-to-gemini/helpers.ts";

Expand Down Expand Up @@ -311,6 +312,9 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
// (400 INVALID_ARGUMENT: "Request contains consecutive messages with the same role").
// Normalize adjacent same-role messages by concatenating their parts.
result.contents = mergeConsecutiveSameRoleContents(result.contents);
// Guard the one alternation violation the merge above cannot reach: history
// that opens with a functionCall-bearing turn instead of a user turn.
result.contents = ensureHistoryDoesNotOpenWithFunctionCall(result.contents);

return result;
}
Expand Down
11 changes: 10 additions & 1 deletion open-sse/translator/request/openai-to-gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,15 @@ import {
type GeminiPart,
type GeminiContent,
mergeConsecutiveSameRoleContents,
ensureHistoryDoesNotOpenWithFunctionCall,
} from "./openai-to-gemini/helpers.ts";

export { mergeConsecutiveSameRoleContents, type GeminiContent, type GeminiPart };
export {
mergeConsecutiveSameRoleContents,
ensureHistoryDoesNotOpenWithFunctionCall,
type GeminiContent,
type GeminiPart,
};

// Observed Antigravity wrapper output cap, not an underlying model capability.
// Keep this bridge-local: Antigravity currently caps visible output around 16K.
Expand Down Expand Up @@ -559,6 +565,9 @@ function openaiToGeminiBase(

// Collapse any consecutive same-role contents Gemini would reject (9router#2191).
result.contents = mergeConsecutiveSameRoleContents(result.contents ?? []);
// Guard the one alternation violation the merge above cannot reach: history
// that opens with a functionCall-bearing turn instead of a user turn.
result.contents = ensureHistoryDoesNotOpenWithFunctionCall(result.contents);

// Convert tools
const bodyTools = body.tools as Array<Record<string, unknown>> | undefined;
Expand Down
27 changes: 27 additions & 0 deletions open-sse/translator/request/openai-to-gemini/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,30 @@ export function mergeConsecutiveSameRoleContents(contents: GeminiContent[]): Gem
}
return merged;
}

// Gemini also rejects a functionCall-bearing "model" turn with no preceding
// turn at all:
// 400 INVALID_ARGUMENT "Please ensure that function call turn comes
// immediately after a user turn or after a function response turn."
// `contents[]` only ever uses role "user" or "model" here, and
// mergeConsecutiveSameRoleContents above guarantees no two adjacent entries
// share a role -- so for every index >= 1 the previous entry can only be
// "user", satisfying this rule automatically. The one case that slips
// through is history that OPENS with a functionCall-bearing "model" turn,
// e.g. because the true leading user turn was dropped somewhere upstream
// (continuation reconstruction, context compression, a truncated client
// history) while a mid-conversation assistant tool-call turn survived.
// Prepend a minimal synthetic user turn so Gemini accepts the request
// instead of rejecting it outright -- cheaper and more robust than trying to
// enumerate every possible upstream cause of a truncated leading turn.
export function ensureHistoryDoesNotOpenWithFunctionCall(
contents: GeminiContent[]
): GeminiContent[] {
const first = contents[0];
if (!first || first.role !== "model") return contents;
const opensWithFunctionCall = first.parts.some(
(part) => part && typeof part === "object" && "functionCall" in part
);
if (!opensWithFunctionCall) return contents;
return [{ role: "user", parts: [{ text: "(continuing the conversation)" }] }, ...contents];
}
20 changes: 14 additions & 6 deletions tests/unit/translator-claude-to-gemini.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,18 @@ test("Claude -> Gemini maps system, thinking, tool use, tool result and tools",
role: "system",
parts: [{ text: "Rules" }],
});
assert.equal(result.contents[0].role, "model");
assert.deepEqual(result.contents[0].parts[0] as any, { thought: true, text: "need tool" });
assert.deepEqual(result.contents[0].parts[1] as any, {
// This fixture's messages start with an assistant tool_use and no leading
// user turn -- ensureHistoryDoesNotOpenWithFunctionCall (Gemini rejects a
// functionCall turn with nothing before it) prepends a synthetic user turn,
// shifting the mapped content this test cares about to index 1/2.
assert.equal(result.contents[0].role, "user");
assert.equal(result.contents[1].role, "model");
assert.deepEqual(result.contents[1].parts[0] as any, { thought: true, text: "need tool" });
assert.deepEqual(result.contents[1].parts[1] as any, {
thoughtSignature: "SIG_MAP_WEATHER",
functionCall: { id: "tu_1", name: "weather", args: { city: "Tokyo" } },
});
assert.deepEqual(result.contents[1].parts[0] as any, {
assert.deepEqual(result.contents[2].parts[0] as any, {
functionResponse: {
id: "tu_1",
name: "weather",
Expand Down Expand Up @@ -258,8 +263,11 @@ test("Claude -> Gemini sanitizes long tool names and exposes a restore map", ()
assert.ok(longToolName.length > 64);
assert.equal(sanitizedToolName.length, 64);
assert.equal((result as any)._toolNameMap.get(sanitizedToolName), longToolName);
assert.equal(getFunctionCall(result.contents[0].parts[0] as any).name, sanitizedToolName);
assert.equal(getFunctionResponse(result.contents[1].parts[0] as any).name, sanitizedToolName);
// Same leading-user-turn shift as above: this fixture also opens on an
// assistant tool_use with no preceding user turn.
assert.equal(result.contents[0].role, "user");
assert.equal(getFunctionCall(result.contents[1].parts[0] as any).name, sanitizedToolName);
assert.equal(getFunctionResponse(result.contents[2].parts[0] as any).name, sanitizedToolName);
assert.equal(parameters.examples, undefined);
assert.equal(parameters.properties?.path?.["x-ui"], undefined);
});
Expand Down
84 changes: 81 additions & 3 deletions tests/unit/translator-gemini-consecutive-role-2191.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import assert from "node:assert/strict";
// Claude paths), so consecutive `user` turns — or a tool-result turn (role:user)
// immediately followed by a plain user turn — produced an invalid alternation.

const { openaiToGeminiRequest, mergeConsecutiveSameRoleContents } = await import(
"../../open-sse/translator/request/openai-to-gemini.ts"
);
const {
openaiToGeminiRequest,
mergeConsecutiveSameRoleContents,
ensureHistoryDoesNotOpenWithFunctionCall,
} = await import("../../open-sse/translator/request/openai-to-gemini.ts");

type GeminiContent = { role: string; parts: Array<Record<string, unknown>> };
type GeminiReq = { contents: GeminiContent[] };
Expand Down Expand Up @@ -120,3 +122,79 @@ test("mergeConsecutiveSameRoleContents merges adjacent same-role entries without
assert.equal(userPartsB.length, 1, "second input parts array must not be mutated");
assert.notStrictEqual(merged[0].parts, userPartsA, "merged parts must be a fresh array");
});

// Regression: Gemini also rejects a functionCall-bearing "model" turn with no
// preceding turn at all -- 400 INVALID_ARGUMENT "Please ensure that function
// call turn comes immediately after a user turn or after a function response
// turn." Observed live when the true leading user turn was missing from the
// reconstructed history (e.g. a dropped/truncated earlier turn), leaving an
// assistant tool-call turn as contents[0].
test("ensureHistoryDoesNotOpenWithFunctionCall prepends a synthetic user turn when history opens with a functionCall", () => {
const input: GeminiContent[] = [
{ role: "model", parts: [{ functionCall: { name: "ls", args: {} } }] },
{ role: "user", parts: [{ functionResponse: { name: "ls", response: { result: "a.ts" } } }] },
];

const fixed = ensureHistoryDoesNotOpenWithFunctionCall(input) as GeminiContent[];

assert.deepEqual(
fixed.map((c) => c.role),
["user", "model", "user"]
);
assert.ok(fixed[0].parts[0].text, "the synthetic leading turn carries plain text, not a tool part");
// Original array and its entries are untouched.
assert.equal(input.length, 2);
});

test("ensureHistoryDoesNotOpenWithFunctionCall leaves a normal user-first history unchanged", () => {
const input: GeminiContent[] = [
{ role: "user", parts: [{ text: "Hi" }] },
{ role: "model", parts: [{ functionCall: { name: "ls", args: {} } }] },
];

const result = ensureHistoryDoesNotOpenWithFunctionCall(input);
assert.strictEqual(result, input, "an already-valid history must be returned as-is");
});

test("ensureHistoryDoesNotOpenWithFunctionCall leaves a text-only leading model turn unchanged", () => {
// Not the violation this guards: a leading "model" turn with no functionCall
// part isn't the rule Gemini enforces here.
const input: GeminiContent[] = [{ role: "model", parts: [{ text: "(no leading user turn)" }] }];
const result = ensureHistoryDoesNotOpenWithFunctionCall(input);
assert.strictEqual(result, input);
});

test("ensureHistoryDoesNotOpenWithFunctionCall handles an empty contents array", () => {
const result = ensureHistoryDoesNotOpenWithFunctionCall([]);
assert.deepEqual(result, []);
});

test("OpenAI -> Gemini: a reconstructed history whose leading user turn was lost still produces a valid, Gemini-acceptable alternation", () => {
// Simulates the reported live failure shape directly through the full
// translator: continuation/compression reconstruction can leave `messages`
// starting with an assistant tool-call turn instead of the true first user
// turn. The translator's own contents[] must still open with role:"user".
const body = {
messages: [
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "ls", arguments: '{"path":"."}' },
},
],
},
{ role: "tool", tool_call_id: "call_1", content: "a.ts\nb.ts" },
{ role: "user", content: "Now read a.ts" },
],
};
const result = openaiToGeminiRequest("gemini-2.5-pro", body, false, null, {
signaturelessToolCallMode: "context",
}) as GeminiReq;

assert.equal(result.contents[0].role, "user", "contents[] must never open with role:model");
assertNoConsecutiveSameRole(result.contents, "reconstructed-history-missing-leading-user-turn");
});
Loading