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::path::PathBuf;
17use std::sync::Arc;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct RoutingConfigSnapshot {
25 pub routing_enabled: bool,
27 pub prefer_cost_efficient: bool,
29 pub fallback_models: Vec<String>,
31 pub excluded_models: Vec<String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct RoutingStatsSnapshot {
39 pub model_calls: HashMap<String, u64>,
41 pub model_cost: HashMap<String, f64>,
43 pub total_requests: u64,
45 pub total_cost: f64,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct FallbackEvent {
53 pub timestamp: DateTime<Utc>,
55 pub from_model: String,
57 pub to_model: String,
59 pub reason: String,
61 pub success: bool,
63}
64
65#[derive(Debug, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct RoutingUpdate {
69 pub routing_enabled: Option<bool>,
70 pub prefer_cost_efficient: Option<bool>,
71 pub fallback_models: Option<Vec<String>>,
72 pub excluded_models: Option<Vec<String>>,
73}
74
75pub struct RoutingStats {
80 calls: RwLock<HashMap<String, u64>>,
81 costs: RwLock<HashMap<String, f64>>,
82 fallbacks: RwLock<std::collections::VecDeque<FallbackEvent>>,
84}
85
86impl Default for RoutingStats {
87 fn default() -> Self {
88 Self {
89 calls: RwLock::new(HashMap::new()),
90 costs: RwLock::new(HashMap::new()),
91 fallbacks: RwLock::new(std::collections::VecDeque::new()),
92 }
93 }
94}
95
96impl RoutingStats {
97 pub fn new() -> Self {
99 Self::default()
100 }
101
102 pub fn record_model_usage(&self, model_id: &str, cost_usd: f64) {
104 let mut calls = self.calls.write();
105 *calls.entry(model_id.to_string()).or_insert(0) += 1;
106 if cost_usd > 0.0 {
107 let mut costs = self.costs.write();
108 *costs.entry(model_id.to_string()).or_insert(0.0) += cost_usd;
109 }
110 }
111
112 pub fn record_fallback(&self, event: FallbackEvent) {
117 let mut fb = self.fallbacks.write();
118 fb.push_back(event);
119 while fb.len() > 200 {
120 fb.pop_front();
121 }
122 }
123
124 pub fn snapshot(&self) -> RoutingStatsSnapshot {
126 let calls = self.calls.read();
127 let costs = self.costs.read();
128 let total_requests: u64 = calls.values().sum();
129 let total_cost: f64 = costs.values().sum();
130 RoutingStatsSnapshot {
131 model_calls: calls.clone(),
132 model_cost: costs.clone(),
133 total_requests,
134 total_cost,
135 }
136 }
137
138 pub fn fallback_history(&self, limit: usize) -> Vec<FallbackEvent> {
140 let fb = self.fallbacks.read();
141 fb.iter().rev().take(limit).cloned().collect()
142 }
143}
144
145pub fn estimate_cost(model_id: &str, input_tokens: u64, output_tokens: u64) -> f64 {
150 let entries = oxi_sdk::get_provider_models(model_id.split('/').next().unwrap_or(model_id));
151 let entry = entries
152 .iter()
153 .find(|e| format!("{}/{}", e.provider, e.id) == model_id);
154 match entry {
155 Some(e) => {
156 (e.cost_input * input_tokens as f64 / 1_000_000.0)
157 + (e.cost_output * output_tokens as f64 / 1_000_000.0)
158 }
159 None => {
160 (0.003 * input_tokens as f64 / 1_000_000.0)
162 + (0.015 * output_tokens as f64 / 1_000_000.0)
163 }
164 }
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "lowercase")]
172pub enum ProviderCategory {
173 Major,
175 Open,
177 Regional,
179 Local,
181}
182
183#[derive(Debug, Clone, Copy)]
196struct ProviderMeta {
197 id: &'static str,
199 display_name: &'static str,
201 category: ProviderCategory,
203 hidden: bool,
207 description: &'static str,
209 env_key: &'static str,
213 aliases: &'static [&'static str],
217}
218
219const PROVIDER_META: &[ProviderMeta] = &[
225 ProviderMeta {
227 id: "anthropic",
228 display_name: "Anthropic",
229 category: ProviderCategory::Major,
230 hidden: false,
231 description: "Claude models with extended thinking",
232 env_key: "ANTHROPIC_API_KEY",
233 aliases: &["anthropic"],
234 },
235 ProviderMeta {
236 id: "openai",
237 display_name: "OpenAI",
238 category: ProviderCategory::Major,
239 hidden: false,
240 description: "GPT, o-series, and Codex models",
241 env_key: "OPENAI_API_KEY",
242 aliases: &["openai"],
243 },
244 ProviderMeta {
245 id: "google",
246 display_name: "Google Gemini",
247 category: ProviderCategory::Major,
248 hidden: false,
249 description: "Gemini models with thinking and tool use",
250 env_key: "GOOGLE_API_KEY",
251 aliases: &["google"],
252 },
253 ProviderMeta {
255 id: "groq",
256 display_name: "Groq",
257 category: ProviderCategory::Open,
258 hidden: false,
259 description: "Fast Llama, Mixtral, and Gemma inference",
260 env_key: "GROQ_API_KEY",
261 aliases: &["groq"],
262 },
263 ProviderMeta {
264 id: "openrouter",
265 display_name: "OpenRouter",
266 category: ProviderCategory::Open,
267 hidden: false,
268 description: "Unified gateway to 200+ models",
269 env_key: "OPENROUTER_API_KEY",
270 aliases: &["openrouter"],
271 },
272 ProviderMeta {
273 id: "deepseek",
274 display_name: "DeepSeek",
275 category: ProviderCategory::Open,
276 hidden: false,
277 description: "DeepSeek-V3 and DeepSeek-R1",
278 env_key: "DEEPSEEK_API_KEY",
279 aliases: &["deepseek"],
280 },
281 ProviderMeta {
282 id: "mistral",
283 display_name: "Mistral",
284 category: ProviderCategory::Open,
285 hidden: false,
286 description: "Mistral and Codestral models",
287 env_key: "MISTRAL_API_KEY",
288 aliases: &["mistral"],
289 },
290 ProviderMeta {
291 id: "xai",
292 display_name: "xAI (Grok)",
293 category: ProviderCategory::Open,
294 hidden: false,
295 description: "Grok models from xAI",
296 env_key: "XAI_API_KEY",
297 aliases: &["xai", "grok"],
298 },
299 ProviderMeta {
300 id: "cerebras",
301 display_name: "Cerebras",
302 category: ProviderCategory::Open,
303 hidden: false,
304 description: "Ultra-fast open model inference",
305 env_key: "CEREBRAS_API_KEY",
306 aliases: &["cerebras"],
307 },
308 ProviderMeta {
309 id: "fireworks",
310 display_name: "Fireworks",
311 category: ProviderCategory::Open,
312 hidden: false,
313 description: "Fast open-source model serving",
314 env_key: "FIREWORKS_API_KEY",
315 aliases: &["fireworks"],
316 },
317 ProviderMeta {
318 id: "github-copilot",
319 display_name: "GitHub Copilot",
320 category: ProviderCategory::Open,
321 hidden: false,
322 description: "GitHub Copilot models (GPT-4, Claude)",
323 env_key: "GITHUB_COPILOT_TOKEN",
324 aliases: &["github-copilot", "copilot"],
325 },
326 ProviderMeta {
327 id: "huggingface",
328 display_name: "Hugging Face",
329 category: ProviderCategory::Open,
330 hidden: false,
331 description: "Open model inference hub",
332 env_key: "HUGGINGFACE_API_KEY",
333 aliases: &["huggingface", "hf"],
334 },
335 ProviderMeta {
336 id: "together",
337 display_name: "Together AI",
338 category: ProviderCategory::Open,
339 hidden: false,
340 description: "Open-source model hosting (Llama, Mixtral, ...)",
341 env_key: "TOGETHER_API_KEY",
342 aliases: &["together", "togetherai"],
343 },
344 ProviderMeta {
345 id: "opencode",
346 display_name: "OpenCode",
347 category: ProviderCategory::Open,
348 hidden: false,
349 description: "OpenCode coding agent gateway",
350 env_key: "",
351 aliases: &["opencode"],
352 },
353 ProviderMeta {
354 id: "perplexity",
355 display_name: "Perplexity",
356 category: ProviderCategory::Open,
357 hidden: false,
358 description: "Search-augmented answer models",
359 env_key: "PERPLEXITY_API_KEY",
360 aliases: &["perplexity"],
361 },
362 ProviderMeta {
363 id: "cohere",
364 display_name: "Cohere",
365 category: ProviderCategory::Open,
366 hidden: false,
367 description: "Cohere Command and Embed models",
368 env_key: "COHERE_API_KEY",
369 aliases: &["cohere"],
370 },
371 ProviderMeta {
373 id: "minimax",
374 display_name: "MiniMax",
375 category: ProviderCategory::Regional,
376 hidden: false,
377 description: "MiniMax-M2.7, abab models",
378 env_key: "MINIMAX_API_KEY",
379 aliases: &["minimax"],
380 },
381 ProviderMeta {
382 id: "moonshotai",
383 display_name: "Moonshot AI (Kimi)",
384 category: ProviderCategory::Regional,
385 hidden: false,
386 description: "Kimi models from Moonshot AI",
387 env_key: "MOONSHOT_API_KEY",
388 aliases: &["moonshotai", "moonshot", "kimi"],
389 },
390 ProviderMeta {
391 id: "kimi-coding",
392 display_name: "Kimi Coding",
393 category: ProviderCategory::Regional,
394 hidden: false,
395 description: "Kimi Coding Plan — optimized for coding",
396 env_key: "KIMI_CODING_API_KEY",
397 aliases: &["kimi-coding"],
398 },
399 ProviderMeta {
400 id: "zai",
401 display_name: "Z.AI (GLM)",
402 category: ProviderCategory::Regional,
403 hidden: false,
404 description: "Z.AI GLM models (coding plan)",
405 env_key: "ZAI_API_KEY",
406 aliases: &["zai"],
407 },
408 ProviderMeta {
414 id: "amazon-bedrock",
415 display_name: "Amazon Bedrock",
416 category: ProviderCategory::Open,
417 hidden: true,
418 description: "Multi-model via AWS Bedrock ConverseStream",
419 env_key: "AWS_ACCESS_KEY_ID",
420 aliases: &["amazon-bedrock", "aws-bedrock", "bedrock"],
421 },
422 ProviderMeta {
423 id: "azure-openai-responses",
424 display_name: "Azure OpenAI (Responses)",
425 category: ProviderCategory::Open,
426 hidden: true,
427 description: "OpenAI models via Azure Cognitive Services",
428 env_key: "AZURE_OPENAI_API_KEY",
429 aliases: &["azure-openai-responses", "azure"],
430 },
431 ProviderMeta {
432 id: "cloudflare-ai-gateway",
433 display_name: "Cloudflare AI Gateway",
434 category: ProviderCategory::Open,
435 hidden: true,
436 description: "Serverless AI via Cloudflare AI Gateway",
437 env_key: "CLOUDFLARE_API_TOKEN",
438 aliases: &["cloudflare-ai-gateway", "cf-ai-gateway"],
439 },
440 ProviderMeta {
441 id: "cloudflare-workers-ai",
442 display_name: "Cloudflare Workers AI",
443 category: ProviderCategory::Open,
444 hidden: true,
445 description: "Serverless AI via Cloudflare Workers",
446 env_key: "CLOUDFLARE_API_KEY",
447 aliases: &["cloudflare-workers-ai", "cloudflare", "workers-ai"],
448 },
449 ProviderMeta {
450 id: "google-vertex",
451 display_name: "Google Vertex AI",
452 category: ProviderCategory::Open,
453 hidden: true,
454 description: "Gemini via Google Cloud Vertex AI",
455 env_key: "GOOGLE_APPLICATION_CREDENTIALS",
456 aliases: &["google-vertex", "vertex"],
457 },
458 ProviderMeta {
459 id: "minimax-cn",
460 display_name: "MiniMax (China)",
461 category: ProviderCategory::Regional,
462 hidden: true,
463 description: "MiniMax China region endpoint",
464 env_key: "MINIMAX_CN_API_KEY",
465 aliases: &["minimax-cn"],
466 },
467 ProviderMeta {
468 id: "moonshotai-cn",
469 display_name: "Moonshot AI (China)",
470 category: ProviderCategory::Regional,
471 hidden: true,
472 description: "Kimi models — China region endpoint",
473 env_key: "MOONSHOT_CN_API_KEY",
474 aliases: &["moonshotai-cn", "moonshot-cn"],
475 },
476 ProviderMeta {
477 id: "openai-codex",
478 display_name: "OpenAI Codex",
479 category: ProviderCategory::Open,
480 hidden: true,
481 description: "OpenAI Codex coding agent (Responses API)",
482 env_key: "OPENAI_API_KEY",
483 aliases: &["openai-codex"],
484 },
485 ProviderMeta {
486 id: "opencode-go",
487 display_name: "OpenCode Go",
488 category: ProviderCategory::Open,
489 hidden: true,
490 description: "OpenCode Go Gateway",
491 env_key: "OPENCODE_GO_API_KEY",
492 aliases: &["opencode-go"],
493 },
494 ProviderMeta {
495 id: "vercel-ai-gateway",
496 display_name: "Vercel AI Gateway",
497 category: ProviderCategory::Open,
498 hidden: true,
499 description: "Vercel AI Gateway",
500 env_key: "VERCEL_API_KEY",
501 aliases: &["vercel-ai-gateway", "vercel"],
502 },
503 ProviderMeta {
504 id: "xiaomi",
505 display_name: "Xiaomi MiMo",
506 category: ProviderCategory::Regional,
507 hidden: true,
508 description: "Xiaomi MiMo models",
509 env_key: "XIAOMI_API_KEY",
510 aliases: &["xiaomi"],
511 },
512];
513
514fn provider_meta(id: &str) -> Option<&'static ProviderMeta> {
516 PROVIDER_META
517 .iter()
518 .find(|m| m.id == id || m.aliases.contains(&id))
519}
520
521fn provider_category(id: &str) -> ProviderCategory {
522 provider_meta(id)
523 .map(|m| m.category)
524 .unwrap_or(ProviderCategory::Open)
525}
526
527fn provider_display_name(id: &str) -> String {
533 provider_meta(id)
534 .map(|m| m.display_name.to_string())
535 .unwrap_or_else(|| fallback_display_name(id))
536}
537
538fn fallback_display_name(id: &str) -> String {
544 id.split(['-', '_'])
545 .filter(|s| !s.is_empty())
546 .map(|segment| {
547 let mut chars = segment.chars();
548 match chars.next() {
549 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
550 None => String::new(),
551 }
552 })
553 .collect::<Vec<_>>()
554 .join(" ")
555}
556
557#[derive(Debug, Clone, Serialize, Deserialize)]
559#[serde(rename_all = "camelCase")]
560pub struct ProviderInfo {
561 pub id: String,
563 pub name: String,
565 pub category: ProviderCategory,
567 pub model_count: usize,
569 pub has_key: bool,
571 #[serde(default)]
575 pub key_source: String,
576 #[serde(default)]
579 pub description: String,
580 #[serde(default)]
584 pub env_key: String,
585}
586
587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
589#[serde(rename_all = "lowercase")]
590pub enum InputModality {
591 Text,
593 Image,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize)]
599#[serde(rename_all = "camelCase")]
600pub struct ModelInfo {
601 pub id: String,
603 pub name: String,
605 pub api: String,
607 pub provider: String,
609 pub reasoning: bool,
611 pub input: Vec<InputModality>,
613 pub context_window: u32,
615 pub max_tokens: u32,
617 pub cost_input: f64,
619 pub cost_output: f64,
621 pub cost_cache_read: f64,
623 pub cost_cache_write: f64,
625}
626
627impl From<&oxi_sdk::ModelEntry> for ModelInfo {
628 fn from(entry: &oxi_sdk::ModelEntry) -> Self {
629 Self {
630 id: format!("{}/{}", entry.provider, entry.id),
631 name: entry.name.to_string(),
632 api: entry.api.to_string(),
633 provider: entry.provider.to_string(),
634 reasoning: entry.reasoning,
635 input: entry
636 .input
637 .iter()
638 .map(|m| match m {
639 oxi_sdk::InputModality::Text => InputModality::Text,
640 oxi_sdk::InputModality::Image => InputModality::Image,
641 _ => InputModality::Text,
642 })
643 .collect(),
644 context_window: entry.context_window,
645 max_tokens: entry.max_tokens,
646 cost_input: entry.cost_input,
647 cost_output: entry.cost_output,
648 cost_cache_read: entry.cost_cache_read,
649 cost_cache_write: entry.cost_cache_write,
650 }
651 }
652}
653
654impl From<&oxi_sdk::CatalogModelEntry> for ModelInfo {
655 fn from(entry: &oxi_sdk::CatalogModelEntry) -> Self {
661 Self {
662 id: format!("{}/{}", entry.provider, entry.model_id),
663 name: entry.name.clone(),
664 api: entry.protocol.as_str().to_string(),
665 provider: entry.provider.clone(),
666 reasoning: entry.reasoning,
667 input: entry
668 .input_modalities
669 .iter()
670 .map(|m| match m.as_str() {
671 "image" => InputModality::Image,
672 _ => InputModality::Text,
673 })
674 .collect(),
675 context_window: entry.context_window,
676 max_tokens: entry.max_tokens,
677 cost_input: entry.cost_input,
678 cost_output: entry.cost_output,
679 cost_cache_read: entry.cost_cache_read,
680 cost_cache_write: entry.cost_cache_write,
681 }
682 }
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize)]
687pub struct EngineConfigResponse {
688 pub default_model: String,
690 pub api_key_set: bool,
692 pub api_key_source: Option<String>,
694 pub provider: Option<String>,
696 pub routing: RoutingConfigSnapshot,
698 #[serde(default, skip_serializing_if = "Option::is_none")]
700 pub role_routing: Option<crate::config::RoleRoutingConfig>,
701 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub quick_ask_model: Option<String>,
705}
706
707#[derive(Debug, Clone, Serialize, Deserialize)]
709pub struct ValidateKeyResult {
710 pub valid: bool,
712 pub provider: String,
714 pub message: Option<String>,
716}
717
718pub struct EngineApi {
730 config: Arc<RwLock<OxiosConfig>>,
731 config_path: PathBuf,
732 routing_stats: Arc<RoutingStats>,
733 engine_handle: Arc<crate::engine::EngineHandle>,
735}
736
737impl EngineApi {
738 pub fn new(
745 config: Arc<RwLock<OxiosConfig>>,
746 config_path: PathBuf,
747 routing_stats: Arc<RoutingStats>,
748 engine_handle: Arc<crate::engine::EngineHandle>,
749 ) -> Self {
750 Self {
751 config,
752 config_path,
753 routing_stats,
754 engine_handle,
755 }
756 }
757
758 pub fn routing_stats(&self) -> Arc<RoutingStats> {
760 Arc::clone(&self.routing_stats)
761 }
762
763 pub fn engine_handle(&self) -> &Arc<crate::engine::EngineHandle> {
765 &self.engine_handle
766 }
767
768 pub fn validate_model(&self, model_id: &str) -> Result<(), String> {
775 self.engine_handle
776 .get()
777 .resolve_model(model_id)
778 .map(|_| ())
779 .map_err(|e| format!("Unknown model '{model_id}': {e}"))
780 }
781 pub fn role_routing(&self) -> crate::config::RoleRoutingConfig {
783 self.config.read().engine.role_routing.clone()
784 }
785
786 pub fn model_for_role(&self, role: &str) -> Option<String> {
791 self.config
792 .read()
793 .engine
794 .role_routing
795 .roles
796 .get(role)
797 .cloned()
798 }
799
800 pub fn set_role_routing(
802 &self,
803 role_routing: crate::config::RoleRoutingConfig,
804 ) -> anyhow::Result<()> {
805 let snapshot = {
806 let mut cfg = self.config.write();
807 cfg.engine.role_routing = role_routing;
808 cfg.clone()
809 };
810 self.persist(&snapshot)?;
811 tracing::info!("Role routing updated");
812 Ok(())
813 }
814
815 pub fn providers(&self) -> Vec<ProviderInfo> {
831 let catalog = self.engine_handle.get().oxi().catalog().clone();
832 let use_catalog = catalog.model_count_sync() > 0;
833 let all: Vec<String> = if use_catalog {
834 catalog.list_providers_sync()
835 } else {
836 oxi_sdk::get_providers()
837 .into_iter()
838 .map(|s| s.to_string())
839 .collect()
840 };
841
842 let api_key_override = {
846 let cfg = self.config.read();
847 cfg.engine
848 .api_key
849 .as_deref()
850 .filter(|k| !k.is_empty())
851 .map(str::to_owned)
852 };
853 all.into_iter()
854 .filter(|p| provider_meta(p).map(|m| !m.hidden).unwrap_or(true))
855 .map(|p| {
856 let model_count = if use_catalog {
857 catalog.list_models_sync(&p).len()
858 } else {
859 oxi_sdk::get_provider_models(&p).len()
860 };
861 let resolved = CredentialStore::resolve(&p, api_key_override.as_deref());
862 let has_key = resolved.is_some();
863 let key_source = resolved
864 .map(|(_, src)| match src {
865 crate::credential::CredentialSource::EnvVar => "env",
866 crate::credential::CredentialSource::Config => "config",
867 crate::credential::CredentialSource::OxiAuthStore => "auth_store",
868 })
869 .unwrap_or("none")
870 .to_string();
871 let meta = provider_meta(&p);
872 ProviderInfo {
873 id: p.clone(),
874 name: provider_display_name(&p),
875 category: provider_category(&p),
876 model_count,
877 has_key,
878 key_source,
879 description: meta.map(|m| m.description.to_string()).unwrap_or_default(),
880 env_key: meta.map(|m| m.env_key.to_string()).unwrap_or_default(),
881 }
882 })
883 .collect()
884 }
885
886 pub fn models(&self, provider: &str, query: Option<&str>) -> Vec<ModelInfo> {
892 let catalog = self.engine_handle.get().oxi().catalog().clone();
893 let live = catalog.list_models_sync(provider);
894 let models: Vec<ModelInfo> = if !live.is_empty() {
895 live.iter().map(ModelInfo::from).collect()
896 } else {
897 oxi_sdk::get_provider_models(provider)
898 .iter()
899 .map(ModelInfo::from)
900 .collect()
901 };
902 models
903 .into_iter()
904 .filter(|m| !m.name.contains("latest"))
905 .filter(|m| {
906 if let Some(q) = query {
907 let q = q.to_lowercase();
908 m.name.to_lowercase().contains(&q)
909 || m.id.to_lowercase().contains(&q)
910 || m.provider.to_lowercase().contains(&q)
911 } else {
912 true
913 }
914 })
915 .collect()
916 }
917
918 pub fn search_models(&self, query: &str) -> Vec<ModelInfo> {
923 let catalog = self.engine_handle.get().oxi().catalog().clone();
924 let live = catalog.search_sync(query);
925 if !live.is_empty() {
926 live.iter().map(ModelInfo::from).collect()
927 } else {
928 oxi_sdk::search_models(query)
929 .into_iter()
930 .map(ModelInfo::from)
931 .collect()
932 }
933 }
934
935 pub fn config(&self) -> EngineConfigResponse {
937 let cfg = self.config.read();
938 let provider =
939 CredentialStore::provider_from_model(&cfg.engine.default_model).map(|s| s.to_string());
940 let api_key_source = provider.as_deref().and_then(|p| {
941 CredentialStore::resolve(p, cfg.api_key().as_deref()).map(|(_, src)| {
942 match src {
943 crate::credential::CredentialSource::EnvVar => "env",
944 crate::credential::CredentialSource::Config => "config",
945 crate::credential::CredentialSource::OxiAuthStore => "auth_store",
946 }
947 .to_string()
948 })
949 });
950 let api_key_set = provider
951 .as_deref()
952 .map(|p| CredentialStore::has_credential(p, cfg.api_key().as_deref()))
953 .unwrap_or(false);
954
955 let role_routing = if cfg.engine.role_routing.roles.is_empty() {
956 None
957 } else {
958 Some(cfg.engine.role_routing.clone())
959 };
960
961 EngineConfigResponse {
962 default_model: cfg.engine.default_model.clone(),
963 api_key_set,
964 api_key_source,
965 provider,
966 routing: RoutingConfigSnapshot {
967 routing_enabled: cfg.engine.routing_enabled,
968 prefer_cost_efficient: cfg.engine.prefer_cost_efficient,
969 fallback_models: cfg.engine.fallback_models.clone(),
970 excluded_models: cfg.engine.excluded_models.clone(),
971 },
972 role_routing,
973 quick_ask_model: cfg.engine.quick_ask_model.clone(),
974 }
975 }
976
977 pub fn routing_stats_snapshot(&self) -> RoutingStatsSnapshot {
978 self.routing_stats.snapshot()
979 }
980
981 pub fn fallback_history(&self, limit: usize) -> Vec<FallbackEvent> {
983 self.routing_stats.fallback_history(limit)
984 }
985
986 pub fn set_model(&self, model_id: &str) -> anyhow::Result<()> {
993 {
999 let engine = self.engine_handle.get();
1000 let model = engine
1001 .resolve_model(model_id)
1002 .with_context(|| format!("Unknown model '{model_id}'"))?;
1003 engine.create_provider(&model.provider).with_context(|| {
1004 format!(
1005 "Provider '{}' is not configured for '{model_id}'",
1006 model.provider
1007 )
1008 })?;
1009 }
1010 let snapshot = {
1011 let mut cfg = self.config.write();
1012 cfg.engine.default_model = model_id.to_string();
1013 cfg.clone()
1014 };
1015 self.persist(&snapshot)?;
1018 tracing::info!(model = %model_id, "Default model updated in config");
1019 self.rebuild_and_swap();
1020 Ok(())
1021 }
1022
1023 pub fn set_quick_ask_model(&self, model_id: Option<&str>) -> anyhow::Result<()> {
1030 let snapshot = {
1031 let mut cfg = self.config.write();
1032 cfg.engine.quick_ask_model = model_id.map(String::from);
1033 cfg.clone()
1034 };
1035 self.persist(&snapshot)?;
1036 tracing::info!(model = ?model_id, "QuickAsk model updated in config");
1037 Ok(())
1038 }
1039
1040 pub fn set_api_key(&self, provider: &str, key: &str) -> anyhow::Result<()> {
1046 CredentialStore::store(provider, key)?;
1047
1048 let snapshot = {
1054 let mut cfg = self.config.write();
1055 let matches = CredentialStore::provider_from_model(&cfg.engine.default_model)
1056 .is_some_and(|current_provider| current_provider == provider);
1057 if matches {
1058 cfg.engine.api_key = Some(key.to_string());
1059 Some(cfg.clone())
1060 } else {
1061 None
1062 }
1063 };
1064 if let Some(snap) = snapshot {
1065 self.persist(&snap)?;
1067 }
1068 tracing::info!(provider = %provider, "API key stored");
1069 self.rebuild_and_swap();
1070 Ok(())
1071 }
1072
1073 pub fn clear_api_key(&self, provider: &str) -> anyhow::Result<()> {
1080 let snapshot = {
1081 let mut cfg = self.config.write();
1082 let matches = CredentialStore::provider_from_model(&cfg.engine.default_model)
1083 .is_some_and(|current_provider| current_provider == provider);
1084 if matches {
1085 cfg.engine.api_key = None;
1086 Some(cfg.clone())
1087 } else {
1088 None
1089 }
1090 };
1091 if let Some(snap) = snapshot {
1092 self.persist(&snap)?;
1093 }
1094 tracing::info!(provider = %provider, "API key cleared from config");
1095 self.rebuild_and_swap();
1096 Ok(())
1097 }
1098
1099 pub fn delete_api_key(&self, provider: &str) -> anyhow::Result<()> {
1110 CredentialStore::delete(provider)?;
1111 let snapshot = {
1113 let mut cfg = self.config.write();
1114 let matches = CredentialStore::provider_from_model(&cfg.engine.default_model)
1115 .is_some_and(|current_provider| current_provider == provider);
1116 if matches {
1117 cfg.engine.api_key = None;
1118 Some(cfg.clone())
1119 } else {
1120 None
1121 }
1122 };
1123 if let Some(snap) = snapshot {
1124 self.persist(&snap)?;
1125 }
1126 tracing::info!(provider = %provider, "API key deleted from credential store");
1127 self.rebuild_and_swap();
1128 Ok(())
1129 }
1130
1131 pub fn set_provider_options(&self, opts: &oxi_sdk::ProviderOptions) -> anyhow::Result<()> {
1136 let snapshot = {
1137 let mut cfg = self.config.write();
1138 cfg.engine.provider_options = Some(opts.clone());
1139 cfg.clone()
1140 };
1141 self.persist(&snapshot)?;
1142 tracing::info!("Provider options updated and persisted");
1143 Ok(())
1146 }
1147
1148 pub fn set_routing(&self, update: RoutingUpdate) -> anyhow::Result<()> {
1153 let snapshot = {
1154 let mut cfg = self.config.write();
1155 if let Some(v) = update.routing_enabled {
1156 cfg.engine.routing_enabled = v;
1157 }
1158 if let Some(v) = update.prefer_cost_efficient {
1159 cfg.engine.prefer_cost_efficient = v;
1160 }
1161 if let Some(v) = update.fallback_models {
1162 cfg.engine.fallback_models = v;
1163 }
1164 if let Some(v) = update.excluded_models {
1165 cfg.engine.excluded_models = v;
1166 }
1167 cfg.clone()
1168 };
1169 self.persist(&snapshot)?;
1170 tracing::info!("Routing configuration updated via API");
1171 self.rebuild_and_swap();
1172 Ok(())
1173 }
1174
1175 pub async fn validate_key(&self, provider: &str, api_key: &str) -> ValidateKeyResult {
1180 match self.try_validate(provider, api_key).await {
1181 Ok(()) => ValidateKeyResult {
1182 valid: true,
1183 provider: provider.to_string(),
1184 message: Some("API key is valid".to_string()),
1185 },
1186 Err(e) => ValidateKeyResult {
1187 valid: false,
1188 provider: provider.to_string(),
1189 message: Some(format!("{e}")),
1190 },
1191 }
1192 }
1193
1194 pub async fn validate_stored_key(&self, provider: &str) -> ValidateKeyResult {
1200 let api_key_override = {
1201 let cfg = self.config.read();
1202 cfg.api_key().as_deref().map(str::to_owned)
1203 };
1204 match CredentialStore::resolve(provider, api_key_override.as_deref()) {
1205 Some((key, _)) => self.validate_key(provider, &key).await,
1206 None => ValidateKeyResult {
1207 valid: false,
1208 provider: provider.to_string(),
1209 message: Some("No API key found for this provider".to_string()),
1210 },
1211 }
1212 }
1213
1214 async fn try_validate(&self, provider: &str, api_key: &str) -> anyhow::Result<()> {
1219 if api_key.is_empty() {
1220 anyhow::bail!("API key is empty");
1221 }
1222
1223 let builder = oxi_sdk::OxiBuilder::new()
1224 .with_builtins()
1225 .api_key(provider, api_key);
1226 let oxi = builder.build();
1227
1228 let models = oxi_sdk::get_provider_models(provider);
1229 if models.is_empty() {
1230 anyhow::bail!("No models found for provider '{provider}'");
1231 }
1232
1233 let model_id = format!("{}/{}", provider, models[0].id);
1234 let model = oxi
1235 .resolve_model(&model_id)
1236 .with_context(|| format!("Unknown model '{model_id}'"))?;
1237 let provider_inst = oxi
1238 .create_provider(provider)
1239 .with_context(|| format!("Failed to create provider '{provider}'"))?;
1240
1241 let mut ctx = oxi_sdk::Context::new();
1242 ctx.add_message(oxi_sdk::Message::User(oxi_sdk::UserMessage::new("Hi")));
1243
1244 let stream_result = tokio::time::timeout(
1245 std::time::Duration::from_secs(15),
1246 provider_inst.stream(&model, &ctx, None),
1247 )
1248 .await
1249 .map_err(|_| anyhow::anyhow!("Request timed out (15s)"))?;
1250
1251 match stream_result {
1252 Ok(_) => {
1253 tracing::debug!(provider = %provider, model = %model_id, "Key validated via real API call");
1254 Ok(())
1255 }
1256 Err(e) => Err(anyhow::anyhow!("{e}")),
1257 }
1258 }
1259
1260 pub fn estimate_cost(model_id: &str, input_tokens: u64, output_tokens: u64) -> f64 {
1264 estimate_cost(model_id, input_tokens, output_tokens)
1265 }
1266
1267 fn persist(&self, config: &OxiosConfig) -> anyhow::Result<()> {
1269 let content = toml::to_string_pretty(config)
1270 .map_err(|e| anyhow::anyhow!("Failed to serialize config: {e}"))?;
1271 std::fs::write(&self.config_path, content)?;
1272 Ok(())
1273 }
1274
1275 fn rebuild_and_swap(&self) {
1282 let (model_id, api_key, catalog) = {
1286 let cfg = self.config.read();
1287 let catalog = self.engine_handle.get().oxi().catalog().clone();
1288 (cfg.engine.default_model.clone(), cfg.api_key(), catalog)
1289 };
1290 let new_engine = crate::engine::OxiosEngine::from_config_with_catalog(
1291 &model_id,
1292 api_key.as_deref(),
1293 catalog,
1294 );
1295 self.engine_handle.swap(new_engine);
1296 }
1297}
1298
1299impl std::fmt::Debug for EngineApi {
1300 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1301 f.debug_struct("EngineApi")
1302 .field("config_path", &self.config_path)
1303 .finish()
1304 }
1305}
1306
1307pub fn record_usage_to_stats(
1310 stats: &Option<Arc<RoutingStats>>,
1311 model_id: &str,
1312 input_tokens: u64,
1313 output_tokens: u64,
1314) {
1315 if let Some(s) = stats {
1316 let cost = estimate_cost(model_id, input_tokens, output_tokens);
1317 s.record_model_usage(model_id, cost);
1318 }
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323 use super::*;
1324
1325 #[test]
1326 fn test_provider_category_known() {
1327 assert_eq!(provider_category("anthropic"), ProviderCategory::Major);
1329 assert_eq!(provider_category("openai"), ProviderCategory::Major);
1330 assert_eq!(provider_category("google"), ProviderCategory::Major);
1331 assert_eq!(provider_category("groq"), ProviderCategory::Open);
1333 assert_eq!(provider_category("opencode"), ProviderCategory::Open);
1334 assert_eq!(provider_category("minimax"), ProviderCategory::Regional);
1336 assert_eq!(provider_category("moonshotai"), ProviderCategory::Regional);
1337 assert_eq!(provider_category("kimi-coding"), ProviderCategory::Regional);
1338 assert_eq!(provider_category("zai"), ProviderCategory::Regional);
1339 assert_eq!(provider_category("minimax-cn"), ProviderCategory::Regional);
1340 assert_eq!(provider_category("xiaomi"), ProviderCategory::Regional);
1341 }
1342
1343 #[test]
1344 fn test_provider_category_fallback() {
1345 assert_eq!(
1347 provider_category("not-a-real-provider"),
1348 ProviderCategory::Open
1349 );
1350 assert_eq!(provider_category(""), ProviderCategory::Open);
1351 }
1352
1353 #[test]
1354 fn test_provider_display_name_known() {
1355 assert_eq!(provider_display_name("anthropic"), "Anthropic");
1356 assert_eq!(provider_display_name("minimax"), "MiniMax");
1357 assert_eq!(provider_display_name("moonshotai"), "Moonshot AI (Kimi)");
1358 assert_eq!(provider_display_name("kimi-coding"), "Kimi Coding");
1359 assert_eq!(provider_display_name("zai"), "Z.AI (GLM)");
1360 assert_eq!(provider_display_name("opencode"), "OpenCode");
1361 assert_eq!(provider_display_name("amazon-bedrock"), "Amazon Bedrock");
1362 }
1363
1364 #[test]
1365 fn test_provider_display_name_fallback() {
1366 assert_eq!(
1368 provider_display_name("some-new-provider"),
1369 "Some New Provider"
1370 );
1371 assert_eq!(provider_display_name("kimi-coding"), "Kimi Coding");
1372 assert_eq!(provider_display_name("some_id"), "Some Id");
1373 assert_eq!(provider_display_name(""), "");
1375 }
1376
1377 #[test]
1378 fn test_provider_meta_lookup_by_alias() {
1379 let by_id = provider_meta("github-copilot").unwrap();
1381 let by_alias = provider_meta("copilot").unwrap();
1382 assert_eq!(by_id.id, by_alias.id);
1383
1384 let bedrock_id = provider_meta("amazon-bedrock").unwrap();
1385 let bedrock_alias = provider_meta("aws-bedrock").unwrap();
1386 let bedrock_canonical = provider_meta("bedrock").unwrap();
1387 assert_eq!(bedrock_id.id, bedrock_alias.id);
1388 assert_eq!(bedrock_id.id, bedrock_canonical.id);
1389 }
1390
1391 #[test]
1392 fn test_provider_meta_unknown_is_none() {
1393 assert!(provider_meta("not-a-real-provider").is_none());
1394 assert!(provider_meta("").is_none());
1395 }
1396
1397 #[test]
1398 fn test_provider_info_serialization() {
1399 let info = ProviderInfo {
1400 id: "anthropic".to_string(),
1401 name: "Anthropic".to_string(),
1402 category: ProviderCategory::Major,
1403 model_count: 15,
1404 has_key: true,
1405 key_source: "auth_store".to_string(),
1406 description: "Claude models with extended thinking".to_string(),
1407 env_key: "ANTHROPIC_API_KEY".to_string(),
1408 };
1409 let json = serde_json::to_string(&info).unwrap();
1410 assert!(json.contains("\"modelCount\":15"));
1412 assert!(json.contains("\"hasKey\":true"));
1413 assert!(json.contains("\"envKey\":\"ANTHROPIC_API_KEY\""));
1414 let restored: ProviderInfo = serde_json::from_str(&json).unwrap();
1415 assert_eq!(restored.id, "anthropic");
1416 assert_eq!(restored.name, "Anthropic");
1417 assert_eq!(restored.model_count, 15);
1418 assert!(restored.has_key);
1419 assert_eq!(restored.env_key, "ANTHROPIC_API_KEY");
1420 }
1421
1422 #[test]
1423 fn test_provider_info_serialization_missing_optional() {
1424 let json = r#"{
1427 "id": "anthropic",
1428 "name": "Anthropic",
1429 "category": "major",
1430 "modelCount": 15,
1431 "hasKey": true
1432 }"#;
1433 let info: ProviderInfo = serde_json::from_str(json).unwrap();
1434 assert_eq!(info.id, "anthropic");
1435 assert_eq!(info.description, "");
1436 assert_eq!(info.env_key, "");
1437 }
1438
1439 #[test]
1440 fn test_model_info_serialization() {
1441 let info = ModelInfo {
1442 id: "anthropic/claude-sonnet-4".to_string(),
1443 name: "Claude Sonnet 4".to_string(),
1444 api: "anthropic-messages".to_string(),
1445 provider: "anthropic".to_string(),
1446 reasoning: true,
1447 input: vec![InputModality::Text, InputModality::Image],
1448 context_window: 200000,
1449 max_tokens: 16000,
1450 cost_input: 3.0,
1451 cost_output: 15.0,
1452 cost_cache_read: 0.3,
1453 cost_cache_write: 3.75,
1454 };
1455 let json = serde_json::to_string(&info).unwrap();
1456 let restored: ModelInfo = serde_json::from_str(&json).unwrap();
1457 assert_eq!(restored.id, "anthropic/claude-sonnet-4");
1458 assert!(restored.reasoning);
1459 assert_eq!(restored.context_window, 200000);
1460 assert!(restored.input.contains(&InputModality::Image));
1461 assert_eq!(restored.api, "anthropic-messages");
1462 }
1463
1464 #[test]
1465 fn test_engine_config_response_serialization() {
1466 let resp = EngineConfigResponse {
1467 default_model: "anthropic/claude-sonnet-4".to_string(),
1468 api_key_set: true,
1469 api_key_source: Some("config.toml".to_string()),
1470 provider: Some("anthropic".to_string()),
1471 routing: RoutingConfigSnapshot {
1472 routing_enabled: false,
1473 prefer_cost_efficient: false,
1474 fallback_models: vec![],
1475 excluded_models: vec![],
1476 },
1477 role_routing: None,
1478 quick_ask_model: None,
1479 };
1480 let json = serde_json::to_string(&resp).unwrap();
1481 let restored: EngineConfigResponse = serde_json::from_str(&json).unwrap();
1482 assert_eq!(restored.default_model, "anthropic/claude-sonnet-4");
1483 assert!(restored.api_key_set);
1484 assert_eq!(restored.api_key_source.as_deref(), Some("config.toml"));
1485 assert!(!restored.routing.routing_enabled);
1486 }
1487
1488 #[test]
1489 fn test_validate_key_result_serialization() {
1490 let result = ValidateKeyResult {
1491 valid: true,
1492 provider: "openai".to_string(),
1493 message: Some("API key is valid".to_string()),
1494 };
1495 let json = serde_json::to_string(&result).unwrap();
1496 let restored: ValidateKeyResult = serde_json::from_str(&json).unwrap();
1497 assert!(restored.valid);
1498 assert_eq!(restored.provider, "openai");
1499 }
1500
1501 #[test]
1502 fn test_validate_key_result_invalid() {
1503 let result = ValidateKeyResult {
1504 valid: false,
1505 provider: "anthropic".to_string(),
1506 message: Some("Validation failed: key too short".to_string()),
1507 };
1508 assert!(!result.valid);
1509 assert!(result.message.as_ref().unwrap().contains("failed"));
1510 }
1511
1512 #[test]
1513 fn test_routing_stats_snapshot() {
1514 let stats = RoutingStats::new();
1515 stats.record_model_usage("anthropic/claude-sonnet-4", 0.05);
1516 stats.record_model_usage("anthropic/claude-sonnet-4", 0.03);
1517 stats.record_model_usage("openai/gpt-4o-mini", 0.01);
1518
1519 let snap = stats.snapshot();
1520 assert_eq!(snap.total_requests, 3);
1521 assert_eq!(snap.model_calls["anthropic/claude-sonnet-4"], 2);
1522 assert_eq!(snap.model_calls["openai/gpt-4o-mini"], 1);
1523 assert!((snap.total_cost - 0.09).abs() < 0.001);
1524 }
1525
1526 #[test]
1527 fn test_fallback_history_circular() {
1528 let stats = RoutingStats::new();
1529 for i in 0..210 {
1530 stats.record_fallback(FallbackEvent {
1531 timestamp: DateTime::from_timestamp(i as i64, 0).unwrap(),
1532 from_model: format!("model-{}", i),
1533 to_model: "fallback".to_string(),
1534 reason: "test".to_string(),
1535 success: true,
1536 });
1537 }
1538 let history = stats.fallback_history(200);
1539 assert_eq!(history.len(), 200);
1540 assert_eq!(history[0].from_model, "model-209");
1542 assert_eq!(history[199].from_model, "model-10");
1543 }
1544
1545 #[test]
1546 fn set_model_rejects_unknown_model_before_persist() {
1547 use crate::engine::{EngineHandle, OxiosEngine};
1548
1549 let engine = Arc::new(OxiosEngine::new("anthropic/claude-sonnet-4-20250514"));
1550 let handle = Arc::new(EngineHandle::new(engine));
1551 let config = Arc::new(parking_lot::RwLock::new(OxiosConfig::default()));
1552 let path = PathBuf::from("/tmp/oxios-set-model-test-NONEXISTENT.toml");
1555 let api = EngineApi::new(config, path, Arc::new(RoutingStats::new()), handle);
1556
1557 let before = api.config.read().engine.default_model.clone();
1560 let err = api.set_model("zai-coding-plan/glm-5-turbo").unwrap_err();
1561 assert!(
1562 err.to_string().contains("Unknown model"),
1563 "expected unknown-model error, got: {err}"
1564 );
1565 assert_eq!(api.config.read().engine.default_model, before);
1567 }
1568
1569 #[test]
1570 fn set_model_accepts_known_builtin_model() {
1571 use crate::engine::{EngineHandle, OxiosEngine};
1572
1573 let engine = Arc::new(OxiosEngine::new("anthropic/claude-sonnet-4-20250514"));
1574 let handle = Arc::new(EngineHandle::new(engine));
1575 let config = Arc::new(parking_lot::RwLock::new(OxiosConfig::default()));
1576 let tmp =
1577 std::env::temp_dir().join(format!("oxios-set-model-ok-{}.toml", std::process::id()));
1578 let api = EngineApi::new(config, tmp.clone(), Arc::new(RoutingStats::new()), handle);
1579
1580 let result = api.set_model("openai/gpt-4o");
1583 match result {
1587 Ok(()) => assert_eq!(api.config.read().engine.default_model, "openai/gpt-4o"),
1588 Err(e) => assert!(
1589 !e.to_string().contains("Unknown model"),
1590 "known model rejected as unknown: {e}"
1591 ),
1592 }
1593 let _ = std::fs::remove_file(&tmp);
1594 }
1595}