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.
374 lines
14 KiB
Rust
374 lines
14 KiB
Rust
//! GyanDoot Providers — LLM provider abstraction layer.
|
|
//!
|
|
//! Defines the [`Provider`] trait and implementations for OpenAI, Anthropic,
|
|
//! Ollama, and other providers. Each implementation handles authentication,
|
|
//! request formatting, response parsing, and streaming.
|
|
//!
|
|
//! The [`ProviderFactory`] creates provider instances from
|
|
//! [`gyandoot_core::ProviderConfig`].
|
|
|
|
use async_trait::async_trait;
|
|
use gyandoot_core::{ConversationContext, ProviderResult, ToolCall};
|
|
|
|
pub mod openai;
|
|
pub mod anthropic;
|
|
pub mod ollama;
|
|
pub mod streaming;
|
|
pub mod gemini;
|
|
pub mod deepseek;
|
|
pub mod openrouter;
|
|
pub mod groq;
|
|
|
|
// ─── Re-exports ───────────────────────────────────────────────────────────
|
|
|
|
pub use openai::OpenAIProvider;
|
|
pub use anthropic::AnthropicProvider;
|
|
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};
|
|
|
|
// ─── ProviderResponse ─────────────────────────────────────────────────────
|
|
|
|
/// A provider response containing the generated text and any tool calls.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProviderResponse {
|
|
pub content: String,
|
|
pub tool_calls: Option<Vec<ToolCall>>,
|
|
pub finish_reason: String,
|
|
}
|
|
|
|
impl ProviderResponse {
|
|
/// Create a new provider response with text content only.
|
|
pub fn new(content: impl Into<String>) -> Self {
|
|
Self {
|
|
content: content.into(),
|
|
tool_calls: None,
|
|
finish_reason: "stop".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Create a provider response with tool calls.
|
|
pub fn with_tool_calls(
|
|
content: impl Into<String>,
|
|
calls: Vec<ToolCall>,
|
|
finish_reason: impl Into<String>,
|
|
) -> Self {
|
|
Self {
|
|
content: content.into(),
|
|
tool_calls: Some(calls),
|
|
finish_reason: finish_reason.into(),
|
|
}
|
|
}
|
|
|
|
/// Return true if this response contains tool calls.
|
|
pub fn has_tool_calls(&self) -> bool {
|
|
self.tool_calls.as_ref().map_or(false, |c| !c.is_empty())
|
|
}
|
|
}
|
|
|
|
// ─── Provider trait ───────────────────────────────────────────────────────
|
|
|
|
/// The trait that all LLM providers must implement.
|
|
#[async_trait]
|
|
pub trait Provider: Send + Sync {
|
|
/// Send a chat completion request and return the full response.
|
|
async fn chat(&self, context: ConversationContext) -> ProviderResult<ProviderResponse>;
|
|
|
|
/// Send a chat completion request with streaming response.
|
|
///
|
|
/// Each item in the stream represents a text delta. Use
|
|
/// [`streaming::collect_stream`] to aggregate the full text.
|
|
async fn chat_stream(
|
|
&self,
|
|
context: ConversationContext,
|
|
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>;
|
|
|
|
/// Return the list of models available for this provider.
|
|
async fn models(&self) -> ProviderResult<Vec<String>> {
|
|
// Default: return an empty list; providers can override.
|
|
let _ = self;
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
/// Return whether this provider supports tool/function calling.
|
|
fn supports_tools(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
// ─── ProviderKind (runtime enum) ──────────────────────────────────────────
|
|
|
|
/// Runtime provider kind for factory dispatch.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ProviderKind {
|
|
OpenAI,
|
|
Anthropic,
|
|
Ollama,
|
|
Gemini,
|
|
DeepSeek,
|
|
OpenRouter,
|
|
Groq,
|
|
}
|
|
|
|
impl ProviderKind {
|
|
/// Parse from a string, case-insensitive.
|
|
pub fn from_str(s: &str) -> Option<Self> {
|
|
match s.to_lowercase().as_str() {
|
|
"openai" => Some(ProviderKind::OpenAI),
|
|
"anthropic" => Some(ProviderKind::Anthropic),
|
|
"ollama" => Some(ProviderKind::Ollama),
|
|
"gemini" => Some(ProviderKind::Gemini),
|
|
"deepseek" => Some(ProviderKind::DeepSeek),
|
|
"openrouter" => Some(ProviderKind::OpenRouter),
|
|
"groq" => Some(ProviderKind::Groq),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Return the canonical string name.
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
ProviderKind::OpenAI => "openai",
|
|
ProviderKind::Anthropic => "anthropic",
|
|
ProviderKind::Ollama => "ollama",
|
|
ProviderKind::Gemini => "gemini",
|
|
ProviderKind::DeepSeek => "deepseek",
|
|
ProviderKind::OpenRouter => "openrouter",
|
|
ProviderKind::Groq => "groq",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for ProviderKind {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.as_str())
|
|
}
|
|
}
|
|
|
|
// ─── ProviderFactory ───────────────────────────────────────────────────────
|
|
|
|
/// Factory for creating provider instances from configuration.
|
|
pub struct ProviderFactory;
|
|
|
|
impl ProviderFactory {
|
|
/// Create a provider from a [`gyandoot_core::ProviderRootConfig`].
|
|
pub fn create(config: &gyandoot_core::ProviderRootConfig) -> ProviderResult<Box<dyn Provider>> {
|
|
let kind = ProviderKind::from_str(&config.default)
|
|
.ok_or_else(|| gyandoot_core::ProviderError::Api {
|
|
status: 0,
|
|
message: format!("Unknown provider name: {}", config.default),
|
|
})?;
|
|
|
|
Self::create_from_kind(kind, config)
|
|
}
|
|
|
|
/// Create a provider from a [`ProviderKind`] and config.
|
|
pub fn create_from_kind(
|
|
kind: ProviderKind,
|
|
config: &gyandoot_core::ProviderRootConfig,
|
|
) -> ProviderResult<Box<dyn Provider>> {
|
|
match kind {
|
|
ProviderKind::OpenAI => {
|
|
let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
|
status: 0,
|
|
message: "OpenAI config not found".to_string(),
|
|
})?;
|
|
Ok(Box::new(openai::OpenAIProvider::new(
|
|
cfg.api_key.clone(),
|
|
cfg.model.clone(),
|
|
cfg.base_url.clone(),
|
|
)))
|
|
}
|
|
ProviderKind::Anthropic => {
|
|
let cfg = config.anthropic.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
|
status: 0,
|
|
message: "Anthropic config not found".to_string(),
|
|
})?;
|
|
Ok(Box::new(anthropic::AnthropicProvider::new(
|
|
cfg.api_key.clone(),
|
|
cfg.model.clone(),
|
|
)))
|
|
}
|
|
ProviderKind::Ollama => {
|
|
let cfg = config.ollama.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
|
status: 0,
|
|
message: "Ollama config not found".to_string(),
|
|
})?;
|
|
Ok(Box::new(ollama::OllamaProvider::new(
|
|
cfg.base_url.clone(),
|
|
cfg.model.clone(),
|
|
cfg.api_key.clone(),
|
|
)))
|
|
}
|
|
ProviderKind::Gemini => {
|
|
let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
|
status: 0,
|
|
message: "Gemini config not found (use openai section)".to_string(),
|
|
})?;
|
|
Ok(Box::new(gemini::GeminiProvider::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.model.clone(),
|
|
cfg.base_url.clone(),
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a provider from just a kind name string and minimal config.
|
|
pub fn from_name(
|
|
name: &str,
|
|
api_key: &str,
|
|
model: &str,
|
|
) -> ProviderResult<Box<dyn Provider>> {
|
|
let kind = ProviderKind::from_str(name)
|
|
.ok_or_else(|| gyandoot_core::ProviderError::Api {
|
|
status: 0,
|
|
message: format!("Unknown provider name: {name}"),
|
|
})?;
|
|
|
|
let config = gyandoot_core::ProviderRootConfig {
|
|
default: name.to_string(),
|
|
openai: if matches!(kind, ProviderKind::OpenAI | ProviderKind::DeepSeek | ProviderKind::OpenRouter | ProviderKind::Gemini | ProviderKind::Groq) {
|
|
Some(gyandoot_core::OpenAIConfig {
|
|
api_key: api_key.to_string(),
|
|
model: model.to_string(),
|
|
base_url: Some(default_base_url(&kind)),
|
|
max_retries: 3,
|
|
})
|
|
} else {
|
|
None
|
|
},
|
|
anthropic: if matches!(kind, ProviderKind::Anthropic) {
|
|
Some(gyandoot_core::AnthropicConfig {
|
|
api_key: api_key.to_string(),
|
|
model: model.to_string(),
|
|
max_retries: 3,
|
|
})
|
|
} else {
|
|
None
|
|
},
|
|
ollama: if matches!(kind, ProviderKind::Ollama) {
|
|
Some(gyandoot_core::OllamaConfig {
|
|
base_url: default_base_url(&kind),
|
|
model: model.to_string(),
|
|
api_key: None,
|
|
max_retries: 2,
|
|
})
|
|
} else {
|
|
None
|
|
},
|
|
};
|
|
|
|
Self::create_from_kind(kind, &config)
|
|
}
|
|
}
|
|
|
|
/// Default base URL for each provider kind.
|
|
fn default_base_url(kind: &ProviderKind) -> String {
|
|
match kind {
|
|
ProviderKind::OpenAI => "https://api.openai.com/v1".to_string(),
|
|
ProviderKind::Anthropic => "https://api.anthropic.com".to_string(),
|
|
ProviderKind::Ollama => "http://localhost:11434".to_string(),
|
|
ProviderKind::Gemini => "https://generativelanguage.googleapis.com/v1".to_string(),
|
|
ProviderKind::DeepSeek => "https://api.deepseek.com/v1".to_string(),
|
|
ProviderKind::OpenRouter => "https://openrouter.ai/api/v1".to_string(),
|
|
ProviderKind::Groq => "https://api.groq.com/openai/v1".to_string(),
|
|
}
|
|
}
|
|
|
|
// ─── Tests ────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_provider_kind_roundtrip() {
|
|
for kind in &[
|
|
ProviderKind::OpenAI,
|
|
ProviderKind::Anthropic,
|
|
ProviderKind::Ollama,
|
|
ProviderKind::Gemini,
|
|
ProviderKind::DeepSeek,
|
|
ProviderKind::OpenRouter,
|
|
ProviderKind::Groq,
|
|
] {
|
|
let s = kind.to_string();
|
|
let parsed = ProviderKind::from_str(&s).unwrap();
|
|
assert_eq!(*kind, parsed);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_provider_response_has_tool_calls() {
|
|
let r = ProviderResponse::new("hello");
|
|
assert!(!r.has_tool_calls());
|
|
|
|
let r = ProviderResponse::with_tool_calls(
|
|
"",
|
|
vec![ToolCall::new("1", "test", serde_json::Value::Null)],
|
|
"tool_calls",
|
|
);
|
|
assert!(r.has_tool_calls());
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_base_urls() {
|
|
assert!(default_base_url(&ProviderKind::OpenAI).contains("openai.com"));
|
|
assert!(default_base_url(&ProviderKind::Anthropic).contains("anthropic.com"));
|
|
assert!(default_base_url(&ProviderKind::Ollama).contains("localhost"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_factory_from_name() {
|
|
let provider = ProviderFactory::from_name("openai", "sk-test", "gpt-4o").unwrap();
|
|
assert!(provider.supports_tools());
|
|
}
|
|
|
|
#[test]
|
|
fn test_factory_invalid_name() {
|
|
let result = ProviderFactory::from_name("nonexistent", "", "");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_provider_kind_unknown_string() {
|
|
assert_eq!(ProviderKind::from_str("unknown"), None);
|
|
}
|
|
} |