From f396e252b53788216280eeb00296390167241f01 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 10 Sep 2026 16:06:02 +0300 Subject: [PATCH 1/5] fix(ai): allow prompt cache keys for compatible proxies --- packages/ai/src/api/openai-completions.ts | 9 ++- packages/ai/src/types.ts | 2 + .../openai-completions-prompt-cache.test.ts | 70 +++++++++++++++++++ ...penai-completions-thinking-as-text.test.ts | 2 +- ...nai-completions-tool-result-images.test.ts | 2 +- packages/coding-agent/docs/models.md | 3 + .../coding-agent/src/core/model-config.ts | 1 + 7 files changed, 85 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index f1deefb2b..fd6e4e134 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -181,10 +181,12 @@ type ResolvedOpenAICompletionsCompat = Omit< | "cacheControlFormat" | "deferredToolsMode" | "supportsThinkingTokenBudget" + | "supportsPromptCacheKey" | "thinkingTokenBudgetField" | "vllmPriority" > & { cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; + supportsPromptCacheKey?: OpenAICompletionsCompat["supportsPromptCacheKey"]; deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; supportsThinkingTokenBudget?: OpenAICompletionsCompat["supportsThinkingTokenBudget"]; thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"]; @@ -808,8 +810,10 @@ function buildParams( messages, stream: true, prompt_cache_key: - (model.baseUrl.includes("api.openai.com") && cacheRetention !== "none") || - (cacheRetention === "long" && compat.supportsLongCacheRetention) + cacheRetention !== "none" && + (compat.supportsPromptCacheKey ?? + (model.baseUrl.includes("api.openai.com") || + (cacheRetention === "long" && compat.supportsLongCacheRetention))) ? clampOpenAIPromptCacheKey(options?.sessionId) : undefined, prompt_cache_retention: cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined, @@ -1712,6 +1716,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion deferredToolsMode: model.compat.deferredToolsMode ?? detected.deferredToolsMode, sessionAffinityFormat: model.compat.sessionAffinityFormat ?? detected.sessionAffinityFormat, supportsLongCacheRetention: model.compat.supportsLongCacheRetention ?? detected.supportsLongCacheRetention, + supportsPromptCacheKey: model.compat.supportsPromptCacheKey, vllmPriority: model.compat.vllmPriority, }; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index d45cc3c4e..c1058eee6 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -631,6 +631,8 @@ export interface OpenAICompletionsCompat { deferredToolsMode?: "kimi"; /** 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 to send `prompt_cache_key` from `options.sessionId` when caching is enabled, independently of long retention support. When unset, enabled for direct OpenAI URLs or compatible long retention requests. */ + supportsPromptCacheKey?: boolean; /** 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; /** diff --git a/packages/ai/test/openai-completions-prompt-cache.test.ts b/packages/ai/test/openai-completions-prompt-cache.test.ts index 2095fa28f..351c684f0 100644 --- a/packages/ai/test/openai-completions-prompt-cache.test.ts +++ b/packages/ai/test/openai-completions-prompt-cache.test.ts @@ -151,6 +151,76 @@ describe("openai-completions prompt caching", () => { expect(payload?.prompt_cache_retention).toBeUndefined(); }); + it.each([undefined, "short", "long"] as const)( + "sends a stable cache key through an opted-in proxy with retention %s", + async (cacheRetention) => { + const model = createModel({ + baseUrl: "https://proxy.example.com/v1", + compat: { supportsPromptCacheKey: true, supportsLongCacheRetention: false }, + }); + const options = { cacheRetention, sessionId: "session-proxy" }; + const first = await captureRequest(options, model); + const repeated = await captureRequest(options, model); + const other = await captureRequest({ ...options, sessionId: "other-session" }, model); + + expect(first.payload?.prompt_cache_key).toBe("session-proxy"); + expect(repeated.payload?.prompt_cache_key).toBe(first.payload?.prompt_cache_key); + expect(other.payload?.prompt_cache_key).toBe("other-session"); + expect(first.payload?.prompt_cache_retention).toBeUndefined(); + expect(first.headers.session_id).toBeUndefined(); + }, + ); + + it("preserves default proxy behavior without an explicit cache-key capability", async () => { + const model = createModel({ baseUrl: "https://proxy.example.com/v1" }); + const short = await captureRequest({ sessionId: "session-proxy" }, model); + const long = await captureRequest({ cacheRetention: "long", sessionId: "session-proxy" }, model); + + expect(short.payload?.prompt_cache_key).toBeUndefined(); + expect(long.payload?.prompt_cache_key).toBe("session-proxy"); + expect(long.payload?.prompt_cache_retention).toBe("24h"); + }); + + it("respects disabled caching on an opted-in proxy", async () => { + process.env.PI_CACHE_RETENTION = "long"; + const model = createModel({ + baseUrl: "https://proxy.example.com/v1", + compat: { supportsPromptCacheKey: true }, + }); + const { payload } = await captureRequest({ cacheRetention: "none", sessionId: "session-proxy" }, model); + + expect(payload?.prompt_cache_key).toBeUndefined(); + expect(payload?.prompt_cache_retention).toBeUndefined(); + }); + + it.each(["short", "long"] as const)("allows disabling cache keys with %s retention", async (cacheRetention) => { + const model = createModel({ compat: { supportsPromptCacheKey: false } }); + const { payload } = await captureRequest({ cacheRetention, sessionId: "session-disabled" }, model); + + expect(payload?.prompt_cache_key).toBeUndefined(); + expect(payload?.prompt_cache_retention).toBe(cacheRetention === "long" ? "24h" : undefined); + }); + + it("does not invent an identity for an opted-in proxy", async () => { + const model = createModel({ + baseUrl: "https://proxy.example.com/v1", + compat: { supportsPromptCacheKey: true }, + }); + const { payload } = await captureRequest({}, model); + + expect(payload?.prompt_cache_key).toBeUndefined(); + }); + + it("clamps proxy cache keys to 64 Unicode characters", async () => { + const model = createModel({ + baseUrl: "https://proxy.example.com/v1", + compat: { supportsPromptCacheKey: true }, + }); + const { payload } = await captureRequest({ sessionId: "𐐀".repeat(67) }, model); + + expect(payload?.prompt_cache_key).toBe("𐐀".repeat(64)); + }); + it("uses PI_CACHE_RETENTION for direct OpenAI requests", async () => { process.env.PI_CACHE_RETENTION = "long"; const { payload } = await captureRequest({ sessionId: "session-env" }); 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 eabaed1c5..3353d8bed 100644 --- a/packages/ai/test/openai-completions-thinking-as-text.test.ts +++ b/packages/ai/test/openai-completions-thinking-as-text.test.ts @@ -48,7 +48,7 @@ const compat = { supportsLongCacheRetention: true, } satisfies Omit< Required, - "cacheControlFormat" | "deferredToolsMode" | "thinkingTokenBudgetField" | "vllmPriority" + "cacheControlFormat" | "deferredToolsMode" | "supportsPromptCacheKey" | "thinkingTokenBudgetField" | "vllmPriority" > & { cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; 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 02de0c623..751b78025 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -21,7 +21,7 @@ const emptyUsage: Usage = { const compat: Omit< Required, - "deferredToolsMode" | "thinkingTokenBudgetField" | "vllmPriority" + "deferredToolsMode" | "supportsPromptCacheKey" | "thinkingTokenBudgetField" | "vllmPriority" > & { deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"]; diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 3cf2ad3ea..f0db37055 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -482,6 +482,7 @@ For providers with partial OpenAI compatibility, use the `compat` field. | `supportsStrictMode` | Whether the provider accepts strict JSON-schema function tool definitions. Defaults depend on the API; built-in OpenAI models carry explicit capability metadata. | | `supportsOpenAIGrammarTools` | Whether OpenAI-compatible APIs emit custom Lark/regex grammar tools. When `false`, grammar-constrained tools fall back to normal function tools. Default: `false`; the built-in model catalog enables it for GPT-5+ models on OpenAI, OpenAI Codex, Azure OpenAI, GitHub Copilot, opencode, and Cloudflare AI Gateway. | | `deferredToolsMode` | Use provider-specific deferred tool serialization. Currently only `"kimi"` is supported for Kimi's OpenAI-compatible Chat Completions format. | +| `supportsPromptCacheKey` | For `openai-completions`, send `prompt_cache_key` from the session id whenever caching is enabled, independently of long retention support. Set to `true` for compatible proxies or `false` to omit it. When unset, pi sends the key for direct OpenAI URLs or compatible long retention requests. | | `supportsLongCacheRetention` | Whether the provider accepts long cache retention when cache retention is `long`: `prompt_cache_options.ttl: "30m"` for GPT-5.6+ Responses models, `prompt_cache_retention: "24h"` for earlier OpenAI models, 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). | | `vercelGatewayRouting` | Vercel AI Gateway routing config for provider selection (`only`, `order`) | @@ -490,6 +491,8 @@ For providers with partial OpenAI compatibility, use the `compat` field. `thinkingTokenBudgetField` is independent of `thinkingFormat`. Do not enable it on the generated Qwen catalog: those models already send `reasoning_effort`, and DashScope rejects `thinking_budget` together with `reasoning_effort`. +For a Chat Completions proxy that accepts `prompt_cache_key`, set `compat.supportsPromptCacheKey` to `true`. This forwards a stable session key with the default short cache retention; it does not require `PI_CACHE_RETENTION=long` or send `prompt_cache_retention: "24h"`. Set `supportsLongCacheRetention` to `false` if the proxy does not accept long retention. `cacheRetention: "none"` still omits the cache key, and session-affinity headers remain separately configured. + `cacheControlFormat: "anthropic"` is for OpenAI-compatible providers that expose Anthropic-style prompt caching through `cache_control` markers on text content and tool definitions. Example: diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index bf5a8467c..4de7c2680 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -108,6 +108,7 @@ const OpenAICompletionsCompatSchema = Type.Object({ sessionAffinityFormat: Type.Optional( Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]), ), + supportsPromptCacheKey: Type.Optional(Type.Boolean()), supportsLongCacheRetention: Type.Optional(Type.Boolean()), vllmPriority: Type.Optional(Type.Number()), }); From 89996a72cfadc8d17dc690f25678df8163dc0544 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 10 Sep 2026 16:14:32 +0300 Subject: [PATCH 2/5] test(ai): cover prompt cache configuration overrides --- packages/ai/src/api/openai-completions.ts | 8 ++-- .../openai-completions-prompt-cache.test.ts | 11 ++++++ .../coding-agent/test/model-registry.test.ts | 37 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index fd6e4e134..6ce296a68 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -804,16 +804,16 @@ function buildParams( ) { const messages = convertMessages(model, context, compat, { grammarToolInputProperties }); const cacheControl = getCompatCacheControl(compat, cacheRetention); + const supportsPromptCacheKey = + compat.supportsPromptCacheKey ?? + (model.baseUrl.includes("api.openai.com") || (cacheRetention === "long" && compat.supportsLongCacheRetention)); const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: model.id, messages, stream: true, prompt_cache_key: - cacheRetention !== "none" && - (compat.supportsPromptCacheKey ?? - (model.baseUrl.includes("api.openai.com") || - (cacheRetention === "long" && compat.supportsLongCacheRetention))) + cacheRetention !== "none" && supportsPromptCacheKey ? clampOpenAIPromptCacheKey(options?.sessionId) : undefined, prompt_cache_retention: cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined, diff --git a/packages/ai/test/openai-completions-prompt-cache.test.ts b/packages/ai/test/openai-completions-prompt-cache.test.ts index 351c684f0..ff1d63e6f 100644 --- a/packages/ai/test/openai-completions-prompt-cache.test.ts +++ b/packages/ai/test/openai-completions-prompt-cache.test.ts @@ -201,6 +201,17 @@ describe("openai-completions prompt caching", () => { expect(payload?.prompt_cache_retention).toBe(cacheRetention === "long" ? "24h" : undefined); }); + it("lets a proxy disable cache keys independently of long retention", async () => { + const model = createModel({ + baseUrl: "https://proxy.example.com/v1", + compat: { supportsPromptCacheKey: false, supportsLongCacheRetention: true }, + }); + const { payload } = await captureRequest({ cacheRetention: "long", sessionId: "session-proxy" }, model); + + expect(payload?.prompt_cache_key).toBeUndefined(); + expect(payload?.prompt_cache_retention).toBe("24h"); + }); + it("does not invent an identity for an opted-in proxy", async () => { const model = createModel({ baseUrl: "https://proxy.example.com/v1", diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 35dd8f954..119a7c331 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -555,6 +555,27 @@ describe("ModelRegistry", () => { expect(compat?.supportsEagerToolInputStreaming).toBe(false); }); + test("custom models inherit and override prompt cache key support independently of long retention", async () => { + const provider: ModelsJsonProvider = { + baseUrl: "https://proxy.example.com/v1", + api: "openai-completions", + compat: { supportsPromptCacheKey: true, supportsLongCacheRetention: false }, + models: [{ id: "inherited" }, { id: "disabled", compat: { supportsPromptCacheKey: false } }], + }; + writeRawModelsJson({ demo: provider }); + + const registry = await createModelRegistry(authStorage, modelsJsonPath); + expect(registry.getError()).toBeUndefined(); + expect(registry.find("demo", "inherited")?.compat).toMatchObject({ + supportsPromptCacheKey: true, + supportsLongCacheRetention: false, + }); + expect(registry.find("demo", "disabled")?.compat).toMatchObject({ + supportsPromptCacheKey: false, + supportsLongCacheRetention: false, + }); + }); + test("compat schema accepts long cache retention flag", async () => { writeRawModelsJson({ demo: { @@ -788,6 +809,22 @@ describe("ModelRegistry", () => { expect((opus?.compat as OpenAICompletionsCompat | undefined)?.supportsFinishReason).toBe(true); }); + test("built-in models inherit prompt cache key support unless overridden", async () => { + const provider: ModelsJsonProvider = { + baseUrl: "https://proxy.example.com/v1", + compat: { supportsPromptCacheKey: true }, + modelOverrides: { + "gpt-4o-mini": { compat: { supportsPromptCacheKey: false } }, + }, + }; + writeRawModelsJson({ openai: provider }); + + const registry = await createModelRegistry(authStorage, modelsJsonPath); + expect(registry.getError()).toBeUndefined(); + expect(registry.find("openai", "gpt-4o")?.compat).toMatchObject({ supportsPromptCacheKey: true }); + expect(registry.find("openai", "gpt-4o-mini")?.compat).toMatchObject({ supportsPromptCacheKey: false }); + }); + test("model override deep merges compat settings", async () => { writeRawModelsJson({ openrouter: { From ade35a6c76ce641367a59e09908d3a5deefc5670 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 11 Sep 2026 15:09:52 +0300 Subject: [PATCH 3/5] fix(ai): make prompt cache key policy explicit --- packages/ai/src/api/openai-completions.ts | 13 ++++++++----- packages/ai/src/types.ts | 4 ++-- .../openai-completions-prompt-cache.test.ts | 19 +++++++++++-------- ...penai-completions-thinking-as-text.test.ts | 2 +- ...nai-completions-tool-result-images.test.ts | 2 +- packages/coding-agent/docs/models.md | 4 ++-- .../coding-agent/src/core/model-config.ts | 4 +++- .../coding-agent/test/model-registry.test.ts | 18 +++++++++--------- 8 files changed, 37 insertions(+), 29 deletions(-) diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 6ce296a68..94112e87c 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -181,12 +181,12 @@ type ResolvedOpenAICompletionsCompat = Omit< | "cacheControlFormat" | "deferredToolsMode" | "supportsThinkingTokenBudget" - | "supportsPromptCacheKey" + | "promptCacheKeyMode" | "thinkingTokenBudgetField" | "vllmPriority" > & { cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; - supportsPromptCacheKey?: OpenAICompletionsCompat["supportsPromptCacheKey"]; + promptCacheKeyMode?: OpenAICompletionsCompat["promptCacheKeyMode"]; deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; supportsThinkingTokenBudget?: OpenAICompletionsCompat["supportsThinkingTokenBudget"]; thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"]; @@ -804,9 +804,12 @@ function buildParams( ) { const messages = convertMessages(model, context, compat, { grammarToolInputProperties }); const cacheControl = getCompatCacheControl(compat, cacheRetention); + const promptCacheKeyMode = compat.promptCacheKeyMode ?? "auto"; const supportsPromptCacheKey = - compat.supportsPromptCacheKey ?? - (model.baseUrl.includes("api.openai.com") || (cacheRetention === "long" && compat.supportsLongCacheRetention)); + promptCacheKeyMode === "enabled" || + (promptCacheKeyMode === "auto" && + (model.baseUrl.includes("api.openai.com") || + (cacheRetention === "long" && compat.supportsLongCacheRetention))); const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: model.id, @@ -1716,7 +1719,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion deferredToolsMode: model.compat.deferredToolsMode ?? detected.deferredToolsMode, sessionAffinityFormat: model.compat.sessionAffinityFormat ?? detected.sessionAffinityFormat, supportsLongCacheRetention: model.compat.supportsLongCacheRetention ?? detected.supportsLongCacheRetention, - supportsPromptCacheKey: model.compat.supportsPromptCacheKey, + promptCacheKeyMode: model.compat.promptCacheKeyMode, vllmPriority: model.compat.vllmPriority, }; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index c1058eee6..f1d7590a9 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -631,8 +631,8 @@ export interface OpenAICompletionsCompat { deferredToolsMode?: "kimi"; /** 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 to send `prompt_cache_key` from `options.sessionId` when caching is enabled, independently of long retention support. When unset, enabled for direct OpenAI URLs or compatible long retention requests. */ - supportsPromptCacheKey?: boolean; + /** Controls `prompt_cache_key`: `auto` preserves URL/retention detection, `enabled` always sends it when caching is active, and `disabled` always omits it. Default: `auto`. */ + promptCacheKeyMode?: "auto" | "enabled" | "disabled"; /** 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; /** diff --git a/packages/ai/test/openai-completions-prompt-cache.test.ts b/packages/ai/test/openai-completions-prompt-cache.test.ts index ff1d63e6f..4b9f18768 100644 --- a/packages/ai/test/openai-completions-prompt-cache.test.ts +++ b/packages/ai/test/openai-completions-prompt-cache.test.ts @@ -156,7 +156,7 @@ describe("openai-completions prompt caching", () => { async (cacheRetention) => { const model = createModel({ baseUrl: "https://proxy.example.com/v1", - compat: { supportsPromptCacheKey: true, supportsLongCacheRetention: false }, + compat: { promptCacheKeyMode: "enabled", supportsLongCacheRetention: false }, }); const options = { cacheRetention, sessionId: "session-proxy" }; const first = await captureRequest(options, model); @@ -171,8 +171,11 @@ describe("openai-completions prompt caching", () => { }, ); - it("preserves default proxy behavior without an explicit cache-key capability", async () => { - const model = createModel({ baseUrl: "https://proxy.example.com/v1" }); + it.each([undefined, "auto"] as const)("preserves default proxy behavior with mode %s", async (mode) => { + const model = createModel({ + baseUrl: "https://proxy.example.com/v1", + compat: mode ? { promptCacheKeyMode: mode } : undefined, + }); const short = await captureRequest({ sessionId: "session-proxy" }, model); const long = await captureRequest({ cacheRetention: "long", sessionId: "session-proxy" }, model); @@ -185,7 +188,7 @@ describe("openai-completions prompt caching", () => { process.env.PI_CACHE_RETENTION = "long"; const model = createModel({ baseUrl: "https://proxy.example.com/v1", - compat: { supportsPromptCacheKey: true }, + compat: { promptCacheKeyMode: "enabled" }, }); const { payload } = await captureRequest({ cacheRetention: "none", sessionId: "session-proxy" }, model); @@ -194,7 +197,7 @@ describe("openai-completions prompt caching", () => { }); it.each(["short", "long"] as const)("allows disabling cache keys with %s retention", async (cacheRetention) => { - const model = createModel({ compat: { supportsPromptCacheKey: false } }); + const model = createModel({ compat: { promptCacheKeyMode: "disabled" } }); const { payload } = await captureRequest({ cacheRetention, sessionId: "session-disabled" }, model); expect(payload?.prompt_cache_key).toBeUndefined(); @@ -204,7 +207,7 @@ describe("openai-completions prompt caching", () => { it("lets a proxy disable cache keys independently of long retention", async () => { const model = createModel({ baseUrl: "https://proxy.example.com/v1", - compat: { supportsPromptCacheKey: false, supportsLongCacheRetention: true }, + compat: { promptCacheKeyMode: "disabled", supportsLongCacheRetention: true }, }); const { payload } = await captureRequest({ cacheRetention: "long", sessionId: "session-proxy" }, model); @@ -215,7 +218,7 @@ describe("openai-completions prompt caching", () => { it("does not invent an identity for an opted-in proxy", async () => { const model = createModel({ baseUrl: "https://proxy.example.com/v1", - compat: { supportsPromptCacheKey: true }, + compat: { promptCacheKeyMode: "enabled" }, }); const { payload } = await captureRequest({}, model); @@ -225,7 +228,7 @@ describe("openai-completions prompt caching", () => { it("clamps proxy cache keys to 64 Unicode characters", async () => { const model = createModel({ baseUrl: "https://proxy.example.com/v1", - compat: { supportsPromptCacheKey: true }, + compat: { promptCacheKeyMode: "enabled" }, }); const { payload } = await captureRequest({ sessionId: "𐐀".repeat(67) }, model); 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 3353d8bed..f69bd1d1e 100644 --- a/packages/ai/test/openai-completions-thinking-as-text.test.ts +++ b/packages/ai/test/openai-completions-thinking-as-text.test.ts @@ -48,7 +48,7 @@ const compat = { supportsLongCacheRetention: true, } satisfies Omit< Required, - "cacheControlFormat" | "deferredToolsMode" | "supportsPromptCacheKey" | "thinkingTokenBudgetField" | "vllmPriority" + "cacheControlFormat" | "deferredToolsMode" | "promptCacheKeyMode" | "thinkingTokenBudgetField" | "vllmPriority" > & { cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; 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 751b78025..d0c3df397 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -21,7 +21,7 @@ const emptyUsage: Usage = { const compat: Omit< Required, - "deferredToolsMode" | "supportsPromptCacheKey" | "thinkingTokenBudgetField" | "vllmPriority" + "deferredToolsMode" | "promptCacheKeyMode" | "thinkingTokenBudgetField" | "vllmPriority" > & { deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"]; diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index f0db37055..f989a5abc 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -482,7 +482,7 @@ For providers with partial OpenAI compatibility, use the `compat` field. | `supportsStrictMode` | Whether the provider accepts strict JSON-schema function tool definitions. Defaults depend on the API; built-in OpenAI models carry explicit capability metadata. | | `supportsOpenAIGrammarTools` | Whether OpenAI-compatible APIs emit custom Lark/regex grammar tools. When `false`, grammar-constrained tools fall back to normal function tools. Default: `false`; the built-in model catalog enables it for GPT-5+ models on OpenAI, OpenAI Codex, Azure OpenAI, GitHub Copilot, opencode, and Cloudflare AI Gateway. | | `deferredToolsMode` | Use provider-specific deferred tool serialization. Currently only `"kimi"` is supported for Kimi's OpenAI-compatible Chat Completions format. | -| `supportsPromptCacheKey` | For `openai-completions`, send `prompt_cache_key` from the session id whenever caching is enabled, independently of long retention support. Set to `true` for compatible proxies or `false` to omit it. When unset, pi sends the key for direct OpenAI URLs or compatible long retention requests. | +| `promptCacheKeyMode` | Controls `prompt_cache_key` for `openai-completions`: `auto` preserves URL/retention detection, `enabled` sends it whenever caching is active, and `disabled` omits it. Default: `auto`. | | `supportsLongCacheRetention` | Whether the provider accepts long cache retention when cache retention is `long`: `prompt_cache_options.ttl: "30m"` for GPT-5.6+ Responses models, `prompt_cache_retention: "24h"` for earlier OpenAI models, 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). | | `vercelGatewayRouting` | Vercel AI Gateway routing config for provider selection (`only`, `order`) | @@ -491,7 +491,7 @@ For providers with partial OpenAI compatibility, use the `compat` field. `thinkingTokenBudgetField` is independent of `thinkingFormat`. Do not enable it on the generated Qwen catalog: those models already send `reasoning_effort`, and DashScope rejects `thinking_budget` together with `reasoning_effort`. -For a Chat Completions proxy that accepts `prompt_cache_key`, set `compat.supportsPromptCacheKey` to `true`. This forwards a stable session key with the default short cache retention; it does not require `PI_CACHE_RETENTION=long` or send `prompt_cache_retention: "24h"`. Set `supportsLongCacheRetention` to `false` if the proxy does not accept long retention. `cacheRetention: "none"` still omits the cache key, and session-affinity headers remain separately configured. +For a Chat Completions proxy that accepts `prompt_cache_key`, set `compat.promptCacheKeyMode` to `enabled`. This forwards a stable session key with the default short cache retention; it does not require `PI_CACHE_RETENTION=long` or send `prompt_cache_retention: "24h"`. Set `supportsLongCacheRetention` to `false` if the proxy does not accept long retention. `cacheRetention: "none"` still omits the cache key, and session-affinity headers remain separately configured. `cacheControlFormat: "anthropic"` is for OpenAI-compatible providers that expose Anthropic-style prompt caching through `cache_control` markers on text content and tool definitions. diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index 4de7c2680..b73faed59 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -108,7 +108,9 @@ const OpenAICompletionsCompatSchema = Type.Object({ sessionAffinityFormat: Type.Optional( Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]), ), - supportsPromptCacheKey: Type.Optional(Type.Boolean()), + promptCacheKeyMode: Type.Optional( + Type.Union([Type.Literal("auto"), Type.Literal("enabled"), Type.Literal("disabled")]), + ), supportsLongCacheRetention: Type.Optional(Type.Boolean()), vllmPriority: Type.Optional(Type.Number()), }); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 119a7c331..e172d8e05 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -555,23 +555,23 @@ describe("ModelRegistry", () => { expect(compat?.supportsEagerToolInputStreaming).toBe(false); }); - test("custom models inherit and override prompt cache key support independently of long retention", async () => { + test("custom models inherit and override prompt cache key mode independently of long retention", async () => { const provider: ModelsJsonProvider = { baseUrl: "https://proxy.example.com/v1", api: "openai-completions", - compat: { supportsPromptCacheKey: true, supportsLongCacheRetention: false }, - models: [{ id: "inherited" }, { id: "disabled", compat: { supportsPromptCacheKey: false } }], + compat: { promptCacheKeyMode: "enabled", supportsLongCacheRetention: false }, + models: [{ id: "inherited" }, { id: "disabled", compat: { promptCacheKeyMode: "disabled" } }], }; writeRawModelsJson({ demo: provider }); const registry = await createModelRegistry(authStorage, modelsJsonPath); expect(registry.getError()).toBeUndefined(); expect(registry.find("demo", "inherited")?.compat).toMatchObject({ - supportsPromptCacheKey: true, + promptCacheKeyMode: "enabled", supportsLongCacheRetention: false, }); expect(registry.find("demo", "disabled")?.compat).toMatchObject({ - supportsPromptCacheKey: false, + promptCacheKeyMode: "disabled", supportsLongCacheRetention: false, }); }); @@ -812,17 +812,17 @@ describe("ModelRegistry", () => { test("built-in models inherit prompt cache key support unless overridden", async () => { const provider: ModelsJsonProvider = { baseUrl: "https://proxy.example.com/v1", - compat: { supportsPromptCacheKey: true }, + compat: { promptCacheKeyMode: "enabled" }, modelOverrides: { - "gpt-4o-mini": { compat: { supportsPromptCacheKey: false } }, + "gpt-4o-mini": { compat: { promptCacheKeyMode: "disabled" } }, }, }; writeRawModelsJson({ openai: provider }); const registry = await createModelRegistry(authStorage, modelsJsonPath); expect(registry.getError()).toBeUndefined(); - expect(registry.find("openai", "gpt-4o")?.compat).toMatchObject({ supportsPromptCacheKey: true }); - expect(registry.find("openai", "gpt-4o-mini")?.compat).toMatchObject({ supportsPromptCacheKey: false }); + expect(registry.find("openai", "gpt-4o")?.compat).toMatchObject({ promptCacheKeyMode: "enabled" }); + expect(registry.find("openai", "gpt-4o-mini")?.compat).toMatchObject({ promptCacheKeyMode: "disabled" }); }); test("model override deep merges compat settings", async () => { From 864aa713e0ec5702a59d1cf970b14dcda5e6f993 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 11 Sep 2026 15:17:45 +0300 Subject: [PATCH 4/5] fix(ai): align DeepSeek tests with generated catalog --- .../openai-completions-tool-choice.test.ts | 5 +-- packages/ai/test/stream.test.ts | 39 +++++++++---------- packages/ai/test/supports-xhigh.test.ts | 4 +- 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index e3fdf7b21..065bd4a22 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1483,10 +1483,7 @@ describe("openai-completions tool_choice", () => { name: "Custom Uppercase DeepSeek Model", baseUrl: "https://API.DeepSeek.COM", } satisfies Model<"openai-completions">; - const nativeModels = [ - getModel("deepseek", "deepseek-v4-flash")!, - getModel("deepseek", "deepseek-v4-pro")!, - ] as const; + const nativeModels = [getModel("deepseek", "deepseek-flash")!, getModel("deepseek", "deepseek-v4-pro")!] as const; const cases = [...nativeModels, customModel, customUppercaseModel] as const; for (const model of nativeModels) { diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index f20709912..8450b52bd 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -445,32 +445,29 @@ describe("Generate E2E Tests", () => { }); }); - describe.skipIf(!process.env.DEEPSEEK_API_KEY)( - "DeepSeek Provider (deepseek-v4-flash via OpenAI Completions)", - () => { - const llm = getModel("deepseek", "deepseek-v4-flash"); + describe.skipIf(!process.env.DEEPSEEK_API_KEY)("DeepSeek Provider (deepseek-flash via OpenAI Completions)", () => { + const llm = getModel("deepseek", "deepseek-flash"); - it("should complete basic text generation", { retry: 3 }, async () => { - await basicTextGeneration(llm); - }); + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); - it("should handle tool calling", { retry: 3 }, async () => { - await handleToolCall(llm); - }); + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); - it("should handle streaming", { retry: 3 }, async () => { - await handleStreaming(llm); - }); + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); - it("should handle thinking mode", { retry: 3 }, async () => { - await handleThinking(llm, { reasoningEffort: "high" }); - }); + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, { reasoningEffort: "high" }); + }); - it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { - await multiTurn(llm, { reasoningEffort: "high" }); - }); - }, - ); + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, { reasoningEffort: "high" }); + }); + }); describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Responses Provider (gpt-5.4)", () => { const llm = getModel("openai", "gpt-5.4"); diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index 4adc03166..a1e43c40b 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -82,8 +82,8 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toEqual(["medium", "high", "xhigh"]); }); - it("includes low/high/max plus off for DeepSeek V4 Flash on the DeepSeek provider", () => { - const model = getModel("deepseek", "deepseek-v4-flash"); + it("includes low/high/max plus off for DeepSeek V4.1 Flash on the DeepSeek provider", () => { + const model = getModel("deepseek", "deepseek-flash"); expect(model).toBeDefined(); expect(getSupportedThinkingLevels(model!)).toEqual(["off", "low", "high", "max"]); }); From 490bebc994d39d6be5b33c00ad9426696a535a0b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 11 Sep 2026 15:48:02 +0300 Subject: [PATCH 5/5] fix(ai): align Responses prompt cache policy --- packages/ai/src/api/openai-responses.ts | 4 +- packages/ai/src/types.ts | 2 + .../openai-responses-prompt-cache.test.ts | 71 +++++++++++++++++++ packages/coding-agent/docs/models.md | 4 +- .../coding-agent/src/core/model-config.ts | 3 + 5 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 packages/ai/test/openai-responses-prompt-cache.test.ts diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 685589115..6244afbc5 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -69,6 +69,7 @@ function getCompat(model: Model<"openai-responses">): Required { + return { + id: "test-model", + name: "Test Model", + api: "openai-responses", + provider: "test-proxy", + baseUrl: "https://proxy.example.com/v1", + compat: { promptCacheKeyMode }, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +async function capturePayload( + promptCacheKeyMode?: OpenAIResponsesCompat["promptCacheKeyMode"], + cacheRetention?: "none" | "short" | "long", +): Promise> { + let payload: Record | undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string | URL, init?: RequestInit) => { + payload = JSON.parse(String(init?.body)); + return new Response( + 'data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2,"input_tokens_details":{"cached_tokens":0}}}}\n\n', + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }), + ); + + await stream(createModel(promptCacheKeyMode), context, { + apiKey: "test-key", + sessionId: "session-123", + cacheRetention, + }).result(); + + if (!payload) throw new Error("Expected request payload"); + return payload; +} + +describe("OpenAI Responses prompt cache key policy", () => { + it("preserves the existing default behavior", async () => { + expect((await capturePayload()).prompt_cache_key).toBe("session-123"); + }); + + it("emits the key when enabled", async () => { + expect((await capturePayload("enabled")).prompt_cache_key).toBe("session-123"); + }); + + it("omits the key when disabled", async () => { + expect((await capturePayload("disabled")).prompt_cache_key).toBeUndefined(); + }); + + it("lets cacheRetention none override enabled mode", async () => { + expect((await capturePayload("enabled", "none")).prompt_cache_key).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index f989a5abc..9fd4cee71 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -482,7 +482,7 @@ For providers with partial OpenAI compatibility, use the `compat` field. | `supportsStrictMode` | Whether the provider accepts strict JSON-schema function tool definitions. Defaults depend on the API; built-in OpenAI models carry explicit capability metadata. | | `supportsOpenAIGrammarTools` | Whether OpenAI-compatible APIs emit custom Lark/regex grammar tools. When `false`, grammar-constrained tools fall back to normal function tools. Default: `false`; the built-in model catalog enables it for GPT-5+ models on OpenAI, OpenAI Codex, Azure OpenAI, GitHub Copilot, opencode, and Cloudflare AI Gateway. | | `deferredToolsMode` | Use provider-specific deferred tool serialization. Currently only `"kimi"` is supported for Kimi's OpenAI-compatible Chat Completions format. | -| `promptCacheKeyMode` | Controls `prompt_cache_key` for `openai-completions`: `auto` preserves URL/retention detection, `enabled` sends it whenever caching is active, and `disabled` omits it. Default: `auto`. | +| `promptCacheKeyMode` | Controls `prompt_cache_key` for `openai-completions` and `openai-responses`: `auto` preserves each adapter's existing behavior, `enabled` sends it whenever caching is active, and `disabled` omits it. Default: `auto`. | | `supportsLongCacheRetention` | Whether the provider accepts long cache retention when cache retention is `long`: `prompt_cache_options.ttl: "30m"` for GPT-5.6+ Responses models, `prompt_cache_retention: "24h"` for earlier OpenAI models, 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). | | `vercelGatewayRouting` | Vercel AI Gateway routing config for provider selection (`only`, `order`) | @@ -491,7 +491,7 @@ For providers with partial OpenAI compatibility, use the `compat` field. `thinkingTokenBudgetField` is independent of `thinkingFormat`. Do not enable it on the generated Qwen catalog: those models already send `reasoning_effort`, and DashScope rejects `thinking_budget` together with `reasoning_effort`. -For a Chat Completions proxy that accepts `prompt_cache_key`, set `compat.promptCacheKeyMode` to `enabled`. This forwards a stable session key with the default short cache retention; it does not require `PI_CACHE_RETENTION=long` or send `prompt_cache_retention: "24h"`. Set `supportsLongCacheRetention` to `false` if the proxy does not accept long retention. `cacheRetention: "none"` still omits the cache key, and session-affinity headers remain separately configured. +For a compatible proxy that accepts `prompt_cache_key`, set `compat.promptCacheKeyMode` to `enabled`. This forwards a stable session key with the default short cache retention; it does not require `PI_CACHE_RETENTION=long` or send `prompt_cache_retention: "24h"`. Set it to `disabled` for a Responses-compatible proxy that rejects the field. Set `supportsLongCacheRetention` to `false` if the proxy does not accept long retention. `cacheRetention: "none"` still omits the cache key, and session-affinity headers remain separately configured. `cacheControlFormat: "anthropic"` is for OpenAI-compatible providers that expose Anthropic-style prompt caching through `cache_control` markers on text content and tool definitions. diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index b73faed59..3a5cba516 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -120,6 +120,9 @@ const OpenAIResponsesCompatSchema = Type.Object({ sessionAffinityFormat: Type.Optional( Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]), ), + promptCacheKeyMode: Type.Optional( + Type.Union([Type.Literal("auto"), Type.Literal("enabled"), Type.Literal("disabled")]), + ), supportsLongCacheRetention: Type.Optional(Type.Boolean()), supportsStrictMode: Type.Optional(Type.Boolean()), supportsOpenAIGrammarTools: Type.Optional(Type.Boolean()),