fix(ai): send OpenRouter session affinity headers by default

Send x-session-id for OpenRouter Chat Completions and Anthropic Messages requests while preserving explicit opt-out and cache-disabled behavior.

closes #9102
This commit is contained in:
David Brailovsky 2026-09-10 10:35:48 +02:00
parent 519184eb65
commit bbb61e34aa
8 changed files with 61 additions and 20 deletions

View file

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

View file

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

View file

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

View file

@ -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<AnthropicMessagesCompat, "forceAdaptiveThinking" | "allowedFallbackModels" | "supportsMidConvoEffort">
> {
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",

View file

@ -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: !(

View file

@ -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.

View file

@ -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<string, unknown>): Record<string, unknown>[] {
return tools as Record<string, unknown>[];
}
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());

View file

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