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 multi_agent_reasoning_effort: None,
103 supported: vec![ReasoningLevel::Low, ReasoningLevel::Medium],
104 default_level: Some(ReasoningLevel::Low),
105 multi_agent_version: None,
106 };
107 m
108 }
109
110 #[test]
111 fn insert_and_get_round_trip() {
112 let m = make_model("openai-codex/gpt-test-cache");
113 insert(m.clone());
114 let got = get("openai-codex/gpt-test-cache").expect("should be cached");
115 assert_eq!(got.id, "gpt-test-cache");
116 assert!(matches!(got.reasoning, ReasoningSupport::CodexNamed { .. }));
117 }
118
119 #[test]
120 fn live_cache_preserves_narrow_capability() {
121 let mut live =
122 CatalogModel::new("openai-codex", "OpenAI Codex", "gpt-cache-narrow").unwrap();
123 live.provider_kind = CatalogProviderKind::OpenAiCodex;
124 live.source = CatalogSource::Live;
125 live.reasoning = ReasoningSupport::CodexNamed {
126 multi_agent_reasoning_effort: None,
127 supported: vec![ReasoningLevel::Low, ReasoningLevel::Medium],
128 default_level: Some(ReasoningLevel::Low),
129 multi_agent_version: None,
130 };
131 insert(live);
132 let cached = get("openai-codex/gpt-cache-narrow").unwrap();
133 match cached.reasoning {
134 ReasoningSupport::CodexNamed { supported, .. } => {
135 assert!(
136 !supported.contains(&ReasoningLevel::Ultra),
137 "live cache must override static — Ultra should be absent"
138 );
139 assert!(supported.contains(&ReasoningLevel::Low));
140 }
141 other => panic!("expected CodexNamed, got {other:?}"),
142 }
143 }
144}