🐛 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:
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user