@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
|
||||||
- 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)).
|
- 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 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 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 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
|
## [0.80.6] - 2026-07-09
|
||||||
|
|
||||||
|
|||||||
@@ -1070,7 +1070,8 @@ interface OpenAICompletionsCompat {
|
|||||||
supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true)
|
supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true)
|
||||||
supportsUsageInStreaming?: boolean; // Whether provider supports `stream_options: { include_usage: true }` (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)
|
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)
|
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)
|
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)
|
requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false)
|
||||||
@@ -1085,7 +1086,7 @@ interface OpenAICompletionsCompat {
|
|||||||
|
|
||||||
interface OpenAIResponsesCompat {
|
interface OpenAIResponsesCompat {
|
||||||
supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true)
|
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)
|
supportsLongCacheRetention?: boolean; // Whether provider supports `prompt_cache_retention: "24h"` (default: true)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -519,9 +519,15 @@ function createClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionId && compat.sendSessionAffinityHeaders) {
|
if (sessionId && compat.sendSessionAffinityHeaders) {
|
||||||
headers.session_id = sessionId;
|
if (compat.sessionAffinityFormat === "openrouter") {
|
||||||
headers["x-client-request-id"] = sessionId;
|
headers["x-session-id"] = sessionId;
|
||||||
headers["x-session-affinity"] = 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
|
// 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,
|
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia,
|
||||||
cacheControlFormat,
|
cacheControlFormat,
|
||||||
sendSessionAffinityHeaders: false,
|
sendSessionAffinityHeaders: false,
|
||||||
|
sessionAffinityFormat: isOpenRouter ? "openrouter" : "openai",
|
||||||
supportsLongCacheRetention: !(
|
supportsLongCacheRetention: !(
|
||||||
isTogether ||
|
isTogether ||
|
||||||
isCloudflareWorkersAI ||
|
isCloudflareWorkersAI ||
|
||||||
@@ -1289,6 +1296,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion
|
|||||||
supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
|
supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
|
||||||
cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat,
|
cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat,
|
||||||
sendSessionAffinityHeaders: model.compat.sendSessionAffinityHeaders ?? detected.sendSessionAffinityHeaders,
|
sendSessionAffinityHeaders: model.compat.sendSessionAffinityHeaders ?? detected.sendSessionAffinityHeaders,
|
||||||
|
sessionAffinityFormat: model.compat.sessionAffinityFormat ?? detected.sessionAffinityFormat,
|
||||||
supportsLongCacheRetention: model.compat.supportsLongCacheRetention ?? detected.supportsLongCacheRetention,
|
supportsLongCacheRetention: model.compat.supportsLongCacheRetention ?? detected.supportsLongCacheRetention,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ function getClientApiKey(provider: string, apiKey: string | undefined, headers:
|
|||||||
throw new Error(`No API key for provider: ${provider}`);
|
throw new Error(`No API key for provider: ${provider}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectSessionAffinityFormat(model: Pick<Model<"openai-responses">, "provider" | "baseUrl">) {
|
||||||
|
return model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai") ? "openrouter" : "openai";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve cache retention preference.
|
* Resolve cache retention preference.
|
||||||
* Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
|
* 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<OpenAIResponsesCompat> {
|
function getCompat(model: Model<"openai-responses">): Required<OpenAIResponsesCompat> {
|
||||||
return {
|
return {
|
||||||
supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true,
|
supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true,
|
||||||
sendSessionIdHeader: model.compat?.sendSessionIdHeader ?? true,
|
sessionAffinityFormat: model.compat?.sessionAffinityFormat ?? detectSessionAffinityFormat(model),
|
||||||
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
|
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
|
||||||
supportsToolSearch: model.compat?.supportsToolSearch ?? false,
|
supportsToolSearch: model.compat?.supportsToolSearch ?? false,
|
||||||
};
|
};
|
||||||
@@ -203,10 +207,14 @@ function createClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
if (compat.sendSessionIdHeader) {
|
if (compat.sessionAffinityFormat === "openrouter") {
|
||||||
headers.session_id = sessionId;
|
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
|
// Merge options headers last so they can override defaults
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export type Transport = "sse" | "websocket" | "websocket-cached" | "auto";
|
|||||||
/** Provider-scoped environment overrides. Values take precedence over process.env. */
|
/** Provider-scoped environment overrides. Values take precedence over process.env. */
|
||||||
export type ProviderEnv = Record<string, string>;
|
export type ProviderEnv = Record<string, string>;
|
||||||
export type ProviderHeaders = Record<string, string | null>;
|
export type ProviderHeaders = Record<string, string | null>;
|
||||||
|
export type SessionAffinityFormat = "openai" | "openai-nosession" | "openrouter";
|
||||||
|
|
||||||
export interface ProviderResponse {
|
export interface ProviderResponse {
|
||||||
status: number;
|
status: number;
|
||||||
@@ -517,8 +518,10 @@ export interface OpenAICompletionsCompat {
|
|||||||
supportsStrictMode?: boolean;
|
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. */
|
/** 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";
|
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;
|
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. */
|
/** 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;
|
supportsLongCacheRetention?: boolean;
|
||||||
}
|
}
|
||||||
@@ -527,8 +530,8 @@ export interface OpenAICompletionsCompat {
|
|||||||
export interface OpenAIResponsesCompat {
|
export interface OpenAIResponsesCompat {
|
||||||
/** Whether the provider supports the `developer` role (vs `system`). Default: true. */
|
/** Whether the provider supports the `developer` role (vs `system`). Default: true. */
|
||||||
supportsDeveloperRole?: boolean;
|
supportsDeveloperRole?: boolean;
|
||||||
/** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */
|
/** 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. */
|
||||||
sendSessionIdHeader?: boolean;
|
sessionAffinityFormat?: SessionAffinityFormat;
|
||||||
/** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */
|
/** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */
|
||||||
supportsLongCacheRetention?: boolean;
|
supportsLongCacheRetention?: boolean;
|
||||||
/** Whether the model supports client-executed tool search for deferred tools. Default: false. */
|
/** Whether the model supports client-executed tool search for deferred tools. Default: false. */
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface FakeOpenAIClientOptions {
|
|||||||
interface CapturedCompletionsPayload {
|
interface CapturedCompletionsPayload {
|
||||||
prompt_cache_key?: string;
|
prompt_cache_key?: string;
|
||||||
prompt_cache_retention?: "24h" | "in-memory" | null;
|
prompt_cache_retention?: "24h" | "in-memory" | null;
|
||||||
|
session_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockState = vi.hoisted(() => ({
|
const mockState = vi.hoisted(() => ({
|
||||||
@@ -170,6 +171,63 @@ describe("openai-completions prompt caching", () => {
|
|||||||
expect(headers["x-session-affinity"]).toBe("session-affinity");
|
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 () => {
|
it("omits session-affinity headers when cacheRetention is none", async () => {
|
||||||
const model = createModel({
|
const model = createModel({
|
||||||
baseUrl: "https://proxy.example.com/v1",
|
baseUrl: "https://proxy.example.com/v1",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const compat = {
|
|||||||
supportsStrictMode: true,
|
supportsStrictMode: true,
|
||||||
cacheControlFormat: undefined,
|
cacheControlFormat: undefined,
|
||||||
sendSessionAffinityHeaders: false,
|
sendSessionAffinityHeaders: false,
|
||||||
|
sessionAffinityFormat: "openai",
|
||||||
supportsLongCacheRetention: true,
|
supportsLongCacheRetention: true,
|
||||||
} satisfies Required<Omit<OpenAICompletionsCompat, "cacheControlFormat">> & {
|
} satisfies Required<Omit<OpenAICompletionsCompat, "cacheControlFormat">> & {
|
||||||
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
|
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
|
||||||
|
|||||||
@@ -1247,6 +1247,7 @@ describe("openai-completions tool_choice", () => {
|
|||||||
zaiToolStream: false,
|
zaiToolStream: false,
|
||||||
supportsStrictMode: true,
|
supportsStrictMode: true,
|
||||||
sendSessionAffinityHeaders: false,
|
sendSessionAffinityHeaders: false,
|
||||||
|
sessionAffinityFormat: "openai",
|
||||||
supportsLongCacheRetention: true,
|
supportsLongCacheRetention: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const compat: Required<OpenAICompletionsCompat> = {
|
|||||||
supportsStrictMode: true,
|
supportsStrictMode: true,
|
||||||
cacheControlFormat: "anthropic",
|
cacheControlFormat: "anthropic",
|
||||||
sendSessionAffinityHeaders: false,
|
sendSessionAffinityHeaders: false,
|
||||||
|
sessionAffinityFormat: "openai",
|
||||||
supportsLongCacheRetention: true,
|
supportsLongCacheRetention: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import type { Model } from "../src/types.ts";
|
|||||||
|
|
||||||
type CapturedHeaders = Headers | string[][] | Record<string, string | readonly string[]> | undefined;
|
type CapturedHeaders = Headers | string[][] | Record<string, string | readonly string[]> | undefined;
|
||||||
|
|
||||||
|
interface CapturedResponsesPayload {
|
||||||
|
prompt_cache_key?: string;
|
||||||
|
session_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function getHeader(headers: CapturedHeaders, name: string): string | null {
|
function getHeader(headers: CapturedHeaders, name: string): string | null {
|
||||||
if (!headers) return null;
|
if (!headers) return null;
|
||||||
if (headers instanceof Headers) return headers.get(name);
|
if (headers instanceof Headers) return headers.get(name);
|
||||||
@@ -25,11 +30,20 @@ function getHeader(headers: CapturedHeaders, name: string): string | null {
|
|||||||
async function captureOpenAIResponseHeaders(
|
async function captureOpenAIResponseHeaders(
|
||||||
options: Parameters<typeof streamOpenAIResponses>[2],
|
options: Parameters<typeof streamOpenAIResponses>[2],
|
||||||
model: Model<"openai-responses"> = getModel("openai", "gpt-5.4"),
|
model: Model<"openai-responses"> = getModel("openai", "gpt-5.4"),
|
||||||
): Promise<{ sessionId: string | null; clientRequestId: string | null }> {
|
): Promise<{
|
||||||
const captured = { sessionId: null as string | null, clientRequestId: null as string | null };
|
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) => {
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
|
||||||
captured.sessionId = getHeader(init?.headers, "session_id");
|
captured.sessionId = getHeader(init?.headers, "session_id");
|
||||||
captured.clientRequestId = getHeader(init?.headers, "x-client-request-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", {
|
return new Response("data: [DONE]\n\n", {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-type": "text/event-stream" },
|
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 () => {
|
it("sets cache-affinity headers for official OpenAI Responses requests with a sessionId", async () => {
|
||||||
const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" });
|
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 () => {
|
it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => {
|
||||||
const sessionId = "x".repeat(67);
|
const sessionId = "x".repeat(67);
|
||||||
let capturedPayload: { prompt_cache_key?: string } | undefined;
|
let capturedPayload: Pick<CapturedResponsesPayload, "prompt_cache_key"> | undefined;
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
new Response("data: [DONE]\n\n", {
|
new Response("data: [DONE]\n\n", {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -247,7 +262,7 @@ describe("openai-responses provider defaults", () => {
|
|||||||
apiKey: "test-key",
|
apiKey: "test-key",
|
||||||
sessionId,
|
sessionId,
|
||||||
onPayload: (payload) => {
|
onPayload: (payload) => {
|
||||||
capturedPayload = payload as { prompt_cache_key?: string };
|
capturedPayload = payload as Pick<CapturedResponsesPayload, "prompt_cache_key">;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -267,19 +282,105 @@ describe("openai-responses provider defaults", () => {
|
|||||||
};
|
};
|
||||||
const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }, proxyModel);
|
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"> = {
|
const proxyModel: Model<"openai-responses"> = {
|
||||||
...getModel("openai", "gpt-5.4"),
|
...getModel("openai", "gpt-5.4"),
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
baseUrl: "https://proxy.example.com/v1",
|
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 () => {
|
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 () => {
|
it("omits OpenAI cache-affinity headers when cacheRetention is none", async () => {
|
||||||
const captured = await captureOpenAIResponseHeaders({ cacheRetention: "none", sessionId: "session-123" });
|
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([
|
it.each([
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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).
|
- **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
|
||||||
|
|
||||||
- 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 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 `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.
|
- Fixed `/login amazon-bedrock` to prompt for and save a Bedrock API key instead of only displaying ambient AWS credential setup instructions.
|
||||||
|
|
||||||
|
|||||||
@@ -722,6 +722,8 @@ interface ProviderModelConfig {
|
|||||||
thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling";
|
thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling";
|
||||||
chatTemplateKwargs?: Record<string, string | number | boolean | null | { "$var": "thinking.enabled" | "thinking.effort"; omitWhenOff?: boolean }>;
|
chatTemplateKwargs?: Record<string, string | number | boolean | null | { "$var": "thinking.enabled" | "thinking.effort"; omitWhenOff?: boolean }>;
|
||||||
cacheControlFormat?: "anthropic";
|
cacheControlFormat?: "anthropic";
|
||||||
|
sessionAffinityFormat?: "openai" | "openai-nosession" | "openrouter";
|
||||||
|
sendSessionAffinityHeaders?: boolean;
|
||||||
|
|
||||||
// anthropic-messages
|
// anthropic-messages
|
||||||
supportsEagerToolInputStreaming?: boolean;
|
supportsEagerToolInputStreaming?: boolean;
|
||||||
|
|||||||
@@ -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 |
|
| `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 |
|
| `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. |
|
| `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 |
|
| `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`. |
|
| `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). |
|
| `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). |
|
||||||
|
|||||||
@@ -133,12 +133,18 @@ const OpenAICompletionsCompatSchema = Type.Object({
|
|||||||
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
|
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
|
||||||
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
|
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
|
||||||
supportsStrictMode: Type.Optional(Type.Boolean()),
|
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()),
|
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const OpenAIResponsesCompatSchema = Type.Object({
|
const OpenAIResponsesCompatSchema = Type.Object({
|
||||||
supportsDeveloperRole: Type.Optional(Type.Boolean()),
|
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()),
|
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
|
||||||
supportsToolSearch: Type.Optional(Type.Boolean()),
|
supportsToolSearch: Type.Optional(Type.Boolean()),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user