mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 20:19:51 +08:00
fix: preserve provider auth metadata
This commit is contained in:
@@ -3,14 +3,12 @@ use std::collections::BTreeMap;
|
||||
use serde_json::Value;
|
||||
use url::form_urlencoded;
|
||||
|
||||
const UNSAFE_AUTH_CONFIG_HEADER_NAMES: &[&str] = &[
|
||||
const UNSAFE_AUTH_CONFIG_HEADER_NAMES: &[&str] = &["content-length", "host", "proxy-authorization"];
|
||||
const RUNTIME_ONLY_AUTH_CONFIG_HEADER_NAMES: &[&str] = &[
|
||||
"api-key",
|
||||
"authorization",
|
||||
"content-length",
|
||||
"content-type",
|
||||
"cookie",
|
||||
"host",
|
||||
"proxy-authorization",
|
||||
"x-api-key",
|
||||
"x-goog-api-key",
|
||||
];
|
||||
@@ -44,14 +42,20 @@ const IGNORABLE_AUTH_CONFIG_METADATA_KEYS: &[&str] = &[
|
||||
"account_name",
|
||||
"account_user_id",
|
||||
"auth_method",
|
||||
"access_token_import_temporary",
|
||||
"email",
|
||||
"expires_at",
|
||||
"model_regions",
|
||||
"organizations",
|
||||
"plan_type",
|
||||
"project_id",
|
||||
"provider_type",
|
||||
"refresh_token_import_error",
|
||||
"region",
|
||||
"scope",
|
||||
"tier",
|
||||
"token_type",
|
||||
"updated_at",
|
||||
"user_id",
|
||||
"workspace_id",
|
||||
"workspace_name",
|
||||
@@ -81,6 +85,30 @@ pub enum LocalAuthConfigAbsorption {
|
||||
},
|
||||
}
|
||||
|
||||
pub fn apply_local_auth_config_header_overrides(
|
||||
headers: &mut BTreeMap<String, String>,
|
||||
raw_auth_config: Option<&str>,
|
||||
) {
|
||||
let Some(raw_auth_config) = raw_auth_config
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Ok(parsed) = serde_json::from_str::<Value>(raw_auth_config) else {
|
||||
return;
|
||||
};
|
||||
let Some(object) = parsed.as_object() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut overrides = BTreeMap::new();
|
||||
collect_auth_config_header_overrides(object, &mut overrides);
|
||||
for (key, value) in overrides {
|
||||
headers.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn absorb_local_auth_config_safe_subset(
|
||||
base_url: &str,
|
||||
header_rules: Option<Value>,
|
||||
@@ -130,6 +158,12 @@ fn parse_local_auth_config_safe_subset(raw: &str) -> Result<LocalAuthConfigSafeS
|
||||
let mut path = None;
|
||||
|
||||
parse_local_auth_config_object(object, &mut headers, &mut query, &mut path, true)?;
|
||||
if headers
|
||||
.keys()
|
||||
.any(|key| RUNTIME_ONLY_AUTH_CONFIG_HEADER_NAMES.contains(&key.as_str()))
|
||||
{
|
||||
return Err(());
|
||||
}
|
||||
|
||||
Ok(LocalAuthConfigSafeSubset {
|
||||
headers,
|
||||
@@ -138,6 +172,26 @@ fn parse_local_auth_config_safe_subset(raw: &str) -> Result<LocalAuthConfigSafeS
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_auth_config_header_overrides(
|
||||
object: &serde_json::Map<String, Value>,
|
||||
out: &mut BTreeMap<String, String>,
|
||||
) {
|
||||
for (key, value) in object {
|
||||
let normalized = key.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"headers" | "extra_headers" | "extraheaders" => {
|
||||
merge_header_string_map_lenient(out, value);
|
||||
}
|
||||
"transport" | "request" => {
|
||||
if let Some(nested) = value.as_object() {
|
||||
collect_auth_config_header_overrides(nested, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_local_auth_config_object(
|
||||
object: &serde_json::Map<String, Value>,
|
||||
headers: &mut BTreeMap<String, String>,
|
||||
@@ -149,7 +203,7 @@ fn parse_local_auth_config_object(
|
||||
let normalized = key.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"headers" | "extra_headers" | "extraheaders" => {
|
||||
merge_string_map(headers, value, normalize_auth_config_header_name)?
|
||||
merge_header_string_map(headers, value)?
|
||||
}
|
||||
"query" | "query_params" | "queryparams" => {
|
||||
merge_string_map(query, value, normalize_auth_config_query_key)?
|
||||
@@ -206,6 +260,38 @@ fn parse_static_auth_config_value(value: &Value) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_header_string_map(out: &mut BTreeMap<String, String>, value: &Value) -> Result<(), ()> {
|
||||
let object = value.as_object().ok_or(())?;
|
||||
for (raw_key, raw_value) in object {
|
||||
let key = normalize_auth_config_header_name(raw_key).ok_or(())?;
|
||||
let value = parse_static_auth_config_header_value(raw_value).ok_or(())?;
|
||||
out.insert(key, value);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_header_string_map_lenient(out: &mut BTreeMap<String, String>, value: &Value) {
|
||||
let Some(object) = value.as_object() else {
|
||||
return;
|
||||
};
|
||||
for (raw_key, raw_value) in object {
|
||||
let Some(key) = normalize_auth_config_header_name(raw_key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(value) = parse_static_auth_config_header_value(raw_value) else {
|
||||
continue;
|
||||
};
|
||||
out.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_static_auth_config_header_value(value: &Value) -> Option<String> {
|
||||
let value = parse_static_auth_config_value(value)?;
|
||||
http::header::HeaderValue::from_str(&value)
|
||||
.is_ok()
|
||||
.then_some(value)
|
||||
}
|
||||
|
||||
fn normalize_auth_config_header_name(raw: &str) -> Option<String> {
|
||||
let value = raw.trim().to_ascii_lowercase();
|
||||
if value.is_empty()
|
||||
@@ -379,7 +465,10 @@ fn split_path_and_query(path: &str) -> Option<(String, BTreeMap<String, String>)
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{absorb_local_auth_config_safe_subset, LocalAuthConfigAbsorption};
|
||||
use super::{
|
||||
absorb_local_auth_config_safe_subset, apply_local_auth_config_header_overrides,
|
||||
LocalAuthConfigAbsorption,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn absorbs_static_headers_and_query_into_existing_transport_fields() {
|
||||
@@ -469,7 +558,7 @@ mod tests {
|
||||
"https://api.openai.example/v1",
|
||||
None,
|
||||
None,
|
||||
Some(r#"{"headers":{"authorization":"Bearer x"}}"#),
|
||||
Some(r#"{"headers":{"host":"api.example.test"}}"#),
|
||||
),
|
||||
LocalAuthConfigAbsorption::Unsupported
|
||||
);
|
||||
@@ -484,6 +573,100 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_header_overrides_even_when_auth_config_has_refresh_token() {
|
||||
let mut headers = std::collections::BTreeMap::from([
|
||||
(
|
||||
"authorization".to_string(),
|
||||
"Bearer direct-token".to_string(),
|
||||
),
|
||||
("content-type".to_string(), "application/json".to_string()),
|
||||
]);
|
||||
|
||||
apply_local_auth_config_header_overrides(
|
||||
&mut headers,
|
||||
Some(
|
||||
r#"{
|
||||
"refresh_token": "rt-1",
|
||||
"headers": {
|
||||
"authorization": "Bearer imported-session",
|
||||
"content-type": "text/plain",
|
||||
"host": "blocked.example"
|
||||
}
|
||||
}"#,
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
headers.get("authorization"),
|
||||
Some(&"Bearer imported-session".to_string())
|
||||
);
|
||||
assert_eq!(headers.get("content-type"), Some(&"text/plain".to_string()));
|
||||
assert!(!headers.contains_key("host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_invalid_auth_config_header_values_when_applying_overrides() {
|
||||
let mut headers = std::collections::BTreeMap::new();
|
||||
|
||||
apply_local_auth_config_header_overrides(
|
||||
&mut headers,
|
||||
Some(r#"{"headers":{"authorization":"Bearer ok","x-bad":"line\nbreak"}}"#),
|
||||
);
|
||||
|
||||
assert_eq!(headers.get("authorization"), Some(&"Bearer ok".to_string()));
|
||||
assert!(!headers.contains_key("x-bad"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_imported_authorization_headers_for_runtime_override() {
|
||||
assert_eq!(
|
||||
absorb_local_auth_config_safe_subset(
|
||||
"https://api.openai.example/v1",
|
||||
None,
|
||||
None,
|
||||
Some(
|
||||
r#"{
|
||||
"provider_type": "codex",
|
||||
"access_token_import_temporary": true,
|
||||
"headers": {
|
||||
"authorization": "Bearer imported-session",
|
||||
"chatgpt-account-id": "acct-1"
|
||||
}
|
||||
}"#,
|
||||
),
|
||||
),
|
||||
LocalAuthConfigAbsorption::Unsupported
|
||||
);
|
||||
|
||||
let mut headers = std::collections::BTreeMap::from([(
|
||||
"authorization".to_string(),
|
||||
"Bearer direct-token".to_string(),
|
||||
)]);
|
||||
apply_local_auth_config_header_overrides(
|
||||
&mut headers,
|
||||
Some(
|
||||
r#"{
|
||||
"provider_type": "codex",
|
||||
"access_token_import_temporary": true,
|
||||
"headers": {
|
||||
"authorization": "Bearer imported-session",
|
||||
"chatgpt-account-id": "acct-1"
|
||||
}
|
||||
}"#,
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
headers.get("authorization"),
|
||||
Some(&"Bearer imported-session".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("chatgpt-account-id"),
|
||||
Some(&"acct-1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absorbs_query_only_configs_into_base_url_for_dynamic_path_formats() {
|
||||
let result = absorb_local_auth_config_safe_subset(
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod windsurf;
|
||||
|
||||
pub use aether_oauth as oauth;
|
||||
pub use auth::{build_passthrough_headers, ensure_upstream_auth_header};
|
||||
pub use auth_config::apply_local_auth_config_header_overrides;
|
||||
pub use cache::{provider_transport_snapshot_looks_refreshed, ProviderTransportSnapshotCacheKey};
|
||||
pub use conversion::{
|
||||
candidate_common_transport_skip_reason, candidate_transport_pair_skip_reason,
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::url::{build_openai_image_url, build_openai_responses_url};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ProviderOpenAiImageHeadersInput<'a> {
|
||||
pub transport: &'a GatewayProviderTransportSnapshot,
|
||||
pub headers: &'a http::HeaderMap,
|
||||
pub auth_header: &'a str,
|
||||
pub auth_value: &'a str,
|
||||
@@ -82,6 +83,10 @@ pub fn build_openai_image_headers(
|
||||
);
|
||||
provider_request_headers.insert("content-type".to_string(), "application/json".to_string());
|
||||
provider_request_headers.insert("accept".to_string(), input.accept.to_string());
|
||||
crate::apply_local_auth_config_header_overrides(
|
||||
&mut provider_request_headers,
|
||||
input.transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
if !apply_local_header_rules_with_request_headers(
|
||||
&mut provider_request_headers,
|
||||
input.header_rules,
|
||||
@@ -253,7 +258,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn builds_json_eventstream_headers_and_applies_rules() {
|
||||
let transport = sample_transport();
|
||||
let headers = build_openai_image_headers(ProviderOpenAiImageHeadersInput {
|
||||
transport: &transport,
|
||||
headers: &HeaderMap::new(),
|
||||
auth_header: "authorization",
|
||||
auth_value: "Bearer secret",
|
||||
@@ -282,8 +289,46 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_openai_compatible_image_headers_can_request_json() {
|
||||
fn auth_config_headers_override_default_authorization() {
|
||||
let mut transport = sample_transport();
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
json!({
|
||||
"refresh_token": "rt-1",
|
||||
"headers": {
|
||||
"authorization": "Bearer imported-session",
|
||||
"chatgpt-account-id": "acct-1"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let headers = build_openai_image_headers(ProviderOpenAiImageHeadersInput {
|
||||
transport: &transport,
|
||||
headers: &HeaderMap::new(),
|
||||
auth_header: "authorization",
|
||||
auth_value: "Bearer refreshed-access-token",
|
||||
accept: "text/event-stream",
|
||||
header_rules: None,
|
||||
provider_request_body: &json!({"model":"gpt-5.4-mini"}),
|
||||
original_request_body: &json!({"prompt":"draw"}),
|
||||
})
|
||||
.expect("headers should build");
|
||||
|
||||
assert_eq!(
|
||||
headers.get("authorization"),
|
||||
Some(&"Bearer imported-session".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("chatgpt-account-id"),
|
||||
Some(&"acct-1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_openai_compatible_image_headers_can_request_json() {
|
||||
let transport = sample_transport();
|
||||
let headers = build_openai_image_headers(ProviderOpenAiImageHeadersInput {
|
||||
transport: &transport,
|
||||
headers: &HeaderMap::new(),
|
||||
auth_header: "authorization",
|
||||
auth_value: "Bearer secret",
|
||||
|
||||
@@ -250,6 +250,10 @@ pub fn build_standard_provider_request_headers(
|
||||
)
|
||||
};
|
||||
|
||||
crate::apply_local_auth_config_header_overrides(
|
||||
&mut headers,
|
||||
input.transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
let protected_headers = if uses_vertex_query_auth {
|
||||
&["content-type"][..]
|
||||
} else {
|
||||
@@ -469,6 +473,70 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_header_rules_do_not_override_protected_authorization() {
|
||||
let mut transport = sample_transport("openai:responses");
|
||||
transport.endpoint.header_rules = Some(json!([
|
||||
{"action":"set","key":"authorization","value":"Bearer imported-session"}
|
||||
]));
|
||||
|
||||
let resolved =
|
||||
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
|
||||
transport: &transport,
|
||||
provider_api_format: "openai:responses",
|
||||
same_format: true,
|
||||
headers: &HeaderMap::new(),
|
||||
auth_header: "authorization",
|
||||
auth_value: "Bearer jwt-access-token",
|
||||
extra_headers: &BTreeMap::new(),
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &json!({"model":"gpt-test"}),
|
||||
original_request_body: &json!({"model":"gpt-test"}),
|
||||
upstream_is_stream: false,
|
||||
})
|
||||
.expect("headers should build");
|
||||
|
||||
assert_eq!(
|
||||
resolved.headers.get("authorization"),
|
||||
Some(&"Bearer jwt-access-token".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_config_headers_can_override_authorization_when_refresh_token_is_present() {
|
||||
let mut transport = sample_transport("openai:responses");
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
json!({
|
||||
"refresh_token": "rt-1",
|
||||
"headers": {
|
||||
"authorization": "Bearer imported-session"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let resolved =
|
||||
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
|
||||
transport: &transport,
|
||||
provider_api_format: "openai:responses",
|
||||
same_format: true,
|
||||
headers: &HeaderMap::new(),
|
||||
auth_header: "authorization",
|
||||
auth_value: "Bearer refreshed-access-token",
|
||||
extra_headers: &BTreeMap::new(),
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &json!({"model":"gpt-test"}),
|
||||
original_request_body: &json!({"model":"gpt-test"}),
|
||||
upstream_is_stream: false,
|
||||
})
|
||||
.expect("headers should build");
|
||||
|
||||
assert_eq!(
|
||||
resolved.headers.get("authorization"),
|
||||
Some(&"Bearer imported-session".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_standard_body_rules_to_surface_built_body() {
|
||||
let body = apply_standard_provider_request_body_rules(
|
||||
|
||||
Reference in New Issue
Block a user