Skip to main content

sim_lib_agent/
config_probe.rs

1//! Configuration probe for model provider defaults.
2
3use regex::Regex;
4use sim_config::{
5    ConfigDir, ConfigLayer, ConfigProbe, ConfigProbeReport, ConfigProbeRequest, ConfigProbeStatus,
6    ConfigSource, ProbeMode,
7};
8use sim_kernel::{Expr, Symbol};
9
10use crate::ModelCard;
11
12const FIXTURE_ECHO_MODEL: &str = "fixture/echo";
13const DEFAULT_MODEL_REGEX: &str = r"^(?:fixture/|sim/|gpt-4\.1|o[34]).*";
14const MODELED_PROVIDER_REGEX: &str = r"^(?:modeled)$";
15
16const EMITTED_KEYS: [&str; 7] = [
17    "model_regex",
18    "provider_regex",
19    "prefer_local",
20    "default_model",
21    "openai_key_present",
22    "openai_base_present",
23    "ollama_host_present",
24];
25
26/// Safe provider-presence facts for model-default probing.
27///
28/// The probe stores only whether an environment-backed provider is present.
29/// It never stores or emits raw environment values.
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub struct AgentModelProviderPresence {
32    /// Whether `OPENAI_API_KEY` is present.
33    pub openai_key_present: bool,
34    /// Whether `OPENAI_BASE_URL` is present.
35    pub openai_base_present: bool,
36    /// Whether `OLLAMA_HOST` is present.
37    pub ollama_host_present: bool,
38}
39
40impl AgentModelProviderPresence {
41    /// Captures provider presence from the current process environment.
42    pub fn from_current_env() -> Self {
43        Self {
44            openai_key_present: std::env::var_os("OPENAI_API_KEY").is_some(),
45            openai_base_present: std::env::var_os("OPENAI_BASE_URL").is_some(),
46            ollama_host_present: std::env::var_os("OLLAMA_HOST").is_some(),
47        }
48    }
49}
50
51/// Returns the stable config library id for model defaults.
52pub fn model_defaults_config_lib_symbol() -> Symbol {
53    Symbol::qualified("model", "defaults")
54}
55
56/// Returns the stable model-default config probe id.
57pub fn agent_model_config_probe_symbol() -> Symbol {
58    Symbol::qualified("config-probe", "model")
59}
60
61/// Safe config probe for AI provider and model defaults.
62pub struct AgentModelConfigProbe {
63    cards: Vec<ModelCard>,
64    model_regex: Regex,
65    provider_presence: AgentModelProviderPresence,
66    prefer_local: bool,
67}
68
69impl AgentModelConfigProbe {
70    /// Builds a probe over caller-supplied runner model cards and provider facts.
71    pub fn new(
72        cards: impl IntoIterator<Item = ModelCard>,
73        provider_presence: AgentModelProviderPresence,
74    ) -> Self {
75        Self {
76            cards: cards.into_iter().collect(),
77            model_regex: Regex::new(DEFAULT_MODEL_REGEX)
78                .expect("default model-default regex is valid"),
79            provider_presence,
80            prefer_local: true,
81        }
82    }
83
84    /// Builds a real-mode probe over cards with provider presence read safely.
85    pub fn from_current_env(cards: impl IntoIterator<Item = ModelCard>) -> Self {
86        Self::new(cards, AgentModelProviderPresence::from_current_env())
87    }
88
89    /// Builds the deterministic modeled probe used by default in tests.
90    pub fn modeled() -> Self {
91        Self::new(Vec::new(), AgentModelProviderPresence::default())
92    }
93}
94
95impl Default for AgentModelConfigProbe {
96    fn default() -> Self {
97        Self::modeled()
98    }
99}
100
101impl ConfigProbe for AgentModelConfigProbe {
102    fn symbol(&self) -> Symbol {
103        agent_model_config_probe_symbol()
104    }
105
106    fn probe(&self, request: &ConfigProbeRequest) -> (Option<ConfigLayer>, ConfigProbeReport) {
107        if request.lib != model_defaults_config_lib_symbol() {
108            return (
109                None,
110                report(
111                    self.symbol(),
112                    request,
113                    ConfigProbeStatus::Skipped {
114                        reason: "model probe only serves model/defaults".to_owned(),
115                    },
116                    &[],
117                ),
118            );
119        }
120
121        if request.mode == ProbeMode::Real && !request.caps.env {
122            return (
123                None,
124                report(
125                    self.symbol(),
126                    request,
127                    ConfigProbeStatus::Denied {
128                        capability: "env".to_owned(),
129                    },
130                    &[],
131                ),
132            );
133        }
134
135        let (default_model, provider_regex, provider_presence) = match request.mode {
136            ProbeMode::Modeled => (
137                FIXTURE_ECHO_MODEL.to_owned(),
138                MODELED_PROVIDER_REGEX.to_owned(),
139                AgentModelProviderPresence::default(),
140            ),
141            ProbeMode::Real => (
142                self.selected_model(),
143                candidate_regex(&self.provider_candidates()),
144                self.provider_presence,
145            ),
146        };
147
148        let layer = ConfigLayer::new(
149            ConfigSource::Probe {
150                probe: self.symbol(),
151                mode: request.mode,
152            },
153            ConfigDir::one(
154                request.lib.clone(),
155                model_defaults_table(
156                    &default_model,
157                    &provider_regex,
158                    self.prefer_local,
159                    provider_presence,
160                ),
161            )
162            .expect("model-defaults config probe builds a map table"),
163        );
164        (
165            Some(layer),
166            report(
167                self.symbol(),
168                request,
169                ConfigProbeStatus::Applied,
170                &EMITTED_KEYS,
171            ),
172        )
173    }
174}
175
176impl AgentModelConfigProbe {
177    fn selected_model(&self) -> String {
178        let mut matches = self
179            .cards
180            .iter()
181            .filter(|card| self.model_regex.is_match(&card.model))
182            .collect::<Vec<_>>();
183        matches.sort_by(|left, right| {
184            model_sort_key(left, self.prefer_local).cmp(&model_sort_key(right, self.prefer_local))
185        });
186        matches
187            .first()
188            .map(|card| card.model.clone())
189            .unwrap_or_else(|| FIXTURE_ECHO_MODEL.to_owned())
190    }
191
192    fn provider_candidates(&self) -> Vec<String> {
193        let mut candidates = vec!["modeled".to_owned()];
194        if self.provider_presence.openai_key_present {
195            push_unique(&mut candidates, "openai");
196        }
197        if self.provider_presence.openai_base_present {
198            push_unique(&mut candidates, "openai-compatible");
199        }
200        if self.provider_presence.ollama_host_present {
201            push_unique(&mut candidates, "ollama");
202        }
203        let mut card_providers = self
204            .cards
205            .iter()
206            .map(|card| card.provider.name.to_string())
207            .collect::<Vec<_>>();
208        card_providers.sort();
209        for provider in card_providers {
210            push_unique(&mut candidates, provider);
211        }
212        candidates
213    }
214}
215
216fn model_sort_key(card: &ModelCard, prefer_local: bool) -> (u8, &str, &str, &str) {
217    let locality = if prefer_local {
218        match card.locality.name.as_ref() {
219            "local" | "modeled" | "fixture" => 0,
220            "fabric" | "agent-backed" => 1,
221            "remote" => 2,
222            _ => 1,
223        }
224    } else {
225        0
226    };
227    (
228        locality,
229        card.model.as_str(),
230        card.provider.name.as_ref(),
231        card.runner.name.as_ref(),
232    )
233}
234
235fn model_defaults_table(
236    default_model: &str,
237    provider_regex: &str,
238    prefer_local: bool,
239    provider_presence: AgentModelProviderPresence,
240) -> Expr {
241    Expr::Map(vec![
242        (
243            key("model_regex"),
244            Expr::String(DEFAULT_MODEL_REGEX.to_owned()),
245        ),
246        (
247            key("provider_regex"),
248            Expr::String(provider_regex.to_owned()),
249        ),
250        (key("prefer_local"), Expr::Bool(prefer_local)),
251        (key("default_model"), Expr::String(default_model.to_owned())),
252        (
253            key("openai_key_present"),
254            Expr::Bool(provider_presence.openai_key_present),
255        ),
256        (
257            key("openai_base_present"),
258            Expr::Bool(provider_presence.openai_base_present),
259        ),
260        (
261            key("ollama_host_present"),
262            Expr::Bool(provider_presence.ollama_host_present),
263        ),
264    ])
265}
266
267fn report(
268    probe: Symbol,
269    request: &ConfigProbeRequest,
270    status: ConfigProbeStatus,
271    keys: &[&str],
272) -> ConfigProbeReport {
273    ConfigProbeReport {
274        probe,
275        lib: request.lib.clone(),
276        mode: request.mode,
277        status,
278        emitted_keys: keys.iter().map(|key| (*key).to_owned()).collect(),
279    }
280}
281
282fn candidate_regex(values: &[String]) -> String {
283    if values.is_empty() {
284        return "(?!)".to_owned();
285    }
286    format!(
287        "^(?:{})$",
288        values
289            .iter()
290            .map(|value| regex_escape(value))
291            .collect::<Vec<_>>()
292            .join("|")
293    )
294}
295
296fn regex_escape(value: &str) -> String {
297    let mut escaped = String::new();
298    for character in value.chars() {
299        if matches!(
300            character,
301            '\\' | '.' | '+' | '*' | '?' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|'
302        ) {
303            escaped.push('\\');
304        }
305        escaped.push(character);
306    }
307    escaped
308}
309
310fn key(name: &str) -> Expr {
311    Expr::Symbol(Symbol::new(name))
312}
313
314fn push_unique(candidates: &mut Vec<String>, value: impl ToString) {
315    let value = value.to_string();
316    if !candidates.iter().any(|candidate| candidate == &value) {
317        candidates.push(value);
318    }
319}