mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
fix(nvidia): normalize tool names and call IDs (#9236)
Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates — only pre-existing audit.test.ts flake).
This commit is contained in:
committed by
GitHub
parent
b8d2478333
commit
3a1e42d985
1
changelog.d/fixes/9236-nvidia-tool-compatibility.md
Normal file
1
changelog.d/fixes/9236-nvidia-tool-compatibility.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(nvidia):** normalize tool names and call ids for NVIDIA compatibility. (thanks @minhnhat166)
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
/**
|
||||
|
||||
@@ -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<string, unknown>).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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>)._toolNameMap instanceof Map
|
||||
? ((withDefaults as Record<string, unknown>)._toolNameMap as Map<string, string>)
|
||||
: null;
|
||||
const responseToolNameMap = existingToolNameMap
|
||||
? new Map(existingToolNameMap)
|
||||
: new Map<string, string>();
|
||||
for (const [alias, original] of toolNameMap) {
|
||||
responseToolNameMap.set(alias, original);
|
||||
}
|
||||
Object.defineProperty(withDefaults, "_toolNameMap", {
|
||||
value: responseToolNameMap,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return withDefaults;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> | null;
|
||||
[responseBody, responseToolNameMap] = restoreNonStreamingToolNames(
|
||||
responseBody,
|
||||
toolNameMap,
|
||||
(finalBody as Record<string, unknown> | 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.)
|
||||
|
||||
@@ -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<string, unknown> | null | undefined) {
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export function buildClaudePassthroughToolNameMap(
|
||||
body: Record<string, unknown> | null | undefined
|
||||
) {
|
||||
if (!body || !Array.isArray(body.tools)) return null;
|
||||
|
||||
const toolNameMap = new Map<string, string>();
|
||||
@@ -47,11 +52,15 @@ export function restoreClaudePassthroughToolNames(
|
||||
|
||||
export function mergeResponseToolNameMap(
|
||||
baseToolNameMap: Map<string, string> | null,
|
||||
transformedBody: Record<string, unknown> | 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<string, string>)
|
||||
transformedRecord?._toolNameMap instanceof Map
|
||||
? (transformedRecord._toolNameMap as Map<string, string>)
|
||||
: null;
|
||||
|
||||
if (!executorToolNameMap?.size) return baseToolNameMap;
|
||||
@@ -63,3 +72,30 @@ export function mergeResponseToolNameMap(
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function restoreNonStreamingToolNames(
|
||||
responseBody: JsonRecord,
|
||||
baseToolNameMap: Map<string, string> | null,
|
||||
transformedBody: unknown,
|
||||
restoreClaudeNames: boolean
|
||||
): [JsonRecord, Map<string, string> | 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<JsonRecord & { message?: { tool_calls?: unknown[] } }>;
|
||||
} | 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,130 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
// Tool call helper functions for translator
|
||||
|
||||
const ALPHANUM9 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type ToolNameAliases = Map<string, string>;
|
||||
|
||||
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_<ts>` when no index is given; `call_<index>_<ts>` 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<T extends ToolCallBody>(
|
||||
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<T extends ToolCallBody>(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<T extends ToolCallBody>(body: T): T {
|
||||
if (!body.messages || !Array.isArray(body.messages)) return body;
|
||||
|
||||
const knownCallIds = new Set<string>();
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
33
open-sse/utils/openAIStreamChunk.ts
Normal file
33
open-sse/utils/openAIStreamChunk.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { restoreOpenAIToolNames } from "../translator/helpers/toolCallHelper.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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) };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
327
tests/unit/nvidia-tool-compatibility-2840.test.ts
Normal file
327
tests/unit/nvidia-tool-compatibility-2840.test.ts
Normal file
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user