From bb22bdc8fbcff1610a997c3979b31e2d1445988c Mon Sep 17 00:00:00 2001 From: Vernon Stinebaker Date: Thu, 19 Feb 2026 17:20:26 +0800 Subject: [PATCH] fix(provider): resolve fallback provider credentials independently Fallback providers in create_resilient_provider_with_options() were created via create_provider_with_options() which passed the primary provider's api_key as credential_override. This caused resolve_provider_credential() to short-circuit on the override and never check the fallback provider's own env var (e.g. DEEPSEEK_API_KEY for a deepseek fallback), resulting in auth failures (401) when the primary and fallback use different API services. Switch to create_provider_with_url(fallback, None, None) so each fallback resolves its own credential via provider-specific env vars. This also enables custom: URL prefixes (e.g. custom:http://host.docker.internal:1234/v1) to work as fallback entries, which was previously impossible through the options path. Add three focused tests covering independent credential resolution, custom URL fallbacks, and mixed fallback chains. --- src/providers/mod.rs | 83 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 3e1cf95..62b6c2d 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -820,8 +820,17 @@ pub fn create_resilient_provider_with_options( continue; } - // Fallback providers don't use the custom api_url (it's specific to primary). - match create_provider_with_options(fallback, api_key, options) { + // Each fallback provider resolves its own credential via provider- + // specific env vars (e.g. DEEPSEEK_API_KEY for "deepseek") instead + // of inheriting the primary provider's key. Passing `None` lets + // `resolve_provider_credential` check the correct env var for the + // fallback provider name. + // + // Route through `create_provider_with_url` (not + // `create_provider_with_options`) so that `custom:` URL prefixes + // (e.g. "custom:http://host.docker.internal:1234/v1") work as + // fallback entries. + match create_provider_with_url(fallback, None, None) { Ok(provider) => providers.push((fallback.clone(), provider)), Err(_error) => { tracing::warn!( @@ -1673,6 +1682,76 @@ mod tests { assert!(provider.is_err()); } + /// Fallback providers resolve their own credentials via provider-specific + /// env vars rather than inheriting the primary provider's key. A provider + /// that requires no key (e.g. lmstudio, ollama) must initialize + /// successfully even when the primary uses a completely different key. + #[test] + fn resilient_fallback_resolves_own_credential() { + let reliability = crate::config::ReliabilityConfig { + provider_retries: 1, + provider_backoff_ms: 100, + fallback_providers: vec!["lmstudio".into(), "ollama".into()], + api_keys: Vec::new(), + model_fallbacks: std::collections::HashMap::new(), + channel_initial_backoff_secs: 2, + channel_max_backoff_secs: 60, + scheduler_poll_secs: 15, + scheduler_retries: 2, + }; + + // Primary uses a ZAI key; fallbacks (lmstudio, ollama) should NOT + // receive this key — they resolve their own credentials independently. + let provider = create_resilient_provider("zai", Some("zai-test-key"), None, &reliability); + assert!(provider.is_ok()); + } + + /// `custom:` URL entries work as fallback providers, enabling arbitrary + /// OpenAI-compatible endpoints (e.g. local LM Studio on a Docker host). + #[test] + fn resilient_fallback_supports_custom_url() { + let reliability = crate::config::ReliabilityConfig { + provider_retries: 1, + provider_backoff_ms: 100, + fallback_providers: vec!["custom:http://host.docker.internal:1234/v1".into()], + api_keys: Vec::new(), + model_fallbacks: std::collections::HashMap::new(), + channel_initial_backoff_secs: 2, + channel_max_backoff_secs: 60, + scheduler_poll_secs: 15, + scheduler_retries: 2, + }; + + let provider = + create_resilient_provider("openai", Some("openai-test-key"), None, &reliability); + assert!(provider.is_ok()); + } + + /// Mixed fallback chain: named providers, custom URLs, and invalid entries + /// all coexist. Invalid entries are silently ignored; valid ones initialize. + #[test] + fn resilient_fallback_mixed_chain() { + let reliability = crate::config::ReliabilityConfig { + provider_retries: 1, + provider_backoff_ms: 100, + fallback_providers: vec![ + "deepseek".into(), + "custom:http://localhost:8080/v1".into(), + "nonexistent-provider".into(), + "lmstudio".into(), + ], + api_keys: Vec::new(), + model_fallbacks: std::collections::HashMap::new(), + channel_initial_backoff_secs: 2, + channel_max_backoff_secs: 60, + scheduler_poll_secs: 15, + scheduler_retries: 2, + }; + + let provider = create_resilient_provider("zai", Some("zai-test-key"), None, &reliability); + assert!(provider.is_ok()); + } + #[test] fn ollama_with_custom_url() { let provider = create_provider_with_url("ollama", None, Some("http://10.100.2.32:11434"));