From d4d11357abc4b70e98dc7fbf6c5d4215321d6c5a Mon Sep 17 00:00:00 2001 From: Ankit Date: Fri, 26 Jun 2026 11:08:44 +0700 Subject: [PATCH] fix(headroom): translate openai-responses input through OpenAI for compression Codex (openai-responses) body.input holds Responses items, not OpenAI messages. Translate input -> OpenAI -> compress -> back to input so the Responses contract is preserved. Fixes #1998 Co-authored-by: Cursor --- open-sse/rtk/headroom.js | 24 ++++++++++ tests/unit/headroom-responses-format.test.js | 50 ++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 tests/unit/headroom-responses-format.test.js diff --git a/open-sse/rtk/headroom.js b/open-sse/rtk/headroom.js index df81ee42..1dd7abea 100644 --- a/open-sse/rtk/headroom.js +++ b/open-sse/rtk/headroom.js @@ -1,5 +1,9 @@ import { claudeToOpenAIRequest } from "../translator/request/claude-to-openai.js"; import { openaiToClaudeRequest } from "../translator/request/openai-to-claude.js"; +import { + openaiResponsesToOpenAIRequest, + openaiToOpenAIResponsesRequest, +} from "../translator/request/openai-responses.js"; const DEFAULT_TIMEOUT_MS = 3000; @@ -135,6 +139,26 @@ export async function compressWithHeadroom(body, { enabled, url, model, format, return data; } + // OpenAI Responses shape (Codex): body.input holds Responses items, NOT OpenAI + // messages. Translate input -> OpenAI -> compress -> translate back to input so + // body.input keeps the Responses contract (the proxy only understands OpenAI). (#1998) + if (format === "openai-responses") { + const oai = openaiResponsesToOpenAIRequest(model, body, false); + if (!Array.isArray(oai?.messages)) return null; + const data = await callCompress(url, oai.messages, model, timeoutMs, compressUserMessages, diagnostics || {}); + if (!data) return null; + // input: undefined so the translator rebuilds input from the compressed + // messages instead of returning the original input unchanged. + const responsesBody = openaiToOpenAIResponsesRequest( + model, + { ...oai, input: undefined, messages: data.messages }, + false + ); + if (Array.isArray(responsesBody?.input)) body.input = responsesBody.input; + if (diagnostics) diagnostics.after = captureSizeSnapshot(body); + return data; + } + // OpenAI shape: messages/input go straight to the proxy. const key = Array.isArray(body.messages) ? "messages" : Array.isArray(body.input) ? "input" diff --git a/tests/unit/headroom-responses-format.test.js b/tests/unit/headroom-responses-format.test.js new file mode 100644 index 00000000..0050a731 --- /dev/null +++ b/tests/unit/headroom-responses-format.test.js @@ -0,0 +1,50 @@ +// #1998 — Headroom compression treated a Codex (openai-responses) body.input +// array as OpenAI messages: it sent Responses items to /v1/compress and then +// assigned the returned OpenAI messages back to body.input, violating the +// Responses format contract. body.input must stay Responses-shaped. +import { describe, it, expect, vi, afterEach } from "vitest"; +import { compressWithHeadroom } from "../../open-sse/rtk/headroom.js"; + +describe("compressWithHeadroom openai-responses format (#1998)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("keeps body.input in Responses format after compressing an openai-responses request", async () => { + // Headroom always returns compressed OpenAI-style messages. + global.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + messages: [{ role: "user", content: "compressed text" }], + tokens_before: 100, + tokens_after: 90, + tokens_saved: 10, + }), + })); + + const body = { + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "a long original message ".repeat(20) }], + }, + ], + }; + + const data = await compressWithHeadroom(body, { + enabled: true, + url: "http://headroom.test", + model: "gpt-5", + format: "openai-responses", + }); + + expect(data).not.toBeNull(); + // body.input must remain Responses items (type:"message" + content array), + // NOT the raw OpenAI messages ({ role, content: "" }) the bug produced. + expect(Array.isArray(body.input)).toBe(true); + expect(body.input[0]).toMatchObject({ type: "message", role: "user" }); + expect(Array.isArray(body.input[0].content)).toBe(true); + expect(typeof body.input[0].content).not.toBe("string"); + }); +});