mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-16 16:07:45 +08:00
fix(responses): bound upstream tool call IDs
This commit is contained in:
@@ -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!({
|
||||
|
||||
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user