Merge upstream/main into main

This commit is contained in:
ZheFox
2026-07-30 19:40:39 +08:00
32 changed files with 742 additions and 163 deletions
@@ -41,7 +41,8 @@ use super::{
LocalSameFormatProviderSpec, LocalSameFormatProviderSpec,
}; };
use crate::ai_serving::planner::standard::{ 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( 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(), prepared.mapped_model.as_str(),
source_model, source_model,
); );
if crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities( if let Err(violation) =
&mut base_provider_request_body, crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
crate::ai_serving::OpenAiProviderRequestFinalization { &mut base_provider_request_body,
source_api_format: spec.api_format, crate::ai_serving::OpenAiProviderRequestFinalization {
provider_api_format: prepared.provider_api_format.as_str(), source_api_format: spec.api_format,
provider_type: transport.provider.provider_type.as_str(), provider_api_format: prepared.provider_api_format.as_str(),
provider_model: prepared.mapped_model.as_str(), provider_type: transport.provider.provider_type.as_str(),
source_model, provider_model: prepared.mapped_model.as_str(),
body_rules: transport.endpoint.body_rules.as_ref(), source_model,
upstream_is_stream: prepared.upstream_is_stream, body_rules: transport.endpoint.body_rules.as_ref(),
require_body_stream_field: request_requires_body_stream_field( upstream_is_stream: prepared.upstream_is_stream,
body_json, require_body_stream_field: request_requires_body_stream_field(
prepared.force_body_stream_field, body_json,
), prepared.force_body_stream_field,
}, ),
codex_model_capabilities.as_ref(), },
) codex_model_capabilities.as_ref(),
.is_err() )
{ {
mark_skipped_local_same_format_provider_candidate( mark_skipped_local_same_format_provider_candidate_with_extra_data(
state, state,
input, input,
trace_id, trace_id,
candidate, candidate,
attempt.candidate_index, attempt.candidate_index,
&attempt.candidate_id, &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; .await;
return Ok(None); return Ok(None);
@@ -23,7 +23,8 @@ use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata;
use crate::ai_serving::planner::standard::{ use crate::ai_serving::planner::standard::{
apply_codex_openai_special_headers, apply_deepseek_tool_call_thinking_compat, apply_codex_openai_special_headers, apply_deepseek_tool_call_thinking_compat,
codex_model_capabilities_for_transport, is_deepseek_provider, 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::{ use crate::ai_serving::transport::kiro::{
build_kiro_provider_headers, build_kiro_provider_request_body, build_kiro_provider_headers, build_kiro_provider_request_body,
@@ -748,24 +749,24 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
prepared_candidate.mapped_model.as_str(), prepared_candidate.mapped_model.as_str(),
source_model, source_model,
); );
if crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities( if let Err(violation) =
&mut provider_request_body, crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
crate::ai_serving::OpenAiProviderRequestFinalization { &mut provider_request_body,
source_api_format: spec_metadata.api_format, crate::ai_serving::OpenAiProviderRequestFinalization {
provider_api_format, source_api_format: spec_metadata.api_format,
provider_type: transport.provider.provider_type.as_str(), provider_api_format,
provider_model: prepared_candidate.mapped_model.as_str(), provider_type: transport.provider.provider_type.as_str(),
source_model, provider_model: prepared_candidate.mapped_model.as_str(),
body_rules: transport.endpoint.body_rules.as_ref(), source_model,
upstream_is_stream, body_rules: transport.endpoint.body_rules.as_ref(),
require_body_stream_field: request_requires_body_stream_field( upstream_is_stream,
body_json, require_body_stream_field: request_requires_body_stream_field(
force_body_stream_field, body_json,
), force_body_stream_field,
}, ),
codex_model_capabilities.as_ref(), },
) codex_model_capabilities.as_ref(),
.is_err() )
{ {
mark_skipped_local_standard_candidate_with_extra_data( mark_skipped_local_standard_candidate_with_extra_data(
state, state,
@@ -775,15 +776,12 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
attempt.candidate_index, attempt.candidate_index,
&attempt.candidate_id, &attempt.candidate_id,
"provider_request_body_build_failed", "provider_request_body_build_failed",
request_conversion_failure_extra_data( Some(openai_provider_request_contract_failure_extra_data(
body_json, &violation,
spec_metadata.api_format, spec_metadata.api_format,
provider_api_format, provider_api_format,
Some(prepared_candidate.mapped_model.as_str()),
Some(parts.uri.path()),
upstream_is_stream,
"standard_family_request_finalization", "standard_family_request_finalization",
), )),
) )
.await; .await;
return Ok(None); return Ok(None);
@@ -65,8 +65,8 @@ pub(crate) use crate::ai_serving::{
normalize_openai_responses_request_to_openai_chat_request, parse_openai_tool_result_content, normalize_openai_responses_request_to_openai_chat_request, parse_openai_tool_result_content,
}; };
pub(crate) use aether_ai_serving::{ pub(crate) use aether_ai_serving::{
request_body_build_failure_extra_data, request_conversion_failure_extra_data, openai_provider_request_contract_failure_extra_data, request_body_build_failure_extra_data,
same_format_provider_request_body_failure_extra_data, request_conversion_failure_extra_data, same_format_provider_request_body_failure_extra_data,
}; };
pub(crate) fn build_standard_upstream_url( pub(crate) fn build_standard_upstream_url(
@@ -81,6 +81,10 @@ pub(crate) fn build_local_openai_responses_request_body_with_codex_model_capabil
&mut provider_request_body, &mut provider_request_body,
provider_api_format, provider_api_format,
); );
crate::ai_serving::strip_incompatible_openai_responses_reasoning_items(
&mut provider_request_body,
provider_api_format,
);
enforce_provider_body_stream_policy( enforce_provider_body_stream_policy(
&mut provider_request_body, &mut provider_request_body,
provider_api_format, provider_api_format,
@@ -172,6 +176,10 @@ pub(crate) fn build_cross_format_openai_responses_request_body_with_codex_model_
&mut provider_request_body, &mut provider_request_body,
provider_api_format, provider_api_format,
); );
crate::ai_serving::strip_incompatible_openai_responses_reasoning_items(
&mut provider_request_body,
provider_api_format,
);
enforce_provider_body_stream_policy( enforce_provider_body_stream_policy(
&mut provider_request_body, &mut provider_request_body,
provider_api_format, provider_api_format,
@@ -152,6 +152,43 @@ fn local_openai_responses_wrapper_preserves_body_order_after_edits() {
assert!(provider_request_body.get("instructions").is_none()); 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] #[test]
fn local_openai_responses_compact_wrapper_strips_store_for_same_format_requests() { fn local_openai_responses_compact_wrapper_strips_store_for_same_format_requests() {
let body_json = json!({ let body_json = json!({
@@ -29,7 +29,8 @@ use crate::ai_serving::planner::standard::{
apply_deepseek_tool_call_thinking_compat, build_cross_format_openai_chat_request_body, 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_cross_format_openai_chat_upstream_url, build_local_openai_chat_request_body,
build_local_openai_chat_upstream_url, codex_model_capabilities_for_transport, 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::antigravity::is_antigravity_provider_transport;
use crate::ai_serving::transport::auth::resolve_local_openai_bearer_auth; 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, original_body: &Value,
transport: &GatewayProviderTransportSnapshot, transport: &GatewayProviderTransportSnapshot,
mapped_model: &str, mapped_model: &str,
) -> bool { ) -> Option<Value> {
if let Some(mapping) = custom_directive_mapping { if let Some(mapping) = custom_directive_mapping {
crate::ai_serving::apply_model_directive_mapping_patch(provider_request_body, 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(), 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)] #[allow(clippy::too_many_arguments)]
@@ -288,7 +297,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
.await; .await;
return Ok(None); 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, &mut provider_request_body,
model_directive_mapping.as_ref(), model_directive_mapping.as_ref(),
provider_api_format, provider_api_format,
@@ -306,11 +315,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
candidate_index, candidate_index,
candidate_id, candidate_id,
"provider_request_body_build_failed", "provider_request_body_build_failed",
request_body_build_failure_extra_data( Some(extra_data),
body_json,
"openai:chat",
provider_api_format,
),
) )
.await; .await;
return Ok(None); return Ok(None);
@@ -501,7 +506,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
"openai_chat_payload_body_build", "openai_chat_payload_body_build",
body_build_started_at.elapsed().as_millis() as u64, 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, &mut provider_request_body,
model_directive_mapping.as_ref(), model_directive_mapping.as_ref(),
"openai:chat", "openai:chat",
@@ -519,11 +524,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
candidate_index, candidate_index,
candidate_id, candidate_id,
"provider_request_body_build_failed", "provider_request_body_build_failed",
request_body_build_failure_extra_data( Some(extra_data),
body_json,
"openai:chat",
provider_api_format,
),
) )
.await; .await;
return Ok(None); return Ok(None);
@@ -813,7 +814,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
.await; .await;
return Ok(None); 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, &mut provider_request_body,
model_directive_mapping.as_ref(), model_directive_mapping.as_ref(),
provider_api_format.as_str(), provider_api_format.as_str(),
@@ -831,15 +832,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
candidate_index, candidate_index,
candidate_id, candidate_id,
"provider_request_body_build_failed", "provider_request_body_build_failed",
request_conversion_failure_extra_data( Some(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",
),
) )
.await; .await;
return Ok(None); return Ok(None);
@@ -32,7 +32,8 @@ use crate::ai_serving::planner::standard::{
build_cross_format_openai_responses_upstream_url, build_cross_format_openai_responses_upstream_url,
build_local_openai_responses_request_body_with_codex_model_capabilities, build_local_openai_responses_request_body_with_codex_model_capabilities,
build_local_openai_responses_upstream_url, codex_model_capabilities_for_transport, 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::antigravity::is_antigravity_provider_transport;
use crate::ai_serving::transport::auth::{ use crate::ai_serving::transport::auth::{
@@ -530,24 +531,24 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
provider_api_format, provider_api_format,
Some(body_json), Some(body_json),
); );
if crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities( if let Err(violation) =
&mut base_provider_request_body, crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
crate::ai_serving::OpenAiProviderRequestFinalization { &mut base_provider_request_body,
source_api_format: spec_metadata.api_format, crate::ai_serving::OpenAiProviderRequestFinalization {
provider_api_format, source_api_format: spec_metadata.api_format,
provider_type: transport.provider.provider_type.as_str(), provider_api_format,
provider_model: mapped_model.as_str(), provider_type: transport.provider.provider_type.as_str(),
source_model, provider_model: mapped_model.as_str(),
body_rules: transport.endpoint.body_rules.as_ref(), source_model,
upstream_is_stream, body_rules: transport.endpoint.body_rules.as_ref(),
require_body_stream_field: request_requires_body_stream_field( upstream_is_stream,
body_json, require_body_stream_field: request_requires_body_stream_field(
force_body_stream_field, body_json,
), force_body_stream_field,
}, ),
codex_model_capabilities.as_ref(), },
) codex_model_capabilities.as_ref(),
.is_err() )
{ {
mark_skipped_local_openai_responses_candidate_with_extra_data( mark_skipped_local_openai_responses_candidate_with_extra_data(
state, state,
@@ -557,15 +558,12 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
candidate_index, candidate_index,
candidate_id, candidate_id,
"provider_request_body_build_failed", "provider_request_body_build_failed",
request_conversion_failure_extra_data( Some(openai_provider_request_contract_failure_extra_data(
body_json, &violation,
spec_metadata.api_format, spec_metadata.api_format,
provider_api_format, provider_api_format,
Some(mapped_model.as_str()), "openai_responses_request_finalization",
Some(parts.uri.path()), )),
upstream_is_stream,
"openai_responses_request_conversion",
),
) )
.await; .await;
return Ok(None); return Ok(None);
@@ -172,7 +172,9 @@ pub(crate) use aether_ai_formats::api::{
pub(crate) use aether_ai_formats::{ pub(crate) use aether_ai_formats::{
api_format_defaults_to_client_error_failover, api_format_defaults_to_non_stream, 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, 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( pub(crate) fn plan_kind_matches_api_operation(
@@ -1,6 +1,8 @@
use http::Uri; use http::Uri;
use super::{classify_control_route, headers}; use super::{classify_control_route, headers};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::shared::local_proxy_route_requires_buffered_body;
#[test] #[test]
fn classifies_admin_pool_overview_as_admin_proxy_route() { fn classifies_admin_pool_overview_as_admin_proxy_route() {
@@ -92,6 +94,16 @@ fn classifies_admin_pool_provider_key_routes_as_admin_proxy_route() {
batch_update.route_kind.as_deref(), batch_update.route_kind.as_deref(),
Some("batch_update_keys") Some("batch_update_keys")
); );
let batch_update_context = GatewayPublicRequestContext::from_request_parts(
"trace-admin-pool-batch-update",
&http::Method::PATCH,
&batch_update_uri,
&headers,
Some(batch_update),
);
assert!(local_proxy_route_requires_buffered_body(
&batch_update_context
));
let resolve_selection_uri: Uri = "/api/admin/pool/provider-1/keys/resolve-selection" let resolve_selection_uri: Uri = "/api/admin/pool/provider-1/keys/resolve-selection"
.parse() .parse()
@@ -26,6 +26,7 @@ use crate::ai_serving::api::{
CanonicalContentPart, CanonicalStreamEvent, CanonicalStreamFrame, ClaudeClientEmitter, CanonicalContentPart, CanonicalStreamEvent, CanonicalStreamFrame, ClaudeClientEmitter,
OpenAIChatClientEmitter, OpenAIResponsesClientEmitter, StreamingCanonicalUsage, OpenAIChatClientEmitter, OpenAIResponsesClientEmitter, StreamingCanonicalUsage,
}; };
use crate::ai_serving::openai_responses_synthetic_reasoning_item_id;
use crate::clock::current_unix_secs; use crate::clock::current_unix_secs;
use crate::execution_runtime::ndjson::encode_stream_frame_ndjson; use crate::execution_runtime::ndjson::encode_stream_frame_ndjson;
use crate::execution_runtime::transport::{ use crate::execution_runtime::transport::{
@@ -2705,7 +2706,7 @@ fn openai_responses_body(
let mut output = Vec::new(); let mut output = Vec::new();
if !collected.thinking.trim().is_empty() { if !collected.thinking.trim().is_empty() {
output.push(json!({ output.push(json!({
"id": format!("{response_id}_rs_0"), "id": openai_responses_synthetic_reasoning_item_id(&response_id, 0),
"type": "reasoning", "type": "reasoning",
"status": "completed", "status": "completed",
"summary": [{ "summary": [{
+128 -17
View File
@@ -8,6 +8,9 @@ use aether_data_contracts::repository::candidates::{
use aether_data_contracts::repository::provider_catalog::{ use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider, StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
}; };
use aether_data_contracts::repository::usage::{
ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY,
};
use aether_usage_runtime::{ use aether_usage_runtime::{
build_usage_event_data_seed, UsageEvent, UsageEventData, UsageEventType, 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 data.provider_api_key_id = data
.provider_api_key_id .provider_api_key_id
.or_else(|| candidate.key_id.clone()); .or_else(|| candidate.key_id.clone());
attach_runtime_miss_candidate_usage_metadata(&mut data, candidate);
} }
exhaustion.data = data; exhaustion.data = data;
@@ -385,23 +389,25 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request(
let selected_candidate = let selected_candidate =
select_last_runtime_miss_executed_candidate(&context.candidate_contexts); 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()) .and_then(|value| value.client_api_format.clone())
.or_else(|| { .or_else(|| {
trimmed_non_empty(decision.and_then(|value| value.auth_endpoint_signature.as_deref())) 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()) .and_then(|value| value.provider_api_format.clone())
.or_else(|| 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()) .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()); .unwrap_or_else(|| "unknown".to_string());
let model = trimmed_non_empty(diagnostic.and_then(|value| value.requested_model.as_deref())) 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(|| routing_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.selected_provider_model_name.clone()))
.unwrap_or_else(|| "unknown".to_string()); .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()) .and_then(|value| value.selected_provider_model_name.clone())
.filter(|value| !value.eq_ignore_ascii_case(model.as_str())); .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, provider_name,
model, model,
target_model, target_model,
provider_id: selected_candidate.and_then(|value| value.candidate.provider_id.clone()), provider_id: routing_candidate.and_then(|value| value.candidate.provider_id.clone()),
provider_endpoint_id: selected_candidate provider_endpoint_id: routing_candidate
.and_then(|value| value.candidate.endpoint_id.clone()), .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())), request_type: Some(infer_request_type(api_format.as_deref())),
api_format: api_format.clone(), api_format: api_format.clone(),
api_family: api_format api_family: api_format
@@ -459,7 +465,7 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request(
.as_deref() .as_deref()
.and_then(infer_endpoint_kind) .and_then(infer_endpoint_kind)
.map(ToOwned::to_owned), .map(ToOwned::to_owned),
has_format_conversion: selected_candidate.and_then(|value| { has_format_conversion: routing_candidate.and_then(|value| {
value value
.client_api_format .client_api_format
.as_deref() .as_deref()
@@ -478,13 +484,16 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request(
client_response_body: Some(client_body), client_response_body: Some(client_body),
..UsageEventData::default() ..UsageEventData::default()
}; };
if let Some(candidate) = routing_candidate {
insert_runtime_miss_candidate_usage_metadata(&mut request_metadata, &candidate.candidate);
}
apply_runtime_miss_usage_routing( apply_runtime_miss_usage_routing(
&mut data, &mut data,
&mut request_metadata, &mut request_metadata,
execution_path, execution_path,
selected_candidate.map(|value| value.candidate.id.as_str()), routing_candidate.map(|value| value.candidate.id.as_str()),
selected_candidate.map(|value| value.candidate.candidate_index), routing_candidate.map(|value| value.candidate.candidate_index),
selected_candidate.and_then(|value| value.key_name.as_deref()), routing_candidate.and_then(|value| value.key_name.as_deref()),
diagnostic, diagnostic,
decision.and_then(|value| value.route_family.as_deref()), decision.and_then(|value| value.route_family.as_deref()),
decision.and_then(|value| value.route_kind.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 { fn request_candidate_represents_provider_execution(candidate: &StoredRequestCandidate) -> bool {
matches!( matches!(
candidate.status, candidate.status,
@@ -935,6 +960,66 @@ fn candidate_extra_data_string(candidate: &StoredRequestCandidate, key: &str) ->
.map(ToOwned::to_owned) .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<String, Value>,
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( fn runtime_miss_candidate_failure_diagnostic(
candidate: &RuntimeMissCandidateContext, candidate: &RuntimeMissCandidateContext,
) -> Option<RuntimeMissFailureDiagnostic> { ) -> Option<RuntimeMissFailureDiagnostic> {
@@ -1170,9 +1255,10 @@ fn trimmed_non_empty(value: Option<&str>) -> Option<String> {
mod tests { mod tests {
use super::{ use super::{
apply_runtime_miss_usage_routing, beautify_local_execution_client_error_message, 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, request_candidate_represents_provider_execution, runtime_miss_client_error_body,
select_last_runtime_miss_executed_candidate, LocalExecutionRuntimeMissContext, select_last_runtime_miss_executed_candidate, select_last_runtime_miss_routing_candidate,
RuntimeMissCandidateContext, LocalExecutionRuntimeMissContext, RuntimeMissCandidateContext,
}; };
use crate::constants::EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS; use crate::constants::EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS;
use crate::state::LocalExecutionRuntimeMissDiagnostic; use crate::state::LocalExecutionRuntimeMissDiagnostic;
@@ -1263,7 +1349,7 @@ mod tests {
#[test] #[test]
fn runtime_miss_executed_candidate_selection_ignores_skipped_only_histories() { 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(), "cand-skipped".to_string(),
"req-1".to_string(), "req-1".to_string(),
Some("user-1".to_string()), Some("user-1".to_string()),
@@ -1290,6 +1376,26 @@ mod tests {
None, None,
) )
.expect("candidate should build"); .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( assert!(!request_candidate_represents_provider_execution(
&skipped_candidate &skipped_candidate
@@ -1307,6 +1413,11 @@ mod tests {
}]; }];
assert!(select_last_runtime_miss_executed_candidate(&contexts).is_none()); 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] #[test]
@@ -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.provider_api_key_id = Some("provider-key-1".to_string());
usage.error_message = Some("no local stream plans".to_string()); usage.error_message = Some("no local stream plans".to_string());
usage.response_time_ms = Some(45); 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 usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![usage]));
let data_state = 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["final_status"], json!("failed"));
assert_eq!(payload["candidates"][0]["id"], json!("routing-cand-1")); assert_eq!(payload["candidates"][0]["id"], json!("routing-cand-1"));
assert_eq!(payload["candidates"][0]["status"], json!("failed")); assert_eq!(payload["candidates"][0]["status"], json!("failed"));
assert_eq!(
payload["candidates"][0]["skip_reason"],
json!("provider_request_body_build_failed")
);
assert_eq!( assert_eq!(
payload["candidates"][0]["error_type"], payload["candidates"][0]["error_type"],
json!("no_local_stream_plans") 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"], payload["candidates"][0]["extra_data"]["execution_path"],
json!("local_execution_runtime_miss") json!("local_execution_runtime_miss")
); );
assert_eq!(
payload["candidates"][0]["extra_data"]["failure_diagnostic"]["path"],
json!("$.reasoning.summary")
);
} }
#[tokio::test] #[tokio::test]
@@ -203,7 +203,7 @@ fn build_admin_monitoring_usage_routing_snapshot_trace(
endpoint_id: usage.provider_endpoint_id.clone(), endpoint_id: usage.provider_endpoint_id.clone(),
key_id: usage.provider_api_key_id.clone(), key_id: usage.provider_api_key_id.clone(),
status, status,
skip_reason: None, skip_reason: usage.routing_candidate_skip_reason().map(ToOwned::to_owned),
is_cached: false, is_cached: false,
status_code: usage.status_code, status_code: usage.status_code,
error_type: usage 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() { if let Some(candidate_index) = usage.routing_candidate_index() {
object.insert("candidate_index".to_string(), json!(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)) Some(Value::Object(object))
} }
@@ -377,6 +377,7 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
| (Some("users_manage"), http::Method::PATCH, Some("lock_user_api_key")) | (Some("users_manage"), http::Method::PATCH, Some("lock_user_api_key"))
| (Some("pool_manage"), http::Method::POST, Some("batch_import_keys")) | (Some("pool_manage"), http::Method::POST, Some("batch_import_keys"))
| (Some("pool_manage"), http::Method::POST, Some("batch_action_keys")) | (Some("pool_manage"), http::Method::POST, Some("batch_action_keys"))
| (Some("pool_manage"), http::Method::PATCH, Some("batch_update_keys"))
| (Some("pool_manage"), http::Method::POST, Some("resolve_selection")) | (Some("pool_manage"), http::Method::POST, Some("resolve_selection"))
| (Some("usage_manage"), http::Method::POST, Some("replay")) | (Some("usage_manage"), http::Method::POST, Some("replay"))
| (Some("wallets_manage"), http::Method::POST, Some("adjust_balance")) | (Some("wallets_manage"), http::Method::POST, Some("adjust_balance"))
@@ -3615,19 +3615,26 @@ async fn gateway_batch_updates_shared_pool_key_configuration() {
Vec::new(), Vec::new(),
vec![first_key, second_key], vec![first_key, second_key],
)); ));
let state = AppState::new() let gateway = build_router_with_state(
.expect("gateway should build") AppState::new()
.with_data_state_for_tests( .expect("gateway should build")
GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone( .with_data_state_for_tests(
&provider_catalog_repository, GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
)), &provider_catalog_repository,
); )),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = local_admin_pool_response( let response = reqwest::Client::new()
&state, .patch(format!(
http::Method::PATCH, "{gateway_url}/api/admin/pool/provider-openai/keys/batch-update"
"/api/admin/pool/provider-openai/keys/batch-update", ))
Some(json!({ .header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"key_ids": ["key-openai-b", "key-openai-a", "key-openai-a"], "key_ids": ["key-openai-b", "key-openai-a", "key-openai-a"],
"patch": { "patch": {
"api_formats": ["openai:responses"], "api_formats": ["openai:responses"],
@@ -3638,17 +3645,13 @@ async fn gateway_batch_updates_shared_pool_key_configuration() {
"locked_models": [], "locked_models": [],
"note": null "note": null
} }
})), }))
) .send()
.await; .await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = serde_json::from_slice( let payload: serde_json::Value = response.json().await.expect("json body should parse");
&to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should read"),
)
.expect("json body should parse");
assert_eq!(payload["affected"], json!(2)); assert_eq!(payload["affected"], json!(2));
assert_eq!(payload["model_sync"], serde_json::Value::Null); assert_eq!(payload["model_sync"], serde_json::Value::Null);
@@ -3670,6 +3673,8 @@ async fn gateway_batch_updates_shared_pool_key_configuration() {
assert_eq!(key.locked_models, None); assert_eq!(key.locked_models, None);
assert_eq!(key.note, None); assert_eq!(key.note, None);
} }
gateway_handle.abort();
} }
#[tokio::test] #[tokio::test]
+11 -4
View File
@@ -1942,14 +1942,14 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s
stored_usage.user_id.as_deref(), stored_usage.user_id.as_deref(),
Some("user-claude-cli-usage-local-miss-1") 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.model, "gpt-5.4");
assert_eq!(stored_usage.api_format.as_deref(), Some("claude:messages")); assert_eq!(stored_usage.api_format.as_deref(), Some("claude:messages"));
assert_eq!( assert_eq!(
stored_usage.endpoint_api_format.as_deref(), 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_planner_kind(), Some("claude_cli_sync"));
assert_eq!(stored_usage.routing_route_family(), Some("claude")); assert_eq!(stored_usage.routing_route_family(), Some("claude"));
assert_eq!(stored_usage.routing_route_kind(), Some("messages")); 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(), stored_candidates[0].skip_reason.as_deref(),
Some("format_conversion_disabled") 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); assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort(); gateway_handle.abort();
+4
View File
@@ -75,6 +75,10 @@ pub use crate::formats::openai::{
validate_openai_provider_request_contract, OpenAiProviderRequestContractViolation, validate_openai_provider_request_contract, OpenAiProviderRequestContractViolation,
OpenAiProviderRequestFinalization, OpenAiProviderRequestFinalization,
}, },
responses::{
openai_responses_synthetic_reasoning_item_id,
strip_incompatible_openai_responses_reasoning_items,
},
}; };
pub use crate::formats::shared::error_body::{ pub use crate::formats::shared::error_body::{
build_core_error_body_for_client_format, is_core_error_finalize_kind, LocalCoreSyncErrorKind, build_core_error_body_for_client_format, is_core_error_finalize_kind, LocalCoreSyncErrorKind,
@@ -8,7 +8,11 @@ use serde_json::{json, Value};
use crate::formats::{ use crate::formats::{
context::FormatContext, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -203,7 +207,7 @@ pub fn build_openai_responses_response_with_content(
} }
output.push(json!({ output.push(json!({
"type": "reasoning", "type": "reasoning",
"id": format!("{response_id}_rs_{index}"), "id": openai_responses_synthetic_reasoning_item_id(response_id, index),
"status": "completed", "status": "completed",
"summary": [{ "summary": [{
"type": "summary_text", "type": "summary_text",
@@ -2,8 +2,11 @@ use std::collections::{BTreeMap, BTreeSet};
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use crate::formats::openai::responses::response::{ use crate::formats::openai::responses::{
ensure_modern_openai_responses_response_fields, openai_responses_current_timestamp, 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::response::build_generated_tool_call_id;
use crate::formats::shared::sse::{encode_done_sse, encode_json_sse}; use crate::formats::shared::sse::{encode_done_sse, encode_json_sse};
@@ -2227,7 +2230,7 @@ impl OpenAIResponsesClientEmitter {
fn reasoning_item_id(&self) -> String { fn reasoning_item_id(&self) -> String {
self.reasoning_item_id self.reasoning_item_id
.clone() .clone()
.unwrap_or_else(|| format!("{}_rs_0", self.response_id())) .unwrap_or_else(|| openai_responses_synthetic_reasoning_item_id(self.response_id(), 0))
} }
fn tool_call_item_id(&self, index: usize) -> String { fn tool_call_item_id(&self, index: usize) -> String {
@@ -2247,7 +2250,10 @@ impl OpenAIResponsesClientEmitter {
fn ensure_reasoning_item_id(&mut self) -> String { fn ensure_reasoning_item_id(&mut self) -> String {
if self.reasoning_item_id.is_none() { 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() self.reasoning_item_id()
} }
@@ -5460,7 +5466,8 @@ mod tests {
assert!(sse.contains("event: response.reasoning_summary_text.delta\n")); 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_text.done\n"));
assert!(sse.contains("event: response.reasoning_summary_part.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!(sse.contains("\"type\":\"reasoning\""));
assert_eq!(response_sequence_numbers(&sse), (1..=9).collect::<Vec<_>>()); assert_eq!(response_sequence_numbers(&sse), (1..=9).collect::<Vec<_>>());
} }
@@ -85,6 +85,10 @@ pub fn finalize_openai_provider_request_with_codex_model_capabilities(
body, body,
finalization.provider_api_format, finalization.provider_api_format,
); );
super::responses::strip_incompatible_openai_responses_reasoning_items(
body,
finalization.provider_api_format,
);
crate::enforce_request_body_stream_field( crate::enforce_request_body_stream_field(
body, body,
finalization.provider_api_format, 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] #[test]
fn non_responses_sources_receive_codex_responses_reasoning_defaults() { fn non_responses_sources_receive_codex_responses_reasoning_defaults() {
for source_api_format in ["openai:chat", "claude:messages", "gemini:generate_content"] { for source_api_format in ["openai:chat", "claude:messages", "gemini:generate_content"] {
@@ -8,6 +8,68 @@ pub mod spec;
pub mod stream; pub mod stream;
const TOOL_ERROR_PREFIX: &str = "[tool error]"; 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 /// Semantic operation carried by an OpenAI Responses request that asks the
/// service to compact a thread. The request still uses the Responses wire /// service to compact a thread. The request still uses the Responses wire
@@ -57,7 +119,10 @@ fn encode_tool_result_error(output: Value, is_error: bool) -> Value {
mod tests { mod tests {
use serde_json::json; 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] #[test]
fn resolves_compaction_trigger_as_compact_operation_on_responses_transport() { fn resolves_compaction_trigger_as_compact_operation_on_responses_transport() {
@@ -89,4 +154,60 @@ mod tests {
Some(OPENAI_RESPONSES_OPERATION_COMPACT) 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));
}
} }
@@ -5,7 +5,10 @@ use std::{
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use super::{encode_tool_result_error, history::record_converted_response_history}; use super::{
encode_tool_result_error, history::record_converted_response_history,
openai_responses_synthetic_reasoning_item_id,
};
use crate::{ use crate::{
formats::context::FormatContext, formats::context::FormatContext,
@@ -201,7 +204,10 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo
item.insert("type".to_string(), Value::String("reasoning".to_string())); item.insert("type".to_string(), Value::String("reasoning".to_string()));
item.insert( item.insert(
"id".to_string(), "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())); item.insert("status".to_string(), Value::String("completed".to_string()));
if let Some(encrypted_content) = encrypted_content { if let Some(encrypted_content) = encrypted_content {
@@ -715,6 +721,9 @@ mod tests {
let rebuilt = to_raw(&canonical, &json!({}), false); let rebuilt = to_raw(&canonical, &json!({}), false);
assert_eq!(rebuilt["output"][0]["type"], "reasoning"); assert_eq!(rebuilt["output"][0]["type"], "reasoning");
assert!(rebuilt["output"][0]["id"]
.as_str()
.is_some_and(|id| id.starts_with("rs")));
assert_eq!( assert_eq!(
rebuilt["output"][0]["encrypted_content"], rebuilt["output"][0]["encrypted_content"],
json!("openai-opaque") json!("openai-opaque")
@@ -153,6 +153,10 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers(
&mut provider_request_body, &mut provider_request_body,
provider_api_format, 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( strip_openai_responses_input_content_cache_control(
&mut provider_request_body, &mut provider_request_body,
provider_api_format, provider_api_format,
@@ -303,6 +303,10 @@ pub fn build_local_openai_responses_request_body_with_model_directives(
require_streaming, require_streaming,
require_body_stream_field, require_body_stream_field,
); );
crate::formats::openai::responses::strip_incompatible_openai_responses_reasoning_items(
&mut provider_request_body,
"openai:responses",
);
Some(provider_request_body) Some(provider_request_body)
} }
@@ -7,6 +7,7 @@ use aether_ai_formats::formats::conversion::response::{
convert_openai_chat_response_to_openai_responses, convert_openai_chat_response_to_openai_responses,
convert_openai_responses_response_to_openai_chat, 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::openai::responses::response::ensure_modern_openai_responses_response_fields;
use aether_ai_formats::formats::registry::{convert_response, FormatContext, FormatError}; use aether_ai_formats::formats::registry::{convert_response, FormatContext, FormatError};
use aether_ai_formats::{ use aether_ai_formats::{
@@ -3185,8 +3186,9 @@ fn materialize_openai_responses_reasoning_item(
let mut item = state.item; let mut item = state.item;
item.entry("type".to_string()) item.entry("type".to_string())
.or_insert_with(|| Value::String("reasoning".to_string())); .or_insert_with(|| Value::String("reasoning".to_string()));
item.entry("id".to_string()) item.entry("id".to_string()).or_insert_with(|| {
.or_insert_with(|| Value::String(format!("{response_id}_rs_0"))); Value::String(openai_responses_synthetic_reasoning_item_id(response_id, 0))
});
item.entry("status".to_string()) item.entry("status".to_string())
.or_insert_with(|| Value::String("completed".to_string())); .or_insert_with(|| Value::String("completed".to_string()));
if !state.summary_text.is_empty() { if !state.summary_text.is_empty() {
+2 -1
View File
@@ -52,7 +52,8 @@ pub use formats::openai::responses::request::{
validate_openai_responses_request_contract, OpenAiResponsesRequestContractViolation, validate_openai_responses_request_contract, OpenAiResponsesRequestContractViolation,
}; };
pub use formats::openai::responses::{ 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::{ pub use formats::registry::{
build_stream_transcoder, convert_request, convert_request_pure, build_stream_transcoder, convert_request, convert_request_pure,
+2 -2
View File
@@ -129,8 +129,8 @@ pub use report_context::{
AiExecutionReportContextParts, AiRequestOrigin, AiExecutionReportContextParts, AiRequestOrigin,
}; };
pub use request_body_diagnostics::{ pub use request_body_diagnostics::{
request_body_build_failure_extra_data, request_conversion_failure_extra_data, openai_provider_request_contract_failure_extra_data, request_body_build_failure_extra_data,
same_format_provider_request_body_failure_extra_data, request_conversion_failure_extra_data, same_format_provider_request_body_failure_extra_data,
}; };
pub use runtime_miss::{ pub use runtime_miss::{
apply_ai_runtime_candidate_evaluation_progress, apply_ai_runtime_candidate_evaluation_progress,
@@ -2,7 +2,7 @@ use serde_json::Value;
use aether_ai_formats::api::{ use aether_ai_formats::api::{
is_claude_messages_shaped_body_on_openai_chat_endpoint, is_openai_responses_family_format, 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}; 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<String>,
) -> 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( pub fn same_format_provider_request_body_failure_extra_data(
body_json: &Value, body_json: &Value,
provider_api_format: &str, provider_api_format: &str,
@@ -798,7 +834,10 @@ fn request_body_build_source(client_api_format: &str, provider_api_format: &str)
mod tests { mod tests {
use serde_json::json; 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] #[test]
fn openai_chat_to_claude_recognizes_compatible_claude_native_tool_shape() { fn openai_chat_to_claude_recognizes_compatible_claude_native_tool_shape() {
@@ -950,6 +989,40 @@ mod tests {
.contains("字段 n")); .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] #[test]
fn same_format_provider_reports_non_object_body() { fn same_format_provider_reports_non_object_body() {
let diagnostic = super::same_format_provider_request_body_failure_extra_data( let diagnostic = super::same_format_provider_request_body_failure_extra_data(
@@ -36,4 +36,5 @@ pub use types::{
UsageTimeSeriesQuery, UsageWriteRepository, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, UsageTimeSeriesQuery, UsageWriteRepository, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY,
PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, PROVIDER_REASONING_EFFORT_METADATA_KEY, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, PROVIDER_REASONING_EFFORT_METADATA_KEY,
PROVIDER_SERVICE_TIER_METADATA_KEY, REQUESTED_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,
}; };
@@ -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_SERVICE_TIER_METADATA_KEY: &str = "provider_service_tier";
pub const PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY: &str = "provider_actual_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 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<String> { pub fn extract_provider_reasoning_effort_from_body(value: Option<&Value>) -> Option<String> {
let object = value.and_then(Value::as_object)?; let object = value.and_then(Value::as_object)?;
@@ -827,6 +829,16 @@ impl StoredRequestUsageAudit {
.as_deref() .as_deref()
.or_else(|| self.request_metadata_string("local_execution_runtime_miss_reason")) .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)] #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -2695,7 +2707,12 @@ mod tests {
"response_body_ref": "blob://legacy-response", "response_body_ref": "blob://legacy-response",
"client_response_body_ref": "blob://legacy-client-response", "client_response_body_ref": "blob://legacy-client-response",
"candidate_id": "cand-legacy", "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!( assert_eq!(
@@ -2727,6 +2744,16 @@ mod tests {
usage.routing_local_execution_runtime_miss_reason(), usage.routing_local_execution_runtime_miss_reason(),
Some("all_candidates_skipped") 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] #[test]
@@ -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 let provider_model = provider_request_body
.get("model") .get("model")
.and_then(Value::as_str) .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] #[test]
fn same_format_stream_policy_wins_after_body_rules() { fn same_format_stream_policy_wins_after_body_rules() {
let body_rules = json!([ let body_rules = json!([
@@ -9,7 +9,8 @@ use aether_data_contracts::repository::usage::{
normalize_provider_service_tier, resolve_provider_cache_ttl_minutes, UsageBodyCaptureState, normalize_provider_service_tier, resolve_provider_cache_ttl_minutes, UsageBodyCaptureState,
PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY,
PROVIDER_REASONING_EFFORT_METADATA_KEY, PROVIDER_SERVICE_TIER_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}; use serde_json::{json, Map, Value};
@@ -376,6 +377,8 @@ fn copy_allowed_metadata_fields(source: &Map<String, Value>, target: &mut Map<St
copy_non_null_value(source, target, "dimensions"); copy_non_null_value(source, target, "dimensions");
copy_non_null_value(source, target, "billing_rule_snapshot"); copy_non_null_value(source, target, "billing_rule_snapshot");
copy_non_null_value(source, target, "scheduling_audit"); copy_non_null_value(source, target, "scheduling_audit");
copy_non_empty_string(source, target, ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY);
copy_non_null_value(source, target, ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY);
copy_non_null_value(source, target, "tls_fingerprint"); copy_non_null_value(source, target, "tls_fingerprint");
copy_number(source, target, "rate_multiplier"); copy_number(source, target, "rate_multiplier");
copy_bool(source, target, "is_free_tier"); copy_bool(source, target, "is_free_tier");
@@ -432,6 +435,12 @@ fn move_allowed_metadata_fields(mut source: Map<String, Value>, target: &mut Map
remove_non_null_value(&mut source, target, "dimensions"); 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, "billing_rule_snapshot");
remove_non_null_value(&mut source, target, "scheduling_audit"); 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_non_null_value(&mut source, target, "tls_fingerprint");
remove_number(&mut source, target, "rate_multiplier"); remove_number(&mut source, target, "rate_multiplier");
remove_bool(&mut source, target, "is_free_tier"); remove_bool(&mut source, target, "is_free_tier");
@@ -800,6 +809,13 @@ mod tests {
"global_model_id": "global-model-1", "global_model_id": "global-model-1",
"global_model_name": "gpt-5", "global_model_name": "gpt-5",
"dimensions": {"total_input_context": 10}, "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, "rate_multiplier": 1.25,
"is_free_tier": false, "is_free_tier": false,
"input_price_per_1m": 3.0, "input_price_per_1m": 3.0,
@@ -844,6 +860,13 @@ mod tests {
"global_model_id": "global-model-1", "global_model_id": "global-model-1",
"global_model_name": "gpt-5", "global_model_name": "gpt-5",
"dimensions": {"total_input_context": 10}, "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, "rate_multiplier": 1.25,
"is_free_tier": false, "is_free_tier": false,
"input_price_per_1m": 3.0, "input_price_per_1m": 3.0,