agent_engine/runtime/openai/catalog/
capability_cache.rs1use std::collections::HashMap;
6use std::sync::{Mutex, OnceLock};
7
8use super::CatalogModel;
9
10static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
11
12#[derive(Default)]
13struct Cache {
14 models: HashMap<String, CatalogModel>,
15 generation: u64,
16}
17
18fn cache() -> &'static Mutex<Cache> {
19 CACHE.get_or_init(|| Mutex::new(Cache::default()))
20}
21
22fn advance(cache: &mut Cache) {
23 cache.generation = cache.generation.saturating_add(1);
24}
25
26pub fn generation() -> u64 {
29 cache().lock().map(|cache| cache.generation).unwrap_or(0)
30}
31
32pub fn insert(model: CatalogModel) {
34 if let Ok(mut cache) = cache().lock() {
35 cache.models.insert(model.runtime_id(), model);
36 advance(&mut cache);
37 }
38}
39
40pub fn populate(models: &[CatalogModel]) {
42 if let Ok(mut cache) = cache().lock() {
43 for m in models {
44 cache.models.insert(m.runtime_id(), m.clone());
45 }
46 advance(&mut cache);
47 }
48}
49
50pub fn replace_provider(provider_key: &str, models: &[CatalogModel]) {
55 let prefix = format!("{provider_key}/");
56 let replacements: Vec<_> = models
57 .iter()
58 .filter(|model| model.provider_key == provider_key)
59 .cloned()
60 .map(|model| (model.runtime_id(), model))
61 .collect();
62
63 if let Ok(mut cache) = cache().lock() {
64 cache
65 .models
66 .retain(|runtime_id, _| !runtime_id.starts_with(&prefix));
67 cache.models.extend(replacements);
68 advance(&mut cache);
69 }
70}
71
72pub fn get(runtime_id: &str) -> Option<CatalogModel> {
74 cache().lock().ok()?.models.get(runtime_id).cloned()
75}
76
77#[allow(dead_code)]
79pub fn has_provider(provider_key: &str) -> bool {
80 let prefix = format!("{provider_key}/");
81 cache()
82 .lock()
83 .ok()
84 .map(|m| m.models.keys().any(|k| k.starts_with(&prefix)))
85 .unwrap_or(false)
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91 use crate::runtime::openai::catalog::{CatalogProviderKind, CatalogSource, ReasoningSupport};
92 use agent_core::reasoning::ReasoningLevel;
93
94 fn make_model(runtime_id: &str) -> CatalogModel {
95 let (provider, id) = runtime_id
96 .split_once('/')
97 .expect("test runtime_id must be 'provider/model'");
98 let mut m = CatalogModel::new(provider, provider, id).unwrap();
99 m.provider_kind = CatalogProviderKind::OpenAiCodex;
100 m.source = CatalogSource::Live;
101 m.reasoning = ReasoningSupport::CodexNamed {
102 supported: vec![ReasoningLevel::Low, ReasoningLevel::Medium],
103 default_level: Some(ReasoningLevel::Low),
104 multi_agent_version: None,
105 };
106 m
107 }
108
109 #[test]
110 fn insert_and_get_round_trip() {
111 let m = make_model("openai-codex/gpt-test-cache");
112 insert(m.clone());
113 let got = get("openai-codex/gpt-test-cache").expect("should be cached");
114 assert_eq!(got.id, "gpt-test-cache");
115 assert!(matches!(got.reasoning, ReasoningSupport::CodexNamed { .. }));
116 }
117
118 #[test]
119 fn live_cache_preserves_narrow_capability() {
120 let mut live =
121 CatalogModel::new("openai-codex", "OpenAI Codex", "gpt-cache-narrow").unwrap();
122 live.provider_kind = CatalogProviderKind::OpenAiCodex;
123 live.source = CatalogSource::Live;
124 live.reasoning = ReasoningSupport::CodexNamed {
125 supported: vec![ReasoningLevel::Low, ReasoningLevel::Medium],
126 default_level: Some(ReasoningLevel::Low),
127 multi_agent_version: None,
128 };
129 insert(live);
130 let cached = get("openai-codex/gpt-cache-narrow").unwrap();
131 match cached.reasoning {
132 ReasoningSupport::CodexNamed { supported, .. } => {
133 assert!(
134 !supported.contains(&ReasoningLevel::Ultra),
135 "live cache must override static — Ultra should be absent"
136 );
137 assert!(supported.contains(&ReasoningLevel::Low));
138 }
139 other => panic!("expected CodexNamed, got {other:?}"),
140 }
141 }
142}