From cc850122e3d98b51d17de489bf56db40af4afd88 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:54:20 -0300 Subject: [PATCH] fix(translator): strip function_call.id for Vertex AI provider (#3440) (#3457) Vertex AI's FunctionCall/FunctionResponse protos have no id field; emitting it made Vertex reject tool calls with 400 'Unknown name id'. The id is now stripped only when the routed provider is vertex/vertex-partner (threaded via credentials._provider), preserving it for the public Gemini API where Gemini 3+ uses it for signature matching. Co-authored-by: nullbytef0x --- CHANGELOG.md | 4 + open-sse/translator/index.ts | 14 +- .../translator/request/claude-to-gemini.ts | 11 +- .../translator/request/openai-to-gemini.ts | 16 +- .../unit/vertex-functioncall-id-3440.test.ts | 143 ++++++++++++++++++ 5 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 tests/unit/vertex-functioncall-id-3440.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aeb8a4b00d..9850b852a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ _Development cycle in progress — entries are added as work merges into `release/v3.8.17` and finalized by the release flow._ +### 🔧 Bug Fixes + +- **fix(translator):** Vertex AI tool calls no longer fail with `400 Unknown name "id" at contents[].parts[].function_call` — the OpenAI-style `id` field is now stripped from `functionCall`/`functionResponse` parts when the routed provider is `vertex`/`vertex-partner`. The public Gemini API still receives `id` (required for Gemini 3+ signature matching). ([#3440](https://github.com/diegosouzapw/OmniRoute/issues/3440) — thanks @nullbytef0x) + --- ## [3.8.16] — 2026-06-08 diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 0b6501da8c..b4928af32e 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -175,7 +175,13 @@ 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) { - result = directTranslator(model, result, stream, credentials); + // Thread the routed provider id so target translators can apply provider-specific + // quirks (e.g. Vertex rejects function_call.id — #3440). + const directCredentials = + provider != null + ? { ...(credentials && typeof credentials === "object" ? credentials : {}), _provider: provider } + : credentials; + result = directTranslator(model, result, stream, directCredentials); } else { // Fallback: hub-and-spoke via OpenAI // Step 1: source -> openai (if source is not openai) @@ -205,13 +211,17 @@ export function translateRequest( const hasNs = options?.signatureNamespace != null; const hasPreCompression = options?.preCompressionBody != null; const hasCopilot = options?.copilotClient === true; + const hasProvider = provider != null; const translationCredentials = - hasNs || hasPreCompression || hasCopilot + hasNs || hasPreCompression || hasCopilot || hasProvider ? { ...(credentials && typeof credentials === "object" ? credentials : {}), ...(hasNs ? { _signatureNamespace: options.signatureNamespace } : {}), ...(hasPreCompression ? { _preCompressionBody: options.preCompressionBody } : {}), ...(hasCopilot ? { _copilotClient: true } : {}), + // Routed provider id so target translators can apply provider-specific + // quirks (e.g. Vertex rejects function_call.id — #3440). + ...(hasProvider ? { _provider: provider } : {}), } : credentials; result = fromOpenAI(model, result, stream, translationCredentials); diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index fb3665c790..fda442ea61 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -14,12 +14,17 @@ import { capMaxOutputTokens } from "../../../src/lib/modelCapabilities.ts"; * Converts Claude Messages API body directly to Gemini format, * skipping the OpenAI hub intermediate step. */ -export function claudeToGeminiRequest(model, body, stream) { +export function claudeToGeminiRequest(model, body, stream, credentials = null) { const toolNameMap = new Map(); const sanitizeToolName = (name: string) => sanitizeGeminiToolName(name, { toolNameMap, }); + // Vertex AI rejects the `id` field inside function_call / function_response parts + // (#3440). The public Gemini API keeps it for Gemini 3+ signature matching, so this + // 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"; const result: { model: string; contents: Array>; @@ -105,7 +110,7 @@ export function claudeToGeminiRequest(model, body, stream) { case "tool_use": parts.push({ functionCall: { - id: block.id, + ...(stripFunctionCallId ? {} : { id: block.id }), name: sanitizeToolName(block.name), args: block.input || {}, }, @@ -127,7 +132,7 @@ export function claudeToGeminiRequest(model, body, stream) { } parts.push({ functionResponse: { - id: block.tool_use_id, + ...(stripFunctionCallId ? {} : { id: block.tool_use_id }), name: toolUseNames[block.tool_use_id] || "unknown", response: { result: parsedContent }, }, diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index b7a5dd0666..1f8a2355b0 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -109,8 +109,19 @@ type GeminiToolNameOptions = { functionResponseShape?: "result" | "output"; signatureNamespace?: string | null; signaturelessToolCallMode?: "native" | "text" | "context"; + // Vertex AI's FunctionCall/FunctionResponse protos have no `id` field; emitting it + // makes Vertex reject the request with 400 "Unknown name id" (#3440). The public + // Gemini API DOES use `id` for Gemini 3+ signature matching, so this is scoped to + // the vertex provider only. + stripFunctionCallId?: boolean; }; +// Vertex AI (and Vertex Partner models) reject the OpenAI-style `id` field inside +// function_call / function_response parts. Detect these by the routed provider id. +function isVertexGeminiProvider(provider: unknown): boolean { + return provider === "vertex" || provider === "vertex-partner"; +} + type OpenAIToolCallLike = { thoughtSignature?: unknown; thought_signature?: unknown; @@ -430,7 +441,7 @@ function openaiToGeminiBase( parts.push({ ...(embeddedThoughtSignature ? { thoughtSignature: embeddedThoughtSignature } : {}), functionCall: { - id: id, + ...(toolNameOptions.stripFunctionCallId ? {} : { id: id }), name: sanitizeToolName(fn.name), args: args, }, @@ -482,7 +493,7 @@ function openaiToGeminiBase( toolParts.push({ functionResponse: { - id: fid, + ...(toolNameOptions.stripFunctionCallId ? {} : { id: fid }), name: name, response: toolNameOptions.functionResponseShape === "output" @@ -605,6 +616,7 @@ export function openaiToGeminiRequest( return openaiToGeminiBase(model, body, stream, { signatureNamespace, signaturelessToolCallMode: options.signaturelessToolCallMode, + stripFunctionCallId: isVertexGeminiProvider(credentials?._provider), }); } diff --git a/tests/unit/vertex-functioncall-id-3440.test.ts b/tests/unit/vertex-functioncall-id-3440.test.ts new file mode 100644 index 0000000000..8609755c36 --- /dev/null +++ b/tests/unit/vertex-functioncall-id-3440.test.ts @@ -0,0 +1,143 @@ +// Regression test for #3440 — Vertex AI rejects tool calls because OmniRoute +// emits an `id` field inside `function_call` / `function_response` parts. +// +// Vertex AI (aiplatform.googleapis.com) follows an older Gemini REST schema whose +// FunctionCall/FunctionResponse protos have no `id` field, so it returns +// `400 INVALID_ARGUMENT: Unknown name "id" at 'contents[].parts[].function_call'`. +// The public Gemini API (generativelanguage.googleapis.com) DOES use `id` for +// Gemini 3+ signature matching, so the strip must be scoped to the vertex provider +// only (threaded via credentials._provider), never applied unconditionally. + +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" +); + +type UnknownRecord = Record; + +function findFunctionCall(result: any): UnknownRecord | undefined { + for (const content of result.contents ?? []) { + for (const part of content.parts ?? []) { + if (part?.functionCall) return part.functionCall as UnknownRecord; + } + } + return undefined; +} + +function findFunctionResponse(result: any): UnknownRecord | undefined { + for (const content of result.contents ?? []) { + for (const part of content.parts ?? []) { + if (part?.functionResponse) return part.functionResponse as UnknownRecord; + } + } + return undefined; +} + +const OPENAI_TOOL_BODY = { + messages: [ + { role: "user", content: "What's the weather?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_weather_1", + type: "function", + function: { name: "get_weather", arguments: '{"city":"Tokyo"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_weather_1", content: '{"temp":20}' }, + ], + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get weather", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + }, + ], +}; + +const CLAUDE_TOOL_BODY = { + messages: [ + { role: "user", content: "What's the weather?" }, + { + role: "assistant", + content: [ + { type: "tool_use", id: "tu_weather_1", name: "get_weather", input: { city: "Tokyo" } }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "tu_weather_1", content: '{"temp":20}' }], + }, + ], +}; + +test("#3440 OpenAI->Gemini: vertex provider omits id from functionCall and functionResponse", () => { + const result = openaiToGeminiRequest("gemini-2.5-pro", OPENAI_TOOL_BODY, false, { + _provider: "vertex", + }); + + const fc = findFunctionCall(result); + assert.ok(fc, "expected a functionCall part"); + assert.equal(fc.id, undefined, "functionCall.id must be omitted for Vertex"); + assert.equal(fc.name, "get_weather"); + + const fr = findFunctionResponse(result); + assert.ok(fr, "expected a functionResponse part"); + assert.equal(fr.id, undefined, "functionResponse.id must be omitted for Vertex"); +}); + +test("#3440 OpenAI->Gemini: vertex-partner provider also omits id", () => { + const result = openaiToGeminiRequest("gemini-2.5-pro", OPENAI_TOOL_BODY, false, { + _provider: "vertex-partner", + }); + assert.equal(findFunctionCall(result)?.id, undefined); + assert.equal(findFunctionResponse(result)?.id, undefined); +}); + +test("#3440 OpenAI->Gemini: public gemini provider PRESERVES id (Gemini 3+ signature matching)", () => { + const result = openaiToGeminiRequest("gemini-2.5-pro", OPENAI_TOOL_BODY, false, { + _provider: "gemini", + }); + assert.equal( + findFunctionCall(result)?.id, + "call_weather_1", + "functionCall.id must be preserved for the public Gemini API" + ); +}); + +test("#3440 OpenAI->Gemini: no provider hint PRESERVES id (default, non-vertex)", () => { + const result = openaiToGeminiRequest("gemini-2.5-pro", OPENAI_TOOL_BODY, false, null); + assert.equal(findFunctionCall(result)?.id, "call_weather_1"); +}); + +test("#3440 Claude->Gemini: vertex provider omits id from functionCall and functionResponse", () => { + const result = claudeToGeminiRequest("gemini-2.5-pro", CLAUDE_TOOL_BODY, false, { + _provider: "vertex", + }); + assert.equal(findFunctionCall(result)?.id, undefined, "functionCall.id must be omitted for Vertex"); + assert.equal( + findFunctionResponse(result)?.id, + undefined, + "functionResponse.id must be omitted for Vertex" + ); +}); + +test("#3440 Claude->Gemini: no provider hint PRESERVES id (default, non-vertex)", () => { + const result = claudeToGeminiRequest("gemini-2.5-pro", CLAUDE_TOOL_BODY, false); + assert.equal(findFunctionCall(result)?.id, "tu_weather_1"); +});