fix: allow empty provider secrets to attempt requests

This commit is contained in:
fawney19
2026-04-29 18:55:52 +08:00
parent d6de917878
commit a16550249b
5 changed files with 190 additions and 22 deletions

View File

@@ -82,3 +82,116 @@ pub(crate) async fn resolve_candidate_oauth_auth(
} }
} }
} }
#[cfg(test)]
mod tests {
use aether_provider_transport::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
};
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use super::{prepare_header_authenticated_candidate, OauthPreparationContext};
use crate::ai_pipeline::PlannerAppState;
fn sample_transport() -> GatewayProviderTransportSnapshot {
GatewayProviderTransportSnapshot {
provider: GatewayProviderTransportProvider {
id: "provider-1".to_string(),
name: "provider".to_string(),
provider_type: "custom".to_string(),
website: None,
is_active: true,
keep_priority_on_conversion: false,
enable_format_conversion: false,
concurrent_limit: None,
max_retries: None,
proxy: None,
request_timeout_secs: None,
stream_first_byte_timeout_secs: None,
config: None,
},
endpoint: GatewayProviderTransportEndpoint {
id: "endpoint-1".to_string(),
provider_id: "provider-1".to_string(),
api_format: "openai:chat".to_string(),
api_family: Some("openai".to_string()),
endpoint_kind: Some("chat".to_string()),
is_active: true,
base_url: "https://example.test".to_string(),
header_rules: None,
body_rules: None,
max_retries: None,
custom_path: None,
config: None,
format_acceptance_config: None,
proxy: None,
},
key: GatewayProviderTransportKey {
id: "key-1".to_string(),
provider_id: "provider-1".to_string(),
name: "key".to_string(),
auth_type: "api_key".to_string(),
is_active: true,
api_formats: Some(vec!["openai:chat".to_string()]),
auth_type_by_format: None,
allowed_models: None,
capabilities: None,
rate_multipliers: None,
global_priority_by_format: None,
expires_at_unix_secs: None,
proxy: None,
fingerprint: None,
decrypted_api_key: String::new(),
decrypted_auth_config: None,
},
}
}
fn sample_candidate() -> SchedulerMinimalCandidateSelectionCandidate {
SchedulerMinimalCandidateSelectionCandidate {
provider_id: "provider-1".to_string(),
provider_name: "provider".to_string(),
provider_type: "custom".to_string(),
provider_priority: 1,
endpoint_id: "endpoint-1".to_string(),
endpoint_api_format: "openai:chat".to_string(),
key_id: "key-1".to_string(),
key_name: "key".to_string(),
key_auth_type: "api_key".to_string(),
key_internal_priority: 1,
key_global_priority_for_format: None,
key_capabilities: None,
model_id: "model-1".to_string(),
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-test".to_string(),
selected_provider_model_name: "gpt-test-upstream".to_string(),
mapping_matched_model: None,
}
}
#[tokio::test]
async fn header_auth_preparation_allows_empty_auth_value() {
let state = crate::AppState::new().expect("state should build");
let transport = sample_transport();
let candidate = sample_candidate();
let prepared = prepare_header_authenticated_candidate(
PlannerAppState::new(&state),
&transport,
&candidate,
Some(("authorization".to_string(), String::new())),
OauthPreparationContext {
trace_id: "trace-empty-auth",
api_format: "openai:chat",
operation: "test",
},
)
.await
.expect("empty auth value should still prepare the candidate");
assert_eq!(prepared.auth_header, "authorization");
assert_eq!(prepared.auth_value, "");
assert_eq!(prepared.mapped_model, "gpt-test-upstream");
}
}

View File

@@ -242,22 +242,23 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
return None; return None;
}; };
let mut provider_request_headers = BTreeMap::from([ let mut provider_request_headers =
("content-type".to_string(), "application/json".to_string()), BTreeMap::from([("content-type".to_string(), "application/json".to_string())]);
(auth_header.clone(), auth_value.clone()), if !auth_header.trim().is_empty() && !auth_value.trim().is_empty() {
]); provider_request_headers.insert(auth_header.clone(), auth_value.clone());
}
if uses_vertex_query_auth { if uses_vertex_query_auth {
provider_request_headers.remove("x-goog-api-key"); provider_request_headers.remove("x-goog-api-key");
} }
let protected_headers = if uses_vertex_query_auth { let protected_headers = if uses_vertex_query_auth || auth_value.trim().is_empty() {
&["content-type"][..] vec!["content-type"]
} else { } else {
&[auth_header.as_str(), "content-type"][..] vec![auth_header.as_str(), "content-type"]
}; };
if !crate::provider_transport::apply_local_header_rules( if !crate::provider_transport::apply_local_header_rules(
&mut provider_request_headers, &mut provider_request_headers,
transport.endpoint.header_rules.as_ref(), transport.endpoint.header_rules.as_ref(),
protected_headers, &protected_headers,
&provider_request_body, &provider_request_body,
None, None,
) { ) {

View File

@@ -95,8 +95,12 @@ pub async fn build_standard_models_fetch_execution_plan(
.ok_or_else(|| { .ok_or_else(|| {
"Rust models fetch auth resolution is not supported for this key".to_string() "Rust models fetch auth resolution is not supported for this key".to_string()
})?; })?;
protected_headers.push(auth_header_name.clone()); insert_non_empty_auth_header(
headers.insert(auth_header_name.clone(), auth_header_value.clone()); &mut headers,
&mut protected_headers,
&auth_header_name,
&auth_header_value,
);
headers = apply_fetch_header_rules(transport, headers, &protected_headers)?; headers = apply_fetch_header_rules(transport, headers, &protected_headers)?;
ensure_upstream_auth_header(&mut headers, &auth_header_name, &auth_header_value); ensure_upstream_auth_header(&mut headers, &auth_header_name, &auth_header_value);
} else { } else {
@@ -181,6 +185,7 @@ pub async fn build_gemini_cli_load_code_assist_plan(
) -> Result<ExecutionPlan, String> { ) -> Result<ExecutionPlan, String> {
let authorization = resolve_bearer_or_oauth_header_auth(runtime, transport) let authorization = resolve_bearer_or_oauth_header_auth(runtime, transport)
.await? .await?
.filter(|(_, value)| !value.trim().is_empty())
.ok_or_else(|| "GeminiCLI loadCodeAssist requires bearer or OAuth auth".to_string())?; .ok_or_else(|| "GeminiCLI loadCodeAssist requires bearer or OAuth auth".to_string())?;
let mut headers = BTreeMap::from([ let mut headers = BTreeMap::from([
@@ -188,8 +193,13 @@ pub async fn build_gemini_cli_load_code_assist_plan(
("accept-encoding".to_string(), "identity".to_string()), ("accept-encoding".to_string(), "identity".to_string()),
("content-type".to_string(), "application/json".to_string()), ("content-type".to_string(), "application/json".to_string()),
]); ]);
headers.insert(authorization.0.clone(), authorization.1.clone()); let mut protected_headers = Vec::new();
let protected_headers = vec![authorization.0]; insert_non_empty_auth_header(
&mut headers,
&mut protected_headers,
&authorization.0,
&authorization.1,
);
headers = apply_fetch_header_rules(transport, headers, &protected_headers)?; headers = apply_fetch_header_rules(transport, headers, &protected_headers)?;
build_execution_plan( build_execution_plan(
@@ -225,8 +235,7 @@ pub async fn build_vertex_models_fetch_execution_plan(
let mut headers = standard_models_fetch_headers(api_format, &transport.provider.provider_type); let mut headers = standard_models_fetch_headers(api_format, &transport.provider.provider_type);
let mut protected_headers = Vec::<String>::new(); let mut protected_headers = Vec::<String>::new();
if let Some((name, value)) = auth_header { if let Some((name, value)) = auth_header {
protected_headers.push(name.clone()); insert_non_empty_auth_header(&mut headers, &mut protected_headers, &name, &value);
headers.insert(name.clone(), value.clone());
headers = apply_fetch_header_rules(transport, headers, &protected_headers)?; headers = apply_fetch_header_rules(transport, headers, &protected_headers)?;
ensure_upstream_auth_header(&mut headers, &name, &value); ensure_upstream_auth_header(&mut headers, &name, &value);
} else { } else {
@@ -472,6 +481,22 @@ fn append_query_param(mut url: String, key: &str, value: &str) -> String {
url url
} }
fn insert_non_empty_auth_header(
headers: &mut BTreeMap<String, String>,
protected_headers: &mut Vec<String>,
name: &str,
value: &str,
) {
let name = name.trim();
let value = value.trim();
if name.is_empty() || value.is_empty() {
return;
}
protected_headers.push(name.to_string());
headers.insert(name.to_string(), value.to_string());
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use aether_contracts::{ExecutionPlan, ExecutionResult, ProxySnapshot}; use aether_contracts::{ExecutionPlan, ExecutionResult, ProxySnapshot};

View File

@@ -236,7 +236,7 @@ pub fn resolve_local_openai_bearer_auth(
} }
let secret = resolved_local_secret(transport)?; let secret = resolved_local_secret(transport)?;
Some(("authorization".to_string(), format!("Bearer {secret}"))) Some(("authorization".to_string(), bearer_auth_value(secret)))
} }
pub fn resolve_local_standard_auth( pub fn resolve_local_standard_auth(
@@ -247,7 +247,7 @@ pub fn resolve_local_standard_auth(
match auth_type.as_str() { match auth_type.as_str() {
"api_key" => Some(("x-api-key".to_string(), secret.to_string())), "api_key" => Some(("x-api-key".to_string(), secret.to_string())),
"bearer" => Some(("authorization".to_string(), format!("Bearer {secret}"))), "bearer" => Some(("authorization".to_string(), bearer_auth_value(secret))),
_ => None, _ => None,
} }
} }
@@ -260,7 +260,7 @@ pub fn resolve_local_gemini_auth(
match auth_type.as_str() { match auth_type.as_str() {
"api_key" => Some(("x-goog-api-key".to_string(), secret.to_string())), "api_key" => Some(("x-goog-api-key".to_string(), secret.to_string())),
"bearer" => Some(("authorization".to_string(), format!("Bearer {secret}"))), "bearer" => Some(("authorization".to_string(), bearer_auth_value(secret))),
_ => None, _ => None,
} }
} }
@@ -291,7 +291,21 @@ pub(crate) fn resolve_local_auth_type_for_transport_format(
fn resolved_local_secret(transport: &GatewayProviderTransportSnapshot) -> Option<&str> { fn resolved_local_secret(transport: &GatewayProviderTransportSnapshot) -> Option<&str> {
let secret = transport.key.decrypted_api_key.trim(); let secret = transport.key.decrypted_api_key.trim();
(!secret.is_empty() && secret != PLACEHOLDER_API_KEY).then_some(secret) if !secret.is_empty() && secret != PLACEHOLDER_API_KEY {
Some(secret)
} else if transport.key.decrypted_auth_config.is_some() {
None
} else {
Some("")
}
}
fn bearer_auth_value(secret: &str) -> String {
if secret.is_empty() {
String::new()
} else {
format!("Bearer {secret}")
}
} }
#[cfg(test)] #[cfg(test)]
@@ -465,16 +479,31 @@ mod tests {
} }
#[test] #[test]
fn local_standard_auth_rejects_placeholder_secret() { fn local_standard_auth_keeps_header_shape_for_placeholder_secret() {
assert!(resolve_local_standard_auth(&sample_transport()).is_none()); assert_eq!(
resolve_local_standard_auth(&sample_transport()),
Some(("authorization".to_string(), String::new()))
);
} }
#[test] #[test]
fn local_standard_auth_rejects_empty_secret() { fn local_standard_auth_keeps_header_shape_for_empty_secret() {
let mut transport = sample_transport(); let mut transport = sample_transport();
transport.key.auth_type = "api_key".to_string(); transport.key.auth_type = "api_key".to_string();
transport.key.decrypted_api_key = String::new(); transport.key.decrypted_api_key = String::new();
assert_eq!(
resolve_local_standard_auth(&transport),
Some(("x-api-key".to_string(), String::new()))
);
}
#[test]
fn local_standard_auth_defers_to_auth_config_when_raw_secret_is_empty() {
let mut transport = sample_transport();
transport.key.decrypted_auth_config =
Some(r#"{"access_token":"cached-token"}"#.to_string());
assert!(resolve_local_standard_auth(&transport).is_none()); assert!(resolve_local_standard_auth(&transport).is_none());
} }

View File

@@ -3,6 +3,6 @@ use super::super::snapshot::GatewayProviderTransportSnapshot;
use super::super::supports_local_oauth_request_auth_resolution; use super::super::supports_local_oauth_request_auth_resolution;
pub fn supports_local_claude_code_auth(transport: &GatewayProviderTransportSnapshot) -> bool { pub fn supports_local_claude_code_auth(transport: &GatewayProviderTransportSnapshot) -> bool {
resolve_local_standard_auth(transport).is_some() resolve_local_standard_auth(transport).is_some_and(|(_, value)| !value.trim().is_empty())
|| supports_local_oauth_request_auth_resolution(transport) || supports_local_oauth_request_auth_resolution(transport)
} }