//! GyanDoot Providers — LLM provider abstraction layer. //! //! 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, ToolCall}; pub mod openai; pub mod anthropic; pub mod ollama; pub mod streaming; pub mod gemini; pub mod deepseek; pub mod openrouter; pub mod groq; // ─── Re-exports ─────────────────────────────────────────────────────────── pub use openai::OpenAIProvider; pub use anthropic::AnthropicProvider; pub use ollama::OllamaProvider; pub use gemini::GeminiProvider; pub use deepseek::DeepSeekProvider; pub use openrouter::OpenRouterProvider; pub use groq::GroqProvider; 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>, pub finish_reason: String, } impl ProviderResponse { /// Create a new provider response with text content only. pub fn new(content: impl Into) -> 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, calls: Vec, finish_reason: impl Into, ) -> 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 full response. async fn chat(&self, context: ConversationContext) -> ProviderResult; /// 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> + Send + Unpin>>; /// Return the list of models available for this provider. async fn models(&self) -> ProviderResult> { // 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, Groq, } impl ProviderKind { /// Parse from a string, case-insensitive. pub fn from_str(s: &str) -> Option { 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), "groq" => Some(ProviderKind::Groq), _ => 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", ProviderKind::Groq => "groq", } } } 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> { 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> { match kind { ProviderKind::OpenAI => { 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(), cfg.api_key.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(), })?; Ok(Box::new(gemini::GeminiProvider::new( cfg.api_key.clone(), cfg.model.clone(), cfg.base_url.clone(), ))) } ProviderKind::DeepSeek => { let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api { status: 0, message: "DeepSeek config not found (use openai section)".to_string(), })?; Ok(Box::new(deepseek::DeepSeekProvider::new( cfg.api_key.clone(), cfg.model.clone(), cfg.base_url.clone(), ))) } ProviderKind::OpenRouter => { let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api { status: 0, message: "OpenRouter config not found (use openai section)".to_string(), })?; Ok(Box::new(openrouter::OpenRouterProvider::new( cfg.api_key.clone(), cfg.model.clone(), cfg.base_url.clone(), ))) } ProviderKind::Groq => { let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api { status: 0, message: "Groq config not found (use openai section)".to_string(), })?; Ok(Box::new(groq::GroqProvider::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> { 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 | ProviderKind::Groq) { 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(), api_key: None, 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(), ProviderKind::Groq => "https://api.groq.com/openai/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, ProviderKind::Groq, ] { 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); } }