Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export interface AgentOptions {
steeringMode?: QueueMode;
followUpMode?: QueueMode;
sessionId?: string;
promptCacheKey?: string;
thinkingBudgets?: ThinkingBudgets;
transport?: Transport;
maxRetryDelayMs?: number;
Expand Down Expand Up @@ -198,6 +199,8 @@ export class Agent {
private activeRun?: ActiveRun;
/** Session identifier forwarded to providers for cache-aware backends. */
public sessionId?: string;
/** Optional override for the provider prompt cache key, replacing `sessionId` as the cache routing key. */
public promptCacheKey?: string;
/** Optional per-level thinking token budgets forwarded to the stream function. */
public thinkingBudgets?: ThinkingBudgets;
/** Preferred transport forwarded to the stream function. */
Expand All @@ -224,6 +227,7 @@ export class Agent {
this.steeringQueue = new PendingMessageQueue(runtimeOptions.steeringMode ?? "one-at-a-time");
this.followUpQueue = new PendingMessageQueue(runtimeOptions.followUpMode ?? "one-at-a-time");
this.sessionId = runtimeOptions.sessionId;
this.promptCacheKey = runtimeOptions.promptCacheKey;
this.thinkingBudgets = runtimeOptions.thinkingBudgets;
this.transport = runtimeOptions.transport ?? "auto";
this.maxRetryDelayMs = runtimeOptions.maxRetryDelayMs;
Expand Down Expand Up @@ -437,6 +441,7 @@ export class Agent {
model: this._state.model,
reasoning: this._state.thinkingLevel === "off" ? undefined : this._state.thinkingLevel,
sessionId: this.sessionId,
promptCacheKey: this.promptCacheKey,
onPayload: this.onPayload,
onResponse: this.onResponse,
transport: this.transport,
Expand Down
2 changes: 2 additions & 0 deletions packages/agent/src/harness/agent-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ function applyStreamOptionsPatch(
if (Object.hasOwn(patch, "maxRetries")) result.maxRetries = patch.maxRetries;
if (Object.hasOwn(patch, "maxRetryDelayMs")) result.maxRetryDelayMs = patch.maxRetryDelayMs;
if (Object.hasOwn(patch, "cacheRetention")) result.cacheRetention = patch.cacheRetention;
if (Object.hasOwn(patch, "promptCacheKey")) result.promptCacheKey = patch.promptCacheKey;

if (Object.hasOwn(patch, "headers")) {
if (patch.headers === undefined) {
Expand Down Expand Up @@ -421,6 +422,7 @@ export class AgentHarness<
reasoning: streamOptions?.reasoning,
signal: streamOptions?.signal,
sessionId: turnState.sessionId,
promptCacheKey: requestOptions.promptCacheKey,
timeoutMs: requestOptions.timeoutMs,
transport: requestOptions.transport,
});
Expand Down
2 changes: 2 additions & 0 deletions packages/agent/src/harness/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ export interface AgentHarnessStreamOptions {
metadata?: SimpleStreamOptions["metadata"];
/** Provider cache retention hint. */
cacheRetention?: SimpleStreamOptions["cacheRetention"];
/** Optional override for the provider prompt cache key, replacing the session id as the cache routing key. */
promptCacheKey?: string;
}

/** Per-request stream option patch returned by provider hooks. */
Expand Down
2 changes: 2 additions & 0 deletions packages/agent/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type ProxySerializableStreamOptions = Pick<
| "reasoning"
| "cacheRetention"
| "sessionId"
| "promptCacheKey"
| "headers"
| "metadata"
| "transport"
Expand Down Expand Up @@ -105,6 +106,7 @@ function buildProxyRequestOptions(options: ProxyStreamOptions): ProxySerializabl
reasoning: options.reasoning,
cacheRetention: options.cacheRetention,
sessionId: options.sessionId,
promptCacheKey: options.promptCacheKey,
headers: options.headers,
metadata: options.metadata,
transport: options.transport,
Expand Down
2 changes: 1 addition & 1 deletion packages/ai/src/api/azure-openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ function buildParams(
model: deploymentName,
input: messages,
stream: true,
prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId),
prompt_cache_key: clampOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId),
store: false,
};

Expand Down
10 changes: 7 additions & 3 deletions packages/ai/src/api/openai-codex-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,11 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
);
const cacheSessionId = options?.cacheRetention === "none" ? undefined : options?.sessionId;
const codexSessionId = clampOpenAIPromptCacheKey(cacheSessionId);
let body = buildRequestBody(model, context, options, codexSessionId, grammarToolInputProperties);
const promptCacheKey =
options?.cacheRetention === "none"
? undefined
: clampOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
let body = buildRequestBody(model, context, options, promptCacheKey, grammarToolInputProperties);
const nextBody = await options?.onPayload?.(body, model);
if (nextBody !== undefined) {
body = nextBody as RequestBody;
Expand Down Expand Up @@ -517,7 +521,7 @@ function buildRequestBody(
model: Model<"openai-codex-responses">,
context: Context,
options: OpenAICodexResponsesOptions | undefined,
cacheSessionId: string | undefined,
promptCacheKey: string | undefined,
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
context.tools,
model.compat?.supportsOpenAIGrammarTools ?? false,
Expand Down Expand Up @@ -545,7 +549,7 @@ function buildRequestBody(
input: messages,
text: { verbosity: options?.textVerbosity || "low" },
include: ["reasoning.encrypted_content"],
prompt_cache_key: cacheSessionId,
prompt_cache_key: promptCacheKey,
tool_choice: options?.toolChoice ?? "auto",
parallel_tool_calls: true,
};
Expand Down
2 changes: 1 addition & 1 deletion packages/ai/src/api/openai-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,7 @@ function buildParams(
prompt_cache_key:
(model.baseUrl.includes("api.openai.com") && cacheRetention !== "none") ||
(cacheRetention === "long" && compat.supportsLongCacheRetention)
? clampOpenAIPromptCacheKey(options?.sessionId)
? clampOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId)
: undefined,
prompt_cache_retention: cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined,
};
Expand Down
5 changes: 4 additions & 1 deletion packages/ai/src/api/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,10 @@ function buildParams(
model: model.id,
input: messages,
stream: true,
prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId),
prompt_cache_key:
cacheRetention === "none"
? undefined
: clampOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId),
prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention),
prompt_cache_options: disableImplicitPromptCache ? { mode: "explicit" } : undefined,
store: false,
Expand Down
1 change: 1 addition & 0 deletions packages/ai/src/api/simple-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function buildBaseOptions(
transport: options?.transport,
cacheRetention: options?.cacheRetention,
sessionId: options?.sessionId,
promptCacheKey: options?.promptCacheKey,
headers: options?.headers,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
Expand Down
9 changes: 9 additions & 0 deletions packages/ai/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ export interface StreamOptions {
* session-aware features. Ignored by providers that don't support it.
*/
sessionId?: string;
/**
* Optional override for the provider prompt cache key, which defaults to
* `sessionId`. Set one shared key on sessions with an identical prompt prefix
* so their requests route to the same cache and hit each other's cached
* prefix. OpenAI recommends roughly 15 requests per minute per key, so
* callers shard the key when concurrency exceeds that. Session-affinity
* headers remain tied to `sessionId`.
*/
promptCacheKey?: string;
/**
* Optional callback for inspecting or replacing provider payloads before sending.
* Return undefined to keep the payload unchanged.
Expand Down
12 changes: 12 additions & 0 deletions packages/ai/test/azure-openai-base-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ describe("azure-openai-responses base URL normalization", () => {
expect(azureMock.lastParams?.prompt_cache_key).toBe("x".repeat(64));
});

it("prefers promptCacheKey over sessionId for prompt_cache_key", 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: "session-azure",
promptCacheKey: "shared-key",
}).result();

expect(azureMock.lastParams?.prompt_cache_key).toBe("shared-key");
});

it("disables server-side response storage", async () => {
const model = getModel("azure-openai-responses", "gpt-4o-mini");
await streamAzureOpenAIResponses(model, context, {
Expand Down
25 changes: 25 additions & 0 deletions packages/ai/test/cache-retention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,31 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
expect(capturedPayload.prompt_cache_key).toBe("session-2");
expect(capturedPayload.prompt_cache_retention).toBe("24h");
});

it("should prefer promptCacheKey over sessionId for prompt_cache_key", async () => {
const model = getModel("openai", "gpt-4o-mini");
let capturedPayload: any = null;

try {
const s = streamOpenAIResponses(model, context, {
apiKey: "fake-key",
sessionId: "session-3",
promptCacheKey: "shared-key",
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
});

for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}

expect(capturedPayload).not.toBeNull();
expect(capturedPayload.prompt_cache_key).toBe("shared-key");
});
});

describe("OpenAI Completions Provider", () => {
Expand Down
50 changes: 50 additions & 0 deletions packages/ai/test/openai-codex-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,56 @@ describe("openai-codex streaming", () => {
expect(capturedHeaders?.get("x-client-request-id")).toBe("x".repeat(64));
});

it("prefers promptCacheKey over sessionId for prompt_cache_key", async () => {
const token = mockToken();
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: "session-codex",
promptCacheKey: "shared-key",
onPayload: (payload) => {
capturedPayload = payload as { prompt_cache_key?: string };
},
}).result();

expect(capturedPayload?.prompt_cache_key).toBe("shared-key");
});

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;
Expand Down
18 changes: 18 additions & 0 deletions packages/ai/test/openai-completions-prompt-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ describe("openai-completions prompt caching", () => {
options?: {
cacheRetention?: "none" | "short" | "long";
sessionId?: string;
promptCacheKey?: string;
headers?: Record<string, string>;
},
model: Model<"openai-completions"> = createModel(),
Expand Down Expand Up @@ -133,6 +134,23 @@ describe("openai-completions prompt caching", () => {
expect(payload?.prompt_cache_key).toBe("x".repeat(64));
});

it("prefers promptCacheKey over sessionId and clamps it", async () => {
const promptCacheKey = "k".repeat(67);
const { payload } = await captureRequest({ sessionId: "session-123", promptCacheKey });

expect(payload?.prompt_cache_key).toBe("k".repeat(64));
});

it("omits prompt_cache_key when cacheRetention is none even with promptCacheKey set", async () => {
const { payload } = await captureRequest({
cacheRetention: "none",
sessionId: "session-123",
promptCacheKey: "shared-key",
});

expect(payload?.prompt_cache_key).toBeUndefined();
});

it("omits prompt cache fields when cacheRetention is none", async () => {
const { payload } = await captureRequest({ cacheRetention: "none", sessionId: "session-789" });

Expand Down
Loading