fix(ai): clamp OpenAI prompt cache keys

closes #4720
This commit is contained in:
Mario Zechner
2026-05-19 10:51:11 +02:00
parent 725e2b7b2a
commit 7be75baded
10 changed files with 128 additions and 5 deletions

View File

@@ -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");

View File

@@ -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;

View File

@@ -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" });

View File

@@ -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"),