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}