oxi-sdk 0.25.4

oxi AI agent SDK — build isolated, multi-agent AI systems
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
//! oxi SDK - Programmatic API for building AI agents
//!
//! # Example
//! ```
//! use oxi_sdk::{OxiBuilder, AgentConfig};
//!
//! let oxi = OxiBuilder::new().with_builtins().build();
//! let agent = oxi.agent(AgentConfig {
//!     model_id: "anthropic/claude-sonnet-4-20250514".into(),
//!     max_iterations: 20,
//!     ..Default::default()
//! }).build().unwrap();
//! ```

pub mod agent_builder;
pub mod agent_definition;
pub mod agent_group;
pub mod builder;
pub mod closure_tool;
pub mod coordination;
pub mod error;
pub mod kernel_bridge;
pub mod lifecycle;
pub mod message_bus;
pub mod metrics;
pub mod middleware;
pub mod multi_provider;
pub mod observability;
pub mod prelude;
pub mod routing;
pub mod security;
pub mod tool_factory;
pub mod workflow_dsl;

// Re-export core SDK types
pub use agent_builder::AgentBuilder;
pub use agent_group::{AgentGroup, AgentGroupOutput, GroupResult, GroupStrategy};
pub use builder::{Oxi, OxiBuilder};
pub use closure_tool::ClosureTool;
pub use kernel_bridge::{KernelToolContext, KernelToolProvider};
pub use message_bus::{InterAgentMessage, LagAwareReceiver, MessageBus, PublishResult};
pub use metrics::{AgentMetrics, MetricsSnapshot};

// Foundation Layer
pub use error::{SdkError, SdkResult};
pub use lifecycle::{
    AgentHandle, AgentLifecycleEvent, AgentSnapshot, AgentStatus, AgentSupervisor,
    FileSnapshotStore, RestartBackoff, SnapshotStore, SupervisorPolicy, ToolManifest,
};
pub use middleware::Middleware;
pub use middleware::{
    build_hooks, MiddlewareContext, MiddlewareData, MiddlewarePhase, MiddlewarePipeline,
    MiddlewareResult,
};
pub use multi_provider::{MultiProviderBuilder, RoutingConfig};
pub use observability::{
    AuditEntry, AuditFilter, AuditLog, CostBreakdown, CostSnapshot, CostTracker, CostTrackerConfig,
    EventQuery, EventStore, EventStoreConfig, GlobalCostSnapshot, Span, SpanContext, SpanGuard,
    SpanId, SpanKind, SpanStatus, StoredEvent, TokenUsage, TraceId, Tracer,
};

// Composition Layer — Security
pub use security::{
    Authorizer, Capability, CapabilitySet, CapabilitySubject, DefaultPolicy, SecurityMiddleware,
    StringPattern,
};

// Composition Layer — Coordination
pub use coordination::{
    Consensus, CoordinatedGroup, CoordinatedGroupBuilder, MemoryEntry, MemoryEvent, MemoryKey,
    SharedMemory, VoteResult, WorkEvent, WorkItem, WorkQueue, WorkQueueConfig, WorkQueueStats,
    WorkResult, WorkStatus,
};

// Runtime routing control
pub use routing::RoutingControl;

// Re-export from oxi-ai
pub use oxi_ai::circuit_breaker::{CircuitBreakerConfig, ProviderCircuitBreaker};
pub use oxi_ai::multi_provider::MultiProviderConfig;
pub use oxi_ai::provider_pool::{ProviderPool, RateLimitPolicy};
pub use oxi_ai::{
    Api, CompactionStrategy, ContentBlock, Context, Cost, InputModality, Message, Model,
    ModelRegistry, Provider, ProviderError, ProviderEvent, ProviderOptions, ProviderRegistry,
    StreamOptions, UserMessage,
};

// Credential management (oauth + env key resolution)
pub use oxi_ai::env_api_keys::{find_env_keys, get_all_env_keys, get_env_api_key, has_env_key};

// Model database — provider catalog, model metadata
pub use oxi_ai::model_db::{
    get_all_models, get_cheapest_models, get_model_entry, get_provider_models, get_providers,
    get_reasoning_models, get_vision_models, model_count, search_models, ModelEntry,
};
pub use oxi_ai::oauth::{
    default_auth_path, load_auth_store, load_token, remove_token, save_auth_store, save_token,
    AuthStore, OAuthError, TokenBundle,
};

// Re-export from oxi-agent
pub use oxi_agent::{
    Agent, AgentConfig, AgentError, AgentEvent, AgentHooks, AgentLoop, AgentLoopConfig, AgentState,
    AgentTool, AgentToolResult, CompactedContext, CompactionEvent, CompactionHook, EditTool,
    FindTool, GetSearchResultsTool, GrepTool, LsTool, OutputMode, ProviderResolver, ReadTool,
    SearchCache, SharedState, StructuredOutput, StructuredOutputError, ToolContext, ToolError,
    ToolExecutionMode, ToolRegistry, WebSearchTool, WriteTool,
};

// ── Concrete provider re-exports ─────────────────────────────────────────
//
// SDK consumers can construct providers directly without depending on
// `oxi-ai`. This enables the single-dependency pattern:
//   oxios → oxi-sdk  (no oxi-ai, no oxi-agent direct dep)

pub use oxi_ai::OpenAiProvider;
pub use oxi_ai::OpenAiResponsesProvider;

// ── Browser engine re-exports ────────────────────────────────────────────────
//
// The browser trait layer (BrowserEngine, BrowserTab, config, error types)
// is always available so SDK consumers can implement custom backends.
// The native oxibrowser-core backend requires the `native-browser` feature.

pub use oxi_agent::tools::browse::{
    BrowseConfig, BrowseExtractTool, BrowseTool, BrowserEngine, BrowserError, BrowserTab,
    ElementInfo, LinkInfo, PageContent, TabGuard,
};

#[cfg(feature = "native-browser")]
pub use oxi_agent::tools::browse::{BrowseScriptTool, BrowseSessionTool, OxiBrowserEngine};

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    /// Helper to build a minimal Model for tests.
    fn test_model(id: &str, provider: &str) -> Model {
        Model::new(
            id,
            id,
            Api::AnthropicMessages,
            provider,
            "https://api.example.com",
        )
    }

    #[test]
    fn test_oxi_builder_new() {
        let oxi = OxiBuilder::new().build();
        // Empty registry — no models
        assert!(oxi
            .resolve_model("anthropic/claude-sonnet-4-20250514")
            .is_err());
    }

    #[test]
    fn test_oxi_builder_with_builtins() {
        let oxi = OxiBuilder::new().with_builtins().build();
        // Should have built-in models
        assert!(oxi
            .resolve_model("anthropic/claude-sonnet-4-20250514")
            .is_ok());
        assert!(oxi.resolve_model("openai/gpt-4o").is_ok());
    }

    #[test]
    fn test_oxi_builder_custom_model() {
        let oxi = OxiBuilder::new()
            .model(test_model("test-model", "test-provider"))
            .build();
        assert!(oxi.resolve_model("test-provider/test-model").is_ok());
    }

    #[test]
    fn test_oxi_provider_resolution() {
        let oxi = OxiBuilder::new().with_builtins().build();
        // Built-in provider (falls back to built-in registry)
        assert!(oxi.create_provider("anthropic").is_ok());
        // Unknown provider
        assert!(oxi.create_provider("nonexistent").is_err());
    }

    #[test]
    fn test_agent_builder_workspace() {
        let oxi = OxiBuilder::new().with_builtins().build();
        let config = AgentConfig {
            model_id: "anthropic/claude-sonnet-4-20250514".into(),
            max_iterations: 10,
            timeout_seconds: 30,
            ..Default::default()
        };
        // AgentBuilder with workspace — should not panic
        let result = oxi.agent(config).workspace("/tmp/test-workspace").build();
        assert!(result.is_ok() || result.is_err());
    }

    #[test]
    fn test_agent_builder_coding_tools() {
        let oxi = OxiBuilder::new().with_builtins().build();
        let config = AgentConfig {
            model_id: "anthropic/claude-sonnet-4-20250514".into(),
            max_iterations: 10,
            timeout_seconds: 30,
            ..Default::default()
        };
        let result = oxi.agent(config).workspace("/tmp").coding_tools().build();
        if let Ok(agent) = result {
            let tool_names = agent.tools().names();
            assert!(tool_names.contains(&"read".to_string()));
            assert!(tool_names.contains(&"write".to_string()));
            assert!(tool_names.contains(&"edit".to_string()));
            assert!(tool_names.contains(&"ls".to_string()));
        }
    }

    #[test]
    fn test_agent_builder_readonly_tools() {
        let oxi = OxiBuilder::new().with_builtins().build();
        let config = AgentConfig {
            model_id: "anthropic/claude-sonnet-4-20250514".into(),
            max_iterations: 10,
            timeout_seconds: 30,
            ..Default::default()
        };
        let result = oxi.agent(config).workspace("/tmp").readonly_tools().build();
        if let Ok(agent) = result {
            let tool_names = agent.tools().names();
            assert!(tool_names.contains(&"read".to_string()));
            assert!(tool_names.contains(&"ls".to_string()));
            // Should NOT have write/edit
            assert!(!tool_names.contains(&"write".to_string()));
        }
    }

    #[test]
    fn test_model_registry_isolation() {
        // Two separate Oxi instances should not share state
        let oxi1 = OxiBuilder::new()
            .model(test_model("unique-1", "test"))
            .build();

        let oxi2 = OxiBuilder::new().with_builtins().build();

        // oxi2 should NOT have oxi1's custom model
        assert!(oxi2.resolve_model("test/unique-1").is_err());
        // oxi1 should have its custom model
        assert!(oxi1.resolve_model("test/unique-1").is_ok());
    }

    #[test]
    fn test_tool_factory_coding_tools() {
        let tools = crate::tool_factory::coding_tools(Path::new("/tmp"));
        let names = tools.names();
        assert!(names.contains(&"read".to_string()));
        assert!(names.contains(&"write".to_string()));
        assert!(names.contains(&"edit".to_string()));
        assert!(names.contains(&"ls".to_string()));
        assert_eq!(names.len(), 4);
    }

    #[test]
    fn test_tool_factory_readonly_tools() {
        let tools = crate::tool_factory::readonly_tools(Path::new("/tmp"));
        let names = tools.names();
        assert!(names.contains(&"read".to_string()));
        assert!(names.contains(&"ls".to_string()));
        assert_eq!(names.len(), 2);
    }

    #[test]
    fn test_tool_registry_extend_from() {
        let base = crate::tool_factory::coding_tools(Path::new("/tmp"));
        let extra = crate::tool_factory::readonly_tools(Path::new("/tmp"));

        // extend_from should add tools from extra into base
        let combined = ToolRegistry::new();
        combined.extend_from(&base);
        combined.extend_from(&extra);

        let names = combined.names();
        assert!(names.contains(&"read".to_string()));
        assert!(names.contains(&"write".to_string()));
        assert!(names.contains(&"ls".to_string()));
        // read and ls are in both — no duplicates expected since same names
    }

    // ── Phase 2+ Tests: ProviderResolver, ClosureTool, Isolation ──

    #[test]
    fn test_provider_resolver_trait_on_oxi() {
        let oxi = OxiBuilder::new().with_builtins().build();
        // Oxi implements ProviderResolver
        let resolver: &dyn ProviderResolver = &oxi;
        assert!(resolver.resolve_provider("anthropic").is_some());
        assert!(resolver.resolve_provider("nonexistent").is_none());
        assert!(resolver
            .resolve_model("anthropic/claude-sonnet-4-20250514")
            .is_some());
        assert!(resolver.resolve_model("nonexistent/model").is_none());
    }

    #[test]
    fn test_agent_uses_resolver_for_switch_model() {
        // Create isolated Oxi with only a mock model
        let oxi = OxiBuilder::new()
            .model(test_model("test-model", "test-provider"))
            .build();

        // This should fail because 'anthropic' provider isn't registered
        let config = AgentConfig {
            model_id: "test-provider/test-model".into(),
            max_iterations: 1,
            timeout_seconds: 5,
            ..Default::default()
        };
        let result = oxi.agent(config).build();
        // Agent build fails because provider 'test-provider' has no implementation
        // (no custom provider registered, no builtins enabled)
        assert!(result.is_err());
    }

    #[test]
    fn test_oxi_builder_without_builtins() {
        let oxi = OxiBuilder::new().build();
        // No models, no providers
        assert!(oxi
            .resolve_model("anthropic/claude-sonnet-4-20250514")
            .is_err());
        assert!(oxi.create_provider("anthropic").is_err());
        assert!(!oxi.has_builtins());
    }

    #[test]
    fn test_oxi_builder_with_builtins_creates_providers() {
        let oxi = OxiBuilder::new().with_builtins().build();
        assert!(oxi.has_builtins());
        // Built-in provider fallback should work
        assert!(oxi.create_provider("anthropic").is_ok());
        assert!(oxi.create_provider("openai").is_ok());
        assert!(oxi.create_provider("deepseek").is_ok());
        // Unknown still fails
        assert!(oxi.create_provider("unknown-provider").is_err());
    }

    #[test]
    fn test_closure_tool_sync() {
        let tool = crate::closure_tool::ClosureTool::new_sync(
            "test_tool",
            "A test tool",
            serde_json::json!({
                "type": "object",
                "properties": {
                    "input": { "type": "string" }
                }
            }),
            |params, _ctx| {
                let input = params["input"].as_str().unwrap_or("default");
                Ok(AgentToolResult::success(format!("processed: {}", input)))
            },
        );

        assert_eq!(tool.name(), "test_tool");
        assert_eq!(tool.description(), "A test tool");

        let rt = tokio::runtime::Runtime::new().unwrap();
        let result = rt
            .block_on(tool.execute(
                "call_1",
                serde_json::json!({"input": "hello"}),
                None,
                &ToolContext::default(),
            ))
            .unwrap();
        assert!(result.success);
        assert!(result.output.contains("processed: hello"));
    }

    #[test]
    fn test_custom_tool_in_agent_builder() {
        let oxi = OxiBuilder::new().with_builtins().build();
        let config = AgentConfig {
            model_id: "anthropic/claude-sonnet-4-20250514".into(),
            max_iterations: 10,
            timeout_seconds: 30,
            ..Default::default()
        };
        let result = oxi
            .agent(config)
            .workspace("/tmp")
            .custom_tool(
                "my_tool",
                "My custom tool",
                serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}),
                |params, _ctx| {
                    Ok(AgentToolResult::success(format!(
                        "result: {}",
                        params["query"]
                    )))
                },
            )
            .build();

        if let Ok(agent) = result {
            let tool_names = agent.tools().names();
            assert!(tool_names.contains(&"my_tool".to_string()));
        }
    }

    #[test]
    fn test_full_isolation_between_instances() {
        // Instance 1: custom model + no builtins
        let oxi1 = OxiBuilder::new()
            .model(test_model("unique-alpha", "p1"))
            .build();

        // Instance 2: builtins only
        let oxi2 = OxiBuilder::new().with_builtins().build();

        // Cross-contamination check
        assert!(oxi2.resolve_model("p1/unique-alpha").is_err());
        assert!(oxi1
            .resolve_model("anthropic/claude-sonnet-4-20250514")
            .is_err());

        // Provider isolation: oxi1 can't create anthropic (no builtins)
        assert!(oxi1.create_provider("anthropic").is_err());
        // oxi2 can create anthropic (builtins enabled)
        assert!(oxi2.create_provider("anthropic").is_ok());
    }

    #[test]
    fn test_agent_builder_system_prompt() {
        let oxi = OxiBuilder::new().with_builtins().build();
        let config = AgentConfig {
            model_id: "anthropic/claude-sonnet-4-20250514".into(),
            max_iterations: 1,
            timeout_seconds: 5,
            ..Default::default()
        };
        let agent = oxi
            .agent(config)
            .workspace("/tmp")
            .system_prompt("You are a test agent.")
            .build()
            .unwrap();
        // Agent built successfully with custom system prompt
        drop(agent);
    }

    #[test]
    fn test_oxi_builder_api_key() {
        // Builder accepts api_key without panic
        let oxi = OxiBuilder::new()
            .with_builtins()
            .api_key("anthropic", "sk-ant-test-key")
            .build();
        // The key is stored internally. create_provider will use it
        // (but actual API calls will fail since it's a fake key).
        assert!(oxi.has_builtins());
    }

    #[test]
    fn test_oxi_builder_base_url() {
        let oxi = OxiBuilder::new()
            .with_builtins()
            .base_url("openai", "https://my-proxy.example.com/v1")
            .build();
        assert!(oxi.has_builtins());
    }

    #[test]
    fn test_oxi_builder_credential() {
        let oxi = OxiBuilder::new()
            .with_builtins()
            .credential(
                "openai",
                "sk-test-key",
                Some("https://proxy.example.com/v1"),
            )
            .credential("anthropic", "sk-ant-test", None)
            .build();
        assert!(oxi.has_builtins());
    }
}