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 <noreply@anthropic.com>
Co-authored-by: David Brailovsky <davidbrai@gmail.com>
This commit is contained in:
Lucas Meijer 2026-10-06 10:01:19 +02:00 • committed by GitHub
parent 1ffb6bd621
commit 0cf65d2bf7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 54 additions and 3 deletions

View file

@ -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;
}

View file

@ -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<Uint8Array>({
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;