/** * KHAELOR * File: src/anthropic/caching.ts * Description: Prompt-cache breakpoint planning โ€” deliberate cache_control placement per byte-stability rules (ARCHITECTURE.md ยง6.5). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { AnthropicMessage, CacheControl, ContentBlockParam, SystemTier, TextBlockParam, } from "./types.js"; /** The API allows at most four cache_control breakpoints per request. */ export const MAX_CACHE_BREAKPOINTS = 4; const EPHEMERAL: CacheControl = { type: "ephemeral" }; /** Deterministic breakpoint plan for one request. */ export interface CachePlan { /** Indices into `system` tiers whose block gets a breakpoint (tier ends, ADR-7 rule 1). */ systemTierIndices: number[]; /** Indices into `messages` whose last cacheable block gets a breakpoint. */ messageIndices: number[]; } function isCacheable(block: ContentBlockParam): boolean { // thinking/redacted_thinking blocks do not accept cache_control. return block.type === "text" || block.type === "tool_result" || block.type === "tool_use"; } function lastCacheableBlockIndex(message: AnthropicMessage): number { for (let i = message.content.length - 1; i >= 0; i--) { const block = message.content[i]; if (block !== undefined && isCacheable(block)) return i; } return -1; } /** * Plan breakpoints (budget: 4): * * - System tiers are byte-stable for the whole session, so tier-end * breakpoints give a durable prefix cache. Up to two are used: the end * of the first tier (survives project-instruction edits at session * boundaries) and the end of the last tier (the full system prompt). * - The remaining budget marks the last cacheable block of the final two * user messages โ€” the standard sliding pattern: history before the * previous breakpoint is a byte-identical prefix (rule 2), so each turn * re-reads the long prefix and writes only the new tail. * * Deterministic: same request shape โ‡’ same plan (replay-safe). */ export function planCacheBreakpoints(input: { system: SystemTier[]; messages: AnthropicMessage[]; }): CachePlan { const systemTierIndices: number[] = []; if (input.system.length > 0) { systemTierIndices.push(input.system.length - 1); // full system prompt if (input.system.length > 1) systemTierIndices.unshift(0); // first tier end } const remaining = MAX_CACHE_BREAKPOINTS - systemTierIndices.length; const messageIndices: number[] = []; for (let i = input.messages.length - 1; i >= 0 && messageIndices.length < remaining; i--) { const message = input.messages[i]; if (message === undefined || message.role !== "user") continue; if (lastCacheableBlockIndex(message) === -1) continue; messageIndices.unshift(i); if (messageIndices.length >= 2) break; // last two user messages only } return { systemTierIndices, messageIndices }; } /** * Render system tiers as API text blocks, applying the plan's breakpoints. * Tier text is passed through verbatim (byte-stability, ADR-7 rule 1). */ export function buildSystemBlocks(system: SystemTier[], plan: CachePlan): TextBlockParam[] { return system.map((tier, index) => { const block: TextBlockParam = { type: "text", text: tier.text }; if (plan.systemTierIndices.includes(index)) block.cache_control = EPHEMERAL; return block; }); } /** * Apply message breakpoints without mutating the byte-stable history: * marked messages are shallow-copied and their last cacheable block gets * cache_control. Everything else is passed through by reference. */ export function buildMessagesWithCacheControl( messages: AnthropicMessage[], plan: CachePlan, ): AnthropicMessage[] { return messages.map((message, index) => { if (!plan.messageIndices.includes(index)) return message; const blockIndex = lastCacheableBlockIndex(message); if (blockIndex === -1) return message; const content = message.content.map((block, i) => i === blockIndex ? ({ ...block, cache_control: EPHEMERAL } as ContentBlockParam) : block, ); return { ...message, content }; }); }