From fe1723d87c8d7a80a9910af02d16ea9de304e0ea Mon Sep 17 00:00:00 2001 From: ZheFox <77232781+zhefox@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:51:56 +0800 Subject: [PATCH] fix(responses): bound upstream tool call IDs --- .../src/formats/openai/request_contract.rs | 76 +++++++++++++ .../src/formats/openai/responses/mod.rs | 104 +++++++++++++++++- 2 files changed, 177 insertions(+), 3 deletions(-) diff --git a/crates/aether-ai/formats/src/formats/openai/request_contract.rs b/crates/aether-ai/formats/src/formats/openai/request_contract.rs index b145d7c69..771f8a184 100644 --- a/crates/aether-ai/formats/src/formats/openai/request_contract.rs +++ b/crates/aether-ai/formats/src/formats/openai/request_contract.rs @@ -147,6 +147,9 @@ fn finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_ finalization.provider_api_format, reasoning_replay_policy, ); + if crate::is_openai_responses_family_format(finalization.provider_api_format) { + super::responses::normalize_openai_responses_call_ids(body); + } if finalization .provider_api_format .trim() @@ -235,6 +238,79 @@ mod tests { }; use crate::CodexResponsesModelCapabilities; + #[test] + fn finalization_bounds_responses_call_ids_and_preserves_pairing() { + let long_id = format!("call_{}", "a".repeat(78)); + let original = json!({ + "model": "gpt-5.4", + "input": [ + {"type": "function_call", "call_id": long_id, "name": "lookup", "arguments": "{}"}, + {"type": "function_call_output", "call_id": long_id, "output": "result"} + ] + }); + + for (source_api_format, provider_type, provider_api_format, websocket_continuation) in [ + ("openai:responses", "codex", "openai:responses", false), + ( + "openai:responses", + "codex", + "openai:responses:compact", + false, + ), + ("openai:responses", "openai", "openai:responses", false), + ( + "openai:responses", + "openai", + "openai:responses:compact", + false, + ), + ("openai:responses", "codex", "openai:responses", true), + ("openai:chat", "codex", "openai:responses", false), + ("openai:chat", "openai", "openai:responses", false), + ("claude:messages", "codex", "openai:responses", false), + ("claude:messages", "openai", "openai:responses", false), + ( + "gemini:generate_content", + "codex", + "openai:responses", + false, + ), + ( + "gemini:generate_content", + "openai", + "openai:responses", + false, + ), + ] { + let mut body = original.clone(); + let finalization = OpenAiProviderRequestFinalization { + source_api_format, + provider_api_format, + provider_type, + provider_model: "gpt-5.4", + source_model: "gpt-5.4", + body_rules: None, + upstream_is_stream: false, + require_body_stream_field: true, + }; + if websocket_continuation { + super::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy_for_websocket_continuation( + &mut body, + finalization, + None, + crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds, + ) + } else { + finalize_openai_provider_request(&mut body, finalization) + } + .expect("request should finalize"); + + let call_id = body["input"][0]["call_id"].as_str().expect("call ID"); + assert!(call_id.len() <= 64, "call ID has {} bytes", call_id.len()); + assert_eq!(body["input"][1]["call_id"], call_id); + } + } + #[test] fn validates_reasoning_and_prompt_cache_against_the_final_provider_model() { let body = json!({ diff --git a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs index cd3c90beb..7e55a848a 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs @@ -1,5 +1,9 @@ -use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _}; +use base64::{ + engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD}, + Engine as _, +}; use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; pub mod codex; pub(crate) mod history; @@ -219,6 +223,28 @@ pub fn normalize_openai_responses_message_item_ids(body: &mut Value) -> usize { repaired } +pub(crate) fn normalize_openai_responses_call_ids(body: &mut Value) { + let Some(input) = body.get_mut("input") else { + return; + }; + let items = match input { + Value::Array(items) => items.as_mut_slice(), + Value::Object(_) => std::slice::from_mut(input), + _ => return, + }; + for item in items { + let Some(Value::String(call_id)) = item.get_mut("call_id") else { + continue; + }; + if call_id.chars().take(65).count() > 64 { + *call_id = format!( + "call_{}", + URL_SAFE_NO_PAD.encode(Sha256::digest(call_id.as_bytes())) + ); + } + } +} + /// 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 @@ -388,8 +414,9 @@ mod tests { use super::{ decode_gemini_tool_signature_carrier, encode_gemini_tool_signature_carrier_with_direction, - normalize_openai_responses_message_item_ids, openai_responses_message_item_id, - openai_responses_request_operation, openai_responses_synthetic_reasoning_item_id, + normalize_openai_responses_call_ids, normalize_openai_responses_message_item_ids, + openai_responses_message_item_id, openai_responses_request_operation, + openai_responses_synthetic_reasoning_item_id, strip_incompatible_openai_responses_reasoning_items, strip_incompatible_openai_responses_reasoning_items_with_policy, GeminiToolSignatureCarrierDirection, OpenAiResponsesReasoningReplayPolicy, @@ -553,6 +580,77 @@ mod tests { assert_ne!(first, other); } + #[test] + fn normalizes_long_call_ids_stably_without_changing_item_ids_or_payloads() { + let long_id = format!("call_{}", "a".repeat(78)); + let other_id = format!("{long_id}b"); + let arguments = json!({"call_id": long_id}).to_string(); + let mut body = json!({"input": [ + {"type": "function_call", "id": "fc_provider", "call_id": long_id, "name": "lookup", "arguments": arguments}, + {"type": "function_call_output", "call_id": long_id, "output": {"call_id": long_id}}, + {"type": "custom_tool_call", "call_id": other_id, "name": "patch", "input": long_id}, + {"type": "custom_tool_call_output", "call_id": other_id, "output": "done"} + ]}); + + normalize_openai_responses_call_ids(&mut body); + + let first_id = body["input"][0]["call_id"].as_str().expect("first call ID"); + let second_id = body["input"][2]["call_id"] + .as_str() + .expect("second call ID"); + for call_id in [first_id, second_id] { + assert!(call_id.len() <= 64); + assert!(call_id.chars().all( + |character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-') + )); + } + assert_ne!(first_id, second_id); + assert_eq!(body["input"][1]["call_id"], first_id); + assert_eq!(body["input"][3]["call_id"], second_id); + assert_eq!(body["input"][0]["id"], "fc_provider"); + assert_eq!(body["input"][0]["arguments"], arguments); + assert_eq!(body["input"][1]["output"]["call_id"], long_id); + assert_eq!(body["input"][2]["input"], long_id); + + let mut continuation = json!({"input": { + "type": "function_call_output", "call_id": long_id, "output": "later" + }}); + normalize_openai_responses_call_ids(&mut continuation); + assert_eq!(continuation["input"]["call_id"], first_id); + + let once = body.clone(); + normalize_openai_responses_call_ids(&mut body); + assert_eq!(body, once); + } + + #[test] + fn call_id_normalization_preserves_valid_boundaries_and_non_item_data() { + let mut body = json!({"input": [ + {"type": "function_call", "call_id": "call_short"}, + {"type": "function_call", "call_id": "a".repeat(64)}, + {"type": "function_call", "call_id": "\u{00e9}".repeat(64)}, + {"type": "message", "content": [{"call_id": "a".repeat(83)}]}, + {"type": "function_call_output", "call_id": null}, + {"type": "function_call_output", "call_id": 42}, + null + ]}); + let unchanged = body.clone(); + normalize_openai_responses_call_ids(&mut body); + assert_eq!(body, unchanged); + + for input in [json!("text"), json!(null)] { + let mut body = json!({"input": input}); + let unchanged = body.clone(); + normalize_openai_responses_call_ids(&mut body); + assert_eq!(body, unchanged); + } + for call_id in ["a".repeat(65), "\u{00e9}".repeat(65)] { + let mut body = json!({"input": [{"type": "function_call", "call_id": call_id}]}); + normalize_openai_responses_call_ids(&mut body); + assert!(body["input"][0]["call_id"].as_str().expect("call ID").len() <= 64); + } + } + #[test] fn normalizes_legacy_message_ids_but_preserves_valid_ids() { let mut body = json!({