1mod transport;
4
5use crate::{ProviderAuth, ProviderConfig, redact::redact_text};
6use sim_kernel::{Error, Expr, Result, Symbol};
7use sim_lib_agent_runner_core::ModelCard;
8use sim_lib_net_core::{HttpHead, UrlParts, parse_url};
9
10pub use transport::HttpProbeTransport;
11
12#[derive(Clone, Debug, PartialEq, Eq)]
14pub enum ProbeStatus {
15 Available,
17 Unavailable,
19 Skipped,
21}
22
23impl ProbeStatus {
24 pub fn as_symbol(&self) -> Symbol {
26 match self {
27 Self::Available => Symbol::new("available"),
28 Self::Unavailable => Symbol::new("unavailable"),
29 Self::Skipped => Symbol::new("skipped"),
30 }
31 }
32}
33
34#[derive(Clone, Debug, PartialEq)]
36pub struct ProviderProbeReport {
37 pub provider: Symbol,
39 pub endpoint: String,
41 pub status: ProbeStatus,
43 pub models: Vec<String>,
45 pub model_cards: Vec<ModelCard>,
47 pub reason: Option<String>,
49 pub redacted: bool,
51}
52
53impl ProviderProbeReport {
54 pub fn to_expr(&self) -> Expr {
56 Expr::Map(vec![
57 map_entry("provider", Expr::Symbol(self.provider.clone())),
58 map_entry("endpoint", Expr::String(self.endpoint.clone())),
59 map_entry("status", Expr::Symbol(self.status.as_symbol())),
60 map_entry(
61 "models",
62 Expr::List(self.models.iter().cloned().map(Expr::String).collect()),
63 ),
64 map_entry(
65 "cards",
66 Expr::List(self.model_cards.iter().cloned().map(Expr::from).collect()),
67 ),
68 map_entry(
69 "reason",
70 self.reason
71 .as_ref()
72 .map(|reason| Expr::String(reason.clone()))
73 .unwrap_or(Expr::Nil),
74 ),
75 map_entry("redacted", Expr::Bool(self.redacted)),
76 ])
77 }
78}
79
80#[derive(Clone, Debug, PartialEq, Eq)]
82pub struct EndpointCandidate {
83 pub endpoint: String,
85 pub models_path: &'static str,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct ProbeHttpRequest<'a> {
92 pub endpoint: &'a str,
94 pub endpoint_parts: UrlParts,
96 pub path: &'a str,
98 pub headers: Vec<(String, String)>,
100 pub timeout: std::time::Duration,
102 pub max_response_bytes: usize,
104}
105
106#[derive(Clone, Debug, PartialEq, Eq)]
108pub struct ProbeHttpResponse {
109 pub head: HttpHead,
111 pub body: Vec<u8>,
113}
114
115impl ProbeHttpResponse {
116 pub fn status(&self) -> u16 {
118 self.head.status
119 }
120}
121
122pub trait ProbeTransport {
124 fn get(&self, request: ProbeHttpRequest<'_>) -> Result<ProbeHttpResponse>;
126}
127
128pub fn probe_provider(
130 transport: &dyn ProbeTransport,
131 config: &ProviderConfig,
132) -> Result<ProviderProbeReport> {
133 let redacted = auth_is_redacted(&config.profile.auth, config.api_key_env.as_deref());
134 let mut headers = Vec::new();
135 let mut secrets = Vec::new();
136 match auth_header(&config.profile.auth, config.api_key_env.as_deref())? {
137 AuthHeader::Header {
138 name,
139 value,
140 secret,
141 } => {
142 secrets.push(secret);
143 secrets.push(value.clone());
144 headers.push((name, value));
145 }
146 AuthHeader::MissingSecret { env } => {
147 let mut report = empty_report(config, &config.endpoint, redacted);
148 report.status = ProbeStatus::Skipped;
149 report.reason = Some(format!("missing secret env {env}"));
150 return Ok(report);
151 }
152 AuthHeader::NoHeader => {}
153 }
154 if config.profile.provider == Symbol::new("anthropic") {
155 headers.push(("anthropic-version".to_owned(), "2023-06-01".to_owned()));
156 }
157
158 let mut last_report = None;
159 for candidate in endpoint_candidates(config) {
160 let report = probe_candidate(
161 transport,
162 config,
163 &candidate,
164 headers.clone(),
165 &secrets,
166 redacted,
167 )?;
168 if report.status == ProbeStatus::Available {
169 return Ok(report);
170 }
171 last_report = Some(report);
172 }
173 Ok(last_report.unwrap_or_else(|| empty_report(config, &config.endpoint, redacted)))
174}
175
176fn probe_candidate(
177 transport: &dyn ProbeTransport,
178 config: &ProviderConfig,
179 candidate: &EndpointCandidate,
180 headers: Vec<(String, String)>,
181 secrets: &[String],
182 redacted: bool,
183) -> Result<ProviderProbeReport> {
184 let mut report = empty_report(config, &candidate.endpoint, redacted);
185 let endpoint_parts = parse_url(&candidate.endpoint)
186 .map_err(|error| Error::Eval(format!("invalid provider endpoint: {error}")))?;
187 let request = ProbeHttpRequest {
188 endpoint: &candidate.endpoint,
189 endpoint_parts,
190 path: candidate.models_path,
191 headers,
192 timeout: config.timeout,
193 max_response_bytes: config.max_output_bytes,
194 };
195 let response = match transport.get(request) {
196 Ok(response) => response,
197 Err(error) => {
198 report.reason = Some(redact_error(error, secrets));
199 return Ok(report);
200 }
201 };
202 if !(200..300).contains(&response.status()) {
203 report.reason = Some(format!(
204 "provider probe returned HTTP {}",
205 response.status()
206 ));
207 return Ok(report);
208 }
209 match parse_provider_model_entries(&config.profile.provider, &response.body) {
210 Ok(entries) => {
211 report.status = ProbeStatus::Available;
212 report.models = entries.iter().map(|entry| entry.name.clone()).collect();
213 report.model_cards = provider_model_cards(config, &entries);
214 }
215 Err(reason) => {
216 report.reason = Some(reason);
217 }
218 }
219 Ok(report)
220}
221
222fn empty_report(config: &ProviderConfig, endpoint: &str, redacted: bool) -> ProviderProbeReport {
223 ProviderProbeReport {
224 provider: config.profile.provider.clone(),
225 endpoint: endpoint.to_owned(),
226 status: ProbeStatus::Unavailable,
227 models: Vec::new(),
228 model_cards: Vec::new(),
229 reason: None,
230 redacted,
231 }
232}
233
234fn endpoint_candidates(config: &ProviderConfig) -> Vec<EndpointCandidate> {
235 if config.profile.provider == Symbol::new("lemonade") {
236 let configured =
237 (config.endpoint != config.profile.default_endpoint).then(|| config.endpoint.clone());
238 return lemonade_candidates(configured);
239 }
240 vec![EndpointCandidate {
241 endpoint: config.endpoint.clone(),
242 models_path: config.profile.models_path,
243 }]
244}
245
246pub fn lemonade_candidates(configured: Option<String>) -> Vec<EndpointCandidate> {
248 if let Some(endpoint) = configured {
249 return vec![EndpointCandidate {
250 endpoint,
251 models_path: "/models",
252 }];
253 }
254 vec![
255 EndpointCandidate {
256 endpoint: "http://127.0.0.1:13305/v1".to_owned(),
257 models_path: "/models",
258 },
259 EndpointCandidate {
260 endpoint: "http://127.0.0.1:13305/api/v1".to_owned(),
261 models_path: "/models",
262 },
263 ]
264}
265
266enum AuthHeader {
267 Header {
268 name: String,
269 value: String,
270 secret: String,
271 },
272 MissingSecret {
273 env: String,
274 },
275 NoHeader,
276}
277
278fn auth_header(auth: &ProviderAuth, api_key_env: Option<&str>) -> Result<AuthHeader> {
279 match (auth, api_key_env) {
280 (ProviderAuth::None, _) | (_, None) => Ok(AuthHeader::NoHeader),
281 (ProviderAuth::BearerEnv { .. } | ProviderAuth::OptionalBearerEnv { .. }, Some(env)) => {
282 match secret_from_env(env) {
283 Some(secret) => Ok(AuthHeader::Header {
284 name: "Authorization".to_owned(),
285 value: format!("Bearer {secret}"),
286 secret,
287 }),
288 None => Ok(AuthHeader::MissingSecret {
289 env: env.to_owned(),
290 }),
291 }
292 }
293 (ProviderAuth::HeaderEnv { header, .. }, Some(env)) => match secret_from_env(env) {
294 Some(secret) => Ok(AuthHeader::Header {
295 name: header.clone(),
296 value: secret.clone(),
297 secret,
298 }),
299 None => Ok(AuthHeader::MissingSecret {
300 env: env.to_owned(),
301 }),
302 },
303 }
304}
305
306fn auth_is_redacted(auth: &ProviderAuth, api_key_env: Option<&str>) -> bool {
307 match auth {
308 ProviderAuth::None => false,
309 ProviderAuth::BearerEnv { .. } | ProviderAuth::HeaderEnv { .. } => api_key_env.is_some(),
310 ProviderAuth::OptionalBearerEnv { .. } => api_key_env.is_some(),
311 }
312}
313
314fn secret_from_env(env: &str) -> Option<String> {
315 match std::env::var(env) {
316 Ok(secret) if !secret.is_empty() => Some(secret),
317 _ => None,
318 }
319}
320
321pub fn parse_ollama_tags(body: &[u8]) -> Result<Vec<String>> {
324 let value: serde_json::Value = serde_json::from_slice(body)
325 .map_err(|error| Error::Eval(format!("ollama tags invalid json: {error}")))?;
326 parse_ollama_models(&value).map_err(Error::Eval)
327}
328
329#[derive(Clone, Debug, PartialEq)]
330struct ProviderModelEntry {
331 name: String,
332 extra: Vec<(Expr, Expr)>,
333}
334
335fn parse_provider_model_entries(
336 provider: &Symbol,
337 body: &[u8],
338) -> std::result::Result<Vec<ProviderModelEntry>, String> {
339 if provider == &Symbol::new("ollama") {
340 return parse_ollama_tags(body)
341 .map(|models| {
342 models
343 .into_iter()
344 .map(|name| ProviderModelEntry {
345 name,
346 extra: Vec::new(),
347 })
348 .collect()
349 })
350 .map_err(|error| error.to_string());
351 }
352 let value: serde_json::Value = serde_json::from_slice(body)
353 .map_err(|error| format!("malformed model list json: {error}"))?;
354 parse_openai_style_model_entries(provider, &value)
355}
356
357fn parse_openai_style_model_entries(
358 provider: &Symbol,
359 value: &serde_json::Value,
360) -> std::result::Result<Vec<ProviderModelEntry>, String> {
361 let data = value
362 .get("data")
363 .and_then(serde_json::Value::as_array)
364 .ok_or_else(|| "model list json missing data array".to_owned())?;
365 Ok(data
366 .iter()
367 .filter_map(|item| {
368 let name = item.get("id").and_then(serde_json::Value::as_str)?;
369 let extra = if provider == &Symbol::new("lemonade") {
370 lemonade_model_extras(item)
371 } else {
372 Vec::new()
373 };
374 Some(ProviderModelEntry {
375 name: name.to_owned(),
376 extra,
377 })
378 })
379 .collect())
380}
381
382fn parse_ollama_models(value: &serde_json::Value) -> std::result::Result<Vec<String>, String> {
383 let models = value
384 .get("models")
385 .and_then(serde_json::Value::as_array)
386 .ok_or_else(|| "ollama model list json missing models array".to_owned())?;
387 Ok(models
388 .iter()
389 .filter_map(|item| item.get("name").and_then(serde_json::Value::as_str))
390 .map(str::to_owned)
391 .collect())
392}
393
394fn redact_error(error: Error, secrets: &[String]) -> String {
395 let text = error.to_string();
396 let secret_refs = secrets.iter().map(String::as_str).collect::<Vec<_>>();
397 redact_text(&text, &secret_refs)
398}
399
400fn provider_model_cards(config: &ProviderConfig, entries: &[ProviderModelEntry]) -> Vec<ModelCard> {
401 entries
402 .iter()
403 .map(|entry| {
404 let mut card = ModelCard::new(
405 config.runner.clone(),
406 entry.name.clone(),
407 config.profile.provider.clone(),
408 config.locality.clone(),
409 );
410 card.extra.extend(entry.extra.clone());
411 card
412 })
413 .collect()
414}
415
416fn lemonade_model_extras(item: &serde_json::Value) -> Vec<(Expr, Expr)> {
417 let mut extras = Vec::new();
418 push_modality_extra(
419 &mut extras,
420 "modalities",
421 modality_field(
422 item,
423 &["modalities", "modality", "capabilities"],
424 &["modalities"],
425 ),
426 );
427 push_modality_extra(
428 &mut extras,
429 "modalities-in",
430 modality_field(
431 item,
432 &[
433 "modalities-in",
434 "modalities_in",
435 "input-modalities",
436 "input_modalities",
437 ],
438 &["modalities_in", "input_modalities", "input"],
439 ),
440 );
441 push_modality_extra(
442 &mut extras,
443 "modalities-out",
444 modality_field(
445 item,
446 &[
447 "modalities-out",
448 "modalities_out",
449 "output-modalities",
450 "output_modalities",
451 ],
452 &["modalities_out", "output_modalities", "output"],
453 ),
454 );
455 extras
456}
457
458fn modality_field(
459 item: &serde_json::Value,
460 direct_names: &[&str],
461 capability_names: &[&str],
462) -> Vec<Symbol> {
463 for name in direct_names {
464 if let Some(value) = item.get(*name) {
465 let symbols = modality_symbols(value);
466 if !symbols.is_empty() {
467 return symbols;
468 }
469 }
470 }
471 let Some(capabilities) = item.get("capabilities") else {
472 return Vec::new();
473 };
474 for name in capability_names {
475 if let Some(value) = capabilities.get(*name) {
476 let symbols = modality_symbols(value);
477 if !symbols.is_empty() {
478 return symbols;
479 }
480 }
481 }
482 Vec::new()
483}
484
485fn modality_symbols(value: &serde_json::Value) -> Vec<Symbol> {
486 let mut symbols = Vec::new();
487 match value {
488 serde_json::Value::String(text) => push_modality_symbol(&mut symbols, text),
489 serde_json::Value::Array(items) => {
490 for item in items {
491 if let Some(text) = item.as_str() {
492 push_modality_symbol(&mut symbols, text);
493 }
494 }
495 }
496 serde_json::Value::Object(map) => {
497 for (key, value) in map {
498 if value.as_bool().unwrap_or(false) {
499 push_modality_symbol(&mut symbols, key);
500 }
501 }
502 }
503 _ => {}
504 }
505 symbols
506}
507
508fn push_modality_symbol(symbols: &mut Vec<Symbol>, text: &str) {
509 let name = text.trim().to_ascii_lowercase().replace('_', "-");
510 if !matches!(name.as_str(), "text" | "image" | "audio") {
511 return;
512 }
513 let symbol = Symbol::new(name);
514 if !symbols.contains(&symbol) {
515 symbols.push(symbol);
516 }
517}
518
519fn push_modality_extra(extras: &mut Vec<(Expr, Expr)>, name: &str, symbols: Vec<Symbol>) {
520 if symbols.is_empty() {
521 return;
522 }
523 extras.push((
524 Expr::Symbol(Symbol::new(name)),
525 Expr::List(symbols.into_iter().map(Expr::Symbol).collect()),
526 ));
527}
528
529fn map_entry(name: &str, value: Expr) -> (Expr, Expr) {
530 (Expr::Symbol(Symbol::new(name)), value)
531}