fix responses replay across Gemini and Codex

This commit is contained in:
ZheFox
2026-09-04 16:54:42 +08:00
parent 66d6c17d2d
commit 14744abd57
2 changed files with 59 additions and 3 deletions
@@ -381,10 +381,21 @@ mod tests {
#[test]
fn finalization_strips_non_replayable_responses_reasoning_history() {
let gemini_carrier =
crate::formats::openai::responses::encode_gemini_tool_signature_carrier(
"opaque-gemini-thought-signature",
)
.expect("Gemini signature carrier");
let mut body = json!({
"model": "gpt-5.4",
"input": [
{"type": "reasoning", "id": "rs_provider_123", "summary": []},
{
"type": "reasoning",
"id": "rs_aether_55070860f6d45c6b8f6fa11efd9dff8a",
"summary": [],
"encrypted_content": gemini_carrier
},
{
"type": "reasoning",
"id": "item_72d3bd8d367d01977ace23f1",
@@ -167,9 +167,10 @@ pub fn normalize_openai_responses_message_item_ids(body: &mut Value) -> usize {
/// 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.
/// prefix. Foreign IDs (for example `item_...`) are therefore removed. Aether's Gemini signature
/// carriers are also removed: they are intentionally transported through the Responses
/// `encrypted_content` field so they can be restored on a later Gemini tool turn, but they are not
/// OpenAI ciphertext and must never be replayed to an OpenAI/Codex backend.
pub fn strip_incompatible_openai_responses_reasoning_items(
body: &mut Value,
provider_api_format: &str,
@@ -221,6 +222,13 @@ fn openai_responses_reasoning_item_is_replayable(
if object.get("type").and_then(Value::as_str) != Some("reasoning") {
return true;
}
if object
.get("encrypted_content")
.and_then(Value::as_str)
.is_some_and(|value| value.starts_with(GEMINI_TOOL_SIGNATURE_CARRIER_PREFIX))
{
return false;
}
if policy == OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
&& deepseek_opaque_reasoning_item_is_replayable(object)
{
@@ -475,6 +483,43 @@ mod tests {
assert_eq!(input[2]["id"], "item_message_123");
}
#[test]
fn strips_gemini_signature_carriers_before_openai_replay() {
let gemini_item_id = openai_responses_synthetic_reasoning_item_id("resp_gemini", 0);
let openai_item_id = openai_responses_synthetic_reasoning_item_id("resp_openai", 0);
let carrier = encode_gemini_tool_signature_carrier_with_direction(
"opaque-gemini-thought-signature",
GeminiToolSignatureCarrierDirection::Next,
)
.expect("Gemini signature carrier");
let mut body = json!({
"input": [
{
"type": "reasoning",
"id": gemini_item_id,
"summary": [],
"encrypted_content": carrier
},
{
"type": "reasoning",
"id": openai_item_id,
"summary": [],
"encrypted_content": "provider-encrypted-state"
},
{"type": "reasoning", "id": "rs_provider_123", "summary": []}
]
});
assert_eq!(
strip_incompatible_openai_responses_reasoning_items(&mut body, "openai:responses"),
1
);
let input = body["input"].as_array().expect("input array");
assert_eq!(input.len(), 2);
assert_eq!(input[0]["encrypted_content"], "provider-encrypted-state");
assert_eq!(input[1]["id"], "rs_provider_123");
}
#[test]
fn reasoning_item_sanitizer_is_scoped_to_responses_targets() {
let mut body = json!({