Preserve supported Responses behavior in Chat translation (#7894)

* fix(responses): preserve additional_tools when downgrading to Chat Completions

* fix: preserve Responses structured output in Chat translation

* fix: translate Responses allowed tools to Chat

* fix: reject unsupported Responses input items

* fix: normalize Responses refusal history for Chat

* fix: strip Responses-only fields from Chat requests

* fix: preserve namespace tools with colliding function names

* fix: merge same-named namespaces during Chat translation
This commit is contained in:
Jan Leon
2026-07-20 22:53:11 +02:00
committed by GitHub
parent 65e0aeda79
commit 4f52e36082
5 changed files with 601 additions and 7 deletions

View File

@@ -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.

View File

@@ -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<string>();
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;
}

View File

@@ -0,0 +1,96 @@
type JsonRecord = Record<string, unknown>;
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<string>();
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<string>();
const namespaceIndexes = new Map<string, number>();
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;
}

View File

@@ -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";
}
);
});

View File

@@ -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<string, unknown>): Record<string, unknown> {
return openaiResponsesToOpenAIRequest("gpt-5", body, false, null) as Record<string, unknown>;
}
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);
});