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