diff --git a/apps/aether-gateway/src/ai_serving/mod.rs b/apps/aether-gateway/src/ai_serving/mod.rs index 1e8dc7e50..483639116 100644 --- a/apps/aether-gateway/src/ai_serving/mod.rs +++ b/apps/aether-gateway/src/ai_serving/mod.rs @@ -23,6 +23,7 @@ pub(crate) use self::finalize::internal::{ maybe_build_sync_finalize_outcome, maybe_compile_sync_finalize_response, SyncToStreamBridgeOutcome, }; +pub(crate) use self::planner::openai_responses_reasoning_replay_policy; pub(crate) use self::planner::{ apply_local_runtime_candidate_terminal_reason, build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision, build_local_gemini_files_stream_attempt_source_for_kind, diff --git a/apps/aether-gateway/src/ai_serving/planner/decision_input.rs b/apps/aether-gateway/src/ai_serving/planner/decision_input.rs index 0080375f1..871500eb4 100644 --- a/apps/aether-gateway/src/ai_serving/planner/decision_input.rs +++ b/apps/aether-gateway/src/ai_serving/planner/decision_input.rs @@ -257,7 +257,7 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision( input.requested_model.as_str(), ) }); - crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities( + crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy( &mut provider_request_body, crate::ai_serving::OpenAiProviderRequestFinalization { source_api_format: context.client_api_format.as_str(), @@ -272,6 +272,14 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision( .is_some_and(|body| body.get("stream").is_some()), }, model_capabilities.as_ref(), + transport + .map(|transport| { + crate::ai_serving::openai_responses_reasoning_replay_policy( + transport.provider.provider_type.as_str(), + transport.endpoint.base_url.as_str(), + ) + }) + .unwrap_or_default(), ) .map_err(|violation| GatewayError::Client { status: StatusCode::BAD_REQUEST, diff --git a/apps/aether-gateway/src/ai_serving/planner/mod.rs b/apps/aether-gateway/src/ai_serving/planner/mod.rs index ddb8e5952..467709e90 100644 --- a/apps/aether-gateway/src/ai_serving/planner/mod.rs +++ b/apps/aether-gateway/src/ai_serving/planner/mod.rs @@ -82,9 +82,9 @@ pub(crate) use self::standard::{ build_local_sync_attempt_source as build_standard_family_sync_attempt_source, build_local_sync_plan_and_reports as build_standard_family_sync_plan_and_reports, codex_model_capabilities_for_transport, maybe_build_responses_websocket_decision, - set_local_openai_chat_execution_exhausted_diagnostic, validate_final_openai_provider_request, - ResponsesWebSocketBodyNormalization, ResponsesWebSocketDecision, - ResponsesWebSocketPinnedCandidate, + openai_responses_reasoning_replay_policy, set_local_openai_chat_execution_exhausted_diagnostic, + validate_final_openai_provider_request, ResponsesWebSocketBodyNormalization, + ResponsesWebSocketDecision, ResponsesWebSocketPinnedCandidate, }; pub(crate) use self::state::{ GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth, diff --git a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs index df1ab560c..921690452 100644 --- a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs @@ -42,7 +42,7 @@ use super::{ }; use crate::ai_serving::planner::standard::{ codex_model_capabilities_for_transport, openai_provider_request_contract_failure_extra_data, - same_format_provider_request_body_failure_extra_data, + openai_responses_reasoning_replay_policy, same_format_provider_request_body_failure_extra_data, }; pub(crate) fn resolve_same_format_provider_transport_unsupported_reason_for_trace( @@ -205,6 +205,10 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts( prepared.kiro_auth.as_ref(), prepared.is_claude_code, false, + openai_responses_reasoning_replay_policy( + prepared.transport.provider.provider_type.as_str(), + prepared.transport.endpoint.base_url.as_str(), + ), ) else { mark_skipped_local_same_format_provider_candidate_with_extra_data( @@ -269,7 +273,7 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts( source_model, ); if let Err(violation) = - crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities( + crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy( &mut base_provider_request_body, crate::ai_serving::OpenAiProviderRequestFinalization { source_api_format: spec.api_format, @@ -285,6 +289,10 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts( ), }, codex_model_capabilities.as_ref(), + openai_responses_reasoning_replay_policy( + transport.provider.provider_type.as_str(), + transport.endpoint.base_url.as_str(), + ), ) { mark_skipped_local_same_format_provider_candidate_with_extra_data( diff --git a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/request/body.rs b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/request/body.rs index a292ce453..b14fa0438 100644 --- a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/request/body.rs +++ b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/request/body.rs @@ -3,7 +3,7 @@ use serde_json::Value; use super::super::LocalSameFormatProviderSpec; use crate::ai_serving::transport::{ build_same_format_provider_request_body as build_same_format_provider_request_body_impl, - build_same_format_provider_request_body_with_compatibility_report as build_same_format_provider_request_body_with_compatibility_report_impl, + build_same_format_provider_request_body_with_compatibility_report_and_reasoning_replay_policy as build_same_format_provider_request_body_with_compatibility_report_impl, SameFormatProviderFamily, SameFormatProviderRequestBodyInput, SameFormatProviderRequestBodyOutput, }; @@ -50,6 +50,7 @@ pub(crate) fn build_same_format_provider_request_body_with_compatibility_report( kiro_auth: Option<&crate::ai_serving::transport::kiro::KiroRequestAuth>, is_claude_code: bool, enable_model_directives: bool, + reasoning_replay_policy: crate::ai_serving::OpenAiResponsesReasoningReplayPolicy, ) -> Option { build_same_format_provider_request_body_with_compatibility_report_impl( SameFormatProviderRequestBodyInput { @@ -67,6 +68,7 @@ pub(crate) fn build_same_format_provider_request_body_with_compatibility_report( is_claude_code, enable_model_directives, }, + reasoning_replay_policy, ) } diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/deepseek.rs b/apps/aether-gateway/src/ai_serving/planner/standard/deepseek.rs index 65bc40bf9..119f6aac3 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/deepseek.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/deepseek.rs @@ -13,6 +13,17 @@ pub(crate) fn is_deepseek_provider(provider_type: &str, base_url: &str) -> bool host == "deepseek.com" || host.ends_with(".deepseek.com") } +pub(crate) fn openai_responses_reasoning_replay_policy( + provider_type: &str, + base_url: &str, +) -> crate::ai_serving::OpenAiResponsesReasoningReplayPolicy { + if is_deepseek_provider(provider_type, base_url) { + crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque + } else { + crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds + } +} + pub(crate) fn apply_deepseek_tool_call_thinking_compat( provider_request_body: &mut Value, provider_type: &str, @@ -241,7 +252,10 @@ fn is_claude_thinking_block(block: &Value) -> bool { mod tests { use serde_json::json; - use super::{apply_deepseek_tool_call_thinking_compat, is_deepseek_provider}; + use super::{ + apply_deepseek_tool_call_thinking_compat, is_deepseek_provider, + openai_responses_reasoning_replay_policy, + }; #[test] fn detects_deepseek_provider_by_type_or_host() { @@ -257,6 +271,52 @@ mod tests { "custom", "https://example.com/deepseek" )); + assert_eq!( + openai_responses_reasoning_replay_policy("custom", "https://api.deepseek.com/v1"), + crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque + ); + assert_eq!( + openai_responses_reasoning_replay_policy("openai", "https://api.openai.com/v1"), + crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds + ); + } + + #[test] + fn custom_deepseek_host_preserves_production_shaped_opaque_reasoning_replay() { + let reasoning_items = (0..66) + .map(|index| { + json!({ + "type": "reasoning", + "encrypted_content": format!("550e8400-e29b-41d4-a716-{index:012}"), + "content": [{ + "type": "reasoning_text", + "text": format!("opaque DeepSeek reasoning {index}") + }] + }) + }) + .collect::>(); + let mut deepseek = json!({"input": reasoning_items.clone()}); + let mut openai = json!({"input": reasoning_items}); + + assert_eq!( + aether_ai_formats::strip_incompatible_openai_responses_reasoning_items_with_policy( + &mut deepseek, + "openai:responses", + openai_responses_reasoning_replay_policy("custom", "https://api.deepseek.com/v1"), + ), + 0 + ); + assert_eq!(deepseek["input"].as_array().map(Vec::len), Some(66)); + + assert_eq!( + aether_ai_formats::strip_incompatible_openai_responses_reasoning_items_with_policy( + &mut openai, + "openai:responses", + openai_responses_reasoning_replay_policy("openai", "https://api.openai.com/v1"), + ), + 66 + ); + assert_eq!(openai["input"].as_array().map(Vec::len), Some(0)); } #[test] diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs index 43124676e..37fcc9d99 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs @@ -23,8 +23,8 @@ use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata; use crate::ai_serving::planner::standard::{ apply_codex_openai_special_headers, apply_deepseek_tool_call_thinking_compat, codex_model_capabilities_for_transport, is_deepseek_provider, - openai_provider_request_contract_failure_extra_data, request_body_build_failure_extra_data, - request_conversion_failure_extra_data, + openai_provider_request_contract_failure_extra_data, openai_responses_reasoning_replay_policy, + request_body_build_failure_extra_data, request_conversion_failure_extra_data, }; use crate::ai_serving::transport::kiro::{ build_kiro_provider_headers, build_kiro_provider_request_body, @@ -620,6 +620,10 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts( Some(input.auth_context.api_key_id.as_str()), Some(effective_headers), false, + openai_responses_reasoning_replay_policy( + transport.provider.provider_type.as_str(), + transport.endpoint.base_url.as_str(), + ), ) { Some(body) => body, None => { @@ -750,7 +754,7 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts( source_model, ); if let Err(violation) = - crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities( + crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy( &mut provider_request_body, crate::ai_serving::OpenAiProviderRequestFinalization { source_api_format: spec_metadata.api_format, @@ -766,6 +770,10 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts( ), }, codex_model_capabilities.as_ref(), + openai_responses_reasoning_replay_policy( + transport.provider.provider_type.as_str(), + transport.endpoint.base_url.as_str(), + ), ) { mark_skipped_local_standard_candidate_with_extra_data( diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/mod.rs b/apps/aether-gateway/src/ai_serving/planner/standard/mod.rs index 426eb612f..f8c3f57bc 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/mod.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/mod.rs @@ -18,7 +18,10 @@ pub(crate) use self::codex::{ apply_codex_openai_responses_special_body_edits, apply_codex_openai_special_headers, codex_model_capabilities_for_transport, }; -pub(crate) use self::deepseek::{apply_deepseek_tool_call_thinking_compat, is_deepseek_provider}; +pub(crate) use self::deepseek::{ + apply_deepseek_tool_call_thinking_compat, is_deepseek_provider, + openai_responses_reasoning_replay_policy, +}; pub(crate) use self::family::{ build_local_stream_attempt_source, build_local_stream_plan_and_reports, build_local_sync_attempt_source, build_local_sync_plan_and_reports, diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs index accbee117..6ac9b14cc 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs @@ -81,10 +81,6 @@ pub(crate) fn build_local_openai_responses_request_body_with_codex_model_capabil &mut provider_request_body, provider_api_format, ); - crate::ai_serving::strip_incompatible_openai_responses_reasoning_items( - &mut provider_request_body, - provider_api_format, - ); enforce_provider_body_stream_policy( &mut provider_request_body, provider_api_format, @@ -176,10 +172,6 @@ pub(crate) fn build_cross_format_openai_responses_request_body_with_codex_model_ &mut provider_request_body, provider_api_format, ); - crate::ai_serving::strip_incompatible_openai_responses_reasoning_items( - &mut provider_request_body, - provider_api_format, - ); enforce_provider_body_stream_policy( &mut provider_request_body, provider_api_format, diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs index 30ab4b12f..b2290eb0c 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs @@ -32,8 +32,8 @@ use crate::ai_serving::planner::standard::{ build_cross_format_openai_responses_upstream_url, build_local_openai_responses_request_body_with_codex_model_capabilities, build_local_openai_responses_upstream_url, codex_model_capabilities_for_transport, - openai_provider_request_contract_failure_extra_data, request_body_build_failure_extra_data, - request_conversion_failure_extra_data, + openai_provider_request_contract_failure_extra_data, openai_responses_reasoning_replay_policy, + request_body_build_failure_extra_data, request_conversion_failure_extra_data, }; use crate::ai_serving::transport::antigravity::is_antigravity_provider_transport; use crate::ai_serving::transport::auth::{ @@ -532,7 +532,7 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts( Some(body_json), ); if let Err(violation) = - crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities( + crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy( &mut base_provider_request_body, crate::ai_serving::OpenAiProviderRequestFinalization { source_api_format: spec_metadata.api_format, @@ -548,6 +548,10 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts( ), }, codex_model_capabilities.as_ref(), + openai_responses_reasoning_replay_policy( + transport.provider.provider_type.as_str(), + transport.endpoint.base_url.as_str(), + ), ) { mark_skipped_local_openai_responses_candidate_with_extra_data( diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/mod.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/mod.rs index ead7fbd38..716a89aa4 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/mod.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/mod.rs @@ -3,6 +3,7 @@ use crate::ai_serving::planner::plan_builders::{AiStreamAttempt, AiSyncAttempt}; use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metadata; use crate::ai_serving::planner::standard::codex::codex_model_capabilities_for_transport; use crate::ai_serving::planner::standard::normalize::build_local_openai_responses_request_body_with_codex_model_capabilities; +use crate::ai_serving::planner::standard::openai_responses_reasoning_replay_policy; use crate::ai_serving::GatewayControlDecision; use crate::orchestration::{ codex_quota_breaker_blocks_candidate, log_codex_quota_breaker_check_failure, @@ -293,6 +294,7 @@ pub(crate) struct ResponsesWebSocketBodyNormalization { body_rules: Option, request_headers: http::HeaderMap, codex_model_capabilities: Option, + reasoning_replay_policy: crate::ai_serving::OpenAiResponsesReasoningReplayPolicy, model_directive_patch: Option, } @@ -313,6 +315,8 @@ impl ResponsesWebSocketBodyNormalization { body_rules: None, request_headers: http::HeaderMap::new(), codex_model_capabilities: None, + reasoning_replay_policy: + crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds, model_directive_patch: None, } } @@ -377,7 +381,7 @@ impl ResponsesWebSocketBodyNormalization { require_body_stream_field, ); } - crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities( + crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy( &mut body, crate::ai_serving::OpenAiProviderRequestFinalization { source_api_format: self.client_api_format.as_str(), @@ -390,6 +394,7 @@ impl ResponsesWebSocketBodyNormalization { require_body_stream_field, }, self.codex_model_capabilities.as_ref(), + self.reasoning_replay_policy, ) .ok()?; Some(body) @@ -539,6 +544,10 @@ pub(crate) async fn maybe_build_responses_websocket_decision( mapped_model.as_str(), source_model, ), + reasoning_replay_policy: openai_responses_reasoning_replay_policy( + transport.provider.provider_type.as_str(), + transport.endpoint.base_url.as_str(), + ), model_directive_patch: input .model_directive_policy .resolve_reasoning( diff --git a/apps/aether-gateway/src/ai_serving/pure/mod.rs b/apps/aether-gateway/src/ai_serving/pure/mod.rs index 88deedf85..e6ca938fc 100644 --- a/apps/aether-gateway/src/ai_serving/pure/mod.rs +++ b/apps/aether-gateway/src/ai_serving/pure/mod.rs @@ -54,6 +54,7 @@ pub(crate) use aether_ai_formats::api::{ enforce_request_body_stream_field, estimate_kiro_tokens, extract_openai_text_content, finalize_openai_provider_request, finalize_openai_provider_request_with_codex_model_capabilities, + finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy, find_kiro_real_thinking_end_tag, find_kiro_real_thinking_end_tag_at_buffer_end, find_kiro_real_thinking_start_tag, forbid_upstream_streaming_for_provider, force_upstream_streaming_for_provider, gemini_request_is_image_generation, @@ -123,9 +124,9 @@ pub(crate) use aether_ai_formats::api::{ OpenAIResponsesProviderState, OpenAiImageNormalizeOptions, OpenAiImageOperation, OpenAiImageRequestForGemini, OpenAiImageResponseFormat, OpenAiImageStreamState, OpenAiImageSyncFinalizeProduct, OpenAiProviderRequestFinalization, - ProviderAdaptationDescriptor, ProviderAdaptationSurface, ProviderPrivateStreamNormalizer, - ReasoningEffort, RequestConversionKind, ResponseHistoryRecord, ServiceTier, - StandardCrossFormatSyncProduct, StandardSyncFinalizeNormalizedProduct, + OpenAiResponsesReasoningReplayPolicy, ProviderAdaptationDescriptor, ProviderAdaptationSurface, + ProviderPrivateStreamNormalizer, ReasoningEffort, RequestConversionKind, ResponseHistoryRecord, + ServiceTier, StandardCrossFormatSyncProduct, StandardSyncFinalizeNormalizedProduct, StreamingStandardFormatMatrix, SyncChatResponseConversionKind, SyncCliResponseConversionKind, SyncToStreamBridgeOutcome, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME, CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND, diff --git a/apps/aether-gateway/src/ai_serving/transport.rs b/apps/aether-gateway/src/ai_serving/transport.rs index e5267484b..42af31e02 100644 --- a/apps/aether-gateway/src/ai_serving/transport.rs +++ b/apps/aether-gateway/src/ai_serving/transport.rs @@ -75,6 +75,7 @@ pub(crate) use aether_provider_transport::{ build_request_trace_proxy_value, build_same_format_provider_headers, build_same_format_provider_request_body, build_same_format_provider_request_body_with_compatibility_report, + build_same_format_provider_request_body_with_compatibility_report_and_reasoning_replay_policy, build_same_format_provider_upstream_url, build_standard_plan_fallback_headers, build_standard_plan_fallback_openai_chat_url, build_standard_plan_fallback_openai_responses_url, build_standard_provider_request_headers, diff --git a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs index e71db89f4..1210e89f0 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs @@ -3174,6 +3174,10 @@ async fn provider_query_execute_standard_test_candidate( Some(candidate.key.id.as_str()), Some(&incoming_request_headers), false, + crate::ai_serving::openai_responses_reasoning_replay_policy( + transport.provider.provider_type.as_str(), + transport.endpoint.base_url.as_str(), + ), ) else { return Ok(provider_query_skipped_execution_outcome( @@ -3218,7 +3222,7 @@ async fn provider_query_execute_standard_test_candidate( if matches!( normalized_provider_api_format.as_str(), "openai:chat" | "openai:responses" | "openai:responses:compact" | "openai:search" - ) && crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities( + ) && crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy( &mut provider_request_body, crate::ai_serving::OpenAiProviderRequestFinalization { source_api_format: client_api_format, @@ -3231,6 +3235,10 @@ async fn provider_query_execute_standard_test_candidate( require_body_stream_field, }, codex_model_capabilities.as_ref(), + crate::ai_serving::openai_responses_reasoning_replay_policy( + transport.provider.provider_type.as_str(), + transport.endpoint.base_url.as_str(), + ), ) .is_err() { diff --git a/crates/aether-ai/formats/src/api.rs b/crates/aether-ai/formats/src/api.rs index d969f1071..283e001f6 100644 --- a/crates/aether-ai/formats/src/api.rs +++ b/crates/aether-ai/formats/src/api.rs @@ -72,12 +72,15 @@ pub use crate::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, validate_openai_provider_request_contract, OpenAiProviderRequestContractViolation, OpenAiProviderRequestFinalization, }, responses::{ openai_responses_synthetic_reasoning_item_id, strip_incompatible_openai_responses_reasoning_items, + strip_incompatible_openai_responses_reasoning_items_with_policy, + OpenAiResponsesReasoningReplayPolicy, }, }; pub use crate::formats::shared::error_body::{ diff --git a/crates/aether-ai/formats/src/formats/openai/request_contract.rs b/crates/aether-ai/formats/src/formats/openai/request_contract.rs index 9510d7888..ffad9d38f 100644 --- a/crates/aether-ai/formats/src/formats/openai/request_contract.rs +++ b/crates/aether-ai/formats/src/formats/openai/request_contract.rs @@ -27,13 +27,32 @@ pub fn finalize_openai_provider_request( body: &mut Value, finalization: OpenAiProviderRequestFinalization<'_>, ) -> Result<(), OpenAiProviderRequestContractViolation> { - finalize_openai_provider_request_with_codex_model_capabilities(body, finalization, None) + finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy( + body, + finalization, + None, + super::responses::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds, + ) } pub fn finalize_openai_provider_request_with_codex_model_capabilities( body: &mut Value, finalization: OpenAiProviderRequestFinalization<'_>, model_capabilities: Option<&super::responses::codex::CodexResponsesModelCapabilities>, +) -> Result<(), OpenAiProviderRequestContractViolation> { + finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy( + body, + finalization, + model_capabilities, + super::responses::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds, + ) +} + +pub fn finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy( + body: &mut Value, + finalization: OpenAiProviderRequestFinalization<'_>, + model_capabilities: Option<&super::responses::codex::CodexResponsesModelCapabilities>, + reasoning_replay_policy: super::responses::OpenAiResponsesReasoningReplayPolicy, ) -> Result<(), OpenAiProviderRequestContractViolation> { let is_codex_reasoning_endpoint = finalization .provider_type @@ -80,9 +99,10 @@ pub fn finalize_openai_provider_request_with_codex_model_capabilities( body, finalization.provider_api_format, ); - super::responses::strip_incompatible_openai_responses_reasoning_items( + super::responses::strip_incompatible_openai_responses_reasoning_items_with_policy( body, finalization.provider_api_format, + reasoning_replay_policy, ); crate::enforce_request_body_stream_field( body, diff --git a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs index 19ad63775..68f6e8a46 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs @@ -10,6 +10,18 @@ pub mod stream; const TOOL_ERROR_PREFIX: &str = "[tool error]"; const AETHER_REASONING_ITEM_ID_PREFIX: &str = "rs_aether_"; +/// Controls which provider-owned reasoning items may be replayed on a Responses request. +/// +/// OpenAI reasoning references are identified by their `rs...` item IDs. DeepSeek's Responses +/// contract instead returns opaque, id-less `reasoning_text` items whose `encrypted_content` +/// must be sent back unchanged on later tool turns. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum OpenAiResponsesReasoningReplayPolicy { + #[default] + OpenAiItemIds, + DeepSeekOpaque, +} + /// Builds a stable, wire-compatible ID for a reasoning item synthesized by Aether. /// /// The marker lets the outbound request sanitizer distinguish synthetic summaries from @@ -35,6 +47,18 @@ pub fn openai_responses_synthetic_reasoning_item_id( pub fn strip_incompatible_openai_responses_reasoning_items( body: &mut Value, provider_api_format: &str, +) -> usize { + strip_incompatible_openai_responses_reasoning_items_with_policy( + body, + provider_api_format, + OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds, + ) +} + +pub fn strip_incompatible_openai_responses_reasoning_items_with_policy( + body: &mut Value, + provider_api_format: &str, + policy: OpenAiResponsesReasoningReplayPolicy, ) -> usize { if !aether_ai_formats::is_openai_responses_family_format(provider_api_format) { return 0; @@ -43,17 +67,25 @@ pub fn strip_incompatible_openai_responses_reasoning_items( return 0; }; let original_len = items.len(); - items.retain(openai_responses_reasoning_item_is_replayable); + items.retain(|item| openai_responses_reasoning_item_is_replayable(item, policy)); original_len.saturating_sub(items.len()) } -fn openai_responses_reasoning_item_is_replayable(item: &Value) -> bool { +fn openai_responses_reasoning_item_is_replayable( + item: &Value, + policy: OpenAiResponsesReasoningReplayPolicy, +) -> bool { let Some(object) = item.as_object() else { return true; }; if object.get("type").and_then(Value::as_str) != Some("reasoning") { return true; } + if policy == OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque + && deepseek_opaque_reasoning_item_is_replayable(object) + { + return true; + } let Some(id) = object .get("id") .and_then(Value::as_str) @@ -71,6 +103,33 @@ fn openai_responses_reasoning_item_is_replayable(item: &Value) -> bool { .is_some_and(|encrypted_content| !encrypted_content.trim().is_empty()) } +fn deepseek_opaque_reasoning_item_is_replayable(object: &serde_json::Map) -> bool { + if let Some(id) = object.get("id") { + let id_is_empty = id.is_null() || id.as_str().is_some_and(|value| value.trim().is_empty()); + if !id_is_empty { + return false; + } + } + let has_encrypted_content = object + .get("encrypted_content") + .and_then(Value::as_str) + .is_some_and(|value| !value.trim().is_empty()); + let has_reasoning_text = + object + .get("content") + .and_then(Value::as_array) + .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()) + }) + }); + has_encrypted_content && has_reasoning_text +} + /// Semantic operation carried by an OpenAI Responses request that asks the /// service to compact a thread. The request still uses the Responses wire /// contract and transport endpoint. @@ -121,7 +180,9 @@ mod tests { use super::{ openai_responses_request_operation, openai_responses_synthetic_reasoning_item_id, - strip_incompatible_openai_responses_reasoning_items, OPENAI_RESPONSES_OPERATION_COMPACT, + strip_incompatible_openai_responses_reasoning_items, + strip_incompatible_openai_responses_reasoning_items_with_policy, + OpenAiResponsesReasoningReplayPolicy, OPENAI_RESPONSES_OPERATION_COMPACT, }; #[test] @@ -210,4 +271,85 @@ mod tests { ); assert_eq!(body["input"].as_array().map(Vec::len), Some(1)); } + + #[test] + fn deepseek_policy_preserves_idless_opaque_reasoning_text_only_for_deepseek() { + let item = json!({ + "type": "reasoning", + "encrypted_content": "550e8400-e29b-41d4-a716-446655440000", + "content": [{ + "type": "reasoning_text", + "text": "opaque provider reasoning that must be replayed" + }] + }); + let mut strict = json!({"input": [item.clone()]}); + let mut deepseek = json!({"input": [item]}); + + assert_eq!( + strip_incompatible_openai_responses_reasoning_items_with_policy( + &mut strict, + "openai:responses", + OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds, + ), + 1 + ); + assert_eq!( + strip_incompatible_openai_responses_reasoning_items_with_policy( + &mut deepseek, + "openai:responses", + OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque, + ), + 0 + ); + assert_eq!(deepseek["input"].as_array().map(Vec::len), Some(1)); + } + + #[test] + fn deepseek_policy_does_not_preserve_unbound_reasoning_summaries() { + let mut body = json!({ + "input": [ + { + "type": "reasoning", + "content": [{"type": "reasoning_text", "text": "missing state"}] + }, + { + "type": "reasoning", + "encrypted_content": "opaque-without-reasoning-text", + "summary": [{"type": "summary_text", "text": "summary"}] + } + ] + }); + + assert_eq!( + strip_incompatible_openai_responses_reasoning_items_with_policy( + &mut body, + "openai:responses", + OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque, + ), + 2 + ); + assert_eq!(body["input"].as_array().map(Vec::len), Some(0)); + } + + #[test] + fn deepseek_policy_does_not_preserve_opaque_item_with_foreign_id() { + let mut body = json!({ + "input": [{ + "type": "reasoning", + "id": "item_provider_owned", + "encrypted_content": "opaque-state", + "content": [{"type": "reasoning_text", "text": "thinking"}] + }] + }); + + 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)); + } } diff --git a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs index f9c78557a..dd2d04134 100644 --- a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs @@ -73,6 +73,7 @@ pub fn build_standard_request_body_with_model_directives( user_api_key_id, None, enable_model_directives, + crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds, ) } @@ -89,6 +90,7 @@ 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, + reasoning_replay_policy: crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy, ) -> Option { let mut format_context = FormatContext::default() .with_mapped_model(mapped_model) @@ -102,13 +104,26 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers( client_api_format, provider_api_format, ); - let mut provider_request_body = convert_request( - source_api_format.as_ref(), - provider_api_format, - body_json, - &format_context, - ) - .ok()?; + // 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()? + }; if enable_model_directives { apply_model_directive_overrides_from_request( @@ -153,9 +168,10 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers( &mut provider_request_body, provider_api_format, ); - crate::formats::openai::responses::strip_incompatible_openai_responses_reasoning_items( + crate::formats::openai::responses::strip_incompatible_openai_responses_reasoning_items_with_policy( &mut provider_request_body, provider_api_format, + reasoning_replay_policy, ); strip_openai_responses_input_content_cache_control( &mut provider_request_body, @@ -176,6 +192,16 @@ 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 compatible_source_format_for_standard_request<'a>( body_json: &Value, client_api_format: &'a str, @@ -352,8 +378,10 @@ 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, normalize_standard_request_to_openai_chat_request, }; + use crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy; use serde_json::{json, Value}; const STANDARD_SURFACES: &[&str] = &[ @@ -500,6 +528,84 @@ mod tests { } } + #[test] + fn same_responses_family_matrix_preserves_opaque_reasoning_items_and_unknown_fields() { + let input = (0..66) + .map(|index| { + json!({ + "type": "reasoning", + "encrypted_content": format!("opaque-state-{index}"), + "content": [{ + "type": "reasoning_text", + "text": format!("thinking {index}") + }], + "future_capability": {"budget_class": "dynamic"} + }) + }) + .collect::>(); + let request = json!({ + "model": "deepseek-v4-flash", + "input": input, + "future_request_field": {"preserve": true} + }); + + let preserved = build_standard_request_body_with_model_directives_and_request_headers( + &request, + "openai:responses", + "deepseek-v4-flash", + "custom", + "openai:responses", + "/v1/responses", + false, + None, + None, + None, + false, + OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque, + ) + .expect("same-family Responses body should build"); + assert_eq!(preserved["model"], "deepseek-v4-flash"); + assert_eq!(preserved["future_request_field"]["preserve"], true); + assert_eq!( + preserved["input"] + .as_array() + .expect("input array") + .iter() + .filter(|item| item["type"] == "reasoning") + .count(), + 66 + ); + assert_eq!( + preserved["input"][0]["future_capability"]["budget_class"], + "dynamic" + ); + + let strict = build_standard_request_body_with_model_directives_and_request_headers( + &request, + "openai:responses", + "deepseek-v4-flash", + "openai", + "openai:responses", + "/v1/responses", + false, + None, + None, + None, + false, + OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds, + ) + .expect("strict same-family Responses body should build"); + assert_eq!( + strict["input"] + .as_array() + .expect("strict input array") + .iter() + .filter(|item| item["type"] == "reasoning") + .count(), + 0 + ); + } + #[test] fn standard_request_body_scopes_previous_response_history_by_api_key() { record_converted_response_history( diff --git a/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs b/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs index d8bacf3cd..de6d1f070 100644 --- a/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs +++ b/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs @@ -303,10 +303,6 @@ pub fn build_local_openai_responses_request_body_with_model_directives( require_streaming, require_body_stream_field, ); - crate::formats::openai::responses::strip_incompatible_openai_responses_reasoning_items( - &mut provider_request_body, - "openai:responses", - ); Some(provider_request_body) } diff --git a/crates/aether-ai/formats/src/lib.rs b/crates/aether-ai/formats/src/lib.rs index b32be13d2..dbcb445e1 100644 --- a/crates/aether-ai/formats/src/lib.rs +++ b/crates/aether-ai/formats/src/lib.rs @@ -39,6 +39,7 @@ pub use formats::openai::reasoning::{ 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, validate_openai_provider_request_contract, OpenAiProviderRequestContractViolation, OpenAiProviderRequestFinalization, }; @@ -54,7 +55,9 @@ pub use formats::openai::responses::request::{ }; pub use formats::openai::responses::{ openai_responses_request_operation, openai_responses_synthetic_reasoning_item_id, - strip_incompatible_openai_responses_reasoning_items, OPENAI_RESPONSES_OPERATION_COMPACT, + strip_incompatible_openai_responses_reasoning_items, + strip_incompatible_openai_responses_reasoning_items_with_policy, + OpenAiResponsesReasoningReplayPolicy, OPENAI_RESPONSES_OPERATION_COMPACT, }; pub use formats::registry::{ build_stream_transcoder, convert_request, convert_request_pure, diff --git a/crates/aether-provider/transport/src/lib.rs b/crates/aether-provider/transport/src/lib.rs index 6f71b5750..225d52123 100644 --- a/crates/aether-provider/transport/src/lib.rs +++ b/crates/aether-provider/transport/src/lib.rs @@ -145,6 +145,7 @@ pub use rules::{ pub use same_format_provider::{ build_same_format_provider_headers, build_same_format_provider_request_body, build_same_format_provider_request_body_with_compatibility_report, + build_same_format_provider_request_body_with_compatibility_report_and_reasoning_replay_policy, build_same_format_provider_upstream_url, classify_same_format_provider_request_behavior, classify_same_format_provider_request_behavior_for_operation, enforce_same_format_provider_api_operation_body_policy, diff --git a/crates/aether-provider/transport/src/same_format_provider/mod.rs b/crates/aether-provider/transport/src/same_format_provider/mod.rs index 4aac68ec4..99a61fddd 100644 --- a/crates/aether-provider/transport/src/same_format_provider/mod.rs +++ b/crates/aether-provider/transport/src/same_format_provider/mod.rs @@ -207,15 +207,32 @@ pub fn classify_same_format_provider_request_behavior_for_operation( pub fn build_same_format_provider_request_body( input: SameFormatProviderRequestBodyInput<'_>, ) -> Option { - build_same_format_provider_request_body_inner(input, None) + build_same_format_provider_request_body_inner( + input, + None, + aether_ai_formats::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds, + ) } pub fn build_same_format_provider_request_body_with_compatibility_report( input: SameFormatProviderRequestBodyInput<'_>, +) -> Option { + build_same_format_provider_request_body_with_compatibility_report_and_reasoning_replay_policy( + input, + aether_ai_formats::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds, + ) +} + +pub fn build_same_format_provider_request_body_with_compatibility_report_and_reasoning_replay_policy( + input: SameFormatProviderRequestBodyInput<'_>, + reasoning_replay_policy: aether_ai_formats::OpenAiResponsesReasoningReplayPolicy, ) -> Option { let mut compatibility_edits = Vec::new(); - let body = - build_same_format_provider_request_body_inner(input, Some(&mut compatibility_edits))?; + let body = build_same_format_provider_request_body_inner( + input, + Some(&mut compatibility_edits), + reasoning_replay_policy, + )?; Some(SameFormatProviderRequestBodyOutput { body, compatibility_edits, @@ -239,6 +256,7 @@ pub fn enforce_same_format_provider_api_operation_body_policy( fn build_same_format_provider_request_body_inner( input: SameFormatProviderRequestBodyInput<'_>, mut compatibility_edits: Option<&mut Vec>, + reasoning_replay_policy: aether_ai_formats::OpenAiResponsesReasoningReplayPolicy, ) -> Option { if let Some(kiro_auth_config) = input.kiro_auth_config { let body = build_kiro_provider_request_body( @@ -437,9 +455,10 @@ fn build_same_format_provider_request_body_inner( ); } let stripped_reasoning_items = - aether_ai_formats::strip_incompatible_openai_responses_reasoning_items( + aether_ai_formats::strip_incompatible_openai_responses_reasoning_items_with_policy( &mut provider_request_body, input.provider_api_format, + reasoning_replay_policy, ); if stripped_reasoning_items > 0 { record_compatibility_edit( @@ -466,7 +485,7 @@ fn build_same_format_provider_request_body_inner( ) { return None; } - if aether_ai_formats::finalize_openai_provider_request( + if aether_ai_formats::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy( &mut provider_request_body, aether_ai_formats::OpenAiProviderRequestFinalization { source_api_format: input.provider_api_format, @@ -478,6 +497,8 @@ fn build_same_format_provider_request_body_inner( upstream_is_stream: input.upstream_is_stream, require_body_stream_field, }, + None, + reasoning_replay_policy, ) .is_err() { @@ -2099,6 +2120,74 @@ mod tests { })); } + #[test] + fn same_format_responses_body_preserves_deepseek_opaque_reasoning_replay() { + let reasoning_items = (0..66) + .map(|index| { + json!({ + "type": "reasoning", + "encrypted_content": format!("opaque-deepseek-state-{index}"), + "content": [{ + "type": "reasoning_text", + "text": format!("provider thinking state {index}") + }] + }) + }) + .chain(std::iter::once(json!({ + "type": "message", + "role": "user", + "content": "continue" + }))) + .collect::>(); + let request_body = json!({ + "model": "deepseek-v4-flash", + "input": reasoning_items + }); + let input = SameFormatProviderRequestBodyInput { + body_json: &request_body, + mapped_model: "deepseek-v4-flash", + client_api_format: "openai:responses", + provider_api_format: "openai:responses", + source_model: Some("deepseek-v4-flash"), + family: SameFormatProviderFamily::Standard, + body_rules: None, + request_headers: None, + upstream_is_stream: false, + force_body_stream_field: false, + kiro_auth_config: None, + is_claude_code: false, + enable_model_directives: false, + }; + + let deepseek = + build_same_format_provider_request_body_with_compatibility_report_and_reasoning_replay_policy( + input, + aether_ai_formats::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque, + ) + .expect("DeepSeek same-format Responses body should build"); + let strict = build_same_format_provider_request_body_with_compatibility_report(input) + .expect("strict same-format Responses body should build"); + + assert_eq!( + deepseek.body["input"] + .as_array() + .expect("DeepSeek input array") + .iter() + .filter(|item| item["type"] == "reasoning") + .count(), + 66 + ); + assert_eq!( + strict.body["input"] + .as_array() + .expect("strict input array") + .iter() + .filter(|item| item["type"] == "reasoning") + .count(), + 0 + ); + } + #[test] fn same_format_stream_policy_wins_after_body_rules() { let body_rules = json!([