mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 22:32:22 +03:00
fix(providers): convert agent_message input items for non-Codex-native Responses upstreams (#13698)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
- **fix(providers):** the shared Responses-API input sanitizer now converts Codex's proprietary
|
||||
`agent_message` input items (used for multi-agent task/reply passing) into a plain `message`
|
||||
item before forwarding to any non-Codex-native Responses upstream. Previously such items
|
||||
reached third-party Responses endpoints untouched, and OpenCode Go Muse Spark 1.3 rejected the
|
||||
request with `input[N] did not match any supported type` (#13698). The real Codex/ChatGPT
|
||||
native passthrough path is unaffected and continues to receive `agent_message` items as-is.
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
withCodexFingerprintCredentials,
|
||||
} from "../config/codexIdentity.ts";
|
||||
import { getAccessToken } from "../services/tokenRefresh.ts";
|
||||
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
|
||||
import { sanitizeCodexResponsesInput } from "../services/responsesInputSanitizer.ts";
|
||||
import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts";
|
||||
import { getForcedReasoningEffort } from "../utils/reasoningRuleContext.ts";
|
||||
import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts";
|
||||
@@ -1306,11 +1306,7 @@ export class CodexExecutor extends BaseExecutor {
|
||||
|
||||
normalizeCodexResponsesInput(body);
|
||||
|
||||
if (Array.isArray(body.input)) {
|
||||
body.input = sanitizeResponsesInputItems(body.input, false, {
|
||||
dropInternalAssistantMessages: !nativeCodexPassthrough,
|
||||
});
|
||||
}
|
||||
sanitizeCodexResponsesInput(body, nativeCodexPassthrough);
|
||||
stripOrphanedCodexFunctionCallOutputs(body);
|
||||
repairMissingCodexToolCallOutputs(body);
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@ import { isValidResponsesItemId } from "./responsesItemId.ts";
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type SanitizeResponsesInputOptions = {
|
||||
dropInternalAssistantMessages?: boolean;
|
||||
// Codex's multi_agent_v2 uses a proprietary `agent_message` input-item type to pass
|
||||
// tasks/replies between a parent thread and a sub-agent. The real Codex/ChatGPT backend
|
||||
// understands this type; every other Responses-API upstream (e.g. Muse Spark 1.3 /
|
||||
// opencode-go) does not and rejects the request with `input[N] did not match any
|
||||
// supported type` (#13698). Set true only for the native Codex/ChatGPT passthrough path.
|
||||
preserveAgentMessages?: boolean;
|
||||
};
|
||||
const INTERNAL_ASSISTANT_PHASES = new Set(["commentary"]);
|
||||
const SERVER_ITEM_ID_PREFIX_BY_TYPE: Record<string, string> = {
|
||||
@@ -141,11 +147,67 @@ function sanitizeOutputContent(record: JsonRecord): JsonRecord {
|
||||
return { ...record, output };
|
||||
}
|
||||
|
||||
function sanitizeInputItem(item: unknown): unknown {
|
||||
function isAgentMessageInputItem(record: JsonRecord): boolean {
|
||||
return record.type === "agent_message" || record.role === "agent_message";
|
||||
}
|
||||
|
||||
function agentMessageContextText(record: JsonRecord): string {
|
||||
return `[Agent message context] ${JSON.stringify({ author: record.author, recipient: record.recipient })}`;
|
||||
}
|
||||
|
||||
function agentMessageContentPart(part: unknown): unknown {
|
||||
const record = toRecord(part);
|
||||
if (!record) return { type: "input_text", text: typeof part === "string" ? part : "" };
|
||||
|
||||
if (record.type === "encrypted_content") {
|
||||
// No plaintext to forward for an encrypted part -- surface a placeholder instead of
|
||||
// leaking an opaque payload or silently dropping the item.
|
||||
return { type: "input_text", text: "[Agent message encrypted content omitted]" };
|
||||
}
|
||||
|
||||
if (record.type === "output_text" || record.type === "text") {
|
||||
return { type: "input_text", text: typeof record.text === "string" ? record.text : "" };
|
||||
}
|
||||
|
||||
if (record.type === "image_url" || record.type === "input_image") {
|
||||
return sanitizeContentPart(record, "user");
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
function convertAgentMessageItem(record: JsonRecord): JsonRecord {
|
||||
const contextPart = { type: "input_text", text: agentMessageContextText(record) };
|
||||
const content = record.content;
|
||||
|
||||
let bodyParts: unknown[];
|
||||
if (typeof content === "string") {
|
||||
bodyParts = [{ type: "input_text", text: content }];
|
||||
} else if (Array.isArray(content)) {
|
||||
bodyParts = content.map((part) =>
|
||||
typeof part === "string" ? { type: "input_text", text: part } : agentMessageContentPart(part)
|
||||
);
|
||||
} else {
|
||||
bodyParts = [{ type: "input_text", text: JSON.stringify(content ?? "") }];
|
||||
}
|
||||
|
||||
return {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [contextPart, ...bodyParts],
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeInputItem(item: unknown, options: SanitizeResponsesInputOptions): unknown {
|
||||
const record = toRecord(item);
|
||||
if (!record) return item;
|
||||
|
||||
let next = sanitizeInputItemId(record);
|
||||
let next = record;
|
||||
if (!options.preserveAgentMessages && isAgentMessageInputItem(next)) {
|
||||
next = convertAgentMessageItem(next);
|
||||
}
|
||||
|
||||
next = sanitizeInputItemId(next);
|
||||
if (isResponsesMessageItem(next)) {
|
||||
next = sanitizeMessageContent(next);
|
||||
}
|
||||
@@ -175,8 +237,22 @@ export function sanitizeResponsesInputItems(
|
||||
}
|
||||
|
||||
const cloned = clone ? structuredClone(item) : item;
|
||||
sanitized.push(sanitizeInputItem(cloned));
|
||||
sanitized.push(sanitizeInputItem(cloned, options));
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// Codex-specific call site (#13698): the native Codex/ChatGPT passthrough path is the only
|
||||
// caller that needs both flags derived from one boolean, kept here (not in the frozen
|
||||
// open-sse/executors/codex.ts) so a per-property change never grows that file's line count.
|
||||
export function sanitizeCodexResponsesInput(
|
||||
body: Record<string, unknown>,
|
||||
nativeCodexPassthrough: boolean
|
||||
): void {
|
||||
if (!Array.isArray(body.input)) return;
|
||||
body.input = sanitizeResponsesInputItems(body.input, false, {
|
||||
dropInternalAssistantMessages: !nativeCodexPassthrough,
|
||||
preserveAgentMessages: nativeCodexPassthrough,
|
||||
});
|
||||
}
|
||||
|
||||
136
tests/unit/responses-input-sanitizer-agent-message.test.ts
Normal file
136
tests/unit/responses-input-sanitizer-agent-message.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { sanitizeResponsesInputItems } from "../../open-sse/services/responsesInputSanitizer.ts";
|
||||
|
||||
// #13698: OpenCode Go Muse Spark 1.3 rejects Codex's proprietary `agent_message` input-item
|
||||
// type on its Responses endpoint with `input[N] did not match any supported type`. The shared
|
||||
// sanitizer must convert `agent_message` items into a plain `message` item for every
|
||||
// non-Codex-native Responses upstream, while leaving the real Codex/ChatGPT native passthrough
|
||||
// path untouched (it understands `agent_message` natively).
|
||||
|
||||
test("#13698: converts a type=agent_message item into a supported message item, preserving context", () => {
|
||||
const input = [
|
||||
{
|
||||
type: "agent_message",
|
||||
author: "worker",
|
||||
recipient: "main",
|
||||
content: [{ type: "input_text", text: "Synthetic worker result: 2+2=4." }],
|
||||
},
|
||||
];
|
||||
|
||||
const [item] = sanitizeResponsesInputItems(input, false) as Array<Record<string, unknown>>;
|
||||
|
||||
assert.equal(item.type, "message");
|
||||
assert.equal(item.role, "user");
|
||||
const content = item.content as Array<Record<string, unknown>>;
|
||||
assert.equal(content.length, 2);
|
||||
assert.equal(content[0].type, "input_text");
|
||||
assert.match(content[0].text as string, /\[Agent message context\]/);
|
||||
assert.match(content[0].text as string, /"author":"worker"/);
|
||||
assert.match(content[0].text as string, /"recipient":"main"/);
|
||||
assert.deepEqual(content[1], { type: "input_text", text: "Synthetic worker result: 2+2=4." });
|
||||
});
|
||||
|
||||
test("#13698: converts a role=agent_message item (no explicit type) the same way", () => {
|
||||
const input = [
|
||||
{ role: "agent_message", author: "a", recipient: "b", content: "plain string body" },
|
||||
];
|
||||
|
||||
const [item] = sanitizeResponsesInputItems(input, false) as Array<Record<string, unknown>>;
|
||||
|
||||
assert.equal(item.type, "message");
|
||||
assert.equal(item.role, "user");
|
||||
const content = item.content as Array<Record<string, unknown>>;
|
||||
assert.equal(content.length, 2);
|
||||
assert.deepEqual(content[1], { type: "input_text", text: "plain string body" });
|
||||
});
|
||||
|
||||
test("#13698: string content is flattened into one input_text part", () => {
|
||||
const input = [{ type: "agent_message", content: "hello sub-agent" }];
|
||||
|
||||
const [item] = sanitizeResponsesInputItems(input, false) as Array<Record<string, unknown>>;
|
||||
const content = item.content as Array<Record<string, unknown>>;
|
||||
assert.equal(content.length, 2);
|
||||
assert.deepEqual(content[1], { type: "input_text", text: "hello sub-agent" });
|
||||
});
|
||||
|
||||
test("#13698: encrypted_content parts become a placeholder, not leaked plaintext or a dropped item", () => {
|
||||
const input = [
|
||||
{
|
||||
type: "agent_message",
|
||||
content: [{ type: "encrypted_content", data: "opaque-ciphertext" }],
|
||||
},
|
||||
];
|
||||
|
||||
const [item] = sanitizeResponsesInputItems(input, false) as Array<Record<string, unknown>>;
|
||||
const content = item.content as Array<Record<string, unknown>>;
|
||||
assert.equal(content.length, 2);
|
||||
assert.equal(content[1].type, "input_text");
|
||||
assert.doesNotMatch(content[1].text as string, /opaque-ciphertext/);
|
||||
});
|
||||
|
||||
test("#13698: image_url / input_image parts are preserved via the existing content-part sanitizer", () => {
|
||||
const input = [
|
||||
{
|
||||
type: "agent_message",
|
||||
content: [{ type: "image_url", image_url: { url: "https://example.com/x.png" } }],
|
||||
},
|
||||
];
|
||||
|
||||
const [item] = sanitizeResponsesInputItems(input, false) as Array<Record<string, unknown>>;
|
||||
const content = item.content as Array<Record<string, unknown>>;
|
||||
assert.equal(content.length, 2);
|
||||
assert.equal(content[1].type, "input_image");
|
||||
assert.equal(content[1].image_url, "https://example.com/x.png");
|
||||
});
|
||||
|
||||
test("#13698: non-array/non-string content falls back to a JSON-stringified input_text part", () => {
|
||||
const input = [{ type: "agent_message", content: { weird: true } }];
|
||||
|
||||
const [item] = sanitizeResponsesInputItems(input, false) as Array<Record<string, unknown>>;
|
||||
const content = item.content as Array<Record<string, unknown>>;
|
||||
assert.equal(content.length, 2);
|
||||
assert.equal(content[1].type, "input_text");
|
||||
assert.equal(content[1].text, JSON.stringify({ weird: true }));
|
||||
});
|
||||
|
||||
test("#13698: preserveAgentMessages:true leaves the item untouched (native Codex/ChatGPT passthrough)", () => {
|
||||
const input = [
|
||||
{
|
||||
type: "agent_message",
|
||||
author: "worker",
|
||||
recipient: "main",
|
||||
content: [{ type: "input_text", text: "task payload" }],
|
||||
},
|
||||
];
|
||||
|
||||
const [item] = sanitizeResponsesInputItems(input, false, {
|
||||
preserveAgentMessages: true,
|
||||
}) as Array<Record<string, unknown>>;
|
||||
|
||||
assert.deepEqual(item, input[0]);
|
||||
});
|
||||
|
||||
test("#13698: conversion is idempotent -- sanitizing the converted output again is a no-op", () => {
|
||||
const input = [
|
||||
{
|
||||
type: "agent_message",
|
||||
author: "worker",
|
||||
recipient: "main",
|
||||
content: [{ type: "input_text", text: "task payload" }],
|
||||
},
|
||||
];
|
||||
|
||||
const once = sanitizeResponsesInputItems(input, false) as Array<Record<string, unknown>>;
|
||||
const twice = sanitizeResponsesInputItems(once, false) as Array<Record<string, unknown>>;
|
||||
assert.deepEqual(once, twice);
|
||||
});
|
||||
|
||||
test("#13698: default sanitizeResponsesInputItems call sites (base/github executors) still convert", () => {
|
||||
// Simulates the base executor / GitHub executor call sites, which pass no third argument
|
||||
// and therefore must pick up the new default (convert) -- this is the OpenCode Go / Muse
|
||||
// Spark 1.3 path from the issue.
|
||||
const input = [{ type: "agent_message", content: "sub-agent reply" }];
|
||||
const [item] = sanitizeResponsesInputItems(input, false) as Array<Record<string, unknown>>;
|
||||
assert.equal(item.type, "message");
|
||||
});
|
||||
Reference in New Issue
Block a user