mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
fix(responses): normalize non-array input (#5204)
Integrated into release/v3.8.39
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "..." }] }]
|
||||
|
||||
94
open-sse/utils/responsesInputNormalization.ts
Normal file
94
open-sse/utils/responsesInputNormalization.ts
Normal 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)];
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }] };
|
||||
|
||||
Reference in New Issue
Block a user