refactor(core)!: rename Provider to LLMProvider and update implementations
- Export `LLMProvider` from `owlen-core` and replace public `Provider` re-exports. - Convert `OllamaProvider` to implement the new `LLMProvider` trait with associated future types. - Adjust imports and trait bounds in `remote_client.rs` to use the updated types. - Add comprehensive provider interface tests (`provider_interface.rs`) verifying router routing and provider registry model listing with `MockProvider`. - Align dependency versions across workspace crates by switching to workspace-managed versions. - Extend CI (`.woodpecker.yml`) with a dedicated test step and generate coverage reports. - Update architecture documentation to reflect the new provider abstraction.
This commit is contained in:
@@ -39,6 +39,14 @@ matrix:
|
|||||||
EXT: ".exe"
|
EXT: ".exe"
|
||||||
|
|
||||||
steps:
|
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
|
- name: build
|
||||||
image: *rust_image
|
image: *rust_image
|
||||||
commands:
|
commands:
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ urlencoding = "2.1"
|
|||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
rpassword = "7.3"
|
rpassword = "7.3"
|
||||||
sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio-rustls", "sqlite", "macros", "uuid", "chrono", "migrate"] }
|
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
|
# Configuration
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ owlen-core = { path = "../owlen-core" }
|
|||||||
# Optional TUI dependency, enabled by the "chat-client" feature.
|
# Optional TUI dependency, enabled by the "chat-client" feature.
|
||||||
owlen-tui = { path = "../owlen-tui", optional = true }
|
owlen-tui = { path = "../owlen-tui", optional = true }
|
||||||
owlen-ollama = { path = "../owlen-ollama" }
|
owlen-ollama = { path = "../owlen-ollama" }
|
||||||
log = "0.4"
|
log = { workspace = true }
|
||||||
|
|
||||||
# CLI framework
|
# CLI framework
|
||||||
clap = { version = "4.0", features = ["derive"] }
|
clap = { workspace = true, features = ["derive"] }
|
||||||
|
|
||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
@@ -44,9 +44,9 @@ crossterm = { workspace = true }
|
|||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
regex = "1"
|
regex = { workspace = true }
|
||||||
thiserror = "1"
|
thiserror = { workspace = true }
|
||||||
dirs = "5"
|
dirs = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -143,28 +143,27 @@ fn run_config_command(command: ConfigCommand) -> Result<()> {
|
|||||||
fn run_config_doctor() -> Result<()> {
|
fn run_config_doctor() -> Result<()> {
|
||||||
let config_path = core_config::default_config_path();
|
let config_path = core_config::default_config_path();
|
||||||
let existed = config_path.exists();
|
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();
|
let mut changes = Vec::new();
|
||||||
|
|
||||||
if !existed {
|
if !existed {
|
||||||
changes.push("created configuration file from defaults".to_string());
|
changes.push("created configuration file from defaults".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if config
|
if !config
|
||||||
.providers
|
.providers
|
||||||
.get(&config.general.default_provider)
|
.contains_key(&config.general.default_provider)
|
||||||
.is_none()
|
|
||||||
{
|
{
|
||||||
config.general.default_provider = "ollama".to_string();
|
config.general.default_provider = "ollama".to_string();
|
||||||
changes.push("default provider missing; reset to '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");
|
core_config::ensure_provider_config(&mut config, "ollama");
|
||||||
changes.push("added default ollama provider configuration".to_string());
|
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");
|
core_config::ensure_provider_config(&mut config, "ollama-cloud");
|
||||||
changes.push("added default ollama-cloud provider configuration".to_string());
|
changes.push("added default ollama-cloud provider configuration".to_string());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ description = "Core traits and types for OWLEN LLM client"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
log = "0.4.20"
|
log = { workspace = true }
|
||||||
regex = { workspace = true }
|
regex = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
@@ -24,7 +24,7 @@ futures = { workspace = true }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
shellexpand = { workspace = true }
|
shellexpand = { workspace = true }
|
||||||
dirs = "5.0"
|
dirs = { workspace = true }
|
||||||
ratatui = { workspace = true }
|
ratatui = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
jsonschema = { workspace = true }
|
jsonschema = { workspace = true }
|
||||||
@@ -42,7 +42,7 @@ duckduckgo = "0.2.0"
|
|||||||
reqwest = { workspace = true, features = ["default"] }
|
reqwest = { workspace = true, features = ["default"] }
|
||||||
reqwest_011 = { version = "0.11", package = "reqwest" }
|
reqwest_011 = { version = "0.11", package = "reqwest" }
|
||||||
path-clean = "1.0"
|
path-clean = "1.0"
|
||||||
tokio-stream = "0.1"
|
tokio-stream = { workspace = true }
|
||||||
tokio-tungstenite = "0.21"
|
tokio-tungstenite = "0.21"
|
||||||
tungstenite = "0.21"
|
tungstenite = "0.21"
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ pub use mcp::{
|
|||||||
pub use mode::*;
|
pub use mode::*;
|
||||||
pub use model::*;
|
pub use model::*;
|
||||||
// Export provider types but exclude test_utils to avoid ambiguity
|
// 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 router::*;
|
||||||
pub use sandbox::*;
|
pub use sandbox::*;
|
||||||
pub use session::*;
|
pub use session::*;
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ use super::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
|
|||||||
use crate::consent::{ConsentManager, ConsentScope};
|
use crate::consent::{ConsentManager, ConsentScope};
|
||||||
use crate::tools::{Tool, WebScrapeTool, WebSearchTool};
|
use crate::tools::{Tool, WebScrapeTool, WebSearchTool};
|
||||||
use crate::types::ModelInfo;
|
use crate::types::ModelInfo;
|
||||||
use crate::{Error, Provider, Result};
|
use crate::types::{ChatResponse, Message, Role};
|
||||||
use async_trait::async_trait;
|
use crate::{provider::chat_via_stream, Error, LLMProvider, Result};
|
||||||
|
use futures::{future::BoxFuture, stream, StreamExt};
|
||||||
use reqwest::Client as HttpClient;
|
use reqwest::Client as HttpClient;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -19,10 +20,6 @@ use tokio::process::{Child, Command};
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||||
use tungstenite::protocol::Message as WsMessage;
|
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.
|
/// Client that talks to the external `owlen-mcp-server` over STDIO, HTTP, or WebSocket.
|
||||||
pub struct RemoteMcpClient {
|
pub struct RemoteMcpClient {
|
||||||
@@ -468,67 +465,66 @@ impl McpClient for RemoteMcpClient {
|
|||||||
// Provider implementation – forwards chat requests to the generate_text tool.
|
// Provider implementation – forwards chat requests to the generate_text tool.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[async_trait]
|
impl LLMProvider for RemoteMcpClient {
|
||||||
impl Provider for RemoteMcpClient {
|
type Stream = stream::Iter<std::vec::IntoIter<Result<ChatResponse>>>;
|
||||||
|
type ListModelsFuture<'a> = BoxFuture<'a, Result<Vec<ModelInfo>>>;
|
||||||
|
type ChatFuture<'a> = BoxFuture<'a, Result<ChatResponse>>;
|
||||||
|
type ChatStreamFuture<'a> = BoxFuture<'a, Result<Self::Stream>>;
|
||||||
|
type HealthCheckFuture<'a> = BoxFuture<'a, Result<()>>;
|
||||||
|
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"mcp-llm-server"
|
"mcp-llm-server"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
fn list_models(&self) -> Self::ListModelsFuture<'_> {
|
||||||
let result = self.send_rpc(methods::MODELS_LIST, json!(null)).await?;
|
Box::pin(async move {
|
||||||
let models: Vec<ModelInfo> = serde_json::from_value(result)?;
|
let result = self.send_rpc(methods::MODELS_LIST, json!(null)).await?;
|
||||||
Ok(models)
|
let models: Vec<ModelInfo> = serde_json::from_value(result)?;
|
||||||
|
Ok(models)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn chat(&self, request: crate::types::ChatRequest) -> Result<ChatResponse> {
|
fn chat(&self, request: crate::types::ChatRequest) -> Self::ChatFuture<'_> {
|
||||||
// Use the streaming implementation and take the first response.
|
Box::pin(chat_via_stream(self, request))
|
||||||
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"))),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn chat_stream(
|
fn chat_stream(&self, request: crate::types::ChatRequest) -> Self::ChatStreamFuture<'_> {
|
||||||
&self,
|
Box::pin(async move {
|
||||||
request: crate::types::ChatRequest,
|
let args = serde_json::json!({
|
||||||
) -> Result<crate::provider::ChatStream> {
|
"messages": request.messages,
|
||||||
// Build arguments matching the generate_text schema.
|
"temperature": request.parameters.temperature,
|
||||||
let args = serde_json::json!({
|
"max_tokens": request.parameters.max_tokens,
|
||||||
"messages": request.messages,
|
"model": request.model,
|
||||||
"temperature": request.parameters.temperature,
|
"stream": request.parameters.stream,
|
||||||
"max_tokens": request.parameters.max_tokens,
|
});
|
||||||
"model": request.model,
|
let call = McpToolCall {
|
||||||
"stream": request.parameters.stream,
|
name: "generate_text".to_string(),
|
||||||
});
|
arguments: args,
|
||||||
let call = McpToolCall {
|
};
|
||||||
name: "generate_text".to_string(),
|
let resp = self.call_tool(call).await?;
|
||||||
arguments: args,
|
let content = resp.output.as_str().unwrap_or("").to_string();
|
||||||
};
|
let message = Message::new(Role::Assistant, content);
|
||||||
let resp = self.call_tool(call).await?;
|
let chat_resp = ChatResponse {
|
||||||
// Build a ChatResponse from the tool output (assumed to be a string).
|
message,
|
||||||
let content = resp.output.as_str().unwrap_or("").to_string();
|
usage: None,
|
||||||
let message = Message::new(Role::Assistant, content);
|
is_streaming: false,
|
||||||
let chat_resp = ChatResponse {
|
is_final: true,
|
||||||
message,
|
};
|
||||||
usage: None,
|
Ok(stream::iter(vec![Ok(chat_resp)]))
|
||||||
is_streaming: false,
|
})
|
||||||
is_final: true,
|
|
||||||
};
|
|
||||||
let stream = stream::once(async move { Ok(chat_resp) });
|
|
||||||
Ok(Box::pin(stream))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health_check(&self) -> Result<()> {
|
fn health_check(&self) -> Self::HealthCheckFuture<'_> {
|
||||||
let params = serde_json::json!({
|
Box::pin(async move {
|
||||||
"protocol_version": PROTOCOL_VERSION,
|
let params = serde_json::json!({
|
||||||
"client_info": {
|
"protocol_version": PROTOCOL_VERSION,
|
||||||
"name": "owlen",
|
"client_info": {
|
||||||
"version": env!("CARGO_PKG_VERSION"),
|
"name": "owlen",
|
||||||
},
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
"capabilities": {}
|
},
|
||||||
});
|
"capabilities": {}
|
||||||
self.send_rpc(methods::INITIALIZE, params).await.map(|_| ())
|
});
|
||||||
|
self.send_rpc(methods::INITIALIZE, params).await.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,109 +1,119 @@
|
|||||||
//! Provider trait and related types
|
//! Provider traits and registries.
|
||||||
|
|
||||||
use crate::{types::*, Result};
|
use crate::{types::*, Error, Result};
|
||||||
use futures::Stream;
|
use anyhow::anyhow;
|
||||||
|
use futures::{Stream, StreamExt};
|
||||||
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// A stream of chat responses
|
/// A stream of chat responses
|
||||||
pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
|
pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
|
||||||
|
|
||||||
/// Trait for LLM providers (Ollama, OpenAI, Anthropic, etc.)
|
/// Trait for LLM providers (Ollama, OpenAI, Anthropic, etc.) with zero-cost static dispatch.
|
||||||
///
|
pub trait LLMProvider: Send + Sync + 'static {
|
||||||
/// # Example
|
type Stream: Stream<Item = Result<ChatResponse>> + Send + 'static;
|
||||||
///
|
|
||||||
/// ```
|
type ListModelsFuture<'a>: Future<Output = Result<Vec<ModelInfo>>> + Send
|
||||||
/// use std::pin::Pin;
|
where
|
||||||
/// use std::sync::Arc;
|
Self: 'a;
|
||||||
/// use futures::Stream;
|
|
||||||
/// use owlen_core::provider::{Provider, ProviderRegistry, ChatStream};
|
type ChatFuture<'a>: Future<Output = Result<ChatResponse>> + Send
|
||||||
/// use owlen_core::types::{ChatRequest, ChatResponse, ModelInfo, Message, Role, ChatParameters};
|
where
|
||||||
/// use owlen_core::Result;
|
Self: 'a;
|
||||||
///
|
|
||||||
/// // 1. Create a mock provider
|
type ChatStreamFuture<'a>: Future<Output = Result<Self::Stream>> + Send
|
||||||
/// struct MockProvider;
|
where
|
||||||
///
|
Self: 'a;
|
||||||
/// #[async_trait::async_trait]
|
|
||||||
/// impl Provider for MockProvider {
|
type HealthCheckFuture<'a>: Future<Output = Result<()>> + Send
|
||||||
/// fn name(&self) -> &str {
|
where
|
||||||
/// "mock"
|
Self: 'a;
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
|
||||||
/// 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<ChatResponse> {
|
|
||||||
/// 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<ChatStream> {
|
|
||||||
/// 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
|
|
||||||
fn name(&self) -> &str;
|
fn name(&self) -> &str;
|
||||||
|
|
||||||
/// List available models from this provider
|
fn list_models(&self) -> Self::ListModelsFuture<'_>;
|
||||||
async fn list_models(&self) -> Result<Vec<ModelInfo>>;
|
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<ChatResponse>;
|
|
||||||
|
|
||||||
/// Send a streaming chat completion request
|
|
||||||
async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream>;
|
|
||||||
|
|
||||||
/// 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 {
|
fn config_schema(&self) -> serde_json::Value {
|
||||||
serde_json::json!({})
|
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<ChatResponse>
|
||||||
|
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<Vec<ModelInfo>>;
|
||||||
|
|
||||||
|
/// Send a chat completion request.
|
||||||
|
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse>;
|
||||||
|
|
||||||
|
/// Send a streaming chat completion request.
|
||||||
|
async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream>;
|
||||||
|
|
||||||
|
/// 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<T> Provider for T
|
||||||
|
where
|
||||||
|
T: LLMProvider,
|
||||||
|
{
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
LLMProvider::name(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
||||||
|
LLMProvider::list_models(self).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
||||||
|
LLMProvider::chat(self, request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> {
|
||||||
|
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
|
/// Configuration for a provider
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct ProviderConfig {
|
pub struct ProviderConfig {
|
||||||
@@ -131,8 +141,8 @@ impl ProviderRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a provider
|
/// Register a provider using static dispatch.
|
||||||
pub fn register<P: Provider + 'static>(&mut self, provider: P) {
|
pub fn register<P: LLMProvider + 'static>(&mut self, provider: P) {
|
||||||
self.register_arc(Arc::new(provider));
|
self.register_arc(Arc::new(provider));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,19 +189,26 @@ impl Default for ProviderRegistry {
|
|||||||
pub mod test_utils {
|
pub mod test_utils {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::types::{ChatRequest, ChatResponse, Message, ModelInfo, Role};
|
use crate::types::{ChatRequest, ChatResponse, Message, ModelInfo, Role};
|
||||||
|
use futures::stream;
|
||||||
|
use std::future::{ready, Ready};
|
||||||
|
|
||||||
/// Mock provider for testing
|
/// Mock provider for testing
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct MockProvider;
|
pub struct MockProvider;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
impl LLMProvider for MockProvider {
|
||||||
impl Provider for MockProvider {
|
type Stream = stream::Iter<std::vec::IntoIter<Result<ChatResponse>>>;
|
||||||
|
type ListModelsFuture<'a> = Ready<Result<Vec<ModelInfo>>>;
|
||||||
|
type ChatFuture<'a> = Ready<Result<ChatResponse>>;
|
||||||
|
type ChatStreamFuture<'a> = Ready<Result<Self::Stream>>;
|
||||||
|
type HealthCheckFuture<'a> = Ready<Result<()>>;
|
||||||
|
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"mock"
|
"mock"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
fn list_models(&self) -> Self::ListModelsFuture<'_> {
|
||||||
Ok(vec![ModelInfo {
|
ready(Ok(vec![ModelInfo {
|
||||||
id: "mock-model".to_string(),
|
id: "mock-model".to_string(),
|
||||||
provider: "mock".to_string(),
|
provider: "mock".to_string(),
|
||||||
name: "mock-model".to_string(),
|
name: "mock-model".to_string(),
|
||||||
@@ -199,24 +216,154 @@ pub mod test_utils {
|
|||||||
context_window: None,
|
context_window: None,
|
||||||
capabilities: vec![],
|
capabilities: vec![],
|
||||||
supports_tools: false,
|
supports_tools: false,
|
||||||
}])
|
}]))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse> {
|
fn chat(&self, request: ChatRequest) -> Self::ChatFuture<'_> {
|
||||||
Ok(ChatResponse {
|
ready(Ok(self.build_response(&request)))
|
||||||
message: Message::new(Role::Assistant, "Mock response".to_string()),
|
}
|
||||||
|
|
||||||
|
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,
|
usage: None,
|
||||||
is_streaming: false,
|
is_streaming: false,
|
||||||
is_final: true,
|
is_final: true,
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async fn chat_stream(&self, _request: ChatRequest) -> Result<ChatStream> {
|
|
||||||
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<std::vec::IntoIter<Result<ChatResponse>>>;
|
||||||
|
type ListModelsFuture<'a> = Ready<Result<Vec<ModelInfo>>>;
|
||||||
|
type ChatFuture<'a> = Ready<Result<ChatResponse>>;
|
||||||
|
type ChatStreamFuture<'a> = Ready<Result<Self::Stream>>;
|
||||||
|
type HealthCheckFuture<'a> = Ready<Result<()>>;
|
||||||
|
|
||||||
|
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<dyn Provider> = 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ impl Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Register a provider with the router
|
/// Register a provider with the router
|
||||||
pub fn register_provider<P: Provider + 'static>(&mut self, provider: P) {
|
pub fn register_provider<P: LLMProvider + 'static>(&mut self, provider: P) {
|
||||||
self.registry.register(provider);
|
self.registry.register(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
43
crates/owlen-core/tests/provider_interface.rs
Normal file
43
crates/owlen-core/tests/provider_interface.rs
Normal file
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,15 +7,15 @@ license = "AGPL-3.0"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
owlen-core = { path = "../owlen-core" }
|
owlen-core = { path = "../owlen-core" }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0"
|
serde_json = { workspace = true }
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { workspace = true }
|
||||||
anyhow = "1.0"
|
anyhow = { workspace = true }
|
||||||
async-trait = "0.1"
|
async-trait = { workspace = true }
|
||||||
bollard = "0.17"
|
bollard = "0.17"
|
||||||
tempfile = "3.0"
|
tempfile = { workspace = true }
|
||||||
uuid = { version = "1.0", features = ["v4"] }
|
uuid = { workspace = true }
|
||||||
futures = "0.3"
|
futures = { workspace = true }
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "owlen_mcp_code_server"
|
name = "owlen_mcp_code_server"
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ edition = "2021"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
owlen-core = { path = "../owlen-core" }
|
owlen-core = { path = "../owlen-core" }
|
||||||
owlen-ollama = { path = "../owlen-ollama" }
|
owlen-ollama = { path = "../owlen-ollama" }
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { workspace = true }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0"
|
serde_json = { workspace = true }
|
||||||
anyhow = "1.0"
|
anyhow = { workspace = true }
|
||||||
tokio-stream = "0.1"
|
tokio-stream = { workspace = true }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "owlen-mcp-llm-server"
|
name = "owlen-mcp-llm-server"
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ license = "AGPL-3.0"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
owlen-core = { path = "../owlen-core" }
|
owlen-core = { path = "../owlen-core" }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0"
|
serde_json = { workspace = true }
|
||||||
serde_yaml = "0.9"
|
serde_yaml = { workspace = true }
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { workspace = true }
|
||||||
anyhow = "1.0"
|
anyhow = { workspace = true }
|
||||||
handlebars = "6.0"
|
handlebars = { workspace = true }
|
||||||
dirs = "5.0"
|
dirs = { workspace = true }
|
||||||
futures = "0.3"
|
futures = { workspace = true }
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "owlen_mcp_prompt_server"
|
name = "owlen_mcp_prompt_server"
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { workspace = true }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0"
|
serde_json = { workspace = true }
|
||||||
anyhow = "1.0"
|
anyhow = { workspace = true }
|
||||||
path-clean = "1.0"
|
path-clean = "1.0"
|
||||||
owlen-core = { path = "../owlen-core" }
|
owlen-core = { path = "../owlen-core" }
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//! Ollama provider for OWLEN LLM client
|
//! Ollama provider for OWLEN LLM client
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::{future::BoxFuture, StreamExt};
|
||||||
use owlen_core::{
|
use owlen_core::{
|
||||||
config::GeneralSettings,
|
config::GeneralSettings,
|
||||||
model::ModelManager,
|
model::ModelManager,
|
||||||
provider::{ChatStream, Provider, ProviderConfig},
|
provider::{LLMProvider, ProviderConfig},
|
||||||
types::{
|
types::{
|
||||||
ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, TokenUsage, ToolCall,
|
ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, TokenUsage, ToolCall,
|
||||||
},
|
},
|
||||||
@@ -639,289 +639,291 @@ impl OllamaProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
impl LLMProvider for OllamaProvider {
|
||||||
impl Provider for OllamaProvider {
|
type Stream = UnboundedReceiverStream<Result<ChatResponse>>;
|
||||||
|
type ListModelsFuture<'a> = BoxFuture<'a, Result<Vec<ModelInfo>>>;
|
||||||
|
type ChatFuture<'a> = BoxFuture<'a, Result<ChatResponse>>;
|
||||||
|
type ChatStreamFuture<'a> = BoxFuture<'a, Result<Self::Stream>>;
|
||||||
|
type HealthCheckFuture<'a> = BoxFuture<'a, Result<()>>;
|
||||||
|
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"ollama"
|
"ollama"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
fn list_models(&self) -> Self::ListModelsFuture<'_> {
|
||||||
self.model_manager
|
Box::pin(async move {
|
||||||
.get_or_refresh(false, || async { self.fetch_models().await })
|
self.model_manager
|
||||||
.await
|
.get_or_refresh(false, || async { self.fetch_models().await })
|
||||||
}
|
.await
|
||||||
|
|
||||||
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
|
||||||
let ChatRequest {
|
|
||||||
model,
|
|
||||||
messages,
|
|
||||||
parameters,
|
|
||||||
tools,
|
|
||||||
} = request;
|
|
||||||
|
|
||||||
let model_id = model.clone();
|
|
||||||
|
|
||||||
let messages: Vec<OllamaMessage> = 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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> {
|
fn chat(&self, request: ChatRequest) -> Self::ChatFuture<'_> {
|
||||||
let ChatRequest {
|
Box::pin(async move {
|
||||||
model,
|
let ChatRequest {
|
||||||
messages,
|
model,
|
||||||
parameters,
|
messages,
|
||||||
tools,
|
parameters,
|
||||||
} = request;
|
tools,
|
||||||
|
} = request;
|
||||||
|
|
||||||
let model_id = model.clone();
|
let model_id = model.clone();
|
||||||
|
let messages: Vec<OllamaMessage> = messages.iter().map(Self::convert_message).collect();
|
||||||
|
let options = Self::build_options(parameters);
|
||||||
|
|
||||||
let messages: Vec<OllamaMessage> = 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.
|
let url = self.api_url("chat");
|
||||||
// Sending an empty tools array causes Ollama to reject the request for
|
let debug_body = if debug_requests_enabled() {
|
||||||
// models without tool support (400 Bad Request).
|
serde_json::to_string_pretty(&ollama_request).ok()
|
||||||
// Retain tools conversion for possible future extensions, but silence unused warnings.
|
} else {
|
||||||
let _ollama_tools = tools
|
None
|
||||||
.as_ref()
|
};
|
||||||
.filter(|t| !t.is_empty())
|
|
||||||
.map(|t| Self::convert_tools_to_ollama(t));
|
|
||||||
|
|
||||||
// Omit the `tools` field for compatibility with models lacking tool support.
|
let mut request_builder = self.client.post(&url).json(&ollama_request);
|
||||||
let ollama_request = OllamaChatRequest {
|
request_builder = self.apply_auth(request_builder);
|
||||||
model,
|
|
||||||
messages,
|
|
||||||
stream: true,
|
|
||||||
tools: None,
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = self.api_url("chat");
|
let request = request_builder.build().map_err(|e| {
|
||||||
let debug_body = if debug_requests_enabled() {
|
owlen_core::Error::Network(format!("Failed to build chat request: {e}"))
|
||||||
serde_json::to_string_pretty(&ollama_request).ok()
|
})?;
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut request_builder = self.client.post(&url).json(&ollama_request);
|
self.debug_log_request("chat", &request, debug_body.as_deref());
|
||||||
request_builder = self.apply_auth(request_builder);
|
|
||||||
|
|
||||||
let request = request_builder.build().map_err(|e| {
|
let response = self
|
||||||
owlen_core::Error::Network(format!("Failed to build streaming request: {e}"))
|
.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
|
let body = response
|
||||||
.client
|
.text()
|
||||||
.execute(request)
|
.await
|
||||||
.await
|
.map_err(|e| map_reqwest_error("chat", e))?;
|
||||||
.map_err(|e| map_reqwest_error("chat_stream", e))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
let mut ollama_response: OllamaChatResponse =
|
||||||
let status = response.status();
|
serde_json::from_str(&body).map_err(owlen_core::Error::Serialization)?;
|
||||||
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();
|
if let Some(error) = ollama_response.error.take() {
|
||||||
let mut stream = response.bytes_stream();
|
return Err(owlen_core::Error::Provider(anyhow::anyhow!(error)));
|
||||||
|
}
|
||||||
|
|
||||||
tokio::spawn(async move {
|
let message = match ollama_response.message {
|
||||||
let mut buffer = String::new();
|
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 {
|
let usage = if let (Some(prompt_tokens), Some(completion_tokens)) = (
|
||||||
match chunk {
|
ollama_response.prompt_eval_count,
|
||||||
Ok(bytes) => {
|
ollama_response.eval_count,
|
||||||
if let Ok(text) = String::from_utf8(bytes.to_vec()) {
|
) {
|
||||||
buffer.push_str(&text);
|
Some(TokenUsage {
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens: prompt_tokens + completion_tokens,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
while let Some(pos) = buffer.find('\n') {
|
Ok(ChatResponse {
|
||||||
let mut line = buffer[..pos].trim().to_string();
|
message,
|
||||||
buffer.drain(..=pos);
|
usage,
|
||||||
|
is_streaming: false,
|
||||||
|
is_final: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if line.is_empty() {
|
fn chat_stream(&self, request: ChatRequest) -> Self::ChatStreamFuture<'_> {
|
||||||
continue;
|
Box::pin(async move {
|
||||||
}
|
let ChatRequest {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
parameters,
|
||||||
|
tools,
|
||||||
|
} = request;
|
||||||
|
|
||||||
if line.ends_with('\r') {
|
let model_id = model.clone();
|
||||||
line.pop();
|
let messages: Vec<OllamaMessage> = messages.iter().map(Self::convert_message).collect();
|
||||||
}
|
let options = Self::build_options(parameters);
|
||||||
|
|
||||||
match serde_json::from_str::<OllamaChatResponse>(&line) {
|
let _ollama_tools = tools
|
||||||
Ok(mut ollama_response) => {
|
.as_ref()
|
||||||
if let Some(error) = ollama_response.error.take() {
|
.filter(|t| !t.is_empty())
|
||||||
let _ = tx.send(Err(owlen_core::Error::Provider(
|
.map(|t| Self::convert_tools_to_ollama(t));
|
||||||
anyhow::anyhow!(error),
|
|
||||||
)));
|
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::<OllamaChatResponse>(&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;
|
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(
|
Err(e) => {
|
||||||
serde_json::Error::io(io::Error::new(
|
let _ = tx.send(Err(owlen_core::Error::Network(format!(
|
||||||
io::ErrorKind::InvalidData,
|
"Stream error: {e}"
|
||||||
"Non UTF-8 chunk from Ollama",
|
))));
|
||||||
)),
|
|
||||||
)));
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
let _ = tx.send(Err(owlen_core::Error::Network(format!(
|
|
||||||
"Stream error: {e}"
|
|
||||||
))));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
let stream = UnboundedReceiverStream::new(rx);
|
let stream = UnboundedReceiverStream::new(rx);
|
||||||
Ok(Box::pin(stream))
|
Ok(stream)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health_check(&self) -> Result<()> {
|
fn health_check(&self) -> Self::HealthCheckFuture<'_> {
|
||||||
let url = self.api_url("version");
|
Box::pin(async move {
|
||||||
|
let url = self.api_url("version");
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.apply_auth(self.client.get(&url))
|
.apply_auth(self.client.get(&url))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_reqwest_error("health check", e))?;
|
.map_err(|e| map_reqwest_error("health check", e))?;
|
||||||
|
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let detail = parse_error_body(response).await;
|
let detail = parse_error_body(response).await;
|
||||||
Err(self.map_http_failure("health check", status, detail, None))
|
Err(self.map_http_failure("health check", status, detail, None))
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_schema(&self) -> serde_json::Value {
|
fn config_schema(&self) -> serde_json::Value {
|
||||||
|
|||||||
@@ -31,13 +31,19 @@ A simplified diagram of how components interact:
|
|||||||
|
|
||||||
## Crate Breakdown
|
## Crate Breakdown
|
||||||
|
|
||||||
- `owlen-core`: Defines the core traits and data structures, like `Provider` and `Session`. Also contains the MCP client implementation.
|
- `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`: Contains all the logic for the terminal user interface, including event handling and rendering.
|
- `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`: The command-line entry point, responsible for parsing arguments and starting the TUI.
|
- `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`: MCP server that wraps Ollama providers and exposes them via the Model Context Protocol.
|
- `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-mcp-server`: Generic MCP server for file operations and resource management.
|
||||||
- `owlen-ollama`: Direct Ollama provider implementation (legacy, used only by MCP servers).
|
- `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)
|
## MCP Architecture (Phase 10)
|
||||||
|
|
||||||
As of Phase 10, OWLEN uses a **MCP-only architecture** where all LLM interactions go through the Model Context Protocol:
|
As of Phase 10, OWLEN uses a **MCP-only architecture** where all LLM interactions go through the Model Context Protocol:
|
||||||
|
|||||||
Reference in New Issue
Block a user