From 8cf381b0c3b6fd485ec4b4333c30fe5b251325b3 Mon Sep 17 00:00:00 2001 From: elky Date: Thu, 13 Aug 2026 09:57:17 +0800 Subject: [PATCH] feat(codex): add OAuth fingerprint convergence --- Cargo.toml | 2 +- .../src/ai_serving/planner/decision_input.rs | 214 +++++++ .../planner/standard/family/payload.rs | 1 + .../standard/openai/chat/decision/request.rs | 1 + .../admin/provider/shared/payloads.rs | 4 + .../handlers/admin/provider/summary/value.rs | 4 + .../admin/provider/write/provider/create.rs | 65 ++ .../admin/provider/write/provider/update.rs | 69 +++ .../src/tests/control/admin/providers.rs | 46 +- .../transport/src/codex_fingerprint.rs | 565 ++++++++++++++++++ crates/aether-provider/transport/src/lib.rs | 5 + frontend/src/api/endpoints/providers.ts | 3 + frontend/src/api/endpoints/types/provider.ts | 7 + .../components/ProviderFormDialog.vue | 38 ++ ...ProviderFormDialog.transfer-limits.spec.ts | 89 +++ frontend/src/i18n/messages.ts | 2 + 16 files changed, 1111 insertions(+), 4 deletions(-) create mode 100644 crates/aether-provider/transport/src/codex_fingerprint.rs diff --git a/Cargo.toml b/Cargo.toml index 9c22d5a3f..3ba5c5036 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -135,7 +135,7 @@ tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal tokio-util = { version = "0.7", features = ["codec", "io-util"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } -uuid = { version = "1", features = ["serde", "v4", "v5"] } +uuid = { version = "1", features = ["serde", "v4", "v5", "v7"] } webpki-roots = "0.26" wreq = { version = "6.0.0-rc.28", default-features = false, features = ["json", "stream", "socks", "webpki-roots", "ws"] } wreq-util = "3.0.0-rc.10" diff --git a/apps/aether-gateway/src/ai_serving/planner/decision_input.rs b/apps/aether-gateway/src/ai_serving/planner/decision_input.rs index 784106113..1ac6268eb 100644 --- a/apps/aether-gateway/src/ai_serving/planner/decision_input.rs +++ b/apps/aether-gateway/src/ai_serving/planner/decision_input.rs @@ -55,6 +55,7 @@ pub(crate) struct LocalRequestedModelDecisionInput { pub(crate) client_surface: Option, pub(crate) gateway_credential_carrier: Option, pub(crate) client_session_affinity: Option, + pub(crate) original_client_session_id: Option, pub(crate) routing_policy: Option, pub(crate) routing_trace_seed: Option, pub(crate) routing_context: Option, @@ -150,6 +151,12 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision( provider_api_format.as_str(), ); } + apply_codex_oauth_fingerprint_convergence_to_decision( + input, + decision, + transport, + provider_api_format.as_str(), + ); return Ok(()); }; let provider_body_rules = decision @@ -207,6 +214,12 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision( provider_api_format.as_str(), ); } + apply_codex_oauth_fingerprint_convergence_to_decision( + input, + decision, + transport, + provider_api_format.as_str(), + ); return Ok(()); } if original_provider_request_body.is_none() && !policy.mutation_plan.body_patch.is_empty() { @@ -305,10 +318,36 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision( if original_provider_request_body.is_some() { decision.provider_request_body = Some(provider_request_body); } + apply_codex_oauth_fingerprint_convergence_to_decision( + input, + decision, + transport, + provider_api_format.as_str(), + ); update_report_context_provider_request_mutation(decision, &policy); Ok(()) } +fn apply_codex_oauth_fingerprint_convergence_to_decision( + input: &LocalRequestedModelDecisionInput, + decision: &mut AiExecutionDecision, + transport: Option<&GatewayProviderTransportSnapshot>, + provider_api_format: &str, +) { + let (Some(transport), Some(provider_request_body)) = + (transport, decision.provider_request_body.as_mut()) + else { + return; + }; + crate::provider_transport::apply_codex_oauth_fingerprint_convergence( + transport, + provider_api_format, + input.original_client_session_id.as_deref(), + &mut decision.provider_request_headers, + provider_request_body, + ); +} + struct GatewayAuthenticatedDecisionInputPort<'a> { state: PlannerAppState<'a>, now_unix_secs: u64, @@ -383,6 +422,7 @@ pub(crate) fn build_local_requested_model_decision_input( client_surface: None, gateway_credential_carrier: None, client_session_affinity: None, + original_client_session_id: None, routing_policy: None, routing_trace_seed: None, routing_context: None, @@ -397,6 +437,8 @@ pub(crate) async fn attach_routing_policy_to_local_requested_model_input( body_json: &Value, client_api_format: &str, ) -> Result<(), GatewayError> { + input.original_client_session_id = routing_header_value_str(&parts.headers, "session-id") + .or_else(|| routing_header_value_str(&parts.headers, "session_id")); let explicit_group = routing_header_value_str(&parts.headers, ROUTING_GROUP_HEADER); let selected_group = match state.routing_group_read_repository() { Some(repository) => { @@ -1154,6 +1196,7 @@ mod tests { client_surface: None, gateway_credential_carrier: None, client_session_affinity: None, + original_client_session_id: None, routing_policy: None, routing_trace_seed: None, model_directive_policy: Default::default(), @@ -1312,6 +1355,57 @@ mod tests { } } + fn sample_codex_fingerprint_transport() -> GatewayProviderTransportSnapshot { + let mut transport = sample_codex_transport_with_card(); + transport.provider.config = Some(json!({ + "codex": {"fingerprint_convergence_enabled": true} + })); + transport.endpoint.api_format = "openai:responses".to_string(); + transport.endpoint.endpoint_kind = Some("responses".to_string()); + transport.key.api_formats = Some(vec!["openai:responses".to_string()]); + transport.key.decrypted_auth_config = + Some(json!({"account_id": "account-codex-1"}).to_string()); + transport + } + + fn sample_codex_fingerprint_decision() -> AiExecutionDecision { + let prompt_cache_key = "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3"; + let mut decision = sample_decision(); + decision.provider_type = Some("codex".to_string()); + decision.provider_api_format = Some("openai:responses".to_string()); + decision.client_api_format = Some("openai:responses".to_string()); + decision.provider_request_headers.extend([ + ("session-id".to_string(), "spoofed-session".to_string()), + ("thread-id".to_string(), "spoofed-thread".to_string()), + ( + "x-codex-turn-metadata".to_string(), + json!({ + "installation_id": "spoofed-installation", + "session_id": "spoofed-session", + "thread_source": "cli" + }) + .to_string(), + ), + ]); + decision.provider_request_body = Some(json!({ + "model": "gpt-5", + "input": [], + "metadata": {}, + "prompt_cache_key": prompt_cache_key, + "client_metadata": { + "session_id": "spoofed-session", + "thread_id": "spoofed-thread", + "caller": "sdk", + "x-codex-turn-metadata": json!({ + "installation_id": "spoofed-installation", + "session_id": "spoofed-session", + "sandbox": "workspace-write" + }).to_string() + } + })); + decision + } + fn set_provider_request_rules( input: &mut LocalRequestedModelDecisionInput, allowed_models: &[&str], @@ -1351,6 +1445,7 @@ mod tests { client_surface: None, gateway_credential_carrier: None, client_session_affinity: None, + original_client_session_id: None, routing_policy: None, routing_trace_seed: None, model_directive_policy: Default::default(), @@ -1420,6 +1515,7 @@ mod tests { client_surface: None, gateway_credential_carrier: None, client_session_affinity: None, + original_client_session_id: None, routing_policy: None, routing_trace_seed: None, routing_context: None, @@ -1488,6 +1584,124 @@ mod tests { ); } + #[test] + fn codex_fingerprint_convergence_runs_at_every_provider_routing_success_exit() { + let transport = sample_codex_fingerprint_transport(); + let mut no_context = sample_decision_input(); + no_context.routing_context = None; + let mut empty_mutation = sample_decision_input(); + empty_mutation + .routing_context + .as_mut() + .expect("routing context") + .group_config_json = json!({ + "allowed_models": ["gpt-5"], + "rules": [] + }); + let mut with_mutation = sample_decision_input(); + for input in [&mut no_context, &mut empty_mutation, &mut with_mutation] { + input.original_client_session_id = Some("client-session-1".to_string()); + } + + let mut stable_identity = None; + let mut turn_ids = std::collections::BTreeSet::new(); + for (exit_name, input) in [ + ("no_context", no_context), + ("empty_mutation", empty_mutation), + ("with_mutation", with_mutation), + ] { + let mut decision = sample_codex_fingerprint_decision(); + + apply_provider_request_routing_policy_to_decision( + &input, + &mut decision, + Some(&transport), + ) + .unwrap_or_else(|error| panic!("{exit_name} should converge: {error:?}")); + + let session_id = decision.provider_request_headers["session-id"].clone(); + let thread_id = decision.provider_request_headers["thread-id"].clone(); + let installation_id = + decision.provider_request_headers["x-codex-installation-id"].clone(); + let window_id = decision.provider_request_headers["x-codex-window-id"].clone(); + assert_eq!(decision.provider_request_headers["session_id"], session_id); + assert_eq!( + decision.provider_request_headers["x-client-request-id"], + thread_id + ); + assert_eq!(window_id, format!("{thread_id}:0")); + assert_eq!( + uuid::Uuid::parse_str(&session_id) + .expect("session UUID") + .get_version_num(), + 4 + ); + assert_eq!( + uuid::Uuid::parse_str(&thread_id) + .expect("thread UUID") + .get_version_num(), + 4 + ); + + let body = decision + .provider_request_body + .as_ref() + .expect("request body"); + assert_eq!( + body["prompt_cache_key"], + "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3" + ); + assert_eq!(body["client_metadata"]["session_id"], session_id); + assert_eq!(body["client_metadata"]["thread_id"], thread_id); + assert_eq!(body["client_metadata"]["caller"], "sdk"); + assert_eq!( + body["client_metadata"]["x-codex-installation-id"], + installation_id + ); + assert_eq!(body["client_metadata"]["x-codex-window-id"], window_id); + + let header_metadata: Value = + serde_json::from_str(&decision.provider_request_headers["x-codex-turn-metadata"]) + .expect("header turn metadata"); + let body_metadata: Value = serde_json::from_str( + body["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("embedded turn metadata"), + ) + .expect("embedded turn metadata JSON"); + assert_eq!(header_metadata["thread_source"], "cli"); + assert_eq!(body_metadata["sandbox"], "workspace-write"); + assert_eq!( + header_metadata["turn_id"], + body["client_metadata"]["turn_id"] + ); + assert_eq!(body_metadata["turn_id"], body["client_metadata"]["turn_id"]); + assert_eq!( + header_metadata["turn_started_at_unix_ms"], + body_metadata["turn_started_at_unix_ms"] + ); + let turn_id = body["client_metadata"]["turn_id"] + .as_str() + .expect("turn ID") + .to_string(); + assert_eq!( + uuid::Uuid::parse_str(&turn_id) + .expect("turn UUID") + .get_version_num(), + 7 + ); + turn_ids.insert(turn_id); + + let identity = (installation_id, session_id, thread_id); + if let Some(expected) = stable_identity.as_ref() { + assert_eq!(&identity, expected, "identity changed at {exit_name}"); + } else { + stable_identity = Some(identity); + } + } + assert_eq!(turn_ids.len(), 3, "each request needs a fresh turn ID"); + } + #[test] fn provider_request_routing_policy_cannot_restore_credentials_or_aether_internal_headers() { for header_name in [ diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/family/payload.rs b/apps/aether-gateway/src/ai_serving/planner/standard/family/payload.rs index cbb556095..d213404a9 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/family/payload.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/family/payload.rs @@ -377,6 +377,7 @@ mod tests { client_surface: None, gateway_credential_carrier: None, client_session_affinity: None, + original_client_session_id: None, routing_policy: None, routing_trace_seed: None, routing_context: None, diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs index 00c22018e..53106406f 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs @@ -2195,6 +2195,7 @@ mod tests { client_surface: None, gateway_credential_carrier: None, client_session_affinity: None, + original_client_session_id: None, routing_policy: None, routing_trace_seed: None, routing_context: None, diff --git a/apps/aether-gateway/src/handlers/admin/provider/shared/payloads.rs b/apps/aether-gateway/src/handlers/admin/provider/shared/payloads.rs index 83b86e14f..ed7e747a9 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/shared/payloads.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/shared/payloads.rs @@ -151,6 +151,8 @@ pub(crate) struct AdminProviderCreateRequest { #[serde(default)] pub(crate) keep_priority_on_conversion: Option, #[serde(default)] + pub(crate) codex_fingerprint_convergence_enabled: Option, + #[serde(default)] pub(crate) is_active: Option, #[serde(default)] pub(crate) concurrent_limit: Option, @@ -210,6 +212,8 @@ pub(crate) struct AdminProviderUpdateRequest { #[serde(default)] pub(crate) keep_priority_on_conversion: Option, #[serde(default)] + pub(crate) codex_fingerprint_convergence_enabled: Option, + #[serde(default)] pub(crate) is_active: Option, #[serde(default)] pub(crate) concurrent_limit: Option, diff --git a/apps/aether-gateway/src/handlers/admin/provider/summary/value.rs b/apps/aether-gateway/src/handlers/admin/provider/summary/value.rs index 86ffe5f5a..2610e6981 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/summary/value.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/summary/value.rs @@ -218,6 +218,10 @@ pub(crate) fn build_admin_provider_summary_value( "ops_architecture_id": ops_architecture_id, "kiro_simulated_cache_enabled": kiro_simulated_cache_enabled, "codex_cyber_flag_passthrough_enabled": codex_cyber_flag_passthrough_enabled(&provider.provider_type, provider.config.as_ref()), + "codex_fingerprint_convergence_enabled": crate::provider_transport::codex_fingerprint_convergence_enabled( + &provider.provider_type, + provider.config.as_ref(), + ), "ops_quota_alert_enabled": ops_quota_alert_enabled, "created_at": endpoint_timestamp_or_now(provider.created_at_unix_ms, now_unix_secs), "updated_at": endpoint_timestamp_or_now(provider.updated_at_unix_secs, now_unix_secs), diff --git a/apps/aether-gateway/src/handlers/admin/provider/write/provider/create.rs b/apps/aether-gateway/src/handlers/admin/provider/write/provider/create.rs index bc3ba3d4b..cf224af6b 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/write/provider/create.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/write/provider/create.rs @@ -140,6 +140,28 @@ pub(crate) async fn build_admin_create_provider_record( if let Some(value) = normalize_pool_advanced_config(payload.pool_advanced)? { config_map.insert("pool_advanced".to_string(), value); } + if let Some(enabled) = payload.codex_fingerprint_convergence_enabled { + if provider_type != "codex" && enabled { + return Err( + "codex_fingerprint_convergence_enabled 仅适用于 provider_type=codex".to_string(), + ); + } + if provider_type == "codex" { + let codex_config = config_map + .entry(crate::provider_transport::CODEX_FINGERPRINT_CONFIG_NAMESPACE.to_string()) + .or_insert_with(|| json!({})); + let Some(codex_config) = codex_config.as_object_mut() else { + return Err("config.codex 必须是 JSON 对象".to_string()); + }; + codex_config.insert( + crate::provider_transport::CODEX_FINGERPRINT_ENABLED_CONFIG_KEY.to_string(), + json!(enabled), + ); + } + } + if provider_type != "codex" { + remove_codex_fingerprint_config(&mut config_map); + } if let Some(value) = normalize_json_object(payload.failover_rules, "failover_rules")? { config_map.insert("failover_rules".to_string(), value); } @@ -204,3 +226,46 @@ pub(crate) async fn build_admin_create_provider_record( Ok((record, shift_existing_priorities_from)) } + +fn remove_codex_fingerprint_config(config_map: &mut serde_json::Map) { + let namespace = crate::provider_transport::CODEX_FINGERPRINT_CONFIG_NAMESPACE; + let key = crate::provider_transport::CODEX_FINGERPRINT_ENABLED_CONFIG_KEY; + let mut remove_namespace = false; + if let Some(codex_config) = config_map + .get_mut(namespace) + .and_then(|value| value.as_object_mut()) + { + codex_config.remove(key); + remove_namespace = codex_config.is_empty(); + } + if remove_namespace { + config_map.remove(namespace); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + #[test] + fn removing_fingerprint_setting_preserves_other_codex_config() { + let mut config = json!({ + "codex": { + "fingerprint_convergence_enabled": true, + "pass_through_cyber_flag_interrupt": true + }, + "other": {"kept": true} + }) + .as_object() + .expect("config object") + .clone(); + + super::remove_codex_fingerprint_config(&mut config); + + assert_eq!( + config["codex"], + json!({"pass_through_cyber_flag_interrupt": true}) + ); + assert_eq!(config["other"], json!({"kept": true})); + } +} diff --git a/apps/aether-gateway/src/handlers/admin/provider/write/provider/update.rs b/apps/aether-gateway/src/handlers/admin/provider/write/provider/update.rs index 15b23ea05..5a23a2566 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/write/provider/update.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/write/provider/update.rs @@ -244,6 +244,32 @@ pub(crate) async fn build_admin_update_provider_record( } } + if fields.contains("codex_fingerprint_convergence_enabled") { + let Some(enabled) = payload.codex_fingerprint_convergence_enabled else { + return Err("codex_fingerprint_convergence_enabled 必须是布尔值".to_string()); + }; + if target_provider_type != "codex" && enabled { + return Err( + "codex_fingerprint_convergence_enabled 仅适用于 provider_type=codex".to_string(), + ); + } + if target_provider_type == "codex" { + let codex_config = config_map + .entry(crate::provider_transport::CODEX_FINGERPRINT_CONFIG_NAMESPACE.to_string()) + .or_insert_with(|| json!({})); + let Some(codex_config) = codex_config.as_object_mut() else { + return Err("config.codex 必须是 JSON 对象".to_string()); + }; + codex_config.insert( + crate::provider_transport::CODEX_FINGERPRINT_ENABLED_CONFIG_KEY.to_string(), + json!(enabled), + ); + } + } + if target_provider_type != "codex" { + remove_codex_fingerprint_config(&mut config_map); + } + for (field_name, payload_value) in [ ( PROVIDER_MAX_TRANSFER_COUNT_CONFIG_KEY, @@ -322,3 +348,46 @@ pub(crate) async fn build_admin_update_provider_record( .map(|duration| duration.as_secs()); Ok(updated) } + +fn remove_codex_fingerprint_config(config_map: &mut serde_json::Map) { + let namespace = crate::provider_transport::CODEX_FINGERPRINT_CONFIG_NAMESPACE; + let key = crate::provider_transport::CODEX_FINGERPRINT_ENABLED_CONFIG_KEY; + let mut remove_namespace = false; + if let Some(codex_config) = config_map + .get_mut(namespace) + .and_then(|value| value.as_object_mut()) + { + codex_config.remove(key); + remove_namespace = codex_config.is_empty(); + } + if remove_namespace { + config_map.remove(namespace); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + #[test] + fn removing_fingerprint_setting_preserves_other_codex_config() { + let mut config = json!({ + "codex": { + "fingerprint_convergence_enabled": true, + "pass_through_cyber_flag_interrupt": true + }, + "other": {"kept": true} + }) + .as_object() + .expect("config object") + .clone(); + + super::remove_codex_fingerprint_config(&mut config); + + assert_eq!( + config["codex"], + json!({"pass_through_cyber_flag_interrupt": true}) + ); + assert_eq!(config["other"], json!({"kept": true})); + } +} diff --git a/apps/aether-gateway/src/tests/control/admin/providers.rs b/apps/aether-gateway/src/tests/control/admin/providers.rs index 680101a90..60919b9b1 100644 --- a/apps/aether-gateway/src/tests/control/admin/providers.rs +++ b/apps/aether-gateway/src/tests/control/admin/providers.rs @@ -1073,7 +1073,11 @@ async fn gateway_creates_admin_provider_locally_with_trusted_admin_principal() { "max_transfer_count": 12, "max_transfer_timeout_seconds": 90, "request_timeout": aether_contracts::MAX_EXECUTION_REQUEST_TIMEOUT_SECS, - "config": {"chat_pii_redaction": {"enabled": true}}, + "config": { + "chat_pii_redaction": {"enabled": true}, + "codex": {"pass_through_cyber_flag_interrupt": true} + }, + "codex_fingerprint_convergence_enabled": true, "pool_advanced": {}, "failover_rules": {"strategy": "ordered"}, "proxy": {"url": "https://proxy.example"} @@ -1152,6 +1156,17 @@ async fn gateway_creates_admin_provider_locally_with_trusted_admin_principal() { .cloned(), Some(json!({"strategy": "ordered"})) ); + assert_eq!( + created + .config + .as_ref() + .and_then(|value| value.get("codex")) + .cloned(), + Some(json!({ + "fingerprint_convergence_enabled": true, + "pass_through_cyber_flag_interrupt": true + })) + ); let invalid_response = reqwest::Client::new() .post(format!("{gateway_url}/api/admin/providers/")) @@ -1268,7 +1283,9 @@ async fn gateway_updates_fixed_provider_and_reconciles_template_managed_endpoint None, None, None, - None, + Some(json!({ + "codex": {"pass_through_cyber_flag_interrupt": true} + })), ); provider.provider_type = "codex".to_string(); let mut cli_endpoint = sample_endpoint( @@ -1313,7 +1330,8 @@ async fn gateway_updates_fixed_provider_and_reconciles_template_managed_endpoint .header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin") .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") .json(&json!({ - "max_retries": 9 + "max_retries": 9, + "codex_fingerprint_convergence_enabled": true })) .send() .await @@ -1322,6 +1340,28 @@ async fn gateway_updates_fixed_provider_and_reconciles_template_managed_endpoint let status = response.status(); let body = response.text().await.expect("body should read"); assert_eq!(status, StatusCode::OK, "body={body}"); + let payload: serde_json::Value = serde_json::from_str(&body).expect("json body should parse"); + assert_eq!(payload["codex_fingerprint_convergence_enabled"], true); + + let providers = provider_catalog_repository + .list_providers(false) + .await + .expect("providers should list"); + let updated_provider = providers + .iter() + .find(|provider| provider.id == "provider-codex") + .expect("updated provider should exist"); + assert_eq!( + updated_provider + .config + .as_ref() + .and_then(|value| value.get("codex")) + .cloned(), + Some(json!({ + "fingerprint_convergence_enabled": true, + "pass_through_cyber_flag_interrupt": true + })) + ); let endpoints = provider_catalog_repository .list_endpoints_by_provider_ids(&["provider-codex".to_string()]) diff --git a/crates/aether-provider/transport/src/codex_fingerprint.rs b/crates/aether-provider/transport/src/codex_fingerprint.rs new file mode 100644 index 000000000..b1b6acee8 --- /dev/null +++ b/crates/aether-provider/transport/src/codex_fingerprint.rs @@ -0,0 +1,565 @@ +use std::collections::BTreeMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::snapshot::GatewayProviderTransportSnapshot; + +pub const CODEX_FINGERPRINT_CONFIG_NAMESPACE: &str = "codex"; +pub const CODEX_FINGERPRINT_ENABLED_CONFIG_KEY: &str = "fingerprint_convergence_enabled"; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CodexConvergedFingerprint { + installation_id: String, + session_id: String, + thread_id: String, + turn_id: String, + window_id: String, + turn_started_at_unix_ms: u64, +} + +pub fn codex_fingerprint_convergence_enabled( + provider_type: &str, + provider_config: Option<&Value>, +) -> bool { + provider_type.trim().eq_ignore_ascii_case("codex") + && provider_config + .and_then(Value::as_object) + .and_then(|config| config.get(CODEX_FINGERPRINT_CONFIG_NAMESPACE)) + .and_then(Value::as_object) + .and_then(|config| config.get(CODEX_FINGERPRINT_ENABLED_CONFIG_KEY)) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +pub fn apply_codex_oauth_fingerprint_convergence( + transport: &GatewayProviderTransportSnapshot, + provider_api_format: &str, + original_client_session_id: Option<&str>, + provider_request_headers: &mut BTreeMap, + provider_request_body: &mut Value, +) -> bool { + if !transport + .provider + .provider_type + .trim() + .eq_ignore_ascii_case("codex") + || !transport.key.auth_type.trim().eq_ignore_ascii_case("oauth") + || crate::agent_identity::is_codex_agent_identity_transport(transport) + || !aether_ai_formats::is_openai_responses_format(provider_api_format) + || aether_ai_formats::openai_responses_request_operation( + provider_api_format, + provider_request_body, + ) == Some(aether_ai_formats::OPENAI_RESPONSES_OPERATION_COMPACT) + || !codex_fingerprint_convergence_enabled( + transport.provider.provider_type.as_str(), + transport.provider.config.as_ref(), + ) + || !provider_request_body.is_object() + { + return false; + } + + let auth_identity = aether_ai_formats::parse_codex_auth_identity( + transport.key.decrypted_auth_config.as_deref(), + ); + let account_seed = auth_identity + .account_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(transport.key.id.as_str()); + let fingerprint = resolve_converged_fingerprint(account_seed, original_client_session_id); + + apply_converged_headers(provider_request_headers, &fingerprint); + apply_converged_client_metadata(provider_request_body, &fingerprint); + true +} + +fn resolve_converged_fingerprint( + account_seed: &str, + original_client_session_id: Option<&str>, +) -> CodexConvergedFingerprint { + let installation_id = + derive_stable_uuid_v4(&format!("aether:codex-installation-id:v1:{account_seed}")); + let session_id = derive_stable_uuid_v4(&format!("aether:codex-session-id:v1:{account_seed}")); + let original_client_session_id = original_client_session_id + .map(str::trim) + .filter(|value| !value.is_empty()); + let thread_id = original_client_session_id + .map(|client_session_id| { + derive_stable_uuid_v4(&format!( + "aether:codex-thread-id:v1:{account_seed}:{client_session_id}" + )) + }) + .unwrap_or_else(|| session_id.clone()); + let window_id = format!("{thread_id}:0"); + let turn_started_at_unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u64::MAX as u128) as u64) + .unwrap_or(0); + + CodexConvergedFingerprint { + installation_id, + session_id, + thread_id, + turn_id: Uuid::now_v7().to_string(), + window_id, + turn_started_at_unix_ms, + } +} + +fn derive_stable_uuid_v4(seed: &str) -> String { + let digest = Sha256::digest(seed.as_bytes()); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Uuid::from_bytes(bytes).to_string() +} + +fn apply_converged_headers( + headers: &mut BTreeMap, + fingerprint: &CodexConvergedFingerprint, +) { + set_header( + headers, + "x-codex-installation-id", + fingerprint.installation_id.clone(), + ); + set_header(headers, "x-codex-window-id", fingerprint.window_id.clone()); + set_header( + headers, + "x-client-request-id", + fingerprint.thread_id.clone(), + ); + set_header(headers, "session-id", fingerprint.session_id.clone()); + set_header(headers, "session_id", fingerprint.session_id.clone()); + set_header(headers, "thread-id", fingerprint.thread_id.clone()); + rewrite_header_turn_metadata(headers, fingerprint); +} + +fn apply_converged_client_metadata(body: &mut Value, fingerprint: &CodexConvergedFingerprint) { + let Some(body) = body.as_object_mut() else { + return; + }; + let metadata = body + .entry("client_metadata".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if !metadata.is_object() { + *metadata = Value::Object(Map::new()); + } + let Some(metadata) = metadata.as_object_mut() else { + return; + }; + + metadata.insert( + "x-codex-installation-id".to_string(), + Value::String(fingerprint.installation_id.clone()), + ); + metadata.insert( + "session_id".to_string(), + Value::String(fingerprint.session_id.clone()), + ); + metadata.insert( + "thread_id".to_string(), + Value::String(fingerprint.thread_id.clone()), + ); + metadata.insert( + "turn_id".to_string(), + Value::String(fingerprint.turn_id.clone()), + ); + metadata.insert( + "x-codex-window-id".to_string(), + Value::String(fingerprint.window_id.clone()), + ); + rewrite_embedded_turn_metadata(metadata, fingerprint); +} + +fn rewrite_header_turn_metadata( + headers: &mut BTreeMap, + fingerprint: &CodexConvergedFingerprint, +) { + let Some((name, raw)) = find_header(headers, "x-codex-turn-metadata") else { + return; + }; + let Ok(mut metadata) = serde_json::from_str::>(&raw) else { + return; + }; + apply_turn_metadata_fields(&mut metadata, fingerprint); + let Ok(rebuilt) = serde_json::to_string(&metadata) else { + return; + }; + headers.remove(&name); + headers.insert("x-codex-turn-metadata".to_string(), rebuilt); +} + +fn rewrite_embedded_turn_metadata( + metadata: &mut Map, + fingerprint: &CodexConvergedFingerprint, +) { + let Some(raw) = metadata + .get("x-codex-turn-metadata") + .and_then(Value::as_str) + else { + return; + }; + let Ok(mut turn_metadata) = serde_json::from_str::>(raw) else { + return; + }; + apply_turn_metadata_fields(&mut turn_metadata, fingerprint); + let Ok(rebuilt) = serde_json::to_string(&turn_metadata) else { + return; + }; + metadata.insert("x-codex-turn-metadata".to_string(), Value::String(rebuilt)); +} + +fn apply_turn_metadata_fields( + metadata: &mut Map, + fingerprint: &CodexConvergedFingerprint, +) { + metadata.insert( + "installation_id".to_string(), + Value::String(fingerprint.installation_id.clone()), + ); + metadata.insert( + "session_id".to_string(), + Value::String(fingerprint.session_id.clone()), + ); + metadata.insert( + "thread_id".to_string(), + Value::String(fingerprint.thread_id.clone()), + ); + metadata.insert( + "turn_id".to_string(), + Value::String(fingerprint.turn_id.clone()), + ); + metadata.insert( + "window_id".to_string(), + Value::String(fingerprint.window_id.clone()), + ); + metadata.insert( + "turn_started_at_unix_ms".to_string(), + Value::from(fingerprint.turn_started_at_unix_ms), + ); +} + +fn set_header(headers: &mut BTreeMap, name: &str, value: String) { + let matching_names = headers + .keys() + .filter(|candidate| candidate.eq_ignore_ascii_case(name)) + .cloned() + .collect::>(); + for matching_name in matching_names { + headers.remove(&matching_name); + } + headers.insert(name.to_string(), value); +} + +fn find_header(headers: &BTreeMap, name: &str) -> Option<(String, String)> { + headers + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) + .map(|(name, value)| (name.clone(), value.clone())) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::snapshot::{ + GatewayProviderTransportEndpoint, GatewayProviderTransportKey, + GatewayProviderTransportProvider, + }; + + fn sample_transport() -> GatewayProviderTransportSnapshot { + GatewayProviderTransportSnapshot { + provider: GatewayProviderTransportProvider { + id: "provider-1".to_string(), + name: "Codex".to_string(), + provider_type: "codex".to_string(), + website: None, + is_active: true, + keep_priority_on_conversion: false, + enable_format_conversion: true, + concurrent_limit: None, + max_retries: None, + proxy: None, + request_timeout_secs: None, + stream_first_byte_timeout_secs: None, + config: Some(json!({ + "codex": {"fingerprint_convergence_enabled": true}, + "unrelated": {"kept": true} + })), + }, + endpoint: GatewayProviderTransportEndpoint { + id: "endpoint-1".to_string(), + provider_id: "provider-1".to_string(), + api_format: "openai:responses".to_string(), + api_family: None, + endpoint_kind: None, + is_active: true, + base_url: "https://chatgpt.com/backend-api/codex".to_string(), + header_rules: None, + body_rules: None, + max_retries: None, + custom_path: None, + config: None, + format_acceptance_config: None, + proxy: None, + }, + key: GatewayProviderTransportKey { + id: "key-1".to_string(), + provider_id: "provider-1".to_string(), + name: "OAuth".to_string(), + auth_type: "oauth".to_string(), + is_active: true, + api_formats: None, + auth_type_by_format: None, + allow_auth_channel_mismatch_formats: None, + allowed_models: None, + capabilities: None, + rate_multipliers: None, + global_priority_by_format: None, + expires_at_unix_secs: None, + proxy: None, + fingerprint: None, + upstream_metadata: None, + decrypted_api_key: "access-token".to_string(), + decrypted_auth_config: Some(json!({"account_id": "account-1"}).to_string()), + }, + } + } + + #[test] + fn provider_config_switch_is_opt_in_and_codex_only() { + assert!(!codex_fingerprint_convergence_enabled("codex", None)); + assert!(!codex_fingerprint_convergence_enabled( + "codex", + Some(&json!({"codex": {"fingerprint_convergence_enabled": false}})) + )); + assert!(codex_fingerprint_convergence_enabled( + "CODEX", + Some(&json!({"codex": {"fingerprint_convergence_enabled": true}})) + )); + assert!(!codex_fingerprint_convergence_enabled( + "openai", + Some(&json!({"codex": {"fingerprint_convergence_enabled": true}})) + )); + } + + #[test] + fn convergence_rewrites_headers_and_body_with_one_identity_set() { + let transport = sample_transport(); + let mut headers = BTreeMap::from([ + ("Session-Id".to_string(), "client-session".to_string()), + ( + "x-codex-turn-metadata".to_string(), + json!({ + "installation_id": "client-installation", + "session_id": "client-session", + "thread_source": "cli" + }) + .to_string(), + ), + ]); + let mut body = json!({ + "model": "gpt-5.4", + "client_metadata": { + "session_id": "client-session", + "x-codex-turn-metadata": json!({ + "installation_id": "client-installation", + "sandbox": "workspace-write" + }).to_string() + } + }); + + assert!(apply_codex_oauth_fingerprint_convergence( + &transport, + "openai:responses", + Some("client-session"), + &mut headers, + &mut body, + )); + + let session_id = headers.get("session-id").expect("session header"); + let thread_id = headers.get("thread-id").expect("thread header"); + let installation_id = headers + .get("x-codex-installation-id") + .expect("installation header"); + assert_eq!( + Uuid::parse_str(session_id) + .expect("session UUID") + .get_version_num(), + 4 + ); + assert_eq!( + Uuid::parse_str(thread_id) + .expect("thread UUID") + .get_version_num(), + 4 + ); + assert_eq!( + Uuid::parse_str(installation_id) + .expect("installation UUID") + .get_version_num(), + 4 + ); + assert_eq!(headers["session_id"], *session_id); + assert_eq!(headers["x-client-request-id"], *thread_id); + assert_eq!(headers["x-codex-window-id"], format!("{thread_id}:0")); + assert_eq!(body["client_metadata"]["session_id"], *session_id); + assert_eq!(body["client_metadata"]["thread_id"], *thread_id); + assert_eq!( + body["client_metadata"]["x-codex-installation-id"], + *installation_id + ); + + let header_metadata: Value = + serde_json::from_str(&headers["x-codex-turn-metadata"]).expect("header metadata"); + let body_metadata: Value = serde_json::from_str( + body["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("embedded metadata"), + ) + .expect("body metadata"); + assert_eq!( + header_metadata["turn_id"], + body["client_metadata"]["turn_id"] + ); + assert_eq!(body_metadata["turn_id"], body["client_metadata"]["turn_id"]); + assert_eq!(header_metadata["thread_source"], "cli"); + assert_eq!(body_metadata["sandbox"], "workspace-write"); + assert_eq!( + Uuid::parse_str( + body["client_metadata"]["turn_id"] + .as_str() + .expect("turn id") + ) + .expect("turn UUID") + .get_version_num(), + 7 + ); + } + + #[test] + fn stable_account_identity_and_per_client_thread_are_deterministic() { + let first = resolve_converged_fingerprint("account-1", Some("client-a")); + let second = resolve_converged_fingerprint("account-1", Some("client-a")); + let other_client = resolve_converged_fingerprint("account-1", Some("client-b")); + let other_account = resolve_converged_fingerprint("account-2", Some("client-a")); + + assert_eq!(first.installation_id, second.installation_id); + assert_eq!(first.session_id, second.session_id); + assert_eq!(first.thread_id, second.thread_id); + assert_ne!(first.turn_id, second.turn_id); + assert_ne!(first.thread_id, other_client.thread_id); + assert_eq!(first.session_id, other_client.session_id); + assert_ne!(first.installation_id, other_account.installation_id); + } + + #[test] + fn disabled_or_out_of_scope_requests_are_unchanged() { + let mut transport = sample_transport(); + let original_headers = BTreeMap::from([("session-id".to_string(), "client".to_string())]); + let original_body = json!({"model": "gpt-5.4"}); + + transport.provider.config = None; + let mut headers = original_headers.clone(); + let mut body = original_body.clone(); + assert!(!apply_codex_oauth_fingerprint_convergence( + &transport, + "openai:responses", + Some("client"), + &mut headers, + &mut body, + )); + assert_eq!(headers, original_headers); + assert_eq!(body, original_body); + transport.provider.config = Some(json!({ + "codex": {"fingerprint_convergence_enabled": true} + })); + + for api_format in [ + "openai:responses:compact", + "openai:chat", + "openai:search", + "openai:image", + ] { + let mut headers = original_headers.clone(); + let mut body = original_body.clone(); + assert!(!apply_codex_oauth_fingerprint_convergence( + &transport, + api_format, + Some("client"), + &mut headers, + &mut body, + )); + assert_eq!(headers, original_headers); + assert_eq!(body, original_body); + } + + let mut compact_v2_headers = original_headers.clone(); + let mut compact_v2_body = json!({ + "model": "gpt-5.4", + "input": [{"type": "compaction_trigger"}] + }); + let original_compact_v2_body = compact_v2_body.clone(); + assert!(!apply_codex_oauth_fingerprint_convergence( + &transport, + "openai:responses", + Some("client"), + &mut compact_v2_headers, + &mut compact_v2_body, + )); + assert_eq!(compact_v2_headers, original_headers); + assert_eq!(compact_v2_body, original_compact_v2_body); + + for auth_type in ["api_key", "bearer"] { + transport.key.auth_type = auth_type.to_string(); + let mut headers = original_headers.clone(); + let mut body = original_body.clone(); + assert!(!apply_codex_oauth_fingerprint_convergence( + &transport, + "openai:responses", + Some("client"), + &mut headers, + &mut body, + )); + assert_eq!(headers, original_headers); + assert_eq!(body, original_body); + } + } + + #[test] + fn agent_identity_oauth_transport_is_unchanged() { + let mut transport = sample_transport(); + transport.key.decrypted_auth_config = Some( + json!({ + "auth_mode": "agentIdentity", + "agent_identity": { + "agent_runtime_id": "runtime-1", + "agent_private_key": "private-key" + } + }) + .to_string(), + ); + let original_headers = BTreeMap::from([("session-id".to_string(), "client".to_string())]); + let original_body = json!({"model": "gpt-5.4"}); + let mut headers = original_headers.clone(); + let mut body = original_body.clone(); + + assert!(!apply_codex_oauth_fingerprint_convergence( + &transport, + "openai:responses", + Some("client"), + &mut headers, + &mut body, + )); + assert_eq!(headers, original_headers); + assert_eq!(body, original_body); + } +} diff --git a/crates/aether-provider/transport/src/lib.rs b/crates/aether-provider/transport/src/lib.rs index 71e251a26..6f71b5750 100644 --- a/crates/aether-provider/transport/src/lib.rs +++ b/crates/aether-provider/transport/src/lib.rs @@ -5,6 +5,7 @@ pub mod auth; mod auth_config; mod cache; pub mod claude_code; +mod codex_fingerprint; pub mod conversion; mod diagnostics; pub mod gemini_cli; @@ -55,6 +56,10 @@ pub use anthropic_compat::{ 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 codex_fingerprint::{ + apply_codex_oauth_fingerprint_convergence, codex_fingerprint_convergence_enabled, + CODEX_FINGERPRINT_CONFIG_NAMESPACE, CODEX_FINGERPRINT_ENABLED_CONFIG_KEY, +}; pub use conversion::{ candidate_common_transport_skip_reason, candidate_transport_pair_skip_reason, request_conversion_direct_auth, request_conversion_enabled_for_transport, diff --git a/frontend/src/api/endpoints/providers.ts b/frontend/src/api/endpoints/providers.ts index f112b5f87..eb8c590f4 100644 --- a/frontend/src/api/endpoints/providers.ts +++ b/frontend/src/api/endpoints/providers.ts @@ -51,6 +51,7 @@ function normalizeProviderSummary( ...provider, chat_pii_redaction: normalizeChatPiiRedactionProvider(provider.chat_pii_redaction), pool_advanced: normalizePoolAdvanced(provider.pool_advanced), + codex_fingerprint_convergence_enabled: provider.codex_fingerprint_convergence_enabled ?? false, kiro_simulated_cache_enabled: provider.kiro_simulated_cache_enabled ?? false, max_transfer_count: provider.max_transfer_count ?? 0, max_transfer_timeout_seconds: provider.max_transfer_timeout_seconds ?? 0, @@ -130,6 +131,7 @@ export async function updateProvider( enable_format_conversion: boolean // 是否允许格式转换(提供商级别开关) is_active: boolean claude_code_advanced: ClaudeCodeAdvancedConfig | null + codex_fingerprint_convergence_enabled: boolean pool_advanced: PoolAdvancedConfig | null failover_rules: FailoverRulesConfig | null config: ProviderConfig | null @@ -164,6 +166,7 @@ export async function createProvider( request_timeout?: number | null proxy?: ProxyConfig | null claude_code_advanced?: ClaudeCodeAdvancedConfig | null + codex_fingerprint_convergence_enabled?: boolean pool_advanced?: PoolAdvancedConfig | null failover_rules?: FailoverRulesConfig | null config?: ProviderConfig | null diff --git a/frontend/src/api/endpoints/types/provider.ts b/frontend/src/api/endpoints/types/provider.ts index fe1c49ce1..351f87be1 100644 --- a/frontend/src/api/endpoints/types/provider.ts +++ b/frontend/src/api/endpoints/types/provider.ts @@ -180,8 +180,14 @@ export interface ChatPiiRedactionProviderConfig { enabled: boolean } +export interface CodexProviderConfig { + fingerprint_convergence_enabled?: boolean + [key: string]: unknown +} + export interface ProviderConfig { chat_pii_redaction?: ChatPiiRedactionProviderConfig + codex?: CodexProviderConfig pool_advanced?: PoolAdvancedConfig failover_rules?: FailoverRulesConfig claude_code_advanced?: ClaudeCodeAdvancedConfig @@ -900,6 +906,7 @@ export interface ProviderWithEndpointsSummary { failover_rules?: FailoverRulesConfig | null ops_configured: boolean // 是否配置了扩展操作(余额监控等) ops_architecture_id?: string // 扩展操作使用的架构 ID(如 cubence, anyrouter) + codex_fingerprint_convergence_enabled?: boolean kiro_simulated_cache_enabled?: boolean ops_quota_alert_enabled?: boolean created_at: string diff --git a/frontend/src/features/providers/components/ProviderFormDialog.vue b/frontend/src/features/providers/components/ProviderFormDialog.vue index c83275b8e..74edf1799 100644 --- a/frontend/src/features/providers/components/ProviderFormDialog.vue +++ b/frontend/src/features/providers/components/ProviderFormDialog.vue @@ -333,6 +333,30 @@ /> +
+
+ +

+ {{ legacyT('统一同一 OAuth 账号的设备与会话标识;关闭时保持现有透传行为。') }} +

+
+ +
+
{{ legacyT('敏感信息保护') }} @@ -453,6 +477,8 @@ const form = ref({ request_timeout: undefined as number | undefined, // 号池模式 pool_mode_enabled: false, + // Codex 专属配置 + codex_fingerprint_convergence_enabled: false, // Kiro 专属配置 kiro_simulated_cache_enabled: false, }) @@ -483,6 +509,8 @@ function resetForm() { request_timeout: undefined, // 号池模式 pool_mode_enabled: false, + // Codex 专属配置 + codex_fingerprint_convergence_enabled: false, // Kiro 专属配置 kiro_simulated_cache_enabled: false, } @@ -517,6 +545,8 @@ function loadProviderData() { request_timeout: props.provider.request_timeout ?? undefined, // 号池模式 pool_mode_enabled: poolAdvanced !== null, + // Codex 专属配置 + codex_fingerprint_convergence_enabled: props.provider.codex_fingerprint_convergence_enabled ?? false, // Kiro 专属配置 kiro_simulated_cache_enabled: props.provider.kiro_simulated_cache_enabled ?? false, } @@ -540,6 +570,9 @@ watch(() => form.value.provider_type, () => { if (form.value.provider_type !== 'kiro') { form.value.kiro_simulated_cache_enabled = false } + if (form.value.provider_type !== 'codex') { + form.value.codex_fingerprint_convergence_enabled = false + } }) // 提交表单 @@ -586,6 +619,11 @@ const handleSubmit = async () => { pool_advanced: form.value.pool_mode_enabled ? (currentPoolAdvanced ?? {}) : null, + ...(form.value.provider_type === 'codex' + ? { + codex_fingerprint_convergence_enabled: form.value.codex_fingerprint_convergence_enabled, + } + : {}), ...(form.value.provider_type === 'kiro' ? { config: { diff --git a/frontend/src/features/providers/components/__tests__/ProviderFormDialog.transfer-limits.spec.ts b/frontend/src/features/providers/components/__tests__/ProviderFormDialog.transfer-limits.spec.ts index f144040d2..062759b90 100644 --- a/frontend/src/features/providers/components/__tests__/ProviderFormDialog.transfer-limits.spec.ts +++ b/frontend/src/features/providers/components/__tests__/ProviderFormDialog.transfer-limits.spec.ts @@ -256,4 +256,93 @@ describe('ProviderFormDialog provider types', () => { }), ) }) + + it('shows fingerprint convergence only for Codex and submits the enabled setting', async () => { + mountDialog(null) + await settle() + + expect(document.body.querySelector('[data-testid="codex-fingerprint-convergence-setting"]')).toBeNull() + + const providerTypeSelect = [...document.body.querySelectorAll('select')] + .find(select => select.querySelector('option[value="codex"]')) + if (!providerTypeSelect) throw new Error('Missing provider type select') + + providerTypeSelect.value = 'codex' + providerTypeSelect.dispatchEvent(new Event('change', { bubbles: true })) + await nextTick() + + const convergenceSwitch = document.body.querySelector( + '#codex-fingerprint-convergence', + ) + expect(convergenceSwitch).not.toBeNull() + expect(convergenceSwitch?.getAttribute('aria-checked')).toBe('false') + + convergenceSwitch?.click() + await nextTick() + expect(convergenceSwitch?.getAttribute('aria-checked')).toBe('true') + + await setInput('#name', 'Codex Provider') + clickButton('创建') + await settle() + + expect(endpointMocks.createProvider).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Codex Provider', + provider_type: 'codex', + codex_fingerprint_convergence_enabled: true, + }), + ) + }) + + it('loads and can disable fingerprint convergence for an existing Codex provider', async () => { + mountDialog(makeProvider({ + provider_type: 'codex', + codex_fingerprint_convergence_enabled: true, + })) + await settle() + + const convergenceSwitch = document.body.querySelector( + '#codex-fingerprint-convergence', + ) + expect(convergenceSwitch?.getAttribute('aria-checked')).toBe('true') + + convergenceSwitch?.click() + clickButton('保存') + await settle() + + expect(endpointMocks.updateProvider).toHaveBeenCalledWith( + 'provider-1', + expect.objectContaining({ + provider_type: 'codex', + codex_fingerprint_convergence_enabled: false, + }), + ) + }) + + it('clears and does not submit the setting after switching away from Codex', async () => { + mountDialog(null) + await settle() + + const providerTypeSelect = [...document.body.querySelectorAll('select')] + .find(select => select.querySelector('option[value="codex"]')) + if (!providerTypeSelect) throw new Error('Missing provider type select') + + providerTypeSelect.value = 'codex' + providerTypeSelect.dispatchEvent(new Event('change', { bubbles: true })) + await nextTick() + document.body.querySelector('#codex-fingerprint-convergence')?.click() + await nextTick() + + providerTypeSelect.value = 'custom' + providerTypeSelect.dispatchEvent(new Event('change', { bubbles: true })) + await nextTick() + expect(document.body.querySelector('[data-testid="codex-fingerprint-convergence-setting"]')).toBeNull() + + await setInput('#name', 'Custom Provider') + clickButton('创建') + await settle() + + const payload = endpointMocks.createProvider.mock.calls[0]?.[0] + expect(payload).not.toHaveProperty('codex_fingerprint_convergence_enabled') + }) }) diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index b5b414f15..20533bcae 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -1440,6 +1440,8 @@ const legacyExactEnglishMessages: Record = { '启用后该提供商的密钥将由号池统一调度': 'When enabled, this provider’s keys are scheduled by the pool.', '模拟缓存模式': 'Simulated cache mode', '启用后仅对 Kiro 请求模拟 prompt cache 读写计量。': 'When enabled, only Kiro requests simulate prompt cache read/write accounting.', + 'Codex OAuth 指纹收敛': 'Codex OAuth fingerprint convergence', + '统一同一 OAuth 账号的设备与会话标识;关闭时保持现有透传行为。': 'Use consistent device and session identifiers for the same OAuth account. When disabled, preserve the existing pass-through behavior.', '请前往模块管理-敏感信息保护中配置详细规则。': 'Configure detailed rules in Modules - Sensitive information protection.', '验证失败': 'Validation failed', '月卡类型必须设置周期开始时间': 'Monthly quota billing requires a cycle start time',