// Unified prompt-cache policy for OpenAI-format providers. // // pi-ai sends OpenAI prompt-cache parameters on requests when the model declares // matching compat flags (see @earendil-works/pi-ai openai-responses): // - compat.supportsExplicitPromptCacheMode -> sends // `prompt_cache_options: { mode: "explicit" }` when cache retention is "none" // (compaction / branch-summary requests); // - compat.supportsLongCacheRetention -> sends `prompt_cache_retention: "24h"` // when cache retention is "long". // Strict gateways reject these parameters ("Unsupported parameter: // prompt_cache_options"), so the API Manager exposes a unified policy to control // them client-side without touching the gateway: // - "off" (default): never advertise the flags -> pi never sends the parameters; // - "auto": advertise the flags only for models that actually support them // (gpt-5.6 and later, per the OpenAI prompt-caching contract); // - "on": always advertise the flags (admin opt-in for cache-capable upstreams). import { readFileSync } from "node:fs"; import { join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { fileExists, isErrno, isRecord, readModelsRoot, serializeMutation, writeModelsRoot, } from "./api-provider-ops.ts"; export type PromptCachePolicy = "auto" | "off" | "on"; export const PROMPT_CACHE_POLICIES: readonly PromptCachePolicy[] = ["auto", "off", "on"] as const; /** "off" keeps the historical behavior: no cache parameters are sent, no gateway 400. */ export const PROMPT_CACHE_POLICY_DEFAULT: PromptCachePolicy = "off"; /** settings.json root key holding the policy (camelCase, matching sibling settings). */ const PROMPT_CACHE_SETTINGS_KEY = "promptCache"; export function isPromptCachePolicy(value: unknown): value is PromptCachePolicy { return value === "auto" || value === "off" || value === "on"; } /** * Cache tier pi-ai applies to requests: "short" (5m on Anthropic, implicit * 30m on OpenAI), "long" (1h on Anthropic, 24h retention on OpenAI) or "none" * (no caching parameters at all). */ export type CacheRetention = "short" | "long" | "none"; /** Main-flow cache tier; "auto" leaves any existing PI_CACHE_RETENTION env untouched. */ export type CacheRetentionSetting = CacheRetention | "auto"; export const CACHE_RETENTION_DEFAULT: CacheRetention = "short"; export const CACHE_RETENTION_SETTING_DEFAULT: CacheRetentionSetting = "auto"; export const AGENT_CACHE_RETENTION_DEFAULT: CacheRetention = "short"; const CACHE_RETENTION_SETTINGS_KEY = "cacheRetention"; const AGENT_CACHE_RETENTION_SETTINGS_KEY = "agentCacheRetention"; export function isCacheRetention(value: unknown): value is CacheRetention { return value === "short" || value === "long" || value === "none"; } export function isCacheRetentionSetting(value: unknown): value is CacheRetentionSetting { return value === "auto" || isCacheRetention(value); } export function isAgentCacheRetention(value: unknown): value is CacheRetention { return isCacheRetention(value); } /** Whether an API format speaks the OpenAI prompt-cache parameter vocabulary. */ export function isOpenAIFormatApi(api: string | undefined): boolean { return api === "openai-responses" || api === "openai-completions" || api === "azure-openai-responses"; } /** * Whether the model accepts the OpenAI `prompt_cache_options` / `prompt_cache_retention` * request parameters. OpenAI supports them on gpt-5.6 and later models only; * gpt-5.5 / gpt-5.4 / o3 and non-OpenAI models reject them upstream. */ export function supportsOpenAIPromptCacheOptions(modelId: string): boolean { const match = /^gpt-(\d+)(?:\.(\d+))?/.exec(modelId.trim().toLowerCase()); if (!match) return false; const major = Number(match[1]); if (major > 5) return true; if (major < 5) return false; const minor = match[2] === undefined ? -1 : Number(match[2]); return minor >= 6; } export interface PromptCacheCompatFlags { supportsExplicitPromptCacheMode: boolean; supportsLongCacheRetention: boolean; } export function promptCacheCompatFlags(policy: PromptCachePolicy, modelId: string): PromptCacheCompatFlags { if (policy === "on") { return { supportsExplicitPromptCacheMode: true, supportsLongCacheRetention: true }; } if (policy === "off") { return { supportsExplicitPromptCacheMode: false, supportsLongCacheRetention: false }; } const supported = supportsOpenAIPromptCacheOptions(modelId); return { supportsExplicitPromptCacheMode: supported, supportsLongCacheRetention: supported }; } export function defaultPromptCacheSettingsPath(): string { return join(getAgentDir(), "settings.json"); } /** Main-flow cache tier from settings.json; "auto" (default) keeps the env untouched. */ export async function loadCacheRetentionSetting( settingsPath = defaultPromptCacheSettingsPath(), ): Promise { const root = await readModelsRoot(settingsPath); const value = root[CACHE_RETENTION_SETTINGS_KEY]; return isCacheRetentionSetting(value) ? value : CACHE_RETENTION_SETTING_DEFAULT; } export function loadCacheRetentionSettingSync(settingsPath: string): CacheRetentionSetting { try { const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as unknown; const value = isRecord(parsed) ? parsed[CACHE_RETENTION_SETTINGS_KEY] : undefined; return isCacheRetentionSetting(value) ? value : CACHE_RETENTION_SETTING_DEFAULT; } catch { return CACHE_RETENTION_SETTING_DEFAULT; } } /** Agent-subprocess cache tier from settings.json; defaults to the short tier. */ export async function loadAgentCacheRetention( settingsPath = defaultPromptCacheSettingsPath(), ): Promise { const root = await readModelsRoot(settingsPath); const value = root[AGENT_CACHE_RETENTION_SETTINGS_KEY]; return isAgentCacheRetention(value) ? value : AGENT_CACHE_RETENTION_DEFAULT; } export function loadAgentCacheRetentionSync(settingsPath: string): CacheRetention { try { const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as unknown; const value = isRecord(parsed) ? parsed[AGENT_CACHE_RETENTION_SETTINGS_KEY] : undefined; return isAgentCacheRetention(value) ? value : AGENT_CACHE_RETENTION_DEFAULT; } catch { return AGENT_CACHE_RETENTION_DEFAULT; } } /** * Apply the persisted tiers to this process: the main-flow tier lands on * PI_CACHE_RETENTION (read by pi-ai per request) and the agent tier on * PI_TEAMMATE_CACHE_RETENTION (read by the teammate spawn pin). "auto" leaves * an existing PI_CACHE_RETENTION untouched so a user-set env wins. */ export function applyCacheRetentionEnv( settingsPath = defaultPromptCacheSettingsPath(), ): void { const main = loadCacheRetentionSettingSync(settingsPath); if (main !== "auto") process.env.PI_CACHE_RETENTION = main; process.env.PI_TEAMMATE_CACHE_RETENTION = loadAgentCacheRetentionSync(settingsPath); } export async function saveCacheRetentionSetting( retention: CacheRetentionSetting, settingsPath = defaultPromptCacheSettingsPath(), ): Promise { if (!isCacheRetentionSetting(retention)) { throw new Error(`Invalid cache retention setting: ${String(retention)}`); } await serializeMutation(settingsPath, async () => { const root = await readModelsRoot(settingsPath); await writeModelsRoot( { ...root, [CACHE_RETENTION_SETTINGS_KEY]: retention }, settingsPath, await fileExists(settingsPath), ); }); } export async function saveAgentCacheRetention( retention: CacheRetention, settingsPath = defaultPromptCacheSettingsPath(), ): Promise { if (!isAgentCacheRetention(retention)) { throw new Error(`Invalid agent cache retention: ${String(retention)}`); } await serializeMutation(settingsPath, async () => { const root = await readModelsRoot(settingsPath); await writeModelsRoot( { ...root, [AGENT_CACHE_RETENTION_SETTINGS_KEY]: retention }, settingsPath, await fileExists(settingsPath), ); }); } export async function loadPromptCachePolicy( settingsPath = defaultPromptCacheSettingsPath(), ): Promise { const root = await readModelsRoot(settingsPath); const value = root[PROMPT_CACHE_SETTINGS_KEY]; return isPromptCachePolicy(value) ? value : PROMPT_CACHE_POLICY_DEFAULT; } /** Synchronous variant for provider-registration hot paths (mirrors configuredProviderIds). */ export function loadPromptCachePolicySync(settingsPath: string): PromptCachePolicy { try { const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as unknown; const value = isRecord(parsed) ? parsed[PROMPT_CACHE_SETTINGS_KEY] : undefined; return isPromptCachePolicy(value) ? value : PROMPT_CACHE_POLICY_DEFAULT; } catch (error) { if (isErrno(error, "ENOENT")) return PROMPT_CACHE_POLICY_DEFAULT; return PROMPT_CACHE_POLICY_DEFAULT; } } export async function savePromptCachePolicy( policy: PromptCachePolicy, settingsPath = defaultPromptCacheSettingsPath(), ): Promise { if (!isPromptCachePolicy(policy)) { throw new Error(`Invalid prompt cache policy: ${String(policy)}`); } await serializeMutation(settingsPath, async () => { const root = await readModelsRoot(settingsPath); await writeModelsRoot( { ...root, [PROMPT_CACHE_SETTINGS_KEY]: policy }, settingsPath, await fileExists(settingsPath), ); }); }