Skip to main content

agent_engine/runtime/openai/catalog/
capability_cache.rs

1//! Process-local capability cache keyed by provider-qualified model id.
2//! Populated when a live Codex catalog is parsed; consulted by validation
3//! before falling back to the static table. No credentials stored.
4
5use 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
26/// Current monotonic catalog generation. Every successful cache mutation
27/// advances it, allowing UI snapshots to reject stale capability decisions.
28pub fn generation() -> u64 {
29    cache().lock().map(|cache| cache.generation).unwrap_or(0)
30}
31
32/// Insert or overwrite an entry keyed by `model.runtime_id()`.
33pub 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
40/// Bulk-populate from an incremental parsed catalog slice.
41pub 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
50/// Atomically replace one provider's complete catalog snapshot.
51///
52/// Rows for other providers are preserved. Models whose provider identity does
53/// not match `provider_key` are ignored rather than crossing provider scopes.
54pub 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
72/// Look up by provider-qualified id (e.g. `"openai-codex/gpt-5.6-sol"`).
73pub fn get(runtime_id: &str) -> Option<CatalogModel> {
74    cache().lock().ok()?.models.get(runtime_id).cloned()
75}
76
77/// True if the cache has any entries for the given provider prefix.
78#[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}