Skip to main content

sim_lib_agent_runner_http/
probe.rs

1//! Provider health probing for HTTP model profiles.
2
3mod 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/// Provider health state reported by a probe.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub enum ProbeStatus {
15    /// The provider answered and model metadata was decoded.
16    Available,
17    /// The provider was reached but returned an unusable response, or transport failed.
18    Unavailable,
19    /// The probe was intentionally not sent, such as when a required secret is absent.
20    Skipped,
21}
22
23impl ProbeStatus {
24    /// Symbol form used when exposing the report as expression data.
25    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/// Provider health report returned by [`probe_provider`].
35#[derive(Clone, Debug, PartialEq)]
36pub struct ProviderProbeReport {
37    /// Provider profile id.
38    pub provider: Symbol,
39    /// Endpoint that was probed.
40    pub endpoint: String,
41    /// Probe outcome.
42    pub status: ProbeStatus,
43    /// Models discovered from the provider model-list response.
44    pub models: Vec<String>,
45    /// Model cards derived from discovered provider metadata.
46    pub model_cards: Vec<ModelCard>,
47    /// Redacted failure or skip reason.
48    pub reason: Option<String>,
49    /// Whether the profile has an authentication shape whose secret is redacted.
50    pub redacted: bool,
51}
52
53impl ProviderProbeReport {
54    /// Converts this report to table-visible expression data.
55    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/// Candidate endpoint and model-list path tried by provider probing.
81#[derive(Clone, Debug, PartialEq, Eq)]
82pub struct EndpointCandidate {
83    /// Candidate base endpoint.
84    pub endpoint: String,
85    /// Provider-specific model-list path for this candidate.
86    pub models_path: &'static str,
87}
88
89/// HTTP GET request issued by a provider probe.
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct ProbeHttpRequest<'a> {
92    /// Base endpoint string from the provider config.
93    pub endpoint: &'a str,
94    /// Parsed base endpoint.
95    pub endpoint_parts: UrlParts,
96    /// Provider-specific model-list path.
97    pub path: &'a str,
98    /// Provider-specific headers, including authentication when configured.
99    pub headers: Vec<(String, String)>,
100    /// Socket read/write timeout.
101    pub timeout: std::time::Duration,
102    /// Maximum response body bytes to decode.
103    pub max_response_bytes: usize,
104}
105
106/// HTTP response returned by a provider probe transport.
107#[derive(Clone, Debug, PartialEq, Eq)]
108pub struct ProbeHttpResponse {
109    /// Parsed HTTP response head.
110    pub head: HttpHead,
111    /// Decoded response body bytes.
112    pub body: Vec<u8>,
113}
114
115impl ProbeHttpResponse {
116    /// Numeric HTTP status code.
117    pub fn status(&self) -> u16 {
118        self.head.status
119    }
120}
121
122/// Transport used by provider probes.
123pub trait ProbeTransport {
124    /// Sends the model-list GET request.
125    fn get(&self, request: ProbeHttpRequest<'_>) -> Result<ProbeHttpResponse>;
126}
127
128/// Probes the configured provider's model-list endpoint.
129pub 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
246/// Returns Lemonade model-list endpoint candidates in probe order.
247pub 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
321/// Parses an Ollama `/api/tags` response into the provider model names it
322/// advertises.
323pub 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}