From 298665cfb9b92def9011a6762a3e7dfbcb6cee13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Mon, 13 Jul 2026 09:57:53 +0200 Subject: [PATCH] fix(ai): support OpenRouter session affinity (#6496) Fixes #6366 --- packages/ai/CHANGELOG.md | 5 + packages/ai/README.md | 5 +- packages/ai/src/api/openai-completions.ts | 14 +- packages/ai/src/api/openai-responses.ts | 16 ++- packages/ai/src/types.ts | 9 +- .../openai-completions-prompt-cache.test.ts | 58 ++++++++ ...penai-completions-thinking-as-text.test.ts | 1 + .../openai-completions-tool-choice.test.ts | 1 + ...nai-completions-tool-result-images.test.ts | 1 + .../openai-responses-copilot-provider.test.ts | 127 ++++++++++++++++-- packages/coding-agent/CHANGELOG.md | 5 + packages/coding-agent/docs/custom-provider.md | 2 + packages/coding-agent/docs/models.md | 2 + .../coding-agent/src/core/model-registry.ts | 8 +- 14 files changed, 229 insertions(+), 25 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index baa399fd..5219441f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- Removed the `OpenAIResponsesCompat.sendSessionIdHeader` flag. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6366](https://github.com/earendil-works/pi/issues/6366)). + ### Added - Added cache-friendly dynamic tool loading. `ToolResultMessage.addedToolNames` marks where tools from `Context.tools` became available; Anthropic and OpenAI Responses use native deferred loading so late tools stay out of the cached prefix, while other providers continue using `Context.tools` normally ([#6474](https://github.com/earendil-works/pi-mono/pull/6474)). @@ -14,6 +18,7 @@ - Fixed OpenRouter model context windows to use the top provider's actual context length ([#6481](https://github.com/earendil-works/pi-mono/pull/6481) by [@davidbrai](https://github.com/davidbrai)). - Fixed the GitHub Copilot `mai-code-1-flash-picker` model to route through the `/responses` endpoint. - Fixed Amazon Bedrock requests to use the generic `apiKey` stream option as a Bedrock bearer token. +- Fixed OpenRouter OpenAI-compatible session IDs to use the `x-session-id` header instead of OpenAI-specific session-affinity fields ([#6366](https://github.com/earendil-works/pi/issues/6366)). ## [0.80.6] - 2026-07-09 diff --git a/packages/ai/README.md b/packages/ai/README.md index c9db20ac..0216cdfa 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1070,7 +1070,8 @@ interface OpenAICompletionsCompat { supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true) supportsUsageInStreaming?: boolean; // Whether provider supports `stream_options: { include_usage: true }` (default: true) supportsStrictMode?: boolean; // Whether provider supports `strict` in tool definitions (default: true) - sendSessionAffinityHeaders?: boolean; // Whether to send `session_id`, `x-client-request-id`, and `x-session-affinity` from `sessionId` when caching is enabled (default: false) + sendSessionAffinityHeaders?: boolean; // Send session-affinity data from `sessionId` (default: false) + sessionAffinityFormat?: 'openai' | 'openai-nosession' | 'openrouter'; // Format for session affinity: 'openai' uses `prompt_cache_key`, `session_id`, `x-client-request-id`, and `x-session-affinity`; 'openai-nosession' uses `prompt_cache_key`, `x-client-request-id`, and `x-session-affinity`; 'openrouter' uses `x-session-id` (default: auto-detected) maxTokensField?: 'max_completion_tokens' | 'max_tokens'; // Which field name to use (default: max_completion_tokens) requiresToolResultName?: boolean; // Whether tool results require the `name` field (default: false) requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false) @@ -1085,7 +1086,7 @@ interface OpenAICompletionsCompat { interface OpenAIResponsesCompat { supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true) - sendSessionIdHeader?: boolean; // Whether to send `session_id` from `sessionId` when caching is enabled (default: true) + 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 the `prompt_cache_key` body param (default: auto-detected) supportsLongCacheRetention?: boolean; // Whether provider supports `prompt_cache_retention: "24h"` (default: true) } ``` diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 32947db4..88df3143 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -519,9 +519,15 @@ function createClient( } if (sessionId && compat.sendSessionAffinityHeaders) { - headers.session_id = sessionId; - headers["x-client-request-id"] = sessionId; - headers["x-session-affinity"] = sessionId; + if (compat.sessionAffinityFormat === "openrouter") { + headers["x-session-id"] = sessionId; + } else { + if (compat.sessionAffinityFormat === "openai") { + headers.session_id = sessionId; + } + headers["x-client-request-id"] = sessionId; + headers["x-session-affinity"] = sessionId; + } } // Merge options headers last so they can override defaults @@ -1250,6 +1256,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, cacheControlFormat, sendSessionAffinityHeaders: false, + sessionAffinityFormat: isOpenRouter ? "openrouter" : "openai", supportsLongCacheRetention: !( isTogether || isCloudflareWorkersAI || @@ -1289,6 +1296,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode, cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat, sendSessionAffinityHeaders: model.compat.sendSessionAffinityHeaders ?? detected.sendSessionAffinityHeaders, + sessionAffinityFormat: model.compat.sessionAffinityFormat ?? detected.sessionAffinityFormat, supportsLongCacheRetention: model.compat.supportsLongCacheRetention ?? detected.supportsLongCacheRetention, }; } diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 287cc4df..cb3a9c93 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -44,6 +44,10 @@ function getClientApiKey(provider: string, apiKey: string | undefined, headers: throw new Error(`No API key for provider: ${provider}`); } +function detectSessionAffinityFormat(model: Pick, "provider" | "baseUrl">) { + return model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai") ? "openrouter" : "openai"; +} + /** * Resolve cache retention preference. * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility. @@ -61,7 +65,7 @@ function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEn function getCompat(model: Model<"openai-responses">): Required { return { supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true, - sendSessionIdHeader: model.compat?.sendSessionIdHeader ?? true, + sessionAffinityFormat: model.compat?.sessionAffinityFormat ?? detectSessionAffinityFormat(model), supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true, supportsToolSearch: model.compat?.supportsToolSearch ?? false, }; @@ -203,10 +207,14 @@ function createClient( } if (sessionId) { - if (compat.sendSessionIdHeader) { - headers.session_id = sessionId; + if (compat.sessionAffinityFormat === "openrouter") { + headers["x-session-id"] = sessionId; + } else { + if (compat.sessionAffinityFormat === "openai") { + headers.session_id = sessionId; + } + headers["x-client-request-id"] = sessionId; } - headers["x-client-request-id"] = sessionId; } // Merge options headers last so they can override defaults diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 211b522c..10530fae 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -100,6 +100,7 @@ export type Transport = "sse" | "websocket" | "websocket-cached" | "auto"; /** Provider-scoped environment overrides. Values take precedence over process.env. */ export type ProviderEnv = Record; export type ProviderHeaders = Record; +export type SessionAffinityFormat = "openai" | "openai-nosession" | "openrouter"; export interface ProviderResponse { status: number; @@ -517,8 +518,10 @@ export interface OpenAICompletionsCompat { supportsStrictMode?: boolean; /** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. */ cacheControlFormat?: "anthropic"; - /** Whether to send known session-affinity headers (`session_id`, `x-client-request-id`, `x-session-affinity`) from `options.sessionId` when caching is enabled. Default: false. */ + /** Whether to send session-affinity data from `options.sessionId`. Default: false. */ sendSessionAffinityHeaders?: boolean; + /** Session-affinity header format: `openai` sends `session_id`, `x-client-request-id`, and `x-session-affinity`; `openai-nosession` sends `x-client-request-id` and `x-session-affinity`; `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param, which is governed by cache retention. Default: auto-detected. */ + sessionAffinityFormat?: SessionAffinityFormat; /** Whether the provider supports long prompt cache retention (`prompt_cache_retention: "24h"` or Anthropic-style `cache_control.ttl: "1h"`, depending on format). Default: true. */ supportsLongCacheRetention?: boolean; } @@ -527,8 +530,8 @@ export interface OpenAICompletionsCompat { export interface OpenAIResponsesCompat { /** Whether the provider supports the `developer` role (vs `system`). Default: true. */ supportsDeveloperRole?: boolean; - /** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */ - sendSessionIdHeader?: boolean; + /** 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 the `prompt_cache_key` body param, which is governed by cache retention. Default: auto-detected. */ + sessionAffinityFormat?: SessionAffinityFormat; /** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */ supportsLongCacheRetention?: boolean; /** Whether the model supports client-executed tool search for deferred tools. Default: false. */ diff --git a/packages/ai/test/openai-completions-prompt-cache.test.ts b/packages/ai/test/openai-completions-prompt-cache.test.ts index 5e905712..b697ff0f 100644 --- a/packages/ai/test/openai-completions-prompt-cache.test.ts +++ b/packages/ai/test/openai-completions-prompt-cache.test.ts @@ -13,6 +13,7 @@ interface FakeOpenAIClientOptions { interface CapturedCompletionsPayload { prompt_cache_key?: string; prompt_cache_retention?: "24h" | "in-memory" | null; + session_id?: string; } const mockState = vi.hoisted(() => ({ @@ -170,6 +171,63 @@ describe("openai-completions prompt caching", () => { expect(headers["x-session-affinity"]).toBe("session-affinity"); }); + it("uses OpenAI no-session format when configured", async () => { + const model = createModel({ + compat: { sendSessionAffinityHeaders: true, sessionAffinityFormat: "openai-nosession" }, + }); + const { payload, headers } = await captureRequest({ sessionId: "session-nosession" }, model); + + expect(payload?.session_id).toBeUndefined(); + expect(payload?.prompt_cache_key).toBe("session-nosession"); + expect(headers.session_id).toBeUndefined(); + expect(headers["x-client-request-id"]).toBe("session-nosession"); + expect(headers["x-session-affinity"]).toBe("session-nosession"); + expect(headers["x-session-id"]).toBeUndefined(); + }); + + it("uses OpenRouter session-affinity header when configured", async () => { + const model = createModel({ + baseUrl: "https://proxy.example.com/v1", + compat: { sendSessionAffinityHeaders: true, sessionAffinityFormat: "openrouter" }, + }); + const { payload, headers } = await captureRequest({ sessionId: "session-proxy" }, model); + + expect(payload?.session_id).toBeUndefined(); + expect(payload?.prompt_cache_key).toBeUndefined(); + expect(headers["x-session-id"]).toBe("session-proxy"); + expect(headers.session_id).toBeUndefined(); + expect(headers["x-client-request-id"]).toBeUndefined(); + expect(headers["x-session-affinity"]).toBeUndefined(); + }); + + it("auto-detects OpenRouter session-affinity header for OpenRouter endpoints", async () => { + const model = createModel({ + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: { sendSessionAffinityHeaders: true }, + }); + const { payload, headers } = await captureRequest({ sessionId: "session-openrouter" }, model); + + expect(payload?.session_id).toBeUndefined(); + expect(payload?.prompt_cache_key).toBeUndefined(); + expect(headers["x-session-id"]).toBe("session-openrouter"); + expect(headers.session_id).toBeUndefined(); + expect(headers["x-client-request-id"]).toBeUndefined(); + expect(headers["x-session-affinity"]).toBeUndefined(); + }); + + it("omits OpenRouter session-affinity data when disabled", async () => { + const model = createModel({ + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + }); + const { payload, headers } = await captureRequest({ sessionId: "session-openrouter" }, model); + + expect(payload?.session_id).toBeUndefined(); + expect(payload?.prompt_cache_key).toBeUndefined(); + expect(headers["x-session-id"]).toBeUndefined(); + }); + it("omits session-affinity headers when cacheRetention is none", async () => { const model = createModel({ baseUrl: "https://proxy.example.com/v1", diff --git a/packages/ai/test/openai-completions-thinking-as-text.test.ts b/packages/ai/test/openai-completions-thinking-as-text.test.ts index d1cbe14a..88af01ea 100644 --- a/packages/ai/test/openai-completions-thinking-as-text.test.ts +++ b/packages/ai/test/openai-completions-thinking-as-text.test.ts @@ -39,6 +39,7 @@ const compat = { supportsStrictMode: true, cacheControlFormat: undefined, sendSessionAffinityHeaders: false, + sessionAffinityFormat: "openai", supportsLongCacheRetention: true, } satisfies Required> & { cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 1dd49c79..b1f07773 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1247,6 +1247,7 @@ describe("openai-completions tool_choice", () => { zaiToolStream: false, supportsStrictMode: true, sendSessionAffinityHeaders: false, + sessionAffinityFormat: "openai", supportsLongCacheRetention: true, }, ); diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index 31be1667..f9ed9d30 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -37,6 +37,7 @@ const compat: Required = { supportsStrictMode: true, cacheControlFormat: "anthropic", sendSessionAffinityHeaders: false, + sessionAffinityFormat: "openai", supportsLongCacheRetention: true, }; diff --git a/packages/ai/test/openai-responses-copilot-provider.test.ts b/packages/ai/test/openai-responses-copilot-provider.test.ts index 1f1f945f..8eeedea7 100644 --- a/packages/ai/test/openai-responses-copilot-provider.test.ts +++ b/packages/ai/test/openai-responses-copilot-provider.test.ts @@ -6,6 +6,11 @@ import type { Model } from "../src/types.ts"; type CapturedHeaders = Headers | string[][] | Record | undefined; +interface CapturedResponsesPayload { + prompt_cache_key?: string; + session_id?: string; +} + function getHeader(headers: CapturedHeaders, name: string): string | null { if (!headers) return null; if (headers instanceof Headers) return headers.get(name); @@ -25,11 +30,20 @@ function getHeader(headers: CapturedHeaders, name: string): string | null { async function captureOpenAIResponseHeaders( options: Parameters[2], model: Model<"openai-responses"> = getModel("openai", "gpt-5.4"), -): Promise<{ sessionId: string | null; clientRequestId: string | null }> { - const captured = { sessionId: null as string | null, clientRequestId: null as string | null }; +): Promise<{ + sessionId: string | null; + clientRequestId: string | null; + xSessionId: string | null; +}> { + const captured = { + sessionId: null as string | null, + clientRequestId: null as string | null, + xSessionId: null as string | null, + }; vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => { captured.sessionId = 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", { status: 200, headers: { "content-type": "text/event-stream" }, @@ -224,12 +238,13 @@ describe("openai-responses provider defaults", () => { it("sets cache-affinity headers for official OpenAI Responses requests with a sessionId", async () => { const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }); - expect(captured).toEqual({ sessionId: "session-123", clientRequestId: "session-123" }); + expect(captured.sessionId).toBe("session-123"); + expect(captured.clientRequestId).toBe("session-123"); }); it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => { const sessionId = "x".repeat(67); - let capturedPayload: { prompt_cache_key?: string } | undefined; + let capturedPayload: Pick | undefined; vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response("data: [DONE]\n\n", { status: 200, @@ -247,7 +262,7 @@ describe("openai-responses provider defaults", () => { apiKey: "test-key", sessionId, onPayload: (payload) => { - capturedPayload = payload as { prompt_cache_key?: string }; + capturedPayload = payload as Pick; }, }, ); @@ -267,19 +282,105 @@ describe("openai-responses provider defaults", () => { }; const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }, proxyModel); - expect(captured).toEqual({ sessionId: "session-123", clientRequestId: "session-123" }); + expect(captured.sessionId).toBe("session-123"); + expect(captured.clientRequestId).toBe("session-123"); }); - it("can omit the session_id header while preserving other cache-affinity headers", async () => { + it("uses OpenRouter session-affinity header when configured", async () => { + const proxyModel: Model<"openai-responses"> = { + ...getModel("openai", "gpt-5.4"), + provider: "proxy", + baseUrl: "https://proxy.example.com/v1", + compat: { sessionAffinityFormat: "openrouter" }, + }; + let capturedPayload: CapturedResponsesPayload | undefined; + const captured = await captureOpenAIResponseHeaders( + { + sessionId: "session-proxy", + onPayload: (payload) => { + capturedPayload = payload as CapturedResponsesPayload; + }, + }, + proxyModel, + ); + + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBeNull(); + expect(captured.xSessionId).toBe("session-proxy"); + expect(capturedPayload?.session_id).toBeUndefined(); + expect(capturedPayload?.prompt_cache_key).toBe("session-proxy"); + }); + + it("auto-detects OpenRouter session-affinity header for OpenRouter Responses endpoints", async () => { + const openRouterModel: Model<"openai-responses"> = { + ...getModel("openai", "gpt-5.4"), + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + }; + let capturedPayload: CapturedResponsesPayload | undefined; + const captured = await captureOpenAIResponseHeaders( + { + sessionId: "session-openrouter", + onPayload: (payload) => { + capturedPayload = payload as CapturedResponsesPayload; + }, + }, + openRouterModel, + ); + + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBeNull(); + expect(captured.xSessionId).toBe("session-openrouter"); + expect(capturedPayload?.session_id).toBeUndefined(); + expect(capturedPayload?.prompt_cache_key).toBe("session-openrouter"); + }); + + it("uses OpenAI no-session format when configured", async () => { + const proxyModel: Model<"openai-responses"> = { + ...getModel("openai", "gpt-5.4"), + provider: "proxy", + baseUrl: "https://proxy.example.com/v1", + compat: { sessionAffinityFormat: "openai-nosession" }, + }; + let capturedPayload: CapturedResponsesPayload | undefined; + const captured = await captureOpenAIResponseHeaders( + { + sessionId: "session-proxy", + onPayload: (payload) => { + capturedPayload = payload as CapturedResponsesPayload; + }, + }, + proxyModel, + ); + + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBe("session-proxy"); + expect(captured.xSessionId).toBeNull(); + expect(capturedPayload?.session_id).toBeUndefined(); + expect(capturedPayload?.prompt_cache_key).toBe("session-proxy"); + }); + + 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", baseUrl: "https://proxy.example.com/v1", - compat: { sendSessionIdHeader: false }, + compat: { sessionAffinityFormat: "openai-nosession" }, }; - const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }, proxyModel); + let capturedPayload: CapturedResponsesPayload | undefined; + const captured = await captureOpenAIResponseHeaders( + { + sessionId: "session-123", + onPayload: (payload) => { + capturedPayload = payload as CapturedResponsesPayload; + }, + }, + proxyModel, + ); - expect(captured).toEqual({ sessionId: null, clientRequestId: "session-123" }); + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBe("session-123"); + expect(capturedPayload?.prompt_cache_key).toBe("session-123"); }); it("lets explicit headers override the default OpenAI cache-affinity headers", async () => { @@ -291,13 +392,15 @@ describe("openai-responses provider defaults", () => { }, }); - expect(captured).toEqual({ sessionId: "override-session", clientRequestId: "override-request" }); + expect(captured.sessionId).toBe("override-session"); + expect(captured.clientRequestId).toBe("override-request"); }); it("omits OpenAI cache-affinity headers when cacheRetention is none", async () => { const captured = await captureOpenAIResponseHeaders({ cacheRetention: "none", sessionId: "session-123" }); - expect(captured).toEqual({ sessionId: null, clientRequestId: null }); + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBeNull(); }); it.each([ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0f070e6c..58e53e3a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- Removed the `openai-responses` `compat.sendSessionIdHeader` flag from `models.json`. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6366](https://github.com/earendil-works/pi/issues/6366)). + ### New Features - **Cache-friendly dynamic tool loading** - Extensions can add tools during execution while supported Anthropic and OpenAI Responses models preserve prompt-cache prefixes. See [Dynamic Tool Loading](docs/extensions.md#dynamic-tool-loading). @@ -17,6 +21,7 @@ ### Fixed - Fixed inherited OpenRouter model context windows to use the top provider's actual context length ([#6481](https://github.com/earendil-works/pi-mono/pull/6481) by [@davidbrai](https://github.com/davidbrai)). +- Fixed inherited OpenRouter OpenAI-compatible session IDs to use the `x-session-id` header instead of OpenAI-specific session-affinity fields ([#6366](https://github.com/earendil-works/pi/issues/6366)). - Fixed `Ctrl+V` to paste clipboard text when the pasteboard does not contain an image. - Fixed `/login amazon-bedrock` to prompt for and save a Bedrock API key instead of only displaying ambient AWS credential setup instructions. diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 6a8a38ed..43576faa 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -722,6 +722,8 @@ interface ProviderModelConfig { thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling"; chatTemplateKwargs?: Record; cacheControlFormat?: "anthropic"; + sessionAffinityFormat?: "openai" | "openai-nosession" | "openrouter"; + sendSessionAffinityHeaders?: boolean; // anthropic-messages supportsEagerToolInputStreaming?: boolean; diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 9e204b1a..94c776b3 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -445,6 +445,8 @@ For providers with partial OpenAI compatibility, use the `compat` field. | `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters | | `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values | | `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user/assistant text content. Currently only `anthropic` is supported. | +| `sendSessionAffinityHeaders` | For `openai-completions`, send session-affinity headers from the session id when caching is enabled. Default: `false`. | +| `sessionAffinityFormat` | For `openai-completions` and `openai-responses`, the session-affinity header format: `openai` sends `session_id`/`x-client-request-id` (completions also `x-session-affinity`), `openai-nosession` omits the underscore-containing `session_id` header, `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param. Default: auto-detected. | | `supportsStrictMode` | Include the `strict` field in tool definitions | | `supportsLongCacheRetention` | Whether the provider accepts long cache retention when cache retention is `long`: `prompt_cache_retention: "24h"` for OpenAI prompt caching, or `cache_control.ttl: "1h"` when `cacheControlFormat` is `anthropic`. Default: `true`. | | `openRouterRouting` | OpenRouter provider routing preferences. This object is sent as-is in the `provider` field of the [OpenRouter API request](https://openrouter.ai/docs/guides/routing/provider-selection). | diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index d74bbde5..cb84fe5d 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -133,12 +133,18 @@ const OpenAICompletionsCompatSchema = Type.Object({ openRouterRouting: Type.Optional(OpenRouterRoutingSchema), vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema), supportsStrictMode: Type.Optional(Type.Boolean()), + sendSessionAffinityHeaders: Type.Optional(Type.Boolean()), + sessionAffinityFormat: Type.Optional( + Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]), + ), supportsLongCacheRetention: Type.Optional(Type.Boolean()), }); const OpenAIResponsesCompatSchema = Type.Object({ supportsDeveloperRole: Type.Optional(Type.Boolean()), - sendSessionIdHeader: Type.Optional(Type.Boolean()), + sessionAffinityFormat: Type.Optional( + Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]), + ), supportsLongCacheRetention: Type.Optional(Type.Boolean()), supportsToolSearch: Type.Optional(Type.Boolean()), });