From 7be75badede41576e0b8a751ac746f048a39357b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 19 May 2026 10:51:11 +0200 Subject: [PATCH] fix(ai): clamp OpenAI prompt cache keys closes #4720 --- packages/ai/CHANGELOG.md | 4 ++ .../src/providers/azure-openai-responses.ts | 3 +- .../src/providers/openai-codex-responses.ts | 3 +- .../ai/src/providers/openai-completions.ts | 3 +- .../ai/src/providers/openai-prompt-cache.ts | 8 +++ packages/ai/src/providers/openai-responses.ts | 3 +- .../ai/test/azure-openai-base-url.test.ts | 20 +++++++- packages/ai/test/openai-codex-stream.test.ts | 50 +++++++++++++++++++ .../openai-completions-prompt-cache.test.ts | 7 +++ .../openai-responses-copilot-provider.test.ts | 32 ++++++++++++ 10 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 packages/ai/src/providers/openai-prompt-cache.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2f5bd8ad..39a71698 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenAI prompt cache keys to clamp session-derived values to the 64-character API limit across OpenAI Responses, Chat Completions, Codex Responses, and Azure OpenAI Responses ([#4720](https://github.com/earendil-works/pi/issues/4720)). + ## [0.75.3] - 2026-05-18 ## [0.75.2] - 2026-05-18 diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 918ff9f5..5625dbd3 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -13,6 +13,7 @@ import type { } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { headersToRecord } from "../utils/headers.js"; +import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js"; import { buildBaseOptions } from "./simple-options.js"; @@ -261,7 +262,7 @@ function buildParams( model: deploymentName, input: messages, stream: true, - prompt_cache_key: options?.sessionId, + prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId), }; if (options?.maxTokens) { diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index dc8d8ef7..ab2ba789 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -40,6 +40,7 @@ import { } from "../utils/diagnostics.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { headersToRecord } from "../utils/headers.js"; +import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js"; import { buildBaseOptions } from "./simple-options.js"; @@ -378,7 +379,7 @@ function buildRequestBody( input: messages, text: { verbosity: options?.textVerbosity || "low" }, include: ["reasoning.encrypted_content"], - prompt_cache_key: options?.sessionId, + prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId), tool_choice: "auto", parallel_tool_calls: true, }; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index a89b6dd9..658d4179 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -36,6 +36,7 @@ import { parseStreamingJson } from "../utils/json-parse.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; +import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js"; import { buildBaseOptions } from "./simple-options.js"; import { transformMessages } from "./transform-messages.js"; @@ -516,7 +517,7 @@ function buildParams( prompt_cache_key: (model.baseUrl.includes("api.openai.com") && cacheRetention !== "none") || (cacheRetention === "long" && compat.supportsLongCacheRetention) - ? options?.sessionId + ? clampOpenAIPromptCacheKey(options?.sessionId) : undefined, prompt_cache_retention: cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined, }; diff --git a/packages/ai/src/providers/openai-prompt-cache.ts b/packages/ai/src/providers/openai-prompt-cache.ts new file mode 100644 index 00000000..1c3355d5 --- /dev/null +++ b/packages/ai/src/providers/openai-prompt-cache.ts @@ -0,0 +1,8 @@ +export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64; + +export function clampOpenAIPromptCacheKey(key: string | undefined): string | undefined { + if (key === undefined) return undefined; + const chars = Array.from(key); + if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key; + return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join(""); +} diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index c3881a0f..a23b197c 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -18,6 +18,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { headersToRecord } from "../utils/headers.js"; import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; +import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js"; import { buildBaseOptions } from "./simple-options.js"; @@ -240,7 +241,7 @@ function buildParams(model: Model<"openai-responses">, context: Context, options model: model.id, input: messages, stream: true, - prompt_cache_key: cacheRetention === "none" ? undefined : options?.sessionId, + prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId), prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention), store: false, }; diff --git a/packages/ai/test/azure-openai-base-url.test.ts b/packages/ai/test/azure-openai-base-url.test.ts index 3a8e9c19..cdaebc4e 100644 --- a/packages/ai/test/azure-openai-base-url.test.ts +++ b/packages/ai/test/azure-openai-base-url.test.ts @@ -11,14 +11,20 @@ interface CapturedAzureClientOptions { baseURL: string; } +interface CapturedAzureResponsesPayload { + prompt_cache_key?: string; +} + const azureMock = vi.hoisted(() => ({ constructorCalls: [] as CapturedAzureClientOptions[], + lastParams: undefined as CapturedAzureResponsesPayload | undefined, })); vi.mock("openai", () => { class AzureOpenAI { responses = { - create: () => { + create: (params: CapturedAzureResponsesPayload) => { + azureMock.lastParams = params; throw new Error("mock create"); }, }; @@ -42,6 +48,7 @@ const originalAzureOpenAIApiKey = process.env.AZURE_OPENAI_API_KEY; beforeEach(() => { azureMock.constructorCalls.length = 0; + azureMock.lastParams = undefined; delete process.env.AZURE_OPENAI_BASE_URL; delete process.env.AZURE_OPENAI_RESOURCE_NAME; delete process.env.AZURE_OPENAI_API_VERSION; @@ -126,6 +133,17 @@ describe("azure-openai-responses base URL normalization", () => { expect(result.errorMessage).toContain("Invalid Azure OpenAI base URL"); }); + it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => { + const model = getModel("azure-openai-responses", "gpt-4o-mini"); + await streamAzureOpenAIResponses(model, context, { + apiKey: "test-api-key", + azureBaseUrl: "https://my-resource.openai.azure.com", + sessionId: "x".repeat(67), + }).result(); + + expect(azureMock.lastParams?.prompt_cache_key).toBe("x".repeat(64)); + }); + it("builds correct default URL from AZURE_OPENAI_RESOURCE_NAME", async () => { process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource"; const model = getModel("azure-openai-responses", "gpt-4o-mini"); diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 65b88315..5c54fdb4 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -411,6 +411,56 @@ describe("openai-codex streaming", () => { await streamResult.result(); }); + it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => { + const token = mockToken(); + const sessionId = "x".repeat(67); + let capturedPayload: { prompt_cache_key?: string } | undefined; + const encoder = new TextEncoder(); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(buildSSEPayload({ status: "completed" }))); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ), + ), + ); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }; + + await streamOpenAICodexResponses(model, context, { + apiKey: token, + transport: "sse", + sessionId, + onPayload: (payload) => { + capturedPayload = payload as { prompt_cache_key?: string }; + }, + }).result(); + + expect(capturedPayload?.prompt_cache_key).toBe("x".repeat(64)); + }); + it("preserves gpt-5.5 xhigh reasoning effort from simple options", async () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); process.env.PI_CODING_AGENT_DIR = tempDir; diff --git a/packages/ai/test/openai-completions-prompt-cache.test.ts b/packages/ai/test/openai-completions-prompt-cache.test.ts index 147097ef..b1846421 100644 --- a/packages/ai/test/openai-completions-prompt-cache.test.ts +++ b/packages/ai/test/openai-completions-prompt-cache.test.ts @@ -125,6 +125,13 @@ describe("openai-completions prompt caching", () => { expect(payload?.prompt_cache_retention).toBe("24h"); }); + it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => { + const sessionId = "x".repeat(67); + const { payload } = await captureRequest({ sessionId }); + + expect(payload?.prompt_cache_key).toBe("x".repeat(64)); + }); + it("omits prompt cache fields when cacheRetention is none", async () => { const { payload } = await captureRequest({ cacheRetention: "none", sessionId: "session-789" }); diff --git a/packages/ai/test/openai-responses-copilot-provider.test.ts b/packages/ai/test/openai-responses-copilot-provider.test.ts index c4140a5c..c956030d 100644 --- a/packages/ai/test/openai-responses-copilot-provider.test.ts +++ b/packages/ai/test/openai-responses-copilot-provider.test.ts @@ -171,6 +171,38 @@ describe("openai-responses provider defaults", () => { expect(captured).toEqual({ sessionId: "session-123", clientRequestId: "session-123" }); }); + it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => { + const sessionId = "x".repeat(67); + let capturedPayload: { prompt_cache_key?: string } | undefined; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + + const stream = streamOpenAIResponses( + getModel("openai", "gpt-5.4"), + { + systemPrompt: "sys", + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }, + { + apiKey: "test-key", + sessionId, + onPayload: (payload) => { + capturedPayload = payload as { prompt_cache_key?: string }; + }, + }, + ); + + for await (const event of stream) { + if (event.type === "done" || event.type === "error") break; + } + + expect(capturedPayload?.prompt_cache_key).toBe("x".repeat(64)); + }); + it("sets cache-affinity headers for proxy OpenAI Responses requests with a sessionId", async () => { const proxyModel: Model<"openai-responses"> = { ...getModel("openai", "gpt-5.4"),