587 lines
20 KiB
Rust
587 lines
20 KiB
Rust
//! TG1: Provider End-to-End Resolution Tests
|
|
//!
|
|
//! Prevents: Pattern 1 — Provider configuration & resolution bugs (27% of user bugs).
|
|
//! Issues: #831, #834, #721, #580, #452, #451, #796, #843
|
|
//!
|
|
//! Tests the full pipeline from config values through `create_provider_with_url()`
|
|
//! to provider construction, verifying factory resolution, URL construction,
|
|
//! credential wiring, and auth header format.
|
|
|
|
use zeroclaw::providers::compatible::{AuthStyle, OpenAiCompatibleProvider};
|
|
use zeroclaw::providers::{
|
|
create_provider, create_provider_with_options, create_provider_with_url,
|
|
};
|
|
|
|
/// Helper: assert provider creation succeeds
|
|
fn assert_provider_ok(name: &str, key: Option<&str>, url: Option<&str>) {
|
|
let result = create_provider_with_url(name, key, url);
|
|
assert!(
|
|
result.is_ok(),
|
|
"{name} provider should resolve: {}",
|
|
result.err().map(|e| e.to_string()).unwrap_or_default()
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Factory resolution: each major provider name resolves without error
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn factory_resolves_openai_provider() {
|
|
assert_provider_ok("openai", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_anthropic_provider() {
|
|
assert_provider_ok("anthropic", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_deepseek_provider() {
|
|
assert_provider_ok("deepseek", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_mistral_provider() {
|
|
assert_provider_ok("mistral", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_ollama_provider() {
|
|
assert_provider_ok("ollama", None, None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_groq_provider() {
|
|
assert_provider_ok("groq", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_xai_provider() {
|
|
assert_provider_ok("xai", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_together_provider() {
|
|
assert_provider_ok("together", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_fireworks_provider() {
|
|
assert_provider_ok("fireworks", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_perplexity_provider() {
|
|
assert_provider_ok("perplexity", Some("test-key"), None);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Factory resolution: alias variants map to same provider
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn factory_grok_alias_resolves_to_xai() {
|
|
assert_provider_ok("grok", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_kimi_alias_resolves_to_moonshot() {
|
|
assert_provider_ok("kimi", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_zhipu_alias_resolves_to_glm() {
|
|
assert_provider_ok("zhipu", Some("test-key"), None);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Custom URL provider creation
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn factory_custom_http_url_resolves() {
|
|
assert_provider_ok("custom:http://localhost:8080", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_custom_https_url_resolves() {
|
|
assert_provider_ok("custom:https://api.example.com/v1", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_custom_ftp_url_rejected() {
|
|
let result = create_provider_with_url("custom:ftp://example.com", None, None);
|
|
assert!(result.is_err(), "ftp scheme should be rejected");
|
|
let err_msg = result.err().unwrap().to_string();
|
|
assert!(
|
|
err_msg.contains("http://") || err_msg.contains("https://"),
|
|
"error should mention valid schemes: {err_msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_custom_empty_url_rejected() {
|
|
let result = create_provider_with_url("custom:", None, None);
|
|
assert!(result.is_err(), "empty custom URL should be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn factory_unknown_provider_rejected() {
|
|
let result = create_provider_with_url("nonexistent_provider_xyz", None, None);
|
|
assert!(result.is_err(), "unknown provider name should be rejected");
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// OpenAiCompatibleProvider: credential and auth style wiring
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn compatible_provider_bearer_auth_style() {
|
|
// Construction with Bearer auth should succeed
|
|
let _provider = OpenAiCompatibleProvider::new(
|
|
"TestProvider",
|
|
"https://api.test.com",
|
|
Some("sk-test-key-12345"),
|
|
AuthStyle::Bearer,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compatible_provider_xapikey_auth_style() {
|
|
// Construction with XApiKey auth should succeed
|
|
let _provider = OpenAiCompatibleProvider::new(
|
|
"TestProvider",
|
|
"https://api.test.com",
|
|
Some("sk-test-key-12345"),
|
|
AuthStyle::XApiKey,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compatible_provider_custom_auth_header() {
|
|
// Construction with Custom auth should succeed
|
|
let _provider = OpenAiCompatibleProvider::new(
|
|
"TestProvider",
|
|
"https://api.test.com",
|
|
Some("sk-test-key-12345"),
|
|
AuthStyle::Custom("X-Custom-Auth".into()),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compatible_provider_no_credential() {
|
|
// Construction without credential should succeed (for local providers)
|
|
let _provider = OpenAiCompatibleProvider::new(
|
|
"TestLocal",
|
|
"http://localhost:11434",
|
|
None,
|
|
AuthStyle::Bearer,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compatible_provider_base_url_trailing_slash_normalized() {
|
|
// Construction with trailing slash URL should succeed
|
|
let _provider = OpenAiCompatibleProvider::new(
|
|
"TestProvider",
|
|
"https://api.test.com/v1/",
|
|
Some("key"),
|
|
AuthStyle::Bearer,
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Provider with api_url override (simulates #721 - Ollama api_url config)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn factory_ollama_with_custom_api_url() {
|
|
assert_provider_ok("ollama", None, Some("http://192.168.1.100:11434"));
|
|
}
|
|
|
|
#[test]
|
|
fn factory_openai_with_custom_api_url() {
|
|
assert_provider_ok(
|
|
"openai",
|
|
Some("test-key"),
|
|
Some("https://custom-openai-proxy.example.com/v1"),
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Provider default convenience factory
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn convenience_factory_resolves_major_providers() {
|
|
for provider_name in &[
|
|
"openai",
|
|
"anthropic",
|
|
"deepseek",
|
|
"mistral",
|
|
"groq",
|
|
"xai",
|
|
"together",
|
|
"fireworks",
|
|
"perplexity",
|
|
] {
|
|
let result = create_provider(provider_name, Some("test-key"));
|
|
assert!(
|
|
result.is_ok(),
|
|
"convenience factory should resolve {provider_name}: {}",
|
|
result.err().map(|e| e.to_string()).unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn convenience_factory_ollama_no_key() {
|
|
let result = create_provider("ollama", None);
|
|
assert!(
|
|
result.is_ok(),
|
|
"ollama should not require api key: {}",
|
|
result.err().map(|e| e.to_string()).unwrap_or_default()
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Primary providers with custom implementations
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn factory_resolves_openrouter_provider() {
|
|
assert_provider_ok("openrouter", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_gemini_provider() {
|
|
assert_provider_ok("gemini", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_bedrock_provider() {
|
|
assert_provider_ok("bedrock", None, None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_copilot_provider() {
|
|
assert_provider_ok("copilot", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_synthetic_provider() {
|
|
assert_provider_ok("synthetic", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_openai_codex_provider() {
|
|
let options = zeroclaw::providers::ProviderRuntimeOptions::default();
|
|
let result = create_provider_with_options("openai-codex", None, &options);
|
|
assert!(
|
|
result.is_ok(),
|
|
"openai-codex provider should resolve: {}",
|
|
result.err().map(|e| e.to_string()).unwrap_or_default()
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// OpenAI-compatible ecosystem providers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn factory_resolves_venice_provider() {
|
|
assert_provider_ok("venice", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_cohere_provider() {
|
|
assert_provider_ok("cohere", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_opencode_provider() {
|
|
assert_provider_ok("opencode", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_astrai_provider() {
|
|
assert_provider_ok("astrai", Some("test-key"), None);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// China region providers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn factory_resolves_moonshot_provider() {
|
|
assert_provider_ok("moonshot", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_glm_provider() {
|
|
assert_provider_ok("glm", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_qwen_provider() {
|
|
assert_provider_ok("qwen", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_doubao_provider() {
|
|
assert_provider_ok("doubao", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_qianfan_provider() {
|
|
assert_provider_ok("qianfan", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_minimax_provider() {
|
|
assert_provider_ok("minimax", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_kimi_code_provider() {
|
|
assert_provider_ok("kimi-code", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_zai_provider() {
|
|
assert_provider_ok("zai", Some("test-key"), None);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Local/self-hosted providers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn factory_resolves_lmstudio_provider() {
|
|
assert_provider_ok("lmstudio", None, None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_llamacpp_provider() {
|
|
assert_provider_ok("llamacpp", None, None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_vllm_provider() {
|
|
assert_provider_ok("vllm", None, None);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Cloud AI endpoints
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn factory_resolves_vercel_provider() {
|
|
assert_provider_ok("vercel", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_cloudflare_provider() {
|
|
assert_provider_ok("cloudflare", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_nvidia_provider() {
|
|
assert_provider_ok("nvidia", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_resolves_ovhcloud_provider() {
|
|
assert_provider_ok("ovhcloud", Some("test-key"), None);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Alias resolution tests
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn factory_google_alias_resolves_to_gemini() {
|
|
assert_provider_ok("google", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_google_gemini_alias_resolves_to_gemini() {
|
|
assert_provider_ok("google-gemini", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_aws_bedrock_alias_resolves_to_bedrock() {
|
|
assert_provider_ok("aws-bedrock", None, None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_github_copilot_alias_resolves_to_copilot() {
|
|
assert_provider_ok("github-copilot", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_vercel_ai_alias_resolves_to_vercel() {
|
|
assert_provider_ok("vercel-ai", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_cloudflare_ai_alias_resolves_to_cloudflare() {
|
|
assert_provider_ok("cloudflare-ai", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_opencode_zen_alias_resolves_to_opencode() {
|
|
assert_provider_ok("opencode-zen", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_lm_studio_alias_resolves_to_lmstudio() {
|
|
assert_provider_ok("lm-studio", None, None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_llama_cpp_alias_resolves_to_llamacpp() {
|
|
assert_provider_ok("llama.cpp", None, None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_nvidia_nim_alias_resolves_to_nvidia() {
|
|
assert_provider_ok("nvidia-nim", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_build_nvidia_com_alias_resolves_to_nvidia() {
|
|
assert_provider_ok("build.nvidia.com", Some("test-key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn factory_ovh_alias_resolves_to_ovhcloud() {
|
|
assert_provider_ok("ovh", Some("test-key"), None);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Custom endpoint tests
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn factory_anthropic_custom_endpoint_resolves() {
|
|
assert_provider_ok(
|
|
"anthropic-custom:https://api.example.com",
|
|
Some("test-key"),
|
|
None,
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn model_fallback_within_same_provider() {
|
|
use std::collections::HashMap;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::Arc;
|
|
use zeroclaw::providers::reliable::ReliableProvider;
|
|
use zeroclaw::providers::traits::{ChatMessage, Provider};
|
|
|
|
// Mock provider that fails for gemini-2.0 but succeeds for gemini-1.5-pro
|
|
struct GeminiMock {
|
|
calls: Arc<AtomicUsize>,
|
|
requested_models: Arc<std::sync::Mutex<Vec<String>>>,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Provider for GeminiMock {
|
|
async fn chat_with_system(
|
|
&self,
|
|
_system: Option<&str>,
|
|
_message: &str,
|
|
model: &str,
|
|
_temp: f64,
|
|
) -> anyhow::Result<String> {
|
|
self.calls.fetch_add(1, Ordering::SeqCst);
|
|
self.requested_models
|
|
.lock()
|
|
.unwrap()
|
|
.push(model.to_string());
|
|
|
|
if model == "gemini-2.0-flash-exp" {
|
|
// Simulate quota exceeded for gemini-2.0
|
|
anyhow::bail!("429 Rate Limited: quota exceeded for gemini-2.0-flash-exp")
|
|
} else if model == "gemini-1.5-pro" {
|
|
Ok(format!("success with {}", model))
|
|
} else {
|
|
anyhow::bail!("Unknown model: {}", model)
|
|
}
|
|
}
|
|
|
|
async fn chat_with_history(
|
|
&self,
|
|
_messages: &[ChatMessage],
|
|
_model: &str,
|
|
_temp: f64,
|
|
) -> anyhow::Result<String> {
|
|
unimplemented!()
|
|
}
|
|
|
|
async fn chat_with_tools(
|
|
&self,
|
|
_messages: &[ChatMessage],
|
|
_tools: &[serde_json::Value],
|
|
_model: &str,
|
|
_temp: f64,
|
|
) -> anyhow::Result<zeroclaw::providers::traits::ChatResponse> {
|
|
unimplemented!()
|
|
}
|
|
}
|
|
|
|
let requested_models = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
|
|
let gemini = GeminiMock {
|
|
calls: Arc::new(AtomicUsize::new(0)),
|
|
requested_models: Arc::clone(&requested_models),
|
|
};
|
|
|
|
// Configure model fallback: gemini-2.0-flash-exp → gemini-1.5-pro
|
|
let mut model_fallbacks = HashMap::new();
|
|
model_fallbacks.insert(
|
|
"gemini-2.0-flash-exp".to_string(),
|
|
vec!["gemini-1.5-pro".to_string()],
|
|
);
|
|
|
|
let provider = ReliableProvider::new(
|
|
vec![("gemini".to_string(), Box::new(gemini) as Box<dyn Provider>)],
|
|
1, // 1 retry
|
|
10, // 10ms backoff
|
|
)
|
|
.with_model_fallbacks(model_fallbacks);
|
|
|
|
// Request with gemini-2.0-flash-exp (will fail due to quota)
|
|
let result = provider
|
|
.chat_with_system(None, "test", "gemini-2.0-flash-exp", 0.7)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Verify model fallback worked
|
|
let models = requested_models.lock().unwrap();
|
|
|
|
// Should try gemini-2.0 (with retries), then fallback to gemini-1.5-pro
|
|
assert!(
|
|
models.len() >= 2,
|
|
"Should try at least 2 models (with retries): {:?}",
|
|
models
|
|
);
|
|
|
|
// First attempts should be gemini-2.0 (potentially multiple due to retries)
|
|
assert_eq!(models[0], "gemini-2.0-flash-exp", "First try gemini-2.0");
|
|
|
|
// Last attempt should be the fallback model
|
|
assert_eq!(
|
|
models.last().unwrap(),
|
|
"gemini-1.5-pro",
|
|
"Should eventually fallback to gemini-1.5-pro"
|
|
);
|
|
|
|
assert!(
|
|
result.contains("gemini-1.5-pro"),
|
|
"Final response should be from fallback model"
|
|
);
|
|
}
|