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!(