From 50c8a495be9f150014dd369f137bfaa6eafa0cfd Mon Sep 17 00:00:00 2001 From: Wilson Date: Sun, 28 Jun 2026 04:12:55 -0300 Subject: [PATCH] fix(responses): normalize non-array input (#5204) Integrated into release/v3.8.39 --- open-sse/executors/codex.ts | 9 +- open-sse/executors/default.ts | 8 +- .../translator/request/openai-responses.ts | 4 +- open-sse/utils/responsesInputNormalization.ts | 94 +++++++++++++++++++ src/shared/utils/inputSanitizer.ts | 18 +++- tests/unit/chatcore-translation-paths.test.ts | 2 +- .../guardrails/injection-extraction.test.ts | 4 + .../unit/responses-translation-fixes.test.ts | 73 ++++++++++++++ .../stream-options-nonstreaming-3884.test.ts | 15 +++ 9 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 open-sse/utils/responsesInputNormalization.ts diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 172fa1a987..9cb167df9d 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -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) => Promise; -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, diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index eb60f95fa5..88788207b5 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -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; + 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, diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 979c92bfcf..004d204819 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -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; 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: "..." }] }] diff --git a/open-sse/utils/responsesInputNormalization.ts b/open-sse/utils/responsesInputNormalization.ts new file mode 100644 index 0000000000..da1517a812 --- /dev/null +++ b/open-sse/utils/responsesInputNormalization.ts @@ -0,0 +1,94 @@ +type JsonRecord = Record; + +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)]; +} diff --git a/src/shared/utils/inputSanitizer.ts b/src/shared/utils/inputSanitizer.ts index 0bed9b3c8c..fdfe8af507 100644 --- a/src/shared/utils/inputSanitizer.ts +++ b/src/shared/utils/inputSanitizer.ts @@ -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") { diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 263debfd60..c3ada3a628 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -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" }); diff --git a/tests/unit/guardrails/injection-extraction.test.ts b/tests/unit/guardrails/injection-extraction.test.ts index 2e1f0d1652..28c72b951d 100644 --- a/tests/unit/guardrails/injection-extraction.test.ts +++ b/tests/unit/guardrails/injection-extraction.test.ts @@ -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")); diff --git a/tests/unit/responses-translation-fixes.test.ts b/tests/unit/responses-translation-fixes.test.ts index 7d163f2673..a155d39d63 100644 --- a/tests/unit/responses-translation-fixes.test.ts +++ b/tests/unit/responses-translation-fixes.test.ts @@ -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 = { 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 = { 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 = { 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", diff --git a/tests/unit/stream-options-nonstreaming-3884.test.ts b/tests/unit/stream-options-nonstreaming-3884.test.ts index e7551766d6..7e3b5b5859 100644 --- a/tests/unit/stream-options-nonstreaming-3884.test.ts +++ b/tests/unit/stream-options-nonstreaming-3884.test.ts @@ -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; + 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; + 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" }] };