Skip to content

Commit 90cbbee

Browse files
committed
fix: harden ChatGPT Responses missing content-type streams
Send SSE Accept headers for native ChatGPT/Codex Responses stream requests, including the transport-aware alias path. Only normalize missing Content-Type responses for the native HTTPS ChatGPT/Codex backend after the body sniffs as SSE. Keep JSON, HTML, unknown bodies, and non-native providers fail-closed.
1 parent bf19d19 commit 90cbbee

4 files changed

Lines changed: 216 additions & 47 deletions

File tree

src/agents/openai-transport-stream.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,50 @@ describe("openai transport stream", () => {
593593
version: "2026.3.22",
594594
"User-Agent": "openclaw/2026.3.22",
595595
});
596+
expect(headers.Accept).toBeUndefined();
597+
expect(headers.accept).toBeUndefined();
598+
});
599+
600+
it("adds SSE Accept only to native ChatGPT/Codex Responses stream requests", () => {
601+
const codexModel = {
602+
id: "gpt-5.5",
603+
name: "GPT-5.5",
604+
api: "openai-chatgpt-responses",
605+
provider: "openai",
606+
baseUrl: "https://chatgpt.com/backend-api/codex",
607+
reasoning: true,
608+
input: ["text"],
609+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
610+
contextWindow: 400000,
611+
maxTokens: 128000,
612+
} satisfies Model<"openai-chatgpt-responses">;
613+
const transportAliasModel = {
614+
...codexModel,
615+
api: "openclaw-openai-responses-transport" as Api,
616+
} satisfies Model;
617+
const nonNativeChatGPTModel = {
618+
...codexModel,
619+
baseUrl: "https://api.openai.com/v1",
620+
} satisfies Model<"openai-chatgpt-responses">;
621+
const openAIModel = {
622+
...codexModel,
623+
api: "openai-responses",
624+
baseUrl: "https://api.openai.com/v1",
625+
} satisfies Model<"openai-responses">;
626+
627+
expect(testing.buildOpenAISdkRequestOptions(codexModel, undefined, { stream: true })).toEqual({
628+
headers: { Accept: "text/event-stream" },
629+
});
630+
expect(
631+
testing.buildOpenAISdkRequestOptions(transportAliasModel, undefined, { stream: true }),
632+
).toEqual({ headers: { Accept: "text/event-stream" } });
633+
expect(testing.buildOpenAISdkRequestOptions(codexModel)).toBeUndefined();
634+
expect(
635+
testing.buildOpenAISdkRequestOptions(nonNativeChatGPTModel, undefined, { stream: true }),
636+
).toBeUndefined();
637+
expect(
638+
testing.buildOpenAISdkRequestOptions(openAIModel, undefined, { stream: true }),
639+
).toBeUndefined();
596640
});
597641

598642
it("moves Azure OpenAI completions api-version headers into default query params", () => {

src/agents/openai-transport-stream.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1846,12 +1846,18 @@ function buildOpenAISdkClientOptions(model: Model): { timeout?: number } {
18461846
function buildOpenAISdkRequestOptions(
18471847
model: Model,
18481848
signal?: AbortSignal,
1849-
): { signal?: AbortSignal; timeout?: number } | undefined {
1849+
options?: { stream?: boolean },
1850+
): { signal?: AbortSignal; timeout?: number; headers?: Record<string, string> } | undefined {
18501851
const timeout = resolveOpenAISdkTimeoutMs(model);
1851-
if (timeout === undefined && !signal) {
1852+
const headers =
1853+
options?.stream === true && usesNativeOpenAICodexResponsesBackend(model)
1854+
? { Accept: "text/event-stream" }
1855+
: undefined;
1856+
if (timeout === undefined && !signal && !headers) {
18521857
return undefined;
18531858
}
18541859
return {
1860+
...(headers ? { headers } : {}),
18551861
...(signal ? { signal } : {}),
18561862
...(timeout !== undefined ? { timeout } : {}),
18571863
};
@@ -1938,7 +1944,9 @@ export function createOpenAIResponsesTransportStreamFn(): StreamFn {
19381944
assertCodeModeResponsesToolSurface(params);
19391945
}
19401946
const requestStartedAt = Date.now();
1941-
const requestOptions = buildOpenAISdkRequestOptions(model, options?.signal);
1947+
const requestOptions = buildOpenAISdkRequestOptions(model, options?.signal, {
1948+
stream: true,
1949+
});
19421950
emitModelTransportDebug(
19431951
log,
19441952
`[responses] start provider=${model.provider} api=${model.api} model=${model.id} ` +
@@ -2078,7 +2086,7 @@ function isNativeOpenAICodexResponsesBaseUrl(baseUrl?: string): boolean {
20782086
}
20792087
try {
20802088
const url = new URL(trimmed);
2081-
if (url.protocol !== "http:" && url.protocol !== "https:") {
2089+
if (url.protocol !== "https:") {
20822090
return false;
20832091
}
20842092
if (url.hostname.toLowerCase() !== "chatgpt.com") {

src/agents/provider-transport-fetch.test.ts

Lines changed: 119 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -242,34 +242,40 @@ describe("buildGuardedModelFetch", () => {
242242
expect(release).toHaveBeenCalled();
243243
});
244244

245-
it("allows missing content-type when streamed OpenAI-compatible responses contain SSE", async () => {
246-
fetchWithSsrFGuardMock.mockResolvedValue({
247-
response: new Response(responseStreamText('data: {"ok": true}\n\ndata: [DONE]\n\n')),
248-
finalUrl: "https://chatgpt.com/backend-api/codex/responses",
249-
release: vi.fn(async () => undefined),
250-
});
251-
const model = {
252-
id: "gpt-5.5",
253-
provider: "openai",
254-
api: "openclaw-openai-responses-transport",
255-
baseUrl: "https://chatgpt.com/backend-api/codex",
256-
} as unknown as Model<"openai-responses">;
245+
it.each([
246+
["direct", "openai-chatgpt-responses"],
247+
["transport alias", "openclaw-openai-responses-transport"],
248+
] as const)(
249+
"allows missing content-type when native ChatGPT/Codex Responses streams contain SSE through %s API",
250+
async (_label, api) => {
251+
fetchWithSsrFGuardMock.mockResolvedValue({
252+
response: new Response(responseStreamText('data: {"ok": true}\n\ndata: [DONE]\n\n')),
253+
finalUrl: "https://chatgpt.com/backend-api/codex/responses",
254+
release: vi.fn(async () => undefined),
255+
});
256+
const model = {
257+
id: "gpt-5.5",
258+
provider: "openai",
259+
api,
260+
baseUrl: "https://chatgpt.com/backend-api/codex",
261+
} as unknown as Model<"openai-responses">;
257262

258-
const response = await buildGuardedModelFetch(model)(
259-
"https://chatgpt.com/backend-api/codex/responses",
260-
{
261-
method: "POST",
262-
headers: { "content-type": "application/json" },
263-
body: JSON.stringify({ model: "gpt-5.5", stream: true }),
264-
},
265-
);
266-
const items = [];
267-
for await (const item of Stream.fromSSEResponse(response, new AbortController())) {
268-
items.push(item);
269-
}
263+
const response = await buildGuardedModelFetch(model)(
264+
"https://chatgpt.com/backend-api/codex/responses",
265+
{
266+
method: "POST",
267+
headers: { "content-type": "application/json" },
268+
body: JSON.stringify({ model: "gpt-5.5", stream: true }),
269+
},
270+
);
271+
const items = [];
272+
for await (const item of Stream.fromSSEResponse(response, new AbortController())) {
273+
items.push(item);
274+
}
270275

271-
expect(items).toEqual([{ ok: true }]);
272-
});
276+
expect(items).toEqual([{ ok: true }]);
277+
},
278+
);
273279

274280
it("returns promptly for missing content-type SSE streams that remain open", async () => {
275281
const source = openResponseStreamText('data: {"ok": true}\n\n');
@@ -341,11 +347,12 @@ describe("buildGuardedModelFetch", () => {
341347
expect(items).toEqual([{ ok: true }]);
342348
});
343349

344-
it("synthesizes SSE for missing content-type JSON returned to streaming SDK requests", async () => {
350+
it("rejects missing content-type JSON-like bodies returned to native ChatGPT/Codex streaming SDK requests", async () => {
351+
const release = vi.fn(async () => undefined);
345352
fetchWithSsrFGuardMock.mockResolvedValue({
346353
response: new Response(responseStreamText('{"ok": true}')),
347354
finalUrl: "https://chatgpt.com/backend-api/codex/responses",
348-
release: vi.fn(async () => undefined),
355+
release,
349356
});
350357
const model = {
351358
id: "gpt-5.5",
@@ -354,21 +361,94 @@ describe("buildGuardedModelFetch", () => {
354361
baseUrl: "https://chatgpt.com/backend-api/codex",
355362
} as unknown as Model<"openai-responses">;
356363

357-
const response = await buildGuardedModelFetch(model)(
358-
"https://chatgpt.com/backend-api/codex/responses",
359-
{
364+
await expect(
365+
buildGuardedModelFetch(model)("https://chatgpt.com/backend-api/codex/responses", {
360366
method: "POST",
361367
headers: { "content-type": "application/json" },
362368
body: JSON.stringify({ model: "gpt-5.5", stream: true }),
363-
},
364-
);
365-
const items = [];
366-
for await (const item of Stream.fromSSEResponse(response, new AbortController())) {
367-
items.push(item);
368-
}
369+
}),
370+
).rejects.toMatchObject({
371+
name: "ProviderHttpError",
372+
status: 200,
373+
code: "invalid_provider_content_type",
374+
errorType: "invalid_response",
375+
});
376+
expect(release).toHaveBeenCalled();
377+
});
369378

370-
expect(response.headers.get("content-type")).toContain("text/event-stream");
371-
expect(items).toEqual([{ ok: true }]);
379+
it.each([
380+
["non-native provider", "openrouter", "openai-responses", "https://openrouter.ai/api/v1"],
381+
[
382+
"wrong base URL",
383+
"openai",
384+
"openclaw-openai-responses-transport",
385+
"https://api.openai.com/v1",
386+
],
387+
[
388+
"insecure native URL",
389+
"openai",
390+
"openclaw-openai-responses-transport",
391+
"http://chatgpt.com/backend-api/codex",
392+
],
393+
] as const)(
394+
"rejects missing content-type SSE outside native ChatGPT/Codex Responses: %s",
395+
async (_label, provider, api, baseUrl) => {
396+
const release = vi.fn(async () => undefined);
397+
const model = {
398+
id: "gpt-5.4",
399+
provider,
400+
api,
401+
baseUrl,
402+
} as unknown as Model<"openai-responses">;
403+
fetchWithSsrFGuardMock.mockResolvedValue({
404+
response: new Response(responseStreamText('data: {"ok": true}\n\ndata: [DONE]\n\n')),
405+
finalUrl: `${baseUrl.replace(/\/+$/u, "")}/responses`,
406+
release,
407+
});
408+
409+
await expect(
410+
buildGuardedModelFetch(model)(`${baseUrl.replace(/\/+$/u, "")}/responses`, {
411+
method: "POST",
412+
headers: { "content-type": "application/json" },
413+
body: JSON.stringify({ model: "gpt-5.4", stream: true }),
414+
}),
415+
).rejects.toMatchObject({
416+
name: "ProviderHttpError",
417+
status: 200,
418+
code: "invalid_provider_content_type",
419+
errorType: "invalid_response",
420+
});
421+
expect(release).toHaveBeenCalled();
422+
},
423+
);
424+
425+
it("rejects missing content-type streamed OpenAI-compatible responses with unknown bodies", async () => {
426+
const release = vi.fn(async () => undefined);
427+
const model = {
428+
id: "gpt-5.5",
429+
provider: "openai",
430+
api: "openclaw-openai-responses-transport",
431+
baseUrl: "https://chatgpt.com/backend-api/codex",
432+
} as unknown as Model<"openai-responses">;
433+
fetchWithSsrFGuardMock.mockResolvedValue({
434+
response: new Response(responseStreamText("not-sse")),
435+
finalUrl: "https://chatgpt.com/backend-api/codex/responses",
436+
release,
437+
});
438+
439+
await expect(
440+
buildGuardedModelFetch(model)("https://chatgpt.com/backend-api/codex/responses", {
441+
method: "POST",
442+
headers: { "content-type": "application/json" },
443+
body: JSON.stringify({ model: "gpt-5.5", stream: true }),
444+
}),
445+
).rejects.toMatchObject({
446+
name: "ProviderHttpError",
447+
status: 200,
448+
code: "invalid_provider_content_type",
449+
errorType: "invalid_response",
450+
});
451+
expect(release).toHaveBeenCalled();
372452
});
373453

374454
it("rejects missing content-type streamed OpenAI-compatible responses with HTML bodies", async () => {

src/agents/provider-transport-fetch.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,46 @@ function isOpenAISdkStreamContentType(contentType: string): boolean {
234234
return /\btext\/event-stream\b/i.test(contentType) || isJsonContentType(contentType);
235235
}
236236

237+
function isOpenAIChatGPTResponsesStreamModel(model: Model): boolean {
238+
return (
239+
model.provider === "openai" &&
240+
(model.api === "openai-chatgpt-responses" ||
241+
model.api === "openclaw-openai-responses-transport")
242+
);
243+
}
244+
245+
function isNativeOpenAIChatGPTResponsesBaseUrl(baseUrl?: string): boolean {
246+
const trimmed = typeof baseUrl === "string" ? baseUrl.trim() : "";
247+
if (!trimmed) {
248+
return false;
249+
}
250+
try {
251+
const url = new URL(trimmed);
252+
if (url.protocol !== "https:") {
253+
return false;
254+
}
255+
if (url.hostname.toLowerCase() !== "chatgpt.com") {
256+
return false;
257+
}
258+
const pathname = url.pathname.replace(/\/+$/u, "").toLowerCase();
259+
return [
260+
"/backend-api",
261+
"/backend-api/v1",
262+
"/backend-api/codex",
263+
"/backend-api/codex/v1",
264+
].includes(pathname);
265+
} catch {
266+
return false;
267+
}
268+
}
269+
270+
function allowsMissingOpenAISdkStreamContentType(model: Model): boolean {
271+
return (
272+
isOpenAIChatGPTResponsesStreamModel(model) &&
273+
isNativeOpenAIChatGPTResponsesBaseUrl(model.baseUrl)
274+
);
275+
}
276+
237277
type OpenAISdkStreamBodyKind = "html" | "json" | "sse" | "unknown";
238278

239279
function classifyOpenAISdkStreamBodyPrefix(text: string): OpenAISdkStreamBodyKind {
@@ -311,16 +351,13 @@ async function normalizeOpenAISdkStreamContentType(params: {
311351
if (!params.response.ok || !params.response.body || isOpenAISdkStreamContentType(contentType)) {
312352
return params.response;
313353
}
314-
if (!contentType.trim()) {
354+
if (!contentType.trim() && allowsMissingOpenAISdkStreamContentType(params.model)) {
315355
// ChatGPT Codex can stream valid SSE with no content-type header. Sniff a
316356
// clone so the SDK still receives the original body once we normalize it.
317357
const kind = await classifyOpenAISdkStreamBody(params.response).catch(() => "unknown" as const);
318358
if (kind === "sse") {
319359
return withOpenAISdkStreamContentType(params.response, "text/event-stream; charset=utf-8");
320360
}
321-
if (kind === "json") {
322-
return withOpenAISdkStreamContentType(params.response, "application/json; charset=utf-8");
323-
}
324361
}
325362
const body = await readResponseTextLimited(params.response).catch(() => "");
326363
await params.release().catch(() => undefined);

0 commit comments

Comments
 (0)