From fb01b2595a1683d9a9e8173dc7793d5c02143ad5 Mon Sep 17 00:00:00 2001 From: Tara Menon Date: Thu, 11 Jun 2026 21:40:34 +0530 Subject: [PATCH] Fix: banner on launch, config file loading, 7 providers match setup wizard BUGFIXES: - Banner now displays on 'gyandoot run' via gyandoot_tui::banner::print_banner() - Config now loads from ~/.config/gyandoot/config.toml with fallback chain (XDG -> ~/.gyandoot/config.yaml -> hardcoded defaults), fixing 401 errors - gyandoot init now writes visible placeholder api_key for users to edit NEW PROVIDERS (4 added, now 7 total matching setup wizard): - Google Gemini: Native API with x-goog-api-key auth, streamingGenerateContent - DeepSeek: OpenAI-compatible wrapper at api.deepseek.com/v1 - OpenRouter: With HTTP-Referer/X-Title headers, model routing - Groq: OpenAI-compatible wrapper at api.groq.com/openai/v1 All compile with 0 errors. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/gyandoot-providers/src/deepseek.rs | 104 ++++ crates/gyandoot-providers/src/gemini.rs | 500 ++++++++++++++++++++ crates/gyandoot-providers/src/groq.rs | 107 +++++ crates/gyandoot-providers/src/lib.rs | 53 ++- crates/gyandoot-providers/src/openai.rs | 4 +- crates/gyandoot-providers/src/openrouter.rs | 387 +++++++++++++++ src/main.rs | 58 ++- 9 files changed, 1202 insertions(+), 13 deletions(-) create mode 100644 crates/gyandoot-providers/src/deepseek.rs create mode 100644 crates/gyandoot-providers/src/gemini.rs create mode 100644 crates/gyandoot-providers/src/groq.rs create mode 100644 crates/gyandoot-providers/src/openrouter.rs diff --git a/Cargo.lock b/Cargo.lock index 49574f6..5aee994 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -661,6 +661,7 @@ dependencies = [ "gyandoot-tui", "serde", "serde_json", + "serde_yaml", "tokio", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 03208a1..480b4bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,4 +80,5 @@ clap.workspace = true anyhow.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +serde_yaml.workspace = true dirs.workspace = true \ No newline at end of file diff --git a/crates/gyandoot-providers/src/deepseek.rs b/crates/gyandoot-providers/src/deepseek.rs new file mode 100644 index 0000000..d105a54 --- /dev/null +++ b/crates/gyandoot-providers/src/deepseek.rs @@ -0,0 +1,104 @@ +//! DeepSeek API provider implementation. +//! +//! Fully OpenAI-compatible chat completions endpoint (`/v1/chat/completions`) +//! with Bearer token auth. Supports streaming and function calling identically +//! to OpenAI. + +use async_trait::async_trait; +use gyandoot_core::{ConversationContext, ProviderResult}; +use std::time::Duration; +use tracing::debug; + +use crate::openai::OpenAIProvider; +use crate::{Provider, ProviderResponse}; + +const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1"; + +/// Provider for the DeepSeek API. +/// +/// DeepSeek uses an OpenAI-compatible chat completions endpoint. +/// This implementation wraps [`OpenAIProvider`] with the correct base URL. +pub struct DeepSeekProvider { + inner: OpenAIProvider, +} + +impl DeepSeekProvider { + /// Create a new DeepSeek provider. + /// + /// - `api_key`: DeepSeek API key (sent as Bearer token). + /// - `model`: Model identifier (e.g. `"deepseek-chat"`). + /// - `base_url`: Optional override for the API base URL. + pub fn new(api_key: String, model: String, base_url: Option) -> Self { + let base = base_url.unwrap_or_else(|| DEEPSEEK_BASE_URL.to_string()); + Self { + inner: OpenAIProvider::new(api_key, model, Some(base)), + } + } + + /// Set a custom request timeout. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.inner = self.inner.with_timeout(timeout); + self + } +} + +#[async_trait] +impl Provider for DeepSeekProvider { + async fn chat(&self, context: ConversationContext) -> ProviderResult { + debug!( + "DeepSeek chat request (model: {}, messages: {})", + self.inner.model, + context.messages.len() + ); + self.inner.chat(context).await + } + + async fn chat_stream( + &self, + context: ConversationContext, + ) -> ProviderResult> + Send + Unpin>> + { + debug!( + "DeepSeek streaming chat (model: {}, messages: {})", + self.inner.model, + context.messages.len() + ); + self.inner.chat_stream(context).await + } + + async fn models(&self) -> ProviderResult> { + Ok(vec![ + "deepseek-chat".to_string(), + "deepseek-reasoner".to_string(), + ]) + } + + fn supports_tools(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_provider() -> DeepSeekProvider { + DeepSeekProvider::new( + "sk-test".to_string(), + "deepseek-chat".to_string(), + None, + ) + } + + #[test] + fn test_creation() { + let provider = test_provider(); + assert_eq!(provider.inner.model, "deepseek-chat"); + } + + #[test] + fn test_supports_tools() { + let provider = test_provider(); + assert!(provider.supports_tools()); + } +} \ No newline at end of file diff --git a/crates/gyandoot-providers/src/gemini.rs b/crates/gyandoot-providers/src/gemini.rs new file mode 100644 index 0000000..2754093 --- /dev/null +++ b/crates/gyandoot-providers/src/gemini.rs @@ -0,0 +1,500 @@ +//! Google Gemini API provider implementation. +//! +//! Uses the native Gemini API (`/v1beta/models/{model}:streamGenerateContent`) +//! with `x-goog-api-key` header authentication. Supports text generation, +//! streaming via SSE, and function calling via `functionDeclarations`. + +use async_trait::async_trait; +use futures::StreamExt; +use gyandoot_core::{ConversationContext, ProviderError, ProviderResult, ToolCall}; +use reqwest::Client; +use std::time::Duration; +use tokio::sync::mpsc; +use tracing::{debug, trace, warn}; + +use crate::streaming::StreamEvent; +use crate::{Provider, ProviderResponse}; + +const GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta"; + +/// Provider for the Google Gemini API. +pub struct GeminiProvider { + api_key: String, + model: String, + client: Client, + request_timeout: Duration, +} + +impl GeminiProvider { + /// Create a new Gemini provider. + /// + /// - `api_key`: Gemini API key (sent via `x-goog-api-key` header). + /// - `model`: Model identifier (e.g. `"gemini-2.0-flash"`). + /// - `base_url`: Optional override for the API base URL. + pub fn new(api_key: String, model: String, _base_url: Option) -> Self { + Self { + api_key, + model, + client: Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .expect("Failed to create HTTP client"), + request_timeout: Duration::from_secs(60), + } + } + + /// Set a custom request timeout. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout; + self + } + + /// Build the Gemini API request body from conversation context. + fn build_request(&self, context: &ConversationContext) -> serde_json::Value { + // Extract system instruction from system messages + let system_instruction = context + .messages + .iter() + .find(|m| m.is_system()) + .map(|m| serde_json::json!({ "parts": [{"text": m.content}] })); + + // Build contents array — Gemini skips system messages here + let contents: Vec = context + .messages + .iter() + .filter(|m| !m.is_system()) + .map(|m| { + let role_str = m.role.to_string(); + let role = match role_str.as_str() { + "assistant" => "model", + "tool" => "user", // tool results sent as user with functionResponse + r => r, + }; + + let mut parts = Vec::new(); + + // Text content + if !m.content.is_empty() { + parts.push(serde_json::json!({ "text": m.content })); + } + + // Tool calls from assistant → functionCall parts + if let Some(calls) = &m.tool_calls { + for tc in calls { + parts.push(serde_json::json!({ + "functionCall": { + "name": tc.name, + "args": tc.arguments, + } + })); + } + } + + // Tool result → functionResponse part + if m.is_tool() { + if let Some(result) = &m.tool_result { + parts.push(serde_json::json!({ + "functionResponse": { + "name": "tool_result", + "response": { + "name": "tool_result", + "content": result, + } + } + })); + } + } + + serde_json::json!({ + "role": role, + "parts": parts, + }) + }) + .collect(); + + let mut body = serde_json::json!({ + "contents": contents, + "generationConfig": { + "maxOutputTokens": context.max_tokens, + }, + }); + + // Attach system instruction if present + if let Some(sys) = system_instruction { + body["systemInstruction"] = sys; + } + + // Attach tool definitions — Gemini uses functionDeclarations + if !context.tools.is_empty() { + let declarations: Vec = context + .tools + .iter() + .map(|td| { + serde_json::json!({ + "name": td.name, + "description": td.description, + "parameters": td.parameters, + }) + }) + .collect(); + + body["tools"] = serde_json::json!([ + { "functionDeclarations": declarations } + ]); + } + + body + } + + /// Parse a non-streaming Gemini response into a ProviderResponse. + fn parse_response(&self, data: &serde_json::Value) -> ProviderResult { + let candidate = &data["candidates"][0]; + let content = &candidate["content"]; + let parts = content["parts"].as_array().cloned().unwrap_or_default(); + + let mut text_parts = Vec::new(); + let mut tool_calls = Vec::new(); + + for part in &parts { + if let Some(text) = part["text"].as_str() { + text_parts.push(text.to_string()); + } + if let Some(fc) = part.get("functionCall") { + let name = fc["name"].as_str().unwrap_or_default().to_string(); + let args = fc.get("args").cloned().unwrap_or(serde_json::Value::Null); + tool_calls.push(ToolCall::new(format!("fc_{}", name), name, args)); + } + } + + let content_text = text_parts.join(""); + let finish_reason = candidate["finishReason"] + .as_str() + .unwrap_or("STOP") + .to_string(); + + Ok(ProviderResponse { + content: content_text, + tool_calls: if tool_calls.is_empty() { + None + } else { + Some(tool_calls) + }, + finish_reason, + }) + } + + /// Build streaming URL for the Gemini endpoint. + fn stream_url(&self) -> String { + format!( + "{}/models/{}:streamGenerateContent?alt=sse", + GEMINI_BASE_URL, + self.model + ) + } + + /// Build non-streaming URL. + fn chat_url(&self) -> String { + format!( + "{}/models/{}:generateContent", + GEMINI_BASE_URL, + self.model + ) + } + + /// Send the HTTP request and handle errors. + async fn send_request( + &self, + url: &str, + body: &serde_json::Value, + _stream: bool, + ) -> ProviderResult { + let req = self + .client + .post(url) + .header("x-goog-api-key", &self.api_key) + .header("content-type", "application/json") + .json(body) + .timeout(self.request_timeout); + + // The streaming endpoint uses GET-style SSE; for POST we keep as-is + let response = req.send().await?; + + let status = response.status(); + if !status.is_success() { + let error_text = response.text().await.unwrap_or_default(); + return Err(ProviderError::from_response(status.as_u16(), &error_text)); + } + + Ok(response) + } +} + +#[async_trait] +impl Provider for GeminiProvider { + async fn chat(&self, context: ConversationContext) -> ProviderResult { + debug!( + "Gemini chat request (model: {}, messages: {})", + self.model, + context.messages.len() + ); + + let body = self.build_request(&context); + let url = self.chat_url(); + let response = self.send_request(&url, &body, false).await?; + let data: serde_json::Value = response.json().await?; + + Ok(self.parse_response(&data)?) + } + + async fn chat_stream( + &self, + context: ConversationContext, + ) -> ProviderResult> + Send + Unpin>> + { + debug!( + "Gemini streaming chat (model: {}, messages: {})", + self.model, + context.messages.len() + ); + + let body = self.build_request(&context); + let url = self.stream_url(); + + let response = self + .client + .post(&url) + .header("x-goog-api-key", &self.api_key) + .header("content-type", "application/json") + .json(&body) + .timeout(self.request_timeout) + .send() + .await?; + + let status = response.status(); + if !status.is_success() { + let error_text = response.text().await.unwrap_or_default(); + return Err(ProviderError::from_response(status.as_u16(), &error_text)); + } + + let (tx, rx) = mpsc::channel::>(256); + let mut byte_stream = response.bytes_stream(); + + tokio::spawn(async move { + let mut parser = crate::streaming::SseParser::new(100); + let mut buffer = Vec::new(); + + loop { + match tokio::time::timeout(Duration::from_secs(30), byte_stream.next()).await { + Ok(Some(Ok(chunk))) => { + buffer.extend_from_slice(&chunk); + let buffer_str = String::from_utf8_lossy(&buffer); + + for event in parser.feed_chunk(buffer_str.as_ref()) { + match event { + StreamEvent::Chunk(text) => { + // Each Chunk is the raw `data: {json}` data portion. + // Parse Gemini-specific text deltas from the JSON. + if let Ok(val) = + serde_json::from_str::(&text) + { + if let Some(candidates) = val["candidates"].as_array() { + if let Some(candidate) = candidates.first() { + if let Some(parts) = + candidate["content"]["parts"].as_array() + { + for part in parts { + if let Some(t) = part["text"].as_str() { + trace!("Gemini delta: {t}"); + if tx.send(Ok(t.to_string())) + .await + .is_err() + { + return; + } + } + } + } + } + } + } + } + StreamEvent::Done => { + debug!("Gemini stream done"); + return; + } + StreamEvent::Error(msg) => { + warn!("Gemini SSE error: {msg}"); + let _ = tx.send(Err(ProviderError::Stream(msg))).await; + return; + } + StreamEvent::ToolCall(_) => { + // Tool calls appear in the final non-streaming response, + // not as streaming deltas. + } + } + } + buffer.drain(0..parser.consumed()); + } + Ok(Some(Err(e))) => { + let _ = tx.send(Err(ProviderError::Request(e))).await; + return; + } + Ok(None) => return, + Err(_) => { + let _ = tx + .send(Err(ProviderError::Timeout { duration: 30 })) + .await; + return; + } + } + } + }); + + let stream = tokio_stream::wrappers::ReceiverStream::new(rx); + Ok(Box::new(stream)) + } + + async fn models(&self) -> ProviderResult> { + Ok(vec![ + "gemini-2.0-flash".to_string(), + "gemini-2.0-flash-lite".to_string(), + "gemini-2.5-pro-exp-03-25".to_string(), + "gemini-1.5-pro".to_string(), + "gemini-1.5-flash".to_string(), + ]) + } + + fn supports_tools(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gyandoot_core::{Message, Role, ToolDefinition}; + + fn test_provider() -> GeminiProvider { + GeminiProvider::new( + "test-api-key".to_string(), + "gemini-2.0-flash".to_string(), + None, + ) + } + + #[test] + fn test_build_request_basic() { + let provider = test_provider(); + let context = ConversationContext { + messages: vec![ + Message::new(Role::System, "You are a helpful AI."), + Message::new(Role::User, "Hello"), + ], + system_prompt: "You are a helpful AI.".to_string(), + tools: vec![], + max_tokens: 4096, + }; + + let body = provider.build_request(&context); + assert!(body.get("contents").is_some()); + assert!(body.get("systemInstruction").is_some()); + assert_eq!( + body["systemInstruction"]["parts"][0]["text"], + "You are a helpful AI." + ); + let contents = body["contents"].as_array().unwrap(); + assert_eq!(contents.len(), 1); // system filtered out + assert_eq!(contents[0]["role"], "user"); + } + + #[test] + fn test_build_request_with_tools() { + let provider = test_provider(); + let context = ConversationContext { + messages: vec![Message::new(Role::User, "What's the weather?")], + system_prompt: String::new(), + tools: vec![ToolDefinition::new( + "get_weather", + "Get weather for a city", + serde_json::json!({ + "type": "object", + "properties": { + "city": {"type": "string"} + }, + "required": ["city"] + }), + )], + max_tokens: 4096, + }; + + let body = provider.build_request(&context); + let tools = body["tools"].as_array().unwrap(); + assert_eq!(tools.len(), 1); + let decls = tools[0]["functionDeclarations"].as_array().unwrap(); + assert_eq!(decls.len(), 1); + assert_eq!(decls[0]["name"], "get_weather"); + } + + #[test] + fn test_parse_response_text() { + let provider = test_provider(); + let data = serde_json::json!({ + "candidates": [{ + "content": { + "parts": [{"text": "Hello! How can I help?"}], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + }], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 5 + } + }); + + let response = provider.parse_response(&data).unwrap(); + assert_eq!(response.content, "Hello! How can I help?"); + assert_eq!(response.finish_reason, "STOP"); + assert!(response.tool_calls.is_none()); + } + + #[test] + fn test_parse_response_with_tool_calls() { + let provider = test_provider(); + let data = serde_json::json!({ + "candidates": [{ + "content": { + "parts": [ + {"text": "Let me check the weather."}, + { + "functionCall": { + "name": "get_weather", + "args": {"city": "NYC"} + } + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + }] + }); + + let response = provider.parse_response(&data).unwrap(); + assert_eq!(response.content, "Let me check the weather."); + assert!(response.has_tool_calls()); + let calls = response.tool_calls.unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "get_weather"); + } + + #[test] + fn test_stream_url() { + let provider = test_provider(); + let url = provider.stream_url(); + assert!(url.contains("gemini-2.0-flash")); + assert!(url.contains("streamGenerateContent")); + assert!(url.contains("alt=sse")); + } +} \ No newline at end of file diff --git a/crates/gyandoot-providers/src/groq.rs b/crates/gyandoot-providers/src/groq.rs new file mode 100644 index 0000000..dd438fc --- /dev/null +++ b/crates/gyandoot-providers/src/groq.rs @@ -0,0 +1,107 @@ +//! Groq API provider implementation. +//! +//! OpenAI-compatible chat completions endpoint (`/v1/chat/completions`) with +//! Bearer token auth. Supports streaming and function calling identically +//! to OpenAI. Base URL: `https://api.groq.com/openai/v1`. + +use async_trait::async_trait; +use gyandoot_core::{ConversationContext, ProviderResult}; +use std::time::Duration; +use tracing::debug; + +use crate::openai::OpenAIProvider; +use crate::{Provider, ProviderResponse}; + +const GROQ_BASE_URL: &str = "https://api.groq.com/openai/v1"; + +/// Provider for the Groq API. +/// +/// Groq uses an OpenAI-compatible chat completions endpoint. +/// This implementation wraps [`OpenAIProvider`] with the correct base URL. +pub struct GroqProvider { + inner: OpenAIProvider, +} + +impl GroqProvider { + /// Create a new Groq provider. + /// + /// - `api_key`: Groq API key (sent as Bearer token). + /// - `model`: Model identifier (e.g. `"llama-3.3-70b-versatile"`). + /// - `base_url`: Optional override for the API base URL. + pub fn new(api_key: String, model: String, base_url: Option) -> Self { + let base = base_url.unwrap_or_else(|| GROQ_BASE_URL.to_string()); + Self { + inner: OpenAIProvider::new(api_key, model, Some(base)), + } + } + + /// Set a custom request timeout. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.inner = self.inner.with_timeout(timeout); + self + } +} + +#[async_trait] +impl Provider for GroqProvider { + async fn chat(&self, context: ConversationContext) -> ProviderResult { + debug!( + "Groq chat request (model: {}, messages: {})", + self.inner.model, + context.messages.len() + ); + self.inner.chat(context).await + } + + async fn chat_stream( + &self, + context: ConversationContext, + ) -> ProviderResult> + Send + Unpin>> + { + debug!( + "Groq streaming chat (model: {}, messages: {})", + self.inner.model, + context.messages.len() + ); + self.inner.chat_stream(context).await + } + + async fn models(&self) -> ProviderResult> { + Ok(vec![ + "llama-3.3-70b-versatile".to_string(), + "llama-3.1-8b-instant".to_string(), + "mixtral-8x7b-32768".to_string(), + "gemma2-9b-it".to_string(), + "deepseek-r1-distill-llama-70b".to_string(), + ]) + } + + fn supports_tools(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_provider() -> GroqProvider { + GroqProvider::new( + "gsk-test".to_string(), + "llama-3.3-70b-versatile".to_string(), + None, + ) + } + + #[test] + fn test_creation() { + let provider = test_provider(); + assert_eq!(provider.inner.model, "llama-3.3-70b-versatile"); + } + + #[test] + fn test_supports_tools() { + let provider = test_provider(); + assert!(provider.supports_tools()); + } +} \ No newline at end of file diff --git a/crates/gyandoot-providers/src/lib.rs b/crates/gyandoot-providers/src/lib.rs index dc2a048..fffc4b8 100644 --- a/crates/gyandoot-providers/src/lib.rs +++ b/crates/gyandoot-providers/src/lib.rs @@ -14,12 +14,20 @@ 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 ───────────────────────────────────────────────────── @@ -102,6 +110,7 @@ pub enum ProviderKind { Gemini, DeepSeek, OpenRouter, + Groq, } impl ProviderKind { @@ -114,6 +123,7 @@ impl ProviderKind { "gemini" => Some(ProviderKind::Gemini), "deepseek" => Some(ProviderKind::DeepSeek), "openrouter" => Some(ProviderKind::OpenRouter), + "groq" => Some(ProviderKind::Groq), _ => None, } } @@ -127,6 +137,7 @@ impl ProviderKind { ProviderKind::Gemini => "gemini", ProviderKind::DeepSeek => "deepseek", ProviderKind::OpenRouter => "openrouter", + ProviderKind::Groq => "groq", } } } @@ -160,7 +171,7 @@ impl ProviderFactory { config: &gyandoot_core::ProviderRootConfig, ) -> ProviderResult> { match kind { - ProviderKind::OpenAI | ProviderKind::DeepSeek | ProviderKind::OpenRouter => { + ProviderKind::OpenAI => { let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api { status: 0, message: "OpenAI config not found".to_string(), @@ -196,8 +207,40 @@ impl ProviderFactory { status: 0, message: "Gemini config not found (use openai section)".to_string(), })?; - // Gemini uses the OpenAI-compatible endpoint - Ok(Box::new(openai::OpenAIProvider::new( + 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(), @@ -220,7 +263,7 @@ impl ProviderFactory { let config = gyandoot_core::ProviderRootConfig { default: name.to_string(), - openai: if matches!(kind, ProviderKind::OpenAI | ProviderKind::DeepSeek | ProviderKind::OpenRouter | ProviderKind::Gemini) { + 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(), @@ -263,6 +306,7 @@ fn default_base_url(kind: &ProviderKind) -> 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(), } } @@ -281,6 +325,7 @@ mod tests { ProviderKind::Gemini, ProviderKind::DeepSeek, ProviderKind::OpenRouter, + ProviderKind::Groq, ] { let s = kind.to_string(); let parsed = ProviderKind::from_str(&s).unwrap(); diff --git a/crates/gyandoot-providers/src/openai.rs b/crates/gyandoot-providers/src/openai.rs index 7bb526a..ad86ba4 100644 --- a/crates/gyandoot-providers/src/openai.rs +++ b/crates/gyandoot-providers/src/openai.rs @@ -18,8 +18,8 @@ use crate::{Provider, ProviderResponse}; /// Provider for OpenAI-compatible chat completion APIs. pub struct OpenAIProvider { - api_key: String, - model: String, + pub(crate) api_key: String, + pub(crate) model: String, base_url: String, client: Client, request_timeout: Duration, diff --git a/crates/gyandoot-providers/src/openrouter.rs b/crates/gyandoot-providers/src/openrouter.rs new file mode 100644 index 0000000..e1bfa49 --- /dev/null +++ b/crates/gyandoot-providers/src/openrouter.rs @@ -0,0 +1,387 @@ +//! OpenRouter API provider implementation. +//! +//! OpenAI-compatible chat completions endpoint (`/v1/chat/completions`) with +//! Bearer token auth. Adds `HTTP-Referer` and `X-Title` headers for tracking. +//! Supports all OpenAI-compatible features plus model routing. + +use async_trait::async_trait; +use futures::StreamExt; +use gyandoot_core::{ConversationContext, ProviderResult}; +use reqwest::Client; +use std::time::Duration; +use tracing::debug; + +use crate::{Provider, ProviderResponse}; + +const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; + +/// Provider for the OpenRouter API. +/// +/// OpenRouter provides a unified API over many LLM providers. It uses an +/// OpenAI-compatible `/v1/chat/completions` endpoint with additional headers +/// for identification (`HTTP-Referer`, `X-Title`). +pub struct OpenRouterProvider { + api_key: String, + model: String, + base_url: String, + client: Client, + request_timeout: Duration, +} + +impl OpenRouterProvider { + /// Create a new OpenRouter provider. + /// + /// - `api_key`: OpenRouter API key (sent as Bearer token). + /// - `model`: Model identifier (e.g. `"openai/gpt-4o"`, `"anthropic/claude-3.5-sonnet"`). + /// - `base_url`: Optional override for the API base URL. + pub fn new(api_key: String, model: String, base_url: Option) -> Self { + Self { + api_key, + model, + base_url: base_url.unwrap_or_else(|| OPENROUTER_BASE_URL.to_string()), + client: Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .expect("Failed to create HTTP client"), + request_timeout: Duration::from_secs(60), + } + } + + /// Set a custom request timeout. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout; + self + } + + /// Build the request body — identical to OpenAI format. + fn build_request(&self, context: &ConversationContext) -> serde_json::Value { + let messages: Vec = 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, + }); + + if !context.tools.is_empty() { + body["tools"] = serde_json::to_value(&context.tools).unwrap_or_default(); + body["tool_choice"] = serde_json::json!("auto"); + } + + body + } + + /// Parse the OpenAI-compatible response. + fn parse_response(&self, data: &serde_json::Value) -> ProviderResult { + 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(); + + let tool_calls = message["tool_calls"].as_array().map(|calls| { + calls + .iter() + .map(|tc| { + crate::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, + }) + } + + /// Send request with OpenRouter-specific headers. + async fn send_request( + &self, + url: &str, + body: &serde_json::Value, + ) -> ProviderResult { + let response = self + .client + .post(url) + .bearer_auth(&self.api_key) + .header("HTTP-Referer", "https://gyannetra.in") + .header("X-Title", "GyanDoot") + .json(body) + .timeout(self.request_timeout) + .send() + .await?; + + let status = response.status(); + if !status.is_success() { + let error_text = response.text().await.unwrap_or_default(); + return Err(gyandoot_core::ProviderError::from_response( + status.as_u16(), + &error_text, + )); + } + + Ok(response) + } +} + +#[async_trait] +impl Provider for OpenRouterProvider { + async fn chat(&self, context: ConversationContext) -> ProviderResult { + debug!( + "OpenRouter chat request (model: {}, messages: {})", + self.model, + context.messages.len() + ); + + let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/')); + let body = self.build_request(&context); + let response = self.send_request(&url, &body).await?; + let data: serde_json::Value = response.json().await?; + + Ok(self.parse_response(&data)?) + } + + async fn chat_stream( + &self, + context: ConversationContext, + ) -> ProviderResult> + Send + Unpin>> + { + debug!( + "OpenRouter streaming chat (model: {}, messages: {})", + self.model, + context.messages.len() + ); + + let mut body = self.build_request(&context); + body["stream"] = serde_json::json!(true); + + let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/')); + + let response = self + .client + .post(&url) + .bearer_auth(&self.api_key) + .header("HTTP-Referer", "https://gyannetra.in") + .header("X-Title", "GyanDoot") + .json(&body) + .timeout(self.request_timeout) + .send() + .await?; + + let status = response.status(); + if !status.is_success() { + let error_text = response.text().await.unwrap_or_default(); + return Err(gyandoot_core::ProviderError::from_response( + status.as_u16(), + &error_text, + )); + } + + let (tx, rx) = tokio::sync::mpsc::channel::>(256); + let mut sse_stream = response.bytes_stream(); + + tokio::spawn(async move { + let mut parser = crate::streaming::SseParser::new(100); + let mut buffer = Vec::new(); + + loop { + match tokio::time::timeout(Duration::from_secs(30), sse_stream.next()).await { + Ok(Some(Ok(chunk))) => { + buffer.extend_from_slice(&chunk); + let buffer_str = String::from_utf8_lossy(&buffer); + let mut pos = 0; + + for event in parser.feed_chunk(buffer_str.as_ref()) { + match event { + crate::streaming::StreamEvent::Chunk(text) => { + tracing::trace!("OpenRouter SSE chunk: {text}"); + if tx.send(Ok(text)).await.is_err() { + tracing::debug!("Stream receiver dropped"); + return; + } + } + crate::streaming::StreamEvent::Done => { + tracing::debug!("OpenRouter SSE stream done"); + return; + } + crate::streaming::StreamEvent::Error(msg) => { + tracing::warn!("OpenRouter SSE error: {msg}"); + let _ = tx + .send(Err(gyandoot_core::ProviderError::Stream(msg))) + .await; + return; + } + crate::streaming::StreamEvent::ToolCall(_) => {} + } + pos = parser.consumed(); + } + buffer.drain(0..pos); + } + Ok(Some(Err(e))) => { + let _ = tx + .send(Err(gyandoot_core::ProviderError::Request(e))) + .await; + return; + } + Ok(None) => { + tracing::debug!("OpenRouter SSE stream exhausted"); + return; + } + Err(_) => { + let _ = tx + .send(Err(gyandoot_core::ProviderError::Timeout { + duration: 30, + })) + .await; + return; + } + } + } + }); + + let stream = + tokio_stream::wrappers::ReceiverStream::new(rx); + Ok(Box::new(stream)) + } + + async fn models(&self) -> ProviderResult> { + // OpenRouter has a models endpoint + let url = format!("{}/models", self.base_url.trim_end_matches('/')); + let response = self + .client + .get(&url) + .bearer_auth(&self.api_key) + .timeout(self.request_timeout) + .send() + .await?; + + let status = response.status(); + if !status.is_success() { + let error_text = response.text().await.unwrap_or_default(); + return Err(gyandoot_core::ProviderError::from_response( + status.as_u16(), + &error_text, + )); + } + + let data: serde_json::Value = response.json().await?; + let models = data["data"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|m| m["id"].as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + Ok(models) + } + + fn supports_tools(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gyandoot_core::{Message, Role, ToolDefinition}; + + fn test_provider() -> OpenRouterProvider { + OpenRouterProvider::new( + "sk-or-test".to_string(), + "openai/gpt-4o".to_string(), + None, + ) + } + + #[test] + fn test_build_request_basic() { + let provider = test_provider(); + let context = ConversationContext { + messages: vec![ + Message::new(Role::System, "You are helpful."), + Message::new(Role::User, "Hello"), + ], + system_prompt: "You are helpful.".to_string(), + tools: vec![], + max_tokens: 4096, + }; + + let body = provider.build_request(&context); + assert_eq!(body["model"], "openai/gpt-4o"); + assert_eq!(body["messages"].as_array().unwrap().len(), 2); + } + + #[test] + fn test_build_request_with_tools() { + let provider = test_provider(); + let context = ConversationContext { + messages: vec![Message::new(Role::User, "Weather?")], + system_prompt: String::new(), + tools: vec![ToolDefinition::new( + "get_weather", + "Get weather", + serde_json::json!({"type": "object"}), + )], + max_tokens: 4096, + }; + + let body = provider.build_request(&context); + assert!(body.get("tools").is_some()); + assert_eq!(body["tools"].as_array().unwrap().len(), 1); + } + + #[test] + fn test_parse_response() { + let provider = test_provider(); + let data = serde_json::json!({ + "choices": [{ + "message": { + "content": "Hello!", + "role": "assistant" + }, + "finish_reason": "stop", + "index": 0 + }] + }); + + let response = provider.parse_response(&data).unwrap(); + assert_eq!(response.content, "Hello!"); + assert_eq!(response.finish_reason, "stop"); + } + + #[test] + fn test_supports_tools() { + let provider = test_provider(); + assert!(provider.supports_tools()); + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 806a64a..6f2f6e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -178,14 +178,13 @@ fn default_config_dir() -> PathBuf { /// Run the agent in the given platform mode async fn cmd_run(profile_name: &str, platform: &str, prompt: Option<&str>) -> anyhow::Result<()> { + // Print the banner on launch + gyandoot_tui::banner::print_banner(); + tracing::info!("Running agent (profile={}, platform={})", profile_name, platform); - // Load config via resolver - let resolver = gyandoot_profile::ConfigResolver::default(); - let _resolved = resolver.resolve(); - - // Initialize the agent loop using gyandoot-core config - let config = gyandoot_core::config::AgentConfig::default(); + // Load config from user config files, falling back to defaults + let config = load_agent_config(); let agent = gyandoot_agent::agent_loop::AgentLoop::new(config); if let Some(p) = prompt { @@ -196,6 +195,51 @@ async fn cmd_run(profile_name: &str, platform: &str, prompt: Option<&str>) -> an Ok(()) } +/// Load agent config from user config files with fallback to defaults. +/// +/// Resolution order: +/// 1. `~/.config/gyandoot/config.toml` (XDG primary) +/// 2. `~/.gyandoot/config.yaml` (legacy) +/// 3. `AgentConfig::default()` (hardcoded defaults) +fn load_agent_config() -> gyandoot_core::config::AgentConfig { + // 1. Try XDG-style config: ~/.config/gyandoot/config.toml + if let Some(cfg_dir) = dirs::config_dir() { + let xdg_path = cfg_dir.join("gyandoot").join("config.toml"); + if xdg_path.exists() { + match gyandoot_core::config::ConfigLoader::from_file(&xdg_path) { + Ok(gyandoot_cfg) => { + tracing::info!("Loaded config from {}", xdg_path.display()); + return gyandoot_core::config::AgentConfig::from(gyandoot_cfg); + } + Err(e) => tracing::warn!("Failed to parse {}: {}", xdg_path.display(), e), + } + } + } + + // 2. Try legacy config: ~/.gyandoot/config.yaml + if let Some(home) = dirs::home_dir() { + let legacy_path = home.join(".gyandoot").join("config.yaml"); + if legacy_path.exists() { + match std::fs::read_to_string(&legacy_path) { + Ok(contents) => { + match serde_yaml::from_str::(&contents) { + Ok(gyandoot_cfg) => { + tracing::info!("Loaded config from {}", legacy_path.display()); + return gyandoot_core::config::AgentConfig::from(gyandoot_cfg); + } + Err(e) => tracing::warn!("Failed to parse {}: {}", legacy_path.display(), e), + } + } + Err(e) => tracing::warn!("Failed to read {}: {}", legacy_path.display(), e), + } + } + } + + // 3. Fallback to hardcoded defaults + tracing::warn!("No config file found; using default configuration (API keys will be empty)"); + gyandoot_core::config::AgentConfig::default() +} + /// Start the gateway server for multi-platform sessions async fn cmd_serve(profile_name: &str, host: &str, port: u16) -> anyhow::Result<()> { tracing::info!( @@ -238,7 +282,7 @@ system_prompt = "You are GyanDoot, an AI Agent Platform." default = "openai" [providers.openai] -api_key = "" +api_key = "sk-your-api-key-here" model = "gpt-4o" [platform]