Skip to main content

relay_knowledge/model_provider/
mod.rs

1//! Model provider profiles, catalog cache, and connectivity diagnostics.
2//!
3//! The module owns provider configuration data and async file/network workflows.
4//! It does not read environment variables directly; callers pass resolved paths,
5//! network policy, and retrieval runtime metadata.
6
7mod catalog;
8mod helpers;
9
10use std::{collections::BTreeMap, error::Error, fmt, time::Instant};
11
12use serde::{Deserialize, Serialize};
13use tokio::fs;
14
15use helpers::*;
16
17use crate::{
18    net::{
19        http::HttpConfig,
20        qos::{QosPolicy, QosRuntime},
21    },
22    paths::RuntimePaths,
23    retrieval::{EmbeddingProviderKind, ReadModelBackendConfig},
24};
25
26const DEFAULT_PROFILE_NAME: &str = "default";
27const DEFAULT_CATALOG_SOURCE_URL: &str = "https://models.dev/api.json";
28const DEFAULT_CONNECT_TIMEOUT_SECONDS: f64 = 30.0;
29const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com";
30const DEFAULT_CODEAGENT_BASE_URL: &str = "https://codeagentcli.rnd.huawei.com/codeAgentPro";
31const DEFAULT_MAAS_BASE_URL: &str =
32    "http://snapengine.cida.cce.prod-szv-g.dragon.tools.huawei.com/api/v2/";
33
34/// Model provider family accepted by profile configuration.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum ModelProviderKind {
38    #[serde(rename = "openai_compatible")]
39    OpenAiCompatible,
40    Anthropic,
41    Bigmodel,
42    Minimax,
43    Maas,
44    Codeagent,
45    Echo,
46}
47
48impl ModelProviderKind {
49    pub const fn as_str(self) -> &'static str {
50        match self {
51            Self::OpenAiCompatible => "openai_compatible",
52            Self::Anthropic => "anthropic",
53            Self::Bigmodel => "bigmodel",
54            Self::Minimax => "minimax",
55            Self::Maas => "maas",
56            Self::Codeagent => "codeagent",
57            Self::Echo => "echo",
58        }
59    }
60
61    const fn default_base_url(self) -> Option<&'static str> {
62        match self {
63            Self::Anthropic => Some(DEFAULT_ANTHROPIC_BASE_URL),
64            Self::Codeagent => Some(DEFAULT_CODEAGENT_BASE_URL),
65            Self::Maas => Some(DEFAULT_MAAS_BASE_URL),
66            Self::Echo => Some("http://127.0.0.1/echo"),
67            Self::OpenAiCompatible | Self::Bigmodel | Self::Minimax => None,
68        }
69    }
70}
71
72/// Secret-bearing request header configured for a model profile.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct ModelRequestHeader {
75    pub name: String,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub value: Option<String>,
78    #[serde(default)]
79    pub secret: bool,
80    #[serde(default)]
81    pub configured: bool,
82}
83
84impl ModelRequestHeader {
85    fn normalized(mut self) -> Result<Self, ModelProviderError> {
86        self.name = non_empty_string(self.name, "header name")?;
87        self.value = self
88            .value
89            .and_then(|value| non_empty_string(value, "header value").ok());
90        self.configured = self.configured || self.value.is_some();
91        Ok(self)
92    }
93
94    fn redacted(&self) -> Self {
95        Self {
96            name: self.name.clone(),
97            value: (!self.secret).then(|| self.value.clone()).flatten(),
98            secret: self.secret,
99            configured: self.configured || self.value.is_some(),
100        }
101    }
102}
103
104/// Optional model capability matrix surfaced in Settings.
105#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
106pub struct ModelCapabilities {
107    #[serde(default)]
108    pub input: ModelModalityMatrix,
109    #[serde(default)]
110    pub output: ModelModalityMatrix,
111}
112
113/// Capability flags per modality.
114#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
115pub struct ModelModalityMatrix {
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub text: Option<bool>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub image: Option<bool>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub audio: Option<bool>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub video: Option<bool>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub pdf: Option<bool>,
126}
127
128/// User-editable profile payload used by the Web API.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct ModelProfileSaveRequest {
131    pub provider: ModelProviderKind,
132    pub model: String,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub base_url: Option<String>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub api_key: Option<String>,
137    #[serde(default)]
138    pub clear_api_key: bool,
139    #[serde(default)]
140    pub headers: Vec<ModelRequestHeader>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub ssl_verify: Option<bool>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub context_window: Option<u32>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub max_tokens: Option<u32>,
147    #[serde(default = "default_temperature")]
148    pub temperature: f64,
149    #[serde(default = "default_top_p")]
150    pub top_p: f64,
151    #[serde(default = "default_connect_timeout_seconds")]
152    pub connect_timeout_seconds: f64,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub capabilities: Option<ModelCapabilities>,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub fallback_policy_id: Option<String>,
157    #[serde(default)]
158    pub fallback_priority: u32,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub catalog_provider_id: Option<String>,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub catalog_provider_name: Option<String>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub catalog_model_name: Option<String>,
165    #[serde(default)]
166    pub is_default: bool,
167}
168
169/// Redacted profile returned by diagnostics and Web Settings.
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171pub struct ModelProfileView {
172    pub name: String,
173    pub provider: ModelProviderKind,
174    pub model: String,
175    pub base_url: String,
176    pub api_key_configured: bool,
177    pub headers: Vec<ModelRequestHeader>,
178    pub ssl_verify: Option<bool>,
179    pub context_window: Option<u32>,
180    pub max_tokens: Option<u32>,
181    pub temperature: f64,
182    pub top_p: f64,
183    pub connect_timeout_seconds: f64,
184    pub capabilities: ModelCapabilities,
185    pub fallback_policy_id: Option<String>,
186    pub fallback_priority: u32,
187    pub catalog_provider_id: Option<String>,
188    pub catalog_provider_name: Option<String>,
189    pub catalog_model_name: Option<String>,
190    pub is_default: bool,
191    pub source: String,
192}
193
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195struct StoredModelProfile {
196    provider: ModelProviderKind,
197    model: String,
198    base_url: String,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    api_key: Option<String>,
201    #[serde(default)]
202    headers: Vec<ModelRequestHeader>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    ssl_verify: Option<bool>,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    context_window: Option<u32>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    max_tokens: Option<u32>,
209    temperature: f64,
210    top_p: f64,
211    connect_timeout_seconds: f64,
212    #[serde(default)]
213    capabilities: ModelCapabilities,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    fallback_policy_id: Option<String>,
216    fallback_priority: u32,
217    #[serde(skip_serializing_if = "Option::is_none")]
218    catalog_provider_id: Option<String>,
219    #[serde(skip_serializing_if = "Option::is_none")]
220    catalog_provider_name: Option<String>,
221    #[serde(skip_serializing_if = "Option::is_none")]
222    catalog_model_name: Option<String>,
223    #[serde(default)]
224    is_default: bool,
225    source: String,
226}
227
228impl StoredModelProfile {
229    fn from_save_request(
230        request: ModelProfileSaveRequest,
231        existing: Option<&Self>,
232    ) -> Result<Self, ModelProviderError> {
233        validate_sampling(
234            request.temperature,
235            request.top_p,
236            request.connect_timeout_seconds,
237        )?;
238        let provider = request.provider;
239        let model = non_empty_string(request.model, "model")?;
240        let base_url = normalized_base_url(provider, request.base_url)?;
241        let api_key = if request.clear_api_key {
242            None
243        } else {
244            match request.api_key {
245                Some(value) => non_empty_string(value, "api_key").ok(),
246                None => existing.and_then(|profile| profile.api_key.clone()),
247            }
248        };
249        let headers = if request.headers.is_empty() {
250            existing
251                .map(|profile| profile.headers.clone())
252                .unwrap_or_default()
253        } else {
254            validate_headers(
255                request.headers,
256                existing.map(|profile| profile.headers.as_slice()),
257            )?
258        };
259        if !provider_allows_missing_auth(provider)
260            && api_key.is_none()
261            && !headers.iter().any(|header| header.configured)
262        {
263            return Err(ModelProviderError::InvalidInput(
264                "model profile requires api_key or at least one configured header".to_owned(),
265            ));
266        }
267
268        Ok(Self {
269            provider,
270            model,
271            base_url,
272            api_key,
273            headers,
274            ssl_verify: request
275                .ssl_verify
276                .or_else(|| existing.and_then(|profile| profile.ssl_verify)),
277            context_window: request.context_window,
278            max_tokens: request.max_tokens,
279            temperature: request.temperature,
280            top_p: request.top_p,
281            connect_timeout_seconds: request.connect_timeout_seconds,
282            capabilities: request.capabilities.unwrap_or_else(|| {
283                existing
284                    .map(|profile| profile.capabilities.clone())
285                    .unwrap_or_default()
286            }),
287            fallback_policy_id: request.fallback_policy_id.and_then(normalize_optional),
288            fallback_priority: request.fallback_priority,
289            catalog_provider_id: request.catalog_provider_id.and_then(normalize_optional),
290            catalog_provider_name: request.catalog_provider_name.and_then(normalize_optional),
291            catalog_model_name: request.catalog_model_name.and_then(normalize_optional),
292            is_default: request.is_default,
293            source: "config".to_owned(),
294        })
295    }
296
297    fn from_runtime(retrieval: &ReadModelBackendConfig) -> Option<Self> {
298        let remote = retrieval.remote_embedding.as_ref()?;
299        Some(Self {
300            provider: match remote.provider {
301                EmbeddingProviderKind::OpenAiCompatible => ModelProviderKind::OpenAiCompatible,
302                EmbeddingProviderKind::Echo => ModelProviderKind::Echo,
303            },
304            model: retrieval.vector_model.name.clone(),
305            base_url: remote.base_url.clone(),
306            api_key: Some(remote.api_key.clone()),
307            headers: Vec::new(),
308            ssl_verify: None,
309            context_window: None,
310            max_tokens: None,
311            temperature: default_temperature(),
312            top_p: default_top_p(),
313            connect_timeout_seconds: default_connect_timeout_seconds(),
314            capabilities: ModelCapabilities {
315                input: ModelModalityMatrix {
316                    text: Some(true),
317                    image: None,
318                    audio: None,
319                    video: None,
320                    pdf: None,
321                },
322                output: ModelModalityMatrix {
323                    text: Some(true),
324                    image: None,
325                    audio: None,
326                    video: None,
327                    pdf: None,
328                },
329            },
330            fallback_policy_id: None,
331            fallback_priority: 0,
332            catalog_provider_id: None,
333            catalog_provider_name: None,
334            catalog_model_name: None,
335            is_default: true,
336            source: "environment".to_owned(),
337        })
338    }
339
340    fn to_view(&self, name: &str, is_default: bool) -> ModelProfileView {
341        ModelProfileView {
342            name: name.to_owned(),
343            provider: self.provider,
344            model: self.model.clone(),
345            base_url: redacted_url(&self.base_url),
346            api_key_configured: self.api_key.is_some(),
347            headers: self
348                .headers
349                .iter()
350                .map(ModelRequestHeader::redacted)
351                .collect(),
352            ssl_verify: self.ssl_verify,
353            context_window: self.context_window,
354            max_tokens: self.max_tokens,
355            temperature: self.temperature,
356            top_p: self.top_p,
357            connect_timeout_seconds: self.connect_timeout_seconds,
358            capabilities: self.capabilities.clone(),
359            fallback_policy_id: self.fallback_policy_id.clone(),
360            fallback_priority: self.fallback_priority,
361            catalog_provider_id: self.catalog_provider_id.clone(),
362            catalog_provider_name: self.catalog_provider_name.clone(),
363            catalog_model_name: self.catalog_model_name.clone(),
364            is_default,
365            source: self.source.clone(),
366        }
367    }
368}
369
370/// Redacted list response for all configured model profiles.
371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
372pub struct ModelProfilesResponse {
373    pub loaded: bool,
374    pub default_profile: Option<String>,
375    pub profiles: Vec<ModelProfileView>,
376    pub error: Option<String>,
377}
378
379/// Small runtime summary embedded in project status.
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
381pub struct ModelProfileRuntimeSummary {
382    pub loaded: bool,
383    pub profile_count: usize,
384    pub default_profile: Option<String>,
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub error: Option<String>,
387}
388
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
390struct StoredProfileFile {
391    default_profile: Option<String>,
392    profiles: BTreeMap<String, StoredModelProfile>,
393}
394
395/// Built-in fallback policy strategy.
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
397#[serde(rename_all = "snake_case")]
398pub enum ModelFallbackStrategy {
399    SameProviderThenOtherProvider,
400    OtherProviderOnly,
401}
402
403/// Model fallback policy used after retryable provider failures.
404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
405pub struct ModelFallbackPolicy {
406    pub policy_id: String,
407    pub name: String,
408    pub description: String,
409    pub enabled: bool,
410    pub strategy: ModelFallbackStrategy,
411    pub max_hops: u32,
412    pub cooldown_seconds: u32,
413}
414
415/// Fallback config returned by Settings.
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417pub struct ModelFallbackConfig {
418    pub policies: Vec<ModelFallbackPolicy>,
419}
420
421/// Request for profile-aware model connectivity checks.
422#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
423pub struct ModelConnectivityProbeRequest {
424    pub profile_name: Option<String>,
425    pub override_config: Option<ModelProfileSaveRequest>,
426    pub timeout_ms: Option<u64>,
427}
428
429/// Request for profile-aware model discovery.
430#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
431pub struct ModelDiscoveryRequest {
432    pub profile_name: Option<String>,
433    pub override_config: Option<ModelProfileSaveRequest>,
434    pub timeout_ms: Option<u64>,
435}
436
437/// Token counts reported by providers that include usage metadata.
438#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
439pub struct ModelConnectivityTokenUsage {
440    pub prompt_tokens: u64,
441    pub completion_tokens: u64,
442    pub total_tokens: u64,
443}
444
445/// Provider connectivity diagnostics safe for Web display.
446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
447pub struct ModelConnectivityDiagnostics {
448    pub endpoint_reachable: bool,
449    pub auth_valid: bool,
450    pub rate_limited: bool,
451}
452
453/// Result of a provider probe request.
454#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
455pub struct ModelConnectivityProbeResult {
456    pub ok: bool,
457    pub provider: ModelProviderKind,
458    pub model: String,
459    pub latency_ms: u64,
460    pub checked_at_ms: u64,
461    pub diagnostics: ModelConnectivityDiagnostics,
462    pub token_usage: Option<ModelConnectivityTokenUsage>,
463    pub error_code: Option<String>,
464    pub error_message: Option<String>,
465    pub retryable: bool,
466}
467
468/// Discovered provider model with optional metadata.
469#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
470pub struct ModelDiscoveryEntry {
471    pub model: String,
472    pub context_window: Option<u32>,
473    pub output_limit: Option<u32>,
474    pub capabilities: ModelCapabilities,
475}
476
477/// Result of a model discovery request.
478#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
479pub struct ModelDiscoveryResult {
480    pub ok: bool,
481    pub provider: ModelProviderKind,
482    pub base_url: String,
483    pub latency_ms: u64,
484    pub checked_at_ms: u64,
485    pub diagnostics: ModelConnectivityDiagnostics,
486    pub models: Vec<String>,
487    pub model_entries: Vec<ModelDiscoveryEntry>,
488    pub error_code: Option<String>,
489    pub error_message: Option<String>,
490    pub retryable: bool,
491}
492
493/// Public model catalog provider entry.
494#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
495pub struct ModelCatalogProvider {
496    pub id: String,
497    pub name: String,
498    pub runtime_provider: ModelProviderKind,
499    pub api: Option<String>,
500    pub doc: Option<String>,
501    pub env: Vec<String>,
502    pub models: Vec<ModelCatalogModel>,
503}
504
505/// Public model catalog model entry.
506#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
507pub struct ModelCatalogModel {
508    pub id: String,
509    pub name: String,
510    pub family: Option<String>,
511    pub context_window: Option<u32>,
512    pub output_limit: Option<u32>,
513    pub capabilities: ModelCapabilities,
514}
515
516/// Catalog fetch result with cache provenance.
517#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
518pub struct ModelCatalogResult {
519    pub ok: bool,
520    pub source_url: String,
521    pub fetched_at_ms: Option<u64>,
522    pub cache_age_seconds: Option<u64>,
523    pub stale: bool,
524    pub providers: Vec<ModelCatalogProvider>,
525    pub error_code: Option<String>,
526    pub error_message: Option<String>,
527}
528
529#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
530struct ModelCatalogCache {
531    source_url: String,
532    fetched_at_ms: u64,
533    providers: Vec<ModelCatalogProvider>,
534}
535
536/// Async model provider configuration service.
537#[derive(Debug, Clone)]
538pub struct ModelProviderConfigService {
539    paths: RuntimePaths,
540    catalog_source_url: String,
541}
542
543impl ModelProviderConfigService {
544    pub fn new(paths: RuntimePaths) -> Self {
545        Self {
546            paths,
547            catalog_source_url: DEFAULT_CATALOG_SOURCE_URL.to_owned(),
548        }
549    }
550
551    pub async fn profiles(
552        &self,
553        retrieval: &ReadModelBackendConfig,
554    ) -> Result<ModelProfilesResponse, ModelProviderError> {
555        let file = self.load_profile_file().await?;
556        Ok(profile_response(file, retrieval))
557    }
558
559    pub async fn profile_summary(
560        &self,
561        retrieval: &ReadModelBackendConfig,
562    ) -> ModelProfileRuntimeSummary {
563        match self.profiles(retrieval).await {
564            Ok(response) => ModelProfileRuntimeSummary {
565                loaded: response.loaded,
566                profile_count: response.profiles.len(),
567                default_profile: response.default_profile,
568                error: response.error,
569            },
570            Err(error) => ModelProfileRuntimeSummary {
571                loaded: false,
572                profile_count: 0,
573                default_profile: None,
574                error: Some(error.to_string()),
575            },
576        }
577    }
578
579    pub async fn save_profile(
580        &self,
581        name: &str,
582        request: ModelProfileSaveRequest,
583        retrieval: &ReadModelBackendConfig,
584    ) -> Result<ModelProfilesResponse, ModelProviderError> {
585        let name = validate_profile_name(name)?;
586        let mut file = self
587            .load_profile_file()
588            .await?
589            .unwrap_or_else(|| StoredProfileFile {
590                default_profile: None,
591                profiles: BTreeMap::new(),
592            });
593        let runtime_profile = runtime_profile_merge_base(&file, &name, retrieval);
594        let existing = file.profiles.get(&name).or(runtime_profile.as_ref());
595        let is_default = request.is_default || file.default_profile.is_none();
596        let stored = StoredModelProfile::from_save_request(request, existing)?;
597        file.profiles.insert(name.clone(), stored);
598        if is_default {
599            file.default_profile = Some(name);
600            for (profile_name, profile) in &mut file.profiles {
601                profile.is_default = file.default_profile.as_ref() == Some(profile_name);
602            }
603        }
604        self.write_profile_file(&file).await?;
605        Ok(profile_response(Some(file), retrieval))
606    }
607
608    pub async fn delete_profile(
609        &self,
610        name: &str,
611        retrieval: &ReadModelBackendConfig,
612    ) -> Result<ModelProfilesResponse, ModelProviderError> {
613        let name = validate_profile_name(name)?;
614        let mut file = self
615            .load_profile_file()
616            .await?
617            .unwrap_or_else(|| StoredProfileFile {
618                default_profile: None,
619                profiles: BTreeMap::new(),
620            });
621        file.profiles.remove(&name);
622        if file.default_profile.as_deref() == Some(&name) {
623            file.default_profile = file.profiles.keys().next().cloned();
624        }
625        for (profile_name, profile) in &mut file.profiles {
626            profile.is_default = file.default_profile.as_ref() == Some(profile_name);
627        }
628        self.write_profile_file(&file).await?;
629        Ok(profile_response(Some(file), retrieval))
630    }
631
632    pub async fn fallback_config(&self) -> Result<ModelFallbackConfig, ModelProviderError> {
633        match fs::read_to_string(self.paths.model_fallback_file()).await {
634            Ok(raw) => serde_json::from_str(&raw).map_err(ModelProviderError::from),
635            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(default_fallback()),
636            Err(error) => Err(ModelProviderError::from(error)),
637        }
638    }
639
640    pub async fn save_fallback_config(
641        &self,
642        config: ModelFallbackConfig,
643    ) -> Result<ModelFallbackConfig, ModelProviderError> {
644        validate_fallback_config(&config)?;
645        write_json(self.paths.model_fallback_file(), &config).await?;
646        Ok(config)
647    }
648
649    pub async fn probe(
650        &self,
651        http: &HttpConfig,
652        retrieval: &ReadModelBackendConfig,
653        request: ModelConnectivityProbeRequest,
654    ) -> Result<ModelConnectivityProbeResult, ModelProviderError> {
655        let qos = QosRuntime::default();
656        let policy = QosPolicy::new(
657            crate::net::qos::DEFAULT_MAX_CONNECTIONS,
658            crate::net::qos::DEFAULT_MAX_IN_FLIGHT_REQUESTS,
659            crate::net::qos::DEFAULT_MAX_QUEUE_DEPTH,
660        )
661        .expect("default QoS policy should validate");
662        self.probe_with_qos(http, &qos, &policy, retrieval, request)
663            .await
664    }
665
666    pub async fn probe_with_qos(
667        &self,
668        http: &HttpConfig,
669        qos: &QosRuntime,
670        policy: &QosPolicy,
671        retrieval: &ReadModelBackendConfig,
672        request: ModelConnectivityProbeRequest,
673    ) -> Result<ModelConnectivityProbeResult, ModelProviderError> {
674        let profile = self
675            .resolve_probe_profile(retrieval, request.profile_name, request.override_config)
676            .await?;
677        let request_timeout = request_timeout_from_ms(request.timeout_ms);
678        let started = Instant::now();
679        let checked_at_ms = now_millis();
680        if profile.provider == ModelProviderKind::Echo {
681            return Ok(ModelConnectivityProbeResult {
682                ok: true,
683                provider: profile.provider,
684                model: profile.model,
685                latency_ms: elapsed_millis(started),
686                checked_at_ms,
687                diagnostics: ok_diagnostics(),
688                token_usage: Some(ModelConnectivityTokenUsage {
689                    prompt_tokens: 4,
690                    completion_tokens: 2,
691                    total_tokens: 6,
692                }),
693                error_code: None,
694                error_message: None,
695                retryable: false,
696            });
697        }
698        if matches!(
699            profile.provider,
700            ModelProviderKind::Maas | ModelProviderKind::Codeagent
701        ) {
702            return Ok(unsupported_probe(profile, started, checked_at_ms));
703        }
704
705        let client = provider_http_client(http, &profile)?;
706        let response =
707            send_probe_request_with_qos(&client, qos, policy, &profile, request_timeout).await;
708        Ok(probe_result_from_http(profile, started, checked_at_ms, response).await)
709    }
710
711    pub async fn discover(
712        &self,
713        http: &HttpConfig,
714        retrieval: &ReadModelBackendConfig,
715        request: ModelDiscoveryRequest,
716    ) -> Result<ModelDiscoveryResult, ModelProviderError> {
717        let qos = QosRuntime::default();
718        let policy = QosPolicy::new(
719            crate::net::qos::DEFAULT_MAX_CONNECTIONS,
720            crate::net::qos::DEFAULT_MAX_IN_FLIGHT_REQUESTS,
721            crate::net::qos::DEFAULT_MAX_QUEUE_DEPTH,
722        )
723        .expect("default QoS policy should validate");
724        self.discover_with_qos(http, &qos, &policy, retrieval, request)
725            .await
726    }
727
728    pub async fn discover_with_qos(
729        &self,
730        http: &HttpConfig,
731        qos: &QosRuntime,
732        policy: &QosPolicy,
733        retrieval: &ReadModelBackendConfig,
734        request: ModelDiscoveryRequest,
735    ) -> Result<ModelDiscoveryResult, ModelProviderError> {
736        let profile = self
737            .resolve_probe_profile(retrieval, request.profile_name, request.override_config)
738            .await?;
739        let request_timeout = request_timeout_from_ms(request.timeout_ms);
740        let started = Instant::now();
741        let checked_at_ms = now_millis();
742        if profile.provider == ModelProviderKind::Echo {
743            return Ok(ModelDiscoveryResult {
744                ok: true,
745                provider: profile.provider,
746                base_url: redacted_url(&profile.base_url),
747                latency_ms: elapsed_millis(started),
748                checked_at_ms,
749                diagnostics: ok_diagnostics(),
750                models: vec![profile.model.clone()],
751                model_entries: vec![ModelDiscoveryEntry {
752                    model: profile.model,
753                    context_window: None,
754                    output_limit: None,
755                    capabilities: ModelCapabilities::default(),
756                }],
757                error_code: None,
758                error_message: None,
759                retryable: false,
760            });
761        }
762        if matches!(
763            profile.provider,
764            ModelProviderKind::Maas | ModelProviderKind::Codeagent
765        ) {
766            return Ok(unsupported_discovery(profile, started, checked_at_ms));
767        }
768
769        let client = provider_http_client(http, &profile)?;
770        let response =
771            send_discovery_request_with_qos(&client, qos, policy, &profile, request_timeout).await;
772        Ok(discovery_result_from_http(profile, started, checked_at_ms, response).await)
773    }
774
775    async fn resolve_probe_profile(
776        &self,
777        retrieval: &ReadModelBackendConfig,
778        profile_name: Option<String>,
779        override_config: Option<ModelProfileSaveRequest>,
780    ) -> Result<StoredModelProfile, ModelProviderError> {
781        match (profile_name, override_config) {
782            (Some(name), Some(request)) => {
783                let base = self.resolve_profile_by_name(retrieval, &name).await?;
784                StoredModelProfile::from_save_request(request, Some(&base))
785            }
786            (Some(name), None) => self.resolve_profile_by_name(retrieval, &name).await,
787            (None, Some(request)) => {
788                let base = match self.resolve_default_profile(retrieval).await {
789                    Ok(profile) => Some(profile),
790                    Err(ModelProviderError::InvalidInput(message))
791                        if message == "no model profile is configured" =>
792                    {
793                        None
794                    }
795                    Err(error) => return Err(error),
796                };
797                StoredModelProfile::from_save_request(request, base.as_ref())
798            }
799            (None, None) => self.resolve_default_profile(retrieval).await,
800        }
801    }
802
803    async fn resolve_default_profile(
804        &self,
805        retrieval: &ReadModelBackendConfig,
806    ) -> Result<StoredModelProfile, ModelProviderError> {
807        let file = self.load_profile_file().await?;
808        let response = profile_response(file.clone(), retrieval);
809        let Some(default_name) = response.default_profile else {
810            return Err(ModelProviderError::InvalidInput(
811                "no model profile is configured".to_owned(),
812            ));
813        };
814        self.resolve_profile_by_name(retrieval, &default_name).await
815    }
816
817    async fn resolve_profile_by_name(
818        &self,
819        retrieval: &ReadModelBackendConfig,
820        name: &str,
821    ) -> Result<StoredModelProfile, ModelProviderError> {
822        let name = validate_profile_name(name)?;
823        if let Some(file) = self.load_profile_file().await? {
824            if let Some(profile) = file.profiles.get(&name) {
825                return Ok(profile.clone());
826            }
827        }
828        if name == DEFAULT_PROFILE_NAME {
829            if let Some(profile) = StoredModelProfile::from_runtime(retrieval) {
830                return Ok(profile);
831            }
832        }
833        Err(ModelProviderError::InvalidInput(format!(
834            "model profile '{name}' was not found"
835        )))
836    }
837
838    async fn load_profile_file(&self) -> Result<Option<StoredProfileFile>, ModelProviderError> {
839        match fs::read_to_string(self.paths.model_profiles_file()).await {
840            Ok(raw) => serde_json::from_str(&raw)
841                .map(Some)
842                .map_err(ModelProviderError::from),
843            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
844            Err(error) => Err(ModelProviderError::from(error)),
845        }
846    }
847
848    async fn write_profile_file(&self, file: &StoredProfileFile) -> Result<(), ModelProviderError> {
849        write_json(self.paths.model_profiles_file(), file).await
850    }
851}
852
853/// Error from model provider configuration and diagnostics.
854#[derive(Debug, Clone, PartialEq, Eq)]
855pub enum ModelProviderError {
856    InvalidInput(String),
857    Io(String),
858    Json(String),
859    Network(String),
860}
861
862impl fmt::Display for ModelProviderError {
863    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
864        match self {
865            Self::InvalidInput(message)
866            | Self::Io(message)
867            | Self::Json(message)
868            | Self::Network(message) => formatter.write_str(message),
869        }
870    }
871}
872
873impl Error for ModelProviderError {}
874
875impl From<std::io::Error> for ModelProviderError {
876    fn from(error: std::io::Error) -> Self {
877        Self::Io(error.to_string())
878    }
879}
880
881impl From<serde_json::Error> for ModelProviderError {
882    fn from(error: serde_json::Error) -> Self {
883        Self::Json(error.to_string())
884    }
885}
886
887#[cfg(test)]
888#[path = "tests.rs"]
889mod tests;