From c5d3978350c7d32db5d8eb28400d4fa851624703 Mon Sep 17 00:00:00 2001 From: "robopipi[bot]" <336283572+robopipi[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:28:59 +0100 Subject: [PATCH] fix(ai): send session-id instead of session_id on OpenAI Responses (#11793) The `openai-responses` client sent the session-affinity header as `session_id`. Proxies such as nginx with `underscores_in_headers off` silently drop headers with underscores, and Envoy can be configured to reject them. Codex now sends `session-id`, and the `openai-codex-responses` path already followed it (#4967). With the default `openai` session-affinity format, `openai-responses` now sends `session-id`. `x-client-request-id` and `prompt_cache_key` are unchanged. The `openai-nosession` and `openrouter` formats behave as before. The compat schema description for `sessionAffinityFormat` (`openai-responses`) and `docs/models-configuration.md` are updated to match. Not changed: `openai-completions` still sends `session_id`. I did not check whether xAI or other backends on the Responses API depend on the underscored name. Their `prompt_cache_key` is still sent, so prompt caching should not depend on the header. Testing: - `test/openai-responses-compat.test.ts` now captures `session-id`. It also checks that `session_id` is no longer sent, and the explicit-override test uses the new name. `test/xai-responses.test.ts` expects `session-id` and no `session_id`. - Without the source change, these tests fail (3 failures); with it, both files pass (50 tests). - `npm run check` passes. - The full `packages/ai` suite passes, except `generate-models-strict.test.ts`. That failure is unrelated: the test depends on live model catalog data. Fixes #11639 Requested by @badlogic Co-authored-by: triagent --- packages/ai/src/api/openai-responses.ts | 2 +- packages/ai/src/providers/compat-schema.ts | 2 +- packages/ai/test/openai-responses-compat.test.ts | 10 +++++++--- packages/ai/test/xai-responses.test.ts | 3 ++- packages/coding-agent/docs/models-configuration.md | 2 +- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index c29e6adfb..8d576a702 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -316,7 +316,7 @@ function createClient( headers["x-session-id"] = sessionId; } else { if (compat.sessionAffinityFormat === "openai") { - headers.session_id = sessionId; + headers["session-id"] = sessionId; } headers["x-client-request-id"] = sessionId; } diff --git a/packages/ai/src/providers/compat-schema.ts b/packages/ai/src/providers/compat-schema.ts index 792b7a1b7..88defded8 100644 --- a/packages/ai/src/providers/compat-schema.ts +++ b/packages/ai/src/providers/compat-schema.ts @@ -364,7 +364,7 @@ const OpenAIResponsesCompatProperties = { }), sessionAffinityFormat: optionalSessionAffinityFormat({ description: - "Session-affinity header format. openai sends session_id and x-client-request-id; openai-nosession sends x-client-request-id; openrouter sends x-session-id. Does not affect prompt_cache_key. Default: auto-detected.", + "Session-affinity header format. openai sends session-id and x-client-request-id; openai-nosession sends x-client-request-id; openrouter sends x-session-id. Does not affect prompt_cache_key. Default: auto-detected.", }), supportsLongCacheRetention: optionalCompatBoolean({ description: diff --git a/packages/ai/test/openai-responses-compat.test.ts b/packages/ai/test/openai-responses-compat.test.ts index ec7783313..2423da236 100644 --- a/packages/ai/test/openai-responses-compat.test.ts +++ b/packages/ai/test/openai-responses-compat.test.ts @@ -35,14 +35,17 @@ async function captureOpenAIResponseHeaders( sessionId: string | null; clientRequestId: string | null; xSessionId: string | null; + legacySessionId: string | null; }> { const captured = { sessionId: null as string | null, clientRequestId: null as string | null, xSessionId: null as string | null, + legacySessionId: null as string | null, }; vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => { - captured.sessionId = getHeader(init?.headers, "session_id"); + captured.sessionId = getHeader(init?.headers, "session-id"); + captured.legacySessionId = getHeader(init?.headers, "session_id"); captured.clientRequestId = getHeader(init?.headers, "x-client-request-id"); captured.xSessionId = getHeader(init?.headers, "x-session-id"); return new Response("data: [DONE]\n\n", { @@ -295,6 +298,7 @@ describe("openai-responses provider defaults", () => { const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }); expect(captured.sessionId).toBe("session-123"); + expect(captured.legacySessionId).toBeNull(); expect(captured.clientRequestId).toBe("session-123"); }); @@ -436,7 +440,7 @@ describe("openai-responses provider defaults", () => { expect(capturedPayload?.prompt_cache_key).toBe("session-opencode"); }); - it("can omit OpenAI session_id header while preserving other affinity data", async () => { + it("can omit OpenAI session-id header while preserving other affinity data", async () => { const proxyModel: Model<"openai-responses"> = { ...getModel("openai", "gpt-5.4"), provider: "opencode", @@ -463,7 +467,7 @@ describe("openai-responses provider defaults", () => { const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123", headers: { - session_id: "override-session", + "session-id": "override-session", "x-client-request-id": "override-request", }, }); diff --git a/packages/ai/test/xai-responses.test.ts b/packages/ai/test/xai-responses.test.ts index 228ebe468..ca5084866 100644 --- a/packages/ai/test/xai-responses.test.ts +++ b/packages/ai/test/xai-responses.test.ts @@ -179,7 +179,8 @@ describe("xAI Responses provider", () => { expect(captured.url).toBe("https://api.x.ai/v1/responses"); expect(captured.headers.get("authorization")).toBe("Bearer xai-test-token"); expect(captured.headers.get("user-agent")).toBe(PI_USER_AGENT); - expect(captured.headers.get("session_id")).toBe("pi-session-123"); + expect(captured.headers.get("session-id")).toBe("pi-session-123"); + expect(captured.headers.get("session_id")).toBeNull(); expect(captured.body).toMatchObject({ model: "grok-4.5", store: false, diff --git a/packages/coding-agent/docs/models-configuration.md b/packages/coding-agent/docs/models-configuration.md index 2a25bdb48..830f9bfdc 100644 --- a/packages/coding-agent/docs/models-configuration.md +++ b/packages/coding-agent/docs/models-configuration.md @@ -146,7 +146,7 @@ Also applies to `azure-openai-responses` and `openai-codex-responses`. |---|---|---|---| | `supportsDeveloperRole` | boolean | `true` | Whether the provider supports the developer role instead of system. | | `supportsMidConvoSystemMessages` | boolean | `false` | Whether the exact model accepts developer or system messages after the conversation has started. When false, later system messages are folded into the leading system message. The generated catalog enables this for verified models. | -| `sessionAffinityFormat` | `"openai" \| "openai-nosession" \| "openrouter"` | | Session-affinity header format. openai sends session_id and x-client-request-id; openai-nosession sends x-client-request-id; openrouter sends x-session-id. Does not affect prompt_cache_key. Default: auto-detected. | +| `sessionAffinityFormat` | `"openai" \| "openai-nosession" \| "openrouter"` | | Session-affinity header format. openai sends session-id and x-client-request-id; openai-nosession sends x-client-request-id; openrouter sends x-session-id. Does not affect prompt_cache_key. Default: auto-detected. | | `supportsLongCacheRetention` | boolean | `true` | Whether the provider supports long prompt cache retention. This uses prompt_cache_options.ttl: "30m" on GPT-5.6+ and prompt_cache_retention: "24h" on earlier models. | | `supportsStrictMode` | boolean | | Whether the provider supports strict JSON-schema function tools. Defaults are API-specific; generated OpenAI models enable it explicitly. | | `supportsOpenAIGrammarTools` | boolean | `false` | Whether to emit OpenAI custom tools with Lark or regex grammar formats. When false, grammar-constrained tools fall back to normal function tools. The generated catalog enables this for capable models. |