From 27a7e992f656da6cef1d80e5eb588a4e24d0a327 Mon Sep 17 00:00:00 2001 From: clearloop Date: Fri, 27 Feb 2026 19:22:28 +0800 Subject: [PATCH 1/8] feat(llm): move llm folder to the top level --- Cargo.toml | 6 +++--- {crates/llm => llm}/Cargo.toml | 0 {crates/llm => llm}/deepseek/Cargo.toml | 0 {crates/llm => llm}/deepseek/README.md | 0 {crates/llm => llm}/deepseek/src/lib.rs | 0 {crates/llm => llm}/deepseek/src/provider.rs | 0 {crates/llm => llm}/deepseek/src/request.rs | 0 {crates/llm => llm}/src/config.rs | 0 {crates/llm => llm}/src/lib.rs | 0 {crates/llm => llm}/src/message.rs | 0 {crates/llm => llm}/src/noop.rs | 0 {crates/llm => llm}/src/provider.rs | 0 {crates/llm => llm}/src/response.rs | 0 {crates/llm => llm}/src/stream.rs | 0 {crates/llm => llm}/src/tool.rs | 0 {crates/llm => llm}/templates/deepseek/response.json | 0 {crates/llm => llm}/templates/deepseek/stream.json | 0 17 files changed, 3 insertions(+), 3 deletions(-) rename {crates/llm => llm}/Cargo.toml (100%) rename {crates/llm => llm}/deepseek/Cargo.toml (100%) rename {crates/llm => llm}/deepseek/README.md (100%) rename {crates/llm => llm}/deepseek/src/lib.rs (100%) rename {crates/llm => llm}/deepseek/src/provider.rs (100%) rename {crates/llm => llm}/deepseek/src/request.rs (100%) rename {crates/llm => llm}/src/config.rs (100%) rename {crates/llm => llm}/src/lib.rs (100%) rename {crates/llm => llm}/src/message.rs (100%) rename {crates/llm => llm}/src/noop.rs (100%) rename {crates/llm => llm}/src/provider.rs (100%) rename {crates/llm => llm}/src/response.rs (100%) rename {crates/llm => llm}/src/stream.rs (100%) rename {crates/llm => llm}/src/tool.rs (100%) rename {crates/llm => llm}/templates/deepseek/response.json (100%) rename {crates/llm => llm}/templates/deepseek/stream.json (100%) diff --git a/Cargo.toml b/Cargo.toml index 2a20d9e..9c7aa27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["app/cli", "app/client", "app/gateway", "app/hub", "app/protocol", "crates/*", "crates/llm/deepseek"] +members = ["app/cli", "app/client", "app/gateway", "app/hub", "app/protocol", "crates/*", "llm", "llm/deepseek"] [workspace.package] version = "0.0.9" @@ -11,8 +11,8 @@ repository = "https://github.com/clearloop/walrus" keywords = ["llm", "agent", "ai"] [workspace.dependencies] -deepseek = { path = "crates/llm/deepseek", package = "walrus-deepseek", version = "0.0.9" } -llm = { path = "crates/llm", package = "walrus-llm", version = "0.0.9" } +deepseek = { path = "llm/deepseek", package = "walrus-deepseek", version = "0.0.9" } +llm = { path = "llm", package = "walrus-llm", version = "0.0.9" } agent = { path = "crates/core", package = "walrus-core", version = "0.0.9" } runtime = { path = "crates/runtime", package = "walrus-runtime", version = "0.0.9" } sqlite = { path = "crates/sqlite", package = "walrus-sqlite", version = "0.0.9" } diff --git a/crates/llm/Cargo.toml b/llm/Cargo.toml similarity index 100% rename from crates/llm/Cargo.toml rename to llm/Cargo.toml diff --git a/crates/llm/deepseek/Cargo.toml b/llm/deepseek/Cargo.toml similarity index 100% rename from crates/llm/deepseek/Cargo.toml rename to llm/deepseek/Cargo.toml diff --git a/crates/llm/deepseek/README.md b/llm/deepseek/README.md similarity index 100% rename from crates/llm/deepseek/README.md rename to llm/deepseek/README.md diff --git a/crates/llm/deepseek/src/lib.rs b/llm/deepseek/src/lib.rs similarity index 100% rename from crates/llm/deepseek/src/lib.rs rename to llm/deepseek/src/lib.rs diff --git a/crates/llm/deepseek/src/provider.rs b/llm/deepseek/src/provider.rs similarity index 100% rename from crates/llm/deepseek/src/provider.rs rename to llm/deepseek/src/provider.rs diff --git a/crates/llm/deepseek/src/request.rs b/llm/deepseek/src/request.rs similarity index 100% rename from crates/llm/deepseek/src/request.rs rename to llm/deepseek/src/request.rs diff --git a/crates/llm/src/config.rs b/llm/src/config.rs similarity index 100% rename from crates/llm/src/config.rs rename to llm/src/config.rs diff --git a/crates/llm/src/lib.rs b/llm/src/lib.rs similarity index 100% rename from crates/llm/src/lib.rs rename to llm/src/lib.rs diff --git a/crates/llm/src/message.rs b/llm/src/message.rs similarity index 100% rename from crates/llm/src/message.rs rename to llm/src/message.rs diff --git a/crates/llm/src/noop.rs b/llm/src/noop.rs similarity index 100% rename from crates/llm/src/noop.rs rename to llm/src/noop.rs diff --git a/crates/llm/src/provider.rs b/llm/src/provider.rs similarity index 100% rename from crates/llm/src/provider.rs rename to llm/src/provider.rs diff --git a/crates/llm/src/response.rs b/llm/src/response.rs similarity index 100% rename from crates/llm/src/response.rs rename to llm/src/response.rs diff --git a/crates/llm/src/stream.rs b/llm/src/stream.rs similarity index 100% rename from crates/llm/src/stream.rs rename to llm/src/stream.rs diff --git a/crates/llm/src/tool.rs b/llm/src/tool.rs similarity index 100% rename from crates/llm/src/tool.rs rename to llm/src/tool.rs diff --git a/crates/llm/templates/deepseek/response.json b/llm/templates/deepseek/response.json similarity index 100% rename from crates/llm/templates/deepseek/response.json rename to llm/templates/deepseek/response.json diff --git a/crates/llm/templates/deepseek/stream.json b/llm/templates/deepseek/stream.json similarity index 100% rename from crates/llm/templates/deepseek/stream.json rename to llm/templates/deepseek/stream.json From 30be6bd6628d9566acf8bfd75c4c34c339ec4f8e Mon Sep 17 00:00:00 2001 From: clearloop Date: Fri, 27 Feb 2026 20:42:16 +0800 Subject: [PATCH 2/8] feat(llm): introduce claude and openai providers --- Cargo.lock | 31 +++++ Cargo.toml | 4 +- app/gateway/Cargo.toml | 2 + app/gateway/src/config.rs | 30 +++++ app/gateway/src/gateway/builder.rs | 44 +++++- app/gateway/src/gateway/mod.rs | 5 +- app/gateway/src/lib.rs | 1 + app/gateway/src/provider.rs | 89 +++++++++++++ llm/claude/Cargo.toml | 23 ++++ llm/claude/src/lib.rs | 49 +++++++ llm/claude/src/provider.rs | 207 +++++++++++++++++++++++++++++ llm/claude/src/request.rs | 167 +++++++++++++++++++++++ llm/claude/src/stream.rs | 203 ++++++++++++++++++++++++++++ llm/openai/Cargo.toml | 22 +++ llm/openai/src/lib.rs | 84 ++++++++++++ llm/openai/src/provider.rs | 82 ++++++++++++ llm/openai/src/request.rs | 134 +++++++++++++++++++ llm/src/lib.rs | 4 +- 18 files changed, 1173 insertions(+), 8 deletions(-) create mode 100644 app/gateway/src/provider.rs create mode 100644 llm/claude/Cargo.toml create mode 100644 llm/claude/src/lib.rs create mode 100644 llm/claude/src/provider.rs create mode 100644 llm/claude/src/request.rs create mode 100644 llm/claude/src/stream.rs create mode 100644 llm/openai/Cargo.toml create mode 100644 llm/openai/src/lib.rs create mode 100644 llm/openai/src/provider.rs create mode 100644 llm/openai/src/request.rs diff --git a/Cargo.lock b/Cargo.lock index 8e3ea0b..6a892b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2092,6 +2092,21 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "walrus-claude" +version = "0.0.9" +dependencies = [ + "anyhow", + "async-stream", + "compact_str", + "futures-core", + "futures-util", + "serde", + "serde_json", + "tracing", + "walrus-llm", +] + [[package]] name = "walrus-cli" version = "0.0.9" @@ -2172,9 +2187,11 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "walrus-claude", "walrus-core", "walrus-deepseek", "walrus-llm", + "walrus-openai", "walrus-protocol", "walrus-runtime", "walrus-sqlite", @@ -2201,6 +2218,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "walrus-openai" +version = "0.0.9" +dependencies = [ + "anyhow", + "async-stream", + "futures-core", + "futures-util", + "serde", + "serde_json", + "tracing", + "walrus-llm", +] + [[package]] name = "walrus-protocol" version = "0.0.9" diff --git a/Cargo.toml b/Cargo.toml index 9c7aa27..0d4b2fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["app/cli", "app/client", "app/gateway", "app/hub", "app/protocol", "crates/*", "llm", "llm/deepseek"] +members = ["app/cli", "app/client", "app/gateway", "app/hub", "app/protocol", "crates/*", "llm", "llm/deepseek", "llm/openai", "llm/claude"] [workspace.package] version = "0.0.9" @@ -12,6 +12,8 @@ keywords = ["llm", "agent", "ai"] [workspace.dependencies] deepseek = { path = "llm/deepseek", package = "walrus-deepseek", version = "0.0.9" } +openai = { path = "llm/openai", package = "walrus-openai", version = "0.0.9" } +claude = { path = "llm/claude", package = "walrus-claude", version = "0.0.9" } llm = { path = "llm", package = "walrus-llm", version = "0.0.9" } agent = { path = "crates/core", package = "walrus-core", version = "0.0.9" } runtime = { path = "crates/runtime", package = "walrus-runtime", version = "0.0.9" } diff --git a/app/gateway/Cargo.toml b/app/gateway/Cargo.toml index a1ce447..fd1df55 100644 --- a/app/gateway/Cargo.toml +++ b/app/gateway/Cargo.toml @@ -15,6 +15,8 @@ path = "src/bin/main.rs" runtime = { workspace = true } agent = { workspace = true } deepseek = { workspace = true } +openai = { workspace = true } +claude = { workspace = true } sqlite = { workspace = true } llm = { workspace = true } protocol = { workspace = true } diff --git a/app/gateway/src/config.rs b/app/gateway/src/config.rs index 0d1b39b..b86cbe1 100644 --- a/app/gateway/src/config.rs +++ b/app/gateway/src/config.rs @@ -55,21 +55,51 @@ pub struct ServerConfig { /// LLM provider configuration. #[derive(Debug, Serialize, Deserialize)] pub struct LlmConfig { + /// Which LLM provider to use. + #[serde(default)] + pub provider: ProviderKind, /// Model identifier. pub model: CompactString, /// API key (supports `${ENV_VAR}` expansion). + #[serde(default)] pub api_key: String, + /// Optional base URL override for the provider endpoint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_url: Option, } impl Default for LlmConfig { fn default() -> Self { Self { + provider: ProviderKind::DeepSeek, model: "deepseek-chat".into(), api_key: "${DEEPSEEK_API_KEY}".to_owned(), + base_url: None, } } } +/// Supported LLM provider kinds. +#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProviderKind { + /// DeepSeek API (default). + #[default] + DeepSeek, + /// OpenAI API. + OpenAI, + /// Grok (xAI) API — OpenAI-compatible. + Grok, + /// Qwen (Alibaba DashScope) API — OpenAI-compatible. + Qwen, + /// Kimi (Moonshot) API — OpenAI-compatible. + Kimi, + /// Ollama local API — OpenAI-compatible, no key required. + Ollama, + /// Claude (Anthropic) Messages API. + Claude, +} + /// Memory backend configuration. #[derive(Debug, Default, Serialize, Deserialize)] #[serde(default)] diff --git a/app/gateway/src/gateway/builder.rs b/app/gateway/src/gateway/builder.rs index fc1e969..af7991a 100644 --- a/app/gateway/src/gateway/builder.rs +++ b/app/gateway/src/gateway/builder.rs @@ -1,11 +1,14 @@ //! Runtime builder — constructs a fully-configured Runtime from GatewayConfig. use crate::MemoryBackend; -use crate::config::{self, MemoryBackendKind}; +use crate::config::{self, MemoryBackendKind, ProviderKind}; use crate::gateway::GatewayHook; +use crate::provider::Provider; use anyhow::Result; +use claude::Claude; use deepseek::DeepSeek; use llm::LLM; +use openai::OpenAI; use runtime::{General, McpBridge, Runtime, SkillRegistry}; use std::path::Path; @@ -35,8 +38,43 @@ pub async fn build_runtime( }; // Construct provider. - let provider = DeepSeek::new(llm::Client::new(), &config.llm.api_key)?; - tracing::info!("provider initialized for model {}", config.llm.model); + let client = llm::Client::new(); + let key = &config.llm.api_key; + let provider = match config.llm.provider { + ProviderKind::DeepSeek => match &config.llm.base_url { + Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), + None => Provider::DeepSeek(DeepSeek::new(client, key)?), + }, + ProviderKind::OpenAI => match &config.llm.base_url { + Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), + None => Provider::OpenAI(OpenAI::api(client, key)?), + }, + ProviderKind::Grok => match &config.llm.base_url { + Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), + None => Provider::OpenAI(OpenAI::grok(client, key)?), + }, + ProviderKind::Qwen => match &config.llm.base_url { + Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), + None => Provider::OpenAI(OpenAI::qwen(client, key)?), + }, + ProviderKind::Kimi => match &config.llm.base_url { + Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), + None => Provider::OpenAI(OpenAI::kimi(client, key)?), + }, + ProviderKind::Ollama => match &config.llm.base_url { + Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), + None => Provider::OpenAI(OpenAI::ollama(client)?), + }, + ProviderKind::Claude => match &config.llm.base_url { + Some(url) => Provider::Claude(Claude::custom(client, key, url)?), + None => Provider::Claude(Claude::anthropic(client, key)?), + }, + }; + tracing::info!( + "provider {:?} initialized for model {}", + config.llm.provider, + config.llm.model + ); // Build general config. let general = General { diff --git a/app/gateway/src/gateway/mod.rs b/app/gateway/src/gateway/mod.rs index b7de16b..1f130d4 100644 --- a/app/gateway/src/gateway/mod.rs +++ b/app/gateway/src/gateway/mod.rs @@ -1,7 +1,6 @@ //! Protocol impls for the gateway. -use crate::MemoryBackend; -use deepseek::DeepSeek; +use crate::{MemoryBackend, provider::Provider}; use runtime::{DEFAULT_COMPACT_PROMPT, DEFAULT_FLUSH_PROMPT, Hook, Runtime}; use std::sync::Arc; @@ -27,7 +26,7 @@ impl Clone for Gateway { pub struct GatewayHook; impl Hook for GatewayHook { - type Provider = DeepSeek; + type Provider = Provider; type Memory = MemoryBackend; fn compact() -> &'static str { diff --git a/app/gateway/src/lib.rs b/app/gateway/src/lib.rs index 6ceb271..7cd28ca 100644 --- a/app/gateway/src/lib.rs +++ b/app/gateway/src/lib.rs @@ -5,6 +5,7 @@ pub mod channel; pub mod config; mod feature; pub mod gateway; +pub mod provider; pub mod utils; pub use channel::router::{ChannelRouter, RoutingRule}; diff --git a/app/gateway/src/provider.rs b/app/gateway/src/provider.rs new file mode 100644 index 0000000..7deddf1 --- /dev/null +++ b/app/gateway/src/provider.rs @@ -0,0 +1,89 @@ +//! Provider enum for runtime dispatch across LLM backends. +//! +//! Follows the same enum dispatch pattern as `MemoryBackend` (DD#22). +//! Each variant wraps a concrete provider. `impl LLM` delegates to the +//! inner provider, converting `General` config to the variant's native +//! request format via `From`. + +use anyhow::Result; +use async_stream::try_stream; +use claude::Claude; +use deepseek::DeepSeek; +use futures_core::Stream; +use futures_util::StreamExt; +use llm::{Client, General, LLM, Message, Response, StreamChunk}; +use openai::OpenAI; + +/// Unified LLM provider enum. +/// +/// The gateway constructs the appropriate variant based on `ProviderKind` +/// in `LlmConfig`. The runtime is monomorphized on `Provider`. +#[derive(Clone)] +pub enum Provider { + /// DeepSeek API. + DeepSeek(DeepSeek), + /// OpenAI-compatible API (covers OpenAI, Grok, Qwen, Kimi, Ollama). + OpenAI(OpenAI), + /// Anthropic Messages API. + Claude(Claude), +} + +impl LLM for Provider { + type ChatConfig = General; + + fn new(client: Client, key: &str) -> Result { + Ok(Self::DeepSeek(DeepSeek::new(client, key)?)) + } + + async fn send(&self, config: &General, messages: &[Message]) -> Result { + match self { + Self::DeepSeek(p) => { + let req = deepseek::Request::from(config.clone()); + p.send(&req, messages).await + } + Self::OpenAI(p) => { + let req = openai::Request::from(config.clone()); + p.send(&req, messages).await + } + Self::Claude(p) => { + let req = claude::Request::from(config.clone()); + p.send(&req, messages).await + } + } + } + + fn stream( + &self, + config: General, + messages: &[Message], + usage: bool, + ) -> impl Stream> + Send { + let messages = messages.to_vec(); + let this = self.clone(); + try_stream! { + match this { + Provider::DeepSeek(p) => { + let req = deepseek::Request::from(config); + let mut stream = std::pin::pin!(p.stream(req, &messages, usage)); + while let Some(chunk) = stream.next().await { + yield chunk?; + } + } + Provider::OpenAI(p) => { + let req = openai::Request::from(config); + let mut stream = std::pin::pin!(p.stream(req, &messages, usage)); + while let Some(chunk) = stream.next().await { + yield chunk?; + } + } + Provider::Claude(p) => { + let req = claude::Request::from(config); + let mut stream = std::pin::pin!(p.stream(req, &messages, usage)); + while let Some(chunk) = stream.next().await { + yield chunk?; + } + } + } + } + } +} diff --git a/llm/claude/Cargo.toml b/llm/claude/Cargo.toml new file mode 100644 index 0000000..5f2172d --- /dev/null +++ b/llm/claude/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "walrus-claude" +description = "Walrus Claude (Anthropic) provider implementation" +documentation = "https://docs.rs/walrus-claude" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true + +[dependencies] +llm.workspace = true + +# crates-io dependencies +anyhow.workspace = true +compact_str.workspace = true +async-stream.workspace = true +futures-core.workspace = true +futures-util.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true diff --git a/llm/claude/src/lib.rs b/llm/claude/src/lib.rs new file mode 100644 index 0000000..4effbf1 --- /dev/null +++ b/llm/claude/src/lib.rs @@ -0,0 +1,49 @@ +//! Claude (Anthropic) LLM provider. +//! +//! Implements the Anthropic Messages API, which differs from the OpenAI +//! chat completions format in message structure and streaming events. + +use llm::reqwest::{Client, header::HeaderMap}; +pub use request::Request; + +mod provider; +mod request; +mod stream; + +/// The Anthropic Messages API endpoint. +pub const ENDPOINT: &str = "https://api.anthropic.com/v1/messages"; + +/// The Anthropic API version header value. +const API_VERSION: &str = "2023-06-01"; + +/// The Claude LLM provider. +#[derive(Clone)] +pub struct Claude { + /// The HTTP client. + pub client: Client, + /// Request headers (x-api-key, anthropic-version, content-type). + headers: HeaderMap, + /// Messages API endpoint URL. + endpoint: String, +} + +impl Claude { + /// Create a provider targeting the Anthropic API. + pub fn anthropic(client: Client, key: &str) -> anyhow::Result { + Self::custom(client, key, ENDPOINT) + } + + /// Create a provider targeting a custom Anthropic-compatible endpoint. + pub fn custom(client: Client, key: &str, endpoint: &str) -> anyhow::Result { + use llm::reqwest::header; + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, "application/json".parse()?); + headers.insert("x-api-key", key.parse()?); + headers.insert("anthropic-version", API_VERSION.parse()?); + Ok(Self { + client, + headers, + endpoint: endpoint.to_owned(), + }) + } +} diff --git a/llm/claude/src/provider.rs b/llm/claude/src/provider.rs new file mode 100644 index 0000000..db68b2b --- /dev/null +++ b/llm/claude/src/provider.rs @@ -0,0 +1,207 @@ +//! LLM trait implementation for the Claude (Anthropic) provider. + +use crate::{Claude, Request, stream::Event}; +use anyhow::Result; +use async_stream::try_stream; +use futures_core::Stream; +use futures_util::StreamExt; +use compact_str::CompactString; +use llm::{ + Choice, Client, CompletionMeta, CompletionTokensDetails, Delta, FinishReason, LLM, Message, + Response, StreamChunk, Usage, + reqwest::{ + Method, + header::{self, HeaderMap}, + }, +}; + +/// Raw Anthropic non-streaming response. +#[derive(serde::Deserialize)] +struct AnthropicResponse { + id: CompactString, + model: CompactString, + content: Vec, + stop_reason: Option, + usage: AnthropicUsage, +} + +#[derive(serde::Deserialize)] +#[serde(tag = "type")] +enum ContentBlock { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "tool_use")] + ToolUse { + id: CompactString, + name: CompactString, + input: serde_json::Value, + }, +} + +#[derive(serde::Deserialize)] +struct AnthropicUsage { + input_tokens: u32, + output_tokens: u32, +} + +impl LLM for Claude { + type ChatConfig = Request; + + fn new(client: Client, key: &str) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, "application/json".parse()?); + headers.insert("x-api-key", key.parse()?); + headers.insert("anthropic-version", crate::API_VERSION.parse()?); + Ok(Self { + client, + headers, + endpoint: crate::ENDPOINT.to_owned(), + }) + } + + async fn send(&self, req: &Request, messages: &[Message]) -> Result { + let body = req.messages(messages); + tracing::trace!("request: {}", serde_json::to_string(&body)?); + let text = self + .client + .request(Method::POST, &self.endpoint) + .headers(self.headers.clone()) + .json(&body) + .send() + .await? + .text() + .await?; + + tracing::trace!("response: {text}"); + let raw: AnthropicResponse = serde_json::from_str(&text)?; + Ok(to_response(raw)) + } + + fn stream( + &self, + req: Request, + messages: &[Message], + _usage: bool, + ) -> impl Stream> { + let body = req.messages(messages).stream(); + if let Ok(body) = serde_json::to_string(&body) { + tracing::trace!("request: {}", body); + } + let request = self + .client + .request(Method::POST, &self.endpoint) + .headers(self.headers.clone()) + .json(&body); + + try_stream! { + let response = request.send().await?; + let mut stream = response.bytes_stream(); + let mut buf = String::new(); + while let Some(Ok(bytes)) = stream.next().await { + buf.push_str(&String::from_utf8_lossy(&bytes)); + while let Some(pos) = buf.find("\n\n") { + let block = buf[..pos].to_owned(); + buf = buf[pos + 2..].to_owned(); + if let Some(chunk) = parse_sse_block(&block) { + yield chunk; + } + } + } + // Handle any remaining data in buffer. + if !buf.trim().is_empty() + && let Some(chunk) = parse_sse_block(&buf) { + yield chunk; + } + } + } +} + +/// Parse a single SSE block (may contain `event:` and `data:` lines). +fn parse_sse_block(block: &str) -> Option { + let mut data_str = None; + for line in block.lines() { + if let Some(d) = line.strip_prefix("data: ") { + data_str = Some(d.trim()); + } + } + let data = data_str?; + if data == "[DONE]" { + return None; + } + match serde_json::from_str::(data) { + Ok(event) => event.into_chunk(), + Err(e) => { + tracing::warn!("failed to parse anthropic event: {e}, data: {data}"); + None + } + } +} + +/// Convert an Anthropic response to the unified `Response` format. +fn to_response(raw: AnthropicResponse) -> Response { + let mut content = String::new(); + let mut tool_calls = Vec::new(); + + for block in raw.content { + match block { + ContentBlock::Text { text } => { + if !content.is_empty() { + content.push('\n'); + } + content.push_str(&text); + } + ContentBlock::ToolUse { id, name, input } => { + tool_calls.push(llm::ToolCall { + id, + index: tool_calls.len() as u32, + call_type: "function".into(), + function: llm::FunctionCall { + name, + arguments: serde_json::to_string(&input).unwrap_or_default(), + }, + }); + } + } + } + + let finish_reason = raw.stop_reason.as_deref().map(|r| match r { + "end_turn" | "stop" => FinishReason::Stop, + "max_tokens" => FinishReason::Length, + "tool_use" => FinishReason::ToolCalls, + _ => FinishReason::Stop, + }); + + Response { + meta: CompletionMeta { + id: raw.id, + object: "chat.completion".into(), + model: raw.model, + ..Default::default() + }, + choices: vec![Choice { + index: 0, + delta: Delta { + role: Some(llm::Role::Assistant), + content: Some(content), + reasoning_content: None, + tool_calls: if tool_calls.is_empty() { + None + } else { + Some(tool_calls) + }, + }, + finish_reason, + logprobs: None, + }], + usage: Usage { + prompt_tokens: raw.usage.input_tokens, + completion_tokens: raw.usage.output_tokens, + total_tokens: raw.usage.input_tokens + raw.usage.output_tokens, + prompt_cache_hit_tokens: None, + prompt_cache_miss_tokens: None, + completion_tokens_details: Some(CompletionTokensDetails { + reasoning_tokens: None, + }), + }, + } +} diff --git a/llm/claude/src/request.rs b/llm/claude/src/request.rs new file mode 100644 index 0000000..1b1e583 --- /dev/null +++ b/llm/claude/src/request.rs @@ -0,0 +1,167 @@ +//! Request body for the Anthropic Messages API. + +use llm::{Config, General, Message, Role, Tool, ToolChoice}; +use serde::Serialize; +use serde_json::{Value, json}; + +/// The request body for the Anthropic Messages API. +#[derive(Debug, Clone, Serialize)] +pub struct Request { + /// The model identifier. + pub model: String, + /// Maximum tokens to generate. + pub max_tokens: usize, + /// System prompt (top-level, not in messages array). + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option, + /// The messages array (Anthropic content block format). + pub messages: Vec, + /// Whether to stream the response. + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + /// Tools the model may call. + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + /// Tool choice control. + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + /// Temperature. + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + /// Top-p sampling. + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, +} + +impl Request { + /// Build the request with the given messages, converting from walrus + /// `Message` format to Anthropic content block format. + pub fn messages(&self, messages: &[Message]) -> Self { + let mut system = self.system.clone(); + let mut anthropic_msgs = Vec::new(); + + for msg in messages { + match msg.role { + Role::System => { + system = Some(msg.content.clone()); + } + Role::User => { + anthropic_msgs.push(json!({ + "role": "user", + "content": msg.content, + })); + } + Role::Assistant => { + let mut content = Vec::new(); + if !msg.content.is_empty() { + content.push(json!({ + "type": "text", + "text": msg.content, + })); + } + for tc in &msg.tool_calls { + let input: Value = + serde_json::from_str(&tc.function.arguments).unwrap_or(json!({})); + content.push(json!({ + "type": "tool_use", + "id": tc.id, + "name": tc.function.name, + "input": input, + })); + } + if content.is_empty() { + content.push(json!({ + "type": "text", + "text": "", + })); + } + anthropic_msgs.push(json!({ + "role": "assistant", + "content": content, + })); + } + Role::Tool => { + anthropic_msgs.push(json!({ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": msg.tool_call_id, + "content": msg.content, + }], + })); + } + } + } + + Self { + system, + messages: anthropic_msgs, + ..self.clone() + } + } + + /// Enable streaming for the request. + pub fn stream(mut self) -> Self { + self.stream = Some(true); + self + } +} + +impl From for Request { + fn from(config: General) -> Self { + let mut req = Self { + model: config.model.to_string(), + max_tokens: config.context_limit.unwrap_or(4096), + system: None, + messages: Vec::new(), + stream: None, + tools: None, + tool_choice: None, + temperature: None, + top_p: None, + }; + + if let Some(tools) = config.tools { + req = req.with_tools(tools); + } + if let Some(tool_choice) = config.tool_choice { + req = req.with_tool_choice(tool_choice); + } + + req + } +} + +impl Config for Request { + fn with_tools(self, tools: Vec) -> Self { + let tools = tools + .into_iter() + .map(|tool| { + json!({ + "name": tool.name, + "description": tool.description, + "input_schema": tool.parameters, + }) + }) + .collect::>(); + Self { + tools: Some(tools), + ..self + } + } + + fn with_tool_choice(self, tool_choice: ToolChoice) -> Self { + Self { + tool_choice: match tool_choice { + ToolChoice::None => Some(json!({"type": "none"})), + ToolChoice::Auto => Some(json!({"type": "auto"})), + ToolChoice::Required => Some(json!({"type": "any"})), + ToolChoice::Function(name) => Some(json!({ + "type": "tool", + "name": name, + })), + }, + ..self + } + } +} diff --git a/llm/claude/src/stream.rs b/llm/claude/src/stream.rs new file mode 100644 index 0000000..2915a02 --- /dev/null +++ b/llm/claude/src/stream.rs @@ -0,0 +1,203 @@ +//! SSE event parsing for the Anthropic streaming Messages API. +//! +//! Anthropic streaming events differ from OpenAI's format: +//! - `message_start` — initial message metadata +//! - `content_block_start` — begin a content block (text or tool_use) +//! - `content_block_delta` — incremental content (text_delta or input_json_delta) +//! - `content_block_stop` — end of a content block +//! - `message_delta` — final stop_reason and usage +//! - `message_stop` — end of message + +use compact_str::CompactString; +use llm::{ + Choice, CompletionMeta, CompletionTokensDetails, Delta, FinishReason, FunctionCall, + StreamChunk, ToolCall, Usage, +}; +use serde::Deserialize; + +/// A raw SSE event from the Anthropic streaming API. +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum Event { + /// Initial message metadata. + #[serde(rename = "message_start")] + MessageStart { message: MessageMeta }, + /// Begin a content block. + #[serde(rename = "content_block_start")] + ContentBlockStart { + index: u32, + content_block: ContentBlock, + }, + /// Incremental content within a block. + #[serde(rename = "content_block_delta")] + ContentBlockDelta { index: u32, delta: BlockDelta }, + /// End of a content block. + #[serde(rename = "content_block_stop")] + ContentBlockStop {}, + /// Final message delta (stop reason + usage). + #[serde(rename = "message_delta")] + MessageDelta { delta: MessageDeltaBody, usage: MessageDeltaUsage }, + /// End of message. + #[serde(rename = "message_stop")] + MessageStop, + /// Ping (keep-alive). + #[serde(rename = "ping")] + Ping, + /// Catch-all for unknown event types. + #[serde(other)] + Unknown, +} + +#[derive(Debug, Deserialize)] +pub struct MessageMeta { + pub id: CompactString, + pub model: CompactString, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum ContentBlock { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "tool_use")] + ToolUse { id: CompactString, name: CompactString }, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum BlockDelta { + #[serde(rename = "text_delta")] + TextDelta { text: String }, + #[serde(rename = "input_json_delta")] + InputJsonDelta { partial_json: String }, +} + +#[derive(Debug, Deserialize)] +pub struct MessageDeltaBody { + pub stop_reason: Option, +} + +#[derive(Debug, Deserialize)] +pub struct MessageDeltaUsage { + pub output_tokens: u32, +} + +impl Event { + /// Convert this Anthropic event to a walrus `StreamChunk`. + /// Returns `None` for events that don't produce output (ping, stop, unknown). + pub fn into_chunk(self) -> Option { + match self { + Self::MessageStart { message } => Some(StreamChunk { + meta: CompletionMeta { + id: message.id, + object: "chat.completion.chunk".into(), + model: message.model, + ..Default::default() + }, + ..Default::default() + }), + Self::ContentBlockStart { + content_block: ContentBlock::Text { text }, + .. + } => { + if text.is_empty() { + None + } else { + Some(StreamChunk { + choices: vec![Choice { + delta: Delta { + content: Some(text), + ..Default::default() + }, + ..Default::default() + }], + ..Default::default() + }) + } + } + Self::ContentBlockStart { + index, + content_block: ContentBlock::ToolUse { id, name }, + } => Some(StreamChunk { + choices: vec![Choice { + delta: Delta { + tool_calls: Some(vec![ToolCall { + id, + index, + call_type: "function".into(), + function: FunctionCall { + name, + arguments: String::new(), + }, + }]), + ..Default::default() + }, + ..Default::default() + }], + ..Default::default() + }), + Self::ContentBlockDelta { + delta: BlockDelta::TextDelta { text }, + .. + } => Some(StreamChunk { + choices: vec![Choice { + delta: Delta { + content: Some(text), + ..Default::default() + }, + ..Default::default() + }], + ..Default::default() + }), + Self::ContentBlockDelta { + index, + delta: BlockDelta::InputJsonDelta { partial_json }, + } => Some(StreamChunk { + choices: vec![Choice { + delta: Delta { + tool_calls: Some(vec![ToolCall { + index, + function: FunctionCall { + arguments: partial_json, + ..Default::default() + }, + ..Default::default() + }]), + ..Default::default() + }, + ..Default::default() + }], + ..Default::default() + }), + Self::MessageDelta { delta, usage } => { + let reason = delta.stop_reason.as_deref().map(|r| match r { + "end_turn" | "stop" => FinishReason::Stop, + "max_tokens" => FinishReason::Length, + "tool_use" => FinishReason::ToolCalls, + _ => FinishReason::Stop, + }); + Some(StreamChunk { + choices: vec![Choice { + finish_reason: reason, + ..Default::default() + }], + usage: Some(Usage { + prompt_tokens: 0, + completion_tokens: usage.output_tokens, + total_tokens: usage.output_tokens, + prompt_cache_hit_tokens: None, + prompt_cache_miss_tokens: None, + completion_tokens_details: Some(CompletionTokensDetails { + reasoning_tokens: None, + }), + }), + ..Default::default() + }) + } + Self::ContentBlockStop {} + | Self::MessageStop + | Self::Ping + | Self::Unknown => None, + } + } +} diff --git a/llm/openai/Cargo.toml b/llm/openai/Cargo.toml new file mode 100644 index 0000000..3e8843f --- /dev/null +++ b/llm/openai/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "walrus-openai" +description = "Walrus OpenAI-compatible provider implementation" +documentation = "https://docs.rs/walrus-openai" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true + +[dependencies] +llm.workspace = true + +# crates-io dependencies +anyhow.workspace = true +async-stream.workspace = true +futures-core.workspace = true +futures-util.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true diff --git a/llm/openai/src/lib.rs b/llm/openai/src/lib.rs new file mode 100644 index 0000000..a199ae0 --- /dev/null +++ b/llm/openai/src/lib.rs @@ -0,0 +1,84 @@ +//! OpenAI-compatible LLM provider. +//! +//! Covers OpenAI, Grok (xAI), Qwen (Alibaba), Kimi (Moonshot), Ollama, +//! and any other service exposing the OpenAI chat completions API. + +use llm::reqwest::{Client, header::HeaderMap}; +pub use request::Request; + +mod provider; +mod request; + +/// OpenAI-compatible endpoint URLs. +pub mod endpoint { + /// OpenAI chat completions. + pub const OPENAI: &str = "https://api.openai.com/v1/chat/completions"; + /// Grok (xAI) chat completions. + pub const GROK: &str = "https://api.x.ai/v1/chat/completions"; + /// Qwen (Alibaba DashScope) chat completions. + pub const QWEN: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"; + /// Kimi (Moonshot) chat completions. + pub const KIMI: &str = "https://api.moonshot.cn/v1/chat/completions"; + /// Ollama local chat completions. + pub const OLLAMA: &str = "http://localhost:11434/v1/chat/completions"; +} + +/// An OpenAI-compatible LLM provider. +#[derive(Clone)] +pub struct OpenAI { + /// The HTTP client. + pub client: Client, + /// Request headers (authorization, content-type). + headers: HeaderMap, + /// Chat completions endpoint URL. + endpoint: String, +} + +impl OpenAI { + /// Create a provider targeting the OpenAI API. + pub fn api(client: Client, key: &str) -> anyhow::Result { + Self::custom(client, key, endpoint::OPENAI) + } + + /// Create a provider targeting the Grok (xAI) API. + pub fn grok(client: Client, key: &str) -> anyhow::Result { + Self::custom(client, key, endpoint::GROK) + } + + /// Create a provider targeting the Qwen (DashScope) API. + pub fn qwen(client: Client, key: &str) -> anyhow::Result { + Self::custom(client, key, endpoint::QWEN) + } + + /// Create a provider targeting the Kimi (Moonshot) API. + pub fn kimi(client: Client, key: &str) -> anyhow::Result { + Self::custom(client, key, endpoint::KIMI) + } + + /// Create a provider targeting a local Ollama instance (no API key). + pub fn ollama(client: Client) -> anyhow::Result { + use llm::reqwest::header; + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, "application/json".parse()?); + headers.insert(header::ACCEPT, "application/json".parse()?); + Ok(Self { + client, + headers, + endpoint: endpoint::OLLAMA.to_owned(), + }) + } + + /// Create a provider targeting a custom OpenAI-compatible endpoint. + pub fn custom(client: Client, key: &str, endpoint: &str) -> anyhow::Result { + use llm::reqwest::header; + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, "application/json".parse()?); + headers.insert(header::ACCEPT, "application/json".parse()?); + headers.insert(header::AUTHORIZATION, format!("Bearer {key}").parse()?); + Ok(Self { + client, + headers, + endpoint: endpoint.to_owned(), + }) + } +} diff --git a/llm/openai/src/provider.rs b/llm/openai/src/provider.rs new file mode 100644 index 0000000..7bc7ad7 --- /dev/null +++ b/llm/openai/src/provider.rs @@ -0,0 +1,82 @@ +//! LLM trait implementation for the OpenAI-compatible provider. + +use crate::{OpenAI, Request}; +use anyhow::Result; +use async_stream::try_stream; +use futures_core::Stream; +use futures_util::StreamExt; +use llm::{ + Client, LLM, Message, Response, StreamChunk, + reqwest::{ + Method, + header::{self, HeaderMap}, + }, +}; + +impl LLM for OpenAI { + type ChatConfig = Request; + + fn new(client: Client, key: &str) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, "application/json".parse()?); + headers.insert(header::ACCEPT, "application/json".parse()?); + headers.insert(header::AUTHORIZATION, format!("Bearer {key}").parse()?); + Ok(Self { + client, + headers, + endpoint: crate::endpoint::OPENAI.to_owned(), + }) + } + + async fn send(&self, req: &Request, messages: &[Message]) -> Result { + let body = req.messages(messages); + tracing::trace!("request: {}", serde_json::to_string(&body)?); + let text = self + .client + .request(Method::POST, &self.endpoint) + .headers(self.headers.clone()) + .json(&body) + .send() + .await? + .text() + .await?; + + serde_json::from_str(&text).map_err(Into::into) + } + + fn stream( + &self, + req: Request, + messages: &[Message], + usage: bool, + ) -> impl Stream> { + let body = req.messages(messages).stream(usage); + if let Ok(body) = serde_json::to_string(&body) { + tracing::trace!("request: {}", body); + } + let request = self + .client + .request(Method::POST, &self.endpoint) + .headers(self.headers.clone()) + .json(&body); + + try_stream! { + let response = request.send().await?; + let mut stream = response.bytes_stream(); + while let Some(Ok(bytes)) = stream.next().await { + let text = String::from_utf8_lossy(&bytes).into_owned(); + tracing::trace!("chunk: {}", text); + for data in text.split("data: ").skip(1).filter(|s| !s.starts_with("[DONE]")) { + let trimmed = data.trim(); + if trimmed.is_empty() { + continue; + } + match serde_json::from_str::(trimmed) { + Ok(chunk) => yield chunk, + Err(e) => tracing::warn!("failed to parse chunk: {e}, data: {trimmed}"), + } + } + } + } + } +} diff --git a/llm/openai/src/request.rs b/llm/openai/src/request.rs new file mode 100644 index 0000000..e1254a3 --- /dev/null +++ b/llm/openai/src/request.rs @@ -0,0 +1,134 @@ +//! Request body for OpenAI-compatible chat completions API. + +use llm::{Config, General, Message, Tool, ToolChoice}; +use serde::Serialize; +use serde_json::{Value, json}; + +/// The request body for the OpenAI chat completions API. +#[derive(Debug, Clone, Serialize)] +pub struct Request { + /// The messages to send. + pub messages: Vec, + /// The model identifier. + pub model: String, + /// Frequency penalty. + #[serde(skip_serializing_if = "Option::is_none")] + pub frequency_penalty: Option, + /// Whether to return log probabilities. + #[serde(skip_serializing_if = "Option::is_none")] + pub logprobs: Option, + /// Maximum tokens to generate. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + /// Presence penalty. + #[serde(skip_serializing_if = "Option::is_none")] + pub presence_penalty: Option, + /// Response format. + #[serde(skip_serializing_if = "Option::is_none")] + pub response_format: Option, + /// Stop sequences. + #[serde(skip_serializing_if = "Option::is_none")] + pub stop: Option, + /// Whether to stream the response. + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + /// Stream options (e.g. include_usage). + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, + /// Temperature. + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + /// Tool choice control. + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + /// Tools the model may call. + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option, + /// Top-p sampling. + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, +} + +impl Request { + /// Clone the request with the given messages. + pub fn messages(&self, messages: &[Message]) -> Self { + Self { + messages: messages.to_vec(), + ..self.clone() + } + } + + /// Enable streaming for the request. + pub fn stream(mut self, usage: bool) -> Self { + self.stream = Some(true); + self.stream_options = if usage { + Some(json!({ "include_usage": true })) + } else { + None + }; + self + } +} + +impl From for Request { + fn from(config: General) -> Self { + let mut req = Self { + messages: Vec::new(), + model: config.model.to_string(), + frequency_penalty: None, + logprobs: None, + max_tokens: None, + presence_penalty: None, + response_format: None, + stop: None, + stream: None, + stream_options: None, + temperature: None, + tool_choice: None, + tools: None, + top_p: None, + }; + + if let Some(tools) = config.tools { + req = req.with_tools(tools); + } + if let Some(tool_choice) = config.tool_choice { + req = req.with_tool_choice(tool_choice); + } + + req + } +} + +impl Config for Request { + fn with_tools(self, tools: Vec) -> Self { + let tools = tools + .into_iter() + .map(|tool| { + json!({ + "type": "function", + "function": json!(tool), + }) + }) + .collect::>(); + Self { + tools: Some(json!(tools)), + ..self + } + } + + fn with_tool_choice(self, tool_choice: ToolChoice) -> Self { + Self { + tool_choice: match tool_choice { + ToolChoice::None => Some(json!("none")), + ToolChoice::Auto => Some(json!("auto")), + ToolChoice::Required => Some(json!("required")), + ToolChoice::Function(name) => Some(json!({ + "type": "function", + "function": { "name": name } + })), + }, + ..self + } + } +} diff --git a/llm/src/lib.rs b/llm/src/lib.rs index b227626..74120853 100644 --- a/llm/src/lib.rs +++ b/llm/src/lib.rs @@ -8,7 +8,9 @@ pub use message::{Message, Role, estimate_tokens}; pub use noop::NoopProvider; pub use provider::LLM; pub use reqwest::{self, Client}; -pub use response::{FinishReason, Response, Usage}; +pub use response::{ + Choice, CompletionMeta, CompletionTokensDetails, Delta, FinishReason, Response, Usage, +}; pub use stream::StreamChunk; pub use tool::{FunctionCall, Tool, ToolCall, ToolChoice}; From 682b79d3d6ab739b184601583c1941187c9f85ce Mon Sep 17 00:00:00 2001 From: clearloop Date: Sat, 28 Feb 2026 07:13:06 +0800 Subject: [PATCH 3/8] feat(llm): introduce wrapper of mistral --- Cargo.lock | 16 +++ Cargo.toml | 3 +- app/gateway/Cargo.toml | 1 + app/gateway/src/config.rs | 2 + app/gateway/src/gateway/builder.rs | 102 +++++++++---- app/gateway/src/provider.rs | 14 ++ app/gateway/tests/config.rs | 49 ++++++- llm/claude/src/provider.rs | 2 +- llm/claude/src/stream.rs | 15 +- llm/mistral/Cargo.toml | 25 ++++ llm/mistral/src/lib.rs | 67 +++++++++ llm/mistral/src/provider.rs | 83 +++++++++++ llm/mistral/src/request.rs | 224 +++++++++++++++++++++++++++++ 13 files changed, 563 insertions(+), 40 deletions(-) create mode 100644 llm/mistral/Cargo.toml create mode 100644 llm/mistral/src/lib.rs create mode 100644 llm/mistral/src/provider.rs create mode 100644 llm/mistral/src/request.rs diff --git a/Cargo.lock b/Cargo.lock index 6a892b7..09b68a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2191,6 +2191,7 @@ dependencies = [ "walrus-core", "walrus-deepseek", "walrus-llm", + "walrus-mistral", "walrus-openai", "walrus-protocol", "walrus-runtime", @@ -2218,6 +2219,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "walrus-mistral" +version = "0.0.9" +dependencies = [ + "anyhow", + "async-stream", + "futures-core", + "futures-util", + "schemars", + "serde", + "serde_json", + "tracing", + "walrus-llm", +] + [[package]] name = "walrus-openai" version = "0.0.9" diff --git a/Cargo.toml b/Cargo.toml index 0d4b2fb..ac34b19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["app/cli", "app/client", "app/gateway", "app/hub", "app/protocol", "crates/*", "llm", "llm/deepseek", "llm/openai", "llm/claude"] +members = ["app/cli", "app/client", "app/gateway", "app/hub", "app/protocol", "crates/*", "llm", "llm/deepseek", "llm/openai", "llm/claude", "llm/mistral"] [workspace.package] version = "0.0.9" @@ -14,6 +14,7 @@ keywords = ["llm", "agent", "ai"] deepseek = { path = "llm/deepseek", package = "walrus-deepseek", version = "0.0.9" } openai = { path = "llm/openai", package = "walrus-openai", version = "0.0.9" } claude = { path = "llm/claude", package = "walrus-claude", version = "0.0.9" } +mistral = { path = "llm/mistral", package = "walrus-mistral", version = "0.0.9" } llm = { path = "llm", package = "walrus-llm", version = "0.0.9" } agent = { path = "crates/core", package = "walrus-core", version = "0.0.9" } runtime = { path = "crates/runtime", package = "walrus-runtime", version = "0.0.9" } diff --git a/app/gateway/Cargo.toml b/app/gateway/Cargo.toml index fd1df55..d26e5da 100644 --- a/app/gateway/Cargo.toml +++ b/app/gateway/Cargo.toml @@ -17,6 +17,7 @@ agent = { workspace = true } deepseek = { workspace = true } openai = { workspace = true } claude = { workspace = true } +mistral = { workspace = true } sqlite = { workspace = true } llm = { workspace = true } protocol = { workspace = true } diff --git a/app/gateway/src/config.rs b/app/gateway/src/config.rs index b86cbe1..9741595 100644 --- a/app/gateway/src/config.rs +++ b/app/gateway/src/config.rs @@ -98,6 +98,8 @@ pub enum ProviderKind { Ollama, /// Claude (Anthropic) Messages API. Claude, + /// Mistral chat completions API. + Mistral, } /// Memory backend configuration. diff --git a/app/gateway/src/gateway/builder.rs b/app/gateway/src/gateway/builder.rs index af7991a..b53f272 100644 --- a/app/gateway/src/gateway/builder.rs +++ b/app/gateway/src/gateway/builder.rs @@ -8,10 +8,50 @@ use anyhow::Result; use claude::Claude; use deepseek::DeepSeek; use llm::LLM; +use mistral::Mistral; use openai::OpenAI; use runtime::{General, McpBridge, Runtime, SkillRegistry}; use std::path::Path; +fn build_provider(config: &crate::config::LlmConfig, client: llm::Client) -> Result { + let key = &config.api_key; + let provider = match config.provider { + ProviderKind::DeepSeek => match &config.base_url { + Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), + None => Provider::DeepSeek(DeepSeek::new(client, key)?), + }, + ProviderKind::OpenAI => match &config.base_url { + Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), + None => Provider::OpenAI(OpenAI::api(client, key)?), + }, + ProviderKind::Grok => match &config.base_url { + Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), + None => Provider::OpenAI(OpenAI::grok(client, key)?), + }, + ProviderKind::Qwen => match &config.base_url { + Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), + None => Provider::OpenAI(OpenAI::qwen(client, key)?), + }, + ProviderKind::Kimi => match &config.base_url { + Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), + None => Provider::OpenAI(OpenAI::kimi(client, key)?), + }, + ProviderKind::Ollama => match &config.base_url { + Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), + None => Provider::OpenAI(OpenAI::ollama(client)?), + }, + ProviderKind::Claude => match &config.base_url { + Some(url) => Provider::Claude(Claude::custom(client, key, url)?), + None => Provider::Claude(Claude::anthropic(client, key)?), + }, + ProviderKind::Mistral => match &config.base_url { + Some(url) => Provider::Mistral(Mistral::custom(client, key, url)?), + None => Provider::Mistral(Mistral::api(client, key)?), + }, + }; + Ok(provider) +} + /// Build a fully-configured `Runtime` from config and directory. /// /// Loads agents from `config_dir/agents/*.md`, skills from `config_dir/skills/`, @@ -39,37 +79,7 @@ pub async fn build_runtime( // Construct provider. let client = llm::Client::new(); - let key = &config.llm.api_key; - let provider = match config.llm.provider { - ProviderKind::DeepSeek => match &config.llm.base_url { - Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), - None => Provider::DeepSeek(DeepSeek::new(client, key)?), - }, - ProviderKind::OpenAI => match &config.llm.base_url { - Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), - None => Provider::OpenAI(OpenAI::api(client, key)?), - }, - ProviderKind::Grok => match &config.llm.base_url { - Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), - None => Provider::OpenAI(OpenAI::grok(client, key)?), - }, - ProviderKind::Qwen => match &config.llm.base_url { - Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), - None => Provider::OpenAI(OpenAI::qwen(client, key)?), - }, - ProviderKind::Kimi => match &config.llm.base_url { - Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), - None => Provider::OpenAI(OpenAI::kimi(client, key)?), - }, - ProviderKind::Ollama => match &config.llm.base_url { - Some(url) => Provider::OpenAI(OpenAI::custom(client, key, url)?), - None => Provider::OpenAI(OpenAI::ollama(client)?), - }, - ProviderKind::Claude => match &config.llm.base_url { - Some(url) => Provider::Claude(Claude::custom(client, key, url)?), - None => Provider::Claude(Claude::anthropic(client, key)?), - }, - }; + let provider = build_provider(&config.llm, client)?; tracing::info!( "provider {:?} initialized for model {}", config.llm.provider, @@ -127,3 +137,33 @@ pub async fn build_runtime( Ok(runtime) } + +#[cfg(test)] +mod tests { + use super::build_provider; + use crate::config::{LlmConfig, ProviderKind}; + + #[test] + fn build_provider_mistral_default_endpoint() { + let config = LlmConfig { + provider: ProviderKind::Mistral, + model: "mistral-small-latest".into(), + api_key: "test-key".to_string(), + base_url: None, + }; + let provider = build_provider(&config, llm::Client::new()).expect("provider"); + assert!(matches!(provider, crate::provider::Provider::Mistral(_))); + } + + #[test] + fn build_provider_mistral_custom_endpoint() { + let config = LlmConfig { + provider: ProviderKind::Mistral, + model: "mistral-small-latest".into(), + api_key: "test-key".to_string(), + base_url: Some("http://localhost:8080/v1/chat/completions".to_string()), + }; + let provider = build_provider(&config, llm::Client::new()).expect("provider"); + assert!(matches!(provider, crate::provider::Provider::Mistral(_))); + } +} diff --git a/app/gateway/src/provider.rs b/app/gateway/src/provider.rs index 7deddf1..50127a7 100644 --- a/app/gateway/src/provider.rs +++ b/app/gateway/src/provider.rs @@ -12,6 +12,7 @@ use deepseek::DeepSeek; use futures_core::Stream; use futures_util::StreamExt; use llm::{Client, General, LLM, Message, Response, StreamChunk}; +use mistral::Mistral; use openai::OpenAI; /// Unified LLM provider enum. @@ -26,6 +27,8 @@ pub enum Provider { OpenAI(OpenAI), /// Anthropic Messages API. Claude(Claude), + /// Mistral chat completions API. + Mistral(Mistral), } impl LLM for Provider { @@ -49,6 +52,10 @@ impl LLM for Provider { let req = claude::Request::from(config.clone()); p.send(&req, messages).await } + Self::Mistral(p) => { + let req = mistral::Request::from(config.clone()); + p.send(&req, messages).await + } } } @@ -83,6 +90,13 @@ impl LLM for Provider { yield chunk?; } } + Provider::Mistral(p) => { + let req = mistral::Request::from(config); + let mut stream = std::pin::pin!(p.stream(req, &messages, usage)); + while let Some(chunk) = stream.next().await { + yield chunk?; + } + } } } } diff --git a/app/gateway/tests/config.rs b/app/gateway/tests/config.rs index a0dc203..cc3b0c9 100644 --- a/app/gateway/tests/config.rs +++ b/app/gateway/tests/config.rs @@ -1,6 +1,9 @@ //! Gateway configuration tests. -use walrus_gateway::{GatewayConfig, config::MemoryBackendKind}; +use walrus_gateway::{ + GatewayConfig, + config::{MemoryBackendKind, ProviderKind}, +}; #[test] fn parse_minimal_config() { @@ -64,6 +67,7 @@ api_key = "key" "#; let config = GatewayConfig::from_toml(toml).unwrap(); assert!(config.server.socket_path.is_none()); + assert_eq!(config.llm.provider, ProviderKind::DeepSeek); } #[test] @@ -157,3 +161,46 @@ fn global_config_dir_is_under_platform_config() { // Should end with "walrus" assert_eq!(dir.file_name().unwrap(), "walrus"); } + +#[test] +fn parse_mistral_provider() { + let toml = r#" +[server] + +[llm] +provider = "mistral" +model = "mistral-small-latest" +api_key = "test-key" +"#; + let config = GatewayConfig::from_toml(toml).unwrap(); + assert_eq!(config.llm.provider, ProviderKind::Mistral); + assert_eq!(config.llm.model.as_str(), "mistral-small-latest"); +} + +#[test] +fn parse_mistral_with_base_url() { + let toml = r#" +[server] + +[llm] +provider = "mistral" +model = "mistral-small-latest" +api_key = "test-key" +base_url = "http://localhost:8080/v1/chat/completions" +"#; + let config = GatewayConfig::from_toml(toml).unwrap(); + assert_eq!(config.llm.provider, ProviderKind::Mistral); + assert_eq!( + config.llm.base_url.as_deref(), + Some("http://localhost:8080/v1/chat/completions") + ); +} + +#[test] +fn provider_kind_mistral_roundtrip() { + let kind = ProviderKind::Mistral; + let serialized = serde_json::to_string(&kind).unwrap(); + assert_eq!(serialized, "\"mistral\""); + let parsed: ProviderKind = serde_json::from_str(&serialized).unwrap(); + assert_eq!(parsed, ProviderKind::Mistral); +} diff --git a/llm/claude/src/provider.rs b/llm/claude/src/provider.rs index db68b2b..466f72e 100644 --- a/llm/claude/src/provider.rs +++ b/llm/claude/src/provider.rs @@ -3,9 +3,9 @@ use crate::{Claude, Request, stream::Event}; use anyhow::Result; use async_stream::try_stream; +use compact_str::CompactString; use futures_core::Stream; use futures_util::StreamExt; -use compact_str::CompactString; use llm::{ Choice, Client, CompletionMeta, CompletionTokensDetails, Delta, FinishReason, LLM, Message, Response, StreamChunk, Usage, diff --git a/llm/claude/src/stream.rs b/llm/claude/src/stream.rs index 2915a02..1a18a65 100644 --- a/llm/claude/src/stream.rs +++ b/llm/claude/src/stream.rs @@ -36,7 +36,10 @@ pub enum Event { ContentBlockStop {}, /// Final message delta (stop reason + usage). #[serde(rename = "message_delta")] - MessageDelta { delta: MessageDeltaBody, usage: MessageDeltaUsage }, + MessageDelta { + delta: MessageDeltaBody, + usage: MessageDeltaUsage, + }, /// End of message. #[serde(rename = "message_stop")] MessageStop, @@ -60,7 +63,10 @@ pub enum ContentBlock { #[serde(rename = "text")] Text { text: String }, #[serde(rename = "tool_use")] - ToolUse { id: CompactString, name: CompactString }, + ToolUse { + id: CompactString, + name: CompactString, + }, } #[derive(Debug, Deserialize)] @@ -194,10 +200,7 @@ impl Event { ..Default::default() }) } - Self::ContentBlockStop {} - | Self::MessageStop - | Self::Ping - | Self::Unknown => None, + Self::ContentBlockStop {} | Self::MessageStop | Self::Ping | Self::Unknown => None, } } } diff --git a/llm/mistral/Cargo.toml b/llm/mistral/Cargo.toml new file mode 100644 index 0000000..c9fe8e4 --- /dev/null +++ b/llm/mistral/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "walrus-mistral" +description = "Walrus Mistral provider implementation" +documentation = "https://docs.rs/walrus-mistral" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true + +[dependencies] +llm.workspace = true + +# crates-io dependencies +anyhow.workspace = true +async-stream.workspace = true +futures-core.workspace = true +futures-util.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true + +[dev-dependencies] +schemars.workspace = true diff --git a/llm/mistral/src/lib.rs b/llm/mistral/src/lib.rs new file mode 100644 index 0000000..cfd95c4 --- /dev/null +++ b/llm/mistral/src/lib.rs @@ -0,0 +1,67 @@ +//! Mistral LLM provider. +//! +//! Supports the Mistral chat completions API and compatible endpoints. + +use llm::reqwest::{Client, header::HeaderMap}; +pub use request::Request; + +mod provider; +mod request; + +/// Mistral endpoint URLs. +pub mod endpoint { + /// Mistral chat completions endpoint. + pub const MISTRAL: &str = "https://api.mistral.ai/v1/chat/completions"; +} + +/// Mistral provider. +#[derive(Clone)] +pub struct Mistral { + /// The HTTP client. + pub client: Client, + /// Request headers (authorization, content-type). + headers: HeaderMap, + /// Chat completions endpoint URL. + endpoint: String, +} + +impl Mistral { + /// Create a provider targeting the Mistral API. + pub fn api(client: Client, key: &str) -> anyhow::Result { + Self::custom(client, key, endpoint::MISTRAL) + } + + /// Create a provider targeting a custom Mistral-compatible endpoint. + pub fn custom(client: Client, key: &str, endpoint: &str) -> anyhow::Result { + use llm::reqwest::header; + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, "application/json".parse()?); + headers.insert(header::ACCEPT, "application/json".parse()?); + headers.insert(header::AUTHORIZATION, format!("Bearer {key}").parse()?); + Ok(Self { + client, + headers, + endpoint: endpoint.to_owned(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::{Mistral, endpoint}; + + #[test] + fn custom_constructor_sets_endpoint() { + let client = llm::Client::new(); + let custom = "http://localhost:9999/v1/chat/completions"; + let provider = Mistral::custom(client, "test-key", custom).expect("provider"); + assert_eq!(provider.endpoint, custom); + } + + #[test] + fn api_constructor_uses_default_endpoint() { + let client = llm::Client::new(); + let provider = Mistral::api(client, "test-key").expect("provider"); + assert_eq!(provider.endpoint, endpoint::MISTRAL); + } +} diff --git a/llm/mistral/src/provider.rs b/llm/mistral/src/provider.rs new file mode 100644 index 0000000..03ef50a --- /dev/null +++ b/llm/mistral/src/provider.rs @@ -0,0 +1,83 @@ +//! LLM trait implementation for the Mistral provider. + +use crate::{Mistral, Request}; +use anyhow::Result; +use async_stream::try_stream; +use futures_core::Stream; +use futures_util::StreamExt; +use llm::{ + Client, LLM, Message, Response, StreamChunk, + reqwest::{ + Method, + header::{self, HeaderMap}, + }, +}; + +impl LLM for Mistral { + type ChatConfig = Request; + + fn new(client: Client, key: &str) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, "application/json".parse()?); + headers.insert(header::ACCEPT, "application/json".parse()?); + headers.insert(header::AUTHORIZATION, format!("Bearer {key}").parse()?); + Ok(Self { + client, + headers, + endpoint: crate::endpoint::MISTRAL.to_owned(), + }) + } + + async fn send(&self, req: &Request, messages: &[Message]) -> Result { + let body = req.messages(messages); + tracing::trace!("request: {}", serde_json::to_string(&body)?); + let text = self + .client + .request(Method::POST, &self.endpoint) + .headers(self.headers.clone()) + .json(&body) + .send() + .await? + .text() + .await?; + + serde_json::from_str(&text).map_err(Into::into) + } + + fn stream( + &self, + req: Request, + messages: &[Message], + usage: bool, + ) -> impl Stream> { + let body = req.messages(messages).stream(usage); + if let Ok(body) = serde_json::to_string(&body) { + tracing::trace!("request: {}", body); + } + let request = self + .client + .request(Method::POST, &self.endpoint) + .headers(self.headers.clone()) + .json(&body); + + try_stream! { + let response = request.send().await?; + let mut stream = response.bytes_stream(); + while let Some(next) = stream.next().await { + let bytes = next?; + let text = String::from_utf8_lossy(&bytes).into_owned(); + tracing::trace!("chunk: {}", text); + for data in text.split("data: ").skip(1).filter(|s| !s.starts_with("[DONE]")) { + let trimmed = data.trim(); + if trimmed.is_empty() { + continue; + } + match serde_json::from_str::(trimmed) { + Ok(chunk) => yield chunk, + Err(e) => tracing::warn!("failed to parse chunk: {e}, data: {trimmed}"), + } + } + } + } + } +} diff --git a/llm/mistral/src/request.rs b/llm/mistral/src/request.rs new file mode 100644 index 0000000..d924839 --- /dev/null +++ b/llm/mistral/src/request.rs @@ -0,0 +1,224 @@ +//! Request body for Mistral chat completions API. + +use llm::{Config, General, Message, Tool, ToolChoice}; +use serde::Serialize; +use serde_json::{Value, json}; + +/// The request body for Mistral chat completions API. +#[derive(Debug, Clone, Serialize)] +pub struct Request { + /// The messages to send. + pub messages: Vec, + /// The model identifier. + pub model: String, + /// Frequency penalty. + #[serde(skip_serializing_if = "Option::is_none")] + pub frequency_penalty: Option, + /// Whether to return log probabilities. + #[serde(skip_serializing_if = "Option::is_none")] + pub logprobs: Option, + /// Maximum tokens to generate. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + /// Presence penalty. + #[serde(skip_serializing_if = "Option::is_none")] + pub presence_penalty: Option, + /// Response format. + #[serde(skip_serializing_if = "Option::is_none")] + pub response_format: Option, + /// Stop sequences. + #[serde(skip_serializing_if = "Option::is_none")] + pub stop: Option, + /// Whether to stream the response. + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + /// Stream options (e.g. include_usage). + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, + /// Temperature. + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + /// Tool choice control. + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + /// Tools the model may call. + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option, + /// Top-p sampling. + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, +} + +impl Request { + /// Clone the request with the given messages. + pub fn messages(&self, messages: &[Message]) -> Self { + Self { + messages: messages.to_vec(), + ..self.clone() + } + } + + /// Enable streaming for the request. + pub fn stream(mut self, usage: bool) -> Self { + self.stream = Some(true); + self.stream_options = if usage { + Some(json!({ "include_usage": true })) + } else { + None + }; + self + } +} + +impl From for Request { + fn from(config: General) -> Self { + let mut req = Self { + messages: Vec::new(), + model: config.model.to_string(), + frequency_penalty: None, + logprobs: None, + max_tokens: None, + presence_penalty: None, + response_format: None, + stop: None, + stream: None, + stream_options: None, + temperature: None, + tool_choice: None, + tools: None, + top_p: None, + }; + + if let Some(tools) = config.tools { + req = req.with_tools(tools); + } + if let Some(tool_choice) = config.tool_choice { + req = req.with_tool_choice(tool_choice); + } + + req + } +} + +impl Config for Request { + fn with_tools(self, tools: Vec) -> Self { + let tools = tools + .into_iter() + .map(|tool| { + json!({ + "type": "function", + "function": json!(tool), + }) + }) + .collect::>(); + Self { + tools: Some(json!(tools)), + ..self + } + } + + fn with_tool_choice(self, tool_choice: ToolChoice) -> Self { + Self { + tool_choice: match tool_choice { + ToolChoice::None => Some(json!("none")), + ToolChoice::Auto => Some(json!("auto")), + ToolChoice::Required => Some(json!("required")), + ToolChoice::Function(name) => Some(json!({ + "type": "function", + "function": { "name": name } + })), + }, + ..self + } + } +} + +#[cfg(test)] +mod tests { + use super::Request; + use llm::{Config, General, Tool, ToolChoice}; + + #[test] + fn request_from_general_sets_model() { + let general = General { + model: "mistral-medium".into(), + ..General::default() + }; + let req = Request::from(general); + assert_eq!(req.model, "mistral-medium"); + } + + #[test] + fn request_from_general_tools() { + let tool = Tool { + name: "search".into(), + description: "find docs".into(), + parameters: schemars::schema_for!(String), + strict: false, + }; + let general = General { + model: "mistral-small".into(), + tools: Some(vec![tool]), + ..General::default() + }; + let req = Request::from(general); + let tools = req.tools.expect("tools"); + assert_eq!(tools[0]["type"], "function"); + assert_eq!(tools[0]["function"]["name"], "search"); + } + + #[test] + fn request_from_general_tool_choice() { + let general = General { + model: "mistral-small".into(), + tool_choice: Some(ToolChoice::Function("search".into())), + ..General::default() + }; + let req = Request::from(general); + let choice = req.tool_choice.expect("tool_choice"); + assert_eq!(choice["type"], "function"); + assert_eq!(choice["function"]["name"], "search"); + } + + #[test] + fn stream_sets_include_usage() { + let req = Request::from(General::default()).stream(true); + assert_eq!(req.stream, Some(true)); + let opts = req.stream_options.expect("stream options"); + assert_eq!(opts["include_usage"], true); + } + + #[test] + fn stream_without_usage_omits_stream_options() { + let req = Request::from(General::default()).stream(false); + assert_eq!(req.stream, Some(true)); + assert!(req.stream_options.is_none()); + } + + #[test] + fn with_tool_choice_auto() { + let req = Request::from(General::default()).with_tool_choice(ToolChoice::Auto); + assert_eq!( + req.tool_choice.expect("tool choice"), + serde_json::json!("auto") + ); + } + + #[test] + fn with_tool_choice_none() { + let req = Request::from(General::default()).with_tool_choice(ToolChoice::None); + assert_eq!( + req.tool_choice.expect("tool choice"), + serde_json::json!("none") + ); + } + + #[test] + fn with_tool_choice_required() { + let req = Request::from(General::default()).with_tool_choice(ToolChoice::Required); + assert_eq!( + req.tool_choice.expect("tool choice"), + serde_json::json!("required") + ); + } +} From e28a2dcc34c9b1cebe8e1ca6ef4e80fc116e986a Mon Sep 17 00:00:00 2001 From: clearloop Date: Sat, 28 Feb 2026 07:34:46 +0800 Subject: [PATCH 4/8] chore(gateway): rename gateway to daemon --- Cargo.lock | 30 +++++++++---------- Cargo.toml | 2 +- app/{gateway => daemon}/Cargo.toml | 2 +- app/{gateway => daemon}/src/bin/main.rs | 4 +-- app/{gateway => daemon}/src/channel/mod.rs | 0 app/{gateway => daemon}/src/channel/router.rs | 0 app/{gateway => daemon}/src/config.rs | 0 app/{gateway => daemon}/src/feature/cron.rs | 0 app/{gateway => daemon}/src/feature/memory.rs | 0 app/{gateway => daemon}/src/feature/mod.rs | 0 .../src/gateway/builder.rs | 0 app/{gateway => daemon}/src/gateway/mod.rs | 0 app/{gateway => daemon}/src/gateway/serve.rs | 0 app/{gateway => daemon}/src/gateway/uds.rs | 0 app/{gateway => daemon}/src/lib.rs | 0 app/{gateway => daemon}/src/provider.rs | 0 app/{gateway => daemon}/src/utils.rs | 0 app/{gateway => daemon}/tests/backend.rs | 8 ++--- app/{gateway => daemon}/tests/config.rs | 4 +-- app/{gateway => daemon}/tests/cron.rs | 2 +- app/{gateway => daemon}/tests/gateway.rs | 2 +- app/{gateway => daemon}/tests/protocol.rs | 0 app/{gateway => daemon}/tests/router.rs | 6 ++-- 23 files changed, 30 insertions(+), 30 deletions(-) rename app/{gateway => daemon}/Cargo.toml (97%) rename app/{gateway => daemon}/src/bin/main.rs (89%) rename app/{gateway => daemon}/src/channel/mod.rs (100%) rename app/{gateway => daemon}/src/channel/router.rs (100%) rename app/{gateway => daemon}/src/config.rs (100%) rename app/{gateway => daemon}/src/feature/cron.rs (100%) rename app/{gateway => daemon}/src/feature/memory.rs (100%) rename app/{gateway => daemon}/src/feature/mod.rs (100%) rename app/{gateway => daemon}/src/gateway/builder.rs (100%) rename app/{gateway => daemon}/src/gateway/mod.rs (100%) rename app/{gateway => daemon}/src/gateway/serve.rs (100%) rename app/{gateway => daemon}/src/gateway/uds.rs (100%) rename app/{gateway => daemon}/src/lib.rs (100%) rename app/{gateway => daemon}/src/provider.rs (100%) rename app/{gateway => daemon}/src/utils.rs (100%) rename app/{gateway => daemon}/tests/backend.rs (94%) rename app/{gateway => daemon}/tests/config.rs (98%) rename app/{gateway => daemon}/tests/cron.rs (96%) rename app/{gateway => daemon}/tests/gateway.rs (91%) rename app/{gateway => daemon}/tests/protocol.rs (100%) rename app/{gateway => daemon}/tests/router.rs (93%) diff --git a/Cargo.lock b/Cargo.lock index 09b68a3..4c43ab5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2153,21 +2153,7 @@ dependencies = [ ] [[package]] -name = "walrus-deepseek" -version = "0.0.9" -dependencies = [ - "anyhow", - "async-stream", - "futures-core", - "futures-util", - "serde", - "serde_json", - "tracing", - "walrus-llm", -] - -[[package]] -name = "walrus-gateway" +name = "walrus-daemon" version = "0.0.9" dependencies = [ "anyhow", @@ -2198,6 +2184,20 @@ dependencies = [ "walrus-sqlite", ] +[[package]] +name = "walrus-deepseek" +version = "0.0.9" +dependencies = [ + "anyhow", + "async-stream", + "futures-core", + "futures-util", + "serde", + "serde_json", + "tracing", + "walrus-llm", +] + [[package]] name = "walrus-hub" version = "0.0.9" diff --git a/Cargo.toml b/Cargo.toml index ac34b19..c5e1a82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["app/cli", "app/client", "app/gateway", "app/hub", "app/protocol", "crates/*", "llm", "llm/deepseek", "llm/openai", "llm/claude", "llm/mistral"] +members = ["app/*", "crates/*", "llm", "llm/deepseek", "llm/openai", "llm/claude", "llm/mistral"] [workspace.package] version = "0.0.9" diff --git a/app/gateway/Cargo.toml b/app/daemon/Cargo.toml similarity index 97% rename from app/gateway/Cargo.toml rename to app/daemon/Cargo.toml index d26e5da..c9c0ed1 100644 --- a/app/gateway/Cargo.toml +++ b/app/daemon/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "walrus-gateway" +name = "walrus-daemon" version.workspace = true edition.workspace = true authors.workspace = true diff --git a/app/gateway/src/bin/main.rs b/app/daemon/src/bin/main.rs similarity index 89% rename from app/gateway/src/bin/main.rs rename to app/daemon/src/bin/main.rs index 5920de9..eb55608 100644 --- a/app/gateway/src/bin/main.rs +++ b/app/daemon/src/bin/main.rs @@ -6,7 +6,7 @@ use anyhow::Result; use tokio::signal; use tracing_subscriber::EnvFilter; -use walrus_gateway::config; +use walrus_daemon::config; #[tokio::main] async fn main() -> Result<()> { @@ -20,7 +20,7 @@ async fn main() -> Result<()> { tracing::info!("created config directory at {}", config_dir.display()); } - let handle = walrus_gateway::serve(&config_dir, None).await?; + let handle = walrus_daemon::serve(&config_dir, None).await?; tracing::info!("walrusd listening on {}", handle.socket_path.display()); signal::ctrl_c().await?; diff --git a/app/gateway/src/channel/mod.rs b/app/daemon/src/channel/mod.rs similarity index 100% rename from app/gateway/src/channel/mod.rs rename to app/daemon/src/channel/mod.rs diff --git a/app/gateway/src/channel/router.rs b/app/daemon/src/channel/router.rs similarity index 100% rename from app/gateway/src/channel/router.rs rename to app/daemon/src/channel/router.rs diff --git a/app/gateway/src/config.rs b/app/daemon/src/config.rs similarity index 100% rename from app/gateway/src/config.rs rename to app/daemon/src/config.rs diff --git a/app/gateway/src/feature/cron.rs b/app/daemon/src/feature/cron.rs similarity index 100% rename from app/gateway/src/feature/cron.rs rename to app/daemon/src/feature/cron.rs diff --git a/app/gateway/src/feature/memory.rs b/app/daemon/src/feature/memory.rs similarity index 100% rename from app/gateway/src/feature/memory.rs rename to app/daemon/src/feature/memory.rs diff --git a/app/gateway/src/feature/mod.rs b/app/daemon/src/feature/mod.rs similarity index 100% rename from app/gateway/src/feature/mod.rs rename to app/daemon/src/feature/mod.rs diff --git a/app/gateway/src/gateway/builder.rs b/app/daemon/src/gateway/builder.rs similarity index 100% rename from app/gateway/src/gateway/builder.rs rename to app/daemon/src/gateway/builder.rs diff --git a/app/gateway/src/gateway/mod.rs b/app/daemon/src/gateway/mod.rs similarity index 100% rename from app/gateway/src/gateway/mod.rs rename to app/daemon/src/gateway/mod.rs diff --git a/app/gateway/src/gateway/serve.rs b/app/daemon/src/gateway/serve.rs similarity index 100% rename from app/gateway/src/gateway/serve.rs rename to app/daemon/src/gateway/serve.rs diff --git a/app/gateway/src/gateway/uds.rs b/app/daemon/src/gateway/uds.rs similarity index 100% rename from app/gateway/src/gateway/uds.rs rename to app/daemon/src/gateway/uds.rs diff --git a/app/gateway/src/lib.rs b/app/daemon/src/lib.rs similarity index 100% rename from app/gateway/src/lib.rs rename to app/daemon/src/lib.rs diff --git a/app/gateway/src/provider.rs b/app/daemon/src/provider.rs similarity index 100% rename from app/gateway/src/provider.rs rename to app/daemon/src/provider.rs diff --git a/app/gateway/src/utils.rs b/app/daemon/src/utils.rs similarity index 100% rename from app/gateway/src/utils.rs rename to app/daemon/src/utils.rs diff --git a/app/gateway/tests/backend.rs b/app/daemon/tests/backend.rs similarity index 94% rename from app/gateway/tests/backend.rs rename to app/daemon/tests/backend.rs index 7592f98..f90f50c 100644 --- a/app/gateway/tests/backend.rs +++ b/app/daemon/tests/backend.rs @@ -1,6 +1,6 @@ //! Tests for the MemoryBackend enum dispatch and configuration integration. -use walrus_gateway::MemoryBackend; +use walrus_daemon::MemoryBackend; #[test] fn in_memory_backend_set_and_get() { @@ -95,7 +95,7 @@ async fn in_memory_backend_compile_relevant() { #[test] fn memory_backend_from_config_inmemory() { - use walrus_gateway::config::{MemoryBackendKind, MemoryConfig}; + use walrus_daemon::config::{MemoryBackendKind, MemoryConfig}; let config = MemoryConfig { backend: MemoryBackendKind::InMemory, }; @@ -106,7 +106,7 @@ fn memory_backend_from_config_inmemory() { #[test] fn memory_backend_from_config_sqlite() { - use walrus_gateway::config::{MemoryBackendKind, MemoryConfig}; + use walrus_daemon::config::{MemoryBackendKind, MemoryConfig}; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cfg.db"); let config = MemoryConfig { @@ -121,7 +121,7 @@ fn memory_backend_from_config_sqlite() { #[test] fn default_bind_address() { - let config = walrus_gateway::GatewayConfig::from_toml( + let config = walrus_daemon::GatewayConfig::from_toml( r#" [server] [llm] diff --git a/app/gateway/tests/config.rs b/app/daemon/tests/config.rs similarity index 98% rename from app/gateway/tests/config.rs rename to app/daemon/tests/config.rs index cc3b0c9..da78b78 100644 --- a/app/gateway/tests/config.rs +++ b/app/daemon/tests/config.rs @@ -1,6 +1,6 @@ //! Gateway configuration tests. -use walrus_gateway::{ +use walrus_daemon::{ GatewayConfig, config::{MemoryBackendKind, ProviderKind}, }; @@ -157,7 +157,7 @@ KEY = "value" #[test] fn global_config_dir_is_under_platform_config() { - let dir = walrus_gateway::config::global_config_dir(); + let dir = walrus_daemon::config::global_config_dir(); // Should end with "walrus" assert_eq!(dir.file_name().unwrap(), "walrus"); } diff --git a/app/gateway/tests/cron.rs b/app/daemon/tests/cron.rs similarity index 96% rename from app/gateway/tests/cron.rs rename to app/daemon/tests/cron.rs index 33b47fa..061794f 100644 --- a/app/gateway/tests/cron.rs +++ b/app/daemon/tests/cron.rs @@ -1,6 +1,6 @@ //! Cron scheduler tests. -use walrus_gateway::{CronJob, CronScheduler}; +use walrus_daemon::{CronJob, CronScheduler}; #[test] fn parse_valid_cron_expression() { diff --git a/app/gateway/tests/gateway.rs b/app/daemon/tests/gateway.rs similarity index 91% rename from app/gateway/tests/gateway.rs rename to app/daemon/tests/gateway.rs index 797918b..1f354e0 100644 --- a/app/gateway/tests/gateway.rs +++ b/app/daemon/tests/gateway.rs @@ -1,6 +1,6 @@ //! Gateway integration tests. -use walrus_gateway::GatewayConfig; +use walrus_daemon::GatewayConfig; /// Verify that GatewayConfig default socket path resolves correctly. #[test] diff --git a/app/gateway/tests/protocol.rs b/app/daemon/tests/protocol.rs similarity index 100% rename from app/gateway/tests/protocol.rs rename to app/daemon/tests/protocol.rs diff --git a/app/gateway/tests/router.rs b/app/daemon/tests/router.rs similarity index 93% rename from app/gateway/tests/router.rs rename to app/daemon/tests/router.rs index 2df1652..c4db6f8 100644 --- a/app/gateway/tests/router.rs +++ b/app/daemon/tests/router.rs @@ -2,7 +2,7 @@ use agent::Platform; use compact_str::CompactString; -use walrus_gateway::{ChannelRouter, RoutingRule}; +use walrus_daemon::{ChannelRouter, RoutingRule}; #[test] fn exact_match_priority() { @@ -73,7 +73,7 @@ fn exact_beats_catchall() { #[test] fn parse_platform_valid() { - use walrus_gateway::channel::router::parse_platform; + use walrus_daemon::channel::router::parse_platform; let p = parse_platform("telegram").unwrap(); assert_eq!(p, Platform::Telegram); let p = parse_platform("Telegram").unwrap(); @@ -82,6 +82,6 @@ fn parse_platform_valid() { #[test] fn parse_platform_invalid() { - use walrus_gateway::channel::router::parse_platform; + use walrus_daemon::channel::router::parse_platform; assert!(parse_platform("unknown").is_err()); } From ed9c3d995a7eb41206595d9566deb8c5d9942ca4 Mon Sep 17 00:00:00 2001 From: clearloop Date: Sat, 28 Feb 2026 07:35:56 +0800 Subject: [PATCH 5/8] chore(crates): remove library hub --- Cargo.lock | 4 ---- app/hub/Cargo.toml | 10 ---------- app/hub/src/lib.rs | 5 ----- app/hub/tests/hub.rs | 9 --------- 4 files changed, 28 deletions(-) delete mode 100644 app/hub/Cargo.toml delete mode 100644 app/hub/src/lib.rs delete mode 100644 app/hub/tests/hub.rs diff --git a/Cargo.lock b/Cargo.lock index 4c43ab5..ad50a6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2198,10 +2198,6 @@ dependencies = [ "walrus-llm", ] -[[package]] -name = "walrus-hub" -version = "0.0.9" - [[package]] name = "walrus-llm" version = "0.0.9" diff --git a/app/hub/Cargo.toml b/app/hub/Cargo.toml deleted file mode 100644 index 79e3cc9..0000000 --- a/app/hub/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "walrus-hub" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -keywords.workspace = true - -[dependencies] diff --git a/app/hub/src/lib.rs b/app/hub/src/lib.rs deleted file mode 100644 index 6288506..0000000 --- a/app/hub/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Walrus hub. - -pub fn add(left: u64, right: u64) -> u64 { - left + right -} diff --git a/app/hub/tests/hub.rs b/app/hub/tests/hub.rs deleted file mode 100644 index 6207050..0000000 --- a/app/hub/tests/hub.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Tests for walrus-hub. - -use walrus_hub::add; - -#[test] -fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); -} From bf825b9bee4a3f54924440fc40be32d4352aa522 Mon Sep 17 00:00:00 2001 From: clearloop Date: Sat, 28 Feb 2026 07:41:13 +0800 Subject: [PATCH 6/8] chore(core): rename the lib name of core in workspace --- Cargo.toml | 20 +++++++++++++++++--- app/daemon/Cargo.toml | 2 +- app/daemon/src/channel/router.rs | 2 +- app/daemon/src/feature/memory.rs | 2 +- app/daemon/src/gateway/builder.rs | 2 +- app/daemon/src/gateway/uds.rs | 2 +- app/daemon/tests/backend.rs | 20 ++++++++++---------- app/daemon/tests/router.rs | 2 +- crates/runtime/Cargo.toml | 2 +- crates/runtime/src/hook.rs | 2 +- crates/runtime/src/lib.rs | 2 +- crates/runtime/src/loader.rs | 2 +- crates/runtime/src/skills.rs | 2 +- crates/runtime/src/team.rs | 2 +- crates/runtime/tests/runtime.rs | 2 +- crates/runtime/tests/skills.rs | 2 +- crates/runtime/tests/team.rs | 2 +- crates/sqlite/Cargo.toml | 2 +- crates/sqlite/src/lib.rs | 4 ++-- crates/sqlite/src/memory.rs | 2 +- crates/sqlite/src/utils.rs | 2 +- crates/sqlite/tests/sqlite.rs | 2 +- crates/telegram/Cargo.toml | 2 +- crates/telegram/src/lib.rs | 2 +- crates/telegram/tests/telegram.rs | 2 +- 25 files changed, 51 insertions(+), 37 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c5e1a82..2906abb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,14 @@ [workspace] resolver = "3" -members = ["app/*", "crates/*", "llm", "llm/deepseek", "llm/openai", "llm/claude", "llm/mistral"] +members = [ + "app/*", + "crates/*", + "llm", + "llm/deepseek", + "llm/openai", + "llm/claude", + "llm/mistral", +] [workspace.package] version = "0.0.9" @@ -16,7 +24,7 @@ openai = { path = "llm/openai", package = "walrus-openai", version = "0.0.9" } claude = { path = "llm/claude", package = "walrus-claude", version = "0.0.9" } mistral = { path = "llm/mistral", package = "walrus-mistral", version = "0.0.9" } llm = { path = "llm", package = "walrus-llm", version = "0.0.9" } -agent = { path = "crates/core", package = "walrus-core", version = "0.0.9" } +wcore = { path = "crates/core", package = "walrus-core", version = "0.0.9" } runtime = { path = "crates/runtime", package = "walrus-runtime", version = "0.0.9" } sqlite = { path = "crates/sqlite", package = "walrus-sqlite", version = "0.0.9" } telegram = { path = "crates/telegram", package = "walrus-telegram", version = "0.0.9" } @@ -39,7 +47,13 @@ serde_json = "1" serde_yaml = "0.9" smallvec = { version = "1", features = ["serde"] } tempfile = "3" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "io-util", "net"] } +tokio = { version = "1", features = [ + "rt-multi-thread", + "macros", + "signal", + "io-util", + "net", +] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } dotenvy = "0.15" diff --git a/app/daemon/Cargo.toml b/app/daemon/Cargo.toml index c9c0ed1..965ab4f 100644 --- a/app/daemon/Cargo.toml +++ b/app/daemon/Cargo.toml @@ -13,7 +13,7 @@ path = "src/bin/main.rs" [dependencies] runtime = { workspace = true } -agent = { workspace = true } +wcore = { workspace = true } deepseek = { workspace = true } openai = { workspace = true } claude = { workspace = true } diff --git a/app/daemon/src/channel/router.rs b/app/daemon/src/channel/router.rs index de4e08c..ed99929 100644 --- a/app/daemon/src/channel/router.rs +++ b/app/daemon/src/channel/router.rs @@ -4,7 +4,7 @@ //! and channel ID with three-tier fallback: exact match, platform //! catch-all, default agent (DD#3). -use agent::Platform; +use wcore::Platform; use compact_str::CompactString; /// A routing rule mapping platform/channel to an agent. diff --git a/app/daemon/src/feature/memory.rs b/app/daemon/src/feature/memory.rs index 813dfe2..2d9cf1b 100644 --- a/app/daemon/src/feature/memory.rs +++ b/app/daemon/src/feature/memory.rs @@ -3,7 +3,7 @@ //! Wraps [`InMemory`] and [`SqliteMemory`] with Memory trait //! delegation, following the Provider enum pattern (DD#22). -use agent::{InMemory, Memory, MemoryEntry, NoEmbedder, RecallOptions}; +use wcore::{InMemory, Memory, MemoryEntry, NoEmbedder, RecallOptions}; use anyhow::Result; use sqlite::SqliteMemory; use std::future::Future; diff --git a/app/daemon/src/gateway/builder.rs b/app/daemon/src/gateway/builder.rs index b53f272..2989d39 100644 --- a/app/daemon/src/gateway/builder.rs +++ b/app/daemon/src/gateway/builder.rs @@ -104,7 +104,7 @@ pub async fn build_runtime( // Load skills if directory exists. let skills_dir = config_dir.join(config::SKILLS_DIR); - match SkillRegistry::load_dir(&skills_dir, agent::SkillTier::Workspace) { + match SkillRegistry::load_dir(&skills_dir, wcore::SkillTier::Workspace) { Ok(registry) => { tracing::info!("loaded {} skill(s)", registry.len()); runtime.set_skills(registry); diff --git a/app/daemon/src/gateway/uds.rs b/app/daemon/src/gateway/uds.rs index 3e4b5e5..3a8ca78 100644 --- a/app/daemon/src/gateway/uds.rs +++ b/app/daemon/src/gateway/uds.rs @@ -1,7 +1,7 @@ //! Unix domain socket server — accept loop and per-connection message handler. use crate::gateway::Gateway; -use agent::Memory; +use wcore::Memory; use compact_str::CompactString; use llm::Message; use protocol::codec::{self, FrameError}; diff --git a/app/daemon/tests/backend.rs b/app/daemon/tests/backend.rs index f90f50c..9df0f50 100644 --- a/app/daemon/tests/backend.rs +++ b/app/daemon/tests/backend.rs @@ -4,7 +4,7 @@ use walrus_daemon::MemoryBackend; #[test] fn in_memory_backend_set_and_get() { - use agent::Memory; + use wcore::Memory; let backend = MemoryBackend::in_memory(); assert!(backend.get("key").is_none()); backend.set("key", "value"); @@ -13,7 +13,7 @@ fn in_memory_backend_set_and_get() { #[test] fn in_memory_backend_entries() { - use agent::Memory; + use wcore::Memory; let backend = MemoryBackend::in_memory(); backend.set("a", "1"); backend.set("b", "2"); @@ -23,7 +23,7 @@ fn in_memory_backend_entries() { #[test] fn in_memory_backend_remove() { - use agent::Memory; + use wcore::Memory; let backend = MemoryBackend::in_memory(); backend.set("key", "value"); let old = backend.remove("key"); @@ -33,7 +33,7 @@ fn in_memory_backend_remove() { #[test] fn sqlite_backend_set_and_get() { - use agent::Memory; + use wcore::Memory; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.db"); let backend = MemoryBackend::sqlite(path.to_str().unwrap()).unwrap(); @@ -44,7 +44,7 @@ fn sqlite_backend_set_and_get() { #[test] fn sqlite_backend_entries() { - use agent::Memory; + use wcore::Memory; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.db"); let backend = MemoryBackend::sqlite(path.to_str().unwrap()).unwrap(); @@ -56,7 +56,7 @@ fn sqlite_backend_entries() { #[test] fn sqlite_backend_remove() { - use agent::Memory; + use wcore::Memory; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.db"); let backend = MemoryBackend::sqlite(path.to_str().unwrap()).unwrap(); @@ -68,7 +68,7 @@ fn sqlite_backend_remove() { #[tokio::test] async fn in_memory_backend_store() { - use agent::Memory; + use wcore::Memory; let backend = MemoryBackend::in_memory(); backend.store("key", "value").await.unwrap(); assert_eq!(backend.get("key").unwrap(), "value"); @@ -76,7 +76,7 @@ async fn in_memory_backend_store() { #[tokio::test] async fn sqlite_backend_store() { - use agent::Memory; + use wcore::Memory; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.db"); let backend = MemoryBackend::sqlite(path.to_str().unwrap()).unwrap(); @@ -86,7 +86,7 @@ async fn sqlite_backend_store() { #[tokio::test] async fn in_memory_backend_compile_relevant() { - use agent::Memory; + use wcore::Memory; let backend = MemoryBackend::in_memory(); backend.set("fact", "the sky is blue"); let compiled = backend.compile_relevant("sky color").await; @@ -114,7 +114,7 @@ fn memory_backend_from_config_sqlite() { }; assert_eq!(config.backend, MemoryBackendKind::Sqlite); let backend = MemoryBackend::sqlite(path.to_str().unwrap()).unwrap(); - use agent::Memory; + use wcore::Memory; backend.set("test", "ok"); assert_eq!(backend.get("test").unwrap(), "ok"); } diff --git a/app/daemon/tests/router.rs b/app/daemon/tests/router.rs index c4db6f8..048b534 100644 --- a/app/daemon/tests/router.rs +++ b/app/daemon/tests/router.rs @@ -1,6 +1,6 @@ //! Channel routing tests. -use agent::Platform; +use wcore::Platform; use compact_str::CompactString; use walrus_daemon::{ChannelRouter, RoutingRule}; diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index 5a59a1e..54c9af8 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -11,7 +11,7 @@ keywords.workspace = true [dependencies] llm.workspace = true -agent.workspace = true +wcore.workspace = true # crates-io dependencies anyhow.workspace = true diff --git a/crates/runtime/src/hook.rs b/crates/runtime/src/hook.rs index 21ce339..d048c81 100644 --- a/crates/runtime/src/hook.rs +++ b/crates/runtime/src/hook.rs @@ -4,7 +4,7 @@ //! which [`Memory`] backend and LLM provider to use, and what prompts //! to send for automatic compaction and memory flush. -use agent::{InMemory, Memory}; +use wcore::{InMemory, Memory}; use llm::{General, LLM, NoopProvider}; /// Type-level runtime configuration. diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 5a5f1ad..662717c 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -15,7 +15,7 @@ //! let response = runtime.send_to("assistant", Message::user("hello")).await?; //! ``` -pub use agent::{Agent, InMemory, Memory, NoEmbedder, Skill, SkillTier}; +pub use wcore::{Agent, InMemory, Memory, NoEmbedder, Skill, SkillTier}; pub use hook::{DEFAULT_COMPACT_PROMPT, DEFAULT_FLUSH_PROMPT, Hook}; pub use llm::{Client, General, Message, Response, Role, StreamChunk, Tool}; pub use loader::{CronEntry, load_agents_dir, load_cron_dir, parse_agent_md, parse_cron_md}; diff --git a/crates/runtime/src/loader.rs b/crates/runtime/src/loader.rs index 35afecb..43452ad 100644 --- a/crates/runtime/src/loader.rs +++ b/crates/runtime/src/loader.rs @@ -5,7 +5,7 @@ //! fields and the markdown body becomes the system prompt (agents) or //! message template (cron). -use agent::Agent; +use wcore::Agent; use compact_str::CompactString; use serde::Deserialize; use std::path::Path; diff --git a/crates/runtime/src/skills.rs b/crates/runtime/src/skills.rs index aeac4f2..4a9d314 100644 --- a/crates/runtime/src/skills.rs +++ b/crates/runtime/src/skills.rs @@ -4,7 +4,7 @@ //! (agentskills.io format). The [`SkillRegistry`] loads them from a directory, //! builds tag/trigger indices, and returns ranked matches. -use agent::{Skill, SkillTier}; +use wcore::{Skill, SkillTier}; use compact_str::CompactString; use serde::Deserialize; use std::collections::BTreeMap; diff --git a/crates/runtime/src/team.rs b/crates/runtime/src/team.rs index 0ea0621..9e4eab6 100644 --- a/crates/runtime/src/team.rs +++ b/crates/runtime/src/team.rs @@ -19,7 +19,7 @@ //! ``` use crate::{Handler, Hook, MAX_TOOL_CALLS, SkillRegistry}; -use agent::{Agent, Memory}; +use wcore::{Agent, Memory}; use compact_str::CompactString; use llm::{Config, General, LLM, Message, Tool, ToolChoice}; use std::{collections::BTreeMap, sync::Arc}; diff --git a/crates/runtime/tests/runtime.rs b/crates/runtime/tests/runtime.rs index e8dca02..22b231f 100644 --- a/crates/runtime/tests/runtime.rs +++ b/crates/runtime/tests/runtime.rs @@ -1,6 +1,6 @@ //! Tests for the Runtime orchestrator. -use agent::{Agent, InMemory, Memory, Skill, SkillTier}; +use wcore::{Agent, InMemory, Memory, Skill, SkillTier}; use compact_str::CompactString; use llm::{FunctionCall, General, Message, NoopProvider, Tool, ToolCall}; use std::collections::BTreeMap; diff --git a/crates/runtime/tests/skills.rs b/crates/runtime/tests/skills.rs index aa5b69b..68243b7 100644 --- a/crates/runtime/tests/skills.rs +++ b/crates/runtime/tests/skills.rs @@ -1,6 +1,6 @@ //! Tests for SkillRegistry. -use agent::{Skill, SkillTier}; +use wcore::{Skill, SkillTier}; use compact_str::CompactString; use std::collections::BTreeMap; use walrus_runtime::parse_skill_md; diff --git a/crates/runtime/tests/team.rs b/crates/runtime/tests/team.rs index ef7a3de..9d4223b 100644 --- a/crates/runtime/tests/team.rs +++ b/crates/runtime/tests/team.rs @@ -1,6 +1,6 @@ //! Tests for team composition. -use agent::{Agent, InMemory}; +use wcore::{Agent, InMemory}; use llm::{General, NoopProvider}; use walrus_runtime::{Runtime, build_team, extract_input, worker_tool}; diff --git a/crates/sqlite/Cargo.toml b/crates/sqlite/Cargo.toml index 4239057..0ecd4a0 100644 --- a/crates/sqlite/Cargo.toml +++ b/crates/sqlite/Cargo.toml @@ -10,7 +10,7 @@ repository.workspace = true keywords.workspace = true [dependencies] -agent.workspace = true +wcore.workspace = true anyhow.workspace = true compact_str.workspace = true rusqlite.workspace = true diff --git a/crates/sqlite/src/lib.rs b/crates/sqlite/src/lib.rs index a101edf..eb65f55 100644 --- a/crates/sqlite/src/lib.rs +++ b/crates/sqlite/src/lib.rs @@ -1,13 +1,13 @@ //! SQLite-backed memory for Walrus agents. //! -//! Provides [`SqliteMemory`], a persistent [`Memory`](agent::Memory) implementation +//! Provides [`SqliteMemory`], a persistent [`Memory`](wcore::Memory) implementation //! using SQLite with FTS5 full-text search and optional hybrid vector recall. //! //! All SQL lives in `sql/*.sql` files, loaded via `include_str!`. pub use crate::utils::cosine_similarity; use crate::utils::{decode_embedding, mmr_rerank, now_unix}; -use agent::{Embedder, MemoryEntry, RecallOptions}; +use wcore::{Embedder, MemoryEntry, RecallOptions}; use anyhow::Result; use compact_str::CompactString; use rusqlite::Connection; diff --git a/crates/sqlite/src/memory.rs b/crates/sqlite/src/memory.rs index 4950255..701b861 100644 --- a/crates/sqlite/src/memory.rs +++ b/crates/sqlite/src/memory.rs @@ -3,7 +3,7 @@ use crate::SqliteMemory; use crate::sql; use crate::utils::now_unix; -use agent::{Embedder, Memory, MemoryEntry, RecallOptions}; +use wcore::{Embedder, Memory, MemoryEntry, RecallOptions}; use anyhow::Result; use std::future::Future; diff --git a/crates/sqlite/src/utils.rs b/crates/sqlite/src/utils.rs index 79e1b86..c1048a0 100644 --- a/crates/sqlite/src/utils.rs +++ b/crates/sqlite/src/utils.rs @@ -1,6 +1,6 @@ //! Utility functions for memory recall scoring and ranking. -use agent::MemoryEntry; +use wcore::MemoryEntry; use std::collections::HashSet; /// Cosine similarity between two float vectors. diff --git a/crates/sqlite/tests/sqlite.rs b/crates/sqlite/tests/sqlite.rs index 0833767..ae9ad4a 100644 --- a/crates/sqlite/tests/sqlite.rs +++ b/crates/sqlite/tests/sqlite.rs @@ -1,6 +1,6 @@ //! Tests for SqliteMemory. -use agent::{Embedder, Memory, MemoryEntry, RecallOptions}; +use wcore::{Embedder, Memory, MemoryEntry, RecallOptions}; use walrus_sqlite::{SqliteMemory, cosine_similarity}; /// Noop embedder for tests that don't need vector search. diff --git a/crates/telegram/Cargo.toml b/crates/telegram/Cargo.toml index 06ac49b..a4c38f8 100644 --- a/crates/telegram/Cargo.toml +++ b/crates/telegram/Cargo.toml @@ -10,7 +10,7 @@ repository.workspace = true keywords.workspace = true [dependencies] -agent.workspace = true +wcore.workspace = true # crates-io dependencies anyhow.workspace = true diff --git a/crates/telegram/src/lib.rs b/crates/telegram/src/lib.rs index 62e7edd..9b2107f 100644 --- a/crates/telegram/src/lib.rs +++ b/crates/telegram/src/lib.rs @@ -3,7 +3,7 @@ //! Connects agents to Telegram via the Bot API using reqwest directly (DD#2). //! Implements the [`Channel`] trait from walrus-core. -use agent::{Attachment, AttachmentKind, Channel, ChannelMessage, Platform}; +use wcore::{Attachment, AttachmentKind, Channel, ChannelMessage, Platform}; use anyhow::Result; use compact_str::CompactString; use futures_core::Stream; diff --git a/crates/telegram/tests/telegram.rs b/crates/telegram/tests/telegram.rs index cdb1e4a..93a11dc 100644 --- a/crates/telegram/tests/telegram.rs +++ b/crates/telegram/tests/telegram.rs @@ -1,6 +1,6 @@ //! Tests for the Telegram channel adapter. -use agent::{Channel, Platform}; +use wcore::{Channel, Platform}; use walrus_telegram::{TelegramChannel, channel_message_from_update, send_message_url}; #[test] From 6ad6aa7d1334a9741111f3505c819108f67464c5 Mon Sep 17 00:00:00 2001 From: clearloop Date: Sat, 28 Feb 2026 07:54:55 +0800 Subject: [PATCH 7/8] chore(llm): move llm core to crates --- Cargo.toml | 14 +++----------- {llm => crates/llm}/Cargo.toml | 0 {llm => crates/llm}/src/config.rs | 0 {llm => crates/llm}/src/lib.rs | 0 {llm => crates/llm}/src/message.rs | 0 {llm => crates/llm}/src/noop.rs | 0 {llm => crates/llm}/src/provider.rs | 0 {llm => crates/llm}/src/response.rs | 0 {llm => crates/llm}/src/stream.rs | 0 {llm => crates/llm}/src/tool.rs | 0 .../llm}/templates/deepseek/response.json | 0 {llm => crates/llm}/templates/deepseek/stream.json | 0 12 files changed, 3 insertions(+), 11 deletions(-) rename {llm => crates/llm}/Cargo.toml (100%) rename {llm => crates/llm}/src/config.rs (100%) rename {llm => crates/llm}/src/lib.rs (100%) rename {llm => crates/llm}/src/message.rs (100%) rename {llm => crates/llm}/src/noop.rs (100%) rename {llm => crates/llm}/src/provider.rs (100%) rename {llm => crates/llm}/src/response.rs (100%) rename {llm => crates/llm}/src/stream.rs (100%) rename {llm => crates/llm}/src/tool.rs (100%) rename {llm => crates/llm}/templates/deepseek/response.json (100%) rename {llm => crates/llm}/templates/deepseek/stream.json (100%) diff --git a/Cargo.toml b/Cargo.toml index 2906abb..0b5fcff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,6 @@ [workspace] resolver = "3" -members = [ - "app/*", - "crates/*", - "llm", - "llm/deepseek", - "llm/openai", - "llm/claude", - "llm/mistral", -] +members = ["app/*", "crates/*", "llm/*"] [workspace.package] version = "0.0.9" @@ -23,13 +15,13 @@ deepseek = { path = "llm/deepseek", package = "walrus-deepseek", version = "0.0. openai = { path = "llm/openai", package = "walrus-openai", version = "0.0.9" } claude = { path = "llm/claude", package = "walrus-claude", version = "0.0.9" } mistral = { path = "llm/mistral", package = "walrus-mistral", version = "0.0.9" } -llm = { path = "llm", package = "walrus-llm", version = "0.0.9" } +llm = { path = "crates/llm", package = "walrus-llm", version = "0.0.9" } wcore = { path = "crates/core", package = "walrus-core", version = "0.0.9" } runtime = { path = "crates/runtime", package = "walrus-runtime", version = "0.0.9" } sqlite = { path = "crates/sqlite", package = "walrus-sqlite", version = "0.0.9" } telegram = { path = "crates/telegram", package = "walrus-telegram", version = "0.0.9" } protocol = { path = "app/protocol", package = "walrus-protocol", version = "0.0.9" } -gateway = { path = "app/gateway", package = "walrus-gateway", version = "0.0.9" } +daemon = { path = "app/daemon", package = "walrus-daemon", version = "0.0.9" } client = { path = "app/client", package = "walrus-client", version = "0.0.9" } # crates.io diff --git a/llm/Cargo.toml b/crates/llm/Cargo.toml similarity index 100% rename from llm/Cargo.toml rename to crates/llm/Cargo.toml diff --git a/llm/src/config.rs b/crates/llm/src/config.rs similarity index 100% rename from llm/src/config.rs rename to crates/llm/src/config.rs diff --git a/llm/src/lib.rs b/crates/llm/src/lib.rs similarity index 100% rename from llm/src/lib.rs rename to crates/llm/src/lib.rs diff --git a/llm/src/message.rs b/crates/llm/src/message.rs similarity index 100% rename from llm/src/message.rs rename to crates/llm/src/message.rs diff --git a/llm/src/noop.rs b/crates/llm/src/noop.rs similarity index 100% rename from llm/src/noop.rs rename to crates/llm/src/noop.rs diff --git a/llm/src/provider.rs b/crates/llm/src/provider.rs similarity index 100% rename from llm/src/provider.rs rename to crates/llm/src/provider.rs diff --git a/llm/src/response.rs b/crates/llm/src/response.rs similarity index 100% rename from llm/src/response.rs rename to crates/llm/src/response.rs diff --git a/llm/src/stream.rs b/crates/llm/src/stream.rs similarity index 100% rename from llm/src/stream.rs rename to crates/llm/src/stream.rs diff --git a/llm/src/tool.rs b/crates/llm/src/tool.rs similarity index 100% rename from llm/src/tool.rs rename to crates/llm/src/tool.rs diff --git a/llm/templates/deepseek/response.json b/crates/llm/templates/deepseek/response.json similarity index 100% rename from llm/templates/deepseek/response.json rename to crates/llm/templates/deepseek/response.json diff --git a/llm/templates/deepseek/stream.json b/crates/llm/templates/deepseek/stream.json similarity index 100% rename from llm/templates/deepseek/stream.json rename to crates/llm/templates/deepseek/stream.json From 0a214c602e4ba77d2b9148a06617e7fc29fbb99a Mon Sep 17 00:00:00 2001 From: clearloop Date: Sat, 28 Feb 2026 09:59:43 +0800 Subject: [PATCH 8/8] feat(llm): slim the llm providers --- Cargo.toml | 2 +- app/daemon/src/channel/router.rs | 2 +- app/daemon/src/feature/memory.rs | 2 +- app/daemon/src/gateway/builder.rs | 1 - app/daemon/src/gateway/uds.rs | 2 +- app/daemon/src/provider.rs | 6 +- app/daemon/tests/router.rs | 2 +- crates/llm/Cargo.toml | 8 +- crates/llm/src/http.rs | 152 ++++++++++++++++++++++++++++++ crates/llm/src/lib.rs | 11 +++ crates/llm/src/noop.rs | 6 +- crates/llm/src/provider.rs | 11 +-- crates/llm/src/request.rs | 150 +++++++++++++++++++++++++++++ crates/llm/tests/http_provider.rs | 66 +++++++++++++ crates/llm/tests/request.rs | 110 +++++++++++++++++++++ crates/runtime/src/hook.rs | 2 +- crates/runtime/src/lib.rs | 2 +- crates/runtime/src/loader.rs | 2 +- crates/runtime/src/skills.rs | 2 +- crates/runtime/src/team.rs | 2 +- crates/runtime/tests/runtime.rs | 2 +- crates/runtime/tests/skills.rs | 2 +- crates/runtime/tests/team.rs | 2 +- crates/sqlite/src/lib.rs | 2 +- crates/sqlite/src/memory.rs | 2 +- crates/sqlite/src/utils.rs | 2 +- crates/sqlite/tests/sqlite.rs | 2 +- crates/telegram/src/lib.rs | 2 +- crates/telegram/tests/telegram.rs | 2 +- llm/claude/src/provider.rs | 20 +--- llm/deepseek/src/provider.rs | 14 +-- llm/mistral/Cargo.toml | 1 + llm/mistral/src/lib.rs | 21 +---- llm/mistral/src/provider.rs | 20 +--- llm/mistral/tests/constructors.rs | 18 ++++ llm/openai/src/provider.rs | 20 +--- 36 files changed, 554 insertions(+), 119 deletions(-) create mode 100644 crates/llm/src/http.rs create mode 100644 crates/llm/src/request.rs create mode 100644 crates/llm/tests/http_provider.rs create mode 100644 crates/llm/tests/request.rs create mode 100644 llm/mistral/tests/constructors.rs diff --git a/Cargo.toml b/Cargo.toml index 0b5fcff..93b26e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ deepseek = { path = "llm/deepseek", package = "walrus-deepseek", version = "0.0. openai = { path = "llm/openai", package = "walrus-openai", version = "0.0.9" } claude = { path = "llm/claude", package = "walrus-claude", version = "0.0.9" } mistral = { path = "llm/mistral", package = "walrus-mistral", version = "0.0.9" } -llm = { path = "crates/llm", package = "walrus-llm", version = "0.0.9" } +llm = { path = "crates/llm", package = "walrus-llm", version = "0.0.9", default-features = true } wcore = { path = "crates/core", package = "walrus-core", version = "0.0.9" } runtime = { path = "crates/runtime", package = "walrus-runtime", version = "0.0.9" } sqlite = { path = "crates/sqlite", package = "walrus-sqlite", version = "0.0.9" } diff --git a/app/daemon/src/channel/router.rs b/app/daemon/src/channel/router.rs index ed99929..360a742 100644 --- a/app/daemon/src/channel/router.rs +++ b/app/daemon/src/channel/router.rs @@ -4,8 +4,8 @@ //! and channel ID with three-tier fallback: exact match, platform //! catch-all, default agent (DD#3). -use wcore::Platform; use compact_str::CompactString; +use wcore::Platform; /// A routing rule mapping platform/channel to an agent. #[derive(Debug, Clone)] diff --git a/app/daemon/src/feature/memory.rs b/app/daemon/src/feature/memory.rs index 2d9cf1b..49252ee 100644 --- a/app/daemon/src/feature/memory.rs +++ b/app/daemon/src/feature/memory.rs @@ -3,10 +3,10 @@ //! Wraps [`InMemory`] and [`SqliteMemory`] with Memory trait //! delegation, following the Provider enum pattern (DD#22). -use wcore::{InMemory, Memory, MemoryEntry, NoEmbedder, RecallOptions}; use anyhow::Result; use sqlite::SqliteMemory; use std::future::Future; +use wcore::{InMemory, Memory, MemoryEntry, NoEmbedder, RecallOptions}; /// Memory backend selected from configuration. /// diff --git a/app/daemon/src/gateway/builder.rs b/app/daemon/src/gateway/builder.rs index 2989d39..27a7c69 100644 --- a/app/daemon/src/gateway/builder.rs +++ b/app/daemon/src/gateway/builder.rs @@ -7,7 +7,6 @@ use crate::provider::Provider; use anyhow::Result; use claude::Claude; use deepseek::DeepSeek; -use llm::LLM; use mistral::Mistral; use openai::OpenAI; use runtime::{General, McpBridge, Runtime, SkillRegistry}; diff --git a/app/daemon/src/gateway/uds.rs b/app/daemon/src/gateway/uds.rs index 3a8ca78..c9719f9 100644 --- a/app/daemon/src/gateway/uds.rs +++ b/app/daemon/src/gateway/uds.rs @@ -1,7 +1,6 @@ //! Unix domain socket server — accept loop and per-connection message handler. use crate::gateway::Gateway; -use wcore::Memory; use compact_str::CompactString; use llm::Message; use protocol::codec::{self, FrameError}; @@ -11,6 +10,7 @@ use std::collections::BTreeMap; use tokio::net::UnixListener; use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; use tokio::sync::{mpsc, oneshot}; +use wcore::Memory; /// Accept connections on the given `UnixListener` until shutdown is signalled. pub async fn accept_loop( diff --git a/app/daemon/src/provider.rs b/app/daemon/src/provider.rs index 50127a7..2c17f9d 100644 --- a/app/daemon/src/provider.rs +++ b/app/daemon/src/provider.rs @@ -11,7 +11,7 @@ use claude::Claude; use deepseek::DeepSeek; use futures_core::Stream; use futures_util::StreamExt; -use llm::{Client, General, LLM, Message, Response, StreamChunk}; +use llm::{General, LLM, Message, Response, StreamChunk}; use mistral::Mistral; use openai::OpenAI; @@ -34,10 +34,6 @@ pub enum Provider { impl LLM for Provider { type ChatConfig = General; - fn new(client: Client, key: &str) -> Result { - Ok(Self::DeepSeek(DeepSeek::new(client, key)?)) - } - async fn send(&self, config: &General, messages: &[Message]) -> Result { match self { Self::DeepSeek(p) => { diff --git a/app/daemon/tests/router.rs b/app/daemon/tests/router.rs index 048b534..6d48648 100644 --- a/app/daemon/tests/router.rs +++ b/app/daemon/tests/router.rs @@ -1,8 +1,8 @@ //! Channel routing tests. -use wcore::Platform; use compact_str::CompactString; use walrus_daemon::{ChannelRouter, RoutingRule}; +use wcore::Platform; #[test] fn exact_match_priority() { diff --git a/crates/llm/Cargo.toml b/crates/llm/Cargo.toml index b8fc577..8a80efa 100644 --- a/crates/llm/Cargo.toml +++ b/crates/llm/Cargo.toml @@ -9,6 +9,10 @@ license.workspace = true repository.workspace = true keywords.workspace = true +[features] +default = ["http"] +http = ["dep:reqwest", "dep:futures-util"] + [dependencies] anyhow.workspace = true async-stream.workspace = true @@ -17,8 +21,8 @@ serde.workspace = true smallvec.workspace = true serde_json.workspace = true futures-core.workspace = true -futures-util.workspace = true -reqwest.workspace = true +futures-util = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true } schemars.workspace = true tracing.workspace = true diff --git a/crates/llm/src/http.rs b/crates/llm/src/http.rs new file mode 100644 index 0000000..461eef2 --- /dev/null +++ b/crates/llm/src/http.rs @@ -0,0 +1,152 @@ +//! Shared HTTP transport for OpenAI-compatible LLM providers (DD#58). +//! +//! `HttpProvider` wraps a `reqwest::Client` with pre-configured headers and +//! endpoint URL. Provides `send()` for non-streaming and `stream_sse()` for +//! Server-Sent Events streaming. Used by DeepSeek, OpenAI, and Mistral — +//! Claude uses its own transport (different SSE format). + +use crate::{Response, StreamChunk}; +use anyhow::Result; +use async_stream::try_stream; +use futures_core::Stream; +use futures_util::StreamExt; +use reqwest::{ + Client, Method, + header::{self, HeaderMap, HeaderName, HeaderValue}, +}; +use serde::Serialize; + +/// Shared HTTP transport for OpenAI-compatible providers. +/// +/// Holds a `reqwest::Client`, pre-built headers (auth + content-type), +/// and the target endpoint URL. +#[derive(Clone)] +pub struct HttpProvider { + client: Client, + headers: HeaderMap, + endpoint: String, +} + +impl HttpProvider { + /// Create a provider with Bearer token authentication. + pub fn bearer(client: Client, key: &str, endpoint: &str) -> Result { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + headers.insert(header::ACCEPT, HeaderValue::from_static("application/json")); + headers.insert(header::AUTHORIZATION, format!("Bearer {key}").parse()?); + Ok(Self { + client, + headers, + endpoint: endpoint.to_owned(), + }) + } + + /// Create a provider without authentication (e.g. Ollama). + pub fn no_auth(client: Client, endpoint: &str) -> Self { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + headers.insert(header::ACCEPT, HeaderValue::from_static("application/json")); + Self { + client, + headers, + endpoint: endpoint.to_owned(), + } + } + + /// Create a provider with a custom header for authentication. + /// + /// Used by providers that don't use Bearer tokens (e.g. Anthropic + /// uses `x-api-key`). + pub fn custom_header( + client: Client, + header_name: &str, + header_value: &str, + endpoint: &str, + ) -> Result { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + headers.insert(header::ACCEPT, HeaderValue::from_static("application/json")); + headers.insert( + header_name.parse::()?, + header_value.parse::()?, + ); + Ok(Self { + client, + headers, + endpoint: endpoint.to_owned(), + }) + } + + /// Send a non-streaming request and deserialize the response as JSON. + pub async fn send(&self, body: &impl Serialize) -> Result { + tracing::trace!("request: {}", serde_json::to_string(body)?); + let text = self + .client + .request(Method::POST, &self.endpoint) + .headers(self.headers.clone()) + .json(body) + .send() + .await? + .text() + .await?; + + serde_json::from_str(&text).map_err(Into::into) + } + + /// Stream an SSE response (OpenAI-compatible format). + /// + /// Parses `data: ` prefixed lines, skips `[DONE]` sentinel, and + /// deserializes each chunk as [`StreamChunk`]. + pub fn stream_sse( + &self, + body: &impl Serialize, + ) -> impl Stream> + Send { + if let Ok(body) = serde_json::to_string(body) { + tracing::trace!("request: {}", body); + } + let request = self + .client + .request(Method::POST, &self.endpoint) + .headers(self.headers.clone()) + .json(body); + + try_stream! { + let response = request.send().await?; + let mut stream = response.bytes_stream(); + while let Some(next) = stream.next().await { + let bytes = next?; + let text = String::from_utf8_lossy(&bytes); + tracing::trace!("chunk: {}", text); + for data in text.split("data: ").skip(1).filter(|s| !s.starts_with("[DONE]")) { + let trimmed = data.trim(); + if trimmed.is_empty() { + continue; + } + match serde_json::from_str::(trimmed) { + Ok(chunk) => yield chunk, + Err(e) => tracing::warn!("failed to parse chunk: {e}, data: {trimmed}"), + } + } + } + } + } + + /// Get the endpoint URL. + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + /// Get a reference to the headers. + pub fn headers(&self) -> &HeaderMap { + &self.headers + } +} diff --git a/crates/llm/src/lib.rs b/crates/llm/src/lib.rs index 74120853..706d65b 100644 --- a/crates/llm/src/lib.rs +++ b/crates/llm/src/lib.rs @@ -2,11 +2,18 @@ //! //! This crate provides the shared types used across all LLM providers: //! `Message`, `Response`, `StreamChunk`, `Tool`, `Config`, and the `LLM` trait. +//! Also provides `HttpProvider` for OpenAI-compatible HTTP transport (DD#58) +//! and a shared `Request` type. pub use config::{Config, General}; +#[cfg(feature = "http")] +pub use http::HttpProvider; pub use message::{Message, Role, estimate_tokens}; pub use noop::NoopProvider; pub use provider::LLM; +#[cfg(feature = "http")] +pub use request::Request; +#[cfg(feature = "http")] pub use reqwest::{self, Client}; pub use response::{ Choice, CompletionMeta, CompletionTokensDetails, Delta, FinishReason, Response, Usage, @@ -15,9 +22,13 @@ pub use stream::StreamChunk; pub use tool::{FunctionCall, Tool, ToolCall, ToolChoice}; mod config; +#[cfg(feature = "http")] +mod http; mod message; mod noop; mod provider; +#[cfg(feature = "http")] +mod request; mod response; mod stream; mod tool; diff --git a/crates/llm/src/noop.rs b/crates/llm/src/noop.rs index 29c76ca..9eb0f6c 100644 --- a/crates/llm/src/noop.rs +++ b/crates/llm/src/noop.rs @@ -4,7 +4,7 @@ //! unit tests that exercise tool dispatch, memory, and session logic //! without making real LLM calls. -use crate::{Client, General, LLM, Message, Response, StreamChunk}; +use crate::{General, LLM, Message, Response, StreamChunk}; use anyhow::Result; use futures_core::Stream; @@ -20,10 +20,6 @@ pub struct NoopProvider; impl LLM for NoopProvider { type ChatConfig = General; - fn new(_client: Client, _key: &str) -> Result { - Ok(Self) - } - async fn send(&self, _config: &General, _messages: &[Message]) -> Result { panic!("NoopProvider::send called — not intended for real LLM calls"); } diff --git a/crates/llm/src/provider.rs b/crates/llm/src/provider.rs index a45b3ed..08fc6c4 100644 --- a/crates/llm/src/provider.rs +++ b/crates/llm/src/provider.rs @@ -3,18 +3,15 @@ use crate::{Config, Message, Response, StreamChunk}; use anyhow::Result; use futures_core::Stream; -use reqwest::Client; -/// A trait for LLM providers +/// A trait for LLM providers. +/// +/// Constructors are inherent methods on each provider — never called +/// polymorphically (DD#57). pub trait LLM: Sized + Clone { /// The chat configuration. type ChatConfig: Config + Send; - /// Create a new LLM provider - fn new(client: Client, key: &str) -> Result - where - Self: Sized; - /// Send a message to the LLM fn send( &self, diff --git a/crates/llm/src/request.rs b/crates/llm/src/request.rs new file mode 100644 index 0000000..1612122 --- /dev/null +++ b/crates/llm/src/request.rs @@ -0,0 +1,150 @@ +//! Shared OpenAI-compatible request body (DD#58). +//! +//! Superset of the fields used by DeepSeek, OpenAI, and Mistral. Fields +//! use `Option` + `skip_serializing_if` so provider-specific extras (like +//! DeepSeek's `thinking`) are simply absent when unused. + +use crate::{Config, General, Message, Tool, ToolChoice}; +use serde::Serialize; +use serde_json::{Value, json}; + +/// OpenAI-compatible chat completions request body. +#[derive(Debug, Clone, Serialize)] +pub struct Request { + /// The messages to send. + pub messages: Vec, + /// The model identifier. + pub model: String, + /// Frequency penalty. + #[serde(skip_serializing_if = "Option::is_none")] + pub frequency_penalty: Option, + /// Whether to return log probabilities. + #[serde(skip_serializing_if = "Option::is_none")] + pub logprobs: Option, + /// Maximum tokens to generate. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + /// Presence penalty. + #[serde(skip_serializing_if = "Option::is_none")] + pub presence_penalty: Option, + /// Response format. + #[serde(skip_serializing_if = "Option::is_none")] + pub response_format: Option, + /// Stop sequences. + #[serde(skip_serializing_if = "Option::is_none")] + pub stop: Option, + /// Whether to stream the response. + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + /// Stream options (e.g. include_usage). + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, + /// Whether to enable thinking (DeepSeek-specific). + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking: Option, + /// Temperature. + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + /// Tool choice control. + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + /// Tools the model may call. + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option, + /// Number of most likely tokens to return at each position. + #[serde(skip_serializing_if = "Option::is_none")] + pub top_logprobs: Option, + /// Top-p sampling. + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, +} + +impl Request { + /// Clone the request with the given messages. + pub fn messages(&self, messages: &[Message]) -> Self { + Self { + messages: messages.to_vec(), + ..self.clone() + } + } + + /// Enable streaming for the request. + pub fn stream(mut self, usage: bool) -> Self { + self.stream = Some(true); + self.stream_options = if usage { + Some(json!({ "include_usage": true })) + } else { + None + }; + self + } +} + +impl From for Request { + fn from(config: General) -> Self { + let mut req = Self { + messages: Vec::new(), + model: config.model.to_string(), + frequency_penalty: None, + logprobs: None, + max_tokens: None, + presence_penalty: None, + response_format: None, + stop: None, + stream: None, + stream_options: None, + thinking: if config.think { + Some(json!({ "type": "enabled" })) + } else { + None + }, + temperature: None, + tool_choice: None, + tools: None, + top_logprobs: None, + top_p: None, + }; + + if let Some(tools) = config.tools { + req = req.with_tools(tools); + } + if let Some(tool_choice) = config.tool_choice { + req = req.with_tool_choice(tool_choice); + } + + req + } +} + +impl Config for Request { + fn with_tools(self, tools: Vec) -> Self { + let tools = tools + .into_iter() + .map(|tool| { + json!({ + "type": "function", + "function": json!(tool), + }) + }) + .collect::>(); + Self { + tools: Some(json!(tools)), + ..self + } + } + + fn with_tool_choice(self, tool_choice: ToolChoice) -> Self { + Self { + tool_choice: match tool_choice { + ToolChoice::None => Some(json!("none")), + ToolChoice::Auto => Some(json!("auto")), + ToolChoice::Required => Some(json!("required")), + ToolChoice::Function(name) => Some(json!({ + "type": "function", + "function": { "name": name } + })), + }, + ..self + } + } +} diff --git a/crates/llm/tests/http_provider.rs b/crates/llm/tests/http_provider.rs new file mode 100644 index 0000000..0d840e6 --- /dev/null +++ b/crates/llm/tests/http_provider.rs @@ -0,0 +1,66 @@ +//! Tests for HttpProvider header construction. + +use walrus_llm::HttpProvider; + +#[test] +fn bearer_sets_authorization_header() { + let client = walrus_llm::Client::new(); + let provider = HttpProvider::bearer(client, "test-key", "http://example.com/v1/chat") + .expect("bearer provider"); + + let auth = provider + .headers() + .get("authorization") + .expect("authorization header"); + assert_eq!(auth.to_str().unwrap(), "Bearer test-key"); + assert_eq!(provider.endpoint(), "http://example.com/v1/chat"); +} + +#[test] +fn no_auth_omits_authorization_header() { + let client = walrus_llm::Client::new(); + let provider = HttpProvider::no_auth(client, "http://localhost:11434/v1/chat"); + + assert!(provider.headers().get("authorization").is_none()); + assert_eq!(provider.endpoint(), "http://localhost:11434/v1/chat"); +} + +#[test] +fn bearer_sets_content_type_and_accept() { + let client = walrus_llm::Client::new(); + let provider = + HttpProvider::bearer(client, "k", "http://example.com").expect("bearer provider"); + + let ct = provider + .headers() + .get("content-type") + .expect("content-type"); + assert_eq!(ct.to_str().unwrap(), "application/json"); + let accept = provider.headers().get("accept").expect("accept"); + assert_eq!(accept.to_str().unwrap(), "application/json"); +} + +#[test] +fn no_auth_sets_content_type_and_accept() { + let client = walrus_llm::Client::new(); + let provider = HttpProvider::no_auth(client, "http://localhost:8080"); + + let ct = provider + .headers() + .get("content-type") + .expect("content-type"); + assert_eq!(ct.to_str().unwrap(), "application/json"); + let accept = provider.headers().get("accept").expect("accept"); + assert_eq!(accept.to_str().unwrap(), "application/json"); +} + +#[test] +fn custom_header_sets_named_header() { + let client = walrus_llm::Client::new(); + let provider = HttpProvider::custom_header(client, "x-api-key", "sk-123", "http://example.com") + .expect("custom header provider"); + + let key = provider.headers().get("x-api-key").expect("x-api-key"); + assert_eq!(key.to_str().unwrap(), "sk-123"); + assert!(provider.headers().get("authorization").is_none()); +} diff --git a/crates/llm/tests/request.rs b/crates/llm/tests/request.rs new file mode 100644 index 0000000..062415c --- /dev/null +++ b/crates/llm/tests/request.rs @@ -0,0 +1,110 @@ +//! Tests for the shared OpenAI-compatible Request type. + +use walrus_llm::{Config, General, Request, Tool, ToolChoice}; + +#[test] +fn request_from_general_sets_model() { + let general = General { + model: "gpt-4".into(), + ..General::default() + }; + let req = Request::from(general); + assert_eq!(req.model, "gpt-4"); +} + +#[test] +fn request_from_general_with_tools() { + let tool = Tool { + name: "search".into(), + description: "find docs".into(), + parameters: schemars::schema_for!(String), + strict: false, + }; + let general = General { + model: "gpt-4".into(), + tools: Some(vec![tool]), + ..General::default() + }; + let req = Request::from(general); + let tools = req.tools.expect("tools"); + assert_eq!(tools[0]["type"], "function"); + assert_eq!(tools[0]["function"]["name"], "search"); +} + +#[test] +fn request_with_tool_choice_auto() { + let req = Request::from(General::default()).with_tool_choice(ToolChoice::Auto); + assert_eq!( + req.tool_choice.expect("tool_choice"), + serde_json::json!("auto") + ); +} + +#[test] +fn request_with_tool_choice_none() { + let req = Request::from(General::default()).with_tool_choice(ToolChoice::None); + assert_eq!( + req.tool_choice.expect("tool_choice"), + serde_json::json!("none") + ); +} + +#[test] +fn request_with_tool_choice_required() { + let req = Request::from(General::default()).with_tool_choice(ToolChoice::Required); + assert_eq!( + req.tool_choice.expect("tool_choice"), + serde_json::json!("required") + ); +} + +#[test] +fn request_with_tool_choice_function() { + let general = General { + model: "gpt-4".into(), + tool_choice: Some(ToolChoice::Function("search".into())), + ..General::default() + }; + let req = Request::from(general); + let choice = req.tool_choice.expect("tool_choice"); + assert_eq!(choice["type"], "function"); + assert_eq!(choice["function"]["name"], "search"); +} + +#[test] +fn request_stream_sets_include_usage() { + let req = Request::from(General::default()).stream(true); + assert_eq!(req.stream, Some(true)); + let opts = req.stream_options.expect("stream_options"); + assert_eq!(opts["include_usage"], true); +} + +#[test] +fn request_stream_without_usage_omits_stream_options() { + let req = Request::from(General::default()).stream(false); + assert_eq!(req.stream, Some(true)); + assert!(req.stream_options.is_none()); +} + +#[test] +fn request_from_general_thinking_enabled() { + let general = General { + model: "deepseek-reasoner".into(), + think: true, + ..General::default() + }; + let req = Request::from(general); + let thinking = req.thinking.expect("thinking"); + assert_eq!(thinking["type"], "enabled"); +} + +#[test] +fn request_from_general_thinking_disabled() { + let general = General { + model: "gpt-4".into(), + think: false, + ..General::default() + }; + let req = Request::from(general); + assert!(req.thinking.is_none()); +} diff --git a/crates/runtime/src/hook.rs b/crates/runtime/src/hook.rs index d048c81..4aafedd 100644 --- a/crates/runtime/src/hook.rs +++ b/crates/runtime/src/hook.rs @@ -4,8 +4,8 @@ //! which [`Memory`] backend and LLM provider to use, and what prompts //! to send for automatic compaction and memory flush. -use wcore::{InMemory, Memory}; use llm::{General, LLM, NoopProvider}; +use wcore::{InMemory, Memory}; /// Type-level runtime configuration. /// diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 662717c..c6504ba 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -15,13 +15,13 @@ //! let response = runtime.send_to("assistant", Message::user("hello")).await?; //! ``` -pub use wcore::{Agent, InMemory, Memory, NoEmbedder, Skill, SkillTier}; pub use hook::{DEFAULT_COMPACT_PROMPT, DEFAULT_FLUSH_PROMPT, Hook}; pub use llm::{Client, General, Message, Response, Role, StreamChunk, Tool}; pub use loader::{CronEntry, load_agents_dir, load_cron_dir, parse_agent_md, parse_cron_md}; pub use mcp::McpBridge; pub use skills::{SkillRegistry, parse_skill_md}; pub use team::{build_team, extract_input, worker_tool}; +pub use wcore::{Agent, InMemory, Memory, NoEmbedder, Skill, SkillTier}; use anyhow::Result; use compact_str::CompactString; diff --git a/crates/runtime/src/loader.rs b/crates/runtime/src/loader.rs index 43452ad..3434952 100644 --- a/crates/runtime/src/loader.rs +++ b/crates/runtime/src/loader.rs @@ -5,10 +5,10 @@ //! fields and the markdown body becomes the system prompt (agents) or //! message template (cron). -use wcore::Agent; use compact_str::CompactString; use serde::Deserialize; use std::path::Path; +use wcore::Agent; /// YAML frontmatter for agent markdown files. #[derive(Deserialize)] diff --git a/crates/runtime/src/skills.rs b/crates/runtime/src/skills.rs index 4a9d314..9726958 100644 --- a/crates/runtime/src/skills.rs +++ b/crates/runtime/src/skills.rs @@ -4,11 +4,11 @@ //! (agentskills.io format). The [`SkillRegistry`] loads them from a directory, //! builds tag/trigger indices, and returns ranked matches. -use wcore::{Skill, SkillTier}; use compact_str::CompactString; use serde::Deserialize; use std::collections::BTreeMap; use std::path::Path; +use wcore::{Skill, SkillTier}; /// An indexed skill with its tier and priority (extracted from metadata). #[derive(Debug, Clone)] diff --git a/crates/runtime/src/team.rs b/crates/runtime/src/team.rs index 9e4eab6..8c845de 100644 --- a/crates/runtime/src/team.rs +++ b/crates/runtime/src/team.rs @@ -19,10 +19,10 @@ //! ``` use crate::{Handler, Hook, MAX_TOOL_CALLS, SkillRegistry}; -use wcore::{Agent, Memory}; use compact_str::CompactString; use llm::{Config, General, LLM, Message, Tool, ToolChoice}; use std::{collections::BTreeMap, sync::Arc}; +use wcore::{Agent, Memory}; /// Build a team: register each worker as a tool and add to the leader. /// diff --git a/crates/runtime/tests/runtime.rs b/crates/runtime/tests/runtime.rs index 22b231f..d0509e2 100644 --- a/crates/runtime/tests/runtime.rs +++ b/crates/runtime/tests/runtime.rs @@ -1,10 +1,10 @@ //! Tests for the Runtime orchestrator. -use wcore::{Agent, InMemory, Memory, Skill, SkillTier}; use compact_str::CompactString; use llm::{FunctionCall, General, Message, NoopProvider, Tool, ToolCall}; use std::collections::BTreeMap; use walrus_runtime::{Hook, Runtime, SkillRegistry}; +use wcore::{Agent, InMemory, Memory, Skill, SkillTier}; fn echo_tool() -> Tool { Tool { diff --git a/crates/runtime/tests/skills.rs b/crates/runtime/tests/skills.rs index 68243b7..fbc6404 100644 --- a/crates/runtime/tests/skills.rs +++ b/crates/runtime/tests/skills.rs @@ -1,10 +1,10 @@ //! Tests for SkillRegistry. -use wcore::{Skill, SkillTier}; use compact_str::CompactString; use std::collections::BTreeMap; use walrus_runtime::parse_skill_md; use walrus_runtime::skills::SkillRegistry; +use wcore::{Skill, SkillTier}; #[test] fn parse_skill_frontmatter() { diff --git a/crates/runtime/tests/team.rs b/crates/runtime/tests/team.rs index 9d4223b..b4e7880 100644 --- a/crates/runtime/tests/team.rs +++ b/crates/runtime/tests/team.rs @@ -1,8 +1,8 @@ //! Tests for team composition. -use wcore::{Agent, InMemory}; use llm::{General, NoopProvider}; use walrus_runtime::{Runtime, build_team, extract_input, worker_tool}; +use wcore::{Agent, InMemory}; #[test] fn extract_input_parses_json() { diff --git a/crates/sqlite/src/lib.rs b/crates/sqlite/src/lib.rs index eb65f55..d3071da 100644 --- a/crates/sqlite/src/lib.rs +++ b/crates/sqlite/src/lib.rs @@ -7,12 +7,12 @@ pub use crate::utils::cosine_similarity; use crate::utils::{decode_embedding, mmr_rerank, now_unix}; -use wcore::{Embedder, MemoryEntry, RecallOptions}; use anyhow::Result; use compact_str::CompactString; use rusqlite::Connection; use serde_json::Value; use std::{collections::HashMap, path::Path, sync::Mutex}; +use wcore::{Embedder, MemoryEntry, RecallOptions}; mod memory; mod sql; diff --git a/crates/sqlite/src/memory.rs b/crates/sqlite/src/memory.rs index 701b861..9b0a9f8 100644 --- a/crates/sqlite/src/memory.rs +++ b/crates/sqlite/src/memory.rs @@ -3,9 +3,9 @@ use crate::SqliteMemory; use crate::sql; use crate::utils::now_unix; -use wcore::{Embedder, Memory, MemoryEntry, RecallOptions}; use anyhow::Result; use std::future::Future; +use wcore::{Embedder, Memory, MemoryEntry, RecallOptions}; impl Memory for SqliteMemory { fn get(&self, key: &str) -> Option { diff --git a/crates/sqlite/src/utils.rs b/crates/sqlite/src/utils.rs index c1048a0..c61d041 100644 --- a/crates/sqlite/src/utils.rs +++ b/crates/sqlite/src/utils.rs @@ -1,7 +1,7 @@ //! Utility functions for memory recall scoring and ranking. -use wcore::MemoryEntry; use std::collections::HashSet; +use wcore::MemoryEntry; /// Cosine similarity between two float vectors. /// diff --git a/crates/sqlite/tests/sqlite.rs b/crates/sqlite/tests/sqlite.rs index ae9ad4a..699101b 100644 --- a/crates/sqlite/tests/sqlite.rs +++ b/crates/sqlite/tests/sqlite.rs @@ -1,7 +1,7 @@ //! Tests for SqliteMemory. -use wcore::{Embedder, Memory, MemoryEntry, RecallOptions}; use walrus_sqlite::{SqliteMemory, cosine_similarity}; +use wcore::{Embedder, Memory, MemoryEntry, RecallOptions}; /// Noop embedder for tests that don't need vector search. struct NoopEmbedder; diff --git a/crates/telegram/src/lib.rs b/crates/telegram/src/lib.rs index 9b2107f..4d54f38 100644 --- a/crates/telegram/src/lib.rs +++ b/crates/telegram/src/lib.rs @@ -3,13 +3,13 @@ //! Connects agents to Telegram via the Bot API using reqwest directly (DD#2). //! Implements the [`Channel`] trait from walrus-core. -use wcore::{Attachment, AttachmentKind, Channel, ChannelMessage, Platform}; use anyhow::Result; use compact_str::CompactString; use futures_core::Stream; use reqwest::Client; use serde::Deserialize; use std::sync::atomic::{AtomicI64, Ordering}; +use wcore::{Attachment, AttachmentKind, Channel, ChannelMessage, Platform}; /// Telegram Bot API channel adapter. /// diff --git a/crates/telegram/tests/telegram.rs b/crates/telegram/tests/telegram.rs index 93a11dc..671f3ec 100644 --- a/crates/telegram/tests/telegram.rs +++ b/crates/telegram/tests/telegram.rs @@ -1,7 +1,7 @@ //! Tests for the Telegram channel adapter. -use wcore::{Channel, Platform}; use walrus_telegram::{TelegramChannel, channel_message_from_update, send_message_url}; +use wcore::{Channel, Platform}; #[test] fn telegram_channel_platform() { diff --git a/llm/claude/src/provider.rs b/llm/claude/src/provider.rs index 466f72e..b9bf430 100644 --- a/llm/claude/src/provider.rs +++ b/llm/claude/src/provider.rs @@ -7,12 +7,8 @@ use compact_str::CompactString; use futures_core::Stream; use futures_util::StreamExt; use llm::{ - Choice, Client, CompletionMeta, CompletionTokensDetails, Delta, FinishReason, LLM, Message, - Response, StreamChunk, Usage, - reqwest::{ - Method, - header::{self, HeaderMap}, - }, + Choice, CompletionMeta, CompletionTokensDetails, Delta, FinishReason, LLM, Message, Response, + StreamChunk, Usage, reqwest::Method, }; /// Raw Anthropic non-streaming response. @@ -47,18 +43,6 @@ struct AnthropicUsage { impl LLM for Claude { type ChatConfig = Request; - fn new(client: Client, key: &str) -> Result { - let mut headers = HeaderMap::new(); - headers.insert(header::CONTENT_TYPE, "application/json".parse()?); - headers.insert("x-api-key", key.parse()?); - headers.insert("anthropic-version", crate::API_VERSION.parse()?); - Ok(Self { - client, - headers, - endpoint: crate::ENDPOINT.to_owned(), - }) - } - async fn send(&self, req: &Request, messages: &[Message]) -> Result { let body = req.messages(messages); tracing::trace!("request: {}", serde_json::to_string(&body)?); diff --git a/llm/deepseek/src/provider.rs b/llm/deepseek/src/provider.rs index 339ce64..0b78491 100644 --- a/llm/deepseek/src/provider.rs +++ b/llm/deepseek/src/provider.rs @@ -15,18 +15,20 @@ use llm::{ const ENDPOINT: &str = "https://api.deepseek.com/chat/completions"; -impl LLM for DeepSeek { - /// The chat configuration. - type ChatConfig = Request; - - /// Create a new LLM provider - fn new(client: Client, key: &str) -> Result { +impl DeepSeek { + /// Create a new DeepSeek provider with bearer auth. + pub fn new(client: Client, key: &str) -> Result { let mut headers = HeaderMap::new(); headers.insert(header::CONTENT_TYPE, "application/json".parse()?); headers.insert(header::ACCEPT, "application/json".parse()?); headers.insert(header::AUTHORIZATION, format!("Bearer {}", key).parse()?); Ok(Self { client, headers }) } +} + +impl LLM for DeepSeek { + /// The chat configuration. + type ChatConfig = Request; /// Send a message to the LLM async fn send(&self, req: &Request, messages: &[Message]) -> Result { diff --git a/llm/mistral/Cargo.toml b/llm/mistral/Cargo.toml index c9fe8e4..9ce7bb0 100644 --- a/llm/mistral/Cargo.toml +++ b/llm/mistral/Cargo.toml @@ -22,4 +22,5 @@ serde_json.workspace = true tracing.workspace = true [dev-dependencies] +llm.workspace = true schemars.workspace = true diff --git a/llm/mistral/src/lib.rs b/llm/mistral/src/lib.rs index cfd95c4..1360695 100644 --- a/llm/mistral/src/lib.rs +++ b/llm/mistral/src/lib.rs @@ -44,24 +44,9 @@ impl Mistral { endpoint: endpoint.to_owned(), }) } -} - -#[cfg(test)] -mod tests { - use super::{Mistral, endpoint}; - - #[test] - fn custom_constructor_sets_endpoint() { - let client = llm::Client::new(); - let custom = "http://localhost:9999/v1/chat/completions"; - let provider = Mistral::custom(client, "test-key", custom).expect("provider"); - assert_eq!(provider.endpoint, custom); - } - #[test] - fn api_constructor_uses_default_endpoint() { - let client = llm::Client::new(); - let provider = Mistral::api(client, "test-key").expect("provider"); - assert_eq!(provider.endpoint, endpoint::MISTRAL); + /// Get the endpoint URL. + pub fn endpoint(&self) -> &str { + &self.endpoint } } diff --git a/llm/mistral/src/provider.rs b/llm/mistral/src/provider.rs index 03ef50a..666cbf4 100644 --- a/llm/mistral/src/provider.rs +++ b/llm/mistral/src/provider.rs @@ -5,29 +5,11 @@ use anyhow::Result; use async_stream::try_stream; use futures_core::Stream; use futures_util::StreamExt; -use llm::{ - Client, LLM, Message, Response, StreamChunk, - reqwest::{ - Method, - header::{self, HeaderMap}, - }, -}; +use llm::{LLM, Message, Response, StreamChunk, reqwest::Method}; impl LLM for Mistral { type ChatConfig = Request; - fn new(client: Client, key: &str) -> Result { - let mut headers = HeaderMap::new(); - headers.insert(header::CONTENT_TYPE, "application/json".parse()?); - headers.insert(header::ACCEPT, "application/json".parse()?); - headers.insert(header::AUTHORIZATION, format!("Bearer {key}").parse()?); - Ok(Self { - client, - headers, - endpoint: crate::endpoint::MISTRAL.to_owned(), - }) - } - async fn send(&self, req: &Request, messages: &[Message]) -> Result { let body = req.messages(messages); tracing::trace!("request: {}", serde_json::to_string(&body)?); diff --git a/llm/mistral/tests/constructors.rs b/llm/mistral/tests/constructors.rs new file mode 100644 index 0000000..02b1c39 --- /dev/null +++ b/llm/mistral/tests/constructors.rs @@ -0,0 +1,18 @@ +//! Tests for Mistral provider constructors. + +use walrus_mistral::{Mistral, endpoint}; + +#[test] +fn custom_constructor_sets_endpoint() { + let client = llm::Client::new(); + let custom = "http://localhost:9999/v1/chat/completions"; + let provider = Mistral::custom(client, "test-key", custom).expect("provider"); + assert_eq!(provider.endpoint(), custom); +} + +#[test] +fn api_constructor_uses_default_endpoint() { + let client = llm::Client::new(); + let provider = Mistral::api(client, "test-key").expect("provider"); + assert_eq!(provider.endpoint(), endpoint::MISTRAL); +} diff --git a/llm/openai/src/provider.rs b/llm/openai/src/provider.rs index 7bc7ad7..e4d2b2f 100644 --- a/llm/openai/src/provider.rs +++ b/llm/openai/src/provider.rs @@ -5,29 +5,11 @@ use anyhow::Result; use async_stream::try_stream; use futures_core::Stream; use futures_util::StreamExt; -use llm::{ - Client, LLM, Message, Response, StreamChunk, - reqwest::{ - Method, - header::{self, HeaderMap}, - }, -}; +use llm::{LLM, Message, Response, StreamChunk, reqwest::Method}; impl LLM for OpenAI { type ChatConfig = Request; - fn new(client: Client, key: &str) -> Result { - let mut headers = HeaderMap::new(); - headers.insert(header::CONTENT_TYPE, "application/json".parse()?); - headers.insert(header::ACCEPT, "application/json".parse()?); - headers.insert(header::AUTHORIZATION, format!("Bearer {key}").parse()?); - Ok(Self { - client, - headers, - endpoint: crate::endpoint::OPENAI.to_owned(), - }) - } - async fn send(&self, req: &Request, messages: &[Message]) -> Result { let body = req.messages(messages); tracing::trace!("request: {}", serde_json::to_string(&body)?);