Skip to main content

oxios_kernel/
engine.rs

1//! Engine provider — thin wrapper around oxi-sdk's Oxi.
2//!
3//! oxios uses oxi-sdk as the AI engine. This module re-exports the
4//! EngineProvider trait for the kernel so it can be swapped for testing.
5//!
6//! All provider/model resolution goes through `oxi_sdk::OxiBuilder`.
7//! The `OxiosEngine` struct wraps the SDK instance and exposes a clean API.
8
9use anyhow::Result;
10use oxi_sdk::{Oxi, OxiBuilder};
11use std::sync::Arc;
12
13/// Register OpenAI-compatible providers via factory.
14///
15/// Each provider is registered lazily — credentials are resolved at first use,
16/// not at build time. This allows the engine to be built without all
17/// credentials available upfront.
18fn register_compatible_providers(builder: OxiBuilder, _default_provider: &str) -> OxiBuilder {
19    let compatible_providers: &[(&str, &str)] = &[
20        ("zai", "https://api.z.ai/api/coding/paas/v4"),
21        // Future OpenAI-compatible providers can be added here
22    ];
23
24    let mut builder = builder;
25    for (name, default_url) in compatible_providers {
26        let name_owned = name.to_string();
27        let url_owned = default_url.to_string();
28        builder = builder.provider_factory(name, move || {
29            let api_key =
30                crate::credential::CredentialStore::resolve(&name_owned, None).map(|(key, _)| key);
31            let base_url = std::env::var(format!("{}_BASE_URL", name_owned.to_uppercase()))
32                .unwrap_or_else(|_| url_owned.clone());
33            let provider = oxi_ai::OpenAiProvider::with_base_url_and_key(&base_url, api_key);
34            tracing::info!(
35                "Registered {} provider (OpenAI-compatible, base_url: {})",
36                name_owned,
37                base_url
38            );
39            Ok(Arc::new(provider))
40        });
41    }
42    builder
43}
44
45/// The kernel's engine — wraps oxi-sdk's Oxi instance.
46pub struct OxiosEngine {
47    oxi: Oxi,
48    default_model_id: String,
49}
50
51impl OxiosEngine {
52    /// Create a new engine with the given default model.
53    ///
54    /// Internally calls `OxiBuilder::new().with_builtins()` to load all
55    /// 50+ built-in models and providers.
56    pub fn new(default_model_id: impl Into<String>) -> Self {
57        let model_id = default_model_id.into();
58        let provider_name = model_id
59            .split_once('/')
60            .map(|(p, _)| p)
61            .unwrap_or("anthropic");
62
63        // Workaround: create_builtin_provider("zai") uses OpenAiProvider::with_base_url()
64        // without an API key. We register a custom provider with the key attached.
65        let mut builder = OxiBuilder::new().with_builtins();
66
67        // Register OpenAI-compatible providers via factory (lazy credential resolution)
68        builder = register_compatible_providers(builder, provider_name);
69
70        let oxi = builder.build();
71        Self {
72            oxi,
73            default_model_id: model_id,
74        }
75    }
76
77    /// Get a reference to the underlying Oxi instance.
78    ///
79    /// Use this when you need to pass the engine to oxi-sdk APIs directly
80    /// (e.g., `AgentBuilder`, `MessageBus`, `AgentGroup`).
81    pub fn oxi(&self) -> &Oxi {
82        &self.oxi
83    }
84
85    /// Resolve a model ID to a Model.
86    ///
87    /// Accepts both `"provider/model"` and bare `"model"` forms.
88    /// When no provider prefix is given, defaults to `"anthropic"`.
89    pub fn resolve_model(&self, model_id: &str) -> Result<oxi_sdk::Model> {
90        self.oxi.resolve_model(model_id)
91    }
92
93    /// Create a provider for the given provider name.
94    ///
95    /// Checks custom providers first, then falls back to built-in
96    /// providers (stateless creation).
97    pub fn create_provider(&self, name: &str) -> Result<Arc<dyn oxi_sdk::Provider>> {
98        self.oxi.create_provider(name)
99    }
100}
101
102// ---------------------------------------------------------------------------
103// EngineProvider trait (kept for API compatibility)
104// ---------------------------------------------------------------------------
105
106/// Engine provider trait — abstracts how the kernel obtains AI providers.
107///
108/// This trait is implemented by `OxiEngineProvider` and can be replaced
109/// with a mock for testing.
110pub trait EngineProvider: Send + Sync {
111    /// Create a provider for the given provider name.
112    fn create_provider(&self, provider_name: &str) -> Result<Arc<dyn oxi_sdk::Provider>>;
113
114    /// Resolve a "provider/model" string to a Model.
115    fn resolve_model(&self, model_id: &str) -> Result<oxi_sdk::Model>;
116
117    /// Get the default model ID.
118    fn default_model_id(&self) -> &str;
119}
120
121/// Default engine provider using oxi-sdk.
122pub struct OxiEngineProvider {
123    engine: OxiosEngine,
124}
125
126impl OxiEngineProvider {
127    /// Create a new engine provider with the given default model ID.
128    pub fn new(default_model_id: impl Into<String>) -> Self {
129        Self {
130            engine: OxiosEngine::new(default_model_id),
131        }
132    }
133}
134
135impl EngineProvider for OxiEngineProvider {
136    fn create_provider(&self, provider_name: &str) -> Result<Arc<dyn oxi_sdk::Provider>> {
137        self.engine.create_provider(provider_name)
138    }
139
140    fn resolve_model(&self, model_id: &str) -> Result<oxi_sdk::Model> {
141        self.engine.resolve_model(model_id)
142    }
143
144    fn default_model_id(&self) -> &str {
145        &self.engine.default_model_id
146    }
147}
148
149impl std::fmt::Debug for OxiEngineProvider {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.debug_struct("OxiEngineProvider")
152            .field("default_model_id", &self.engine.default_model_id)
153            .finish()
154    }
155}
156
157// ---------------------------------------------------------------------------
158// Tests
159// ---------------------------------------------------------------------------
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn test_resolve_model_with_provider_prefix() {
167        let engine = OxiEngineProvider::new("anthropic/claude-sonnet-4-20250514");
168        let model = engine.resolve_model("openai/gpt-4o").unwrap();
169        assert_eq!(model.provider, "openai");
170        assert_eq!(model.id, "gpt-4o");
171    }
172
173    #[test]
174    fn test_resolve_model_without_provider_prefix() {
175        let engine = OxiEngineProvider::new("anthropic/claude-sonnet-4-20250514");
176        let model = engine.resolve_model("claude-sonnet-4-20250514").unwrap();
177        assert_eq!(model.provider, "anthropic");
178    }
179
180    #[test]
181    fn test_default_model_id() {
182        let engine = OxiEngineProvider::new("anthropic/claude-sonnet-4-20250514");
183        assert_eq!(
184            engine.default_model_id(),
185            "anthropic/claude-sonnet-4-20250514"
186        );
187    }
188
189    #[test]
190    fn test_resolve_model_not_found() {
191        let engine = OxiEngineProvider::new("anthropic/claude-sonnet-4-20250514");
192        let result = engine.resolve_model("nonexistent/model-xyz");
193        assert!(result.is_err());
194    }
195
196    #[test]
197    fn test_create_provider_anthropic() {
198        let engine = OxiEngineProvider::new("anthropic/claude-sonnet-4-20250514");
199        let provider = engine.create_provider("anthropic");
200        assert!(provider.is_ok());
201    }
202
203    #[test]
204    fn test_create_provider_not_found() {
205        let engine = OxiEngineProvider::new("anthropic/claude-sonnet-4-20250514");
206        let result = engine.create_provider("nonexistent_provider");
207        assert!(result.is_err());
208    }
209}