fix(provider): fallback to config.api_key when auth-profiles.json has no key (#2724)

Co-authored-by: root <root@10.0.0.3>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
ccpty
2026-05-29 10:58:22 -07:00
committed by GitHub
co-authored by root Steven Enamakel
parent 45d1d2105b
commit fb708b92ae
2 changed files with 115 additions and 0 deletions
@@ -608,6 +608,36 @@ fn legacy_custom_inference_provider_string(config: &Config) -> Option<String> {
.map(|entry| cloud_entry_provider_string(entry, config))
}
/// Resolve the slug of the cloud-provider entry that represents the legacy
/// direct-inference route — the entry whose endpoint matches the configured
/// custom `inference_url`.
///
/// Top-level `config.api_key` was historically paired with `inference_url`
/// for direct endpoint routing, so it is scoped to this single provider. The
/// `lookup_key_for_slug` fallback uses this to avoid leaking the global key to
/// any other provider slug whose auth-profile lookup returned empty.
fn legacy_inference_slug(config: &Config) -> Option<&str> {
let inference_url = config
.inference_url
.as_deref()
.map(str::trim)
.filter(|url| !url.is_empty())?;
if looks_like_openhuman_backend(inference_url) {
return None;
}
let normalized_inference = normalize_endpoint_for_compare(inference_url);
config
.cloud_providers
.iter()
.find(|entry| {
!is_openhuman_cloud_entry(entry)
&& normalize_endpoint_for_compare(&entry.endpoint) == normalized_inference
})
.map(|entry| entry.slug.as_str())
}
fn cloud_entry_provider_string(
entry: &crate::openhuman::config::schema::cloud_providers::CloudProviderCreds,
config: &Config,
@@ -949,6 +979,28 @@ pub fn lookup_key_for_slug(slug: &str, config: &Config) -> anyhow::Result<String
}
}
// Fallback: read from top-level config.api_key (direct config.toml api_key).
// This handles the case where a key was set in config.toml but not saved
// through the UI into auth-profiles.json.
//
// Scoped to the legacy direct-inference provider only — the cloud-provider
// slug whose endpoint matches `config.inference_url`. `config.api_key` was
// historically paired with `inference_url` for direct endpoint routing, so
// an unscoped fallback would leak this global key to any other provider
// whose auth-profile lookup returned empty (cross-provider credential leak
// flagged by CodeRabbit + maintainers on #2724).
if legacy_inference_slug(config) == Some(slug) {
if let Some(config_key) = config.api_key.as_ref() {
if !config_key.trim().is_empty() {
log::debug!(
"[providers][chat-factory] auth lookup slug={} key_present=true (config.toml fallback for legacy inference_url)",
slug
);
return Ok(config_key.trim().to_string());
}
}
}
log::debug!(
"[providers][chat-factory] auth lookup slug={} key_present=false",
slug
@@ -1581,3 +1581,66 @@ fn nvidia_nim_falls_back_to_default_model_when_no_model_in_string() {
"should fall back to default_model from config entry"
);
}
// ── config.api_key fallback scoping (PR #2724) ───────────────────────────
/// Build a tempdir-backed Config with a global `config.api_key`, a custom
/// `inference_url`, and two cloud providers: one whose endpoint matches the
/// inference_url (the legacy direct-inference slug) and one that does not.
///
/// The tempdir workspace has no stored auth-profiles, so `lookup_key_for_slug`
/// exhausts the standard auth path and reaches the `config.api_key` fallback.
fn config_for_api_key_fallback(tmp: &TempDir) -> Config {
let mut custom = openai_entry("p_custom", "custom");
custom.endpoint = "https://inference.example.com/v1".to_string();
let config = config_with_providers_in_tempdir(
tmp,
vec![custom, anthropic_entry("p_anthropic", "anthropic")],
);
let mut config = config;
config.api_key = Some("global-key".to_string());
config.inference_url = Some("https://inference.example.com/v1".to_string());
config
}
/// The legacy direct-inference slug — the provider whose endpoint matches
/// `config.inference_url` — inherits the global `config.api_key`.
#[test]
fn config_api_key_fallback_applies_to_legacy_inference_slug() {
let tmp = TempDir::new().expect("tempdir");
let config = config_for_api_key_fallback(&tmp);
assert_eq!(
lookup_key_for_slug("custom", &config).expect("lookup must succeed"),
"global-key",
"legacy direct-inference slug must inherit config.api_key fallback",
);
}
/// Load-bearing negative assertion: a provider whose endpoint does NOT match
/// `config.inference_url` must NOT inherit the global `config.api_key`.
/// Without this guard the fallback would leak one provider's credential to
/// every other provider (cross-provider credential leak, PR #2724).
#[test]
fn config_api_key_fallback_does_not_leak_to_other_slugs() {
let tmp = TempDir::new().expect("tempdir");
let config = config_for_api_key_fallback(&tmp);
assert_eq!(
lookup_key_for_slug("anthropic", &config).expect("lookup must succeed"),
"",
"non-matching slug must NOT inherit config.api_key — would leak credentials",
);
}
/// When `inference_url` itself is unset, the `config.api_key` fallback never
/// fires (no legacy direct-inference slug to scope to), so no slug inherits it.
#[test]
fn config_api_key_fallback_inert_without_inference_url() {
let tmp = TempDir::new().expect("tempdir");
let mut config = config_for_api_key_fallback(&tmp);
config.inference_url = None;
assert_eq!(
lookup_key_for_slug("custom", &config).expect("lookup must succeed"),
"",
"without inference_url there is no legacy slug — fallback must stay inert",
);
}