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