Files
diegosouzapw--omniroute/tests/unit/translator-openai-responses-req.test.ts
2026-07-13 13:39:12 +08:00

1172 lines
36 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
const { openaiResponsesToOpenAIRequest, openaiToOpenAIResponsesRequest } =
await import("../../open-sse/translator/request/openai-responses.ts");
test("Responses -> Chat converts instructions, inputs, function calls, outputs, tools and tool_choice", () => {
const result = openaiResponsesToOpenAIRequest(
"gpt-4o",
{
instructions: "Rules",
input: [
{
type: "message",
role: "user",
content: [
{ type: "input_text", text: "Hello" },
{ type: "input_image", image_url: "https://example.com/cat.png", detail: "high" },
{ type: "input_file", file_data: "abc", filename: "doc.txt" },
],
},
{
type: "function_call",
call_id: "call_1",
name: "read_file",
arguments: { path: "/tmp/a" },
},
{
type: "function_call_output",
call_id: "call_1",
output: { ok: true },
},
],
tools: [
{
type: "function",
name: "read_file",
description: "Read",
parameters: { type: "object" },
},
],
tool_choice: { type: "function", name: "read_file" },
},
false,
null
);
assert.deepEqual((result as any).messages, [
{ role: "system", content: "Rules" },
{
role: "user",
content: [
{ type: "text", text: "Hello" },
{ type: "image_url", image_url: { url: "https://example.com/cat.png", detail: "high" } },
{ type: "file", file: { file_data: "abc", filename: "doc.txt" } },
],
},
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "read_file", arguments: '{"path":"/tmp/a"}' },
},
],
},
{ role: "tool", tool_call_id: "call_1", content: '{"ok":true}' },
]);
(assert as any).deepEqual((result as any).tools, [
{
type: "function",
function: {
name: "read_file",
description: "Read",
parameters: { type: "object" },
strict: undefined,
},
},
]);
(assert as any).deepEqual((result as any).tool_choice, {
type: "function",
function: { name: "read_file" },
});
});
test("Responses -> Chat filters orphan tool outputs and supports role-based message items", () => {
const result = openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [
{ role: "user", content: [{ type: "input_text", text: "Hello" }] },
{ type: "function_call_output", call_id: "orphan", output: "skip" },
{ type: "function_call", call_id: "call_2", name: "search", arguments: "{}" },
{ type: "function_call_output", call_id: "call_2", output: "found" },
],
},
false,
null
);
assert.equal((result as any).messages.length, 3);
assert.equal((result as any).messages[0].role, "user");
assert.equal((result as any).messages[1].tool_calls[0].id, "call_2");
(assert as any).deepEqual((result as any).messages[2], {
role: "tool",
tool_call_id: "call_2",
content: "found",
});
});
test("Responses -> Chat rejects unsupported built-in tools (non-web_search)", () => {
// file_search and code_interpreter are Responses-API-only tools with no Chat Completions
// equivalent and are not in the web_search family — they must still throw 400.
assert.throws(
() =>
openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [],
tools: [{ type: "file_search", name: "search" }],
},
false,
null
),
(error: any) => error.statusCode === 400 && error.errorType === "unsupported_feature"
);
});
test("Responses -> Chat passes through web_search_preview tool (web_search family)", () => {
// web_search_preview is OpenAI's Responses API server tool; it matches ^web_search and
// is preserved as-is rather than rejected with 400.
const result = openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [],
tools: [{ type: "web_search_preview", name: "search" }],
},
false,
null
) as Record<string, unknown>;
assert.ok(Array.isArray(result.tools), "tools array must be present");
assert.equal((result.tools as any[])[0].type, "web_search_preview");
});
test("Responses -> Chat strips background flag and degrades to synchronous execution", () => {
// Previously this threw 400 unsupported_feature. OmniRoute is a forward proxy
// and cannot host the deferred run + poll contract, so background=true is
// silently dropped and the request runs synchronously. Clients that set the
// flag opportunistically (Capy Captain Pro, Codex agents) work unchanged.
const warnLog: string[] = [];
const originalWarn = console.warn;
console.warn = (msg: unknown) => warnLog.push(String(msg));
try {
const result = openaiResponsesToOpenAIRequest(
"gpt-5.5",
{
input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }],
background: true,
},
true,
{ provider: "codex" }
);
const r = result as Record<string, unknown>;
assert.equal(r.background, undefined, "background flag must be stripped from output");
assert.ok(Array.isArray(r.messages), "translation must complete and produce messages");
assert.equal((r.messages as unknown[]).length, 1, "user message must be preserved");
assert.ok(
warnLog.some((m) => m.startsWith("BACKGROUND_DEGRADE provider=codex model=gpt-5.5")),
"BACKGROUND_DEGRADE warning log must be emitted when background=true"
);
} finally {
console.warn = originalWarn;
}
});
test("Responses -> Chat passes through when background flag is unset or false (no degrade log)", () => {
// Verifies the inverse of the degradation case: when background is absent or
// explicitly false, no warning should be emitted and the request body should
// not carry a residual background field. Guards against accidental log spam
// and confirms the degradation logic is conditional on background === true.
const warnLog: string[] = [];
const originalWarn = console.warn;
console.warn = (msg: unknown) => warnLog.push(String(msg));
try {
// Case 1: background unset
const r1 = openaiResponsesToOpenAIRequest(
"gpt-5.5",
{ input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }] },
true,
{ provider: "codex" }
) as Record<string, unknown>;
assert.equal(r1.background, undefined, "background must be absent on output (unset case)");
// Case 2: background explicitly false
const r2 = openaiResponsesToOpenAIRequest(
"gpt-5.5",
{
input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }],
background: false,
},
true,
{ provider: "codex" }
) as Record<string, unknown>;
assert.equal(r2.background, undefined, "background must be stripped on output (false case)");
assert.equal(
warnLog.filter((m) => m.startsWith("BACKGROUND_DEGRADE")).length,
0,
"BACKGROUND_DEGRADE must NOT be emitted for unset or false values"
);
} finally {
console.warn = originalWarn;
}
});
test("Responses -> Chat strips safety_identifier (LobeHub #2770)", () => {
// LobeHub sends safety_identifier in Responses API bodies. Chat Completions rejects it
// with HTTP 400. The translator must strip it in the Responses-API cleanup block.
const result = openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }],
safety_identifier: "sid-xyz",
},
false,
null
) as Record<string, unknown>;
assert.equal(
result.safety_identifier,
undefined,
"safety_identifier must be stripped before forwarding to Chat Completions"
);
assert.ok(Array.isArray(result.messages), "translation must still produce messages");
});
test("Responses -> Chat strips client_metadata (Mistral 422 fix)", () => {
// Codex CLI always sends client_metadata in Responses API requests. Mistral (and other
// strict upstreams) reject it with HTTP 422 extra_forbidden. The translator must strip
// the field in the Responses-API cleanup block so it never reaches the upstream.
const result = openaiResponsesToOpenAIRequest(
"mistral-large-latest",
{
input: [{ role: "user", content: [{ type: "input_text", text: "oi" }] }],
client_metadata: { session_id: "abc123", foo: "bar" },
},
false,
null
) as Record<string, unknown>;
assert.equal(
result.client_metadata,
undefined,
"client_metadata must be stripped before forwarding to Chat Completions"
);
assert.ok(Array.isArray(result.messages), "translation must still produce messages");
assert.equal((result.messages as unknown[]).length, 1, "user message must be preserved");
});
test("Chat -> Responses clamps call_id to 64 chars and keeps the pair matched (port from 9router#396)", () => {
// The Responses API rejects call_id values longer than 64 characters. A long
// upstream tool-call id must be clamped on BOTH the function_call and its matching
// function_call_output, identically, so the orphan filter still pairs them.
const longId = "call_" + "a".repeat(80); // 85 chars, > 64
const result = openaiToOpenAIResponsesRequest(
"gpt-4o",
{
messages: [
{
role: "assistant",
content: null,
tool_calls: [
{ id: longId, type: "function", function: { name: "read_file", arguments: "{}" } },
],
},
{ role: "tool", tool_call_id: longId, content: "ok" },
],
},
false,
null
) as any;
const input = result.input as Array<Record<string, any>>;
const fnCall = input.find((i) => i.type === "function_call");
const fnOut = input.find((i) => i.type === "function_call_output");
assert.ok(fnCall, "function_call item must exist");
assert.equal(fnCall.call_id.length, 64, "function_call call_id must be clamped to 64 chars");
assert.ok(fnOut, "function_call_output must survive the orphan filter after clamping");
assert.equal(
fnOut.call_id,
fnCall.call_id,
"output call_id must match the clamped function_call id"
);
});
test("Chat -> Responses converts messages, tool calls, tool outputs, tools and pass-through params", () => {
const result = openaiToOpenAIResponsesRequest(
"gpt-4o",
{
messages: [
{ role: "system", content: "Rules" },
{
role: "user",
content: [
{ type: "text", text: "Hello" },
{
type: "image_url",
image_url: { url: "https://example.com/cat.png", detail: "high" },
},
{ type: "file", file: { file_data: "abc", filename: "doc.txt" } },
],
},
{
role: "assistant",
content: "Done",
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "read_file", arguments: '{"path":"/tmp/a"}' },
},
],
},
{ role: "tool", tool_call_id: "call_1", content: [{ type: "text", text: "ok" }] },
],
tools: [
{
type: "function",
function: {
name: "read_file",
description: "Read",
parameters: { type: "object" },
strict: true,
},
},
],
tool_choice: { type: "function", function: { name: "read_file" } },
previous_response_id: "resp_prev_123",
temperature: 0.2,
max_tokens: 100,
top_p: 0.9,
},
false,
null
);
assert.equal((result as any).instructions, "Rules");
assert.equal((result as any).stream, true);
assert.equal((result as any).store, false);
assert.equal((result as any).previous_response_id, "resp_prev_123");
assert.deepEqual((result as any).input, [
{
type: "message",
role: "user",
content: [
{ type: "input_text", text: "Hello" },
{ type: "input_image", image_url: "https://example.com/cat.png", detail: "high" },
{ type: "input_file", file_data: "abc", filename: "doc.txt" },
],
},
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Done" }],
},
{
type: "function_call",
call_id: "call_1",
name: "read_file",
arguments: '{"path":"/tmp/a"}',
},
{
type: "function_call_output",
call_id: "call_1",
output: [{ type: "input_text", text: "ok" }],
},
]);
assert.deepEqual((result as any).tools, [
{
type: "function",
name: "read_file",
description: "Read",
parameters: { type: "object" },
strict: true,
},
]);
assert.deepEqual((result as any).tool_choice, { type: "function", name: "read_file" });
assert.equal((result as any).temperature, 0.2);
assert.equal((result as any).max_output_tokens, 100);
assert.equal((result as any).top_p, 0.9);
});
test("Chat -> Responses converts json_schema response_format to text.format", () => {
const schema = {
type: "object",
additionalProperties: false,
properties: { answer: { type: "string" } },
required: ["answer"],
};
const result = openaiToOpenAIResponsesRequest(
"gpt-5.2-codex",
{
messages: [{ role: "user", content: "Return the answer as JSON" }],
response_format: {
type: "json_schema",
json_schema: {
name: "answer_schema",
description: "A structured answer",
strict: true,
schema,
},
},
},
false,
null
) as any;
assert.deepEqual(result.text, {
format: {
type: "json_schema",
name: "answer_schema",
description: "A structured answer",
strict: true,
schema,
},
});
assert.equal(result.response_format, undefined);
});
test("Chat -> Responses uses response_format over nonstandard chat text.format", () => {
const result = openaiToOpenAIResponsesRequest(
"gpt-5.2-codex",
{
messages: [{ role: "user", content: "Return JSON" }],
text: {
format: { type: "json_schema", name: "nonstandard", schema: { type: "object" } },
verbosity: "low",
},
response_format: {
type: "json_schema",
json_schema: { name: "chat", schema: { type: "object", properties: {} } },
},
},
false,
null
) as any;
assert.deepEqual(result.text, {
format: { type: "json_schema", name: "chat", schema: { type: "object", properties: {} } },
});
});
test("Chat -> Responses ignores nonstandard chat text.format without response_format", () => {
const result = openaiToOpenAIResponsesRequest(
"gpt-5.2-codex",
{
messages: [{ role: "user", content: "Return JSON" }],
text: { format: { type: "json_schema", name: "nonstandard", schema: { type: "object" } } },
},
false,
null
) as any;
assert.equal(result.text, undefined);
});
test("Responses round-trip preserves store and previous_response_id when opt-in is enabled", () => {
const credentials = {
providerSpecificData: {
openaiStoreEnabled: true,
},
};
const chatBody = openaiResponsesToOpenAIRequest(
"gpt-4o",
{
instructions: "Rules",
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Hello" }] }],
previous_response_id: "resp_prev_store",
store: true,
},
false,
credentials
);
const result = openaiToOpenAIResponsesRequest("gpt-4o", chatBody, false, credentials);
assert.equal((result as any).previous_response_id, "resp_prev_store");
assert.equal((result as any).store, true);
assert.equal((result as any).instructions, "Rules");
});
test("Chat -> Responses converts assistant image_url history parts to output_text", () => {
const result = openaiToOpenAIResponsesRequest(
"gpt-4o",
{
messages: [
{
role: "assistant",
content: [
{ type: "text", text: "I inspected the screenshot." },
{ type: "image_url", image_url: { url: "https://example.com/scope.png" } },
],
},
],
},
true,
null
);
assert.deepEqual((result as any).input, [
{
type: "message",
role: "assistant",
content: [
{ type: "output_text", text: "I inspected the screenshot." },
{ type: "output_text", text: "[Image: https://example.com/scope.png]" },
],
},
]);
assert.equal(JSON.stringify(result).includes('"image_url"'), false);
});
test("Chat -> Responses preserves prompt_cache_key and session affinity fields", () => {
const result = openaiToOpenAIResponsesRequest(
"gpt-5.3-codex",
{
messages: [{ role: "user", content: "Hello" }],
prompt_cache_key: "cache-key-1",
session_id: "omniroute-session-abc",
conversation_id: "conv-123",
},
false,
{ providerSpecificData: { openaiStoreEnabled: true } }
);
(assert as any).equal((result as any).prompt_cache_key, "cache-key-1");
(assert as any).equal((result as any).session_id, "omniroute-session-abc");
assert.equal((result as any).conversation_id, "conv-123");
assert.equal((result as any).store, undefined);
});
test("Chat -> Responses preserves explicit reasoning objects", () => {
const result = openaiToOpenAIResponsesRequest(
"gpt-5.3-codex-spark",
{
messages: [{ role: "user", content: "Hello" }],
reasoning: { effort: "low" },
},
false,
null
);
assert.deepEqual((result as any).reasoning, { effort: "low" });
assert.equal((result as any).store, false);
});
test("Chat -> Responses propagates include so upstream streams the reasoning summary", () => {
const result = openaiToOpenAIResponsesRequest(
"gpt-5.3-codex-spark",
{
messages: [{ role: "user", content: "Hello" }],
reasoning: { effort: "high", summary: "auto" },
include: ["reasoning.encrypted_content"],
},
false,
null
);
assert.deepEqual((result as any).include, ["reasoning.encrypted_content"]);
});
test("Chat -> Responses does not inject include when caller did not set one", () => {
const result = openaiToOpenAIResponsesRequest(
"gpt-5.3-codex-spark",
{
messages: [{ role: "user", content: "Hello" }],
reasoning: { effort: "high" },
},
false,
null
);
assert.equal((result as any).include, undefined);
});
test("Chat -> Responses maps reasoning_effort into Responses reasoning", () => {
const result = openaiToOpenAIResponsesRequest(
"gpt-5.3-codex-spark",
{
messages: [{ role: "user", content: "Hello" }],
reasoning_effort: "low",
},
false,
null
);
assert.deepEqual((result as any).reasoning, { effort: "low" });
assert.equal((result as any).reasoning_effort, undefined);
assert.equal((result as any).store, false);
});
test("Chat -> Responses normalizes reasoning_effort max to xhigh", () => {
const result = openaiToOpenAIResponsesRequest(
"gpt-5.5",
{
messages: [{ role: "user", content: "Hello" }],
reasoning_effort: "max",
},
false,
null
);
assert.deepEqual((result as any).reasoning, { effort: "xhigh" });
assert.equal((result as any).reasoning_effort, undefined);
});
test("Chat -> Responses filters orphan function_call_output items and leaves empty instructions when absent", () => {
const result = openaiToOpenAIResponsesRequest(
"gpt-4o",
{
messages: [
{ role: "user", content: "Hello" },
{ role: "tool", tool_call_id: "orphan", content: "skip" },
{
role: "assistant",
tool_calls: [
{
id: "call_2",
type: "function",
function: { name: "search", arguments: "{}" },
},
],
},
{ role: "tool", tool_call_id: "call_2", content: "found" },
],
},
false,
null
);
assert.equal((result as any).instructions, "");
assert.equal(
(result as any).input.some((item) => item.call_id === "orphan"),
false
);
assert.equal(
(result as any).input.filter((item) => item.type === "function_call_output").length,
1
);
assert.equal(
(result as any).input.find((item) => item.type === "function_call_output").call_id,
"call_2"
);
});
test("Chat -> Responses maps max_completion_tokens to max_output_tokens", () => {
const result = openaiToOpenAIResponsesRequest(
"gpt-4o",
{
messages: [{ role: "user", content: "Hello" }],
max_completion_tokens: 2048,
},
false,
null
);
(assert as any).equal((result as any).max_output_tokens, 2048);
assert.equal((result as any).max_tokens, undefined);
assert.equal((result as any).max_completion_tokens, undefined);
});
test("Chat -> Responses maps legacy max_tokens to max_output_tokens when max_completion_tokens is absent", () => {
const result = openaiToOpenAIResponsesRequest(
"gpt-4o",
{
messages: [{ role: "user", content: "Hello" }],
max_tokens: 512,
},
false,
null
);
assert.equal((result as any).max_output_tokens, 512);
assert.equal((result as any).max_tokens, undefined);
});
test("Chat -> Responses prefers max_completion_tokens over max_tokens when both are present", () => {
const result = openaiToOpenAIResponsesRequest(
"gpt-4o",
{
messages: [{ role: "user", content: "Hello" }],
max_tokens: 100,
max_completion_tokens: 4096,
},
false,
null
);
(assert as any).equal((result as any).max_output_tokens, 4096);
assert.equal((result as any).max_tokens, undefined);
assert.equal((result as any).max_completion_tokens, undefined);
});
test("Responses -> Chat drops `reasoning` and promotes effort to reasoning_effort even without Copilot marker", () => {
// Updated per upstream PR decolua/9router#1817 (ryanngit): the OpenAI-native
// `reasoning_effort` hint is always preserved across the Responses -> Chat
// hop; only the Copilot-specific `summary` -> Claude marker stays gated.
const result = openaiResponsesToOpenAIRequest(
"claude-opus-4-7",
{
input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }],
reasoning: { effort: "high" },
},
true,
null
) as Record<string, unknown>;
assert.equal(result.reasoning, undefined);
assert.equal(result.reasoning_effort, "high");
});
test("Responses -> Chat promotes reasoning.effort to reasoning_effort when _copilotClient is set", () => {
const result = openaiResponsesToOpenAIRequest(
"claude-opus-4-7",
{
input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }],
reasoning: { effort: "high" },
},
true,
{ _copilotClient: true }
) as Record<string, unknown>;
assert.equal(result.reasoning, undefined);
assert.equal(result.reasoning_effort, "high");
});
test("Responses -> Chat normalizes Copilot reasoning.effort=max to xhigh", () => {
const result = openaiResponsesToOpenAIRequest(
"claude-opus-4-7",
{
input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }],
reasoning: { effort: "max" },
},
true,
{ _copilotClient: true }
) as Record<string, unknown>;
assert.equal(result.reasoning_effort, "xhigh");
});
test("Responses -> Chat keeps an explicit reasoning_effort over reasoning.effort when _copilotClient is set", () => {
const result = openaiResponsesToOpenAIRequest(
"claude-opus-4-7",
{
input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }],
reasoning: { effort: "low" },
reasoning_effort: "high",
},
true,
{ _copilotClient: true }
) as Record<string, unknown>;
assert.equal(result.reasoning_effort, "high");
});
test("Responses -> Chat ignores Copilot marker when reasoning field is absent", () => {
const result = openaiResponsesToOpenAIRequest(
"claude-opus-4-7",
{ input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }] },
true,
{ _copilotClient: true }
) as Record<string, unknown>;
assert.equal(result.reasoning_effort, undefined);
});
// --- Issue #2695: web_search tool types (Anthropic versioned names) ---
test("Responses -> Chat: web_search_20250305 tool does not throw (issue #2695)", () => {
// Claude Code sends the Anthropic versioned tool name; must NOT reject with 400.
assert.doesNotThrow(() =>
openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [{ role: "user", content: [{ type: "input_text", text: "search" }] }],
tools: [{ type: "web_search_20250305" }],
},
false,
null
)
);
});
test("Responses -> Chat: web_search_20250305 tool is preserved in output tools array", () => {
const result = openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [{ role: "user", content: [{ type: "input_text", text: "search" }] }],
tools: [{ type: "web_search_20250305" }],
},
false,
null
) as Record<string, unknown>;
const tools = result.tools as any[];
assert.ok(Array.isArray(tools), "tools array must be present");
assert.equal(tools.length, 1, "one tool must be present");
// Original versioned name is preserved so Anthropic-compatible upstreams receive what they expect.
assert.equal(tools[0].type, "web_search_20250305");
});
test("Responses -> Chat: plain web_search tool does not throw", () => {
assert.doesNotThrow(() =>
openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [{ role: "user", content: [{ type: "input_text", text: "search" }] }],
tools: [{ type: "web_search" }],
},
false,
null
)
);
});
test("Responses -> Chat: function tool still translates correctly (no regression)", () => {
const result = openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
tools: [
{
type: "function",
name: "my_func",
description: "does stuff",
parameters: { type: "object" },
},
],
},
false,
null
) as Record<string, unknown>;
const tools = result.tools as any[];
assert.ok(Array.isArray(tools), "tools array must be present");
assert.equal(tools[0].type, "function");
assert.ok(tools[0].function, "function tool must have .function property");
assert.equal(tools[0].function.name, "my_func");
});
test("Responses -> Chat: unknown tool type still throws unsupported_feature (no regression)", () => {
assert.throws(
() =>
openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [],
tools: [{ type: "unknown_tool_xyz" }],
},
false,
null
),
(error: any) => error.statusCode === 400 && error.errorType === "unsupported_feature"
);
});
// --- Issue #2766: tool_search built-in should be silently dropped ---
test("Responses -> Chat: tool_search does not throw (issue #2766)", () => {
// Codex newer clients send tool_search as a Responses API built-in.
// OmniRoute must not return 400 — it should silently drop the tool_search entry.
assert.doesNotThrow(() =>
openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [{ role: "user", content: [{ type: "input_text", text: "search" }] }],
tools: [{ type: "tool_search", name: "search" }],
},
false,
null
)
);
});
test("Responses -> Chat: tool_search is stripped from output tools array (issue #2766)", () => {
// Codex clients send tool_search alongside function tools. tool_search has no
// Chat Completions equivalent and must be dropped; function tools must remain.
const result = openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
tools: [
{ type: "tool_search", name: "search" },
{
type: "function",
name: "foo",
description: "A function",
parameters: { type: "object" },
},
],
},
false,
null
) as Record<string, unknown>;
const tools = result.tools as any[];
assert.ok(Array.isArray(tools), "tools array must be present");
assert.equal(
tools.some((t) => t.type === "tool_search"),
false,
"tool_search must be stripped from output"
);
assert.equal(tools.length, 1, "only the function tool must remain");
assert.equal(tools[0].type, "function");
assert.equal(tools[0].function.name, "foo");
});
// --- Issue #2950: image_generation built-in should be silently dropped ---
test("Responses -> Chat: image_generation does not throw (issue #2950)", () => {
// Codex Desktop injects an image_generation hosted tool into every Responses
// request, even text-only ones. It has no Chat Completions equivalent and must
// be dropped silently, not rejected with 400.
assert.doesNotThrow(() =>
openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }],
tools: [{ type: "image_generation", output_format: "png" }],
},
false,
null
)
);
});
test("Responses -> Chat: image_generation is stripped from output tools array (issue #2950)", () => {
const result = openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
tools: [
{ type: "image_generation", output_format: "png" },
{
type: "function",
name: "foo",
description: "A function",
parameters: { type: "object" },
},
],
},
false,
null
) as Record<string, unknown>;
const tools = result.tools as any[];
assert.ok(Array.isArray(tools), "tools array must be present");
assert.equal(
tools.some((t) => t.type === "image_generation"),
false,
"image_generation must be stripped from output"
);
assert.equal(tools.length, 1, "only the function tool must remain");
assert.equal(tools[0].type, "function");
assert.equal(tools[0].function.name, "foo");
});
// --- Codex CLI: local_shell built-in should be mapped to a function tool ---
test("Responses -> Chat: local_shell does not throw", () => {
assert.doesNotThrow(() =>
openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [{ role: "user", content: [{ type: "input_text", text: "pwd" }] }],
tools: [{ type: "local_shell" }],
},
false,
null
)
);
});
test("Responses -> Chat: local_shell maps to a shell function tool", () => {
const result = openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [{ role: "user", content: [{ type: "input_text", text: "pwd" }] }],
tools: [{ type: "local_shell" }],
},
false,
null
) as Record<string, unknown>;
const tools = result.tools as any[];
assert.ok(Array.isArray(tools), "tools array must be present");
assert.equal(tools.length, 1, "local_shell must be represented as one function tool");
assert.equal(tools[0].type, "function");
assert.equal(tools[0].function.name, "shell");
assert.equal(tools[0].function.parameters.type, "object");
assert.deepEqual(tools[0].function.parameters.required, ["command"]);
});
test("Responses -> Chat: local_shell tool_choice maps to shell function choice", () => {
const result = openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [{ role: "user", content: [{ type: "input_text", text: "pwd" }] }],
tools: [{ type: "local_shell" }],
tool_choice: { type: "local_shell" },
},
false,
null
) as Record<string, unknown>;
assert.deepEqual(result.tool_choice, { type: "function", function: { name: "shell" } });
});
test("Chat -> Responses: shell function stays caller-side and does not leak local_shell", () => {
const result = openaiToOpenAIResponsesRequest(
"gpt-4o",
{
messages: [{ role: "user", content: "pwd" }],
tools: [
{
type: "function",
function: {
name: "shell",
description: "Run a shell command",
parameters: { type: "object" },
},
},
],
tool_choice: { type: "function", function: { name: "shell" } },
},
false,
null
) as Record<string, unknown>;
assert.equal((result.tools as any[])[0].type, "function");
assert.equal((result.tools as any[])[0].name, "shell");
assert.equal((result.tools as any[])[0].description, "Run a shell command");
assert.deepEqual((result.tools as any[])[0].parameters, { type: "object" });
assert.deepEqual(result.tool_choice, { type: "function", name: "shell" });
});
// --- Issue #2893: orphaned tool results from empty/missing call_id ---
test("Responses -> Chat: function_call with empty call_id is dropped together with its output (issue #2893)", () => {
const result = openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
{ type: "function_call", name: "read", call_id: "", arguments: "{}" },
{ type: "function_call_output", call_id: "", output: "result" },
],
},
false,
null
) as Record<string, unknown>;
const messages = result.messages as any[];
assert.equal(
messages.some((m) => m.role === "tool"),
false,
"tool result with empty tool_call_id must be dropped"
);
const danglingEmptyId = messages.some(
(m) =>
m.role === "assistant" &&
Array.isArray(m.tool_calls) &&
m.tool_calls.some((tc: any) => !tc.id)
);
assert.equal(danglingEmptyId, false, "assistant tool_call with empty id must be dropped");
});
test("Responses -> Chat: function_call with empty name leaves no orphan tool output (issue #2893)", () => {
const result = openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [
{ type: "function_call", name: "", call_id: "c-orphan", arguments: "{}" },
{ type: "function_call_output", call_id: "c-orphan", output: "result" },
],
},
false,
null
) as Record<string, unknown>;
const messages = result.messages as any[];
assert.equal(
messages.some((m) => m.role === "tool"),
false,
"an output whose function_call was skipped (empty name) must not survive as an orphan"
);
});
test("Responses -> Chat: a valid function_call/output pair is preserved (issue #2893 regression)", () => {
const result = openaiResponsesToOpenAIRequest(
"gpt-4o",
{
input: [
{ type: "function_call", name: "read", call_id: "c1", arguments: "{}" },
{ type: "function_call_output", call_id: "c1", output: "result" },
],
},
false,
null
) as Record<string, unknown>;
const messages = result.messages as any[];
const assistant = messages.find((m) => m.role === "assistant" && Array.isArray(m.tool_calls));
assert.ok(assistant, "assistant message with tool_calls must be present");
assert.equal(assistant.tool_calls[0].id, "c1");
const toolMsg = messages.find((m) => m.role === "tool");
assert.ok(toolMsg, "matching tool result must be preserved");
assert.equal(toolMsg.tool_call_id, "c1");
});
// --- AI SDK image content part (#1330) ---
test("Chat -> Responses converts AI SDK image content part to input_image", () => {
// AI SDK emits image parts as { type: "image", image: "data:...;base64,..." }
// rather than the OpenAI { type: "image_url", image_url: { url } } shape. The
// Responses translator must forward them as input_image (#1330).
const imageUrl = "data:image/png;base64,iVBORw0KGgo=";
const result = openaiToOpenAIResponsesRequest(
"gpt-5.2",
{
messages: [
{
role: "user",
content: [
{ type: "text", text: "Describe this image" },
{ type: "image", image: imageUrl, detail: "high" },
],
},
],
},
true,
{}
) as Record<string, unknown>;
const input = result.input as any[];
assert.deepEqual(input[0].content, [
{ type: "input_text", text: "Describe this image" },
{ type: "input_image", image_url: imageUrl, detail: "high" },
]);
});
test("Chat -> Responses defaults AI SDK image detail to auto", () => {
const imageUrl = "data:image/jpeg;base64,/9j/4AAQ=";
const result = openaiToOpenAIResponsesRequest(
"gpt-5.2",
{ messages: [{ role: "user", content: [{ type: "image", image: imageUrl }] }] },
true,
{}
) as Record<string, unknown>;
const input = result.input as any[];
assert.deepEqual(input[0].content[0], {
type: "input_image",
image_url: imageUrl,
detail: "auto",
});
});