fix(openai): align context and catalog tolerance

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-25 03:26:09 +08:00
co-authored by Codex
parent ef57b88066
commit aa3e2ddc8f
4 changed files with 132 additions and 58 deletions
@@ -165,6 +165,9 @@ pub enum CodexCatalogVisibility {
Hide,
/// 不进入 picker。
None,
/// 上游新增的可见性值;保留模型条目但不作本地可见性判断。
#[serde(other)]
Unknown,
}
/// 一个已完整校验的 Codex 真实模型。
@@ -1,6 +1,7 @@
//! 核心 Generate operation 到 Codex Responses wire request 的严格编码。
use gateway_core::operation::GenerateRequest;
use gateway_protocol::openai::WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
@@ -127,64 +128,37 @@ struct ExtractedRequestContext {
impl ExtractedRequestContext {
fn from_body(body: &Map<String, Value>) -> Self {
Self {
turn_state: body_or_metadata_string(
body,
&["turnState", "turn_state", "x-codex-turn-state"],
),
turn_metadata: body_or_metadata_string(
body,
&["turnMetadata", "turn_metadata", "x-codex-turn-metadata"],
),
beta_features: body_or_metadata_string(body, &["betaFeatures", "beta_features"]),
version: body_or_metadata_string(body, &["version"]),
include_timing_metrics: body_or_metadata_string(
body,
&["includeTimingMetrics", "include_timing_metrics"],
),
codex_window_id: body_or_metadata_string(
body,
&["codexWindowId", "codex_window_id", "x-codex-window-id"],
),
parent_thread_id: body_or_metadata_string(
body,
&[
"parentThreadId",
"parent_thread_id",
"x-codex-parent-thread-id",
],
),
conversation_id: body_or_metadata_string(body, &["conversation_id", "conversationId"]),
session_id: body_or_metadata_string(body, &["session_id", "sessionId"]),
thread_id: body_or_metadata_string(body, &["thread_id", "threadId"]),
client_request_id: body_or_metadata_string(
body,
&[
"x-client-request-id",
"client_request_id",
"clientRequestId",
],
),
turn_id: body_or_metadata_string(body, &["turn_id", "turnId", "x-codex-turn-id"]),
responses_lite: body_or_metadata_string(
body,
&["ws_request_header_x_openai_internal_codex_responses_lite"],
),
memgen_request: body_or_metadata_string(
body,
&["x-openai-memgen-request", "memgen_request"],
),
turn_state: body_string(body, "turnState"),
turn_metadata: body_string(body, "turnMetadata"),
beta_features: body_string(body, "betaFeatures"),
version: body_string(body, "version"),
include_timing_metrics: body_string(body, "includeTimingMetrics"),
codex_window_id: body_string(body, "codexWindowId"),
parent_thread_id: body_string(body, "parentThreadId"),
conversation_id: body_string(body, "conversation_id"),
session_id: body_string(body, "session_id"),
thread_id: body_string(body, "thread_id"),
client_request_id: body_string(body, "x-client-request-id"),
turn_id: body_string(body, "turn_id"),
// Responses Lite 在 WebSocket body 中的这个键是官方 header 投影,
// 不是普通上下文字段的 metadata 回退。
responses_lite: body
.get("client_metadata")
.and_then(Value::as_object)
.and_then(|metadata| {
non_empty_string(
metadata.get(WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY),
)
}),
// Memory consolidation 只由官方请求头提供;body 中同名字段不参与
// transport 事实提取。
memgen_request: None,
}
}
}
fn body_or_metadata_string(body: &Map<String, Value>, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| non_empty_string(body.get(*key)))
.or_else(|| {
let metadata = body.get("client_metadata")?.as_object()?;
keys.iter()
.find_map(|key| non_empty_string(metadata.get(*key)))
})
fn body_string(body: &Map<String, Value>, key: &str) -> Option<String> {
non_empty_string(body.get(key))
}
fn non_empty_string(value: Option<&Value>) -> Option<String> {
@@ -528,10 +502,12 @@ fn apply_protocol_context(request: &mut CodexResponsesRequest, context: &Map<Str
.parent_thread_id
.take()
.or_else(|| context_string(context, "parent_thread_id"));
let prompt_cache_key = request.prompt_cache_key().map(ToOwned::to_owned);
request.client_conversation_id = request
.client_conversation_id
.take()
.or_else(|| context_string(context, "conversation_id"));
.or_else(|| context_string(context, "conversation_id"))
.or(prompt_cache_key);
request.client_session_id = request
.client_session_id
.take()
@@ -1,9 +1,9 @@
use chrono::{TimeZone, Utc};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use provider_openai::transport::{
CodexBackendClient, CodexCatalogCapabilityEvidence, CodexClientError, CodexModelCatalogError,
CodexRequestContext, MAX_CODEX_MODEL_CATALOG_BYTES, build_reqwest_client,
parse_codex_model_catalog,
CodexBackendClient, CodexCatalogCapabilityEvidence, CodexCatalogVisibility, CodexClientError,
CodexModelCatalogError, CodexRequestContext, MAX_CODEX_MODEL_CATALOG_BYTES,
build_reqwest_client, parse_codex_model_catalog,
};
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -172,6 +172,20 @@ fn unknown_input_modality_should_degrade_capability_evidence_to_unknown() {
);
}
#[test]
fn unknown_visibility_should_degrade_without_rejecting_the_snapshot() {
let snapshot = parse_codex_model_catalog(
br#"{"models":[{"slug":"gpt-future","display_name":"Future","visibility":"preview_only"}]}"#,
None,
)
.expect("future visibility should not reject the snapshot");
assert_eq!(
snapshot.models()[0].metadata().visibility(),
Some(CodexCatalogVisibility::Unknown)
);
}
#[test]
fn invalid_request_slug_should_fail_the_entire_snapshot() {
let result = parse_codex_model_catalog(
@@ -126,6 +126,87 @@ fn encoder_should_forward_the_client_prompt_cache_key() {
);
}
#[test]
fn encoder_should_restore_conversation_fallback_after_body_and_context_values() {
let request = request(Map::from_iter([
("model".to_owned(), json!("client-model")),
("input".to_owned(), json!("prompt")),
("prompt_cache_key".to_owned(), json!("cache-conversation")),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
assert_eq!(
encoded.client_conversation_id.as_deref(),
Some("cache-conversation")
);
}
#[test]
fn encoder_should_ignore_legacy_context_aliases_and_metadata_fallbacks() {
let request = request(Map::from_iter([
("model".to_owned(), json!("client-model")),
("input".to_owned(), json!("prompt")),
("turn_state".to_owned(), json!("legacy-turn-state")),
(
"x-codex-turn-state".to_owned(),
json!("legacy-header-turn-state"),
),
("turn_metadata".to_owned(), json!("legacy-turn-metadata")),
("beta_features".to_owned(), json!("legacy-beta")),
("include_timing_metrics".to_owned(), json!("legacy-timing")),
("codex_window_id".to_owned(), json!("legacy-window")),
(
"x-codex-window-id".to_owned(),
json!("legacy-header-window"),
),
("parent_thread_id".to_owned(), json!("legacy-parent")),
(
"x-codex-parent-thread-id".to_owned(),
json!("legacy-header-parent"),
),
("conversationId".to_owned(), json!("legacy-conversation")),
("sessionId".to_owned(), json!("legacy-session")),
("threadId".to_owned(), json!("legacy-thread")),
("client_request_id".to_owned(), json!("legacy-request")),
("clientRequestId".to_owned(), json!("legacy-camel-request")),
("turnId".to_owned(), json!("legacy-turn")),
("x-codex-turn-id".to_owned(), json!("legacy-header-turn")),
(
"client_metadata".to_owned(),
json!({
"turnState": "metadata-turn-state",
"turnMetadata": "metadata-turn-metadata",
"conversation_id": "metadata-conversation",
"session_id": "metadata-session",
"thread_id": "metadata-thread",
"x-codex-window-id": "metadata-window"
}),
),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
assert_eq!(
(
encoded.turn_state,
encoded.turn_metadata,
encoded.beta_features,
encoded.include_timing_metrics,
encoded.codex_window_id,
encoded.parent_thread_id,
encoded.client_conversation_id,
encoded.client_session_id,
encoded.client_thread_id,
encoded.client_request_id,
encoded.client_turn_id,
),
(
None, None, None, None, None, None, None, None, None, None, None,
)
);
}
#[test]
fn encoder_should_project_explicit_websocket_transport_without_touching_body() {
let payload = ProtocolPayload::json_object(