From 0cf65d2bf7cad7a58437a6c8a9172f29dbc43ea7 Mon Sep 17 00:00:00 2001 From: Lucas Meijer Date: Tue, 6 Oct 2026 10:01:19 +0200 Subject: [PATCH] fix(ai): let caller headers override Codex originator and User-Agent (#10429) StreamOptions.headers documents that caller values override provider defaults, and every other provider applies model/caller headers after its User-Agent default. The Codex provider set originator and User-Agent after merging caller headers, so they could not be overridden. Set the defaults first; Authorization and chatgpt-account-id stay fixed. Co-authored-by: Lucas Meijer <308974+lucasmeijer@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: David Brailovsky --- packages/ai/src/api/openai-codex-responses.ts | 8 +-- packages/ai/test/openai-codex-stream.test.ts | 49 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 9c1831a02..a6bbc2ce7 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -1643,7 +1643,11 @@ function buildBaseCodexHeaders( accountId: string, token: string, ): Headers { - const headers = new Headers(initHeaders); + // Defaults first so model and caller headers can override them, matching the other providers. + const headers = new Headers({ originator: "pi", "User-Agent": getPiUserAgent() }); + for (const [key, value] of Object.entries(initHeaders || {})) { + headers.set(key, value); + } for (const [key, value] of Object.entries(additionalHeaders || {})) { if (value === null) { headers.delete(key); @@ -1653,8 +1657,6 @@ function buildBaseCodexHeaders( } headers.set("Authorization", `Bearer ${token}`); headers.set("chatgpt-account-id", accountId); - headers.set("originator", "pi"); - headers.set("User-Agent", getPiUserAgent()); return headers; } diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 9aabc09c3..9283a95d6 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -797,6 +797,55 @@ describe("openai-codex streaming", () => { expect(capturedHeaders?.get("x-client-request-id")).toBe("x".repeat(64)); }); + it("lets model and caller headers override originator and User-Agent", async () => { + const token = mockToken(); + let capturedHeaders: Headers | undefined; + const encoder = new TextEncoder(); + vi.stubGlobal( + "fetch", + vi.fn(async (_input: string | URL, init?: RequestInit) => { + capturedHeaders = init?.headers instanceof Headers ? init.headers : undefined; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(buildSSEPayload({ status: "completed" }))); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }), + ); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + headers: { originator: "my-app" }, + }; + const context = normalizeContext({ + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }); + + await streamOpenAICodexResponses(model, context, { + apiKey: token, + transport: "sse", + headers: { "user-agent": "my-app/1.0", Authorization: "Bearer ignored" }, + }).result(); + + expect(capturedHeaders?.get("originator")).toBe("my-app"); + expect(capturedHeaders?.get("User-Agent")).toBe("my-app/1.0"); + expect(capturedHeaders?.get("Authorization")).toBe(`Bearer ${token}`); + }); + it("preserves gpt-5.5 xhigh reasoning effort from simple options", async () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); process.env.PI_CODING_AGENT_DIR = tempDir;