1use crate::config::OxiosConfig;
10use crate::credential::CredentialStore;
11use anyhow::Context;
12use chrono::{DateTime, Utc};
13use parking_lot::RwLock;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::fs;
17use std::path::PathBuf;
18use std::sync::Arc;
19
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
24pub struct ProviderSettings {
25 #[serde(default)]
26 pub enabled: bool,
27 #[serde(default)]
28 pub sort_order: i32,
29 #[serde(default)]
30 pub custom_endpoint: Option<String>,
31 #[serde(default)]
32 pub models: ModelListSettings,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ModelListSettings {
38 #[serde(default)]
39 pub mode: ModelListMode,
40 #[serde(default)]
41 pub allow: Vec<String>,
42 #[serde(default)]
43 pub deny: Vec<String>,
44}
45
46impl Default for ModelListSettings {
47 fn default() -> Self {
48 Self {
49 mode: ModelListMode::All,
50 allow: vec![],
51 deny: vec![],
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
58#[serde(rename_all = "lowercase")]
59pub enum ModelListMode {
60 #[default]
61 All,
62 Allowlist,
63 Denylist,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct CustomProviderDef {
69 pub id: String,
70 pub name: String,
71 pub sdk_type: SdkType,
72 pub base_url: String,
73 #[serde(default)]
74 pub api_key_env: Option<String>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum SdkType {
81 OpenAI,
82 Anthropic,
83 Google,
84 #[serde(rename = "openai-compatible")]
85 OpenAICompatible,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90struct ProviderStateFile {
91 #[serde(default)]
92 providers: HashMap<String, ProviderSettings>,
93 #[serde(default)]
94 custom_providers: Vec<CustomProviderDef>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct RoutingConfigSnapshot {
103 pub routing_enabled: bool,
105 pub prefer_cost_efficient: bool,
107 pub fallback_models: Vec<String>,
109 pub excluded_models: Vec<String>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct RoutingStatsSnapshot {
117 pub model_calls: HashMap<String, u64>,
119 pub model_cost: HashMap<String, f64>,
121 pub total_requests: u64,
123 pub total_cost: f64,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(rename_all = "camelCase")]
130pub struct FallbackEvent {
131 pub timestamp: DateTime<Utc>,
133 pub from_model: String,
135 pub to_model: String,
137 pub reason: String,
139 pub success: bool,
141}
142
143#[derive(Debug, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct RoutingUpdate {
147 pub routing_enabled: Option<bool>,
148 pub prefer_cost_efficient: Option<bool>,
149 pub fallback_models: Option<Vec<String>>,
150 pub excluded_models: Option<Vec<String>>,
151}
152
153pub struct RoutingStats {
158 calls: RwLock<HashMap<String, u64>>,
159 costs: RwLock<HashMap<String, f64>>,
160 fallbacks: RwLock<std::collections::VecDeque<FallbackEvent>>,
162}
163
164impl Default for RoutingStats {
165 fn default() -> Self {
166 Self {
167 calls: RwLock::new(HashMap::new()),
168 costs: RwLock::new(HashMap::new()),
169 fallbacks: RwLock::new(std::collections::VecDeque::new()),
170 }
171 }
172}
173
174impl RoutingStats {
175 pub fn new() -> Self {
177 Self::default()
178 }
179
180 pub fn record_model_usage(&self, model_id: &str, cost_usd: f64) {
182 let mut calls = self.calls.write();
183 *calls.entry(model_id.to_string()).or_insert(0) += 1;
184 if cost_usd > 0.0 {
185 let mut costs = self.costs.write();
186 *costs.entry(model_id.to_string()).or_insert(0.0) += cost_usd;
187 }
188 }
189
190 pub fn record_fallback(&self, event: FallbackEvent) {
195 let mut fb = self.fallbacks.write();
196 fb.push_back(event);
197 while fb.len() > 200 {
198 fb.pop_front();
199 }
200 }
201
202 pub fn snapshot(&self) -> RoutingStatsSnapshot {
204 let calls = self.calls.read();
205 let costs = self.costs.read();
206 let total_requests: u64 = calls.values().sum();
207 let total_cost: f64 = costs.values().sum();
208 RoutingStatsSnapshot {
209 model_calls: calls.clone(),
210 model_cost: costs.clone(),
211 total_requests,
212 total_cost,
213 }
214 }
215
216 pub fn fallback_history(&self, limit: usize) -> Vec<FallbackEvent> {
218 let fb = self.fallbacks.read();
219 fb.iter().rev().take(limit).cloned().collect()
220 }
221}
222
223pub fn estimate_cost(model_id: &str, input_tokens: u64, output_tokens: u64) -> f64 {
228 let entries = oxi_sdk::get_provider_models(model_id.split('/').next().unwrap_or(model_id));
229 let entry = entries
230 .iter()
231 .find(|e| format!("{}/{}", e.provider, e.id) == model_id);
232 match entry {
233 Some(e) => {
234 (e.cost_input * input_tokens as f64 / 1_000_000.0)
235 + (e.cost_output * output_tokens as f64 / 1_000_000.0)
236 }
237 None => {
238 (0.003 * input_tokens as f64 / 1_000_000.0)
240 + (0.015 * output_tokens as f64 / 1_000_000.0)
241 }
242 }
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(rename_all = "lowercase")]
250pub enum ProviderCategory {
251 Major,
253 Open,
255 Regional,
257 Local,
259}
260
261#[derive(Debug, Clone, Copy)]
274struct ProviderMeta {
275 id: &'static str,
277 display_name: &'static str,
279 category: ProviderCategory,
281 hidden: bool,
285 description: &'static str,
287 env_key: &'static str,
291 aliases: &'static [&'static str],
295}
296
297const PROVIDER_META: &[ProviderMeta] = &[
303 ProviderMeta {
305 id: "anthropic",
306 display_name: "Anthropic",
307 category: ProviderCategory::Major,
308 hidden: false,
309 description: "Claude models with extended thinking",
310 env_key: "ANTHROPIC_API_KEY",
311 aliases: &["anthropic"],
312 },
313 ProviderMeta {
314 id: "openai",
315 display_name: "OpenAI",
316 category: ProviderCategory::Major,
317 hidden: false,
318 description: "GPT, o-series, and Codex models",
319 env_key: "OPENAI_API_KEY",
320 aliases: &["openai"],
321 },
322 ProviderMeta {
323 id: "google",
324 display_name: "Google Gemini",
325 category: ProviderCategory::Major,
326 hidden: false,
327 description: "Gemini models with thinking and tool use",
328 env_key: "GOOGLE_API_KEY",
329 aliases: &["google"],
330 },
331 ProviderMeta {
333 id: "groq",
334 display_name: "Groq",
335 category: ProviderCategory::Open,
336 hidden: false,
337 description: "Fast Llama, Mixtral, and Gemma inference",
338 env_key: "GROQ_API_KEY",
339 aliases: &["groq"],
340 },
341 ProviderMeta {
342 id: "openrouter",
343 display_name: "OpenRouter",
344 category: ProviderCategory::Open,
345 hidden: false,
346 description: "Unified gateway to 200+ models",
347 env_key: "OPENROUTER_API_KEY",
348 aliases: &["openrouter"],
349 },
350 ProviderMeta {
351 id: "deepseek",
352 display_name: "DeepSeek",
353 category: ProviderCategory::Open,
354 hidden: false,
355 description: "DeepSeek-V3 and DeepSeek-R1",
356 env_key: "DEEPSEEK_API_KEY",
357 aliases: &["deepseek"],
358 },
359 ProviderMeta {
360 id: "mistral",
361 display_name: "Mistral",
362 category: ProviderCategory::Open,
363 hidden: false,
364 description: "Mistral and Codestral models",
365 env_key: "MISTRAL_API_KEY",
366 aliases: &["mistral"],
367 },
368 ProviderMeta {
369 id: "xai",
370 display_name: "xAI (Grok)",
371 category: ProviderCategory::Open,
372 hidden: false,
373 description: "Grok models from xAI",
374 env_key: "XAI_API_KEY",
375 aliases: &["xai", "grok"],
376 },
377 ProviderMeta {
378 id: "cerebras",
379 display_name: "Cerebras",
380 category: ProviderCategory::Open,
381 hidden: false,
382 description: "Ultra-fast open model inference",
383 env_key: "CEREBRAS_API_KEY",
384 aliases: &["cerebras"],
385 },
386 ProviderMeta {
387 id: "fireworks",
388 display_name: "Fireworks",
389 category: ProviderCategory::Open,
390 hidden: false,
391 description: "Fast open-source model serving",
392 env_key: "FIREWORKS_API_KEY",
393 aliases: &["fireworks"],
394 },
395 ProviderMeta {
396 id: "github-copilot",
397 display_name: "GitHub Copilot",
398 category: ProviderCategory::Open,
399 hidden: false,
400 description: "GitHub Copilot models (GPT-4, Claude)",
401 env_key: "GITHUB_COPILOT_TOKEN",
402 aliases: &["github-copilot", "copilot"],
403 },
404 ProviderMeta {
405 id: "huggingface",
406 display_name: "Hugging Face",
407 category: ProviderCategory::Open,
408 hidden: false,
409 description: "Open model inference hub",
410 env_key: "HUGGINGFACE_API_KEY",
411 aliases: &["huggingface", "hf"],
412 },
413 ProviderMeta {
414 id: "together",
415 display_name: "Together AI",
416 category: ProviderCategory::Open,
417 hidden: false,
418 description: "Open-source model hosting (Llama, Mixtral, ...)",
419 env_key: "TOGETHER_API_KEY",
420 aliases: &["together", "togetherai"],
421 },
422 ProviderMeta {
423 id: "opencode",
424 display_name: "OpenCode",
425 category: ProviderCategory::Open,
426 hidden: false,
427 description: "OpenCode coding agent gateway",
428 env_key: "",
429 aliases: &["opencode"],
430 },
431 ProviderMeta {
432 id: "perplexity",
433 display_name: "Perplexity",
434 category: ProviderCategory::Open,
435 hidden: false,
436 description: "Search-augmented answer models",
437 env_key: "PERPLEXITY_API_KEY",
438 aliases: &["perplexity"],
439 },
440 ProviderMeta {
441 id: "cohere",
442 display_name: "Cohere",
443 category: ProviderCategory::Open,
444 hidden: false,
445 description: "Cohere Command and Embed models",
446 env_key: "COHERE_API_KEY",
447 aliases: &["cohere"],
448 },
449 ProviderMeta {
451 id: "minimax",
452 display_name: "MiniMax",
453 category: ProviderCategory::Regional,
454 hidden: false,
455 description: "MiniMax-M2.7, abab models",
456 env_key: "MINIMAX_API_KEY",
457 aliases: &["minimax"],
458 },
459 ProviderMeta {
460 id: "moonshotai",
461 display_name: "Moonshot AI (Kimi)",
462 category: ProviderCategory::Regional,
463 hidden: false,
464 description: "Kimi models from Moonshot AI",
465 env_key: "MOONSHOT_API_KEY",
466 aliases: &["moonshotai", "moonshot", "kimi"],
467 },
468 ProviderMeta {
469 id: "kimi-coding",
470 display_name: "Kimi Coding",
471 category: ProviderCategory::Regional,
472 hidden: false,
473 description: "Kimi Coding Plan — optimized for coding",
474 env_key: "KIMI_CODING_API_KEY",
475 aliases: &["kimi-coding"],
476 },
477 ProviderMeta {
478 id: "zai",
479 display_name: "Z.AI (GLM)",
480 category: ProviderCategory::Regional,
481 hidden: false,
482 description: "Z.AI GLM models (coding plan)",
483 env_key: "ZAI_API_KEY",
484 aliases: &["zai"],
485 },
486 ProviderMeta {
492 id: "amazon-bedrock",
493 display_name: "Amazon Bedrock",
494 category: ProviderCategory::Open,
495 hidden: true,
496 description: "Multi-model via AWS Bedrock ConverseStream",
497 env_key: "AWS_ACCESS_KEY_ID",
498 aliases: &["amazon-bedrock", "aws-bedrock", "bedrock"],
499 },
500 ProviderMeta {
501 id: "azure-openai-responses",
502 display_name: "Azure OpenAI (Responses)",
503 category: ProviderCategory::Open,
504 hidden: true,
505 description: "OpenAI models via Azure Cognitive Services",
506 env_key: "AZURE_OPENAI_API_KEY",
507 aliases: &["azure-openai-responses", "azure"],
508 },
509 ProviderMeta {
510 id: "cloudflare-ai-gateway",
511 display_name: "Cloudflare AI Gateway",
512 category: ProviderCategory::Open,
513 hidden: true,
514 description: "Serverless AI via Cloudflare AI Gateway",
515 env_key: "CLOUDFLARE_API_TOKEN",
516 aliases: &["cloudflare-ai-gateway", "cf-ai-gateway"],
517 },
518 ProviderMeta {
519 id: "cloudflare-workers-ai",
520 display_name: "Cloudflare Workers AI",
521 category: ProviderCategory::Open,
522 hidden: true,
523 description: "Serverless AI via Cloudflare Workers",
524 env_key: "CLOUDFLARE_API_KEY",
525 aliases: &["cloudflare-workers-ai", "cloudflare", "workers-ai"],
526 },
527 ProviderMeta {
528 id: "google-vertex",
529 display_name: "Google Vertex AI",
530 category: ProviderCategory::Open,
531 hidden: true,
532 description: "Gemini via Google Cloud Vertex AI",
533 env_key: "GOOGLE_APPLICATION_CREDENTIALS",
534 aliases: &["google-vertex", "vertex"],
535 },
536 ProviderMeta {
537 id: "minimax-cn",
538 display_name: "MiniMax (China)",
539 category: ProviderCategory::Regional,
540 hidden: true,
541 description: "MiniMax China region endpoint",
542 env_key: "MINIMAX_CN_API_KEY",
543 aliases: &["minimax-cn"],
544 },
545 ProviderMeta {
546 id: "moonshotai-cn",
547 display_name: "Moonshot AI (China)",
548 category: ProviderCategory::Regional,
549 hidden: true,
550 description: "Kimi models — China region endpoint",
551 env_key: "MOONSHOT_CN_API_KEY",
552 aliases: &["moonshotai-cn", "moonshot-cn"],
553 },
554 ProviderMeta {
555 id: "openai-codex",
556 display_name: "OpenAI Codex",
557 category: ProviderCategory::Open,
558 hidden: true,
559 description: "OpenAI Codex coding agent (Responses API)",
560 env_key: "OPENAI_API_KEY",
561 aliases: &["openai-codex"],
562 },
563 ProviderMeta {
564 id: "opencode-go",
565 display_name: "OpenCode Go",
566 category: ProviderCategory::Open,
567 hidden: true,
568 description: "OpenCode Go Gateway",
569 env_key: "OPENCODE_GO_API_KEY",
570 aliases: &["opencode-go"],
571 },
572 ProviderMeta {
573 id: "vercel-ai-gateway",
574 display_name: "Vercel AI Gateway",
575 category: ProviderCategory::Open,
576 hidden: true,
577 description: "Vercel AI Gateway",
578 env_key: "VERCEL_API_KEY",
579 aliases: &["vercel-ai-gateway", "vercel"],
580 },
581 ProviderMeta {
582 id: "xiaomi",
583 display_name: "Xiaomi MiMo",
584 category: ProviderCategory::Regional,
585 hidden: true,
586 description: "Xiaomi MiMo models",
587 env_key: "XIAOMI_API_KEY",
588 aliases: &["xiaomi"],
589 },
590];
591
592fn provider_meta(id: &str) -> Option<&'static ProviderMeta> {
594 PROVIDER_META
595 .iter()
596 .find(|m| m.id == id || m.aliases.contains(&id))
597}
598
599fn provider_category(id: &str) -> ProviderCategory {
600 provider_meta(id)
601 .map(|m| m.category)
602 .unwrap_or(ProviderCategory::Open)
603}
604
605fn provider_display_name(id: &str) -> String {
611 provider_meta(id)
612 .map(|m| m.display_name.to_string())
613 .unwrap_or_else(|| fallback_display_name(id))
614}
615
616fn fallback_display_name(id: &str) -> String {
622 id.split(['-', '_'])
623 .filter(|s| !s.is_empty())
624 .map(|segment| {
625 let mut chars = segment.chars();
626 match chars.next() {
627 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
628 None => String::new(),
629 }
630 })
631 .collect::<Vec<_>>()
632 .join(" ")
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize)]
637#[serde(rename_all = "camelCase")]
638pub struct ProviderInfo {
639 pub id: String,
641 pub name: String,
643 pub category: ProviderCategory,
645 pub model_count: usize,
647 pub has_key: bool,
649 #[serde(default)]
653 pub key_source: String,
654 #[serde(default)]
657 pub description: String,
658 #[serde(default)]
662 pub env_key: String,
663}
664
665#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
667#[serde(rename_all = "lowercase")]
668pub enum InputModality {
669 Text,
671 Image,
673}
674
675#[derive(Debug, Clone, Serialize, Deserialize)]
677#[serde(rename_all = "camelCase")]
678pub struct ModelInfo {
679 pub id: String,
681 pub name: String,
683 pub api: String,
685 pub provider: String,
687 pub reasoning: bool,
689 pub input: Vec<InputModality>,
691 pub context_window: u32,
693 pub max_tokens: u32,
695 pub cost_input: f64,
697 pub cost_output: f64,
699 pub cost_cache_read: f64,
701 pub cost_cache_write: f64,
703}
704
705impl From<&oxi_sdk::ModelEntry> for ModelInfo {
706 fn from(entry: &oxi_sdk::ModelEntry) -> Self {
707 Self {
708 id: format!("{}/{}", entry.provider, entry.id),
709 name: entry.name.to_string(),
710 api: entry.api.to_string(),
711 provider: entry.provider.to_string(),
712 reasoning: entry.reasoning,
713 input: entry
714 .input
715 .iter()
716 .map(|m| match m {
717 oxi_sdk::InputModality::Text => InputModality::Text,
718 oxi_sdk::InputModality::Image => InputModality::Image,
719 _ => InputModality::Text,
720 })
721 .collect(),
722 context_window: entry.context_window,
723 max_tokens: entry.max_tokens,
724 cost_input: entry.cost_input,
725 cost_output: entry.cost_output,
726 cost_cache_read: entry.cost_cache_read,
727 cost_cache_write: entry.cost_cache_write,
728 }
729 }
730}
731
732impl From<&oxi_sdk::CatalogModelEntry> for ModelInfo {
733 fn from(entry: &oxi_sdk::CatalogModelEntry) -> Self {
739 Self {
740 id: format!("{}/{}", entry.provider, entry.model_id),
741 name: entry.name.clone(),
742 api: entry.protocol.as_str().to_string(),
743 provider: entry.provider.clone(),
744 reasoning: entry.reasoning,
745 input: entry
746 .input_modalities
747 .iter()
748 .map(|m| match m.as_str() {
749 "image" => InputModality::Image,
750 _ => InputModality::Text,
751 })
752 .collect(),
753 context_window: entry.context_window,
754 max_tokens: entry.max_tokens,
755 cost_input: entry.cost_input,
756 cost_output: entry.cost_output,
757 cost_cache_read: entry.cost_cache_read,
758 cost_cache_write: entry.cost_cache_write,
759 }
760 }
761}
762
763#[derive(Debug, Clone, Serialize, Deserialize)]
765pub struct EngineConfigResponse {
766 pub default_model: String,
768 pub api_key_set: bool,
770 pub api_key_source: Option<String>,
772 pub provider: Option<String>,
774 pub routing: RoutingConfigSnapshot,
776 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub role_routing: Option<crate::config::RoleRoutingConfig>,
779 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub quick_ask_model: Option<String>,
783}
784
785#[derive(Debug, Clone, Serialize, Deserialize)]
787pub struct ValidateKeyResult {
788 pub valid: bool,
790 pub provider: String,
792 pub message: Option<String>,
794}
795
796#[derive(Debug, Clone, Serialize)]
798pub struct ProviderConfigResponse {
799 pub provider: ProviderInfo,
800 pub settings: ProviderSettings,
801 pub models: Vec<String>,
802}
803
804#[derive(Debug, Clone, Serialize)]
806pub struct ConnectionCheckResult {
807 pub success: bool,
808 pub model: String,
809 pub latency_ms: u64,
810 #[serde(skip_serializing_if = "Option::is_none")]
811 pub error: Option<String>,
812}
813
814pub struct EngineApi {
826 config: Arc<RwLock<OxiosConfig>>,
827 config_path: PathBuf,
828 routing_stats: Arc<RoutingStats>,
829 engine_handle: Arc<crate::engine::EngineHandle>,
831 provider_configs: parking_lot::RwLock<HashMap<String, ProviderSettings>>,
833 custom_providers: parking_lot::RwLock<Vec<CustomProviderDef>>,
835}
836
837impl EngineApi {
838 pub fn new(
845 config: Arc<RwLock<OxiosConfig>>,
846 config_path: PathBuf,
847 routing_stats: Arc<RoutingStats>,
848 engine_handle: Arc<crate::engine::EngineHandle>,
849 ) -> Self {
850 let api = Self {
851 config,
852 config_path,
853 routing_stats,
854 engine_handle,
855 provider_configs: parking_lot::RwLock::new(HashMap::new()),
856 custom_providers: parking_lot::RwLock::new(Vec::new()),
857 };
858 if let Ok(state) = api.read_provider_state() {
860 *api.provider_configs.write() = state.providers;
861 *api.custom_providers.write() = state.custom_providers;
862 }
863 api
864 }
865 pub fn engine_handle(&self) -> &Arc<crate::engine::EngineHandle> {
867 &self.engine_handle
868 }
869
870 pub fn validate_model(&self, model_id: &str) -> Result<(), String> {
877 self.engine_handle
878 .get()
879 .resolve_model(model_id)
880 .map(|_| ())
881 .map_err(|e| format!("Unknown model '{model_id}': {e}"))
882 }
883 pub fn role_routing(&self) -> crate::config::RoleRoutingConfig {
885 self.config.read().engine.role_routing.clone()
886 }
887
888 pub fn model_for_role(&self, role: &str) -> Option<String> {
893 self.config
894 .read()
895 .engine
896 .role_routing
897 .roles
898 .get(role)
899 .cloned()
900 }
901
902 pub fn set_role_routing(
904 &self,
905 role_routing: crate::config::RoleRoutingConfig,
906 ) -> anyhow::Result<()> {
907 let snapshot = {
908 let mut cfg = self.config.write();
909 cfg.engine.role_routing = role_routing;
910 cfg.clone()
911 };
912 self.persist(&snapshot)?;
913 tracing::info!("Role routing updated");
914 Ok(())
915 }
916
917 pub fn providers(&self) -> Vec<ProviderInfo> {
933 let catalog = self.engine_handle.get().oxi().catalog().clone();
934 let use_catalog = catalog.model_count_sync() > 0;
935 let all: Vec<String> = if use_catalog {
936 catalog.list_providers_sync()
937 } else {
938 oxi_sdk::get_providers()
939 .into_iter()
940 .map(|s| s.to_string())
941 .collect()
942 };
943
944 let api_key_override = {
948 let cfg = self.config.read();
949 cfg.engine
950 .api_key
951 .as_deref()
952 .filter(|k| !k.is_empty())
953 .map(str::to_owned)
954 };
955 all.into_iter()
956 .filter(|p| provider_meta(p).map(|m| !m.hidden).unwrap_or(true))
957 .map(|p| {
958 let model_count = if use_catalog {
959 catalog.list_models_sync(&p).len()
960 } else {
961 oxi_sdk::get_provider_models(&p).len()
962 };
963 let resolved = CredentialStore::resolve(&p, api_key_override.as_deref());
964 let has_key = resolved.is_some();
965 let key_source = resolved
966 .map(|(_, src)| match src {
967 crate::credential::CredentialSource::EnvVar => "env",
968 crate::credential::CredentialSource::Config => "config",
969 crate::credential::CredentialSource::OxiAuthStore => "auth_store",
970 })
971 .unwrap_or("none")
972 .to_string();
973 let meta = provider_meta(&p);
974 ProviderInfo {
975 id: p.clone(),
976 name: provider_display_name(&p),
977 category: provider_category(&p),
978 model_count,
979 has_key,
980 key_source,
981 description: meta.map(|m| m.description.to_string()).unwrap_or_default(),
982 env_key: meta.map(|m| m.env_key.to_string()).unwrap_or_default(),
983 }
984 })
985 .collect()
986 }
987
988 pub fn models(&self, provider: &str, query: Option<&str>) -> Vec<ModelInfo> {
994 let catalog = self.engine_handle.get().oxi().catalog().clone();
995 let live = catalog.list_models_sync(provider);
996 let models: Vec<ModelInfo> = if !live.is_empty() {
997 live.iter().map(ModelInfo::from).collect()
998 } else {
999 oxi_sdk::get_provider_models(provider)
1000 .iter()
1001 .map(ModelInfo::from)
1002 .collect()
1003 };
1004 models
1005 .into_iter()
1006 .filter(|m| !m.name.contains("latest"))
1007 .filter(|m| {
1008 if let Some(q) = query {
1009 let q = q.to_lowercase();
1010 m.name.to_lowercase().contains(&q)
1011 || m.id.to_lowercase().contains(&q)
1012 || m.provider.to_lowercase().contains(&q)
1013 } else {
1014 true
1015 }
1016 })
1017 .collect()
1018 }
1019
1020 pub fn search_models(&self, query: &str) -> Vec<ModelInfo> {
1025 let catalog = self.engine_handle.get().oxi().catalog().clone();
1026 let live = catalog.search_sync(query);
1027 if !live.is_empty() {
1028 live.iter().map(ModelInfo::from).collect()
1029 } else {
1030 oxi_sdk::search_models(query)
1031 .into_iter()
1032 .map(ModelInfo::from)
1033 .collect()
1034 }
1035 }
1036
1037 pub fn config(&self) -> EngineConfigResponse {
1039 let cfg = self.config.read();
1040 let provider =
1041 CredentialStore::provider_from_model(&cfg.engine.default_model).map(|s| s.to_string());
1042 let api_key_source = provider.as_deref().and_then(|p| {
1043 CredentialStore::resolve(p, cfg.api_key().as_deref()).map(|(_, src)| {
1044 match src {
1045 crate::credential::CredentialSource::EnvVar => "env",
1046 crate::credential::CredentialSource::Config => "config",
1047 crate::credential::CredentialSource::OxiAuthStore => "auth_store",
1048 }
1049 .to_string()
1050 })
1051 });
1052 let api_key_set = provider
1053 .as_deref()
1054 .map(|p| CredentialStore::has_credential(p, cfg.api_key().as_deref()))
1055 .unwrap_or(false);
1056
1057 let role_routing = if cfg.engine.role_routing.roles.is_empty() {
1058 None
1059 } else {
1060 Some(cfg.engine.role_routing.clone())
1061 };
1062
1063 EngineConfigResponse {
1064 default_model: cfg.engine.default_model.clone(),
1065 api_key_set,
1066 api_key_source,
1067 provider,
1068 routing: RoutingConfigSnapshot {
1069 routing_enabled: cfg.engine.routing_enabled,
1070 prefer_cost_efficient: cfg.engine.prefer_cost_efficient,
1071 fallback_models: cfg.engine.fallback_models.clone(),
1072 excluded_models: cfg.engine.excluded_models.clone(),
1073 },
1074 role_routing,
1075 quick_ask_model: cfg.engine.quick_ask_model.clone(),
1076 }
1077 }
1078
1079 pub fn routing_stats_snapshot(&self) -> RoutingStatsSnapshot {
1080 self.routing_stats.snapshot()
1081 }
1082
1083 pub fn fallback_history(&self, limit: usize) -> Vec<FallbackEvent> {
1085 self.routing_stats.fallback_history(limit)
1086 }
1087
1088 pub fn set_model(&self, model_id: &str) -> anyhow::Result<()> {
1095 {
1101 let engine = self.engine_handle.get();
1102 let model = engine
1103 .resolve_model(model_id)
1104 .with_context(|| format!("Unknown model '{model_id}'"))?;
1105 engine.create_provider(&model.provider).with_context(|| {
1106 format!(
1107 "Provider '{}' is not configured for '{model_id}'",
1108 model.provider
1109 )
1110 })?;
1111 }
1112 let snapshot = {
1113 let mut cfg = self.config.write();
1114 cfg.engine.default_model = model_id.to_string();
1115 cfg.clone()
1116 };
1117 self.persist(&snapshot)?;
1120 tracing::info!(model = %model_id, "Default model updated in config");
1121 self.rebuild_and_swap();
1122 Ok(())
1123 }
1124
1125 pub fn set_quick_ask_model(&self, model_id: Option<&str>) -> anyhow::Result<()> {
1132 let snapshot = {
1133 let mut cfg = self.config.write();
1134 cfg.engine.quick_ask_model = model_id.map(String::from);
1135 cfg.clone()
1136 };
1137 self.persist(&snapshot)?;
1138 tracing::info!(model = ?model_id, "QuickAsk model updated in config");
1139 Ok(())
1140 }
1141
1142 pub fn set_api_key(&self, provider: &str, key: &str) -> anyhow::Result<()> {
1148 CredentialStore::store(provider, key)?;
1149
1150 let snapshot = {
1156 let mut cfg = self.config.write();
1157 let matches = CredentialStore::provider_from_model(&cfg.engine.default_model)
1158 .is_some_and(|current_provider| current_provider == provider);
1159 if matches {
1160 cfg.engine.api_key = Some(key.to_string());
1161 Some(cfg.clone())
1162 } else {
1163 None
1164 }
1165 };
1166 if let Some(snap) = snapshot {
1167 self.persist(&snap)?;
1169 }
1170 tracing::info!(provider = %provider, "API key stored");
1171 self.rebuild_and_swap();
1172 Ok(())
1173 }
1174
1175 pub fn clear_api_key(&self, provider: &str) -> anyhow::Result<()> {
1182 let snapshot = {
1183 let mut cfg = self.config.write();
1184 let matches = CredentialStore::provider_from_model(&cfg.engine.default_model)
1185 .is_some_and(|current_provider| current_provider == provider);
1186 if matches {
1187 cfg.engine.api_key = None;
1188 Some(cfg.clone())
1189 } else {
1190 None
1191 }
1192 };
1193 if let Some(snap) = snapshot {
1194 self.persist(&snap)?;
1195 }
1196 tracing::info!(provider = %provider, "API key cleared from config");
1197 self.rebuild_and_swap();
1198 Ok(())
1199 }
1200
1201 pub fn delete_api_key(&self, provider: &str) -> anyhow::Result<()> {
1212 CredentialStore::delete(provider)?;
1213 let snapshot = {
1215 let mut cfg = self.config.write();
1216 let matches = CredentialStore::provider_from_model(&cfg.engine.default_model)
1217 .is_some_and(|current_provider| current_provider == provider);
1218 if matches {
1219 cfg.engine.api_key = None;
1220 Some(cfg.clone())
1221 } else {
1222 None
1223 }
1224 };
1225 if let Some(snap) = snapshot {
1226 self.persist(&snap)?;
1227 }
1228 tracing::info!(provider = %provider, "API key deleted from credential store");
1229 self.rebuild_and_swap();
1230 Ok(())
1231 }
1232
1233 pub fn set_provider_options(&self, opts: &oxi_sdk::ProviderOptions) -> anyhow::Result<()> {
1238 let snapshot = {
1239 let mut cfg = self.config.write();
1240 cfg.engine.provider_options = Some(opts.clone());
1241 cfg.clone()
1242 };
1243 self.persist(&snapshot)?;
1244 tracing::info!("Provider options updated and persisted");
1245 Ok(())
1248 }
1249
1250 pub fn set_routing(&self, update: RoutingUpdate) -> anyhow::Result<()> {
1255 let snapshot = {
1256 let mut cfg = self.config.write();
1257 if let Some(v) = update.routing_enabled {
1258 cfg.engine.routing_enabled = v;
1259 }
1260 if let Some(v) = update.prefer_cost_efficient {
1261 cfg.engine.prefer_cost_efficient = v;
1262 }
1263 if let Some(v) = update.fallback_models {
1264 cfg.engine.fallback_models = v;
1265 }
1266 if let Some(v) = update.excluded_models {
1267 cfg.engine.excluded_models = v;
1268 }
1269 cfg.clone()
1270 };
1271 self.persist(&snapshot)?;
1272 tracing::info!("Routing configuration updated via API");
1273 self.rebuild_and_swap();
1274 Ok(())
1275 }
1276
1277 pub async fn validate_key(&self, provider: &str, api_key: &str) -> ValidateKeyResult {
1282 match self.try_validate(provider, api_key).await {
1283 Ok(()) => ValidateKeyResult {
1284 valid: true,
1285 provider: provider.to_string(),
1286 message: Some("API key is valid".to_string()),
1287 },
1288 Err(e) => ValidateKeyResult {
1289 valid: false,
1290 provider: provider.to_string(),
1291 message: Some(format!("{e}")),
1292 },
1293 }
1294 }
1295
1296 pub async fn validate_stored_key(&self, provider: &str) -> ValidateKeyResult {
1302 let api_key_override = {
1303 let cfg = self.config.read();
1304 cfg.api_key().as_deref().map(str::to_owned)
1305 };
1306 match CredentialStore::resolve(provider, api_key_override.as_deref()) {
1307 Some((key, _)) => self.validate_key(provider, &key).await,
1308 None => ValidateKeyResult {
1309 valid: false,
1310 provider: provider.to_string(),
1311 message: Some("No API key found for this provider".to_string()),
1312 },
1313 }
1314 }
1315
1316 pub fn get_provider_config(&self, provider_id: &str) -> anyhow::Result<ProviderConfigResponse> {
1320 let ps = self
1321 .provider_configs
1322 .read()
1323 .get(provider_id)
1324 .cloned()
1325 .unwrap_or_default();
1326
1327 let models: Vec<String> = self.list_model_names(provider_id);
1328
1329 let provider = self.build_provider_info(provider_id);
1330 Ok(ProviderConfigResponse {
1331 provider,
1332 settings: ps,
1333 models,
1334 })
1335 }
1336
1337 pub fn set_provider_config(
1339 &self,
1340 provider_id: &str,
1341 settings: ProviderSettings,
1342 ) -> anyhow::Result<ProviderConfigResponse> {
1343 {
1345 let mut providers = self.provider_configs.write();
1346 providers.insert(provider_id.to_string(), settings.clone());
1347 self.save_provider_state()?;
1348 }
1349
1350 let engine = self.engine_handle.get();
1352 if let Some(routing) = engine.routing_control() {
1353 match settings.models.mode {
1354 ModelListMode::Denylist => {
1355 for denied in &settings.models.deny {
1356 routing.exclude_model(&format!("{provider_id}/{denied}"));
1357 }
1358 }
1359 ModelListMode::Allowlist => {
1360 let all_models = self.list_model_names(provider_id);
1361 for model in &all_models {
1362 if !settings.models.allow.contains(model) {
1363 routing.exclude_model(&format!("{provider_id}/{model}"));
1364 }
1365 }
1366 }
1367 ModelListMode::All => {}
1368 }
1369 }
1370
1371 self.get_provider_config(provider_id)
1372 }
1373
1374 pub fn check_provider_connection(
1378 &self,
1379 provider_id: &str,
1380 model_id: &str,
1381 ) -> anyhow::Result<ConnectionCheckResult> {
1382 let start = std::time::Instant::now();
1383 let engine = self.engine_handle.get();
1384 match engine.create_provider(provider_id) {
1385 Ok(_provider) => {
1386 let latency = start.elapsed().as_millis() as u64;
1387 Ok(ConnectionCheckResult {
1388 success: true,
1389 model: model_id.to_string(),
1390 latency_ms: latency,
1391 error: None,
1392 })
1393 }
1394 Err(e) => {
1395 let latency = start.elapsed().as_millis() as u64;
1396 Ok(ConnectionCheckResult {
1397 success: false,
1398 model: model_id.to_string(),
1399 latency_ms: latency,
1400 error: Some(e.to_string()),
1401 })
1402 }
1403 }
1404 }
1405
1406 pub fn set_model_list(
1408 &self,
1409 provider_id: &str,
1410 model_config: ModelListSettings,
1411 ) -> anyhow::Result<ProviderConfigResponse> {
1412 let mut settings = {
1413 let providers = self.provider_configs.read();
1414 providers.get(provider_id).cloned().unwrap_or_default()
1415 };
1416 settings.models = model_config;
1417 self.set_provider_config(provider_id, settings)
1418 }
1419
1420 pub fn add_custom_provider(&self, def: CustomProviderDef) -> anyhow::Result<ProviderInfo> {
1422 let provider_id = def.id.clone();
1423 {
1424 let mut custom = self.custom_providers.write();
1425 if custom.iter().any(|cp| cp.id == provider_id) {
1426 anyhow::bail!("Custom provider '{}' already exists", provider_id);
1427 }
1428 custom.push(def);
1429 self.save_provider_state()?;
1430 }
1431 self.rebuild_and_swap();
1433 Ok(self.build_provider_info(&provider_id))
1434 }
1435
1436 pub fn remove_custom_provider(&self, id: &str) -> anyhow::Result<()> {
1438 {
1439 let mut custom = self.custom_providers.write();
1440 let before = custom.len();
1441 custom.retain(|cp| cp.id != id);
1442 if custom.len() == before {
1443 anyhow::bail!("Custom provider '{}' not found", id);
1444 }
1445 self.save_provider_state()?;
1446 }
1447 self.rebuild_and_swap();
1448 Ok(())
1449 }
1450
1451 async fn try_validate(&self, provider: &str, api_key: &str) -> anyhow::Result<()> {
1456 if api_key.is_empty() {
1457 anyhow::bail!("API key is empty");
1458 }
1459
1460 let builder = oxi_sdk::OxiBuilder::new()
1461 .with_builtins()
1462 .api_key(provider, api_key);
1463 let oxi = builder.build();
1464
1465 let models = oxi_sdk::get_provider_models(provider);
1466 if models.is_empty() {
1467 anyhow::bail!("No models found for provider '{provider}'");
1468 }
1469
1470 let model_id = format!("{}/{}", provider, models[0].id);
1471 let model = oxi
1472 .resolve_model(&model_id)
1473 .with_context(|| format!("Unknown model '{model_id}'"))?;
1474 let provider_inst = oxi
1475 .create_provider(provider)
1476 .with_context(|| format!("Failed to create provider '{provider}'"))?;
1477
1478 let mut ctx = oxi_sdk::Context::new();
1479 ctx.add_message(oxi_sdk::Message::User(oxi_sdk::UserMessage::new("Hi")));
1480
1481 let stream_result = tokio::time::timeout(
1482 std::time::Duration::from_secs(15),
1483 provider_inst.stream(&model, &ctx, None),
1484 )
1485 .await
1486 .map_err(|_| anyhow::anyhow!("Request timed out (15s)"))?;
1487
1488 match stream_result {
1489 Ok(_) => {
1490 tracing::debug!(provider = %provider, model = %model_id, "Key validated via real API call");
1491 Ok(())
1492 }
1493 Err(e) => Err(anyhow::anyhow!("{e}")),
1494 }
1495 }
1496
1497 pub fn estimate_cost(model_id: &str, input_tokens: u64, output_tokens: u64) -> f64 {
1501 estimate_cost(model_id, input_tokens, output_tokens)
1502 }
1503
1504 fn persist(&self, config: &OxiosConfig) -> anyhow::Result<()> {
1506 let content = toml::to_string_pretty(config)
1507 .map_err(|e| anyhow::anyhow!("Failed to serialize config: {e}"))?;
1508 std::fs::write(&self.config_path, content)?;
1509 Ok(())
1510 }
1511
1512 fn rebuild_and_swap(&self) {
1519 let (model_id, api_key, catalog) = {
1523 let cfg = self.config.read();
1524 let catalog = self.engine_handle.get().oxi().catalog().clone();
1525 (cfg.engine.default_model.clone(), cfg.api_key(), catalog)
1526 };
1527 let new_engine = crate::engine::OxiosEngine::from_config_with_catalog(
1528 &model_id,
1529 api_key.as_deref(),
1530 catalog,
1531 );
1532 self.engine_handle.swap(new_engine);
1533 }
1534 fn provider_state_path(&self) -> PathBuf {
1537 let mut p = self.config_path.clone();
1538 p.set_extension("providers.toml");
1539 p
1540 }
1541
1542 fn read_provider_state(&self) -> anyhow::Result<ProviderStateFile> {
1544 let path = self.provider_state_path();
1545 if !path.exists() {
1546 return Ok(ProviderStateFile {
1547 providers: HashMap::new(),
1548 custom_providers: Vec::new(),
1549 });
1550 }
1551 let content = fs::read_to_string(&path)?;
1552 let state: ProviderStateFile = toml::from_str(&content)
1553 .map_err(|e| anyhow::anyhow!("Failed to parse provider state: {e}"))?;
1554 Ok(state)
1555 }
1556
1557 fn save_provider_state(&self) -> anyhow::Result<()> {
1559 let state = ProviderStateFile {
1560 providers: self.provider_configs.read().clone(),
1561 custom_providers: self.custom_providers.read().clone(),
1562 };
1563 let content = toml::to_string_pretty(&state)
1564 .map_err(|e| anyhow::anyhow!("Failed to serialize provider state: {e}"))?;
1565 fs::write(self.provider_state_path(), content)?;
1566 Ok(())
1567 }
1568
1569 fn build_provider_info(&self, provider_id: &str) -> ProviderInfo {
1571 let meta = provider_meta(provider_id);
1572 let resolved = CredentialStore::resolve(provider_id, None);
1573 let key_source = resolved
1574 .as_ref()
1575 .map(|(_, src)| match src {
1576 crate::credential::CredentialSource::EnvVar => "env",
1577 crate::credential::CredentialSource::Config
1578 | crate::credential::CredentialSource::OxiAuthStore => "auth_store",
1579 })
1580 .unwrap_or("none")
1581 .to_string();
1582 ProviderInfo {
1583 id: provider_id.to_string(),
1584 name: provider_display_name(provider_id),
1585 category: provider_category(provider_id),
1586 model_count: 0,
1587 has_key: resolved.is_some(),
1588 key_source,
1589 description: meta.map(|m| m.description.to_string()).unwrap_or_default(),
1590 env_key: meta.map(|m| m.env_key.to_string()).unwrap_or_default(),
1591 }
1592 }
1593
1594 fn list_model_names(&self, provider_id: &str) -> Vec<String> {
1597 let catalog = self.engine_handle.get().oxi().catalog().clone();
1598 let live = catalog.list_models_sync(provider_id);
1599 if !live.is_empty() {
1600 live.iter().map(|m| m.model_id.clone()).collect()
1601 } else {
1602 oxi_sdk::get_provider_models(provider_id)
1603 .iter()
1604 .map(|m| m.id.to_string())
1605 .collect()
1606 }
1607 }
1608}
1609
1610impl std::fmt::Debug for EngineApi {
1611 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1612 f.debug_struct("EngineApi")
1613 .field("config_path", &self.config_path)
1614 .field("provider_configs", &self.provider_configs.read().len())
1615 .field("custom_providers", &self.custom_providers.read().len())
1616 .finish()
1617 }
1618}
1619
1620pub fn record_usage_to_stats(
1623 stats: &Option<Arc<RoutingStats>>,
1624 model_id: &str,
1625 input_tokens: u64,
1626 output_tokens: u64,
1627) {
1628 if let Some(s) = stats {
1629 let cost = estimate_cost(model_id, input_tokens, output_tokens);
1630 s.record_model_usage(model_id, cost);
1631 }
1632}
1633
1634#[cfg(test)]
1635mod tests {
1636 use super::*;
1637
1638 #[test]
1639 fn test_provider_category_known() {
1640 assert_eq!(provider_category("anthropic"), ProviderCategory::Major);
1642 assert_eq!(provider_category("openai"), ProviderCategory::Major);
1643 assert_eq!(provider_category("google"), ProviderCategory::Major);
1644 assert_eq!(provider_category("groq"), ProviderCategory::Open);
1646 assert_eq!(provider_category("opencode"), ProviderCategory::Open);
1647 assert_eq!(provider_category("minimax"), ProviderCategory::Regional);
1649 assert_eq!(provider_category("moonshotai"), ProviderCategory::Regional);
1650 assert_eq!(provider_category("kimi-coding"), ProviderCategory::Regional);
1651 assert_eq!(provider_category("zai"), ProviderCategory::Regional);
1652 assert_eq!(provider_category("minimax-cn"), ProviderCategory::Regional);
1653 assert_eq!(provider_category("xiaomi"), ProviderCategory::Regional);
1654 }
1655
1656 #[test]
1657 fn test_provider_category_fallback() {
1658 assert_eq!(
1660 provider_category("not-a-real-provider"),
1661 ProviderCategory::Open
1662 );
1663 assert_eq!(provider_category(""), ProviderCategory::Open);
1664 }
1665
1666 #[test]
1667 fn test_provider_display_name_known() {
1668 assert_eq!(provider_display_name("anthropic"), "Anthropic");
1669 assert_eq!(provider_display_name("minimax"), "MiniMax");
1670 assert_eq!(provider_display_name("moonshotai"), "Moonshot AI (Kimi)");
1671 assert_eq!(provider_display_name("kimi-coding"), "Kimi Coding");
1672 assert_eq!(provider_display_name("zai"), "Z.AI (GLM)");
1673 assert_eq!(provider_display_name("opencode"), "OpenCode");
1674 assert_eq!(provider_display_name("amazon-bedrock"), "Amazon Bedrock");
1675 }
1676
1677 #[test]
1678 fn test_provider_display_name_fallback() {
1679 assert_eq!(
1681 provider_display_name("some-new-provider"),
1682 "Some New Provider"
1683 );
1684 assert_eq!(provider_display_name("kimi-coding"), "Kimi Coding");
1685 assert_eq!(provider_display_name("some_id"), "Some Id");
1686 assert_eq!(provider_display_name(""), "");
1688 }
1689
1690 #[test]
1691 fn test_provider_meta_lookup_by_alias() {
1692 let by_id = provider_meta("github-copilot").unwrap();
1694 let by_alias = provider_meta("copilot").unwrap();
1695 assert_eq!(by_id.id, by_alias.id);
1696
1697 let bedrock_id = provider_meta("amazon-bedrock").unwrap();
1698 let bedrock_alias = provider_meta("aws-bedrock").unwrap();
1699 let bedrock_canonical = provider_meta("bedrock").unwrap();
1700 assert_eq!(bedrock_id.id, bedrock_alias.id);
1701 assert_eq!(bedrock_id.id, bedrock_canonical.id);
1702 }
1703
1704 #[test]
1705 fn test_provider_meta_unknown_is_none() {
1706 assert!(provider_meta("not-a-real-provider").is_none());
1707 assert!(provider_meta("").is_none());
1708 }
1709
1710 #[test]
1711 fn test_provider_info_serialization() {
1712 let info = ProviderInfo {
1713 id: "anthropic".to_string(),
1714 name: "Anthropic".to_string(),
1715 category: ProviderCategory::Major,
1716 model_count: 15,
1717 has_key: true,
1718 key_source: "auth_store".to_string(),
1719 description: "Claude models with extended thinking".to_string(),
1720 env_key: "ANTHROPIC_API_KEY".to_string(),
1721 };
1722 let json = serde_json::to_string(&info).unwrap();
1723 assert!(json.contains("\"modelCount\":15"));
1725 assert!(json.contains("\"hasKey\":true"));
1726 assert!(json.contains("\"envKey\":\"ANTHROPIC_API_KEY\""));
1727 let restored: ProviderInfo = serde_json::from_str(&json).unwrap();
1728 assert_eq!(restored.id, "anthropic");
1729 assert_eq!(restored.name, "Anthropic");
1730 assert_eq!(restored.model_count, 15);
1731 assert!(restored.has_key);
1732 assert_eq!(restored.env_key, "ANTHROPIC_API_KEY");
1733 }
1734
1735 #[test]
1736 fn test_provider_info_serialization_missing_optional() {
1737 let json = r#"{
1740 "id": "anthropic",
1741 "name": "Anthropic",
1742 "category": "major",
1743 "modelCount": 15,
1744 "hasKey": true
1745 }"#;
1746 let info: ProviderInfo = serde_json::from_str(json).unwrap();
1747 assert_eq!(info.id, "anthropic");
1748 assert_eq!(info.description, "");
1749 assert_eq!(info.env_key, "");
1750 }
1751
1752 #[test]
1753 fn test_model_info_serialization() {
1754 let info = ModelInfo {
1755 id: "anthropic/claude-sonnet-4".to_string(),
1756 name: "Claude Sonnet 4".to_string(),
1757 api: "anthropic-messages".to_string(),
1758 provider: "anthropic".to_string(),
1759 reasoning: true,
1760 input: vec![InputModality::Text, InputModality::Image],
1761 context_window: 200000,
1762 max_tokens: 16000,
1763 cost_input: 3.0,
1764 cost_output: 15.0,
1765 cost_cache_read: 0.3,
1766 cost_cache_write: 3.75,
1767 };
1768 let json = serde_json::to_string(&info).unwrap();
1769 let restored: ModelInfo = serde_json::from_str(&json).unwrap();
1770 assert_eq!(restored.id, "anthropic/claude-sonnet-4");
1771 assert!(restored.reasoning);
1772 assert_eq!(restored.context_window, 200000);
1773 assert!(restored.input.contains(&InputModality::Image));
1774 assert_eq!(restored.api, "anthropic-messages");
1775 }
1776
1777 #[test]
1778 fn test_engine_config_response_serialization() {
1779 let resp = EngineConfigResponse {
1780 default_model: "anthropic/claude-sonnet-4".to_string(),
1781 api_key_set: true,
1782 api_key_source: Some("config.toml".to_string()),
1783 provider: Some("anthropic".to_string()),
1784 routing: RoutingConfigSnapshot {
1785 routing_enabled: false,
1786 prefer_cost_efficient: false,
1787 fallback_models: vec![],
1788 excluded_models: vec![],
1789 },
1790 role_routing: None,
1791 quick_ask_model: None,
1792 };
1793 let json = serde_json::to_string(&resp).unwrap();
1794 let restored: EngineConfigResponse = serde_json::from_str(&json).unwrap();
1795 assert_eq!(restored.default_model, "anthropic/claude-sonnet-4");
1796 assert!(restored.api_key_set);
1797 assert_eq!(restored.api_key_source.as_deref(), Some("config.toml"));
1798 assert!(!restored.routing.routing_enabled);
1799 }
1800
1801 #[test]
1802 fn test_validate_key_result_serialization() {
1803 let result = ValidateKeyResult {
1804 valid: true,
1805 provider: "openai".to_string(),
1806 message: Some("API key is valid".to_string()),
1807 };
1808 let json = serde_json::to_string(&result).unwrap();
1809 let restored: ValidateKeyResult = serde_json::from_str(&json).unwrap();
1810 assert!(restored.valid);
1811 assert_eq!(restored.provider, "openai");
1812 }
1813
1814 #[test]
1815 fn test_validate_key_result_invalid() {
1816 let result = ValidateKeyResult {
1817 valid: false,
1818 provider: "anthropic".to_string(),
1819 message: Some("Validation failed: key too short".to_string()),
1820 };
1821 assert!(!result.valid);
1822 assert!(result.message.as_ref().unwrap().contains("failed"));
1823 }
1824
1825 #[test]
1826 fn test_routing_stats_snapshot() {
1827 let stats = RoutingStats::new();
1828 stats.record_model_usage("anthropic/claude-sonnet-4", 0.05);
1829 stats.record_model_usage("anthropic/claude-sonnet-4", 0.03);
1830 stats.record_model_usage("openai/gpt-4o-mini", 0.01);
1831
1832 let snap = stats.snapshot();
1833 assert_eq!(snap.total_requests, 3);
1834 assert_eq!(snap.model_calls["anthropic/claude-sonnet-4"], 2);
1835 assert_eq!(snap.model_calls["openai/gpt-4o-mini"], 1);
1836 assert!((snap.total_cost - 0.09).abs() < 0.001);
1837 }
1838
1839 #[test]
1840 fn test_fallback_history_circular() {
1841 let stats = RoutingStats::new();
1842 for i in 0..210 {
1843 stats.record_fallback(FallbackEvent {
1844 timestamp: DateTime::from_timestamp(i as i64, 0).unwrap(),
1845 from_model: format!("model-{}", i),
1846 to_model: "fallback".to_string(),
1847 reason: "test".to_string(),
1848 success: true,
1849 });
1850 }
1851 let history = stats.fallback_history(200);
1852 assert_eq!(history.len(), 200);
1853 assert_eq!(history[0].from_model, "model-209");
1855 assert_eq!(history[199].from_model, "model-10");
1856 }
1857
1858 #[test]
1859 fn set_model_rejects_unknown_model_before_persist() {
1860 use crate::engine::{EngineHandle, OxiosEngine};
1861
1862 let engine = Arc::new(OxiosEngine::new("anthropic/claude-sonnet-4-20250514"));
1863 let handle = Arc::new(EngineHandle::new(engine));
1864 let config = Arc::new(parking_lot::RwLock::new(OxiosConfig::default()));
1865 let path = PathBuf::from("/tmp/oxios-set-model-test-NONEXISTENT.toml");
1868 let api = EngineApi::new(config, path, Arc::new(RoutingStats::new()), handle);
1869
1870 let before = api.config.read().engine.default_model.clone();
1873 let err = api.set_model("zai-coding-plan/glm-5-turbo").unwrap_err();
1874 assert!(
1875 err.to_string().contains("Unknown model"),
1876 "expected unknown-model error, got: {err}"
1877 );
1878 assert_eq!(api.config.read().engine.default_model, before);
1880 }
1881
1882 #[test]
1883 fn set_model_accepts_known_builtin_model() {
1884 use crate::engine::{EngineHandle, OxiosEngine};
1885
1886 let engine = Arc::new(OxiosEngine::new("anthropic/claude-sonnet-4-20250514"));
1887 let handle = Arc::new(EngineHandle::new(engine));
1888 let config = Arc::new(parking_lot::RwLock::new(OxiosConfig::default()));
1889 let tmp =
1890 std::env::temp_dir().join(format!("oxios-set-model-ok-{}.toml", std::process::id()));
1891 let api = EngineApi::new(config, tmp.clone(), Arc::new(RoutingStats::new()), handle);
1892
1893 let result = api.set_model("openai/gpt-4o");
1896 match result {
1900 Ok(()) => assert_eq!(api.config.read().engine.default_model, "openai/gpt-4o"),
1901 Err(e) => assert!(
1902 !e.to_string().contains("Unknown model"),
1903 "known model rejected as unknown: {e}"
1904 ),
1905 }
1906 let _ = std::fs::remove_file(&tmp);
1907 }
1908}