From f1c30439ff3f60ad828e1b7cad977788acdeb341 Mon Sep 17 00:00:00 2001 From: elky Date: Sat, 20 Jun 2026 22:08:33 +0800 Subject: [PATCH] fix: preserve provider auth metadata --- .../planner/specialized/image/request.rs | 1 + .../planner/standard/family/request.rs | 1 + .../standard/openai/chat/decision/request.rs | 1 + .../openai/responses/decision/request.rs | 25 +- .../src/ai_serving/transport.rs | 4 +- .../monitoring/cache_payloads.rs | 11 +- .../oauth/dispatch/batch/execution.rs | 18 +- .../provider/oauth/dispatch/batch/parse.rs | 140 +++++++++- .../admin/provider/oauth/dispatch/import.rs | 111 +++++++- .../provider/oauth/dispatch/token_import.rs | 126 ++++++++- .../admin/provider/oauth/quota/chatgpt_web.rs | 6 +- .../admin/provider/oauth/quota/codex/plan.rs | 9 +- .../handlers/admin/provider/pool/config.rs | 7 - .../admin/provider/pool/runtime/writes.rs | 7 +- .../admin/provider/pool_admin/payloads.rs | 6 +- .../read_routes/resolve_selection.rs | 7 +- .../admin/provider/query/models/model_test.rs | 1 + .../handlers/admin/provider/shared/support.rs | 1 - .../handlers/admin/provider/write/reveal.rs | 119 ++++++++ .../system_modules_helpers/keys_grouped.rs | 23 +- .../src/handlers/shared/catalog.rs | 18 +- apps/aether-gateway/src/provider_key_auth.rs | 40 ++- .../src/tests/control/admin/endpoints/keys.rs | 226 ++++++++++++++++ .../src/tests/control/admin/oauth.rs | 89 ++++++ .../src/tests/control/admin/pool.rs | 130 +++++++++ crates/aether-provider-pool/src/lib.rs | 23 ++ .../src/providers/codex.rs | 16 +- .../src/auth_config.rs | 197 +++++++++++++- crates/aether-provider-transport/src/lib.rs | 1 + .../src/openai_image/mod.rs | 47 +++- .../src/standard/mod.rs | 68 +++++ frontend/src/api/dashboard.ts | 6 + frontend/src/api/endpoints/pool.ts | 2 + frontend/src/api/endpoints/provider_oauth.ts | 1 + .../types/__tests__/provider.spec.ts | 2 +- frontend/src/api/endpoints/types/provider.ts | 7 +- .../pool/components/PoolAdvancedDialog.vue | 253 +++++------------- .../__tests__/poolAdvancedDialog.spec.ts | 31 +-- .../features/pool/utils/poolAdvancedDialog.ts | 36 +-- .../components/EndpointFormDialog.vue | 97 +++++-- .../components/OAuthAccountDialog.vue | 39 ++- .../components/ProviderDetailDrawer.vue | 5 +- .../OAuthAccountDialog.grok-import.spec.ts | 36 +++ .../usage/components/RequestDetailDrawer.vue | 64 +++++ .../__tests__/useUsageData.spec.ts | 166 ++++++++++++ .../usage/composables/useUsageData.ts | 196 ++++++++------ .../utils/__tests__/providerKeyAuth.spec.ts | 10 + frontend/src/utils/providerKeyAuth.ts | 2 + frontend/src/views/admin/CacheMonitoring.vue | 1 + frontend/src/views/admin/PoolManagement.vue | 1 + frontend/src/views/shared/Usage.vue | 84 ++++++ 51 files changed, 2098 insertions(+), 420 deletions(-) diff --git a/apps/aether-gateway/src/ai_serving/planner/specialized/image/request.rs b/apps/aether-gateway/src/ai_serving/planner/specialized/image/request.rs index fd50ff996..ea191261a 100644 --- a/apps/aether-gateway/src/ai_serving/planner/specialized/image/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/specialized/image/request.rs @@ -210,6 +210,7 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts( }) } else { build_openai_image_headers(ProviderOpenAiImageHeadersInput { + transport, headers: effective_headers, auth_header: &auth_header, auth_value: &auth_value, diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs index 47864b94e..c6a631360 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs @@ -1227,6 +1227,7 @@ async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts( let effective_headers = input.effective_headers(&parts.headers); let Some(mut provider_request_headers) = build_openai_image_headers(ProviderOpenAiImageHeadersInput { + transport, headers: effective_headers, auth_header: &prepared_candidate.auth_header, auth_value: &prepared_candidate.auth_value, diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs index 1f80fe6f6..13c7e2af9 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs @@ -1053,6 +1053,7 @@ async fn resolve_openai_chat_to_openai_image_payload_parts( }; let Some(mut provider_request_headers) = build_openai_image_headers(ProviderOpenAiImageHeadersInput { + transport, headers: &parts.headers, auth_header: &prepared_candidate.auth_header, auth_value: &prepared_candidate.auth_value, diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs index 3daeba7c3..7c730bfc6 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs @@ -46,12 +46,12 @@ use crate::ai_serving::transport::kiro::{ KiroRequestAuth, KIRO_ENVELOPE_NAME, }; use crate::ai_serving::transport::{ - build_grok_browser_headers, build_grok_upstream_url, build_kiro_cross_format_upstream_url, - build_openai_image_headers, build_openai_image_upstream_url, - build_standard_provider_request_headers, build_windsurf_cascade_headers, - build_windsurf_cascade_request_body, build_windsurf_cascade_upstream_url, - is_gemini_cli_provider_transport, is_windsurf_provider_transport, - local_standard_transport_unsupported_reason_with_network, + apply_local_auth_config_header_overrides, build_grok_browser_headers, build_grok_upstream_url, + build_kiro_cross_format_upstream_url, build_openai_image_headers, + build_openai_image_upstream_url, build_standard_provider_request_headers, + build_windsurf_cascade_headers, build_windsurf_cascade_request_body, + build_windsurf_cascade_upstream_url, is_gemini_cli_provider_transport, + is_windsurf_provider_transport, local_standard_transport_unsupported_reason_with_network, local_windsurf_request_transport_unsupported_reason_with_network, openai_image_transport_unsupported_reason, resolve_openai_image_auth, GrokHeaderInput, ProviderOpenAiImageHeadersInput, StandardProviderRequestHeadersInput, @@ -681,6 +681,10 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts( Some(trace_id), transport.key.decrypted_auth_config.as_deref(), ); + apply_local_auth_config_header_overrides( + &mut provider_request_headers, + transport.key.decrypted_auth_config.as_deref(), + ); } request_identity_response_encoding_when_redacted( &mut provider_request_headers, @@ -857,6 +861,10 @@ async fn build_gemini_cli_openai_responses_payload_parts( Some(trace_id), resolved.transport.key.decrypted_auth_config.as_deref(), ); + apply_local_auth_config_header_overrides( + &mut provider_request_headers, + resolved.transport.key.decrypted_auth_config.as_deref(), + ); request_identity_response_encoding_when_redacted( &mut provider_request_headers, request_redacted, @@ -1143,6 +1151,7 @@ async fn resolve_openai_responses_to_openai_image_payload_parts( }; let Some(mut provider_request_headers) = build_openai_image_headers(ProviderOpenAiImageHeadersInput { + transport, headers: &parts.headers, auth_header: &prepared_candidate.auth_header, auth_value: &prepared_candidate.auth_value, @@ -1181,6 +1190,10 @@ async fn resolve_openai_responses_to_openai_image_payload_parts( Some(trace_id), transport.key.decrypted_auth_config.as_deref(), ); + apply_local_auth_config_header_overrides( + &mut provider_request_headers, + transport.key.decrypted_auth_config.as_deref(), + ); } let (execution_strategy, conversion_mode) = diff --git a/apps/aether-gateway/src/ai_serving/transport.rs b/apps/aether-gateway/src/ai_serving/transport.rs index d74f0ae33..6ea43c541 100644 --- a/apps/aether-gateway/src/ai_serving/transport.rs +++ b/apps/aether-gateway/src/ai_serving/transport.rs @@ -59,8 +59,8 @@ pub(crate) mod windsurf { } pub(crate) use aether_provider_transport::{ - append_transport_diagnostics_to_value, apply_local_body_rules, - apply_local_body_rules_with_request_headers, apply_local_header_rules, + append_transport_diagnostics_to_value, apply_local_auth_config_header_overrides, + apply_local_body_rules, apply_local_body_rules_with_request_headers, apply_local_header_rules, apply_local_header_rules_with_request_headers, apply_standard_provider_request_body_rules, apply_standard_provider_request_body_rules_with_request_headers, apply_transport_request_body_semantics, body_rules_are_locally_supported, diff --git a/apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_payloads.rs b/apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_payloads.rs index 380586d3d..0e20d5fd1 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_payloads.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_payloads.rs @@ -1,4 +1,5 @@ use crate::handlers::admin::request::AdminAppState; +use crate::provider_key_auth::provider_key_auth_config_uses_header_authorization; use aether_crypto::decrypt_python_fernet_ciphertext; #[cfg(test)] use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY; @@ -28,7 +29,15 @@ pub(super) fn admin_monitoring_masked_provider_key_prefix( ) -> Option { match key.auth_type.trim() { "service_account" | "vertex_ai" => Some("[Service Account]".to_string()), - "oauth" => Some("[OAuth Token]".to_string()), + "oauth" => { + if provider_key_auth_config_uses_header_authorization( + state.parse_catalog_auth_config_json(key).as_ref(), + ) { + Some("[OAuth Header]".to_string()) + } else { + Some("[OAuth Token]".to_string()) + } + } _ => { let full_key = key .encrypted_api_key diff --git a/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/execution.rs b/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/execution.rs index ea5a6bdb8..55465dcb6 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/execution.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/execution.rs @@ -1,6 +1,7 @@ use super::super::helpers::admin_provider_oauth_key_name_from_auth_config; use super::super::token_import::{ - build_provider_access_token_import_auth_config, provider_type_supports_access_token_import, + build_provider_access_token_import_auth_config, decode_access_token_expires_at, + provider_oauth_import_authorization_bearer_token, provider_type_supports_access_token_import, }; use super::kiro_import::execute_admin_provider_oauth_kiro_batch_import; use super::parse::{ @@ -394,9 +395,22 @@ pub(super) async fn execute_admin_provider_oauth_batch_import( let AdminProviderOAuthResolvedBatchImport { access_token, mut auth_config, - expires_at, + mut expires_at, } = resolved_import; apply_admin_provider_oauth_batch_import_hints(provider_type, entry, &mut auth_config); + if let Some(header_access_token) = + provider_oauth_import_authorization_bearer_token(entry.request_headers.as_ref()) + { + if let Some(header_expires_at) = + decode_access_token_expires_at(&header_access_token).or(entry.expires_at) + { + auth_config.insert("expires_at".to_string(), json!(header_expires_at)); + expires_at = Some(header_expires_at); + } else { + auth_config.remove("expires_at"); + expires_at = None; + } + } let duplicate = match find_duplicate_provider_oauth_key(state, provider_id, &auth_config, None).await { diff --git a/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/parse.rs b/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/parse.rs index 1d00d0dfb..93287c387 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/parse.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/parse.rs @@ -1,4 +1,8 @@ -use super::super::token_import::{import_tokens_from_raw_token, normalize_provider_import_tokens}; +use super::super::token_import::{ + import_tokens_from_raw_token, normalize_provider_import_tokens, + normalize_provider_oauth_import_headers_from_object, + provider_oauth_import_authorization_bearer_token, +}; use crate::handlers::admin::provider::oauth::errors::build_internal_control_error_response; use crate::handlers::admin::provider::oauth::state::{current_unix_secs, json_u64_value}; use axum::{ @@ -9,6 +13,7 @@ use axum::{ }; use serde::Deserialize; use serde_json::json; +use std::collections::BTreeMap; use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone, Deserialize)] @@ -22,6 +27,7 @@ pub(super) struct AdminProviderOAuthBatchImportEntry { pub parse_error: Option, pub refresh_token: Option, pub access_token: Option, + pub export_access_token: Option, pub raw_credentials: Option, pub expires_at: Option, pub account_id: Option, @@ -37,6 +43,7 @@ pub(super) struct AdminProviderOAuthBatchImportEntry { pub sso_rw_token: Option, pub cf_cookies: Option, pub cf_clearance: Option, + pub request_headers: Option>, pub user_agent: Option, pub browser_profile: Option, } @@ -183,6 +190,7 @@ fn extract_admin_provider_oauth_batch_import_entry( parse_error: None, refresh_token, access_token, + export_access_token: None, raw_credentials: None, expires_at: None, account_id: None, @@ -198,6 +206,7 @@ fn extract_admin_provider_oauth_batch_import_entry( sso_rw_token: grok_cookie_value(raw_token, "sso-rw"), cf_cookies: grok_cookie_profile(raw_token), cf_clearance: grok_cookie_value(raw_token, "cf_clearance"), + request_headers: None, user_agent: None, browser_profile: None, }) @@ -237,10 +246,24 @@ fn extract_admin_provider_oauth_batch_import_entry( .as_deref() .and_then(|cookie| grok_cookie_value(cookie, "sso")) }); + let request_headers = normalize_provider_oauth_import_headers_from_object(object); + let header_bearer_token = + provider_oauth_import_authorization_bearer_token(request_headers.as_ref()); + let export_access_token = if refresh_token.is_none() { + access_token + .as_ref() + .filter(|value| header_bearer_token.as_deref() != Some(value.as_str())) + .cloned() + } else { + None + }; let (refresh_token, access_token) = normalize_provider_import_tokens( provider_type, refresh_token.as_deref(), - access_token.as_deref().or(session_token.as_deref()), + access_token + .as_deref() + .or(session_token.as_deref()) + .or(header_bearer_token.as_deref()), ); let windsurf_api_key = is_windsurf .then(|| { @@ -386,6 +409,7 @@ fn extract_admin_provider_oauth_batch_import_entry( parse_error: None, refresh_token, access_token, + export_access_token, raw_credentials, expires_at, account_id, @@ -401,6 +425,7 @@ fn extract_admin_provider_oauth_batch_import_entry( sso_rw_token, cf_cookies, cf_clearance, + request_headers, user_agent, browser_profile, }) @@ -479,6 +504,7 @@ fn parse_error_entry(error: String) -> AdminProviderOAuthBatchImportEntry { parse_error: Some(error), refresh_token: None, access_token: None, + export_access_token: None, raw_credentials: None, expires_at: None, account_id: None, @@ -494,6 +520,7 @@ fn parse_error_entry(error: String) -> AdminProviderOAuthBatchImportEntry { sso_rw_token: None, cf_cookies: None, cf_clearance: None, + request_headers: None, user_agent: None, browser_profile: None, } @@ -569,6 +596,11 @@ pub(super) fn apply_admin_provider_oauth_batch_import_hints( .entry("pool_tier".to_string()) .or_insert_with(|| json!(pool_tier)); } + if let Some(access_token) = entry.export_access_token.as_ref() { + auth_config + .entry("access_token".to_string()) + .or_insert_with(|| json!(access_token)); + } if let Some(user_id) = entry.user_id.as_ref() { auth_config .entry("user_id".to_string()) @@ -599,6 +631,11 @@ pub(super) fn apply_admin_provider_oauth_batch_import_hints( .entry("cf_clearance".to_string()) .or_insert_with(|| json!(cf_clearance)); } + if let Some(request_headers) = entry.request_headers.as_ref() { + auth_config + .entry("headers".to_string()) + .or_insert_with(|| json!(request_headers)); + } if let Some(user_agent) = entry.user_agent.as_ref() { auth_config .entry("user_agent".to_string()) @@ -721,11 +758,110 @@ mod tests { assert_eq!(entries.len(), 1); assert_eq!(entries[0].refresh_token, None); assert_eq!(entries[0].access_token.as_deref(), Some("at_1")); + assert_eq!(entries[0].export_access_token.as_deref(), Some("at_1")); assert_eq!(entries[0].expires_at, Some(2_100_000_000)); assert_eq!(entries[0].account_id.as_deref(), Some("acc-1")); assert_eq!(entries[0].email.as_deref(), Some("u@example.com")); } + #[test] + fn parses_codex_import_header_overrides() { + let entries = parse_admin_provider_oauth_batch_import_entries( + "codex", + r#"[{"access_token":"jwt_access","headers":{"authorization":"Bearer session-1","chatgpt-account-id":"acc-1","host":"blocked.example"}}]"#, + ); + + assert_eq!(entries.len(), 1); + let headers = entries[0] + .request_headers + .as_ref() + .expect("headers should parse"); + assert_eq!( + headers.get("authorization"), + Some(&"Bearer session-1".to_string()) + ); + assert_eq!( + headers.get("chatgpt-account-id"), + Some(&"acc-1".to_string()) + ); + assert!(!headers.contains_key("host")); + } + + #[test] + fn treats_import_authorization_header_as_session_access_token() { + let entries = parse_admin_provider_oauth_batch_import_entries( + "codex", + r#"[{"access_token":"jwt_access","headers":{"authorization":"Bearer session-1"}}]"#, + ); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].access_token.as_deref(), Some("jwt_access")); + assert_eq!( + entries[0].export_access_token.as_deref(), + Some("jwt_access") + ); + + let entries = parse_admin_provider_oauth_batch_import_entries( + "codex", + r#"[{"headers":{"authorization":"Bearer session-1"}}]"#, + ); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].access_token.as_deref(), Some("session-1")); + assert!(entries[0].export_access_token.is_none()); + } + + #[test] + fn applies_codex_import_header_overrides_to_auth_config() { + let entries = parse_admin_provider_oauth_batch_import_entries( + "codex", + r#"{"accessToken":"at_1","headers":{"Authorization":"Bearer session-1"}}"#, + ); + let mut auth_config = serde_json::Map::new(); + + apply_admin_provider_oauth_batch_import_hints("codex", &entries[0], &mut auth_config); + + assert_eq!( + auth_config.get("headers"), + Some(&json!({"authorization":"Bearer session-1"})) + ); + assert_eq!(auth_config.get("access_token"), Some(&json!("at_1"))); + } + + #[test] + fn does_not_export_access_token_when_it_is_the_header_bearer() { + let entries = parse_admin_provider_oauth_batch_import_entries( + "codex", + r#"{"accessToken":"session-1","headers":{"Authorization":"Bearer session-1"}}"#, + ); + let mut auth_config = serde_json::Map::new(); + + apply_admin_provider_oauth_batch_import_hints("codex", &entries[0], &mut auth_config); + + assert!(entries[0].export_access_token.is_none()); + assert!(auth_config.get("access_token").is_none()); + assert_eq!( + auth_config.get("headers"), + Some(&json!({"authorization":"Bearer session-1"})) + ); + } + + #[test] + fn does_not_export_access_token_from_refresh_token_payload() { + let entries = parse_admin_provider_oauth_batch_import_entries( + "codex", + r#"{"refreshToken":"refresh-1","accessToken":"refreshed-access"}"#, + ); + let mut auth_config = serde_json::Map::new(); + + apply_admin_provider_oauth_batch_import_hints("codex", &entries[0], &mut auth_config); + + assert_eq!(entries[0].refresh_token.as_deref(), Some("refresh-1")); + assert_eq!(entries[0].access_token.as_deref(), Some("refreshed-access")); + assert!(entries[0].export_access_token.is_none()); + assert!(auth_config.get("access_token").is_none()); + } + #[test] fn parses_common_chatgpt_web_json_aliases() { let entries = parse_admin_provider_oauth_batch_import_entries( diff --git a/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/import.rs b/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/import.rs index bf3a0dee4..704b50ddf 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/import.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/import.rs @@ -16,7 +16,9 @@ use super::super::state::{ }; use super::helpers::admin_provider_oauth_key_name_from_auth_config; use super::token_import::{ - build_provider_access_token_import_auth_config, normalize_provider_import_tokens, + build_provider_access_token_import_auth_config, decode_access_token_expires_at, + normalize_provider_import_tokens, normalize_provider_oauth_import_headers_from_object, + provider_oauth_import_authorization_bearer_token_from_object, provider_type_supports_access_token_import, }; use crate::handlers::admin::provider::shared::paths::admin_provider_oauth_import_provider_id; @@ -151,6 +153,20 @@ fn import_payload_u64_any( }) } +fn import_payload_export_access_token( + payload: &serde_json::Map, +) -> Option { + if import_payload_string(payload, "refresh_token", "refreshToken").is_some() { + return None; + } + let access_token = import_payload_string(payload, "access_token", "accessToken")?; + let header_bearer = provider_oauth_import_authorization_bearer_token_from_object(payload); + if header_bearer.as_deref() == Some(access_token.as_str()) { + return None; + } + Some(access_token) +} + fn apply_single_import_hints( provider_type: &str, payload: &serde_json::Map, @@ -267,6 +283,18 @@ fn apply_single_import_hints( } }); } + + if let Some(access_token) = import_payload_export_access_token(payload) { + auth_config + .entry("access_token".to_string()) + .or_insert_with(|| json!(access_token)); + } + + if let Some(request_headers) = normalize_provider_oauth_import_headers_from_object(payload) { + auth_config + .entry("headers".to_string()) + .or_insert_with(|| json!(request_headers)); + } } async fn resolve_admin_provider_oauth_single_import_tokens( @@ -497,7 +525,8 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token( "session_token", "sessionToken", ], - ); + ) + .or_else(|| provider_oauth_import_authorization_bearer_token_from_object(&raw_payload)); let imported_expires_at = import_payload_u64_any(&raw_payload, &["expires_at", "expiresAt", "expired"]); let name = raw_payload @@ -614,9 +643,22 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token( let AdminProviderOAuthSingleImportTokens { access_token, mut auth_config, - expires_at, + mut expires_at, } = resolved_import; apply_single_import_hints(&provider_type, &raw_payload, &mut auth_config); + if let Some(header_access_token) = + provider_oauth_import_authorization_bearer_token_from_object(&raw_payload) + { + if let Some(header_expires_at) = + decode_access_token_expires_at(&header_access_token).or(imported_expires_at) + { + auth_config.insert("expires_at".to_string(), json!(header_expires_at)); + expires_at = Some(header_expires_at); + } else { + auth_config.remove("expires_at"); + expires_at = None; + } + } let has_refresh_token = auth_config .get("refresh_token") .and_then(serde_json::Value::as_str) @@ -776,6 +818,69 @@ mod tests { assert_eq!(auth_config.get("user_agent"), Some(&json!("antigravity"))); } + #[test] + fn single_import_preserves_distinct_top_level_access_token_for_export() { + let payload = json!({ + "access_token": "jwt-access-token", + "headers": { + "authorization": "Bearer imported-session-token" + }, + }) + .as_object() + .cloned() + .expect("payload should be an object"); + let mut auth_config = serde_json::Map::new(); + + apply_single_import_hints("codex", &payload, &mut auth_config); + + assert_eq!( + auth_config.get("access_token"), + Some(&json!("jwt-access-token")) + ); + assert_eq!( + auth_config.get("headers"), + Some(&json!({"authorization":"Bearer imported-session-token"})) + ); + } + + #[test] + fn single_import_does_not_preserve_header_bearer_as_export_access_token() { + let payload = json!({ + "access_token": "imported-session-token", + "headers": { + "authorization": "Bearer imported-session-token" + }, + }) + .as_object() + .cloned() + .expect("payload should be an object"); + let mut auth_config = serde_json::Map::new(); + + apply_single_import_hints("codex", &payload, &mut auth_config); + + assert!(auth_config.get("access_token").is_none()); + assert_eq!( + auth_config.get("headers"), + Some(&json!({"authorization":"Bearer imported-session-token"})) + ); + } + + #[test] + fn single_import_does_not_preserve_access_token_from_refresh_token_payload_for_export() { + let payload = json!({ + "refresh_token": "refresh-token", + "access_token": "refreshed-access-token", + }) + .as_object() + .cloned() + .expect("payload should be an object"); + let mut auth_config = serde_json::Map::new(); + + apply_single_import_hints("codex", &payload, &mut auth_config); + + assert!(auth_config.get("access_token").is_none()); + } + #[test] fn windsurf_import_error_redacts_http_body() { let error = OAuthError::HttpStatus { diff --git a/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/token_import.rs b/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/token_import.rs index 8c69a00b7..eb26af782 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/token_import.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/token_import.rs @@ -5,6 +5,7 @@ use base64::{ Engine as _, }; use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; fn decode_base64_url_part(value: &str) -> Option> { URL_SAFE_NO_PAD @@ -117,6 +118,95 @@ pub(super) fn decode_access_token_expires_at(access_token: &str) -> Option json_u64_value(claims.get("exp")) } +fn normalize_import_header_name(raw: &str) -> Option { + let value = raw.trim().to_ascii_lowercase(); + if value.is_empty() + || matches!( + value.as_str(), + "host" + | "content-length" + | "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "proxy-connection" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) + { + return None; + } + http::header::HeaderName::from_bytes(value.as_bytes()) + .ok() + .map(|name| name.as_str().to_string()) +} + +fn normalize_import_header_value(value: &Value) -> Option { + let value = match value { + Value::String(raw) => raw.trim().to_string(), + Value::Number(raw) => raw.to_string(), + Value::Bool(raw) => raw.to_string(), + _ => return None, + }; + if value.is_empty() || http::header::HeaderValue::from_str(&value).is_err() { + return None; + } + Some(value) +} + +pub(super) fn normalize_provider_oauth_import_headers( + value: Option<&Value>, +) -> Option> { + let object = value?.as_object()?; + let mut headers = BTreeMap::new(); + for (raw_key, raw_value) in object { + let Some(key) = normalize_import_header_name(raw_key) else { + continue; + }; + let Some(value) = normalize_import_header_value(raw_value) else { + continue; + }; + headers.insert(key, value); + } + (!headers.is_empty()).then_some(headers) +} + +pub(super) fn normalize_provider_oauth_import_headers_from_object( + object: &Map, +) -> Option> { + normalize_provider_oauth_import_headers( + object + .get("headers") + .or_else(|| object.get("request_headers")) + .or_else(|| object.get("requestHeaders")) + .or_else(|| object.get("header_overrides")) + .or_else(|| object.get("headerOverrides")) + .or_else(|| object.get("extra_headers")) + .or_else(|| object.get("extraHeaders")), + ) +} + +pub(super) fn provider_oauth_import_authorization_bearer_token( + headers: Option<&BTreeMap>, +) -> Option { + let value = headers?.get("authorization")?.trim(); + let (scheme, token) = value.split_once(' ')?; + if !scheme.eq_ignore_ascii_case("bearer") { + return None; + } + let token = token.trim(); + (!token.is_empty()).then(|| token.to_string()) +} + +pub(super) fn provider_oauth_import_authorization_bearer_token_from_object( + object: &Map, +) -> Option { + let headers = normalize_provider_oauth_import_headers_from_object(object)?; + provider_oauth_import_authorization_bearer_token(Some(&headers)) +} + pub(super) fn provider_type_supports_access_token_import(provider_type: &str) -> bool { matches!( provider_type.trim().to_ascii_lowercase().as_str(), @@ -176,7 +266,9 @@ pub(super) fn build_provider_access_token_import_auth_config( mod tests { use super::{ build_provider_access_token_import_auth_config, decode_access_token_expires_at, - looks_like_access_token, normalize_provider_import_tokens, normalize_single_import_tokens, + looks_like_access_token, normalize_provider_import_tokens, + normalize_provider_oauth_import_headers, normalize_single_import_tokens, + provider_oauth_import_authorization_bearer_token, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use serde_json::json; @@ -247,6 +339,38 @@ mod tests { ); } + #[test] + fn normalizes_import_header_overrides_for_auth_headers() { + let headers = normalize_provider_oauth_import_headers(Some(&json!({ + "Authorization": " Bearer session-token ", + "X-Feature": true, + "Host": "evil.example", + "X-Bad": "line\nbreak", + }))) + .expect("headers should normalize"); + + assert_eq!( + headers.get("authorization"), + Some(&"Bearer session-token".to_string()) + ); + assert_eq!(headers.get("x-feature"), Some(&"true".to_string())); + assert!(!headers.contains_key("host")); + assert!(!headers.contains_key("x-bad")); + } + + #[test] + fn extracts_import_authorization_header_bearer_token() { + let headers = normalize_provider_oauth_import_headers(Some(&json!({ + "Authorization": "Bearer at-session-token", + }))) + .expect("headers should normalize"); + + assert_eq!( + provider_oauth_import_authorization_bearer_token(Some(&headers)).as_deref(), + Some("at-session-token") + ); + } + #[test] fn builds_chatgpt_web_temporary_auth_config_from_access_token() { let token = unsigned_jwt(json!({ diff --git a/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/chatgpt_web.rs b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/chatgpt_web.rs index 373d00c24..8714372a2 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/chatgpt_web.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/chatgpt_web.rs @@ -76,8 +76,12 @@ async fn execute_chatgpt_web_quota_plan( state.resolve_transport_execution_timeouts(transport), proxy.as_ref(), )); - let spec = + let mut spec = build_chatgpt_web_pool_quota_request(&transport.key.id, &endpoint.base_url, authorization); + crate::provider_transport::apply_local_auth_config_header_overrides( + &mut spec.headers, + transport.key.decrypted_auth_config.as_deref(), + ); let resolved_transport_profile = state.resolve_transport_profile(transport); let plan = super::shared::build_provider_quota_execution_plan( transport, diff --git a/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/plan.rs b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/plan.rs index 1d7975370..d8049fb81 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/plan.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/plan.rs @@ -16,12 +16,17 @@ pub(super) fn build_codex_quota_request_spec( .decrypted_auth_config .as_deref() .and_then(|raw| serde_json::from_str::(raw).ok()); - build_codex_pool_quota_request( + let mut request = build_codex_pool_quota_request( &transport.key.id, resolved_oauth_auth, Some(transport.key.decrypted_api_key.as_str()), auth_config.as_ref(), - ) + )?; + crate::provider_transport::apply_local_auth_config_header_overrides( + &mut request.headers, + transport.key.decrypted_auth_config.as_deref(), + ); + Ok(request) } pub(super) async fn execute_codex_quota_plan( diff --git a/apps/aether-gateway/src/handlers/admin/provider/pool/config.rs b/apps/aether-gateway/src/handlers/admin/provider/pool/config.rs index 3a57688f2..1f35fa249 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/pool/config.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/pool/config.rs @@ -405,7 +405,6 @@ pub(crate) fn admin_provider_pool_config_from_config_value( cost_limit_per_key_tokens: None, rate_limit_cooldown_seconds: 300, overload_cooldown_seconds: 30, - health_policy_enabled: true, probing_enabled: false, probing_target_percent: None, probing_target_count: None, @@ -466,10 +465,6 @@ pub(crate) fn admin_provider_pool_config_from_config_value( .and_then(json_u64) .filter(|value| *value > 0) .unwrap_or(30), - health_policy_enabled: pool_advanced - .get("health_policy_enabled") - .and_then(Value::as_bool) - .unwrap_or(true), probing_enabled: pool_advanced .get("probing_enabled") .and_then(Value::as_bool) @@ -583,7 +578,6 @@ mod tests { "cost_limit_per_key_tokens": 12000, "rate_limit_cooldown_seconds": 420, "overload_cooldown_seconds": 45, - "health_policy_enabled": false, "probing_enabled": true, "probing_target_percent": 25, "probing_target_count": 3, @@ -624,7 +618,6 @@ mod tests { assert_eq!(config.cost_limit_per_key_tokens, Some(12_000)); assert_eq!(config.rate_limit_cooldown_seconds, 420); assert_eq!(config.overload_cooldown_seconds, 45); - assert!(!config.health_policy_enabled); assert!(config.probing_enabled); assert_eq!(config.probing_target_percent, Some(25.0)); assert_eq!(config.probing_target_count, Some(3)); diff --git a/apps/aether-gateway/src/handlers/admin/provider/pool/runtime/writes.rs b/apps/aether-gateway/src/handlers/admin/provider/pool/runtime/writes.rs index eca877f66..47ac80860 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/pool/runtime/writes.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/pool/runtime/writes.rs @@ -437,10 +437,6 @@ pub(crate) async fn record_admin_provider_pool_error( error_body: Option<&str>, response_headers: Option<&BTreeMap>, ) { - if !pool_config.health_policy_enabled { - return; - } - let error_message = extract_error_message(error_body).to_ascii_lowercase(); if status_code == 401 { @@ -553,7 +549,7 @@ pub(crate) async fn record_admin_provider_pool_stream_timeout( key_id: &str, pool_config: &AdminProviderPoolConfig, ) { - if !pool_config.health_policy_enabled || pool_config.stream_timeout_threshold == 0 { + if pool_config.stream_timeout_threshold == 0 { return; } @@ -637,7 +633,6 @@ mod tests { cost_limit_per_key_tokens: Some(10_000), rate_limit_cooldown_seconds: 300, overload_cooldown_seconds: 30, - health_policy_enabled: true, probing_enabled: false, probing_target_percent: None, probing_target_count: None, diff --git a/apps/aether-gateway/src/handlers/admin/provider/pool_admin/payloads.rs b/apps/aether-gateway/src/handlers/admin/provider/pool_admin/payloads.rs index d79ff2aee..dd346a590 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/pool_admin/payloads.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/pool_admin/payloads.rs @@ -4,7 +4,8 @@ use crate::handlers::admin::provider::shared::support::{ use crate::handlers::admin::request::AdminAppState; use crate::handlers::admin::shared::{provider_key_status_snapshot_payload, unix_secs_to_rfc3339}; use crate::provider_key_auth::{ - provider_key_auth_semantics, provider_key_can_refresh_oauth, provider_key_effective_api_formats, + provider_key_auth_config_uses_header_authorization, provider_key_auth_semantics, + provider_key_can_refresh_oauth, provider_key_effective_api_formats, }; use aether_admin::provider::pool as admin_provider_pool_pure; use aether_admin::provider::quota as admin_provider_quota_pure; @@ -1114,6 +1115,8 @@ pub(super) fn build_admin_pool_key_payload( .and_then(|config| config.get("access_token_import_temporary")) .and_then(serde_json::Value::as_bool) .unwrap_or(false); + let oauth_header_auth = auth_semantics.oauth_managed() + && provider_key_auth_config_uses_header_authorization(auth_config.as_ref()); let account_status_code = admin_pool_trimmed_string_from_map(account_snapshot, "code"); let account_status_label = admin_pool_trimmed_string(account_snapshot.and_then(|item| item.get("label"))); @@ -1193,6 +1196,7 @@ pub(super) fn build_admin_pool_key_payload( "can_edit_oauth".to_string(), json!(auth_semantics.can_edit_oauth()), ); + payload.insert("oauth_header_auth".to_string(), json!(oauth_header_auth)); payload.insert("oauth_expires_at".to_string(), json!(oauth_expires_at)); payload.insert("oauth_invalid_at".to_string(), json!(oauth_invalid_at)); payload.insert( diff --git a/apps/aether-gateway/src/handlers/admin/provider/pool_admin/read_routes/resolve_selection.rs b/apps/aether-gateway/src/handlers/admin/provider/pool_admin/read_routes/resolve_selection.rs index 123b1c91c..a440b0395 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/pool_admin/read_routes/resolve_selection.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/pool_admin/read_routes/resolve_selection.rs @@ -3,7 +3,10 @@ use super::{ AdminPoolResolveSelectionRequest, ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL, }; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; -use crate::provider_key_auth::{provider_key_auth_semantics, provider_key_can_refresh_oauth}; +use crate::provider_key_auth::{ + provider_key_auth_config_uses_header_authorization, provider_key_auth_semantics, + provider_key_can_refresh_oauth, +}; use crate::GatewayError; use aether_admin::provider::pool as admin_provider_pool_pure; use axum::{ @@ -106,6 +109,8 @@ pub(super) async fn build_admin_pool_resolve_selection_response( "can_refresh_oauth": provider_key_can_refresh_oauth(auth_semantics, auth_config.as_ref()), "can_export_oauth": auth_semantics.can_export_oauth(), "can_edit_oauth": auth_semantics.can_edit_oauth(), + "oauth_header_auth": auth_semantics.oauth_managed() + && provider_key_auth_config_uses_header_authorization(auth_config.as_ref()), }) }) .collect::>(); diff --git a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs index ac5da44e9..cd58b31a3 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs @@ -2180,6 +2180,7 @@ async fn provider_query_execute_openai_image_test_candidate( } else { crate::provider_transport::build_openai_image_headers( crate::provider_transport::ProviderOpenAiImageHeadersInput { + transport: &transport, headers: &parts.headers, auth_header: &auth_header, auth_value: &auth_value, diff --git a/apps/aether-gateway/src/handlers/admin/provider/shared/support.rs b/apps/aether-gateway/src/handlers/admin/provider/shared/support.rs index cdfeadaa2..83361b2f2 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/shared/support.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/shared/support.rs @@ -43,7 +43,6 @@ pub(crate) struct AdminProviderPoolConfig { pub(crate) cost_limit_per_key_tokens: Option, pub(crate) rate_limit_cooldown_seconds: u64, pub(crate) overload_cooldown_seconds: u64, - pub(crate) health_policy_enabled: bool, pub(crate) probing_enabled: bool, pub(crate) probing_target_percent: Option, pub(crate) probing_target_count: Option, diff --git a/apps/aether-gateway/src/handlers/admin/provider/write/reveal.rs b/apps/aether-gateway/src/handlers/admin/provider/write/reveal.rs index 3ca946ca8..8648eb18e 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/write/reveal.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/write/reveal.rs @@ -97,6 +97,7 @@ fn provider_oauth_export_payload( if let Some(access_token) = fallback_access_token .map(str::trim) .filter(|value| !value.is_empty() && *value != "__placeholder__") + .filter(|value| !oauth_export_fallback_matches_authorization_header(&payload, value)) { payload.insert("access_token".to_string(), json!(access_token)); } @@ -116,6 +117,43 @@ fn provider_oauth_export_payload( payload } +fn oauth_export_fallback_matches_authorization_header( + payload: &serde_json::Map, + fallback_access_token: &str, +) -> bool { + let fallback_access_token = fallback_access_token.trim(); + if fallback_access_token.is_empty() { + return false; + } + let Some(authorization) = payload + .get("headers") + .and_then(serde_json::Value::as_object) + .and_then(|headers| { + headers + .iter() + .find(|(key, _)| key.trim().eq_ignore_ascii_case("authorization")) + .and_then(|(_, value)| value.as_str()) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return false; + }; + + if authorization == fallback_access_token { + return true; + } + + let mut parts = authorization.splitn(2, char::is_whitespace); + let Some(scheme) = parts.next() else { + return false; + }; + let Some(token) = parts.next() else { + return false; + }; + scheme.eq_ignore_ascii_case("bearer") && token.trim() == fallback_access_token +} + fn json_map_has_non_empty_string( map: &serde_json::Map, keys: &[&str], @@ -186,3 +224,84 @@ pub(crate) async fn build_admin_export_key_payload( ); Ok(serde_json::Value::Object(payload)) } + +#[cfg(test)] +mod tests { + use super::provider_oauth_export_payload; + use serde_json::json; + + #[test] + fn oauth_export_preserves_imported_request_headers() { + let auth_config = json!({ + "provider_type": "codex", + "email": "user@example.com", + "headers": { + "authorization": "Bearer imported-session", + "chatgpt-account-id": "acct-1" + } + }) + .as_object() + .cloned() + .expect("auth_config should be an object"); + + let payload = provider_oauth_export_payload("codex", &auth_config, None, Some("fallback")); + + assert_eq!( + payload.get("headers"), + Some(&json!({ + "authorization": "Bearer imported-session", + "chatgpt-account-id": "acct-1" + })) + ); + assert_eq!(payload.get("access_token"), Some(&json!("fallback"))); + } + + #[test] + fn oauth_export_does_not_promote_imported_header_bearer_to_access_token() { + let auth_config = json!({ + "provider_type": "codex", + "email": "user@example.com", + "headers": { + "authorization": "Bearer imported-session" + } + }) + .as_object() + .cloned() + .expect("auth_config should be an object"); + + let payload = + provider_oauth_export_payload("codex", &auth_config, None, Some("imported-session")); + + assert_eq!( + payload.get("headers"), + Some(&json!({"authorization": "Bearer imported-session"})) + ); + assert!(payload.get("access_token").is_none()); + } + + #[test] + fn oauth_export_keeps_explicit_access_token_even_with_header_bearer() { + let auth_config = json!({ + "provider_type": "codex", + "access_token": "jwt-access-token", + "headers": { + "authorization": "Bearer imported-session" + } + }) + .as_object() + .cloned() + .expect("auth_config should be an object"); + + let payload = + provider_oauth_export_payload("codex", &auth_config, None, Some("imported-session")); + + assert_eq!( + payload.get("access_token"), + Some(&json!("jwt-access-token")) + ); + assert_eq!( + payload.get("headers"), + Some(&json!({"authorization": "Bearer imported-session"})) + ); + } +} diff --git a/apps/aether-gateway/src/handlers/public/system_modules_helpers/keys_grouped.rs b/apps/aether-gateway/src/handlers/public/system_modules_helpers/keys_grouped.rs index 61bc87e62..bb11bd5a3 100644 --- a/apps/aether-gateway/src/handlers/public/system_modules_helpers/keys_grouped.rs +++ b/apps/aether-gateway/src/handlers/public/system_modules_helpers/keys_grouped.rs @@ -1,16 +1,27 @@ use super::enabled_key_capability_short_names; -use crate::handlers::shared::unix_secs_to_rfc3339; -use crate::provider_key_auth::provider_key_effective_api_formats; +use crate::handlers::shared::{parse_catalog_auth_config_json, unix_secs_to_rfc3339}; +use crate::provider_key_auth::{ + provider_key_auth_config_uses_header_authorization, provider_key_effective_api_formats, +}; use crate::AppState; +use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey; use aether_scheduler_core::provider_key_circuit_payload_is_active_open_at; use serde_json::json; use std::collections::{BTreeMap, HashMap}; use std::time::{SystemTime, UNIX_EPOCH}; -fn grouped_key_masked_label(auth_type: &str) -> &'static str { - match auth_type.trim() { +fn grouped_key_masked_label(state: &AppState, key: &StoredProviderCatalogKey) -> &'static str { + match key.auth_type.trim() { "service_account" | "vertex_ai" => "[Service Account]", - "oauth" => "[OAuth Token]", + "oauth" => { + if provider_key_auth_config_uses_header_authorization( + parse_catalog_auth_config_json(state, key).as_ref(), + ) { + "[OAuth Header]" + } else { + "[OAuth Token]" + } + } _ => "[API Key]", } } @@ -150,7 +161,7 @@ pub(crate) async fn build_admin_keys_grouped_by_format_payload( "provider_id": key.provider_id, "name": key.name, "auth_type": key.auth_type, - "api_key_masked": grouped_key_masked_label(&key.auth_type), + "api_key_masked": grouped_key_masked_label(state, &key), "internal_priority": key.internal_priority, "global_priority_by_format": key.global_priority_by_format, "rate_multipliers": key.rate_multipliers, diff --git a/apps/aether-gateway/src/handlers/shared/catalog.rs b/apps/aether-gateway/src/handlers/shared/catalog.rs index 62bf1b52c..3148027b1 100644 --- a/apps/aether-gateway/src/handlers/shared/catalog.rs +++ b/apps/aether-gateway/src/handlers/shared/catalog.rs @@ -1,7 +1,8 @@ use crate::handlers::shared::{json_string_list, unix_secs_to_rfc3339}; use crate::provider_key_auth::{ - provider_key_auth_semantics, provider_key_can_refresh_oauth, - provider_key_configured_api_formats, provider_key_inherits_provider_api_formats, + provider_key_auth_config_uses_header_authorization, provider_key_auth_semantics, + provider_key_can_refresh_oauth, provider_key_configured_api_formats, + provider_key_inherits_provider_api_formats, }; use crate::AppState; use aether_admin::provider::quota as admin_provider_quota_pure; @@ -132,7 +133,15 @@ pub(crate) fn take_secret_suffix(value: &str, suffix_chars: usize) -> &str { pub(crate) fn masked_catalog_api_key(state: &AppState, key: &StoredProviderCatalogKey) -> String { match key.auth_type.trim() { "service_account" | "vertex_ai" => "[Service Account]".to_string(), - "oauth" => "[OAuth Token]".to_string(), + "oauth" => { + if provider_key_auth_config_uses_header_authorization( + parse_catalog_auth_config_json(state, key).as_ref(), + ) { + "[OAuth Header]".to_string() + } else { + "[OAuth Token]".to_string() + } + } _ => { let Some(ciphertext) = key .encrypted_api_key @@ -2207,6 +2216,8 @@ pub(crate) fn build_admin_provider_key_response( .and_then(|config| config.get("access_token_import_temporary")) .and_then(serde_json::Value::as_bool) .unwrap_or(false); + let oauth_header_auth = auth_semantics.oauth_managed() + && provider_key_auth_config_uses_header_authorization(auth_config.as_ref()); let oauth_plan_type = derive_catalog_oauth_plan_type(key, provider_type, auth_config.as_ref()); let ( health_score, @@ -2279,6 +2290,7 @@ pub(crate) fn build_admin_provider_key_response( "can_edit_oauth".to_string(), json!(auth_semantics.can_edit_oauth()), ); + payload.insert("oauth_header_auth".to_string(), json!(oauth_header_auth)); payload.insert("name".to_string(), json!(key.name)); payload.insert("rate_multipliers".to_string(), json!(key.rate_multipliers)); payload.insert( diff --git a/apps/aether-gateway/src/provider_key_auth.rs b/apps/aether-gateway/src/provider_key_auth.rs index 342f9055f..d84a9e203 100644 --- a/apps/aether-gateway/src/provider_key_auth.rs +++ b/apps/aether-gateway/src/provider_key_auth.rs @@ -95,6 +95,23 @@ pub(crate) fn provider_key_can_refresh_oauth( .is_some_and(|value| !value.is_empty()) } +pub(crate) fn provider_key_auth_config_uses_header_authorization( + auth_config: Option<&Map>, +) -> bool { + auth_config + .and_then(|config| config.get("headers")) + .and_then(Value::as_object) + .is_some_and(|headers| { + headers.iter().any(|(key, value)| { + key.trim().eq_ignore_ascii_case("authorization") + && value + .as_str() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + }) + }) +} + fn normalized_auth_type(key: &StoredProviderCatalogKey) -> String { key.auth_type.trim().to_ascii_lowercase() } @@ -255,7 +272,8 @@ pub(crate) fn provider_key_effective_api_formats( #[cfg(test)] mod tests { use super::{ - provider_active_api_formats, provider_key_auth_semantics, provider_key_can_refresh_oauth, + provider_active_api_formats, provider_key_auth_config_uses_header_authorization, + provider_key_auth_semantics, provider_key_can_refresh_oauth, provider_key_configured_api_formats, provider_key_effective_api_formats, provider_key_inherits_provider_api_formats, ProviderKeyCredentialKind, ProviderKeyRuntimeAuthKind, @@ -377,6 +395,26 @@ mod tests { )); } + #[test] + fn detects_oauth_header_authorization_in_auth_config() { + assert!(provider_key_auth_config_uses_header_authorization( + json!({ + "headers": { + "Authorization": "Bearer imported-session" + } + }) + .as_object() + )); + assert!(!provider_key_auth_config_uses_header_authorization( + json!({ + "headers": { + "authorization": " " + } + }) + .as_object() + )); + } + #[test] fn recognizes_legacy_kiro_bearer_key_with_auth_config_as_oauth_managed() { let mut key = sample_key("bearer"); diff --git a/apps/aether-gateway/src/tests/control/admin/endpoints/keys.rs b/apps/aether-gateway/src/tests/control/admin/endpoints/keys.rs index 36d1f5fbd..27813041e 100644 --- a/apps/aether-gateway/src/tests/control/admin/endpoints/keys.rs +++ b/apps/aether-gateway/src/tests/control/admin/endpoints/keys.rs @@ -498,6 +498,81 @@ async fn gateway_admin_provider_keys_prefers_upstream_plan_type_over_auth_config upstream_handle.abort(); } +#[tokio::test] +async fn gateway_admin_provider_keys_marks_oauth_header_auth() { + let upstream_hits = Arc::new(Mutex::new(0usize)); + let upstream_hits_clone = Arc::clone(&upstream_hits); + let upstream = Router::new().route( + "/api/admin/endpoints/providers/provider-codex/keys", + any(move |_request: Request| { + let upstream_hits_inner = Arc::clone(&upstream_hits_clone); + async move { + *upstream_hits_inner.lock().expect("mutex should lock") += 1; + (StatusCode::OK, Body::from("unexpected upstream hit")) + } + }), + ); + + let mut provider = sample_provider("provider-codex", "codex", 10); + provider.provider_type = "codex".to_string(); + let mut key = sample_key( + "key-codex-oauth-header", + "provider-codex", + "openai:responses", + "imported-session-token", + ); + key.auth_type = "oauth".to_string(); + key.encrypted_auth_config = Some( + encrypt_python_fernet_plaintext( + DEVELOPMENT_ENCRYPTION_KEY, + r#"{"provider_type":"codex","headers":{"authorization":"Bearer imported-session-token"}}"#, + ) + .expect("auth config should encrypt"), + ); + + let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![provider], + vec![], + vec![key], + )); + + let (_upstream_url, upstream_handle) = start_server(upstream).await; + let gateway = build_router_with_state( + AppState::new() + .expect("gateway should build") + .with_data_state_for_tests( + GatewayDataState::with_provider_catalog_reader_for_tests( + provider_catalog_repository, + ) + .with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY), + ), + ); + let (gateway_url, gateway_handle) = start_server(gateway).await; + + let response = reqwest::Client::new() + .get(format!( + "{gateway_url}/api/admin/endpoints/providers/provider-codex/keys?skip=0&limit=50" + )) + .header(crate::constants::GATEWAY_HEADER, "rust-phase3b") + .header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123") + .header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin") + .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") + .send() + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = response.json().await.expect("json body should parse"); + let items = payload.as_array().expect("payload should be an array"); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["api_key_masked"], "[OAuth Header]"); + assert_eq!(items[0]["oauth_header_auth"], true); + assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); + + gateway_handle.abort(); + upstream_handle.abort(); +} + #[tokio::test] async fn gateway_creates_admin_provider_key_locally_with_trusted_admin_principal() { let upstream_hits = Arc::new(Mutex::new(0usize)); @@ -1123,6 +1198,157 @@ async fn gateway_exports_admin_provider_key_access_token_when_refresh_token_is_m upstream_handle.abort(); } +#[tokio::test] +async fn gateway_export_does_not_emit_access_token_from_imported_authorization_header() { + let upstream_hits = Arc::new(Mutex::new(0usize)); + let upstream_hits_clone = Arc::clone(&upstream_hits); + let upstream = Router::new().route( + "/api/admin/endpoints/keys/key-codex-a/export", + any(move |_request: Request| { + let upstream_hits_inner = Arc::clone(&upstream_hits_clone); + async move { + *upstream_hits_inner.lock().expect("mutex should lock") += 1; + (StatusCode::OK, Body::from("unexpected upstream hit")) + } + }), + ); + + let mut key = sample_key( + "key-codex-a", + "provider-codex", + "openai:responses", + "imported-session-token", + ); + key.auth_type = "oauth".to_string(); + key.encrypted_auth_config = Some( + encrypt_python_fernet_plaintext( + DEVELOPMENT_ENCRYPTION_KEY, + r#"{"provider_type":"codex","email":"codex@example.com","headers":{"authorization":"Bearer imported-session-token"}}"#, + ) + .expect("auth config ciphertext should build"), + ); + + let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![sample_provider("provider-codex", "codex", 10)], + vec![], + vec![key], + )); + + let (upstream_url, upstream_handle) = start_server(upstream).await; + let gateway = build_router_with_state( + AppState::new() + .expect("gateway should build") + .with_data_state_for_tests( + GatewayDataState::with_provider_catalog_reader_for_tests( + provider_catalog_repository, + ) + .with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY), + ), + ); + let (gateway_url, gateway_handle) = start_server(gateway).await; + + let response = reqwest::Client::new() + .get(format!( + "{gateway_url}/api/admin/endpoints/keys/key-codex-a/export" + )) + .header(crate::constants::GATEWAY_HEADER, "rust-phase3b") + .header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123") + .header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin") + .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") + .send() + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = response.json().await.expect("json body should parse"); + assert_eq!(payload["provider_type"], "codex"); + assert_eq!(payload["email"], "codex@example.com"); + assert_eq!( + payload["headers"]["authorization"], + "Bearer imported-session-token" + ); + assert!(payload.get("access_token").is_none()); + assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); + + gateway_handle.abort(); + upstream_handle.abort(); +} + +#[tokio::test] +async fn gateway_export_preserves_distinct_imported_access_token_with_authorization_header() { + let upstream_hits = Arc::new(Mutex::new(0usize)); + let upstream_hits_clone = Arc::clone(&upstream_hits); + let upstream = Router::new().route( + "/api/admin/endpoints/keys/key-codex-a/export", + any(move |_request: Request| { + let upstream_hits_inner = Arc::clone(&upstream_hits_clone); + async move { + *upstream_hits_inner.lock().expect("mutex should lock") += 1; + (StatusCode::OK, Body::from("unexpected upstream hit")) + } + }), + ); + + let mut key = sample_key( + "key-codex-a", + "provider-codex", + "openai:responses", + "jwt-access-token", + ); + key.auth_type = "oauth".to_string(); + key.encrypted_auth_config = Some( + encrypt_python_fernet_plaintext( + DEVELOPMENT_ENCRYPTION_KEY, + r#"{"provider_type":"codex","email":"codex@example.com","access_token":"jwt-access-token","headers":{"authorization":"Bearer imported-session-token"}}"#, + ) + .expect("auth config ciphertext should build"), + ); + + let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![sample_provider("provider-codex", "codex", 10)], + vec![], + vec![key], + )); + + let (_upstream_url, upstream_handle) = start_server(upstream).await; + let gateway = build_router_with_state( + AppState::new() + .expect("gateway should build") + .with_data_state_for_tests( + GatewayDataState::with_provider_catalog_reader_for_tests( + provider_catalog_repository, + ) + .with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY), + ), + ); + let (gateway_url, gateway_handle) = start_server(gateway).await; + + let response = reqwest::Client::new() + .get(format!( + "{gateway_url}/api/admin/endpoints/keys/key-codex-a/export" + )) + .header(crate::constants::GATEWAY_HEADER, "rust-phase3b") + .header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123") + .header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin") + .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") + .send() + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = response.json().await.expect("json body should parse"); + assert_eq!(payload["provider_type"], "codex"); + assert_eq!(payload["access_token"], "jwt-access-token"); + assert_eq!( + payload["headers"]["authorization"], + "Bearer imported-session-token" + ); + assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); + + gateway_handle.abort(); + upstream_handle.abort(); +} + #[tokio::test] async fn gateway_clears_admin_provider_key_oauth_invalid_locally_with_trusted_admin_principal() { let upstream_hits = Arc::new(Mutex::new(0usize)); diff --git a/apps/aether-gateway/src/tests/control/admin/oauth.rs b/apps/aether-gateway/src/tests/control/admin/oauth.rs index 20522a894..d35780b64 100644 --- a/apps/aether-gateway/src/tests/control/admin/oauth.rs +++ b/apps/aether-gateway/src/tests/control/admin/oauth.rs @@ -3417,6 +3417,95 @@ async fn gateway_imports_codex_access_token_without_refresh_token_as_temporary_a token_handle.abort(); } +#[tokio::test] +async fn gateway_imports_codex_header_authorization_without_overwriting_payload_access_token() { + let mut provider = sample_provider("provider-codex", "codex", 10); + provider.provider_type = "codex".to_string(); + let endpoint = sample_endpoint( + "endpoint-codex-chat", + "provider-codex", + "openai:chat", + "https://chatgpt.com/backend-api/codex", + ); + let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![provider], + vec![endpoint], + vec![], + )); + + let gateway = build_router_with_state( + AppState::new() + .expect("gateway should build") + .with_data_state_for_tests( + GatewayDataState::with_provider_catalog_repository_for_tests( + provider_catalog_repository.clone(), + ) + .with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY), + ), + ); + let (gateway_url, gateway_handle) = start_server(gateway).await; + let access_token = + sample_codex_access_token_with_profile_email("profile@example.com", "acct-profile-123"); + + let response = reqwest::Client::new() + .post(format!( + "{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token" + )) + .header(crate::constants::GATEWAY_HEADER, "rust-phase3b") + .header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123") + .header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin") + .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") + .json(&json!({ + "access_token": access_token, + "headers": { + "authorization": "Bearer imported-session-token", + "chatgpt-account-id": "acct-header" + }, + "name": "temporary-codex-header-auth", + })) + .send() + .await + .expect("request should succeed"); + + let status = response.status(); + let payload: serde_json::Value = response.json().await.expect("json body should parse"); + assert_eq!(status, StatusCode::OK, "payload={payload}"); + + let reloaded = provider_catalog_repository + .list_keys_by_provider_ids(&["provider-codex".to_string()]) + .await + .expect("keys should load"); + let persisted = reloaded.first().expect("persisted key should exist"); + let decrypted_api_key = decrypt_python_fernet_ciphertext( + DEVELOPMENT_ENCRYPTION_KEY, + persisted + .encrypted_api_key + .as_deref() + .expect("api key should be present"), + ) + .expect("api key should decrypt"); + assert_eq!(decrypted_api_key, access_token); + let decrypted_auth_config = decrypt_python_fernet_ciphertext( + DEVELOPMENT_ENCRYPTION_KEY, + persisted + .encrypted_auth_config + .as_deref() + .expect("auth config should be stored"), + ) + .expect("auth config should decrypt"); + let auth_config: serde_json::Value = + serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse"); + assert_eq!(auth_config["email"], "profile@example.com"); + assert_eq!(auth_config["access_token"], access_token); + assert_eq!( + auth_config["headers"]["authorization"], + "Bearer imported-session-token" + ); + assert_eq!(auth_config["headers"]["chatgpt-account-id"], "acct-header"); + + gateway_handle.abort(); +} + #[tokio::test] async fn gateway_imports_chatgpt_web_access_token_without_refresh_token_as_temporary_account() { let token_hits = Arc::new(Mutex::new(0usize)); diff --git a/apps/aether-gateway/src/tests/control/admin/pool.rs b/apps/aether-gateway/src/tests/control/admin/pool.rs index d2e109c88..116aeb7e8 100644 --- a/apps/aether-gateway/src/tests/control/admin/pool.rs +++ b/apps/aether-gateway/src/tests/control/admin/pool.rs @@ -3040,6 +3040,136 @@ async fn gateway_pool_plan_free_selector_prefers_upstream_plan_type() { ); } +#[tokio::test] +async fn gateway_pool_keys_mark_oauth_header_auth() { + let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields( + true, + false, + true, + None, + None, + None, + None, + None, + Some(json!({ + "pool_advanced": { + "enabled": true + } + })), + ); + provider.provider_type = "codex".to_string(); + let mut key = sample_key( + "key-codex-oauth-header", + "provider-codex", + "openai:responses", + "imported-session-token", + ); + key.auth_type = "oauth".to_string(); + key.encrypted_auth_config = Some( + encrypt_python_fernet_plaintext( + DEVELOPMENT_ENCRYPTION_KEY, + r#"{"provider_type":"codex","headers":{"authorization":"Bearer imported-session-token"}}"#, + ) + .expect("auth config should encrypt"), + ); + + let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![provider], + Vec::new(), + vec![key], + )); + let state = AppState::new() + .expect("gateway should build") + .with_data_state_for_tests( + GatewayDataState::with_provider_catalog_reader_for_tests(provider_catalog_repository) + .with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY), + ); + + let response = local_admin_pool_response( + &state, + http::Method::GET, + "/api/admin/pool/provider-codex/keys?page=1&page_size=50&status=all", + None, + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = serde_json::from_slice( + &to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"), + ) + .expect("json body should parse"); + let keys = payload["keys"].as_array().expect("keys should be array"); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0]["oauth_header_auth"], true); +} + +#[tokio::test] +async fn gateway_pool_resolve_selection_marks_oauth_header_auth() { + let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields( + true, + false, + true, + None, + None, + None, + None, + None, + Some(json!({ + "pool_advanced": { + "enabled": true + } + })), + ); + provider.provider_type = "codex".to_string(); + let mut key = sample_key( + "key-codex-oauth-header", + "provider-codex", + "openai:responses", + "imported-session-token", + ); + key.auth_type = "oauth".to_string(); + key.encrypted_auth_config = Some( + encrypt_python_fernet_plaintext( + DEVELOPMENT_ENCRYPTION_KEY, + r#"{"provider_type":"codex","headers":{"authorization":"Bearer imported-session-token"}}"#, + ) + .expect("auth config should encrypt"), + ); + + let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![provider], + Vec::new(), + vec![key], + )); + let state = AppState::new() + .expect("gateway should build") + .with_data_state_for_tests( + GatewayDataState::with_provider_catalog_reader_for_tests(provider_catalog_repository) + .with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY), + ); + + let response = local_admin_pool_response( + &state, + http::Method::POST, + "/api/admin/pool/provider-codex/keys/resolve-selection", + Some(json!({})), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = serde_json::from_slice( + &to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"), + ) + .expect("json body should parse"); + let items = payload["items"].as_array().expect("items should be array"); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["oauth_header_auth"], true); +} + #[tokio::test] async fn gateway_handles_admin_pool_resolve_selection_locally_with_trusted_admin_principal() { let upstream_hits = Arc::new(Mutex::new(0usize)); diff --git a/crates/aether-provider-pool/src/lib.rs b/crates/aether-provider-pool/src/lib.rs index ef2ed67f3..4aa997d4b 100644 --- a/crates/aether-provider-pool/src/lib.rs +++ b/crates/aether-provider-pool/src/lib.rs @@ -135,6 +135,29 @@ mod tests { ); } + #[test] + fn codex_quota_request_prefers_imported_authorization_header() { + let spec = build_codex_pool_quota_request( + "key-1", + Some(( + "authorization".to_string(), + "Bearer jwt-access-token".to_string(), + )), + None, + Some(&json!({ + "headers": { + "authorization": "Bearer imported-session" + } + })), + ) + .expect("spec should build"); + + assert_eq!( + spec.headers.get("authorization").map(String::as_str), + Some("Bearer imported-session") + ); + } + #[test] fn gemini_cli_quota_request_uses_v1internal_retrieve_user_quota() { let spec = build_gemini_cli_pool_quota_request( diff --git a/crates/aether-provider-pool/src/providers/codex.rs b/crates/aether-provider-pool/src/providers/codex.rs index 368fa5691..6730112e4 100644 --- a/crates/aether-provider-pool/src/providers/codex.rs +++ b/crates/aether-provider-pool/src/providers/codex.rs @@ -76,8 +76,22 @@ pub fn build_codex_pool_quota_request( ) -> Result { let mut headers = BTreeMap::new(); headers.insert("accept".to_string(), "application/json".to_string()); + let auth_config_headers = auth_config + .and_then(|value| value.get("headers")) + .and_then(Value::as_object); + let auth_config_authorization = auth_config_headers + .and_then(|headers| { + headers + .get("authorization") + .or_else(|| headers.get("Authorization")) + }) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); - if let Some((name, value)) = resolved_oauth_auth { + if let Some(authorization) = auth_config_authorization { + headers.insert("authorization".to_string(), authorization.to_string()); + } else if let Some((name, value)) = resolved_oauth_auth { headers.insert(name.to_ascii_lowercase(), value); } else { let decrypted_key = decrypted_api_key.unwrap_or_default().trim(); diff --git a/crates/aether-provider-transport/src/auth_config.rs b/crates/aether-provider-transport/src/auth_config.rs index bb3fdf192..5092a14c4 100644 --- a/crates/aether-provider-transport/src/auth_config.rs +++ b/crates/aether-provider-transport/src/auth_config.rs @@ -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, + 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::(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, @@ -130,6 +158,12 @@ fn parse_local_auth_config_safe_subset(raw: &str) -> Result Result, + out: &mut BTreeMap, +) { + 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, headers: &mut BTreeMap, @@ -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 { } } +fn merge_header_string_map(out: &mut BTreeMap, 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, 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 { + 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 { 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) 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( diff --git a/crates/aether-provider-transport/src/lib.rs b/crates/aether-provider-transport/src/lib.rs index 79a60d527..f5387d763 100644 --- a/crates/aether-provider-transport/src/lib.rs +++ b/crates/aether-provider-transport/src/lib.rs @@ -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, diff --git a/crates/aether-provider-transport/src/openai_image/mod.rs b/crates/aether-provider-transport/src/openai_image/mod.rs index 9be56286b..24acfb3dc 100644 --- a/crates/aether-provider-transport/src/openai_image/mod.rs +++ b/crates/aether-provider-transport/src/openai_image/mod.rs @@ -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", diff --git a/crates/aether-provider-transport/src/standard/mod.rs b/crates/aether-provider-transport/src/standard/mod.rs index 27a0c1518..8e69c0cbf 100644 --- a/crates/aether-provider-transport/src/standard/mod.rs +++ b/crates/aether-provider-transport/src/standard/mod.rs @@ -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( diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index b04cf9e9a..3241174c0 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -169,8 +169,11 @@ export interface RequestDetail { provider: string api_format?: string endpoint_api_format?: string + has_format_conversion?: boolean | null model: string target_model?: string | null // 映射后的目标模型名 + reasoning_effort?: string | null + service_tier?: string | null tokens: { input: number output: number @@ -189,11 +192,14 @@ export interface RequestDetail { cache_creation_input_tokens?: number cache_creation_input_tokens_5m?: number cache_creation_input_tokens_1h?: number + cache_creation_ephemeral_5m_input_tokens?: number + cache_creation_ephemeral_1h_input_tokens?: number cache_read_input_tokens?: number // Additional cost fields input_cost?: number output_cost?: number total_cost?: number + actual_cost?: number cache_creation_cost?: number cache_read_cost?: number image_output_cost?: number diff --git a/frontend/src/api/endpoints/pool.ts b/frontend/src/api/endpoints/pool.ts index 78706d0c1..b7278d6fd 100644 --- a/frontend/src/api/endpoints/pool.ts +++ b/frontend/src/api/endpoints/pool.ts @@ -120,6 +120,7 @@ export interface PoolKeyDetail { credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null oauth_managed?: boolean + oauth_header_auth?: boolean can_refresh_oauth?: boolean can_export_oauth?: boolean can_edit_oauth?: boolean @@ -317,6 +318,7 @@ export interface PoolKeySelectionItem { credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null oauth_managed?: boolean + oauth_header_auth?: boolean can_refresh_oauth?: boolean can_export_oauth?: boolean can_edit_oauth?: boolean diff --git a/frontend/src/api/endpoints/provider_oauth.ts b/frontend/src/api/endpoints/provider_oauth.ts index 325a6ed67..2c2879af0 100644 --- a/frontend/src/api/endpoints/provider_oauth.ts +++ b/frontend/src/api/endpoints/provider_oauth.ts @@ -256,6 +256,7 @@ export async function importProviderRefreshToken( browser_profile?: string user_id?: string account_name?: string + headers?: Record } ): Promise { const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data) diff --git a/frontend/src/api/endpoints/types/__tests__/provider.spec.ts b/frontend/src/api/endpoints/types/__tests__/provider.spec.ts index d7d155f48..d97d246d8 100644 --- a/frontend/src/api/endpoints/types/__tests__/provider.spec.ts +++ b/frontend/src/api/endpoints/types/__tests__/provider.spec.ts @@ -5,7 +5,7 @@ import { normalizeChatPiiRedactionProviderConfig, normalizePoolAdvancedConfig } describe('normalizePoolAdvancedConfig', () => { it('keeps object payloads, including empty objects', () => { expect(normalizePoolAdvancedConfig({})).toEqual({}) - expect(normalizePoolAdvancedConfig({ global_priority: 5 })).toEqual({ global_priority: 5 }) + expect(normalizePoolAdvancedConfig({ rate_limit_cooldown_seconds: 300 })).toEqual({ rate_limit_cooldown_seconds: 300 }) }) it('maps legacy boolean payloads to the current object semantics', () => { diff --git a/frontend/src/api/endpoints/types/provider.ts b/frontend/src/api/endpoints/types/provider.ts index dbc36f66f..3a7ba128e 100644 --- a/frontend/src/api/endpoints/types/provider.ts +++ b/frontend/src/api/endpoints/types/provider.ts @@ -237,6 +237,7 @@ export interface EndpointAPIKey { credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null oauth_managed?: boolean + oauth_header_auth?: boolean can_refresh_oauth?: boolean can_export_oauth?: boolean can_edit_oauth?: boolean @@ -708,7 +709,9 @@ export interface PoolScoreRules { } export interface PoolAdvancedConfig { + // deprecated: hidden from pool advanced dialog, retained for backward-compatible reads global_priority?: number | null + // deprecated: hidden from pool advanced dialog, retained for backward-compatible reads sticky_session_ttl_seconds?: number | null load_threshold_percent?: number | null skip_exhausted_accounts?: boolean | null @@ -725,13 +728,15 @@ export interface PoolAdvancedConfig { } | null latency_window_seconds?: number | null latency_sample_limit?: number | null + // deprecated: hidden from pool advanced dialog, retained for backward-compatible reads cost_window_seconds?: number | null + // deprecated: hidden from pool advanced dialog, retained for backward-compatible reads cost_limit_per_key_tokens?: number | null + // deprecated: hidden from pool advanced dialog, retained for backward-compatible reads cost_soft_threshold_percent?: number | null rate_limit_cooldown_seconds?: number | null overload_cooldown_seconds?: number | null proactive_refresh_seconds?: number | null - health_policy_enabled?: boolean unschedulable_rules?: Array> | null batch_concurrency?: number | null probe_concurrency?: number | null diff --git a/frontend/src/features/pool/components/PoolAdvancedDialog.vue b/frontend/src/features/pool/components/PoolAdvancedDialog.vue index f1a96ad28..a8e033775 100644 --- a/frontend/src/features/pool/components/PoolAdvancedDialog.vue +++ b/frontend/src/features/pool/components/PoolAdvancedDialog.vue @@ -2,7 +2,7 @@ @@ -11,18 +11,18 @@

- 冷却与健康 + 冷却与热池

核心策略

- 控制自动冷却、自适应热池、异常清理和全局调度优先级。 + 控制冷却时间、自适应热池和异常清理。

-
+
+
+ + +
+
+
+

+ 批量操作 +

+ + 任务效率 + +
+

+ 控制刷新 OAuth、自适应热池和批量额度处理时的并行请求数。 +

+
+ +
+

+ 为空时沿用默认值;数值越大,批量操作越快,但会增加瞬时请求压力。 +

+
+
+ +
+
+
+ +
-
-
-
-
-

- 成本控制 -

- - 额度保护 - -
-

- 控制窗口期、Key 限额与软阈值,防止个别账号短时间内过度消耗。 -

-
- -
-
- - -
-
- - -
-
- - -
-
-
- -
-
-
-

- 批量操作 -

- - 任务效率 - -
-

- 控制刷新 OAuth、自适应热池和批量额度处理时的并行请求数。 -

-
- -
-
- - -

- 为空时沿用默认值;数值越大,批量操作越快,但会增加瞬时请求压力。 -

-
-
- - -
-
- - -
-
- - -
-
-
-
-
@@ -625,9 +533,7 @@ import { parseApiError } from '@/utils/errorParser' import { updateProvider } from '@/api/endpoints' import { buildPoolCooldownFieldLayout, - buildPoolCostFieldLayout, buildPoolHealthToggleCards, - buildPoolSecondarySectionLayout, type PoolHealthToggleKey, } from '@/features/pool/utils/poolAdvancedDialog' import type { @@ -658,18 +564,10 @@ const isClaudeCode = computed(() => { const healthToggleCards = buildPoolHealthToggleCards() const cooldownFieldLayout = buildPoolCooldownFieldLayout() -const costFieldLayout = buildPoolCostFieldLayout() -const secondarySectionLayout = buildPoolSecondarySectionLayout() const form = ref({ - global_priority: null as number | null | undefined, - sticky_session_ttl_seconds: null as number | null | undefined, - health_policy_enabled: true, rate_limit_cooldown_seconds: null as number | null | undefined, overload_cooldown_seconds: null as number | null | undefined, - cost_window_seconds: null as number | null | undefined, - cost_limit_per_key_tokens: null as number | null | undefined, - cost_soft_threshold_percent: null as number | null | undefined, batch_concurrency: null as number | null | undefined, probe_concurrency: null as number | null | undefined, score_top_n: null as number | null | undefined, @@ -721,8 +619,6 @@ function parseNum(v: string | number): number | undefined { function getHealthToggleValue(key: PoolHealthToggleKey): boolean { switch (key) { - case 'health_policy_enabled': - return form.value.health_policy_enabled case 'probing_enabled': return form.value.probing_enabled case 'account_self_check_enabled': @@ -736,9 +632,6 @@ function getHealthToggleValue(key: PoolHealthToggleKey): boolean { function updateHealthToggleValue(key: PoolHealthToggleKey, value: boolean): void { switch (key) { - case 'health_policy_enabled': - form.value.health_policy_enabled = value - return case 'probing_enabled': form.value.probing_enabled = value return @@ -760,14 +653,8 @@ watch(() => props.modelValue, (open) => { const scoreRules = cfg?.score_rules const scoreWeights = scoreRules?.weights form.value = { - global_priority: cfg?.global_priority ?? null, - sticky_session_ttl_seconds: cfg?.sticky_session_ttl_seconds ?? null, - health_policy_enabled: cfg?.health_policy_enabled !== false, rate_limit_cooldown_seconds: cfg?.rate_limit_cooldown_seconds ?? null, overload_cooldown_seconds: cfg?.overload_cooldown_seconds ?? null, - cost_window_seconds: cfg?.cost_window_seconds ?? null, - cost_limit_per_key_tokens: cfg?.cost_limit_per_key_tokens ?? null, - cost_soft_threshold_percent: cfg?.cost_soft_threshold_percent ?? null, batch_concurrency: cfg?.batch_concurrency ?? null, probe_concurrency: cfg?.probe_concurrency ?? null, score_top_n: cfg?.score_top_n ?? null, @@ -836,20 +723,20 @@ async function handleSave() { 'self_check_method', 'account_self_check_request', 'self_check_request', + 'health_policy_enabled', + 'sticky_session_ttl_seconds', + 'global_priority', + 'cost_window_seconds', + 'cost_limit_per_key_tokens', + 'cost_soft_threshold_percent', ]) { delete existingPoolAdvanced[key] } // 合并已有配置(保留 scheduling_presets 等不在此对话框编辑的字段) const poolAdvanced: Record = { ...existingPoolAdvanced, - global_priority: form.value.global_priority ?? undefined, - sticky_session_ttl_seconds: form.value.sticky_session_ttl_seconds ?? undefined, - cost_window_seconds: form.value.cost_window_seconds ?? undefined, - cost_limit_per_key_tokens: form.value.cost_limit_per_key_tokens ?? undefined, - cost_soft_threshold_percent: form.value.cost_soft_threshold_percent ?? undefined, rate_limit_cooldown_seconds: form.value.rate_limit_cooldown_seconds ?? undefined, overload_cooldown_seconds: form.value.overload_cooldown_seconds ?? undefined, - health_policy_enabled: form.value.health_policy_enabled, batch_concurrency: form.value.batch_concurrency ?? undefined, probe_concurrency: form.value.probe_concurrency ?? undefined, score_top_n: form.value.score_top_n ?? undefined, diff --git a/frontend/src/features/pool/utils/__tests__/poolAdvancedDialog.spec.ts b/frontend/src/features/pool/utils/__tests__/poolAdvancedDialog.spec.ts index 9190ab584..60e085704 100644 --- a/frontend/src/features/pool/utils/__tests__/poolAdvancedDialog.spec.ts +++ b/frontend/src/features/pool/utils/__tests__/poolAdvancedDialog.spec.ts @@ -3,14 +3,11 @@ import { describe, expect, it } from 'vitest' import { buildPoolCooldownFieldLayout, buildPoolHealthToggleCards, - buildPoolCostFieldLayout, - buildPoolSecondarySectionLayout, } from '@/features/pool/utils/poolAdvancedDialog' describe('poolAdvancedDialog', () => { it('returns health toggle cards in the desktop display order', () => { expect(buildPoolHealthToggleCards().map(item => item.key)).toEqual([ - 'health_policy_enabled', 'probing_enabled', 'account_self_check_enabled', 'auto_remove_banned_keys', @@ -20,11 +17,6 @@ describe('poolAdvancedDialog', () => { it('provides tooltip copy for every desktop health toggle card', () => { expect(buildPoolHealthToggleCards()).toEqual([ - { - key: 'health_policy_enabled', - label: '健康策略', - description: '按上游错误自动冷却并跳过异常账号。', - }, { key: 'probing_enabled', label: '自适应热池', @@ -48,32 +40,13 @@ describe('poolAdvancedDialog', () => { ]) }) - it('returns the four cooldown-related fields in one desktop row order', () => { + it('returns only cooldown-related fields in one desktop row order', () => { expect(buildPoolCooldownFieldLayout()).toEqual({ fields: [ 'rate_limit_cooldown_seconds', 'overload_cooldown_seconds', - 'sticky_session_ttl_seconds', - 'global_priority', ], - desktopColumnsClass: 'xl:grid-cols-4', - }) - }) - - it('stacks batch and cost sections as full-width rows on desktop', () => { - expect(buildPoolSecondarySectionLayout()).toEqual({ - wrapperClass: 'space-y-4', - }) - }) - - it('returns the three cost fields in one desktop row order', () => { - expect(buildPoolCostFieldLayout()).toEqual({ - fields: [ - 'cost_window_seconds', - 'cost_limit_per_key_tokens', - 'cost_soft_threshold_percent', - ], - desktopColumnsClass: 'xl:grid-cols-3', + desktopColumnsClass: 'xl:grid-cols-2', }) }) }) diff --git a/frontend/src/features/pool/utils/poolAdvancedDialog.ts b/frontend/src/features/pool/utils/poolAdvancedDialog.ts index eb0da0b11..343288cef 100644 --- a/frontend/src/features/pool/utils/poolAdvancedDialog.ts +++ b/frontend/src/features/pool/utils/poolAdvancedDialog.ts @@ -1,5 +1,4 @@ export type PoolHealthToggleKey = - | 'health_policy_enabled' | 'probing_enabled' | 'account_self_check_enabled' | 'auto_remove_banned_keys' @@ -16,22 +15,8 @@ export interface PoolCooldownFieldLayout { desktopColumnsClass: string } -export interface PoolSecondarySectionLayout { - wrapperClass: string -} - -export interface PoolCostFieldLayout { - fields: string[] - desktopColumnsClass: string -} - export function buildPoolHealthToggleCards(): PoolHealthToggleCard[] { return [ - { - key: 'health_policy_enabled', - label: '健康策略', - description: '按上游错误自动冷却并跳过异常账号。', - }, { key: 'probing_enabled', label: '自适应热池', @@ -60,26 +45,7 @@ export function buildPoolCooldownFieldLayout(): PoolCooldownFieldLayout { fields: [ 'rate_limit_cooldown_seconds', 'overload_cooldown_seconds', - 'sticky_session_ttl_seconds', - 'global_priority', ], - desktopColumnsClass: 'xl:grid-cols-4', - } -} - -export function buildPoolSecondarySectionLayout(): PoolSecondarySectionLayout { - return { - wrapperClass: 'space-y-4', - } -} - -export function buildPoolCostFieldLayout(): PoolCostFieldLayout { - return { - fields: [ - 'cost_window_seconds', - 'cost_limit_per_key_tokens', - 'cost_soft_threshold_percent', - ], - desktopColumnsClass: 'xl:grid-cols-3', + desktopColumnsClass: 'xl:grid-cols-2', } } diff --git a/frontend/src/features/providers/components/EndpointFormDialog.vue b/frontend/src/features/providers/components/EndpointFormDialog.vue index db6c7a8a5..590b03dbe 100644 --- a/frontend/src/features/providers/components/EndpointFormDialog.vue +++ b/frontend/src/features/providers/components/EndpointFormDialog.vue @@ -1455,7 +1455,10 @@ const defaultBodyRulesByFormat = ref>({}) const defaultBodyRulesLoaded = ref>({}) const loadingDefaultBodyRulesByFormat = ref>({}) -// 系统保留的 header 名称(不允许用户设置) +// Endpoint 规则会经过通用 header/body rule 引擎执行。认证头、协议控制头和 +// 响应长度这类字段在后端同样是 protected keys;前端也保持同一语义,避免用户 +// 以为普通 endpoint rules 能改认证。OAuth/账号导入保留 headers 走 auth_config, +// 不走这里的 endpoint header_rules。 const RESERVED_HEADERS = new Set([ 'authorization', 'x-api-key', @@ -1463,6 +1466,7 @@ const RESERVED_HEADERS = new Set([ 'content-type', 'content-length', 'host', + 'proxy-authorization', ]) const RESERVED_RESPONSE_HEADERS = new Set([ @@ -1472,7 +1476,6 @@ const RESERVED_RESPONSE_HEADERS = new Set([ const RESPONSE_HEADER_RULES_CONFIG_KEY = 'response_header_rules' const RESPONSE_HEADER_RULES_CAMEL_CONFIG_KEY = 'responseHeaderRules' -// 系统保留的 body 字段名(不允许用户设置) const RESERVED_BODY_FIELDS = new Set([ 'stream', ]) @@ -1539,7 +1542,41 @@ function requireJsonString(rule: Record, key: string, label: st return typeof rule[key] === 'string' ? null : `${label}第 ${index + 1} 条:${key} 必须是字符串` } -function validateHeaderRuleJson(rule: unknown, label: string, index: number): string | null { +function normalizeHeaderRuleName(raw: string): string { + return raw.trim().toLowerCase() +} + +function reservedHeaderRuleError(raw: string): string | null { + const name = normalizeHeaderRuleName(raw) + return name && RESERVED_HEADERS.has(name) ? `"${raw}" 是系统保留的请求头` : null +} + +function reservedResponseHeaderRuleError(raw: string): string | null { + const name = normalizeHeaderRuleName(raw) + return name && RESERVED_RESPONSE_HEADERS.has(name) ? `"${raw}" 是系统保留的响应头` : null +} + +function bodyRuleTopLevelField(rawPath: string): string | null { + const raw = rawPath.trim() + if (!raw) return null + const dotPart = raw.includes('[') ? raw.slice(0, raw.indexOf('[')) : raw + const parts = dotPart ? parseBodyRulePathParts(dotPart) : [raw.split('[')[0] || raw] + return parts?.[0]?.trim().toLowerCase() || null +} + +function reservedBodyRuleFieldError(rawPath: string): string | null { + const topField = bodyRuleTopLevelField(rawPath) + return topField && RESERVED_BODY_FIELDS.has(topField) + ? `"${topField}" 是系统保留的顶层字段` + : null +} + +function validateHeaderRuleJson( + rule: unknown, + label: string, + index: number, + reservedNameError: (raw: string) => string | null = reservedHeaderRuleError +): string | null { if (!isJsonObject(rule)) return `${label}第 ${index + 1} 条必须是对象` if (rule.enabled !== undefined && typeof rule.enabled !== 'boolean') { return `${label}第 ${index + 1} 条:enabled 必须是布尔值` @@ -1550,15 +1587,19 @@ function validateHeaderRuleJson(rule: unknown, label: string, index: number): st } if (action === 'set') { return requireJsonString(rule, 'key', label, index) + || reservedNameError(rule.key as string) || requireJsonString(rule, 'value', label, index) || validateJsonCondition(rule, label, index) } if (action === 'drop') { return requireJsonString(rule, 'key', label, index) + || reservedNameError(rule.key as string) || validateJsonCondition(rule, label, index) } return requireJsonString(rule, 'from', label, index) || requireJsonString(rule, 'to', label, index) + || reservedNameError(rule.from as string) + || reservedNameError(rule.to as string) || validateJsonCondition(rule, label, index) } @@ -1574,26 +1615,32 @@ function validateBodyRuleJson(rule: unknown, label: string, index: number): stri if (action === 'set' || action === 'append') { return requireJsonString(rule, 'path', label, index) + || reservedBodyRuleFieldError(rule.path as string) || (Object.prototype.hasOwnProperty.call(rule, 'value') ? null : `${label}第 ${index + 1} 条:value 不能为空`) || validateJsonCondition(rule, label, index) } if (action === 'drop') { return requireJsonString(rule, 'path', label, index) + || reservedBodyRuleFieldError(rule.path as string) || validateJsonCondition(rule, label, index) } if (action === 'rename') { return requireJsonString(rule, 'from', label, index) || requireJsonString(rule, 'to', label, index) + || reservedBodyRuleFieldError(rule.from as string) + || reservedBodyRuleFieldError(rule.to as string) || validateJsonCondition(rule, label, index) } if (action === 'insert') { if (requireJsonString(rule, 'path', label, index)) return requireJsonString(rule, 'path', label, index) + if (reservedBodyRuleFieldError(rule.path as string)) return reservedBodyRuleFieldError(rule.path as string) if (!Number.isInteger(rule.index)) return `${label}第 ${index + 1} 条:index 必须是整数` if (!Object.prototype.hasOwnProperty.call(rule, 'value')) return `${label}第 ${index + 1} 条:value 不能为空` return validateJsonCondition(rule, label, index) } if (action === 'regex_replace') { if (requireJsonString(rule, 'path', label, index)) return requireJsonString(rule, 'path', label, index) + if (reservedBodyRuleFieldError(rule.path as string)) return reservedBodyRuleFieldError(rule.path as string) if (requireJsonString(rule, 'pattern', label, index)) return requireJsonString(rule, 'pattern', label, index) if (typeof rule.replacement !== 'string') return `${label}第 ${index + 1} 条:replacement 必须是字符串` if (rule.flags !== undefined && typeof rule.flags !== 'string') return `${label}第 ${index + 1} 条:flags 必须是字符串` @@ -1633,7 +1680,12 @@ function parseEndpointRulesJsonDraft(draft: string): { value: EndpointRulesJsonP if (error) return { value: null, error } } for (let i = 0; i < response.value.length; i++) { - const error = validateHeaderRuleJson(response.value[i], 'response_header_rules ', i) + const error = validateHeaderRuleJson( + response.value[i], + 'response_header_rules ', + i, + reservedResponseHeaderRuleError, + ) if (error) return { value: null, error } } @@ -2334,9 +2386,8 @@ function validateRuleKeyForEndpoint(endpointId: string, key: string, index: numb const trimmedKey = key.trim().toLowerCase() if (!trimmedKey) return null - if (RESERVED_HEADERS.has(trimmedKey)) { - return `"${key}" 是系统保留的请求头` - } + const reservedErr = reservedHeaderRuleError(key) + if (reservedErr) return reservedErr const rules = getEndpointEditRules(endpointId) const currentRule = rules[index] @@ -2359,6 +2410,9 @@ function validateRenameFromForEndpoint(endpointId: string, from: string, index: const trimmedFrom = from.trim().toLowerCase() if (!trimmedFrom) return null + const reservedErr = reservedHeaderRuleError(from) + if (reservedErr) return reservedErr + const rules = getEndpointEditRules(endpointId) const currentRule = rules[index] if (currentRule && !currentRule.enabled) return null @@ -2380,9 +2434,8 @@ function validateRenameToForEndpoint(endpointId: string, to: string, index: numb const trimmedTo = to.trim().toLowerCase() if (!trimmedTo) return null - if (RESERVED_HEADERS.has(trimmedTo)) { - return `"${to}" 是系统保留的请求头` - } + const reservedErr = reservedHeaderRuleError(to) + if (reservedErr) return reservedErr const rules = getEndpointEditRules(endpointId) const currentRule = rules[index] @@ -2525,11 +2578,8 @@ function validateBodyRulePathForEndpoint(endpointId: string, path: string, index return '路径格式无效' } - // 提取顶层 key(去除数组索引部分) - const topKey = (parts[0] || '').trim().toLowerCase() - if (RESERVED_BODY_FIELDS.has(topKey)) { - return `"${parts[0]}" 是系统保留的顶层字段` - } + const reservedErr = reservedBodyRuleFieldError(raw) + if (reservedErr) return reservedErr const normalizedPath = raw.toLowerCase() @@ -2560,10 +2610,8 @@ function validateBodyRenameFromForEndpoint(endpointId: string, from: string, ind return '路径格式无效(不允许 .a / a. / a..b)' } - const topKey = (parts[0] || '').trim().toLowerCase() - if (RESERVED_BODY_FIELDS.has(topKey)) { - return `"${parts[0]}" 是系统保留的顶层字段` - } + const reservedErr = reservedBodyRuleFieldError(raw) + if (reservedErr) return reservedErr const normalizedFrom = raw.toLowerCase() @@ -2593,10 +2641,8 @@ function validateBodyRenameToForEndpoint(endpointId: string, to: string, index: return '路径格式无效(不允许 .a / a. / a..b)' } - const topKey = (parts[0] || '').trim().toLowerCase() - if (RESERVED_BODY_FIELDS.has(topKey)) { - return `"${parts[0]}" 是系统保留的顶层字段` - } + const reservedErr = reservedBodyRuleFieldError(raw) + if (reservedErr) return reservedErr const normalizedTo = raw.toLowerCase() @@ -3095,9 +3141,8 @@ function validateResponseHeaderNameForEndpoint(endpointId: string, name: string, const trimmedName = name.trim().toLowerCase() if (!trimmedName) return null - if ((field === 'key' || field === 'to') && RESERVED_RESPONSE_HEADERS.has(trimmedName)) { - return `"${name}" 是系统保留的响应头` - } + const reservedErr = reservedResponseHeaderRuleError(name) + if (reservedErr) return reservedErr const rules = getEndpointEditResponseRules(endpointId) const currentRule = rules[index] diff --git a/frontend/src/features/providers/components/OAuthAccountDialog.vue b/frontend/src/features/providers/components/OAuthAccountDialog.vue index 92f08e71f..274f6f26e 100644 --- a/frontend/src/features/providers/components/OAuthAccountDialog.vue +++ b/frontend/src/features/providers/components/OAuthAccountDialog.vue @@ -1352,6 +1352,7 @@ function parseImportText(text: string): { browser_profile?: string user_id?: string account_name?: string + headers?: Record } | null { const trimmed = text.trim() if (!trimmed) return null @@ -1422,7 +1423,14 @@ function parseImportText(text: string): { const normalizedSessionToken = typeof sessionToken === 'string' && sessionToken.trim() ? sessionToken.trim() : (typeof sessionTokenCamel === 'string' && sessionTokenCamel.trim() ? sessionTokenCamel.trim() : undefined) - const importedAccessToken = normalizedAccessToken ?? grokSsoToken ?? normalizedSessionToken + const normalizedHeaders = normalizeHeadersField(obj.headers) + ?? normalizeHeadersField(obj.request_headers) + ?? normalizeHeadersField(obj.requestHeaders) + ?? normalizeHeadersField(obj.header_overrides) + ?? normalizeHeadersField(obj.headerOverrides) + ?? normalizeHeadersField(obj.extra_headers) + ?? normalizeHeadersField(obj.extraHeaders) + const importedAccessToken = normalizedAccessToken ?? grokSsoToken ?? normalizedSessionToken ?? bearerTokenFromHeaders(normalizedHeaders) if (normalizedRefreshToken || importedAccessToken) { return { refresh_token: normalizedRefreshToken, @@ -1441,6 +1449,7 @@ function parseImportText(text: string): { browser_profile: isGrokProvider.value ? normalizeStringField(obj.browser_profile) ?? normalizeStringField(obj.browserProfile) ?? normalizeStringField(obj.browser) ?? normalizeStringField(obj.impersonate) ?? grokCookieImport?.browser_profile : undefined, user_id: normalizeStringField(obj.user_id) ?? normalizeStringField(obj.userId) ?? normalizeStringField(obj.chatgpt_user_id) ?? normalizeStringField(obj.chatgptUserId), account_name: normalizeStringField(obj.account_name) ?? normalizeStringField(obj.accountName), + headers: normalizedHeaders, } } return null @@ -1526,6 +1535,34 @@ function normalizeStringField(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined } +function normalizeHeadersField(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined + const headers: Record = {} + for (const [rawKey, rawValue] of Object.entries(value as Record)) { + const key = rawKey.trim().toLowerCase() + if (!key || ['host', 'content-length', 'connection', 'transfer-encoding', 'proxy-authorization'].includes(key)) { + continue + } + let headerValue: string | undefined + if (typeof rawValue === 'string') { + headerValue = rawValue.trim() + } else if (typeof rawValue === 'number' || typeof rawValue === 'boolean') { + headerValue = String(rawValue) + } + if (headerValue) { + headers[key] = headerValue + } + } + return Object.keys(headers).length > 0 ? headers : undefined +} + +function bearerTokenFromHeaders(headers: Record | undefined): string | undefined { + const authorization = headers?.authorization?.trim() + if (!authorization) return undefined + const match = authorization.match(/^bearer\s+(.+)$/i) + return match?.[1]?.trim() || undefined +} + function normalizeNumberField(value: unknown): number | undefined { if (typeof value === 'number' && Number.isFinite(value) && value > 0) { return Math.floor(value) diff --git a/frontend/src/features/providers/components/ProviderDetailDrawer.vue b/frontend/src/features/providers/components/ProviderDetailDrawer.vue index 5e98e25c8..84a3012d8 100644 --- a/frontend/src/features/providers/components/ProviderDetailDrawer.vue +++ b/frontend/src/features/providers/components/ProviderDetailDrawer.vue @@ -323,14 +323,14 @@
- {{ isOAuthManagedCredential(key) ? '[Refresh Token]' : (isServiceAccountCredential(key) ? '[Service Account]' : key.api_key_masked) }} + {{ getProviderMaskedSecretLabel(key, provider.provider_type) }}