From 81d539cfee6fa5323bd264fdec408d99cda0f1b5 Mon Sep 17 00:00:00 2001 From: Tara Menon Date: Thu, 11 Jun 2026 22:05:02 +0530 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20Ollama=20auth=20+=20config?= =?UTF-8?q?=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: 1. Added to (config.rs) 2. Rewrote with dual-mode logic: - OpenAI-compatible mode (api_key set OR non-localhost URL): uses /chat/completions + Bearer auth - Native mode: uses /api/chat (no auth) 3. Updated in agent_loop.rs to pass api_key 4. Updated to pass api_key 5. Added in main.rs — parses flat format AND canonical format 6. Added dependency to root Cargo.toml The o | - - - - - - .---. - - - - - - - - - - - - - - - [ ^ ^ ] - - - - - - - - - - - - - - - - | ___ | - - - - - - - `-----' ____ __ __ ___ _ _ ____ ___ ___ _____ / ___|\ \ / / / _ \ | \ | || _ \ / _ \ / _ \|_ _| | | _ \ V / / /_\ \| \| || | | | | | | | | | | | | |_| | | | | _ || |\ || |_| | |_| | |_| | | | \____/ |_| |_| |_||_| \_||____/ \___/ \___/ |_| o - - - - - - - knowledge. delivered. - - - - - - - o ╔══════════════════════════════════════════════╗ ║ ज्ञानदूत GyanDoot — Setup Wizard ║ ╚══════════════════════════════════════════════╝ Configure your AI provider in 4 simple steps This wizard will help you configure GyanDoot to connect to your preferred LLM provider. You can re-run this anytime. Select a provider: ──────────────────────────────────────────────────────────── 1. Alibaba (Qwen) (alibaba) 2. Anthropic (anthropic) 3. Azure OpenAI (azure) 4. Amazon Bedrock (bedrock) 5. DeepSeek (deepseek) 6. Google Gemini (gemini) 7. Groq (groq) 8. HuggingFace (huggingface) 9. Kilocode (kilocode) 10. MiniMax (minimax) 11. Novita AI (novita) 12. Ollama (Local or Cloud) (ollama) 13. OpenAI (openai) 14. OpenRouter (openrouter) 15. StepFun (stepfun) 16. Together AI (together) 17. xAI / Grok (xai) 18. Xiaomi (MiLM) (xiaomi) 19. ZAI (01.AI / Yi) (zai) 20. Custom / Other Enter number (1-20): wizard writes (singular) but the config loader only parsed (plural). Now both formats work. Ollama cloud endpoints (ollama.com/v1) get proper Bearer auth with the API key from the setup wizard. --- Cargo.lock | 1 + Cargo.toml | 3 +- crates/gyandoot-agent/src/agent_loop.rs | 1 + crates/gyandoot-core/src/config.rs | 4 + crates/gyandoot-providers/src/lib.rs | 2 + crates/gyandoot-providers/src/ollama.rs | 238 ++++++++++++++++++------ src/main.rs | 88 +++++++-- 7 files changed, 266 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5aee994..4e2f7f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -663,6 +663,7 @@ dependencies = [ "serde_json", "serde_yaml", "tokio", + "toml", "tracing", "tracing-subscriber", ] diff --git a/Cargo.toml b/Cargo.toml index 480b4bf..42df105 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,4 +81,5 @@ anyhow.workspace = true tracing.workspace = true tracing-subscriber.workspace = true serde_yaml.workspace = true -dirs.workspace = true \ No newline at end of file +dirs.workspace = true +toml.workspace = true \ No newline at end of file diff --git a/crates/gyandoot-agent/src/agent_loop.rs b/crates/gyandoot-agent/src/agent_loop.rs index 78eb908..c1304c1 100644 --- a/crates/gyandoot-agent/src/agent_loop.rs +++ b/crates/gyandoot-agent/src/agent_loop.rs @@ -551,6 +551,7 @@ fn create_provider(config: &AgentConfig) -> Box { Box::new(gyandoot_providers::ollama::OllamaProvider::new( cfg.base_url, cfg.model, + cfg.api_key.clone(), )) } other => { diff --git a/crates/gyandoot-core/src/config.rs b/crates/gyandoot-core/src/config.rs index c97c6de..7712189 100644 --- a/crates/gyandoot-core/src/config.rs +++ b/crates/gyandoot-core/src/config.rs @@ -404,6 +404,8 @@ impl Default for AnthropicConfig { pub struct OllamaConfig { pub base_url: String, pub model: String, + #[serde(default)] + pub api_key: Option, pub max_retries: u32, } @@ -416,6 +418,7 @@ impl OllamaConfig { Self { base_url: table.get("base_url").and_then(|v| v.as_str()).unwrap_or("http://localhost:11434").to_string(), model: table.get("model").and_then(|v| v.as_str()).unwrap_or("llama3").to_string(), + api_key: table.get("api_key").and_then(|v| v.as_str()).map(|s| s.to_string()), max_retries: table.get("max_retries").and_then(|v| v.as_integer()).unwrap_or(2) as u32, } } @@ -426,6 +429,7 @@ impl Default for OllamaConfig { Self { base_url: "http://localhost:11434".to_string(), model: "llama3".to_string(), + api_key: None, max_retries: 2, } } diff --git a/crates/gyandoot-providers/src/lib.rs b/crates/gyandoot-providers/src/lib.rs index fffc4b8..3e28b99 100644 --- a/crates/gyandoot-providers/src/lib.rs +++ b/crates/gyandoot-providers/src/lib.rs @@ -200,6 +200,7 @@ impl ProviderFactory { Ok(Box::new(ollama::OllamaProvider::new( cfg.base_url.clone(), cfg.model.clone(), + cfg.api_key.clone(), ))) } ProviderKind::Gemini => { @@ -286,6 +287,7 @@ impl ProviderFactory { Some(gyandoot_core::OllamaConfig { base_url: default_base_url(&kind), model: model.to_string(), + api_key: None, max_retries: 2, }) } else { diff --git a/crates/gyandoot-providers/src/ollama.rs b/crates/gyandoot-providers/src/ollama.rs index b13ccd7..a09e9d7 100644 --- a/crates/gyandoot-providers/src/ollama.rs +++ b/crates/gyandoot-providers/src/ollama.rs @@ -1,4 +1,8 @@ //! Ollama API provider implementation. +//! +//! Supports both native Ollama `/api/chat` and OpenAI-compatible +//! `/chat/completions` endpoints. Auto-detects which to use based on +//! the presence of an `api_key` and whether the URL looks like localhost. use async_trait::async_trait; use gyandoot_core::{ConversationContext, ProviderResult}; @@ -7,79 +11,161 @@ use tracing::debug; use crate::{Provider, ProviderResponse}; -/// Provider for local Ollama models. +/// Provider for Ollama models (native + OpenAI-compatible). pub struct OllamaProvider { base_url: String, model: String, + api_key: Option, client: Client, } impl OllamaProvider { /// Create a new Ollama provider. - pub fn new(base_url: String, model: String) -> Self { + /// + /// - `base_url`: The Ollama API base URL. + /// For native Ollama: `http://localhost:11434` + /// For cloud/OpenAI-compatible: `http://ollama.com/v1` + /// - `model`: Model name (e.g. `"llama3"`, `"qwen3.5:cloud"`) + /// - `api_key`: Optional API key. Required for cloud/OpenAI-compatible endpoints. + pub fn new(base_url: String, model: String, api_key: Option) -> Self { Self { - base_url, + base_url: base_url.trim_end_matches('/').to_string(), model, + api_key, client: Client::new(), } } + + /// Returns true if this is an OpenAI-compatible endpoint (not native Ollama). + /// Heuristic: if api_key is set or URL is not localhost/127.0.0.1, treat as OpenAI-compatible. + fn is_openai_compatible(&self) -> bool { + if self.api_key.is_some() { + return true; + } + let lower = self.base_url.to_lowercase(); + !lower.contains("localhost") && !lower.contains("127.0.0.1") && !lower.contains("0.0.0.0") + } } #[async_trait] impl Provider for OllamaProvider { async fn chat(&self, context: ConversationContext) -> ProviderResult { - debug!("Ollama chat request (model: {}, url: {})", self.model, self.base_url); + debug!( + "Ollama chat request (model: {}, url: {}, openai_compat: {})", + self.model, + self.base_url, + self.is_openai_compatible() + ); - let messages: Vec = context - .messages - .iter() - .map(|m| { - serde_json::json!({ - "role": m.role.to_string(), - "content": m.content, + if self.is_openai_compatible() { + // --- OpenAI-compatible mode (/chat/completions) --- + let messages: Vec = context + .messages + .iter() + .map(|m| { + serde_json::json!({ + "role": m.role.to_string(), + "content": m.content, + }) }) - }) - .collect(); + .collect(); - let body = serde_json::json!({ - "model": self.model, - "messages": messages, - "stream": false, - "options": { - "num_predict": context.max_tokens, - } - }); - - let url = format!("{}/api/chat", self.base_url.trim_end_matches('/')); - let response = self - .client - .post(&url) - .json(&body) - .send() - .await?; - - if !response.status().is_success() { - return Err(gyandoot_core::ProviderError::Api { - status: response.status().as_u16(), - message: format!( - "Ollama API error: {}", - response.status() - ), + let body = serde_json::json!({ + "model": self.model, + "messages": messages, + "max_tokens": context.max_tokens, + "stream": false, }); + + let url = format!("{}/chat/completions", self.base_url); + let mut request = self.client.post(&url).json(&body); + + // Add bearer auth if api_key is set + if let Some(ref key) = self.api_key { + request = request.header("Authorization", format!("Bearer {}", key)); + } + + let response = request.send().await.map_err(|e| { + gyandoot_core::ProviderError::Request(e) + })?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let error_text = response.text().await.unwrap_or_default(); + return Err(gyandoot_core::ProviderError::from_response(status, &error_text)); + } + + let data: serde_json::Value = response.json().await.map_err(|e| { + gyandoot_core::ProviderError::Request(e) + })?; + + let content = data["choices"][0]["message"]["content"] + .as_str() + .unwrap_or_default() + .to_string(); + let finish_reason = data["choices"][0]["finish_reason"] + .as_str() + .unwrap_or("stop") + .to_string(); + + Ok(ProviderResponse { + content, + tool_calls: None, + finish_reason, + }) + } else { + // --- Native Ollama mode (/api/chat) --- + let messages: Vec = context + .messages + .iter() + .map(|m| { + serde_json::json!({ + "role": m.role.to_string(), + "content": m.content, + }) + }) + .collect(); + + let body = serde_json::json!({ + "model": self.model, + "messages": messages, + "stream": false, + "options": { + "num_predict": context.max_tokens, + } + }); + + let url = format!("{}/api/chat", self.base_url); + let response = self + .client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| gyandoot_core::ProviderError::Request(e))?; + + if !response.status().is_success() { + return Err(gyandoot_core::ProviderError::Api { + status: response.status().as_u16(), + message: format!("Ollama API error: {}", response.status()), + }); + } + + let data: serde_json::Value = response.json().await.map_err(|e| { + gyandoot_core::ProviderError::Request(e) + })?; + let content = data["message"]["content"] + .as_str() + .unwrap_or_default() + .to_string(); + let finish_reason = data["done_reason"].as_str().unwrap_or("stop").to_string(); + + Ok(ProviderResponse { + content, + tool_calls: None, + finish_reason, + }) } - - let data: serde_json::Value = response.json().await?; - let content = data["message"]["content"] - .as_str() - .unwrap_or_default() - .to_string(); - let finish_reason = data["done_reason"].as_str().unwrap_or("stop").to_string(); - - Ok(ProviderResponse { - content, - tool_calls: None, - finish_reason, - }) } async fn chat_stream( @@ -100,21 +186,55 @@ impl Provider for OllamaProvider { #[cfg(test)] mod tests { use super::*; - use gyandoot_core::Message; + use gyandoot_core::{Message, MessageRole}; + + fn test_messages() -> ConversationContext { + ConversationContext { + messages: vec![Message::new(MessageRole::User, "hi")], + system_prompt: String::new(), + tools: vec![], + max_tokens: 100, + } + } + + #[test] + fn test_is_openai_compatible_with_api_key() { + let provider = OllamaProvider::new( + "http://localhost:11434".to_string(), + "llama3".to_string(), + Some("test-key".to_string()), + ); + assert!(provider.is_openai_compatible()); + } + + #[test] + fn test_is_openai_compatible_with_remote_url() { + let provider = OllamaProvider::new( + "http://ollama.com/v1".to_string(), + "qwen3.5".to_string(), + None, + ); + assert!(provider.is_openai_compatible()); + } + + #[test] + fn test_is_native_ollama() { + let provider = OllamaProvider::new( + "http://localhost:11434".to_string(), + "llama3".to_string(), + None, + ); + assert!(!provider.is_openai_compatible()); + } #[tokio::test] async fn test_ollama_connection_refused() { let provider = OllamaProvider::new( "http://127.0.0.1:1".to_string(), "llama3".to_string(), + None, ); - let context = ConversationContext { - messages: vec![Message::new(MessageRole::User, "hi")], - system_prompt: String::new(), - tools: vec![], - max_tokens: 100, - }; - let result = provider.chat(context).await; - assert!(result.is_err()); // Connection refused expected + let result = provider.chat(test_messages()).await; + assert!(result.is_err()); } } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 4a6cf41..da00b0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -207,11 +207,8 @@ fn load_agent_config() -> gyandoot_core::config::AgentConfig { if let Some(home) = dirs::home_dir() { let explicit_path = home.join(".config").join("gyandoot").join("config.toml"); if explicit_path.exists() { - match gyandoot_core::config::ConfigLoader::from_file(&explicit_path) { - Ok(gyandoot_cfg) => { - tracing::info!("Loaded config from {}", explicit_path.display()); - return gyandoot_core::config::AgentConfig::from(gyandoot_cfg); - } + match load_and_convert_config(&explicit_path) { + Ok(cfg) => return cfg, Err(e) => tracing::warn!("Failed to parse {}: {}", explicit_path.display(), e), } } @@ -221,11 +218,8 @@ fn load_agent_config() -> gyandoot_core::config::AgentConfig { 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); - } + match load_and_convert_config(&xdg_path) { + Ok(cfg) => return cfg, Err(e) => tracing::warn!("Failed to parse {}: {}", xdg_path.display(), e), } } @@ -250,11 +244,83 @@ fn load_agent_config() -> gyandoot_core::config::AgentConfig { } } - // 3. Fallback to hardcoded defaults + // 4. Fallback to hardcoded defaults tracing::warn!("No config file found; using default configuration (API keys will be empty)"); gyandoot_core::config::AgentConfig::default() } +/// Load a TOML config file, converting `[provider]` (singular, flat format +/// written by `gyandoot setup`) to the canonical `[providers]` format. +fn load_and_convert_config(path: &std::path::Path) -> Result> { + let content = std::fs::read_to_string(path)?; + + // Try parsing as canonical GyandootConfig first + if let Ok(gyandoot_cfg) = toml::from_str::(&content) { + tracing::info!("Loaded config from {}", path.display()); + return Ok(gyandoot_core::config::AgentConfig::from(gyandoot_cfg)); + } + + // Fallback: parse flat `[provider]` format and convert to canonical + #[derive(serde::Deserialize)] + struct FlatConfig { + provider: FlatProvider, + agent: Option, + } + + #[derive(serde::Deserialize)] + struct FlatProvider { + kind: String, + base_url: String, + api_key: Option, + model: String, + temperature: Option, + max_tokens: Option, + #[serde(default)] + merge_system: bool, + #[serde(default)] + no_tools: bool, + } + + #[derive(serde::Deserialize)] + struct FlatAgent { + name: Option, + } + + let flat: FlatConfig = toml::from_str(&content)?; + tracing::info!("Loaded config from {} (converted from flat [provider] format)", path.display()); + + // Build canonical GyandootConfig from flat data + let provider_name = flat.provider.kind.to_lowercase(); + let mut providers_map = std::collections::HashMap::new(); + let mut provider_table = toml::map::Map::new(); + provider_table.insert("model".to_string(), toml::Value::String(flat.provider.model.clone())); + provider_table.insert("base_url".to_string(), toml::Value::String(flat.provider.base_url)); + if let Some(key) = &flat.provider.api_key { + provider_table.insert("api_key".to_string(), toml::Value::String(key.clone())); + } + providers_map.insert(provider_name.clone(), toml::Value::Table(provider_table)); + + let gyandoot_cfg = gyandoot_core::config::GyandootConfig { + agent: gyandoot_core::config::AgentSection { + name: flat.agent.and_then(|a| a.name).unwrap_or_else(|| "gyandoot".to_string()), + model: flat.provider.model.clone(), + max_tokens: flat.provider.max_tokens.unwrap_or(4096) as usize, + temperature: flat.provider.temperature.unwrap_or(0.7), + system_prompt: String::new(), + }, + providers: gyandoot_core::config::ProvidersSection { + default: provider_name, + providers: providers_map, + }, + platform: gyandoot_core::config::PlatformSection::default(), + memory: gyandoot_core::config::MemorySection::default(), + gateway: gyandoot_core::config::GatewaySection::default(), + browser: gyandoot_core::config::BrowserSection::default(), + }; + + Ok(gyandoot_core::config::AgentConfig::from(gyandoot_cfg)) +} + /// Start the gateway server for multi-platform sessions async fn cmd_serve(profile_name: &str, host: &str, port: u16) -> anyhow::Result<()> { tracing::info!(