Skip to main content

rig_core/providers/gemini/
caching.rs

1//! Automatic explicit caching for long Gemini runs.
2//!
3//! [`Caching`](super::Caching) is a transport that wraps another. Every `generateContent`
4//! and `streamGenerateContent` request passes through it with its encoded
5//! body, and a shared [`CacheBook`] decides what the request reads from a
6//! cache and when a new cache is worth creating:
7//!
8//! ```no_run
9//! use rig_core::providers::gemini::{AutoCache, CacheBook, Gemini};
10//!
11//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
12//! let gemini = Gemini::from_env()?;
13//! let book = CacheBook::new(AutoCache::default());
14//! let model = gemini.completion("gemini-3.8-flash").caching(&book);
15//! // ... run an agent or a chat on `model` ...
16//! book.close(&gemini.cached_contents()).await;
17//! println!("{:?}", book.report());
18//! # Ok(())
19//! # }
20//! ```
21//!
22//! # What it does
23//!
24//! Gemini's implicit caching reuses an earlier request's prefix at no cost,
25//! but on gemini-3.8-flash a chat-sized request that extends the previous one
26//! almost never hits (measured: 1 hit in 138). An explicit cache
27//! (`cachedContents`) can hold the whole conversation so far: system
28//! instruction, tools, tool config, every model turn with its signatures, and
29//! function calls and responses. A request that reads it sends only what
30//! came after. Caches cannot be extended or chained, so a growing
31//! conversation needs a new cache from time to time ("rolling"), and each new
32//! cache is billed again at the input price.
33//!
34//! The `Auto` policy rolls when rolling has already paid for itself: it
35//! counts the input premium the conversation paid for tokens a cache could
36//! have held, and creates a new cache once that premium covers the new
37//! cache's creation and storage (ski rental). So:
38//!
39//! - a cache read once never pays, and is never made;
40//! - rolling beats one fixed prefix cache from about 20 calls, and the saving
41//!   grows with the run;
42//! - where implicit caching already serves a conversation (large contexts,
43//!   repeated documents), the book stays inline instead of paying to cache
44//!   what is already cheap. It goes inline above `implicit_ceiling` tokens
45//!   until implicit caching is seen failing.
46//!
47//! Cost savings top out below 90%, because a cached read costs 10% of the
48//! input price. How close a run gets depends on its shape: a large stable
49//! prefix with little new content per call approaches 99% cached (a batch of
50//! short questions over a 5.5k-token prefix measured 99.6%); a chat on a small
51//! preamble cannot, because each call's new turn is never cached, and on
52//! Gemini 3 each earlier thought a request replays through its signature is
53//! billed again as input that no explicit cache holds (see
54//! [`ThoughtReplay`](super::completion::ThoughtReplay)).
55//!
56//! # Lifecycle
57//!
58//! - The book tracks each cache's expiry and never sends one it knows has
59//!   expired. A new cache's TTL follows the gaps between calls (at least
60//!   twice the longest gap seen, never less than [`AutoCache::ttl`], never more
61//!   than 81 minutes, past which keeping a cache costs more than it saves at
62//!   the default prices), and a cache read close to expiry is extended.
63//! - A replaced cache is deleted when the roll replaces it, and a
64//!   conversation cache nobody has read for a while is deleted too.
65//! - A cache that answers 403 (deleted elsewhere, or expired early) is
66//!   forgotten and the request is sent again inline, once.
67//! - [`CacheBook::close`] deletes everything still live.
68//!
69//! # Resume
70//!
71//! A checkpoint stores [`CacheBook::leases`] beside the run. A new book in a
72//! new process calls [`CacheBook::restore`] with them, then
73//! [`CacheBook::prove`], which keeps a lease only if Google still has it
74//! under its name. The resumed run then reads the surviving cache instead of
75//! creating a new one.
76//!
77//! # Scope
78//!
79//! This covers `generateContent` and `streamGenerateContent` on the Gemini
80//! Developer API. The Interactions API has no explicit caching. Vertex AI
81//! and `rig-gemini-grpc` are not covered, nor are hosted-tool parts, which
82//! this crate's Gemini decoder does not keep in history.
83
84use std::collections::{BTreeMap, HashMap, HashSet};
85use std::sync::{Arc, Mutex, PoisonError};
86use std::time::Duration;
87
88use serde::{Deserialize, Serialize};
89use serde_json::value::RawValue;
90use sha2::{Digest, Sha256};
91
92use crate::completion::CacheCost;
93
94/// Unix seconds.
95pub type Clock = Arc<dyn Fn() -> u64 + Send + Sync>;
96
97/// Seconds since the Unix epoch, from the system clock where the target has
98/// one. On `wasm32-unknown-unknown` there is none: pass a clock with
99/// [`CacheBook::with_clock`] there, or every cache looks forever young and
100/// expiry is found by Gemini's 403 instead.
101fn system_now() -> u64 {
102    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
103    {
104        std::time::SystemTime::now()
105            .duration_since(std::time::UNIX_EPOCH)
106            .map_or(0, |elapsed| elapsed.as_secs())
107    }
108    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
109    {
110        0
111    }
112}
113
114/// The longest TTL the book gives a cache: past about 81 minutes between
115/// reads, storage costs more than a cached read saves at the default prices.
116const MAX_TTL_SECS: u64 = 81 * 60;
117
118/// A cache is used only with more than this many seconds left.
119const EXPIRY_MARGIN_SECS: u64 = 5;
120
121/// A conversation cache unread for this long, or three times its line's
122/// longest gap if longer, belongs to a conversation that moved on.
123const MIN_IDLE_SECS: u64 = 60;
124
125/// The `Auto` policy's parameters. The price ratios default to
126/// gemini-3.8-flash standard prices relative to its input price (cached read
127/// $0.075 and storage $0.50 per 1M tokens per hour, against $0.75 input);
128/// [`AutoCache::for_spec`] reads the cached-read ratio of another model
129/// from its catalog spec. The catalog holds no storage price.
130#[derive(Clone, Copy, Debug, PartialEq)]
131pub struct AutoCache {
132    /// The shortest TTL a new cache gets.
133    pub ttl: Duration,
134    /// Smallest request implicit caching serves (4,096 tokens on Gemini 3).
135    pub implicit_floor: u64,
136    /// Above this many cacheable tokens the book sends requests inline and
137    /// lets implicit caching serve them, until implicit caching is seen
138    /// failing.
139    pub implicit_ceiling: u64,
140    /// Gemini's minimum cache size.
141    pub min_tokens: u64,
142    /// The fewest tokens a new cache must add over the one it replaces.
143    pub min_gain: u64,
144    /// Cached-read price over input price.
145    pub cached_ratio: f64,
146    /// Storage price per hour over input price.
147    pub storage_ratio_per_hour: f64,
148}
149
150impl Default for AutoCache {
151    fn default() -> Self {
152        Self {
153            ttl: Duration::from_secs(60 * 60),
154            implicit_floor: 4_096,
155            implicit_ceiling: 16_000,
156            min_tokens: 1_024,
157            min_gain: 256,
158            cached_ratio: 0.1,
159            storage_ratio_per_hour: 0.5 / 0.75,
160        }
161    }
162}
163
164impl AutoCache {
165    /// The default policy with the cached-read ratio of `spec`'s pricing,
166    /// such as the spec a model carries ([`DynModel::spec`]). A spec with no
167    /// usable price keeps the default ratio.
168    ///
169    /// [`DynModel::spec`]: crate::DynModel::spec
170    pub fn for_spec(spec: &crate::catalog::ModelSpec) -> Self {
171        Self::priced(spec.pricing.as_ref())
172    }
173
174    /// The default policy with the cached-read ratio of `pricing`. Without
175    /// a cached-read price, with a negative one, or with no positive input
176    /// price to divide by, the default ratio stays.
177    fn priced(pricing: Option<&crate::catalog::Pricing>) -> Self {
178        let ratio = pricing
179            .and_then(|pricing| Some((pricing.cache_read?, pricing.input)))
180            .filter(|(read, input)| *read >= 0.0 && *input > 0.0)
181            .map(|(read, input)| read / input);
182        let default = Self::default();
183        Self {
184            cached_ratio: ratio.unwrap_or(default.cached_ratio),
185            ..default
186        }
187    }
188}
189
190/// One cache the book holds.
191#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
192pub struct Lease {
193    /// `cachedContents/<id>`.
194    pub name: String,
195    /// Hex SHA-256 of the prefix the cache holds. Its first 40 characters
196    /// end the cache's display name, which is how [`CacheBook::prove`]
197    /// recognizes it.
198    pub digest: String,
199    /// How many contents the cache holds.
200    pub covers: usize,
201    /// Its size, as Gemini counted it when it was created.
202    pub tokens: u64,
203    /// Unix seconds.
204    pub expires_at: u64,
205    /// The model the cache belongs to.
206    pub model: String,
207}
208
209/// Something that happened to a cache.
210#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
211pub enum CacheEvent {
212    /// A cache was created.
213    Created {
214        /// `cachedContents/<id>`.
215        name: String,
216        /// Its size, as Gemini counted it.
217        tokens: u64,
218        /// The book's estimate of its size before creating it.
219        estimated: u64,
220        /// How many contents it holds.
221        covers: usize,
222        /// Its TTL.
223        ttl_secs: u64,
224        /// Unix seconds.
225        at: u64,
226    },
227    /// The book deleted a cache it no longer needs.
228    Retired {
229        /// `cachedContents/<id>`.
230        name: String,
231        /// Unix seconds.
232        at: u64,
233    },
234    /// A cache answered 403 when a request named it, and was forgotten.
235    Lost {
236        /// `cachedContents/<id>`.
237        name: String,
238        /// Unix seconds.
239        at: u64,
240    },
241    /// A cache's expiry was pushed back.
242    Extended {
243        /// `cachedContents/<id>`.
244        name: String,
245        /// The new expiry, Unix seconds.
246        expires_at: u64,
247        /// Unix seconds.
248        at: u64,
249    },
250    /// Gemini refused to create a cache; the request went out without it.
251    CreateFailed {
252        /// The HTTP status, when there was one.
253        status: Option<u16>,
254        /// Gemini's message.
255        message: String,
256        /// Unix seconds.
257        at: u64,
258    },
259}
260
261/// One cache the book created.
262#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
263pub struct CreatedCache {
264    /// `cachedContents/<id>`.
265    pub name: String,
266    /// Its size.
267    pub tokens: u64,
268    /// Unix seconds.
269    pub at: u64,
270}
271
272/// What the book's caches did and cost, for pricing a run. Creation is
273/// billed like input (`tokens` × input price); storage is `token_hours` ×
274/// the storage price.
275#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
276pub struct CacheReport {
277    /// Every cache created, in order.
278    pub created: Vec<CreatedCache>,
279    /// How many requests read each cache, by name.
280    pub reads: BTreeMap<String, u64>,
281    /// Caches the book deleted because a roll replaced them, their
282    /// conversation moved on, or [`CacheBook::close`] ran.
283    pub retired: Vec<String>,
284    /// Caches that answered 403 and were forgotten.
285    pub lost: Vec<String>,
286    /// Σ tokens × hours each cache lived, until it was deleted, lost or
287    /// expired, or until now for a live one.
288    pub token_hours: f64,
289    /// The caches still live.
290    pub live: Vec<Lease>,
291}
292
293/// The book's caches as billed beside the calls: every created token as a
294/// cache write (priced at the input price) and `token_hours` of storage. Add
295/// it to the calls' [`CacheCost::from_usage`] to price a run.
296impl From<&CacheReport> for CacheCost {
297    fn from(report: &CacheReport) -> Self {
298        Self {
299            cache_writes: report.created.iter().map(|created| created.tokens).sum(),
300            storage_token_hours: report.token_hours,
301            ..Self::default()
302        }
303    }
304}
305
306#[derive(Clone, Debug, Default)]
307struct Line {
308    /// Premium tokens: cacheable input paid at full price, weighted by
309    /// `1 - cached_ratio`, since the line last rolled.
310    premium: f64,
311    /// EWMA of implicit coverage on inline calls big enough for it.
312    implicit: Option<f64>,
313    rolled_at: u64,
314    last_call: Option<u64>,
315    longest_gap: u64,
316    /// Coverable size at the last refused creation: no retry until the line
317    /// grows past it.
318    failed_at: Option<u64>,
319}
320
321#[derive(Clone, Debug)]
322struct Life {
323    tokens: u64,
324    created: u64,
325    expires_at: u64,
326    ended: Option<u64>,
327    last_read: u64,
328    line_gap: u64,
329}
330
331#[derive(Default)]
332pub(crate) struct Book {
333    pub(crate) leases: HashMap<String, Lease>,
334    lives: BTreeMap<String, Life>,
335    /// For each prefix, the conversations seen on it, by their first content.
336    lineages: HashMap<String, HashSet<String>>,
337    lines: HashMap<String, Line>,
338    events: Vec<CacheEvent>,
339    report: CacheReport,
340    /// Gemini's tokens over this book's estimate, learned from each cache it
341    /// creates.
342    calibration: Option<f64>,
343}
344
345/// The shared, content-addressed record of the caches a set of models
346/// reads. Clone it into every model that should share caches: sub-agents on
347/// one prefix, a resumed run and its successor.
348#[derive(Clone)]
349pub struct CacheBook {
350    inner: Arc<Mutex<Book>>,
351    /// Serializes cache creation, so a prefix is created once.
352    pub(crate) create: Arc<futures::lock::Mutex<()>>,
353    policy: AutoCache,
354    clock: Clock,
355    pub(crate) display_prefix: Arc<str>,
356}
357
358impl std::fmt::Debug for CacheBook {
359    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360        f.debug_struct("CacheBook")
361            .field("policy", &self.policy)
362            .field("display_prefix", &self.display_prefix)
363            .finish_non_exhaustive()
364    }
365}
366
367impl CacheBook {
368    /// A book with `policy`, on the system clock.
369    pub fn new(policy: AutoCache) -> Self {
370        Self {
371            inner: Arc::default(),
372            create: Arc::default(),
373            policy,
374            clock: Arc::new(system_now),
375            display_prefix: Arc::from("rig-cache-"),
376        }
377    }
378
379    /// The same book reading time from `clock` (Unix seconds).
380    pub fn with_clock(mut self, clock: impl Fn() -> u64 + Send + Sync + 'static) -> Self {
381        self.clock = Arc::new(clock);
382        self
383    }
384
385    /// The same book naming its caches `<prefix><digest>`, so they can be
386    /// listed and swept. Defaults to `rig-cache-`.
387    pub fn with_display_prefix(mut self, prefix: &str) -> Self {
388        self.display_prefix = Arc::from(prefix);
389        self
390    }
391
392    /// The book's policy.
393    pub fn policy(&self) -> AutoCache {
394        self.policy
395    }
396
397    pub(crate) fn book(&self) -> std::sync::MutexGuard<'_, Book> {
398        self.inner.lock().unwrap_or_else(PoisonError::into_inner)
399    }
400
401    pub(crate) fn now(&self) -> u64 {
402        (self.clock)()
403    }
404
405    /// Everything that happened to the book's caches, in order.
406    pub fn events(&self) -> Vec<CacheEvent> {
407        self.book().events.clone()
408    }
409
410    /// The caches the book holds, for a checkpoint.
411    pub fn leases(&self) -> Vec<Lease> {
412        let mut leases: Vec<Lease> = self.book().leases.values().cloned().collect();
413        leases.sort_by(|a, b| a.name.cmp(&b.name));
414        leases
415    }
416
417    /// Put checkpointed leases back. Call [`Self::prove`] next.
418    pub fn restore(&self, leases: Vec<Lease>) {
419        let now = self.now();
420        let mut book = self.book();
421        for lease in leases {
422            book.lives.entry(lease.name.clone()).or_insert(Life {
423                tokens: lease.tokens,
424                created: now,
425                expires_at: lease.expires_at,
426                ended: None,
427                last_read: now,
428                line_gap: 0,
429            });
430            book.leases.insert(lease.digest.clone(), lease);
431        }
432    }
433
434    /// What the caches did and cost so far.
435    pub fn report(&self) -> CacheReport {
436        let now = self.now();
437        let book = self.book();
438        let mut report = book.report.clone();
439        report.token_hours = book
440            .lives
441            .values()
442            .map(|life| {
443                let end = life.ended.unwrap_or(now).min(life.expires_at);
444                life.tokens as f64 * end.saturating_sub(life.created) as f64 / 3600.0
445            })
446            .sum();
447        report.live = book.leases.values().cloned().collect();
448        report.live.sort_by(|a, b| a.name.cmp(&b.name));
449        report
450    }
451
452    pub(crate) fn retired(&self, lease: &Lease) {
453        let now = self.now();
454        let mut book = self.book();
455        book.leases.remove(&lease.digest);
456        end_life(&mut book, &lease.name, now);
457        book.events.push(CacheEvent::Retired {
458            name: lease.name.clone(),
459            at: now,
460        });
461        book.report.retired.push(lease.name.clone());
462        tracing::info!(target: "gemini.cache.retired", name = %lease.name);
463    }
464
465    pub(crate) fn lost(&self, lease: &Lease) {
466        let now = self.now();
467        let mut book = self.book();
468        book.leases.remove(&lease.digest);
469        end_life(&mut book, &lease.name, now);
470        book.events.push(CacheEvent::Lost {
471            name: lease.name.clone(),
472            at: now,
473        });
474        book.report.lost.push(lease.name.clone());
475        tracing::info!(target: "gemini.cache.lost", name = %lease.name);
476    }
477}
478
479pub(crate) fn end_life(book: &mut Book, name: &str, now: u64) {
480    if let Some(life) = book.lives.get_mut(name)
481        && life.ended.is_none()
482    {
483        life.ended = Some(now);
484    }
485}
486
487pub(crate) fn short_digest(digest: &str) -> &str {
488    digest.get(..40).unwrap_or(digest)
489}
490
491// ---------------------------------------------------------------------------
492// Request bodies: parsing, digests, stripping and cache bodies.
493
494const PREFIX_KEYS: [&str; 3] = ["systemInstruction", "tools", "toolConfig"];
495
496/// A `generateContent` body, its contents kept as their exact bytes.
497pub(crate) struct Parsed {
498    /// Every other top-level field, in order, as exact bytes.
499    pub(crate) rest: Vec<(String, Box<RawValue>)>,
500    /// `systemInstruction`, `tools` and `toolConfig`, when present and not null.
501    pub(crate) prefix: [Option<Box<RawValue>>; 3],
502    pub(crate) contents: Vec<Box<RawValue>>,
503    pub(crate) has_cached_content: bool,
504}
505
506/// A JSON object's members in their order, each as its exact bytes. A
507/// repeated key keeps its first place and its last value, as a map does.
508struct Members(Vec<(String, Box<RawValue>)>);
509
510impl<'de> Deserialize<'de> for Members {
511    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
512        struct Visitor;
513        impl<'de> serde::de::Visitor<'de> for Visitor {
514            type Value = Members;
515            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516                formatter.write_str("a JSON object")
517            }
518            fn visit_map<A: serde::de::MapAccess<'de>>(
519                self,
520                mut map: A,
521            ) -> Result<Members, A::Error> {
522                let mut members: Vec<(String, Box<RawValue>)> = Vec::new();
523                while let Some((key, value)) = map.next_entry::<String, Box<RawValue>>()? {
524                    match members.iter_mut().find(|(held, _)| *held == key) {
525                        Some((_, held)) => *held = value,
526                        None => members.push((key, value)),
527                    }
528                }
529                Ok(Members(members))
530            }
531        }
532        deserializer.deserialize_map(Visitor)
533    }
534}
535
536pub(crate) fn parse(bytes: &[u8]) -> Option<Parsed> {
537    let Members(members) = serde_json::from_slice(bytes).ok()?;
538    let mut parsed = Parsed {
539        rest: Vec::new(),
540        prefix: [None, None, None],
541        contents: Vec::new(),
542        has_cached_content: false,
543    };
544    for (key, value) in members {
545        if key == "contents" {
546            parsed.contents = serde_json::from_str(value.get()).ok()?;
547        } else if let Some(slot) = PREFIX_KEYS.iter().position(|prefix| *prefix == key) {
548            if value.get() != "null"
549                && let Some(field) = parsed.prefix.get_mut(slot)
550            {
551                *field = Some(value);
552            }
553        } else {
554            if key == "cachedContent" && value.get() != "null" {
555                parsed.has_cached_content = true;
556            }
557            parsed.rest.push((key, value));
558        }
559    }
560    Some(parsed)
561}
562
563fn hex(bytes: &[u8]) -> String {
564    use std::fmt::Write;
565    bytes.iter().fold(String::new(), |mut out, byte| {
566        let _ = write!(out, "{byte:02x}");
567        out
568    })
569}
570
571/// What the book reads off a body, before it takes its lock.
572pub(crate) struct Digested {
573    /// `d[k]`: the digest of the model, the prefix and `contents[..k]`.
574    pub(crate) d: Vec<String>,
575    /// The token estimate of the prefix.
576    pub(crate) prefix: u64,
577    /// The token estimate of each content.
578    pub(crate) contents: Vec<u64>,
579}
580
581pub(crate) fn digests(model: &str, parsed: &Parsed) -> Digested {
582    let mut hasher = Sha256::new();
583    hasher.update(model.as_bytes());
584    let mut prefix = 0;
585    for field in &parsed.prefix {
586        hasher.update([0u8]);
587        if let Some(raw) = field {
588            prefix += digest(&mut hasher, raw.get());
589        }
590    }
591    let mut state = hasher.finalize().to_vec();
592    let mut d = vec![hex(&state)];
593    let mut contents = Vec::with_capacity(parsed.contents.len());
594    for content in &parsed.contents {
595        let mut hasher = Sha256::new();
596        hasher.update(&state);
597        contents.push(digest(&mut hasher, content.get()));
598        state = hasher.finalize().to_vec();
599        d.push(hex(&state));
600    }
601    Digested {
602        d,
603        prefix,
604        contents,
605    }
606}
607
608/// Hash `fragment` with its keys sorted (a history replays a provider part
609/// with the key order it arrived in, which a recording does not keep, so
610/// the digests read the content, not its spelling), and return a rough
611/// token count of it: its bytes over four, without thought signatures,
612/// which Gemini expands into restored thoughts that no explicit cache holds.
613fn digest(hasher: &mut Sha256, fragment: &str) -> u64 {
614    let Ok(mut value) = serde_json::from_str::<serde_json::Value>(fragment) else {
615        hasher.update(fragment.as_bytes());
616        return (fragment.len() / 4) as u64;
617    };
618    hasher.update(crate::json_utils::to_canonical_string(&value).as_bytes());
619    fn scrub(value: &mut serde_json::Value) {
620        match value {
621            serde_json::Value::Object(map) => {
622                map.shift_remove("thoughtSignature");
623                map.values_mut().for_each(scrub);
624            }
625            serde_json::Value::Array(items) => items.iter_mut().for_each(scrub),
626            _ => {}
627        }
628    }
629    scrub(&mut value);
630    (value.to_string().len() / 4) as u64
631}
632
633/// Whether a content is the user's text: a new user turn, not a tool result.
634pub(crate) fn is_user_text(raw: &RawValue) -> bool {
635    #[derive(Deserialize)]
636    struct Content {
637        role: Option<String>,
638        #[serde(default)]
639        parts: Vec<serde_json::Map<String, serde_json::Value>>,
640    }
641    serde_json::from_str::<Content>(raw.get()).is_ok_and(|content| {
642        content.role.as_deref() == Some("user")
643            && content.parts.iter().any(|part| part.contains_key("text"))
644            && !content
645                .parts
646                .iter()
647                .any(|part| part.contains_key("functionResponse"))
648    })
649}
650
651/// The body that reads `lease` instead of what it holds.
652pub(crate) fn stripped(parsed: &Parsed, lease: &Lease) -> Option<Vec<u8>> {
653    let mut out: Vec<(String, &RawValue)> = Vec::new();
654    let name = serde_json::value::to_raw_value(&lease.name).ok()?;
655    let contents = serde_json::value::to_raw_value(&parsed.contents.get(lease.covers..)?).ok()?;
656    out.push(("cachedContent".to_owned(), &name));
657    out.push(("contents".to_owned(), &contents));
658    for (key, value) in &parsed.rest {
659        if key != "cachedContent" {
660            out.push((key.clone(), value));
661        }
662    }
663    let mut bytes = b"{".to_vec();
664    for (index, (key, value)) in out.iter().enumerate() {
665        if index > 0 {
666            bytes.push(b',');
667        }
668        bytes.extend(serde_json::to_vec(key).ok()?);
669        bytes.push(b':');
670        bytes.extend(value.get().as_bytes());
671    }
672    bytes.push(b'}');
673    Some(bytes)
674}
675
676/// The `cachedContents` create body for the first `covers` contents, from
677/// the request's own bytes.
678pub(crate) fn cache_body(
679    model: &str,
680    parsed: &Parsed,
681    covers: usize,
682    display_name: &str,
683    ttl_secs: u64,
684) -> Option<Vec<u8>> {
685    let mut bytes = b"{".to_vec();
686    let mut field = |key: &str, value: &str| {
687        if bytes.len() > 1 {
688            bytes.push(b',');
689        }
690        bytes.extend(format!("\"{key}\":").as_bytes());
691        bytes.extend(value.as_bytes());
692    };
693    field(
694        "model",
695        &serde_json::to_string(&format!("models/{model}")).ok()?,
696    );
697    field("displayName", &serde_json::to_string(display_name).ok()?);
698    field("ttl", &format!("\"{ttl_secs}s\""));
699    for (key, value) in PREFIX_KEYS.iter().zip(&parsed.prefix) {
700        if let Some(value) = value {
701            field(key, value.get());
702        }
703    }
704    if covers > 0 {
705        let contents = serde_json::value::to_raw_value(&parsed.contents.get(..covers)?).ok()?;
706        field("contents", contents.get());
707    }
708    bytes.push(b'}');
709    Some(bytes)
710}
711
712// ---------------------------------------------------------------------------
713// The plan.
714
715/// What one request reads, creates, retires and extends.
716pub(crate) struct Plan {
717    pub(crate) read: Option<Lease>,
718    pub(crate) create: Option<Create>,
719    pub(crate) retire: Vec<Lease>,
720    pub(crate) extend: Option<(Lease, u64)>,
721    pub(crate) line: String,
722    pub(crate) coverable: u64,
723}
724
725pub(crate) struct Create {
726    pub(crate) covers: usize,
727    pub(crate) digest: String,
728    pub(crate) ttl_secs: u64,
729    /// Uncalibrated bytes/4 estimate of what the cache holds.
730    estimate: u64,
731    /// The calibrated estimate the plan used.
732    expected: u64,
733    pub(crate) replaces: Option<Lease>,
734}
735
736impl CacheBook {
737    pub(crate) fn plan(&self, model: &str, digested: &Digested, roll_allowed: bool) -> Plan {
738        let p = self.policy;
739        let now = self.now();
740        let n = digested.contents.len();
741        // `d` holds one digest per content boundary, `0..=n`.
742        let at = |k: usize| digested.d.get(k).map_or("", String::as_str);
743        let (prefix_estimate, content_estimates) = (digested.prefix, &digested.contents);
744        let mut book = self.book();
745        let calibration = book.calibration.unwrap_or(1.0);
746
747        // Who shares this prefix: a second conversation on it (sub-agents,
748        // concurrent users, or the conversation a compaction started).
749        if n >= 1 {
750            let first = at(1).to_owned();
751            book.lineages
752                .entry(at(0).to_owned())
753                .or_default()
754                .insert(first);
755        }
756        let shared = book.lineages.get(at(0)).is_some_and(|seen| seen.len() >= 2);
757
758        // Retire conversation caches nobody reads any more.
759        let idle: Vec<Lease> = book
760            .leases
761            .values()
762            .filter(|lease| lease.covers > 0 && lease.model == model)
763            .filter(|lease| {
764                book.lives.get(&lease.name).is_some_and(|life| {
765                    now.saturating_sub(life.last_read)
766                        > MIN_IDLE_SECS.max(life.line_gap.saturating_mul(3))
767                })
768            })
769            .cloned()
770            .collect();
771
772        // The longest live cache this request starts with; never the newest content.
773        let read = (0..n.max(1))
774            .rev()
775            .find_map(|k| {
776                book.leases
777                    .get(at(k))
778                    .filter(|lease| lease.model == model)
779                    .filter(|lease| lease.expires_at > now + EXPIRY_MARGIN_SECS)
780                    .cloned()
781            })
782            .filter(|lease| !idle.iter().any(|gone| gone.name == lease.name));
783        let covered = read.as_ref().map_or(0, |lease| lease.covers);
784        let tail: u64 = content_estimates
785            .get(covered..n.saturating_sub(1).max(covered))
786            .unwrap_or_default()
787            .iter()
788            .sum();
789        let coverable = match &read {
790            Some(lease) => lease.tokens + (tail as f64 * calibration) as u64,
791            None => ((prefix_estimate + tail) as f64 * calibration) as u64,
792        };
793
794        // The conversation's line: its cache, or its first content when inline.
795        let line_key = read
796            .as_ref()
797            .filter(|lease| lease.covers > 0)
798            .map_or_else(|| at(1.min(n)).to_owned(), |lease| lease.digest.clone());
799        let prefix_leased = book.leases.contains_key(at(0));
800        let line = book.lines.entry(line_key.clone()).or_insert_with(|| Line {
801            rolled_at: now,
802            ..Line::default()
803        });
804        if let Some(last) = line.last_call {
805            line.longest_gap = line.longest_gap.max(now.saturating_sub(last));
806        }
807        line.last_call = Some(now);
808        let longest_gap = line.longest_gap;
809        let ttl_secs = p
810            .ttl
811            .as_secs()
812            .max(longest_gap.saturating_mul(2).min(MAX_TTL_SECS))
813            .max(1);
814
815        let mut plan = Plan {
816            read: read.clone(),
817            create: None,
818            retire: idle,
819            extend: None,
820            line: line_key,
821            coverable,
822        };
823
824        // Past the ceiling, implicit caching serves: go inline until it fails.
825        let implicit_serving = line.implicit.is_none_or(|ratio| ratio >= 0.5);
826        if coverable >= p.implicit_ceiling && implicit_serving {
827            plan.read = None;
828            return plan;
829        }
830
831        let failed_below = line.failed_at.is_some_and(|at| coverable < at + p.min_gain);
832        if read.is_none()
833            && shared
834            && !prefix_leased
835            && (prefix_estimate as f64 * calibration) as u64 >= p.min_tokens
836            && !failed_below
837        {
838            plan.create = Some(Create {
839                covers: 0,
840                digest: at(0).to_owned(),
841                ttl_secs,
842                estimate: prefix_estimate,
843                expected: (prefix_estimate as f64 * calibration) as u64,
844                replaces: None,
845            });
846        } else if n >= 2 && roll_allowed && !failed_below {
847            let cached = read.as_ref().map_or(0, |lease| lease.tokens);
848            let gain = coverable.saturating_sub(cached);
849            let held_hours = now.saturating_sub(line.rolled_at).max(60) as f64 / 3600.0;
850            let cost = coverable as f64 * (1.0 + p.storage_ratio_per_hour * held_hours);
851            if gain >= p.min_gain && coverable >= p.min_tokens && line.premium >= cost {
852                plan.create = Some(Create {
853                    covers: n - 1,
854                    digest: at(n - 1).to_owned(),
855                    ttl_secs,
856                    estimate: prefix_estimate
857                        + content_estimates
858                            .get(..n - 1)
859                            .unwrap_or_default()
860                            .iter()
861                            .sum::<u64>(),
862                    expected: coverable,
863                    replaces: read.clone().filter(|lease| lease.covers > 0),
864                });
865            }
866        }
867
868        // Extend a cache that would expire before its line's next call.
869        if plan.create.is_none()
870            && let Some(lease) = &read
871            && longest_gap > 0
872            && lease.expires_at.saturating_sub(now) < longest_gap.saturating_mul(3) / 2
873        {
874            plan.extend = Some((lease.clone(), ttl_secs));
875        }
876        plan
877    }
878
879    /// Account for one reply's usage on `line`.
880    pub(crate) fn observe(&self, line: &str, read: Option<&str>, coverable: u64, cached: u64) {
881        let p = self.policy;
882        let mut book = self.book();
883        if let Some(name) = read {
884            *book.report.reads.entry(name.to_owned()).or_default() += 1;
885        }
886        let entry = book.lines.entry(line.to_owned()).or_default();
887        let mut missed = coverable.saturating_sub(cached) as f64;
888        if read.is_none() && coverable >= p.implicit_floor {
889            let ratio = cached as f64 / coverable.max(1) as f64;
890            let implicit = entry
891                .implicit
892                .map_or(ratio, |ewma| 0.5 * ewma + 0.5 * ratio);
893            entry.implicit = Some(implicit);
894            if implicit >= 0.5 {
895                missed = 0.0;
896            }
897        }
898        entry.premium += missed * (1.0 - p.cached_ratio);
899    }
900
901    pub(crate) fn created(
902        &self,
903        from_line: &str,
904        create: &Create,
905        name: String,
906        tokens: u64,
907        model: &str,
908    ) -> Lease {
909        let now = self.now();
910        let lease = Lease {
911            name: name.clone(),
912            digest: create.digest.clone(),
913            covers: create.covers,
914            tokens,
915            expires_at: now + create.ttl_secs,
916            model: model.to_owned(),
917        };
918        let mut book = self.book();
919        if create.estimate > 0 && tokens > 0 {
920            let ratio = tokens as f64 / create.estimate as f64;
921            book.calibration = Some(book.calibration.map_or(ratio, |c| 0.5 * c + 0.5 * ratio));
922        }
923        let previous = book.lines.get(from_line).cloned().unwrap_or_default();
924        if create.covers > 0 {
925            book.lines.insert(
926                lease.digest.clone(),
927                Line {
928                    premium: 0.0,
929                    implicit: previous.implicit,
930                    rolled_at: now,
931                    last_call: previous.last_call,
932                    longest_gap: previous.longest_gap,
933                    failed_at: None,
934                },
935            );
936        }
937        book.lives.insert(
938            name.clone(),
939            Life {
940                tokens,
941                created: now,
942                expires_at: lease.expires_at,
943                ended: None,
944                last_read: now,
945                line_gap: previous.longest_gap,
946            },
947        );
948        book.events.push(CacheEvent::Created {
949            name: name.clone(),
950            tokens,
951            estimated: create.expected,
952            covers: create.covers,
953            ttl_secs: create.ttl_secs,
954            at: now,
955        });
956        book.report.created.push(CreatedCache {
957            name,
958            tokens,
959            at: now,
960        });
961        book.leases.insert(lease.digest.clone(), lease.clone());
962        tracing::info!(target: "gemini.cache.created", name = %lease.name, tokens, covers = create.covers);
963        lease
964    }
965
966    pub(crate) fn create_failed(
967        &self,
968        line: &str,
969        coverable: u64,
970        status: Option<u16>,
971        message: String,
972    ) {
973        let now = self.now();
974        let mut book = self.book();
975        if let Some(entry) = book.lines.get_mut(line) {
976            entry.failed_at = Some(coverable);
977        }
978        book.events.push(CacheEvent::CreateFailed {
979            status,
980            message,
981            at: now,
982        });
983    }
984
985    pub(crate) fn extended(&self, lease: &Lease, ttl_secs: u64) {
986        let now = self.now();
987        let expires_at = now + ttl_secs;
988        let mut book = self.book();
989        if let Some(held) = book.leases.get_mut(&lease.digest) {
990            held.expires_at = expires_at;
991        }
992        if let Some(life) = book.lives.get_mut(&lease.name) {
993            life.expires_at = expires_at;
994        }
995        book.events.push(CacheEvent::Extended {
996            name: lease.name.clone(),
997            expires_at,
998            at: now,
999        });
1000        tracing::info!(target: "gemini.cache.extended", name = %lease.name, expires_at);
1001    }
1002
1003    pub(crate) fn touched(&self, lease: &Lease) {
1004        let now = self.now();
1005        let mut book = self.book();
1006        let gap = book
1007            .lines
1008            .get(&lease.digest)
1009            .map_or(0, |line| line.longest_gap);
1010        if let Some(life) = book.lives.get_mut(&lease.name) {
1011            life.last_read = now;
1012            life.line_gap = life.line_gap.max(gap);
1013        }
1014    }
1015}
1016
1017#[cfg(test)]
1018mod tests;