Skip to content

Commit 693e8a4

Browse files
committed
fix(openai): strip non-msg item ids from passthrough message input
Drop agent_message ids during user-message conversion and guard native passthrough serialization against non-msg message ids.
1 parent 80206e1 commit 693e8a4

3 files changed

Lines changed: 65 additions & 2 deletions

File tree

src/adapters/openai-responses.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@ function sanitizeReasoningInputContent(body: unknown): unknown {
5252
return changed ? { ...raw, input } : body;
5353
}
5454

55+
function stripInvalidMessageIds(body: unknown): unknown {
56+
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
57+
58+
let changed = false;
59+
const input = body.input.map(item => {
60+
if (!isPlainObject(item) || item.type !== "message") return item;
61+
if (typeof item.id === "string" && item.id.startsWith("msg_")) return item;
62+
if (!("id" in item)) return item;
63+
changed = true;
64+
const next = { ...item };
65+
delete next.id;
66+
return next;
67+
});
68+
69+
return changed ? { ...body, input } : body;
70+
}
71+
5572
/**
5673
* Replace proxy-minted compaction items (`encrypted_content` starting with `ocx1:`) with plain
5774
* user messages before forwarding to the ChatGPT backend. Our envelope is transparent base64, not
@@ -282,7 +299,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
282299
url,
283300
method: "POST",
284301
headers,
285-
body: JSON.stringify(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody)))),
302+
body: JSON.stringify(stripInvalidMessageIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody))))),
286303
};
287304
},
288305

src/server/responses.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,9 +346,10 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number {
346346
&& (child as { type?: unknown }).type === "agent_message"
347347
&& !hasEncryptedContentPart((child as { content?: unknown }).content)
348348
) {
349-
const message = child as { type: string; role?: string; author?: unknown; recipient?: unknown };
349+
const message = child as { type: string; role?: string; id?: unknown; author?: unknown; recipient?: unknown };
350350
message.type = "message";
351351
message.role = "user";
352+
delete message.id;
352353
delete message.author;
353354
delete message.recipient;
354355
}

tests/openai-responses-passthrough.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, test } from "bun:test";
22
import { createResponsesPassthroughAdapter } from "../src/adapters/openai-responses";
3+
import { sanitizeEncryptedContentInPlace } from "../src/server/responses";
34

45
const provider = {
56
adapter: "openai-responses",
@@ -8,6 +9,50 @@ const provider = {
89
};
910

1011
describe("OpenAI Responses passthrough sanitization", () => {
12+
test("agent_message conversion removes its non-OpenAI item id", () => {
13+
const input = [{
14+
type: "agent_message",
15+
id: "019f5e7f-ac31-7610-b69c-43ae41759fce",
16+
author: "/root",
17+
recipient: "/root/worker",
18+
content: [{ type: "encrypted_content", encrypted_content: "delegated task" }],
19+
}];
20+
21+
expect(sanitizeEncryptedContentInPlace(input)).toBe(1);
22+
expect(input[0]).toEqual({
23+
type: "message",
24+
role: "user",
25+
content: [{ type: "input_text", text: "delegated task" }],
26+
});
27+
expect(input[0]).not.toHaveProperty("id");
28+
});
29+
30+
test("strips only non-msg ids from serialized message input items", () => {
31+
const adapter = createResponsesPassthroughAdapter(provider);
32+
const encryptedContent = "opaque-openai-encrypted-content";
33+
const input = [
34+
{ type: "message", id: "019f5e7f-ac31-7610-b69c-43ae41759fce", role: "user", content: "first" },
35+
{ type: "message", id: "msg_abc", role: "assistant", content: "second" },
36+
{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: encryptedContent },
37+
{ type: "function_call", id: "fc_1", call_id: "call_1", name: "ping", arguments: "{}" },
38+
{ type: "function_call_output", call_id: "call_1", output: "pong" },
39+
];
40+
const request = adapter.buildRequest({
41+
modelId: "gpt-5.5",
42+
context: { messages: [] },
43+
stream: true,
44+
options: {},
45+
_rawBody: { model: "gpt-5.5", input },
46+
}, { headers: new Headers({ authorization: "Bearer token" }) });
47+
const body = JSON.parse(request.body) as { input: Record<string, unknown>[] };
48+
49+
expect(body.input[0]).not.toHaveProperty("id");
50+
expect(body.input[1].id).toBe("msg_abc");
51+
expect(body.input[2]).toMatchObject({ id: "rs_1", encrypted_content: encryptedContent });
52+
expect(body.input[3]).toMatchObject({ id: "fc_1", call_id: "call_1" });
53+
expect(body.input[4]).toMatchObject({ type: "function_call_output", call_id: "call_1" });
54+
});
55+
1156
test("drops raw reasoning input content before native GPT passthrough", () => {
1257
const adapter = createResponsesPassthroughAdapter(provider);
1358
const request = adapter.buildRequest({

0 commit comments

Comments
 (0)