mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
对齐 Codex CLI 0.159.3 的通用画像、模型能力与原生协议
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user