@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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.3] - 2026-05-18
|
||||||
|
|
||||||
## [0.75.2] - 2026-05-18
|
## [0.75.2] - 2026-05-18
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||||
import { headersToRecord } from "../utils/headers.js";
|
import { headersToRecord } from "../utils/headers.js";
|
||||||
|
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js";
|
||||||
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js";
|
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js";
|
||||||
import { buildBaseOptions } from "./simple-options.js";
|
import { buildBaseOptions } from "./simple-options.js";
|
||||||
|
|
||||||
@@ -261,7 +262,7 @@ function buildParams(
|
|||||||
model: deploymentName,
|
model: deploymentName,
|
||||||
input: messages,
|
input: messages,
|
||||||
stream: true,
|
stream: true,
|
||||||
prompt_cache_key: options?.sessionId,
|
prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options?.maxTokens) {
|
if (options?.maxTokens) {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from "../utils/diagnostics.js";
|
} from "../utils/diagnostics.js";
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||||
import { headersToRecord } from "../utils/headers.js";
|
import { headersToRecord } from "../utils/headers.js";
|
||||||
|
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js";
|
||||||
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js";
|
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js";
|
||||||
import { buildBaseOptions } from "./simple-options.js";
|
import { buildBaseOptions } from "./simple-options.js";
|
||||||
|
|
||||||
@@ -378,7 +379,7 @@ function buildRequestBody(
|
|||||||
input: messages,
|
input: messages,
|
||||||
text: { verbosity: options?.textVerbosity || "low" },
|
text: { verbosity: options?.textVerbosity || "low" },
|
||||||
include: ["reasoning.encrypted_content"],
|
include: ["reasoning.encrypted_content"],
|
||||||
prompt_cache_key: options?.sessionId,
|
prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId),
|
||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
parallel_tool_calls: true,
|
parallel_tool_calls: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { parseStreamingJson } from "../utils/json-parse.js";
|
|||||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||||
import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js";
|
import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js";
|
||||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
|
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
|
||||||
|
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js";
|
||||||
import { buildBaseOptions } from "./simple-options.js";
|
import { buildBaseOptions } from "./simple-options.js";
|
||||||
import { transformMessages } from "./transform-messages.js";
|
import { transformMessages } from "./transform-messages.js";
|
||||||
|
|
||||||
@@ -516,7 +517,7 @@ function buildParams(
|
|||||||
prompt_cache_key:
|
prompt_cache_key:
|
||||||
(model.baseUrl.includes("api.openai.com") && cacheRetention !== "none") ||
|
(model.baseUrl.includes("api.openai.com") && cacheRetention !== "none") ||
|
||||||
(cacheRetention === "long" && compat.supportsLongCacheRetention)
|
(cacheRetention === "long" && compat.supportsLongCacheRetention)
|
||||||
? options?.sessionId
|
? clampOpenAIPromptCacheKey(options?.sessionId)
|
||||||
: undefined,
|
: undefined,
|
||||||
prompt_cache_retention: cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : 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 { headersToRecord } from "../utils/headers.js";
|
||||||
import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js";
|
import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js";
|
||||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.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 { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js";
|
||||||
import { buildBaseOptions } from "./simple-options.js";
|
import { buildBaseOptions } from "./simple-options.js";
|
||||||
|
|
||||||
@@ -240,7 +241,7 @@ function buildParams(model: Model<"openai-responses">, context: Context, options
|
|||||||
model: model.id,
|
model: model.id,
|
||||||
input: messages,
|
input: messages,
|
||||||
stream: true,
|
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),
|
prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention),
|
||||||
store: false,
|
store: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,14 +11,20 @@ interface CapturedAzureClientOptions {
|
|||||||
baseURL: string;
|
baseURL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CapturedAzureResponsesPayload {
|
||||||
|
prompt_cache_key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const azureMock = vi.hoisted(() => ({
|
const azureMock = vi.hoisted(() => ({
|
||||||
constructorCalls: [] as CapturedAzureClientOptions[],
|
constructorCalls: [] as CapturedAzureClientOptions[],
|
||||||
|
lastParams: undefined as CapturedAzureResponsesPayload | undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("openai", () => {
|
vi.mock("openai", () => {
|
||||||
class AzureOpenAI {
|
class AzureOpenAI {
|
||||||
responses = {
|
responses = {
|
||||||
create: () => {
|
create: (params: CapturedAzureResponsesPayload) => {
|
||||||
|
azureMock.lastParams = params;
|
||||||
throw new Error("mock create");
|
throw new Error("mock create");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -42,6 +48,7 @@ const originalAzureOpenAIApiKey = process.env.AZURE_OPENAI_API_KEY;
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
azureMock.constructorCalls.length = 0;
|
azureMock.constructorCalls.length = 0;
|
||||||
|
azureMock.lastParams = undefined;
|
||||||
delete process.env.AZURE_OPENAI_BASE_URL;
|
delete process.env.AZURE_OPENAI_BASE_URL;
|
||||||
delete process.env.AZURE_OPENAI_RESOURCE_NAME;
|
delete process.env.AZURE_OPENAI_RESOURCE_NAME;
|
||||||
delete process.env.AZURE_OPENAI_API_VERSION;
|
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");
|
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 () => {
|
it("builds correct default URL from AZURE_OPENAI_RESOURCE_NAME", async () => {
|
||||||
process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource";
|
process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource";
|
||||||
const model = getModel("azure-openai-responses", "gpt-4o-mini");
|
const model = getModel("azure-openai-responses", "gpt-4o-mini");
|
||||||
|
|||||||
@@ -411,6 +411,56 @@ describe("openai-codex streaming", () => {
|
|||||||
await streamResult.result();
|
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 () => {
|
it("preserves gpt-5.5 xhigh reasoning effort from simple options", async () => {
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
|
||||||
process.env.PI_CODING_AGENT_DIR = tempDir;
|
process.env.PI_CODING_AGENT_DIR = tempDir;
|
||||||
|
|||||||
@@ -125,6 +125,13 @@ describe("openai-completions prompt caching", () => {
|
|||||||
expect(payload?.prompt_cache_retention).toBe("24h");
|
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 () => {
|
it("omits prompt cache fields when cacheRetention is none", async () => {
|
||||||
const { payload } = await captureRequest({ cacheRetention: "none", sessionId: "session-789" });
|
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" });
|
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 () => {
|
it("sets cache-affinity headers for proxy OpenAI Responses requests with a sessionId", async () => {
|
||||||
const proxyModel: Model<"openai-responses"> = {
|
const proxyModel: Model<"openai-responses"> = {
|
||||||
...getModel("openai", "gpt-5.4"),
|
...getModel("openai", "gpt-5.4"),
|
||||||
|
|||||||
Reference in New Issue
Block a user