use crate::config::OxiosConfig;
use crate::credential::CredentialStore;
use chrono::{DateTime, Utc};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoutingConfigSnapshot {
pub routing_enabled: bool,
pub prefer_cost_efficient: bool,
pub fallback_models: Vec<String>,
pub excluded_models: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoutingStatsSnapshot {
pub model_calls: HashMap<String, u64>,
pub model_cost: HashMap<String, f64>,
pub total_requests: u64,
pub total_cost: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FallbackEvent {
pub timestamp: DateTime<Utc>,
pub from_model: String,
pub to_model: String,
pub reason: String,
pub success: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoutingUpdate {
pub routing_enabled: Option<bool>,
pub prefer_cost_efficient: Option<bool>,
pub fallback_models: Option<Vec<String>>,
pub excluded_models: Option<Vec<String>>,
}
pub struct RoutingStats {
calls: RwLock<HashMap<String, u64>>,
costs: RwLock<HashMap<String, f64>>,
fallbacks: RwLock<Vec<FallbackEvent>>,
}
impl Default for RoutingStats {
fn default() -> Self {
Self {
calls: RwLock::new(HashMap::new()),
costs: RwLock::new(HashMap::new()),
fallbacks: RwLock::new(Vec::new()),
}
}
}
impl RoutingStats {
pub fn new() -> Self {
Self::default()
}
pub fn record_model_usage(&self, model_id: &str, cost_usd: f64) {
let mut calls = self.calls.write();
*calls.entry(model_id.to_string()).or_insert(0) += 1;
if cost_usd > 0.0 {
let mut costs = self.costs.write();
*costs.entry(model_id.to_string()).or_insert(0.0) += cost_usd;
}
}
pub fn record_fallback(&self, event: FallbackEvent) {
let mut fb = self.fallbacks.write();
fb.push(event);
let keep = fb.len().saturating_sub(200);
if keep > 0 {
fb.drain(0..keep);
}
}
pub fn snapshot(&self) -> RoutingStatsSnapshot {
let calls = self.calls.read();
let costs = self.costs.read();
let total_requests: u64 = calls.values().sum();
let total_cost: f64 = costs.values().sum();
RoutingStatsSnapshot {
model_calls: calls.clone(),
model_cost: costs.clone(),
total_requests,
total_cost,
}
}
pub fn fallback_history(&self, limit: usize) -> Vec<FallbackEvent> {
let fb = self.fallbacks.read();
fb.iter().rev().take(limit).cloned().collect()
}
}
pub fn estimate_cost(model_id: &str, input_tokens: u64, output_tokens: u64) -> f64 {
let entries = oxi_sdk::get_provider_models(model_id.split('/').next().unwrap_or(model_id));
let entry = entries
.iter()
.find(|e| format!("{}/{}", e.provider, e.id) == model_id);
match entry {
Some(e) => {
(e.cost_input * input_tokens as f64 / 1_000_000.0)
+ (e.cost_output * output_tokens as f64 / 1_000_000.0)
}
None => {
(0.003 * input_tokens as f64 / 1_000_000.0)
+ (0.015 * output_tokens as f64 / 1_000_000.0)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProviderCategory {
Major,
Open,
Regional,
Local,
}
#[derive(Debug, Clone, Copy)]
struct ProviderMeta {
id: &'static str,
display_name: &'static str,
category: ProviderCategory,
hidden: bool,
description: &'static str,
env_key: &'static str,
aliases: &'static [&'static str],
}
const PROVIDER_META: &[ProviderMeta] = &[
ProviderMeta {
id: "anthropic",
display_name: "Anthropic",
category: ProviderCategory::Major,
hidden: false,
description: "Claude models with extended thinking",
env_key: "ANTHROPIC_API_KEY",
aliases: &["anthropic"],
},
ProviderMeta {
id: "openai",
display_name: "OpenAI",
category: ProviderCategory::Major,
hidden: false,
description: "GPT, o-series, and Codex models",
env_key: "OPENAI_API_KEY",
aliases: &["openai"],
},
ProviderMeta {
id: "google",
display_name: "Google Gemini",
category: ProviderCategory::Major,
hidden: false,
description: "Gemini models with thinking and tool use",
env_key: "GOOGLE_API_KEY",
aliases: &["google"],
},
ProviderMeta {
id: "groq",
display_name: "Groq",
category: ProviderCategory::Open,
hidden: false,
description: "Fast Llama, Mixtral, and Gemma inference",
env_key: "GROQ_API_KEY",
aliases: &["groq"],
},
ProviderMeta {
id: "openrouter",
display_name: "OpenRouter",
category: ProviderCategory::Open,
hidden: false,
description: "Unified gateway to 200+ models",
env_key: "OPENROUTER_API_KEY",
aliases: &["openrouter"],
},
ProviderMeta {
id: "deepseek",
display_name: "DeepSeek",
category: ProviderCategory::Open,
hidden: false,
description: "DeepSeek-V3 and DeepSeek-R1",
env_key: "DEEPSEEK_API_KEY",
aliases: &["deepseek"],
},
ProviderMeta {
id: "mistral",
display_name: "Mistral",
category: ProviderCategory::Open,
hidden: false,
description: "Mistral and Codestral models",
env_key: "MISTRAL_API_KEY",
aliases: &["mistral"],
},
ProviderMeta {
id: "xai",
display_name: "xAI (Grok)",
category: ProviderCategory::Open,
hidden: false,
description: "Grok models from xAI",
env_key: "XAI_API_KEY",
aliases: &["xai", "grok"],
},
ProviderMeta {
id: "cerebras",
display_name: "Cerebras",
category: ProviderCategory::Open,
hidden: false,
description: "Ultra-fast open model inference",
env_key: "CEREBRAS_API_KEY",
aliases: &["cerebras"],
},
ProviderMeta {
id: "fireworks",
display_name: "Fireworks",
category: ProviderCategory::Open,
hidden: false,
description: "Fast open-source model serving",
env_key: "FIREWORKS_API_KEY",
aliases: &["fireworks"],
},
ProviderMeta {
id: "github-copilot",
display_name: "GitHub Copilot",
category: ProviderCategory::Open,
hidden: false,
description: "GitHub Copilot models (GPT-4, Claude)",
env_key: "GITHUB_COPILOT_TOKEN",
aliases: &["github-copilot", "copilot"],
},
ProviderMeta {
id: "huggingface",
display_name: "Hugging Face",
category: ProviderCategory::Open,
hidden: false,
description: "Open model inference hub",
env_key: "HUGGINGFACE_API_KEY",
aliases: &["huggingface", "hf"],
},
ProviderMeta {
id: "together",
display_name: "Together AI",
category: ProviderCategory::Open,
hidden: false,
description: "Open-source model hosting (Llama, Mixtral, ...)",
env_key: "TOGETHER_API_KEY",
aliases: &["together", "togetherai"],
},
ProviderMeta {
id: "opencode",
display_name: "OpenCode",
category: ProviderCategory::Open,
hidden: false,
description: "OpenCode coding agent gateway",
env_key: "",
aliases: &["opencode"],
},
ProviderMeta {
id: "perplexity",
display_name: "Perplexity",
category: ProviderCategory::Open,
hidden: false,
description: "Search-augmented answer models",
env_key: "PERPLEXITY_API_KEY",
aliases: &["perplexity"],
},
ProviderMeta {
id: "cohere",
display_name: "Cohere",
category: ProviderCategory::Open,
hidden: false,
description: "Cohere Command and Embed models",
env_key: "COHERE_API_KEY",
aliases: &["cohere"],
},
ProviderMeta {
id: "minimax",
display_name: "MiniMax",
category: ProviderCategory::Regional,
hidden: false,
description: "MiniMax-M2.7, abab models",
env_key: "MINIMAX_API_KEY",
aliases: &["minimax"],
},
ProviderMeta {
id: "moonshotai",
display_name: "Moonshot AI (Kimi)",
category: ProviderCategory::Regional,
hidden: false,
description: "Kimi models from Moonshot AI",
env_key: "MOONSHOT_API_KEY",
aliases: &["moonshotai", "moonshot", "kimi"],
},
ProviderMeta {
id: "kimi-coding",
display_name: "Kimi Coding",
category: ProviderCategory::Regional,
hidden: false,
description: "Kimi Coding Plan — optimized for coding",
env_key: "KIMI_CODING_API_KEY",
aliases: &["kimi-coding"],
},
ProviderMeta {
id: "zai",
display_name: "Z.AI (GLM)",
category: ProviderCategory::Regional,
hidden: false,
description: "Z.AI GLM models (coding plan)",
env_key: "ZAI_API_KEY",
aliases: &["zai"],
},
ProviderMeta {
id: "amazon-bedrock",
display_name: "Amazon Bedrock",
category: ProviderCategory::Open,
hidden: true,
description: "Multi-model via AWS Bedrock ConverseStream",
env_key: "AWS_ACCESS_KEY_ID",
aliases: &["amazon-bedrock", "aws-bedrock", "bedrock"],
},
ProviderMeta {
id: "azure-openai-responses",
display_name: "Azure OpenAI (Responses)",
category: ProviderCategory::Open,
hidden: true,
description: "OpenAI models via Azure Cognitive Services",
env_key: "AZURE_OPENAI_API_KEY",
aliases: &["azure-openai-responses", "azure"],
},
ProviderMeta {
id: "cloudflare-ai-gateway",
display_name: "Cloudflare AI Gateway",
category: ProviderCategory::Open,
hidden: true,
description: "Serverless AI via Cloudflare AI Gateway",
env_key: "CLOUDFLARE_API_TOKEN",
aliases: &["cloudflare-ai-gateway", "cf-ai-gateway"],
},
ProviderMeta {
id: "cloudflare-workers-ai",
display_name: "Cloudflare Workers AI",
category: ProviderCategory::Open,
hidden: true,
description: "Serverless AI via Cloudflare Workers",
env_key: "CLOUDFLARE_API_KEY",
aliases: &["cloudflare-workers-ai", "cloudflare", "workers-ai"],
},
ProviderMeta {
id: "google-vertex",
display_name: "Google Vertex AI",
category: ProviderCategory::Open,
hidden: true,
description: "Gemini via Google Cloud Vertex AI",
env_key: "GOOGLE_APPLICATION_CREDENTIALS",
aliases: &["google-vertex", "vertex"],
},
ProviderMeta {
id: "minimax-cn",
display_name: "MiniMax (China)",
category: ProviderCategory::Regional,
hidden: true,
description: "MiniMax China region endpoint",
env_key: "MINIMAX_CN_API_KEY",
aliases: &["minimax-cn"],
},
ProviderMeta {
id: "moonshotai-cn",
display_name: "Moonshot AI (China)",
category: ProviderCategory::Regional,
hidden: true,
description: "Kimi models — China region endpoint",
env_key: "MOONSHOT_CN_API_KEY",
aliases: &["moonshotai-cn", "moonshot-cn"],
},
ProviderMeta {
id: "openai-codex",
display_name: "OpenAI Codex",
category: ProviderCategory::Open,
hidden: true,
description: "OpenAI Codex coding agent (Responses API)",
env_key: "OPENAI_API_KEY",
aliases: &["openai-codex"],
},
ProviderMeta {
id: "opencode-go",
display_name: "OpenCode Go",
category: ProviderCategory::Open,
hidden: true,
description: "OpenCode Go Gateway",
env_key: "OPENCODE_GO_API_KEY",
aliases: &["opencode-go"],
},
ProviderMeta {
id: "vercel-ai-gateway",
display_name: "Vercel AI Gateway",
category: ProviderCategory::Open,
hidden: true,
description: "Vercel AI Gateway",
env_key: "VERCEL_API_KEY",
aliases: &["vercel-ai-gateway", "vercel"],
},
ProviderMeta {
id: "xiaomi",
display_name: "Xiaomi MiMo",
category: ProviderCategory::Regional,
hidden: true,
description: "Xiaomi MiMo models",
env_key: "XIAOMI_API_KEY",
aliases: &["xiaomi"],
},
];
fn provider_meta(id: &str) -> Option<&'static ProviderMeta> {
PROVIDER_META
.iter()
.find(|m| m.id == id || m.aliases.contains(&id))
}
fn provider_category(id: &str) -> ProviderCategory {
provider_meta(id)
.map(|m| m.category)
.unwrap_or(ProviderCategory::Open)
}
fn provider_display_name(id: &str) -> String {
provider_meta(id)
.map(|m| m.display_name.to_string())
.unwrap_or_else(|| fallback_display_name(id))
}
fn fallback_display_name(id: &str) -> String {
id.split(['-', '_'])
.filter(|s| !s.is_empty())
.map(|segment| {
let mut chars = segment.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderInfo {
pub id: String,
pub name: String,
pub category: ProviderCategory,
pub model_count: usize,
pub has_key: bool,
#[serde(default)]
pub description: String,
#[serde(default)]
pub env_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InputModality {
Text,
Image,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelInfo {
pub id: String,
pub name: String,
pub api: String,
pub provider: String,
pub reasoning: bool,
pub input: Vec<InputModality>,
pub context_window: u32,
pub max_tokens: u32,
pub cost_input: f64,
pub cost_output: f64,
pub cost_cache_read: f64,
pub cost_cache_write: f64,
}
impl From<&oxi_sdk::ModelEntry> for ModelInfo {
fn from(entry: &oxi_sdk::ModelEntry) -> Self {
Self {
id: format!("{}/{}", entry.provider, entry.id),
name: entry.name.to_string(),
api: entry.api.to_string(),
provider: entry.provider.to_string(),
reasoning: entry.reasoning,
input: entry
.input
.iter()
.map(|m| match m {
oxi_sdk::InputModality::Text => InputModality::Text,
oxi_sdk::InputModality::Image => InputModality::Image,
_ => InputModality::Text,
})
.collect(),
context_window: entry.context_window,
max_tokens: entry.max_tokens,
cost_input: entry.cost_input,
cost_output: entry.cost_output,
cost_cache_read: entry.cost_cache_read,
cost_cache_write: entry.cost_cache_write,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EngineConfigResponse {
pub default_model: String,
pub api_key_set: bool,
pub api_key_source: Option<String>,
pub provider: Option<String>,
pub routing: RoutingConfigSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidateKeyResult {
pub valid: bool,
pub provider: String,
pub message: Option<String>,
}
pub struct EngineApi {
config: Arc<RwLock<OxiosConfig>>,
config_path: PathBuf,
routing_stats: Arc<RoutingStats>,
engine_handle: Arc<crate::engine::EngineHandle>,
}
impl EngineApi {
pub fn new(
config: Arc<RwLock<OxiosConfig>>,
config_path: PathBuf,
routing_stats: Arc<RoutingStats>,
engine_handle: Arc<crate::engine::EngineHandle>,
) -> Self {
Self {
config,
config_path,
routing_stats,
engine_handle,
}
}
pub fn routing_stats(&self) -> Arc<RoutingStats> {
Arc::clone(&self.routing_stats)
}
pub fn engine_handle(&self) -> &Arc<crate::engine::EngineHandle> {
&self.engine_handle
}
pub fn providers(&self) -> Vec<ProviderInfo> {
let all = oxi_sdk::get_providers();
all.into_iter()
.filter(|p| provider_meta(p).map(|m| !m.hidden).unwrap_or(true))
.map(|p| {
let model_count = oxi_sdk::get_provider_models(p).len();
let has_key = CredentialStore::has_credential(
p,
self.config
.read()
.engine
.api_key
.as_deref()
.filter(|k| !k.is_empty()),
);
let meta = provider_meta(p);
ProviderInfo {
id: p.to_string(),
name: provider_display_name(p),
category: provider_category(p),
model_count,
has_key,
description: meta.map(|m| m.description.to_string()).unwrap_or_default(),
env_key: meta.map(|m| m.env_key.to_string()).unwrap_or_default(),
}
})
.collect()
}
pub fn models(&self, provider: &str, query: Option<&str>) -> Vec<ModelInfo> {
let entries = oxi_sdk::get_provider_models(provider);
entries
.iter()
.filter(|e| {
!e.name.contains("latest")
})
.filter(|e| {
if let Some(q) = query {
let q = q.to_lowercase();
e.name.to_lowercase().contains(&q)
|| e.id.to_lowercase().contains(&q)
|| e.provider.to_lowercase().contains(&q)
} else {
true
}
})
.map(ModelInfo::from)
.collect()
}
pub fn search_models(&self, query: &str) -> Vec<ModelInfo> {
oxi_sdk::search_models(query)
.into_iter()
.map(ModelInfo::from)
.collect()
}
pub fn config(&self) -> EngineConfigResponse {
let cfg = self.config.read();
let provider =
CredentialStore::provider_from_model(&cfg.engine.default_model).map(|s| s.to_string());
let api_key_source = provider.as_deref().and_then(|p| {
CredentialStore::resolve(p, cfg.api_key().as_deref()).map(|(_, src)| {
match src {
crate::credential::CredentialSource::EnvVar => "env",
crate::credential::CredentialSource::Config => "config",
crate::credential::CredentialSource::OxiAuthStore => "auth_store",
}
.to_string()
})
});
let api_key_set = provider
.as_deref()
.map(|p| CredentialStore::has_credential(p, cfg.api_key().as_deref()))
.unwrap_or(false);
EngineConfigResponse {
default_model: cfg.engine.default_model.clone(),
api_key_set,
api_key_source,
provider,
routing: RoutingConfigSnapshot {
routing_enabled: cfg.engine.routing_enabled,
prefer_cost_efficient: cfg.engine.prefer_cost_efficient,
fallback_models: cfg.engine.fallback_models.clone(),
excluded_models: cfg.engine.excluded_models.clone(),
},
}
}
pub fn routing_stats_snapshot(&self) -> RoutingStatsSnapshot {
self.routing_stats.snapshot()
}
pub fn fallback_history(&self, limit: usize) -> Vec<FallbackEvent> {
self.routing_stats.fallback_history(limit)
}
pub fn set_model(&self, model_id: &str) -> anyhow::Result<()> {
{
let mut cfg = self.config.write();
cfg.engine.default_model = model_id.to_string();
self.persist(&cfg)?;
}
tracing::info!(model = %model_id, "Default model updated in config");
self.rebuild_and_swap();
Ok(())
}
pub fn set_api_key(&self, provider: &str, key: &str) -> anyhow::Result<()> {
CredentialStore::store(provider, key)?;
let cfg = self.config.read();
if let Some(current_provider) =
CredentialStore::provider_from_model(&cfg.engine.default_model)
{
if current_provider == provider {
drop(cfg);
let mut cfg = self.config.write();
cfg.engine.api_key = Some(key.to_string());
self.persist(&cfg)?;
}
}
tracing::info!(provider = %provider, "API key stored");
self.rebuild_and_swap();
Ok(())
}
pub fn set_provider_options(&self, opts: &oxi_sdk::ProviderOptions) -> anyhow::Result<()> {
{
let mut cfg = self.config.write();
cfg.engine.provider_options = Some(opts.clone());
self.persist(&cfg)?;
}
tracing::info!("Provider options updated and persisted");
Ok(())
}
pub fn set_routing(&self, update: RoutingUpdate) -> anyhow::Result<()> {
{
let mut cfg = self.config.write();
if let Some(v) = update.routing_enabled {
cfg.engine.routing_enabled = v;
}
if let Some(v) = update.prefer_cost_efficient {
cfg.engine.prefer_cost_efficient = v;
}
if let Some(v) = update.fallback_models {
cfg.engine.fallback_models = v;
}
if let Some(v) = update.excluded_models {
cfg.engine.excluded_models = v;
}
self.persist(&cfg)?;
}
tracing::info!("Routing configuration updated via API");
self.rebuild_and_swap();
Ok(())
}
pub fn validate_key(&self, provider: &str, api_key: &str) -> ValidateKeyResult {
let result = self.try_validate(provider, api_key);
match result {
Ok(()) => ValidateKeyResult {
valid: true,
provider: provider.to_string(),
message: Some("API key is valid".to_string()),
},
Err(e) => ValidateKeyResult {
valid: false,
provider: provider.to_string(),
message: Some(format!("Validation failed: {e}")),
},
}
}
fn try_validate(&self, provider: &str, api_key: &str) -> anyhow::Result<()> {
let builder = oxi_sdk::OxiBuilder::new()
.with_builtins()
.api_key(provider, api_key);
let oxi = builder.build();
let models = oxi_sdk::get_provider_models(provider);
if models.is_empty() {
anyhow::bail!("No models found for provider '{provider}'");
}
let model_id = format!("{}/{}", provider, models[0].id);
let _model = oxi.resolve_model(&model_id)?;
let _provider = oxi.create_provider(provider)?;
if api_key.is_empty() {
anyhow::bail!("API key is empty");
}
if api_key.len() < 8 {
anyhow::bail!("API key appears too short");
}
tracing::debug!(
provider = %provider,
model = %model_id,
"Key validation: provider resolved with injected key"
);
Ok(())
}
pub fn estimate_cost(model_id: &str, input_tokens: u64, output_tokens: u64) -> f64 {
estimate_cost(model_id, input_tokens, output_tokens)
}
fn persist(&self, config: &OxiosConfig) -> anyhow::Result<()> {
let content = toml::to_string_pretty(config)
.map_err(|e| anyhow::anyhow!("Failed to serialize config: {e}"))?;
std::fs::write(&self.config_path, content)?;
Ok(())
}
fn rebuild_and_swap(&self) {
let cfg = self.config.read();
let model_id = &cfg.engine.default_model;
let new_engine =
crate::engine::OxiosEngine::from_config(model_id, cfg.api_key().as_deref());
drop(cfg);
self.engine_handle.swap(new_engine);
}
}
impl std::fmt::Debug for EngineApi {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EngineApi")
.field("config_path", &self.config_path)
.finish()
}
}
pub fn record_usage_to_stats(
stats: &Option<Arc<RoutingStats>>,
model_id: &str,
input_tokens: u64,
output_tokens: u64,
) {
if let Some(s) = stats {
let cost = estimate_cost(model_id, input_tokens, output_tokens);
s.record_model_usage(model_id, cost);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_provider_category_known() {
assert_eq!(provider_category("anthropic"), ProviderCategory::Major);
assert_eq!(provider_category("openai"), ProviderCategory::Major);
assert_eq!(provider_category("google"), ProviderCategory::Major);
assert_eq!(provider_category("groq"), ProviderCategory::Open);
assert_eq!(provider_category("opencode"), ProviderCategory::Open);
assert_eq!(provider_category("minimax"), ProviderCategory::Regional);
assert_eq!(provider_category("moonshotai"), ProviderCategory::Regional);
assert_eq!(provider_category("kimi-coding"), ProviderCategory::Regional);
assert_eq!(provider_category("zai"), ProviderCategory::Regional);
assert_eq!(provider_category("minimax-cn"), ProviderCategory::Regional);
assert_eq!(provider_category("xiaomi"), ProviderCategory::Regional);
}
#[test]
fn test_provider_category_fallback() {
assert_eq!(
provider_category("not-a-real-provider"),
ProviderCategory::Open
);
assert_eq!(provider_category(""), ProviderCategory::Open);
}
#[test]
fn test_provider_display_name_known() {
assert_eq!(provider_display_name("anthropic"), "Anthropic");
assert_eq!(provider_display_name("minimax"), "MiniMax");
assert_eq!(provider_display_name("moonshotai"), "Moonshot AI (Kimi)");
assert_eq!(provider_display_name("kimi-coding"), "Kimi Coding");
assert_eq!(provider_display_name("zai"), "Z.AI (GLM)");
assert_eq!(provider_display_name("opencode"), "OpenCode");
assert_eq!(provider_display_name("amazon-bedrock"), "Amazon Bedrock");
}
#[test]
fn test_provider_display_name_fallback() {
assert_eq!(
provider_display_name("some-new-provider"),
"Some New Provider"
);
assert_eq!(provider_display_name("kimi-coding"), "Kimi Coding");
assert_eq!(provider_display_name("some_id"), "Some Id");
assert_eq!(provider_display_name(""), "");
}
#[test]
fn test_provider_meta_lookup_by_alias() {
let by_id = provider_meta("github-copilot").unwrap();
let by_alias = provider_meta("copilot").unwrap();
assert_eq!(by_id.id, by_alias.id);
let bedrock_id = provider_meta("amazon-bedrock").unwrap();
let bedrock_alias = provider_meta("aws-bedrock").unwrap();
let bedrock_canonical = provider_meta("bedrock").unwrap();
assert_eq!(bedrock_id.id, bedrock_alias.id);
assert_eq!(bedrock_id.id, bedrock_canonical.id);
}
#[test]
fn test_provider_meta_unknown_is_none() {
assert!(provider_meta("not-a-real-provider").is_none());
assert!(provider_meta("").is_none());
}
#[test]
fn test_provider_info_serialization() {
let info = ProviderInfo {
id: "anthropic".to_string(),
name: "Anthropic".to_string(),
category: ProviderCategory::Major,
model_count: 15,
has_key: true,
description: "Claude models with extended thinking".to_string(),
env_key: "ANTHROPIC_API_KEY".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("\"modelCount\":15"));
assert!(json.contains("\"hasKey\":true"));
assert!(json.contains("\"envKey\":\"ANTHROPIC_API_KEY\""));
let restored: ProviderInfo = serde_json::from_str(&json).unwrap();
assert_eq!(restored.id, "anthropic");
assert_eq!(restored.name, "Anthropic");
assert_eq!(restored.model_count, 15);
assert!(restored.has_key);
assert_eq!(restored.env_key, "ANTHROPIC_API_KEY");
}
#[test]
fn test_provider_info_serialization_missing_optional() {
let json = r#"{
"id": "anthropic",
"name": "Anthropic",
"category": "major",
"modelCount": 15,
"hasKey": true
}"#;
let info: ProviderInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.id, "anthropic");
assert_eq!(info.description, "");
assert_eq!(info.env_key, "");
}
#[test]
fn test_model_info_serialization() {
let info = ModelInfo {
id: "anthropic/claude-sonnet-4".to_string(),
name: "Claude Sonnet 4".to_string(),
api: "anthropic-messages".to_string(),
provider: "anthropic".to_string(),
reasoning: true,
input: vec![InputModality::Text, InputModality::Image],
context_window: 200000,
max_tokens: 16000,
cost_input: 3.0,
cost_output: 15.0,
cost_cache_read: 0.3,
cost_cache_write: 3.75,
};
let json = serde_json::to_string(&info).unwrap();
let restored: ModelInfo = serde_json::from_str(&json).unwrap();
assert_eq!(restored.id, "anthropic/claude-sonnet-4");
assert!(restored.reasoning);
assert_eq!(restored.context_window, 200000);
assert!(restored.input.contains(&InputModality::Image));
assert_eq!(restored.api, "anthropic-messages");
}
#[test]
fn test_engine_config_response_serialization() {
let resp = EngineConfigResponse {
default_model: "anthropic/claude-sonnet-4".to_string(),
api_key_set: true,
api_key_source: Some("config.toml".to_string()),
provider: Some("anthropic".to_string()),
routing: RoutingConfigSnapshot {
routing_enabled: false,
prefer_cost_efficient: false,
fallback_models: vec![],
excluded_models: vec![],
},
};
let json = serde_json::to_string(&resp).unwrap();
let restored: EngineConfigResponse = serde_json::from_str(&json).unwrap();
assert_eq!(restored.default_model, "anthropic/claude-sonnet-4");
assert!(restored.api_key_set);
assert_eq!(restored.api_key_source.as_deref(), Some("config.toml"));
assert!(!restored.routing.routing_enabled);
}
#[test]
fn test_validate_key_result_serialization() {
let result = ValidateKeyResult {
valid: true,
provider: "openai".to_string(),
message: Some("API key is valid".to_string()),
};
let json = serde_json::to_string(&result).unwrap();
let restored: ValidateKeyResult = serde_json::from_str(&json).unwrap();
assert!(restored.valid);
assert_eq!(restored.provider, "openai");
}
#[test]
fn test_validate_key_result_invalid() {
let result = ValidateKeyResult {
valid: false,
provider: "anthropic".to_string(),
message: Some("Validation failed: key too short".to_string()),
};
assert!(!result.valid);
assert!(result.message.as_ref().unwrap().contains("failed"));
}
#[test]
fn test_routing_stats_snapshot() {
let stats = RoutingStats::new();
stats.record_model_usage("anthropic/claude-sonnet-4", 0.05);
stats.record_model_usage("anthropic/claude-sonnet-4", 0.03);
stats.record_model_usage("openai/gpt-4o-mini", 0.01);
let snap = stats.snapshot();
assert_eq!(snap.total_requests, 3);
assert_eq!(snap.model_calls["anthropic/claude-sonnet-4"], 2);
assert_eq!(snap.model_calls["openai/gpt-4o-mini"], 1);
assert!((snap.total_cost - 0.09).abs() < 0.001);
}
#[test]
fn test_fallback_history_circular() {
let stats = RoutingStats::new();
for i in 0..210 {
stats.record_fallback(FallbackEvent {
timestamp: DateTime::from_timestamp(i as i64, 0).unwrap(),
from_model: format!("model-{}", i),
to_model: "fallback".to_string(),
reason: "test".to_string(),
success: true,
});
}
let history = stats.fallback_history(200);
assert_eq!(history.len(), 200);
assert_eq!(history[0].from_model, "model-209");
assert_eq!(history[199].from_model, "model-10");
}
}