mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 15:52:52 +03:00
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:
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user