diff --git a/changelog.d/fixes/responses-additional-tools.md b/changelog.d/fixes/responses-additional-tools.md new file mode 100644 index 0000000000..a340625de7 --- /dev/null +++ b/changelog.d/fixes/responses-additional-tools.md @@ -0,0 +1 @@ +- **fix(responses):** Make Responses API to Chat Completions downgrades provider-neutral and loss-aware. Tools declared through `additional_tools` are preserved, structured output and `allowed_tools` are translated, refusal history is normalized, unsupported input item types fail explicitly instead of disappearing, and Responses-only execution/cache fields no longer leak to strict Chat endpoints. diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 7141d9f946..b6d7679cc6 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -8,6 +8,7 @@ import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults"; import { FORMATS } from "../formats.ts"; import { register } from "../registry.ts"; import { normalizeResponsesInputForChat } from "../../utils/responsesInputNormalization.ts"; +import { collectResponsesTools } from "./openai-responses/additionalTools.ts"; import { openaiToOpenAIResponsesRequest } from "./openai-responses/toResponses.ts"; import { JsonRecord, @@ -17,7 +18,6 @@ import { TOOL_SEARCH_TOOL_TYPES, IMAGE_GENERATION_TOOL_TYPES, toRecord, - toArray, toString, normalizeVerbosity, normalizeResponsesReasoningEffort, @@ -46,9 +46,12 @@ export function openaiResponsesToOpenAIRequest( if (root.input === undefined) return body; const credentialRecord = toRecord(credentials); const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData); + const rawInputItems = normalizeResponsesInputForChat(root.input); - // Validate tool types — only function tools can be translated to Chat Completions - const tools = toArray(root.tools); + // Tools may be declared at the Responses top level or in one or more + // `additional_tools` input items. Normalize both forms before validation/conversion so + // every downgraded provider receives the same available tool set. + const tools = collectResponsesTools(root.tools, rawInputItems); if (tools.length > 0) { for (const toolValue of tools) { const tool = toRecord(toolValue); @@ -101,6 +104,20 @@ export function openaiResponsesToOpenAIRequest( // `text` object (a strict Chat endpoint 400s on unknown fields). const responsesVerbosity = normalizeVerbosity(toRecord(result.text).verbosity); if (responsesVerbosity && isOpenAIDestination) result.verbosity = responsesVerbosity; + const responsesTextFormat = toRecord(toRecord(result.text).format); + if (responsesTextFormat.type === "json_schema" && responsesTextFormat.schema !== undefined) { + const jsonSchema: JsonRecord = { + name: toString(responsesTextFormat.name, "response"), + schema: responsesTextFormat.schema, + }; + if (responsesTextFormat.description !== undefined) { + jsonSchema.description = responsesTextFormat.description; + } + if (responsesTextFormat.strict !== undefined) jsonSchema.strict = responsesTextFormat.strict; + result.response_format = { type: "json_schema", json_schema: jsonSchema }; + } else if (responsesTextFormat.type === "json_object") { + result.response_format = { type: "json_object" }; + } delete result.text; // background: true requests a deferred Responses API run (the upstream @@ -138,7 +155,6 @@ export function openaiResponsesToOpenAIRequest( // Upstream providers reject messages:[] with "400: at least one message is required". // When the client sends input:[] (empty), inject a placeholder user message — mirrors // upstream 9router#419 (and the existing empty-string handling elsewhere in this file). - const rawInputItems = normalizeResponsesInputForChat(root.input); const inputItems: unknown[] = rawInputItems.length === 0 ? [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }] @@ -175,6 +191,9 @@ export function openaiResponsesToOpenAIRequest( if (contentItem.type === "output_text") { return { type: "text", text: toString(contentItem.text) }; } + if (contentItem.type === "refusal") { + return { type: "text", text: toString(contentItem.refusal) }; + } if (contentItem.type === "input_image") { const imgResult: JsonRecord = { type: "image_url", @@ -330,6 +349,15 @@ export function openaiResponsesToOpenAIRequest( // Skip reasoning items - they are display-only metadata continue; } + + if (itemType === "additional_tools") { + // Already consumed by collectResponsesTools() before message conversion. + continue; + } + + throw unsupportedFeature( + `Unsupported Responses API feature: input item type '${itemType || "missing"}' cannot be represented in Chat Completions` + ); } // Flush remainder @@ -343,8 +371,8 @@ export function openaiResponsesToOpenAIRequest( } // Convert tools format - if (Array.isArray(root.tools)) { - result.tools = root.tools + if (tools.length > 0) { + result.tools = tools .filter((toolValue) => { const tool = toRecord(toolValue); const toolType = toString(tool.type); @@ -519,7 +547,55 @@ export function openaiResponsesToOpenAIRequest( result.tool_choice = { type: "function", function: { name: tc.name } }; } else if (tcType === "local_shell") { result.tool_choice = { type: "function", function: { name: "shell" } }; - } else if (tcType && tcType !== "function" && tcType !== "allowed_tools") { + } else if (tcType === "allowed_tools") { + const mode = toString(tc.mode); + if (mode !== "auto" && mode !== "required") { + throw unsupportedFeature( + `Unsupported Responses API feature: allowed_tools mode '${mode || "missing"}' is not supported by omniroute` + ); + } + if (!Array.isArray(tc.tools) || tc.tools.length === 0) { + throw unsupportedFeature( + "Unsupported Responses API feature: allowed_tools requires at least one function tool" + ); + } + + const allowedNames = new Set(); + for (const allowedValue of tc.tools) { + const allowed = toRecord(allowedValue); + const allowedType = toString(allowed.type); + const allowedName = toString(allowed.name).trim(); + if (allowedType !== "function" || !allowedName) { + throw unsupportedFeature( + `Unsupported Responses API feature: allowed_tools descriptor type '${allowedType || "missing"}' cannot be represented in Chat Completions` + ); + } + allowedNames.add(allowedName); + } + + const chatTools = Array.isArray(result.tools) ? result.tools : []; + const availableNames = new Set( + chatTools + .map((toolValue) => toString(toRecord(toRecord(toolValue).function).name)) + .filter(Boolean) + ); + const missingNames = [...allowedNames].filter((name) => !availableNames.has(name)); + if (missingNames.length > 0) { + throw unsupportedFeature( + `Unsupported Responses API feature: allowed_tools references unavailable function tool(s): ${missingNames.join(", ")}` + ); + } + + result.tools = chatTools.filter((toolValue) => + allowedNames.has(toString(toRecord(toRecord(toolValue).function).name)) + ); + if (result.tools.length === 0) { + throw unsupportedFeature( + "Unsupported Responses API feature: allowed_tools resolved to zero Chat Completions function tools" + ); + } + result.tool_choice = mode; + } else if (tcType && tcType !== "function") { // Built-in tool types (web_search_preview, file_search, etc.) have no Chat equivalent throw unsupportedFeature( `Unsupported Responses API feature: tool_choice type '${tcType}' is not supported by omniroute` @@ -573,6 +649,12 @@ export function openaiResponsesToOpenAIRequest( // Completions equivalent. Strict non-OpenAI upstreams (e.g. NVIDIA NIM) reject // it with HTTP 400 "Unsupported parameter(s): truncation" (#2311). delete result.truncation; + // These fields configure Responses-owned state, caching, and tool execution limits. + // Chat Completions has no equivalent and strict compatible endpoints reject them. + delete result.max_tool_calls; + delete result.conversation; + delete result.prompt_cache_options; + delete result.prompt_cache_retention; return result; } diff --git a/open-sse/translator/request/openai-responses/additionalTools.ts b/open-sse/translator/request/openai-responses/additionalTools.ts new file mode 100644 index 0000000000..50cb27004f --- /dev/null +++ b/open-sse/translator/request/openai-responses/additionalTools.ts @@ -0,0 +1,96 @@ +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toolName(value: unknown): string { + const tool = toRecord(value); + const nestedFunction = toRecord(tool.function); + return typeof tool.name === "string" && tool.name.trim() + ? tool.name.trim() + : typeof nestedFunction.name === "string" && nestedFunction.name.trim() + ? nestedFunction.name.trim() + : ""; +} + +function toolIdentity(value: unknown): string | null { + const tool = toRecord(value); + const name = toolName(value); + if (!name) return null; + + // Namespace names are only containers. The Chat conversion flattens their nested + // tools, so they do not collide with a top-level function of the same name. + return tool.type === "namespace" ? `namespace:${name}` : `name:${name}`; +} + +function mergeNamespaceTools(first: unknown, second: unknown): unknown[] { + const merged: unknown[] = []; + const seenNames = new Set(); + + for (const source of [first, second]) { + if (!Array.isArray(source)) continue; + for (const tool of source) { + const name = toolName(tool); + if (name && seenNames.has(name)) continue; + if (name) seenNames.add(name); + merged.push(tool); + } + } + + return merged; +} + +/** + * Collect all Responses tool declarations before downgrading to Chat Completions. + * + * Most clients use the top-level `tools` array. Newer agent clients may instead add one or + * more `{ type: "additional_tools", tools: [...] }` input items so tool availability can be + * changed alongside the conversation transcript. Both forms describe tools available for + * the current response and therefore share the same downstream conversion path. + * + * Explicit top-level declarations take precedence on named collisions. Same-named namespaces are + * merged before deduplication because the Chat conversion flattens their members. Unnamed hosted + * tools are kept verbatim because their type can be repeated with distinct provider-specific + * configuration. This keeps the established request contract stable and prevents duplicate + * function names from reaching strict upstreams. + */ +export function collectResponsesTools(rootTools: unknown, inputItems: unknown[]): unknown[] { + const sources: unknown[][] = [Array.isArray(rootTools) ? rootTools : []]; + + for (const itemValue of inputItems) { + const item = toRecord(itemValue); + if (item.type === "additional_tools" && Array.isArray(item.tools)) { + sources.push(item.tools); + } + } + + const merged: unknown[] = []; + const seen = new Set(); + const namespaceIndexes = new Map(); + for (const source of sources) { + for (const tool of source) { + const toolRecord = toRecord(tool); + if (toolRecord.type === "namespace") { + const namespaceName = toolName(tool); + const existingIndex = namespaceName ? namespaceIndexes.get(namespaceName) : undefined; + if (existingIndex !== undefined) { + const existing = toRecord(merged[existingIndex]); + merged[existingIndex] = { + ...toolRecord, + ...existing, + tools: mergeNamespaceTools(existing.tools, toolRecord.tools), + }; + continue; + } + if (namespaceName) namespaceIndexes.set(namespaceName, merged.length); + } + + const identity = toolIdentity(tool); + if (identity && seen.has(identity)) continue; + if (identity) seen.add(identity); + merged.push(tool); + } + } + return merged; +} diff --git a/tests/unit/responses-additional-tools.test.ts b/tests/unit/responses-additional-tools.test.ts new file mode 100644 index 0000000000..517b1e94e0 --- /dev/null +++ b/tests/unit/responses-additional-tools.test.ts @@ -0,0 +1,223 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiResponsesToOpenAIRequest } = + await import("../../open-sse/translator/request/openai-responses.ts"); + +interface ChatTool { + function: { + name: string; + description?: string; + parameters?: unknown; + }; +} + +interface ChatRequest { + messages: unknown[]; + tools: ChatTool[]; +} + +test("Responses -> Chat merges additional_tools into the universal tool conversion path", () => { + const result = openaiResponsesToOpenAIRequest( + "any-model", + { + input: [ + { + type: "additional_tools", + role: "developer", + tools: [ + { + type: "custom", + name: "exec", + description: "Run orchestration code", + format: { type: "grammar", syntax: "lark", definition: "start: /.+/" }, + }, + { + type: "function", + name: "wait", + description: "Wait for a running operation", + parameters: { + type: "object", + properties: { cell_id: { type: "string" } }, + required: ["cell_id"], + }, + }, + { + type: "namespace", + name: "collaboration", + tools: [ + { + name: "spawn_agent", + description: "Spawn an agent", + parameters: { type: "object", properties: {} }, + }, + ], + }, + ], + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Use the tools" }], + }, + ], + tool_choice: "auto", + }, + true, + { provider: "any-openai-compatible-provider" } + ) as ChatRequest; + + assert.deepEqual(result.messages, [ + { role: "user", content: [{ type: "text", text: "Use the tools" }] }, + ]); + assert.deepEqual( + result.tools.map((tool) => tool.function?.name), + ["exec", "wait", "spawn_agent"] + ); + assert.deepEqual(result.tools[0].function.parameters, { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + additionalProperties: false, + }); +}); + +test("Responses -> Chat merges multiple tool sources and keeps top-level declarations on conflict", () => { + const topLevel = { + type: "function", + name: "lookup", + description: "Authoritative top-level declaration", + parameters: { type: "object", properties: { id: { type: "string" } } }, + }; + const result = openaiResponsesToOpenAIRequest( + "any-model", + { + input: [ + { + type: "additional_tools", + tools: [ + { ...topLevel, description: "Conflicting deferred declaration" }, + { type: "function", name: "first", parameters: { type: "object" } }, + ], + }, + { + type: "additional_tools", + tools: [{ type: "function", name: "second", parameters: { type: "object" } }], + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }, + ], + tools: [topLevel], + }, + false, + { provider: "another-provider" } + ) as ChatRequest; + + assert.deepEqual( + result.tools.map((tool) => tool.function.name), + ["lookup", "first", "second"] + ); + assert.equal(result.tools[0].function.description, "Authoritative top-level declaration"); +}); + +test("Responses -> Chat preserves a namespace that shares a name with a function", () => { + const result = openaiResponsesToOpenAIRequest( + "any-model", + { + input: [ + { + type: "additional_tools", + tools: [ + { + type: "namespace", + name: "server", + tools: [ + { + name: "mcp__server__read", + parameters: { type: "object", properties: {} }, + }, + ], + }, + ], + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }, + ], + tools: [{ type: "function", name: "server", parameters: { type: "object" } }], + }, + false, + { provider: "another-provider" } + ) as ChatRequest; + + assert.deepEqual( + result.tools.map((tool) => tool.function.name), + ["server", "mcp__server__read"] + ); +}); + +test("Responses -> Chat merges members from same-named namespaces", () => { + const result = openaiResponsesToOpenAIRequest( + "any-model", + { + input: [ + { + type: "additional_tools", + tools: [ + { + type: "namespace", + name: "server", + tools: [ + { + name: "mcp__server__write", + parameters: { type: "object", properties: {} }, + }, + ], + }, + ], + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }, + ], + tools: [ + { + type: "namespace", + name: "server", + tools: [ + { + name: "mcp__server__read", + parameters: { type: "object", properties: {} }, + }, + ], + }, + ], + }, + false, + { provider: "another-provider" } + ) as ChatRequest; + + assert.deepEqual( + result.tools.map((tool) => tool.function.name), + ["mcp__server__read", "mcp__server__write"] + ); +}); + +test("Responses -> Chat validates tools supplied through additional_tools", () => { + assert.throws( + () => + openaiResponsesToOpenAIRequest( + "any-model", + { + input: [ + { + type: "additional_tools", + tools: [{ type: "file_search", name: "search" }], + }, + { type: "message", role: "user", content: "hi" }, + ], + }, + false, + { provider: "any-provider" } + ), + (error: unknown) => { + const typedError = error as { statusCode?: number; errorType?: string }; + return typedError.statusCode === 400 && typedError.errorType === "unsupported_feature"; + } + ); +}); diff --git a/tests/unit/responses-chat-translation-gaps.test.ts b/tests/unit/responses-chat-translation-gaps.test.ts new file mode 100644 index 0000000000..47ed3785d9 --- /dev/null +++ b/tests/unit/responses-chat-translation-gaps.test.ts @@ -0,0 +1,192 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiResponsesToOpenAIRequest } = + await import("../../open-sse/translator/request/openai-responses.ts"); + +function translate(body: Record): Record { + return openaiResponsesToOpenAIRequest("gpt-5", body, false, null) as Record; +} + +test("Responses -> Chat preserves json_schema structured output", () => { + const result = translate({ + input: "Return JSON", + text: { + format: { + type: "json_schema", + name: "answer", + description: "Structured answer", + schema: { type: "object", properties: { answer: { type: "string" } } }, + strict: true, + }, + }, + }); + + assert.deepEqual(result.response_format, { + type: "json_schema", + json_schema: { + name: "answer", + description: "Structured answer", + schema: { type: "object", properties: { answer: { type: "string" } } }, + strict: true, + }, + }); + assert.equal(result.text, undefined); +}); + +test("Responses -> Chat preserves json_object structured output", () => { + const result = translate({ input: "Return JSON", text: { format: { type: "json_object" } } }); + + assert.deepEqual(result.response_format, { type: "json_object" }); + assert.equal(result.text, undefined); +}); + +test("Responses -> Chat restricts tools selected by allowed_tools", () => { + const result = translate({ + input: "Use one tool", + tools: [ + { type: "function", name: "keep", parameters: { type: "object" } }, + { type: "function", name: "remove", parameters: { type: "object" } }, + ], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "function", name: "keep" }], + }, + }); + + assert.equal(result.tool_choice, "required"); + assert.deepEqual( + (result.tools as Array<{ function: { name: string } }>).map((tool) => tool.function.name), + ["keep"] + ); +}); + +test("Responses -> Chat resolves allowed_tools against flattened namespace tools", () => { + const result = translate({ + input: "Use a namespaced tool", + tools: [ + { + type: "namespace", + name: "server", + tools: [{ name: "mcp__server__read", parameters: { type: "object" } }], + }, + ], + tool_choice: { + type: "allowed_tools", + mode: "auto", + tools: [{ type: "function", name: "mcp__server__read" }], + }, + }); + + assert.equal(result.tool_choice, "auto"); + assert.equal( + (result.tools as Array<{ function: { name: string } }>)[0].function.name, + "mcp__server__read" + ); +}); + +test("Responses -> Chat rejects malformed or unavailable allowed_tools", () => { + assert.throws( + () => + translate({ + input: "Use a tool", + tools: [{ type: "function", name: "available", parameters: { type: "object" } }], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "function", name: "missing" }], + }, + }), + (error: unknown) => + error instanceof Error && + (error as Error & { errorType?: string }).errorType === "unsupported_feature" + ); + + assert.throws( + () => + translate({ + input: "Use a tool", + tools: [{ type: "function", name: "available", parameters: { type: "object" } }], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "web_search", name: "available" }], + }, + }), + (error: unknown) => + error instanceof Error && + (error as Error & { errorType?: string }).errorType === "unsupported_feature" + ); +}); + +test("Responses -> Chat rejects input item types without a lossless Chat equivalent", () => { + for (const item of [ + { type: "item_reference", id: "item_123" }, + { type: "computer_call_output", call_id: "call_1", output: {} }, + { type: "mcp_call", name: "remote", arguments: "{}" }, + { type: "web_search_call", id: "search_1" }, + { unexpected: true }, + ]) { + assert.throws( + () => translate({ input: [item] }), + (error: unknown) => + error instanceof Error && + (error as Error & { errorType?: string }).errorType === "unsupported_feature" && + error.message.includes("input item type") + ); + } +}); + +test("Responses -> Chat consumes additional_tools input items without emitting messages", () => { + const result = translate({ + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "Use it" }] }, + { + type: "additional_tools", + tools: [{ type: "function", name: "extra", parameters: { type: "object" } }], + }, + ], + }); + + assert.equal((result.messages as unknown[]).length, 1); + assert.equal((result.tools as Array<{ function: { name: string } }>)[0].function.name, "extra"); +}); + +test("Responses -> Chat converts refusal history to valid Chat text content", () => { + const result = translate({ + input: [ + { + type: "message", + role: "assistant", + content: [{ type: "refusal", refusal: "I cannot help with that." }], + }, + ], + }); + + assert.deepEqual(result.messages, [ + { + role: "assistant", + content: [{ type: "text", text: "I cannot help with that." }], + }, + ]); +}); + +test("Responses -> Chat strips Responses-only execution and cache fields", () => { + const result = translate({ + input: "Hello", + max_tool_calls: 3, + conversation: "conv_123", + prompt_cache_options: { retention: "24h" }, + prompt_cache_retention: "24h", + metadata: { keep: true }, + parallel_tool_calls: true, + }); + + assert.equal(result.max_tool_calls, undefined); + assert.equal(result.conversation, undefined); + assert.equal(result.prompt_cache_options, undefined); + assert.equal(result.prompt_cache_retention, undefined); + assert.deepEqual(result.metadata, { keep: true }); + assert.equal(result.parallel_tool_calls, true); +});