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 d2406c7bb..df1ab560c 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 @@ -41,7 +41,8 @@ use super::{ LocalSameFormatProviderSpec, }; use crate::ai_serving::planner::standard::{ - codex_model_capabilities_for_transport, same_format_provider_request_body_failure_extra_data, + codex_model_capabilities_for_transport, openai_provider_request_contract_failure_extra_data, + same_format_provider_request_body_failure_extra_data, }; pub(crate) fn resolve_same_format_provider_transport_unsupported_reason_for_trace( @@ -267,33 +268,39 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts( prepared.mapped_model.as_str(), source_model, ); - if crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities( - &mut base_provider_request_body, - crate::ai_serving::OpenAiProviderRequestFinalization { - source_api_format: spec.api_format, - provider_api_format: prepared.provider_api_format.as_str(), - provider_type: transport.provider.provider_type.as_str(), - provider_model: prepared.mapped_model.as_str(), - source_model, - body_rules: transport.endpoint.body_rules.as_ref(), - upstream_is_stream: prepared.upstream_is_stream, - require_body_stream_field: request_requires_body_stream_field( - body_json, - prepared.force_body_stream_field, - ), - }, - codex_model_capabilities.as_ref(), - ) - .is_err() + if let Err(violation) = + crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities( + &mut base_provider_request_body, + crate::ai_serving::OpenAiProviderRequestFinalization { + source_api_format: spec.api_format, + provider_api_format: prepared.provider_api_format.as_str(), + provider_type: transport.provider.provider_type.as_str(), + provider_model: prepared.mapped_model.as_str(), + source_model, + body_rules: transport.endpoint.body_rules.as_ref(), + upstream_is_stream: prepared.upstream_is_stream, + require_body_stream_field: request_requires_body_stream_field( + body_json, + prepared.force_body_stream_field, + ), + }, + codex_model_capabilities.as_ref(), + ) { - mark_skipped_local_same_format_provider_candidate( + mark_skipped_local_same_format_provider_candidate_with_extra_data( state, input, trace_id, candidate, attempt.candidate_index, &attempt.candidate_id, - "provider_request_body_missing", + "provider_request_body_build_failed", + Some(openai_provider_request_contract_failure_extra_data( + &violation, + spec.api_format, + prepared.provider_api_format.as_str(), + "same_format_provider_request_finalization", + )), ) .await; return Ok(None); 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 e2c29c99e..082926ef1 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,7 +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, - request_body_build_failure_extra_data, request_conversion_failure_extra_data, + openai_provider_request_contract_failure_extra_data, 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, @@ -740,24 +741,24 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts( prepared_candidate.mapped_model.as_str(), source_model, ); - if crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities( - &mut provider_request_body, - crate::ai_serving::OpenAiProviderRequestFinalization { - source_api_format: spec_metadata.api_format, - provider_api_format, - provider_type: transport.provider.provider_type.as_str(), - provider_model: prepared_candidate.mapped_model.as_str(), - source_model, - body_rules: transport.endpoint.body_rules.as_ref(), - upstream_is_stream, - require_body_stream_field: request_requires_body_stream_field( - body_json, - force_body_stream_field, - ), - }, - codex_model_capabilities.as_ref(), - ) - .is_err() + if let Err(violation) = + crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities( + &mut provider_request_body, + crate::ai_serving::OpenAiProviderRequestFinalization { + source_api_format: spec_metadata.api_format, + provider_api_format, + provider_type: transport.provider.provider_type.as_str(), + provider_model: prepared_candidate.mapped_model.as_str(), + source_model, + body_rules: transport.endpoint.body_rules.as_ref(), + upstream_is_stream, + require_body_stream_field: request_requires_body_stream_field( + body_json, + force_body_stream_field, + ), + }, + codex_model_capabilities.as_ref(), + ) { mark_skipped_local_standard_candidate_with_extra_data( state, @@ -767,15 +768,12 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts( attempt.candidate_index, &attempt.candidate_id, "provider_request_body_build_failed", - request_conversion_failure_extra_data( - body_json, + Some(openai_provider_request_contract_failure_extra_data( + &violation, spec_metadata.api_format, provider_api_format, - Some(prepared_candidate.mapped_model.as_str()), - Some(parts.uri.path()), - upstream_is_stream, "standard_family_request_finalization", - ), + )), ) .await; return Ok(None); 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 c8ab13128..fcf68f7ae 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/mod.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/mod.rs @@ -65,8 +65,8 @@ pub(crate) use crate::ai_serving::{ normalize_openai_responses_request_to_openai_chat_request, parse_openai_tool_result_content, }; pub(crate) use aether_ai_serving::{ - request_body_build_failure_extra_data, request_conversion_failure_extra_data, - same_format_provider_request_body_failure_extra_data, + openai_provider_request_contract_failure_extra_data, request_body_build_failure_extra_data, + request_conversion_failure_extra_data, same_format_provider_request_body_failure_extra_data, }; pub(crate) fn build_standard_upstream_url( 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 dabdc5895..84b687d9e 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,6 +81,10 @@ 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, @@ -169,6 +173,10 @@ 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/normalize/tests.rs b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/tests.rs index 0297f282b..3bd3fab09 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/tests.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/tests.rs @@ -152,6 +152,43 @@ fn local_openai_responses_wrapper_preserves_body_order_after_edits() { assert!(provider_request_body.get("instructions").is_none()); } +#[test] +fn local_openai_responses_wrapper_strips_foreign_reasoning_item_ids() { + let body_json = json!({ + "model": "gpt-5.4", + "input": [ + {"type": "reasoning", "id": "rs_provider_123", "summary": []}, + { + "type": "reasoning", + "id": "item_72d3bd8d367d01977ace23f1", + "summary": [] + }, + {"type": "message", "role": "user", "content": "continue"} + ] + }); + + let provider_request_body = build_local_openai_responses_request_body( + &body_json, + "gpt-5.4", + false, + false, + "codex", + "openai:responses", + None, + None, + &http::HeaderMap::new(), + false, + ) + .expect("local OpenAI Responses body should build"); + + let input = provider_request_body["input"] + .as_array() + .expect("input array"); + assert_eq!(input.len(), 2); + assert_eq!(input[0]["id"], "rs_provider_123"); + assert_eq!(input[1]["type"], "message"); +} + #[test] fn local_openai_responses_compact_wrapper_strips_store_for_same_format_requests() { let body_json = json!({ diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs index a35ae8167..00c22018e 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs @@ -29,7 +29,8 @@ use crate::ai_serving::planner::standard::{ apply_deepseek_tool_call_thinking_compat, build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_upstream_url, build_local_openai_chat_request_body, build_local_openai_chat_upstream_url, codex_model_capabilities_for_transport, - request_body_build_failure_extra_data, request_conversion_failure_extra_data, + openai_provider_request_contract_failure_extra_data, 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::resolve_local_openai_bearer_auth; @@ -109,7 +110,7 @@ fn finalize_openai_chat_provider_request_body( original_body: &Value, transport: &GatewayProviderTransportSnapshot, mapped_model: &str, -) -> bool { +) -> Option { if let Some(mapping) = custom_directive_mapping { crate::ai_serving::apply_model_directive_mapping_patch(provider_request_body, mapping); } @@ -156,7 +157,15 @@ fn finalize_openai_chat_provider_request_body( }, codex_model_capabilities.as_ref(), ) - .is_ok() + .err() + .map(|violation| { + openai_provider_request_contract_failure_extra_data( + &violation, + "openai:chat", + provider_api_format, + "openai_chat_request_finalization", + ) + }) } #[allow(clippy::too_many_arguments)] @@ -288,7 +297,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts( .await; return Ok(None); }; - if !finalize_openai_chat_provider_request_body( + if let Some(extra_data) = finalize_openai_chat_provider_request_body( &mut provider_request_body, model_directive_mapping.as_ref(), provider_api_format, @@ -306,11 +315,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts( candidate_index, candidate_id, "provider_request_body_build_failed", - request_body_build_failure_extra_data( - body_json, - "openai:chat", - provider_api_format, - ), + Some(extra_data), ) .await; return Ok(None); @@ -501,7 +506,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts( "openai_chat_payload_body_build", body_build_started_at.elapsed().as_millis() as u64, ); - if !finalize_openai_chat_provider_request_body( + if let Some(extra_data) = finalize_openai_chat_provider_request_body( &mut provider_request_body, model_directive_mapping.as_ref(), "openai:chat", @@ -519,11 +524,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts( candidate_index, candidate_id, "provider_request_body_build_failed", - request_body_build_failure_extra_data( - body_json, - "openai:chat", - provider_api_format, - ), + Some(extra_data), ) .await; return Ok(None); @@ -813,7 +814,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts( .await; return Ok(None); }; - if !finalize_openai_chat_provider_request_body( + if let Some(extra_data) = finalize_openai_chat_provider_request_body( &mut provider_request_body, model_directive_mapping.as_ref(), provider_api_format.as_str(), @@ -831,15 +832,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts( candidate_index, candidate_id, "provider_request_body_build_failed", - request_conversion_failure_extra_data( - body_json, - "openai:chat", - provider_api_format.as_str(), - Some(prepared_candidate.mapped_model.as_str()), - Some(parts.uri.path()), - upstream_is_stream, - "openai_chat_request_conversion", - ), + Some(extra_data), ) .await; return Ok(None); 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 449a7cd14..869b9dca8 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,7 +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, - request_body_build_failure_extra_data, request_conversion_failure_extra_data, + openai_provider_request_contract_failure_extra_data, 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::{ @@ -431,24 +432,24 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts( provider_api_format, Some(body_json), ); - if crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities( - &mut base_provider_request_body, - crate::ai_serving::OpenAiProviderRequestFinalization { - source_api_format: spec_metadata.api_format, - provider_api_format, - provider_type: transport.provider.provider_type.as_str(), - provider_model: mapped_model.as_str(), - source_model, - body_rules: transport.endpoint.body_rules.as_ref(), - upstream_is_stream, - require_body_stream_field: request_requires_body_stream_field( - body_json, - force_body_stream_field, - ), - }, - codex_model_capabilities.as_ref(), - ) - .is_err() + if let Err(violation) = + crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities( + &mut base_provider_request_body, + crate::ai_serving::OpenAiProviderRequestFinalization { + source_api_format: spec_metadata.api_format, + provider_api_format, + provider_type: transport.provider.provider_type.as_str(), + provider_model: mapped_model.as_str(), + source_model, + body_rules: transport.endpoint.body_rules.as_ref(), + upstream_is_stream, + require_body_stream_field: request_requires_body_stream_field( + body_json, + force_body_stream_field, + ), + }, + codex_model_capabilities.as_ref(), + ) { mark_skipped_local_openai_responses_candidate_with_extra_data( state, @@ -458,15 +459,12 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts( candidate_index, candidate_id, "provider_request_body_build_failed", - request_conversion_failure_extra_data( - body_json, + Some(openai_provider_request_contract_failure_extra_data( + &violation, spec_metadata.api_format, provider_api_format, - Some(mapped_model.as_str()), - Some(parts.uri.path()), - upstream_is_stream, - "openai_responses_request_conversion", - ), + "openai_responses_request_finalization", + )), ) .await; return Ok(None); diff --git a/apps/aether-gateway/src/ai_serving/pure/mod.rs b/apps/aether-gateway/src/ai_serving/pure/mod.rs index d710ff9d7..39e44b0c8 100644 --- a/apps/aether-gateway/src/ai_serving/pure/mod.rs +++ b/apps/aether-gateway/src/ai_serving/pure/mod.rs @@ -171,7 +171,9 @@ pub(crate) use aether_ai_formats::api::{ pub(crate) use aether_ai_formats::{ api_format_defaults_to_client_error_failover, api_format_defaults_to_non_stream, api_format_permission_covers, intersect_api_format_allowed_lists, is_embedding_api_format, - is_rerank_api_format, openai_responses_request_operation, ApiOperation, ClientSurface, + is_rerank_api_format, openai_responses_request_operation, + openai_responses_synthetic_reasoning_item_id, + strip_incompatible_openai_responses_reasoning_items, ApiOperation, ClientSurface, }; pub(crate) fn plan_kind_matches_api_operation( diff --git a/apps/aether-gateway/src/execution_runtime/grok.rs b/apps/aether-gateway/src/execution_runtime/grok.rs index 48918c677..a6f37cabf 100644 --- a/apps/aether-gateway/src/execution_runtime/grok.rs +++ b/apps/aether-gateway/src/execution_runtime/grok.rs @@ -26,6 +26,7 @@ use crate::ai_serving::api::{ CanonicalContentPart, CanonicalStreamEvent, CanonicalStreamFrame, ClaudeClientEmitter, OpenAIChatClientEmitter, OpenAIResponsesClientEmitter, StreamingCanonicalUsage, }; +use crate::ai_serving::openai_responses_synthetic_reasoning_item_id; use crate::clock::current_unix_secs; use crate::execution_runtime::ndjson::encode_stream_frame_ndjson; use crate::execution_runtime::transport::{ @@ -2705,7 +2706,7 @@ fn openai_responses_body( let mut output = Vec::new(); if !collected.thinking.trim().is_empty() { output.push(json!({ - "id": format!("{response_id}_rs_0"), + "id": openai_responses_synthetic_reasoning_item_id(&response_id, 0), "type": "reasoning", "status": "completed", "summary": [{ diff --git a/apps/aether-gateway/src/executor/outcome.rs b/apps/aether-gateway/src/executor/outcome.rs index 046e415e4..a9a255743 100644 --- a/apps/aether-gateway/src/executor/outcome.rs +++ b/apps/aether-gateway/src/executor/outcome.rs @@ -8,6 +8,9 @@ use aether_data_contracts::repository::candidates::{ use aether_data_contracts::repository::provider_catalog::{ StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider, }; +use aether_data_contracts::repository::usage::{ + ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY, +}; use aether_usage_runtime::{ build_usage_event_data_seed, UsageEvent, UsageEventData, UsageEventType, }; @@ -195,6 +198,7 @@ pub(crate) async fn build_local_execution_exhaustion( data.provider_api_key_id = data .provider_api_key_id .or_else(|| candidate.key_id.clone()); + attach_runtime_miss_candidate_usage_metadata(&mut data, candidate); } exhaustion.data = data; @@ -385,23 +389,25 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request( let selected_candidate = select_last_runtime_miss_executed_candidate(&context.candidate_contexts); - let api_format = selected_candidate + let routing_candidate = selected_candidate + .or_else(|| select_last_runtime_miss_routing_candidate(&context.candidate_contexts)); + let api_format = routing_candidate .and_then(|value| value.client_api_format.clone()) .or_else(|| { trimmed_non_empty(decision.and_then(|value| value.auth_endpoint_signature.as_deref())) }); - let provider_api_format = selected_candidate + let provider_api_format = routing_candidate .and_then(|value| value.provider_api_format.clone()) .or_else(|| api_format.clone()); - let provider_name = selected_candidate + let provider_name = routing_candidate .and_then(|value| value.provider_name.clone()) - .or_else(|| selected_candidate.and_then(|value| value.candidate.provider_id.clone())) + .or_else(|| routing_candidate.and_then(|value| value.candidate.provider_id.clone())) .unwrap_or_else(|| "unknown".to_string()); let model = trimmed_non_empty(diagnostic.and_then(|value| value.requested_model.as_deref())) - .or_else(|| selected_candidate.and_then(|value| value.global_model_name.clone())) - .or_else(|| selected_candidate.and_then(|value| value.selected_provider_model_name.clone())) + .or_else(|| routing_candidate.and_then(|value| value.global_model_name.clone())) + .or_else(|| routing_candidate.and_then(|value| value.selected_provider_model_name.clone())) .unwrap_or_else(|| "unknown".to_string()); - let target_model = selected_candidate + let target_model = routing_candidate .and_then(|value| value.selected_provider_model_name.clone()) .filter(|value| !value.eq_ignore_ascii_case(model.as_str())); @@ -436,10 +442,10 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request( provider_name, model, target_model, - provider_id: selected_candidate.and_then(|value| value.candidate.provider_id.clone()), - provider_endpoint_id: selected_candidate + provider_id: routing_candidate.and_then(|value| value.candidate.provider_id.clone()), + provider_endpoint_id: routing_candidate .and_then(|value| value.candidate.endpoint_id.clone()), - provider_api_key_id: selected_candidate.and_then(|value| value.candidate.key_id.clone()), + provider_api_key_id: routing_candidate.and_then(|value| value.candidate.key_id.clone()), request_type: Some(infer_request_type(api_format.as_deref())), api_format: api_format.clone(), api_family: api_format @@ -459,7 +465,7 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request( .as_deref() .and_then(infer_endpoint_kind) .map(ToOwned::to_owned), - has_format_conversion: selected_candidate.and_then(|value| { + has_format_conversion: routing_candidate.and_then(|value| { value .client_api_format .as_deref() @@ -478,13 +484,16 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request( client_response_body: Some(client_body), ..UsageEventData::default() }; + if let Some(candidate) = routing_candidate { + insert_runtime_miss_candidate_usage_metadata(&mut request_metadata, &candidate.candidate); + } apply_runtime_miss_usage_routing( &mut data, &mut request_metadata, execution_path, - selected_candidate.map(|value| value.candidate.id.as_str()), - selected_candidate.map(|value| value.candidate.candidate_index), - selected_candidate.and_then(|value| value.key_name.as_deref()), + routing_candidate.map(|value| value.candidate.id.as_str()), + routing_candidate.map(|value| value.candidate.candidate_index), + routing_candidate.and_then(|value| value.key_name.as_deref()), diagnostic, decision.and_then(|value| value.route_family.as_deref()), decision.and_then(|value| value.route_kind.as_deref()), @@ -637,6 +646,22 @@ fn select_last_runtime_miss_executed_candidate( }) } +fn select_last_runtime_miss_routing_candidate( + candidates: &[RuntimeMissCandidateContext], +) -> Option<&RuntimeMissCandidateContext> { + candidates.iter().max_by_key(|candidate| { + ( + candidate.candidate.retry_index, + candidate.candidate.candidate_index, + candidate + .candidate + .finished_at_unix_ms + .or(candidate.candidate.started_at_unix_ms) + .unwrap_or(candidate.candidate.created_at_unix_ms), + ) + }) +} + fn request_candidate_represents_provider_execution(candidate: &StoredRequestCandidate) -> bool { matches!( candidate.status, @@ -935,6 +960,66 @@ fn candidate_extra_data_string(candidate: &StoredRequestCandidate, key: &str) -> .map(ToOwned::to_owned) } +fn attach_runtime_miss_candidate_usage_metadata( + data: &mut UsageEventData, + candidate: &StoredRequestCandidate, +) { + let mut metadata = match data.request_metadata.take() { + Some(Value::Object(object)) => object, + Some(other) => Map::from_iter([("seed".to_string(), other)]), + None => Map::new(), + }; + insert_runtime_miss_candidate_usage_metadata(&mut metadata, candidate); + data.request_metadata = (!metadata.is_empty()).then_some(Value::Object(metadata)); +} + +fn insert_runtime_miss_candidate_usage_metadata( + metadata: &mut Map, + candidate: &StoredRequestCandidate, +) { + if let Some(skip_reason) = candidate + .skip_reason + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + metadata.insert( + ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY.to_string(), + Value::String(skip_reason.to_string()), + ); + } + + let diagnostic = candidate + .extra_data + .as_ref() + .and_then(Value::as_object) + .and_then(|extra_data| { + extra_data + .get("failure_diagnostic") + .filter(|value| { + value.as_object().is_some_and(|diagnostic| { + diagnostic.get("safe_to_show") != Some(&Value::Bool(false)) + }) + }) + .or_else(|| { + extra_data + .get("request_conversion_error") + .filter(|v| v.is_object()) + }) + .or_else(|| { + extra_data + .get("request_body_build_error") + .filter(|v| v.is_object()) + }) + }); + if let Some(diagnostic) = diagnostic { + metadata.insert( + ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY.to_string(), + diagnostic.clone(), + ); + } +} + fn runtime_miss_candidate_failure_diagnostic( candidate: &RuntimeMissCandidateContext, ) -> Option { @@ -1170,9 +1255,10 @@ fn trimmed_non_empty(value: Option<&str>) -> Option { mod tests { use super::{ apply_runtime_miss_usage_routing, beautify_local_execution_client_error_message, + insert_runtime_miss_candidate_usage_metadata, request_candidate_represents_provider_execution, runtime_miss_client_error_body, - select_last_runtime_miss_executed_candidate, LocalExecutionRuntimeMissContext, - RuntimeMissCandidateContext, + select_last_runtime_miss_executed_candidate, select_last_runtime_miss_routing_candidate, + LocalExecutionRuntimeMissContext, RuntimeMissCandidateContext, }; use crate::constants::EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS; use crate::state::LocalExecutionRuntimeMissDiagnostic; @@ -1263,7 +1349,7 @@ mod tests { #[test] fn runtime_miss_executed_candidate_selection_ignores_skipped_only_histories() { - let skipped_candidate = StoredRequestCandidate::new( + let mut skipped_candidate = StoredRequestCandidate::new( "cand-skipped".to_string(), "req-1".to_string(), Some("user-1".to_string()), @@ -1290,6 +1376,26 @@ mod tests { None, ) .expect("candidate should build"); + skipped_candidate.skip_reason = Some("provider_request_body_build_failed".to_string()); + skipped_candidate.extra_data = Some(json!({ + "failure_diagnostic": { + "kind": "request_body_build", + "path": "$.reasoning.summary", + "message": "invalid reasoning summary", + "safe_to_show": true + } + })); + + let mut request_metadata = Map::new(); + insert_runtime_miss_candidate_usage_metadata(&mut request_metadata, &skipped_candidate); + assert_eq!( + request_metadata["routing_candidate_skip_reason"], + "provider_request_body_build_failed" + ); + assert_eq!( + request_metadata["routing_failure_diagnostic"]["path"], + "$.reasoning.summary" + ); assert!(!request_candidate_represents_provider_execution( &skipped_candidate @@ -1307,6 +1413,11 @@ mod tests { }]; assert!(select_last_runtime_miss_executed_candidate(&contexts).is_none()); + assert_eq!( + select_last_runtime_miss_routing_candidate(&contexts) + .map(|candidate| candidate.candidate.id.as_str()), + Some("cand-skipped") + ); } #[test] diff --git a/apps/aether-gateway/src/handlers/admin/observability/monitoring/tests/trace.rs b/apps/aether-gateway/src/handlers/admin/observability/monitoring/tests/trace.rs index 7b1d87fdf..f5f183391 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/monitoring/tests/trace.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/monitoring/tests/trace.rs @@ -236,6 +236,15 @@ async fn admin_monitoring_trace_request_falls_back_to_usage_routing_snapshot() { usage.provider_api_key_id = Some("provider-key-1".to_string()); usage.error_message = Some("no local stream plans".to_string()); usage.response_time_ms = Some(45); + usage.request_metadata = Some(json!({ + "routing_candidate_skip_reason": "provider_request_body_build_failed", + "routing_failure_diagnostic": { + "kind": "request_body_build", + "path": "$.reasoning.summary", + "message": "上游请求体语义校验失败", + "safe_to_show": true + } + })); let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![usage])); let data_state = @@ -267,6 +276,10 @@ async fn admin_monitoring_trace_request_falls_back_to_usage_routing_snapshot() { assert_eq!(payload["final_status"], json!("failed")); assert_eq!(payload["candidates"][0]["id"], json!("routing-cand-1")); assert_eq!(payload["candidates"][0]["status"], json!("failed")); + assert_eq!( + payload["candidates"][0]["skip_reason"], + json!("provider_request_body_build_failed") + ); assert_eq!( payload["candidates"][0]["error_type"], json!("no_local_stream_plans") @@ -279,6 +292,10 @@ async fn admin_monitoring_trace_request_falls_back_to_usage_routing_snapshot() { payload["candidates"][0]["extra_data"]["execution_path"], json!("local_execution_runtime_miss") ); + assert_eq!( + payload["candidates"][0]["extra_data"]["failure_diagnostic"]["path"], + json!("$.reasoning.summary") + ); } #[tokio::test] diff --git a/apps/aether-gateway/src/handlers/admin/observability/monitoring/trace.rs b/apps/aether-gateway/src/handlers/admin/observability/monitoring/trace.rs index 1f5a39616..c3e5fd80c 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/monitoring/trace.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/monitoring/trace.rs @@ -203,7 +203,7 @@ fn build_admin_monitoring_usage_routing_snapshot_trace( endpoint_id: usage.provider_endpoint_id.clone(), key_id: usage.provider_api_key_id.clone(), status, - skip_reason: None, + skip_reason: usage.routing_candidate_skip_reason().map(ToOwned::to_owned), is_cached: false, status_code: usage.status_code, error_type: usage @@ -356,6 +356,9 @@ fn build_admin_monitoring_usage_routing_snapshot_extra_data( if let Some(candidate_index) = usage.routing_candidate_index() { object.insert("candidate_index".to_string(), json!(candidate_index)); } + if let Some(diagnostic) = usage.routing_failure_diagnostic() { + object.insert("failure_diagnostic".to_string(), diagnostic.clone()); + } Some(Value::Object(object)) } diff --git a/apps/aether-gateway/src/tests/usage/local.rs b/apps/aether-gateway/src/tests/usage/local.rs index b4e23bd58..bdb4c12ba 100644 --- a/apps/aether-gateway/src/tests/usage/local.rs +++ b/apps/aether-gateway/src/tests/usage/local.rs @@ -1942,14 +1942,14 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s stored_usage.user_id.as_deref(), Some("user-claude-cli-usage-local-miss-1") ); - assert_eq!(stored_usage.provider_name, "unknown"); + assert_eq!(stored_usage.provider_name, "RightCode"); assert_eq!(stored_usage.model, "gpt-5.4"); assert_eq!(stored_usage.api_format.as_deref(), Some("claude:messages")); assert_eq!( stored_usage.endpoint_api_format.as_deref(), - Some("claude:messages") + Some("openai:responses") ); - assert_eq!(stored_usage.routing_key_name(), None); + assert_eq!(stored_usage.routing_key_name(), Some("codex")); assert_eq!(stored_usage.routing_planner_kind(), Some("claude_cli_sync")); assert_eq!(stored_usage.routing_route_family(), Some("claude")); assert_eq!(stored_usage.routing_route_kind(), Some("messages")); @@ -2011,7 +2011,14 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s stored_candidates[0].skip_reason.as_deref(), Some("format_conversion_disabled") ); - assert_eq!(stored_usage.routing_candidate_id(), None); + assert_eq!( + stored_usage.routing_candidate_id(), + Some(stored_candidates[0].id.as_str()) + ); + assert_eq!( + stored_usage.routing_candidate_skip_reason(), + Some("format_conversion_disabled") + ); assert_eq!(*public_hits.lock().expect("mutex should lock"), 0); gateway_handle.abort(); diff --git a/crates/aether-ai/formats/src/api.rs b/crates/aether-ai/formats/src/api.rs index d38bc0cdf..bdd3f2c1d 100644 --- a/crates/aether-ai/formats/src/api.rs +++ b/crates/aether-ai/formats/src/api.rs @@ -75,6 +75,10 @@ pub use crate::formats::openai::{ validate_openai_provider_request_contract, OpenAiProviderRequestContractViolation, OpenAiProviderRequestFinalization, }, + responses::{ + openai_responses_synthetic_reasoning_item_id, + strip_incompatible_openai_responses_reasoning_items, + }, }; pub use crate::formats::shared::error_body::{ build_core_error_body_for_client_format, is_core_error_finalize_kind, LocalCoreSyncErrorKind, diff --git a/crates/aether-ai/formats/src/formats/conversion/response.rs b/crates/aether-ai/formats/src/formats/conversion/response.rs index 3fda75f0f..126fcf25f 100644 --- a/crates/aether-ai/formats/src/formats/conversion/response.rs +++ b/crates/aether-ai/formats/src/formats/conversion/response.rs @@ -8,7 +8,11 @@ use serde_json::{json, Value}; use crate::formats::{ context::FormatContext, - openai::responses::response::ensure_modern_openai_responses_response_fields, registry, + openai::responses::{ + openai_responses_synthetic_reasoning_item_id, + response::ensure_modern_openai_responses_response_fields, + }, + registry, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -203,7 +207,7 @@ pub fn build_openai_responses_response_with_content( } output.push(json!({ "type": "reasoning", - "id": format!("{response_id}_rs_{index}"), + "id": openai_responses_synthetic_reasoning_item_id(response_id, index), "status": "completed", "summary": [{ "type": "summary_text", diff --git a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs index 32992dceb..cf5950cf6 100644 --- a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs +++ b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs @@ -2,8 +2,11 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::{json, Map, Value}; -use crate::formats::openai::responses::response::{ - ensure_modern_openai_responses_response_fields, openai_responses_current_timestamp, +use crate::formats::openai::responses::{ + openai_responses_synthetic_reasoning_item_id, + response::{ + ensure_modern_openai_responses_response_fields, openai_responses_current_timestamp, + }, }; use crate::formats::shared::response::build_generated_tool_call_id; use crate::formats::shared::sse::{encode_done_sse, encode_json_sse}; @@ -2223,7 +2226,7 @@ impl OpenAIResponsesClientEmitter { fn reasoning_item_id(&self) -> String { self.reasoning_item_id .clone() - .unwrap_or_else(|| format!("{}_rs_0", self.response_id())) + .unwrap_or_else(|| openai_responses_synthetic_reasoning_item_id(&self.response_id(), 0)) } fn ensure_message_item_id(&mut self) -> String { @@ -2235,7 +2238,10 @@ impl OpenAIResponsesClientEmitter { fn ensure_reasoning_item_id(&mut self) -> String { if self.reasoning_item_id.is_none() { - self.reasoning_item_id = Some(format!("{}_rs_0", self.response_id())); + self.reasoning_item_id = Some(openai_responses_synthetic_reasoning_item_id( + &self.response_id(), + 0, + )); } self.reasoning_item_id() } @@ -5216,7 +5222,8 @@ mod tests { assert!(sse.contains("event: response.reasoning_summary_text.delta\n")); assert!(sse.contains("event: response.reasoning_summary_text.done\n")); assert!(sse.contains("event: response.reasoning_summary_part.done\n")); - assert!(sse.contains("\"item_id\":\"resp_456_rs_0\"")); + let reasoning_item_id = openai_responses_synthetic_reasoning_item_id("resp_456", 0); + assert!(sse.contains(&format!("\"item_id\":\"{reasoning_item_id}\""))); assert!(sse.contains("\"type\":\"reasoning\"")); assert_eq!(response_sequence_numbers(&sse), (1..=9).collect::>()); } 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 ea9425374..deeeb68f6 100644 --- a/crates/aether-ai/formats/src/formats/openai/request_contract.rs +++ b/crates/aether-ai/formats/src/formats/openai/request_contract.rs @@ -85,6 +85,10 @@ pub fn finalize_openai_provider_request_with_codex_model_capabilities( body, finalization.provider_api_format, ); + super::responses::strip_incompatible_openai_responses_reasoning_items( + body, + finalization.provider_api_format, + ); crate::enforce_request_body_stream_field( body, finalization.provider_api_format, @@ -296,6 +300,42 @@ mod tests { } } + #[test] + fn finalization_strips_non_replayable_responses_reasoning_history() { + let mut body = json!({ + "model": "gpt-5.4", + "input": [ + {"type": "reasoning", "id": "rs_provider_123", "summary": []}, + { + "type": "reasoning", + "id": "item_72d3bd8d367d01977ace23f1", + "summary": [] + }, + {"type": "message", "role": "user", "content": "continue"} + ] + }); + + finalize_openai_provider_request( + &mut body, + OpenAiProviderRequestFinalization { + source_api_format: "openai:responses", + provider_api_format: "openai:responses", + provider_type: "openai", + provider_model: "gpt-5.4", + source_model: "gpt-5.4", + body_rules: None, + upstream_is_stream: false, + require_body_stream_field: false, + }, + ) + .expect("foreign reasoning history should be sanitized before validation"); + + let input = body["input"].as_array().expect("input array"); + assert_eq!(input.len(), 2); + assert_eq!(input[0]["id"], "rs_provider_123"); + assert_eq!(input[1]["type"], "message"); + } + #[test] fn non_responses_sources_receive_codex_responses_reasoning_defaults() { for source_api_format in ["openai:chat", "claude:messages", "gemini:generate_content"] { 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 7fca6658e..eb56c0aac 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs @@ -7,6 +7,68 @@ pub mod spec; pub mod stream; const TOOL_ERROR_PREFIX: &str = "[tool error]"; +const AETHER_REASONING_ITEM_ID_PREFIX: &str = "rs_aether_"; + +/// Builds a stable, wire-compatible ID for a reasoning item synthesized by Aether. +/// +/// The marker lets the outbound request sanitizer distinguish synthetic summaries from +/// provider-backed reasoning items. Synthetic items without encrypted reasoning state are useful +/// in client responses, but cannot be replayed as provider-owned reasoning state. +pub fn openai_responses_synthetic_reasoning_item_id( + response_id: &str, + output_index: usize, +) -> String { + let seed = format!("{response_id}:{output_index}"); + format!( + "{AETHER_REASONING_ITEM_ID_PREFIX}{}", + uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, seed.as_bytes()).simple() + ) +} + +/// Removes reasoning history items that cannot be replayed against an OpenAI Responses backend. +/// +/// Reasoning IDs are opaque provider references and must never be repaired by changing their +/// prefix. Foreign IDs (for example `item_...`) are therefore removed. Aether-synthesized +/// reasoning summaries are also removed unless they carry encrypted reasoning state that can be +/// replayed statelessly. +pub fn strip_incompatible_openai_responses_reasoning_items( + body: &mut Value, + provider_api_format: &str, +) -> usize { + if !aether_ai_formats::is_openai_responses_family_format(provider_api_format) { + return 0; + } + let Some(items) = body.get_mut("input").and_then(Value::as_array_mut) else { + return 0; + }; + let original_len = items.len(); + items.retain(openai_responses_reasoning_item_is_replayable); + original_len.saturating_sub(items.len()) +} + +fn openai_responses_reasoning_item_is_replayable(item: &Value) -> bool { + let Some(object) = item.as_object() else { + return true; + }; + if object.get("type").and_then(Value::as_str) != Some("reasoning") { + return true; + } + let Some(id) = object + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| id.starts_with("rs")) + else { + return false; + }; + if !id.starts_with(AETHER_REASONING_ITEM_ID_PREFIX) { + return true; + } + object + .get("encrypted_content") + .and_then(Value::as_str) + .is_some_and(|encrypted_content| !encrypted_content.trim().is_empty()) +} /// Semantic operation carried by an OpenAI Responses request that asks the /// service to compact a thread. The request still uses the Responses wire @@ -56,7 +118,10 @@ fn encode_tool_result_error(output: Value, is_error: bool) -> Value { mod tests { use serde_json::json; - use super::{openai_responses_request_operation, OPENAI_RESPONSES_OPERATION_COMPACT}; + use super::{ + openai_responses_request_operation, openai_responses_synthetic_reasoning_item_id, + strip_incompatible_openai_responses_reasoning_items, OPENAI_RESPONSES_OPERATION_COMPACT, + }; #[test] fn resolves_compaction_trigger_as_compact_operation_on_responses_transport() { @@ -88,4 +153,60 @@ mod tests { Some(OPENAI_RESPONSES_OPERATION_COMPACT) ); } + + #[test] + fn synthetic_reasoning_item_ids_are_stable_and_wire_compatible() { + let first = openai_responses_synthetic_reasoning_item_id("resp_123", 0); + let second = openai_responses_synthetic_reasoning_item_id("resp_123", 0); + let other = openai_responses_synthetic_reasoning_item_id("resp_123", 1); + + assert!(first.starts_with("rs_aether_")); + assert_eq!(first, second); + assert_ne!(first, other); + } + + #[test] + fn strips_foreign_and_non_replayable_synthetic_reasoning_items() { + let portable_synthetic = openai_responses_synthetic_reasoning_item_id("resp_123", 1); + let local_synthetic = openai_responses_synthetic_reasoning_item_id("resp_123", 2); + let mut body = json!({ + "input": [ + {"type": "reasoning", "id": "rs_provider_123", "summary": []}, + {"type": "reasoning", "id": "item_72d3bd8d367d01977ace23f1", "summary": []}, + {"type": "reasoning", "id": "resp_123_rs_0", "summary": []}, + {"type": "reasoning", "summary": []}, + { + "type": "reasoning", + "id": portable_synthetic, + "summary": [], + "encrypted_content": "opaque" + }, + {"type": "reasoning", "id": local_synthetic, "summary": []}, + {"type": "message", "id": "item_message_123", "role": "user", "content": "hi"} + ] + }); + + assert_eq!( + strip_incompatible_openai_responses_reasoning_items(&mut body, "openai:responses"), + 4 + ); + let input = body["input"].as_array().expect("input array"); + assert_eq!(input.len(), 3); + assert_eq!(input[0]["id"], "rs_provider_123"); + assert_eq!(input[1]["encrypted_content"], "opaque"); + assert_eq!(input[2]["id"], "item_message_123"); + } + + #[test] + fn reasoning_item_sanitizer_is_scoped_to_responses_targets() { + let mut body = json!({ + "input": [{"type": "reasoning", "id": "item_foreign", "summary": []}] + }); + + assert_eq!( + strip_incompatible_openai_responses_reasoning_items(&mut body, "openai:chat"), + 0 + ); + assert_eq!(body["input"].as_array().map(Vec::len), Some(1)); + } } diff --git a/crates/aether-ai/formats/src/formats/openai/responses/response.rs b/crates/aether-ai/formats/src/formats/openai/responses/response.rs index 088ccc60d..4533b6b6e 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/response.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/response.rs @@ -5,7 +5,7 @@ use std::{ use serde_json::{json, Map, Value}; -use super::encode_tool_result_error; +use super::{encode_tool_result_error, openai_responses_synthetic_reasoning_item_id}; use crate::{ formats::context::FormatContext, @@ -198,7 +198,10 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo item.insert("type".to_string(), Value::String("reasoning".to_string())); item.insert( "id".to_string(), - Value::String(format!("{}_rs_{}", response_id, output.len())), + Value::String(openai_responses_synthetic_reasoning_item_id( + &response_id, + output.len(), + )), ); item.insert("status".to_string(), Value::String("completed".to_string())); if let Some(encrypted_content) = encrypted_content { @@ -712,6 +715,9 @@ mod tests { let rebuilt = to_raw(&canonical, &json!({}), false); assert_eq!(rebuilt["output"][0]["type"], "reasoning"); + assert!(rebuilt["output"][0]["id"] + .as_str() + .is_some_and(|id| id.starts_with("rs"))); assert_eq!( rebuilt["output"][0]["encrypted_content"], json!("openai-opaque") 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 5e79591cf..e1a5cd9e5 100644 --- a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs @@ -150,6 +150,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( + &mut provider_request_body, + provider_api_format, + ); strip_openai_responses_input_content_cache_control( &mut provider_request_body, provider_api_format, 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 470336daa..166dcacae 100644 --- a/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs +++ b/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs @@ -297,6 +297,10 @@ 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/formats/shared/sync_products.rs b/crates/aether-ai/formats/src/formats/shared/sync_products.rs index e2e451c23..c13bbd3f9 100644 --- a/crates/aether-ai/formats/src/formats/shared/sync_products.rs +++ b/crates/aether-ai/formats/src/formats/shared/sync_products.rs @@ -7,6 +7,7 @@ use aether_ai_formats::formats::conversion::response::{ convert_openai_chat_response_to_openai_responses, convert_openai_responses_response_to_openai_chat, }; +use aether_ai_formats::formats::openai::responses::openai_responses_synthetic_reasoning_item_id; use aether_ai_formats::formats::openai::responses::response::ensure_modern_openai_responses_response_fields; use aether_ai_formats::formats::registry::{convert_response, FormatContext, FormatError}; use aether_ai_formats::{ @@ -3185,8 +3186,9 @@ fn materialize_openai_responses_reasoning_item( let mut item = state.item; item.entry("type".to_string()) .or_insert_with(|| Value::String("reasoning".to_string())); - item.entry("id".to_string()) - .or_insert_with(|| Value::String(format!("{response_id}_rs_0"))); + item.entry("id".to_string()).or_insert_with(|| { + Value::String(openai_responses_synthetic_reasoning_item_id(response_id, 0)) + }); item.entry("status".to_string()) .or_insert_with(|| Value::String("completed".to_string())); if !state.summary_text.is_empty() { diff --git a/crates/aether-ai/formats/src/lib.rs b/crates/aether-ai/formats/src/lib.rs index 17f1654ff..452733881 100644 --- a/crates/aether-ai/formats/src/lib.rs +++ b/crates/aether-ai/formats/src/lib.rs @@ -52,7 +52,8 @@ pub use formats::openai::responses::request::{ validate_openai_responses_request_contract, OpenAiResponsesRequestContractViolation, }; pub use formats::openai::responses::{ - openai_responses_request_operation, OPENAI_RESPONSES_OPERATION_COMPACT, + openai_responses_request_operation, openai_responses_synthetic_reasoning_item_id, + strip_incompatible_openai_responses_reasoning_items, OPENAI_RESPONSES_OPERATION_COMPACT, }; pub use formats::registry::{ build_stream_transcoder, convert_request, convert_request_pure, diff --git a/crates/aether-ai/serving/src/lib.rs b/crates/aether-ai/serving/src/lib.rs index 5c44bcaf3..502bfef9a 100644 --- a/crates/aether-ai/serving/src/lib.rs +++ b/crates/aether-ai/serving/src/lib.rs @@ -129,8 +129,8 @@ pub use report_context::{ AiExecutionReportContextParts, AiRequestOrigin, }; pub use request_body_diagnostics::{ - request_body_build_failure_extra_data, request_conversion_failure_extra_data, - same_format_provider_request_body_failure_extra_data, + openai_provider_request_contract_failure_extra_data, request_body_build_failure_extra_data, + request_conversion_failure_extra_data, same_format_provider_request_body_failure_extra_data, }; pub use runtime_miss::{ apply_ai_runtime_candidate_evaluation_progress, diff --git a/crates/aether-ai/serving/src/request_body_diagnostics.rs b/crates/aether-ai/serving/src/request_body_diagnostics.rs index fd9ecc488..931f016c3 100644 --- a/crates/aether-ai/serving/src/request_body_diagnostics.rs +++ b/crates/aether-ai/serving/src/request_body_diagnostics.rs @@ -2,7 +2,7 @@ use serde_json::Value; use aether_ai_formats::api::{ is_claude_messages_shaped_body_on_openai_chat_endpoint, is_openai_responses_family_format, - normalize_api_format_alias, + normalize_api_format_alias, OpenAiProviderRequestContractViolation, }; use aether_ai_formats::{convert_request_pure_with_context, FormatContext, FormatError}; @@ -51,6 +51,42 @@ pub fn request_conversion_failure_extra_data( ) } +pub fn openai_provider_request_contract_failure_extra_data( + violation: &OpenAiProviderRequestContractViolation, + client_api_format: &str, + provider_api_format: &str, + source: impl Into, +) -> Value { + let (field, reason) = openai_provider_request_contract_violation_parts(violation); + CandidateFailureDiagnostic::new( + CandidateFailureDiagnosticKind::RequestBodyBuild, + field_to_json_path(field), + format!("上游请求体语义校验失败:{reason}"), + ) + .formats(client_api_format, provider_api_format) + .source(source) + .to_extra_data() +} + +fn openai_provider_request_contract_violation_parts( + violation: &OpenAiProviderRequestContractViolation, +) -> (&str, &str) { + match violation { + OpenAiProviderRequestContractViolation::CodexCompact(violation) => { + (violation.field, violation.reason) + } + OpenAiProviderRequestContractViolation::Responses(violation) => { + (violation.field, violation.reason) + } + OpenAiProviderRequestContractViolation::PromptCache(violation) => { + (violation.field.as_str(), violation.reason.as_str()) + } + OpenAiProviderRequestContractViolation::Reasoning(violation) => { + (violation.field.as_str(), violation.reason.as_str()) + } + } +} + pub fn same_format_provider_request_body_failure_extra_data( body_json: &Value, provider_api_format: &str, @@ -798,7 +834,10 @@ fn request_body_build_source(client_api_format: &str, provider_api_format: &str) mod tests { use serde_json::json; - use super::{request_body_build_failure_extra_data, request_conversion_failure_extra_data}; + use super::{ + openai_provider_request_contract_failure_extra_data, request_body_build_failure_extra_data, + request_conversion_failure_extra_data, + }; #[test] fn openai_chat_to_claude_recognizes_compatible_claude_native_tool_shape() { @@ -950,6 +989,40 @@ mod tests { .contains("字段 n")); } + #[test] + fn provider_contract_failure_preserves_finalization_field_and_reason() { + let violation = aether_ai_formats::OpenAiProviderRequestContractViolation::Responses( + aether_ai_formats::OpenAiResponsesRequestContractViolation { + field: "reasoning.summary", + reason: "OpenAI multi-agent requests do not support reasoning summaries", + }, + ); + + let diagnostic = openai_provider_request_contract_failure_extra_data( + &violation, + "openai:responses", + "openai:responses", + "openai_responses_request_finalization", + ); + + assert_eq!( + diagnostic["failure_diagnostic"]["kind"], + "request_body_build" + ); + assert_eq!( + diagnostic["failure_diagnostic"]["path"], + "$.reasoning.summary" + ); + assert_eq!( + diagnostic["failure_diagnostic"]["source"], + "openai_responses_request_finalization" + ); + assert!(diagnostic["request_body_build_error"]["message"] + .as_str() + .expect("message") + .contains("do not support reasoning summaries")); + } + #[test] fn same_format_provider_reports_non_object_body() { let diagnostic = super::same_format_provider_request_body_failure_extra_data( diff --git a/crates/aether-data/contracts/src/repository/usage/mod.rs b/crates/aether-data/contracts/src/repository/usage/mod.rs index 864ed34ce..00c784f03 100644 --- a/crates/aether-data/contracts/src/repository/usage/mod.rs +++ b/crates/aether-data/contracts/src/repository/usage/mod.rs @@ -36,4 +36,5 @@ pub use types::{ UsageTimeSeriesQuery, UsageWriteRepository, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, PROVIDER_REASONING_EFFORT_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY, REQUESTED_REASONING_EFFORT_METADATA_KEY, + ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY, }; diff --git a/crates/aether-data/contracts/src/repository/usage/types.rs b/crates/aether-data/contracts/src/repository/usage/types.rs index c9c48918e..8587cc5b0 100644 --- a/crates/aether-data/contracts/src/repository/usage/types.rs +++ b/crates/aether-data/contracts/src/repository/usage/types.rs @@ -7,6 +7,8 @@ pub const REQUESTED_REASONING_EFFORT_METADATA_KEY: &str = "requested_reasoning_e pub const PROVIDER_SERVICE_TIER_METADATA_KEY: &str = "provider_service_tier"; pub const PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY: &str = "provider_actual_service_tier"; pub const PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY: &str = "provider_cache_ttl_minutes"; +pub const ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY: &str = "routing_candidate_skip_reason"; +pub const ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY: &str = "routing_failure_diagnostic"; pub fn extract_provider_reasoning_effort_from_body(value: Option<&Value>) -> Option { let object = value.and_then(Value::as_object)?; @@ -827,6 +829,16 @@ impl StoredRequestUsageAudit { .as_deref() .or_else(|| self.request_metadata_string("local_execution_runtime_miss_reason")) } + + pub fn routing_candidate_skip_reason(&self) -> Option<&str> { + self.request_metadata_string(ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY) + } + + pub fn routing_failure_diagnostic(&self) -> Option<&Value> { + self.request_metadata_object() + .and_then(|metadata| metadata.get(ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY)) + .filter(|value| value.is_object()) + } } #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] @@ -2695,7 +2707,12 @@ mod tests { "response_body_ref": "blob://legacy-response", "client_response_body_ref": "blob://legacy-client-response", "candidate_id": "cand-legacy", - "key_name": "primary-legacy" + "key_name": "primary-legacy", + "routing_candidate_skip_reason": "provider_request_body_build_failed", + "routing_failure_diagnostic": { + "path": "$.reasoning.summary", + "message": "invalid reasoning summary" + } })); assert_eq!( @@ -2727,6 +2744,16 @@ mod tests { usage.routing_local_execution_runtime_miss_reason(), Some("all_candidates_skipped") ); + assert_eq!( + usage.routing_candidate_skip_reason(), + Some("provider_request_body_build_failed") + ); + assert_eq!( + usage + .routing_failure_diagnostic() + .and_then(|diagnostic| diagnostic.get("path")), + Some(&json!("$.reasoning.summary")) + ); } #[test] 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 e8f841b6a..a01abd72a 100644 --- a/crates/aether-provider/transport/src/same_format_provider/mod.rs +++ b/crates/aether-provider/transport/src/same_format_provider/mod.rs @@ -436,6 +436,21 @@ fn build_same_format_provider_request_body_inner( ), ); } + let stripped_reasoning_items = + aether_ai_formats::strip_incompatible_openai_responses_reasoning_items( + &mut provider_request_body, + input.provider_api_format, + ); + if stripped_reasoning_items > 0 { + record_compatibility_edit( + &mut compatibility_edits, + "input[].id", + SameFormatProviderCompatibilityEditAction::ProviderCompatibilityRewrite, + format!( + "stripped {stripped_reasoning_items} non-replayable OpenAI Responses reasoning item(s)" + ), + ); + } let provider_model = provider_request_body .get("model") .and_then(Value::as_str) @@ -2003,6 +2018,50 @@ mod tests { })); } + #[test] + fn same_format_responses_body_strips_foreign_reasoning_ids_and_reports_the_edit() { + let request_body = json!({ + "model": "gpt-5.4", + "input": [ + {"type": "reasoning", "id": "rs_provider_123", "summary": []}, + { + "type": "reasoning", + "id": "item_72d3bd8d367d01977ace23f1", + "summary": [] + }, + {"type": "message", "role": "user", "content": "continue"} + ] + }); + let output = build_same_format_provider_request_body_with_compatibility_report( + SameFormatProviderRequestBodyInput { + body_json: &request_body, + mapped_model: "gpt-5.4", + client_api_format: "openai:responses", + provider_api_format: "openai:responses", + source_model: Some("gpt-5.4"), + 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, + }, + ) + .expect("same-format Responses body should build"); + + let input = output.body["input"].as_array().expect("input array"); + assert_eq!(input.len(), 2); + assert_eq!(input[0]["id"], "rs_provider_123"); + assert_eq!(input[1]["type"], "message"); + assert!(output.compatibility_edits.iter().any(|edit| { + edit.field == "input[].id" + && edit.action + == SameFormatProviderCompatibilityEditAction::ProviderCompatibilityRewrite + })); + } + #[test] fn same_format_stream_policy_wins_after_body_rules() { let body_rules = json!([ diff --git a/crates/aether-usage/runtime/src/request_metadata.rs b/crates/aether-usage/runtime/src/request_metadata.rs index df62aa09b..ee56d4f5a 100644 --- a/crates/aether-usage/runtime/src/request_metadata.rs +++ b/crates/aether-usage/runtime/src/request_metadata.rs @@ -9,7 +9,8 @@ use aether_data_contracts::repository::usage::{ normalize_provider_service_tier, resolve_provider_cache_ttl_minutes, UsageBodyCaptureState, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, PROVIDER_REASONING_EFFORT_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY, - REQUESTED_REASONING_EFFORT_METADATA_KEY, + REQUESTED_REASONING_EFFORT_METADATA_KEY, ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, + ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY, }; use serde_json::{json, Map, Value}; @@ -376,6 +377,8 @@ fn copy_allowed_metadata_fields(source: &Map, target: &mut Map, target: &mut Map remove_non_null_value(&mut source, target, "dimensions"); remove_non_null_value(&mut source, target, "billing_rule_snapshot"); remove_non_null_value(&mut source, target, "scheduling_audit"); + remove_non_empty_string( + &mut source, + target, + ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, + ); + remove_non_null_value(&mut source, target, ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY); remove_non_null_value(&mut source, target, "tls_fingerprint"); remove_number(&mut source, target, "rate_multiplier"); remove_bool(&mut source, target, "is_free_tier"); @@ -800,6 +809,13 @@ mod tests { "global_model_id": "global-model-1", "global_model_name": "gpt-5", "dimensions": {"total_input_context": 10}, + "routing_candidate_skip_reason": "provider_request_body_build_failed", + "routing_failure_diagnostic": { + "kind": "request_body_build", + "path": "$.reasoning.summary", + "message": "invalid reasoning summary", + "safe_to_show": true + }, "rate_multiplier": 1.25, "is_free_tier": false, "input_price_per_1m": 3.0, @@ -844,6 +860,13 @@ mod tests { "global_model_id": "global-model-1", "global_model_name": "gpt-5", "dimensions": {"total_input_context": 10}, + "routing_candidate_skip_reason": "provider_request_body_build_failed", + "routing_failure_diagnostic": { + "kind": "request_body_build", + "path": "$.reasoning.summary", + "message": "invalid reasoning summary", + "safe_to_show": true + }, "rate_multiplier": 1.25, "is_free_tier": false, "input_price_per_1m": 3.0,