From 3a1e42d98548a48eacaa46d98b4e4e769830eb03 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 5 Aug 2026 22:41:06 -0300 Subject: [PATCH] fix(nvidia): normalize tool names and call IDs (#9236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates — only pre-existing audit.test.ts flake). --- .../fixes/9236-nvidia-tool-compatibility.md | 1 + .../config/providers/registry/nvidia/index.ts | 1 + open-sse/config/providers/shared.ts | 2 + open-sse/executors/default.ts | 67 ++++ open-sse/handlers/chatCore.ts | 28 +- .../handlers/chatCore/passthroughToolNames.ts | 44 ++- open-sse/handlers/responseTranslator.ts | 8 + open-sse/translator/helpers/toolCallHelper.ts | 165 ++++++++- .../translator/response/openai-to-claude.ts | 1 + open-sse/utils/openAIStreamChunk.ts | 33 ++ open-sse/utils/passthroughTailProcessor.ts | 3 + open-sse/utils/stream.ts | 38 +- .../nvidia-tool-compatibility-2840.test.ts | 327 ++++++++++++++++++ 13 files changed, 651 insertions(+), 67 deletions(-) create mode 100644 changelog.d/fixes/9236-nvidia-tool-compatibility.md create mode 100644 open-sse/utils/openAIStreamChunk.ts create mode 100644 tests/unit/nvidia-tool-compatibility-2840.test.ts diff --git a/changelog.d/fixes/9236-nvidia-tool-compatibility.md b/changelog.d/fixes/9236-nvidia-tool-compatibility.md new file mode 100644 index 0000000000..b97bd98faf --- /dev/null +++ b/changelog.d/fixes/9236-nvidia-tool-compatibility.md @@ -0,0 +1 @@ +- **fix(nvidia):** normalize tool names and call ids for NVIDIA compatibility. (thanks @minhnhat166) diff --git a/open-sse/config/providers/registry/nvidia/index.ts b/open-sse/config/providers/registry/nvidia/index.ts index fb36561e07..c6e349fce1 100644 --- a/open-sse/config/providers/registry/nvidia/index.ts +++ b/open-sse/config/providers/registry/nvidia/index.ts @@ -8,6 +8,7 @@ export const nvidiaProvider: RegistryEntry = { baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions", authType: "apikey", authHeader: "bearer", + toolNameMaxLength: 64, // #6773: nvidia multiplexes 17 models from 9 different upstream vendors // (z-ai/, minimaxai/, deepseek-ai/, qwen/, mistralai/, stepfun-ai/, // moonshotai/, openai/, nvidia/) behind ONE connection — mark it passthrough diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index b41d24256e..08cdea6f6d 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -141,6 +141,8 @@ export interface RegistryEntry { passthroughModels?: boolean; /** Default context window for all models in this provider (can be overridden per-model) */ defaultContextLength?: number; + /** Maximum OpenAI-compatible function name length accepted by this provider. */ + toolNameMaxLength?: number; /** Optional session pool config for rate limit management */ poolConfig?: Record; /** diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 34dac8a4af..4b44537ce4 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { mapNvidiaGlm52ReasoningParams } from "./base/reasoningEffort.ts"; import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts"; @@ -18,6 +20,7 @@ import { import { isOfficialAnthropicBaseUrl } from "../utils/anthropicHost.ts"; import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; import { stripUnsupportedParams } from "../translator/paramSupport.ts"; +import { normalizeOpenAIToolNames } from "../translator/helpers/toolCallHelper.ts"; import { injectReasoningContentForThinkingModel, shouldInjectReasoningContentPlaceholder, @@ -63,6 +66,38 @@ import { usesCcWireImage } from "../services/ccWireImageBuiltins.ts"; import type { PoolConfig } from "../services/sessionPool/types.ts"; +const NVIDIA_TOOL_CALL_ID_PATTERN = /^[A-Za-z0-9]{9}$/; + +function normalizeNvidiaToolCallId(id: unknown): unknown { + if (id === null || id === undefined) return id; + const value = String(id); + if (NVIDIA_TOOL_CALL_ID_PATTERN.test(value)) return value; + return createHash("sha256").update(value).digest("hex").slice(0, 9); +} + +function normalizeNvidiaToolCallIds(body: unknown): void { + if (!body || typeof body !== "object" || Array.isArray(body)) return; + const messages = (body as Record).messages; + if (!Array.isArray(messages)) return; + + for (const message of messages) { + if (!message || typeof message !== "object" || Array.isArray(message)) continue; + const record = message as Record; + if (Array.isArray(record.tool_calls)) { + for (const toolCall of record.tool_calls) { + if (!toolCall || typeof toolCall !== "object" || Array.isArray(toolCall)) continue; + const call = toolCall as Record; + if (call.id !== null && call.id !== undefined) { + call.id = normalizeNvidiaToolCallId(call.id); + } + } + } + if (record.tool_call_id !== null && record.tool_call_id !== undefined) { + record.tool_call_id = normalizeNvidiaToolCallId(record.tool_call_id); + } + } +} + /** * Apply operator-configured per-provider custom headers onto an outgoing header * map. Defense-in-depth on top of the Zod `customHeadersSchema`: @@ -638,6 +673,10 @@ export class DefaultExecutor extends BaseExecutor { withDefaults = this.applyJsonSchemaFallback(withDefaults); withDefaults = this.defaultResponsesTextFormat(withDefaults); + if (this.provider === "nvidia") { + normalizeNvidiaToolCallIds(withDefaults); + } + // Port of decolua/9router commit d652300e: // Cerebras returns 400 (wrong_api_format), Mistral returns 422 // (extra_forbidden), and NVIDIA's OpenAI-compatible wrapper returns 400 @@ -823,6 +862,34 @@ export class DefaultExecutor extends BaseExecutor { } } + const toolNameMaxLength = getRegistryEntry(this.provider)?.toolNameMaxLength; + if ( + toolNameMaxLength && + withDefaults && + typeof withDefaults === "object" && + !Array.isArray(withDefaults) + ) { + const toolNameMap = normalizeOpenAIToolNames(withDefaults, toolNameMaxLength); + if (toolNameMap.size > 0) { + const existingToolNameMap = + (withDefaults as Record)._toolNameMap instanceof Map + ? ((withDefaults as Record)._toolNameMap as Map) + : null; + const responseToolNameMap = existingToolNameMap + ? new Map(existingToolNameMap) + : new Map(); + for (const [alias, original] of toolNameMap) { + responseToolNameMap.set(alias, original); + } + Object.defineProperty(withDefaults, "_toolNameMap", { + value: responseToolNameMap, + enumerable: false, + configurable: true, + writable: true, + }); + } + } + return withDefaults; } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 8f8808208f..913716487e 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -222,8 +222,8 @@ import { recordCost } from "@/domain/costRules"; import { calculateCost } from "@/lib/usage/costCalculator"; import { buildClaudePassthroughToolNameMap, - restoreClaudePassthroughToolNames, - mergeResponseToolNameMap, + normalizeOpenAIToolFinishReasons, + restoreNonStreamingToolNames, } from "./chatCore/passthroughToolNames.ts"; import { resolveCompressionSettings } from "./chatCore/compressionSettings.ts"; import { isCompressionExcluded } from "../services/compression/exclusions.ts"; @@ -4195,14 +4195,14 @@ export async function handleChatCore({ } } - const responseToolNameMap = mergeResponseToolNameMap( + const restoreClaudeNames = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE; + let responseToolNameMap: Map | null; + [responseBody, responseToolNameMap] = restoreNonStreamingToolNames( + responseBody, toolNameMap, - (finalBody as Record | null | undefined) ?? null + finalBody, + restoreClaudeNames ); - - if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE) { - responseBody = restoreClaudePassthroughToolNames(responseBody, responseToolNameMap); - } reqLogger.logProviderResponse( providerResponse.status, providerResponse.statusText, @@ -4298,17 +4298,7 @@ export async function handleChatCore({ } // T18: Normalize finish_reason to 'tool_calls' if tool calls are present - if (translatedResponse?.choices) { - for (const choice of translatedResponse.choices) { - if ( - choice.message?.tool_calls && - choice.message.tool_calls.length > 0 && - choice.finish_reason !== "tool_calls" - ) { - choice.finish_reason = "tool_calls"; - } - } - } + normalizeOpenAIToolFinishReasons(translatedResponse); // Reasoning Replay Cache (#1628): Capture reasoning_content from non-streaming responses // with tool_calls so it can be replayed on subsequent turns (DeepSeek V4, Kimi K2, etc.) diff --git a/open-sse/handlers/chatCore/passthroughToolNames.ts b/open-sse/handlers/chatCore/passthroughToolNames.ts index 0ab6b17d4d..87069c7360 100644 --- a/open-sse/handlers/chatCore/passthroughToolNames.ts +++ b/open-sse/handlers/chatCore/passthroughToolNames.ts @@ -1,6 +1,11 @@ import { CLAUDE_OAUTH_TOOL_PREFIX } from "../../translator/request/openai-to-claude.ts"; +import { restoreOpenAIToolNames } from "../../translator/helpers/toolCallHelper.ts"; -export function buildClaudePassthroughToolNameMap(body: Record | null | undefined) { +type JsonRecord = Record; + +export function buildClaudePassthroughToolNameMap( + body: Record | null | undefined +) { if (!body || !Array.isArray(body.tools)) return null; const toolNameMap = new Map(); @@ -47,11 +52,15 @@ export function restoreClaudePassthroughToolNames( export function mergeResponseToolNameMap( baseToolNameMap: Map | null, - transformedBody: Record | null | undefined + transformedBody: unknown ) { + const transformedRecord = + transformedBody && typeof transformedBody === "object" && !Array.isArray(transformedBody) + ? (transformedBody as JsonRecord) + : null; const executorToolNameMap = - transformedBody && transformedBody._toolNameMap instanceof Map - ? (transformedBody._toolNameMap as Map) + transformedRecord?._toolNameMap instanceof Map + ? (transformedRecord._toolNameMap as Map) : null; if (!executorToolNameMap?.size) return baseToolNameMap; @@ -63,3 +72,30 @@ export function mergeResponseToolNameMap( } return merged; } + +export function restoreNonStreamingToolNames( + responseBody: JsonRecord, + baseToolNameMap: Map | null, + transformedBody: unknown, + restoreClaudeNames: boolean +): [JsonRecord, Map | null] { + const responseToolNameMap = mergeResponseToolNameMap(baseToolNameMap, transformedBody); + const restoredBody = restoreClaudeNames + ? restoreClaudePassthroughToolNames(responseBody, responseToolNameMap) + : responseBody; + restoreOpenAIToolNames(restoredBody, responseToolNameMap); + return [restoredBody, responseToolNameMap]; +} + +export function normalizeOpenAIToolFinishReasons(responseBody: unknown): void { + const response = responseBody as { + choices?: Array; + } | null; + if (!response?.choices) return; + + for (const choice of response.choices) { + if (choice.message?.tool_calls?.length > 0 && choice.finish_reason !== "tool_calls") { + choice.finish_reason = "tool_calls"; + } + } +} diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 80ad25d801..35f66e1087 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -6,6 +6,7 @@ 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"; type JsonRecord = Record; @@ -137,11 +138,18 @@ export function translateNonStreamingResponse( ): unknown { // If already in source format, return as-is if (targetFormat === sourceFormat) { + if (targetFormat === FORMATS.OPENAI) { + restoreOpenAIToolNames(responseBody, toolNameMap); + } return responseBody; } let intermediateOpenAI = responseBody; + if (targetFormat === FORMATS.OPENAI) { + restoreOpenAIToolNames(intermediateOpenAI, toolNameMap); + } + // Handle OpenAI Responses API format if (targetFormat === FORMATS.OPENAI_RESPONSES) { const responseRoot = toRecord(responseBody); diff --git a/open-sse/translator/helpers/toolCallHelper.ts b/open-sse/translator/helpers/toolCallHelper.ts index caf69dd184..9583368c2f 100644 --- a/open-sse/translator/helpers/toolCallHelper.ts +++ b/open-sse/translator/helpers/toolCallHelper.ts @@ -1,7 +1,130 @@ +import { createHash } from "node:crypto"; + // Tool call helper functions for translator const ALPHANUM9 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +type JsonRecord = Record; +type ToolNameAliases = Map; + +interface ToolFunction extends JsonRecord { + name?: unknown; + arguments?: unknown; +} + +interface ToolCallRecord extends JsonRecord { + id?: unknown; + type?: unknown; + function?: ToolFunction; +} + +interface ToolContentBlock extends JsonRecord { + type?: unknown; + id?: unknown; + tool_use_id?: unknown; +} + +interface ToolMessage extends JsonRecord { + role?: unknown; + tool_calls?: ToolCallRecord[]; + tool_call_id?: unknown; + content?: unknown; +} + +interface ToolCallBody extends JsonRecord { + messages?: ToolMessage[]; +} + +function toRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +function aliasOpenAIToolName(name: unknown, maxLength: number, aliases: ToolNameAliases): unknown { + if (typeof name !== "string" || name.length === 0) return name; + + const safe = name.replace(/[^A-Za-z0-9_-]/g, "_"); + if (safe === name && safe.length <= maxLength) return safe; + + const hash = createHash("sha256").update(name).digest("hex").slice(0, 12); + const prefixLength = Math.max(0, maxLength - hash.length - 1); + const shortened = + prefixLength > 0 ? `${safe.slice(0, prefixLength)}_${hash}` : hash.slice(0, maxLength); + aliases.set(shortened, name); + return shortened; +} + +/** + * Mutates an OpenAI-compatible request so every function name satisfies a + * provider's maximum length and `[A-Za-z0-9_-]` character constraints. + * Returns alias → original entries for response restoration. + */ +export function normalizeOpenAIToolNames(body: unknown, maxLength: number): ToolNameAliases { + const aliases: ToolNameAliases = new Map(); + const root = toRecord(body); + if (!root || !Number.isInteger(maxLength) || maxLength < 1) return aliases; + + const alias = (name: unknown): unknown => aliasOpenAIToolName(name, maxLength, aliases); + + if (Array.isArray(root.tools)) { + for (const tool of root.tools) { + const fn = toRecord(toRecord(tool)?.function); + if (fn && typeof fn.name === "string") fn.name = alias(fn.name); + } + } + + const toolChoiceFunction = toRecord(toRecord(root.tool_choice)?.function); + if (toolChoiceFunction && typeof toolChoiceFunction.name === "string") { + toolChoiceFunction.name = alias(toolChoiceFunction.name); + } + + if (Array.isArray(root.messages)) { + for (const message of root.messages) { + const msg = toRecord(message); + if (!msg) continue; + if (Array.isArray(msg.tool_calls)) { + for (const toolCall of msg.tool_calls) { + const fn = toRecord(toRecord(toolCall)?.function); + if (fn && typeof fn.name === "string") fn.name = alias(fn.name); + } + } + if (msg.role === "tool" && typeof msg.name === "string") { + msg.name = alias(msg.name); + } + } + } + + return aliases; +} + +/** 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; + const root = toRecord(body); + if (!root || !Array.isArray(root.choices)) return false; + + let changed = false; + const restoreCalls = (calls: unknown): void => { + if (!Array.isArray(calls)) return; + for (const toolCall of calls) { + const fn = toRecord(toRecord(toolCall)?.function); + if (!fn || typeof fn.name !== "string") continue; + const original = aliases.get(fn.name); + if (typeof original !== "string" || original === fn.name) continue; + fn.name = original; + changed = true; + } + }; + + for (const choice of root.choices) { + const record = toRecord(choice); + if (!record) continue; + restoreCalls(toRecord(record.delta)?.tool_calls); + restoreCalls(toRecord(record.message)?.tool_calls); + } + + return changed; +} + // Fallback streaming tool_call id when a provider response omits one (index optional). // `call_` when no index is given; `call__` when an index is supplied. export function fallbackToolCallId(index?: number): string { @@ -23,7 +146,10 @@ function generateToolCallId9(): string { } /** @param options.use9CharId - When true, normalize ids to 9-char [a-zA-Z0-9] (e.g. Mistral); when false, only fix type/arguments, leave ids as-is */ -export function ensureToolCallIds(body, options?: { use9CharId?: boolean }) { +export function ensureToolCallIds( + body: T, + options?: { use9CharId?: boolean } +): T { if (!body.messages || !Array.isArray(body.messages)) return body; const use9CharId = options?.use9CharId === true; @@ -79,23 +205,23 @@ export function ensureToolCallIds(body, options?: { use9CharId?: boolean }) { } // Get tool_call ids from assistant message (OpenAI format: tool_calls, Claude format: tool_use in content) -export function getToolCallIds(msg) { +export function getToolCallIds(msg: ToolMessage): string[] { if (msg.role !== "assistant") return []; - const ids = []; + const ids: string[] = []; // OpenAI format: tool_calls array if (msg.tool_calls && Array.isArray(msg.tool_calls)) { for (const tc of msg.tool_calls) { - if (tc.id) ids.push(tc.id); + if (tc.id) ids.push(String(tc.id)); } } // Claude format: tool_use blocks in content if (Array.isArray(msg.content)) { - for (const block of msg.content) { + for (const block of msg.content as ToolContentBlock[]) { if (block.type === "tool_use" && block.id) { - ids.push(block.id); + ids.push(String(block.id)); } } } @@ -104,18 +230,25 @@ export function getToolCallIds(msg) { } // Check if user message has tool_result for given ids (OpenAI format: role=tool, Claude format: tool_result in content) -export function hasToolResults(msg, toolCallIds) { +export function hasToolResults( + msg: ToolMessage | null | undefined, + toolCallIds: string[] +): boolean { if (!msg || !toolCallIds.length) return false; // OpenAI format: role = "tool" with tool_call_id if (msg.role === "tool" && msg.tool_call_id) { - return toolCallIds.includes(msg.tool_call_id); + return toolCallIds.includes(String(msg.tool_call_id)); } // Claude format: tool_result blocks in user message content if (msg.role === "user" && Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "tool_result" && toolCallIds.includes(block.tool_use_id)) { + for (const block of msg.content as ToolContentBlock[]) { + if ( + block.type === "tool_result" && + block.tool_use_id && + toolCallIds.includes(String(block.tool_use_id)) + ) { return true; } } @@ -127,10 +260,10 @@ export function hasToolResults(msg, toolCallIds) { // Fix missing tool responses - insert empty tool_result if assistant has tool_use but next message has no tool_result. // Inserts in the same shape as the opening assistant message: OpenAI tool_calls → role:"tool"; // Claude tool_use blocks → role:"user" with tool_result content blocks. -export function fixMissingToolResponses(body) { +export function fixMissingToolResponses(body: T): T { if (!body.messages || !Array.isArray(body.messages)) return body; - const newMessages = []; + const newMessages: ToolMessage[] = []; for (let i = 0; i < body.messages.length; i++) { const msg = body.messages[i]; @@ -179,7 +312,7 @@ export function fixMissingToolResponses(body) { // role:"tool" messages and Claude-format tool_result content blocks. Drops a // user message entirely if stripping empties its content array. Returns the // same body reference when nothing needs to change (no-op fast path). -export function stripOrphanedToolResults(body) { +export function stripOrphanedToolResults(body: T): T { if (!body.messages || !Array.isArray(body.messages)) return body; const knownCallIds = new Set(); @@ -190,11 +323,11 @@ export function stripOrphanedToolResults(body) { } let changed = false; - const filteredMessages = []; + const filteredMessages: ToolMessage[] = []; for (const msg of body.messages) { if (msg.role === "tool" && msg.tool_call_id) { - if (knownCallIds.has(msg.tool_call_id)) { + if (knownCallIds.has(String(msg.tool_call_id))) { filteredMessages.push(msg); } else { changed = true; @@ -203,7 +336,7 @@ export function stripOrphanedToolResults(body) { } if (Array.isArray(msg.content)) { - const cleanedContent = msg.content.filter((block) => { + const cleanedContent = (msg.content as ToolContentBlock[]).filter((block) => { if (block?.type !== "tool_result") return true; return typeof block.tool_use_id === "string" && knownCallIds.has(block.tool_use_id); }); diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts index 15b481da74..6a5d1687ad 100644 --- a/open-sse/translator/response/openai-to-claude.ts +++ b/open-sse/translator/response/openai-to-claude.ts @@ -284,6 +284,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; if (n.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) n = n.slice(CLAUDE_OAUTH_TOOL_PREFIX.length); return n; })(); diff --git a/open-sse/utils/openAIStreamChunk.ts b/open-sse/utils/openAIStreamChunk.ts new file mode 100644 index 0000000000..d249d78ff8 --- /dev/null +++ b/open-sse/utils/openAIStreamChunk.ts @@ -0,0 +1,33 @@ +import { restoreOpenAIToolNames } from "../translator/helpers/toolCallHelper.ts"; + +type JsonRecord = Record; + +export function normalizeFinalOpenAIStreamChunk( + parsed: JsonRecord, + toolNameMap: unknown +): { changed: boolean; hasFinishReason: boolean } { + let changed = false; + if (parsed.id != null && typeof parsed.id !== "string") { + parsed.id = String(parsed.id); + changed = true; + } + + if (Array.isArray(parsed.choices)) { + for (const choice of parsed.choices as JsonRecord[]) { + const delta = (choice as JsonRecord | null | undefined)?.delta as JsonRecord | undefined; + if (!Array.isArray(delta?.tool_calls)) continue; + for (const toolCall of delta.tool_calls as JsonRecord[]) { + if (toolCall?.id != null && typeof toolCall.id !== "string") { + toolCall.id = String(toolCall.id); + changed = true; + } + } + } + } + + changed = restoreOpenAIToolNames(parsed, toolNameMap) || changed; + const firstChoice = Array.isArray(parsed.choices) + ? (parsed.choices[0] as JsonRecord | undefined) + : undefined; + return { changed, hasFinishReason: Boolean(firstChoice?.finish_reason) }; +} diff --git a/open-sse/utils/passthroughTailProcessor.ts b/open-sse/utils/passthroughTailProcessor.ts index 732e09cb4f..23d04e8a27 100644 --- a/open-sse/utils/passthroughTailProcessor.ts +++ b/open-sse/utils/passthroughTailProcessor.ts @@ -45,6 +45,7 @@ export type PassthroughTailProcessorContext = { setPassthroughResponsesCurrentFunctionCallKey: (value: string | null) => void; hasPassthroughToolCalls: () => boolean; toResponsesCompletedWithToolCalls: (parsed: JsonRecord) => JsonRecord; + restoreOpenAIToolNames: (parsed: JsonRecord) => boolean; }; function asRecord(value: unknown): JsonRecord { @@ -290,7 +291,9 @@ export function processBufferedPassthroughLine( if (isResponses) { output = handleResponsesTailPayload(parsed, output, context); } else if (!isClaude) { + const restoredToolName = context.restoreOpenAIToolNames(parsed); handleOpenAiTailPayload(parsed, context); + if (restoredToolName) output = `data: ${JSON.stringify(parsed)}\n\n`; } context.pushClientPayload(parsed); diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index f5560fa03c..85d2524dfc 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -64,6 +64,8 @@ import { hasUnsupportedReasoningSignal, } from "./reasoningFields.ts"; import { applyThinkTag, flushThink, initThinkState } from "./thinkTagParser.ts"; +import { restoreOpenAIToolNames } from "../translator/helpers/toolCallHelper.ts"; +import { normalizeFinalOpenAIStreamChunk } from "./openAIStreamChunk.ts"; /** * Race a response body read against a timeout. @@ -1735,6 +1737,7 @@ export function createSSEStream(options: StreamOptions = {}) { continue; } + const restoredOpenAIToolName = restoreOpenAIToolNames(parsed, toolNameMap); const idFixed = hadNonStringTopLevelId ? false : fixInvalidId(parsed); if (!hasValuableContent(parsed, FORMATS.OPENAI)) { @@ -1923,7 +1926,8 @@ export function createSSEStream(options: StreamOptions = {}) { needsReserialization || toolCallIdCoerced || hadNonStringToolCallId || - hadNonStringTopLevelId + hadNonStringTopLevelId || + restoredOpenAIToolName ) { output = `data: ${JSON.stringify(parsed)}\n\n`; injectedUsage = true; @@ -2253,6 +2257,8 @@ export function createSSEStream(options: StreamOptions = {}) { toResponsesCompletedWithToolCalls(parsed, [ ...passthroughToolCalls.values(), ]) as JsonRecord, + restoreOpenAIToolNames: (parsed: JsonRecord) => + restoreOpenAIToolNames(parsed, toolNameMap), }; for (const line of normalizedTailLines) { @@ -2300,36 +2306,12 @@ export function createSSEStream(options: StreamOptions = {}) { output = `data: ${JSON.stringify(flushedParsed)}\n\n`; } } else if (!isClaude) { - let flushChanged = false; - const flushedHadNonStringTopLevelId = - flushedParsed?.id != null && typeof flushedParsed.id !== "string"; - if (flushedHadNonStringTopLevelId) { - flushedParsed.id = String(flushedParsed.id); - flushChanged = true; - } - if (Array.isArray(flushedParsed.choices)) { - for (const choice of flushedParsed.choices as JsonRecord[]) { - const tcs = (choice as JsonRecord | undefined)?.delta as - JsonRecord | undefined; - if (Array.isArray(tcs?.tool_calls)) { - for (const tc of tcs.tool_calls as JsonRecord[]) { - if (tc?.id != null && typeof tc.id !== "string") { - tc.id = String(tc.id); - flushChanged = true; - } - } - } - } - } + const { changed: flushChanged, hasFinishReason } = + normalizeFinalOpenAIStreamChunk(flushedParsed, toolNameMap); // #7800: track finish_reason in the flush path too, so a // final chunk without trailing newline still suppresses the // synthetic finish_reason synthesis. - if ( - Array.isArray(flushedParsed.choices) && - (flushedParsed.choices[0] as JsonRecord | undefined)?.finish_reason - ) { - passthroughSawFinishReason = true; - } + if (hasFinishReason) passthroughSawFinishReason = true; if (flushChanged) { output = `data: ${JSON.stringify(flushedParsed)}\n\n`; } diff --git a/tests/unit/nvidia-tool-compatibility-2840.test.ts b/tests/unit/nvidia-tool-compatibility-2840.test.ts new file mode 100644 index 0000000000..6d4e001f74 --- /dev/null +++ b/tests/unit/nvidia-tool-compatibility-2840.test.ts @@ -0,0 +1,327 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; +import { getRegistryEntry } from "../../open-sse/config/providerRegistry.ts"; +import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts"; +import { + normalizeOpenAIToolNames, + restoreOpenAIToolNames, +} from "../../open-sse/translator/helpers/toolCallHelper.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; +import { openaiToClaudeResponse } from "../../open-sse/translator/response/openai-to-claude.ts"; +import { createPassthroughStreamWithLogger } from "../../open-sse/utils/stream.ts"; + +test("NVIDIA keeps tool calls and tool results linked with deterministic 9-character IDs", () => { + const body = { + messages: [ + { + role: "assistant", + tool_calls: [ + { + id: "6075034-0", + type: "function", + function: { name: "lookup", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: "6075034-0", content: "ok" }, + ], + }; + + const first = new DefaultExecutor("nvidia").transformRequest( + "mistralai/mistral-medium-3.5-128b", + structuredClone(body), + false, + null + ); + const second = new DefaultExecutor("nvidia").transformRequest( + "mistralai/mistral-medium-3.5-128b", + structuredClone(body), + false, + null + ); + + const callId = first.messages[0].tool_calls[0].id; + assert.match(callId, /^[A-Za-z0-9]{9}$/); + assert.equal(first.messages[1].tool_call_id, callId); + assert.equal(second.messages[0].tool_calls[0].id, callId); + + const valid = new DefaultExecutor("nvidia").transformRequest( + "mistralai/mistral-medium-3.5-128b", + { + messages: [ + { + role: "assistant", + tool_calls: [ + { + id: "Abc123XyZ", + type: "function", + function: { name: "lookup", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: "Abc123XyZ", content: "ok" }, + ], + }, + false, + null + ); + assert.equal(valid.messages[0].tool_calls[0].id, "Abc123XyZ"); + assert.equal(valid.messages[1].tool_call_id, "Abc123XyZ"); +}); + +test("NVIDIA aliases every OpenAI tool-name surface and can restore the provider response", () => { + const original = "mcp__plugin_chrome-devtools-mcp_chrome-devtools__get.console/message"; + const body = { + tools: [{ type: "function", function: { name: original } }], + tool_choice: { type: "function", function: { name: original } }, + messages: [ + { + role: "assistant", + tool_calls: [{ type: "function", function: { name: original, arguments: "{}" } }], + }, + { role: "tool", name: original, tool_call_id: "valid1234", content: "ok" }, + ], + }; + + const aliases = normalizeOpenAIToolNames(body, 64); + const alias = body.tools[0].function.name; + + assert.match(alias, /^[A-Za-z0-9_-]{1,64}$/); + assert.notEqual(alias, original); + assert.equal(body.tool_choice.function.name, alias); + assert.equal(body.messages[0].tool_calls[0].function.name, alias); + assert.equal(body.messages[1].name, alias); + + const response = { + choices: [ + { + message: { + tool_calls: [{ type: "function", function: { name: alias, arguments: "{}" } }], + }, + }, + ], + }; + + assert.equal(restoreOpenAIToolNames(response, aliases), true); + assert.equal(response.choices[0].message.tool_calls[0].function.name, original); + + const collisions = { + tools: ["a.b", "a?b"].map((name) => ({ type: "function", function: { name } })), + }; + normalizeOpenAIToolNames(collisions, 64); + assert.notEqual(collisions.tools[0].function.name, collisions.tools[1].function.name); +}); + +test("NVIDIA executor applies its registry-scoped tool-name limit without changing OpenAI", () => { + const original = "mcp__plugin_chrome-devtools-mcp_chrome-devtools__get.console/message"; + const request = { + tools: [{ type: "function", function: { name: original } }], + messages: [ + { + role: "assistant", + tool_calls: [{ type: "function", function: { name: original, arguments: "{}" } }], + }, + ], + }; + + assert.equal(getRegistryEntry("nvidia")?.toolNameMaxLength, 64); + + const nvidia = new DefaultExecutor("nvidia").transformRequest( + "mistralai/mistral-medium-3.5-128b", + structuredClone(request), + false, + null + ); + const openai = new DefaultExecutor("openai").transformRequest( + "gpt-5.4", + structuredClone(request), + false, + null + ); + + const alias = nvidia.tools[0].function.name; + assert.match(alias, /^[A-Za-z0-9_-]{1,64}$/); + assert.equal(nvidia.messages[0].tool_calls[0].function.name, alias); + assert.equal(nvidia._toolNameMap.get(alias), original); + assert.equal(Object.prototype.propertyIsEnumerable.call(nvidia, "_toolNameMap"), false); + assert.equal(openai.tools[0].function.name, original); + assert.equal(openai._toolNameMap, undefined); +}); + +test("NVIDIA restores aliases in non-streaming OpenAI and OpenAI-to-Claude responses", () => { + const original = "mcp__plugin_chrome-devtools-mcp_chrome-devtools__get.console/message"; + const request = { tools: [{ type: "function", function: { name: original } }] }; + const aliases = normalizeOpenAIToolNames(request, 64); + const alias = request.tools[0].function.name; + const providerResponse = { + id: "chatcmpl_1", + object: "chat.completion", + choices: [ + { + message: { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: alias, arguments: "{}" }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + }; + + const openai = structuredClone(providerResponse); + assert.equal(restoreOpenAIToolNames(openai, aliases), true); + assert.equal(openai.choices[0].message.tool_calls[0].function.name, original); + + const claude = translateNonStreamingResponse( + structuredClone(providerResponse), + FORMATS.OPENAI, + FORMATS.CLAUDE, + aliases + ); + const toolUse = claude.content.find((block) => block.type === "tool_use"); + assert.equal(toolUse.name, original); +}); + +test("NVIDIA restores aliases in passthrough OpenAI SSE chunks", async () => { + const original = "mcp__plugin_chrome-devtools-mcp_chrome-devtools__get.console/message"; + const request = { tools: [{ type: "function", function: { name: original } }] }; + const aliases = normalizeOpenAIToolNames(request, 64); + const alias = request.tools[0].function.name; + const transform = createPassthroughStreamWithLogger("nvidia", null, aliases); + const writer = transform.writable.getWriter(); + const reader = transform.readable.getReader(); + const output = (async () => { + let result = ""; + const decoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); + if (done) return result; + result += decoder.decode(value); + } + })(); + + await writer.write( + new TextEncoder().encode( + `data: ${JSON.stringify({ + id: "chatcmpl_1", + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + type: "function", + function: { name: alias, arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + })}\n\n` + ) + ); + await writer.close(); + + const text = await output; + assert.match(text, new RegExp(original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.doesNotMatch(text, new RegExp(alias)); +}); + +test("NVIDIA restores aliases before emitting translated OpenAI-to-Claude tool blocks", () => { + const original = "mcp__plugin_chrome-devtools-mcp_chrome-devtools__get.console/message"; + const request = { tools: [{ type: "function", function: { name: original } }] }; + const aliases = normalizeOpenAIToolNames(request, 64); + const alias = request.tools[0].function.name; + const translated = openaiToClaudeResponse( + { + id: "chatcmpl_1", + model: "mistralai/mistral-medium-3.5-128b", + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + type: "function", + function: { name: alias, arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + toolNameMap: aliases, + toolCalls: new Map(), + nextBlockIndex: 0, + textBlockIndex: -1, + thinkingBlockIndex: -1, + } + ); + + const block = translated.find((event) => event.type === "content_block_start"); + assert.equal(block.content_block.name, original); +}); + +test("NVIDIA restores an alias in the final passthrough SSE chunk without a trailing newline", async () => { + const original = "mcp__plugin_chrome-devtools-mcp_chrome-devtools__get.console/message"; + const request = { tools: [{ type: "function", function: { name: original } }] }; + const aliases = normalizeOpenAIToolNames(request, 64); + const alias = request.tools[0].function.name; + const transform = createPassthroughStreamWithLogger("nvidia", null, aliases); + const writer = transform.writable.getWriter(); + const reader = transform.readable.getReader(); + const output = (async () => { + let result = ""; + const decoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); + if (done) return result; + result += decoder.decode(value); + } + })(); + + await writer.write( + new TextEncoder().encode( + `data: ${JSON.stringify({ + id: "chatcmpl_2", + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_2", + type: "function", + function: { name: alias, arguments: "{}" }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + })}` + ) + ); + await writer.close(); + + const text = await output; + assert.match(text, new RegExp(original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.doesNotMatch(text, new RegExp(alias)); +});