diff --git a/crates/owlen-core/Cargo.toml b/crates/owlen-core/Cargo.toml index 8298a68..2f728bc 100644 --- a/crates/owlen-core/Cargo.toml +++ b/crates/owlen-core/Cargo.toml @@ -49,3 +49,4 @@ ollama-rs = { version = "0.3", features = ["stream", "headers"] } [dev-dependencies] tokio-test = { workspace = true } httpmock = "0.7" +wiremock = "0.6" diff --git a/crates/owlen-core/tests/fixtures/README.md b/crates/owlen-core/tests/fixtures/README.md new file mode 100644 index 0000000..95ef053 --- /dev/null +++ b/crates/owlen-core/tests/fixtures/README.md @@ -0,0 +1,11 @@ +# Integration Fixtures + +This directory holds canned Ollama responses used by the Wiremock-backed +integration tests in `ollama_wiremock.rs`. Each file mirrors the JSON payloads +served by the real Ollama HTTP API so the tests can stub +`/api/tags`, `/api/chat`, and `/v1/web/search` without contacting the network. + +- `ollama_tags.json` – minimal model listing shared by the local and cloud scenarios. +- `ollama_local_completion.json` – non-streaming completion for the local provider. +- `ollama_cloud_tool_call.json` – first chat turn that requests the `web_search` tool. +- `ollama_cloud_final.json` – follow-up completion after the tool result is injected. diff --git a/crates/owlen-core/tests/fixtures/ollama_cloud_final.json b/crates/owlen-core/tests/fixtures/ollama_cloud_final.json new file mode 100644 index 0000000..ff8edf9 --- /dev/null +++ b/crates/owlen-core/tests/fixtures/ollama_cloud_final.json @@ -0,0 +1,18 @@ +{ + "model": "llama3:8b-cloud", + "created_at": "2025-10-23T12:05:05Z", + "message": { + "role": "assistant", + "content": "Rust 1.85 shipped today. Summarising the highlights now.", + "tool_calls": [] + }, + "done": true, + "final_data": { + "total_duration": 2500000000, + "load_duration": 60000000, + "prompt_eval_count": 64, + "prompt_eval_duration": 420000000, + "eval_count": 48, + "eval_duration": 520000000 + } +} diff --git a/crates/owlen-core/tests/fixtures/ollama_cloud_tool_call.json b/crates/owlen-core/tests/fixtures/ollama_cloud_tool_call.json new file mode 100644 index 0000000..48ca5e8 --- /dev/null +++ b/crates/owlen-core/tests/fixtures/ollama_cloud_tool_call.json @@ -0,0 +1,20 @@ +{ + "model": "llama3:8b-cloud", + "created_at": "2025-10-23T12:05:00Z", + "message": { + "role": "assistant", + "content": "Let me check the latest Rust updates.", + "tool_calls": [ + { + "function": { + "name": "web_search", + "arguments": { + "query": "latest Rust release", + "max_results": 5 + } + } + } + ] + }, + "done": false +} diff --git a/crates/owlen-core/tests/fixtures/ollama_local_completion.json b/crates/owlen-core/tests/fixtures/ollama_local_completion.json new file mode 100644 index 0000000..edfac1d --- /dev/null +++ b/crates/owlen-core/tests/fixtures/ollama_local_completion.json @@ -0,0 +1,18 @@ +{ + "model": "local-mini", + "created_at": "2025-10-23T12:00:00Z", + "message": { + "role": "assistant", + "content": "Local response complete.", + "tool_calls": [] + }, + "done": true, + "final_data": { + "total_duration": 1200000000, + "load_duration": 50000000, + "prompt_eval_count": 24, + "prompt_eval_duration": 320000000, + "eval_count": 12, + "eval_duration": 480000000 + } +} diff --git a/crates/owlen-core/tests/fixtures/ollama_tags.json b/crates/owlen-core/tests/fixtures/ollama_tags.json new file mode 100644 index 0000000..493bdd9 --- /dev/null +++ b/crates/owlen-core/tests/fixtures/ollama_tags.json @@ -0,0 +1,28 @@ +{ + "models": [ + { + "name": "local-mini", + "size": 1048576, + "digest": "local-digest", + "modified_at": "2025-10-23T00:00:00Z", + "details": { + "format": "gguf", + "family": "llama3", + "parameter_size": "3B", + "quantization_level": "Q4" + } + }, + { + "name": "llama3:8b-cloud", + "size": 8589934592, + "digest": "cloud-digest", + "modified_at": "2025-10-23T00:05:00Z", + "details": { + "format": "gguf", + "family": "llama3", + "parameter_size": "8B", + "quantization_level": "Q4" + } + } + ] +} diff --git a/crates/owlen-core/tests/ollama_wiremock.rs b/crates/owlen-core/tests/ollama_wiremock.rs new file mode 100644 index 0000000..04ffbec --- /dev/null +++ b/crates/owlen-core/tests/ollama_wiremock.rs @@ -0,0 +1,385 @@ +use std::sync::Arc; + +use owlen_core::types::{ChatParameters, Role}; +use owlen_core::{ + Config, Provider, + providers::OllamaProvider, + session::{SessionController, SessionOutcome}, + storage::StorageManager, + ui::NoOpUiController, +}; +use serde_json::{Value, json}; +use tempfile::{TempDir, tempdir}; +use wiremock::{ + Match, Mock, MockServer, Request, ResponseTemplate, + matchers::{header, method, path}, +}; + +#[derive(Clone, Copy)] +struct BodySubstringMatcher { + needle: &'static str, + should_contain: bool, +} + +impl BodySubstringMatcher { + const fn contains(needle: &'static str) -> Self { + Self { + needle, + should_contain: true, + } + } + + const fn not_contains(needle: &'static str) -> Self { + Self { + needle, + should_contain: false, + } + } +} + +impl Match for BodySubstringMatcher { + fn matches(&self, request: &Request) -> bool { + let body_str = std::str::from_utf8(&request.body).unwrap_or_default(); + body_str.contains(self.needle) == self.should_contain + } +} + +fn load_fixture(name: &str) -> Value { + match name { + "ollama_tags" => serde_json::from_str(include_str!("fixtures/ollama_tags.json")) + .expect("valid tags fixture"), + "ollama_local_completion" => { + serde_json::from_str(include_str!("fixtures/ollama_local_completion.json")) + .expect("valid local completion fixture") + } + "ollama_cloud_tool_call" => { + serde_json::from_str(include_str!("fixtures/ollama_cloud_tool_call.json")) + .expect("valid cloud tool call fixture") + } + "ollama_cloud_final" => { + serde_json::from_str(include_str!("fixtures/ollama_cloud_final.json")) + .expect("valid cloud final fixture") + } + other => panic!("unknown fixture '{other}'"), + } +} + +async fn create_session( + provider: Arc, + config: Config, +) -> (SessionController, TempDir) { + let temp_dir = tempdir().expect("temp dir"); + let storage_path = temp_dir.path().join("owlen-tests.db"); + let storage = Arc::new( + StorageManager::with_database_path(storage_path) + .await + .expect("storage manager"), + ); + let ui = Arc::new(NoOpUiController); + + let session = SessionController::new(provider, config, storage, ui, false, None) + .await + .expect("session controller"); + (session, temp_dir) +} + +fn configure_local(base_url: &str) -> Config { + let mut config = Config::default(); + config.general.default_provider = "ollama_local".into(); + config.general.default_model = Some("local-mini".into()); + config.general.enable_streaming = false; + config.privacy.encrypt_local_data = false; + config.privacy.require_consent_per_session = false; + + if let Some(local) = config.providers.get_mut("ollama_local") { + local.enabled = true; + local.base_url = Some(base_url.to_string()); + } + + config +} + +fn configure_cloud(base_url: &str) -> Config { + let mut config = Config::default(); + config.general.default_provider = "ollama_cloud".into(); + config.general.default_model = Some("llama3:8b-cloud".into()); + config.general.enable_streaming = false; + config.privacy.enable_remote_search = true; + config.privacy.encrypt_local_data = false; + config.privacy.require_consent_per_session = false; + config.tools.web_search.enabled = true; + + if let Some(cloud) = config.providers.get_mut("ollama_cloud") { + cloud.enabled = true; + cloud.base_url = Some(base_url.to_string()); + cloud.api_key = Some("test-key".into()); + cloud.extra.insert( + "web_search_endpoint".into(), + Value::String("/v1/web/search".into()), + ); + } + + config +} + +#[tokio::test(flavor = "multi_thread")] +async fn local_provider_happy_path_records_usage() { + let server = MockServer::start().await; + + let tags = load_fixture("ollama_tags"); + let completion = load_fixture("ollama_local_completion"); + + let base_url = server.uri(); + + let tags_template = ResponseTemplate::new(200).set_body_json(tags); + Mock::given(method("GET")) + .and(path("/api/tags")) + .respond_with(tags_template.clone()) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/api/chat")) + .respond_with(ResponseTemplate::new(200).set_body_json(completion)) + .mount(&server) + .await; + + let config = configure_local(&base_url); + let provider: Arc = + Arc::new(OllamaProvider::new(base_url.clone()).expect("local provider")); + + let (mut session, _tmp) = create_session(provider, config).await; + + let outcome = session + .send_message( + "Summarise the local status.".to_string(), + ChatParameters::default(), + ) + .await + .expect("local completion"); + + let response = match outcome { + SessionOutcome::Complete(response) => response, + _ => panic!("expected complete outcome"), + }; + assert_eq!(response.message.content, "Local response complete."); + + let snapshot = session + .current_usage_snapshot() + .await + .expect("usage snapshot"); + assert_eq!(snapshot.provider, "ollama_local"); + assert_eq!(snapshot.hourly.total_tokens, 36); + assert_eq!(snapshot.weekly.total_tokens, 36); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cloud_tool_call_flows_through_web_search() { + let server = MockServer::start().await; + + let tags = load_fixture("ollama_tags"); + let tool_call = load_fixture("ollama_cloud_tool_call"); + let final_chunk = load_fixture("ollama_cloud_final"); + + let base_url = server.uri(); + + let tags_template = ResponseTemplate::new(200).set_body_json(tags); + Mock::given(method("GET")) + .and(path("/api/tags")) + .respond_with(tags_template.clone()) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/api/chat")) + .and(BodySubstringMatcher::not_contains("\"role\":\"tool\"")) + .respond_with(ResponseTemplate::new(200).set_body_json(tool_call)) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/api/chat")) + .and(BodySubstringMatcher::contains("\"role\":\"tool\"")) + .respond_with(ResponseTemplate::new(200).set_body_json(final_chunk)) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/v1/web/search")) + .and(header("authorization", "Bearer test-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [ + { + "title": "Rust 1.85 Released", + "url": "https://blog.rust-lang.org/2025/10/23/Rust-1.85.html", + "snippet": "Rust 1.85 lands with incremental compilation improvements." + } + ] + }))) + .expect(1) + .mount(&server) + .await; + + let config = configure_cloud(&base_url); + let cloud_cfg = config + .providers + .get("ollama_cloud") + .expect("cloud provider config") + .clone(); + let provider: Arc = Arc::new( + OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general)) + .expect("cloud provider"), + ); + + let (mut session, _tmp) = create_session(provider, config).await; + session.grant_consent( + "web_search", + vec!["network".into()], + vec![format!("{}/v1/web/search", base_url)], + ); + + let outcome = session + .send_message( + "What is new in Rust today?".to_string(), + ChatParameters::default(), + ) + .await + .expect("cloud completion"); + + let response = match outcome { + SessionOutcome::Complete(response) => response, + _ => panic!("expected complete outcome"), + }; + assert_eq!( + response.message.content, + "Rust 1.85 shipped today. Summarising the highlights now." + ); + + let convo = session.conversation(); + let tool_messages: Vec<_> = convo + .messages + .iter() + .filter(|msg| msg.role == Role::Tool) + .collect(); + assert_eq!(tool_messages.len(), 1); + assert!( + tool_messages[0].content.contains("Rust 1.85 Released"), + "tool response should include search result" + ); + + let snapshot = session + .current_usage_snapshot() + .await + .expect("usage snapshot"); + assert_eq!(snapshot.provider, "ollama_cloud"); + assert_eq!(snapshot.hourly.total_tokens, 112); // 64 prompt + 48 completion + assert_eq!(snapshot.weekly.total_tokens, 112); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cloud_unauthorized_degrades_without_usage() { + let server = MockServer::start().await; + + let tags = load_fixture("ollama_tags"); + let tags_template = ResponseTemplate::new(200).set_body_json(tags); + let base_url = server.uri(); + Mock::given(method("GET")) + .and(path("/api/tags")) + .respond_with(tags_template.clone()) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/api/chat")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": "unauthorized" + }))) + .mount(&server) + .await; + + let config = configure_cloud(&base_url); + let cloud_cfg = config + .providers + .get("ollama_cloud") + .expect("cloud provider config") + .clone(); + let provider: Arc = Arc::new( + OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general)) + .expect("cloud provider"), + ); + + let (mut session, _tmp) = create_session(provider, config).await; + + let err_text = match session + .send_message("Switch to cloud".to_string(), ChatParameters::default()) + .await + { + Ok(_) => panic!("expected unauthorized error, but request succeeded"), + Err(err) => err.to_string(), + }; + assert!( + err_text.contains("unauthorized") || err_text.contains("API key"), + "error should surface unauthorized detail, got: {err_text}" + ); + + let snapshot = session + .current_usage_snapshot() + .await + .expect("usage snapshot"); + assert_eq!(snapshot.hourly.total_tokens, 0); + assert_eq!(snapshot.weekly.total_tokens, 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cloud_rate_limit_returns_error_without_usage() { + let server = MockServer::start().await; + + let tags = load_fixture("ollama_tags"); + let tags_template = ResponseTemplate::new(200).set_body_json(tags); + let base_url = server.uri(); + Mock::given(method("GET")) + .and(path("/api/tags")) + .respond_with(tags_template.clone()) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/api/chat")) + .respond_with(ResponseTemplate::new(429).set_body_json(json!({ + "error": "too many requests" + }))) + .mount(&server) + .await; + + let config = configure_cloud(&base_url); + let cloud_cfg = config + .providers + .get("ollama_cloud") + .expect("cloud provider config") + .clone(); + let provider: Arc = Arc::new( + OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general)) + .expect("cloud provider"), + ); + + let (mut session, _tmp) = create_session(provider, config).await; + + let err_text = match session + .send_message("Hit rate limit".to_string(), ChatParameters::default()) + .await + { + Ok(_) => panic!("expected rate-limit error, but request succeeded"), + Err(err) => err.to_string(), + }; + assert!( + err_text.contains("rate limited") || err_text.contains("429"), + "error should mention rate limiting, got: {err_text}" + ); + + let snapshot = session + .current_usage_snapshot() + .await + .expect("usage snapshot"); + assert_eq!(snapshot.hourly.total_tokens, 0); + assert_eq!(snapshot.weekly.total_tokens, 0); +}