Skip to main content

lean_ctx_proxy/
cache_share.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Cache-share guard: per provider+model cost log, rolling cache-read share,
3//! alarm below the threshold, and the provider allowlist for open models.
4//!
5//! A customer at the plan limit costs several times more when the provider's
6//! prompt cache does not hit: every prompt token then bills at the full input
7//! rate instead of the cache-read rate. This module watches that per
8//! provider+model:
9//!
10//! - **Cost log** — [`record_turn`] logs every measured provider turn with its
11//!   provider, model, input/cache-read/cache-write/output tokens and the
12//!   purchase price of each token class (USD per 1M tokens).
13//! - **Rolling aggregate** — the turns land in time buckets per
14//!   provider+model; [`CacheShareLedger::report`] sums the configured window
15//!   into the cache-read share of the prompt tokens and the effective cost
16//!   (with what the same tokens would have cost without any cache).
17//! - **Alarm** — when a window with enough prompt tokens drops below
18//!   `[proxy.cache_share] alarm_threshold` (default 90 %), a warning is logged
19//!   and a `VerificationWarning` event (`cache_share_below_threshold`) is
20//!   emitted once; it re-arms when the share recovers.
21//! - **Open-model allowlist** — an open-weight model served by a provider
22//!   outside `open_model_providers` is flagged (warning + event) or, with
23//!   `open_model_policy = "refuse"`, refused before the request leaves
24//!   ([`open_model_refusal`]). Local endpoints are exempt.
25//!
26//! The ledger persists to `proxy_cache_share.json` next to `proxy_usage.json`
27//! so `lean-ctx spend --cache` reads it from another process.
28
29use std::collections::VecDeque;
30use std::sync::{Mutex, OnceLock};
31
32use serde::{Deserialize, Serialize};
33
34use lean_ctx::core::config::CacheShareConfig;
35use lean_ctx::core::gain::model_pricing::{ModelCost, ModelPricing};
36
37/// File the ledger persists to, in the lean-ctx data directory.
38const CACHE_SHARE_FILE: &str = "proxy_cache_share.json";
39/// Distinct provider+model keys kept; further keys fold into one overflow key.
40const MAX_TRACKED_KEYS: usize = 256;
41/// Buckets per window: the window is summed from this many time slices.
42const BUCKETS_PER_WINDOW: u64 = 60;
43/// Reason code of a refused open model on an unlisted provider.
44pub const OPEN_MODEL_PROVIDER_NOT_ALLOWED: &str = "destination.open_model_provider_not_allowed";
45/// Event kind of the cache-share alarm.
46pub const ALARM_EVENT: &str = "cache_share_below_threshold";
47/// Event kind of an open model on an unlisted provider.
48pub const OPEN_MODEL_EVENT: &str = "open_model_provider_not_allowlisted";
49
50/// Built-in name fragments of open-weight model families.
51const OPEN_MODEL_FRAGMENTS: &[&str] = &[
52    "llama",
53    "qwen",
54    "qwq",
55    "deepseek",
56    "mistral",
57    "mixtral",
58    "codestral",
59    "devstral",
60    "magistral",
61    "gemma",
62    "kimi",
63    "glm-",
64    "gpt-oss",
65    "minimax",
66    "nemotron",
67    "olmo",
68    "granite",
69    "phi-",
70];
71
72/// Resolved `[proxy.cache_share]` settings.
73#[derive(Debug, Clone, PartialEq)]
74pub struct Settings {
75    pub threshold: f64,
76    pub window_secs: u64,
77    pub min_prompt_tokens: u64,
78    pub open_model_providers: Vec<String>,
79    pub open_model_patterns: Vec<String>,
80    pub refuse_unlisted_open_models: bool,
81}
82
83impl Settings {
84    #[must_use]
85    pub fn from_config(cfg: &CacheShareConfig) -> Self {
86        Self {
87            threshold: cfg.effective_threshold(),
88            window_secs: cfg.effective_window_secs(),
89            min_prompt_tokens: cfg.effective_min_prompt_tokens(),
90            open_model_providers: cfg
91                .open_model_providers
92                .iter()
93                .map(|p| p.trim().to_ascii_lowercase())
94                .filter(|p| !p.is_empty())
95                .collect(),
96            open_model_patterns: cfg
97                .open_model_patterns
98                .iter()
99                .map(|p| p.trim().to_ascii_lowercase())
100                .filter(|p| !p.is_empty())
101                .collect(),
102            refuse_unlisted_open_models: cfg.refuses_unlisted_open_models(),
103        }
104    }
105
106    /// Settings from the current `config.toml`.
107    #[must_use]
108    pub fn load() -> Self {
109        Self::from_config(&lean_ctx::core::config::Config::load().proxy.cache_share)
110    }
111
112    fn bucket_secs(&self) -> u64 {
113        (self.window_secs / BUCKETS_PER_WINDOW).max(1)
114    }
115}
116
117impl Default for Settings {
118    fn default() -> Self {
119        Self::from_config(&CacheShareConfig::default())
120    }
121}
122
123/// Purchase price per token class, USD per 1M tokens.
124#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
125pub struct TokenPrices {
126    pub input_per_m: f64,
127    pub cache_read_per_m: f64,
128    pub cache_write_per_m: f64,
129    pub output_per_m: f64,
130}
131
132impl From<ModelCost> for TokenPrices {
133    fn from(c: ModelCost) -> Self {
134        Self {
135            input_per_m: c.input_per_m,
136            cache_read_per_m: c.cache_read_per_m,
137            cache_write_per_m: c.cache_write_per_m,
138            output_per_m: c.output_per_m,
139        }
140    }
141}
142
143/// One measured provider turn, priced.
144#[derive(Debug, Clone, PartialEq)]
145pub struct CallCost {
146    pub provider: String,
147    pub model: String,
148    /// Served by a local endpoint: nothing is bought, no alarm.
149    pub local: bool,
150    pub input_tokens: u64,
151    pub cache_read_tokens: u64,
152    pub cache_write_tokens: u64,
153    pub output_tokens: u64,
154    pub prices: TokenPrices,
155    /// What the turn cost: the provider's own charge when reported, else the
156    /// table price of the four token classes.
157    pub cost_usd: f64,
158    /// What the same tokens cost with every prompt token at the input rate.
159    pub uncached_cost_usd: f64,
160}
161
162impl CallCost {
163    /// Prices one turn with `prices`; `measured_usd` (the provider's own
164    /// charge) wins over the table price.
165    #[must_use]
166    #[allow(clippy::too_many_arguments)]
167    pub fn priced(
168        provider: &str,
169        model: &str,
170        local: bool,
171        input_tokens: u64,
172        cache_read_tokens: u64,
173        cache_write_tokens: u64,
174        output_tokens: u64,
175        prices: TokenPrices,
176        measured_usd: Option<f64>,
177    ) -> Self {
178        #[allow(clippy::cast_precision_loss)]
179        let per_m = |tokens: u64, rate: f64| tokens as f64 / 1_000_000.0 * rate;
180        let table = per_m(input_tokens, prices.input_per_m)
181            + per_m(cache_read_tokens, prices.cache_read_per_m)
182            + per_m(cache_write_tokens, prices.cache_write_per_m)
183            + per_m(output_tokens, prices.output_per_m);
184        let prompt = input_tokens
185            .saturating_add(cache_read_tokens)
186            .saturating_add(cache_write_tokens);
187        Self {
188            provider: provider.to_string(),
189            model: model.to_string(),
190            local,
191            input_tokens,
192            cache_read_tokens,
193            cache_write_tokens,
194            output_tokens,
195            prices,
196            cost_usd: measured_usd.unwrap_or(table),
197            uncached_cost_usd: per_m(prompt, prices.input_per_m)
198                + per_m(output_tokens, prices.output_per_m),
199        }
200    }
201}
202
203/// Cache-read share of the prompt tokens (uncached input + cache reads +
204/// cache writes); `None` when there are no prompt tokens.
205#[must_use]
206pub fn cache_read_share(input: u64, cache_read: u64, cache_write: u64) -> Option<f64> {
207    let prompt = input.saturating_add(cache_read).saturating_add(cache_write);
208    #[allow(clippy::cast_precision_loss)]
209    (prompt > 0).then(|| cache_read as f64 / prompt as f64)
210}
211
212/// True when `model` names an open-weight model family (built-in fragments
213/// plus `extra`, all lowercase).
214#[must_use]
215pub fn is_open_model(model: &str, extra: &[String]) -> bool {
216    let m = model.to_ascii_lowercase();
217    OPEN_MODEL_FRAGMENTS.iter().any(|f| m.contains(f))
218        || extra.iter().any(|f| m.contains(f.as_str()))
219}
220
221/// How the open-model allowlist judges one provider+model.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
223#[serde(rename_all = "snake_case")]
224pub enum OpenModelVerdict {
225    /// Not an open model, or served locally.
226    NotApplicable,
227    /// Open model on an allowlisted provider.
228    Allowed,
229    /// Open model on a provider without a listed cache discount.
230    Flagged,
231    /// As `Flagged`, and the policy refuses it.
232    Refused,
233}
234
235/// Judges `model` on `provider` against the open-model allowlist.
236#[must_use]
237pub fn open_model_verdict(
238    provider: &str,
239    model: &str,
240    local: bool,
241    settings: &Settings,
242) -> OpenModelVerdict {
243    if local || !is_open_model(model, &settings.open_model_patterns) {
244        return OpenModelVerdict::NotApplicable;
245    }
246    let provider = provider.trim().to_ascii_lowercase();
247    if settings.open_model_providers.contains(&provider) {
248        OpenModelVerdict::Allowed
249    } else if settings.refuse_unlisted_open_models {
250        OpenModelVerdict::Refused
251    } else {
252        OpenModelVerdict::Flagged
253    }
254}
255
256/// Request-time check: the refusal code when the policy refuses this open
257/// model on this provider; a flag-only verdict is reported at record time.
258#[must_use]
259pub fn open_model_refusal(provider: &str, model: &str, local: bool) -> Option<&'static str> {
260    (open_model_verdict(provider, model, local, &Settings::load()) == OpenModelVerdict::Refused)
261        .then_some(OPEN_MODEL_PROVIDER_NOT_ALLOWED)
262}
263
264/// Token and cost sums of one time slice.
265#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
266struct Bucket {
267    start: u64,
268    requests: u64,
269    input_tokens: u64,
270    cache_read_tokens: u64,
271    cache_write_tokens: u64,
272    output_tokens: u64,
273    cost_usd: f64,
274    uncached_cost_usd: f64,
275}
276
277/// Rolling state of one provider+model.
278#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
279struct KeyState {
280    provider: String,
281    model: String,
282    #[serde(default)]
283    local: bool,
284    buckets: VecDeque<Bucket>,
285    /// Prices of the latest turn.
286    prices: TokenPrices,
287    #[serde(default)]
288    alarm_active: bool,
289    #[serde(default)]
290    alarms_fired: u64,
291    #[serde(default)]
292    last_alarm_at: Option<u64>,
293    #[serde(default)]
294    open_model_flagged: bool,
295}
296
297/// State change worth a log line and an event.
298#[derive(Debug, Clone, PartialEq)]
299pub enum Signal {
300    AlarmRaised {
301        provider: String,
302        model: String,
303        share: f64,
304        threshold: f64,
305        prompt_tokens: u64,
306    },
307    AlarmCleared {
308        provider: String,
309        model: String,
310        share: f64,
311    },
312    OpenModelFlagged {
313        provider: String,
314        model: String,
315    },
316}
317
318/// Health of one provider+model over the window.
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
320#[serde(rename_all = "snake_case")]
321pub enum ShareStatus {
322    Ok,
323    BelowThreshold,
324    /// Fewer prompt tokens than `min_prompt_tokens` in the window.
325    InsufficientData,
326    /// Served locally: nothing bought, no alarm.
327    Local,
328}
329
330/// One provider+model over the window.
331#[derive(Debug, Clone, PartialEq, Serialize)]
332pub struct CacheShareRow {
333    pub provider: String,
334    pub model: String,
335    pub requests: u64,
336    pub input_tokens: u64,
337    pub cache_read_tokens: u64,
338    pub cache_write_tokens: u64,
339    pub output_tokens: u64,
340    pub prompt_tokens: u64,
341    /// Cache reads / prompt tokens; `None` without prompt tokens.
342    pub cache_read_share: Option<f64>,
343    pub cost_usd: f64,
344    /// Cost of the same tokens with every prompt token at the input rate.
345    pub uncached_cost_usd: f64,
346    /// Purchase price per token class of the latest turn (USD per 1M).
347    pub prices: TokenPrices,
348    pub status: ShareStatus,
349    pub alarm_active: bool,
350    pub alarms_fired: u64,
351    pub last_alarm_at: Option<u64>,
352    pub open_model: OpenModelVerdict,
353}
354
355/// The window report: settings plus one row per provider+model.
356#[derive(Debug, Clone, PartialEq, Serialize)]
357pub struct CacheShareReport {
358    pub window_secs: u64,
359    pub alarm_threshold: f64,
360    pub min_prompt_tokens: u64,
361    pub rows: Vec<CacheShareRow>,
362}
363
364/// Rolling per provider+model ledger. Pure: the caller passes the clock and
365/// the settings, and turns the returned [`Signal`]s into logs and events.
366#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
367pub struct CacheShareLedger {
368    keys: Vec<KeyState>,
369}
370
371struct Window {
372    requests: u64,
373    input: u64,
374    cache_read: u64,
375    cache_write: u64,
376    output: u64,
377    cost: f64,
378    uncached: f64,
379}
380
381impl KeyState {
382    fn window(&self, now: u64, settings: &Settings) -> Window {
383        let from = now.saturating_sub(settings.window_secs);
384        let mut w = Window {
385            requests: 0,
386            input: 0,
387            cache_read: 0,
388            cache_write: 0,
389            output: 0,
390            cost: 0.0,
391            uncached: 0.0,
392        };
393        for b in self.buckets.iter().filter(|b| b.start >= from) {
394            w.requests += b.requests;
395            w.input += b.input_tokens;
396            w.cache_read += b.cache_read_tokens;
397            w.cache_write += b.cache_write_tokens;
398            w.output += b.output_tokens;
399            w.cost += b.cost_usd;
400            w.uncached += b.uncached_cost_usd;
401        }
402        w
403    }
404
405    fn status(&self, w: &Window, settings: &Settings) -> (Option<f64>, ShareStatus) {
406        let share = cache_read_share(w.input, w.cache_read, w.cache_write);
407        let prompt = w.input + w.cache_read + w.cache_write;
408        let status = if self.local {
409            ShareStatus::Local
410        } else if prompt < settings.min_prompt_tokens.max(1) {
411            ShareStatus::InsufficientData
412        } else if share.unwrap_or(0.0) < settings.threshold {
413            ShareStatus::BelowThreshold
414        } else {
415            ShareStatus::Ok
416        };
417        (share, status)
418    }
419}
420
421impl CacheShareLedger {
422    /// Adds one turn at `now` and returns the state changes it caused.
423    pub fn record(&mut self, call: &CallCost, now: u64, settings: &Settings) -> Vec<Signal> {
424        let idx = self.key_index(&call.provider, &call.model);
425        let key = &mut self.keys[idx];
426        key.local = call.local;
427        key.prices = call.prices;
428        let bucket_secs = settings.bucket_secs();
429        let start = now - now % bucket_secs;
430        match key.buckets.back_mut() {
431            Some(b) if b.start == start => add(b, call),
432            _ => {
433                let mut b = Bucket {
434                    start,
435                    ..Bucket::default()
436                };
437                add(&mut b, call);
438                key.buckets.push_back(b);
439            }
440        }
441        let from = now.saturating_sub(settings.window_secs);
442        while key.buckets.front().is_some_and(|b| b.start < from) {
443            key.buckets.pop_front();
444        }
445
446        let mut signals = Vec::new();
447        if !key.open_model_flagged
448            && open_model_verdict(&key.provider, &key.model, key.local, settings)
449                == OpenModelVerdict::Flagged
450        {
451            key.open_model_flagged = true;
452            signals.push(Signal::OpenModelFlagged {
453                provider: key.provider.clone(),
454                model: key.model.clone(),
455            });
456        }
457        let w = key.window(now, settings);
458        let (share, status) = key.status(&w, settings);
459        match status {
460            ShareStatus::BelowThreshold if !key.alarm_active => {
461                key.alarm_active = true;
462                key.alarms_fired += 1;
463                key.last_alarm_at = Some(now);
464                signals.push(Signal::AlarmRaised {
465                    provider: key.provider.clone(),
466                    model: key.model.clone(),
467                    share: share.unwrap_or(0.0),
468                    threshold: settings.threshold,
469                    prompt_tokens: w.input + w.cache_read + w.cache_write,
470                });
471            }
472            ShareStatus::Ok if key.alarm_active => {
473                key.alarm_active = false;
474                signals.push(Signal::AlarmCleared {
475                    provider: key.provider.clone(),
476                    model: key.model.clone(),
477                    share: share.unwrap_or(0.0),
478                });
479            }
480            _ => {}
481        }
482        signals
483    }
484
485    /// The window ending at `now`, one row per provider+model with turns in
486    /// it, sorted by cost descending.
487    #[must_use]
488    pub fn report(&self, now: u64, settings: &Settings) -> CacheShareReport {
489        let mut rows: Vec<CacheShareRow> = self
490            .keys
491            .iter()
492            .filter_map(|key| {
493                let w = key.window(now, settings);
494                if w.requests == 0 {
495                    return None;
496                }
497                let (share, status) = key.status(&w, settings);
498                Some(CacheShareRow {
499                    provider: key.provider.clone(),
500                    model: key.model.clone(),
501                    requests: w.requests,
502                    input_tokens: w.input,
503                    cache_read_tokens: w.cache_read,
504                    cache_write_tokens: w.cache_write,
505                    output_tokens: w.output,
506                    prompt_tokens: w.input + w.cache_read + w.cache_write,
507                    cache_read_share: share,
508                    cost_usd: w.cost,
509                    uncached_cost_usd: w.uncached,
510                    prices: key.prices,
511                    status,
512                    alarm_active: key.alarm_active,
513                    alarms_fired: key.alarms_fired,
514                    last_alarm_at: key.last_alarm_at,
515                    open_model: open_model_verdict(&key.provider, &key.model, key.local, settings),
516                })
517            })
518            .collect();
519        rows.sort_by(|a, b| {
520            b.cost_usd
521                .partial_cmp(&a.cost_usd)
522                .unwrap_or(std::cmp::Ordering::Equal)
523                .then_with(|| (&a.provider, &a.model).cmp(&(&b.provider, &b.model)))
524        });
525        CacheShareReport {
526            window_secs: settings.window_secs,
527            alarm_threshold: settings.threshold,
528            min_prompt_tokens: settings.min_prompt_tokens,
529            rows,
530        }
531    }
532
533    fn key_index(&mut self, provider: &str, model: &str) -> usize {
534        if let Some(i) = self
535            .keys
536            .iter()
537            .position(|k| k.provider == provider && k.model == model)
538        {
539            return i;
540        }
541        let (provider, model) = if self.keys.len() >= MAX_TRACKED_KEYS {
542            ("other", "other")
543        } else {
544            (provider, model)
545        };
546        if let Some(i) = self
547            .keys
548            .iter()
549            .position(|k| k.provider == provider && k.model == model)
550        {
551            return i;
552        }
553        self.keys.push(KeyState {
554            provider: provider.to_string(),
555            model: model.to_string(),
556            ..KeyState::default()
557        });
558        self.keys.len() - 1
559    }
560}
561
562fn add(b: &mut Bucket, call: &CallCost) {
563    b.requests += 1;
564    b.input_tokens += call.input_tokens;
565    b.cache_read_tokens += call.cache_read_tokens;
566    b.cache_write_tokens += call.cache_write_tokens;
567    b.output_tokens += call.output_tokens;
568    b.cost_usd += call.cost_usd;
569    b.uncached_cost_usd += call.uncached_cost_usd;
570}
571
572fn ledger() -> &'static Mutex<CacheShareLedger> {
573    static LEDGER: OnceLock<Mutex<CacheShareLedger>> = OnceLock::new();
574    LEDGER.get_or_init(|| Mutex::new(load_persisted().unwrap_or_default()))
575}
576
577fn ledger_path() -> Option<std::path::PathBuf> {
578    lean_ctx::core::data_dir::lean_ctx_data_dir()
579        .ok()
580        .map(|d| d.join(CACHE_SHARE_FILE))
581}
582
583fn now_secs() -> u64 {
584    std::time::SystemTime::now()
585        .duration_since(std::time::UNIX_EPOCH)
586        .unwrap_or_default()
587        .as_secs()
588}
589
590/// Cross-process read of the persisted ledger.
591#[must_use]
592pub fn load_persisted() -> Option<CacheShareLedger> {
593    let data = std::fs::read_to_string(ledger_path()?).ok()?;
594    serde_json::from_str(&data).ok()
595}
596
597/// The persisted window report for the CLI, or `None` before any turn.
598#[must_use]
599pub fn persisted_report() -> Option<CacheShareReport> {
600    load_persisted().map(|l| l.report(now_secs(), &Settings::load()))
601}
602
603/// Logs, aggregates and persists one measured turn (called from
604/// [`super::usage_meter::record`], the single choke-point of measured turns).
605pub fn record_turn(u: &super::usage::RealUsage, pricing: &ModelPricing) {
606    let (provider, local) = u
607        .wire
608        .as_deref()
609        .map_or(("unknown", false), |w| (w.provider.as_str(), w.is_local));
610    let model = match u.model.trim() {
611        "" => "unknown",
612        m => m,
613    };
614    let prices = TokenPrices::from(pricing.quote(Some(model)).cost);
615    let call = CallCost::priced(
616        provider,
617        model,
618        local,
619        u.input_tokens,
620        u.cache_read_tokens,
621        u.cache_write_tokens,
622        u.output_tokens,
623        prices,
624        u.provider_cost_usd,
625    );
626    tracing::info!(
627        target: "lean_ctx_proxy::cost",
628        provider = %call.provider,
629        model = %call.model,
630        input_tokens = call.input_tokens,
631        cache_read_tokens = call.cache_read_tokens,
632        cache_write_tokens = call.cache_write_tokens,
633        output_tokens = call.output_tokens,
634        input_per_m = prices.input_per_m,
635        cache_read_per_m = prices.cache_read_per_m,
636        cache_write_per_m = prices.cache_write_per_m,
637        output_per_m = prices.output_per_m,
638        cost_usd = call.cost_usd,
639        measured = u.provider_cost_usd.is_some(),
640        "provider call cost"
641    );
642    let settings = Settings::load();
643    let (signals, snapshot) = {
644        let mut ledger = ledger()
645            .lock()
646            .unwrap_or_else(std::sync::PoisonError::into_inner);
647        let signals = ledger.record(&call, now_secs(), &settings);
648        (signals, ledger.clone())
649    };
650    for signal in &signals {
651        emit(signal);
652    }
653    persist(&snapshot);
654}
655
656fn emit(signal: &Signal) {
657    match signal {
658        Signal::AlarmRaised {
659            provider,
660            model,
661            share,
662            threshold,
663            prompt_tokens,
664        } => {
665            let detail = format!(
666                "{provider}/{model}: cache-read share {:.1} % below {:.1} % over {prompt_tokens} prompt tokens",
667                share * 100.0,
668                threshold * 100.0
669            );
670            tracing::warn!(target: "lean_ctx_proxy::cost", "cache share alarm: {detail}");
671            lean_ctx::core::events::emit_verification_warning(ALARM_EVENT, &detail, "warning");
672        }
673        Signal::AlarmCleared {
674            provider,
675            model,
676            share,
677        } => {
678            tracing::info!(
679                target: "lean_ctx_proxy::cost",
680                "cache share recovered: {provider}/{model} at {:.1} %",
681                share * 100.0
682            );
683        }
684        Signal::OpenModelFlagged { provider, model } => {
685            let detail = format!(
686                "open model {model} served by {provider}, which is not in [proxy.cache_share] open_model_providers"
687            );
688            tracing::warn!(target: "lean_ctx_proxy::cost", "{detail}");
689            lean_ctx::core::events::emit_verification_warning(OPEN_MODEL_EVENT, &detail, "warning");
690        }
691    }
692}
693
694fn persist(ledger: &CacheShareLedger) {
695    let Some(path) = ledger_path() else {
696        return;
697    };
698    let Ok(json) = serde_json::to_string(ledger) else {
699        return;
700    };
701    let tmp = path.with_extension("json.tmp");
702    if std::fs::write(&tmp, json).is_ok() {
703        let _ = std::fs::rename(&tmp, &path);
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    const PRICES: TokenPrices = TokenPrices {
712        input_per_m: 3.0,
713        cache_read_per_m: 0.3,
714        cache_write_per_m: 3.75,
715        output_per_m: 15.0,
716    };
717
718    fn call(provider: &str, model: &str, input: u64, read: u64, write: u64) -> CallCost {
719        CallCost::priced(
720            provider, model, false, input, read, write, 1_000, PRICES, None,
721        )
722    }
723
724    fn settings() -> Settings {
725        Settings {
726            min_prompt_tokens: 100_000,
727            ..Settings::default()
728        }
729    }
730
731    #[test]
732    fn share_and_cost_follow_the_token_classes() {
733        assert_eq!(cache_read_share(0, 0, 0), None);
734        assert_eq!(cache_read_share(5, 90, 5), Some(0.9));
735
736        let c = call("Anthropic", "claude-sonnet-4-5", 100_000, 900_000, 0);
737        // 0.1 M × 3 + 0.9 M × 0.3 + 0.001 M × 15
738        assert!((c.cost_usd - 0.585).abs() < 1e-9);
739        // 1 M × 3 + 0.001 M × 15
740        assert!((c.uncached_cost_usd - 3.015).abs() < 1e-9);
741        let measured = CallCost::priced("OpenRouter", "m", false, 1, 0, 0, 0, PRICES, Some(0.42));
742        assert!((measured.cost_usd - 0.42).abs() < f64::EPSILON);
743
744        let mut ledger = CacheShareLedger::default();
745        let s = settings();
746        ledger.record(&c, 10_000, &s);
747        ledger.record(
748            &call("Anthropic", "claude-sonnet-4-5", 0, 0, 100_000),
749            10_010,
750            &s,
751        );
752        let report = ledger.report(10_020, &s);
753        let row = &report.rows[0];
754        assert_eq!(row.requests, 2);
755        assert_eq!(row.prompt_tokens, 1_100_000);
756        assert!((row.cache_read_share.unwrap() - 900_000.0 / 1_100_000.0).abs() < 1e-12);
757        assert_eq!(row.prices, PRICES);
758        // Turns older than the window drop out of the report.
759        assert!(
760            ledger
761                .report(10_020 + s.window_secs + 60, &s)
762                .rows
763                .is_empty()
764        );
765    }
766
767    #[test]
768    fn alarm_fires_once_below_threshold_and_rearms_on_recovery() {
769        let s = settings();
770        let mut ledger = CacheShareLedger::default();
771        let key = ("Anthropic", "claude-sonnet-4-5");
772
773        // Cold start: all cache writes, below min_prompt_tokens → no alarm.
774        assert!(
775            ledger
776                .record(&call(key.0, key.1, 0, 0, 50_000), 0, &s)
777                .is_empty()
778        );
779        // Exactly at the threshold (90 %) is not below it.
780        let at = ledger.record(&call(key.0, key.1, 0, 450_000, 0), 10, &s);
781        assert!(at.is_empty(), "{at:?}");
782
783        // A cache miss turn pulls the window under 90 %.
784        let miss = ledger.record(&call(key.0, key.1, 400_000, 0, 0), 20, &s);
785        assert!(matches!(
786            miss.as_slice(),
787            [Signal::AlarmRaised { share, threshold, .. }] if *share < 0.9 && (*threshold - 0.9).abs() < 1e-12
788        ));
789        // Still below: no second alarm.
790        assert!(
791            ledger
792                .record(&call(key.0, key.1, 1_000, 0, 0), 30, &s)
793                .is_empty()
794        );
795        let row = ledger.report(30, &s).rows.remove(0);
796        assert_eq!(
797            (row.status, row.alarm_active, row.alarms_fired),
798            (ShareStatus::BelowThreshold, true, 1)
799        );
800
801        // Recovery re-arms; a configurable threshold moves the line.
802        let back = ledger.record(&call(key.0, key.1, 0, 20_000_000, 0), 40, &s);
803        assert!(matches!(back.as_slice(), [Signal::AlarmCleared { .. }]));
804        let strict = Settings {
805            threshold: 0.99,
806            ..settings()
807        };
808        let again = ledger.record(&call(key.0, key.1, 0, 1, 0), 50, &strict);
809        assert!(matches!(again.as_slice(), [Signal::AlarmRaised { .. }]));
810        assert_eq!(ledger.report(50, &strict).rows[0].alarms_fired, 2);
811
812        // Local endpoints never alarm.
813        let mut local = call("ollama", "qwen3-coder", 1_000_000, 0, 0);
814        local.local = true;
815        assert!(ledger.record(&local, 60, &s).is_empty());
816    }
817
818    #[test]
819    fn open_models_need_an_allowlisted_provider() {
820        let mut s = Settings::from_config(&CacheShareConfig {
821            open_model_providers: vec![" DeepSeek ".into()],
822            open_model_patterns: vec!["my-oss".into()],
823            ..CacheShareConfig::default()
824        });
825        assert_eq!(
826            open_model_verdict("Anthropic", "claude-opus-4-5", false, &s),
827            OpenModelVerdict::NotApplicable
828        );
829        assert_eq!(
830            open_model_verdict("deepseek", "deepseek-chat", false, &s),
831            OpenModelVerdict::Allowed
832        );
833        assert_eq!(
834            open_model_verdict("groq", "meta-llama/Llama-3.3-70B", false, &s),
835            OpenModelVerdict::Flagged
836        );
837        assert_eq!(
838            open_model_verdict("groq", "acme/My-OSS-7b", false, &s),
839            OpenModelVerdict::Flagged
840        );
841        assert_eq!(
842            open_model_verdict("ollama", "qwen3-coder", true, &s),
843            OpenModelVerdict::NotApplicable
844        );
845
846        // Flagged once per provider+model in the ledger.
847        let mut ledger = CacheShareLedger::default();
848        let first = ledger.record(&call("groq", "qwen3-32b", 10, 0, 0), 0, &s);
849        assert!(matches!(
850            first.as_slice(),
851            [Signal::OpenModelFlagged { .. }]
852        ));
853        assert!(
854            ledger
855                .record(&call("groq", "qwen3-32b", 10, 0, 0), 1, &s)
856                .is_empty()
857        );
858
859        assert!(
860            CacheShareConfig {
861                open_model_policy: Some(" Refuse".into()),
862                ..CacheShareConfig::default()
863            }
864            .refuses_unlisted_open_models()
865        );
866        s.refuse_unlisted_open_models = true;
867        assert_eq!(
868            open_model_verdict("groq", "qwen3-32b", false, &s),
869            OpenModelVerdict::Refused
870        );
871        assert_eq!(
872            open_model_verdict("deepseek", "deepseek-chat", false, &s),
873            OpenModelVerdict::Allowed
874        );
875    }
876}