diff --git a/.woodpecker.yml b/.woodpecker.yml index c658930..a28d15e 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -39,6 +39,14 @@ matrix: EXT: ".exe" steps: + - name: tests + image: *rust_image + commands: + - rustup component add llvm-tools-preview + - cargo install cargo-llvm-cov --locked + - cargo llvm-cov --workspace --all-features --summary-only + - cargo llvm-cov --workspace --all-features --lcov --output-path coverage.lcov --no-run + - name: build image: *rust_image commands: diff --git a/Cargo.toml b/Cargo.toml index 990b1e6..4359747 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,10 @@ urlencoding = "2.1" regex = "1.10" rpassword = "7.3" sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio-rustls", "sqlite", "macros", "uuid", "chrono", "migrate"] } +log = "0.4" +dirs = "5.0" +serde_yaml = "0.9" +handlebars = "6.0" # Configuration toml = "0.8" diff --git a/crates/owlen-cli/Cargo.toml b/crates/owlen-cli/Cargo.toml index 84c0b34..4e815af 100644 --- a/crates/owlen-cli/Cargo.toml +++ b/crates/owlen-cli/Cargo.toml @@ -27,10 +27,10 @@ owlen-core = { path = "../owlen-core" } # Optional TUI dependency, enabled by the "chat-client" feature. owlen-tui = { path = "../owlen-tui", optional = true } owlen-ollama = { path = "../owlen-ollama" } -log = "0.4" +log = { workspace = true } # CLI framework -clap = { version = "4.0", features = ["derive"] } +clap = { workspace = true, features = ["derive"] } # Async runtime tokio = { workspace = true } @@ -44,9 +44,9 @@ crossterm = { workspace = true } anyhow = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -regex = "1" -thiserror = "1" -dirs = "5" +regex = { workspace = true } +thiserror = { workspace = true } +dirs = { workspace = true } [dev-dependencies] tokio = { workspace = true } diff --git a/crates/owlen-cli/src/main.rs b/crates/owlen-cli/src/main.rs index 75dfcf6..f919d1b 100644 --- a/crates/owlen-cli/src/main.rs +++ b/crates/owlen-cli/src/main.rs @@ -143,28 +143,27 @@ fn run_config_command(command: ConfigCommand) -> Result<()> { fn run_config_doctor() -> Result<()> { let config_path = core_config::default_config_path(); let existed = config_path.exists(); - let mut config = config::try_load_config().unwrap_or_else(|| Config::default()); + let mut config = config::try_load_config().unwrap_or_default(); let mut changes = Vec::new(); if !existed { changes.push("created configuration file from defaults".to_string()); } - if config + if !config .providers - .get(&config.general.default_provider) - .is_none() + .contains_key(&config.general.default_provider) { config.general.default_provider = "ollama".to_string(); changes.push("default provider missing; reset to 'ollama'".to_string()); } - if config.providers.get("ollama").is_none() { + if !config.providers.contains_key("ollama") { core_config::ensure_provider_config(&mut config, "ollama"); changes.push("added default ollama provider configuration".to_string()); } - if config.providers.get("ollama-cloud").is_none() { + if !config.providers.contains_key("ollama-cloud") { core_config::ensure_provider_config(&mut config, "ollama-cloud"); changes.push("added default ollama-cloud provider configuration".to_string()); } diff --git a/crates/owlen-core/Cargo.toml b/crates/owlen-core/Cargo.toml index 85e3145..70a092f 100644 --- a/crates/owlen-core/Cargo.toml +++ b/crates/owlen-core/Cargo.toml @@ -10,7 +10,7 @@ description = "Core traits and types for OWLEN LLM client" [dependencies] anyhow = { workspace = true } -log = "0.4.20" +log = { workspace = true } regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -24,7 +24,7 @@ futures = { workspace = true } async-trait = { workspace = true } toml = { workspace = true } shellexpand = { workspace = true } -dirs = "5.0" +dirs = { workspace = true } ratatui = { workspace = true } tempfile = { workspace = true } jsonschema = { workspace = true } @@ -42,7 +42,7 @@ duckduckgo = "0.2.0" reqwest = { workspace = true, features = ["default"] } reqwest_011 = { version = "0.11", package = "reqwest" } path-clean = "1.0" -tokio-stream = "0.1" +tokio-stream = { workspace = true } tokio-tungstenite = "0.21" tungstenite = "0.21" diff --git a/crates/owlen-core/src/lib.rs b/crates/owlen-core/src/lib.rs index a0c82c0..31277c1 100644 --- a/crates/owlen-core/src/lib.rs +++ b/crates/owlen-core/src/lib.rs @@ -42,7 +42,7 @@ pub use mcp::{ pub use mode::*; pub use model::*; // Export provider types but exclude test_utils to avoid ambiguity -pub use provider::{ChatStream, Provider, ProviderConfig, ProviderRegistry}; +pub use provider::{ChatStream, LLMProvider, Provider, ProviderConfig, ProviderRegistry}; pub use router::*; pub use sandbox::*; pub use session::*; diff --git a/crates/owlen-core/src/mcp/remote_client.rs b/crates/owlen-core/src/mcp/remote_client.rs index fccda6d..33b8188 100644 --- a/crates/owlen-core/src/mcp/remote_client.rs +++ b/crates/owlen-core/src/mcp/remote_client.rs @@ -6,8 +6,9 @@ use super::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse}; use crate::consent::{ConsentManager, ConsentScope}; use crate::tools::{Tool, WebScrapeTool, WebSearchTool}; use crate::types::ModelInfo; -use crate::{Error, Provider, Result}; -use async_trait::async_trait; +use crate::types::{ChatResponse, Message, Role}; +use crate::{provider::chat_via_stream, Error, LLMProvider, Result}; +use futures::{future::BoxFuture, stream, StreamExt}; use reqwest::Client as HttpClient; use serde_json::json; use std::path::Path; @@ -19,10 +20,6 @@ use tokio::process::{Child, Command}; use tokio::sync::Mutex; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; use tungstenite::protocol::Message as WsMessage; -// Provider trait is already imported via the earlier use statement. -use crate::types::{ChatResponse, Message, Role}; -use futures::stream; -use futures::StreamExt; /// Client that talks to the external `owlen-mcp-server` over STDIO, HTTP, or WebSocket. pub struct RemoteMcpClient { @@ -468,67 +465,66 @@ impl McpClient for RemoteMcpClient { // Provider implementation – forwards chat requests to the generate_text tool. // --------------------------------------------------------------------------- -#[async_trait] -impl Provider for RemoteMcpClient { +impl LLMProvider for RemoteMcpClient { + type Stream = stream::Iter>>; + type ListModelsFuture<'a> = BoxFuture<'a, Result>>; + type ChatFuture<'a> = BoxFuture<'a, Result>; + type ChatStreamFuture<'a> = BoxFuture<'a, Result>; + type HealthCheckFuture<'a> = BoxFuture<'a, Result<()>>; + fn name(&self) -> &str { "mcp-llm-server" } - async fn list_models(&self) -> Result> { - let result = self.send_rpc(methods::MODELS_LIST, json!(null)).await?; - let models: Vec = serde_json::from_value(result)?; - Ok(models) + fn list_models(&self) -> Self::ListModelsFuture<'_> { + Box::pin(async move { + let result = self.send_rpc(methods::MODELS_LIST, json!(null)).await?; + let models: Vec = serde_json::from_value(result)?; + Ok(models) + }) } - async fn chat(&self, request: crate::types::ChatRequest) -> Result { - // Use the streaming implementation and take the first response. - let mut stream = self.chat_stream(request).await?; - match stream.next().await { - Some(Ok(resp)) => Ok(resp), - Some(Err(e)) => Err(e), - None => Err(Error::Provider(anyhow::anyhow!("Empty chat stream"))), - } + fn chat(&self, request: crate::types::ChatRequest) -> Self::ChatFuture<'_> { + Box::pin(chat_via_stream(self, request)) } - async fn chat_stream( - &self, - request: crate::types::ChatRequest, - ) -> Result { - // Build arguments matching the generate_text schema. - let args = serde_json::json!({ - "messages": request.messages, - "temperature": request.parameters.temperature, - "max_tokens": request.parameters.max_tokens, - "model": request.model, - "stream": request.parameters.stream, - }); - let call = McpToolCall { - name: "generate_text".to_string(), - arguments: args, - }; - let resp = self.call_tool(call).await?; - // Build a ChatResponse from the tool output (assumed to be a string). - let content = resp.output.as_str().unwrap_or("").to_string(); - let message = Message::new(Role::Assistant, content); - let chat_resp = ChatResponse { - message, - usage: None, - is_streaming: false, - is_final: true, - }; - let stream = stream::once(async move { Ok(chat_resp) }); - Ok(Box::pin(stream)) + fn chat_stream(&self, request: crate::types::ChatRequest) -> Self::ChatStreamFuture<'_> { + Box::pin(async move { + let args = serde_json::json!({ + "messages": request.messages, + "temperature": request.parameters.temperature, + "max_tokens": request.parameters.max_tokens, + "model": request.model, + "stream": request.parameters.stream, + }); + let call = McpToolCall { + name: "generate_text".to_string(), + arguments: args, + }; + let resp = self.call_tool(call).await?; + let content = resp.output.as_str().unwrap_or("").to_string(); + let message = Message::new(Role::Assistant, content); + let chat_resp = ChatResponse { + message, + usage: None, + is_streaming: false, + is_final: true, + }; + Ok(stream::iter(vec![Ok(chat_resp)])) + }) } - async fn health_check(&self) -> Result<()> { - let params = serde_json::json!({ - "protocol_version": PROTOCOL_VERSION, - "client_info": { - "name": "owlen", - "version": env!("CARGO_PKG_VERSION"), - }, - "capabilities": {} - }); - self.send_rpc(methods::INITIALIZE, params).await.map(|_| ()) + fn health_check(&self) -> Self::HealthCheckFuture<'_> { + Box::pin(async move { + let params = serde_json::json!({ + "protocol_version": PROTOCOL_VERSION, + "client_info": { + "name": "owlen", + "version": env!("CARGO_PKG_VERSION"), + }, + "capabilities": {} + }); + self.send_rpc(methods::INITIALIZE, params).await.map(|_| ()) + }) } } diff --git a/crates/owlen-core/src/provider.rs b/crates/owlen-core/src/provider.rs index ed9a019..eb65981 100644 --- a/crates/owlen-core/src/provider.rs +++ b/crates/owlen-core/src/provider.rs @@ -1,109 +1,119 @@ -//! Provider trait and related types +//! Provider traits and registries. -use crate::{types::*, Result}; -use futures::Stream; +use crate::{types::*, Error, Result}; +use anyhow::anyhow; +use futures::{Stream, StreamExt}; +use std::future::Future; use std::pin::Pin; use std::sync::Arc; /// A stream of chat responses pub type ChatStream = Pin> + Send>>; -/// Trait for LLM providers (Ollama, OpenAI, Anthropic, etc.) -/// -/// # Example -/// -/// ``` -/// use std::pin::Pin; -/// use std::sync::Arc; -/// use futures::Stream; -/// use owlen_core::provider::{Provider, ProviderRegistry, ChatStream}; -/// use owlen_core::types::{ChatRequest, ChatResponse, ModelInfo, Message, Role, ChatParameters}; -/// use owlen_core::Result; -/// -/// // 1. Create a mock provider -/// struct MockProvider; -/// -/// #[async_trait::async_trait] -/// impl Provider for MockProvider { -/// fn name(&self) -> &str { -/// "mock" -/// } -/// -/// async fn list_models(&self) -> Result> { -/// Ok(vec![ModelInfo { -/// id: "mock-model".to_string(), -/// provider: "mock".to_string(), -/// name: "mock-model".to_string(), -/// description: None, -/// context_window: None, -/// capabilities: vec![], -/// supports_tools: false, -/// }]) -/// } -/// -/// async fn chat(&self, request: ChatRequest) -> Result { -/// let content = format!("Response to: {}", request.messages.last().unwrap().content); -/// Ok(ChatResponse { -/// message: Message::new(Role::Assistant, content), -/// usage: None, -/// is_streaming: false, -/// is_final: true, -/// }) -/// } -/// -/// async fn chat_stream(&self, request: ChatRequest) -> Result { -/// unimplemented!(); -/// } -/// -/// async fn health_check(&self) -> Result<()> { -/// Ok(()) -/// } -/// } -/// -/// // 2. Use the provider with a registry -/// #[tokio::main] -/// async fn main() { -/// let mut registry = ProviderRegistry::new(); -/// registry.register(MockProvider); -/// -/// let provider = registry.get("mock").unwrap(); -/// let models = provider.list_models().await.unwrap(); -/// assert_eq!(models[0].name, "mock-model"); -/// -/// let request = ChatRequest { -/// model: "mock-model".to_string(), -/// messages: vec![Message::new(Role::User, "Hello".to_string())], -/// parameters: ChatParameters::default(), -/// tools: None, -/// }; -/// -/// let response = provider.chat(request).await.unwrap(); -/// assert_eq!(response.message.content, "Response to: Hello"); -/// } -/// ``` -#[async_trait::async_trait] -pub trait Provider: Send + Sync { - /// Get the name of this provider +/// Trait for LLM providers (Ollama, OpenAI, Anthropic, etc.) with zero-cost static dispatch. +pub trait LLMProvider: Send + Sync + 'static { + type Stream: Stream> + Send + 'static; + + type ListModelsFuture<'a>: Future>> + Send + where + Self: 'a; + + type ChatFuture<'a>: Future> + Send + where + Self: 'a; + + type ChatStreamFuture<'a>: Future> + Send + where + Self: 'a; + + type HealthCheckFuture<'a>: Future> + Send + where + Self: 'a; + fn name(&self) -> &str; - /// List available models from this provider - async fn list_models(&self) -> Result>; + fn list_models(&self) -> Self::ListModelsFuture<'_>; + fn chat(&self, request: ChatRequest) -> Self::ChatFuture<'_>; + fn chat_stream(&self, request: ChatRequest) -> Self::ChatStreamFuture<'_>; + fn health_check(&self) -> Self::HealthCheckFuture<'_>; - /// Send a chat completion request - async fn chat(&self, request: ChatRequest) -> Result; - - /// Send a streaming chat completion request - async fn chat_stream(&self, request: ChatRequest) -> Result; - - /// Check if the provider is available/healthy - async fn health_check(&self) -> Result<()>; - - /// Get provider-specific configuration schema fn config_schema(&self) -> serde_json::Value { serde_json::json!({}) } } +/// Helper that implements [`LLMProvider::chat`] in terms of [`LLMProvider::chat_stream`]. +pub async fn chat_via_stream<'a, P>(provider: &'a P, request: ChatRequest) -> Result +where + P: LLMProvider + 'a, +{ + let stream = provider.chat_stream(request).await?; + let mut boxed: ChatStream = Box::pin(stream); + match boxed.next().await { + Some(Ok(response)) => Ok(response), + Some(Err(err)) => Err(err), + None => Err(Error::Provider(anyhow!( + "Empty chat stream from provider {}", + provider.name() + ))), + } +} + +/// Object-safe wrapper trait for runtime-configurable provider usage. +#[async_trait::async_trait] +pub trait Provider: Send + Sync { + /// Get the name of this provider. + fn name(&self) -> &str; + + /// List available models from this provider. + async fn list_models(&self) -> Result>; + + /// Send a chat completion request. + async fn chat(&self, request: ChatRequest) -> Result; + + /// Send a streaming chat completion request. + async fn chat_stream(&self, request: ChatRequest) -> Result; + + /// Check if the provider is available/healthy. + async fn health_check(&self) -> Result<()>; + + /// Get provider-specific configuration schema. + fn config_schema(&self) -> serde_json::Value { + serde_json::json!({}) + } +} + +#[async_trait::async_trait] +impl Provider for T +where + T: LLMProvider, +{ + fn name(&self) -> &str { + LLMProvider::name(self) + } + + async fn list_models(&self) -> Result> { + LLMProvider::list_models(self).await + } + + async fn chat(&self, request: ChatRequest) -> Result { + LLMProvider::chat(self, request).await + } + + async fn chat_stream(&self, request: ChatRequest) -> Result { + let stream = LLMProvider::chat_stream(self, request).await?; + Ok(Box::pin(stream)) + } + + async fn health_check(&self) -> Result<()> { + LLMProvider::health_check(self).await + } + + fn config_schema(&self) -> serde_json::Value { + LLMProvider::config_schema(self) + } +} + /// Configuration for a provider #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ProviderConfig { @@ -131,8 +141,8 @@ impl ProviderRegistry { } } - /// Register a provider - pub fn register(&mut self, provider: P) { + /// Register a provider using static dispatch. + pub fn register(&mut self, provider: P) { self.register_arc(Arc::new(provider)); } @@ -179,19 +189,26 @@ impl Default for ProviderRegistry { pub mod test_utils { use super::*; use crate::types::{ChatRequest, ChatResponse, Message, ModelInfo, Role}; + use futures::stream; + use std::future::{ready, Ready}; /// Mock provider for testing #[derive(Default)] pub struct MockProvider; - #[async_trait::async_trait] - impl Provider for MockProvider { + impl LLMProvider for MockProvider { + type Stream = stream::Iter>>; + type ListModelsFuture<'a> = Ready>>; + type ChatFuture<'a> = Ready>; + type ChatStreamFuture<'a> = Ready>; + type HealthCheckFuture<'a> = Ready>; + fn name(&self) -> &str { "mock" } - async fn list_models(&self) -> Result> { - Ok(vec![ModelInfo { + fn list_models(&self) -> Self::ListModelsFuture<'_> { + ready(Ok(vec![ModelInfo { id: "mock-model".to_string(), provider: "mock".to_string(), name: "mock-model".to_string(), @@ -199,24 +216,154 @@ pub mod test_utils { context_window: None, capabilities: vec![], supports_tools: false, - }]) + }])) } - async fn chat(&self, _request: ChatRequest) -> Result { - Ok(ChatResponse { - message: Message::new(Role::Assistant, "Mock response".to_string()), + fn chat(&self, request: ChatRequest) -> Self::ChatFuture<'_> { + ready(Ok(self.build_response(&request))) + } + + fn chat_stream(&self, request: ChatRequest) -> Self::ChatStreamFuture<'_> { + let response = self.build_response(&request); + ready(Ok(stream::iter(vec![Ok(response)]))) + } + + fn health_check(&self) -> Self::HealthCheckFuture<'_> { + ready(Ok(())) + } + } + + impl MockProvider { + fn build_response(&self, request: &ChatRequest) -> ChatResponse { + let content = format!( + "Mock response to: {}", + request + .messages + .last() + .map(|m| m.content.clone()) + .unwrap_or_default() + ); + + ChatResponse { + message: Message::new(Role::Assistant, content), usage: None, is_streaming: false, is_final: true, - }) - } - - async fn chat_stream(&self, _request: ChatRequest) -> Result { - unimplemented!("MockProvider does not support streaming") - } - - async fn health_check(&self) -> Result<()> { - Ok(()) + } } } } + +#[cfg(test)] +mod tests { + use super::test_utils::MockProvider; + use super::*; + use crate::types::{ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role}; + use futures::stream; + use std::future::{ready, Ready}; + use std::sync::Arc; + + struct StreamingProvider; + + impl LLMProvider for StreamingProvider { + type Stream = stream::Iter>>; + type ListModelsFuture<'a> = Ready>>; + type ChatFuture<'a> = Ready>; + type ChatStreamFuture<'a> = Ready>; + type HealthCheckFuture<'a> = Ready>; + + fn name(&self) -> &str { + "streaming" + } + + fn list_models(&self) -> Self::ListModelsFuture<'_> { + ready(Ok(vec![ModelInfo { + id: "stream-model".to_string(), + provider: "streaming".to_string(), + name: "stream-model".to_string(), + description: None, + context_window: None, + capabilities: vec!["chat".to_string()], + supports_tools: false, + }])) + } + + fn chat(&self, request: ChatRequest) -> Self::ChatFuture<'_> { + ready(Ok(self.response(&request))) + } + + fn chat_stream(&self, request: ChatRequest) -> Self::ChatStreamFuture<'_> { + let response = self.response(&request); + ready(Ok(stream::iter(vec![Ok(response)]))) + } + + fn health_check(&self) -> Self::HealthCheckFuture<'_> { + ready(Ok(())) + } + } + + impl StreamingProvider { + fn response(&self, request: &ChatRequest) -> ChatResponse { + let reply = format!( + "echo:{}", + request + .messages + .last() + .map(|m| m.content.clone()) + .unwrap_or_default() + ); + ChatResponse { + message: Message::new(Role::Assistant, reply), + usage: None, + is_streaming: true, + is_final: true, + } + } + } + + #[tokio::test] + async fn default_chat_reads_from_stream() { + let provider = StreamingProvider; + let request = ChatRequest { + model: "stream-model".to_string(), + messages: vec![Message::new(Role::User, "ping".to_string())], + parameters: ChatParameters::default(), + tools: None, + }; + + let response = LLMProvider::chat(&provider, request) + .await + .expect("chat succeeded"); + assert_eq!(response.message.content, "echo:ping"); + assert!(response.is_final); + } + + #[tokio::test] + async fn registry_registers_static_provider() { + let mut registry = ProviderRegistry::new(); + registry.register(StreamingProvider); + + let provider = registry.get("streaming").expect("provider registered"); + let models = provider.list_models().await.expect("models listed"); + assert_eq!(models[0].id, "stream-model"); + } + + #[tokio::test] + async fn registry_accepts_dynamic_provider() { + let mut registry = ProviderRegistry::new(); + let provider: Arc = Arc::new(MockProvider::default()); + registry.register_arc(provider.clone()); + + let fetched = registry.get("mock").expect("mock provider present"); + let request = ChatRequest { + model: "mock-model".to_string(), + messages: vec![Message::new(Role::User, "hi".to_string())], + parameters: ChatParameters::default(), + tools: None, + }; + let response = Provider::chat(fetched.as_ref(), request) + .await + .expect("chat succeeded"); + assert_eq!(response.message.content, "Mock response to: hi"); + } +} diff --git a/crates/owlen-core/src/router.rs b/crates/owlen-core/src/router.rs index 118d554..23f6eec 100644 --- a/crates/owlen-core/src/router.rs +++ b/crates/owlen-core/src/router.rs @@ -32,7 +32,7 @@ impl Router { } /// Register a provider with the router - pub fn register_provider(&mut self, provider: P) { + pub fn register_provider(&mut self, provider: P) { self.registry.register(provider); } diff --git a/crates/owlen-core/tests/provider_interface.rs b/crates/owlen-core/tests/provider_interface.rs new file mode 100644 index 0000000..59d5489 --- /dev/null +++ b/crates/owlen-core/tests/provider_interface.rs @@ -0,0 +1,43 @@ +use futures::StreamExt; +use owlen_core::provider::test_utils::MockProvider; +use owlen_core::{provider::ProviderRegistry, types::*, Router}; +use std::sync::Arc; + +fn request(message: &str) -> ChatRequest { + ChatRequest { + model: "mock-model".to_string(), + messages: vec![Message::new(Role::User, message.to_string())], + parameters: ChatParameters::default(), + tools: None, + } +} + +#[tokio::test] +async fn router_routes_to_registered_provider() { + let mut router = Router::new(); + router.register_provider(MockProvider::default()); + router.set_default_provider("mock".to_string()); + + let resp = router.chat(request("ping")).await.expect("chat succeeded"); + assert_eq!(resp.message.content, "Mock response to: ping"); + + let mut stream = router + .chat_stream(request("pong")) + .await + .expect("stream returned"); + let first = stream.next().await.expect("stream item").expect("ok item"); + assert_eq!(first.message.content, "Mock response to: pong"); +} + +#[tokio::test] +async fn registry_lists_models_from_all_providers() { + let mut registry = ProviderRegistry::new(); + registry.register(MockProvider::default()); + registry.register_arc(Arc::new(MockProvider::default())); + + let models = registry.list_all_models().await.expect("listed"); + assert!( + models.iter().any(|m| m.name == "mock-model"), + "expected mock-model in model list" + ); +} diff --git a/crates/owlen-mcp-code-server/Cargo.toml b/crates/owlen-mcp-code-server/Cargo.toml index f9380bb..6c27cdf 100644 --- a/crates/owlen-mcp-code-server/Cargo.toml +++ b/crates/owlen-mcp-code-server/Cargo.toml @@ -7,15 +7,15 @@ license = "AGPL-3.0" [dependencies] owlen-core = { path = "../owlen-core" } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -tokio = { version = "1.0", features = ["full"] } -anyhow = "1.0" -async-trait = "0.1" +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } bollard = "0.17" -tempfile = "3.0" -uuid = { version = "1.0", features = ["v4"] } -futures = "0.3" +tempfile = { workspace = true } +uuid = { workspace = true } +futures = { workspace = true } [lib] name = "owlen_mcp_code_server" diff --git a/crates/owlen-mcp-llm-server/Cargo.toml b/crates/owlen-mcp-llm-server/Cargo.toml index 303cf70..bd02f07 100644 --- a/crates/owlen-mcp-llm-server/Cargo.toml +++ b/crates/owlen-mcp-llm-server/Cargo.toml @@ -6,11 +6,11 @@ edition = "2021" [dependencies] owlen-core = { path = "../owlen-core" } owlen-ollama = { path = "../owlen-ollama" } -tokio = { version = "1.0", features = ["full"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -anyhow = "1.0" -tokio-stream = "0.1" +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +tokio-stream = { workspace = true } [[bin]] name = "owlen-mcp-llm-server" diff --git a/crates/owlen-mcp-prompt-server/Cargo.toml b/crates/owlen-mcp-prompt-server/Cargo.toml index 9b7efd9..ac2e03c 100644 --- a/crates/owlen-mcp-prompt-server/Cargo.toml +++ b/crates/owlen-mcp-prompt-server/Cargo.toml @@ -7,14 +7,14 @@ license = "AGPL-3.0" [dependencies] owlen-core = { path = "../owlen-core" } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -serde_yaml = "0.9" -tokio = { version = "1.0", features = ["full"] } -anyhow = "1.0" -handlebars = "6.0" -dirs = "5.0" -futures = "0.3" +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } +handlebars = { workspace = true } +dirs = { workspace = true } +futures = { workspace = true } [lib] name = "owlen_mcp_prompt_server" diff --git a/crates/owlen-mcp-server/Cargo.toml b/crates/owlen-mcp-server/Cargo.toml index 3a7a4bf..81246f5 100644 --- a/crates/owlen-mcp-server/Cargo.toml +++ b/crates/owlen-mcp-server/Cargo.toml @@ -4,9 +4,9 @@ version = "0.1.0" edition = "2021" [dependencies] -tokio = { version = "1.0", features = ["full"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -anyhow = "1.0" +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } path-clean = "1.0" owlen-core = { path = "../owlen-core" } diff --git a/crates/owlen-ollama/src/lib.rs b/crates/owlen-ollama/src/lib.rs index b432bc6..c7a4545 100644 --- a/crates/owlen-ollama/src/lib.rs +++ b/crates/owlen-ollama/src/lib.rs @@ -1,10 +1,10 @@ //! Ollama provider for OWLEN LLM client -use futures_util::StreamExt; +use futures_util::{future::BoxFuture, StreamExt}; use owlen_core::{ config::GeneralSettings, model::ModelManager, - provider::{ChatStream, Provider, ProviderConfig}, + provider::{LLMProvider, ProviderConfig}, types::{ ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, TokenUsage, ToolCall, }, @@ -639,289 +639,291 @@ impl OllamaProvider { } } -#[async_trait::async_trait] -impl Provider for OllamaProvider { +impl LLMProvider for OllamaProvider { + type Stream = UnboundedReceiverStream>; + type ListModelsFuture<'a> = BoxFuture<'a, Result>>; + type ChatFuture<'a> = BoxFuture<'a, Result>; + type ChatStreamFuture<'a> = BoxFuture<'a, Result>; + type HealthCheckFuture<'a> = BoxFuture<'a, Result<()>>; + fn name(&self) -> &str { "ollama" } - async fn list_models(&self) -> Result> { - self.model_manager - .get_or_refresh(false, || async { self.fetch_models().await }) - .await - } - - async fn chat(&self, request: ChatRequest) -> Result { - let ChatRequest { - model, - messages, - parameters, - tools, - } = request; - - let model_id = model.clone(); - - let messages: Vec = messages.iter().map(Self::convert_message).collect(); - - let options = Self::build_options(parameters); - - // Only send the `tools` field if there is at least one tool. - // An empty array makes Ollama validate tool support and can cause a - // 400 Bad Request for models that do not support tools. - // Currently the `tools` field is omitted for compatibility; the variable is retained - // for potential future use. - let _ollama_tools = tools - .as_ref() - .filter(|t| !t.is_empty()) - .map(|t| Self::convert_tools_to_ollama(t)); - - // Ollama currently rejects any presence of the `tools` field for models that - // do not support function calling. To be safe, we omit the field entirely. - let ollama_request = OllamaChatRequest { - model, - messages, - stream: false, - tools: None, - options, - }; - - let url = self.api_url("chat"); - let debug_body = if debug_requests_enabled() { - serde_json::to_string_pretty(&ollama_request).ok() - } else { - None - }; - - let mut request_builder = self.client.post(&url).json(&ollama_request); - request_builder = self.apply_auth(request_builder); - - let request = request_builder.build().map_err(|e| { - owlen_core::Error::Network(format!("Failed to build chat request: {e}")) - })?; - - self.debug_log_request("chat", &request, debug_body.as_deref()); - - let response = self - .client - .execute(request) - .await - .map_err(|e| map_reqwest_error("chat", e))?; - - if !response.status().is_success() { - let status = response.status(); - let error = parse_error_body(response).await; - return Err(self.map_http_failure("chat", status, error, Some(&model_id))); - } - - let body = response - .text() - .await - .map_err(|e| map_reqwest_error("chat", e))?; - - let mut ollama_response: OllamaChatResponse = - serde_json::from_str(&body).map_err(owlen_core::Error::Serialization)?; - - if let Some(error) = ollama_response.error.take() { - return Err(owlen_core::Error::Provider(anyhow::anyhow!(error))); - } - - let message = match ollama_response.message { - Some(ref msg) => Self::convert_ollama_message(msg), - None => { - return Err(owlen_core::Error::Provider(anyhow::anyhow!( - "Ollama response missing message" - ))) - } - }; - - let usage = if let (Some(prompt_tokens), Some(completion_tokens)) = ( - ollama_response.prompt_eval_count, - ollama_response.eval_count, - ) { - Some(TokenUsage { - prompt_tokens, - completion_tokens, - total_tokens: prompt_tokens + completion_tokens, - }) - } else { - None - }; - - Ok(ChatResponse { - message, - usage, - is_streaming: false, - is_final: true, + fn list_models(&self) -> Self::ListModelsFuture<'_> { + Box::pin(async move { + self.model_manager + .get_or_refresh(false, || async { self.fetch_models().await }) + .await }) } - async fn chat_stream(&self, request: ChatRequest) -> Result { - let ChatRequest { - model, - messages, - parameters, - tools, - } = request; + fn chat(&self, request: ChatRequest) -> Self::ChatFuture<'_> { + Box::pin(async move { + let ChatRequest { + model, + messages, + parameters, + tools, + } = request; - let model_id = model.clone(); + let model_id = model.clone(); + let messages: Vec = messages.iter().map(Self::convert_message).collect(); + let options = Self::build_options(parameters); - let messages: Vec = messages.iter().map(Self::convert_message).collect(); + let _ollama_tools = tools + .as_ref() + .filter(|t| !t.is_empty()) + .map(|t| Self::convert_tools_to_ollama(t)); - let options = Self::build_options(parameters); + let ollama_request = OllamaChatRequest { + model, + messages, + stream: false, + tools: None, + options, + }; - // Only include the `tools` field if there is at least one tool. - // Sending an empty tools array causes Ollama to reject the request for - // models without tool support (400 Bad Request). - // Retain tools conversion for possible future extensions, but silence unused warnings. - let _ollama_tools = tools - .as_ref() - .filter(|t| !t.is_empty()) - .map(|t| Self::convert_tools_to_ollama(t)); + let url = self.api_url("chat"); + let debug_body = if debug_requests_enabled() { + serde_json::to_string_pretty(&ollama_request).ok() + } else { + None + }; - // Omit the `tools` field for compatibility with models lacking tool support. - let ollama_request = OllamaChatRequest { - model, - messages, - stream: true, - tools: None, - options, - }; + let mut request_builder = self.client.post(&url).json(&ollama_request); + request_builder = self.apply_auth(request_builder); - let url = self.api_url("chat"); - let debug_body = if debug_requests_enabled() { - serde_json::to_string_pretty(&ollama_request).ok() - } else { - None - }; + let request = request_builder.build().map_err(|e| { + owlen_core::Error::Network(format!("Failed to build chat request: {e}")) + })?; - let mut request_builder = self.client.post(&url).json(&ollama_request); - request_builder = self.apply_auth(request_builder); + self.debug_log_request("chat", &request, debug_body.as_deref()); - let request = request_builder.build().map_err(|e| { - owlen_core::Error::Network(format!("Failed to build streaming request: {e}")) - })?; + let response = self + .client + .execute(request) + .await + .map_err(|e| map_reqwest_error("chat", e))?; - self.debug_log_request("chat_stream", &request, debug_body.as_deref()); + if !response.status().is_success() { + let status = response.status(); + let error = parse_error_body(response).await; + return Err(self.map_http_failure("chat", status, error, Some(&model_id))); + } - let response = self - .client - .execute(request) - .await - .map_err(|e| map_reqwest_error("chat_stream", e))?; + let body = response + .text() + .await + .map_err(|e| map_reqwest_error("chat", e))?; - if !response.status().is_success() { - let status = response.status(); - let error = parse_error_body(response).await; - return Err(self.map_http_failure("chat_stream", status, error, Some(&model_id))); - } + let mut ollama_response: OllamaChatResponse = + serde_json::from_str(&body).map_err(owlen_core::Error::Serialization)?; - let (tx, rx) = mpsc::unbounded_channel(); - let mut stream = response.bytes_stream(); + if let Some(error) = ollama_response.error.take() { + return Err(owlen_core::Error::Provider(anyhow::anyhow!(error))); + } - tokio::spawn(async move { - let mut buffer = String::new(); + let message = match ollama_response.message { + Some(ref msg) => Self::convert_ollama_message(msg), + None => { + return Err(owlen_core::Error::Provider(anyhow::anyhow!( + "Ollama response missing message" + ))) + } + }; - while let Some(chunk) = stream.next().await { - match chunk { - Ok(bytes) => { - if let Ok(text) = String::from_utf8(bytes.to_vec()) { - buffer.push_str(&text); + let usage = if let (Some(prompt_tokens), Some(completion_tokens)) = ( + ollama_response.prompt_eval_count, + ollama_response.eval_count, + ) { + Some(TokenUsage { + prompt_tokens, + completion_tokens, + total_tokens: prompt_tokens + completion_tokens, + }) + } else { + None + }; - while let Some(pos) = buffer.find('\n') { - let mut line = buffer[..pos].trim().to_string(); - buffer.drain(..=pos); + Ok(ChatResponse { + message, + usage, + is_streaming: false, + is_final: true, + }) + }) + } - if line.is_empty() { - continue; - } + fn chat_stream(&self, request: ChatRequest) -> Self::ChatStreamFuture<'_> { + Box::pin(async move { + let ChatRequest { + model, + messages, + parameters, + tools, + } = request; - if line.ends_with('\r') { - line.pop(); - } + let model_id = model.clone(); + let messages: Vec = messages.iter().map(Self::convert_message).collect(); + let options = Self::build_options(parameters); - match serde_json::from_str::(&line) { - Ok(mut ollama_response) => { - if let Some(error) = ollama_response.error.take() { - let _ = tx.send(Err(owlen_core::Error::Provider( - anyhow::anyhow!(error), - ))); + let _ollama_tools = tools + .as_ref() + .filter(|t| !t.is_empty()) + .map(|t| Self::convert_tools_to_ollama(t)); + + let ollama_request = OllamaChatRequest { + model, + messages, + stream: true, + tools: None, + options, + }; + + let url = self.api_url("chat"); + let debug_body = if debug_requests_enabled() { + serde_json::to_string_pretty(&ollama_request).ok() + } else { + None + }; + + let mut request_builder = self.client.post(&url).json(&ollama_request); + request_builder = self.apply_auth(request_builder); + + let request = request_builder.build().map_err(|e| { + owlen_core::Error::Network(format!("Failed to build streaming request: {e}")) + })?; + + self.debug_log_request("chat_stream", &request, debug_body.as_deref()); + + let response = self + .client + .execute(request) + .await + .map_err(|e| map_reqwest_error("chat_stream", e))?; + + if !response.status().is_success() { + let status = response.status(); + let error = parse_error_body(response).await; + return Err(self.map_http_failure("chat_stream", status, error, Some(&model_id))); + } + + let (tx, rx) = mpsc::unbounded_channel(); + let mut stream = response.bytes_stream(); + + tokio::spawn(async move { + let mut buffer = String::new(); + + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + if let Ok(text) = String::from_utf8(bytes.to_vec()) { + buffer.push_str(&text); + + while let Some(pos) = buffer.find('\n') { + let mut line = buffer[..pos].trim().to_string(); + buffer.drain(..=pos); + + if line.is_empty() { + continue; + } + + if line.ends_with('\r') { + line.pop(); + } + + match serde_json::from_str::(&line) { + Ok(mut ollama_response) => { + if let Some(error) = ollama_response.error.take() { + let _ = tx.send(Err(owlen_core::Error::Provider( + anyhow::anyhow!(error), + ))); + break; + } + + if let Some(message) = ollama_response.message { + let mut chat_response = ChatResponse { + message: Self::convert_ollama_message(&message), + usage: None, + is_streaming: true, + is_final: ollama_response.done, + }; + + if let ( + Some(prompt_tokens), + Some(completion_tokens), + ) = ( + ollama_response.prompt_eval_count, + ollama_response.eval_count, + ) { + chat_response.usage = Some(TokenUsage { + prompt_tokens, + completion_tokens, + total_tokens: prompt_tokens + + completion_tokens, + }); + } + + if tx.send(Ok(chat_response)).is_err() { + break; + } + + if ollama_response.done { + break; + } + } + } + Err(e) => { + let _ = + tx.send(Err(owlen_core::Error::Serialization(e))); break; } - - if let Some(message) = ollama_response.message { - let mut chat_response = ChatResponse { - message: Self::convert_ollama_message(&message), - usage: None, - is_streaming: true, - is_final: ollama_response.done, - }; - - if let (Some(prompt_tokens), Some(completion_tokens)) = ( - ollama_response.prompt_eval_count, - ollama_response.eval_count, - ) { - chat_response.usage = Some(TokenUsage { - prompt_tokens, - completion_tokens, - total_tokens: prompt_tokens + completion_tokens, - }); - } - - if tx.send(Ok(chat_response)).is_err() { - break; - } - - if ollama_response.done { - break; - } - } - } - Err(e) => { - let _ = tx.send(Err(owlen_core::Error::Serialization(e))); - break; } } + } else { + let _ = tx.send(Err(owlen_core::Error::Serialization( + serde_json::Error::io(io::Error::new( + io::ErrorKind::InvalidData, + "Non UTF-8 chunk from Ollama", + )), + ))); + break; } - } else { - let _ = tx.send(Err(owlen_core::Error::Serialization( - serde_json::Error::io(io::Error::new( - io::ErrorKind::InvalidData, - "Non UTF-8 chunk from Ollama", - )), - ))); + } + Err(e) => { + let _ = tx.send(Err(owlen_core::Error::Network(format!( + "Stream error: {e}" + )))); break; } } - Err(e) => { - let _ = tx.send(Err(owlen_core::Error::Network(format!( - "Stream error: {e}" - )))); - break; - } } - } - }); + }); - let stream = UnboundedReceiverStream::new(rx); - Ok(Box::pin(stream)) + let stream = UnboundedReceiverStream::new(rx); + Ok(stream) + }) } - async fn health_check(&self) -> Result<()> { - let url = self.api_url("version"); + fn health_check(&self) -> Self::HealthCheckFuture<'_> { + Box::pin(async move { + let url = self.api_url("version"); - let response = self - .apply_auth(self.client.get(&url)) - .send() - .await - .map_err(|e| map_reqwest_error("health check", e))?; + let response = self + .apply_auth(self.client.get(&url)) + .send() + .await + .map_err(|e| map_reqwest_error("health check", e))?; - if response.status().is_success() { - Ok(()) - } else { - let status = response.status(); - let detail = parse_error_body(response).await; - Err(self.map_http_failure("health check", status, detail, None)) - } + if response.status().is_success() { + Ok(()) + } else { + let status = response.status(); + let detail = parse_error_body(response).await; + Err(self.map_http_failure("health check", status, detail, None)) + } + }) } fn config_schema(&self) -> serde_json::Value { diff --git a/docs/architecture.md b/docs/architecture.md index ae901a4..2b9573b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,13 +31,19 @@ A simplified diagram of how components interact: ## Crate Breakdown -- `owlen-core`: Defines the core traits and data structures, like `Provider` and `Session`. Also contains the MCP client implementation. -- `owlen-tui`: Contains all the logic for the terminal user interface, including event handling and rendering. -- `owlen-cli`: The command-line entry point, responsible for parsing arguments and starting the TUI. -- `owlen-mcp-llm-server`: MCP server that wraps Ollama providers and exposes them via the Model Context Protocol. +- `owlen-core`: Defines the `LLMProvider` abstraction, routing, configuration, session state, encryption, and the MCP client layer. This crate is UI-agnostic and must not depend on concrete providers, terminals, or blocking I/O. +- `owlen-tui`: Hosts all terminal UI behaviour (event loop, rendering, input modes) while delegating business logic and provider access back to `owlen-core`. +- `owlen-cli`: Small entry point that parses command-line options, resolves configuration, selects providers, and launches either the TUI or headless agent flows by calling into `owlen-core`. +- `owlen-mcp-llm-server`: Runs concrete providers (e.g., Ollama) behind an MCP boundary, exposing them as `generate_text` tools. This crate owns provider-specific wiring and process sandboxing. - `owlen-mcp-server`: Generic MCP server for file operations and resource management. - `owlen-ollama`: Direct Ollama provider implementation (legacy, used only by MCP servers). +### Boundary Guidelines + +- **owlen-core**: The dependency ceiling for most crates. Keep it free of terminal logic, CLIs, or provider-specific HTTP clients. New features should expose traits or data types here and let other crates supply concrete implementations. +- **owlen-cli**: Only orchestrates startup/shutdown. Avoid adding business logic; when a new command needs behaviour, implement it in `owlen-core` or another library crate and invoke it from the CLI. +- **owlen-mcp-llm-server**: The only crate that should directly talk to Ollama (or other provider processes). TUI/CLI code communicates with providers exclusively through MCP clients in `owlen-core`. + ## MCP Architecture (Phase 10) As of Phase 10, OWLEN uses a **MCP-only architecture** where all LLM interactions go through the Model Context Protocol: