feat(codex): add OAuth fingerprint convergence

This commit is contained in:
elky
2026-08-13 09:57:17 +08:00
parent 654c4f6978
commit 8cf381b0c3
16 changed files with 1111 additions and 4 deletions
+1 -1
View File
@@ -135,7 +135,7 @@ tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal
tokio-util = { version = "0.7", features = ["codec", "io-util"] } tokio-util = { version = "0.7", features = ["codec", "io-util"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } 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" webpki-roots = "0.26"
wreq = { version = "6.0.0-rc.28", default-features = false, features = ["json", "stream", "socks", "webpki-roots", "ws"] } wreq = { version = "6.0.0-rc.28", default-features = false, features = ["json", "stream", "socks", "webpki-roots", "ws"] }
wreq-util = "3.0.0-rc.10" wreq-util = "3.0.0-rc.10"
@@ -55,6 +55,7 @@ pub(crate) struct LocalRequestedModelDecisionInput {
pub(crate) client_surface: Option<ClientSurface>, pub(crate) client_surface: Option<ClientSurface>,
pub(crate) gateway_credential_carrier: Option<GatewayCredentialCarrier>, pub(crate) gateway_credential_carrier: Option<GatewayCredentialCarrier>,
pub(crate) client_session_affinity: Option<ClientSessionAffinity>, pub(crate) client_session_affinity: Option<ClientSessionAffinity>,
pub(crate) original_client_session_id: Option<String>,
pub(crate) routing_policy: Option<ResolvedRoutingPolicy>, pub(crate) routing_policy: Option<ResolvedRoutingPolicy>,
pub(crate) routing_trace_seed: Option<RoutingDecisionTrace>, pub(crate) routing_trace_seed: Option<RoutingDecisionTrace>,
pub(crate) routing_context: Option<LocalRoutingRequestContext>, pub(crate) routing_context: Option<LocalRoutingRequestContext>,
@@ -150,6 +151,12 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision(
provider_api_format.as_str(), provider_api_format.as_str(),
); );
} }
apply_codex_oauth_fingerprint_convergence_to_decision(
input,
decision,
transport,
provider_api_format.as_str(),
);
return Ok(()); return Ok(());
}; };
let provider_body_rules = decision let provider_body_rules = decision
@@ -207,6 +214,12 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision(
provider_api_format.as_str(), provider_api_format.as_str(),
); );
} }
apply_codex_oauth_fingerprint_convergence_to_decision(
input,
decision,
transport,
provider_api_format.as_str(),
);
return Ok(()); return Ok(());
} }
if original_provider_request_body.is_none() && !policy.mutation_plan.body_patch.is_empty() { 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() { if original_provider_request_body.is_some() {
decision.provider_request_body = Some(provider_request_body); 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); update_report_context_provider_request_mutation(decision, &policy);
Ok(()) 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> { struct GatewayAuthenticatedDecisionInputPort<'a> {
state: PlannerAppState<'a>, state: PlannerAppState<'a>,
now_unix_secs: u64, now_unix_secs: u64,
@@ -383,6 +422,7 @@ pub(crate) fn build_local_requested_model_decision_input(
client_surface: None, client_surface: None,
gateway_credential_carrier: None, gateway_credential_carrier: None,
client_session_affinity: None, client_session_affinity: None,
original_client_session_id: None,
routing_policy: None, routing_policy: None,
routing_trace_seed: None, routing_trace_seed: None,
routing_context: None, routing_context: None,
@@ -397,6 +437,8 @@ pub(crate) async fn attach_routing_policy_to_local_requested_model_input(
body_json: &Value, body_json: &Value,
client_api_format: &str, client_api_format: &str,
) -> Result<(), GatewayError> { ) -> 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 explicit_group = routing_header_value_str(&parts.headers, ROUTING_GROUP_HEADER);
let selected_group = match state.routing_group_read_repository() { let selected_group = match state.routing_group_read_repository() {
Some(repository) => { Some(repository) => {
@@ -1154,6 +1196,7 @@ mod tests {
client_surface: None, client_surface: None,
gateway_credential_carrier: None, gateway_credential_carrier: None,
client_session_affinity: None, client_session_affinity: None,
original_client_session_id: None,
routing_policy: None, routing_policy: None,
routing_trace_seed: None, routing_trace_seed: None,
model_directive_policy: Default::default(), 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( fn set_provider_request_rules(
input: &mut LocalRequestedModelDecisionInput, input: &mut LocalRequestedModelDecisionInput,
allowed_models: &[&str], allowed_models: &[&str],
@@ -1351,6 +1445,7 @@ mod tests {
client_surface: None, client_surface: None,
gateway_credential_carrier: None, gateway_credential_carrier: None,
client_session_affinity: None, client_session_affinity: None,
original_client_session_id: None,
routing_policy: None, routing_policy: None,
routing_trace_seed: None, routing_trace_seed: None,
model_directive_policy: Default::default(), model_directive_policy: Default::default(),
@@ -1420,6 +1515,7 @@ mod tests {
client_surface: None, client_surface: None,
gateway_credential_carrier: None, gateway_credential_carrier: None,
client_session_affinity: None, client_session_affinity: None,
original_client_session_id: None,
routing_policy: None, routing_policy: None,
routing_trace_seed: None, routing_trace_seed: None,
routing_context: 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] #[test]
fn provider_request_routing_policy_cannot_restore_credentials_or_aether_internal_headers() { fn provider_request_routing_policy_cannot_restore_credentials_or_aether_internal_headers() {
for header_name in [ for header_name in [
@@ -377,6 +377,7 @@ mod tests {
client_surface: None, client_surface: None,
gateway_credential_carrier: None, gateway_credential_carrier: None,
client_session_affinity: None, client_session_affinity: None,
original_client_session_id: None,
routing_policy: None, routing_policy: None,
routing_trace_seed: None, routing_trace_seed: None,
routing_context: None, routing_context: None,
@@ -2195,6 +2195,7 @@ mod tests {
client_surface: None, client_surface: None,
gateway_credential_carrier: None, gateway_credential_carrier: None,
client_session_affinity: None, client_session_affinity: None,
original_client_session_id: None,
routing_policy: None, routing_policy: None,
routing_trace_seed: None, routing_trace_seed: None,
routing_context: None, routing_context: None,
@@ -151,6 +151,8 @@ pub(crate) struct AdminProviderCreateRequest {
#[serde(default)] #[serde(default)]
pub(crate) keep_priority_on_conversion: Option<bool>, pub(crate) keep_priority_on_conversion: Option<bool>,
#[serde(default)] #[serde(default)]
pub(crate) codex_fingerprint_convergence_enabled: Option<bool>,
#[serde(default)]
pub(crate) is_active: Option<bool>, pub(crate) is_active: Option<bool>,
#[serde(default)] #[serde(default)]
pub(crate) concurrent_limit: Option<i32>, pub(crate) concurrent_limit: Option<i32>,
@@ -210,6 +212,8 @@ pub(crate) struct AdminProviderUpdateRequest {
#[serde(default)] #[serde(default)]
pub(crate) keep_priority_on_conversion: Option<bool>, pub(crate) keep_priority_on_conversion: Option<bool>,
#[serde(default)] #[serde(default)]
pub(crate) codex_fingerprint_convergence_enabled: Option<bool>,
#[serde(default)]
pub(crate) is_active: Option<bool>, pub(crate) is_active: Option<bool>,
#[serde(default)] #[serde(default)]
pub(crate) concurrent_limit: Option<i32>, pub(crate) concurrent_limit: Option<i32>,
@@ -218,6 +218,10 @@ pub(crate) fn build_admin_provider_summary_value(
"ops_architecture_id": ops_architecture_id, "ops_architecture_id": ops_architecture_id,
"kiro_simulated_cache_enabled": kiro_simulated_cache_enabled, "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_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, "ops_quota_alert_enabled": ops_quota_alert_enabled,
"created_at": endpoint_timestamp_or_now(provider.created_at_unix_ms, now_unix_secs), "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), "updated_at": endpoint_timestamp_or_now(provider.updated_at_unix_secs, now_unix_secs),
@@ -140,6 +140,28 @@ pub(crate) async fn build_admin_create_provider_record(
if let Some(value) = normalize_pool_advanced_config(payload.pool_advanced)? { if let Some(value) = normalize_pool_advanced_config(payload.pool_advanced)? {
config_map.insert("pool_advanced".to_string(), value); 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")? { if let Some(value) = normalize_json_object(payload.failover_rules, "failover_rules")? {
config_map.insert("failover_rules".to_string(), value); 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)) Ok((record, shift_existing_priorities_from))
} }
fn remove_codex_fingerprint_config(config_map: &mut serde_json::Map<String, serde_json::Value>) {
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}));
}
}
@@ -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 [ for (field_name, payload_value) in [
( (
PROVIDER_MAX_TRANSFER_COUNT_CONFIG_KEY, PROVIDER_MAX_TRANSFER_COUNT_CONFIG_KEY,
@@ -322,3 +348,46 @@ pub(crate) async fn build_admin_update_provider_record(
.map(|duration| duration.as_secs()); .map(|duration| duration.as_secs());
Ok(updated) Ok(updated)
} }
fn remove_codex_fingerprint_config(config_map: &mut serde_json::Map<String, serde_json::Value>) {
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}));
}
}
@@ -1073,7 +1073,11 @@ async fn gateway_creates_admin_provider_locally_with_trusted_admin_principal() {
"max_transfer_count": 12, "max_transfer_count": 12,
"max_transfer_timeout_seconds": 90, "max_transfer_timeout_seconds": 90,
"request_timeout": aether_contracts::MAX_EXECUTION_REQUEST_TIMEOUT_SECS, "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": {}, "pool_advanced": {},
"failover_rules": {"strategy": "ordered"}, "failover_rules": {"strategy": "ordered"},
"proxy": {"url": "https://proxy.example"} "proxy": {"url": "https://proxy.example"}
@@ -1152,6 +1156,17 @@ async fn gateway_creates_admin_provider_locally_with_trusted_admin_principal() {
.cloned(), .cloned(),
Some(json!({"strategy": "ordered"})) 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() let invalid_response = reqwest::Client::new()
.post(format!("{gateway_url}/api/admin/providers/")) .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,
None, None,
None, Some(json!({
"codex": {"pass_through_cyber_flag_interrupt": true}
})),
); );
provider.provider_type = "codex".to_string(); provider.provider_type = "codex".to_string();
let mut cli_endpoint = sample_endpoint( 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_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({ .json(&json!({
"max_retries": 9 "max_retries": 9,
"codex_fingerprint_convergence_enabled": true
})) }))
.send() .send()
.await .await
@@ -1322,6 +1340,28 @@ async fn gateway_updates_fixed_provider_and_reconciles_template_managed_endpoint
let status = response.status(); let status = response.status();
let body = response.text().await.expect("body should read"); let body = response.text().await.expect("body should read");
assert_eq!(status, StatusCode::OK, "body={body}"); 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 let endpoints = provider_catalog_repository
.list_endpoints_by_provider_ids(&["provider-codex".to_string()]) .list_endpoints_by_provider_ids(&["provider-codex".to_string()])
@@ -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<String, String>,
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<String, String>,
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<String, String>,
fingerprint: &CodexConvergedFingerprint,
) {
let Some((name, raw)) = find_header(headers, "x-codex-turn-metadata") else {
return;
};
let Ok(mut metadata) = serde_json::from_str::<Map<String, Value>>(&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<String, Value>,
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::<Map<String, Value>>(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<String, Value>,
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<String, String>, name: &str, value: String) {
let matching_names = headers
.keys()
.filter(|candidate| candidate.eq_ignore_ascii_case(name))
.cloned()
.collect::<Vec<_>>();
for matching_name in matching_names {
headers.remove(&matching_name);
}
headers.insert(name.to_string(), value);
}
fn find_header(headers: &BTreeMap<String, String>, 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);
}
}
@@ -5,6 +5,7 @@ pub mod auth;
mod auth_config; mod auth_config;
mod cache; mod cache;
pub mod claude_code; pub mod claude_code;
mod codex_fingerprint;
pub mod conversion; pub mod conversion;
mod diagnostics; mod diagnostics;
pub mod gemini_cli; 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::{build_passthrough_headers, ensure_upstream_auth_header};
pub use auth_config::apply_local_auth_config_header_overrides; 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 codex_fingerprint::{
apply_codex_oauth_fingerprint_convergence, codex_fingerprint_convergence_enabled,
CODEX_FINGERPRINT_CONFIG_NAMESPACE, CODEX_FINGERPRINT_ENABLED_CONFIG_KEY,
};
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,
request_conversion_direct_auth, request_conversion_enabled_for_transport, request_conversion_direct_auth, request_conversion_enabled_for_transport,
+3
View File
@@ -51,6 +51,7 @@ function normalizeProviderSummary(
...provider, ...provider,
chat_pii_redaction: normalizeChatPiiRedactionProvider(provider.chat_pii_redaction), chat_pii_redaction: normalizeChatPiiRedactionProvider(provider.chat_pii_redaction),
pool_advanced: normalizePoolAdvanced(provider.pool_advanced), 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, kiro_simulated_cache_enabled: provider.kiro_simulated_cache_enabled ?? false,
max_transfer_count: provider.max_transfer_count ?? 0, max_transfer_count: provider.max_transfer_count ?? 0,
max_transfer_timeout_seconds: provider.max_transfer_timeout_seconds ?? 0, max_transfer_timeout_seconds: provider.max_transfer_timeout_seconds ?? 0,
@@ -130,6 +131,7 @@ export async function updateProvider(
enable_format_conversion: boolean // 是否允许格式转换(提供商级别开关) enable_format_conversion: boolean // 是否允许格式转换(提供商级别开关)
is_active: boolean is_active: boolean
claude_code_advanced: ClaudeCodeAdvancedConfig | null claude_code_advanced: ClaudeCodeAdvancedConfig | null
codex_fingerprint_convergence_enabled: boolean
pool_advanced: PoolAdvancedConfig | null pool_advanced: PoolAdvancedConfig | null
failover_rules: FailoverRulesConfig | null failover_rules: FailoverRulesConfig | null
config: ProviderConfig | null config: ProviderConfig | null
@@ -164,6 +166,7 @@ export async function createProvider(
request_timeout?: number | null request_timeout?: number | null
proxy?: ProxyConfig | null proxy?: ProxyConfig | null
claude_code_advanced?: ClaudeCodeAdvancedConfig | null claude_code_advanced?: ClaudeCodeAdvancedConfig | null
codex_fingerprint_convergence_enabled?: boolean
pool_advanced?: PoolAdvancedConfig | null pool_advanced?: PoolAdvancedConfig | null
failover_rules?: FailoverRulesConfig | null failover_rules?: FailoverRulesConfig | null
config?: ProviderConfig | null config?: ProviderConfig | null
@@ -180,8 +180,14 @@ export interface ChatPiiRedactionProviderConfig {
enabled: boolean enabled: boolean
} }
export interface CodexProviderConfig {
fingerprint_convergence_enabled?: boolean
[key: string]: unknown
}
export interface ProviderConfig { export interface ProviderConfig {
chat_pii_redaction?: ChatPiiRedactionProviderConfig chat_pii_redaction?: ChatPiiRedactionProviderConfig
codex?: CodexProviderConfig
pool_advanced?: PoolAdvancedConfig pool_advanced?: PoolAdvancedConfig
failover_rules?: FailoverRulesConfig failover_rules?: FailoverRulesConfig
claude_code_advanced?: ClaudeCodeAdvancedConfig claude_code_advanced?: ClaudeCodeAdvancedConfig
@@ -900,6 +906,7 @@ export interface ProviderWithEndpointsSummary {
failover_rules?: FailoverRulesConfig | null failover_rules?: FailoverRulesConfig | null
ops_configured: boolean // 是否配置了扩展操作(余额监控等) ops_configured: boolean // 是否配置了扩展操作(余额监控等)
ops_architecture_id?: string // 扩展操作使用的架构 ID(如 cubence, anyrouter) ops_architecture_id?: string // 扩展操作使用的架构 ID(如 cubence, anyrouter)
codex_fingerprint_convergence_enabled?: boolean
kiro_simulated_cache_enabled?: boolean kiro_simulated_cache_enabled?: boolean
ops_quota_alert_enabled?: boolean ops_quota_alert_enabled?: boolean
created_at: string created_at: string
@@ -333,6 +333,30 @@
/> />
</div> </div>
<div
v-if="form.provider_type === 'codex'"
class="flex items-center justify-between gap-4 p-3 border rounded-lg bg-muted/50"
data-testid="codex-fingerprint-convergence-setting"
>
<div class="space-y-0.5">
<Label
for="codex-fingerprint-convergence"
class="text-sm font-medium"
>
{{ legacyT('Codex OAuth 指纹收敛') }}
</Label>
<p class="text-xs text-muted-foreground leading-relaxed">
{{ legacyT('统一同一 OAuth 账号的设备与会话标识;关闭时保持现有透传行为。') }}
</p>
</div>
<Switch
id="codex-fingerprint-convergence"
:model-value="form.codex_fingerprint_convergence_enabled"
:aria-label="legacyT('Codex OAuth 指纹收敛')"
@update:model-value="(v: boolean) => form.codex_fingerprint_convergence_enabled = v"
/>
</div>
<div class="flex items-center justify-between gap-4 p-3 border rounded-lg bg-muted/50"> <div class="flex items-center justify-between gap-4 p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5"> <div class="space-y-0.5">
<span class="text-sm font-medium">{{ legacyT('敏感信息保护') }}</span> <span class="text-sm font-medium">{{ legacyT('敏感信息保护') }}</span>
@@ -453,6 +477,8 @@ const form = ref({
request_timeout: undefined as number | undefined, request_timeout: undefined as number | undefined,
// 号池模式 // 号池模式
pool_mode_enabled: false, pool_mode_enabled: false,
// Codex 专属配置
codex_fingerprint_convergence_enabled: false,
// Kiro 专属配置 // Kiro 专属配置
kiro_simulated_cache_enabled: false, kiro_simulated_cache_enabled: false,
}) })
@@ -483,6 +509,8 @@ function resetForm() {
request_timeout: undefined, request_timeout: undefined,
// 号池模式 // 号池模式
pool_mode_enabled: false, pool_mode_enabled: false,
// Codex 专属配置
codex_fingerprint_convergence_enabled: false,
// Kiro 专属配置 // Kiro 专属配置
kiro_simulated_cache_enabled: false, kiro_simulated_cache_enabled: false,
} }
@@ -517,6 +545,8 @@ function loadProviderData() {
request_timeout: props.provider.request_timeout ?? undefined, request_timeout: props.provider.request_timeout ?? undefined,
// 号池模式 // 号池模式
pool_mode_enabled: poolAdvanced !== null, pool_mode_enabled: poolAdvanced !== null,
// Codex 专属配置
codex_fingerprint_convergence_enabled: props.provider.codex_fingerprint_convergence_enabled ?? false,
// Kiro 专属配置 // Kiro 专属配置
kiro_simulated_cache_enabled: props.provider.kiro_simulated_cache_enabled ?? false, 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') { if (form.value.provider_type !== 'kiro') {
form.value.kiro_simulated_cache_enabled = false 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 pool_advanced: form.value.pool_mode_enabled
? (currentPoolAdvanced ?? {}) ? (currentPoolAdvanced ?? {})
: null, : null,
...(form.value.provider_type === 'codex'
? {
codex_fingerprint_convergence_enabled: form.value.codex_fingerprint_convergence_enabled,
}
: {}),
...(form.value.provider_type === 'kiro' ...(form.value.provider_type === 'kiro'
? { ? {
config: { config: {
@@ -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<HTMLSelectElement>('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<HTMLButtonElement>(
'#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<HTMLButtonElement>(
'#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<HTMLSelectElement>('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<HTMLButtonElement>('#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')
})
}) })
+2
View File
@@ -1440,6 +1440,8 @@ const legacyExactEnglishMessages: Record<string, string> = {
'启用后该提供商的密钥将由号池统一调度': 'When enabled, this provider’s keys are scheduled by the pool.', '启用后该提供商的密钥将由号池统一调度': 'When enabled, this provider’s keys are scheduled by the pool.',
'模拟缓存模式': 'Simulated cache mode', '模拟缓存模式': 'Simulated cache mode',
'启用后仅对 Kiro 请求模拟 prompt cache 读写计量。': 'When enabled, only Kiro requests simulate prompt cache read/write accounting.', '启用后仅对 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.', '请前往模块管理-敏感信息保护中配置详细规则。': 'Configure detailed rules in Modules - Sensitive information protection.',
'验证失败': 'Validation failed', '验证失败': 'Validation failed',
'月卡类型必须设置周期开始时间': 'Monthly quota billing requires a cycle start time', '月卡类型必须设置周期开始时间': 'Monthly quota billing requires a cycle start time',