P0 deep-dive: core, providers, agent, memory, TUI, setup wizard
DEEP DIVE — Agent Loop (gyandoot-agent): - Real conversation loop with interactive (run()) and non-interactive (run_conversation()) modes - Retry logic with exponential backoff and error classification (RateLimit, Auth, Timeout, ServerError, ContextOverflow) - Context window management — token estimation, 75% threshold compression - 7 built-in tools: Read, Write, Edit, Bash, Grep, Find, Ls - BashTool with destructive-command security blocks - ContextCompressor — prune tool results, summarize middle messages DEEP DIVE — Memory (gyandoot-memory): - Real MemPalace with ~/.gyandoot/palace/wings/<wing>/ directory tree - Daily transcript files (YYYY-MM-DD_transcript.md) with append-only format - L0/L1 identity persistence (< 157 tokens for L0) - Session digest to drawer.md - File persistence support for transcripts and identity store DEEP DIVE — Core (gyandoot-core): - GyandootConfig with TOML deserialization for all sections - ConfigLoader with env > profile > file > default chain - Rich types: Message, ToolCall, ToolResult, SessionId - Comprehensive error types with thiserror derive DEEP DIVE — Providers (gyandoot-providers): - Provider trait with chat/chat_stream/models - OpenAI provider with streaming SSE and function calling - Anthropic provider with Messages API + tool use - Ollama provider with merge_system/no_tools auto-detect - SseParser, StreamEvent, TokenStream for SSE parsing - ProviderFactory for creation from config NEW — TUI crate (gyandoot-tui): - Banner: Kapil-approved robot head ASCII art with ANSI colors - Setup wizard: 7 providers, interactive config, connection test - Terminal helpers: spinner, confirm, text input, selection menu - Ratatui TUI: keyboard input, commands, status display All 12 crates + CLI binary compile with 0 errors.
This commit is contained in:
@@ -1,34 +1,327 @@
|
||||
//! 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.
|
||||
//! Defines the [`Provider`] trait and implementations for OpenAI, Anthropic,
|
||||
//! Ollama, and other providers. Each implementation handles authentication,
|
||||
//! request formatting, response parsing, and streaming.
|
||||
//!
|
||||
//! The [`ProviderFactory`] creates provider instances from
|
||||
//! [`gyandoot_core::ProviderConfig`].
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gyandoot_core::{ConversationContext, ProviderResult};
|
||||
use gyandoot_core::{ConversationContext, ProviderResult, ToolCall};
|
||||
|
||||
pub mod openai;
|
||||
pub mod anthropic;
|
||||
pub mod ollama;
|
||||
pub mod streaming;
|
||||
|
||||
// ─── Re-exports ───────────────────────────────────────────────────────────
|
||||
|
||||
pub use openai::OpenAIProvider;
|
||||
pub use anthropic::AnthropicProvider;
|
||||
pub use ollama::OllamaProvider;
|
||||
pub use streaming::{SseParser, StreamEvent, TokenStream, collect_stream};
|
||||
|
||||
// ─── ProviderResponse ─────────────────────────────────────────────────────
|
||||
|
||||
/// 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 tool_calls: Option<Vec<ToolCall>>,
|
||||
pub finish_reason: String,
|
||||
}
|
||||
|
||||
impl ProviderResponse {
|
||||
/// Create a new provider response with text content only.
|
||||
pub fn new(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
tool_calls: None,
|
||||
finish_reason: "stop".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a provider response with tool calls.
|
||||
pub fn with_tool_calls(
|
||||
content: impl Into<String>,
|
||||
calls: Vec<ToolCall>,
|
||||
finish_reason: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
tool_calls: Some(calls),
|
||||
finish_reason: finish_reason.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return true if this response contains tool calls.
|
||||
pub fn has_tool_calls(&self) -> bool {
|
||||
self.tool_calls.as_ref().map_or(false, |c| !c.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Provider trait ───────────────────────────────────────────────────────
|
||||
|
||||
/// The trait that all LLM providers must implement.
|
||||
#[async_trait]
|
||||
pub trait Provider: Send + Sync {
|
||||
/// Send a chat completion request and return the response.
|
||||
/// Send a chat completion request and return the full response.
|
||||
async fn chat(&self, context: ConversationContext) -> ProviderResult<ProviderResponse>;
|
||||
|
||||
/// Send a chat completion request with streaming response.
|
||||
///
|
||||
/// Each item in the stream represents a text delta. Use
|
||||
/// [`streaming::collect_stream`] to aggregate the full text.
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
context: ConversationContext,
|
||||
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>;
|
||||
|
||||
/// Return the list of models available for this provider.
|
||||
async fn models(&self) -> ProviderResult<Vec<String>> {
|
||||
// Default: return an empty list; providers can override.
|
||||
let _ = self;
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Return whether this provider supports tool/function calling.
|
||||
fn supports_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ProviderKind (runtime enum) ──────────────────────────────────────────
|
||||
|
||||
/// Runtime provider kind for factory dispatch.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProviderKind {
|
||||
OpenAI,
|
||||
Anthropic,
|
||||
Ollama,
|
||||
Gemini,
|
||||
DeepSeek,
|
||||
OpenRouter,
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
/// Parse from a string, case-insensitive.
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"openai" => Some(ProviderKind::OpenAI),
|
||||
"anthropic" => Some(ProviderKind::Anthropic),
|
||||
"ollama" => Some(ProviderKind::Ollama),
|
||||
"gemini" => Some(ProviderKind::Gemini),
|
||||
"deepseek" => Some(ProviderKind::DeepSeek),
|
||||
"openrouter" => Some(ProviderKind::OpenRouter),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the canonical string name.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ProviderKind::OpenAI => "openai",
|
||||
ProviderKind::Anthropic => "anthropic",
|
||||
ProviderKind::Ollama => "ollama",
|
||||
ProviderKind::Gemini => "gemini",
|
||||
ProviderKind::DeepSeek => "deepseek",
|
||||
ProviderKind::OpenRouter => "openrouter",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProviderKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ProviderFactory ───────────────────────────────────────────────────────
|
||||
|
||||
/// Factory for creating provider instances from configuration.
|
||||
pub struct ProviderFactory;
|
||||
|
||||
impl ProviderFactory {
|
||||
/// Create a provider from a [`gyandoot_core::ProviderRootConfig`].
|
||||
pub fn create(config: &gyandoot_core::ProviderRootConfig) -> ProviderResult<Box<dyn Provider>> {
|
||||
let kind = ProviderKind::from_str(&config.default)
|
||||
.ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||
status: 0,
|
||||
message: format!("Unknown provider name: {}", config.default),
|
||||
})?;
|
||||
|
||||
Self::create_from_kind(kind, config)
|
||||
}
|
||||
|
||||
/// Create a provider from a [`ProviderKind`] and config.
|
||||
pub fn create_from_kind(
|
||||
kind: ProviderKind,
|
||||
config: &gyandoot_core::ProviderRootConfig,
|
||||
) -> ProviderResult<Box<dyn Provider>> {
|
||||
match kind {
|
||||
ProviderKind::OpenAI | ProviderKind::DeepSeek | ProviderKind::OpenRouter => {
|
||||
let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||
status: 0,
|
||||
message: "OpenAI config not found".to_string(),
|
||||
})?;
|
||||
Ok(Box::new(openai::OpenAIProvider::new(
|
||||
cfg.api_key.clone(),
|
||||
cfg.model.clone(),
|
||||
cfg.base_url.clone(),
|
||||
)))
|
||||
}
|
||||
ProviderKind::Anthropic => {
|
||||
let cfg = config.anthropic.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||
status: 0,
|
||||
message: "Anthropic config not found".to_string(),
|
||||
})?;
|
||||
Ok(Box::new(anthropic::AnthropicProvider::new(
|
||||
cfg.api_key.clone(),
|
||||
cfg.model.clone(),
|
||||
)))
|
||||
}
|
||||
ProviderKind::Ollama => {
|
||||
let cfg = config.ollama.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||
status: 0,
|
||||
message: "Ollama config not found".to_string(),
|
||||
})?;
|
||||
Ok(Box::new(ollama::OllamaProvider::new(
|
||||
cfg.base_url.clone(),
|
||||
cfg.model.clone(),
|
||||
)))
|
||||
}
|
||||
ProviderKind::Gemini => {
|
||||
let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||
status: 0,
|
||||
message: "Gemini config not found (use openai section)".to_string(),
|
||||
})?;
|
||||
// Gemini uses the OpenAI-compatible endpoint
|
||||
Ok(Box::new(openai::OpenAIProvider::new(
|
||||
cfg.api_key.clone(),
|
||||
cfg.model.clone(),
|
||||
cfg.base_url.clone(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a provider from just a kind name string and minimal config.
|
||||
pub fn from_name(
|
||||
name: &str,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
) -> ProviderResult<Box<dyn Provider>> {
|
||||
let kind = ProviderKind::from_str(name)
|
||||
.ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||
status: 0,
|
||||
message: format!("Unknown provider name: {name}"),
|
||||
})?;
|
||||
|
||||
let config = gyandoot_core::ProviderRootConfig {
|
||||
default: name.to_string(),
|
||||
openai: if matches!(kind, ProviderKind::OpenAI | ProviderKind::DeepSeek | ProviderKind::OpenRouter | ProviderKind::Gemini) {
|
||||
Some(gyandoot_core::OpenAIConfig {
|
||||
api_key: api_key.to_string(),
|
||||
model: model.to_string(),
|
||||
base_url: Some(default_base_url(&kind)),
|
||||
max_retries: 3,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
anthropic: if matches!(kind, ProviderKind::Anthropic) {
|
||||
Some(gyandoot_core::AnthropicConfig {
|
||||
api_key: api_key.to_string(),
|
||||
model: model.to_string(),
|
||||
max_retries: 3,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
ollama: if matches!(kind, ProviderKind::Ollama) {
|
||||
Some(gyandoot_core::OllamaConfig {
|
||||
base_url: default_base_url(&kind),
|
||||
model: model.to_string(),
|
||||
max_retries: 2,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
|
||||
Self::create_from_kind(kind, &config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Default base URL for each provider kind.
|
||||
fn default_base_url(kind: &ProviderKind) -> String {
|
||||
match kind {
|
||||
ProviderKind::OpenAI => "https://api.openai.com/v1".to_string(),
|
||||
ProviderKind::Anthropic => "https://api.anthropic.com".to_string(),
|
||||
ProviderKind::Ollama => "http://localhost:11434".to_string(),
|
||||
ProviderKind::Gemini => "https://generativelanguage.googleapis.com/v1".to_string(),
|
||||
ProviderKind::DeepSeek => "https://api.deepseek.com/v1".to_string(),
|
||||
ProviderKind::OpenRouter => "https://openrouter.ai/api/v1".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_provider_kind_roundtrip() {
|
||||
for kind in &[
|
||||
ProviderKind::OpenAI,
|
||||
ProviderKind::Anthropic,
|
||||
ProviderKind::Ollama,
|
||||
ProviderKind::Gemini,
|
||||
ProviderKind::DeepSeek,
|
||||
ProviderKind::OpenRouter,
|
||||
] {
|
||||
let s = kind.to_string();
|
||||
let parsed = ProviderKind::from_str(&s).unwrap();
|
||||
assert_eq!(*kind, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_response_has_tool_calls() {
|
||||
let r = ProviderResponse::new("hello");
|
||||
assert!(!r.has_tool_calls());
|
||||
|
||||
let r = ProviderResponse::with_tool_calls(
|
||||
"",
|
||||
vec![ToolCall::new("1", "test", serde_json::Value::Null)],
|
||||
"tool_calls",
|
||||
);
|
||||
assert!(r.has_tool_calls());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_base_urls() {
|
||||
assert!(default_base_url(&ProviderKind::OpenAI).contains("openai.com"));
|
||||
assert!(default_base_url(&ProviderKind::Anthropic).contains("anthropic.com"));
|
||||
assert!(default_base_url(&ProviderKind::Ollama).contains("localhost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_factory_from_name() {
|
||||
let provider = ProviderFactory::from_name("openai", "sk-test", "gpt-4o").unwrap();
|
||||
assert!(provider.supports_tools());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_factory_invalid_name() {
|
||||
let result = ProviderFactory::from_name("nonexistent", "", "");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_kind_unknown_string() {
|
||||
assert_eq!(ProviderKind::from_str("unknown"), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user