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::{Oxi, OxiBuilder, ProviderPool, RateLimitPolicy};
20use std::sync::Arc;
21
22use crate::credential::CredentialStore;
23
24/// The kernel's engine — wraps oxi-sdk's Oxi instance.
25///
26/// Created via [`OxiosEngine::new()`] or [`OxiosEngine::builder()`].
27/// Provides access to providers, models, routing, pooling, and agent construction.
28///
29/// # RFC-014 Phase D
30///
31/// `authorizer` / `tracer` / `cost_tracker` are optional, engine-level
32/// observability and security handles. When set, they are propagated to
33/// every agent built via [`OxiosEngine::oxi().agent()`][Oxi::agent] using
34/// the new `AgentBuilder::authorizer()` / `.tracer()` / `.cost_tracker()`
35/// API. All three are `None` by default, keeping the existing call sites
36/// fully backward compatible.
37pub struct OxiosEngine {
38    oxi: Oxi,
39    default_model_id: String,
40    /// Runtime routing control for dynamic model selection.
41    routing_control: Option<oxi_sdk::RoutingControl>,
42    /// Pooled providers with rate limiting.
43    /// Key: provider name (e.g. "anthropic"), Value: ProviderPool wrapper.
44    pools: parking_lot::RwLock<std::collections::HashMap<String, Arc<dyn oxi_sdk::Provider>>>,
45    /// ── RFC-014 Phase D: engine-level observability/security handles ──
46    /// When `Some`, these are attached to every `Agent` built via the
47    /// `AgentBuilder` API in `agent_runtime.rs::run_agent()`.
48    /// Default: `None` (preserves pre-Phase-D behavior).
49    authorizer: Option<Arc<oxi_sdk::Authorizer>>,
50    tracer: Option<Arc<oxi_sdk::Tracer>>,
51    cost_tracker: Option<Arc<oxi_sdk::CostTracker>>,
52}
53
54impl OxiosEngine {
55    /// Create a new engine with the given default model.
56    ///
57    /// Internally calls `OxiBuilder::new().with_builtins()` to load all
58    /// built-in models and providers.
59    pub fn new(default_model_id: impl Into<String>) -> Self {
60        let model_id = default_model_id.into();
61        let oxi = OxiBuilder::new().with_builtins().build();
62        Self {
63            oxi,
64            default_model_id: model_id,
65            routing_control: None,
66            pools: parking_lot::RwLock::new(std::collections::HashMap::new()),
67            // RFC-014 Phase D: optional, off by default
68            authorizer: None,
69            tracer: None,
70            cost_tracker: None,
71        }
72    }
73
74    /// Create a new engine with credentials from config.
75    ///
76    /// Resolves API keys from CredentialStore for each known provider
77    /// and injects them into the OxiBuilder. This enables the engine
78    /// to create properly authenticated providers.
79    ///
80    /// Resolution order (per provider): env var → config.toml → ~/.oxi/auth.json
81    pub fn from_config(default_model_id: impl Into<String>, config_api_key: Option<&str>) -> Self {
82        let model_id = default_model_id.into();
83
84        // Resolve the primary provider's credential
85        let primary_provider = model_id
86            .split_once('/')
87            .map(|(p, _)| p)
88            .unwrap_or("anthropic");
89
90        let mut builder = OxiBuilder::new().with_builtins();
91
92        // Inject credentials for all major providers via CredentialStore.
93        // This ensures `create_provider()` can always build an authenticated provider.
94        let providers = ["anthropic", "openai", "google", "deepseek", "xai"];
95        for provider in providers {
96            // Use the config-level key only for the primary provider;
97            // other providers resolve from env/auth.json.
98            let config_key = if provider == primary_provider {
99                config_api_key
100            } else {
101                None
102            };
103
104            if let Some((key, source)) = CredentialStore::resolve(provider, config_key) {
105                tracing::debug!(
106                    provider,
107                    source = ?source,
108                    "Injected credential into engine"
109                );
110                builder = builder.api_key(provider, key);
111            }
112        }
113
114        let oxi = builder.build();
115        Self {
116            oxi,
117            default_model_id: model_id,
118            routing_control: None,
119            pools: parking_lot::RwLock::new(std::collections::HashMap::new()),
120            // RFC-014 Phase D: optional, off by default
121            authorizer: None,
122            tracer: None,
123            cost_tracker: None,
124        }
125    }
126
127    /// Create an engine builder for advanced configuration.
128    ///
129    /// Use this when you need credential injection, routing, or
130    /// custom provider registration.
131    ///
132    /// # RFC-014 Phase D
133    ///
134    /// The builder also exposes `.with_authorizer()` / `.with_tracer()` /
135    /// `.with_cost_tracker()` for attaching engine-level observability
136    /// and security handles. All three are `None` by default.
137    ///
138    /// # Example
139    ///
140    /// ```no_run
141    /// use oxios_kernel::engine::OxiosEngine;
142    ///
143    /// let engine = OxiosEngine::builder()
144    ///     .default_model("anthropic/claude-sonnet-4-20250514")
145    ///     .api_key("anthropic", "sk-ant-...")
146    ///     .build();
147    /// ```
148    pub fn builder() -> OxiosEngineBuilder {
149        OxiosEngineBuilder {
150            inner: OxiBuilder::new().with_builtins(),
151            default_model_id: "anthropic/claude-sonnet-4-20250514".to_string(),
152            // RFC-014 Phase D: optional, off by default
153            authorizer: None,
154            tracer: None,
155            cost_tracker: None,
156        }
157    }
158
159    /// Get a reference to the underlying Oxi instance.
160    ///
161    /// Use this when you need to pass the engine to oxi-sdk APIs directly
162    /// (e.g., `AgentBuilder`, `MessageBus`, `AgentGroup`).
163    pub fn oxi(&self) -> &Oxi {
164        &self.oxi
165    }
166
167    /// RFC-014 Phase D: get the engine-level `Authorizer`, if any.
168    ///
169    /// When `Some`, the authorizer is attached to every `Agent` built via
170    /// `Oxi::agent().authorizer(...)` in `agent_runtime.rs::run_agent()`.
171    pub fn authorizer(&self) -> Option<&Arc<oxi_sdk::Authorizer>> {
172        self.authorizer.as_ref()
173    }
174
175    /// RFC-014 Phase D: get the engine-level `Tracer`, if any.
176    ///
177    /// When `Some`, the tracer is attached to every `Agent` built via
178    /// `Oxi::agent().tracer(...)` in `agent_runtime.rs::run_agent()`.
179    pub fn tracer(&self) -> Option<&Arc<oxi_sdk::Tracer>> {
180        self.tracer.as_ref()
181    }
182
183    /// RFC-014 Phase D: get the engine-level `CostTracker`, if any.
184    ///
185    /// When `Some`, the cost tracker is attached to every `Agent` built via
186    /// `Oxi::agent().cost_tracker(...)` in `agent_runtime.rs::run_agent()`.
187    pub fn cost_tracker(&self) -> Option<&Arc<oxi_sdk::CostTracker>> {
188        self.cost_tracker.as_ref()
189    }
190
191    /// Resolve a model ID to a Model.
192    pub fn resolve_model(&self, model_id: &str) -> Result<oxi_sdk::Model> {
193        self.oxi.resolve_model(model_id)
194    }
195
196    /// Create a provider for the given provider name.
197    pub fn create_provider(&self, name: &str) -> Result<Arc<dyn oxi_sdk::Provider>> {
198        self.oxi.create_provider(name)
199    }
200
201    /// Get the default model ID.
202    pub fn default_model_id(&self) -> &str {
203        &self.default_model_id
204    }
205
206    /// Get the routing control, if routing is enabled.
207    pub fn routing_control(&self) -> Option<&oxi_sdk::RoutingControl> {
208        self.routing_control.as_ref()
209    }
210
211    /// Get a rate-limited provider from the pool.
212    ///
213    /// On first call for a provider name, creates a `ProviderPool` wrapping
214    /// the base provider with the given RPM/concurrency limits.
215    /// Subsequent calls return the same pooled instance.
216    ///
217    /// If no rate limit is needed, returns the base provider directly.
218    pub fn pooled_provider(&self, name: &str, rpm: u32) -> Result<Arc<dyn oxi_sdk::Provider>> {
219        // Check if already pooled.
220        {
221            let pools = self.pools.read();
222            if let Some(pooled) = pools.get(name) {
223                return Ok(pooled.clone());
224            }
225        }
226
227        // Create new pool.
228        let base = self.create_provider(name)?;
229        let policy = RateLimitPolicy::rpm(rpm);
230        let pool = ProviderPool::new(base, policy, name);
231        let pooled: Arc<dyn oxi_sdk::Provider> = Arc::new(pool);
232
233        // Cache it.
234        {
235            let mut pools = self.pools.write();
236            pools.insert(name.to_string(), pooled.clone());
237        }
238
239        tracing::info!(provider = name, rpm, "Created provider pool");
240        Ok(pooled)
241    }
242}
243
244// ---------------------------------------------------------------------------
245// EngineBuilder
246// ---------------------------------------------------------------------------
247
248/// Builder for creating an `OxiosEngine` with advanced configuration.
249pub struct OxiosEngineBuilder {
250    inner: OxiBuilder,
251    default_model_id: String,
252    // ── RFC-014 Phase D: optional engine-level observability/security handles ──
253    // All default to `None` so existing builder chains remain unchanged.
254    authorizer: Option<Arc<oxi_sdk::Authorizer>>,
255    tracer: Option<Arc<oxi_sdk::Tracer>>,
256    cost_tracker: Option<Arc<oxi_sdk::CostTracker>>,
257}
258
259impl OxiosEngineBuilder {
260    /// Set the default model ID.
261    pub fn default_model(mut self, model_id: impl Into<String>) -> Self {
262        self.default_model_id = model_id.into();
263        self
264    }
265
266    /// Register an API key for a specific provider.
267    pub fn api_key(self, provider: &str, key: impl Into<String>) -> Self {
268        Self {
269            inner: self.inner.api_key(provider, key),
270            default_model_id: self.default_model_id,
271            authorizer: self.authorizer,
272            tracer: self.tracer,
273            cost_tracker: self.cost_tracker,
274        }
275    }
276
277    /// Register a full credential (API key + optional base URL).
278    pub fn credential(
279        self,
280        provider: &str,
281        api_key: impl Into<String>,
282        base_url: Option<&str>,
283    ) -> Self {
284        Self {
285            inner: self.inner.credential(provider, api_key, base_url),
286            default_model_id: self.default_model_id,
287            authorizer: self.authorizer,
288            tracer: self.tracer,
289            cost_tracker: self.cost_tracker,
290        }
291    }
292
293    /// Register a custom provider.
294    pub fn provider(self, name: &str, p: impl oxi_sdk::Provider + 'static) -> Self {
295        Self {
296            inner: self.inner.provider(name, p),
297            default_model_id: self.default_model_id,
298            authorizer: self.authorizer,
299            tracer: self.tracer,
300            cost_tracker: self.cost_tracker,
301        }
302    }
303
304    /// Build the engine.
305    pub fn build(self) -> OxiosEngine {
306        OxiosEngine {
307            oxi: self.inner.build(),
308            default_model_id: self.default_model_id,
309            routing_control: None,
310            pools: parking_lot::RwLock::new(std::collections::HashMap::new()),
311            // RFC-014 Phase D: optional, off by default
312            authorizer: self.authorizer,
313            tracer: self.tracer,
314            cost_tracker: self.cost_tracker,
315        }
316    }
317
318    /// Build the engine with routing enabled.
319    ///
320    /// Returns `(OxiosEngine, RoutingControl)` for runtime routing control.
321    pub fn build_with_routing(self) -> (OxiosEngine, oxi_sdk::RoutingControl) {
322        use oxi_sdk::RoutingControl;
323
324        let routing_config = oxi_sdk::routing::RoutingConfig::default();
325        let routing_control = RoutingControl::new(routing_config);
326        let engine = OxiosEngine {
327            oxi: self.inner.build(),
328            default_model_id: self.default_model_id,
329            routing_control: Some(routing_control.clone()),
330            pools: parking_lot::RwLock::new(std::collections::HashMap::new()),
331            // RFC-014 Phase D: optional, off by default
332            authorizer: self.authorizer,
333            tracer: self.tracer,
334            cost_tracker: self.cost_tracker,
335        };
336        (engine, routing_control)
337    }
338
339    // ── RFC-014 Phase D: engine-level observability/security handles ──
340    //
341    // These methods let callers attach shared `Authorizer` / `Tracer` /
342    // `CostTracker` instances to the engine. `agent_runtime.rs::run_agent()`
343    // reads them via `OxiosEngine::authorizer()` / `.tracer()` /
344    // `.cost_tracker()` and propagates them to the new `AgentBuilder` API.
345    //
346    // Backward compatible: all three are `None` by default.
347
348    /// Attach an `Authorizer` to the engine. Agents built via `Oxi::agent()`
349    /// will receive this authorizer through the new `AgentBuilder::authorizer()` API.
350    pub fn with_authorizer(mut self, authorizer: Arc<oxi_sdk::Authorizer>) -> Self {
351        self.authorizer = Some(authorizer);
352        self
353    }
354
355    /// Attach a `Tracer` to the engine. Agents built via `Oxi::agent()`
356    /// will receive this tracer through the new `AgentBuilder::tracer()` API.
357    pub fn with_tracer(mut self, tracer: Arc<oxi_sdk::Tracer>) -> Self {
358        self.tracer = Some(tracer);
359        self
360    }
361
362    /// Attach a `CostTracker` to the engine. Agents built via `Oxi::agent()`
363    /// will receive this cost tracker through the new `AgentBuilder::cost_tracker()` API.
364    pub fn with_cost_tracker(mut self, cost_tracker: Arc<oxi_sdk::CostTracker>) -> Self {
365        self.cost_tracker = Some(cost_tracker);
366        self
367    }
368}
369
370// ---------------------------------------------------------------------------
371// EngineProvider trait (for testability and dependency inversion)
372// ---------------------------------------------------------------------------
373
374/// Engine provider trait — abstracts how the kernel obtains AI providers.
375///
376/// Implemented by `OxiosEngine` directly. Use a mock for testing.
377pub trait EngineProvider: Send + Sync {
378    /// Create a provider for the given provider name.
379    fn create_provider(&self, provider_name: &str) -> Result<Arc<dyn oxi_sdk::Provider>>;
380
381    /// Resolve a "provider/model" string to a Model.
382    fn resolve_model(&self, model_id: &str) -> Result<oxi_sdk::Model>;
383
384    /// Get the default model ID.
385    fn default_model_id(&self) -> &str;
386}
387
388impl EngineProvider for OxiosEngine {
389    fn create_provider(&self, provider_name: &str) -> Result<Arc<dyn oxi_sdk::Provider>> {
390        self.create_provider(provider_name)
391    }
392
393    fn resolve_model(&self, model_id: &str) -> Result<oxi_sdk::Model> {
394        self.resolve_model(model_id)
395    }
396
397    fn default_model_id(&self) -> &str {
398        &self.default_model_id
399    }
400}
401
402impl std::fmt::Debug for OxiosEngine {
403    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
404        f.debug_struct("OxiosEngine")
405            .field("default_model_id", &self.default_model_id)
406            .field("routing_enabled", &self.routing_control.is_some())
407            .finish()
408    }
409}
410
411// ---------------------------------------------------------------------------
412// EngineHandle — hot-swappable engine reference
413// ---------------------------------------------------------------------------
414
415/// Shared, hot-swappable reference to the active [`OxiosEngine`].
416///
417/// Wraps `RwLock<Arc<OxiosEngine>>` so that:
418/// - **Writers** (`EngineApi`) can atomically replace the engine on config change
419/// - **Readers** (`AgentRuntime`) always get the current engine at execution time
420///
421/// # Cost
422///
423/// Rebuilding `OxiosEngine` is cheap: `OxiBuilder::new().with_builtins().build()`
424/// populates registries from static `model_db` data (~1μs, no I/O, no network).
425///
426/// # Concurrency
427///
428/// - `parking_lot::RwLock` is not async-aware, but engine swap only occurs on
429///   explicit user action (Web UI / CLI config change) — never in a hot path.
430/// - Agent execution reads the engine once at the start of `execute()` and
431///   uses the same `Arc<OxiosEngine>` for the entire run (consistent within one execution).
432pub struct EngineHandle {
433    inner: parking_lot::RwLock<Arc<OxiosEngine>>,
434}
435
436impl EngineHandle {
437    /// Create a new handle wrapping the given engine.
438    pub fn new(engine: Arc<OxiosEngine>) -> Self {
439        Self {
440            inner: parking_lot::RwLock::new(engine),
441        }
442    }
443
444    /// Get a snapshot of the current engine.
445    ///
446    /// The returned `Arc` is stable — it won't change even if another thread
447    /// calls `swap()` concurrently.
448    pub fn get(&self) -> Arc<OxiosEngine> {
449        Arc::clone(&self.inner.read())
450    }
451
452    /// Atomically replace the engine with a new one.
453    ///
454    /// Callers should rebuild `OxiosEngine` with updated credentials/model
455    /// before calling this.
456    pub fn swap(&self, new_engine: OxiosEngine) {
457        let mut guard = self.inner.write();
458        let old_id = guard.default_model_id().to_string();
459        *guard = Arc::new(new_engine);
460        tracing::info!(
461            old_model = %old_id,
462            new_model = %guard.default_model_id(),
463            "Engine hot-swapped"
464        );
465    }
466}
467
468impl std::fmt::Debug for EngineHandle {
469    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
470        let engine = self.inner.read();
471        f.debug_struct("EngineHandle")
472            .field("current_model", &engine.default_model_id())
473            .finish()
474    }
475}
476
477// ---------------------------------------------------------------------------
478// Tests
479// ---------------------------------------------------------------------------
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn test_resolve_model_with_provider_prefix() {
487        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
488        let model = engine.resolve_model("openai/gpt-4o").unwrap();
489        assert_eq!(model.provider, "openai");
490        assert_eq!(model.id, "gpt-4o");
491    }
492
493    #[test]
494    fn test_resolve_model_without_provider_prefix() {
495        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
496        let model = engine.resolve_model("claude-sonnet-4-20250514").unwrap();
497        assert_eq!(model.provider, "anthropic");
498    }
499
500    #[test]
501    fn test_default_model_id() {
502        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
503        assert_eq!(
504            engine.default_model_id(),
505            "anthropic/claude-sonnet-4-20250514"
506        );
507    }
508
509    #[test]
510    fn test_resolve_model_not_found() {
511        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
512        let result = engine.resolve_model("nonexistent/model-xyz");
513        assert!(result.is_err());
514    }
515
516    #[test]
517    fn test_create_provider_anthropic() {
518        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
519        let provider = engine.create_provider("anthropic");
520        assert!(provider.is_ok());
521    }
522
523    #[test]
524    fn test_create_provider_not_found() {
525        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
526        let result = engine.create_provider("nonexistent_provider");
527        assert!(result.is_err());
528    }
529
530    #[test]
531    fn test_builder_with_credential() {
532        let engine = OxiosEngine::builder()
533            .default_model("openai/gpt-4o")
534            .credential("openai", "sk-test", None)
535            .build();
536        assert_eq!(engine.default_model_id(), "openai/gpt-4o");
537    }
538
539    #[test]
540    fn test_engine_provider_trait_on_engine() {
541        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
542        let provider: &dyn EngineProvider = &engine;
543        assert!(provider.create_provider("anthropic").is_ok());
544        assert!(provider.resolve_model("openai/gpt-4o").is_ok());
545    }
546
547    // ── EngineHandle tests ──
548
549    #[test]
550    fn test_engine_handle_get_returns_current() {
551        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
552        let handle = EngineHandle::new(Arc::new(engine));
553        let e = handle.get();
554        assert_eq!(e.default_model_id(), "anthropic/claude-sonnet-4-20250514");
555    }
556
557    #[test]
558    fn test_engine_handle_swap_updates() {
559        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
560        let handle = EngineHandle::new(Arc::new(engine));
561
562        let new_engine = OxiosEngine::new("openai/gpt-4o");
563        handle.swap(new_engine);
564
565        let e = handle.get();
566        assert_eq!(e.default_model_id(), "openai/gpt-4o");
567    }
568
569    #[test]
570    fn test_engine_handle_swap_preserves_old_arc() {
571        // An Arc obtained before swap should remain valid.
572        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
573        let handle = EngineHandle::new(Arc::new(engine));
574
575        let old = handle.get();
576        assert_eq!(old.default_model_id(), "anthropic/claude-sonnet-4-20250514");
577
578        handle.swap(OxiosEngine::new("openai/gpt-4o"));
579
580        // `old` still points to the pre-swap engine.
581        assert_eq!(old.default_model_id(), "anthropic/claude-sonnet-4-20250514");
582
583        // New get() returns the swapped engine.
584        let current = handle.get();
585        assert_eq!(current.default_model_id(), "openai/gpt-4o");
586    }
587
588    // ── RFC-014 Phase D: engine-level observability/security handles ──
589
590    #[test]
591    fn test_rfc014_phase_d_default_fields_are_none() {
592        // Backward compatibility: `OxiosEngine::new()` / `from_config()` /
593        // `builder().build()` must all leave the new optional fields as
594        // `None` so existing call sites are unaffected.
595        let engine = OxiosEngine::new("anthropic/claude-sonnet-4-20250514");
596        assert!(engine.authorizer().is_none());
597        assert!(engine.tracer().is_none());
598        assert!(engine.cost_tracker().is_none());
599
600        let engine = OxiosEngine::from_config("anthropic/claude-sonnet-4-20250514", None);
601        assert!(engine.authorizer().is_none());
602        assert!(engine.tracer().is_none());
603        assert!(engine.cost_tracker().is_none());
604
605        let engine = OxiosEngine::builder()
606            .default_model("openai/gpt-4o")
607            .build();
608        assert!(engine.authorizer().is_none());
609        assert!(engine.tracer().is_none());
610        assert!(engine.cost_tracker().is_none());
611
612        let (engine, _rc) = OxiosEngine::builder()
613            .default_model("openai/gpt-4o")
614            .build_with_routing();
615        assert!(engine.authorizer().is_none());
616        assert!(engine.tracer().is_none());
617        assert!(engine.cost_tracker().is_none());
618    }
619
620    #[test]
621    fn test_rfc014_phase_d_with_tracer() {
622        // `with_tracer` attaches a `Tracer`; accessor returns `Some`.
623        let tracer = Arc::new(oxi_sdk::Tracer::new());
624        let engine = OxiosEngine::builder()
625            .default_model("openai/gpt-4o")
626            .with_tracer(tracer.clone())
627            .build();
628        assert!(engine.tracer().is_some());
629        assert!(engine.authorizer().is_none());
630        assert!(engine.cost_tracker().is_none());
631    }
632
633    #[test]
634    fn test_rfc014_phase_d_with_cost_tracker() {
635        // `with_cost_tracker` attaches a `CostTracker`; accessor returns `Some`.
636        // `CostTracker::new` needs an `Arc<ModelRegistry>`; the engine's
637        // own registry (via `models_arc`) is fine for construction-only
638        // assertions like this one.
639        let oxi_for_registry = oxi_sdk::OxiBuilder::new().with_builtins().build();
640        let model_registry = oxi_for_registry.models_arc();
641        let cost_tracker = Arc::new(oxi_sdk::CostTracker::new(
642            model_registry,
643            oxi_sdk::CostTrackerConfig::default(),
644        ));
645        let engine = OxiosEngine::builder()
646            .default_model("openai/gpt-4o")
647            .with_cost_tracker(cost_tracker)
648            .build();
649        assert!(engine.cost_tracker().is_some());
650        assert!(engine.authorizer().is_none());
651        assert!(engine.tracer().is_none());
652    }
653
654    #[test]
655    fn test_rfc014_phase_d_with_authorizer() {
656        // `with_authorizer` attaches an `Authorizer`; accessor returns `Some`.
657        let audit = Arc::new(oxi_sdk::AuditLog::new(16));
658        let authorizer = Arc::new(oxi_sdk::Authorizer::new(audit));
659        let engine = OxiosEngine::builder()
660            .default_model("openai/gpt-4o")
661            .with_authorizer(authorizer)
662            .build();
663        assert!(engine.authorizer().is_some());
664        assert!(engine.tracer().is_none());
665        assert!(engine.cost_tracker().is_none());
666    }
667
668    #[test]
669    fn test_rfc014_phase_d_all_three_handles() {
670        // All three handles can be set at once. The build chain must
671        // preserve them through `api_key` / `credential` / `provider`
672        // builder methods (they should be no-ops for the new fields).
673        let audit = Arc::new(oxi_sdk::AuditLog::new(16));
674        let authorizer = Arc::new(oxi_sdk::Authorizer::new(audit));
675        let tracer = Arc::new(oxi_sdk::Tracer::new());
676        let oxi_for_registry = oxi_sdk::OxiBuilder::new().with_builtins().build();
677        let model_registry = oxi_for_registry.models_arc();
678        let cost_tracker = Arc::new(oxi_sdk::CostTracker::new(
679            model_registry,
680            oxi_sdk::CostTrackerConfig::default(),
681        ));
682
683        let engine = OxiosEngine::builder()
684            .default_model("openai/gpt-4o")
685            .api_key("openai", "sk-test")
686            .with_authorizer(authorizer)
687            .with_tracer(tracer)
688            .with_cost_tracker(cost_tracker)
689            .build();
690
691        assert!(engine.authorizer().is_some());
692        assert!(engine.tracer().is_some());
693        assert!(engine.cost_tracker().is_some());
694        assert_eq!(engine.default_model_id(), "openai/gpt-4o");
695    }
696}