Skip to main content

oxios_kernel/kernel_handle/
engine_api.rs

1//! Engine API — LLM engine introspection + config writes + routing control.
2//!
3//! Provides access to the oxi-sdk model catalog (providers, models, search)
4//! and write operations that persist to config.toml (model, API key, routing).
5//!
6//! Routing statistics (`RoutingStats`) are shared between this API and
7//! `AgentRuntime` via an `Arc`, so model usage is recorded end-to-end.
8
9use 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// ── Routing types ─────────────────────────────────────────────────────────────
20
21/// Snapshot of routing configuration (read-only API response).
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct RoutingConfigSnapshot {
25    /// Whether automatic model routing is enabled.
26    pub routing_enabled: bool,
27    /// Whether cost-efficient models are preferred when routing.
28    pub prefer_cost_efficient: bool,
29    /// Ordered list of fallback models (tried left-to-right on primary failure).
30    pub fallback_models: Vec<String>,
31    /// Models excluded from automatic routing.
32    pub excluded_models: Vec<String>,
33}
34
35/// Model usage statistics.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct RoutingStatsSnapshot {
39    /// Model ID → number of calls.
40    pub model_calls: HashMap<String, u64>,
41    /// Model ID → estimated total cost (USD).
42    pub model_cost: HashMap<String, f64>,
43    /// Total number of requests.
44    pub total_requests: u64,
45    /// Total estimated cost (USD).
46    pub total_cost: f64,
47}
48
49/// Single fallback event record.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct FallbackEvent {
53    /// When the fallback occurred.
54    pub timestamp: DateTime<Utc>,
55    /// Model that was skipped/replaced.
56    pub from_model: String,
57    /// Model that was used instead.
58    pub to_model: String,
59    /// Reason for fallback (e.g. "rate_limit", "context_overflow", "error").
60    pub reason: String,
61    /// Whether the fallback succeeded (no further fallback needed).
62    pub success: bool,
63}
64
65/// Request body for `PUT /api/engine/routing`.
66#[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
75// ── RoutingStats ─────────────────────────────────────────────────────────────
76
77/// In-memory routing statistics, shared between `EngineApi` and `AgentRuntime`.
78/// Uses simple RwLock for thread-safe reads/writes.
79pub struct RoutingStats {
80    calls: RwLock<HashMap<String, u64>>,
81    costs: RwLock<HashMap<String, f64>>,
82    /// Circular buffer of recent fallback events (max 200).
83    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    /// Create a new stats tracker.
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Record one model invocation.
103    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    /// Record a fallback event.
113    ///
114    /// Uses `VecDeque` so trimming is O(1) (`pop_front`) instead of the O(n)
115    /// memmove that `Vec::drain(0..keep)` performs under the write lock.
116    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    /// Get a snapshot of current stats.
125    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    /// Get recent fallback events, newest first.
139    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
145// ── Model cost estimation ────────────────────────────────────────────────────
146
147/// Estimate cost in USD for a model given token usage.
148/// Uses oxi-sdk's model_db for per-model pricing.
149pub 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            // Fall back to a rough estimate for unknown models
161            (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// ── Provider/Model response types ──────────────────────────────────────────
168
169/// Provider category for UI grouping.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "lowercase")]
172pub enum ProviderCategory {
173    /// Major providers (Anthropic, OpenAI, Google).
174    Major,
175    /// Open / specialty providers (Groq, OpenRouter, DeepSeek, etc.).
176    Open,
177    /// Regional providers.
178    Regional,
179    /// Local / self-hosted providers.
180    Local,
181}
182
183/// Static metadata for an LLM provider.
184///
185/// This table is the **single source of truth** for provider-facing
186/// metadata in the Web UI. It enriches the dynamic list returned by
187/// `oxi_sdk::get_providers()` with human-friendly labels, UI grouping,
188/// and a flag for providers that should not be exposed to the Web
189/// dashboard (e.g. those requiring non-API-key auth like AWS SigV4 or
190/// OAuth, or region-specific endpoints).
191///
192/// New providers added to `oxi-sdk` automatically appear in the UI
193/// with sensible fallbacks (`Open` category, derived display name)
194/// even before they get an entry here.
195#[derive(Debug, Clone, Copy)]
196struct ProviderMeta {
197    /// Canonical provider id (matches `oxi_sdk::get_providers()`).
198    id: &'static str,
199    /// Human-readable name shown in dropdowns and badges.
200    display_name: &'static str,
201    /// UI grouping for the provider selector.
202    category: ProviderCategory,
203    /// Whether to exclude from the Web UI providers list.
204    /// Used for providers with non-standard auth (AWS SigV4, OAuth,
205    /// account-scoped URLs) or that are region-specific duplicates.
206    hidden: bool,
207    /// Short description for tooltips / help text.
208    description: &'static str,
209    /// Primary environment variable name holding the API key.
210    /// Empty string when the provider does not use a single env var
211    /// (e.g. AWS Bedrock uses a credential chain).
212    env_key: &'static str,
213    /// Alternative ids that should resolve to this provider.
214    /// Used so that an alias such as `aws-bedrock` matches the
215    /// canonical `amazon-bedrock` entry.
216    aliases: &'static [&'static str],
217}
218
219/// All provider metadata, in a single static table.
220///
221/// Order is for human readability only — the runtime lookup is O(n)
222/// linear scan, which is fine for ~30 entries. If the table grows
223/// past ~100 entries, swap to a `phf` or `once_cell` hash map.
224const PROVIDER_META: &[ProviderMeta] = &[
225    // ── Major (top 3) ──────────────────────────────────────────────
226    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    // ── Open / specialty (gateways + open-weight hosts) ────────────
254    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    // ── Regional (Chinese / Asian providers) ───────────────────────
372    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    // ── Hidden in Web UI today; mapped for forward-compatibility ───
409    // These providers are not exposed by `EngineHandle::providers()`
410    // because they require non-standard auth or region-specific setup,
411    // but listing them here means the metadata is already wired up if
412    // a future change decides to surface them.
413    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
514/// Look up metadata by canonical id or alias.
515fn 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
527/// Resolve a display name for a provider id.
528///
529/// Falls back to a Title-Cased id for unknown providers so that
530/// newly added `oxi-sdk` providers still render acceptably until a
531/// real entry lands in [`PROVIDER_META`].
532fn 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
538/// Render a fallback display name by splitting on `-` / `_` and
539/// Title-Casing each segment. Examples:
540///   `"kimi-coding"`   → `"Kimi Coding"`
541///   `"some_id"`       → `"Some Id"`
542///   `"openai"`        → `"Openai"`
543fn 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/// Summary of an LLM provider.
558#[derive(Debug, Clone, Serialize, Deserialize)]
559#[serde(rename_all = "camelCase")]
560pub struct ProviderInfo {
561    /// Provider identifier (e.g. "anthropic", "openai").
562    pub id: String,
563    /// Human-readable display name.
564    pub name: String,
565    /// Category for UI grouping.
566    pub category: ProviderCategory,
567    /// Number of models available for this provider.
568    pub model_count: usize,
569    /// Whether an API key is currently configured.
570    pub has_key: bool,
571    /// Source of the API key: `"env"`, `"auth_store"`, `"config"`, or `"none"`.
572    /// Used by the Web UI to determine whether the key is removable
573    /// (env-var-sourced keys cannot be cleared via the API).
574    #[serde(default)]
575    pub key_source: String,
576    /// Short description for tooltips / help text. Empty for unknown
577    /// providers that have no entry in [`PROVIDER_META`].
578    #[serde(default)]
579    pub description: String,
580    /// Primary environment variable name for the API key. Empty for
581    /// providers that do not use a single env var (e.g. AWS Bedrock
582    /// uses a credential chain rather than a single API key var).
583    #[serde(default)]
584    pub env_key: String,
585}
586
587/// Input modality for a model.
588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
589#[serde(rename_all = "lowercase")]
590pub enum InputModality {
591    /// Text input.
592    Text,
593    /// Image input (vision).
594    Image,
595}
596
597/// Summary of a model from the catalog.
598#[derive(Debug, Clone, Serialize, Deserialize)]
599#[serde(rename_all = "camelCase")]
600pub struct ModelInfo {
601    /// Full model ID: "provider/model-id".
602    pub id: String,
603    /// Human-readable model name.
604    pub name: String,
605    /// API protocol used by the model's provider.
606    pub api: String,
607    /// Provider name.
608    pub provider: String,
609    /// Whether this model supports reasoning/thinking.
610    pub reasoning: bool,
611    /// Supported input modalities.
612    pub input: Vec<InputModality>,
613    /// Maximum context window in tokens.
614    pub context_window: u32,
615    /// Maximum output tokens.
616    pub max_tokens: u32,
617    /// Cost per million input tokens (USD).
618    pub cost_input: f64,
619    /// Cost per million output tokens (USD).
620    pub cost_output: f64,
621    /// Cost per million cached read tokens (USD).
622    pub cost_cache_read: f64,
623    /// Cost per million cached write tokens (USD).
624    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    /// Build a [`ModelInfo`] from a live catalog entry (catalog port).
656    ///
657    /// Same fields as the [`ModelEntry`](oxi_sdk::ModelEntry) path; the
658    /// catalog entry additionally reflects runtime models.dev refresh +
659    /// user overrides when wired into the engine.
660    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/// Current engine configuration + credential status + routing.
686#[derive(Debug, Clone, Serialize, Deserialize)]
687pub struct EngineConfigResponse {
688    /// Currently configured default model.
689    pub default_model: String,
690    /// Whether an API key is set for the current provider.
691    pub api_key_set: bool,
692    /// Source of the API key (if any).
693    pub api_key_source: Option<String>,
694    /// Provider name extracted from default_model.
695    pub provider: Option<String>,
696    /// Current routing configuration.
697    pub routing: RoutingConfigSnapshot,
698    /// Role-based model routing config (RFC-032).
699    #[serde(default, skip_serializing_if = "Option::is_none")]
700    pub role_routing: Option<crate::config::RoleRoutingConfig>,
701    /// Default model for one-shot (QuickAsk) requests. None ⇒ falls back to
702    /// `default_model`.
703    #[serde(default, skip_serializing_if = "Option::is_none")]
704    pub quick_ask_model: Option<String>,
705}
706
707/// Result of an API key validation attempt.
708#[derive(Debug, Clone, Serialize, Deserialize)]
709pub struct ValidateKeyResult {
710    /// Whether the key is valid.
711    pub valid: bool,
712    /// Provider that was validated.
713    pub provider: String,
714    /// Optional message (error detail or success note).
715    pub message: Option<String>,
716}
717
718// ── EngineApi ───────────────────────────────────────────────────────────────
719
720/// Engine API facade — model catalog introspection + config writes + routing.
721///
722/// Holds a shared reference to the live config (behind `RwLock`) and the
723/// path to config.toml so write operations can persist to disk.
724/// Routing stats are shared with `AgentRuntime` via `Arc<RoutingStats>`.
725///
726/// When config writes change the model or API key, `EngineApi` rebuilds
727/// `OxiosEngine` via [`EngineHandle`] so the runtime picks up the change
728/// on the next agent execution (hot-swap, no restart required).
729pub struct EngineApi {
730    config: Arc<RwLock<OxiosConfig>>,
731    config_path: PathBuf,
732    routing_stats: Arc<RoutingStats>,
733    /// Hot-swap handle — config writes rebuild `OxiosEngine` and swap it in.
734    engine_handle: Arc<crate::engine::EngineHandle>,
735}
736
737impl EngineApi {
738    /// Create a new EngineApi.
739    ///
740    /// - `config` — shared config store (backed by RwLock)
741    /// - `config_path` — path to config.toml for persistence
742    /// - `routing_stats` — shared stats tracker (shared with AgentRuntime)
743    /// - `engine_handle` — hot-swap handle for live engine replacement
744    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    /// Get the shared `RoutingStats` reference (for `AgentRuntime` wiring).
759    pub fn routing_stats(&self) -> Arc<RoutingStats> {
760        Arc::clone(&self.routing_stats)
761    }
762
763    /// Get a reference to the engine handle.
764    pub fn engine_handle(&self) -> &Arc<crate::engine::EngineHandle> {
765        &self.engine_handle
766    }
767
768    /// Validate that a model ID is resolvable by the current engine.
769    ///
770    /// Checks the catalog→static resolution path (same as
771    /// `agent_runtime.rs:503` and `AgentBuilder::build()`). Use this
772    /// to reject unknown model IDs early — before the orchestrator
773    /// wastes time on assess/crystallize for a model that can't stream.
774    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    /// RFC-032: Get the current role routing config (role → model mapping).
782    pub fn role_routing(&self) -> crate::config::RoleRoutingConfig {
783        self.config.read().engine.role_routing.clone()
784    }
785
786    /// RFC-032: Resolve the model ID for a given role, if configured.
787    /// Reads the LIVE config under its shared RwLock so updates take
788    /// effect immediately. Returns `None` when the role is not in
789    /// the mapping.
790    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    /// RFC-032: Update role routing config and persist to config.toml.
801    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    // ── Read operations ────────────────────────────────────────────────
816
817    /// List all available providers from the oxi-sdk catalog.
818    ///
819    /// Reads provider/model counts from the live catalog (runtime models.dev
820    /// refresh + user overrides) when wired into the engine, falling back to
821    /// the static registry otherwise.
822    ///
823    /// Filters out hidden/internal providers (those flagged with
824    /// `hidden: true` in [`PROVIDER_META`]) and augments each entry
825    /// with credential status, display name, and description.
826    ///
827    /// Providers without a [`PROVIDER_META`] entry are shown by
828    /// default — a new provider landing in `oxi-sdk` should be
829    /// available to users even before its metadata is added here.
830    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        // Hoist the read-lock out of the per-provider closure: the previous
843        // implementation took the lock once per provider (~30 times) and
844        // re-read the same api_key each time. One read + clone is enough.
845        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    /// List models for a given provider, optionally filtered by a query.
887    ///
888    /// Reads from the live catalog (runtime models.dev refresh + user
889    /// overrides) when wired into the engine, falling back to the static
890    /// registry (embedded snapshot) otherwise.
891    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    /// Search models across all providers.
919    ///
920    /// Uses the live catalog's `search_sync` when available, else the static
921    /// registry.
922    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    /// Get the current engine configuration + credential status + routing.
936    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    /// Get recent fallback history.
982    pub fn fallback_history(&self, limit: usize) -> Vec<FallbackEvent> {
983        self.routing_stats.fallback_history(limit)
984    }
985
986    // ── Write operations ───────────────────────────────────────────────
987
988    /// Set the default model in config.toml.
989    ///
990    /// Updates both the in-memory config and the on-disk file, then
991    /// hot-swaps the runtime engine so the next agent execution uses the new model.
992    pub fn set_model(&self, model_id: &str) -> anyhow::Result<()> {
993        // Validate BEFORE persisting/swapping: reject unknown models and
994        // unconfigured providers so the Web UI's "switch succeeded" is truthful.
995        // This prevents the divergence where a bad model ID was silently
996        // accepted at swap time and only surfaced as "Model not found" at the
997        // execute phase — after interview/crystallize had already run.
998        {
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        // Persist outside the write lock — synchronous fs::write under the
1016        // lock would serialize every reader (providers/config/routing_stats).
1017        self.persist(&snapshot)?;
1018        tracing::info!(model = %model_id, "Default model updated in config");
1019        self.rebuild_and_swap();
1020        Ok(())
1021    }
1022
1023    /// Set the default model for one-shot (QuickAsk) requests.
1024    ///
1025    /// Unlike `set_model`, this does NOT validate the model or hot-swap the
1026    /// runtime — it is a pure config value. The one-shot WS message carries
1027    /// it as `model` → `model_override`, which the agent runtime validates at
1028    /// execute time (`agent_runtime.rs:481-484`).
1029    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    /// Set an API key for a provider.
1041    ///
1042    /// Stores the key via CredentialStore (→ ~/.oxi/auth.json) and also
1043    /// updates config.toml's `[engine].api_key` when the provider matches
1044    /// the current default model. Hot-swaps the runtime engine afterward.
1045    pub fn set_api_key(&self, provider: &str, key: &str) -> anyhow::Result<()> {
1046        CredentialStore::store(provider, key)?;
1047
1048        // Acquire the write lock up-front and do the provider-match check and
1049        // the assignment atomically. The previous read-lock-then-write-lock
1050        // sequence was a TOCTOU: another writer could change `default_model`
1051        // between the check and the assignment, leaving an api_key stored
1052        // against the wrong provider.
1053        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            // Persist outside the lock (see set_model).
1066            self.persist(&snap)?;
1067        }
1068        tracing::info!(provider = %provider, "API key stored");
1069        self.rebuild_and_swap();
1070        Ok(())
1071    }
1072
1073    /// Clear an API key for a provider.
1074    ///
1075    /// Removes the key from config.toml's `[engine].api_key` (when the
1076    /// provider matches the current default model) and rebuilds the engine
1077    /// so the credential is dropped from the running process. The key in
1078    /// `~/.oxi/auth.json` must be removed separately via `CredentialStore::delete`.
1079    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    /// Delete a provider's API key entirely.
1100    ///
1101    /// Removes the credential from both the auth store (`~/.oxi/auth.json`)
1102    /// and `config.toml` (when the provider matches the current default).
1103    /// Hot-swaps the runtime engine so the credential is dropped immediately.
1104    ///
1105    /// Note: keys sourced from environment variables (`OXIOS_<PROVIDER>_API_KEY`
1106    /// or provider-native vars) cannot be removed via this method — they persist
1107    /// as long as the env var is set. The caller should check the credential
1108    /// source before offering a "remove" action.
1109    pub fn delete_api_key(&self, provider: &str) -> anyhow::Result<()> {
1110        CredentialStore::delete(provider)?;
1111        // Also clear from config.toml if this is the default provider.
1112        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    /// Update provider options in config.toml.
1132    ///
1133    /// Persists the options and makes them available for the next agent run.
1134    /// They are passed through to `AgentLoopConfig::provider_options`.
1135    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        // No engine rebuild needed — provider_options are per-request,
1144        // picked up from config on the next agent run.
1145        Ok(())
1146    }
1147
1148    /// Update routing configuration in config.toml.
1149    ///
1150    /// Only the fields provided in `update` are changed; others are left untouched.
1151    /// Changes are persisted to disk immediately.
1152    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    /// Validate an API key by making a real minimal completion request.
1176    ///
1177    /// Sends a 1-token "Hi" request to the provider's API. If the key
1178    /// is invalid or expired, the provider returns an auth error.
1179    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    /// Validate the stored API key for a provider.
1195    ///
1196    /// Resolves the key from the credential store (env var → config → auth.json)
1197    /// and validates it via a real API call. Returns `valid: false` with a
1198    /// descriptive message when no key is found.
1199    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    /// Make a real minimal API call to verify the key works.
1215    ///
1216    /// Sends a "Hi" completion request with a 15-second timeout.
1217    /// Invalid/expired keys trigger an immediate auth error from the provider.
1218    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    // ── Helpers ─────────────────────────────────────────────────────────
1261
1262    /// Estimate cost for a model invocation.
1263    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    /// Persist the current config to disk.
1268    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    /// Rebuild `OxiosEngine` from current config and swap into the handle.
1276    ///
1277    /// Reuses the model catalog from the current engine (it holds the
1278    /// in-memory models.dev snapshot — re-initializing it on every config
1279    /// change would just reload the same data). No network calls beyond
1280    /// what `CredentialStore` already caches in memory.
1281    fn rebuild_and_swap(&self) {
1282        // Narrow the read-lock window: clone the small fields we need, then
1283        // build the engine outside the lock so concurrent readers/writers are
1284        // not blocked by `OxiosEngine::from_config_with_catalog` work.
1285        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
1307// Expose `RoutingStats::record_model_usage` via a public helper for AgentRuntime.
1308// This avoids exposing the internal Arc to outside crates.
1309pub 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        // Major
1328        assert_eq!(provider_category("anthropic"), ProviderCategory::Major);
1329        assert_eq!(provider_category("openai"), ProviderCategory::Major);
1330        assert_eq!(provider_category("google"), ProviderCategory::Major);
1331        // Open / specialty
1332        assert_eq!(provider_category("groq"), ProviderCategory::Open);
1333        assert_eq!(provider_category("opencode"), ProviderCategory::Open);
1334        // Regional
1335        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        // Unknown ids fall back to Open, not panic.
1346        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        // Unknown ids get Title-Cased per segment as a fallback.
1367        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        // Empty string stays empty.
1374        assert_eq!(provider_display_name(""), "");
1375    }
1376
1377    #[test]
1378    fn test_provider_meta_lookup_by_alias() {
1379        // Aliases resolve to the same meta entry as the canonical id.
1380        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        // camelCase serialization
1411        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        // description / env_key have serde(default) so old clients that
1425        // omit them still deserialize cleanly.
1426        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        // Most recent first (i=209 down to i=10)
1541        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        // Validation runs before any IO, so a non-existent path is safe — it
1553        // must never be written to.
1554        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        // The malformed id from the user-reported bug. Must be rejected, not
1558        // silently accepted and deferred to the execute phase.
1559        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        // Rejection happened before persist: config is untouched.
1566        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        // A builtin model with a built-in provider resolves + creates a provider
1581        // without any API key, so validation passes. The swap should succeed.
1582        let result = api.set_model("openai/gpt-4o");
1583        // create_provider may still fail without a key on some SDK builds; treat
1584        // both Ok and a provider-config error as acceptable, but never an
1585        // "Unknown model" rejection for a known builtin.
1586        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}