1mod 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#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "snake_case")]
395pub enum ModelFallbackStrategy {
396 SameProviderThenOtherProvider,
397 OtherProviderOnly,
398}
399
400#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
414pub struct ModelFallbackConfig {
415 pub policies: Vec<ModelFallbackPolicy>,
416}
417
418#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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;