fix(ws): harden Responses continuation state

This commit is contained in:
ZheFox
2026-08-20 08:51:16 +08:00
parent bef282cfee
commit 654f798d25
45 changed files with 6403 additions and 493 deletions
+3
View File
@@ -73,6 +73,7 @@ pub use crate::formats::openai::{
finalize_openai_provider_request,
finalize_openai_provider_request_with_codex_model_capabilities,
finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy,
finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy_for_websocket_continuation,
validate_openai_provider_request_contract, OpenAiProviderRequestContractViolation,
OpenAiProviderRequestFinalization,
},
@@ -188,6 +189,7 @@ pub use crate::formats::{
apply_codex_openai_responses_lite_header_with_capabilities,
apply_codex_openai_responses_special_body_edits,
apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities,
apply_codex_openai_responses_websocket_continuation_body_edits_with_source_model_and_capabilities,
apply_codex_openai_special_headers,
apply_openai_responses_compact_special_body_edits,
build_codex_model_catalog_metadata, parse_codex_auth_identity,
@@ -212,6 +214,7 @@ pub use crate::formats::{
standard_matrix::{
build_standard_request_body, build_standard_request_body_with_model_directives,
build_standard_request_body_with_model_directives_and_request_headers,
build_standard_request_body_with_model_directives_and_request_headers_and_reasoning_replay_policy,
},
standard_normalize::{
build_cross_format_openai_chat_request_body,
@@ -53,6 +53,37 @@ pub fn finalize_openai_provider_request_with_codex_model_capabilities_and_reason
finalization: OpenAiProviderRequestFinalization<'_>,
model_capabilities: Option<&super::responses::codex::CodexResponsesModelCapabilities>,
reasoning_replay_policy: super::responses::OpenAiResponsesReasoningReplayPolicy,
) -> Result<(), OpenAiProviderRequestContractViolation> {
finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy_inner(
body,
finalization,
model_capabilities,
reasoning_replay_policy,
false,
)
}
pub fn finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy_for_websocket_continuation(
body: &mut Value,
finalization: OpenAiProviderRequestFinalization<'_>,
model_capabilities: Option<&super::responses::codex::CodexResponsesModelCapabilities>,
reasoning_replay_policy: super::responses::OpenAiResponsesReasoningReplayPolicy,
) -> Result<(), OpenAiProviderRequestContractViolation> {
finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy_inner(
body,
finalization,
model_capabilities,
reasoning_replay_policy,
true,
)
}
fn finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy_inner(
body: &mut Value,
finalization: OpenAiProviderRequestFinalization<'_>,
model_capabilities: Option<&super::responses::codex::CodexResponsesModelCapabilities>,
reasoning_replay_policy: super::responses::OpenAiResponsesReasoningReplayPolicy,
websocket_continuation: bool,
) -> Result<(), OpenAiProviderRequestContractViolation> {
let is_codex_reasoning_endpoint = finalization
.provider_type
@@ -73,15 +104,27 @@ pub fn finalize_openai_provider_request_with_codex_model_capabilities_and_reason
.flatten();
match crate::normalize_api_format_alias(finalization.source_api_format).as_str() {
"openai:responses" | "openai:responses:compact" => {
super::responses::codex::apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities(
body,
finalization.provider_type,
finalization.provider_api_format,
finalization.provider_model,
finalization.source_model,
model_capabilities,
finalization.body_rules,
);
if websocket_continuation {
super::responses::codex::apply_codex_openai_responses_websocket_continuation_body_edits_with_source_model_and_capabilities(
body,
finalization.provider_type,
finalization.provider_api_format,
finalization.provider_model,
finalization.source_model,
model_capabilities,
finalization.body_rules,
);
} else {
super::responses::codex::apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities(
body,
finalization.provider_type,
finalization.provider_api_format,
finalization.provider_model,
finalization.source_model,
model_capabilities,
finalization.body_rules,
);
}
}
_ => {
super::responses::codex::apply_codex_openai_responses_chat_body_edits_with_source_model_and_capabilities(
@@ -1258,11 +1258,23 @@ fn is_codex_responses_lite_additional_tools_item(value: &Value) -> bool {
.is_some_and(|item_type| item_type == "additional_tools")
}
fn is_codex_responses_lite_static_additional_tools_item(value: &Value) -> bool {
is_codex_responses_lite_additional_tools_item(value)
&& value.get("role").and_then(Value::as_str) == Some("developer")
&& value.get("tools").is_some_and(Value::is_array)
}
fn codex_tool_type_accepts_top_level_name(tool_type: &str) -> bool {
matches!(tool_type, "function" | "custom" | "namespace")
}
fn is_codex_client_executed_tool(tool: &Value) -> bool {
/// Returns whether a tool is represented in the Responses Lite synthetic
/// `additional_tools` item and therefore becomes part of stored history.
///
/// Keep callers that compare raw client configuration with the synthetic wire
/// prefix on this predicate so hosted/server-executed tools do not create a
/// false configuration change.
pub fn codex_responses_lite_tool_is_client_executed(tool: &Value) -> bool {
match tool.get("type").and_then(Value::as_str) {
Some("function" | "custom" | "namespace") => true,
Some("tool_search") => tool.get("execution").and_then(Value::as_str) == Some("client"),
@@ -1277,7 +1289,7 @@ fn retain_codex_client_executed_tools(additional_tools: &mut Value) {
else {
return;
};
tools.retain(is_codex_client_executed_tool);
tools.retain(codex_responses_lite_tool_is_client_executed);
}
fn is_codex_responses_lite_instruction_item(value: &Value) -> bool {
@@ -1370,7 +1382,7 @@ fn apply_codex_responses_lite_body_contract(
.map(|tools| {
tools
.into_iter()
.filter(is_codex_client_executed_tool)
.filter(codex_responses_lite_tool_is_client_executed)
.collect::<Vec<_>>()
});
let top_level_instructions = body_object
@@ -1389,10 +1401,28 @@ fn apply_codex_responses_lite_body_contract(
// the Lite wire requirements below, but do not synthesize static config
// for a continuation.
if websocket_continuation {
for item in input
.iter_mut()
.filter(|item| !is_codex_responses_lite_additional_tools_item(item))
// A few compatible clients send the already-normalized Lite prefix
// instead of top-level fields. It is still inherited through
// `previous_response_id`, so remove every repeated leading prefix
// item. Limit this to the prefix: a later developer message can be
// genuine incremental input and must not be deleted by shape alone.
let mut repeated_prefix_len = 0;
while input
.get(repeated_prefix_len)
.is_some_and(is_codex_responses_lite_static_additional_tools_item)
{
repeated_prefix_len += 1;
if input
.get(repeated_prefix_len)
.is_some_and(is_codex_responses_lite_instruction_item)
{
repeated_prefix_len += 1;
}
}
if repeated_prefix_len > 0 {
input.drain(..repeated_prefix_len);
}
for item in input.iter_mut() {
strip_codex_responses_lite_image_details(item);
}
body_object.insert("parallel_tool_calls".to_string(), json!(false));
@@ -1719,6 +1749,50 @@ pub fn apply_codex_openai_responses_special_body_edits_with_source_model_and_cap
source_model: &str,
model_capabilities: Option<&CodexResponsesModelCapabilities>,
body_rules: Option<&Value>,
) {
apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities_inner(
provider_request_body,
provider_type,
provider_api_format,
provider_model,
source_model,
model_capabilities,
body_rules,
false,
);
}
pub fn apply_codex_openai_responses_websocket_continuation_body_edits_with_source_model_and_capabilities(
provider_request_body: &mut Value,
provider_type: &str,
provider_api_format: &str,
provider_model: &str,
source_model: &str,
model_capabilities: Option<&CodexResponsesModelCapabilities>,
body_rules: Option<&Value>,
) {
apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities_inner(
provider_request_body,
provider_type,
provider_api_format,
provider_model,
source_model,
model_capabilities,
body_rules,
true,
);
}
#[allow(clippy::too_many_arguments)]
fn apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities_inner(
provider_request_body: &mut Value,
provider_type: &str,
provider_api_format: &str,
provider_model: &str,
source_model: &str,
model_capabilities: Option<&CodexResponsesModelCapabilities>,
body_rules: Option<&Value>,
websocket_continuation: bool,
) {
if !is_codex_openai_responses_request(provider_type, provider_api_format) {
return;
@@ -1728,21 +1802,8 @@ pub fn apply_codex_openai_responses_special_body_edits_with_source_model_and_cap
return;
};
// HTTP Responses bodies do not carry an event `type`. Preserve this
// marker only for an actual WebSocket response.create continuation so it
// survives both normalization/finalization passes. The framing layer owns
// the field and will forward it verbatim to the already-bound upstream.
let websocket_continuation = body_object.get("type").and_then(Value::as_str)
== Some("response.create")
&& body_object
.get("previous_response_id")
.is_some_and(|value| !value.is_null());
wrap_codex_responses_string_input_for_backend(body_object, provider_api_format);
for field in CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS {
if *field == "previous_response_id" && websocket_continuation {
continue;
}
if !body_rules_handle_path(body_rules, field) {
body_object.remove(*field);
}
@@ -2052,6 +2113,7 @@ mod tests {
apply_codex_openai_responses_lite_header_with_capabilities,
apply_codex_openai_responses_special_body_edits,
apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities,
apply_codex_openai_responses_websocket_continuation_body_edits_with_source_model_and_capabilities,
apply_codex_openai_special_headers, apply_openai_responses_compact_special_body_edits,
build_codex_model_catalog_metadata, bundled_codex_model_cards, effective_codex_model_cards,
project_codex_catalog_model_card, resolve_codex_responses_model_capabilities,
@@ -3257,21 +3319,89 @@ mod tests {
"input": incremental_input.clone()
});
apply_codex_openai_responses_special_body_edits(
apply_codex_openai_responses_websocket_continuation_body_edits_with_source_model_and_capabilities(
&mut provider_request_body,
"codex",
"openai:responses",
"gpt-5.6-sol",
"gpt-5.6-sol",
None,
None,
);
assert_eq!(provider_request_body["previous_response_id"], "resp_123");
assert!(provider_request_body.get("previous_response_id").is_none());
assert!(provider_request_body.get("instructions").is_none());
assert!(provider_request_body.get("tools").is_none());
assert_eq!(provider_request_body["input"], incremental_input);
assert_eq!(provider_request_body["parallel_tool_calls"], false);
let once = provider_request_body.clone();
apply_codex_openai_responses_websocket_continuation_body_edits_with_source_model_and_capabilities(
&mut provider_request_body,
"codex",
"openai:responses",
"gpt-5.6-sol",
"gpt-5.6-sol",
None,
None,
);
assert_eq!(provider_request_body, once);
}
#[test]
fn codex_responses_lite_websocket_continuation_removes_only_the_static_prefix() {
let later_developer_message = json!({
"type": "message",
"role": "developer",
"content": [{"type": "input_text", "text": "genuine incremental input"}]
});
let mut provider_request_body = json!({
"model": "gpt-5.6-sol",
"input": [
{
"type": "additional_tools",
"role": "developer",
"tools": [{"type": "function", "name": "lookup", "parameters": {}}]
},
{
"type": "message",
"role": "developer",
"content": [{"type": "input_text", "text": "static instructions"}]
},
{"type": "function_call_output", "call_id": "call_1", "output": "ok"},
later_developer_message.clone()
]
});
apply_codex_openai_responses_websocket_continuation_body_edits_with_source_model_and_capabilities(
&mut provider_request_body,
"codex",
"openai:responses",
"gpt-5.6-sol",
"gpt-5.6-sol",
None,
None,
);
let input = provider_request_body["input"]
.as_array()
.expect("continuation input");
assert_eq!(input.len(), 2);
assert_eq!(input[0]["type"], "function_call_output");
assert_eq!(input[1], later_developer_message);
}
#[test]
fn codex_responses_lite_http_body_cannot_opt_into_websocket_continuation_edits() {
let mut provider_request_body = json!({
"type": "response.create",
"model": "gpt-5.6-sol",
"previous_response_id": "resp_123",
"instructions": "Static developer instructions.",
"tools": [{"type": "function", "name": "lookup", "parameters": {}}],
"input": [{"type": "message", "role": "user", "content": []}]
});
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
"codex",
@@ -3279,7 +3409,13 @@ mod tests {
None,
None,
);
assert_eq!(provider_request_body, once);
assert!(provider_request_body.get("previous_response_id").is_none());
assert_eq!(
provider_request_body["input"][0]["type"],
"additional_tools"
);
assert_eq!(provider_request_body["input"][1]["role"], "developer");
}
#[test]
@@ -63,6 +63,20 @@ pub fn strip_incompatible_openai_responses_reasoning_items_with_policy(
if !aether_ai_formats::is_openai_responses_family_format(provider_api_format) {
return 0;
}
// DeepSeek's id-less opaque state is valid only on the normal Responses
// continuation contract. Both the legacy Compact endpoint and the current
// `compaction_trigger` operation must retain the strict OpenAI item-id
// replay rules even when the same provider key serves ordinary Responses.
let normal_responses =
aether_ai_formats::normalize_api_format_alias(provider_api_format) == "openai:responses";
let compact_operation = openai_responses_request_operation(provider_api_format, body).is_some();
let policy = if policy == OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
&& (!normal_responses || compact_operation)
{
OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds
} else {
policy
};
let Some(items) = body.get_mut("input").and_then(Value::as_array_mut) else {
return 0;
};
@@ -121,10 +135,7 @@ fn deepseek_opaque_reasoning_item_is_replayable(object: &serde_json::Map<String,
.is_some_and(|content| {
content.iter().any(|part| {
part.get("type").and_then(Value::as_str) == Some("reasoning_text")
&& part
.get("text")
.and_then(Value::as_str)
.is_some_and(|text| !text.is_empty())
&& part.get("text").is_some_and(Value::is_string)
})
});
has_encrypted_content && has_reasoning_text
@@ -331,6 +342,74 @@ mod tests {
assert_eq!(body["input"].as_array().map(Vec::len), Some(0));
}
#[test]
fn deepseek_policy_preserves_empty_reasoning_text_with_opaque_state() {
let mut body = json!({
"input": [{
"type": "reasoning",
"encrypted_content": "opaque-state",
"content": [{"type": "reasoning_text", "text": ""}],
"future_capability": {"preserve": true}
}]
});
assert_eq!(
strip_incompatible_openai_responses_reasoning_items_with_policy(
&mut body,
"openai:responses",
OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque,
),
0
);
assert_eq!(body["input"][0]["content"][0]["text"], "");
assert_eq!(body["input"][0]["future_capability"]["preserve"], true);
}
#[test]
fn deepseek_policy_rejects_non_string_reasoning_text() {
let mut body = json!({
"input": [{
"type": "reasoning",
"encrypted_content": "opaque-state",
"content": [{"type": "reasoning_text", "text": {"not": "text"}}]
}]
});
assert_eq!(
strip_incompatible_openai_responses_reasoning_items_with_policy(
&mut body,
"openai:responses",
OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque,
),
1
);
assert_eq!(body["input"].as_array().map(Vec::len), Some(0));
}
#[test]
fn deepseek_policy_keeps_strict_replay_for_compaction_trigger_operation() {
let mut body = json!({
"input": [
{
"type": "reasoning",
"encrypted_content": "opaque-state",
"content": [{"type": "reasoning_text", "text": "thinking"}]
},
{"type": "compaction_trigger"}
]
});
assert_eq!(
strip_incompatible_openai_responses_reasoning_items_with_policy(
&mut body,
"openai:responses",
OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque,
),
1
);
assert_eq!(body["input"], json!([{"type": "compaction_trigger"}]));
}
#[test]
fn deepseek_policy_does_not_preserve_opaque_item_with_foreign_id() {
let mut body = json!({
@@ -73,7 +73,6 @@ pub fn build_standard_request_body_with_model_directives(
user_api_key_id,
None,
enable_model_directives,
crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds,
)
}
@@ -90,6 +89,36 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers(
user_api_key_id: Option<&str>,
request_headers: Option<&http::HeaderMap>,
enable_model_directives: bool,
) -> Option<Value> {
build_standard_request_body_with_model_directives_and_request_headers_and_reasoning_replay_policy(
body_json,
client_api_format,
mapped_model,
provider_type,
provider_api_format,
request_path,
upstream_is_stream,
body_rules,
user_api_key_id,
request_headers,
enable_model_directives,
crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds,
)
}
#[allow(clippy::too_many_arguments)]
pub fn build_standard_request_body_with_model_directives_and_request_headers_and_reasoning_replay_policy(
body_json: &Value,
client_api_format: &str,
mapped_model: &str,
provider_type: &str,
provider_api_format: &str,
request_path: &str,
upstream_is_stream: bool,
body_rules: Option<&Value>,
user_api_key_id: Option<&str>,
request_headers: Option<&http::HeaderMap>,
enable_model_directives: bool,
reasoning_replay_policy: crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy,
) -> Option<Value> {
let mut format_context = FormatContext::default()
@@ -104,26 +133,29 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers(
client_api_format,
provider_api_format,
);
// A same-family Responses hop is a wire-preserving route. Parsing through the
// canonical request model here would intentionally discard provider-owned
// input item fields (for example DeepSeek's id-less `reasoning_text` and
// future opaque capability fields), even though no format conversion is
// required. Keep the original object and only rewrite the routing model;
// the normal provider-contract and compatibility passes below still apply.
let mut provider_request_body =
if is_same_openai_responses_family(source_api_format.as_ref(), provider_api_format) {
let mut object = body_json.as_object()?.clone();
object.insert("model".to_string(), Value::String(mapped_model.to_string()));
Value::Object(object)
} else {
convert_request(
source_api_format.as_ref(),
provider_api_format,
body_json,
&format_context,
)
.ok()?
};
// DeepSeek's Responses continuation state is opaque. Parsing a same-wire-format
// request through the canonical model would discard its id-less `reasoning_text`
// items and future provider-owned fields even though no conversion is required.
// Keep that provider-specific route wire-preserving, while retaining canonical
// normalization for ordinary OpenAI Responses and for Responses/Compact
// cross-format conversions.
let mut provider_request_body = if is_wire_preserving_deepseek_responses_hop(
source_api_format.as_ref(),
provider_api_format,
reasoning_replay_policy,
) {
let mut object = body_json.as_object()?.clone();
object.insert("model".to_string(), Value::String(mapped_model.to_string()));
Value::Object(object)
} else {
convert_request(
source_api_format.as_ref(),
provider_api_format,
body_json,
&format_context,
)
.ok()?
};
if enable_model_directives {
apply_model_directive_overrides_from_request(
@@ -192,14 +224,19 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers(
Some(provider_request_body)
}
fn is_same_openai_responses_family(source_api_format: &str, provider_api_format: &str) -> bool {
matches!(
aether_ai_formats::normalize_api_format_alias(source_api_format).as_str(),
"openai:responses" | "openai:responses:compact"
) && matches!(
aether_ai_formats::normalize_api_format_alias(provider_api_format).as_str(),
"openai:responses" | "openai:responses:compact"
)
fn is_wire_preserving_deepseek_responses_hop(
source_api_format: &str,
provider_api_format: &str,
reasoning_replay_policy: crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy,
) -> bool {
if reasoning_replay_policy
!= crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
{
return false;
}
let source_api_format = aether_ai_formats::normalize_api_format_alias(source_api_format);
let provider_api_format = aether_ai_formats::normalize_api_format_alias(provider_api_format);
source_api_format == "openai:responses" && source_api_format == provider_api_format
}
fn compatible_source_format_for_standard_request<'a>(
@@ -378,7 +415,7 @@ mod tests {
use super::{
build_standard_request_body, build_standard_request_body_from_canonical,
build_standard_request_body_with_model_directives,
build_standard_request_body_with_model_directives_and_request_headers,
build_standard_request_body_with_model_directives_and_request_headers_and_reasoning_replay_policy,
normalize_standard_request_to_openai_chat_request,
};
use crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy;
@@ -549,7 +586,7 @@ mod tests {
"future_request_field": {"preserve": true}
});
let preserved = build_standard_request_body_with_model_directives_and_request_headers(
let preserved = build_standard_request_body_with_model_directives_and_request_headers_and_reasoning_replay_policy(
&request,
"openai:responses",
"deepseek-v4-flash",
@@ -580,7 +617,7 @@ mod tests {
"dynamic"
);
let strict = build_standard_request_body_with_model_directives_and_request_headers(
let strict = build_standard_request_body_with_model_directives_and_request_headers_and_reasoning_replay_policy(
&request,
"openai:responses",
"deepseek-v4-flash",
@@ -604,6 +641,32 @@ mod tests {
.count(),
0
);
let compact = build_standard_request_body_with_model_directives_and_request_headers_and_reasoning_replay_policy(
&request,
"openai:responses:compact",
"deepseek-v4-flash",
"custom",
"openai:responses:compact",
"/v1/responses/compact",
false,
None,
None,
None,
false,
OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque,
)
.expect("Compact body should continue through canonical normalization");
assert_eq!(
compact["input"]
.as_array()
.expect("compact input array")
.iter()
.filter(|item| item["type"] == "reasoning")
.count(),
0,
"compact must not use the DeepSeek opaque replay policy"
);
}
#[test]
@@ -3184,6 +3184,13 @@ fn materialize_openai_responses_reasoning_item(
state: OpenAIResponsesSyncReasoningState,
) -> Value {
let mut item = state.item;
let has_provider_opaque_state = item
.get("encrypted_content")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty());
if has_provider_opaque_state {
return Value::Object(item);
}
item.entry("type".to_string())
.or_insert_with(|| Value::String("reasoning".to_string()));
item.entry("id".to_string()).or_insert_with(|| {
@@ -3939,7 +3946,7 @@ mod tests {
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
aggregate_openai_chat_stream_sync_response,
aggregate_openai_responses_stream_sync_response, convert_standard_chat_response,
convert_standard_cli_response,
convert_standard_cli_response, materialize_openai_responses_reasoning_item,
maybe_build_openai_chat_cross_format_sync_product_from_normalized_payload,
maybe_build_openai_responses_cross_format_sync_product_from_normalized_payload,
maybe_build_openai_responses_same_family_sync_body_from_normalized_payload,
@@ -3947,7 +3954,9 @@ mod tests {
maybe_build_standard_cross_format_sync_product_from_normalized_payload,
maybe_build_standard_same_format_sync_body_from_normalized_payload,
maybe_build_standard_sync_finalize_product_from_normalized_payload,
try_aggregate_openai_responses_stream_sync_response, StandardSyncFinalizeNormalizedProduct,
openai_responses_synthetic_reasoning_item_id,
try_aggregate_openai_responses_stream_sync_response, OpenAIResponsesSyncReasoningState,
StandardSyncFinalizeNormalizedProduct,
};
use aether_ai_formats::formats::conversion::response::{
convert_claude_chat_response_to_openai_chat, convert_gemini_chat_response_to_openai_chat,
@@ -5336,6 +5345,85 @@ mod tests {
assert!(result["completed_at"].as_i64().is_some());
}
#[test]
fn preserves_idless_provider_opaque_reasoning_item_during_materialization() {
let original = json!({
"type": "reasoning",
"encrypted_content": "opaque-provider-state",
"content": [{
"type": "reasoning_text",
"text": "private chain of thought"
}],
"summary": [{
"type": "provider_summary",
"text": "provider-owned summary"
}],
"future_provider_field": {"version": 2}
});
let state = OpenAIResponsesSyncReasoningState {
item: original
.as_object()
.expect("reasoning item should be an object")
.clone(),
summary_text: "must not replace provider-owned state".to_string(),
};
let materialized = materialize_openai_responses_reasoning_item("resp_opaque_123", state);
assert_eq!(materialized, original);
assert!(materialized.get("id").is_none());
assert!(materialized.get("status").is_none());
}
#[test]
fn aggregates_authoritative_provider_opaque_reasoning_item_without_mutation() {
let body = concat!(
"event: response.output_item.added\n",
"data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"reasoning\",\"encrypted_content\":\"opaque-provider-state\",\"content\":[]}}\n\n",
"event: response.reasoning_text.done\n",
"data: {\"type\":\"response.reasoning_text.done\",\"output_index\":0,\"text\":\"provider reasoning\"}\n\n",
"event: response.output_item.done\n",
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"reasoning\",\"encrypted_content\":\"opaque-provider-state\",\"content\":[{\"type\":\"reasoning_text\",\"text\":\"provider reasoning\"}],\"future_provider_field\":{\"version\":2}}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_opaque_stream_123\",\"object\":\"response\",\"model\":\"deepseek-reasoner\",\"status\":\"completed\",\"output\":[]}}\n\n",
);
let result = aggregate_openai_responses_stream_sync_response(body.as_bytes())
.expect("provider opaque reasoning stream should aggregate");
assert_eq!(
result["output"][0],
json!({
"type": "reasoning",
"encrypted_content": "opaque-provider-state",
"content": [{
"type": "reasoning_text",
"text": "provider reasoning"
}],
"future_provider_field": {"version": 2}
})
);
}
#[test]
fn synthesizes_wire_compatible_id_for_local_reasoning_summary() {
let state = OpenAIResponsesSyncReasoningState {
item: json!({"type": "reasoning"})
.as_object()
.expect("reasoning item should be an object")
.clone(),
summary_text: "Need care".to_string(),
};
let materialized = materialize_openai_responses_reasoning_item("resp_summary_123", state);
assert_eq!(
materialized["id"],
openai_responses_synthetic_reasoning_item_id("resp_summary_123", 0)
);
assert_eq!(materialized["summary"][0]["text"], "Need care");
}
#[test]
fn accepts_openai_responses_same_family_stream_when_needs_conversion_is_true() {
let body = concat!(
+3 -1
View File
@@ -40,11 +40,13 @@ pub use formats::openai::request_contract::{
finalize_openai_provider_request,
finalize_openai_provider_request_with_codex_model_capabilities,
finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy,
finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy_for_websocket_continuation,
validate_openai_provider_request_contract, OpenAiProviderRequestContractViolation,
OpenAiProviderRequestFinalization,
};
pub use formats::openai::responses::codex::{
build_codex_model_catalog_metadata, bundled_codex_model_cards, effective_codex_model_cards,
build_codex_model_catalog_metadata, bundled_codex_model_cards,
codex_responses_lite_tool_is_client_executed, effective_codex_model_cards,
parse_codex_auth_identity, project_codex_catalog_model_card,
resolve_codex_responses_model_capabilities, CodexAuthIdentity, CodexResponsesModelCapabilities,
CODEX_CLIENT_ORIGINATOR, CODEX_CLIENT_USER_AGENT, CODEX_CLIENT_VERSION,
@@ -1,5 +1,5 @@
use std::{
collections::{BTreeMap, HashSet},
collections::{BTreeMap, BTreeSet, HashSet},
sync::OnceLock,
};
@@ -193,6 +193,73 @@ pub fn body_rules_have_enabled_rules(rules: Option<&Value>) -> bool {
.any(|rule| rule.as_object().is_some_and(body_rule_is_enabled))
}
/// Returns the normalized request-header names that can affect enabled body
/// rules.
///
/// Callers that persist a body-normalization identity should bind only these
/// values, rather than every incidental ingress header. Dynamic proxy headers
/// such as request IDs and forwarding hops do not change the provider body
/// unless an enabled, locally supported condition actually reads them.
pub fn body_rules_request_header_dependencies(rules: Option<&Value>) -> BTreeSet<String> {
let Some(rules) = rules.and_then(Value::as_array) else {
return BTreeSet::new();
};
let mut dependencies = BTreeSet::new();
for rule in rules {
let Some(rule) = rule.as_object() else {
continue;
};
if !body_rule_is_enabled(rule) {
continue;
}
let Some(condition) = rule.get("condition").filter(|value| !value.is_null()) else {
continue;
};
if condition_is_locally_supported(condition) {
collect_condition_request_header_dependencies(condition, &mut dependencies);
}
}
dependencies
}
fn collect_condition_request_header_dependencies(
condition: &Value,
dependencies: &mut BTreeSet<String>,
) {
let Some(condition) = condition.as_object() else {
return;
};
if let Some(children) = condition.get("all").and_then(Value::as_array) {
for child in children {
collect_condition_request_header_dependencies(child, dependencies);
}
return;
}
if let Some(children) = condition.get("any").and_then(Value::as_array) {
for child in children {
collect_condition_request_header_dependencies(child, dependencies);
}
return;
}
let source = condition
.get("source")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("body");
if !condition_source_is_headers(source) {
return;
}
if let Some(path) = condition
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|path| !path.is_empty())
{
dependencies.insert(path.to_ascii_lowercase());
}
}
pub fn body_rules_handle_path(rules: Option<&Value>, path: &str) -> bool {
let Some(target_path) = parse_body_path(path) else {
return false;
@@ -245,7 +312,7 @@ pub fn apply_local_body_rules(
rules: Option<&Value>,
original_body: Option<&Value>,
) -> bool {
apply_local_body_rules_inner(body, rules, original_body, None)
apply_local_body_rules_inner(body, rules, original_body, None, None)
}
pub fn apply_local_body_rules_with_request_headers(
@@ -259,14 +326,56 @@ pub fn apply_local_body_rules_with_request_headers(
rules,
original_body,
request_headers.map(ConditionHeaders::Request),
None,
)
}
/// Applies body rules and reports whether an enabled, condition-matching rule
/// actually handled `path`.
///
/// This differs from [`body_rules_handle_path`], which intentionally answers a
/// static configuration question and therefore cannot account for per-request
/// conditions. WebSocket framing uses this variant before deciding whether it
/// may restore a client protocol field after provider normalization.
pub fn apply_local_body_rules_with_request_headers_and_track_path(
body: &mut Value,
rules: Option<&Value>,
original_body: Option<&Value>,
request_headers: Option<&http::HeaderMap>,
path: &str,
) -> Option<bool> {
let target = parse_body_path(path)?;
let mut tracker = AppliedBodyRulePath {
target,
handled: false,
};
apply_local_body_rules_inner(
body,
rules,
original_body,
request_headers.map(ConditionHeaders::Request),
Some(&mut tracker),
)
.then_some(tracker.handled)
}
struct AppliedBodyRulePath {
target: Vec<BodyPathSegment>,
handled: bool,
}
impl AppliedBodyRulePath {
fn observe(&mut self, path: &[BodyPathSegment]) {
self.handled |= path_matches(path, &self.target);
}
}
fn apply_local_body_rules_inner(
body: &mut Value,
rules: Option<&Value>,
original_body: Option<&Value>,
request_headers: Option<ConditionHeaders<'_>>,
mut tracker: Option<&mut AppliedBodyRulePath>,
) -> bool {
let Some(rules) = rules else {
return true;
@@ -331,7 +440,11 @@ fn apply_local_body_rules_inner(
} else {
value_template.clone()
};
let _ = set_nested_value(body, &target_path, value);
if set_nested_value(body, &target_path, value) {
if let Some(tracker) = tracker.as_deref_mut() {
tracker.observe(&target_path);
}
}
}
}
Some("drop") => {
@@ -354,7 +467,11 @@ fn apply_local_body_rules_inner(
reverse: true,
},
) {
let _ = delete_nested_value(body, &target_path);
if delete_nested_value(body, &target_path) {
if let Some(tracker) = tracker.as_deref_mut() {
tracker.observe(&target_path);
}
}
}
}
Some("rename") => {
@@ -375,7 +492,12 @@ fn apply_local_body_rules_inner(
if has_wildcard(&from) || has_wildcard(&to) {
continue;
}
let _ = rename_nested_value(body, &from, &to);
if rename_nested_value(body, &from, &to) {
if let Some(tracker) = tracker.as_deref_mut() {
tracker.observe(&from);
tracker.observe(&to);
}
}
}
Some("append") => {
let Some(path) = rule
@@ -401,6 +523,9 @@ fn apply_local_body_rules_inner(
if let Some(target) = get_nested_value_mut(body, &target_path) {
if let Some(values) = target.as_array_mut() {
values.push(value.clone());
if let Some(tracker) = tracker.as_deref_mut() {
tracker.observe(&target_path);
}
}
}
}
@@ -424,6 +549,9 @@ fn apply_local_body_rules_inner(
if let Some(values) = target.as_array_mut() {
let insert_index = normalize_insert_index(values.len(), index);
values.insert(insert_index, value);
if let Some(tracker) = tracker.as_deref_mut() {
tracker.observe(&path);
}
}
}
}
@@ -474,6 +602,9 @@ fn apply_local_body_rules_inner(
pattern.replacen(&current, count, replacement).to_string()
};
*target = Value::String(replaced);
if let Some(tracker) = tracker.as_deref_mut() {
tracker.observe(&target_path);
}
}
}
}
@@ -1331,9 +1462,10 @@ fn rename_nested_value(
mod tests {
use super::{
apply_local_body_rules, apply_local_body_rules_with_request_headers,
apply_local_header_rules, apply_local_header_rules_with_request_headers,
body_rules_are_locally_supported, body_rules_handle_path, body_rules_have_enabled_rules,
header_rules_are_locally_supported, header_rules_have_enabled_rules,
apply_local_body_rules_with_request_headers_and_track_path, apply_local_header_rules,
apply_local_header_rules_with_request_headers, body_rules_are_locally_supported,
body_rules_handle_path, body_rules_have_enabled_rules, header_rules_are_locally_supported,
header_rules_have_enabled_rules,
};
#[test]
@@ -1782,4 +1914,48 @@ mod tests {
assert!(!body_rules_handle_path(Some(&rules), "tools[3].kind"));
assert!(!body_rules_handle_path(Some(&rules), "instructions"));
}
#[test]
fn body_rule_path_tracking_requires_the_rule_condition_to_apply() {
let rules = serde_json::json!([{
"action": "set",
"path": "store",
"value": false,
"condition": {"path": "metadata.mode", "op": "eq", "value": "enforce"}
}]);
let skipped_original = serde_json::json!({
"store": true,
"metadata": {"mode": "observe"}
});
let mut skipped = skipped_original.clone();
assert_eq!(
apply_local_body_rules_with_request_headers_and_track_path(
&mut skipped,
Some(&rules),
Some(&skipped_original),
None,
"store",
),
Some(false)
);
assert_eq!(skipped["store"], true);
let applied_original = serde_json::json!({
"store": false,
"metadata": {"mode": "enforce"}
});
let mut applied = applied_original.clone();
assert_eq!(
apply_local_body_rules_with_request_headers_and_track_path(
&mut applied,
Some(&rules),
Some(&applied_original),
None,
"store",
),
Some(true),
"setting the existing value still counts as rule ownership"
);
}
}