mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
fix(ws): harden Responses continuation state
This commit is contained in:
@@ -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!(
|
||||
|
||||
@@ -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(¤t, 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user