对齐 Codex CLI 0.159.3 的通用画像、模型能力与原生协议

This commit is contained in:
MMEXA
2026-10-01 17:54:04 +08:00
parent 54fbcc25a1
commit 14befeda2c
38 changed files with 1938 additions and 383 deletions
@@ -101,7 +101,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
| aether_ai_formats::contracts::OPENAI_MEMORIES_SYNC_PLAN_KIND => {
build_passthrough_sync_plan_from_decision(parts, payload)?
}
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND => {
@@ -123,6 +123,8 @@ 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(aether_ai_formats::contracts::OPENAI_MEMORIES_SYNC_PLAN_KIND);
let provider_api_format = decision
.provider_api_format
.clone()
@@ -150,7 +152,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 +166,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 +268,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 +326,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 +353,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 +366,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 +401,17 @@ 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(aether_ai_formats::contracts::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,
@@ -255,7 +255,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 +277,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 +316,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 +601,14 @@ 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(
aether_ai_formats::formats::openai::responses::codex::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(),
@@ -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,
)
}
@@ -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,
+1
View File
@@ -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",
@@ -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",
@@ -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"}),
@@ -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"
@@ -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<Cow<'a, Value>>),
/// 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,
@@ -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::<Vec<_>>();
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<Cow<'_, Value>> {
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<String, Value> = 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;
@@ -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)
@@ -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("[email protected]".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("[email protected]".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::<serde_json::Value>::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,118 @@ 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}}));
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");
assert_eq!(error.status(), StatusCode::BAD_REQUEST);
assert_eq!(
error.json::<serde_json::Value>().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();
},
);
}