fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575)

Closes #9575
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-06 22:58:51 -03:00
committed by GitHub
parent c9debe92bd
commit c9a3361e5a
7 changed files with 178 additions and 22 deletions

View File

@@ -0,0 +1 @@
- fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575)

View File

@@ -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<string, unknown>;
@@ -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",

View File

@@ -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<string, string> | 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;

View File

@@ -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<Record<string, unknown>>
) {
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 = {

View File

@@ -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;
})();

View File

@@ -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;

View File

@@ -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<string, string>([
["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<string, string>([["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<string, string>([["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");
});
});