diff --git a/src/proxy.mjs b/src/proxy.mjs index 7962ea0..19404bb 100644 --- a/src/proxy.mjs +++ b/src/proxy.mjs @@ -117,6 +117,42 @@ function openCompaction(value, secret) { } } +// DeepSeek deserializes the content of an input message into a closed enum — input_text, +// output_text, input_image, input_file — and rejects the entire request with a 422 on +// anything else. Codex keeps introducing block types (an inter-agent task arrives as +// `encrypted_content`), and a single unknown block takes a whole agent turn offline, so +// translate every block into something DeepSeek accepts rather than waiting to be taught +// each new type one incident at a time. +const DEEPSEEK_CONTENT_TYPES = new Set(["input_text", "output_text", "input_image", "input_file"]); + +const acceptsBlock = (block) => DEEPSEEK_CONTENT_TYPES.has(block?.type); + +function textCarriedBy(block) { + for (const value of [block.text, block.refusal, block.content]) { + if (typeof value === "string" && value.length > 0) return value; + } + return null; +} + +function contentBlockForDeepSeek(block, compactionSecret) { + if (!block || typeof block !== "object") return block; + if (acceptsBlock(block)) return block; + if (block.type === "encrypted_content") { + const value = block.encrypted_content; + if (typeof value !== "string" || value.length === 0) return null; + if (value.startsWith(COMPACTION_PREFIX)) { + const summary = openCompaction(value, compactionSecret); + return summary ? { type: "input_text", text: summary } : null; + } + // Unreadable bytes are never handed to the model as if they were prose. + if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(value)) return null; + return { type: "input_text", text: value }; + } + // An unrecognised block: keep the text it carries, drop what cannot be read as text. + const text = textCarriedBy(block); + return text === null ? null : { type: "input_text", text }; +} + function convertInputItem(item, compactionSecret) { if (!item || typeof item !== "object" || Array.isArray(item)) return item; if (item.type === "compaction") { @@ -134,31 +170,57 @@ function convertInputItem(item, compactionSecret) { } const converted = { ...item }; delete converted.id; + delete converted.internal_chat_message_metadata_passthrough; if (converted.type === "agent_message") { + // An inter-agent message is a task handed *to* this agent, not a turn the model + // produced. Replaying it as `assistant` makes DeepSeek treat it as its own prior + // thinking turn: with tools in the request it answers "The `reasoning_text` in + // the thinking mode must be passed back to the API." and the whole request fails, + // which is fatal for a freshly spawned child agent whose task message is the last + // item. `user` carries the same text without claiming prior reasoning. converted.type = "message"; - converted.role = "assistant"; + converted.role = "user"; + } + if (Array.isArray(converted.content) && converted.content.some((block) => !acceptsBlock(block))) { + converted.content = converted.content + .map((block) => contentBlockForDeepSeek(block, compactionSecret)) + .filter((block) => block != null); } return converted; } +// ChatGPT verifies every encrypted payload it is handed and fails the whole turn with +// "the encrypted content could not be verified / decrypted" when one was issued by another +// provider. That is what breaks a GPT sub-agent spawned from a DeepSeek session: the child's +// request replays history carrying DeepSeek-issued encrypted fields — and DeepSeek returns a +// non-null `encrypted_content` that is not ChatGPT ciphertext, so the old "is the field +// empty?" test never caught it. ChatGPT's own ciphertext is base64 beginning "gAAAAA", so +// that is the only encrypted payload allowed through; DSCodex-sealed compactions are unwrapped +// below; everything else is dropped rather than replayed for the upstream to reject. +const CHATGPT_SEALED_PREFIX = "gAAAAA"; +const sealedByChatGpt = (value) => + typeof value === "string" && value.startsWith(CHATGPT_SEALED_PREFIX); + // Provider-specific replay artifacts cannot be sent to ChatGPT. Keep native GPT // reasoning intact and avoid re-encoding ordinary GPT requests at all. function buildChatGptBody(body, compactionSecret) { if (!Array.isArray(body?.input)) return null; let changed = false; const input = body.input.flatMap((item) => { - if (item?.type === "reasoning" - && !item.encrypted_content - && Array.isArray(item.content) - && item.content.some((part) => part?.type === "reasoning_text")) { - changed = true; - return []; + if (item?.type === "reasoning" && !sealedByChatGpt(item.encrypted_content)) { + const foreign = item.encrypted_content != null + || (Array.isArray(item.content) && item.content.some((part) => part?.type === "reasoning_text")); + if (foreign) { + changed = true; + return []; + } } - if (item?.type === "compaction" - && typeof item.encrypted_content === "string" - && item.encrypted_content.startsWith(COMPACTION_PREFIX)) { + if (item?.type === "compaction" && !sealedByChatGpt(item.encrypted_content)) { changed = true; - const summary = openCompaction(item.encrypted_content, compactionSecret); + const summary = typeof item.encrypted_content === "string" + && item.encrypted_content.startsWith(COMPACTION_PREFIX) + ? openCompaction(item.encrypted_content, compactionSecret) + : null; return summary ? [{ type: "message", role: "assistant", content: [{ type: "output_text", text: `[Compacted prior context]\n${summary}` }], diff --git a/test/proxy.test.mjs b/test/proxy.test.mjs index 550e6a7..ed4384d 100644 --- a/test/proxy.test.mjs +++ b/test/proxy.test.mjs @@ -96,11 +96,115 @@ test("routes Flash and legacy task aliases to the current Flash model", async (t assert.equal(request.body.store, false); assert.equal("previous_response_id" in request.body, false); assert.equal("metadata" in request.body, false); - assert.deepEqual(request.body.input[0], { type: "message", role: "assistant", content: "prior answer" }); + assert.deepEqual(request.body.input[0], { type: "message", role: "user", content: "prior answer" }); assert.deepEqual(request.body.input[1], { type: "function_call_output", call_id: "call_7", output: "done" }); } }); +test("forwards an inter-agent task message as text instead of an encrypted_content block", async (t) => { + const observed = []; + const upstream = http.createServer(async (request, response) => { + observed.push(JSON.parse(await bodyOf(request))); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end("event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n"); + }); + const upstreamUrl = await listen(upstream); + const proxy = createProxyServer({ + deepSeekKey: "test-key", + deepSeekBaseUrl: upstreamUrl, + chatGptBaseUrl: upstreamUrl, + logger: { info() {}, error() {} }, + routerToken: ROUTER_TOKEN, + }); + const proxyUrl = await listen(proxy); + t.after(async () => { await close(proxy); await close(upstream); }); + + // Codex ships a task handed to another agent as a message whose payload block is + // typed `encrypted_content` even though the text is plain. DeepSeek's content enum + // only knows input_text/output_text/input_image/input_file, so forwarding the block + // unchanged fails the whole request with a 422 and the child agent never starts. + const taskText = "Message Type: NEW_TASK\nTask name: /root/child\nSender: /root\nPayload:\n"; + const payload = "reply with banana"; + const response = await fetch(route(proxyUrl), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "deepseek/deepseek-flash", + stream: true, + input: [{ + id: "amsg_1", + type: "agent_message", + author: "/root", + recipient: "/root/child", + content: [ + { type: "input_text", text: taskText }, + { type: "encrypted_content", encrypted_content: payload }, + ], + internal_chat_message_metadata_passthrough: { turn_id: "turn_1" }, + }], + }), + }); + + assert.equal(response.status, 200); + await response.text(); + const forwarded = observed.at(-1).input[0]; + assert.equal(forwarded.type, "message"); + // `user`, not `assistant`: DeepSeek rejects a replayed assistant turn without + // reasoning_text once the request carries tools, which killed every child agent. + assert.equal(forwarded.role, "user"); + assert.deepEqual(forwarded.content, [ + { type: "input_text", text: taskText }, + { type: "input_text", text: payload }, + ]); + assert.equal("internal_chat_message_metadata_passthrough" in forwarded, false); +}); + +test("never forwards a content block DeepSeek cannot deserialize", async (t) => { + const observed = []; + const upstream = http.createServer(async (request, response) => { + observed.push(JSON.parse(await bodyOf(request))); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end("event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n"); + }); + const upstreamUrl = await listen(upstream); + const proxy = createProxyServer({ + deepSeekKey: "test-key", + deepSeekBaseUrl: upstreamUrl, + chatGptBaseUrl: upstreamUrl, + logger: { info() {}, error() {} }, + routerToken: ROUTER_TOKEN, + }); + const proxyUrl = await listen(proxy); + t.after(async () => { await close(proxy); await close(upstream); }); + + // DeepSeek deserializes an input message's content into a closed enum: input_text, + // output_text, input_image, input_file. Anything else fails the entire request with a + // 422 — one unknown block type is enough to take a whole agent turn offline — so the + // router has to guarantee the enum on the way out instead of waiting to be taught each + // new block type one at a time. Known blocks must survive byte for byte. + const response = await fetch(route(proxyUrl), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "deepseek/deepseek-flash", + stream: true, + input: [ + { type: "message", role: "assistant", content: [{ type: "refusal", refusal: "I cannot help with that." }] }, + { type: "message", role: "assistant", content: [{ type: "encrypted_content", encrypted_content: null }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "go on" }] }, + ], + }), + }); + + assert.equal(response.status, 200); + await response.text(); + const input = observed.at(-1).input; + assert.deepEqual(input[0].content, [{ type: "input_text", text: "I cannot help with that." }]); + // A block that carries no recoverable text is dropped rather than guessed at. + assert.deepEqual(input[1].content, []); + assert.deepEqual(input[2].content, [{ type: "input_text", text: "go on" }]); +}); + test("adapts Codex remote compaction v2 to a DeepSeek summary and restores it on replay", async (t) => { const observed = []; const summary = "The user approved the router fix; tests and a restart are still pending."; @@ -703,10 +807,10 @@ for (const compressed of [false, true]) { const url = await listen(proxy); t.after(async () => { await close(proxy); await close(upstream); }); const retained = [ - { type: "reasoning", encrypted_content: "gpt-sealed", content: [{ type: "reasoning_text", text: "native" }] }, + { type: "reasoning", summary: [], content: [], encrypted_content: "gAAAAABmSealedByChatGptForReplay0000000000000000" }, { type: "reasoning", summary: [{ type: "summary_text", text: "keep" }] }, { type: "message", role: "user", content: "reasoning_text is literal user text" }, - { type: "compaction", encrypted_content: "gpt-compaction" }, + { type: "compaction", encrypted_content: "gAAAAABmSealedByChatGptForReplay0000000000000001" }, { type: "function_call", call_id: "call_1", name: "shell", arguments: "{}" }, { type: "function_call_output", call_id: "call_1", output: "done" }, ]; @@ -727,6 +831,44 @@ for (const compressed of [false, true]) { }); } +test("GPT replay drops encrypted payloads that another provider issued", async (t) => { + let observed; + const upstream = http.createServer(async (request, response) => { + observed = JSON.parse(await bodyOf(request)); + response.end('{}'); + }); + const proxy = createProxyServer({ chatGptBaseUrl: await listen(upstream), routerToken: ROUTER_TOKEN, logger: { info() {}, error() {} } }); + const url = await listen(proxy); + t.after(async () => { await close(proxy); await close(upstream); }); + + // ChatGPT verifies every encrypted payload it is handed. A reasoning item that came back + // from a different provider — a DeepSeek token, a DSCodex-sealed blob — cannot be verified, + // and Codex then fails the turn with "the encrypted content could not be verified / + // decrypted". That is what breaks a GPT sub-agent spawned from a DeepSeek session: the + // child's request replays the DeepSeek-flavoured history. Only ChatGPT-issued ciphertext + // (base64 "gAAAAA…") may be replayed; everything else is dropped. + const body = JSON.stringify({ + model: "gpt-5.6-sol", + input: [ + { + type: "reasoning", + summary: [], + content: [{ type: "reasoning_text", text: "deepseek thinking" }], + encrypted_content: "9591cfc5-c41a-4b44-9f51-a82a2f61d6ff-0", + }, + { type: "reasoning", summary: [], content: [], encrypted_content: "gAAAAABmSealedByChatGptForReplay0000000000000002" }, + { type: "compaction", encrypted_content: "ZGVlcHNlZWstc2VhbGVkLWJsb2I=" }, + { type: "message", role: "user", content: [{ type: "input_text", text: "go on" }] }, + ], + }); + const response = await fetch(route(url), { method: "POST", headers: { "content-type": "application/json" }, body }); + + assert.equal(response.status, 200); + await response.text(); + assert.deepEqual(observed.input.map((item) => item.type), ["reasoning", "message"]); + assert.equal(observed.input[0].encrypted_content, "gAAAAABmSealedByChatGptForReplay0000000000000002"); +}); + test("ordinary compressed GPT traffic preserves exact bytes", async (t) => { let observed; const upstream = http.createServer(async (request, response) => { @@ -926,14 +1068,14 @@ test("GPT websocket rewrite strips foreign DeepSeek reasoning_text", async (t) = type: "response.create", model: "gpt-6-astra", input: [ - { type: "reasoning", encrypted_content: "gpt-sealed", content: [{ type: "reasoning_text", text: "native" }] }, + { type: "reasoning", encrypted_content: "gAAAAABmSealedByChatGptForReplay0000000000000003", content: [{ type: "reasoning_text", text: "native" }] }, { type: "reasoning", encrypted_content: null, content: [{ type: "reasoning_text", text: "foreign" }] }, { type: "message", role: "user", content: "hi" }, ], })); await waitUntil(() => upstream.state.messages.length >= 1); assert.deepEqual(JSON.parse(upstream.state.messages[0]).input, [ - { type: "reasoning", encrypted_content: "gpt-sealed", content: [{ type: "reasoning_text", text: "native" }] }, + { type: "reasoning", encrypted_content: "gAAAAABmSealedByChatGptForReplay0000000000000003", content: [{ type: "reasoning_text", text: "native" }] }, { type: "message", role: "user", content: "hi" }, ]); });