From c9a3361e5a7229a23adcf1d45bd8c06ac5415b04 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 6 Aug 2026 22:58:51 -0300 Subject: [PATCH] fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575) Closes #9575 --- changelog.d/fixes/9575-tool-call-case.md | 1 + open-sse/handlers/responseTranslator.ts | 12 +- open-sse/translator/helpers/toolCallHelper.ts | 33 ++++- .../translator/response/gemini-to-openai.ts | 16 +-- .../translator/response/openai-to-claude.ts | 3 +- open-sse/utils/stream.ts | 7 +- tests/unit/probe-9575-tool-name-case.test.ts | 128 ++++++++++++++++++ 7 files changed, 178 insertions(+), 22 deletions(-) create mode 100644 changelog.d/fixes/9575-tool-call-case.md create mode 100644 tests/unit/probe-9575-tool-name-case.test.ts diff --git a/changelog.d/fixes/9575-tool-call-case.md b/changelog.d/fixes/9575-tool-call-case.md new file mode 100644 index 0000000000..dd3e888151 --- /dev/null +++ b/changelog.d/fixes/9575-tool-call-case.md @@ -0,0 +1 @@ +- fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575) diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index d157537db2..165e292dc0 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -6,7 +6,10 @@ import { import { normalizeOpenAICompatibleFinishReasonString } from "../utils/finishReason.ts"; import { containsTextualToolCallMarker } from "../utils/textualToolCall.ts"; import { getAnyReasoningValue } from "../utils/reasoningFields.ts"; -import { restoreOpenAIToolNames } from "../translator/helpers/toolCallHelper.ts"; +import { + caseInsensitiveToolNameLookup, + restoreOpenAIToolNames, +} from "../translator/helpers/toolCallHelper.ts"; type JsonRecord = Record; @@ -206,7 +209,7 @@ export function translateNonStreamingResponse( typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit || {}); const rawName = toString(itemObj.name); // Strip Claude OAuth proxy_ prefix using toolNameMap - const resolvedName = toolNameMap?.get(rawName) ?? rawName; + const resolvedName = caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName; toolCalls.push({ id: callId, type: "function", @@ -388,7 +391,8 @@ export function translateNonStreamingResponse( if (partObj.functionCall) { const fn = toRecord(partObj.functionCall); const rawName = toString(fn.name); - const restoredName = toolNameMap?.get(rawName) ?? rawName; + const restoredName = + caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName; const nativeId = toString(fn.id); const toolCallId = nativeId.length > 0 @@ -507,7 +511,7 @@ export function translateNonStreamingResponse( thinkingContent += toString(blockObj.thinking); } else if (blockObj.type === "tool_use") { const rawName = toString(blockObj.name); - const strippedName = toolNameMap?.get(rawName) ?? rawName; + const strippedName = caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName; toolCalls.push({ id: toString(blockObj.id, `call_${Date.now()}_${toolCalls.length}`), type: "function", diff --git a/open-sse/translator/helpers/toolCallHelper.ts b/open-sse/translator/helpers/toolCallHelper.ts index f0869951c7..817aabbf57 100644 --- a/open-sse/translator/helpers/toolCallHelper.ts +++ b/open-sse/translator/helpers/toolCallHelper.ts @@ -96,6 +96,37 @@ export function normalizeOpenAIToolNames(body: unknown, maxLength: number): Tool return aliases; } +/** + * Case-insensitive fallback for tool name lookups from upstream responses. + * + * Many upstream providers/models return tool call names in lowercase (e.g., "bash") + * even when the tool definition used PascalCase ("Bash"). This helper tries an exact + * match first (fast path for well-behaved providers), then falls back to a + * case-insensitive scan over the map entries. + * + * Returns the mapped value on match, or `undefined` when no entry matches. + */ +export function caseInsensitiveToolNameLookup( + name: string, + map: Map | null | undefined +): string | undefined { + if (!map || !name) return undefined; + + // Fast path: exact match (PascalCase-preserving providers) + const exact = map.get(name); + if (exact !== undefined) return exact; + + // Fallback: case-insensitive scan + const lowerName = name.toLowerCase(); + for (const [key, value] of map) { + if (key.toLowerCase() === lowerName) { + return value; + } + } + + return undefined; +} + /** Restore normalized function names in OpenAI Chat Completions responses. */ export function restoreOpenAIToolNames(body: unknown, aliases: unknown): boolean { if (!(aliases instanceof Map) || aliases.size === 0) return false; @@ -108,7 +139,7 @@ export function restoreOpenAIToolNames(body: unknown, aliases: unknown): boolean for (const toolCall of calls) { const fn = toRecord(toRecord(toolCall)?.function); if (!fn || typeof fn.name !== "string") continue; - const original = aliases.get(fn.name); + const original = caseInsensitiveToolNameLookup(fn.name, aliases); if (typeof original !== "string" || original === fn.name) continue; fn.name = original; changed = true; diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index 1c0a899c98..2a8240e290 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -4,6 +4,7 @@ import { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature, } from "../../services/geminiThoughtSignatureStore.ts"; +import { caseInsensitiveToolNameLookup } from "../helpers/toolCallHelper.ts"; import { parseTextualToolCallCandidate, containsTextualToolCallMarker, @@ -256,20 +257,7 @@ function emitFunctionCallPart( results: Array> ) { const rawToolName = part.functionCall.name; - const fcName = (() => { - const direct = state.toolNameMap?.get(rawToolName); - if (direct) return direct; - // Case-insensitive fallback: Gemini always lowercases tool names in - // functionCall responses, so a direct match by lowercase key may have - // been missed if the map entry somehow didn't include the lowercase - // alias (#9568). - if (state.toolNameMap) { - for (const [key, val] of state.toolNameMap) { - if (key.toLowerCase() === rawToolName.toLowerCase()) return val; - } - } - return rawToolName; - })(); + const fcName = caseInsensitiveToolNameLookup(rawToolName, state.toolNameMap) ?? rawToolName; const fcArgs = normalizeToolCallArgs(part.functionCall.args || {}); const toolCallIndex = state.functionIndex++; const toolCall = { diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts index 6a5d1687ad..cac48d5848 100644 --- a/open-sse/translator/response/openai-to-claude.ts +++ b/open-sse/translator/response/openai-to-claude.ts @@ -1,6 +1,7 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { CLAUDE_OAUTH_TOOL_PREFIX } from "../request/openai-to-claude.ts"; +import { caseInsensitiveToolNameLookup } from "../helpers/toolCallHelper.ts"; import { hasToolCallShim, applyToolCallShimToBuffer } from "../helpers/toolCallShim.ts"; import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts"; import { isAbortFinishReason } from "../../utils/finishReason.ts"; @@ -284,7 +285,7 @@ export function openaiToClaudeResponse(chunk, state) { // Strip the Claude OAuth prefix from an incoming tool name (if any). const incomingName = (() => { let n = tc.function?.name || ""; - n = state.toolNameMap?.get(n) || n; + n = caseInsensitiveToolNameLookup(n, state.toolNameMap) ?? n; if (n.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) n = n.slice(CLAUDE_OAUTH_TOOL_PREFIX.length); return n; })(); diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 086c938fcf..7f0991a0b9 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -70,7 +70,10 @@ import { hasUnsupportedReasoningSignal, } from "./reasoningFields.ts"; import { applyThinkTag, flushThink, initThinkState } from "./thinkTagParser.ts"; -import { restoreOpenAIToolNames } from "../translator/helpers/toolCallHelper.ts"; +import { + caseInsensitiveToolNameLookup, + restoreOpenAIToolNames, +} from "../translator/helpers/toolCallHelper.ts"; import { normalizeFinalOpenAIStreamChunk } from "./openAIStreamChunk.ts"; /** @@ -578,7 +581,7 @@ function restoreClaudePassthroughToolUseName(parsed: JsonRecord, toolNameMap: un : null; if (!block || block.type !== "tool_use" || typeof block.name !== "string") return false; - const restoredName = toolNameMap.get(block.name) ?? block.name; + const restoredName = caseInsensitiveToolNameLookup(block.name, toolNameMap) ?? block.name; if (restoredName === block.name) return false; block.name = restoredName; return true; diff --git a/tests/unit/probe-9575-tool-name-case.test.ts b/tests/unit/probe-9575-tool-name-case.test.ts new file mode 100644 index 0000000000..2b6cfa819b --- /dev/null +++ b/tests/unit/probe-9575-tool-name-case.test.ts @@ -0,0 +1,128 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// We test the helper that will be added to toolCallHelper.ts. +// For the TDD probe, we directly test the scenario: case-sensitive Map.get +// fails for lowercase names, and the fix (case-insensitive fallback) resolves it. +// After the fix is implemented, the actual functions being tested here are +// restoreOpenAIToolNames (already exported) and the new caseInsensitiveToolNameLookup. + +describe("9575 - tool call name case sensitivity", () => { + const toolNameMap = new Map([ + ["Bash", "Bash"], + ["Read", "Read"], + ["Write", "Write"], + ["Glob", "Glob"], + ["Skill", "Skill"], + ["Edit", "Edit"], + ]); + + it("case-sensitive Map.get fails for lowercase tool names (THE BUG)", () => { + // Simulate upstream returning lowercase "bash" when tool is "Bash" + const upstreamName = "bash"; + const result = toolNameMap.get(upstreamName); + // Case-sensitive lookup returns undefined - this IS the bug + assert.equal(result, undefined, "case-sensitive get should fail for lowercase 'bash'"); + // The fallback expression: get() || name — passes through unchanged + const passthrough = toolNameMap.get(upstreamName) ?? upstreamName; + assert.equal(passthrough, "bash", "lowercase 'bash' passes through unchanged (THE BUG)"); + }); + + it("case-insensitive fallback resolves lowercase to PascalCase (THE FIX)", () => { + const upstreamName = "bash"; + // Simulate the fix: iteration-based case-insensitive lookup + const lowerName = upstreamName.toLowerCase(); + let found: string | undefined; + for (const [key, value] of toolNameMap) { + if (key.toLowerCase() === lowerName) { + found = value; + break; + } + } + assert.equal(found, "Bash", "case-insensitive lookup finds 'Bash' from 'bash'"); + }); + + it("exact match still works for already-correct PascalCase names", () => { + // When upstream returns correct PascalCase, exact Match.get should work + const result = toolNameMap.get("Bash"); + assert.equal(result, "Bash", "exact match works for PascalCase 'Bash'"); + }); + + it("restoreOpenAIToolNames: lowercase in aliases map", async () => { + // Test restoreOpenAIToolNames which uses aliases.get(fn.name) + const { restoreOpenAIToolNames } = + await import("../../open-sse/translator/helpers/toolCallHelper.ts"); + + // Simulate aliases where the key is the shortened lowercase version + const aliases = new Map([["bash", "Bash"]]); + const body = { + choices: [ + { + message: { + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "bash", arguments: "{}" }, + }, + ], + }, + }, + ], + }; + + // Before fix: aliases.get("bash") returns "Bash" directly because + // the key IS "bash" — this one actually works with exact match. + // The bug scenario is when aliases key is "Bash" and upstream returns "bash". + const aliasesReversed = new Map([["Bash", "bash"]]); + const bodyReversed = { + choices: [ + { + message: { + tool_calls: [ + { + id: "call_2", + type: "function", + function: { name: "bash", arguments: "{}" }, + }, + ], + }, + }, + ], + }; + + // Without fix: "bash" is not in map (has "Bash" as key), so lookup fails + const originalGet = aliasesReversed.get("bash"); + assert.equal( + originalGet, + undefined, + "case-sensitive get fails when key is 'Bash' but input is 'bash'" + ); + }); + + it("full pipeline: toolNameMap with PascalCase keys, response with lowercase", async () => { + // This simulates the exact bug scenario: + // toolNameMap has PascalCase entries from request translation + // Upstream model returns lowercase function call names + + const { caseInsensitiveToolNameLookup } = + await import("../../open-sse/translator/helpers/toolCallHelper.ts"); + + // Test the fix function + // Exact match case + const exactResult = caseInsensitiveToolNameLookup("Bash", toolNameMap); + assert.equal(exactResult, "Bash", "exact match works"); + + // Case-insensitive fallback case (THE BUG SCENARIO) + const fallbackResult = caseInsensitiveToolNameLookup("bash", toolNameMap); + assert.equal(fallbackResult, "Bash", "case-insensitive fallback resolves 'bash' to 'Bash'"); + + // Non-existent tool name + const noResult = caseInsensitiveToolNameLookup("nonexistent", toolNameMap); + assert.equal(noResult, undefined, "non-existent tool returns undefined"); + + // Null/undefined map + const nullResult = caseInsensitiveToolNameLookup("bash", null); + assert.equal(nullResult, undefined, "null map returns undefined"); + }); +});