1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub struct AgentModelProviderPresence {
32 pub openai_key_present: bool,
34 pub openai_base_present: bool,
36 pub ollama_host_present: bool,
38}
39
40impl AgentModelProviderPresence {
41 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
51pub fn model_defaults_config_lib_symbol() -> Symbol {
53 Symbol::qualified("model", "defaults")
54}
55
56pub fn agent_model_config_probe_symbol() -> Symbol {
58 Symbol::qualified("config-probe", "model")
59}
60
61pub struct AgentModelConfigProbe {
63 cards: Vec<ModelCard>,
64 model_regex: Regex,
65 provider_presence: AgentModelProviderPresence,
66 prefer_local: bool,
67}
68
69impl AgentModelConfigProbe {
70 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 pub fn from_current_env(cards: impl IntoIterator<Item = ModelCard>) -> Self {
86 Self::new(cards, AgentModelProviderPresence::from_current_env())
87 }
88
89 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}