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:
2026-06-11 20:53:38 +05:30
parent 03c103061a
commit 54d8ca3774
29 changed files with 7211 additions and 755 deletions

View File

@@ -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);
}
}