zeroclaw/src/providers/mod.rs
argenis de la rosa 4e6da51924 merge: resolve conflicts between feat/whatsapp-email-channels and main
- Keep main's WhatsApp implementation (webhook-based, simpler)
- Preserve email channel fixes from our branch
- Merge all main branch updates (daemon, cron, health, etc.)
- Resolve Cargo.lock conflicts
2026-02-14 14:59:16 -05:00

411 lines
14 KiB
Rust

pub mod anthropic;
pub mod compatible;
pub mod gemini;
pub mod ollama;
pub mod openai;
pub mod openrouter;
pub mod reliable;
pub mod traits;
pub use traits::Provider;
use compatible::{AuthStyle, OpenAiCompatibleProvider};
use reliable::ReliableProvider;
/// Factory: create the right provider from config
#[allow(clippy::too_many_lines)]
pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<dyn Provider>> {
match name {
// ── Primary providers (custom implementations) ───────
"openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(api_key))),
"anthropic" => Ok(Box::new(anthropic::AnthropicProvider::new(api_key))),
"openai" => Ok(Box::new(openai::OpenAiProvider::new(api_key))),
"ollama" => Ok(Box::new(ollama::OllamaProvider::new(
api_key.filter(|k| !k.is_empty()),
))),
"gemini" | "google" | "google-gemini" => {
Ok(Box::new(gemini::GeminiProvider::new(api_key)))
}
// ── OpenAI-compatible providers ──────────────────────
"venice" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Venice", "https://api.venice.ai", api_key, AuthStyle::Bearer,
))),
"vercel" | "vercel-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Vercel AI Gateway", "https://api.vercel.ai", api_key, AuthStyle::Bearer,
))),
"cloudflare" | "cloudflare-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Cloudflare AI Gateway",
"https://gateway.ai.cloudflare.com/v1",
api_key,
AuthStyle::Bearer,
))),
"moonshot" | "kimi" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Moonshot", "https://api.moonshot.cn", api_key, AuthStyle::Bearer,
))),
"synthetic" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Synthetic", "https://api.synthetic.com", api_key, AuthStyle::Bearer,
))),
"opencode" | "opencode-zen" => Ok(Box::new(OpenAiCompatibleProvider::new(
"OpenCode Zen", "https://api.opencode.ai", api_key, AuthStyle::Bearer,
))),
"zai" | "z.ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Z.AI", "https://api.z.ai", api_key, AuthStyle::Bearer,
))),
"glm" | "zhipu" => Ok(Box::new(OpenAiCompatibleProvider::new(
"GLM", "https://open.bigmodel.cn/api/paas", api_key, AuthStyle::Bearer,
))),
"minimax" => Ok(Box::new(OpenAiCompatibleProvider::new(
"MiniMax", "https://api.minimax.chat", api_key, AuthStyle::Bearer,
))),
"bedrock" | "aws-bedrock" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Amazon Bedrock",
"https://bedrock-runtime.us-east-1.amazonaws.com",
api_key,
AuthStyle::Bearer,
))),
"qianfan" | "baidu" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Qianfan", "https://aip.baidubce.com", api_key, AuthStyle::Bearer,
))),
// ── Extended ecosystem (community favorites) ─────────
"groq" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Groq", "https://api.groq.com/openai", api_key, AuthStyle::Bearer,
))),
"mistral" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Mistral", "https://api.mistral.ai", api_key, AuthStyle::Bearer,
))),
"xai" | "grok" => Ok(Box::new(OpenAiCompatibleProvider::new(
"xAI", "https://api.x.ai", api_key, AuthStyle::Bearer,
))),
"deepseek" => Ok(Box::new(OpenAiCompatibleProvider::new(
"DeepSeek", "https://api.deepseek.com", api_key, AuthStyle::Bearer,
))),
"together" | "together-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Together AI", "https://api.together.xyz", api_key, AuthStyle::Bearer,
))),
"fireworks" | "fireworks-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Fireworks AI", "https://api.fireworks.ai/inference", api_key, AuthStyle::Bearer,
))),
"perplexity" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Perplexity", "https://api.perplexity.ai", api_key, AuthStyle::Bearer,
))),
"cohere" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Cohere", "https://api.cohere.com/compatibility", api_key, AuthStyle::Bearer,
))),
// ── Bring Your Own Provider (custom URL) ───────────
// Format: "custom:https://your-api.com" or "custom:http://localhost:1234"
name if name.starts_with("custom:") => {
let base_url = name.strip_prefix("custom:").unwrap_or("");
if base_url.is_empty() {
anyhow::bail!("Custom provider requires a URL. Format: custom:https://your-api.com");
}
Ok(Box::new(OpenAiCompatibleProvider::new(
"Custom",
base_url,
api_key,
AuthStyle::Bearer,
)))
}
_ => anyhow::bail!(
"Unknown provider: {name}. Check README for supported providers or run `zeroclaw onboard --interactive` to reconfigure.\n\
Tip: Use \"custom:https://your-api.com\" for any OpenAI-compatible endpoint."
),
}
}
/// Create provider chain with retry and fallback behavior.
pub fn create_resilient_provider(
primary_name: &str,
api_key: Option<&str>,
reliability: &crate::config::ReliabilityConfig,
) -> anyhow::Result<Box<dyn Provider>> {
let mut providers: Vec<(String, Box<dyn Provider>)> = Vec::new();
providers.push((
primary_name.to_string(),
create_provider(primary_name, api_key)?,
));
for fallback in &reliability.fallback_providers {
if fallback == primary_name || providers.iter().any(|(name, _)| name == fallback) {
continue;
}
match create_provider(fallback, api_key) {
Ok(provider) => providers.push((fallback.clone(), provider)),
Err(e) => {
tracing::warn!(
fallback_provider = fallback,
"Ignoring invalid fallback provider: {e}"
);
}
}
}
Ok(Box::new(ReliableProvider::new(
providers,
reliability.provider_retries,
reliability.provider_backoff_ms,
)))
}
#[cfg(test)]
mod tests {
use super::*;
// ── Primary providers ────────────────────────────────────
#[test]
fn factory_openrouter() {
assert!(create_provider("openrouter", Some("sk-test")).is_ok());
assert!(create_provider("openrouter", None).is_ok());
}
#[test]
fn factory_anthropic() {
assert!(create_provider("anthropic", Some("sk-test")).is_ok());
}
#[test]
fn factory_openai() {
assert!(create_provider("openai", Some("sk-test")).is_ok());
}
#[test]
fn factory_ollama() {
assert!(create_provider("ollama", None).is_ok());
}
#[test]
fn factory_gemini() {
assert!(create_provider("gemini", Some("test-key")).is_ok());
assert!(create_provider("google", Some("test-key")).is_ok());
assert!(create_provider("google-gemini", Some("test-key")).is_ok());
// Should also work without key (will try CLI auth)
assert!(create_provider("gemini", None).is_ok());
}
// ── OpenAI-compatible providers ──────────────────────────
#[test]
fn factory_venice() {
assert!(create_provider("venice", Some("vn-key")).is_ok());
}
#[test]
fn factory_vercel() {
assert!(create_provider("vercel", Some("key")).is_ok());
assert!(create_provider("vercel-ai", Some("key")).is_ok());
}
#[test]
fn factory_cloudflare() {
assert!(create_provider("cloudflare", Some("key")).is_ok());
assert!(create_provider("cloudflare-ai", Some("key")).is_ok());
}
#[test]
fn factory_moonshot() {
assert!(create_provider("moonshot", Some("key")).is_ok());
assert!(create_provider("kimi", Some("key")).is_ok());
}
#[test]
fn factory_synthetic() {
assert!(create_provider("synthetic", Some("key")).is_ok());
}
#[test]
fn factory_opencode() {
assert!(create_provider("opencode", Some("key")).is_ok());
assert!(create_provider("opencode-zen", Some("key")).is_ok());
}
#[test]
fn factory_zai() {
assert!(create_provider("zai", Some("key")).is_ok());
assert!(create_provider("z.ai", Some("key")).is_ok());
}
#[test]
fn factory_glm() {
assert!(create_provider("glm", Some("key")).is_ok());
assert!(create_provider("zhipu", Some("key")).is_ok());
}
#[test]
fn factory_minimax() {
assert!(create_provider("minimax", Some("key")).is_ok());
}
#[test]
fn factory_bedrock() {
assert!(create_provider("bedrock", Some("key")).is_ok());
assert!(create_provider("aws-bedrock", Some("key")).is_ok());
}
#[test]
fn factory_qianfan() {
assert!(create_provider("qianfan", Some("key")).is_ok());
assert!(create_provider("baidu", Some("key")).is_ok());
}
// ── Extended ecosystem ───────────────────────────────────
#[test]
fn factory_groq() {
assert!(create_provider("groq", Some("key")).is_ok());
}
#[test]
fn factory_mistral() {
assert!(create_provider("mistral", Some("key")).is_ok());
}
#[test]
fn factory_xai() {
assert!(create_provider("xai", Some("key")).is_ok());
assert!(create_provider("grok", Some("key")).is_ok());
}
#[test]
fn factory_deepseek() {
assert!(create_provider("deepseek", Some("key")).is_ok());
}
#[test]
fn factory_together() {
assert!(create_provider("together", Some("key")).is_ok());
assert!(create_provider("together-ai", Some("key")).is_ok());
}
#[test]
fn factory_fireworks() {
assert!(create_provider("fireworks", Some("key")).is_ok());
assert!(create_provider("fireworks-ai", Some("key")).is_ok());
}
#[test]
fn factory_perplexity() {
assert!(create_provider("perplexity", Some("key")).is_ok());
}
#[test]
fn factory_cohere() {
assert!(create_provider("cohere", Some("key")).is_ok());
}
// ── Custom / BYOP provider ─────────────────────────────
#[test]
fn factory_custom_url() {
let p = create_provider("custom:https://my-llm.example.com", Some("key"));
assert!(p.is_ok());
}
#[test]
fn factory_custom_localhost() {
let p = create_provider("custom:http://localhost:1234", Some("key"));
assert!(p.is_ok());
}
#[test]
fn factory_custom_no_key() {
let p = create_provider("custom:https://my-llm.example.com", None);
assert!(p.is_ok());
}
#[test]
fn factory_custom_empty_url_errors() {
match create_provider("custom:", None) {
Err(e) => assert!(
e.to_string().contains("requires a URL"),
"Expected 'requires a URL', got: {e}"
),
Ok(_) => panic!("Expected error for empty custom URL"),
}
}
// ── Error cases ──────────────────────────────────────────
#[test]
fn factory_unknown_provider_errors() {
let p = create_provider("nonexistent", None);
assert!(p.is_err());
let msg = p.err().unwrap().to_string();
assert!(msg.contains("Unknown provider"));
assert!(msg.contains("nonexistent"));
}
#[test]
fn factory_empty_name_errors() {
assert!(create_provider("", None).is_err());
}
#[test]
fn resilient_provider_ignores_duplicate_and_invalid_fallbacks() {
let reliability = crate::config::ReliabilityConfig {
provider_retries: 1,
provider_backoff_ms: 100,
fallback_providers: vec![
"openrouter".into(),
"nonexistent-provider".into(),
"openai".into(),
"openai".into(),
],
channel_initial_backoff_secs: 2,
channel_max_backoff_secs: 60,
scheduler_poll_secs: 15,
scheduler_retries: 2,
};
let provider = create_resilient_provider("openrouter", Some("sk-test"), &reliability);
assert!(provider.is_ok());
}
#[test]
fn resilient_provider_errors_for_invalid_primary() {
let reliability = crate::config::ReliabilityConfig::default();
let provider = create_resilient_provider("totally-invalid", Some("sk-test"), &reliability);
assert!(provider.is_err());
}
#[test]
fn factory_all_providers_create_successfully() {
let providers = [
"openrouter",
"anthropic",
"openai",
"ollama",
"gemini",
"venice",
"vercel",
"cloudflare",
"moonshot",
"synthetic",
"opencode",
"zai",
"glm",
"minimax",
"bedrock",
"qianfan",
"groq",
"mistral",
"xai",
"deepseek",
"together",
"fireworks",
"perplexity",
"cohere",
];
for name in providers {
assert!(
create_provider(name, Some("test-key")).is_ok(),
"Provider '{name}' should create successfully"
);
}
}
}