@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
8
packages/ai/src/providers/openai-prompt-cache.ts
Normal file
8
packages/ai/src/providers/openai-prompt-cache.ts
Normal file
@@ -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("");
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<Uint8Array>({
|
||||
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;
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user