mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575)
Closes #9575
This commit is contained in:
committed by
GitHub
parent
c9debe92bd
commit
c9a3361e5a
1
changelog.d/fixes/9575-tool-call-case.md
Normal file
1
changelog.d/fixes/9575-tool-call-case.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575)
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
128
tests/unit/probe-9575-tool-name-case.test.ts
Normal file
128
tests/unit/probe-9575-tool-name-case.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user