fix(responses): normalize non-array input (#5204)

Integrated into release/v3.8.39
This commit is contained in:
Wilson
2026-06-28 04:12:55 -03:00
committed by GitHub
parent 14f6666f33
commit 50c8a495be
9 changed files with 211 additions and 16 deletions

View File

@@ -34,6 +34,7 @@ import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer
import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts";
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
import { CORS_HEADERS } from "../utils/cors.ts";
import { normalizeCodexResponsesInput } from "../utils/responsesInputNormalization.ts";
import * as prl from "../utils/providerRequestLogging.ts";
import { createRequire } from "module";
@@ -52,11 +53,7 @@ type WreqWebSocket = {
onclose: (() => void) | null;
};
type WebsocketFn = (url: string, opts?: Record<string, unknown>) => Promise<WreqWebSocket>;
type ResponsesMessageInput = {
role?: unknown;
phase?: unknown;
content?: unknown;
};
type ResponsesMessageInput = { role?: unknown; phase?: unknown; content?: unknown };
let _websocketFn: WebsocketFn | null = null;
let _wreqChecked = false;
@@ -1327,6 +1324,8 @@ export class CodexExecutor extends BaseExecutor {
}));
}
normalizeCodexResponsesInput(body);
if (Array.isArray(body.input)) {
body.input = sanitizeResponsesInputItems(body.input, false, {
dropInternalAssistantMessages: !nativeCodexPassthrough,

View File

@@ -691,12 +691,11 @@ export class DefaultExecutor extends BaseExecutor {
// injection when `thinking` / `enable_thinking` is set. Skip injection in
// those cases instead of unconditionally adding `stream_options`.
const defaultsRecord = withDefaults as Record<string, unknown>;
const bodyDisablesStreamOptions = defaultsRecord.stream !== undefined && defaultsRecord.stream !== true;
const qwenBlocksStreamOptions =
this.provider === "qwen" &&
(defaultsRecord.stream === false ||
Boolean(defaultsRecord.thinking) ||
Boolean(defaultsRecord.enable_thinking));
if (qwenBlocksStreamOptions) {
(Boolean(defaultsRecord.thinking) || Boolean(defaultsRecord.enable_thinking));
if (bodyDisablesStreamOptions || qwenBlocksStreamOptions) {
if (Object.prototype.hasOwnProperty.call(defaultsRecord, "stream_options")) {
const withoutStreamOptions = { ...defaultsRecord };
delete withoutStreamOptions.stream_options;
@@ -705,6 +704,7 @@ export class DefaultExecutor extends BaseExecutor {
} else if (!credentials?.providerSpecificData?.disableStreamOptions) {
withDefaults = {
...withDefaults,
stream: true,
stream_options: {
...((defaultsRecord.stream_options as object) || {}),
include_usage: true,

View File

@@ -8,7 +8,7 @@ import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults";
import { FORMATS } from "../formats.ts";
import { generateToolCallId } from "../helpers/toolCallHelper.ts";
import { register } from "../registry.ts";
import { normalizeResponsesInputForChat } from "../../utils/responsesInputNormalization.ts";
type JsonRecord = Record<string, unknown>;
const RESPONSES_STORE_MARKER = "_omnirouteResponsesStore";
const COPILOT_REASONING_SUMMARY_MARKER = "_omnirouteCopilotReasoningSummary";
@@ -166,7 +166,7 @@ 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 = toArray(root.input);
const rawInputItems = normalizeResponsesInputForChat(root.input);
const inputItems: unknown[] =
rawInputItems.length === 0
? [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }]

View File

@@ -0,0 +1,94 @@
type JsonRecord = Record<string, unknown>;
function textPartTypeForRole(role: string): "input_text" | "output_text" {
return role === "assistant" ? "output_text" : "input_text";
}
function normalizeCodexMessageContentPart(part: unknown, role: string): unknown {
if (typeof part === "string") return { type: textPartTypeForRole(role), text: part };
if (!part || typeof part !== "object" || Array.isArray(part)) return part;
const record = { ...(part as JsonRecord) };
if (record.type === "text") record.type = textPartTypeForRole(role);
return record;
}
function buildCodexMessageContent(item: JsonRecord, role: string): unknown[] {
if (Array.isArray(item.content)) {
return item.content.map((part) => normalizeCodexMessageContentPart(part, role));
}
if (typeof item.content === "string") {
return [{ type: textPartTypeForRole(role), text: item.content }];
}
if (typeof item.text === "string") {
return [{ type: textPartTypeForRole(role), text: item.text }];
}
return [];
}
function normalizeCodexResponsesInputItem(itemValue: unknown): unknown {
if (typeof itemValue === "string") {
return { type: "message", role: "user", content: [{ type: "input_text", text: itemValue }] };
}
if (!itemValue || typeof itemValue !== "object" || Array.isArray(itemValue)) return itemValue;
const item = { ...(itemValue as JsonRecord) };
const role = typeof item.role === "string" ? item.role : "user";
const type = typeof item.type === "string" ? item.type : "";
if (!type && item.content === undefined && typeof item.text === "string") {
return { type: "message", role, content: [{ type: textPartTypeForRole(role), text: item.text }] };
}
if (!type && role) item.type = "message";
if (item.type === "message" || (!type && item.content !== undefined)) {
item.role = role;
item.content = buildCodexMessageContent(item, role);
item.type = "message";
}
return item;
}
export function normalizeCodexResponsesInput(body: JsonRecord): void {
if (Array.isArray(body.input)) {
body.input = body.input.map(normalizeCodexResponsesInputItem);
return;
}
// undefined → leave as-is; null → empty list (not [null], which would surface a bogus
// item downstream); anything else → wrap the single item.
if (body.input === undefined) return;
body.input = body.input === null ? [] : [normalizeCodexResponsesInputItem(body.input)];
}
function normalizeResponsesInputItemForChat(value: unknown): unknown {
if (typeof value === "string") {
return { type: "message", role: "user", content: [{ type: "input_text", text: value }] };
}
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const item = { ...(value as JsonRecord) };
const hasType = typeof item.type === "string" && item.type.length > 0;
const hasRole = typeof item.role === "string" && item.role.length > 0;
if (hasType || hasRole) {
if (!hasType && hasRole) item.type = "message";
return item;
}
if (typeof item.text === "string") {
return { type: "message", role: "user", content: [{ type: "input_text", text: item.text }] };
}
if (item.content !== undefined) return { type: "message", role: "user", content: item.content };
return item;
}
export function normalizeResponsesInputForChat(input: unknown): unknown[] {
// == null matches both undefined and null (neither is a spec-valid input) → empty list.
if (input == null) return [];
if (Array.isArray(input)) return input.map(normalizeResponsesInputItemForChat);
return [normalizeResponsesInputItemForChat(input)];
}

View File

@@ -137,13 +137,18 @@ function getConfig() {
function extractMessageContents(body) {
const contents = [];
const messages = body.messages || body.input || [];
const messageSource = body.messages !== undefined ? body.messages : body.input;
const messages = Array.isArray(messageSource)
? messageSource
: messageSource === undefined || messageSource === null
? []
: [messageSource];
for (const msg of messages) {
if (typeof msg === "string") {
contents.push(msg);
} else if (typeof msg.content === "string") {
} else if (msg && typeof msg.content === "string") {
contents.push(msg.content);
} else if (Array.isArray(msg.content)) {
} else if (msg && Array.isArray(msg.content)) {
for (const part of msg.content) {
if (typeof part === "string") {
contents.push(part);
@@ -300,7 +305,12 @@ export function sanitizeRequest(body, logger = console) {
*/
function redactBody(body) {
const clone = JSON.parse(JSON.stringify(body));
const messages = clone.messages || clone.input || [];
const messageSource = clone.messages !== undefined ? clone.messages : clone.input;
const messages = Array.isArray(messageSource)
? messageSource
: messageSource && typeof messageSource === "object"
? [messageSource]
: [];
for (const msg of messages) {
if (typeof msg.content === "string") {

View File

@@ -499,7 +499,7 @@ test("chatCore keeps Responses-native Codex payloads in native passthrough mode"
assert.equal(result.success, true);
assert.match(call.url, /\/responses$/);
assert.equal(call.body.input, "ship it");
assert.deepEqual(call.body.input, [{ type: "message", role: "user", content: [{ type: "input_text", text: "ship it" }] }]);
assert.equal(call.body.instructions, "custom system prompt");
assert.equal(call.body.store, false);
assert.deepEqual(call.body.metadata, { source: "codex-client" });

View File

@@ -19,6 +19,10 @@ test("extracts body.input as STRING without char-splitting", () => {
test("extracts body.input as array of strings", () => {
assert.ok(extractMessageContents({ input: [INJ, "y"] }).join("\n").includes(INJ));
});
test("extracts body.input as Responses object without throwing", () => {
const out = extractMessageContents({ input: { role: "user", content: INJ } }).join("\n");
assert.ok(out.includes(INJ));
});
test("extracts body.query + body.documents (rerank)", () => {
const out = extractMessageContents({ query: INJ, documents: ["doc1", "doc2"] }).join("\n");
assert.ok(out.includes(INJ) && out.includes("doc1"));

View File

@@ -5,6 +5,8 @@ const { convertResponsesApiFormat } =
await import("../../open-sse/translator/helpers/responsesApiHelper.ts");
const { openaiResponsesToOpenAIRequest, openaiToOpenAIResponsesRequest } =
await import("../../open-sse/translator/request/openai-responses.ts");
const { normalizeCodexResponsesInput, normalizeResponsesInputForChat } =
await import("../../open-sse/utils/responsesInputNormalization.ts");
test("convertResponsesApiFormat filters orphaned function_call_output items", () => {
const body = {
@@ -57,6 +59,77 @@ test("Responses→Chat: input_image converted to image_url with detail", () => {
assert.equal(imgPart.image_url.detail, "high");
});
test("Responses→Chat: string input becomes a user message instead of an empty prompt", () => {
const result = openaiResponsesToOpenAIRequest(
null,
{ model: "gpt-4", input: "Responda apenas: OK", max_output_tokens: 80 },
null,
null
);
assert.equal((result as any).input, undefined);
assert.equal((result as any).messages.length, 1);
assert.equal((result as any).messages[0].role, "user");
assert.deepEqual((result as any).messages[0].content, [
{ type: "text", text: "Responda apenas: OK" },
]);
});
test("Responses→Chat: object input becomes a single user message", () => {
const result = openaiResponsesToOpenAIRequest(
null,
{ model: "gpt-4", input: { text: "Ping" } },
null,
null
);
assert.equal((result as any).messages.length, 1);
assert.equal((result as any).messages[0].role, "user");
assert.deepEqual((result as any).messages[0].content, [{ type: "text", text: "Ping" }]);
});
test("Responses→Chat: role/content object input becomes a single user message", () => {
const result = openaiResponsesToOpenAIRequest(
null,
{ model: "gpt-4", input: { role: "user", content: "Ping" } },
null,
null
);
assert.equal((result as any).messages.length, 1);
assert.equal((result as any).messages[0].role, "user");
assert.equal((result as any).messages[0].content, "Ping");
});
test("Codex Responses input: string input becomes a list-shaped user message", () => {
const body: Record<string, unknown> = { input: "ship it" };
normalizeCodexResponsesInput(body);
assert.deepEqual(body.input, [
{ type: "message", role: "user", content: [{ type: "input_text", text: "ship it" }] },
]);
});
test("Codex Responses input: object input becomes a single item", () => {
const body: Record<string, unknown> = { input: { role: "user", text: "ship it" } };
normalizeCodexResponsesInput(body);
assert.deepEqual(body.input, [
{ type: "message", role: "user", content: [{ type: "input_text", text: "ship it" }] },
]);
});
test("Codex Responses input: null input normalizes to an empty list (not [null])", () => {
const body: Record<string, unknown> = { input: null };
normalizeCodexResponsesInput(body);
assert.deepEqual(body.input, []);
});
test("Responses→Chat: null input normalizes to an empty list (not [null])", () => {
assert.deepEqual(normalizeResponsesInputForChat(null), []);
});
test("Responses→Chat: input_image without detail omits detail field", () => {
const body = {
model: "gpt-4",

View File

@@ -34,9 +34,24 @@ test("#3884 streaming request still injects stream_options.include_usage", () =>
const executor = new DefaultExecutor("openai");
const body = { model: "gpt-4.1", messages: [{ role: "user", content: "hi" }] };
const result = executor.transformRequest("gpt-4.1", body, true, {}) as Record<string, unknown>;
assert.equal(result.stream, true);
assert.deepEqual(result.stream_options, { include_usage: true });
});
test("#3884 internal streaming strips stream_options when body explicitly disables stream", () => {
const executor = new DefaultExecutor("openai-compatible-deepseek");
const body = {
model: "deepseek-chat",
messages: [{ role: "user", content: "hi" }],
stream: false,
};
const result = executor.transformRequest("deepseek-chat", body, true, {
providerSpecificData: { baseUrl: "https://proxy.example/v1" },
}) as Record<string, unknown>;
assert.equal(result.stream, false);
assert.equal(result.stream_options, undefined);
});
test("#3884 non-streaming request without stream_options stays clean", () => {
const executor = new DefaultExecutor("openai");
const body = { model: "gpt-4.1", messages: [{ role: "user", content: "hi" }] };