diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index ced9601339..0f93991bd2 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1,6 +1,7 @@ import { extractRequestToolIdentityMap } from "./chatCore/requestToolIdentity.ts"; import { injectMemoryAndSkills } from "./chatCore/memorySkillsInjection.ts"; import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts"; +import { normalizeOpenAICompatibleTools } from "./chatCore/openAICompatibleTools.ts"; import { buildFailureUsageRecord } from "./chatCore/failureUsage.ts"; import { estimateFinalInputTokens } from "./chatCore/contextEstimation.ts"; import { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts"; @@ -2162,26 +2163,12 @@ export async function handleChatCore({ // - tools without a name AND without .function → dropped (unconvertible) // This must happen before translateRequest, which validates and throws on unknown types. if (provider?.startsWith("openai-compatible-") && Array.isArray(translatedBody.tools)) { - const before = (translatedBody.tools as unknown[]).length; - translatedBody.tools = (translatedBody.tools as Record[]) - .filter((t) => !t.type || t.type === "function" || !!t.function || !!t.name) - .map((t) => { - if (!t.type || t.type === "function" || t.function) return t; - // Named non-function tool: normalise to function format so the translator - // does not throw on the unknown type. - return { - type: "function", - function: { - name: t.name, - ...(t.description === undefined ? {} : { description: t.description }), - ...(t.parameters !== undefined || t.input_schema !== undefined - ? { parameters: t.parameters ?? t.input_schema ?? {} } - : {}), - ...(t.strict === undefined ? {} : { strict: t.strict }), - }, - }; - }); - const dropped = before - (translatedBody.tools as unknown[]).length; + const normalized = normalizeOpenAICompatibleTools( + translatedBody.tools as Record[], + sourceFormat + ); + translatedBody.tools = normalized.tools; + const { dropped } = normalized; if (dropped > 0) { log?.debug?.( "TOOLS", diff --git a/open-sse/handlers/chatCore/openAICompatibleTools.ts b/open-sse/handlers/chatCore/openAICompatibleTools.ts new file mode 100644 index 0000000000..3b692be36d --- /dev/null +++ b/open-sse/handlers/chatCore/openAICompatibleTools.ts @@ -0,0 +1,46 @@ +import { FORMATS } from "../../translator/formats.ts"; + +type Tool = Record; + +export function normalizeOpenAICompatibleTools( + tools: Tool[], + sourceFormat: string +): { tools: Tool[]; dropped: number } { + // The Responses translator has dedicated handling for custom, namespace, + // tool_search, local_shell, and hosted tool types. Normalizing any of them + // here destroys information before that format-aware conversion can run. + if (sourceFormat === FORMATS.OPENAI_RESPONSES) { + return { tools, dropped: 0 }; + } + + const before = tools.length; + const normalized = tools + .filter((tool) => + !tool.type || tool.type === "function" || !!tool.function || !!tool.name + ) + .map((tool) => { + // Responses custom tools carry free-form input. Preserve their native shape so + // the Responses translator can produce the required { input: string } schema. + if ( + !tool.type || + tool.type === "function" || + tool.function + ) { + return tool; + } + + return { + type: "function", + function: { + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + ...(tool.parameters !== undefined || tool.input_schema !== undefined + ? { parameters: tool.parameters ?? tool.input_schema ?? {} } + : {}), + ...(tool.strict === undefined ? {} : { strict: tool.strict }), + }, + }; + }); + + return { tools: normalized, dropped: before - normalized.length }; +} diff --git a/open-sse/translator/response/openai-responses/requestToolIdentity.ts b/open-sse/translator/response/openai-responses/requestToolIdentity.ts index 94d4b4cb80..f92f65b171 100644 --- a/open-sse/translator/response/openai-responses/requestToolIdentity.ts +++ b/open-sse/translator/response/openai-responses/requestToolIdentity.ts @@ -1,17 +1,45 @@ -/** * Resolve a flattened Chat function name back to the identity declared by the * request's Responses namespace tool. The request path supplies this map on * the response translation state; this resolver intentionally never parses a name. */ export function resolveRequestToolIdentity( - identityMap: unknown, - toolName: string -) { - if (!toolName || !identityMap) return null; - const identity = - identityMap instanceof Map - ? identityMap.get(toolName) - : typeof identityMap === "object" && !Array.isArray(identityMap) - ? (identityMap as Record)[toolName] - : undefined; - if (!identity || typeof identity !== "object" || Array.isArray(identity)) return null; - const { namespace, name } = identity as Record; +type RequestToolIdentity = { namespace: string; name: string }; + +function asRequestToolIdentity(value: unknown): RequestToolIdentity | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const { namespace, name } = value as Record; return typeof namespace === "string" && namespace && typeof name === "string" && name ? { namespace, name } : null; } + +/** + * Resolve a flattened Chat function name back to the identity declared by the + * request's Responses namespace tool. The request path supplies this map on + * the response translation state. + * + * Some model-specific tool parsers render the registered `namespace__leaf` + * wire name as `namespace.leaf`. Accept that spelling only when it matches an + * identity already present in the request ledger; never infer a namespace by + * splitting an otherwise unknown tool name. + */ +export function resolveRequestToolIdentity(identityMap: unknown, toolName: string) { + if (!toolName) return null; + + const direct = + identityMap instanceof Map + ? identityMap.get(toolName) + : identityMap && typeof identityMap === "object" && !Array.isArray(identityMap) + ? (identityMap as Record)[toolName] + : undefined; + const directIdentity = asRequestToolIdentity(direct); + if (directIdentity) return directIdentity; + + const candidates = + identityMap instanceof Map + ? identityMap.values() + : identityMap && typeof identityMap === "object" && !Array.isArray(identityMap) + ? Object.values(identityMap as Record) + : []; + for (const candidate of candidates) { + const identity = asRequestToolIdentity(candidate); + if (identity && `${identity.namespace}.${identity.name}` === toolName) return identity; + } + + return null; +} diff --git a/tests/unit/openai-compatible-tools.test.ts b/tests/unit/openai-compatible-tools.test.ts new file mode 100644 index 0000000000..5d00e2a69e --- /dev/null +++ b/tests/unit/openai-compatible-tools.test.ts @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { normalizeOpenAICompatibleTools } from "../../open-sse/handlers/chatCore/openAICompatibleTools.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; + +test("preserves all Responses tool types for the Responses translator", () => { + const customTool = { + type: "custom", + name: "exec", + description: "Run a shell command", + }; + const namespaceTool = { + type: "namespace", + name: "functions", + tools: [customTool], + }; + const localShellTool = { type: "local_shell" }; + + const result = normalizeOpenAICompatibleTools( + [customTool, namespaceTool, localShellTool], + FORMATS.OPENAI_RESPONSES + ); + + assert.deepEqual(result, { + tools: [customTool, namespaceTool, localShellTool], + dropped: 0, + }); +}); + +test("normalizes named non-function tools for other source formats", () => { + const result = normalizeOpenAICompatibleTools( + [{ type: "custom", name: "exec", input_schema: { type: "object" } }], + FORMATS.OPENAI + ); + + assert.deepEqual(result, { + tools: [ + { + type: "function", + function: { + name: "exec", + parameters: { type: "object" }, + }, + }, + ], + dropped: 0, + }); +}); diff --git a/tests/unit/request-tool-identity-dotted-alias.test.ts b/tests/unit/request-tool-identity-dotted-alias.test.ts new file mode 100644 index 0000000000..c5ee020faf --- /dev/null +++ b/tests/unit/request-tool-identity-dotted-alias.test.ts @@ -0,0 +1,23 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { resolveRequestToolIdentity } from "../../open-sse/translator/response/openai-responses/requestToolIdentity.ts"; + +test("restores a dotted namespace alias from the request identity ledger", () => { + const identity = { namespace: "exec", name: "exec_command" }; + const identityMap = new Map([["exec__exec_command", identity]]); + + assert.deepEqual(resolveRequestToolIdentity(identityMap, "exec__exec_command"), identity); + assert.deepEqual(resolveRequestToolIdentity(identityMap, "exec.exec_command"), identity); + assert.equal(resolveRequestToolIdentity(identityMap, "other.exec_command"), null); +}); + +test("prefers an exact map entry over dotted-alias fallback", () => { + const exact = { namespace: "literal", name: "tool" }; + const identityMap = new Map([ + ["exec__exec_command", { namespace: "exec", name: "exec_command" }], + ["exec.exec_command", exact], + ]); + + assert.deepEqual(resolveRequestToolIdentity(identityMap, "exec.exec_command"), exact); +});