fix: preserve provider auth metadata

This commit is contained in:
elky
2026-06-20 22:11:42 +08:00
parent 1123095bb7
commit f1c30439ff
51 changed files with 2098 additions and 420 deletions
@@ -210,6 +210,7 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
}) })
} else { } else {
build_openai_image_headers(ProviderOpenAiImageHeadersInput { build_openai_image_headers(ProviderOpenAiImageHeadersInput {
transport,
headers: effective_headers, headers: effective_headers,
auth_header: &auth_header, auth_header: &auth_header,
auth_value: &auth_value, auth_value: &auth_value,
@@ -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 effective_headers = input.effective_headers(&parts.headers);
let Some(mut provider_request_headers) = let Some(mut provider_request_headers) =
build_openai_image_headers(ProviderOpenAiImageHeadersInput { build_openai_image_headers(ProviderOpenAiImageHeadersInput {
transport,
headers: effective_headers, headers: effective_headers,
auth_header: &prepared_candidate.auth_header, auth_header: &prepared_candidate.auth_header,
auth_value: &prepared_candidate.auth_value, auth_value: &prepared_candidate.auth_value,
@@ -1053,6 +1053,7 @@ async fn resolve_openai_chat_to_openai_image_payload_parts(
}; };
let Some(mut provider_request_headers) = let Some(mut provider_request_headers) =
build_openai_image_headers(ProviderOpenAiImageHeadersInput { build_openai_image_headers(ProviderOpenAiImageHeadersInput {
transport,
headers: &parts.headers, headers: &parts.headers,
auth_header: &prepared_candidate.auth_header, auth_header: &prepared_candidate.auth_header,
auth_value: &prepared_candidate.auth_value, auth_value: &prepared_candidate.auth_value,
@@ -46,12 +46,12 @@ use crate::ai_serving::transport::kiro::{
KiroRequestAuth, KIRO_ENVELOPE_NAME, KiroRequestAuth, KIRO_ENVELOPE_NAME,
}; };
use crate::ai_serving::transport::{ use crate::ai_serving::transport::{
build_grok_browser_headers, build_grok_upstream_url, build_kiro_cross_format_upstream_url, apply_local_auth_config_header_overrides, build_grok_browser_headers, build_grok_upstream_url,
build_openai_image_headers, build_openai_image_upstream_url, build_kiro_cross_format_upstream_url, build_openai_image_headers,
build_standard_provider_request_headers, build_windsurf_cascade_headers, build_openai_image_upstream_url, build_standard_provider_request_headers,
build_windsurf_cascade_request_body, build_windsurf_cascade_upstream_url, build_windsurf_cascade_headers, build_windsurf_cascade_request_body,
is_gemini_cli_provider_transport, is_windsurf_provider_transport, build_windsurf_cascade_upstream_url, is_gemini_cli_provider_transport,
local_standard_transport_unsupported_reason_with_network, is_windsurf_provider_transport, local_standard_transport_unsupported_reason_with_network,
local_windsurf_request_transport_unsupported_reason_with_network, local_windsurf_request_transport_unsupported_reason_with_network,
openai_image_transport_unsupported_reason, resolve_openai_image_auth, GrokHeaderInput, openai_image_transport_unsupported_reason, resolve_openai_image_auth, GrokHeaderInput,
ProviderOpenAiImageHeadersInput, StandardProviderRequestHeadersInput, ProviderOpenAiImageHeadersInput, StandardProviderRequestHeadersInput,
@@ -681,6 +681,10 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
Some(trace_id), Some(trace_id),
transport.key.decrypted_auth_config.as_deref(), 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( request_identity_response_encoding_when_redacted(
&mut provider_request_headers, &mut provider_request_headers,
@@ -857,6 +861,10 @@ async fn build_gemini_cli_openai_responses_payload_parts(
Some(trace_id), Some(trace_id),
resolved.transport.key.decrypted_auth_config.as_deref(), 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( request_identity_response_encoding_when_redacted(
&mut provider_request_headers, &mut provider_request_headers,
request_redacted, request_redacted,
@@ -1143,6 +1151,7 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
}; };
let Some(mut provider_request_headers) = let Some(mut provider_request_headers) =
build_openai_image_headers(ProviderOpenAiImageHeadersInput { build_openai_image_headers(ProviderOpenAiImageHeadersInput {
transport,
headers: &parts.headers, headers: &parts.headers,
auth_header: &prepared_candidate.auth_header, auth_header: &prepared_candidate.auth_header,
auth_value: &prepared_candidate.auth_value, auth_value: &prepared_candidate.auth_value,
@@ -1181,6 +1190,10 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
Some(trace_id), Some(trace_id),
transport.key.decrypted_auth_config.as_deref(), 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) = let (execution_strategy, conversion_mode) =
@@ -59,8 +59,8 @@ pub(crate) mod windsurf {
} }
pub(crate) use aether_provider_transport::{ pub(crate) use aether_provider_transport::{
append_transport_diagnostics_to_value, apply_local_body_rules, append_transport_diagnostics_to_value, apply_local_auth_config_header_overrides,
apply_local_body_rules_with_request_headers, apply_local_header_rules, 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_local_header_rules_with_request_headers, apply_standard_provider_request_body_rules,
apply_standard_provider_request_body_rules_with_request_headers, apply_standard_provider_request_body_rules_with_request_headers,
apply_transport_request_body_semantics, body_rules_are_locally_supported, apply_transport_request_body_semantics, body_rules_are_locally_supported,
@@ -1,4 +1,5 @@
use crate::handlers::admin::request::AdminAppState; 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; use aether_crypto::decrypt_python_fernet_ciphertext;
#[cfg(test)] #[cfg(test)]
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY; use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
@@ -28,7 +29,15 @@ pub(super) fn admin_monitoring_masked_provider_key_prefix(
) -> Option<String> { ) -> Option<String> {
match key.auth_type.trim() { match key.auth_type.trim() {
"service_account" | "vertex_ai" => Some("[Service Account]".to_string()), "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 let full_key = key
.encrypted_api_key .encrypted_api_key
@@ -1,6 +1,7 @@
use super::super::helpers::admin_provider_oauth_key_name_from_auth_config; use super::super::helpers::admin_provider_oauth_key_name_from_auth_config;
use super::super::token_import::{ 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::kiro_import::execute_admin_provider_oauth_kiro_batch_import;
use super::parse::{ use super::parse::{
@@ -394,9 +395,22 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
let AdminProviderOAuthResolvedBatchImport { let AdminProviderOAuthResolvedBatchImport {
access_token, access_token,
mut auth_config, mut auth_config,
expires_at, mut expires_at,
} = resolved_import; } = resolved_import;
apply_admin_provider_oauth_batch_import_hints(provider_type, entry, &mut auth_config); 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 = let duplicate =
match find_duplicate_provider_oauth_key(state, provider_id, &auth_config, None).await { match find_duplicate_provider_oauth_key(state, provider_id, &auth_config, None).await {
@@ -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::errors::build_internal_control_error_response;
use crate::handlers::admin::provider::oauth::state::{current_unix_secs, json_u64_value}; use crate::handlers::admin::provider::oauth::state::{current_unix_secs, json_u64_value};
use axum::{ use axum::{
@@ -9,6 +13,7 @@ use axum::{
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@@ -22,6 +27,7 @@ pub(super) struct AdminProviderOAuthBatchImportEntry {
pub parse_error: Option<String>, pub parse_error: Option<String>,
pub refresh_token: Option<String>, pub refresh_token: Option<String>,
pub access_token: Option<String>, pub access_token: Option<String>,
pub export_access_token: Option<String>,
pub raw_credentials: Option<serde_json::Value>, pub raw_credentials: Option<serde_json::Value>,
pub expires_at: Option<u64>, pub expires_at: Option<u64>,
pub account_id: Option<String>, pub account_id: Option<String>,
@@ -37,6 +43,7 @@ pub(super) struct AdminProviderOAuthBatchImportEntry {
pub sso_rw_token: Option<String>, pub sso_rw_token: Option<String>,
pub cf_cookies: Option<String>, pub cf_cookies: Option<String>,
pub cf_clearance: Option<String>, pub cf_clearance: Option<String>,
pub request_headers: Option<BTreeMap<String, String>>,
pub user_agent: Option<String>, pub user_agent: Option<String>,
pub browser_profile: Option<String>, pub browser_profile: Option<String>,
} }
@@ -183,6 +190,7 @@ fn extract_admin_provider_oauth_batch_import_entry(
parse_error: None, parse_error: None,
refresh_token, refresh_token,
access_token, access_token,
export_access_token: None,
raw_credentials: None, raw_credentials: None,
expires_at: None, expires_at: None,
account_id: 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"), sso_rw_token: grok_cookie_value(raw_token, "sso-rw"),
cf_cookies: grok_cookie_profile(raw_token), cf_cookies: grok_cookie_profile(raw_token),
cf_clearance: grok_cookie_value(raw_token, "cf_clearance"), cf_clearance: grok_cookie_value(raw_token, "cf_clearance"),
request_headers: None,
user_agent: None, user_agent: None,
browser_profile: None, browser_profile: None,
}) })
@@ -237,10 +246,24 @@ fn extract_admin_provider_oauth_batch_import_entry(
.as_deref() .as_deref()
.and_then(|cookie| grok_cookie_value(cookie, "sso")) .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( let (refresh_token, access_token) = normalize_provider_import_tokens(
provider_type, provider_type,
refresh_token.as_deref(), 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 let windsurf_api_key = is_windsurf
.then(|| { .then(|| {
@@ -386,6 +409,7 @@ fn extract_admin_provider_oauth_batch_import_entry(
parse_error: None, parse_error: None,
refresh_token, refresh_token,
access_token, access_token,
export_access_token,
raw_credentials, raw_credentials,
expires_at, expires_at,
account_id, account_id,
@@ -401,6 +425,7 @@ fn extract_admin_provider_oauth_batch_import_entry(
sso_rw_token, sso_rw_token,
cf_cookies, cf_cookies,
cf_clearance, cf_clearance,
request_headers,
user_agent, user_agent,
browser_profile, browser_profile,
}) })
@@ -479,6 +504,7 @@ fn parse_error_entry(error: String) -> AdminProviderOAuthBatchImportEntry {
parse_error: Some(error), parse_error: Some(error),
refresh_token: None, refresh_token: None,
access_token: None, access_token: None,
export_access_token: None,
raw_credentials: None, raw_credentials: None,
expires_at: None, expires_at: None,
account_id: None, account_id: None,
@@ -494,6 +520,7 @@ fn parse_error_entry(error: String) -> AdminProviderOAuthBatchImportEntry {
sso_rw_token: None, sso_rw_token: None,
cf_cookies: None, cf_cookies: None,
cf_clearance: None, cf_clearance: None,
request_headers: None,
user_agent: None, user_agent: None,
browser_profile: None, browser_profile: None,
} }
@@ -569,6 +596,11 @@ pub(super) fn apply_admin_provider_oauth_batch_import_hints(
.entry("pool_tier".to_string()) .entry("pool_tier".to_string())
.or_insert_with(|| json!(pool_tier)); .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() { if let Some(user_id) = entry.user_id.as_ref() {
auth_config auth_config
.entry("user_id".to_string()) .entry("user_id".to_string())
@@ -599,6 +631,11 @@ pub(super) fn apply_admin_provider_oauth_batch_import_hints(
.entry("cf_clearance".to_string()) .entry("cf_clearance".to_string())
.or_insert_with(|| json!(cf_clearance)); .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() { if let Some(user_agent) = entry.user_agent.as_ref() {
auth_config auth_config
.entry("user_agent".to_string()) .entry("user_agent".to_string())
@@ -721,11 +758,110 @@ mod tests {
assert_eq!(entries.len(), 1); assert_eq!(entries.len(), 1);
assert_eq!(entries[0].refresh_token, None); assert_eq!(entries[0].refresh_token, None);
assert_eq!(entries[0].access_token.as_deref(), Some("at_1")); 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].expires_at, Some(2_100_000_000));
assert_eq!(entries[0].account_id.as_deref(), Some("acc-1")); assert_eq!(entries[0].account_id.as_deref(), Some("acc-1"));
assert_eq!(entries[0].email.as_deref(), Some("[email protected]")); assert_eq!(entries[0].email.as_deref(), Some("[email protected]"));
} }
#[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] #[test]
fn parses_common_chatgpt_web_json_aliases() { fn parses_common_chatgpt_web_json_aliases() {
let entries = parse_admin_provider_oauth_batch_import_entries( let entries = parse_admin_provider_oauth_batch_import_entries(
@@ -16,7 +16,9 @@ use super::super::state::{
}; };
use super::helpers::admin_provider_oauth_key_name_from_auth_config; use super::helpers::admin_provider_oauth_key_name_from_auth_config;
use super::token_import::{ 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, provider_type_supports_access_token_import,
}; };
use crate::handlers::admin::provider::shared::paths::admin_provider_oauth_import_provider_id; 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<String, serde_json::Value>,
) -> Option<String> {
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( fn apply_single_import_hints(
provider_type: &str, provider_type: &str,
payload: &serde_json::Map<String, serde_json::Value>, payload: &serde_json::Map<String, serde_json::Value>,
@@ -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( 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", "session_token",
"sessionToken", "sessionToken",
], ],
); )
.or_else(|| provider_oauth_import_authorization_bearer_token_from_object(&raw_payload));
let imported_expires_at = let imported_expires_at =
import_payload_u64_any(&raw_payload, &["expires_at", "expiresAt", "expired"]); import_payload_u64_any(&raw_payload, &["expires_at", "expiresAt", "expired"]);
let name = raw_payload let name = raw_payload
@@ -614,9 +643,22 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
let AdminProviderOAuthSingleImportTokens { let AdminProviderOAuthSingleImportTokens {
access_token, access_token,
mut auth_config, mut auth_config,
expires_at, mut expires_at,
} = resolved_import; } = resolved_import;
apply_single_import_hints(&provider_type, &raw_payload, &mut auth_config); 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 let has_refresh_token = auth_config
.get("refresh_token") .get("refresh_token")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
@@ -776,6 +818,69 @@ mod tests {
assert_eq!(auth_config.get("user_agent"), Some(&json!("antigravity"))); 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] #[test]
fn windsurf_import_error_redacts_http_body() { fn windsurf_import_error_redacts_http_body() {
let error = OAuthError::HttpStatus { let error = OAuthError::HttpStatus {
@@ -5,6 +5,7 @@ use base64::{
Engine as _, Engine as _,
}; };
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
fn decode_base64_url_part(value: &str) -> Option<Vec<u8>> { fn decode_base64_url_part(value: &str) -> Option<Vec<u8>> {
URL_SAFE_NO_PAD URL_SAFE_NO_PAD
@@ -117,6 +118,95 @@ pub(super) fn decode_access_token_expires_at(access_token: &str) -> Option<u64>
json_u64_value(claims.get("exp")) json_u64_value(claims.get("exp"))
} }
fn normalize_import_header_name(raw: &str) -> Option<String> {
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<String> {
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<BTreeMap<String, String>> {
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<String, Value>,
) -> Option<BTreeMap<String, String>> {
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<String, String>>,
) -> Option<String> {
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<String, Value>,
) -> Option<String> {
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 { pub(super) fn provider_type_supports_access_token_import(provider_type: &str) -> bool {
matches!( matches!(
provider_type.trim().to_ascii_lowercase().as_str(), provider_type.trim().to_ascii_lowercase().as_str(),
@@ -176,7 +266,9 @@ pub(super) fn build_provider_access_token_import_auth_config(
mod tests { mod tests {
use super::{ use super::{
build_provider_access_token_import_auth_config, decode_access_token_expires_at, 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 base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde_json::json; 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] #[test]
fn builds_chatgpt_web_temporary_auth_config_from_access_token() { fn builds_chatgpt_web_temporary_auth_config_from_access_token() {
let token = unsigned_jwt(json!({ let token = unsigned_jwt(json!({
@@ -76,8 +76,12 @@ async fn execute_chatgpt_web_quota_plan(
state.resolve_transport_execution_timeouts(transport), state.resolve_transport_execution_timeouts(transport),
proxy.as_ref(), proxy.as_ref(),
)); ));
let spec = let mut spec =
build_chatgpt_web_pool_quota_request(&transport.key.id, &endpoint.base_url, authorization); 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 resolved_transport_profile = state.resolve_transport_profile(transport);
let plan = super::shared::build_provider_quota_execution_plan( let plan = super::shared::build_provider_quota_execution_plan(
transport, transport,
@@ -16,12 +16,17 @@ pub(super) fn build_codex_quota_request_spec(
.decrypted_auth_config .decrypted_auth_config
.as_deref() .as_deref()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok()); .and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok());
build_codex_pool_quota_request( let mut request = build_codex_pool_quota_request(
&transport.key.id, &transport.key.id,
resolved_oauth_auth, resolved_oauth_auth,
Some(transport.key.decrypted_api_key.as_str()), Some(transport.key.decrypted_api_key.as_str()),
auth_config.as_ref(), 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( pub(super) async fn execute_codex_quota_plan(
@@ -405,7 +405,6 @@ pub(crate) fn admin_provider_pool_config_from_config_value(
cost_limit_per_key_tokens: None, cost_limit_per_key_tokens: None,
rate_limit_cooldown_seconds: 300, rate_limit_cooldown_seconds: 300,
overload_cooldown_seconds: 30, overload_cooldown_seconds: 30,
health_policy_enabled: true,
probing_enabled: false, probing_enabled: false,
probing_target_percent: None, probing_target_percent: None,
probing_target_count: None, probing_target_count: None,
@@ -466,10 +465,6 @@ pub(crate) fn admin_provider_pool_config_from_config_value(
.and_then(json_u64) .and_then(json_u64)
.filter(|value| *value > 0) .filter(|value| *value > 0)
.unwrap_or(30), .unwrap_or(30),
health_policy_enabled: pool_advanced
.get("health_policy_enabled")
.and_then(Value::as_bool)
.unwrap_or(true),
probing_enabled: pool_advanced probing_enabled: pool_advanced
.get("probing_enabled") .get("probing_enabled")
.and_then(Value::as_bool) .and_then(Value::as_bool)
@@ -583,7 +578,6 @@ mod tests {
"cost_limit_per_key_tokens": 12000, "cost_limit_per_key_tokens": 12000,
"rate_limit_cooldown_seconds": 420, "rate_limit_cooldown_seconds": 420,
"overload_cooldown_seconds": 45, "overload_cooldown_seconds": 45,
"health_policy_enabled": false,
"probing_enabled": true, "probing_enabled": true,
"probing_target_percent": 25, "probing_target_percent": 25,
"probing_target_count": 3, "probing_target_count": 3,
@@ -624,7 +618,6 @@ mod tests {
assert_eq!(config.cost_limit_per_key_tokens, Some(12_000)); assert_eq!(config.cost_limit_per_key_tokens, Some(12_000));
assert_eq!(config.rate_limit_cooldown_seconds, 420); assert_eq!(config.rate_limit_cooldown_seconds, 420);
assert_eq!(config.overload_cooldown_seconds, 45); assert_eq!(config.overload_cooldown_seconds, 45);
assert!(!config.health_policy_enabled);
assert!(config.probing_enabled); assert!(config.probing_enabled);
assert_eq!(config.probing_target_percent, Some(25.0)); assert_eq!(config.probing_target_percent, Some(25.0));
assert_eq!(config.probing_target_count, Some(3)); assert_eq!(config.probing_target_count, Some(3));
@@ -437,10 +437,6 @@ pub(crate) async fn record_admin_provider_pool_error(
error_body: Option<&str>, error_body: Option<&str>,
response_headers: Option<&BTreeMap<String, String>>, response_headers: Option<&BTreeMap<String, String>>,
) { ) {
if !pool_config.health_policy_enabled {
return;
}
let error_message = extract_error_message(error_body).to_ascii_lowercase(); let error_message = extract_error_message(error_body).to_ascii_lowercase();
if status_code == 401 { if status_code == 401 {
@@ -553,7 +549,7 @@ pub(crate) async fn record_admin_provider_pool_stream_timeout(
key_id: &str, key_id: &str,
pool_config: &AdminProviderPoolConfig, pool_config: &AdminProviderPoolConfig,
) { ) {
if !pool_config.health_policy_enabled || pool_config.stream_timeout_threshold == 0 { if pool_config.stream_timeout_threshold == 0 {
return; return;
} }
@@ -637,7 +633,6 @@ mod tests {
cost_limit_per_key_tokens: Some(10_000), cost_limit_per_key_tokens: Some(10_000),
rate_limit_cooldown_seconds: 300, rate_limit_cooldown_seconds: 300,
overload_cooldown_seconds: 30, overload_cooldown_seconds: 30,
health_policy_enabled: true,
probing_enabled: false, probing_enabled: false,
probing_target_percent: None, probing_target_percent: None,
probing_target_count: None, probing_target_count: None,
@@ -4,7 +4,8 @@ use crate::handlers::admin::provider::shared::support::{
use crate::handlers::admin::request::AdminAppState; use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::{provider_key_status_snapshot_payload, unix_secs_to_rfc3339}; use crate::handlers::admin::shared::{provider_key_status_snapshot_payload, unix_secs_to_rfc3339};
use crate::provider_key_auth::{ 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::pool as admin_provider_pool_pure;
use aether_admin::provider::quota as admin_provider_quota_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(|config| config.get("access_token_import_temporary"))
.and_then(serde_json::Value::as_bool) .and_then(serde_json::Value::as_bool)
.unwrap_or(false); .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_code = admin_pool_trimmed_string_from_map(account_snapshot, "code");
let account_status_label = let account_status_label =
admin_pool_trimmed_string(account_snapshot.and_then(|item| item.get("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(), "can_edit_oauth".to_string(),
json!(auth_semantics.can_edit_oauth()), 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_expires_at".to_string(), json!(oauth_expires_at));
payload.insert("oauth_invalid_at".to_string(), json!(oauth_invalid_at)); payload.insert("oauth_invalid_at".to_string(), json!(oauth_invalid_at));
payload.insert( payload.insert(
@@ -3,7 +3,10 @@ use super::{
AdminPoolResolveSelectionRequest, ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL, AdminPoolResolveSelectionRequest, ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
}; };
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; 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 crate::GatewayError;
use aether_admin::provider::pool as admin_provider_pool_pure; use aether_admin::provider::pool as admin_provider_pool_pure;
use axum::{ 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_refresh_oauth": provider_key_can_refresh_oauth(auth_semantics, auth_config.as_ref()),
"can_export_oauth": auth_semantics.can_export_oauth(), "can_export_oauth": auth_semantics.can_export_oauth(),
"can_edit_oauth": auth_semantics.can_edit_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::<Vec<_>>(); .collect::<Vec<_>>();
@@ -2180,6 +2180,7 @@ async fn provider_query_execute_openai_image_test_candidate(
} else { } else {
crate::provider_transport::build_openai_image_headers( crate::provider_transport::build_openai_image_headers(
crate::provider_transport::ProviderOpenAiImageHeadersInput { crate::provider_transport::ProviderOpenAiImageHeadersInput {
transport: &transport,
headers: &parts.headers, headers: &parts.headers,
auth_header: &auth_header, auth_header: &auth_header,
auth_value: &auth_value, auth_value: &auth_value,
@@ -43,7 +43,6 @@ pub(crate) struct AdminProviderPoolConfig {
pub(crate) cost_limit_per_key_tokens: Option<u64>, pub(crate) cost_limit_per_key_tokens: Option<u64>,
pub(crate) rate_limit_cooldown_seconds: u64, pub(crate) rate_limit_cooldown_seconds: u64,
pub(crate) overload_cooldown_seconds: u64, pub(crate) overload_cooldown_seconds: u64,
pub(crate) health_policy_enabled: bool,
pub(crate) probing_enabled: bool, pub(crate) probing_enabled: bool,
pub(crate) probing_target_percent: Option<f64>, pub(crate) probing_target_percent: Option<f64>,
pub(crate) probing_target_count: Option<u64>, pub(crate) probing_target_count: Option<u64>,
@@ -97,6 +97,7 @@ fn provider_oauth_export_payload(
if let Some(access_token) = fallback_access_token if let Some(access_token) = fallback_access_token
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty() && *value != "__placeholder__") .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)); payload.insert("access_token".to_string(), json!(access_token));
} }
@@ -116,6 +117,43 @@ fn provider_oauth_export_payload(
payload payload
} }
fn oauth_export_fallback_matches_authorization_header(
payload: &serde_json::Map<String, serde_json::Value>,
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( fn json_map_has_non_empty_string(
map: &serde_json::Map<String, serde_json::Value>, map: &serde_json::Map<String, serde_json::Value>,
keys: &[&str], keys: &[&str],
@@ -186,3 +224,84 @@ pub(crate) async fn build_admin_export_key_payload(
); );
Ok(serde_json::Value::Object(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": "[email protected]",
"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": "[email protected]",
"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"}))
);
}
}
@@ -1,16 +1,27 @@
use super::enabled_key_capability_short_names; use super::enabled_key_capability_short_names;
use crate::handlers::shared::unix_secs_to_rfc3339; use crate::handlers::shared::{parse_catalog_auth_config_json, unix_secs_to_rfc3339};
use crate::provider_key_auth::provider_key_effective_api_formats; use crate::provider_key_auth::{
provider_key_auth_config_uses_header_authorization, provider_key_effective_api_formats,
};
use crate::AppState; use crate::AppState;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use aether_scheduler_core::provider_key_circuit_payload_is_active_open_at; use aether_scheduler_core::provider_key_circuit_payload_is_active_open_at;
use serde_json::json; use serde_json::json;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
fn grouped_key_masked_label(auth_type: &str) -> &'static str { fn grouped_key_masked_label(state: &AppState, key: &StoredProviderCatalogKey) -> &'static str {
match auth_type.trim() { match key.auth_type.trim() {
"service_account" | "vertex_ai" => "[Service Account]", "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]", _ => "[API Key]",
} }
} }
@@ -150,7 +161,7 @@ pub(crate) async fn build_admin_keys_grouped_by_format_payload(
"provider_id": key.provider_id, "provider_id": key.provider_id,
"name": key.name, "name": key.name,
"auth_type": key.auth_type, "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, "internal_priority": key.internal_priority,
"global_priority_by_format": key.global_priority_by_format, "global_priority_by_format": key.global_priority_by_format,
"rate_multipliers": key.rate_multipliers, "rate_multipliers": key.rate_multipliers,
@@ -1,7 +1,8 @@
use crate::handlers::shared::{json_string_list, unix_secs_to_rfc3339}; use crate::handlers::shared::{json_string_list, unix_secs_to_rfc3339};
use crate::provider_key_auth::{ use crate::provider_key_auth::{
provider_key_auth_semantics, provider_key_can_refresh_oauth, provider_key_auth_config_uses_header_authorization, provider_key_auth_semantics,
provider_key_configured_api_formats, provider_key_inherits_provider_api_formats, provider_key_can_refresh_oauth, provider_key_configured_api_formats,
provider_key_inherits_provider_api_formats,
}; };
use crate::AppState; use crate::AppState;
use aether_admin::provider::quota as admin_provider_quota_pure; 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 { pub(crate) fn masked_catalog_api_key(state: &AppState, key: &StoredProviderCatalogKey) -> String {
match key.auth_type.trim() { match key.auth_type.trim() {
"service_account" | "vertex_ai" => "[Service Account]".to_string(), "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 let Some(ciphertext) = key
.encrypted_api_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(|config| config.get("access_token_import_temporary"))
.and_then(serde_json::Value::as_bool) .and_then(serde_json::Value::as_bool)
.unwrap_or(false); .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 oauth_plan_type = derive_catalog_oauth_plan_type(key, provider_type, auth_config.as_ref());
let ( let (
health_score, health_score,
@@ -2279,6 +2290,7 @@ pub(crate) fn build_admin_provider_key_response(
"can_edit_oauth".to_string(), "can_edit_oauth".to_string(),
json!(auth_semantics.can_edit_oauth()), 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("name".to_string(), json!(key.name));
payload.insert("rate_multipliers".to_string(), json!(key.rate_multipliers)); payload.insert("rate_multipliers".to_string(), json!(key.rate_multipliers));
payload.insert( payload.insert(
+39 -1
View File
@@ -95,6 +95,23 @@ pub(crate) fn provider_key_can_refresh_oauth(
.is_some_and(|value| !value.is_empty()) .is_some_and(|value| !value.is_empty())
} }
pub(crate) fn provider_key_auth_config_uses_header_authorization(
auth_config: Option<&Map<String, Value>>,
) -> 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 { fn normalized_auth_type(key: &StoredProviderCatalogKey) -> String {
key.auth_type.trim().to_ascii_lowercase() key.auth_type.trim().to_ascii_lowercase()
} }
@@ -255,7 +272,8 @@ pub(crate) fn provider_key_effective_api_formats(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ 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_configured_api_formats, provider_key_effective_api_formats,
provider_key_inherits_provider_api_formats, ProviderKeyCredentialKind, provider_key_inherits_provider_api_formats, ProviderKeyCredentialKind,
ProviderKeyRuntimeAuthKind, 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] #[test]
fn recognizes_legacy_kiro_bearer_key_with_auth_config_as_oauth_managed() { fn recognizes_legacy_kiro_bearer_key_with_auth_config_as_oauth_managed() {
let mut key = sample_key("bearer"); let mut key = sample_key("bearer");
@@ -498,6 +498,81 @@ async fn gateway_admin_provider_keys_prefers_upstream_plan_type_over_auth_config
upstream_handle.abort(); 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] #[tokio::test]
async fn gateway_creates_admin_provider_key_locally_with_trusted_admin_principal() { async fn gateway_creates_admin_provider_key_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize)); 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(); 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":"[email protected]","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"], "[email protected]");
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":"[email protected]","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] #[tokio::test]
async fn gateway_clears_admin_provider_key_oauth_invalid_locally_with_trusted_admin_principal() { async fn gateway_clears_admin_provider_key_oauth_invalid_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize)); let upstream_hits = Arc::new(Mutex::new(0usize));
@@ -3417,6 +3417,95 @@ async fn gateway_imports_codex_access_token_without_refresh_token_as_temporary_a
token_handle.abort(); 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("[email protected]", "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"], "[email protected]");
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] #[tokio::test]
async fn gateway_imports_chatgpt_web_access_token_without_refresh_token_as_temporary_account() { async fn gateway_imports_chatgpt_web_access_token_without_refresh_token_as_temporary_account() {
let token_hits = Arc::new(Mutex::new(0usize)); let token_hits = Arc::new(Mutex::new(0usize));
@@ -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] #[tokio::test]
async fn gateway_handles_admin_pool_resolve_selection_locally_with_trusted_admin_principal() { async fn gateway_handles_admin_pool_resolve_selection_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize)); let upstream_hits = Arc::new(Mutex::new(0usize));
+23
View File
@@ -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] #[test]
fn gemini_cli_quota_request_uses_v1internal_retrieve_user_quota() { fn gemini_cli_quota_request_uses_v1internal_retrieve_user_quota() {
let spec = build_gemini_cli_pool_quota_request( let spec = build_gemini_cli_pool_quota_request(
@@ -76,8 +76,22 @@ pub fn build_codex_pool_quota_request(
) -> Result<ProviderPoolQuotaRequestSpec, String> { ) -> Result<ProviderPoolQuotaRequestSpec, String> {
let mut headers = BTreeMap::new(); let mut headers = BTreeMap::new();
headers.insert("accept".to_string(), "application/json".to_string()); 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); headers.insert(name.to_ascii_lowercase(), value);
} else { } else {
let decrypted_key = decrypted_api_key.unwrap_or_default().trim(); let decrypted_key = decrypted_api_key.unwrap_or_default().trim();
@@ -3,14 +3,12 @@ use std::collections::BTreeMap;
use serde_json::Value; use serde_json::Value;
use url::form_urlencoded; 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", "api-key",
"authorization", "authorization",
"content-length",
"content-type", "content-type",
"cookie", "cookie",
"host",
"proxy-authorization",
"x-api-key", "x-api-key",
"x-goog-api-key", "x-goog-api-key",
]; ];
@@ -44,14 +42,20 @@ const IGNORABLE_AUTH_CONFIG_METADATA_KEYS: &[&str] = &[
"account_name", "account_name",
"account_user_id", "account_user_id",
"auth_method", "auth_method",
"access_token_import_temporary",
"email", "email",
"expires_at",
"model_regions", "model_regions",
"organizations", "organizations",
"plan_type", "plan_type",
"project_id", "project_id",
"provider_type", "provider_type",
"refresh_token_import_error",
"region", "region",
"scope",
"tier", "tier",
"token_type",
"updated_at",
"user_id", "user_id",
"workspace_id", "workspace_id",
"workspace_name", "workspace_name",
@@ -81,6 +85,30 @@ pub enum LocalAuthConfigAbsorption {
}, },
} }
pub fn apply_local_auth_config_header_overrides(
headers: &mut BTreeMap<String, String>,
raw_auth_config: Option<&str>,
) {
let Some(raw_auth_config) = raw_auth_config
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
let Ok(parsed) = serde_json::from_str::<Value>(raw_auth_config) else {
return;
};
let Some(object) = parsed.as_object() else {
return;
};
let mut overrides = BTreeMap::new();
collect_auth_config_header_overrides(object, &mut overrides);
for (key, value) in overrides {
headers.insert(key, value);
}
}
pub fn absorb_local_auth_config_safe_subset( pub fn absorb_local_auth_config_safe_subset(
base_url: &str, base_url: &str,
header_rules: Option<Value>, header_rules: Option<Value>,
@@ -130,6 +158,12 @@ fn parse_local_auth_config_safe_subset(raw: &str) -> Result<LocalAuthConfigSafeS
let mut path = None; let mut path = None;
parse_local_auth_config_object(object, &mut headers, &mut query, &mut path, true)?; parse_local_auth_config_object(object, &mut headers, &mut query, &mut path, true)?;
if headers
.keys()
.any(|key| RUNTIME_ONLY_AUTH_CONFIG_HEADER_NAMES.contains(&key.as_str()))
{
return Err(());
}
Ok(LocalAuthConfigSafeSubset { Ok(LocalAuthConfigSafeSubset {
headers, headers,
@@ -138,6 +172,26 @@ fn parse_local_auth_config_safe_subset(raw: &str) -> Result<LocalAuthConfigSafeS
}) })
} }
fn collect_auth_config_header_overrides(
object: &serde_json::Map<String, Value>,
out: &mut BTreeMap<String, String>,
) {
for (key, value) in object {
let normalized = key.trim().to_ascii_lowercase();
match normalized.as_str() {
"headers" | "extra_headers" | "extraheaders" => {
merge_header_string_map_lenient(out, value);
}
"transport" | "request" => {
if let Some(nested) = value.as_object() {
collect_auth_config_header_overrides(nested, out);
}
}
_ => {}
}
}
}
fn parse_local_auth_config_object( fn parse_local_auth_config_object(
object: &serde_json::Map<String, Value>, object: &serde_json::Map<String, Value>,
headers: &mut BTreeMap<String, String>, headers: &mut BTreeMap<String, String>,
@@ -149,7 +203,7 @@ fn parse_local_auth_config_object(
let normalized = key.trim().to_ascii_lowercase(); let normalized = key.trim().to_ascii_lowercase();
match normalized.as_str() { match normalized.as_str() {
"headers" | "extra_headers" | "extraheaders" => { "headers" | "extra_headers" | "extraheaders" => {
merge_string_map(headers, value, normalize_auth_config_header_name)? merge_header_string_map(headers, value)?
} }
"query" | "query_params" | "queryparams" => { "query" | "query_params" | "queryparams" => {
merge_string_map(query, value, normalize_auth_config_query_key)? merge_string_map(query, value, normalize_auth_config_query_key)?
@@ -206,6 +260,38 @@ fn parse_static_auth_config_value(value: &Value) -> Option<String> {
} }
} }
fn merge_header_string_map(out: &mut BTreeMap<String, String>, value: &Value) -> Result<(), ()> {
let object = value.as_object().ok_or(())?;
for (raw_key, raw_value) in object {
let key = normalize_auth_config_header_name(raw_key).ok_or(())?;
let value = parse_static_auth_config_header_value(raw_value).ok_or(())?;
out.insert(key, value);
}
Ok(())
}
fn merge_header_string_map_lenient(out: &mut BTreeMap<String, String>, value: &Value) {
let Some(object) = value.as_object() else {
return;
};
for (raw_key, raw_value) in object {
let Some(key) = normalize_auth_config_header_name(raw_key) else {
continue;
};
let Some(value) = parse_static_auth_config_header_value(raw_value) else {
continue;
};
out.insert(key, value);
}
}
fn parse_static_auth_config_header_value(value: &Value) -> Option<String> {
let value = parse_static_auth_config_value(value)?;
http::header::HeaderValue::from_str(&value)
.is_ok()
.then_some(value)
}
fn normalize_auth_config_header_name(raw: &str) -> Option<String> { fn normalize_auth_config_header_name(raw: &str) -> Option<String> {
let value = raw.trim().to_ascii_lowercase(); let value = raw.trim().to_ascii_lowercase();
if value.is_empty() if value.is_empty()
@@ -379,7 +465,10 @@ fn split_path_and_query(path: &str) -> Option<(String, BTreeMap<String, String>)
mod tests { mod tests {
use serde_json::json; 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] #[test]
fn absorbs_static_headers_and_query_into_existing_transport_fields() { fn absorbs_static_headers_and_query_into_existing_transport_fields() {
@@ -469,7 +558,7 @@ mod tests {
"https://api.openai.example/v1", "https://api.openai.example/v1",
None, None,
None, None,
Some(r#"{"headers":{"authorization":"Bearer x"}}"#), Some(r#"{"headers":{"host":"api.example.test"}}"#),
), ),
LocalAuthConfigAbsorption::Unsupported 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] #[test]
fn absorbs_query_only_configs_into_base_url_for_dynamic_path_formats() { fn absorbs_query_only_configs_into_base_url_for_dynamic_path_formats() {
let result = absorb_local_auth_config_safe_subset( let result = absorb_local_auth_config_safe_subset(
@@ -29,6 +29,7 @@ pub mod windsurf;
pub use aether_oauth as oauth; pub use aether_oauth as oauth;
pub use auth::{build_passthrough_headers, ensure_upstream_auth_header}; 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 cache::{provider_transport_snapshot_looks_refreshed, ProviderTransportSnapshotCacheKey};
pub use conversion::{ pub use conversion::{
candidate_common_transport_skip_reason, candidate_transport_pair_skip_reason, candidate_common_transport_skip_reason, candidate_transport_pair_skip_reason,
@@ -11,6 +11,7 @@ use crate::url::{build_openai_image_url, build_openai_responses_url};
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct ProviderOpenAiImageHeadersInput<'a> { pub struct ProviderOpenAiImageHeadersInput<'a> {
pub transport: &'a GatewayProviderTransportSnapshot,
pub headers: &'a http::HeaderMap, pub headers: &'a http::HeaderMap,
pub auth_header: &'a str, pub auth_header: &'a str,
pub auth_value: &'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("content-type".to_string(), "application/json".to_string());
provider_request_headers.insert("accept".to_string(), input.accept.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( if !apply_local_header_rules_with_request_headers(
&mut provider_request_headers, &mut provider_request_headers,
input.header_rules, input.header_rules,
@@ -253,7 +258,9 @@ mod tests {
#[test] #[test]
fn builds_json_eventstream_headers_and_applies_rules() { fn builds_json_eventstream_headers_and_applies_rules() {
let transport = sample_transport();
let headers = build_openai_image_headers(ProviderOpenAiImageHeadersInput { let headers = build_openai_image_headers(ProviderOpenAiImageHeadersInput {
transport: &transport,
headers: &HeaderMap::new(), headers: &HeaderMap::new(),
auth_header: "authorization", auth_header: "authorization",
auth_value: "Bearer secret", auth_value: "Bearer secret",
@@ -282,8 +289,46 @@ mod tests {
} }
#[test] #[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 { 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(), headers: &HeaderMap::new(),
auth_header: "authorization", auth_header: "authorization",
auth_value: "Bearer secret", auth_value: "Bearer secret",
@@ -250,6 +250,10 @@ pub fn build_standard_provider_request_headers(
) )
}; };
crate::apply_local_auth_config_header_overrides(
&mut headers,
input.transport.key.decrypted_auth_config.as_deref(),
);
let protected_headers = if uses_vertex_query_auth { let protected_headers = if uses_vertex_query_auth {
&["content-type"][..] &["content-type"][..]
} else { } 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] #[test]
fn applies_standard_body_rules_to_surface_built_body() { fn applies_standard_body_rules_to_surface_built_body() {
let body = apply_standard_provider_request_body_rules( let body = apply_standard_provider_request_body_rules(
+6
View File
@@ -169,8 +169,11 @@ export interface RequestDetail {
provider: string provider: string
api_format?: string api_format?: string
endpoint_api_format?: string endpoint_api_format?: string
has_format_conversion?: boolean | null
model: string model: string
target_model?: string | null // 映射后的目标模型名 target_model?: string | null // 映射后的目标模型名
reasoning_effort?: string | null
service_tier?: string | null
tokens: { tokens: {
input: number input: number
output: number output: number
@@ -189,11 +192,14 @@ export interface RequestDetail {
cache_creation_input_tokens?: number cache_creation_input_tokens?: number
cache_creation_input_tokens_5m?: number cache_creation_input_tokens_5m?: number
cache_creation_input_tokens_1h?: 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 cache_read_input_tokens?: number
// Additional cost fields // Additional cost fields
input_cost?: number input_cost?: number
output_cost?: number output_cost?: number
total_cost?: number total_cost?: number
actual_cost?: number
cache_creation_cost?: number cache_creation_cost?: number
cache_read_cost?: number cache_read_cost?: number
image_output_cost?: number image_output_cost?: number
+2
View File
@@ -120,6 +120,7 @@ export interface PoolKeyDetail {
credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null
runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null
oauth_managed?: boolean oauth_managed?: boolean
oauth_header_auth?: boolean
can_refresh_oauth?: boolean can_refresh_oauth?: boolean
can_export_oauth?: boolean can_export_oauth?: boolean
can_edit_oauth?: boolean can_edit_oauth?: boolean
@@ -317,6 +318,7 @@ export interface PoolKeySelectionItem {
credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null
runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null
oauth_managed?: boolean oauth_managed?: boolean
oauth_header_auth?: boolean
can_refresh_oauth?: boolean can_refresh_oauth?: boolean
can_export_oauth?: boolean can_export_oauth?: boolean
can_edit_oauth?: boolean can_edit_oauth?: boolean
@@ -256,6 +256,7 @@ export async function importProviderRefreshToken(
browser_profile?: string browser_profile?: string
user_id?: string user_id?: string
account_name?: string account_name?: string
headers?: Record<string, string>
} }
): Promise<ProviderOAuthCompleteResponseWithKey> { ): Promise<ProviderOAuthCompleteResponseWithKey> {
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data) const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
@@ -5,7 +5,7 @@ import { normalizeChatPiiRedactionProviderConfig, normalizePoolAdvancedConfig }
describe('normalizePoolAdvancedConfig', () => { describe('normalizePoolAdvancedConfig', () => {
it('keeps object payloads, including empty objects', () => { it('keeps object payloads, including empty objects', () => {
expect(normalizePoolAdvancedConfig({})).toEqual({}) 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', () => { it('maps legacy boolean payloads to the current object semantics', () => {
+6 -1
View File
@@ -237,6 +237,7 @@ export interface EndpointAPIKey {
credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null
runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null
oauth_managed?: boolean oauth_managed?: boolean
oauth_header_auth?: boolean
can_refresh_oauth?: boolean can_refresh_oauth?: boolean
can_export_oauth?: boolean can_export_oauth?: boolean
can_edit_oauth?: boolean can_edit_oauth?: boolean
@@ -708,7 +709,9 @@ export interface PoolScoreRules {
} }
export interface PoolAdvancedConfig { export interface PoolAdvancedConfig {
// deprecated: hidden from pool advanced dialog, retained for backward-compatible reads
global_priority?: number | null global_priority?: number | null
// deprecated: hidden from pool advanced dialog, retained for backward-compatible reads
sticky_session_ttl_seconds?: number | null sticky_session_ttl_seconds?: number | null
load_threshold_percent?: number | null load_threshold_percent?: number | null
skip_exhausted_accounts?: boolean | null skip_exhausted_accounts?: boolean | null
@@ -725,13 +728,15 @@ export interface PoolAdvancedConfig {
} | null } | null
latency_window_seconds?: number | null latency_window_seconds?: number | null
latency_sample_limit?: number | null latency_sample_limit?: number | null
// deprecated: hidden from pool advanced dialog, retained for backward-compatible reads
cost_window_seconds?: number | null cost_window_seconds?: number | null
// deprecated: hidden from pool advanced dialog, retained for backward-compatible reads
cost_limit_per_key_tokens?: number | null cost_limit_per_key_tokens?: number | null
// deprecated: hidden from pool advanced dialog, retained for backward-compatible reads
cost_soft_threshold_percent?: number | null cost_soft_threshold_percent?: number | null
rate_limit_cooldown_seconds?: number | null rate_limit_cooldown_seconds?: number | null
overload_cooldown_seconds?: number | null overload_cooldown_seconds?: number | null
proactive_refresh_seconds?: number | null proactive_refresh_seconds?: number | null
health_policy_enabled?: boolean
unschedulable_rules?: Array<Record<string, unknown>> | null unschedulable_rules?: Array<Record<string, unknown>> | null
batch_concurrency?: number | null batch_concurrency?: number | null
probe_concurrency?: number | null probe_concurrency?: number | null
@@ -2,7 +2,7 @@
<Dialog <Dialog
:model-value="modelValue" :model-value="modelValue"
title="高级设置" title="高级设置"
description="冷却、健康、成本控制与其他高级参数" description="冷却、热池与其他高级参数"
size="3xl" size="3xl"
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
@@ -11,18 +11,18 @@
<div class="space-y-1"> <div class="space-y-1">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
冷却与健康 冷却与热池
</h3> </h3>
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground"> <span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
核心策略 核心策略
</span> </span>
</div> </div>
<p class="text-xs leading-5 text-muted-foreground"> <p class="text-xs leading-5 text-muted-foreground">
控制自动冷却、自适应热池、异常清理和全局调度优先级。 控制冷却时间、自适应热池和异常清理。
</p> </p>
</div> </div>
<div class="grid gap-3 lg:grid-cols-3"> <div class="grid gap-3 lg:grid-cols-2">
<div <div
v-for="item in healthToggleCards" v-for="item in healthToggleCards"
:key="item.key" :key="item.key"
@@ -134,175 +134,83 @@
@update:model-value="(v) => form.overload_cooldown_seconds = parseNum(v)" @update:model-value="(v) => form.overload_cooldown_seconds = parseNum(v)"
/> />
</div> </div>
</div>
</section>
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">
批量操作
</h3>
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
任务效率
</span>
</div>
<p class="text-xs leading-5 text-muted-foreground">
控制刷新 OAuth、自适应热池和批量额度处理时的并行请求数。
</p>
</div>
<div class="grid gap-3 rounded-xl bg-muted/30 p-4 sm:grid-cols-2 xl:grid-cols-4">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label> <Label>
粘性会话 TTL 并发数
<span class="text-xs text-muted-foreground">(秒)</span>
</Label> </Label>
<Input <Input
:model-value="form.sticky_session_ttl_seconds ?? ''" :model-value="form.batch_concurrency ?? ''"
type="number" type="number"
min="60" min="1"
max="86400" max="32"
placeholder="3600 (留空禁用)" placeholder="8"
@update:model-value="(v) => form.sticky_session_ttl_seconds = parseNum(v)" @update:model-value="(v) => form.batch_concurrency = parseNum(v)"
/>
<p class="text-[11px] leading-5 text-muted-foreground">
为空时沿用默认值;数值越大,批量操作越快,但会增加瞬时请求压力。
</p>
</div>
<div class="space-y-1.5">
<Label>
探测并发
</Label>
<Input
:model-value="form.probe_concurrency ?? ''"
type="number"
min="1"
max="64"
placeholder="4"
@update:model-value="(v) => form.probe_concurrency = parseNum(v)"
/> />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label> <Label>
全局优先级 评分 Top-N
</Label> </Label>
<Input <Input
:model-value="form.global_priority ?? ''" :model-value="form.score_top_n ?? ''"
type="number" type="number"
min="0" min="1"
max="999999" max="4096"
placeholder="留空回退 provider_priority" placeholder="128"
@update:model-value="(v) => form.global_priority = parseNum(v)" @update:model-value="(v) => form.score_top_n = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
回退扫描
</Label>
<Input
:model-value="form.score_fallback_scan_limit ?? ''"
type="number"
min="1"
max="100000"
placeholder="1024"
@update:model-value="(v) => form.score_fallback_scan_limit = parseNum(v)"
/> />
</div> </div>
</div> </div>
</section> </section>
<div :class="secondarySectionLayout.wrapperClass">
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">
成本控制
</h3>
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
额度保护
</span>
</div>
<p class="text-xs leading-5 text-muted-foreground">
控制窗口期、Key 限额与软阈值,防止个别账号短时间内过度消耗。
</p>
</div>
<div
class="grid gap-3 sm:grid-cols-2"
:class="costFieldLayout.desktopColumnsClass"
>
<div class="space-y-1.5">
<Label>
成本窗口
<span class="text-xs text-muted-foreground">(秒)</span>
</Label>
<Input
:model-value="form.cost_window_seconds ?? ''"
type="number"
min="3600"
max="86400"
placeholder="18000 (5 小时)"
@update:model-value="(v) => form.cost_window_seconds = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
Key 窗口限额
<span class="text-xs text-muted-foreground">(tokens)</span>
</Label>
<Input
:model-value="form.cost_limit_per_key_tokens ?? ''"
type="number"
min="0"
placeholder="留空 = 不限"
@update:model-value="(v) => form.cost_limit_per_key_tokens = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
软阈值
<span class="text-xs text-muted-foreground">(%)</span>
</Label>
<Input
:model-value="form.cost_soft_threshold_percent ?? ''"
type="number"
min="0"
max="100"
placeholder="80"
@update:model-value="(v) => form.cost_soft_threshold_percent = parseNum(v)"
/>
</div>
</div>
</section>
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">
批量操作
</h3>
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
任务效率
</span>
</div>
<p class="text-xs leading-5 text-muted-foreground">
控制刷新 OAuth、自适应热池和批量额度处理时的并行请求数。
</p>
</div>
<div class="grid gap-3 rounded-xl bg-muted/30 p-4 sm:grid-cols-2">
<div class="space-y-1.5">
<Label>
并发数
</Label>
<Input
:model-value="form.batch_concurrency ?? ''"
type="number"
min="1"
max="32"
placeholder="8"
@update:model-value="(v) => form.batch_concurrency = parseNum(v)"
/>
<p class="text-[11px] leading-5 text-muted-foreground">
为空时沿用默认值;数值越大,批量操作越快,但会增加瞬时请求压力。
</p>
</div>
<div class="space-y-1.5">
<Label>
探测并发
</Label>
<Input
:model-value="form.probe_concurrency ?? ''"
type="number"
min="1"
max="64"
placeholder="4"
@update:model-value="(v) => form.probe_concurrency = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
评分 Top-N
</Label>
<Input
:model-value="form.score_top_n ?? ''"
type="number"
min="1"
max="4096"
placeholder="128"
@update:model-value="(v) => form.score_top_n = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
回退扫描
</Label>
<Input
:model-value="form.score_fallback_scan_limit ?? ''"
type="number"
min="1"
max="100000"
placeholder="1024"
@update:model-value="(v) => form.score_fallback_scan_limit = parseNum(v)"
/>
</div>
</div>
</section>
</div>
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5"> <section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
<div class="space-y-1"> <div class="space-y-1">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@@ -625,9 +533,7 @@ import { parseApiError } from '@/utils/errorParser'
import { updateProvider } from '@/api/endpoints' import { updateProvider } from '@/api/endpoints'
import { import {
buildPoolCooldownFieldLayout, buildPoolCooldownFieldLayout,
buildPoolCostFieldLayout,
buildPoolHealthToggleCards, buildPoolHealthToggleCards,
buildPoolSecondarySectionLayout,
type PoolHealthToggleKey, type PoolHealthToggleKey,
} from '@/features/pool/utils/poolAdvancedDialog' } from '@/features/pool/utils/poolAdvancedDialog'
import type { import type {
@@ -658,18 +564,10 @@ const isClaudeCode = computed(() => {
const healthToggleCards = buildPoolHealthToggleCards() const healthToggleCards = buildPoolHealthToggleCards()
const cooldownFieldLayout = buildPoolCooldownFieldLayout() const cooldownFieldLayout = buildPoolCooldownFieldLayout()
const costFieldLayout = buildPoolCostFieldLayout()
const secondarySectionLayout = buildPoolSecondarySectionLayout()
const form = ref({ 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, rate_limit_cooldown_seconds: null as number | null | undefined,
overload_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, batch_concurrency: null as number | null | undefined,
probe_concurrency: null as number | null | undefined, probe_concurrency: null as number | null | undefined,
score_top_n: 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 { function getHealthToggleValue(key: PoolHealthToggleKey): boolean {
switch (key) { switch (key) {
case 'health_policy_enabled':
return form.value.health_policy_enabled
case 'probing_enabled': case 'probing_enabled':
return form.value.probing_enabled return form.value.probing_enabled
case 'account_self_check_enabled': case 'account_self_check_enabled':
@@ -736,9 +632,6 @@ function getHealthToggleValue(key: PoolHealthToggleKey): boolean {
function updateHealthToggleValue(key: PoolHealthToggleKey, value: boolean): void { function updateHealthToggleValue(key: PoolHealthToggleKey, value: boolean): void {
switch (key) { switch (key) {
case 'health_policy_enabled':
form.value.health_policy_enabled = value
return
case 'probing_enabled': case 'probing_enabled':
form.value.probing_enabled = value form.value.probing_enabled = value
return return
@@ -760,14 +653,8 @@ watch(() => props.modelValue, (open) => {
const scoreRules = cfg?.score_rules const scoreRules = cfg?.score_rules
const scoreWeights = scoreRules?.weights const scoreWeights = scoreRules?.weights
form.value = { 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, rate_limit_cooldown_seconds: cfg?.rate_limit_cooldown_seconds ?? null,
overload_cooldown_seconds: cfg?.overload_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, batch_concurrency: cfg?.batch_concurrency ?? null,
probe_concurrency: cfg?.probe_concurrency ?? null, probe_concurrency: cfg?.probe_concurrency ?? null,
score_top_n: cfg?.score_top_n ?? null, score_top_n: cfg?.score_top_n ?? null,
@@ -836,20 +723,20 @@ async function handleSave() {
'self_check_method', 'self_check_method',
'account_self_check_request', 'account_self_check_request',
'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] delete existingPoolAdvanced[key]
} }
// 合并已有配置(保留 scheduling_presets 等不在此对话框编辑的字段) // 合并已有配置(保留 scheduling_presets 等不在此对话框编辑的字段)
const poolAdvanced: Record<string, unknown> = { const poolAdvanced: Record<string, unknown> = {
...existingPoolAdvanced, ...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, rate_limit_cooldown_seconds: form.value.rate_limit_cooldown_seconds ?? undefined,
overload_cooldown_seconds: form.value.overload_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, batch_concurrency: form.value.batch_concurrency ?? undefined,
probe_concurrency: form.value.probe_concurrency ?? undefined, probe_concurrency: form.value.probe_concurrency ?? undefined,
score_top_n: form.value.score_top_n ?? undefined, score_top_n: form.value.score_top_n ?? undefined,
@@ -3,14 +3,11 @@ import { describe, expect, it } from 'vitest'
import { import {
buildPoolCooldownFieldLayout, buildPoolCooldownFieldLayout,
buildPoolHealthToggleCards, buildPoolHealthToggleCards,
buildPoolCostFieldLayout,
buildPoolSecondarySectionLayout,
} from '@/features/pool/utils/poolAdvancedDialog' } from '@/features/pool/utils/poolAdvancedDialog'
describe('poolAdvancedDialog', () => { describe('poolAdvancedDialog', () => {
it('returns health toggle cards in the desktop display order', () => { it('returns health toggle cards in the desktop display order', () => {
expect(buildPoolHealthToggleCards().map(item => item.key)).toEqual([ expect(buildPoolHealthToggleCards().map(item => item.key)).toEqual([
'health_policy_enabled',
'probing_enabled', 'probing_enabled',
'account_self_check_enabled', 'account_self_check_enabled',
'auto_remove_banned_keys', 'auto_remove_banned_keys',
@@ -20,11 +17,6 @@ describe('poolAdvancedDialog', () => {
it('provides tooltip copy for every desktop health toggle card', () => { it('provides tooltip copy for every desktop health toggle card', () => {
expect(buildPoolHealthToggleCards()).toEqual([ expect(buildPoolHealthToggleCards()).toEqual([
{
key: 'health_policy_enabled',
label: '健康策略',
description: '按上游错误自动冷却并跳过异常账号。',
},
{ {
key: 'probing_enabled', key: 'probing_enabled',
label: '自适应热池', 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({ expect(buildPoolCooldownFieldLayout()).toEqual({
fields: [ fields: [
'rate_limit_cooldown_seconds', 'rate_limit_cooldown_seconds',
'overload_cooldown_seconds', 'overload_cooldown_seconds',
'sticky_session_ttl_seconds',
'global_priority',
], ],
desktopColumnsClass: 'xl:grid-cols-4', desktopColumnsClass: 'xl:grid-cols-2',
})
})
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',
}) })
}) })
}) })
@@ -1,5 +1,4 @@
export type PoolHealthToggleKey = export type PoolHealthToggleKey =
| 'health_policy_enabled'
| 'probing_enabled' | 'probing_enabled'
| 'account_self_check_enabled' | 'account_self_check_enabled'
| 'auto_remove_banned_keys' | 'auto_remove_banned_keys'
@@ -16,22 +15,8 @@ export interface PoolCooldownFieldLayout {
desktopColumnsClass: string desktopColumnsClass: string
} }
export interface PoolSecondarySectionLayout {
wrapperClass: string
}
export interface PoolCostFieldLayout {
fields: string[]
desktopColumnsClass: string
}
export function buildPoolHealthToggleCards(): PoolHealthToggleCard[] { export function buildPoolHealthToggleCards(): PoolHealthToggleCard[] {
return [ return [
{
key: 'health_policy_enabled',
label: '健康策略',
description: '按上游错误自动冷却并跳过异常账号。',
},
{ {
key: 'probing_enabled', key: 'probing_enabled',
label: '自适应热池', label: '自适应热池',
@@ -60,26 +45,7 @@ export function buildPoolCooldownFieldLayout(): PoolCooldownFieldLayout {
fields: [ fields: [
'rate_limit_cooldown_seconds', 'rate_limit_cooldown_seconds',
'overload_cooldown_seconds', 'overload_cooldown_seconds',
'sticky_session_ttl_seconds',
'global_priority',
], ],
desktopColumnsClass: 'xl:grid-cols-4', desktopColumnsClass: 'xl:grid-cols-2',
}
}
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',
} }
} }
@@ -1455,7 +1455,10 @@ const defaultBodyRulesByFormat = ref<Record<string, BodyRule[]>>({})
const defaultBodyRulesLoaded = ref<Record<string, boolean>>({}) const defaultBodyRulesLoaded = ref<Record<string, boolean>>({})
const loadingDefaultBodyRulesByFormat = ref<Record<string, boolean>>({}) const loadingDefaultBodyRulesByFormat = ref<Record<string, boolean>>({})
// 系统保留的 header 名称(不允许用户设置) // Endpoint 规则会经过通用 header/body rule 引擎执行。认证头、协议控制头和
// 响应长度这类字段在后端同样是 protected keys;前端也保持同一语义,避免用户
// 以为普通 endpoint rules 能改认证。OAuth/账号导入保留 headers 走 auth_config,
// 不走这里的 endpoint header_rules。
const RESERVED_HEADERS = new Set([ const RESERVED_HEADERS = new Set([
'authorization', 'authorization',
'x-api-key', 'x-api-key',
@@ -1463,6 +1466,7 @@ const RESERVED_HEADERS = new Set([
'content-type', 'content-type',
'content-length', 'content-length',
'host', 'host',
'proxy-authorization',
]) ])
const RESERVED_RESPONSE_HEADERS = new Set([ 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_CONFIG_KEY = 'response_header_rules'
const RESPONSE_HEADER_RULES_CAMEL_CONFIG_KEY = 'responseHeaderRules' const RESPONSE_HEADER_RULES_CAMEL_CONFIG_KEY = 'responseHeaderRules'
// 系统保留的 body 字段名(不允许用户设置)
const RESERVED_BODY_FIELDS = new Set([ const RESERVED_BODY_FIELDS = new Set([
'stream', 'stream',
]) ])
@@ -1539,7 +1542,41 @@ function requireJsonString(rule: Record<string, unknown>, key: string, label: st
return typeof rule[key] === 'string' ? null : `${label}第 ${index + 1} 条:${key} 必须是字符串` 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 (!isJsonObject(rule)) return `${label}第 ${index + 1} 条必须是对象`
if (rule.enabled !== undefined && typeof rule.enabled !== 'boolean') { if (rule.enabled !== undefined && typeof rule.enabled !== 'boolean') {
return `${label}第 ${index + 1} 条:enabled 必须是布尔值` return `${label}第 ${index + 1} 条:enabled 必须是布尔值`
@@ -1550,15 +1587,19 @@ function validateHeaderRuleJson(rule: unknown, label: string, index: number): st
} }
if (action === 'set') { if (action === 'set') {
return requireJsonString(rule, 'key', label, index) return requireJsonString(rule, 'key', label, index)
|| reservedNameError(rule.key as string)
|| requireJsonString(rule, 'value', label, index) || requireJsonString(rule, 'value', label, index)
|| validateJsonCondition(rule, label, index) || validateJsonCondition(rule, label, index)
} }
if (action === 'drop') { if (action === 'drop') {
return requireJsonString(rule, 'key', label, index) return requireJsonString(rule, 'key', label, index)
|| reservedNameError(rule.key as string)
|| validateJsonCondition(rule, label, index) || validateJsonCondition(rule, label, index)
} }
return requireJsonString(rule, 'from', label, index) return requireJsonString(rule, 'from', label, index)
|| requireJsonString(rule, 'to', label, index) || requireJsonString(rule, 'to', label, index)
|| reservedNameError(rule.from as string)
|| reservedNameError(rule.to as string)
|| validateJsonCondition(rule, label, index) || validateJsonCondition(rule, label, index)
} }
@@ -1574,26 +1615,32 @@ function validateBodyRuleJson(rule: unknown, label: string, index: number): stri
if (action === 'set' || action === 'append') { if (action === 'set' || action === 'append') {
return requireJsonString(rule, 'path', label, index) return requireJsonString(rule, 'path', label, index)
|| reservedBodyRuleFieldError(rule.path as string)
|| (Object.prototype.hasOwnProperty.call(rule, 'value') ? null : `${label}第 ${index + 1} 条:value 不能为空`) || (Object.prototype.hasOwnProperty.call(rule, 'value') ? null : `${label}第 ${index + 1} 条:value 不能为空`)
|| validateJsonCondition(rule, label, index) || validateJsonCondition(rule, label, index)
} }
if (action === 'drop') { if (action === 'drop') {
return requireJsonString(rule, 'path', label, index) return requireJsonString(rule, 'path', label, index)
|| reservedBodyRuleFieldError(rule.path as string)
|| validateJsonCondition(rule, label, index) || validateJsonCondition(rule, label, index)
} }
if (action === 'rename') { if (action === 'rename') {
return requireJsonString(rule, 'from', label, index) return requireJsonString(rule, 'from', label, index)
|| requireJsonString(rule, 'to', label, index) || requireJsonString(rule, 'to', label, index)
|| reservedBodyRuleFieldError(rule.from as string)
|| reservedBodyRuleFieldError(rule.to as string)
|| validateJsonCondition(rule, label, index) || validateJsonCondition(rule, label, index)
} }
if (action === 'insert') { if (action === 'insert') {
if (requireJsonString(rule, 'path', label, index)) return requireJsonString(rule, 'path', label, index) 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 (!Number.isInteger(rule.index)) return `${label}第 ${index + 1} 条:index 必须是整数`
if (!Object.prototype.hasOwnProperty.call(rule, 'value')) return `${label}第 ${index + 1} 条:value 不能为空` if (!Object.prototype.hasOwnProperty.call(rule, 'value')) return `${label}第 ${index + 1} 条:value 不能为空`
return validateJsonCondition(rule, label, index) return validateJsonCondition(rule, label, index)
} }
if (action === 'regex_replace') { if (action === 'regex_replace') {
if (requireJsonString(rule, 'path', label, index)) return requireJsonString(rule, 'path', label, index) 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 (requireJsonString(rule, 'pattern', label, index)) return requireJsonString(rule, 'pattern', label, index)
if (typeof rule.replacement !== 'string') return `${label}第 ${index + 1} 条:replacement 必须是字符串` if (typeof rule.replacement !== 'string') return `${label}第 ${index + 1} 条:replacement 必须是字符串`
if (rule.flags !== undefined && typeof rule.flags !== 'string') return `${label}第 ${index + 1} 条:flags 必须是字符串` 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 } if (error) return { value: null, error }
} }
for (let i = 0; i < response.value.length; i++) { 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 } if (error) return { value: null, error }
} }
@@ -2334,9 +2386,8 @@ function validateRuleKeyForEndpoint(endpointId: string, key: string, index: numb
const trimmedKey = key.trim().toLowerCase() const trimmedKey = key.trim().toLowerCase()
if (!trimmedKey) return null if (!trimmedKey) return null
if (RESERVED_HEADERS.has(trimmedKey)) { const reservedErr = reservedHeaderRuleError(key)
return `"${key}" 是系统保留的请求头` if (reservedErr) return reservedErr
}
const rules = getEndpointEditRules(endpointId) const rules = getEndpointEditRules(endpointId)
const currentRule = rules[index] const currentRule = rules[index]
@@ -2359,6 +2410,9 @@ function validateRenameFromForEndpoint(endpointId: string, from: string, index:
const trimmedFrom = from.trim().toLowerCase() const trimmedFrom = from.trim().toLowerCase()
if (!trimmedFrom) return null if (!trimmedFrom) return null
const reservedErr = reservedHeaderRuleError(from)
if (reservedErr) return reservedErr
const rules = getEndpointEditRules(endpointId) const rules = getEndpointEditRules(endpointId)
const currentRule = rules[index] const currentRule = rules[index]
if (currentRule && !currentRule.enabled) return null if (currentRule && !currentRule.enabled) return null
@@ -2380,9 +2434,8 @@ function validateRenameToForEndpoint(endpointId: string, to: string, index: numb
const trimmedTo = to.trim().toLowerCase() const trimmedTo = to.trim().toLowerCase()
if (!trimmedTo) return null if (!trimmedTo) return null
if (RESERVED_HEADERS.has(trimmedTo)) { const reservedErr = reservedHeaderRuleError(to)
return `"${to}" 是系统保留的请求头` if (reservedErr) return reservedErr
}
const rules = getEndpointEditRules(endpointId) const rules = getEndpointEditRules(endpointId)
const currentRule = rules[index] const currentRule = rules[index]
@@ -2525,11 +2578,8 @@ function validateBodyRulePathForEndpoint(endpointId: string, path: string, index
return '路径格式无效' return '路径格式无效'
} }
// 提取顶层 key(去除数组索引部分) const reservedErr = reservedBodyRuleFieldError(raw)
const topKey = (parts[0] || '').trim().toLowerCase() if (reservedErr) return reservedErr
if (RESERVED_BODY_FIELDS.has(topKey)) {
return `"${parts[0]}" 是系统保留的顶层字段`
}
const normalizedPath = raw.toLowerCase() const normalizedPath = raw.toLowerCase()
@@ -2560,10 +2610,8 @@ function validateBodyRenameFromForEndpoint(endpointId: string, from: string, ind
return '路径格式无效(不允许 .a / a. / a..b)' return '路径格式无效(不允许 .a / a. / a..b)'
} }
const topKey = (parts[0] || '').trim().toLowerCase() const reservedErr = reservedBodyRuleFieldError(raw)
if (RESERVED_BODY_FIELDS.has(topKey)) { if (reservedErr) return reservedErr
return `"${parts[0]}" 是系统保留的顶层字段`
}
const normalizedFrom = raw.toLowerCase() const normalizedFrom = raw.toLowerCase()
@@ -2593,10 +2641,8 @@ function validateBodyRenameToForEndpoint(endpointId: string, to: string, index:
return '路径格式无效(不允许 .a / a. / a..b)' return '路径格式无效(不允许 .a / a. / a..b)'
} }
const topKey = (parts[0] || '').trim().toLowerCase() const reservedErr = reservedBodyRuleFieldError(raw)
if (RESERVED_BODY_FIELDS.has(topKey)) { if (reservedErr) return reservedErr
return `"${parts[0]}" 是系统保留的顶层字段`
}
const normalizedTo = raw.toLowerCase() const normalizedTo = raw.toLowerCase()
@@ -3095,9 +3141,8 @@ function validateResponseHeaderNameForEndpoint(endpointId: string, name: string,
const trimmedName = name.trim().toLowerCase() const trimmedName = name.trim().toLowerCase()
if (!trimmedName) return null if (!trimmedName) return null
if ((field === 'key' || field === 'to') && RESERVED_RESPONSE_HEADERS.has(trimmedName)) { const reservedErr = reservedResponseHeaderRuleError(name)
return `"${name}" 是系统保留的响应头` if (reservedErr) return reservedErr
}
const rules = getEndpointEditResponseRules(endpointId) const rules = getEndpointEditResponseRules(endpointId)
const currentRule = rules[index] const currentRule = rules[index]
@@ -1352,6 +1352,7 @@ function parseImportText(text: string): {
browser_profile?: string browser_profile?: string
user_id?: string user_id?: string
account_name?: string account_name?: string
headers?: Record<string, string>
} | null { } | null {
const trimmed = text.trim() const trimmed = text.trim()
if (!trimmed) return null if (!trimmed) return null
@@ -1422,7 +1423,14 @@ function parseImportText(text: string): {
const normalizedSessionToken = typeof sessionToken === 'string' && sessionToken.trim() const normalizedSessionToken = typeof sessionToken === 'string' && sessionToken.trim()
? sessionToken.trim() ? sessionToken.trim()
: (typeof sessionTokenCamel === 'string' && sessionTokenCamel.trim() ? sessionTokenCamel.trim() : undefined) : (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) { if (normalizedRefreshToken || importedAccessToken) {
return { return {
refresh_token: normalizedRefreshToken, 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, 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), 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), account_name: normalizeStringField(obj.account_name) ?? normalizeStringField(obj.accountName),
headers: normalizedHeaders,
} }
} }
return null return null
@@ -1526,6 +1535,34 @@ function normalizeStringField(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined return typeof value === 'string' && value.trim() ? value.trim() : undefined
} }
function normalizeHeadersField(value: unknown): Record<string, string> | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
const headers: Record<string, string> = {}
for (const [rawKey, rawValue] of Object.entries(value as Record<string, unknown>)) {
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<string, string> | 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 { function normalizeNumberField(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) { if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.floor(value) return Math.floor(value)
@@ -323,14 +323,14 @@
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-[11px] font-mono text-muted-foreground"> <span class="text-[11px] font-mono text-muted-foreground">
{{ isOAuthManagedCredential(key) ? '[Refresh Token]' : (isServiceAccountCredential(key) ? '[Service Account]' : key.api_key_masked) }} {{ getProviderMaskedSecretLabel(key, provider.provider_type) }}
</span> </span>
<Button <Button
v-if="canExportOAuthCredential(key)" v-if="canExportOAuthCredential(key)"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-4 w-4 shrink-0" class="h-4 w-4 shrink-0"
title="下载 Refresh Token 授权文件" title="下载 OAuth 授权文件"
@click.stop="downloadRefreshToken(key)" @click.stop="downloadRefreshToken(key)"
> >
<Download class="w-2.5 h-2.5" /> <Download class="w-2.5 h-2.5" />
@@ -1513,6 +1513,7 @@ import {
canRefreshOAuthCredential, canRefreshOAuthCredential,
isOAuthManagedCredential, isOAuthManagedCredential,
isServiceAccountCredential, isServiceAccountCredential,
getProviderMaskedSecretLabel,
shouldShowOAuthRefreshControl, shouldShowOAuthRefreshControl,
} from '@/utils/providerKeyAuth' } from '@/utils/providerKeyAuth'
import { import {
@@ -260,6 +260,11 @@ function getButton(root: HTMLElement, text: string) {
.find(button => button.textContent?.includes(text)) .find(button => button.textContent?.includes(text))
} }
function getExactButton(root: HTMLElement, text: string) {
return Array.from(root.querySelectorAll('button'))
.find(button => button.textContent?.trim() === text)
}
function getImportTextarea(root: HTMLElement) { function getImportTextarea(root: HTMLElement) {
const textarea = root.querySelector('textarea') const textarea = root.querySelector('textarea')
if (!(textarea instanceof HTMLTextAreaElement)) { if (!(textarea instanceof HTMLTextAreaElement)) {
@@ -350,9 +355,40 @@ describe('OAuthAccountDialog Grok import', () => {
account_id: undefined, account_id: undefined,
account_user_id: undefined, account_user_id: undefined,
user_id: undefined, user_id: undefined,
headers: undefined,
}) })
}) })
it('keeps Codex imported request headers on single JSON import', async () => {
const root = mountDialog('codex')
await settle()
getButton(root, '导入授权')?.click()
await settle()
const textarea = getImportTextarea(root)
textarea.value = JSON.stringify({
access_token: 'jwt-access-token',
headers: {
authorization: 'Bearer session-token',
},
accountId: 'acct-1',
})
textarea.dispatchEvent(new Event('input'))
await settle()
getExactButton(root, '导入')?.click()
await settle()
expect(endpointMocks.importProviderRefreshToken).toHaveBeenCalledWith('provider-1', expect.objectContaining({
access_token: 'jwt-access-token',
headers: {
authorization: 'Bearer session-token',
},
account_id: 'acct-1',
}))
})
it('keeps Grok multiline token import on the batch task path', async () => { it('keeps Grok multiline token import on the batch task path', async () => {
const root = mountDialog('grok') const root = mountDialog('grok')
await settle() await settle()
@@ -931,7 +931,28 @@ const emit = defineEmits<{
requestId?: string | null requestId?: string | null
status?: RequestStateStatus status?: RequestStateStatus
statusCode?: number | null statusCode?: number | null
inputTokens?: number | null
effectiveInputTokens?: number | null
outputTokens?: number | null
totalTokens?: number | null
cacheCreationInputTokens?: number | null
cacheCreationEphemeral5mInputTokens?: number | null
cacheCreationEphemeral1hInputTokens?: number | null
cacheReadInputTokens?: number | null
cost?: number | null
actualCost?: number | null
responseTimeMs?: number | null responseTimeMs?: number | null
firstByteTimeMs?: number | null
isStream?: boolean | null
upstreamIsStream?: boolean | null
clientRequestedStream?: boolean | null
clientIsStream?: boolean | null
apiFormat?: string | null
endpointApiFormat?: string | null
hasFormatConversion?: boolean | null
targetModel?: string | null
reasoningEffort?: string | null
serviceTier?: string | null
imageProgress?: ImageProgress | null imageProgress?: ImageProgress | null
errorMessage?: string | null errorMessage?: string | null
}] }]
@@ -1092,6 +1113,28 @@ function resolveRequestStateStatusFromDetail(nextDetail: Pick<RequestDetail, 'st
return resolveRequestStateStatus(nextDetail.status, nextDetail.status_code, nextDetail.error_message) return resolveRequestStateStatus(nextDetail.status, nextDetail.status_code, nextDetail.error_message)
} }
function detailTotalCost(nextDetail: RequestDetail): number | null {
const structuredCost = typeof nextDetail.cost === 'object' ? nextDetail.cost?.total : null
const totalCost = toNumber(nextDetail.total_cost)
const legacyCost = toNumber(nextDetail.cost)
return [structuredCost, totalCost, legacyCost]
.map(value => toNumber(value))
.find((value): value is number => value != null && value > 0)
?? toNumber(structuredCost)
?? totalCost
?? legacyCost
}
function detailCacheCreation5mTokens(nextDetail: RequestDetail): number | null {
return toNumber(nextDetail.cache_creation_input_tokens_5m)
?? toNumber(nextDetail.cache_creation_ephemeral_5m_input_tokens)
}
function detailCacheCreation1hTokens(nextDetail: RequestDetail): number | null {
return toNumber(nextDetail.cache_creation_input_tokens_1h)
?? toNumber(nextDetail.cache_creation_ephemeral_1h_input_tokens)
}
function emitDetailRequestState(nextDetail: RequestDetail) { function emitDetailRequestState(nextDetail: RequestDetail) {
const id = props.requestId const id = props.requestId
if (!id) return if (!id) return
@@ -1101,7 +1144,28 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
requestId: nextDetail.request_id || nextDetail.id || null, requestId: nextDetail.request_id || nextDetail.id || null,
status: resolveRequestStateStatusFromDetail(nextDetail), status: resolveRequestStateStatusFromDetail(nextDetail),
statusCode: nextDetail.status_code ?? undefined, statusCode: nextDetail.status_code ?? undefined,
inputTokens: nextDetail.input_tokens ?? nextDetail.tokens?.input ?? null,
effectiveInputTokens: displayInputTokens.value,
outputTokens: nextDetail.output_tokens ?? nextDetail.tokens?.output ?? null,
totalTokens: nextDetail.total_tokens ?? nextDetail.tokens?.total ?? null,
cacheCreationInputTokens: nextDetail.cache_creation_input_tokens ?? null,
cacheCreationEphemeral5mInputTokens: detailCacheCreation5mTokens(nextDetail),
cacheCreationEphemeral1hInputTokens: detailCacheCreation1hTokens(nextDetail),
cacheReadInputTokens: nextDetail.cache_read_input_tokens ?? null,
cost: detailTotalCost(nextDetail),
actualCost: nextDetail.actual_cost ?? null,
responseTimeMs: nextDetail.response_time_ms ?? undefined, responseTimeMs: nextDetail.response_time_ms ?? undefined,
firstByteTimeMs: nextDetail.first_byte_time_ms ?? null,
isStream: nextDetail.is_stream ?? null,
upstreamIsStream: nextDetail.upstream_is_stream ?? null,
clientRequestedStream: nextDetail.client_requested_stream ?? null,
clientIsStream: nextDetail.client_is_stream ?? null,
apiFormat: nextDetail.api_format ?? null,
endpointApiFormat: nextDetail.endpoint_api_format ?? null,
hasFormatConversion: nextDetail.has_format_conversion ?? null,
targetModel: nextDetail.target_model ?? null,
reasoningEffort: nextDetail.reasoning_effort ?? null,
serviceTier: nextDetail.service_tier ?? null,
errorMessage: nextDetail.error_message ?? undefined, errorMessage: nextDetail.error_message ?? undefined,
}) })
} }
@@ -147,6 +147,172 @@ describe('useUsageData', () => {
}) })
}) })
it('preserves detail-filled usage metrics when a later list refresh is still empty', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'completed',
input_tokens: 0,
effective_input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
cache_creation_input_tokens: 0,
cache_creation_ephemeral_5m_input_tokens: 0,
cache_creation_ephemeral_1h_input_tokens: 0,
cache_read_input_tokens: 0,
cost: 0,
actual_cost: 0,
response_time_ms: null,
first_byte_time_ms: null,
is_stream: false,
upstream_is_stream: false,
client_requested_stream: false,
client_is_stream: false,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
Object.assign(currentRecords.value[0], {
input_tokens: 1138,
effective_input_tokens: 1138,
output_tokens: 244,
total_tokens: 81126,
cache_creation_input_tokens: 17,
cache_creation_ephemeral_5m_input_tokens: 5,
cache_creation_ephemeral_1h_input_tokens: 12,
cache_read_input_tokens: 79744,
cost: 0.052882,
actual_cost: 0.052882,
response_time_ms: 5570,
first_byte_time_ms: 1600,
is_stream: true,
upstream_is_stream: true,
client_requested_stream: true,
client_is_stream: true,
api_format: 'openai:responses',
endpoint_api_format: 'openai:responses',
has_format_conversion: false,
has_retry: true,
target_model: 'gpt-5.5',
reasoning_effort: 'xhigh',
service_tier: 'auto',
})
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'completed',
input_tokens: 0,
effective_input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
cache_creation_input_tokens: 0,
cache_creation_ephemeral_5m_input_tokens: 0,
cache_creation_ephemeral_1h_input_tokens: 0,
cache_read_input_tokens: 0,
cost: 0,
actual_cost: 0,
response_time_ms: null,
first_byte_time_ms: null,
is_stream: false,
upstream_is_stream: false,
client_requested_stream: false,
client_is_stream: false,
api_format: undefined,
endpoint_api_format: undefined,
has_format_conversion: undefined,
has_retry: false,
target_model: null,
reasoning_effort: null,
service_tier: null,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
status: 'completed',
input_tokens: 1138,
effective_input_tokens: 1138,
output_tokens: 244,
total_tokens: 81126,
cache_creation_input_tokens: 17,
cache_creation_ephemeral_5m_input_tokens: 5,
cache_creation_ephemeral_1h_input_tokens: 12,
cache_read_input_tokens: 79744,
cost: 0.052882,
actual_cost: 0.052882,
response_time_ms: 5570,
first_byte_time_ms: 1600,
is_stream: true,
upstream_is_stream: true,
client_requested_stream: true,
client_is_stream: true,
api_format: 'openai:responses',
endpoint_api_format: 'openai:responses',
has_format_conversion: false,
has_retry: true,
target_model: 'gpt-5.5',
reasoning_effort: 'xhigh',
service_tier: 'auto',
})
})
it('allows finalized list metrics to replace larger detail estimates', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'completed',
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
cost: 0.09,
actual_cost: 0.09,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'completed',
input_tokens: 1100,
output_tokens: 250,
total_tokens: 1350,
cost: 0.07,
actual_cost: 0.07,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
input_tokens: 1100,
output_tokens: 250,
total_tokens: 1350,
cost: 0.07,
actual_cost: 0.07,
})
})
it('refreshes exact admin record totals after an estimated first page', async () => { it('refreshes exact admin record totals after an estimated first page', async () => {
const isAdminPage = ref(true) const isAdminPage = ref(true)
const { loadRecords, totalRecords } = useUsageData({ isAdminPage }) const { loadRecords, totalRecords } = useUsageData({ isAdminPage })
@@ -436,6 +436,38 @@ export function useUsageData(options: UseUsageDataOptions) {
return existingValue ?? nextValue return existingValue ?? nextValue
} }
function mergeSparseRecordMetric(
existingValue: number | null | undefined,
nextValue: number | null | undefined
): number | null | undefined {
const existingIsPositive = typeof existingValue === 'number' && Number.isFinite(existingValue) && existingValue > 0
const nextIsPositive = typeof nextValue === 'number' && Number.isFinite(nextValue) && nextValue > 0
if (nextIsPositive) {
return nextValue
}
if (existingIsPositive) {
return existingValue
}
return existingValue ?? nextValue
}
function mergeBooleanTrueWins(
existingValue: boolean | null | undefined,
nextValue: boolean | null | undefined
): boolean | undefined {
if (existingValue === true || nextValue === true) {
return true
}
if (typeof nextValue === 'boolean') {
return nextValue
}
if (typeof existingValue === 'boolean') {
return existingValue
}
return undefined
}
function mergeRecordStatus( function mergeRecordStatus(
current: UsageRecord[], current: UsageRecord[],
next: UsageRecord[] next: UsageRecord[]
@@ -466,104 +498,94 @@ export function useUsageData(options: UseUsageDataOptions) {
(nextRank === currentRank && existing.status === record.status) (nextRank === currentRank && existing.status === record.status)
) )
const mergedStatus = statusProgressed ? record.status : existing.status const mergedStatus = statusProgressed ? record.status : existing.status
const protectStatus = mergedStatus !== record.status
// 确定是否需要保护 provider(避免 pending/unknown/unknow 覆盖已有的正确值) // 确定是否需要保护 provider(避免 pending/unknown/unknow 覆盖已有的正确值)
const isPendingProvider = !isUsageProviderVisible(record.provider) const isPendingProvider = !isUsageProviderVisible(record.provider)
const hasValidExistingProvider = isUsageProviderVisible(existing.provider) const hasValidExistingProvider = isUsageProviderVisible(existing.provider)
const protectProvider = isPendingProvider && hasValidExistingProvider const protectProvider = isPendingProvider && hasValidExistingProvider
// 如果需要保护状态,说明本地数据比后端更新,应该保留本地的所有实时更新字段 const recordUpstreamIsStream = typeof record.upstream_is_stream === 'boolean'
if (protectStatus) { ? record.upstream_is_stream
const recordUpstreamIsStream = typeof record.upstream_is_stream === 'boolean' : typeof record.is_stream === 'boolean'
? record.upstream_is_stream ? record.is_stream
: typeof record.is_stream === 'boolean' : undefined
? record.is_stream const existingUpstreamIsStream = typeof existing.upstream_is_stream === 'boolean'
: undefined ? existing.upstream_is_stream
const existingUpstreamIsStream = typeof existing.upstream_is_stream === 'boolean' : typeof existing.is_stream === 'boolean'
? existing.upstream_is_stream ? existing.is_stream
: typeof existing.is_stream === 'boolean' : undefined
? existing.is_stream const upstreamIsStream = mergeBooleanTrueWins(existingUpstreamIsStream, recordUpstreamIsStream) ?? false
: undefined
const upstreamIsStream = recordUpstreamIsStream ?? existingUpstreamIsStream ?? false
const recordClientRequestedStream = typeof record.client_requested_stream === 'boolean' const recordClientRequestedStream = typeof record.client_requested_stream === 'boolean'
? record.client_requested_stream ? record.client_requested_stream
: typeof record.client_is_stream === 'boolean' : typeof record.client_is_stream === 'boolean'
? record.client_is_stream
: undefined
const existingClientRequestedStream = typeof existing.client_requested_stream === 'boolean'
? existing.client_requested_stream
: typeof existing.client_is_stream === 'boolean'
? existing.client_is_stream
: undefined
const clientRequestedStream = recordClientRequestedStream ?? existingClientRequestedStream
const recordClientIsStream = typeof record.client_is_stream === 'boolean'
? record.client_is_stream ? record.client_is_stream
: typeof record.client_requested_stream === 'boolean' : undefined
? record.client_requested_stream const existingClientRequestedStream = typeof existing.client_requested_stream === 'boolean'
: undefined ? existing.client_requested_stream
const existingClientIsStream = typeof existing.client_is_stream === 'boolean' : typeof existing.client_is_stream === 'boolean'
? existing.client_is_stream ? existing.client_is_stream
: typeof existing.client_requested_stream === 'boolean' : undefined
? existing.client_requested_stream const clientRequestedStream = mergeBooleanTrueWins(existingClientRequestedStream, recordClientRequestedStream)
: undefined
const clientIsStream = recordClientIsStream ?? existingClientIsStream ?? clientRequestedStream
return { const recordClientIsStream = typeof record.client_is_stream === 'boolean'
...record, ? record.client_is_stream
// 保留本地的状态和所有通过轮询更新的字段 : typeof record.client_requested_stream === 'boolean'
status: mergedStatus, ? record.client_requested_stream
provider: protectProvider ? existing.provider : (record.provider || existing.provider), : undefined
input_tokens: Number.isFinite(record.input_tokens) const existingClientIsStream = typeof existing.client_is_stream === 'boolean'
? record.input_tokens ? existing.client_is_stream
: existing.input_tokens, : typeof existing.client_requested_stream === 'boolean'
effective_input_tokens: record.effective_input_tokens ?? existing.effective_input_tokens, ? existing.client_requested_stream
output_tokens: existing.output_tokens || record.output_tokens, : undefined
cache_creation_input_tokens: existing.cache_creation_input_tokens ?? record.cache_creation_input_tokens, const clientIsStream = mergeBooleanTrueWins(existingClientIsStream, recordClientIsStream) ?? clientRequestedStream
cache_creation_ephemeral_5m_input_tokens:
existing.cache_creation_ephemeral_5m_input_tokens return {
?? record.cache_creation_ephemeral_5m_input_tokens, ...record,
cache_creation_ephemeral_1h_input_tokens: // 保留详情抽屉/活跃轮询已经拿到的完整指标,避免列表刷新用 0 或空值回退。
existing.cache_creation_ephemeral_1h_input_tokens status: mergedStatus,
?? record.cache_creation_ephemeral_1h_input_tokens, provider: protectProvider ? existing.provider : (record.provider || existing.provider),
cache_read_input_tokens: existing.cache_read_input_tokens ?? record.cache_read_input_tokens, input_tokens: mergeSparseRecordMetric(existing.input_tokens, record.input_tokens) ?? record.input_tokens,
cost: existing.cost || record.cost, effective_input_tokens: mergeSparseRecordMetric(existing.effective_input_tokens, record.effective_input_tokens) ?? record.effective_input_tokens,
actual_cost: existing.actual_cost ?? record.actual_cost, output_tokens: mergeSparseRecordMetric(existing.output_tokens, record.output_tokens) ?? record.output_tokens,
response_time_ms: mergePositiveDurationMs(existing.response_time_ms, record.response_time_ms), total_tokens: mergeSparseRecordMetric(existing.total_tokens, record.total_tokens) ?? record.total_tokens,
first_byte_time_ms: mergePositiveDurationMs(existing.first_byte_time_ms, record.first_byte_time_ms), cache_creation_input_tokens: mergeSparseRecordMetric(existing.cache_creation_input_tokens, record.cache_creation_input_tokens) ?? record.cache_creation_input_tokens,
updated_at: existing.updated_at ?? record.updated_at, cache_creation_ephemeral_5m_input_tokens:
response_time_updated_at: existing.response_time_updated_at ?? record.response_time_updated_at, mergeSparseRecordMetric(
status_code: existing.status_code ?? record.status_code, existing.cache_creation_ephemeral_5m_input_tokens,
error_message: existing.error_message ?? record.error_message, record.cache_creation_ephemeral_5m_input_tokens
image_progress: existing.image_progress ?? record.image_progress, ) ?? record.cache_creation_ephemeral_5m_input_tokens,
is_stream: upstreamIsStream, cache_creation_ephemeral_1h_input_tokens:
upstream_is_stream: upstreamIsStream, mergeSparseRecordMetric(
client_requested_stream: clientRequestedStream, existing.cache_creation_ephemeral_1h_input_tokens,
client_is_stream: clientIsStream, record.cache_creation_ephemeral_1h_input_tokens
api_format: existing.api_format || record.api_format, ) ?? record.cache_creation_ephemeral_1h_input_tokens,
endpoint_api_format: existing.endpoint_api_format || record.endpoint_api_format, cache_read_input_tokens: mergeSparseRecordMetric(existing.cache_read_input_tokens, record.cache_read_input_tokens) ?? record.cache_read_input_tokens,
has_format_conversion: existing.has_format_conversion ?? record.has_format_conversion, cost: mergeSparseRecordMetric(existing.cost, record.cost) ?? record.cost,
has_fallback: existing.has_fallback === true || record.has_fallback === true, actual_cost: mergeSparseRecordMetric(existing.actual_cost, record.actual_cost) ?? record.actual_cost,
api_key_name: existing.api_key_name || record.api_key_name, response_time_ms: mergePositiveDurationMs(existing.response_time_ms, record.response_time_ms),
provider_key_name: existing.provider_key_name || record.provider_key_name, first_byte_time_ms: mergePositiveDurationMs(existing.first_byte_time_ms, record.first_byte_time_ms),
rate_multiplier: existing.rate_multiplier ?? record.rate_multiplier, updated_at: record.updated_at ?? existing.updated_at,
target_model: existing.target_model || record.target_model, response_time_updated_at: record.response_time_updated_at ?? existing.response_time_updated_at,
reasoning_effort: existing.reasoning_effort || record.reasoning_effort, status_code: record.status_code ?? existing.status_code,
service_tier: existing.service_tier || record.service_tier error_message: record.error_message ?? existing.error_message,
} image_progress: record.image_progress ?? existing.image_progress,
is_stream: upstreamIsStream,
upstream_is_stream: upstreamIsStream,
client_requested_stream: clientRequestedStream,
client_is_stream: clientIsStream,
api_format: record.api_format || existing.api_format,
endpoint_api_format: record.endpoint_api_format || existing.endpoint_api_format,
has_format_conversion: record.has_format_conversion ?? existing.has_format_conversion,
has_fallback: existing.has_fallback === true || record.has_fallback === true,
has_retry: existing.has_retry === true || record.has_retry === true,
api_key_name: record.api_key_name || existing.api_key_name,
provider_key_name: record.provider_key_name || existing.provider_key_name,
rate_multiplier: record.rate_multiplier ?? existing.rate_multiplier,
target_model: record.target_model ?? existing.target_model,
reasoning_effort: record.reasoning_effort ?? existing.reasoning_effort,
service_tier: record.service_tier ?? existing.service_tier
} }
// 只需要保护 provider
if (protectProvider) {
return {
...record,
provider: existing.provider
}
}
return record
}) })
} }
@@ -39,4 +39,14 @@ describe('providerKeyAuth', () => {
expect(shouldShowOAuthRefreshControl(input)).toBe(true) expect(shouldShowOAuthRefreshControl(input)).toBe(true)
expect(getProviderMaskedSecretLabel(input, 'codex')).toBe('[OAuth Token]') expect(getProviderMaskedSecretLabel(input, 'codex')).toBe('[OAuth Token]')
}) })
it('renders OAuth-managed authorization header credentials as OAuth Header', () => {
const input = {
auth_type: 'oauth',
oauth_managed: true,
oauth_header_auth: true,
}
expect(getProviderMaskedSecretLabel(input, 'codex')).toBe('[OAuth Header]')
})
}) })
+2
View File
@@ -4,6 +4,7 @@ export interface ProviderKeyAuthCarrier {
credential_kind?: string | null credential_kind?: string | null
runtime_auth_kind?: string | null runtime_auth_kind?: string | null
oauth_managed?: boolean | null oauth_managed?: boolean | null
oauth_header_auth?: boolean | null
oauth_temporary?: boolean | null oauth_temporary?: boolean | null
can_refresh_oauth?: boolean | null can_refresh_oauth?: boolean | null
can_export_oauth?: boolean | null can_export_oauth?: boolean | null
@@ -121,6 +122,7 @@ export function getProviderMaskedSecretLabel(
providerType?: string | null, providerType?: string | null,
): string { ): string {
if (isGrokSessionCredential(input, providerType)) return '[Session Cookie]' if (isGrokSessionCredential(input, providerType)) return '[Session Cookie]'
if (isOAuthManagedCredential(input) && input.oauth_header_auth === true) return '[OAuth Header]'
if (isOAuthManagedCredential(input)) return '[OAuth Token]' if (isOAuthManagedCredential(input)) return '[OAuth Token]'
if (isServiceAccountCredential(input)) return '[Service Account]' if (isServiceAccountCredential(input)) return '[Service Account]'
if (getProviderRuntimeAuthKind(input) === 'mixed') return '[Key]' if (getProviderRuntimeAuthKind(input) === 'mixed') return '[Key]'
@@ -268,6 +268,7 @@ function affinityModelSubtitle(item: UserAffinity): string {
function providerKeyLabel(item: UserAffinity): string { function providerKeyLabel(item: UserAffinity): string {
if (item.key_name) return item.key_name if (item.key_name) return item.key_name
if (item.key_prefix === '[OAuth Token]') return 'OAuth 认证' if (item.key_prefix === '[OAuth Token]') return 'OAuth 认证'
if (item.key_prefix === '[OAuth Header]') return 'OAuth Header'
return item.key_prefix || '---' return item.key_prefix || '---'
} }
@@ -2761,6 +2761,7 @@ function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
credential_kind: key.credential_kind ?? null, credential_kind: key.credential_kind ?? null,
runtime_auth_kind: key.runtime_auth_kind ?? null, runtime_auth_kind: key.runtime_auth_kind ?? null,
oauth_managed: key.oauth_managed ?? undefined, oauth_managed: key.oauth_managed ?? undefined,
oauth_header_auth: key.oauth_header_auth ?? undefined,
can_refresh_oauth: key.can_refresh_oauth ?? undefined, can_refresh_oauth: key.can_refresh_oauth ?? undefined,
can_export_oauth: key.can_export_oauth ?? undefined, can_export_oauth: key.can_export_oauth ?? undefined,
can_edit_oauth: key.can_edit_oauth ?? undefined, can_edit_oauth: key.can_edit_oauth ?? undefined,
+84
View File
@@ -1013,7 +1013,28 @@ function handleDetailRequestState(update: {
id: string id: string
status?: RequestStatus status?: RequestStatus
statusCode?: number | null statusCode?: number | null
inputTokens?: number | null
effectiveInputTokens?: number | null
outputTokens?: number | null
totalTokens?: number | null
cacheCreationInputTokens?: number | null
cacheCreationEphemeral5mInputTokens?: number | null
cacheCreationEphemeral1hInputTokens?: number | null
cacheReadInputTokens?: number | null
cost?: number | null
actualCost?: number | null
responseTimeMs?: number | null responseTimeMs?: number | null
firstByteTimeMs?: number | null
isStream?: boolean | null
upstreamIsStream?: boolean | null
clientRequestedStream?: boolean | null
clientIsStream?: boolean | null
apiFormat?: string | null
endpointApiFormat?: string | null
hasFormatConversion?: boolean | null
targetModel?: string | null
reasoningEffort?: string | null
serviceTier?: string | null
imageProgress?: ImageProgress | null imageProgress?: ImageProgress | null
errorMessage?: string | null errorMessage?: string | null
}) { }) {
@@ -1039,9 +1060,72 @@ function handleDetailRequestState(update: {
if ('statusCode' in update) { if ('statusCode' in update) {
record.status_code = update.statusCode ?? undefined record.status_code = update.statusCode ?? undefined
} }
if ('inputTokens' in update && update.inputTokens != null) {
record.input_tokens = update.inputTokens
}
if ('effectiveInputTokens' in update && update.effectiveInputTokens != null) {
record.effective_input_tokens = update.effectiveInputTokens
}
if ('outputTokens' in update && update.outputTokens != null) {
record.output_tokens = update.outputTokens
}
if ('totalTokens' in update && update.totalTokens != null) {
record.total_tokens = update.totalTokens
}
if ('cacheCreationInputTokens' in update && update.cacheCreationInputTokens != null) {
record.cache_creation_input_tokens = update.cacheCreationInputTokens
}
if ('cacheCreationEphemeral5mInputTokens' in update && update.cacheCreationEphemeral5mInputTokens != null) {
record.cache_creation_ephemeral_5m_input_tokens = update.cacheCreationEphemeral5mInputTokens
}
if ('cacheCreationEphemeral1hInputTokens' in update && update.cacheCreationEphemeral1hInputTokens != null) {
record.cache_creation_ephemeral_1h_input_tokens = update.cacheCreationEphemeral1hInputTokens
}
if ('cacheReadInputTokens' in update && update.cacheReadInputTokens != null) {
record.cache_read_input_tokens = update.cacheReadInputTokens
}
if ('cost' in update && update.cost != null) {
record.cost = update.cost
}
if ('actualCost' in update && update.actualCost != null) {
record.actual_cost = update.actualCost
}
if ('responseTimeMs' in update && update.responseTimeMs != null) { if ('responseTimeMs' in update && update.responseTimeMs != null) {
record.response_time_ms = update.responseTimeMs record.response_time_ms = update.responseTimeMs
} }
if ('firstByteTimeMs' in update) {
record.first_byte_time_ms = update.firstByteTimeMs ?? undefined
}
if ('isStream' in update && typeof update.isStream === 'boolean') {
record.is_stream = update.isStream
}
if ('upstreamIsStream' in update && typeof update.upstreamIsStream === 'boolean') {
record.upstream_is_stream = update.upstreamIsStream
}
if ('clientRequestedStream' in update && typeof update.clientRequestedStream === 'boolean') {
record.client_requested_stream = update.clientRequestedStream
}
if ('clientIsStream' in update && typeof update.clientIsStream === 'boolean') {
record.client_is_stream = update.clientIsStream
}
if ('apiFormat' in update && typeof update.apiFormat === 'string') {
record.api_format = update.apiFormat
}
if ('endpointApiFormat' in update && typeof update.endpointApiFormat === 'string') {
record.endpoint_api_format = update.endpointApiFormat
}
if ('hasFormatConversion' in update && typeof update.hasFormatConversion === 'boolean') {
record.has_format_conversion = update.hasFormatConversion
}
if ('targetModel' in update) {
record.target_model = typeof update.targetModel === 'string' ? update.targetModel : update.targetModel ?? undefined
}
if ('reasoningEffort' in update) {
record.reasoning_effort = typeof update.reasoningEffort === 'string' ? update.reasoningEffort : null
}
if ('serviceTier' in update) {
record.service_tier = typeof update.serviceTier === 'string' ? update.serviceTier : null
}
if ('imageProgress' in update) { if ('imageProgress' in update) {
const nextProgress = update.imageProgress ?? null const nextProgress = update.imageProgress ?? null
if (!sameImageProgress(record.image_progress, nextProgress)) { if (!sameImageProgress(record.image_progress, nextProgress)) {