fix: align OpenAI cache affinity and use uuidv7 session ids

This commit is contained in:
Mario Zechner
2026-04-14 23:20:13 +02:00
parent d62d22173a
commit 018b40c30c
11 changed files with 245 additions and 13 deletions

View File

@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed direct OpenAI Responses requests to send aligned `prompt_cache_key`, `session_id`, and `x-client-request-id` values when `sessionId` is provided, improving prompt cache affinity for append-only sessions ([#3018](https://github.com/badlogic/pi-mono/pull/3018) by [@steipete](https://github.com/steipete))
## [0.67.1] - 2026-04-13
## [0.67.0] - 2026-04-13

View File

@@ -950,6 +950,7 @@ function buildSSEHeaders(
if (sessionId) {
headers.set("session_id", sessionId);
headers.set("x-client-request-id", sessionId);
}
return headers;

View File

@@ -88,7 +88,9 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes
try {
// Create OpenAI client
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const client = createClient(model, context, apiKey, options?.headers);
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
const client = createClient(model, context, apiKey, options?.headers, cacheSessionId);
let params = buildParams(model, context, options);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
@@ -155,6 +157,7 @@ function createClient(
context: Context,
apiKey?: string,
optionsHeaders?: Record<string, string>,
sessionId?: string,
) {
if (!apiKey) {
if (!process.env.OPENAI_API_KEY) {
@@ -175,6 +178,11 @@ function createClient(
Object.assign(headers, copilotHeaders);
}
if (sessionId && model.provider === "openai" && model.baseUrl.includes("api.openai.com")) {
headers.session_id = sessionId;
headers["x-client-request-id"] = sessionId;
}
// Merge options headers last so they can override defaults
if (optionsHeaders) {
Object.assign(headers, optionsHeaders);

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { getModel } from "../src/models.js";
import { complete } from "../src/stream.js";
import type { Context } from "../src/types.js";
import { resolveApiKey } from "./oauth.js";
const codexToken = await resolveApiKey("openai-codex");
describe("openai-codex cache affinity e2e", () => {
it.skipIf(!codexToken)("handles SSE requests with aligned cache-affinity identifiers", async () => {
const model = getModel("openai-codex", "gpt-5.3-codex");
const sessionId = "0195d6e4-4cf9-7f44-a2d8-f8f7f49ee9d3";
const context: Context = {
systemPrompt: "You are a helpful assistant. Reply exactly as requested.",
messages: [
{
role: "user",
content: "Reply with exactly: cache affinity e2e success",
timestamp: Date.now(),
},
],
};
const response = await complete(model, context, {
apiKey: codexToken,
sessionId,
transport: "sse",
});
expect(response.stopReason, response.errorMessage).not.toBe("error");
expect(response.errorMessage).toBeUndefined();
expect(response.content.map((block) => (block.type === "text" ? block.text : "")).join("")).toContain(
"cache affinity e2e success",
);
});
});

View File

@@ -303,7 +303,7 @@ describe("openai-codex streaming", () => {
expect(result.stopReason).toBe("length");
});
it("sets conversation_id/session_id headers and prompt_cache_key when sessionId is provided", async () => {
it("sets session_id/x-client-request-id headers and prompt_cache_key when sessionId is provided", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
process.env.PI_CODING_AGENT_DIR = tempDir;
@@ -364,13 +364,12 @@ describe("openai-codex streaming", () => {
if (url === "https://chatgpt.com/backend-api/codex/responses") {
const headers = init?.headers instanceof Headers ? init.headers : undefined;
// Verify sessionId is set in headers
expect(headers?.get("conversation_id")).toBe(sessionId);
expect(headers?.get("session_id")).toBe(sessionId);
expect(headers?.get("x-client-request-id")).toBe(sessionId);
// Verify sessionId is set in request body as prompt_cache_key
const body = typeof init?.body === "string" ? (JSON.parse(init.body) as Record<string, unknown>) : null;
expect(body?.prompt_cache_key).toBe(sessionId);
expect(body?.prompt_cache_retention).toBe("in-memory");
return new Response(stream, {
status: 200,
@@ -500,7 +499,7 @@ describe("openai-codex streaming", () => {
await streamResult.result();
});
it("does not set conversation_id/session_id headers when sessionId is not provided", async () => {
it("does not set session_id/x-client-request-id headers when sessionId is not provided", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
process.env.PI_CODING_AGENT_DIR = tempDir;
@@ -560,8 +559,8 @@ describe("openai-codex streaming", () => {
if (url === "https://chatgpt.com/backend-api/codex/responses") {
const headers = init?.headers instanceof Headers ? init.headers : undefined;
// Verify headers are not set when sessionId is not provided
expect(headers?.has("conversation_id")).toBe(false);
expect(headers?.has("session_id")).toBe(false);
expect(headers?.has("x-client-request-id")).toBe(false);
return new Response(stream, {
status: 200,

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { getModel } from "../src/models.js";
import { complete } from "../src/stream.js";
import type { Context } from "../src/types.js";
describe.skipIf(!process.env.OPENAI_API_KEY)("openai responses cache affinity e2e", () => {
it("handles direct OpenAI Responses requests with aligned cache-affinity identifiers", { retry: 2 }, async () => {
const model = getModel("openai", "gpt-5.4");
const sessionId = "0195d6e4-4cf9-7f44-a2d8-f8f7f49ee9d3";
const context: Context = {
systemPrompt: "You are a helpful assistant. Reply exactly as requested.",
messages: [
{
role: "user",
content: "Reply with exactly: openai cache affinity e2e success",
timestamp: Date.now(),
},
],
};
const response = await complete(model, context, {
apiKey: process.env.OPENAI_API_KEY!,
sessionId,
});
expect(response.stopReason, response.errorMessage).not.toBe("error");
expect(response.errorMessage).toBeUndefined();
expect(response.content.map((block) => (block.type === "text" ? block.text : "")).join("")).toContain(
"openai cache affinity e2e success",
);
});
});

View File

@@ -2,7 +2,54 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { getModel } from "../src/models.js";
import { streamOpenAIResponses } from "../src/providers/openai-responses.js";
describe("openai-responses github-copilot defaults", () => {
type CapturedHeaders = Headers | string[][] | Record<string, string | readonly string[]> | undefined;
function getHeader(headers: CapturedHeaders, name: string): string | null {
if (!headers) return null;
if (headers instanceof Headers) return headers.get(name);
const lowerName = name.toLowerCase();
if (Array.isArray(headers)) {
const match = headers.find(([key]) => key?.toLowerCase() === lowerName);
return match?.[1] ?? null;
}
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === lowerName) return typeof value === "string" ? value : value.join(", ");
}
return null;
}
async function captureOpenAIResponseHeaders(
options: Parameters<typeof streamOpenAIResponses>[2],
): Promise<{ sessionId: string | null; clientRequestId: string | null }> {
const captured = { sessionId: null as string | null, clientRequestId: null as string | null };
vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
captured.sessionId = getHeader(init?.headers, "session_id");
captured.clientRequestId = getHeader(init?.headers, "x-client-request-id");
return 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", ...options },
);
for await (const event of stream) {
if (event.type === "done" || event.type === "error") break;
}
return captured;
}
describe("openai-responses provider defaults", () => {
afterEach(() => {
vi.restoreAllMocks();
});
@@ -41,4 +88,28 @@ describe("openai-responses github-copilot defaults", () => {
reasoning: expect.anything(),
});
});
it("sets cache-affinity headers for official OpenAI Responses requests with a sessionId", async () => {
const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" });
expect(captured).toEqual({ sessionId: "session-123", clientRequestId: "session-123" });
});
it("lets explicit headers override the default OpenAI cache-affinity headers", async () => {
const captured = await captureOpenAIResponseHeaders({
sessionId: "session-123",
headers: {
session_id: "override-session",
"x-client-request-id": "override-request",
},
});
expect(captured).toEqual({ sessionId: "override-session", clientRequestId: "override-request" });
});
it("omits OpenAI cache-affinity headers when cacheRetention is none", async () => {
const captured = await captureOpenAIResponseHeaders({ cacheRetention: "none", sessionId: "session-123" });
expect(captured).toEqual({ sessionId: null, clientRequestId: null });
});
});