mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
feat(codex): add OAuth fingerprint convergence
This commit is contained in:
+1
-1
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
+89
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user