fix(ai): allow prompt cache keys for compatible proxies (#9442)

This commit is contained in:
Danila Poyarkov 2026-11-04 14:36:35 +04:00 • committed by GitHub
parent a3a1005053
commit 0f4d5e960a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 252 additions and 7 deletions

View file

@ -205,7 +205,8 @@ function buildParams(
model: deploymentName,
input: messages,
stream: true,
prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId),
prompt_cache_key:
model.compat?.promptCacheKeyMode === "disabled" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId),
store: false,
};

View file

@ -177,12 +177,14 @@ type ResolvedOpenAICompletionsCompat = Omit<
Required<OpenAICompletionsCompat>,
| "cacheControlFormat"
| "supportsThinkingTokenBudget"
| "promptCacheKeyMode"
| "thinkingTokenBudgetField"
| "supportsMidConvoSystemMessages"
| "supportsMidConvoToolAdditions"
| "vllmPriority"
> & {
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
promptCacheKeyMode?: OpenAICompletionsCompat["promptCacheKeyMode"];
supportsThinkingTokenBudget?: OpenAICompletionsCompat["supportsThinkingTokenBudget"];
thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"];
supportsMidConvoSystemMessages?: OpenAICompletionsCompat["supportsMidConvoSystemMessages"];
@ -803,14 +805,19 @@ function buildParams(
grammarToolInputProperties,
});
const cacheControl = getCompatCacheControl(compat, cacheRetention);
const promptCacheKeyMode = compat.promptCacheKeyMode ?? "auto";
const supportsPromptCacheKey =
promptCacheKeyMode === "enabled" ||
(promptCacheKeyMode === "auto" &&
(model.baseUrl.includes("api.openai.com") ||
(cacheRetention === "long" && compat.supportsLongCacheRetention)));
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: model.id,
messages,
stream: true,
prompt_cache_key:
(model.baseUrl.includes("api.openai.com") && cacheRetention !== "none") ||
(cacheRetention === "long" && compat.supportsLongCacheRetention)
cacheRetention !== "none" && supportsPromptCacheKey
? clampOpenAIPromptCacheKey(options?.sessionId)
: undefined,
prompt_cache_retention: cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined,
@ -1726,6 +1733,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion
sendSessionAffinityHeaders: model.compat.sendSessionAffinityHeaders ?? detected.sendSessionAffinityHeaders,
sessionAffinityFormat: model.compat.sessionAffinityFormat ?? detected.sessionAffinityFormat,
supportsLongCacheRetention: model.compat.supportsLongCacheRetention ?? detected.supportsLongCacheRetention,
promptCacheKeyMode: model.compat.promptCacheKeyMode,
vllmPriority: model.compat.vllmPriority,
};
}

View file

@ -93,6 +93,7 @@ function getCompat(model: Model<"openai-responses">): Required<OpenAIResponsesCo
supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true,
supportsMidConvoSystemMessages: model.compat?.supportsMidConvoSystemMessages ?? false,
sessionAffinityFormat: model.compat?.sessionAffinityFormat ?? detectSessionAffinityFormat(model),
promptCacheKeyMode: model.compat?.promptCacheKeyMode ?? "auto",
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
supportsStrictMode: model.compat?.supportsStrictMode ?? false,
supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false,
@ -358,13 +359,14 @@ function buildParams(
});
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
const sendPromptCacheKey = cacheRetention !== "none" && compat.promptCacheKeyMode !== "disabled";
// Sign in with ChatGPT rejects these request fields.
const omitUnsupportedFields = isChatGPTSignIn(model, options?.apiKey);
const params: ResponseCreateParamsStreaming = {
model: model.id,
input: messages,
stream: true,
prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId),
prompt_cache_key: sendPromptCacheKey ? clampOpenAIPromptCacheKey(options?.sessionId) : undefined,
prompt_cache_retention: omitUnsupportedFields ? undefined : getPromptCacheRetention(compat, cacheRetention),
prompt_cache_options: omitUnsupportedFields ? undefined : getPromptCacheOptions(compat, cacheRetention),
store: false,

View file

@ -189,6 +189,10 @@ function optionalSessionAffinityFormat(options?: Annotations<"openai" | "openai-
return opt(sessionAffinityFormat(options));
}
function optionalPromptCacheKeyMode(options?: Annotations<"auto" | "enabled" | "disabled">) {
return opt(union([stringLiteral("auto"), stringLiteral("enabled"), stringLiteral("disabled")], options));
}
const OpenAICompletionsCompatProperties = {
supportsStore: opt(
bool({
@ -340,6 +344,10 @@ const OpenAICompletionsCompatProperties = {
description:
"Session-affinity header format. openai sends session_id, x-client-request-id, and x-session-affinity; openai-nosession sends x-client-request-id and x-session-affinity; openrouter sends x-session-id. Does not affect prompt_cache_key. Default: auto-detected.",
}),
promptCacheKeyMode: optionalPromptCacheKeyMode({
description:
"Controls the prompt_cache_key request field. auto sends it to api.openai.com, or with long cache retention when supportsLongCacheRetention is true; enabled sends it whenever caching is active, for compatible proxies; disabled never sends it. Default: auto.",
}),
supportsLongCacheRetention: optionalCompatBoolean({
description:
'Whether the provider supports long prompt cache retention (prompt_cache_retention: "24h" or Anthropic-style cache_control.ttl: "1h", depending on format). Default: auto-detected from provider and URL.',
@ -372,6 +380,10 @@ const OpenAIResponsesCompatProperties = {
description:
"Session-affinity header format. openai sends session-id and x-client-request-id; openai-nosession sends x-client-request-id; openrouter sends x-session-id. Does not affect prompt_cache_key. Default: auto-detected.",
}),
promptCacheKeyMode: optionalPromptCacheKeyMode({
description:
"Controls the prompt_cache_key request field. auto and enabled send it whenever caching is active; disabled never sends it, for compatible proxies that reject it. Default: auto.",
}),
supportsLongCacheRetention: optionalCompatBoolean({
description:
'Whether the provider supports long prompt cache retention. This uses prompt_cache_options.ttl: "30m" on GPT-5.6+ and prompt_cache_retention: "24h" on earlier models.',
@ -571,6 +583,10 @@ const ProviderCompatPropertyOverrides = {
sessionAffinityFormat: optionalSessionAffinityFormat({
description: "Session-affinity header format. Defaults are API-specific or auto-detected.",
}),
promptCacheKeyMode: optionalPromptCacheKeyMode({
description:
"Controls the prompt_cache_key request field for openai-completions and openai-responses: auto keeps each API's default, enabled sends it whenever caching is active, disabled never sends it. Default: auto.",
}),
supportsMidConvoEffort: optionalCompatBoolean({
description:
"Whether thinking level changes are sent inside the conversation instead of changing the request's effort, so a change keeps the prompt cache. Used by the anthropic-messages and openai-responses APIs.",

View file

@ -12,7 +12,10 @@ const emptyUsage: Usage = {
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const baseCompat: Omit<Required<OpenAICompletionsCompat>, "thinkingTokenBudgetField" | "vllmPriority"> & {
const baseCompat: Omit<
Required<OpenAICompletionsCompat>,
"promptCacheKeyMode" | "thinkingTokenBudgetField" | "vllmPriority"
> & {
thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"];
} = {
supportsStore: true,

View file

@ -151,6 +151,90 @@ describe("openai-completions prompt caching", () => {
expect(payload?.prompt_cache_retention).toBeUndefined();
});
it.each([undefined, "short", "long"] as const)(
"sends a stable cache key through an opted-in proxy with retention %s",
async (cacheRetention) => {
const model = createModel({
baseUrl: "https://proxy.example.com/v1",
compat: { promptCacheKeyMode: "enabled", supportsLongCacheRetention: false },
});
const options = { cacheRetention, sessionId: "session-proxy" };
const first = await captureRequest(options, model);
const repeated = await captureRequest(options, model);
const other = await captureRequest({ ...options, sessionId: "other-session" }, model);
expect(first.payload?.prompt_cache_key).toBe("session-proxy");
expect(repeated.payload?.prompt_cache_key).toBe(first.payload?.prompt_cache_key);
expect(other.payload?.prompt_cache_key).toBe("other-session");
expect(first.payload?.prompt_cache_retention).toBeUndefined();
expect(first.headers.session_id).toBeUndefined();
},
);
it.each([undefined, "auto"] as const)("preserves default proxy behavior with mode %s", async (mode) => {
const model = createModel({
baseUrl: "https://proxy.example.com/v1",
compat: mode ? { promptCacheKeyMode: mode } : undefined,
});
const short = await captureRequest({ sessionId: "session-proxy" }, model);
const long = await captureRequest({ cacheRetention: "long", sessionId: "session-proxy" }, model);
expect(short.payload?.prompt_cache_key).toBeUndefined();
expect(long.payload?.prompt_cache_key).toBe("session-proxy");
expect(long.payload?.prompt_cache_retention).toBe("24h");
});
it("respects disabled caching on an opted-in proxy", async () => {
process.env.PI_CACHE_RETENTION = "long";
const model = createModel({
baseUrl: "https://proxy.example.com/v1",
compat: { promptCacheKeyMode: "enabled" },
});
const { payload } = await captureRequest({ cacheRetention: "none", sessionId: "session-proxy" }, model);
expect(payload?.prompt_cache_key).toBeUndefined();
expect(payload?.prompt_cache_retention).toBeUndefined();
});
it.each(["short", "long"] as const)("allows disabling cache keys with %s retention", async (cacheRetention) => {
const model = createModel({ compat: { promptCacheKeyMode: "disabled" } });
const { payload } = await captureRequest({ cacheRetention, sessionId: "session-disabled" }, model);
expect(payload?.prompt_cache_key).toBeUndefined();
expect(payload?.prompt_cache_retention).toBe(cacheRetention === "long" ? "24h" : undefined);
});
it("lets a proxy disable cache keys independently of long retention", async () => {
const model = createModel({
baseUrl: "https://proxy.example.com/v1",
compat: { promptCacheKeyMode: "disabled", supportsLongCacheRetention: true },
});
const { payload } = await captureRequest({ cacheRetention: "long", sessionId: "session-proxy" }, model);
expect(payload?.prompt_cache_key).toBeUndefined();
expect(payload?.prompt_cache_retention).toBe("24h");
});
it("does not invent an identity for an opted-in proxy", async () => {
const model = createModel({
baseUrl: "https://proxy.example.com/v1",
compat: { promptCacheKeyMode: "enabled" },
});
const { payload } = await captureRequest({}, model);
expect(payload?.prompt_cache_key).toBeUndefined();
});
it("clamps proxy cache keys to 64 Unicode characters", async () => {
const model = createModel({
baseUrl: "https://proxy.example.com/v1",
compat: { promptCacheKeyMode: "enabled" },
});
const { payload } = await captureRequest({ sessionId: "𐐀".repeat(67) }, model);
expect(payload?.prompt_cache_key).toBe("𐐀".repeat(64));
});
it("uses PI_CACHE_RETENTION for direct OpenAI requests", async () => {
process.env.PI_CACHE_RETENTION = "long";
const { payload } = await captureRequest({ sessionId: "session-env" });

View file

@ -52,7 +52,7 @@ const compat = {
supportsLongCacheRetention: true,
} satisfies Omit<
Required<OpenAICompletionsCompat>,
"cacheControlFormat" | "thinkingTokenBudgetField" | "vllmPriority"
"cacheControlFormat" | "promptCacheKeyMode" | "thinkingTokenBudgetField" | "vllmPriority"
> & {
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"];

View file

@ -12,7 +12,10 @@ const emptyUsage: Usage = {
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const compat: Omit<Required<OpenAICompletionsCompat>, "thinkingTokenBudgetField" | "vllmPriority"> & {
const compat: Omit<
Required<OpenAICompletionsCompat>,
"promptCacheKeyMode" | "thinkingTokenBudgetField" | "vllmPriority"
> & {
thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"];
} = {
supportsStore: true,

View file

@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { stream } from "../src/api/openai-responses.ts";
import { normalizeContext } from "../src/compat.ts";
import type { Model, OpenAIResponsesCompat } from "../src/types.ts";
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: 1 }],
});
function createModel(promptCacheKeyMode?: OpenAIResponsesCompat["promptCacheKeyMode"]): Model<"openai-responses"> {
return {
id: "test-model",
name: "Test Model",
api: "openai-responses",
provider: "test-proxy",
baseUrl: "https://proxy.example.com/v1",
compat: { promptCacheKeyMode },
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
};
}
afterEach(() => {
vi.unstubAllGlobals();
});
async function capturePayload(
promptCacheKeyMode?: OpenAIResponsesCompat["promptCacheKeyMode"],
cacheRetention?: "none" | "short" | "long",
): Promise<Record<string, unknown>> {
let payload: Record<string, unknown> | undefined;
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string | URL, init?: RequestInit) => {
payload = JSON.parse(String(init?.body));
return new Response(
'data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2,"input_tokens_details":{"cached_tokens":0}}}}\n\n',
{ status: 200, headers: { "content-type": "text/event-stream" } },
);
}),
);
await stream(createModel(promptCacheKeyMode), context, {
apiKey: "test-key",
sessionId: "session-123",
cacheRetention,
}).result();
if (!payload) throw new Error("Expected request payload");
return payload;
}
describe("OpenAI Responses prompt cache key policy", () => {
it("preserves the existing default behavior", async () => {
expect((await capturePayload()).prompt_cache_key).toBe("session-123");
});
it("emits the key when enabled", async () => {
expect((await capturePayload("enabled")).prompt_cache_key).toBe("session-123");
});
it("omits the key when disabled", async () => {
expect((await capturePayload("disabled")).prompt_cache_key).toBeUndefined();
});
it("lets cacheRetention none override enabled mode", async () => {
expect((await capturePayload("enabled", "none")).prompt_cache_key).toBeUndefined();
});
});