mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Merge upstream/main into main
This commit is contained in:
@@ -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};
|
||||
@@ -2227,7 +2230,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 tool_call_item_id(&self, index: usize) -> String {
|
||||
@@ -2247,7 +2250,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()
|
||||
}
|
||||
@@ -5460,7 +5466,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"] {
|
||||
|
||||
@@ -8,6 +8,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
|
||||
@@ -57,7 +119,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() {
|
||||
@@ -89,4 +154,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,10 @@ use std::{
|
||||
|
||||
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::{
|
||||
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(
|
||||
"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 {
|
||||
@@ -715,6 +721,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")
|
||||
|
||||
@@ -153,6 +153,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,
|
||||
|
||||
@@ -303,6 +303,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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user