mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 20:19:51 +08:00
fix: preserve provider auth metadata
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
+19
-6
@@ -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(
|
||||||
|
|||||||
+6
-1
@@ -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(
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+36
@@ -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]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user