fix(ai): resolved OpenAI cache routing

- Replaced Snowflake session IDs with UUIDv7 for created, forked, branched, and resumed sessions.
- Derived cache session IDs from OpenAI request options and passed them into Responses client creation.
- Used derived session IDs for OpenAI `session_id`/`x-client-request-id` headers and `prompt_cache_key`; omitted headers when retention was none.
- Added tests for UUIDv7 session creation/branching and OpenAI cache-affinity default, override, and disabled-header modes.
- Documented UUIDv7 session handling and OpenAI cache-routing fixes in package Unreleased changelogs.
This commit is contained in:
can1357
2026-04-15 08:18:01 +02:00
parent 028f8c5436
commit 22b1685394
6 changed files with 213 additions and 6 deletions
+5
View File
@@ -1,6 +1,11 @@
# Changelog
## [Unreleased]
### Fixed
- Preserved user-provided `session_id` and `x-client-request-id` headers in OpenAI Responses requests instead of overriding them with automatic session-derived values
- Stopped sending `session_id` and `x-client-request-id` headers for OpenAI Responses requests when `cacheRetention` is set to `none`
- Fixed direct OpenAI Responses requests to send `session_id` and `x-client-request-id` from the same session-derived value as `prompt_cache_key`, improving prompt cache affinity for append-only sessions
## [14.1.1] - 2026-04-14
+15 -2
View File
@@ -170,7 +170,8 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
const { requestAbortController, requestSignal } = abortTracker;
try {
// Create OpenAI client
// Keep request headers and prompt-cache routing on the same session-derived value.
const cacheSessionId = getOpenAIResponsesCacheSessionId(options);
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const { client, copilotPremiumRequests, baseUrl } = createClient(
model,
@@ -178,6 +179,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
apiKey,
options?.headers,
options?.initiatorOverride,
cacheSessionId,
);
const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState);
const { params } = buildParams(model, context, options, providerSessionState, baseUrl);
@@ -261,6 +263,7 @@ function createClient(
apiKey?: string,
extraHeaders?: Record<string, string>,
initiatorOverride?: MessageAttribution,
sessionId?: string,
): {
client: OpenAI;
copilotPremiumRequests: number | undefined;
@@ -294,6 +297,10 @@ function createClient(
copilotPremiumRequests = copilot.premiumRequests;
baseUrl = resolveGitHubCopilotBaseUrl(model.baseUrl, rawApiKey) ?? model.baseUrl;
}
if (sessionId && model.provider === "openai" && (baseUrl ?? "").toLowerCase().includes("api.openai.com")) {
headers.session_id ??= sessionId;
headers["x-client-request-id"] ??= sessionId;
}
return {
client: new OpenAI({
apiKey,
@@ -307,6 +314,12 @@ function createClient(
};
}
function getOpenAIResponsesCacheSessionId(
options: Pick<OpenAIResponsesOptions, "cacheRetention" | "sessionId"> | undefined,
): string | undefined {
return resolveCacheRetention(options?.cacheRetention) === "none" ? undefined : options?.sessionId;
}
function buildParams(
model: Model<"openai-responses">,
context: Context,
@@ -334,7 +347,7 @@ function buildParams(
}
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
const promptCacheKey = cacheRetention === "none" ? undefined : options?.sessionId;
const promptCacheKey = getOpenAIResponsesCacheSessionId(options);
const params: OpenAIResponsesSamplingParams = {
model: model.id,
input: messages,
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { getBundledModel } from "../src/models";
import { type OpenAIResponsesOptions, streamOpenAIResponses } from "../src/providers/openai-responses";
import type { Model } from "../src/types";
const originalFetch = global.fetch;
const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">;
function createSseResponse(events: unknown[]): Response {
const payload = `${events.map(event => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n`;
return new Response(payload, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
function getHeader(headers: RequestInit["headers"], name: string): string | null {
return new Headers(headers).get(name);
}
async function captureOpenAIResponseHeaders(
options: OpenAIResponsesOptions,
): Promise<{ sessionId: string | null; clientRequestId: string | null }> {
const captured = { sessionId: null as string | null, clientRequestId: null as string | null };
const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
captured.sessionId = getHeader(init?.headers, "session_id");
captured.clientRequestId = getHeader(init?.headers, "x-client-request-id");
return createSseResponse([
{
type: "response.output_item.added",
item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] },
},
{ type: "response.content_part.added", part: { type: "output_text", text: "" } },
{ type: "response.output_text.delta", delta: "Hello" },
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Hello" }],
},
},
{
type: "response.completed",
response: {
status: "completed",
usage: {
input_tokens: 5,
output_tokens: 3,
total_tokens: 8,
input_tokens_details: { cached_tokens: 0 },
},
},
},
]);
});
global.fetch = Object.assign(fetchMock, { preconnect: originalFetch.preconnect }) as typeof fetch;
const stream = streamOpenAIResponses(
model,
{
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;
}
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("openai-responses cache affinity", () => {
it("sets session routing 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 session routing 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 session routing headers when cache retention is disabled", async () => {
const captured = await captureOpenAIResponseHeaders({ cacheRetention: "none", sessionId: "session-123" });
expect(captured).toEqual({ sessionId: null, clientRequestId: null });
});
});
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed newly generated session IDs to use UUIDv7 for new, forked, and branched sessions while preserving resumed session IDs
## [14.1.1] - 2026-04-14
### Breaking Changes
@@ -286,6 +286,10 @@ export type ReadonlySessionManager = Pick<
| "putBlob"
>;
function createSessionId(): string {
return Bun.randomUUIDv7();
}
/** Generate a unique short ID (8 hex chars, collision-checked) */
function generateId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
@@ -1500,7 +1504,7 @@ export class SessionManager {
this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage);
if (this.#fileEntries.length > 0) {
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
this.#sessionId = header?.id ?? Snowflake.next();
this.#sessionId = header?.id ?? createSessionId();
this.#sessionName = header?.title;
this.#titleSource = header?.titleSource;
@@ -1549,7 +1553,7 @@ export class SessionManager {
this.#persistErrorReported = false;
// Create new session ID and header
this.#sessionId = Snowflake.next();
this.#sessionId = createSessionId();
const timestamp = new Date().toISOString();
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
@@ -1680,7 +1684,7 @@ export class SessionManager {
this.#persistChain = Promise.resolve();
this.#persistError = undefined;
this.#persistErrorReported = false;
this.#sessionId = Snowflake.next();
this.#sessionId = createSessionId();
this.#sessionName = undefined;
this.#titleSource = undefined;
const timestamp = new Date().toISOString();
@@ -2554,7 +2558,7 @@ export class SessionManager {
// Filter out LabelEntry from path - we'll recreate them from the resolved map
const pathWithoutLabels = branchPath.filter(e => e.type !== "label");
const newSessionId = Snowflake.next();
const newSessionId = createSessionId();
const timestamp = new Date().toISOString();
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
const newSessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
@@ -0,0 +1,75 @@
import { describe, expect, it } from "bun:test";
import * as path from "node:path";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
function expectUuidV7SessionId(session: SessionManager): string {
const sessionId = session.getSessionId();
expect(sessionId).toMatch(UUID_V7_RE);
const header = session.getHeader();
if (!header) throw new Error("Expected session header");
expect(header.id).toBe(sessionId);
return sessionId;
}
describe("SessionManager session ids", () => {
it("generates UUIDv7 ids for new in-memory sessions", () => {
const session = SessionManager.inMemory();
expectUuidV7SessionId(session);
});
it("generates a fresh UUIDv7 when starting a new session", async () => {
const session = SessionManager.inMemory();
const firstId = expectUuidV7SessionId(session);
await session.newSession();
const secondId = expectUuidV7SessionId(session);
expect(secondId).not.toBe(firstId);
});
it("generates a UUIDv7 when branching a session", () => {
const session = SessionManager.inMemory();
session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
const branchPointId = session.appendMessage({ role: "user", content: "follow up", timestamp: 2 });
const firstId = expectUuidV7SessionId(session);
session.createBranchedSession(branchPointId);
const branchedId = expectUuidV7SessionId(session);
expect(branchedId).not.toBe(firstId);
});
it("generates a UUIDv7 when forking a persisted session", async () => {
using tempDir = TempDir.createSync("@pi-session-id-fork-");
const session = SessionManager.create(tempDir.path(), tempDir.path());
session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
await session.flush();
const firstId = expectUuidV7SessionId(session);
const forkResult = await session.fork();
if (!forkResult) throw new Error("Expected fork result");
const forkedId = expectUuidV7SessionId(session);
expect(forkedId).not.toBe(firstId);
expect(session.getHeader()?.parentSession).toBe(firstId);
});
it("preserves existing session ids when reopening a saved session", async () => {
using tempDir = TempDir.createSync("@pi-session-id-open-");
const sessionFile = path.join(tempDir.path(), "existing.jsonl");
const existingId = "existing-session-id";
await Bun.write(
sessionFile,
`${JSON.stringify({ type: "session", id: existingId, timestamp: new Date().toISOString(), cwd: tempDir.path() })}\n`,
);
const session = await SessionManager.open(sessionFile, tempDir.path());
expect(session.getSessionId()).toBe(existingId);
expect(session.getHeader()?.id).toBe(existingId);
});
});