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.
This commit is contained in:
2026-06-11 21:40:34 +05:30
parent dd21654555
commit fb01b2595a
9 changed files with 1202 additions and 13 deletions

1
Cargo.lock generated
View File

@@ -661,6 +661,7 @@ dependencies = [
"gyandoot-tui", "gyandoot-tui",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",

View File

@@ -80,4 +80,5 @@ clap.workspace = true
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
serde_yaml.workspace = true
dirs.workspace = true dirs.workspace = true

View File

@@ -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<String>) -> 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<ProviderResponse> {
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<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + 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<Vec<String>> {
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());
}
}

View File

@@ -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<String>) -> 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<serde_json::Value> = 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<serde_json::Value> = 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<ProviderResponse> {
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<reqwest::Response> {
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<ProviderResponse> {
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<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + 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::<ProviderResult<String>>(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::<serde_json::Value>(&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<Vec<String>> {
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"));
}
}

View File

@@ -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<String>) -> 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<ProviderResponse> {
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<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + 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<Vec<String>> {
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());
}
}

View File

@@ -14,12 +14,20 @@ pub mod openai;
pub mod anthropic; pub mod anthropic;
pub mod ollama; pub mod ollama;
pub mod streaming; pub mod streaming;
pub mod gemini;
pub mod deepseek;
pub mod openrouter;
pub mod groq;
// ─── Re-exports ─────────────────────────────────────────────────────────── // ─── Re-exports ───────────────────────────────────────────────────────────
pub use openai::OpenAIProvider; pub use openai::OpenAIProvider;
pub use anthropic::AnthropicProvider; pub use anthropic::AnthropicProvider;
pub use ollama::OllamaProvider; 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}; pub use streaming::{SseParser, StreamEvent, TokenStream, collect_stream};
// ─── ProviderResponse ───────────────────────────────────────────────────── // ─── ProviderResponse ─────────────────────────────────────────────────────
@@ -102,6 +110,7 @@ pub enum ProviderKind {
Gemini, Gemini,
DeepSeek, DeepSeek,
OpenRouter, OpenRouter,
Groq,
} }
impl ProviderKind { impl ProviderKind {
@@ -114,6 +123,7 @@ impl ProviderKind {
"gemini" => Some(ProviderKind::Gemini), "gemini" => Some(ProviderKind::Gemini),
"deepseek" => Some(ProviderKind::DeepSeek), "deepseek" => Some(ProviderKind::DeepSeek),
"openrouter" => Some(ProviderKind::OpenRouter), "openrouter" => Some(ProviderKind::OpenRouter),
"groq" => Some(ProviderKind::Groq),
_ => None, _ => None,
} }
} }
@@ -127,6 +137,7 @@ impl ProviderKind {
ProviderKind::Gemini => "gemini", ProviderKind::Gemini => "gemini",
ProviderKind::DeepSeek => "deepseek", ProviderKind::DeepSeek => "deepseek",
ProviderKind::OpenRouter => "openrouter", ProviderKind::OpenRouter => "openrouter",
ProviderKind::Groq => "groq",
} }
} }
} }
@@ -160,7 +171,7 @@ impl ProviderFactory {
config: &gyandoot_core::ProviderRootConfig, config: &gyandoot_core::ProviderRootConfig,
) -> ProviderResult<Box<dyn Provider>> { ) -> ProviderResult<Box<dyn Provider>> {
match kind { match kind {
ProviderKind::OpenAI | ProviderKind::DeepSeek | ProviderKind::OpenRouter => { ProviderKind::OpenAI => {
let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api { let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
status: 0, status: 0,
message: "OpenAI config not found".to_string(), message: "OpenAI config not found".to_string(),
@@ -196,8 +207,40 @@ impl ProviderFactory {
status: 0, status: 0,
message: "Gemini config not found (use openai section)".to_string(), message: "Gemini config not found (use openai section)".to_string(),
})?; })?;
// Gemini uses the OpenAI-compatible endpoint Ok(Box::new(gemini::GeminiProvider::new(
Ok(Box::new(openai::OpenAIProvider::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.api_key.clone(),
cfg.model.clone(), cfg.model.clone(),
cfg.base_url.clone(), cfg.base_url.clone(),
@@ -220,7 +263,7 @@ impl ProviderFactory {
let config = gyandoot_core::ProviderRootConfig { let config = gyandoot_core::ProviderRootConfig {
default: name.to_string(), 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 { Some(gyandoot_core::OpenAIConfig {
api_key: api_key.to_string(), api_key: api_key.to_string(),
model: model.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::Gemini => "https://generativelanguage.googleapis.com/v1".to_string(),
ProviderKind::DeepSeek => "https://api.deepseek.com/v1".to_string(), ProviderKind::DeepSeek => "https://api.deepseek.com/v1".to_string(),
ProviderKind::OpenRouter => "https://openrouter.ai/api/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::Gemini,
ProviderKind::DeepSeek, ProviderKind::DeepSeek,
ProviderKind::OpenRouter, ProviderKind::OpenRouter,
ProviderKind::Groq,
] { ] {
let s = kind.to_string(); let s = kind.to_string();
let parsed = ProviderKind::from_str(&s).unwrap(); let parsed = ProviderKind::from_str(&s).unwrap();

View File

@@ -18,8 +18,8 @@ use crate::{Provider, ProviderResponse};
/// Provider for OpenAI-compatible chat completion APIs. /// Provider for OpenAI-compatible chat completion APIs.
pub struct OpenAIProvider { pub struct OpenAIProvider {
api_key: String, pub(crate) api_key: String,
model: String, pub(crate) model: String,
base_url: String, base_url: String,
client: Client, client: Client,
request_timeout: Duration, request_timeout: Duration,

View File

@@ -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<String>) -> 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<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,
});
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<ProviderResponse> {
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<reqwest::Response> {
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<ProviderResponse> {
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<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + 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::<ProviderResult<String>>(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<Vec<String>> {
// 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());
}
}

View File

@@ -178,14 +178,13 @@ fn default_config_dir() -> PathBuf {
/// Run the agent in the given platform mode /// Run the agent in the given platform mode
async fn cmd_run(profile_name: &str, platform: &str, prompt: Option<&str>) -> anyhow::Result<()> { 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); tracing::info!("Running agent (profile={}, platform={})", profile_name, platform);
// Load config via resolver // Load config from user config files, falling back to defaults
let resolver = gyandoot_profile::ConfigResolver::default(); let config = load_agent_config();
let _resolved = resolver.resolve();
// Initialize the agent loop using gyandoot-core config
let config = gyandoot_core::config::AgentConfig::default();
let agent = gyandoot_agent::agent_loop::AgentLoop::new(config); let agent = gyandoot_agent::agent_loop::AgentLoop::new(config);
if let Some(p) = prompt { if let Some(p) = prompt {
@@ -196,6 +195,51 @@ async fn cmd_run(profile_name: &str, platform: &str, prompt: Option<&str>) -> an
Ok(()) 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::<gyandoot_core::config::GyandootConfig>(&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 /// Start the gateway server for multi-platform sessions
async fn cmd_serve(profile_name: &str, host: &str, port: u16) -> anyhow::Result<()> { async fn cmd_serve(profile_name: &str, host: &str, port: u16) -> anyhow::Result<()> {
tracing::info!( tracing::info!(
@@ -238,7 +282,7 @@ system_prompt = "You are GyanDoot, an AI Agent Platform."
default = "openai" default = "openai"
[providers.openai] [providers.openai]
api_key = "" api_key = "sk-your-api-key-here"
model = "gpt-4o" model = "gpt-4o"
[platform] [platform]