diff --git a/Cargo.lock b/Cargo.lock index 5d6742d56..6cf6705f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,7 +55,7 @@ dependencies = [ "aether-provider-pool", "aether-provider-transport", "axum", - "base64", + "base64 0.22.1", "chrono", "chrono-tz", "http", @@ -83,8 +83,9 @@ name = "aether-ai-formats" version = "0.1.0" dependencies = [ "aether-contracts", - "base64", + "base64 0.22.1", "http", + "os_info", "regex", "serde", "serde_json", @@ -104,7 +105,7 @@ dependencies = [ "aether-pool-core", "aether-scheduler-core", "async-trait", - "base64", + "base64 0.22.1", "http", "serde", "serde_json", @@ -135,7 +136,7 @@ name = "aether-contracts" version = "0.1.0" dependencies = [ "aes-gcm", - "base64", + "base64 0.22.1", "bytes", "flate2", "hmac", @@ -152,7 +153,7 @@ version = "0.1.0" dependencies = [ "aes", "aws-lc-rs", - "base64", + "base64 0.22.1", "cbc", "hmac", "pbkdf2", @@ -194,7 +195,7 @@ dependencies = [ "aether-contracts", "aether-routing-core", "async-trait", - "base64", + "base64 0.22.1", "bcrypt", "chrono", "chrono-tz", @@ -296,7 +297,7 @@ dependencies = [ "async-trait", "aws-lc-rs", "axum", - "base64", + "base64 0.22.1", "bcrypt", "brotli", "bytes", @@ -391,7 +392,7 @@ version = "0.1.0" dependencies = [ "aether-admission-core", "aether-contracts", - "base64", + "base64 0.22.1", "bytes", "http", "serde", @@ -479,7 +480,7 @@ dependencies = [ "aether-scheduler-core", "async-trait", "aws-lc-rs", - "base64", + "base64 0.22.1", "regex", "serde_json", "tokio", @@ -493,7 +494,7 @@ version = "0.1.0" dependencies = [ "aether-contracts", "async-trait", - "base64", + "base64 0.22.1", "http", "reqwest 0.12.28", "serde", @@ -550,7 +551,7 @@ dependencies = [ "async-trait", "aws-lc-rs", "axum", - "base64", + "base64 0.22.1", "chrono", "crypto_box", "ed25519-dalek", @@ -684,7 +685,7 @@ dependencies = [ "anyhow", "arc-swap", "axum", - "base64", + "base64 0.22.1", "bytes", "clap", "crossterm 0.28.1", @@ -735,7 +736,7 @@ dependencies = [ "aether-data-contracts", "aether-runtime-state", "async-trait", - "base64", + "base64 0.22.1", "futures-util", "serde", "serde_json", @@ -852,7 +853,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -863,7 +864,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1033,7 +1034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -1096,6 +1097,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -1108,7 +1115,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b1866ecef4f2d06a0bb77880015fdf2b89e25a1c2e5addacb87e459c86dc67e" dependencies = [ - "base64", + "base64 0.22.1", "blowfish", "getrandom 0.2.17", "subtle", @@ -1984,7 +1991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2581,7 +2588,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2592,7 +2599,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-layer", "tower-service", @@ -2739,12 +2746,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -3227,7 +3234,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3320,7 +3327,7 @@ checksum = "d354792e39fa5f0009e47623cf8b15b099bf9a652fa55c6f817fe28ac84fea50" dependencies = [ "async-trait", "aws-lc-rs", - "base64", + "base64 0.22.1", "bytes", "chrono", "crc-fast", @@ -3336,7 +3343,7 @@ dependencies = [ "md-5 0.11.0", "parking_lot", "percent-encoding", - "quick-xml", + "quick-xml 0.41.0", "rand 0.10.2", "reqwest 0.13.4", "rustls-pki-types", @@ -3404,6 +3411,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "os_info" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3" +dependencies = [ + "log", + "plist", + "windows-sys 0.52.0", +] + [[package]] name = "palette" version = "0.7.7" @@ -3649,6 +3667,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -3731,6 +3762,15 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -3744,7 +3784,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -3782,7 +3822,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -4081,7 +4121,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -4123,7 +4163,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -4237,7 +4277,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4296,7 +4336,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4622,7 +4662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4669,7 +4709,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bigdecimal", "bytes", "chrono", @@ -4746,7 +4786,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", "bitflags 2.13.1", "byteorder", @@ -4790,7 +4830,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", "bitflags 2.13.1", "byteorder", @@ -4981,7 +5021,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5012,7 +5052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.13.1", "fancy-regex", "filedescriptor", @@ -6009,7 +6049,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/apps/aether-gateway/.aether-windsurf-binary-test-03d740bc-64f1-49cb-b13d-4e5234e5de31/language-server b/apps/aether-gateway/.aether-windsurf-binary-test-03d740bc-64f1-49cb-b13d-4e5234e5de31/language-server deleted file mode 100755 index f8b18e900..000000000 --- a/apps/aether-gateway/.aether-windsurf-binary-test-03d740bc-64f1-49cb-b13d-4e5234e5de31/language-server +++ /dev/null @@ -1 +0,0 @@ -test binary \ No newline at end of file diff --git a/apps/aether-gateway/src/ai_serving/planner/common.rs b/apps/aether-gateway/src/ai_serving/planner/common.rs index 9bcbdf7b8..d568b1204 100644 --- a/apps/aether-gateway/src/ai_serving/planner/common.rs +++ b/apps/aether-gateway/src/ai_serving/planner/common.rs @@ -19,12 +19,12 @@ pub(crate) use crate::ai_serving::{ GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, - OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, - OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND, - OPENAI_RESPONSES_SYNC_PLAN_KIND, OPENAI_SEARCH_SYNC_PLAN_KIND, - OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND, - OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, - OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND, + OPENAI_MEMORIES_SYNC_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND, + OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, + OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND, + OPENAI_SEARCH_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, + OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, + OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND, }; pub(crate) use aether_ai_serving::AiRequestedModelFamily as RequestedModelFamily; diff --git a/apps/aether-gateway/src/ai_serving/planner/decision/control_plan.rs b/apps/aether-gateway/src/ai_serving/planner/decision/control_plan.rs index a101dfdf5..86259727b 100644 --- a/apps/aether-gateway/src/ai_serving/planner/decision/control_plan.rs +++ b/apps/aether-gateway/src/ai_serving/planner/decision/control_plan.rs @@ -6,12 +6,13 @@ use crate::ai_serving::planner::common::{ GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND, - OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND, - OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, - OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND, - OPENAI_SEARCH_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, - OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, - OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND, + OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_MEMORIES_SYNC_PLAN_KIND, + OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, + OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND, + OPENAI_RESPONSES_SYNC_PLAN_KIND, OPENAI_SEARCH_SYNC_PLAN_KIND, + OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND, + OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, + OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND, }; use crate::ai_serving::planner::plan_builders::{ build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision, @@ -101,7 +102,9 @@ fn build_sync_plan_payload_from_decision( OPENAI_RESPONSES_SYNC_PLAN_KIND => { build_openai_responses_sync_plan_from_decision(parts, body_json, payload, false)? } - OPENAI_IMAGE_SYNC_PLAN_KIND | OPENAI_SEARCH_SYNC_PLAN_KIND => { + OPENAI_IMAGE_SYNC_PLAN_KIND + | OPENAI_SEARCH_SYNC_PLAN_KIND + | OPENAI_MEMORIES_SYNC_PLAN_KIND => { build_passthrough_sync_plan_from_decision(parts, payload)? } OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND => { diff --git a/apps/aether-gateway/src/ai_serving/planner/decision_input.rs b/apps/aether-gateway/src/ai_serving/planner/decision_input.rs index efa915eb0..2bcf078ba 100644 --- a/apps/aether-gateway/src/ai_serving/planner/decision_input.rs +++ b/apps/aether-gateway/src/ai_serving/planner/decision_input.rs @@ -17,7 +17,7 @@ use crate::ai_serving::transport::ProviderOutboundRequestContext; use crate::ai_serving::{ ClientSurface, ExecutionRuntimeAuthContext, GatewayAuthApiKeySnapshot, GatewayCredentialCarrier, GatewayProviderTransportSnapshot, PlannerAppState, - CODEX_RESPONSES_LITE_HEADER, + CODEX_RESPONSES_LITE_HEADER, OPENAI_MEMORIES_SYNC_PLAN_KIND, }; use crate::cache::CacheLoadObserver; use crate::client_session_affinity::client_session_affinity_from_api_request; @@ -123,6 +123,7 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision_with_websocket_m transport: Option<&GatewayProviderTransportSnapshot>, websocket_continuation: bool, ) -> Result<(), GatewayError> { + let native_memories = decision.decision_kind.as_deref() == Some(OPENAI_MEMORIES_SYNC_PLAN_KIND); let provider_api_format = decision .provider_api_format .clone() @@ -150,7 +151,12 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision_with_websocket_m input.requested_model.as_str(), ) }); - crate::ai_serving::apply_codex_openai_responses_lite_header_for_request_body_with_capabilities( + if native_memories { + decision + .provider_request_headers + .retain(|name, _| !name.eq_ignore_ascii_case(CODEX_RESPONSES_LITE_HEADER)); + } else { + crate::ai_serving::apply_codex_openai_responses_lite_header_for_request_body_with_capabilities( &mut decision.provider_request_headers, decision.provider_request_body.as_ref(), provider_type.as_str(), @@ -159,6 +165,7 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision_with_websocket_m input.requested_model.as_str(), model_capabilities.as_ref(), ); + } let Some(context) = input.routing_context.as_ref() else { // Cache identity headers are projected only at the terminal boundary. Any non-empty @@ -260,7 +267,7 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision_with_websocket_m provider_headers.insert(HeaderName::from_static(name), value); } } - if original_provider_request_body.is_some() { + if original_provider_request_body.is_some() && !native_memories { let provider_model = provider_request_body .get("model") .and_then(Value::as_str) @@ -318,6 +325,12 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision_with_websocket_m } .map_err(|_| invalid_routing_provider_contract())?; } + if native_memories { + crate::ai_serving::transport::enforce_same_format_provider_api_operation_body_policy( + &mut provider_request_body, + Some(crate::ai_serving::ApiOperation::OpenAiMemoriesSummarize), + ); + } let provider_model = provider_request_body .get("model") .and_then(Value::as_str) @@ -339,7 +352,11 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision_with_websocket_m provider_type.as_str(), provider_api_format.as_str(), ); - crate::ai_serving::apply_codex_openai_responses_lite_header_for_request_body_with_capabilities( + if native_memories { + provider_request_headers + .retain(|name, _| !name.eq_ignore_ascii_case(CODEX_RESPONSES_LITE_HEADER)); + } else { + crate::ai_serving::apply_codex_openai_responses_lite_header_for_request_body_with_capabilities( &mut provider_request_headers, Some(&provider_request_body), provider_type.as_str(), @@ -348,6 +365,7 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision_with_websocket_m input.requested_model.as_str(), model_capabilities.as_ref(), ); + } crate::ai_serving::apply_codex_openai_compact_terminal_headers( &mut provider_request_headers, provider_type.as_str(), @@ -382,6 +400,15 @@ fn apply_provider_outbound_request_policies_to_decision( let Some(context) = input.provider_outbound_context.as_ref() else { return; }; + let native_context; + let context = if decision.decision_kind.as_deref() == Some(OPENAI_MEMORIES_SYNC_PLAN_KIND) { + native_context = context + .clone() + .with_api_operation(crate::ai_serving::ApiOperation::OpenAiMemoriesSummarize); + &native_context + } else { + context + }; let results = crate::ai_serving::transport::apply_provider_outbound_request_policies( transport, provider_api_format, diff --git a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs index 4bd50da98..10002cfc0 100644 --- a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs @@ -24,7 +24,9 @@ use crate::ai_serving::transport::{ SameFormatProviderCompatibilityEditAction, SameFormatProviderHeadersInput, GEMINI_CLI_USER_AGENT, GROK_CHAT_PATH, }; -use crate::ai_serving::{CandidateFailureDiagnostic, GatewayProviderTransportSnapshot}; +use crate::ai_serving::{ + CandidateFailureDiagnostic, GatewayProviderTransportSnapshot, CODEX_RESPONSES_LITE_HEADER, +}; use crate::{AppState, GatewayError}; mod policy; @@ -255,7 +257,9 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts( // re-enforce stream-field policy afterward. // Kiro behavior classification already hard-requires upstream streaming, // and the Kiro envelope does not use a top-level body stream field. - if prepared.kiro_auth.is_none() { + if prepared.kiro_auth.is_none() + && spec.operation != Some(crate::ai_serving::ApiOperation::OpenAiMemoriesSummarize) + { enforce_provider_body_stream_policy( &mut base_provider_request_body, prepared.provider_api_format.as_str(), @@ -275,7 +279,8 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts( prepared.mapped_model.as_str(), source_model, ); - if let Err(violation) = + if spec.operation != Some(crate::ai_serving::ApiOperation::OpenAiMemoriesSummarize) { + if let Err(violation) = crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy( &mut base_provider_request_body, crate::ai_serving::OpenAiProviderRequestFinalization { @@ -313,6 +318,7 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts( .await; return Ok(None); } + } // Same-format requests skip `apply_transport_request_body_semantics`, so the opt-in // Claude Code body mimicry has to be applied here as well. @@ -597,6 +603,11 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts( source_model, codex_model_capabilities.as_ref(), ); + if spec.operation == Some(crate::ai_serving::ApiOperation::OpenAiMemoriesSummarize) { + provider_request_headers + .retain(|name, _| !name.eq_ignore_ascii_case(CODEX_RESPONSES_LITE_HEADER)); + provider_request_headers.insert("accept".to_string(), "application/json".to_string()); + } crate::ai_serving::transport::xai::insert_cli_identity_headers_if_needed( transport.as_ref(), prepared.provider_api_format.as_str(), diff --git a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/request/body.rs b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/request/body.rs index b14fa0438..2e48c9ca5 100644 --- a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/request/body.rs +++ b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/request/body.rs @@ -3,7 +3,7 @@ use serde_json::Value; use super::super::LocalSameFormatProviderSpec; use crate::ai_serving::transport::{ build_same_format_provider_request_body as build_same_format_provider_request_body_impl, - build_same_format_provider_request_body_with_compatibility_report_and_reasoning_replay_policy as build_same_format_provider_request_body_with_compatibility_report_impl, + build_same_format_provider_request_body_for_operation as build_same_format_provider_request_body_with_compatibility_report_impl, SameFormatProviderFamily, SameFormatProviderRequestBodyInput, SameFormatProviderRequestBodyOutput, }; @@ -69,6 +69,7 @@ pub(crate) fn build_same_format_provider_request_body_with_compatibility_report( enable_model_directives, }, reasoning_replay_policy, + spec.operation, ) } diff --git a/apps/aether-gateway/src/ai_serving/pure/mod.rs b/apps/aether-gateway/src/ai_serving/pure/mod.rs index 2b3eb5d7a..7850ed894 100644 --- a/apps/aether-gateway/src/ai_serving/pure/mod.rs +++ b/apps/aether-gateway/src/ai_serving/pure/mod.rs @@ -163,7 +163,7 @@ pub(crate) use aether_ai_formats::api::{ OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND, OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND, - OPENAI_REALTIME_STREAM_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND, + OPENAI_MEMORIES_SYNC_PLAN_KIND, OPENAI_REALTIME_STREAM_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND, OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, diff --git a/apps/aether-gateway/src/ai_serving/transport.rs b/apps/aether-gateway/src/ai_serving/transport.rs index 8c81cb17d..627c3b739 100644 --- a/apps/aether-gateway/src/ai_serving/transport.rs +++ b/apps/aether-gateway/src/ai_serving/transport.rs @@ -78,7 +78,7 @@ pub(crate) use aether_provider_transport::{ build_local_openai_chat_upstream_url, build_local_openai_responses_upstream_url, build_openai_image_headers, build_openai_image_upstream_url, build_passthrough_headers, build_request_trace_proxy_value, build_same_format_provider_headers, - build_same_format_provider_request_body, + build_same_format_provider_request_body, build_same_format_provider_request_body_for_operation, build_same_format_provider_request_body_with_compatibility_report, build_same_format_provider_request_body_with_compatibility_report_and_reasoning_replay_policy, build_same_format_provider_upstream_url, build_standard_plan_fallback_headers, diff --git a/apps/aether-gateway/src/constants.rs b/apps/aether-gateway/src/constants.rs index 9a2bcbe14..e9ebd5f1c 100644 --- a/apps/aether-gateway/src/constants.rs +++ b/apps/aether-gateway/src/constants.rs @@ -126,6 +126,7 @@ pub(crate) const RUST_FRONTDOOR_OWNED_ROUTE_PATTERNS: &[&str] = &[ "/v1/messages/count_tokens", "/v1/responses", "/v1/responses/compact", + "/v1/memories/trace_summarize", "/v1/realtime", "/v1/realtime/calls", "/v1/live", diff --git a/apps/aether-gateway/src/control/route/ai.rs b/apps/aether-gateway/src/control/route/ai.rs index 856f62543..960620a89 100644 --- a/apps/aether-gateway/src/control/route/ai.rs +++ b/apps/aether-gateway/src/control/route/ai.rs @@ -88,6 +88,11 @@ pub(super) fn classify_ai_public_route( true, )) } + } else if method == http::Method::POST && normalized_path == "/v1/memories/trace_summarize" { + Some( + classified("ai_public", "openai", "memories", "openai:responses", true) + .with_api_operation(ApiOperation::OpenAiMemoriesSummarize), + ) } else if method == http::Method::POST && normalized_path == "/v1/alpha/search" { Some(classified( "ai_public", diff --git a/apps/aether-gateway/src/execution_runtime/fallback.rs b/apps/aether-gateway/src/execution_runtime/fallback.rs index 86d178abf..7907ded6a 100644 --- a/apps/aether-gateway/src/execution_runtime/fallback.rs +++ b/apps/aether-gateway/src/execution_runtime/fallback.rs @@ -191,6 +191,7 @@ pub(crate) fn resolve_core_sync_error_finalize_report_kind( let report_kind = match plan_kind { "openai_chat_sync" => "openai_chat_sync_finalize", "openai_responses_sync" => "openai_responses_sync_finalize", + "openai_memories_sync" => "openai_memories_sync_finalize", "openai_responses_compact_sync" => "openai_responses_compact_sync_finalize", "claude_chat_sync" => "claude_chat_sync_finalize", "gemini_chat_sync" => "gemini_chat_sync_finalize", @@ -576,6 +577,14 @@ mod tests { error: None, }; + assert_eq!( + resolve_core_sync_error_finalize_report_kind( + "openai_memories_sync", + &result, + Some(&serde_json::json!({"error":{"message":"synthetic"}})) + ), + Some("openai_memories_sync_finalize".to_string()) + ); for body_json in [ serde_json::json!({"status": "failed", "error": null}), serde_json::json!({"type": "error"}), diff --git a/apps/aether-gateway/src/frontdoor_loop_guard.rs b/apps/aether-gateway/src/frontdoor_loop_guard.rs index b3824be3d..852693229 100644 --- a/apps/aether-gateway/src/frontdoor_loop_guard.rs +++ b/apps/aether-gateway/src/frontdoor_loop_guard.rs @@ -45,6 +45,7 @@ pub(crate) fn frontdoor_self_loop_public_ai_path(path: &str) -> bool { | "/v1/rerank" | "/v1/responses" | "/v1/responses/compact" + | "/v1/memories/trace_summarize" | "/v1/realtime" | "/v1/realtime/calls" | "/v1/live" diff --git a/apps/aether-gateway/src/handlers/proxy/websocket/responses/adapter.rs b/apps/aether-gateway/src/handlers/proxy/websocket/responses/adapter.rs index 3b3d6a2d8..209189eac 100644 --- a/apps/aether-gateway/src/handlers/proxy/websocket/responses/adapter.rs +++ b/apps/aether-gateway/src/handlers/proxy/websocket/responses/adapter.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use serde_json::Value; +use std::borrow::Cow; use super::adapters::CODEX_RESPONSES_WEBSOCKET_ADAPTER; use crate::ai_serving::AiExecutionDecision; @@ -59,7 +60,7 @@ pub(super) enum ResponsesWebSocketRelayDirective<'a> { ForwardOriginal, /// The provider frame was a private batch envelope. Forward each retained /// event in document order by serializing the complete borrowed value. - ForwardEvents(Vec<&'a Value>), + ForwardEvents(Vec>), /// The entire frame was an explicitly recognized provider-private /// envelope and therefore has no public event to relay. SuppressProviderPrivate, @@ -88,8 +89,8 @@ pub(super) trait ResponsesWebSocketProtocolAdapter: Send + Sync { /// observably ambiguous to the client. fn rebind_safety_for_upstream_event(&self, event: &Value) -> ResponsesWebSocketRebindSafety; - /// Selects the public relay shape without projecting a provider event - /// through an Aether-owned field or event-type allowlist. + /// 公开事件保持完整;提供商私有元数据仅投影到客户端使用的公开字段, + /// 不公开账户信息。 fn relay_directive_for_upstream_event<'a>( &self, _event: &'a Value, diff --git a/apps/aether-gateway/src/handlers/proxy/websocket/responses/adapters/codex.rs b/apps/aether-gateway/src/handlers/proxy/websocket/responses/adapters/codex.rs index d1ebad819..cbededa4a 100644 --- a/apps/aether-gateway/src/handlers/proxy/websocket/responses/adapters/codex.rs +++ b/apps/aether-gateway/src/handlers/proxy/websocket/responses/adapters/codex.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use serde_json::{Map, Value}; +use std::borrow::Cow; use super::super::adapter::{ is_standard_responses_event, ResponsesWebSocketAdapterObservation, @@ -222,7 +223,7 @@ fn codex_relay_directive(event: &Value) -> ResponsesWebSocketRelayDirective<'_> Some(Value::Array(chunks)) if is_explicit_codex_batch_envelope(event) => { let public_events = chunks .iter() - .filter(|chunk| !is_codex_private_leaf_event(chunk)) + .filter_map(codex_public_event) .collect::>(); if public_events.is_empty() { ResponsesWebSocketRelayDirective::SuppressProviderPrivate @@ -233,13 +234,51 @@ fn codex_relay_directive(event: &Value) -> ResponsesWebSocketRelayDirective<'_> // A malformed or future shape is not proven private. Preserve it // opaquely rather than guessing at a provider schema. Some(_) => ResponsesWebSocketRelayDirective::ForwardOriginal, - None if is_codex_private_leaf_event(event) => { - ResponsesWebSocketRelayDirective::SuppressProviderPrivate - } + None if is_codex_private_leaf_event(event) => match codex_public_event(event) { + Some(projected) => ResponsesWebSocketRelayDirective::ForwardEvents(vec![projected]), + None => ResponsesWebSocketRelayDirective::SuppressProviderPrivate, + }, None => ResponsesWebSocketRelayDirective::ForwardOriginal, } } +fn codex_public_event(event: &Value) -> Option> { + if !is_codex_private_leaf_event(event) { + return Some(Cow::Borrowed(event)); + } + if event.get("type").and_then(Value::as_str) != Some("codex.response.metadata") { + // 配额属于选中的上游账户,不能代表网关用户配额;仅交给 + // 账户级熔断和持久化路径处理。 + return None; + } + let headers = event.get("headers")?.as_object()?; + let public_headers: Map = headers + .iter() + .filter_map(|(name, value)| { + let name = name.to_ascii_lowercase(); + if matches!( + name.as_str(), + "x-models-etag" + | "x-codex-turn-state" + | "openai-model" + | "x-codex-safety-buffering-enabled" + | "x-codex-safety-buffering-faster-model" + ) && value.as_str().is_some() + { + Some((name, value.clone())) + } else { + None + } + }) + .collect(); + if public_headers.is_empty() { + return None; + } + Some(Cow::Owned(serde_json::json!({ + "type": "codex.response.metadata", "headers": public_headers + }))) +} + /// Recognizes only Codex's private batch container. A type-less object must /// contain exactly `chunks`; unknown siblings could be future public protocol /// data and therefore force opaque forwarding. A named Codex private root may @@ -549,6 +588,55 @@ mod tests { } } + #[test] + fn codex_metadata_relays_cli_catalog_and_turn_state_without_account_fields() { + let event = json!({ + "type": "codex.response.metadata", + "headers": { + "X-Models-Etag": "catalog-v2", + "x-codex-turn-state": "synthetic-turn-state", + "openai-model": "gpt-6.1-sol", + "x-codex-safety-buffering-enabled": "true", + "x-codex-safety-buffering-faster-model": "gpt-6-luna", + "chatgpt-account-id": "synthetic-private-account", + "set-cookie": "synthetic-private-cookie", + "authorization": "synthetic-private-token" + }, + "account_hint": "private", + "metadata": {"user_id": "private"} + }); + let ResponsesWebSocketRelayDirective::ForwardEvents(events) = + CodexResponsesWebSocketAdapter.relay_directive_for_upstream_event(&event) + else { + panic!("CLI metadata must reach the client"); + }; + assert_eq!(events.len(), 1); + assert_eq!( + *events[0], + json!({ + "type": "codex.response.metadata", + "headers": {"x-models-etag": "catalog-v2", "x-codex-turn-state": "synthetic-turn-state", "openai-model": "gpt-6.1-sol", "x-codex-safety-buffering-enabled": "true", "x-codex-safety-buffering-faster-model": "gpt-6-luna"} + }) + ); + } + + #[test] + fn codex_batch_retains_safe_metadata_in_public_event_order() { + let event = json!({"chunks": [ + {"type": "codex.response.metadata", "headers": {"x-models-etag": "catalog-v3"}}, + {"type": "codex.rate_limits", "plan_type": "private-plan"}, + {"type": "response.created", "response": {"id": "resp_synthetic"}, "future": 42} + ]}); + let ResponsesWebSocketRelayDirective::ForwardEvents(events) = + CodexResponsesWebSocketAdapter.relay_directive_for_upstream_event(&event) + else { + panic!("batch must retain public events"); + }; + assert_eq!(events.len(), 2); + assert_eq!(events[0]["headers"]["x-models-etag"], "catalog-v3"); + assert_eq!(events[1]["future"], 42); + } + #[test] fn mixed_codex_batch_forwards_whole_non_private_events_in_order() { let adapter = CodexResponsesWebSocketAdapter; diff --git a/apps/aether-gateway/src/handlers/proxy/websocket/responses/connection.rs b/apps/aether-gateway/src/handlers/proxy/websocket/responses/connection.rs index 23774c5f5..d09e268a2 100644 --- a/apps/aether-gateway/src/handlers/proxy/websocket/responses/connection.rs +++ b/apps/aether-gateway/src/handlers/proxy/websocket/responses/connection.rs @@ -610,6 +610,7 @@ pub(super) async fn relay_bound_connection( } Some(ResponsesWebSocketRelayDirective::ForwardEvents(events)) => { for event in events { + let event = event.as_ref(); let text = match bound .redaction_restorer .restore_provider_frame_text(event) diff --git a/apps/aether-gateway/src/model_fetch/catalog.rs b/apps/aether-gateway/src/model_fetch/catalog.rs index 65e274e6f..8d19a3750 100644 --- a/apps/aether-gateway/src/model_fetch/catalog.rs +++ b/apps/aether-gateway/src/model_fetch/catalog.rs @@ -2663,14 +2663,11 @@ mod tests { assert_eq!(plans.len(), 2); assert!(plans[0].url.ends_with("/models?client_version=0.145.2")); assert!(plans[1].url.ends_with("/models?client_version=0.146.0")); - assert_eq!( - plans[0].headers.get("user-agent").map(String::as_str), - Some("codex_cli_rs/0.145.2") - ); - assert_eq!( - plans[1].headers.get("user-agent").map(String::as_str), - Some("codex_cli_rs/0.146.0") - ); + for (plan, version) in plans.iter().zip(["0.145.2", "0.146.0"]) { + let ua = plan.headers.get("user-agent").expect("画像应包含 UA"); + assert!(ua.starts_with(&format!("codex_cli_rs/{version} ("))); + assert!(ua.ends_with(") unknown")); + } let target = target(); assert_ne!( diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/search.rs b/apps/aether-gateway/src/tests/ai_execute/sync/search.rs index 5b2ec2067..fa9412d16 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/search.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/search.rs @@ -45,6 +45,153 @@ where } } +fn hash_api_key(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn auth_snapshot() -> StoredAuthApiKeySnapshot { + StoredAuthApiKeySnapshot::new( + "user-search-1".to_string(), + "alice".to_string(), + Some("alice@example.com".to_string()), + "user".to_string(), + "local".to_string(), + true, + false, + Some(json!(["openai", "codex"])), + Some(json!(["openai:responses"])), + None, + "api-key-search-1".to_string(), + Some("search-client".to_string()), + true, + false, + false, + Some(60), + Some(5), + Some(4_102_444_800_i64), + Some(json!(["openai", "codex"])), + Some(json!(["openai:responses"])), + None, + ) + .expect("auth snapshot should build") +} + +fn candidate_row(api_format: &str) -> StoredMinimalCandidateSelectionRow { + StoredMinimalCandidateSelectionRow { + provider_id: "provider-codex-search-1".to_string(), + provider_name: "codex".to_string(), + provider_type: "codex".to_string(), + provider_priority: 10, + provider_is_active: true, + endpoint_id: "endpoint-codex-search-1".to_string(), + endpoint_api_format: api_format.to_string(), + endpoint_api_family: Some("openai".to_string()), + endpoint_kind: Some(api_format.split_once(':').expect("format").1.to_string()), + endpoint_is_active: true, + key_id: "key-codex-search-1".to_string(), + key_name: "oauth".to_string(), + key_auth_type: "oauth".to_string(), + key_is_active: true, + key_api_formats: Some(vec!["openai:responses".to_string()]), + key_allowed_models: None, + key_capabilities: None, + key_internal_priority: 5, + key_global_priority_by_format: Some(json!({"openai:search": 1})), + model_id: "model-codex-search-1".to_string(), + global_model_id: "global-model-codex-search-1".to_string(), + global_model_name: "gpt-5.6-sol".to_string(), + global_model_mappings: None, + global_model_supports_streaming: Some(false), + model_provider_model_name: "gpt-5.6-sol".to_string(), + model_provider_model_mappings: Some(vec![StoredProviderModelMapping { + name: "gpt-5.6-sol".to_string(), + priority: 1, + api_formats: Some(vec!["openai:responses".to_string()]), + endpoint_ids: None, + operations: None, + }]), + model_supports_streaming: Some(false), + model_is_active: true, + model_is_available: true, + } +} + +fn provider() -> StoredProviderCatalogProvider { + StoredProviderCatalogProvider::new( + "provider-codex-search-1".to_string(), + "codex".to_string(), + Some("https://chatgpt.com".to_string()), + "codex".to_string(), + ) + .expect("provider should build") + .with_transport_fields( + true, + false, + false, + None, + Some(1), + None, + Some(900.0), + None, + None, + ) +} + +fn endpoint(api_format: &str) -> StoredProviderCatalogEndpoint { + StoredProviderCatalogEndpoint::new( + "endpoint-codex-search-1".to_string(), + "provider-codex-search-1".to_string(), + api_format.to_string(), + Some("openai".to_string()), + Some(api_format.split_once(':').expect("format").1.to_string()), + true, + ) + .expect("endpoint should build") + .with_transport_fields( + "https://chatgpt.com/backend-api/codex".to_string(), + None, + None, + Some(1), + None, + None, + None, + None, + ) + .expect("endpoint transport should build") +} + +fn key() -> StoredProviderCatalogKey { + let auth_config = encrypt_python_fernet_plaintext( + DEVELOPMENT_ENCRYPTION_KEY, + r#"{"provider_type":"codex","account_id":"account-search-1","is_fedramp":true}"#, + ) + .expect("auth config should encrypt"); + StoredProviderCatalogKey::new( + "key-codex-search-1".to_string(), + "provider-codex-search-1".to_string(), + "oauth".to_string(), + "oauth".to_string(), + None, + true, + ) + .expect("key should build") + .with_transport_fields( + Some(json!(["openai:responses"])), + encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "codex-search-access-token") + .expect("access token should encrypt"), + Some(auth_config), + None, + Some(json!({"openai:search": 1})), + None, + Some(4_102_444_800), + None, + None, + ) + .expect("key transport should build") +} + #[test] fn gateway_executes_codex_search_with_responses_permission_and_search_contract() { run_search_sync_test( @@ -54,156 +201,6 @@ fn gateway_executes_codex_search_with_responses_permission_and_search_contract() } async fn gateway_executes_codex_search_with_responses_permission_and_search_contract_impl() { - fn hash_api_key(value: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(value.as_bytes()); - format!("{:x}", hasher.finalize()) - } - - fn auth_snapshot() -> StoredAuthApiKeySnapshot { - StoredAuthApiKeySnapshot::new( - "user-search-1".to_string(), - "alice".to_string(), - Some("alice@example.com".to_string()), - "user".to_string(), - "local".to_string(), - true, - false, - Some(json!(["openai", "codex"])), - Some(json!(["openai:responses"])), - None, - "api-key-search-1".to_string(), - Some("search-client".to_string()), - true, - false, - false, - Some(60), - Some(5), - Some(4_102_444_800_i64), - Some(json!(["openai", "codex"])), - Some(json!(["openai:responses"])), - None, - ) - .expect("auth snapshot should build") - } - - fn candidate_row() -> StoredMinimalCandidateSelectionRow { - StoredMinimalCandidateSelectionRow { - provider_id: "provider-codex-search-1".to_string(), - provider_name: "codex".to_string(), - provider_type: "codex".to_string(), - provider_priority: 10, - provider_is_active: true, - endpoint_id: "endpoint-codex-search-1".to_string(), - endpoint_api_format: "openai:search".to_string(), - endpoint_api_family: Some("openai".to_string()), - endpoint_kind: Some("search".to_string()), - endpoint_is_active: true, - key_id: "key-codex-search-1".to_string(), - key_name: "oauth".to_string(), - key_auth_type: "oauth".to_string(), - key_is_active: true, - key_api_formats: Some(vec!["openai:responses".to_string()]), - key_allowed_models: None, - key_capabilities: None, - key_internal_priority: 5, - key_global_priority_by_format: Some(json!({"openai:search": 1})), - model_id: "model-codex-search-1".to_string(), - global_model_id: "global-model-codex-search-1".to_string(), - global_model_name: "gpt-5.6-sol".to_string(), - global_model_mappings: None, - global_model_supports_streaming: Some(false), - model_provider_model_name: "gpt-5.6-sol".to_string(), - model_provider_model_mappings: Some(vec![StoredProviderModelMapping { - name: "gpt-5.6-sol".to_string(), - priority: 1, - api_formats: Some(vec!["openai:responses".to_string()]), - endpoint_ids: None, - operations: None, - }]), - model_supports_streaming: Some(false), - model_is_active: true, - model_is_available: true, - } - } - - fn provider() -> StoredProviderCatalogProvider { - StoredProviderCatalogProvider::new( - "provider-codex-search-1".to_string(), - "codex".to_string(), - Some("https://chatgpt.com".to_string()), - "codex".to_string(), - ) - .expect("provider should build") - .with_transport_fields( - true, - false, - false, - None, - Some(1), - None, - Some(900.0), - None, - None, - ) - } - - fn endpoint() -> StoredProviderCatalogEndpoint { - StoredProviderCatalogEndpoint::new( - "endpoint-codex-search-1".to_string(), - "provider-codex-search-1".to_string(), - "openai:search".to_string(), - Some("openai".to_string()), - Some("search".to_string()), - true, - ) - .expect("endpoint should build") - .with_transport_fields( - "https://chatgpt.com/backend-api/codex".to_string(), - None, - None, - Some(1), - None, - None, - None, - None, - ) - .expect("endpoint transport should build") - } - - fn key() -> StoredProviderCatalogKey { - let auth_config = encrypt_python_fernet_plaintext( - DEVELOPMENT_ENCRYPTION_KEY, - r#"{"provider_type":"codex","account_id":"account-search-1","is_fedramp":true}"#, - ) - .expect("auth config should encrypt"); - StoredProviderCatalogKey::new( - "key-codex-search-1".to_string(), - "provider-codex-search-1".to_string(), - "oauth".to_string(), - "oauth".to_string(), - None, - true, - ) - .expect("key should build") - .with_transport_fields( - Some(json!(["openai:responses"])), - encrypt_python_fernet_plaintext( - DEVELOPMENT_ENCRYPTION_KEY, - "codex-search-access-token", - ) - .expect("access token should encrypt"), - Some(auth_config), - None, - Some(json!({"openai:search": 1})), - None, - Some(4_102_444_800), - None, - None, - ) - .expect("key transport should build") - } - let seen_plans = Arc::new(Mutex::new(Vec::::new())); let seen_plans_clone = Arc::clone(&seen_plans); let execution_runtime = Router::new().route( @@ -316,7 +313,7 @@ async fn gateway_executes_codex_search_with_responses_permission_and_search_cont auth_snapshot(), )])); let candidate_repository = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed({ - let primary = candidate_row(); + let primary = candidate_row("openai:search"); let mut backup = primary.clone(); backup.provider_id = "provider-codex-search-2".to_string(); backup.provider_name = "codex-backup".to_string(); @@ -338,7 +335,7 @@ async fn gateway_executes_codex_search_with_responses_permission_and_search_cont vec![primary, backup] }, { - let primary = endpoint(); + let primary = endpoint("openai:search"); let mut backup = primary.clone(); backup.id = "endpoint-codex-search-2".to_string(); backup.provider_id = "provider-codex-search-2".to_string(); @@ -598,3 +595,120 @@ async fn gateway_executes_codex_search_with_responses_permission_and_search_cont gateway_handle.abort(); execution_runtime_handle.abort(); } + +#[test] +fn gateway_executes_codex_memories_with_responses_permission_and_native_json() { + run_search_sync_test( + "gateway_executes_codex_memories_with_responses_permission_and_native_json", + || async { + let response_body = json!({"output":[{"trace_summary":"synthetic trace", "memory_summary":"synthetic memory"}],"future_response_field":{"enabled":true}}); + let seen = Arc::new(Mutex::new(None)); + let captured = Arc::clone(&seen); + let expected = response_body.clone(); + let runtime = Router::new().route("/v1/execute/sync", any(move |request: Request| { + let captured = Arc::clone(&captured); + let response_body = expected.clone(); + async move { + let bytes = to_bytes(request.into_body(), usize::MAX).await.expect("read plan"); + let plan: serde_json::Value = serde_json::from_slice(&bytes).expect("parse plan"); + let request_id = plan["request_id"].clone(); + *captured.lock().expect("capture lock") = Some(plan); + let (status_code, response_body) = if request_id == json!("trace-memory-error") { + (400, json!({"error":{"type":"invalid_request_error","message":"synthetic invalid trace","code":"invalid_trace"},"future_error_field":{"enabled":true}})) + } else { (200, response_body) }; + Json(json!({"request_id":request_id,"status_code":status_code,"headers":{"content-type":"application/json"},"body":{"json_body":response_body},"telemetry":{"elapsed_ms":1}})) + } + })); + let client_key = "sk-synthetic-memory-client"; + let auth = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![( + Some(hash_api_key(client_key)), + auth_snapshot(), + )])); + let candidates = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![ + candidate_row("openai:responses"), + ])); + let mut memory_provider = provider(); + memory_provider.config = Some( + json!({"codex":{"fingerprint_convergence_enabled":true},"failover_rules":{"stop_status_codes":[400]}}), + ); + let catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![memory_provider], + vec![endpoint("openai:responses")], + vec![key()], + )); + let request_candidates = Arc::new(InMemoryRequestCandidateRepository::default()); + let data = crate::data::GatewayDataState::with_auth_candidate_selection_provider_catalog_and_request_candidate_repository_for_tests(auth,candidates,catalog,Arc::clone(&request_candidates),DEVELOPMENT_ENCRYPTION_KEY) + .with_system_config_values_for_tests([(crate::system_features::ENABLE_MODEL_DIRECTIVES_CONFIG_KEY.to_string(),json!(true))]); + let (runtime_url, runtime_handle) = start_server(runtime).await; + let state = build_state_with_execution_runtime_override(runtime_url) + .with_data_state_for_tests(data); + let (url, gateway_handle) = start_server(build_router_with_state(state)).await; + let input = json!({"model":"gpt-5.6-sol-max","traces":[{"id":"synthetic-trace","metadata":{"source_path":"/synthetic/trace.json"},"items":[{"type":"message","role":"user","content":[]}]}],"reasoning":{"effort":"low"},"future_request_field":{"enabled":true}}); + let response = reqwest::Client::new() + .post(format!("{url}/v1/memories/trace_summarize")) + .header(http::header::AUTHORIZATION, format!("Bearer {client_key}")) + .header(TRACE_ID_HEADER, "trace-memory-1") + .json(&input) + .send() + .await + .expect("send request"); + let status = response.status(); + let body: serde_json::Value = response.json().await.expect("read response"); + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body, response_body); + let plan = seen + .lock() + .expect("capture lock") + .clone() + .expect("captured plan"); + assert_eq!( + plan["url"], + "https://chatgpt.com/backend-api/codex/memories/trace_summarize" + ); + assert_eq!(plan["stream"], false); + assert_eq!(plan["client_api_format"], "openai:responses"); + assert_eq!(plan["provider_api_format"], "openai:responses"); + assert_eq!(plan["headers"]["originator"], "codex_cli_rs"); + assert_eq!( + plan["headers"]["authorization"], + "Bearer codex-search-access-token" + ); + assert_eq!(plan["headers"]["accept"], "application/json"); + assert!(plan["headers"] + .get("x-openai-internal-codex-responses-lite") + .is_none()); + let mut expected_input = input; + expected_input["model"] = json!("gpt-5.6-sol"); + expected_input["reasoning"]["effort"] = json!("max"); + assert_eq!(plan["body"]["json_body"], expected_input); + let stored = request_candidates + .list_by_request_id("trace-memory-1") + .await + .expect("read candidates"); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].status, RequestCandidateStatus::Success); + let error = reqwest::Client::new() + .post(format!("{url}/v1/memories/trace_summarize")) + .header(http::header::AUTHORIZATION, format!("Bearer {client_key}")) + .header(TRACE_ID_HEADER, "trace-memory-error") + .json(&expected_input) + .send() + .await + .expect("error response"); + let error_status = error.status(); + assert_eq!(error_status, StatusCode::BAD_REQUEST); + assert_eq!( + error.json::().await.expect("error JSON"), + json!({"error":{"type":"invalid_request_error","message":"synthetic invalid trace","code":"invalid_trace"},"future_error_field":{"enabled":true}}) + ); + let error_candidates = request_candidates + .list_by_request_id("trace-memory-error") + .await + .expect("error candidate"); + assert_eq!(error_candidates.len(), 1); + assert_eq!(error_candidates[0].status, RequestCandidateStatus::Failed); + gateway_handle.abort(); + runtime_handle.abort(); + }, + ); +} diff --git a/apps/aether-gateway/src/tests/control/admin/provider_query.rs b/apps/aether-gateway/src/tests/control/admin/provider_query.rs index 336063114..d44d64592 100644 --- a/apps/aether-gateway/src/tests/control/admin/provider_query.rs +++ b/apps/aether-gateway/src/tests/control/admin/provider_query.rs @@ -849,6 +849,12 @@ async fn gateway_handles_admin_provider_query_models_falls_back_to_codex_preset_ "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra", + "gpt-6-astra", + "gpt-6-luna", + "gpt-6-sol", + "gpt-6.1-sol", + "gpt-daybreak-blue-latest", + "gpt-daybreak-red-latest", ] ); assert_eq!( diff --git a/apps/aether-gateway/src/tests/frontdoor/ai.rs b/apps/aether-gateway/src/tests/frontdoor/ai.rs index 1ba25afc6..06157a44e 100644 --- a/apps/aether-gateway/src/tests/frontdoor/ai.rs +++ b/apps/aether-gateway/src/tests/frontdoor/ai.rs @@ -1270,10 +1270,9 @@ async fn run_versioned_codex_model_cards_frontdoor_scenario() { captured_catalog_plan.0, "https://chatgpt.example/backend-api/codex/models?client_version=0.145.2" ); - assert_eq!( - captured_catalog_plan.1.as_deref(), - Some("codex_cli_rs/0.145.2") - ); + let captured_ua = captured_catalog_plan.1.expect("目录请求应包含 UA"); + assert!(captured_ua.starts_with("codex_cli_rs/0.145.2 (")); + assert!(captured_ua.ends_with(") unknown")); let fresh_response = client .get(format!("{gateway_url}/v1/models?client_version=0.145.2")) diff --git a/apps/aether-tunnel/src/setup/service.rs b/apps/aether-tunnel/src/setup/service.rs index e4e5f1f8c..7aea5a309 100644 --- a/apps/aether-tunnel/src/setup/service.rs +++ b/apps/aether-tunnel/src/setup/service.rs @@ -340,7 +340,12 @@ fn render_systemd_unit( validate_service_unit_path(working_dir, "working directory")?; let exe_path = systemd_quote(exe_path); - let working_dir = systemd_quote(working_dir); + // systemd treats a quoted WorkingDirectory value as part of the path + // (systemd-analyze reports `path is not absolute: "/etc/aether-tunnel"`), + // so this directive must stay unquoted. The directive takes the rest of + // the line as the path, so values with spaces stay intact, and + // validate_service_unit_path rejects newline/specifier injection. + let working_dir = working_dir.to_string(); let config_env = systemd_quote(&format!("AETHER_TUNNEL_CONFIG={config_path}")); Ok(format!( "[Unit]\n\ @@ -980,7 +985,8 @@ mod tests { assert!(unit.contains( r#"Environment="AETHER_TUNNEL_CONFIG=/var/lib/aether tunnel/config\\\\node.toml""# )); - assert!(unit.contains(r#"WorkingDirectory="/var/lib/aether tunnel""#)); + assert!(unit.contains("WorkingDirectory=/var/lib/aether tunnel\n")); + assert!(!unit.contains("WorkingDirectory=\"")); assert_eq!(systemd_quote("a\\b\"c"), r#""a\\b\"c""#); } diff --git a/crates/aether-ai/formats/Cargo.toml b/crates/aether-ai/formats/Cargo.toml index 4e3f2154b..4d6ea21f7 100644 --- a/crates/aether-ai/formats/Cargo.toml +++ b/crates/aether-ai/formats/Cargo.toml @@ -10,6 +10,7 @@ description = "Pure AI API format, surface, planning, and finalize logic for Aet aether-contracts.workspace = true base64.workspace = true http.workspace = true +os_info = { version = "=3.12.0", default-features = false } regex.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/aether-ai/formats/src/api.rs b/crates/aether-ai/formats/src/api.rs index fbbd51933..f9cc246ff 100644 --- a/crates/aether-ai/formats/src/api.rs +++ b/crates/aether-ai/formats/src/api.rs @@ -33,7 +33,7 @@ pub use crate::contracts::{ OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND, OPENAI_IMAGE_SYNC_ERROR_REPORT_KIND, OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND, - OPENAI_REALTIME_STREAM_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND, + OPENAI_MEMORIES_SYNC_PLAN_KIND, OPENAI_REALTIME_STREAM_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND, OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, diff --git a/crates/aether-ai/formats/src/codex_profile.rs b/crates/aether-ai/formats/src/codex_profile.rs index 1338a9f8c..5d5c4eb89 100644 --- a/crates/aether-ai/formats/src/codex_profile.rs +++ b/crates/aether-ai/formats/src/codex_profile.rs @@ -1,4 +1,6 @@ -use std::sync::{OnceLock, RwLock}; +use std::sync::{LazyLock, OnceLock, RwLock}; + +static OS_INFO: LazyLock = LazyLock::new(os_info::get); /// 当前支持的 Codex 客户端类型。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -32,7 +34,19 @@ impl CodexClientProfile { Ok(Self { client_kind: CodexClientKind::Cli, codex_version: version.to_owned(), - user_agent: format!("{}/{}", originator, version), + // 按 CLI 格式使用当前网关的公开平台信息。无客户端终端时使用官方 + // unknown 标识,不复制调用方终端后缀、安装标识或个人身份。 + user_agent: format!( + "{}/{} ({} {}; {}) unknown", + originator, + version, + OS_INFO.os_type(), + OS_INFO.version(), + OS_INFO.architecture().unwrap_or(std::env::consts::ARCH), + ) + .chars() + .map(|ch| if matches!(ch, ' '..='~') { ch } else { '_' }) + .collect(), originator, }) } @@ -40,8 +54,8 @@ impl CodexClientProfile { impl Default for CodexClientProfile { fn default() -> Self { - // 远程发布检查不可用时仍保持现有线上行为,避免启动或请求被版本服务拖住。 - Self::cli("0.153.4").expect("built-in Codex CLI profile must be valid") + // 最新已核验稳定版本;后台版本刷新继续作为版本真源。 + Self::cli("0.159.3").expect("built-in Codex CLI profile must be valid") } } @@ -97,7 +111,9 @@ mod tests { let profile = CodexClientProfile::cli("0.200.1").expect("valid version"); assert_eq!(profile.client_kind, CodexClientKind::Cli); assert_eq!(profile.originator, "codex_cli_rs"); - assert_eq!(profile.user_agent, "codex_cli_rs/0.200.1"); + assert!(profile.user_agent.starts_with("codex_cli_rs/0.200.1 (")); + assert!(profile.user_agent.ends_with(") unknown")); + assert!(profile.user_agent.contains(std::env::consts::ARCH)); } #[test] diff --git a/crates/aether-ai/formats/src/contracts/mod.rs b/crates/aether-ai/formats/src/contracts/mod.rs index 900c0afd9..8bee53926 100644 --- a/crates/aether-ai/formats/src/contracts/mod.rs +++ b/crates/aether-ai/formats/src/contracts/mod.rs @@ -22,7 +22,7 @@ pub use plan_kinds::{ GEMINI_INTERACTIONS_SYNC_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, - OPENAI_REALTIME_STREAM_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND, + OPENAI_MEMORIES_SYNC_PLAN_KIND, OPENAI_REALTIME_STREAM_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND, OPENAI_SEARCH_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, @@ -49,7 +49,8 @@ pub use report_kinds::{ OPENAI_EMBEDDING_SYNC_ERROR_REPORT_KIND, OPENAI_EMBEDDING_SYNC_FINALIZE_REPORT_KIND, OPENAI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND, OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND, OPENAI_IMAGE_SYNC_ERROR_REPORT_KIND, OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND, - OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND, + OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND, OPENAI_MEMORIES_SYNC_SUCCESS_REPORT_KIND, + OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND, OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND, OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND, diff --git a/crates/aether-ai/formats/src/contracts/plan_kinds.rs b/crates/aether-ai/formats/src/contracts/plan_kinds.rs index 0da34706f..5bbaf89d9 100644 --- a/crates/aether-ai/formats/src/contracts/plan_kinds.rs +++ b/crates/aether-ai/formats/src/contracts/plan_kinds.rs @@ -5,6 +5,7 @@ pub const GEMINI_FILES_DELETE_PLAN_KIND: &str = "gemini_files_delete"; pub const GEMINI_FILES_DOWNLOAD_PLAN_KIND: &str = "gemini_files_download"; pub const OPENAI_IMAGE_STREAM_PLAN_KIND: &str = "openai_image_stream"; pub const OPENAI_IMAGE_SYNC_PLAN_KIND: &str = "openai_image_sync"; +pub const OPENAI_MEMORIES_SYNC_PLAN_KIND: &str = "openai_memories_sync"; pub const OPENAI_VIDEO_CONTENT_PLAN_KIND: &str = "openai_video_content"; pub const OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "openai_video_cancel_sync"; pub const OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND: &str = "openai_video_remix_sync"; diff --git a/crates/aether-ai/formats/src/contracts/report_kinds.rs b/crates/aether-ai/formats/src/contracts/report_kinds.rs index 80d23275a..5bf91c8bd 100644 --- a/crates/aether-ai/formats/src/contracts/report_kinds.rs +++ b/crates/aether-ai/formats/src/contracts/report_kinds.rs @@ -31,6 +31,8 @@ pub const OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND: &str = "openai_responses_compact_sync_success"; pub const OPENAI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND: &str = "openai_embedding_sync_success"; pub const OPENAI_SEARCH_SYNC_SUCCESS_REPORT_KIND: &str = "openai_search_sync_success"; +pub const OPENAI_MEMORIES_SYNC_FINALIZE_REPORT_KIND: &str = "openai_memories_sync_finalize"; +pub const OPENAI_MEMORIES_SYNC_SUCCESS_REPORT_KIND: &str = "openai_memories_sync_success"; pub const GEMINI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_embedding_sync_success"; pub const OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND: &str = "openai_image_sync_success"; pub const CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "claude_cli_sync_success"; @@ -62,6 +64,9 @@ pub const GEMINI_CLI_SYNC_ERROR_REPORT_KIND: &str = "gemini_cli_sync_error"; pub fn implicit_sync_finalize_report_kind(plan_kind: &str) -> Option<&'static str> { match plan_kind { + super::plan_kinds::OPENAI_MEMORIES_SYNC_PLAN_KIND => { + Some(OPENAI_MEMORIES_SYNC_FINALIZE_REPORT_KIND) + } OPENAI_CHAT_SYNC_PLAN_KIND => Some(OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND), CLAUDE_CHAT_SYNC_PLAN_KIND => Some(CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND), GEMINI_CHAT_SYNC_PLAN_KIND => Some(GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND), @@ -80,6 +85,7 @@ pub fn implicit_sync_finalize_report_kind(plan_kind: &str) -> Option<&'static st pub fn core_error_default_client_api_format(report_kind: &str) -> Option<&'static str> { match report_kind { + OPENAI_MEMORIES_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses"), OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("openai:chat"), CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("claude:messages"), GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("gemini:generate_content"), @@ -98,6 +104,7 @@ pub fn core_error_default_client_api_format(report_kind: &str) -> Option<&'stati pub fn core_error_background_report_kind(report_kind: &str) -> Option<&'static str> { match report_kind { + OPENAI_MEMORIES_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND), OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CHAT_SYNC_ERROR_REPORT_KIND), CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND), GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CHAT_SYNC_ERROR_REPORT_KIND), @@ -124,6 +131,7 @@ pub fn core_error_background_report_kind(report_kind: &str) -> Option<&'static s pub fn core_success_background_report_kind(report_kind: &str) -> Option<&'static str> { match report_kind { + OPENAI_MEMORIES_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_MEMORIES_SYNC_SUCCESS_REPORT_KIND), OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND), CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND), GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND), diff --git a/crates/aether-ai/formats/src/contracts/request_dimensions.rs b/crates/aether-ai/formats/src/contracts/request_dimensions.rs index 2ac98aa3c..3ed0879c8 100644 --- a/crates/aether-ai/formats/src/contracts/request_dimensions.rs +++ b/crates/aether-ai/formats/src/contracts/request_dimensions.rs @@ -34,6 +34,7 @@ pub enum ApiOperation { ClaudeMessagesCreate, ClaudeCountTokens, OpenAiResponsesCompact, + OpenAiMemoriesSummarize, } impl ApiOperation { @@ -42,6 +43,7 @@ impl ApiOperation { Self::ClaudeMessagesCreate => "messages", Self::ClaudeCountTokens => "count_tokens", Self::OpenAiResponsesCompact => "compact", + Self::OpenAiMemoriesSummarize => "trace_summarize", } } } @@ -55,5 +57,9 @@ mod tests { assert_eq!(ClientSurface::ClaudeCode.as_str(), "claude_code"); assert_eq!(ApiOperation::ClaudeMessagesCreate.as_str(), "messages"); assert_eq!(ApiOperation::ClaudeCountTokens.as_str(), "count_tokens"); + assert_eq!( + ApiOperation::OpenAiMemoriesSummarize.as_str(), + "trace_summarize" + ); } } diff --git a/crates/aether-ai/formats/src/formats/openai/responses/codex.rs b/crates/aether-ai/formats/src/formats/openai/responses/codex.rs index 548a65894..5182fccc1 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/codex.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/codex.rs @@ -380,149 +380,22 @@ fn bundled_codex_model_card(spec: BundledCodexModelCardSpec<'_>) -> Value { }) } -fn bundled_gpt_5_6_codex_model_card( - model_id: &str, - display_name: &str, - description: &str, - default_reasoning_level: &str, - priority: u64, - multi_agent_version: &str, - supports_ultra: bool, -) -> Value { - let mut efforts = vec!["low", "medium", "high", "xhigh", "max"]; - if supports_ultra { - efforts.push("ultra"); - } - let mut card = bundled_codex_model_card(BundledCodexModelCardSpec { - model_id, - display_name, - description, - default_reasoning_level, - default_reasoning_summary: "none", - use_responses_lite: true, - efforts: &efforts, - default_verbosity: "low", - supports_priority_tier: true, - }); - let object = card - .as_object_mut() - .expect("bundled Codex model card must be an object"); - object.extend( - json!({ - "shell_type": "shell_command", - "supports_image_detail_original": true, - "supports_search_tool": true, - "input_modalities": ["text", "image"], - "context_window": 372_000, - "max_context_window": 372_000, - "comp_hash": "3000", - "experimental_supported_tools": [], - "visibility": "list", - "supported_in_api": true, - "priority": priority, - "additional_speed_tiers": ["fast"], - "multi_agent_version": multi_agent_version, - "tool_mode": "code_mode_only", - "prefer_websockets": true, - "reasoning_summary_format": "experimental", - "include_skills_usage_instructions": false, - "apply_patch_tool_type": "freeform", - "web_search_tool_type": "text_and_image", - "truncation_policy": { "mode": "tokens", "limit": 10_000 }, - "minimal_client_version": "0.144.0", - }) - .as_object() - .expect("bundled Codex model card extension must be an object") - .clone(), - ); - card -} - -fn bundled_codex_auto_review_model_card() -> Value { - let mut card = bundled_codex_model_card(BundledCodexModelCardSpec { - model_id: "codex-auto-review", - display_name: "Codex Auto Review", - description: "Automatic approval review model for Codex.", - default_reasoning_level: "medium", - default_reasoning_summary: "none", - use_responses_lite: false, - efforts: &["low", "medium", "high", "xhigh"], - default_verbosity: "low", - supports_priority_tier: false, - }); - let object = card - .as_object_mut() - .expect("bundled Codex model card must be an object"); - object.extend( - json!({ - "shell_type": "shell_command", - "supports_image_detail_original": true, - "supports_search_tool": true, - "input_modalities": ["text", "image"], - "context_window": 272_000, - "max_context_window": 1_000_000, - "experimental_supported_tools": [], - "visibility": "hide", - "supported_in_api": true, - "priority": 43, - "additional_speed_tiers": [], - "prefer_websockets": true, - "reasoning_summary_format": "experimental", - "include_skills_usage_instructions": false, - "apply_patch_tool_type": "freeform", - "web_search_tool_type": "text_and_image", - "truncation_policy": { "mode": "tokens", "limit": 10_000 }, - "minimal_client_version": "0.98.0", - }) - .as_object() - .expect("bundled Codex model card extension must be an object") - .clone(), - ); - card -} - pub fn bundled_codex_model_cards() -> &'static [Value] { static CARDS: OnceLock> = OnceLock::new(); CARDS.get_or_init(|| { - vec![ - bundled_gpt_5_6_codex_model_card( - "gpt-5.6-sol", - "GPT-5.6-Sol", - "Latest frontier agentic coding model.", - "low", - 1, - "v2", - true, - ), - bundled_gpt_5_6_codex_model_card( - "gpt-5.6-terra", - "GPT-5.6-Terra", - "Balanced agentic coding model for everyday work.", - "medium", - 2, - "v2", - true, - ), - bundled_gpt_5_6_codex_model_card( - "gpt-5.6-luna", - "GPT-5.6-Luna", - "Fast and affordable agentic coding model.", - "medium", - 3, - "v1", - false, - ), - bundled_codex_model_card(BundledCodexModelCardSpec { - model_id: "gpt-5.5", - display_name: "GPT-5.5", - description: "Frontier model for complex coding, research, and real-world work.", - default_reasoning_level: "medium", - default_reasoning_summary: "none", - use_responses_lite: false, - efforts: &["low", "medium", "high", "xhigh"], - default_verbosity: "low", - supports_priority_tier: true, - }), + // 公开能力快照来自 openai/codex rust-v0.159.3 的 + // codex-rs/models-manager/models.json;账户远端目录继续优先。 + let mut cards: Vec = serde_json::from_str(include_str!("codex_models_0_159_3.json")) + .expect("bundled Codex model capability snapshot must be valid JSON"); + for card in &mut cards { + let object = card.as_object_mut().expect("model card must be an object"); + object.insert("id".to_string(), object["slug"].clone()); + object.insert("object".to_string(), json!("model")); + object.insert("owned_by".to_string(), json!("openai")); + object.insert("api_formats".to_string(), json!(["openai:responses"])); + } + // 保留现有配置路由使用的旧模型标识。 + cards.extend(vec![ bundled_codex_model_card(BundledCodexModelCardSpec { model_id: "gpt-5.4", display_name: "GPT-5.4", @@ -556,8 +429,8 @@ pub fn bundled_codex_model_cards() -> &'static [Value] { default_verbosity: "low", supports_priority_tier: false, }), - bundled_codex_auto_review_model_card(), - ] + ]); + cards }) } @@ -2183,10 +2056,46 @@ mod tests { #[test] fn codex_client_user_agent_matches_originator_and_version() { let profile = crate::codex_client_profile(); - assert_eq!( - profile.user_agent, - format!("{}/{}", profile.originator, profile.codex_version) - ); + assert!(profile.user_agent.starts_with(&format!( + "{}/{} (", + profile.originator, profile.codex_version + ))); + assert!(profile.user_agent.ends_with(") unknown")); + } + + #[test] + fn bundled_latest_cli_models_preserve_capabilities_without_account_data() { + for model in ["gpt-6-astra", "gpt-6.1-sol"] { + let card = bundled_codex_model_cards() + .iter() + .find(|card| card["slug"] == model) + .unwrap(); + assert_eq!(card["context_window"], 272_000); + assert_eq!(card["max_context_window"], 872_000); + assert_eq!(card["comp_hash"], "3000"); + assert_eq!(card["minimal_client_version"], "0.153.0"); + assert_eq!( + card["supported_reasoning_levels"] + .as_array() + .unwrap() + .iter() + .map(|level| level["effort"].as_str().unwrap()) + .collect::>(), + ["low", "medium", "high", "xhigh", "max", "ultra"] + ); + for private in [ + "account_id", + "user_id", + "installation_id", + "cookie", + "authorization", + ] { + assert!( + card.get(private).is_none(), + "unexpected account field: {private}" + ); + } + } } #[test] @@ -2824,7 +2733,7 @@ mod tests { "codex-auto-review", None, ); - assert!(!capabilities.use_responses_lite); + assert!(capabilities.use_responses_lite); assert_eq!( capabilities.default_reasoning_effort.as_deref(), Some("medium") @@ -2832,7 +2741,7 @@ mod tests { assert_eq!(capabilities.default_reasoning_summary, None); assert!(capabilities.supports_parallel_tool_calls); assert_eq!(capabilities.default_verbosity.as_deref(), Some("low")); - assert!(capabilities.supported_service_tiers.is_empty()); + assert_eq!(capabilities.supported_service_tiers, vec!["priority"]); } #[test] diff --git a/crates/aether-ai/formats/src/formats/openai/responses/codex_models_0_159_3.json b/crates/aether-ai/formats/src/formats/openai/responses/codex_models_0_159_3.json new file mode 100644 index 000000000..8a313d328 --- /dev/null +++ b/crates/aether-ai/formats/src/formats/openai/responses/codex_models_0_159_3.json @@ -0,0 +1,963 @@ +[ + { + "slug": "gpt-6-astra", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": [ + "text", + "image" + ], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": "xhigh", + "use_responses_lite": true, + "supports_reasoning_effort_updates": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": true, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-6-Astra", + "description": "Frontier intelligence for the most demanding work.", + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.153.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 2, + "experimental_supported_tools": [ + "send_user_message_async", + "clock" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "2x speed, increased usage" + } + ], + "additional_speed_tiers": [ + "fast" + ], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-6.1-sol", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": [ + "text", + "image" + ], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": "xhigh", + "use_responses_lite": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": true, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-6.1-Sol", + "description": "Latest workhorse model for coding and everyday work.", + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.153.0", + "supported_in_api": true, + "availability_nux": { + "message": "Maximize usage with GPT-6.1 Sol. Try it on complex work for near-Astra performance at a lower cost." + }, + "upgrade": null, + "priority": 1, + "experimental_supported_tools": [ + "send_user_message_async", + "clock" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "2x speed, increased usage" + } + ], + "additional_speed_tiers": [ + "fast" + ], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true, + "supports_reasoning_effort_updates": true + }, + { + "slug": "gpt-6-sol", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": [ + "text", + "image" + ], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": true, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-6-Sol", + "description": "Previous generation workhorse model.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.155.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 3, + "experimental_supported_tools": [ + "send_user_message_async", + "clock" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": "priority", + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed" + } + ], + "additional_speed_tiers": [ + "fast" + ], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-6-luna", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": [ + "text", + "image" + ], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-6-Luna", + "description": "Fast and affordable model for easier tasks.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.155.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 4, + "experimental_supported_tools": [ + "send_user_message_async", + "clock" + ], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": "priority", + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed" + } + ], + "additional_speed_tiers": [ + "fast" + ], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-5.6-sol", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": [ + "text", + "image" + ], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "supports_reasoning_effort_updates": false, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": true, + "include_plugin_usage_instructions": true, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-5.6-Sol", + "description": "Older generation workhorse model.", + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.144.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": { + "model": "gpt-6-sol", + "migration_markdown": "Meet GPT-6 Sol\n\nOur latest Sol is more intelligent and more efficient so your usage limits go further. This model is a great daily driver for complex tasks, especially coding.\n", + "retirement_at": null + }, + "priority": 5, + "experimental_supported_tools": [], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed, increased usage" + } + ], + "additional_speed_tiers": [ + "fast" + ], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-5.6-terra", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": [ + "text", + "image" + ], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "supports_reasoning_effort_updates": false, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": true, + "include_plugin_usage_instructions": true, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-5.6-Terra", + "description": "Older balanced model for straightforward work.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.144.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": { + "model": "gpt-6-sol", + "migration_markdown": "Meet GPT-6 Sol\n\nOur latest Sol is more intelligent and more efficient so your usage limits go further. This model is a great daily driver for complex tasks, especially coding.\n", + "retirement_at": null + }, + "priority": 8, + "experimental_supported_tools": [], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed, increased usage" + } + ], + "additional_speed_tiers": [ + "fast" + ], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-5.6-luna", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": [ + "text", + "image" + ], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v1", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "supports_reasoning_effort_updates": false, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": true, + "include_plugin_usage_instructions": true, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "GPT-5.6-Luna", + "description": "Older fast and efficient model.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.144.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": { + "model": "gpt-6-luna", + "migration_markdown": "Meet GPT-6 Luna\n\nOur latest Luna is significantly more efficient, so your usage limits go even further. Reach for it for any job that doesn't require frontier intelligence.\n", + "retirement_at": null + }, + "priority": 9, + "experimental_supported_tools": [], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed, increased usage" + } + ], + "additional_speed_tiers": [ + "fast" + ], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-daybreak-blue-latest", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": [ + "text", + "image" + ], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "use_responses_lite": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": true, + "include_plugin_usage_instructions": true, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "auto_review_model_override": null, + "model_specialty": "cyber", + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "Daybreak Blue", + "description": "Latest frontier agentic coding model for broad defensive cybersecurity work.", + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "hide", + "minimal_client_version": "0.142.2", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 11, + "experimental_supported_tools": [], + "supports_search_tool": true, + "default_service_tier": null, + "service_tiers": [], + "additional_speed_tiers": [], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-daybreak-red-latest", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "high", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": [ + "text", + "image" + ], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "use_responses_lite": true, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "auto_review_model_override": null, + "model_specialty": "cyber", + "context_window": 372000, + "max_context_window": 372000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "Daybreak Red", + "description": "Cyber-permissive variant of our latest frontier agentic coding model for advanced, authorized cybersecurity research.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "shell_command", + "visibility": "hide", + "minimal_client_version": "0.142.2", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 12, + "experimental_supported_tools": [], + "supports_search_tool": true, + "default_service_tier": null, + "service_tiers": [], + "additional_speed_tiers": [], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "gpt-5.5", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": [ + "text", + "image" + ], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": null, + "multi_agent_version": null, + "multi_agent_reasoning_effort": null, + "use_responses_lite": false, + "supports_reasoning_effort_updates": false, + "include_skills_usage_instructions": true, + "include_apps_usage_instructions": true, + "include_plugin_usage_instructions": true, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 272000, + "auto_compact_token_limit": null, + "comp_hash": "2911", + "default_reasoning_summary": "none", + "display_name": "GPT-5.5", + "description": "Legacy coding model.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.124.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": { + "model": "gpt-6-sol", + "migration_markdown": "Meet GPT-6 Sol\n\nOur latest Sol is more intelligent and more efficient so your usage limits go further. This model is a great daily driver for complex tasks, especially coding.\n", + "retirement_at": null + }, + "priority": 13, + "experimental_supported_tools": [], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed, increased usage" + } + ], + "additional_speed_tiers": [ + "fast" + ], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + }, + { + "slug": "codex-auto-review", + "prefer_websockets": true, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "input_modalities": [ + "text", + "image" + ], + "supports_image_detail_original": true, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "tool_mode": "code_mode_only", + "multi_agent_version": "v1", + "multi_agent_reasoning_effort": null, + "use_responses_lite": true, + "supports_reasoning_effort_updates": false, + "include_skills_usage_instructions": false, + "include_apps_usage_instructions": false, + "include_plugin_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "requires_sandboxed_review": false, + "auto_review_model_override": null, + "model_specialty": null, + "context_window": 272000, + "max_context_window": 872000, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "default_reasoning_summary": "none", + "display_name": "Codex Auto Review", + "description": "Automatic approval review model for Codex.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + } + ], + "shell_type": "shell_command", + "visibility": "hide", + "minimal_client_version": "0.98.0", + "supported_in_api": true, + "availability_nux": null, + "upgrade": null, + "priority": 43, + "experimental_supported_tools": [], + "supports_search_tool": true, + "supports_experimental_context": false, + "default_service_tier": null, + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed, increased usage" + } + ], + "additional_speed_tiers": [ + "fast" + ], + "supports_reasoning_summary_parameter": true, + "supports_reasoning_summaries": true + } +] diff --git a/crates/aether-ai/formats/src/formats/shared/passthrough.rs b/crates/aether-ai/formats/src/formats/shared/passthrough.rs index 2d8c8ed82..6c72d1860 100644 --- a/crates/aether-ai/formats/src/formats/shared/passthrough.rs +++ b/crates/aether-ai/formats/src/formats/shared/passthrough.rs @@ -7,6 +7,7 @@ use crate::contracts::{ GEMINI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND, GEMINI_INTERACTIONS_STREAM_PLAN_KIND, GEMINI_INTERACTIONS_STREAM_SUCCESS_REPORT_KIND, GEMINI_INTERACTIONS_SYNC_PLAN_KIND, GEMINI_INTERACTIONS_SYNC_SUCCESS_REPORT_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND, + OPENAI_MEMORIES_SYNC_PLAN_KIND, OPENAI_MEMORIES_SYNC_SUCCESS_REPORT_KIND, OPENAI_REALTIME_STREAM_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_SEARCH_SYNC_PLAN_KIND, OPENAI_SEARCH_SYNC_SUCCESS_REPORT_KIND, }; @@ -29,6 +30,14 @@ pub struct LocalSameFormatProviderSpec { pub fn resolve_sync_spec(plan_kind: &str) -> Option { match plan_kind { + OPENAI_MEMORIES_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec { + api_format: "openai:responses", + decision_kind: OPENAI_MEMORIES_SYNC_PLAN_KIND, + report_kind: OPENAI_MEMORIES_SYNC_SUCCESS_REPORT_KIND, + family: LocalSameFormatProviderFamily::Standard, + require_streaming: false, + operation: Some(ApiOperation::OpenAiMemoriesSummarize), + }), CLAUDE_CHAT_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec { api_format: "claude:messages", decision_kind: CLAUDE_CHAT_SYNC_PLAN_KIND, @@ -260,4 +269,16 @@ mod tests { assert_eq!(spec.report_kind, "openai_search_sync_success"); assert!(!spec.require_streaming); } + + #[test] + fn memories_uses_responses_permissions_and_a_native_sync_operation() { + let spec = resolve_sync_spec("openai_memories_sync").expect("memory spec"); + assert_eq!(spec.api_format, "openai:responses"); + assert_eq!( + spec.operation, + Some(crate::ApiOperation::OpenAiMemoriesSummarize) + ); + assert_eq!(spec.report_kind, "openai_memories_sync_success"); + assert!(!spec.require_streaming); + } } diff --git a/crates/aether-ai/formats/src/formats/shared/routing.rs b/crates/aether-ai/formats/src/formats/shared/routing.rs index c85aec418..ab9f25717 100644 --- a/crates/aether-ai/formats/src/formats/shared/routing.rs +++ b/crates/aether-ai/formats/src/formats/shared/routing.rs @@ -11,13 +11,13 @@ use crate::contracts::{ GEMINI_INTERACTIONS_STREAM_PLAN_KIND, GEMINI_INTERACTIONS_SYNC_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND, - OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_REALTIME_STREAM_PLAN_KIND, - OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, - OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND, - OPENAI_RESPONSES_SYNC_PLAN_KIND, OPENAI_SEARCH_SYNC_PLAN_KIND, - OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND, - OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, - OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND, + OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_MEMORIES_SYNC_PLAN_KIND, + OPENAI_REALTIME_STREAM_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND, + OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, + OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND, + OPENAI_SEARCH_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, + OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, + OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND, }; use crate::formats::openai::image::request::is_openai_image_stream_request; @@ -193,6 +193,14 @@ pub fn resolve_execution_runtime_sync_plan_kind_with_client_surface( return None; } + if route_family == Some("openai") + && route_kind == Some("memories") + && *method == Method::POST + && path == "/v1/memories/trace_summarize" + { + return Some(OPENAI_MEMORIES_SYNC_PLAN_KIND); + } + if route_family == Some("openai") && route_kind == Some("video") && *method == Method::POST @@ -580,6 +588,7 @@ pub fn supports_sync_execution_decision_kind(plan_kind: &str) -> bool { matches!( plan_kind, OPENAI_CHAT_SYNC_PLAN_KIND + | OPENAI_MEMORIES_SYNC_PLAN_KIND | OPENAI_EMBEDDING_SYNC_PLAN_KIND | OPENAI_RERANK_SYNC_PLAN_KIND | OPENAI_SEARCH_SYNC_PLAN_KIND diff --git a/crates/aether-ai/formats/src/formats/shared/sync_products.rs b/crates/aether-ai/formats/src/formats/shared/sync_products.rs index 93fe07764..616777eb3 100644 --- a/crates/aether-ai/formats/src/formats/shared/sync_products.rs +++ b/crates/aether-ai/formats/src/formats/shared/sync_products.rs @@ -746,6 +746,9 @@ fn maybe_build_standard_same_format_sync_body( } let body_json = body_json?; + if report_kind == "openai_memories_sync_finalize" { + return Some(body_json.clone()); + } if is_error_like_sync_body(body_json) { return None; } @@ -1949,6 +1952,7 @@ fn is_openai_responses_finalize_kind(report_kind: &str) -> bool { fn standard_same_format_api_format(report_kind: &str) -> Option<&'static str> { match report_kind { + "openai_memories_sync_finalize" => Some("openai:responses"), "openai_chat_sync_finalize" => Some("openai:chat"), "claude_chat_sync_finalize" => Some("claude:messages"), "gemini_chat_sync_finalize" => Some("gemini:generate_content"), @@ -4163,6 +4167,24 @@ mod tests { use base64::Engine as _; use serde_json::json; + #[test] + fn native_memories_response_keeps_output_array_and_future_fields() { + let body = json!({"output":[{"trace_summary":"synthetic", "memory_summary":"memory"}], "future_response_field":{"enabled":true}}); + let context = json!({"provider_api_format":"openai:responses","client_api_format":"openai:responses","needs_conversion":false,"requested_model":"gpt-6.1-sol-max"}); + assert_eq!( + maybe_build_standard_same_format_sync_body_from_normalized_payload( + "openai_memories_sync_finalize", + 200, + Some(&context), + Some(&body), + None + ) + .expect("native response") + .expect("body"), + body + ); + } + #[test] fn converts_openai_images_sync_body_to_gemini_image_body() { let provider_body_json = json!({ diff --git a/crates/aether-model-fetch/src/logic.rs b/crates/aether-model-fetch/src/logic.rs index e0eb69759..a8fccfa86 100644 --- a/crates/aether-model-fetch/src/logic.rs +++ b/crates/aether-model-fetch/src/logic.rs @@ -1860,14 +1860,20 @@ mod tests { assert_eq!( model_ids, vec![ + "gpt-6-astra", + "gpt-6.1-sol", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", + "gpt-daybreak-blue-latest", + "gpt-daybreak-red-latest", "gpt-5.5", + "codex-auto-review", "gpt-5.4", "gpt-5.4-mini", "gpt-5.2", - "codex-auto-review", ] ); let sol = models @@ -1886,7 +1892,7 @@ mod tests { ); assert_eq!(sol["multi_agent_version"], "v2"); assert_eq!(sol["supports_image_detail_original"], true); - assert_eq!(sol["context_window"], 372_000); + assert_eq!(sol["context_window"], 272_000); for model_id in ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] { let model = models @@ -1898,7 +1904,7 @@ mod tests { assert_eq!(model["experimental_supported_tools"], json!([])); assert_eq!(model["tool_mode"], "code_mode_only"); assert_eq!(model["prefer_websockets"], true); - assert_eq!(model["reasoning_summary_format"], "experimental"); + assert!(model.get("reasoning_summary_format").is_none()); assert_eq!(model["truncation_policy"]["limit"], 10_000); assert_eq!(model["minimal_client_version"], "0.144.0"); assert!(model.get("effective_context_window_percent").is_none()); @@ -1924,7 +1930,7 @@ mod tests { assert_eq!(auto_review["supported_in_api"], true); assert_eq!(auto_review["default_reasoning_level"], "medium"); assert_eq!(auto_review["default_reasoning_summary"], "none"); - assert_eq!(auto_review["use_responses_lite"], false); + assert_eq!(auto_review["use_responses_lite"], true); } #[test] diff --git a/crates/aether-model-fetch/src/transport.rs b/crates/aether-model-fetch/src/transport.rs index cc1d165e7..3f71ae349 100644 --- a/crates/aether-model-fetch/src/transport.rs +++ b/crates/aether-model-fetch/src/transport.rs @@ -129,6 +129,12 @@ pub async fn build_standard_models_fetch_execution_plan_for_client_version( provider_type == "codex" && api_format.starts_with("openai:"); let is_deepseek_anthropic_models_fetch = api_format.starts_with("claude:") && deepseek_anthropic_models_fetch_uses_openai_auth(&transport.endpoint.base_url); + if is_codex_openai_models_fetch { + if let Some(version) = codex_client_version { + aether_ai_formats::CodexClientProfile::cli(version) + .map_err(|error| error.to_string())?; + } + } let mut headers = standard_models_fetch_headers(&api_format, &provider_type, codex_client_version); if is_codex_openai_models_fetch { @@ -636,10 +642,9 @@ fn standard_models_fetch_headers( return BTreeMap::from([ ( "user-agent".to_string(), - format!( - "{}/{client_version}", - aether_ai_formats::codex_client_originator() - ), + aether_ai_formats::CodexClientProfile::cli(client_version) + .expect("validated Codex catalog client version") + .user_agent, ), ( "originator".to_string(), @@ -1059,7 +1064,12 @@ mod tests { ); assert_eq!( plan.headers.get("user-agent").map(String::as_str), - Some("codex_cli_rs/0.145.2") + Some( + aether_ai_formats::CodexClientProfile::cli("0.145.2") + .unwrap() + .user_agent + .as_str() + ) ); assert_eq!( plan.headers.get("originator").map(String::as_str), diff --git a/crates/aether-oauth/src/provider/providers/codex.rs b/crates/aether-oauth/src/provider/providers/codex.rs index 6b64a48e4..a9313774f 100644 --- a/crates/aether-oauth/src/provider/providers/codex.rs +++ b/crates/aether-oauth/src/provider/providers/codex.rs @@ -3,6 +3,16 @@ use super::generic::{ }; use crate::provider::ProviderOAuthAdapter; +/// Codex CLI 的公开 OAuth 权限范围,不包含账户专属值。 +pub const CODEX_OAUTH_SCOPES: &[&str] = &[ + "openid", + "profile", + "email", + "offline_access", + "api.connectors.read", + "api.connectors.invoke", +]; + #[derive(Debug, Clone)] pub struct CodexProviderOAuthAdapter { inner: GenericProviderOAuthAdapter, @@ -45,6 +55,7 @@ impl ProviderOAuthAdapter for CodexProviderOAuthAdapter { query.append_pair("prompt", "login"); query.append_pair("id_token_add_organizations", "true"); query.append_pair("codex_cli_simplified_flow", "true"); + query.append_pair("originator", "codex_cli_rs"); } response.authorize_url = url.to_string(); Ok(response) @@ -156,6 +167,26 @@ mod tests { assert!(response .authorize_url .contains("codex_cli_simplified_flow=true")); + let url = url::Url::parse(&response.authorize_url).unwrap(); + let query = url.query_pairs().collect::>(); + assert_eq!( + query.get("scope").map(|value| value.as_ref()), + Some("openid profile email offline_access api.connectors.read api.connectors.invoke") + ); + assert_eq!( + query.get("originator").map(|value| value.as_ref()), + Some("codex_cli_rs") + ); + assert_eq!( + query + .get("code_challenge_method") + .map(|value| value.as_ref()), + Some("S256") + ); + assert_eq!( + query.get("state").map(|value| value.as_ref()), + Some("state-1") + ); } #[tokio::test] diff --git a/crates/aether-oauth/src/provider/providers/generic.rs b/crates/aether-oauth/src/provider/providers/generic.rs index cfe85b19d..42cf619fa 100644 --- a/crates/aether-oauth/src/provider/providers/generic.rs +++ b/crates/aether-oauth/src/provider/providers/generic.rs @@ -92,7 +92,7 @@ pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[ client_id: "app_EMoamEEZ73f0CkXaXp7hrann", client_id_env: None, client_secret_env: None, - scopes: &["openid", "email", "profile", "offline_access"], + scopes: super::codex::CODEX_OAUTH_SCOPES, redirect_uri: "http://localhost:1455/auth/callback", use_pkce: true, uses_json_payload: false, diff --git a/crates/aether-oauth/src/provider/providers/mod.rs b/crates/aether-oauth/src/provider/providers/mod.rs index f0cfeea68..69dfe6193 100644 --- a/crates/aether-oauth/src/provider/providers/mod.rs +++ b/crates/aether-oauth/src/provider/providers/mod.rs @@ -12,7 +12,7 @@ pub use claude_code::{ CLAUDE_CODE_COOKIE_SCOPE, CLAUDE_CODE_OAUTH_SCOPES, CLAUDE_CODE_PROVIDER_TYPE, CLAUDE_CODE_REDIRECT_URI, CLAUDE_CODE_TOKEN_URL, CLAUDE_CODE_WEB_BASE_URL, }; -pub use codex::CodexProviderOAuthAdapter; +pub use codex::{CodexProviderOAuthAdapter, CODEX_OAUTH_SCOPES}; pub use generic::{ derive_codex_identity_fingerprint, GenericProviderOAuthAdapter, GenericProviderOAuthTemplate, ANTIGRAVITY_OAUTH_CLIENT_ID_ENV, ANTIGRAVITY_OAUTH_CLIENT_SECRET_ENV, diff --git a/crates/aether-provider/transport/src/codex_fingerprint.rs b/crates/aether-provider/transport/src/codex_fingerprint.rs index ab0b4355a..1667aaba2 100644 --- a/crates/aether-provider/transport/src/codex_fingerprint.rs +++ b/crates/aether-provider/transport/src/codex_fingerprint.rs @@ -107,6 +107,12 @@ pub(crate) fn apply_codex_fingerprint_convergence_policy( } let is_responses = aether_ai_formats::is_openai_responses_format(provider_api_format); + if context.api_operation() == Some(aether_ai_formats::ApiOperation::OpenAiMemoriesSummarize) { + return ProviderOutboundRequestPolicyResult::skipped( + policy, + ProviderOutboundRequestPolicyReason::NativeOperationExcluded, + ); + } let is_live = aether_ai_formats::api_format_alias_matches(provider_api_format, "codex:live"); if !is_responses && !is_live { return ProviderOutboundRequestPolicyResult::skipped( @@ -567,6 +573,30 @@ mod tests { } } + #[test] + fn native_memories_excludes_responses_fingerprint_body_mutations() { + let transport = sample_transport(); + let context = + ProviderOutboundRequestContext::new("synthetic-memory-turn", 1_700_000_000_123) + .with_api_operation(aether_ai_formats::ApiOperation::OpenAiMemoriesSummarize); + let mut headers = BTreeMap::new(); + let mut body = json!({"model":"gpt-6.1-sol","traces":[],"future":42}); + let original = body.clone(); + let result = apply_codex_fingerprint_convergence_policy( + &transport, + "openai:responses", + &context, + &mut headers, + &mut body, + ); + assert_eq!( + result.reason, + ProviderOutboundRequestPolicyReason::NativeOperationExcluded + ); + assert_eq!(body, original); + assert!(headers.is_empty()); + } + #[test] fn provider_config_switch_is_opt_in_and_codex_only() { assert!(!codex_fingerprint_convergence_enabled("codex", None)); diff --git a/crates/aether-provider/transport/src/lib.rs b/crates/aether-provider/transport/src/lib.rs index 21411f369..0f12952e7 100644 --- a/crates/aether-provider/transport/src/lib.rs +++ b/crates/aether-provider/transport/src/lib.rs @@ -154,6 +154,7 @@ pub use rules::{ }; pub use same_format_provider::{ build_same_format_provider_headers, build_same_format_provider_request_body, + build_same_format_provider_request_body_for_operation, build_same_format_provider_request_body_with_compatibility_report, build_same_format_provider_request_body_with_compatibility_report_and_reasoning_replay_policy, build_same_format_provider_upstream_url, classify_same_format_provider_request_behavior, diff --git a/crates/aether-provider/transport/src/outbound_request_policy.rs b/crates/aether-provider/transport/src/outbound_request_policy.rs index d6948286d..397cd3fca 100644 --- a/crates/aether-provider/transport/src/outbound_request_policy.rs +++ b/crates/aether-provider/transport/src/outbound_request_policy.rs @@ -23,6 +23,7 @@ pub const PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES: usize = 256; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderOutboundRequestContext { + api_operation: Option, logical_turn_id: String, original_turn_id: Option, original_client_session_id: Option, @@ -33,6 +34,7 @@ pub struct ProviderOutboundRequestContext { impl ProviderOutboundRequestContext { pub fn new(logical_turn_id: impl Into, turn_started_at_unix_ms: u64) -> Self { Self { + api_operation: None, logical_turn_id: canonical_required_value(logical_turn_id.into(), "logical_turn_id"), original_turn_id: None, original_client_session_id: None, @@ -68,6 +70,15 @@ impl ProviderOutboundRequestContext { self.logical_turn_id.as_str() } + pub fn with_api_operation(mut self, operation: aether_ai_formats::ApiOperation) -> Self { + self.api_operation = Some(operation); + self + } + + pub fn api_operation(&self) -> Option { + self.api_operation + } + pub fn original_turn_id(&self) -> Option<&str> { self.original_turn_id.as_deref() } @@ -106,6 +117,7 @@ pub enum ProviderOutboundRequestPolicyReason { AgentIdentityExcluded, UnsupportedApiFormat, CompactOperationExcluded, + NativeOperationExcluded, Disabled, RequestBodyNotObject, } diff --git a/crates/aether-provider/transport/src/provider_types.rs b/crates/aether-provider/transport/src/provider_types.rs index cac278079..a40df9356 100644 --- a/crates/aether-provider/transport/src/provider_types.rs +++ b/crates/aether-provider/transport/src/provider_types.rs @@ -604,7 +604,7 @@ pub fn provider_type_admin_oauth_template(provider_type: &str) -> Option, ) -> bool { + if operation == Some(ApiOperation::OpenAiMemoriesSummarize) { + return aether_ai_formats::normalize_api_format_alias(provider_api_format) + == "openai:responses" + && !crate::kiro::is_kiro_provider_transport(transport) + && !crate::grok::is_grok_provider_transport(transport) + && !is_antigravity_provider_transport(transport) + && !is_gemini_cli_provider_transport(transport); + } if operation != Some(ApiOperation::ClaudeCountTokens) { return true; } @@ -1221,6 +1266,73 @@ mod tests { assert_eq!(url, "https://api.openai.example/v1/responses?tenant=demo"); } + #[test] + fn memories_url_respects_operation_templates_and_rejects_incompatible_paths() { + let params = TransportRequestUrlParams { + provider_api_format: "openai:responses", + mapped_model: Some("gpt-6.1-sol"), + upstream_is_stream: false, + request_query: Some("key=synthetic&tenant=demo"), + kiro_api_region: None, + api_operation: Some(ApiOperation::OpenAiMemoriesSummarize), + }; + let transport = sample_transport( + "codex", + "openai:responses", + "https://example.com", + Some("/native/{operation}"), + ); + assert_eq!( + build_transport_request_url(&transport, params).as_deref(), + Some("https://example.com/native/trace_summarize?tenant=demo") + ); + let incompatible = sample_transport( + "codex", + "openai:responses", + "https://example.com", + Some("/native/responses"), + ); + assert!(build_transport_request_url(&incompatible, params).is_none()); + for provider in ["kiro", "grok", "antigravity", "gemini_cli"] { + let private = + sample_transport(provider, "openai:responses", "https://example.com", None); + assert!( + build_transport_request_url(&private, params).is_none(), + "{provider}" + ); + } + } + + #[test] + fn memories_url_uses_the_configured_provider_root_and_removes_gateway_auth() { + for (provider, base, expected) in [ + ( + "codex", + "https://chatgpt.com/backend-api/codex", + "https://chatgpt.com/backend-api/codex/memories/trace_summarize?tenant=demo", + ), + ( + "custom", + "https://example.com/v1", + "https://example.com/v1/memories/trace_summarize?tenant=demo", + ), + ] { + let transport = sample_transport(provider, "openai:responses", base, None); + let result = build_transport_request_url( + &transport, + TransportRequestUrlParams { + provider_api_format: "openai:responses", + mapped_model: Some("gpt-6.1-sol"), + upstream_is_stream: false, + request_query: Some("key=synthetic-secret&tenant=demo"), + kiro_api_region: None, + api_operation: Some(ApiOperation::OpenAiMemoriesSummarize), + }, + ); + assert_eq!(result.as_deref(), Some(expected)); + } + } + #[test] fn builds_openai_search_url_for_codex_provider_root() { let transport = sample_transport( diff --git a/crates/aether-provider/transport/src/same_format_provider/mod.rs b/crates/aether-provider/transport/src/same_format_provider/mod.rs index 3f3fa6358..7f443498f 100644 --- a/crates/aether-provider/transport/src/same_format_provider/mod.rs +++ b/crates/aether-provider/transport/src/same_format_provider/mod.rs @@ -273,7 +273,10 @@ pub fn classify_same_format_provider_request_behavior_for_operation( ); let operation_requires_sync = matches!( api_operation, - Some(aether_ai_formats::ApiOperation::ClaudeCountTokens) + Some( + aether_ai_formats::ApiOperation::ClaudeCountTokens + | aether_ai_formats::ApiOperation::OpenAiMemoriesSummarize + ) ); let upstream_is_stream = !operation_requires_sync && aether_ai_formats::resolve_upstream_is_stream_for_provider( @@ -321,6 +324,7 @@ pub fn build_same_format_provider_request_body( input, None, aether_ai_formats::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds, + false, ) } @@ -337,11 +341,34 @@ pub fn build_same_format_provider_request_body_with_compatibility_report_and_rea input: SameFormatProviderRequestBodyInput<'_>, reasoning_replay_policy: aether_ai_formats::OpenAiResponsesReasoningReplayPolicy, ) -> Option { + build_same_format_provider_request_body_for_operation(input, reasoning_replay_policy, None) +} + +pub fn build_same_format_provider_request_body_for_operation( + input: SameFormatProviderRequestBodyInput<'_>, + reasoning_replay_policy: aether_ai_formats::OpenAiResponsesReasoningReplayPolicy, + api_operation: Option, +) -> Option { + let native_memories = + api_operation == Some(aether_ai_formats::ApiOperation::OpenAiMemoriesSummarize); + if native_memories + && (!aether_ai_formats::api_format_alias_matches( + input.provider_api_format, + "openai:responses", + ) || !aether_ai_formats::api_format_alias_matches( + input.client_api_format, + "openai:responses", + ) || input.kiro_auth_config.is_some() + || input.is_claude_code) + { + return None; + } let mut compatibility_edits = Vec::new(); let body = build_same_format_provider_request_body_inner( input, Some(&mut compatibility_edits), reasoning_replay_policy, + native_memories, )?; Some(SameFormatProviderRequestBodyOutput { body, @@ -355,7 +382,10 @@ pub fn enforce_same_format_provider_api_operation_body_policy( ) -> bool { if !matches!( api_operation, - Some(aether_ai_formats::ApiOperation::ClaudeCountTokens) + Some( + aether_ai_formats::ApiOperation::ClaudeCountTokens + | aether_ai_formats::ApiOperation::OpenAiMemoriesSummarize + ) ) { return false; } @@ -367,6 +397,7 @@ fn build_same_format_provider_request_body_inner( input: SameFormatProviderRequestBodyInput<'_>, mut compatibility_edits: Option<&mut Vec>, reasoning_replay_policy: aether_ai_formats::OpenAiResponsesReasoningReplayPolicy, + native_memories: bool, ) -> Option { if let Some(kiro_auth_config) = input.kiro_auth_config { let body = build_kiro_provider_request_body( @@ -523,6 +554,11 @@ fn build_same_format_provider_request_body_inner( "applied configured provider body rules", ); } + if native_memories { + // 记忆端点使用原生 JSON,不注入 Responses 的 input/store/include/stream, + // 也不通过 Responses 规则归一化 traces。 + return Some(provider_request_body); + } if matches!(input.family, SameFormatProviderFamily::Gemini) && aether_ai_formats::api_format_alias_matches( input.provider_api_format, @@ -992,6 +1028,39 @@ mod tests { }; use serde_json::json; + #[test] + fn memories_preserves_native_traces_and_does_not_apply_responses_stream_policy() { + let body = json!({"model": "memory-global", "reasoning": {"effort": "high"}, + "traces": [{"id": "synthetic-trace", "items": [{"future": 7}]}], + "future_request": {"opaque": [1, 2, 3]}}); + let input = SameFormatProviderRequestBodyInput { + body_json: &body, + mapped_model: "memory-upstream", + client_api_format: "openai:responses", + provider_api_format: "openai:responses", + source_model: Some("memory-global"), + family: SameFormatProviderFamily::Standard, + body_rules: None, + request_headers: None, + upstream_is_stream: false, + force_body_stream_field: true, + kiro_auth_config: None, + is_claude_code: false, + enable_model_directives: false, + }; + let result = build_same_format_provider_request_body_for_operation( + input, + aether_ai_formats::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds, + Some(aether_ai_formats::ApiOperation::OpenAiMemoriesSummarize), + ) + .unwrap(); + let mut expected = body.clone(); + expected["model"] = json!("memory-upstream"); + assert_eq!(result.body, expected); + assert!(result.body.get("input").is_none()); + assert!(result.body.get("stream").is_none()); + } + fn sample_transport(provider_type: &str) -> GatewayProviderTransportSnapshot { GatewayProviderTransportSnapshot { provider: GatewayProviderTransportProvider { diff --git a/crates/aether-usage/runtime/src/report.rs b/crates/aether-usage/runtime/src/report.rs index d6591116d..8ee4d916a 100644 --- a/crates/aether-usage/runtime/src/report.rs +++ b/crates/aether-usage/runtime/src/report.rs @@ -299,6 +299,7 @@ pub fn is_local_ai_sync_report_kind(report_kind: &str) -> bool { | "claude_chat_sync_error" | "gemini_chat_sync_error" | "openai_responses_sync_success" + | "openai_memories_sync_success" | "openai_responses_compact_sync_success" | "openai_responses_sync_error" | "openai_responses_compact_sync_error" @@ -946,6 +947,7 @@ mod tests { )); assert!(is_local_ai_sync_report_kind("openai_image_sync_success")); assert!(is_local_ai_sync_report_kind("openai_search_sync_success")); + assert!(is_local_ai_sync_report_kind("openai_memories_sync_success")); assert!(is_local_ai_sync_report_kind("openai_image_sync_error")); assert!(is_local_ai_sync_report_kind( "openai_embedding_sync_success" diff --git a/docs/operations/codex-cli-alignment.md b/docs/operations/codex-cli-alignment.md new file mode 100644 index 000000000..186b89127 --- /dev/null +++ b/docs/operations/codex-cli-alignment.md @@ -0,0 +1,43 @@ +# Codex CLI 通用协议对齐 + +本次对齐以官方 `openai/codex` 稳定标签 `rust-v0.159.3` 为可发布版本依据,同时核查最新 `main` 的协议实现。稳定标签提交为 `01fc69f4026735edfdf6789820549727a4867b11`,核查的 main 提交为 `444da310e108da16aaeb18fd790b0ac464f08aca`。 + +网关承接的是客户端与上游之间的协议,不复制 CLI 的本地工具执行、终端界面或个人账户状态。 + +| 对象 | 当前行为 | +| --- | --- | +| 客户端画像 | 默认版本更新为 0.159.3,现有后台 npm 稳定版本刷新继续生效;UA 按官方格式使用网关公开 OS、版本和架构,无终端时采用官方 `unknown` 标识 | +| OAuth | Codex 模板共享六项官方 scope,包含连接器读取及调用权限,并携带 `originator=codex_cli_rs`;其他 OAuth 类型保持独立模板 | +| 模型目录发现 | UA 与查询参数 `client_version` 使用同一版本;拒绝非法版本,保留现有保护头和运行时账户鉴权 | +| 模型能力 | 从官方公开模型目录提取能力快照,涵盖 `gpt-6-astra`、`gpt-6.1-sol`、六档推理强度及其他模型;账户返回的模型目录继续优先 | +| WebSocket 元数据 | 仅公开模型目录 ETag、轮次状态、实际模型与安全缓冲头;保留公开事件及未知公开字段、事件顺序 | +| 上游账户配额 | `codex.rate_limits` 继续进入账户级熔断与持久化路径,不当作网关用户自己的配额公开 | +| 原生记忆接口 | `POST /v1/memories/trace_summarize` 复用 Responses 权限及调度,执行原生同步操作,保留 traces、output 数组和未来字段,不注入 Responses 的 input/store/include 或流式默认值 | + +公开快照来源为 `codex-rs/models-manager/models.json`。未复制提示词或账户套餐可见性;保留官方公开的 `comp_hash` 压缩兼容性标记,以支持 CLI Guardian 的压缩上下文复用;没有引入个人用户标识、已有会话 UUID、Cookie、访问令牌或账户凭据。运行时鉴权和账户字段仍由提供商密钥配置产生。 + +新增模型能力快照不等于授权访问该模型。可用模型应通过正式管理界面的上游模型查询、全局模型和提供商模型配置,以及密钥模型限制来设置。远端目录及实际账户权限决定上游是否支持模型,不能通过修改 `/models` 列表绕过。 + +六档推理强度描述 CLI 的选择项。`ultra` 是 CLI 的本地多代理模式:官方 `ModelInfo::resolve_reasoning_effort` 会把普通推理请求转换为有效的 `multi_agent_reasoning_effort`,否则依次采用 `max`、最高非 `ultra` 档位或 `medium`;本地模式仍保持 `ultra`。网关保留模型卡片的相关公开能力,由 CLI 完成该转换,不在原始 Responses 请求中擅自把 `ultra` 等同于 `max`,也不实现客户端的多代理编排。直接发送 `reasoning.effort=ultra` 与 CLI 选择 `ultra` 的请求不同,上游可能拒绝前者。 + +现场使用校验过发行资产摘要的官方 `0.159.3` CLI,分别对 `gpt-6-astra` 和 `gpt-6.1-sol` 选择 `ultra`,均成功完成;网关使用记录确认请求档位为模型指定的 `xhigh`。两模型的原始 Responses `low`、`medium`、`high`、`xhigh`、`max` 调用也均完成。直接发送原始 `ultra` 时,现场上游候选返回 HTTP 400,按现有重试策略最终返回 503;该结果不作为 CLI 的 `ultra` 模式验收失败。 + +同一现场的 WebSocket 验收对两模型分别完成两轮 `generate=false` 预热和 `previous_response_id` 续接,接收到了公开完成事件与 Codex metadata;这项检查覆盖连接、元数据和续接,不等同于完整生成、工具调用或所有重连场景。验证使用正式提供商开关,完成后恢复原配置。 + +记忆接口是 CLI 可选功能对应的协议。是否启动记忆任务仍由 CLI 自己的 feature/config 决定。它要求提供商支持该原生端点;Kiro、Grok、Antigravity 和 Gemini CLI 等私有适配器不能通过格式标签冒充支持。配置自定义路径时须使用 `/memories/{operation}` 等模板;仅描述 Responses 的固定路径会被拒绝。 + +Apps 文件上传属于 CLI 的 ChatGPT Apps 专用路径,普通自定义 API 提供商不会启动该流程;本次不将其伪装成通用 Responses 路由。`aether-vscodex` 的 app-server UI 协议版本属于独立客户端,不覆盖成 CLI 版本。 + +验证命令: + +```bash +cargo fmt --all -- --check +RUST_MIN_STACK=16777216 cargo test --locked -p aether-ai-formats -p aether-oauth -p aether-model-fetch -p aether-provider-transport -p aether-usage-runtime --lib -- --test-threads=1 +RUST_MIN_STACK=16777216 cargo test --locked -p aether-gateway --lib -- --test-threads=1 +RUST_MIN_STACK=16777216 cargo test --locked -p aether-gateway --bins +RUST_MIN_STACK=16777216 cargo test --locked -p aether-gateway --test admin_unsigned_identity_headers --test architecture_guard +``` + +完整网关测试需要可执行的临时 PostgreSQL 工具(`AETHER_INITDB_BIN`、`AETHER_POSTGRES_BIN`、`AETHER_PG_CTL_BIN`)、权限受控的临时目录,以及不允许组写入的测试运行目录。Unix socket、更新元数据与私有运行文件测试会主动拒绝不安全的路径;测试目录应以 `umask 022` 或更严格的权限创建。 + +原生记忆端到端测试覆盖真实网关的权限、候选调度、执行计划、模型指令、原生 JSON、成功候选状态与明确停止重试策略下的上游错误响应;执行端使用本地测试服务器,实际账户网络可用性须在部署现场单独验证。 diff --git a/frontend/src/features/providers/composables/useProviderDisplayOrder.ts b/frontend/src/features/providers/composables/useProviderDisplayOrder.ts index b00451cd7..6f61c28e1 100644 --- a/frontend/src/features/providers/composables/useProviderDisplayOrder.ts +++ b/frontend/src/features/providers/composables/useProviderDisplayOrder.ts @@ -34,6 +34,8 @@ export function useProviderDisplayOrder( ? [...new Set(savedOrder.value.filter((providerId): providerId is string => typeof providerId === 'string'))] : []) + const hasCustomOrder = computed(() => normalizedOrder.value.length > 0) + const orderedProviders = computed(() => { const ranks = new Map(normalizedOrder.value.map((providerId, index) => [providerId, index])) return [...providers()].sort((first, second) => ( @@ -212,6 +214,7 @@ export function useProviderDisplayOrder( return { orderedProviders, + hasCustomOrder, draggingProvider, dragPreviewStyle, announcement, diff --git a/frontend/src/views/admin/ProviderManagement.vue b/frontend/src/views/admin/ProviderManagement.vue index 9f4ff6a8c..f250f66b2 100644 --- a/frontend/src/views/admin/ProviderManagement.vue +++ b/frontend/src/views/admin/ProviderManagement.vue @@ -576,7 +576,8 @@ function sortProvidersByActiveAndPriority(items: ProviderWithEndpointsSummary[]) const providerListRef = ref(null) const { - orderedProviders: displayedProviders, + orderedProviders, + hasCustomOrder, draggingProvider, dragPreviewStyle, announcement, @@ -587,6 +588,16 @@ const { sortItemClass, } = useProviderDisplayOrder(() => sortProvidersByActiveAndPriority(providers.value), providerListRef) +// 自定义展示顺序需要作用于整个列表,因此改为一次性拉取全量并在前端分页, +// 否则服务端分页切片会把拖到前面的供应商排除在当前页之外(换每页条数后顺序回退)。 +const DISPLAY_ORDER_FETCH_PAGE_SIZE = 10_000 +const localPaging = computed(() => hasCustomOrder.value) +const displayedProviders = computed(() => { + if (!localPaging.value) return orderedProviders.value + const start = (currentPage.value - 1) * pageSize.value + return orderedProviders.value.slice(start, start + pageSize.value) +}) + watch([loading, cardView, queryParams], cancelDrag) function startEditDescription(_event: Event, provider: ProviderWithEndpointsSummary) { @@ -645,7 +656,10 @@ async function loadProviders(options: { cacheTtlMs?: number } = {}) { const requestId = ++providersRequestId loading.value = true try { - const response = await getProvidersSummary(queryParams.value, { + const requestParams = localPaging.value + ? { ...queryParams.value, page: 1, page_size: DISPLAY_ORDER_FETCH_PAGE_SIZE } + : queryParams.value + const response = await getProvidersSummary(requestParams, { cacheTtlMs: options.cacheTtlMs ?? 0, }) if (requestId !== providersRequestId) return @@ -656,7 +670,8 @@ async function loadProviders(options: { cacheTtlMs?: number } = {}) { Object.assign(existing, item) return existing }) - total.value = response.total + // 前端分页模式下总数以实际拉取到的条目为准,避免切片越界 + total.value = localPaging.value ? response.items.length : response.total // 异步加载配置了 ops 的 provider 的余额数据 loadBalances(providers.value) } catch (err: unknown) { @@ -669,10 +684,26 @@ async function loadProviders(options: { cacheTtlMs?: number } = {}) { } } +// 首次建立自定义顺序时,当前页可能只是全量的一部分,需要补拉全量以支持前端分页 +watch(hasCustomOrder, (enabled) => { + if (enabled && providers.value.length < total.value) { + void loadProviders({ cacheTtlMs: PROVIDER_SUMMARY_CACHE_TTL_MS }) + } +}) + // 分页/筛选/搜索变化时重新加载 let debounceTimer: ReturnType | null = null watch(queryParams, (newParams, oldParams) => { if (debounceTimer) clearTimeout(debounceTimer) + // 前端分页模式下翻页/换每页条数只影响本地切片,无需重新请求 + const isPagingOnly = (newParams.page !== oldParams?.page || newParams.page_size !== oldParams?.page_size) && + newParams.search === oldParams?.search && + newParams.status === oldParams?.status && + newParams.api_format === oldParams?.api_format && + newParams.model_id === oldParams?.model_id + if (localPaging.value && isPagingOnly) { + return + } // 搜索输入 debounce 300ms,其他变化立即执行 const isSearchOnly = newParams.search !== oldParams?.search && newParams.page === oldParams?.page && diff --git a/frontend/src/views/admin/__tests__/ProviderManagement.card-view.spec.ts b/frontend/src/views/admin/__tests__/ProviderManagement.card-view.spec.ts index 39a7c8800..57e0014d1 100644 --- a/frontend/src/views/admin/__tests__/ProviderManagement.card-view.spec.ts +++ b/frontend/src/views/admin/__tests__/ProviderManagement.card-view.spec.ts @@ -528,32 +528,45 @@ describe('ProviderManagement shared display order', () => { expect(providerOrder(root)).toEqual(['provider-1', 'provider-2', 'provider-3', 'provider-4']) }) - it('preserves saved ordering on other pages when reordering the current page', async () => { - const providers = mockSortableProviders() - apiMocks.getProvidersSummary.mockImplementation(async ({ page }: { page: number }) => ({ - items: page === 1 ? providers.slice(0, 2) : providers.slice(2), - total: 40, + it('keeps the saved ordering across local pages after reordering the current page', async () => { + const providers = Array.from({ length: 12 }, (_, index) => createProvider({ + id: `provider-${index + 1}`, + name: `Provider ${index + 1}`, + provider_priority: (index + 1) * 10, })) + apiMocks.getProvidersSummary.mockImplementation(async (query: { page?: number, page_size?: number } = {}) => { + const page = query.page ?? 1 + const pageSize = query.page_size ?? 20 + return { + items: providers.slice((page - 1) * pageSize, page * pageSize), + total: providers.length, + } + }) + localStorage.setItem('provider-management-page-size', '10') const root = await mountView() - const firstDrag = startProviderDrag(root, 'provider-1', 'provider-2') - await dropProvider(firstDrag.handle) + const firstDrag = startProviderDrag(root, 'provider-2', 'provider-1') + await dropProvider(firstDrag.handle) + expect(providerOrder(root).slice(0, 2)).toEqual(['provider-2', 'provider-1']) + + const requestsBeforePaging = apiMocks.getProvidersSummary.mock.calls.length const secondPage = [...root.querySelectorAll('button')] .find(button => button.textContent?.trim() === '2')! secondPage.click() await settle() - expect(providerOrder(root)).toEqual(['provider-3', 'provider-4']) - const secondDrag = startProviderDrag(root, 'provider-4', 'provider-3') - await dropProvider(secondDrag.handle) - expect(providerOrder(root)).toEqual(['provider-4', 'provider-3']) + expect(providerOrder(root)).toEqual(['provider-11', 'provider-12']) + expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(requestsBeforePaging) const firstPage = [...root.querySelectorAll('button')] .find(button => button.textContent?.trim() === '1')! firstPage.click() await settle() - expect(providerOrder(root)).toEqual(['provider-2', 'provider-1']) + expect(providerOrder(root).slice(0, 2)).toEqual(['provider-2', 'provider-1']) expect(JSON.parse(localStorage.getItem('aether-provider-display-order')!)) - .toEqual(['provider-2', 'provider-1', 'provider-4', 'provider-3']) + .toEqual([ + 'provider-2', 'provider-1', 'provider-3', 'provider-4', 'provider-5', 'provider-6', + 'provider-7', 'provider-8', 'provider-9', 'provider-10', 'provider-11', 'provider-12', + ]) }) it('ignores stale IDs and appends providers that are not in the saved order', async () => { @@ -562,4 +575,36 @@ describe('ProviderManagement shared display order', () => { const root = await mountView() expect(providerOrder(root)).toEqual(['provider-3', 'provider-1', 'provider-2', 'provider-4']) }) + + it('keeps the dragged provider first after switching to a smaller page size', async () => { + const providers = Array.from({ length: 12 }, (_, index) => createProvider({ + id: `provider-${index + 1}`, + name: `Provider ${index + 1}`, + provider_priority: (index + 1) * 10, + })) + apiMocks.getProvidersSummary.mockImplementation(async (query: { page?: number, page_size?: number } = {}) => { + const page = query.page ?? 1 + const pageSize = query.page_size ?? 20 + return { + items: providers.slice((page - 1) * pageSize, page * pageSize), + total: providers.length, + } + }) + + localStorage.setItem('provider-management-page-size', '50') + let root = await mountView() + const { handle } = startProviderDrag(root, 'provider-12', 'provider-1') + await dropProvider(handle) + expect(providerOrder(root)[0]).toBe('provider-12') + + unmountView() + localStorage.setItem('provider-management-page-size', '10') + root = await mountView() + + expect(providerOrder(root)).toEqual([ + 'provider-12', + 'provider-1', 'provider-2', 'provider-3', 'provider-4', 'provider-5', + 'provider-6', 'provider-7', 'provider-8', 'provider-9', + ]) + }) })