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