🐛 Fix Ollama auth + config format

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.
This commit is contained in:
2026-06-11 22:05:02 +05:30
parent bff44b8b59
commit 81d539cfee
7 changed files with 266 additions and 71 deletions

1
Cargo.lock generated
View File

@@ -663,6 +663,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"tokio",
"toml",
"tracing",
"tracing-subscriber",
]

View File

@@ -82,3 +82,4 @@ tracing.workspace = true
tracing-subscriber.workspace = true
serde_yaml.workspace = true
dirs.workspace = true
toml.workspace = true

View File

@@ -551,6 +551,7 @@ fn create_provider(config: &AgentConfig) -> Box<dyn Provider + Send + Sync> {
Box::new(gyandoot_providers::ollama::OllamaProvider::new(
cfg.base_url,
cfg.model,
cfg.api_key.clone(),
))
}
other => {

View File

@@ -404,6 +404,8 @@ impl Default for AnthropicConfig {
pub struct OllamaConfig {
pub base_url: String,
pub model: String,
#[serde(default)]
pub api_key: Option<String>,
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,
}
}

View File

@@ -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 {

View File

@@ -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<String>,
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<String>) -> 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<ProviderResponse> {
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<serde_json::Value> = 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<serde_json::Value> = 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<serde_json::Value> = 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());
}
}

View File

@@ -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<gyandoot_core::config::AgentConfig, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
// Try parsing as canonical GyandootConfig first
if let Ok(gyandoot_cfg) = toml::from_str::<gyandoot_core::config::GyandootConfig>(&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<FlatAgent>,
}
#[derive(serde::Deserialize)]
struct FlatProvider {
kind: String,
base_url: String,
api_key: Option<String>,
model: String,
temperature: Option<f64>,
max_tokens: Option<u32>,
#[serde(default)]
merge_system: bool,
#[serde(default)]
no_tools: bool,
}
#[derive(serde::Deserialize)]
struct FlatAgent {
name: Option<String>,
}
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!(