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.
This commit is contained in:
Vernon Stinebaker 2026-02-19 17:20:26 +08:00 committed by Chummy
parent f9e1ffe634
commit bb22bdc8fb

View file

@ -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"));