Initial scaffold: GyanDoot v2.0 in Rust
Full Cargo workspace with 11 crates: - gyandoot-core: Shared types, config, error types - gyandoot-agent: Agent loop, tool execution, context compression - gyandoot-providers: LLM provider abstraction (OpenAI, Anthropic, Ollama) - gyandoot-memory: MemPalace persistent memory system - gyandoot-platform: Platform adapters (Telegram, Discord, Webhook, CLI) - gyandoot-security: Credential vault, sanitizer, threat detector - gyandoot-kanban: Task decomposition, swarm orchestration, diagnostics - gyandoot-subagents: Sub-agent spawning and orchestration - gyandoot-skills: Skills discovery, loading, preprocessing, bundles - gyandoot-profile: Multi-agent profile manager - gyandoot-gateway: Gateway sessions, routing, access control Plus: CLI entry point with clap, cross-platform installer script, KANBAN.md, .gnpl/AGENTS.md Workspace compiles clean: cargo check passes with 0 errors.
This commit is contained in:
19
crates/gyandoot-providers/Cargo.toml
Normal file
19
crates/gyandoot-providers/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "gyandoot-providers"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "GyanDoot v2 — LLM provider abstraction (OpenAI, Anthropic, Ollama, etc)"
|
||||
|
||||
[dependencies]
|
||||
gyandoot-core = { path = "../gyandoot-core" }
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
reqwest.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
async-trait.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
futures.workspace = true
|
||||
122
crates/gyandoot-providers/src/anthropic.rs
Normal file
122
crates/gyandoot-providers/src/anthropic.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! Anthropic API provider implementation.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gyandoot_core::{ConversationContext, MessageRole, ProviderResult};
|
||||
use reqwest::Client;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{Provider, ProviderResponse};
|
||||
|
||||
/// Provider for the Anthropic (Claude) API.
|
||||
pub struct AnthropicProvider {
|
||||
api_key: String,
|
||||
model: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl AnthropicProvider {
|
||||
/// Create a new Anthropic provider.
|
||||
pub fn new(api_key: String, model: String) -> Self {
|
||||
Self {
|
||||
api_key,
|
||||
model,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the messages array in Anthropic's format.
|
||||
fn build_messages(&self, context: &ConversationContext) -> Vec<serde_json::Value> {
|
||||
context
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| m.role != MessageRole::System)
|
||||
.map(|m| {
|
||||
let role = match m.role {
|
||||
MessageRole::User => "user",
|
||||
MessageRole::Assistant => "assistant",
|
||||
MessageRole::Tool => "user",
|
||||
MessageRole::System => "user", // filtered above
|
||||
};
|
||||
serde_json::json!({
|
||||
"role": role,
|
||||
"content": m.content,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract the system prompt from context (Anthropic uses a separate field).
|
||||
fn extract_system(&self, context: &ConversationContext) -> String {
|
||||
context
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| m.role == MessageRole::System)
|
||||
.map(|m| m.content.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for AnthropicProvider {
|
||||
async fn chat(&self, context: ConversationContext) -> ProviderResult<ProviderResponse> {
|
||||
debug!("Anthropic chat request (model: {})", self.model);
|
||||
|
||||
let system = self.extract_system(&context);
|
||||
let messages = self.build_messages(&context);
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"max_tokens": context.max_tokens,
|
||||
});
|
||||
|
||||
if !system.is_empty() {
|
||||
body["system"] = serde_json::Value::String(system);
|
||||
}
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post("https://api.anthropic.com/v1/messages")
|
||||
.header("x-api-key", &self.api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(gyandoot_core::ProviderError::Api(format!(
|
||||
"Anthropic API error ({}): {}",
|
||||
status, error_text
|
||||
)));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response.json().await?;
|
||||
let content = data["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let finish_reason = data["stop_reason"].as_str().unwrap_or("end_turn").to_string();
|
||||
|
||||
Ok(ProviderResponse {
|
||||
content,
|
||||
tool_calls: None,
|
||||
finish_reason,
|
||||
})
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
context: ConversationContext,
|
||||
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>
|
||||
{
|
||||
// Fall back to non-streaming for now
|
||||
let result = self.chat(context).await?;
|
||||
let stream = tokio_stream::StreamExt::chain(
|
||||
tokio_stream::once(Ok(result.content)),
|
||||
tokio_stream::empty(),
|
||||
);
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
}
|
||||
34
crates/gyandoot-providers/src/lib.rs
Normal file
34
crates/gyandoot-providers/src/lib.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
//! GyanDoot Providers — LLM provider abstraction layer.
|
||||
//!
|
||||
//! Defines the `Provider` trait and implementations for OpenAI, Anthropic,
|
||||
//! and Ollama. Each provider handles authentication, request formatting,
|
||||
//! response parsing, and streaming.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gyandoot_core::{ConversationContext, ProviderResult};
|
||||
|
||||
pub mod openai;
|
||||
pub mod anthropic;
|
||||
pub mod ollama;
|
||||
pub mod streaming;
|
||||
|
||||
/// A provider response containing the generated text and any tool calls.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderResponse {
|
||||
pub content: String,
|
||||
pub tool_calls: Option<Vec<gyandoot_core::ToolCall>>,
|
||||
pub finish_reason: String,
|
||||
}
|
||||
|
||||
/// The trait that all LLM providers must implement.
|
||||
#[async_trait]
|
||||
pub trait Provider: Send + Sync {
|
||||
/// Send a chat completion request and return the response.
|
||||
async fn chat(&self, context: ConversationContext) -> ProviderResult<ProviderResponse>;
|
||||
|
||||
/// Send a chat completion request with streaming response.
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
context: ConversationContext,
|
||||
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>;
|
||||
}
|
||||
117
crates/gyandoot-providers/src/ollama.rs
Normal file
117
crates/gyandoot-providers/src/ollama.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Ollama API provider implementation.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gyandoot_core::{ConversationContext, MessageRole, ProviderResult};
|
||||
use reqwest::Client;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{Provider, ProviderResponse};
|
||||
|
||||
/// Provider for local Ollama models.
|
||||
pub struct OllamaProvider {
|
||||
base_url: String,
|
||||
model: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl OllamaProvider {
|
||||
/// Create a new Ollama provider.
|
||||
pub fn new(base_url: String, model: String) -> Self {
|
||||
Self {
|
||||
base_url,
|
||||
model,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for OllamaProvider {
|
||||
async fn chat(&self, context: ConversationContext) -> ProviderResult<ProviderResponse> {
|
||||
debug!("Ollama chat request (model: {}, url: {})", self.model, self.base_url);
|
||||
|
||||
let messages: Vec<serde_json::Value> = context
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
serde_json::json!({
|
||||
"role": m.role.to_string(),
|
||||
"content": m.content,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let body = serde_json::json!({
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"stream": false,
|
||||
"options": {
|
||||
"num_predict": context.max_tokens,
|
||||
}
|
||||
});
|
||||
|
||||
let url = format!("{}/api/chat", self.base_url.trim_end_matches('/'));
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(gyandoot_core::ProviderError::Api(format!(
|
||||
"Ollama API error: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response.json().await?;
|
||||
let content = data["message"]["content"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let finish_reason = data["done_reason"].as_str().unwrap_or("stop").to_string();
|
||||
|
||||
Ok(ProviderResponse {
|
||||
content,
|
||||
tool_calls: None,
|
||||
finish_reason,
|
||||
})
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
context: ConversationContext,
|
||||
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>
|
||||
{
|
||||
// For now, fall back to non-streaming
|
||||
let result = self.chat(context).await?;
|
||||
let stream = tokio_stream::StreamExt::chain(
|
||||
tokio_stream::once(Ok(result.content)),
|
||||
tokio_stream::empty(),
|
||||
);
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gyandoot_core::Message;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ollama_connection_refused() {
|
||||
let provider = OllamaProvider::new(
|
||||
"http://127.0.0.1:1".to_string(),
|
||||
"llama3".to_string(),
|
||||
);
|
||||
let context = ConversationContext {
|
||||
messages: vec![Message::new(MessageRole::User, "hi")],
|
||||
system_prompt: String::new(),
|
||||
tools: vec![],
|
||||
max_tokens: 100,
|
||||
};
|
||||
let result = provider.chat(context).await;
|
||||
assert!(result.is_err()); // Connection refused expected
|
||||
}
|
||||
}
|
||||
187
crates/gyandoot-providers/src/openai.rs
Normal file
187
crates/gyandoot-providers/src/openai.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! OpenAI API provider implementation.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gyandoot_core::{ConversationContext, MessageRole, ProviderResult};
|
||||
use reqwest::Client;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{Provider, ProviderResponse};
|
||||
|
||||
/// Provider for the OpenAI chat completion API.
|
||||
pub struct OpenAIProvider {
|
||||
api_key: String,
|
||||
model: String,
|
||||
base_url: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl OpenAIProvider {
|
||||
/// Create a new OpenAI provider.
|
||||
pub fn new(api_key: String, model: String, base_url: Option<String>) -> Self {
|
||||
Self {
|
||||
api_key,
|
||||
model,
|
||||
base_url: base_url.unwrap_or_else(|| "https://api.openai.com/v1".to_string()),
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the request body for the chat completions endpoint.
|
||||
fn build_request(&self, context: &ConversationContext) -> serde_json::Value {
|
||||
let messages: Vec<serde_json::Value> = context
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let mut msg = serde_json::json!({
|
||||
"role": m.role.to_string(),
|
||||
"content": m.content,
|
||||
});
|
||||
if let Some(tool_calls) = &m.tool_calls {
|
||||
msg["tool_calls"] = serde_json::to_value(tool_calls).unwrap_or_default();
|
||||
}
|
||||
if let Some(result) = &m.tool_result {
|
||||
msg["tool_call_id"] = serde_json::Value::Null;
|
||||
msg["content"] = serde_json::Value::String(result.clone());
|
||||
}
|
||||
msg
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"max_tokens": context.max_tokens,
|
||||
});
|
||||
|
||||
// Add tools if present
|
||||
if !context.tools.is_empty() {
|
||||
body["tools"] = serde_json::to_value(&context.tools).unwrap_or_default();
|
||||
body["tool_choice"] = serde_json::json!("auto");
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for OpenAIProvider {
|
||||
async fn chat(&self, context: ConversationContext) -> ProviderResult<ProviderResponse> {
|
||||
debug!("OpenAI chat request (model: {})", self.model);
|
||||
|
||||
let request_body = self.build_request(&context);
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(&self.api_key)
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(gyandoot_core::ProviderError::Api(format!(
|
||||
"OpenAI API error ({}): {}",
|
||||
status, error_text
|
||||
)));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response.json().await?;
|
||||
|
||||
let choice = &data["choices"][0];
|
||||
let message = &choice["message"];
|
||||
let content = message["content"].as_str().unwrap_or_default().to_string();
|
||||
let finish_reason = choice["finish_reason"].as_str().unwrap_or("stop").to_string();
|
||||
|
||||
// Parse tool calls
|
||||
let tool_calls = message["tool_calls"]
|
||||
.as_array()
|
||||
.map(|calls| {
|
||||
calls
|
||||
.iter()
|
||||
.map(|tc| gyandoot_core::ToolCall {
|
||||
id: tc["id"].as_str().unwrap_or_default().to_string(),
|
||||
name: tc["function"]["name"].as_str().unwrap_or_default().to_string(),
|
||||
arguments: tc["function"]["arguments"]
|
||||
.as_str()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
Ok(ProviderResponse {
|
||||
content,
|
||||
tool_calls,
|
||||
finish_reason,
|
||||
})
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
context: ConversationContext,
|
||||
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>
|
||||
{
|
||||
debug!("OpenAI streaming chat request (model: {})", self.model);
|
||||
|
||||
let request_body = self.build_request(&context);
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(&self.api_key)
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(gyandoot_core::ProviderError::Api(format!(
|
||||
"OpenAI stream error: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
// We can't easily return a boxed stream from reqwest's streaming API
|
||||
// without concrete types. For now we'll delegate to the non-streaming path
|
||||
// wrapped in a simple stream. Real implementation would use SSE parsing.
|
||||
let result = self.chat(context).await?;
|
||||
let stream = tokio_stream::StreamExt::chain(
|
||||
tokio_stream::once(Ok(result.content)),
|
||||
tokio_stream::empty(),
|
||||
);
|
||||
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gyandoot_core::{Message, ToolDefinition};
|
||||
|
||||
#[test]
|
||||
fn test_build_request() {
|
||||
let provider = OpenAIProvider::new(
|
||||
"sk-test".to_string(),
|
||||
"gpt-4o".to_string(),
|
||||
None,
|
||||
);
|
||||
|
||||
let context = ConversationContext {
|
||||
messages: vec![
|
||||
Message::new(MessageRole::System, "You are an AI."),
|
||||
Message::new(MessageRole::User, "Hello"),
|
||||
],
|
||||
system_prompt: "You are an AI.".to_string(),
|
||||
tools: vec![],
|
||||
max_tokens: 4096,
|
||||
};
|
||||
|
||||
let body = provider.build_request(&context);
|
||||
assert_eq!(body["model"], "gpt-4o");
|
||||
assert_eq!(body["messages"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
58
crates/gyandoot-providers/src/streaming.rs
Normal file
58
crates/gyandoot-providers/src/streaming.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! Streaming utilities for provider responses.
|
||||
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
/// Collect a streaming LLM response into a single string.
|
||||
pub async fn collect_stream(
|
||||
mut stream: impl tokio_stream::Stream<Item = super::ProviderResult<String>> + Unpin,
|
||||
) -> super::ProviderResult<String> {
|
||||
let mut full = String::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(text) => full.push_str(&text),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(full)
|
||||
}
|
||||
|
||||
/// A simple stream wrapper that yields tokens one by one.
|
||||
pub struct TokenStream {
|
||||
tokens: Vec<String>,
|
||||
index: usize,
|
||||
}
|
||||
|
||||
impl TokenStream {
|
||||
pub fn new(tokens: Vec<String>) -> Self {
|
||||
Self { tokens, index: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio_stream::Stream for TokenStream {
|
||||
type Item = super::ProviderResult<String>;
|
||||
|
||||
fn poll_next(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
if self.index < self.tokens.len() {
|
||||
let token = self.tokens[self.index].clone();
|
||||
self.index += 1;
|
||||
std::task::Poll::Ready(Some(Ok(token)))
|
||||
} else {
|
||||
std::task::Poll::Ready(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_collect_stream() {
|
||||
let stream = TokenStream::new(vec!["hello".to_string(), " ".to_string(), "world".to_string()]);
|
||||
let result = collect_stream(stream).await.unwrap();
|
||||
assert_eq!(result, "hello world");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user