diff --git a/apps/aether-gateway/src/handlers/proxy/websocket/responses/request.rs b/apps/aether-gateway/src/handlers/proxy/websocket/responses/request.rs index 33d187221..a3530fcc0 100644 --- a/apps/aether-gateway/src/handlers/proxy/websocket/responses/request.rs +++ b/apps/aether-gateway/src/handlers/proxy/websocket/responses/request.rs @@ -388,6 +388,54 @@ mod tests { assert_eq!(normalized["previous_response_id"], "resp_123"); } + #[test] + fn lite_continuations_forward_only_each_turns_incremental_input() { + let normalization = ResponsesWebSocketBodyNormalization::for_tests("gpt-5.6-sol") + .with_provider_type_for_tests("codex"); + + for turn in 1..=4 { + let event = json!({ + "type": "response.create", + "model": "gpt-5.6-sol", + "previous_response_id": format!("resp_{turn}"), + "instructions": "The same large developer instructions.", + "tools": [{ + "type": "function", + "name": "shell", + "parameters": {"type": "object"} + }], + "input": [ + { + "type": "reasoning", + "id": format!("rs_{turn}"), + "content": [{ + "type": "reasoning_text", + "text": format!("reasoning state {turn}") + }], + "encrypted_content": format!("opaque-{turn}") + }, + { + "type": "function_call_output", + "call_id": format!("call_{turn}"), + "output": format!("result {turn}") + } + ] + }); + + let normalized = normalized_continuation(&event, &normalization); + let input = normalized["input"].as_array().expect("incremental input"); + assert_eq!(input.len(), 2); + assert_eq!(input[0]["type"], "reasoning"); + assert_eq!(input[0]["content"][0]["type"], "reasoning_text"); + assert_eq!(input[1]["type"], "function_call_output"); + assert!(normalized.get("instructions").is_none()); + assert!(normalized.get("tools").is_none()); + assert!(!input.iter().any(|item| item["type"] == "additional_tools")); + assert!(!input.iter().any(|item| item["role"] == "developer")); + assert_eq!(normalized["previous_response_id"], format!("resp_{turn}")); + } + } + #[test] fn continuation_keeps_a_warmup_generate_flag() { let event = json!({ diff --git a/crates/aether-ai/formats/src/formats/openai/responses/codex.rs b/crates/aether-ai/formats/src/formats/openai/responses/codex.rs index 745840528..d53ec50a6 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/codex.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/codex.rs @@ -1345,6 +1345,7 @@ fn codex_responses_lite_supports_request_body(provider_request_body: Option<&Val fn apply_codex_responses_lite_body_contract( body_object: &mut serde_json::Map, capabilities: &CodexResponsesModelCapabilities, + websocket_continuation: bool, ) { if !capabilities.use_responses_lite { return; @@ -1381,6 +1382,23 @@ fn apply_codex_responses_lite_body_contract( .and_then(Value::as_array_mut) .expect("Responses Lite input was validated as an array"); + // `previous_response_id` already references the prior response's stored + // input. The Codex client sends only the new input items on these turns; + // replaying the current top-level tools/instructions as synthetic history + // would permanently append another copy on every tool round-trip. Keep + // 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)) + { + strip_codex_responses_lite_image_details(item); + } + body_object.insert("parallel_tool_calls".to_string(), json!(false)); + return; + } + let existing_additional_tools = input .iter() .position(is_codex_responses_lite_additional_tools_item) @@ -1710,8 +1728,21 @@ 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); } @@ -1794,7 +1825,7 @@ pub fn apply_codex_openai_responses_special_body_edits_with_source_model_and_cap capabilities, body_rules, ); - apply_codex_responses_lite_body_contract(body_object, capabilities); + apply_codex_responses_lite_body_contract(body_object, capabilities, websocket_continuation); strip_codex_cache_control_fields(provider_request_body); apply_codex_openai_responses_compact_body_edits( provider_request_body, @@ -3195,6 +3226,93 @@ mod tests { ); } + #[test] + fn codex_responses_lite_websocket_continuations_do_not_reappend_static_config() { + let incremental_input = json!([ + { + "type": "reasoning", + "id": "rs_provider_123", + "content": [{ + "type": "reasoning_text", + "text": "Pass the provider reasoning state through unchanged." + }], + "encrypted_content": "opaque-provider-state" + }, + { + "type": "function_call_output", + "call_id": "call_123", + "output": "small result" + } + ]); + 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": {"type": "object"} + }], + "input": incremental_input.clone() + }); + + apply_codex_openai_responses_special_body_edits( + &mut provider_request_body, + "codex", + "openai:responses", + None, + None, + ); + + assert_eq!(provider_request_body["previous_response_id"], "resp_123"); + 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_special_body_edits( + &mut provider_request_body, + "codex", + "openai:responses", + None, + None, + ); + assert_eq!(provider_request_body, once); + } + + #[test] + fn codex_responses_lite_null_previous_id_is_an_independent_turn() { + let mut provider_request_body = json!({ + "type": "response.create", + "model": "gpt-5.6-sol", + "previous_response_id": null, + "instructions": "Fresh 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", + "openai:responses", + None, + None, + ); + + 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"); + assert_eq!( + provider_request_body["input"][1]["content"][0]["text"], + "Fresh instructions." + ); + } + #[test] fn codex_responses_body_edits_strip_name_from_hosted_web_search_tool() { let mut provider_request_body = json!({