fix(ai): harden responses replay and failure diagnostics

This commit is contained in:
elky
2026-07-30 17:19:54 +08:00
parent 1ab4f079c9
commit 050eb77508
29 changed files with 700 additions and 142 deletions
+4
View File
@@ -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,
@@ -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",
@@ -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::<Vec<_>>());
}
@@ -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"] {
@@ -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));
}
}
@@ -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")
@@ -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,
@@ -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)
}
@@ -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() {
+2 -1
View File
@@ -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,