mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(ai): allow prompt cache keys for compatible proxies (#9442)
This commit is contained in:
parent
a3a1005053
commit
0f4d5e960a
12 changed files with 252 additions and 7 deletions
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
72
packages/ai/test/openai-responses-prompt-cache.test.ts
Normal file
72
packages/ai/test/openai-responses-prompt-cache.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue