mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
519184eb65
commit
bbb61e34aa
8 changed files with 61 additions and 20 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: !(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue