diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 4b440b8de..fe2df0789 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed OpenCode and OpenCode Go requests to send `x-opencode-session` from `sessionId` across all supported API adapters ([#9326](https://github.com/earendil-works/pi/issues/9326)). - Fixed OpenAI Codex requests to send the model's Off reasoning effort instead of omitting it, while respecting unsupported Off mappings ([#9191](https://github.com/earendil-works/pi/issues/9191)). - Fixed Fireworks unsigned thinking replay and reasoning effort selection using catalog metadata, with verified DeepSeek V4 and Qwen3.8 fallbacks and removal of redundant GLM 5.2 and Kimi K3 effort aliases ([#9323](https://github.com/earendil-works/pi/issues/9323)). +- Fixed OpenRouter requests to send `x-session-id` from `sessionId` for Chat Completions and Anthropic Messages models when prompt caching is enabled ([#9102](https://github.com/earendil-works/pi/issues/9102)). ## [0.85.1] - 2026-09-05 diff --git a/packages/ai/README.md b/packages/ai/README.md index 97bf64471..58e10acda 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1208,7 +1208,7 @@ interface OpenAICompletionsCompat { supportsUsageInStreaming?: boolean; // Whether provider supports `stream_options: { include_usage: true }` (default: true) supportsStrictMode?: boolean; // Whether provider supports `strict` in tool definitions (default: true) supportsOpenAIGrammarTools?: boolean; // Whether to emit OpenAI custom Lark/regex grammar tools; false falls back to normal function tools (default: false; the generated catalog enables it for capable models) - sendSessionAffinityHeaders?: boolean; // Send session-affinity data from `sessionId` (default: false) + sendSessionAffinityHeaders?: boolean; // Send session-affinity data from `sessionId` (default: true for OpenRouter, false otherwise) 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) @@ -1234,6 +1234,8 @@ interface OpenAIResponsesCompat { } ``` +OpenRouter requests send `x-session-id` from `sessionId` when prompt caching is enabled. Chat Completions and Anthropic Messages both auto-detect OpenRouter endpoints unless `sendSessionAffinityHeaders` is explicitly false. On Anthropic-compatible models, `sessionAffinityFormat: "openrouter"` selects `x-session-id`; when unset, the existing `x-session-affinity` format is used. Explicit request headers take precedence over generated headers. + If `compat` is not set, the library falls back to URL-based detection. If `compat` is partially set, unspecified fields use the detected defaults. This is useful for: - **LiteLLM proxies**: May not support `store` field diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 389d4f023..d6550e1ac 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -726,7 +726,7 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, supportsOpenAIGrammarTools: false, ...(cacheControlFormat ? { cacheControlFormat } : {}), - sendSessionAffinityHeaders: false, + sendSessionAffinityHeaders: isOpenRouter, supportsLongCacheRetention: !( isTogether || isCloudflareWorkersAI || diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index de3a51cf4..1892f1063 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -12,7 +12,6 @@ import type { } from "@anthropic-ai/sdk/resources/beta/messages/messages.js"; import { calculateCost } from "../models.ts"; import type { - AnthropicMessagesCompat, Api, AssistantMessage, CacheRetention, @@ -182,15 +181,13 @@ function shouldUseServerSideFallbackBeta(model: Model<"anthropic-messages">): bo return (model.compat?.allowedFallbackModels?.length ?? 0) > 0; } -function getAnthropicCompat( - model: Model<"anthropic-messages">, -): Required< - Omit -> { +function getAnthropicCompat(model: Model<"anthropic-messages">) { + const isOpenRouter = model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai"); return { supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? true, supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true, - sendSessionAffinityHeaders: model.compat?.sendSessionAffinityHeaders ?? false, + sendSessionAffinityHeaders: model.compat?.sendSessionAffinityHeaders ?? isOpenRouter, + sessionAffinityFormat: model.compat?.sessionAffinityFormat ?? (isOpenRouter ? "openrouter" : undefined), supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? true, supportsTemperature: model.compat?.supportsTemperature ?? true, allowEmptySignature: model.compat?.allowEmptySignature ?? false, @@ -952,8 +949,12 @@ function createClient( } // API key or header-owned auth. - const sessionAffinityHeaders: ProviderHeaders = - sessionId && getAnthropicCompat(model).sendSessionAffinityHeaders ? { "x-session-affinity": sessionId } : {}; + const compat = getAnthropicCompat(model); + const sessionAffinityHeaders: ProviderHeaders = {}; + if (sessionId && compat.sendSessionAffinityHeaders) { + const header = compat.sessionAffinityFormat === "openrouter" ? "x-session-id" : "x-session-affinity"; + sessionAffinityHeaders[header] = sessionId; + } const defaultHeaders = mergeClientHeaders( { accept: "application/json", diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 8ac17dbae..f1deefb2b 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -1662,7 +1662,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, supportsOpenAIGrammarTools: false, cacheControlFormat, - sendSessionAffinityHeaders: false, + sendSessionAffinityHeaders: isOpenRouter, deferredToolsMode: undefined, sessionAffinityFormat: isOpenRouter ? "openrouter" : "openai", supportsLongCacheRetention: !( diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index fe318fe42..d45cc3c4e 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -625,7 +625,7 @@ 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, or tool-result text content. */ cacheControlFormat?: "anthropic"; - /** Whether to send session-affinity data from `options.sessionId`. Default: false. */ + /** Whether to send session-affinity data from `options.sessionId`. Default: true for OpenRouter endpoints, false otherwise. */ sendSessionAffinityHeaders?: boolean; /** Provider-specific deferred tool serialization mode. */ deferredToolsMode?: "kimi"; @@ -684,6 +684,8 @@ export interface AnthropicMessagesCompat { * Default: false. */ sendSessionAffinityHeaders?: boolean; + /** Session-affinity format. `"openrouter"` sends `x-session-id`; when unset, sends `x-session-affinity`. */ + sessionAffinityFormat?: "openrouter"; /** * Whether the provider supports Anthropic-style `cache_control` markers on * tool definitions. When false, `cache_control` is omitted from tool params. diff --git a/packages/ai/test/fireworks-models.test.ts b/packages/ai/test/fireworks-models.test.ts index 1ea1e58d5..96003af11 100644 --- a/packages/ai/test/fireworks-models.test.ts +++ b/packages/ai/test/fireworks-models.test.ts @@ -260,6 +260,15 @@ function createAnthropicModel(): Model<"anthropic-messages"> { }; } +function createOpenRouterModel(): Model<"anthropic-messages"> { + return { + ...createAnthropicModel(), + id: "anthropic/claude-opus-4.8", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api", + }; +} + function createContext(tools: Tool[] = [tool]): Context { return { messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }], @@ -331,7 +340,7 @@ function getTools(body: Record): Record[] { return tools as Record[]; } -describe("Fireworks Anthropic session affinity and tool compat", () => { +describe("Anthropic-compatible session affinity and tool compat", () => { it("sends x-session-affinity header for Fireworks models", async () => { const model = createFireworksModel(); // Need a real port, capture will assign one @@ -361,6 +370,35 @@ describe("Fireworks Anthropic session affinity and tool compat", () => { expect(request.headers["x-session-affinity"]).toBeUndefined(); }); + // Regression test for https://github.com/earendil-works/pi/issues/9102 + it("sends only x-session-id for OpenRouter models", async () => { + const request = await captureAnthropicRequest(createOpenRouterModel(), createContext(), { + sessionId: "openrouter-session-1", + }); + + expect(request.headers["x-session-id"]).toBe("openrouter-session-1"); + expect(request.headers["x-session-affinity"]).toBeUndefined(); + }); + + it("omits OpenRouter session headers when cacheRetention is none", async () => { + const request = await captureAnthropicRequest(createOpenRouterModel(), createContext(), { + sessionId: "openrouter-session-2", + cacheRetention: "none", + }); + + expect(request.headers["x-session-id"]).toBeUndefined(); + expect(request.headers["x-session-affinity"]).toBeUndefined(); + }); + + it("allows OpenRouter session headers to be disabled", async () => { + const model = { ...createOpenRouterModel(), compat: { sendSessionAffinityHeaders: false } }; + const request = await captureAnthropicRequest(model, createContext(), { + sessionId: "openrouter-session-3", + }); + + expect(request.headers["x-session-id"]).toBeUndefined(); + }); + it("omits cache_control on tools for Fireworks models", async () => { const model = createFireworksModel(); const request = await captureAnthropicRequest(model, createContext()); diff --git a/packages/ai/test/openai-completions-prompt-cache.test.ts b/packages/ai/test/openai-completions-prompt-cache.test.ts index 28b255160..2095fa28f 100644 --- a/packages/ai/test/openai-completions-prompt-cache.test.ts +++ b/packages/ai/test/openai-completions-prompt-cache.test.ts @@ -210,12 +210,8 @@ describe("openai-completions prompt caching", () => { 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 }, - }); + it("sends OpenRouter session-affinity header by default for built-in OpenRouter models", async () => { + const model = getModel("openrouter", "auto"); const { payload, headers } = await captureRequest({ sessionId: "session-openrouter" }, model); expect(payload?.session_id).toBeUndefined(); @@ -230,6 +226,7 @@ describe("openai-completions prompt caching", () => { const model = createModel({ provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", + compat: { sendSessionAffinityHeaders: false }, }); const { payload, headers } = await captureRequest({ sessionId: "session-openrouter" }, model);