Skip to main content

prompt_cache_warmer/
lib.rs

1//! # prompt-cache-warmer
2//!
3//! Pre-warm Anthropic prompt cache before user traffic.
4//!
5//! Anthropic charges 25% more on the first request that creates a cache
6//! entry and 10% as much on subsequent reads. If user requests are slow or
7//! expensive on the first hit of a new system prompt, you want that first
8//! hit to be a cheap synthetic warmup, not a real user.
9//!
10//! This crate:
11//!
12//!   1. Takes your long system prompt (string or block list) and a model
13//!      name.
14//!   2. Inserts up to N `cache_control` breakpoints in the right places.
15//!   3. Fires a tiny warmup call (`max_tokens = 8` by default).
16//!   4. Optionally fires a second verification call and asserts
17//!      `cache_read_input_tokens > 0`.
18//!   5. Returns a [`WarmResult`] with timings, token counts, and estimated
19//!      cost.
20//!
21//! ## Quick example
22//!
23//! ```
24//! use prompt_cache_warmer::{Block, Usage, WarmCall, WarmRequest, WarmResponse, Warmer};
25//!
26//! // BYO transport: anything that implements `WarmCall`.
27//! struct FakeClient;
28//! impl WarmCall for FakeClient {
29//!     type Error = std::convert::Infallible;
30//!     fn call(&self, _req: &WarmRequest) -> Result<WarmResponse, Self::Error> {
31//!         Ok(WarmResponse {
32//!             usage: Usage {
33//!                 input_tokens: 10,
34//!                 output_tokens: 4,
35//!                 cache_creation_input_tokens: 12_000,
36//!                 cache_read_input_tokens: 0,
37//!             },
38//!         })
39//!     }
40//! }
41//!
42//! let warmer = Warmer::new(FakeClient);
43//! let out = warmer
44//!     .warm("claude-opus-4-7", "long system text")
45//!     .unwrap();
46//! assert_eq!(out.cache_creation_input_tokens, 12_000);
47//! ```
48//!
49//! `Warmer` is generic over the transport, so you can plug in the real
50//! Anthropic HTTP client, a Bedrock wrapper, or a fake for tests.
51
52#![deny(missing_docs)]
53
54use std::collections::HashMap;
55use std::time::Instant;
56
57// ---- public usage / response shapes ----------------------------------------
58
59/// Token usage returned by the model on a single warm call.
60///
61/// This mirrors the four fields Anthropic returns on `message.usage`.
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
63pub struct Usage {
64    /// Regular (non-cache) input tokens.
65    pub input_tokens: u64,
66    /// Output tokens generated by the model.
67    pub output_tokens: u64,
68    /// Tokens billed at the cache-write multiplier on this call.
69    pub cache_creation_input_tokens: u64,
70    /// Tokens billed at the cache-read multiplier on this call.
71    pub cache_read_input_tokens: u64,
72}
73
74/// One block of a system prompt.
75///
76/// Anthropic accepts a system prompt as either a string or a list of
77/// `{type: "text", text, cache_control?}` blocks. We model the block form
78/// directly; conversion from a plain string is handled by
79/// [`to_system_blocks`] and the `From` impls.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Block {
82    /// The block's text content.
83    pub text: String,
84    /// Optional `cache_control` marker. `Some(CacheControl::Ephemeral)`
85    /// means the block ends a cacheable prefix.
86    pub cache_control: Option<CacheControl>,
87}
88
89/// Cache control marker for a [`Block`].
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum CacheControl {
92    /// Anthropic's `{"type": "ephemeral"}` breakpoint.
93    Ephemeral,
94}
95
96impl From<String> for Block {
97    fn from(text: String) -> Self {
98        Block {
99            text,
100            cache_control: None,
101        }
102    }
103}
104
105impl From<&str> for Block {
106    fn from(text: &str) -> Self {
107        Block {
108            text: text.to_string(),
109            cache_control: None,
110        }
111    }
112}
113
114/// The request payload handed to a [`WarmCall`] transport.
115///
116/// This is intentionally minimal; we leave model-specific extras (top-p,
117/// metadata, etc.) to the caller's transport wrapper.
118#[derive(Debug, Clone)]
119pub struct WarmRequest {
120    /// Anthropic model id (e.g. `"claude-opus-4-7"`).
121    pub model: String,
122    /// System prompt, as one or more cacheable blocks.
123    pub system_blocks: Vec<Block>,
124    /// User/assistant messages. Defaults to a single `"ok"` ping if the
125    /// caller doesn't supply any.
126    pub messages: Vec<Message>,
127    /// Optional tool definitions (`name`, `input_schema`, ...). Opaque to
128    /// this crate; pass them straight through.
129    pub tools: Vec<Tool>,
130    /// `max_tokens` for the warm call. Defaults to 8.
131    pub max_tokens: u32,
132}
133
134/// A single chat message (role + content text).
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct Message {
137    /// `"user"` or `"assistant"`.
138    pub role: String,
139    /// Free-form text content.
140    pub content: String,
141}
142
143/// An opaque tool definition. The transport owns serialization.
144///
145/// We only carry the tool name here so [`Warmer::warm`] callers can
146/// inspect what got sent; richer tool shapes live in the transport.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct Tool {
149    /// Tool name (matches Anthropic's `tools[].name`).
150    pub name: String,
151}
152
153/// Response shape returned by a [`WarmCall`] transport.
154#[derive(Debug, Clone)]
155pub struct WarmResponse {
156    /// Token usage for this call.
157    pub usage: Usage,
158}
159
160/// A transport that can execute a [`WarmRequest`] and return a
161/// [`WarmResponse`].
162///
163/// Implement this for the Anthropic SDK, a Bedrock client, or a fake.
164pub trait WarmCall {
165    /// Transport-specific error type.
166    type Error: std::error::Error;
167    /// Execute the warm request.
168    fn call(&self, req: &WarmRequest) -> Result<WarmResponse, Self::Error>;
169}
170
171// ---- block helpers ---------------------------------------------------------
172
173/// Coerce a system arg into the Anthropic block list shape.
174///
175/// ```
176/// use prompt_cache_warmer::{to_system_blocks, Block};
177/// let blocks = to_system_blocks("hi");
178/// assert_eq!(blocks, vec![Block::from("hi")]);
179/// ```
180pub fn to_system_blocks(system: &str) -> Vec<Block> {
181    vec![Block::from(system)]
182}
183
184/// Add up to `n` ephemeral `cache_control` markers, evenly spaced and
185/// ending with the last block.
186///
187/// Anthropic caps the number of breakpoints at 4 per request; we cap at 4
188/// here too. Blocks that already carry a `cache_control` are preserved.
189///
190/// ```
191/// use prompt_cache_warmer::{add_cache_breakpoints, Block, CacheControl};
192/// let blocks = vec![Block::from("a"), Block::from("b")];
193/// let out = add_cache_breakpoints(&blocks, 1);
194/// assert_eq!(out[0].cache_control, None);
195/// assert_eq!(out[1].cache_control, Some(CacheControl::Ephemeral));
196/// ```
197pub fn add_cache_breakpoints(blocks: &[Block], n: usize) -> Vec<Block> {
198    if n == 0 || blocks.is_empty() {
199        return blocks.to_vec();
200    }
201
202    let capped = n.min(4);
203    let len = blocks.len();
204    let positions: std::collections::HashSet<usize> = if capped >= len {
205        (0..len).collect()
206    } else {
207        let step = len as f64 / capped as f64;
208        (0..capped)
209            .map(|i| (((i + 1) as f64 * step) as usize).saturating_sub(1))
210            .collect()
211    };
212
213    blocks
214        .iter()
215        .enumerate()
216        .map(|(i, b)| {
217            let mut nb = b.clone();
218            if positions.contains(&i) && nb.cache_control.is_none() {
219                nb.cache_control = Some(CacheControl::Ephemeral);
220            }
221            nb
222        })
223        .collect()
224}
225
226// ---- pricing ---------------------------------------------------------------
227
228/// Per-million-token list price for a single model.
229///
230/// Multipliers ([`CACHE_WRITE_MULTIPLIER`], [`CACHE_READ_MULTIPLIER`]) are
231/// applied at cost-estimate time, not stored here.
232#[derive(Debug, Clone, Copy, PartialEq)]
233pub struct ModelPrice {
234    /// Per-1M-token regular input price (USD).
235    pub input: f64,
236    /// Per-1M-token output price (USD).
237    pub output: f64,
238}
239
240/// Lookup table mapping model id -> [`ModelPrice`].
241pub type PriceTable = HashMap<&'static str, ModelPrice>;
242
243/// Cache-write multiplier applied to `cache_creation_input_tokens`.
244pub const CACHE_WRITE_MULTIPLIER: f64 = 1.25;
245/// Cache-read multiplier applied to `cache_read_input_tokens`.
246pub const CACHE_READ_MULTIPLIER: f64 = 0.10;
247
248/// Built-in best-effort pricing as of 2026-Q2. Override with
249/// [`Warmer::with_prices`] for unsupported models.
250pub fn default_prices() -> PriceTable {
251    let mut t: PriceTable = HashMap::new();
252    t.insert(
253        "claude-opus-4-7",
254        ModelPrice {
255            input: 15.0,
256            output: 75.0,
257        },
258    );
259    t.insert(
260        "claude-opus-4-6",
261        ModelPrice {
262            input: 15.0,
263            output: 75.0,
264        },
265    );
266    t.insert(
267        "claude-sonnet-4-6",
268        ModelPrice {
269            input: 3.0,
270            output: 15.0,
271        },
272    );
273    t.insert(
274        "claude-haiku-4-5",
275        ModelPrice {
276            input: 0.80,
277            output: 4.0,
278        },
279    );
280    t
281}
282
283// ---- result type -----------------------------------------------------------
284
285/// Result of a single [`Warmer::warm`] (or [`Warmer::warm_verified`]) call.
286#[derive(Debug, Clone, PartialEq)]
287pub struct WarmResult {
288    /// The model id passed in.
289    pub model: String,
290    /// `cache_creation_input_tokens` from the first call.
291    pub cache_creation_input_tokens: u64,
292    /// `cache_read_input_tokens` from the first call.
293    pub cache_read_input_tokens: u64,
294    /// `input_tokens` from the first call.
295    pub input_tokens: u64,
296    /// `output_tokens` from the first call.
297    pub output_tokens: u64,
298    /// Wall-clock latency of the first (warm) call, in milliseconds.
299    pub latency_ms_warm: u64,
300    /// `cache_read_input_tokens` from the optional verification call.
301    pub verified_hit_tokens: Option<u64>,
302    /// Wall-clock latency of the optional verification call, in
303    /// milliseconds.
304    pub latency_ms_verify: Option<u64>,
305    /// Estimated USD cost of the warm call, or `None` if the model isn't
306    /// in the price table.
307    pub cost_usd: Option<f64>,
308}
309
310// ---- main type -------------------------------------------------------------
311
312/// Caller-facing entry point. Generic over a [`WarmCall`] transport.
313pub struct Warmer<C: WarmCall> {
314    client: C,
315    prices: PriceTable,
316}
317
318/// High-level input for [`Warmer::warm`]/[`Warmer::warm_verified`].
319///
320/// Use [`WarmInput::new`] for the common case (string system prompt + the
321/// default "ok" ping). For richer cases, build the struct directly.
322#[derive(Debug, Clone)]
323pub struct WarmInput {
324    /// Anthropic model id.
325    pub model: String,
326    /// System prompt blocks.
327    pub system_blocks: Vec<Block>,
328    /// Optional user/assistant messages. If empty, a single user `"ok"`
329    /// message is sent.
330    pub messages: Vec<Message>,
331    /// Optional tool list.
332    pub tools: Vec<Tool>,
333    /// `max_tokens` for the warm call. Defaults to 8.
334    pub max_tokens: u32,
335    /// Number of `cache_control` breakpoints to inject (capped at 4).
336    pub breakpoints: usize,
337    /// Text used for the default user ping when `messages` is empty.
338    pub ping_text: String,
339}
340
341impl WarmInput {
342    /// Shortcut: a string system prompt with default options.
343    pub fn new(model: impl Into<String>, system: impl AsRef<str>) -> Self {
344        WarmInput {
345            model: model.into(),
346            system_blocks: to_system_blocks(system.as_ref()),
347            messages: Vec::new(),
348            tools: Vec::new(),
349            max_tokens: 8,
350            breakpoints: 1,
351            ping_text: "ok".to_string(),
352        }
353    }
354}
355
356impl<C: WarmCall> Warmer<C> {
357    /// Build a [`Warmer`] using the default price table.
358    pub fn new(client: C) -> Self {
359        Warmer {
360            client,
361            prices: default_prices(),
362        }
363    }
364
365    /// Build a [`Warmer`] with a caller-supplied price table.
366    pub fn with_prices(client: C, prices: PriceTable) -> Self {
367        Warmer { client, prices }
368    }
369
370    /// Borrow the underlying transport. Useful when the transport carries
371    /// its own state (call log, metrics, etc.) you want to inspect.
372    pub fn client(&self) -> &C {
373        &self.client
374    }
375
376    /// Convenience shortcut around [`Warmer::warm_with`] using the default
377    /// [`WarmInput::new`] options.
378    pub fn warm(
379        &self,
380        model: impl Into<String>,
381        system: impl AsRef<str>,
382    ) -> Result<WarmResult, C::Error> {
383        self.warm_with(WarmInput::new(model, system))
384    }
385
386    /// Convenience shortcut that also runs a verification call.
387    pub fn warm_verified(
388        &self,
389        model: impl Into<String>,
390        system: impl AsRef<str>,
391    ) -> Result<WarmResult, C::Error> {
392        self.warm_with_verified(WarmInput::new(model, system))
393    }
394
395    /// Fire a single warm call.
396    pub fn warm_with(&self, input: WarmInput) -> Result<WarmResult, C::Error> {
397        self.warm_inner(input, false)
398    }
399
400    /// Fire a warm call, then a second call, and record the second call's
401    /// `cache_read_input_tokens` on the result.
402    pub fn warm_with_verified(&self, input: WarmInput) -> Result<WarmResult, C::Error> {
403        self.warm_inner(input, true)
404    }
405
406    fn warm_inner(&self, input: WarmInput, verify: bool) -> Result<WarmResult, C::Error> {
407        let WarmInput {
408            model,
409            system_blocks,
410            messages,
411            tools,
412            max_tokens,
413            breakpoints,
414            ping_text,
415        } = input;
416
417        let system_blocks = add_cache_breakpoints(&system_blocks, breakpoints);
418        let messages = if messages.is_empty() {
419            vec![Message {
420                role: "user".to_string(),
421                content: ping_text,
422            }]
423        } else {
424            messages
425        };
426
427        let request = WarmRequest {
428            model: model.clone(),
429            system_blocks,
430            messages,
431            tools,
432            max_tokens,
433        };
434
435        let t0 = Instant::now();
436        let resp1 = self.client.call(&request)?;
437        let warm_ms = t0.elapsed().as_millis() as u64;
438        let usage1 = resp1.usage;
439
440        let (verified, verify_ms) = if verify {
441            let t1 = Instant::now();
442            let resp2 = self.client.call(&request)?;
443            let verify_ms = t1.elapsed().as_millis() as u64;
444            (Some(resp2.usage.cache_read_input_tokens), Some(verify_ms))
445        } else {
446            (None, None)
447        };
448
449        Ok(WarmResult {
450            cost_usd: self.estimate_cost(&model, &usage1),
451            model,
452            cache_creation_input_tokens: usage1.cache_creation_input_tokens,
453            cache_read_input_tokens: usage1.cache_read_input_tokens,
454            input_tokens: usage1.input_tokens,
455            output_tokens: usage1.output_tokens,
456            latency_ms_warm: warm_ms,
457            verified_hit_tokens: verified,
458            latency_ms_verify: verify_ms,
459        })
460    }
461
462    fn estimate_cost(&self, model: &str, usage: &Usage) -> Option<f64> {
463        let price = self.prices.get(model)?;
464        let per_in = price.input / 1_000_000.0;
465        let per_out = price.output / 1_000_000.0;
466        let regular_in = usage.input_tokens as f64;
467        let write_in = usage.cache_creation_input_tokens as f64;
468        let read_in = usage.cache_read_input_tokens as f64;
469        let out = usage.output_tokens as f64;
470        Some(
471            regular_in * per_in
472                + write_in * per_in * CACHE_WRITE_MULTIPLIER
473                + read_in * per_in * CACHE_READ_MULTIPLIER
474                + out * per_out,
475        )
476    }
477}