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