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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user