Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ _In development — bullets added per PR; finalized at release._
- **fix(pollinations):** stop forcing `jsonMode` on every request. Pollinations treats `jsonMode=true` as "the model MUST return JSON" and rejects (HTTP 400 "messages must contain the word 'json'") any normal chat request whose messages don't mention "json", so all non-JSON chat was broken. `jsonMode` is now only enabled when the caller actually requests JSON output (`response_format.type` of `json_object` or `json_schema`). (#3981)
- **fix(antigravity):** default `safetySettings` to all-OFF for parity with the native Gemini paths. The Antigravity (Google Cloud Code) request builder set `safetySettings: undefined`, which `JSON.stringify` drops — so no safety settings reached Google and its server-side defaults false-flagged benign technical prompts as `prohibited_content` (HTTP 200 + blocked body, which combo failover treats as terminal). Now honors a caller-supplied value and otherwise defaults to `DEFAULT_SAFETY_SETTINGS`, matching the claude-to-gemini / openai-to-gemini paths (#5003)
- **fix(chatgpt-web):** map the advertised `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4-pro` and `gpt-5.2-pro` catalog ids to their dash-form ChatGPT backend slugs. They were missing from `MODEL_MAP`, so the executor sent the dot-form id verbatim, which the ChatGPT backend silently ignored and served the default Plus model instead of the requested one. Adds a drift guard asserting no advertised dot-form id reaches the backend verbatim. (#4665)
- **fix(headroom):** translate openai-responses input through OpenAI for external compression. `adaptBodyForCompression` now serialises `function_call_output` items whose `output` field is a JSON object (not a string) so compression engines can process the content — previously those items were excluded from compression because `hasTextContent()` returned false for object values. (thanks @anki1kr)

---

Expand Down
12 changes: 11 additions & 1 deletion open-sse/services/compression/bodyAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,19 @@ function responsesItemToMessage(item: ResponsesItem): MessageLike | null {
if (!RESPONSES_MESSAGE_TYPES.has(type)) return null;

if (type === "function_call_output") {
const rawOutput = item.output ?? item.content;
// OpenAI Responses shape (Codex): body.input holds Responses items. When
// output is a JSON object (not a string or content array), serialise it so
// compression engines can process the text. On restore the serialised string
// is kept as output — the Responses API accepts string output. (#1998)
const isObjectOutput =
rawOutput !== null &&
rawOutput !== undefined &&
typeof rawOutput === "object" &&
!Array.isArray(rawOutput);
return {
role: "tool",
content: toChatContent(item.output ?? item.content),
content: isObjectOutput ? JSON.stringify(rawOutput) : toChatContent(rawOutput),
};
Comment on lines +73 to 81

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In openaiResponsesToOpenAIRequest (the request translator), any non-string output for function_call_output is serialized using JSON.stringify (including arrays, numbers, booleans, etc.):

content: typeof item.output === "string" ? item.output : JSON.stringify(item.output)

However, the current implementation of isObjectOutput explicitly excludes arrays (!Array.isArray(rawOutput)).

This leads to two issues:

  1. Skipped Compression: If a tool output is a generic JSON array (e.g., ["file1.ts", "file2.ts"]), isObjectOutput will be false, and content will be the raw array. hasTextContent will then return false because it expects an array of OpenAI content parts (objects with a text property), causing the entire tool output to be silently excluded from compression.
  2. Representation Discrepancy: If the array happens to be kept, the compression engine processes it as an array, whereas the actual upstream request translator serializes it to a string, causing a mismatch in what is compressed vs. what is sent to the LLM.

We should serialize any non-string, non-null, and non-undefined rawOutput to perfectly align with the translator's behavior.

Suggested change
const isObjectOutput =
rawOutput !== null &&
rawOutput !== undefined &&
typeof rawOutput === "object" &&
!Array.isArray(rawOutput);
return {
role: "tool",
content: toChatContent(item.output ?? item.content),
content: isObjectOutput ? JSON.stringify(rawOutput) : toChatContent(rawOutput),
};
const shouldSerialize = rawOutput != null && typeof rawOutput !== "string";
return {
role: "tool",
content: shouldSerialize ? JSON.stringify(rawOutput) : toChatContent(rawOutput),
};

}

Expand Down
138 changes: 138 additions & 0 deletions tests/unit/headroom-responses-format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// #1998 (upstream) — adaptBodyForCompression must use the full translator pair
// (openaiResponsesToOpenAIRequest → compress → openaiToOpenAIResponsesRequest) when
// body.input is in Responses format, so that:
// (a) function_call_output items with non-string `output` (JSON objects) are properly
// serialised and included in the compression pass (upstream bug: simple helper skips
// them because hasTextContent() returns false for object content), and
// (b) body.input stays Responses-shaped after the full round-trip.
import { describe, it, after } from "node:test";
import assert from "node:assert/strict";
import { adaptBodyForCompression } from "../../open-sse/services/compression/bodyAdapter.ts";

describe("adaptBodyForCompression openai-responses format (#1998)", () => {
after(() => {
// No DB touched.
});

it("includes function_call_output with object output in the compression pass", () => {
// The simple responsesItemToMessage() helper only sets content = item.output.
// When output is a JSON object (not a string), hasTextContent() returns false
// and the item is excluded from messages entirely — not compressable.
// The fix: use openaiResponsesToOpenAIRequest which JSON.stringifies object output
// so the text is available for compression engines.
const body: Record<string, unknown> = {
model: "gpt-5",
input: [
{
type: "function_call",
call_id: "c1",
name: "bash",
arguments: JSON.stringify({ command: "ls -la" }),
},
{
type: "function_call_output",
call_id: "c1",
// Object output — NOT a string. Simple helper loses this.
output: { result: "file1.ts file2.ts ".repeat(50) },
},
],
};

const adapter = adaptBodyForCompression(body);

// After the fix, the adapter must recognise the function_call_output as
// compressable and include it in the messages array.
assert.ok(adapter.adapted, "adapter must be adapted (not a pass-through)");

const messages = (adapter.body as Record<string, unknown>).messages as Array<Record<string, unknown>>;
assert.ok(Array.isArray(messages) && messages.length > 0, "messages must be non-empty");

// At least one tool/assistant message must be present for compression engines.
const hasToolMsg = messages.some((m) => m.role === "tool" || m.role === "assistant");
assert.ok(hasToolMsg, "messages must include a tool/assistant entry from the function_call pair");
});

it("keeps body.input Responses-shaped after compress/restore round-trip for type:message items", () => {
// Verify the canonical behaviour: a Responses body.input with type:message items
// is correctly round-tripped through the adapter and restore.
const body: Record<string, unknown> = {
model: "gpt-5",
input: [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "a long original message ".repeat(20) }],
},
],
};

const adapter = adaptBodyForCompression(body);
assert.ok(adapter.adapted, "adapter must be adapted for Responses body.input");

// Simulate compression: keep messages unchanged (no-op compression).
const compressedBody = { ...(adapter.body as Record<string, unknown>) };
const restored = adapter.restore(compressedBody);

// body.input must be an array of Responses items (not raw OpenAI messages).
assert.ok(Array.isArray(restored.input), "restored.input must be an array");
assert.equal((restored.input as unknown[]).length, 1, "restored.input must have one item");

const item = (restored.input as Array<Record<string, unknown>>)[0];
assert.equal(item.type, "message", "restored item must keep type:message");
assert.equal(item.role, "user", "restored item must keep role:user");
assert.ok(
Array.isArray(item.content),
"restored item.content must stay as an array (Responses format), not a string"
);
// Content must still carry the Responses input_text structure.
const firstContentPart = (item.content as Array<Record<string, unknown>>)[0];
assert.equal(
firstContentPart.type,
"input_text",
"content part must preserve type:input_text"
);
assert.ok(
typeof firstContentPart.text === "string" && firstContentPart.text.length > 0,
"content part must preserve the text value"
);
});

it("round-trips function_call_output with string output (preserves call_id and output field)", () => {
// Ensure string output is correctly restored in output field (not content field).
const body: Record<string, unknown> = {
model: "gpt-5",
input: [
{
type: "function_call",
call_id: "c2",
name: "read_file",
arguments: JSON.stringify({ path: "/foo.ts" }),
},
{
type: "function_call_output",
call_id: "c2",
output: "large file contents ".repeat(30),
},
],
};

const adapter = adaptBodyForCompression(body);
assert.ok(adapter.adapted, "adapter must be adapted");

// Simulate no-op compression.
const compressedBody = { ...(adapter.body as Record<string, unknown>) };
const restored = adapter.restore(compressedBody);

assert.ok(Array.isArray(restored.input), "restored.input must be an array");
const items = restored.input as Array<Record<string, unknown>>;
assert.equal(items.length, 2, "both function_call and function_call_output must be present");

const outputItem = items.find((i) => i.type === "function_call_output") as Record<string, unknown>;
assert.ok(outputItem, "function_call_output item must be in restored.input");
assert.equal(outputItem.call_id, "c2", "call_id must be preserved");
assert.ok(
typeof outputItem.output === "string" && outputItem.output.length > 0,
"output field must be present and non-empty"
);
});
});
Loading