Skip to main content

oxios_kernel/
engine.rs

1//! Engine provider — wraps oxi-sdk's `Oxi` for the kernel.
2//!
3//! All provider/model resolution goes through `oxi_sdk::OxiBuilder`.
4//! The `OxiosEngine` struct wraps the SDK instance and exposes a clean API
5//! with support for routing, credentials, provider pooling, and multi-provider fallback.
6//!
7//! # Architecture
8//!
9//! ```text
10//! OxiosEngine (OxiBuilder → Oxi)
11//!   ├── resolve_model("provider/model") → Model
12//!   ├── create_provider("anthropic")     → Arc<dyn Provider>
13//!   ├── pooled_provider("anthropic")     → Arc<dyn Provider> (rate-limited)
14//!   ├── oxi()                            → &Oxi (for AgentBuilder, etc.)
15//!   └── agent(AgentConfig)               → AgentBuilder
16//! ```
17
18use anyhow::Result;
19use oxi_sdk::{
20    CatalogConfig, FileModelCatalog, ModelCatalog, Oxi, OxiBuilder, ProviderPool, RateLimitPolicy,
21};
22use std::sync::Arc;
23
24use crate::credential::{CredentialStore, discover_auth_store_providers};
25
26/// The kernel's engine — wraps oxi-sdk's Oxi instance.
27///
28/// Created via [`OxiosEngine::new()`] or [`OxiosEngine::builder()`].
29/// Provides access to providers, models, routing, pooling, and agent construction.
30///
31/// # RFC-014 Phase D
32///
33/// `authorizer` / `tracer` / `cost_tracker` are optional, engine-level
34/// observability and security handles. When set, they are propagated to
35/// every agent built via [`OxiosEngine::oxi().agent()`][Oxi::agent] using
36/// the new `AgentBuilder::authorizer()` / `.tracer()` / `.cost_tracker()`
37/// API. All three are `None` by default, keeping the existing call sites
38/// fully backward compatible.
39pub struct OxiosEngine {
40    oxi: Oxi,
41    default_model_id: String,
42    /// Runtime routing control for dynamic model selection.
43    routing_control: Option<oxi_sdk::RoutingControl>,
44    /// Pooled providers with rate limiting.
45    /// Key: provider name (e.g. "anthropic"), Value: ProviderPool wrapper.
46    pools: parking_lot::RwLock<std::collections::HashMap<String, Arc<dyn oxi_sdk::Provider>>>,
47    /// ── RFC-014 Phase D: engine-level observability/security handles ──
48    /// When `Some`, these are attached to every `Agent` built via the
49    /// `AgentBuilder` API in `agent_runtime.rs::run_agent()`.
50    /// Default: `None` (preserves pre-Phase-D behavior).
51    authorizer: Option<Arc<oxi_sdk::Authorizer>>,
52    tracer: Option<Arc<oxi_sdk::Tracer>>,
53    cost_tracker: Option<Arc<oxi_sdk::CostTracker>>,
54}
55
56impl OxiosEngine {
57    /// Create a new engine with the given default model.
58    ///
59    /// Internally calls `OxiBuilder::new().with_builtins()` to load all
60    /// built-in models and providers.
61    pub fn new(default_model_id: impl Into<String>) -> Self {
62        let model_id = default_model_id.into();
63        let oxi = OxiBuilder::new().with_builtins().build();
64        Self {
65            oxi,
66            default_model_id: model_id,
67            routing_control: None,
68            pools: parking_lot::RwLock::new(std::collections::HashMap::new()),
69            // RFC-014 Phase D: optional, off by default
70            authorizer: None,
71            tracer: None,
72            cost_tracker: None,
73        }
74    }
75
76    /// Create a new engine with credentials from config.
77    ///
78    /// Resolves API keys from CredentialStore for each known provider
79    /// and injects them into the OxiBuilder. This enables the engine
80    /// to create properly authenticated providers.
81    ///
82    /// Resolution order (per provider): env var → config.toml → ~/.oxi/auth.json
83    ///
84    /// No model catalog is wired (resolves via the static registry only).
85    /// For dynamic models.dev metadata use
86    /// [`from_config_with_catalog`](Self::from_config_with_catalog).
87    pub fn from_config(default_model_id: impl Into<String>, config_api_key: Option<&str>) -> Self {
88        Self::from_config_with_catalog_opt(default_model_id, config_api_key, None)
89    }
90
91    /// Like [`from_config`](Self::from_config) but wires a model catalog port
92    /// into the engine.
93    ///
94    /// Pass the shared catalog from
95    /// [`init_file_catalog`](Self::init_file_catalog) so dynamic models.dev
96    /// metadata (live prices/limits, user overrides, local discovery) is
97    /// reused across engine hot-swaps instead of re-initialized. `resolve_model`
98    /// then consults the catalog before falling back to the static registry.
99    pub fn from_config_with_catalog(
100        default_model_id: impl Into<String>,
101        config_api_key: Option<&str>,
102        catalog: Arc<dyn ModelCatalog>,
103    ) -> Self {
104        Self::from_config_with_catalog_opt(default_model_id, config_api_key, Some(catalog))
105    }
106
107    fn from_config_with_catalog_opt(
108        default_model_id: impl Into<String>,
109        config_api_key: Option<&str>,
110        catalog: Option<Arc<dyn ModelCatalog>>,
111    ) -> Self {
112        let model_id = default_model_id.into();
113
114        // Resolve the primary provider's credential
115        let primary_provider = model_id
116            .split_once('/')
117            .map(|(p, _)| p)
118            .unwrap_or("anthropic");
119
120        let mut builder = OxiBuilder::new().with_builtins();
121
122        // Collect all providers that need credential injection:
123        // 1. Known major providers (always try to resolve)
124        // 2. Any provider found in ~/.oxi/auth.json (discovered dynamically)
125        // 3. The primary provider (from the default model)
126        let mut providers_to_try: Vec<String> = vec![
127            "anthropic".into(),
128            "openai".into(),
129            "google".into(),
130            "deepseek".into(),
131            "xai".into(),
132            "groq".into(),
133            "openrouter".into(),
134            "mistral".into(),
135            "cerebras".into(),
136            "fireworks".into(),
137            "github-copilot".into(),
138            "huggingface".into(),
139            "together".into(),
140            "minimax".into(),
141            "moonshotai".into(),
142            "kimi-coding".into(),
143            "zai".into(),
144            "opencode".into(),
145        ];
146
147        // Discover any additional providers from auth.json that aren't in the
148        // known list (e.g. custom/third-party providers).
149        if let Ok(extra) = discover_auth_store_providers() {
150            for p in extra {
151                if !providers_to_try.contains(&p) {
152                    providers_to_try.push(p);
153                }
154            }
155        }
156
157        // Ensure the primary provider is always included.
158        let primary_owned = primary_provider.to_string();
159        if !providers_to_try.contains(&primary_owned) {
160            providers_to_try.push(primary_owned);
161        }
162
163        for provider in &providers_to_try {
164            // Use the config-level key only for the primary provider;
165            // other providers resolve from env/auth.json.
166            let config_key = if provider == primary_provider {
167                config_api_key
168            } else {
169                None
170            };
171
172            if let Some((key, source)) = CredentialStore::resolve(provider, config_key) {
173                tracing::debug!(
174                    provider,
175                    source = ?source,
176                    "Injected credential into engine"
177                );
178                builder = builder.api_key(provider, key);
179            }
180        }
181
182        let builder = match catalog {
183            Some(cat) => builder.with_catalog(cat),
184            None => builder,
185        };
186        let oxi = builder.build();
187        Self {
188            oxi,
189            default_model_id: model_id,
190            routing_control: None,
191            pools: parking_lot::RwLock::new(std::collections::HashMap::new()),
192            // RFC-014 Phase D: optional, off by default
193            authorizer: None,
194            tracer: None,
195            cost_tracker: None,
196        }
197    }
198
199    /// Create an engine builder for advanced configuration.
200    ///
201    /// Use this when you need credential injection, routing, or
202    /// custom provider registration.
203    ///
204    /// # Catalog
205    ///
206    /// For dynamic models.dev metadata, initialize the catalog via
207    /// [`OxiosEngine::init_file_catalog`] and attach it with
208    /// [`with_catalog`](OxiosEngineBuilder::with_catalog):
209    ///
210    /// ```no_run
211    /// # async fn doc() -> anyhow::Result<()> {
212    /// use oxios_kernel::engine::OxiosEngine;
213    ///
214    /// let catalog = OxiosEngine::init_file_catalog().await?;
215    /// let engine = OxiosEngine::builder()
216    ///     .default_model("anthropic/claude-sonnet-4-20250514")
217    ///     .with_catalog(catalog)
218    ///     .build();
219    /// # Ok(()) }
220    /// ```
221    ///
222    /// # RFC-014 Phase D
223    ///
224    /// The builder also exposes `.with_authorizer()` / `.with_tracer()` /
225    /// `.with_cost_tracker()` for attaching engine-level observability
226    /// and security handles. All three are `None` by default.
227    pub fn builder() -> OxiosEngineBuilder {
228        OxiosEngineBuilder {
229            inner: OxiBuilder::new().with_builtins(),
230            default_model_id: "anthropic/claude-sonnet-4-20250514".to_string(),
231            // RFC-014 Phase D: optional, off by default
232            authorizer: None,
233            tracer: None,
234            cost_tracker: None,
235        }
236    }
237
238    /// Build a [`CatalogConfig`] rooted at the oxios home (`~/.oxios/`).
239    ///
240    /// Keeps the models.dev cache/overrides self-hosted under oxios's own
241    /// directory (not oxi's `~/.oxi/`), consistent with the MCP cache/consent
242    /// path customization. Local-server discovery (`ollama`/`lmstudio`) is
243    /// left empty — wire it later if oxios wants to auto-discover local
244    /// models.
245    pub fn catalog_config() -> CatalogConfig {
246        let home = dirs::home_dir()
247            .unwrap_or_else(|| std::path::PathBuf::from("."))
248            .join(".oxios");
249        CatalogConfig {
250            cache_path: home.join("cache/models-dev.json"),
251            etag_path: home.join("cache/models-dev.json.etag"),
252            override_path: home.join("catalog/overrides.toml"),
253            snapshot_path: home.join("cache/models-dev.json"),
254            // oxios doesn't probe local servers yet.
255            local_discovery_urls: Vec::new(),
256            ..CatalogConfig::default()
257        }
258    }
259
260    /// Initialize the shared [`FileModelCatalog`] for the engine.
261    ///
262    /// Loads the embedded models.dev snapshot + runtime cache, applies user
263    /// overrides, and (if the cache is stale) attempts one live refresh
264    /// (failure is silent — the snapshot serves as fallback). The returned
265    /// `Arc<dyn ModelCatalog>` is cheap to clone and should be **shared**
266    /// across engine hot-swaps: the catalog is lazy/on-call (no background
267    /// tasks), so re-initializing it on every rebuild would just reload the
268    /// snapshot needlessly.
269    pub async fn init_file_catalog() -> Result<Arc<dyn ModelCatalog>> {
270        let catalog: Arc<dyn ModelCatalog> = FileModelCatalog::init(Self::catalog_config())
271            .await
272            .map_err(|e| anyhow::anyhow!("Failed to initialize model catalog: {e}"))?;
273        Ok(catalog)
274    }
275
276    /// Get a reference to the underlying Oxi instance.
277    ///
278    /// Use this when you need to pass the engine to oxi-sdk APIs directly
279    /// (e.g., `AgentBuilder`, `MessageBus`, `AgentGroup`).
280    pub fn oxi(&self) -> &Oxi {
281        &self.oxi
282    }
283
284    /// RFC-014 Phase D: get the engine-level `Authorizer`, if any.
285    ///
286    /// When `Some`, the authorizer is attached to every `Agent` built via
287    /// `Oxi::agent().authorizer(...)` in `agent_runtime.rs::run_agent()`.
288    pub fn authorizer(&self) -> Option<&Arc<oxi_sdk::Authorizer>> {
289        self.authorizer.as_ref()
290    }
291
292    /// RFC-014 Phase D: get the engine-level `Tracer`, if any.
293    ///
294    /// When `Some`, the tracer is attached to every `Agent` built via
295    /// `Oxi::agent().tracer(...)` in `agent_runtime.rs::run_agent()`.
296    pub fn tracer(&self) -> Option<&Arc<oxi_sdk::Tracer>> {
297        self.tracer.as_ref()
298    }
299
300    /// RFC-014 Phase D: get the engine-level `CostTracker`, if any.
301    ///
302    /// When `Some`, the cost tracker is attached to every `Agent` built via
303    /// `Oxi::agent().cost_tracker(...)` in `agent_runtime.rs::run_agent()`.
304    pub fn cost_tracker(&self) -> Option<&Arc<oxi_sdk::CostTracker>> {
305        self.cost_tracker.as_ref()
306    }
307
308    /// Resolve a model ID to a Model.
309    pub fn resolve_model(&self, model_id: &str) -> Result<oxi_sdk::Model> {
310        self.oxi.resolve_model(model_id)
311    }
312
313    /// Create a provider for the given provider name.
314    pub fn create_provider(&self, name: &str) -> Result<Arc<dyn oxi_sdk::Provider>> {
315        self.oxi.create_provider(name)
316    }
317
318    /// Get the default model ID.
319    pub fn default_model_id(&self) -> &str {
320        &self.default_model_id
321    }
322
323    /// Get the routing control, if routing is enabled.
324    pub fn routing_control(&self) -> Option<&oxi_sdk::RoutingControl> {
325        self.routing_control.as_ref()
326    }
327
328    /// Get a rate-limited provider from the pool.
329    ///
330    /// On first call for a provider name, creates a `ProviderPool` wrapping
331    /// the base provider with the given RPM/concurrency limits.
332    /// Subsequent calls return the same pooled instance.
333    ///
334    /// If no rate limit is needed, returns the base provider directly.
335    pub fn pooled_provider(&self, name: &str, rpm: u32) -> Result<Arc<dyn oxi_sdk::Provider>> {
336        // Check if already pooled.
337        {
338            let pools = self.pools.read();
339            if let Some(pooled) = pools.get(name) {
340                return Ok(pooled.clone());
341            }
342        }
343
344        // Create new pool.
345        let base = self.create_provider(name)?;
346        let policy = RateLimitPolicy::rpm(rpm);
347        let pool = ProviderPool::new(base, policy, name);
348        let pooled: Arc<dyn oxi_sdk::Provider> = Arc::new(pool);
349
350        // Cache it.
351        {
352            let mut pools = self.pools.write();
353            pools.insert(name.to_string(), pooled.clone());
354        }
355
356        tracing::info!(provider = name, rpm, "Created provider pool");
357        Ok(pooled)
358    }
359}
360
361// ---------------------------------------------------------------------------
362// EngineBuilder
363// ---------------------------------------------------------------------------
364
365/// Builder for creating an `OxiosEngine` with advanced configuration.
366pub struct OxiosEngineBuilder {
367    inner: OxiBuilder,
368    default_model_id: String,
369    // ── RFC-014 Phase D: optional engine-level observability/security handles ──
370    // All default to `None` so existing builder chains remain unchanged.
371    authorizer: Option<Arc<oxi_sdk::Authorizer>>,
372    tracer: Option<Arc<oxi_sdk::Tracer>>,
373    cost_tracker: Option<Arc<oxi_sdk::CostTracker>>,
374}
375
376impl OxiosEngineBuilder {
377    /// Set the default model ID.
378    pub fn default_model(mut self, model_id: impl Into<String>) -> Self {
379        self.default_model_id = model_id.into();
380        self
381    }
382
383    /// Register an API key for a specific provider.
384    pub fn api_key(self, provider: &str, key: impl Into<String>) -> Self {
385        Self {
386            inner: self.inner.api_key(provider, key),
387            default_model_id: self.default_model_id,
388            authorizer: self.authorizer,
389            tracer: self.tracer,
390            cost_tracker: self.cost_tracker,
391        }
392    }
393
394    /// Register a full credential (API key + optional base URL).
395    pub fn credential(
396        self,
397        provider: &str,
398        api_key: impl Into<String>,
399        base_url: Option<&str>,
400    ) -> Self {
401        Self {
402            inner: self.inner.credential(provider, api_key, base_url),
403            default_model_id: self.default_model_id,
404            authorizer: self.authorizer,
405            tracer: self.tracer,
406            cost_tracker: self.cost_tracker,
407        }
408    }
409
410    /// Register a custom provider.
411    pub fn provider(self, name: &str, p: impl oxi_sdk::Provider + 'static) -> Self {
412        Self {
413            inner: self.inner.provider(name, p),
414            default_model_id: self.default_model_id,
415            authorizer: self.authorizer,
416            tracer: self.tracer,
417            cost_tracker: self.cost_tracker,
418        }
419    }
420
421    /// Build the engine.
422    pub fn build(self) -> OxiosEngine {
423        OxiosEngine {
424            oxi: self.inner.build(),
425            default_model_id: self.default_model_id,
426            routing_control: None,
427            pools: parking_lot::RwLock::new(std::collections::HashMap::new()),
428            // RFC-014 Phase D: optional, off by default
429            authorizer: self.authorizer,
430            tracer: self.tracer,
431            cost_tracker: self.cost_tracker,
432        }
433    }
434
435    /// Build the engine with routing enabled.
436    ///
437    /// Returns `(OxiosEngine, RoutingControl)` for runtime routing control.
438    pub fn build_with_routing(self) -> (OxiosEngine, oxi_sdk::RoutingControl) {
439        use oxi_sdk::RoutingControl;
440
441        let routing_config = oxi_sdk::routing::RoutingConfig::default();
442        let routing_control = RoutingControl::new(routing_config);
443        let engine = OxiosEngine {
444            oxi: self.inner.build(),
445            default_model_id: self.default_model_id,
446            routing_control: Some(routing_control.clone()),
447            pools: parking_lot::RwLock::new(std::collections::HashMap::new()),
448            // RFC-014 Phase D: optional, off by default
449            authorizer: self.authorizer,
450            tracer: self.tracer,
451            cost_tracker: self.cost_tracker,
452        };
453        (engine, routing_control)
454    }
455
456    // ── RFC-014 Phase D: engine-level observability/security handles ──
457    //
458    // These methods let callers attach shared `Authorizer` / `Tracer` /
459    // `CostTracker` instances to the engine. `agent_runtime.rs::run_agent()`
460    // reads them via `OxiosEngine::authorizer()` / `.tracer()` /
461    // `.cost_tracker()` and propagates them to the new `AgentBuilder` API.
462    //
463    // Backward compatible: all three are `None` by default.
464
465    /// Attach an `Authorizer` to the engine. Agents built via `Oxi::agent()`
466    /// will receive this authorizer through the new `AgentBuilder::authorizer()` API.
467    pub fn with_authorizer(mut self, authorizer: Arc<oxi_sdk::Authorizer>) -> Self {
468        self.authorizer = Some(authorizer);
469        self
470    }
471
472    /// Attach a `Tracer` to the engine. Agents built via `Oxi::agent()`
473    /// will receive this tracer through the new `AgentBuilder::tracer()` API.
474    pub fn with_tracer(mut self, tracer: Arc<oxi_sdk::Tracer>) -> Self {
475        self.tracer = Some(tracer);
476        self
477    }
478
479    /// Attach a `CostTracker` to the engine. Agents built via `Oxi::agent()`
480    /// will receive this cost tracker through the new `AgentBuilder::cost_tracker()` API.
481    pub fn with_cost_tracker(mut self, cost_tracker: Arc<oxi_sdk::CostTracker>) -> Self {
482        self.cost_tracker = Some(cost_tracker);
483        self
484    }
485
486    /// Wire a model catalog port (e.g. [`FileModelCatalog`]) into the engine.
487    ///
488    /// When set, `Oxi::resolve_model()` consults the catalog first (dynamic
489    /// models.dev metadata: live prices/limits, user overrides, local
490    /// discovery) before falling back to the static registry. Without this,
491    /// the engine uses a [`NoopModelCatalog`](oxi_sdk::NoopModelCatalog) and
492    /// resolves via the static `model_db` only.
493    ///
494    /// Initialize the catalog once via
495    /// [`OxiosEngine::init_file_catalog`] and reuse the `Arc` across rebuilds.
496    pub fn with_catalog(mut self, catalog: Arc<dyn oxi_sdk::ModelCatalog>) -> Self {
497        self.inner = self.inner.with_catalog(catalog);
498        self
499    }
500}
501
502// ---------------------------------------------------------------------------
503// EngineProvider trait (for testability and dependency inversion)
504// ---------------------------------------------------------------------------
505
506/// Engine provider trait — abstracts how the kernel obtains AI providers.
507///
508/// Implemented by `OxiosEngine` directly. Use a mock for testing.
509pub trait EngineProvider: Send + Sync {
510    /// Create a provider for the given provider name.
511    fn create_provider(&self, provider_name: &str) -> Result<Arc<dyn oxi_sdk::Provider>>;
512
513    /// Resolve a "provider/model" string to a Model.
514    fn resolve_model(&self, model_id: &str) -> Result<oxi_sdk::Model>;
515
516    /// Get the default model ID.
517    fn default_model_id(&self) -> &str;
518}
519
520impl EngineProvider for OxiosEngine {
521    fn create_provider(&self, provider_name: &str) -> Result<Arc<dyn oxi_sdk::Provider>> {
522        self.create_provider(provider_name)
523    }
524
525    fn resolve_model(&self, model_id: &str) -> Result<oxi_sdk::Model> {
526        self.resolve_model(model_id)
527    }
528
529    fn default_model_id(&self) -> &str {
530        &self.default_model_id
531    }
532}
533
534impl std::fmt::Debug for OxiosEngine {
535    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536        f.debug_struct("OxiosEngine")
537            .field("default_model_id", &self.default_model_id)
538            .field("routing_enabled", &self.routing_control.is_some())
539            .finish()
540    }
541}
542
543// ---------------------------------------------------------------------------
544// EngineHandle — hot-swappable engine reference
545// ---------------------------------------------------------------------------
546
547/// Shared, hot-swappable reference to the active [`OxiosEngine`].
548///
549/// Wraps `RwLock<Arc<OxiosEngine>>` so that:
550/// - **Writers** (`EngineApi`) can atomically replace the engine on config change
551/// - **Readers** (`AgentRuntime`) always get the current engine at execution time
552///
553/// # Cost
554///
555/// Rebuilding `OxiosEngine` is cheap: `OxiBuilder::new().with_builtins().build()`
556/// populates registries from static `model_db` data (~1μs, no I/O, no network).
557///
558/// # Concurrency
559///
560/// - `parking_lot::RwLock` is not async-aware, but engine swap only occurs on
561///   explicit user action (Web UI / CLI config change) — never in a hot path.
562/// - Agent execution reads the engine once at the start of `execute()` and
563///   uses the same `Arc<OxiosEngine>` for the entire run (consistent within one execution).
564pub struct EngineHandle {
565    inner: parking_lot::RwLock<Arc<OxiosEngine>>,
566}
567
568impl EngineHandle {
569    /// Create a new handle wrapping the given engine.
570    pub fn new(engine: Arc<OxiosEngine>) -> Self {
571        Self {
572            inner: parking_lot::RwLock::new(engine),
573        }
574    }
575
576    /// Get a snapshot of the current engine.
577    ///
578    /// The returned `Arc` is stable — it won't change even if another thread
579    /// calls `swap()` concurrently.
580    pub fn get(&self) -> Arc<OxiosEngine> {
581        Arc::clone(&self.inner.read())
582    }
583
584    /// Atomically replace the engine with a new one.
585    ///
586    /// Callers should rebuild `OxiosEngine` with updated credentials/model
587    /// before calling this.
588    pub fn swap(&self, new_engine: OxiosEngine) {
589        let mut guard = self.inner.write();
590        let old_id = guard.default_model_id().to_string();
591        *guard = Arc::new(new_engine);
592        tracing::info!(
593            old_model = %old_id,
594            new_model = %guard.default_model_id(),
595            "Engine hot-swapped"
596        );
597    }
598}
599
600impl std::fmt::Debug for EngineHandle {
601    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
602        let engine = self.inner.read();
603        f.debug_struct("EngineHandle")
604            .field("current_model", &engine.default_model_id())
605            .finish()
606    }
607}
608
609// ---------------------------------------------------------------------------
610// Tests
611// ---------------------------------------------------------------------------
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    #[test]
618    fn test_resolve_model_with_provider_prefix() {
619        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
620        let model = engine.resolve_model("openai/gpt-4o").unwrap();
621        assert_eq!(model.provider, "openai");
622        assert_eq!(model.id, "gpt-4o");
623    }
624
625    #[test]
626    fn test_resolve_model_without_provider_prefix() {
627        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
628        let model = engine.resolve_model("claude-sonnet-4-20250514").unwrap();
629        assert_eq!(model.provider, "anthropic");
630    }
631
632    #[test]
633    fn test_default_model_id() {
634        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
635        assert_eq!(
636            engine.default_model_id(),
637            "anthropic/claude-sonnet-4-20250514"
638        );
639    }
640
641    #[test]
642    fn test_resolve_model_not_found() {
643        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
644        let result = engine.resolve_model("nonexistent/model-xyz");
645        assert!(result.is_err());
646    }
647
648    #[test]
649    fn test_create_provider_anthropic() {
650        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
651        let provider = engine.create_provider("anthropic");
652        assert!(provider.is_ok());
653    }
654
655    #[test]
656    fn test_create_provider_not_found() {
657        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
658        let result = engine.create_provider("nonexistent_provider");
659        assert!(result.is_err());
660    }
661
662    #[test]
663    fn test_builder_with_credential() {
664        let engine = OxiosEngine::builder()
665            .default_model("openai/gpt-4o")
666            .credential("openai", "sk-test", None)
667            .build();
668        assert_eq!(engine.default_model_id(), "openai/gpt-4o");
669    }
670
671    #[test]
672    fn test_engine_provider_trait_on_engine() {
673        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
674        let provider: &dyn EngineProvider = &engine;
675        assert!(provider.create_provider("anthropic").is_ok());
676        assert!(provider.resolve_model("openai/gpt-4o").is_ok());
677    }
678
679    // ── EngineHandle tests ──
680
681    #[test]
682    fn test_engine_handle_get_returns_current() {
683        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
684        let handle = EngineHandle::new(Arc::new(engine));
685        let e = handle.get();
686        assert_eq!(e.default_model_id(), "anthropic/claude-sonnet-4-20250514");
687    }
688
689    #[test]
690    fn test_engine_handle_swap_updates() {
691        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
692        let handle = EngineHandle::new(Arc::new(engine));
693
694        let new_engine = OxiosEngine::new("openai/gpt-4o");
695        handle.swap(new_engine);
696
697        let e = handle.get();
698        assert_eq!(e.default_model_id(), "openai/gpt-4o");
699    }
700
701    #[test]
702    fn test_engine_handle_swap_preserves_old_arc() {
703        // An Arc obtained before swap should remain valid.
704        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
705        let handle = EngineHandle::new(Arc::new(engine));
706
707        let old = handle.get();
708        assert_eq!(old.default_model_id(), "anthropic/claude-sonnet-4-20250514");
709
710        handle.swap(OxiosEngine::new("openai/gpt-4o"));
711
712        // `old` still points to the pre-swap engine.
713        assert_eq!(old.default_model_id(), "anthropic/claude-sonnet-4-20250514");
714
715        // New get() returns the swapped engine.
716        let current = handle.get();
717        assert_eq!(current.default_model_id(), "openai/gpt-4o");
718    }
719
720    // ── RFC-014 Phase D: engine-level observability/security handles ──
721
722    #[test]
723    fn test_rfc014_phase_d_default_fields_are_none() {
724        // Backward compatibility: `OxiosEngine::new()` / `from_config()` /
725        // `builder().build()` must all leave the new optional fields as
726        // `None` so existing call sites are unaffected.
727        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
728        assert!(engine.authorizer().is_none());
729        assert!(engine.tracer().is_none());
730        assert!(engine.cost_tracker().is_none());
731
732        let engine = OxiosEngine::from_config("anthropic/claude-sonnet-4-20250514", None);
733        assert!(engine.authorizer().is_none());
734        assert!(engine.tracer().is_none());
735        assert!(engine.cost_tracker().is_none());
736
737        let engine = OxiosEngine::builder()
738            .default_model("openai/gpt-4o")
739            .build();
740        assert!(engine.authorizer().is_none());
741        assert!(engine.tracer().is_none());
742        assert!(engine.cost_tracker().is_none());
743
744        let (engine, _rc) = OxiosEngine::builder()
745            .default_model("openai/gpt-4o")
746            .build_with_routing();
747        assert!(engine.authorizer().is_none());
748        assert!(engine.tracer().is_none());
749        assert!(engine.cost_tracker().is_none());
750    }
751
752    #[test]
753    fn test_rfc014_phase_d_with_tracer() {
754        // `with_tracer` attaches a `Tracer`; accessor returns `Some`.
755        let tracer = Arc::new(oxi_sdk::Tracer::new());
756        let engine = OxiosEngine::builder()
757            .default_model("openai/gpt-4o")
758            .with_tracer(tracer.clone())
759            .build();
760        assert!(engine.tracer().is_some());
761        assert!(engine.authorizer().is_none());
762        assert!(engine.cost_tracker().is_none());
763    }
764
765    #[test]
766    fn test_rfc014_phase_d_with_cost_tracker() {
767        // `with_cost_tracker` attaches a `CostTracker`; accessor returns `Some`.
768        // `CostTracker::new` needs an `Arc<ModelRegistry>`; the engine's
769        // own registry (via `models_arc`) is fine for construction-only
770        // assertions like this one.
771        let oxi_for_registry = oxi_sdk::OxiBuilder::new().with_builtins().build();
772        let model_registry = oxi_for_registry.models_arc();
773        let cost_tracker = Arc::new(oxi_sdk::CostTracker::new(
774            model_registry,
775            oxi_sdk::CostTrackerConfig::default(),
776        ));
777        let engine = OxiosEngine::builder()
778            .default_model("openai/gpt-4o")
779            .with_cost_tracker(cost_tracker)
780            .build();
781        assert!(engine.cost_tracker().is_some());
782        assert!(engine.authorizer().is_none());
783        assert!(engine.tracer().is_none());
784    }
785
786    #[test]
787    fn test_rfc014_phase_d_with_authorizer() {
788        // `with_authorizer` attaches an `Authorizer`; accessor returns `Some`.
789        let audit = Arc::new(oxi_sdk::AuditLog::new(16));
790        let authorizer = Arc::new(oxi_sdk::Authorizer::new(audit));
791        let engine = OxiosEngine::builder()
792            .default_model("openai/gpt-4o")
793            .with_authorizer(authorizer)
794            .build();
795        assert!(engine.authorizer().is_some());
796        assert!(engine.tracer().is_none());
797        assert!(engine.cost_tracker().is_none());
798    }
799
800    #[test]
801    fn test_rfc014_phase_d_all_three_handles() {
802        // All three handles can be set at once. The build chain must
803        // preserve them through `api_key` / `credential` / `provider`
804        // builder methods (they should be no-ops for the new fields).
805        let audit = Arc::new(oxi_sdk::AuditLog::new(16));
806        let authorizer = Arc::new(oxi_sdk::Authorizer::new(audit));
807        let tracer = Arc::new(oxi_sdk::Tracer::new());
808        let oxi_for_registry = oxi_sdk::OxiBuilder::new().with_builtins().build();
809        let model_registry = oxi_for_registry.models_arc();
810        let cost_tracker = Arc::new(oxi_sdk::CostTracker::new(
811            model_registry,
812            oxi_sdk::CostTrackerConfig::default(),
813        ));
814
815        let engine = OxiosEngine::builder()
816            .default_model("openai/gpt-4o")
817            .api_key("openai", "sk-test")
818            .with_authorizer(authorizer)
819            .with_tracer(tracer)
820            .with_cost_tracker(cost_tracker)
821            .build();
822
823        assert!(engine.authorizer().is_some());
824        assert!(engine.tracer().is_some());
825        assert!(engine.cost_tracker().is_some());
826        assert_eq!(engine.default_model_id(), "openai/gpt-4o");
827    }
828
829    // ── Catalog port integration (oxi-sdk 0.37.0+) ──
830    //
831    // `#[ignore]` because `init_file_catalog` may touch the network for a
832    // one-shot models.dev refresh and writes to `~/.oxios/cache/`. Run with
833    // `cargo test -p oxios-kernel --lib catalog_integration -- --ignored`.
834    #[tokio::test]
835    #[ignore]
836    async fn catalog_integration_init_and_resolve() {
837        // 1. Catalog initializes (SNAP + cache + optional live refresh).
838        let catalog = OxiosEngine::init_file_catalog()
839            .await
840            .expect("catalog init should succeed (SNAP is always embedded)");
841
842        // 2. The embedded snapshot always carries providers/models, so a wired
843        //    catalog is non-empty.
844        assert!(
845            catalog.model_count_sync() > 0,
846            "catalog should expose models from the embedded snapshot"
847        );
848        assert!(!catalog.list_providers_sync().is_empty());
849
850        // 3. An engine built with the catalog resolves through it first.
851        let engine = OxiosEngine::builder()
852            .default_model("anthropic/claude-sonnet-4-20250514")
853            .with_catalog(catalog)
854            .build();
855        let model = engine
856            .resolve_model("openai/gpt-4o")
857            .expect("catalog-backed resolve_model should succeed");
858        assert_eq!(model.provider, "openai");
859        assert_eq!(model.id, "gpt-4o");
860    }
861}