Merge branch 'claude-to-gemini-400-error-fix' of https://github.com/csoftware-arigpt/OmniRoute into fix/pr-8755-thought-signature

This commit is contained in:
diegosouzapw
2026-08-04 08:27:04 -03:00
4 changed files with 114 additions and 35 deletions

View File

@@ -256,13 +256,17 @@ export function translateRequest(
// Check for direct translation path first (e.g., Claude → Gemini)
const directTranslator = getRequestTranslator(sourceFormat, targetFormat);
if (directTranslator && sourceFormat !== FORMATS.OPENAI && targetFormat !== FORMATS.OPENAI) {
// Thread the routed provider id so target translators can apply provider-specific
// quirks (e.g. Vertex rejects function_call.id — #3440).
// Thread the routed provider id AND the per-connection signature namespace so
// direct target translators can apply the same quirks as the hub path — notably
// Claude→Gemini needs _signatureNamespace to replay Gemini 3+ thought_signature
// on multi-turn tool calls (#2504, direct-path port).
const directHasNs = options?.signatureNamespace != null;
const directCredentials =
provider != null
provider != null || directHasNs
? {
...(credentials && typeof credentials === "object" ? credentials : {}),
_provider: provider,
...(provider != null ? { _provider: provider } : {}),
...(directHasNs ? { _signatureNamespace: options.signatureNamespace } : {}),
}
: credentials;
result = directTranslator(model, result, stream, directCredentials);

View File

@@ -7,6 +7,10 @@ import {
} from "../helpers/geminiHelper.ts";
import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts";
import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts";
import {
buildGeminiThoughtSignatureKey,
resolveGeminiThoughtSignature,
} from "../../services/geminiThoughtSignatureStore.ts";
import { capMaxOutputTokens, capThinkingBudget } from "../../../src/lib/modelCapabilities.ts";
import { getModelSpec } from "../../../src/shared/constants/modelSpecs.ts";
@@ -26,6 +30,11 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
// is scoped to the routed vertex provider only (threaded via credentials._provider).
const provider = credentials && typeof credentials === "object" ? credentials._provider : null;
const stripFunctionCallId = provider === "vertex" || provider === "vertex-partner";
// Per-connection namespace so cached thought_signatures don't collide across
// conversations (#2504). Threaded via credentials._signatureNamespace by the
// dispatcher (connectionId) when translateRequest runs the direct path.
const signatureNamespace =
credentials && typeof credentials === "object" ? credentials._signatureNamespace : null;
const result: {
model: string;
contents: Array<Record<string, unknown>>;
@@ -97,6 +106,9 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
// ── Convert messages ───────────────────────────────────────────
if (body.messages && Array.isArray(body.messages)) {
// Tool-ids whose functionCall was omitted (no stored thought_signature) so the
// matching tool_result becomes text instead of a Gemini-400'd functionResponse.
const omittedToolCallIds = new Set<string>();
for (const msg of body.messages) {
const parts = [];
@@ -114,15 +126,30 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
}
break;
case "tool_use":
parts.push({
functionCall: {
...(stripFunctionCallId ? {} : { id: block.id }),
name: sanitizeToolName(block.name),
args: block.input || {},
},
});
case "tool_use": {
// Gemini 3+ strictly validates thought_signature on every functionCall
// part in a multi-turn tool-call batch and returns 400 without it. Resolve
// the stored signature captured on the prior Gemini response (keyed by this
// tool id) and replay it. When no signature is available (historical tool
// calls predating the store), omit the functionCall and convert the matching
// tool_result to text — mirrors openai→gemini context mode (#2504).
const thoughtSignature = resolveGeminiThoughtSignature(
buildGeminiThoughtSignatureKey(signatureNamespace, block.id)
);
if (thoughtSignature) {
parts.push({
thoughtSignature,
functionCall: {
...(stripFunctionCallId ? {} : { id: block.id }),
name: sanitizeToolName(block.name),
args: block.input || {},
},
});
} else {
omittedToolCallIds.add(block.id);
}
break;
}
case "tool_result": {
let content = block.content;
@@ -137,13 +164,20 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
} else if (typeof parsedContent !== "object") {
parsedContent = { result: parsedContent };
}
parts.push({
functionResponse: {
...(stripFunctionCallId ? {} : { id: block.tool_use_id }),
name: toolUseNames[block.tool_use_id] || "unknown",
response: { result: parsedContent },
},
});
if (omittedToolCallIds.has(block.tool_use_id)) {
// Matching tool_use was omitted — emit this result as plain text so
// Gemini doesn't 400 a bare functionResponse without a matching
// functionCall carrying thought_signature.
parts.push({ text: JSON.stringify(parsedContent) });
} else {
parts.push({
functionResponse: {
...(stripFunctionCallId ? {} : { id: block.tool_use_id }),
name: toolUseNames[block.tool_use_id] || "unknown",
response: { result: parsedContent },
},
});
}
break;
}
@@ -168,13 +202,6 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
// Map Claude roles to Gemini roles
const geminiRole = msg.role === "assistant" ? "model" : "user";
// Gemini 3+ expects the signature on all functionCall parts in a tool-call
// batch. If there is no real signature, we don't inject a fake one because
// Gemini API strictly validates it and returns 400.
if (geminiRole === "model") {
// No operation needed since we no longer inject fake signatures.
}
result.contents.push({ role: geminiRole, parts });
}
}

View File

@@ -2,6 +2,10 @@ import { register } from "../registry.ts";
import { FORMATS } from "../formats.ts";
import { isAbortFinishReason } from "../../utils/finishReason.ts";
import { REVERSE_MAP } from "../../services/claudeCodeToolRemapper.ts";
import {
buildGeminiThoughtSignatureKey,
storeGeminiThoughtSignature,
} from "../../services/geminiThoughtSignatureStore.ts";
function normalizeToolName(name: string): string {
return REVERSE_MAP[name] ?? name;
@@ -56,6 +60,15 @@ export function geminiToClaudeResponse(chunk, state) {
const hasThoughtSig = part.thoughtSignature || part.thought_signature;
const isThought = part.thought === true;
// Capture thought_signature from any part (thought, standalone signature, or
// functionCall) so it can be stored against the next functionCall's tool id.
// Mirrors the gemini→openai direct path — the signature frequently lands on a
// preceding thought part rather than the functionCall part itself.
const partSig = part.thoughtSignature || part.thought_signature;
if (typeof partSig === "string" && partSig) {
state.pendingThoughtSignature = partSig;
}
// Thinking content → thinking block (always open+close per chunk)
if (isThought && part.text) {
// Close any open text block first
@@ -87,10 +100,27 @@ export function geminiToClaudeResponse(chunk, state) {
}
const fc = part.functionCall;
const rawToolName = fc.name;
const restoredToolName = normalizeToolName(state.toolNameMap?.get(rawToolName) || rawToolName);
const restoredToolName = normalizeToolName(
state.toolNameMap?.get(rawToolName) || rawToolName
);
const idx = state.contentBlockIndex++;
const toolId = fc.id || `toolu_${Date.now()}_${idx}`;
// Persist the thought_signature keyed by this tool id (scoped to the
// connection) so the next Claude→Gemini request can replay it on the
// functionCall part. Without it Gemini 3+ 400s multi-turn tool calls.
const sig =
(typeof part.thoughtSignature === "string" && part.thoughtSignature) ||
(typeof part.thought_signature === "string" && part.thought_signature) ||
state.pendingThoughtSignature;
if (sig) {
storeGeminiThoughtSignature(
buildGeminiThoughtSignatureKey(state.signatureNamespace, toolId),
sig
);
state.pendingThoughtSignature = null;
}
results.push({
type: "content_block_start",
index: idx,

View File

@@ -11,15 +11,24 @@
import test from "node:test";
import assert from "node:assert/strict";
const { openaiToGeminiRequest } = await import(
"../../open-sse/translator/request/openai-to-gemini.ts"
);
const { claudeToGeminiRequest } = await import(
"../../open-sse/translator/request/claude-to-gemini.ts"
);
const { openaiToGeminiRequest } =
await import("../../open-sse/translator/request/openai-to-gemini.ts");
const { claudeToGeminiRequest } =
await import("../../open-sse/translator/request/claude-to-gemini.ts");
const { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature } =
await import("../../open-sse/services/geminiThoughtSignatureStore.ts");
type UnknownRecord = Record<string, unknown>;
const CLAUDE_SIGNATURE_NAMESPACE = "regression-3440";
function seedClaudeThoughtSignature() {
storeGeminiThoughtSignature(
buildGeminiThoughtSignatureKey(CLAUDE_SIGNATURE_NAMESPACE, "tu_weather_1"),
"SIG_3440"
);
}
function findFunctionCall(result: any): UnknownRecord | undefined {
for (const content of result.contents ?? []) {
for (const part of content.parts ?? []) {
@@ -126,10 +135,16 @@ test("#3440 OpenAI->Gemini: no provider hint PRESERVES id (default, non-vertex)"
});
test("#3440 Claude->Gemini: vertex provider omits id from functionCall and functionResponse", () => {
seedClaudeThoughtSignature();
const result = claudeToGeminiRequest("gemini-2.5-pro", CLAUDE_TOOL_BODY, false, {
_provider: "vertex",
_signatureNamespace: CLAUDE_SIGNATURE_NAMESPACE,
});
assert.equal(findFunctionCall(result)?.id, undefined, "functionCall.id must be omitted for Vertex");
assert.equal(
findFunctionCall(result)?.id,
undefined,
"functionCall.id must be omitted for Vertex"
);
assert.equal(
findFunctionResponse(result)?.id,
undefined,
@@ -138,6 +153,9 @@ test("#3440 Claude->Gemini: vertex provider omits id from functionCall and funct
});
test("#3440 Claude->Gemini: no provider hint PRESERVES id (default, non-vertex)", () => {
const result = claudeToGeminiRequest("gemini-2.5-pro", CLAUDE_TOOL_BODY, false);
seedClaudeThoughtSignature();
const result = claudeToGeminiRequest("gemini-2.5-pro", CLAUDE_TOOL_BODY, false, {
_signatureNamespace: CLAUDE_SIGNATURE_NAMESPACE,
});
assert.equal(findFunctionCall(result)?.id, "tu_weather_1");
});