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 = crate::credential::CredentialStore::resolve(&name_owned, None)
30                .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, base_url
37            );
38            Ok(Arc::new(provider))
39        });
40    }
41    builder
42}
43
44/// The kernel's engine — wraps oxi-sdk's Oxi instance.
45pub struct OxiosEngine {
46    oxi: Oxi,
47    default_model_id: String,
48}
49
50impl OxiosEngine {
51    /// Create a new engine with the given default model.
52    ///
53    /// Internally calls `OxiBuilder::new().with_builtins()` to load all
54    /// 50+ built-in models and providers.
55    pub fn new(default_model_id: impl Into<String>) -> Self {
56        let model_id = default_model_id.into();
57        let provider_name = model_id
58            .split_once('/')
59            .map(|(p, _)| p)
60            .unwrap_or("anthropic");
61
62        // Workaround: create_builtin_provider("zai") uses OpenAiProvider::with_base_url()
63        // without an API key. We register a custom provider with the key attached.
64        let mut builder = OxiBuilder::new().with_builtins();
65
66        // Register OpenAI-compatible providers via factory (lazy credential resolution)
67        builder = register_compatible_providers(builder, provider_name);
68
69        let oxi = builder.build();
70        Self {
71            oxi,
72            default_model_id: model_id,
73        }
74    }
75
76    /// Get a reference to the underlying Oxi instance.
77    ///
78    /// Use this when you need to pass the engine to oxi-sdk APIs directly
79    /// (e.g., `AgentBuilder`, `MessageBus`, `AgentGroup`).
80    pub fn oxi(&self) -> &Oxi {
81        &self.oxi
82    }
83
84    /// Resolve a model ID to a Model.
85    ///
86    /// Accepts both `"provider/model"` and bare `"model"` forms.
87    /// When no provider prefix is given, defaults to `"anthropic"`.
88    pub fn resolve_model(&self, model_id: &str) -> Result<oxi_sdk::Model> {
89        self.oxi.resolve_model(model_id)
90    }
91
92    /// Create a provider for the given provider name.
93    ///
94    /// Checks custom providers first, then falls back to built-in
95    /// providers (stateless creation).
96    pub fn create_provider(&self, name: &str) -> Result<Arc<dyn oxi_sdk::Provider>> {
97        self.oxi.create_provider(name)
98    }
99}
100
101// ---------------------------------------------------------------------------
102// EngineProvider trait (kept for API compatibility)
103// ---------------------------------------------------------------------------
104
105/// Engine provider trait — abstracts how the kernel obtains AI providers.
106///
107/// This trait is implemented by `OxiEngineProvider` and can be replaced
108/// with a mock for testing.
109pub trait EngineProvider: Send + Sync {
110    /// Create a provider for the given provider name.
111    fn create_provider(&self, provider_name: &str) -> Result<Arc<dyn oxi_sdk::Provider>>;
112
113    /// Resolve a "provider/model" string to a Model.
114    fn resolve_model(&self, model_id: &str) -> Result<oxi_sdk::Model>;
115
116    /// Get the default model ID.
117    fn default_model_id(&self) -> &str;
118}
119
120/// Default engine provider using oxi-sdk.
121pub struct OxiEngineProvider {
122    engine: OxiosEngine,
123}
124
125impl OxiEngineProvider {
126    /// Create a new engine provider with the given default model ID.
127    pub fn new(default_model_id: impl Into<String>) -> Self {
128        Self {
129            engine: OxiosEngine::new(default_model_id),
130        }
131    }
132}
133
134impl EngineProvider for OxiEngineProvider {
135    fn create_provider(&self, provider_name: &str) -> Result<Arc<dyn oxi_sdk::Provider>> {
136        self.engine.create_provider(provider_name)
137    }
138
139    fn resolve_model(&self, model_id: &str) -> Result<oxi_sdk::Model> {
140        self.engine.resolve_model(model_id)
141    }
142
143    fn default_model_id(&self) -> &str {
144        &self.engine.default_model_id
145    }
146}
147
148impl std::fmt::Debug for OxiEngineProvider {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.debug_struct("OxiEngineProvider")
151            .field("default_model_id", &self.engine.default_model_id)
152            .finish()
153    }
154}
155
156// ---------------------------------------------------------------------------
157// Tests
158// ---------------------------------------------------------------------------
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn test_resolve_model_with_provider_prefix() {
166        let engine = OxiEngineProvider::new("anthropic/claude-sonnet-4-20250514");
167        let model = engine.resolve_model("openai/gpt-4o").unwrap();
168        assert_eq!(model.provider, "openai");
169        assert_eq!(model.id, "gpt-4o");
170    }
171
172    #[test]
173    fn test_resolve_model_without_provider_prefix() {
174        let engine = OxiEngineProvider::new("anthropic/claude-sonnet-4-20250514");
175        let model = engine.resolve_model("claude-sonnet-4-20250514").unwrap();
176        assert_eq!(model.provider, "anthropic");
177    }
178
179    #[test]
180    fn test_default_model_id() {
181        let engine = OxiEngineProvider::new("anthropic/claude-sonnet-4-20250514");
182        assert_eq!(
183            engine.default_model_id(),
184            "anthropic/claude-sonnet-4-20250514"
185        );
186    }
187
188    #[test]
189    fn test_resolve_model_not_found() {
190        let engine = OxiEngineProvider::new("anthropic/claude-sonnet-4-20250514");
191        let result = engine.resolve_model("nonexistent/model-xyz");
192        assert!(result.is_err());
193    }
194
195    #[test]
196    fn test_create_provider_anthropic() {
197        let engine = OxiEngineProvider::new("anthropic/claude-sonnet-4-20250514");
198        let provider = engine.create_provider("anthropic");
199        assert!(provider.is_ok());
200    }
201
202    #[test]
203    fn test_create_provider_not_found() {
204        let engine = OxiEngineProvider::new("anthropic/claude-sonnet-4-20250514");
205        let result = engine.create_provider("nonexistent_provider");
206        assert!(result.is_err());
207    }
208}