Repository navigation
fix(translator): strip function_call.id for Vertex AI provider (#3440) #3457
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 : {}), | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similarly, we can simplify this spread expression by using
Suggested change
|
||||||
| ...(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); | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<string, string>(); | ||||||||||
| 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"; | ||||||||||
|
Comment on lines
+26
to
+27
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can simplify this check using optional chaining (
Suggested change
|
||||||||||
| const result: { | ||||||||||
| model: string; | ||||||||||
| contents: Array<Record<string, unknown>>; | ||||||||||
|
|
@@ -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 }, | ||||||||||
| }, | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, unknown>; | ||
|
|
||
| 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"); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This ternary check can be simplified. In modern JavaScript/TypeScript, spreading
nullorundefinedis a safe no-op (e.g.,{ ...null }evaluates to{}). We can simplify the check totypeof credentials === "object" ? credentials : nullto avoid the redundant truthiness check.