mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
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:
1
changelog.d/fixes/responses-additional-tools.md
Normal file
1
changelog.d/fixes/responses-additional-tools.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
223
tests/unit/responses-additional-tools.test.ts
Normal file
223
tests/unit/responses-additional-tools.test.ts
Normal 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";
|
||||
}
|
||||
);
|
||||
});
|
||||
192
tests/unit/responses-chat-translation-gaps.test.ts
Normal file
192
tests/unit/responses-chat-translation-gaps.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user