1use 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
37const CACHE_SHARE_FILE: &str = "proxy_cache_share.json";
39const MAX_TRACKED_KEYS: usize = 256;
41const BUCKETS_PER_WINDOW: u64 = 60;
43pub const OPEN_MODEL_PROVIDER_NOT_ALLOWED: &str = "destination.open_model_provider_not_allowed";
45pub const ALARM_EVENT: &str = "cache_share_below_threshold";
47pub const OPEN_MODEL_EVENT: &str = "open_model_provider_not_allowlisted";
49
50const 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#[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 #[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#[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#[derive(Debug, Clone, PartialEq)]
145pub struct CallCost {
146 pub provider: String,
147 pub model: String,
148 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 pub cost_usd: f64,
158 pub uncached_cost_usd: f64,
160}
161
162impl CallCost {
163 #[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
223#[serde(rename_all = "snake_case")]
224pub enum OpenModelVerdict {
225 NotApplicable,
227 Allowed,
229 Flagged,
231 Refused,
233}
234
235#[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#[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#[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#[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: 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
320#[serde(rename_all = "snake_case")]
321pub enum ShareStatus {
322 Ok,
323 BelowThreshold,
324 InsufficientData,
326 Local,
328}
329
330#[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 pub cache_read_share: Option<f64>,
343 pub cost_usd: f64,
344 pub uncached_cost_usd: f64,
346 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#[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#[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 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 #[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#[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#[must_use]
599pub fn persisted_report() -> Option<CacheShareReport> {
600 load_persisted().map(|l| l.report(now_secs(), &Settings::load()))
601}
602
603pub 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 assert!((c.cost_usd - 0.585).abs() < 1e-9);
739 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 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 assert!(
775 ledger
776 .record(&call(key.0, key.1, 0, 0, 50_000), 0, &s)
777 .is_empty()
778 );
779 let at = ledger.record(&call(key.0, key.1, 0, 450_000, 0), 10, &s);
781 assert!(at.is_empty(), "{at:?}");
782
783 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 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 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 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 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}