From 8cdfa338e5fa132a21beef5f770b041bfa7f403c Mon Sep 17 00:00:00 2001 From: ZheFox <77232781+zhefox@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:15:00 +0800 Subject: [PATCH] fix(gemini): pair idless tool history --- .../aether-ai/formats/src/formats/registry.rs | 80 ++++++ .../formats/src/protocol/canonical.rs | 248 +++++++++++++++++- 2 files changed, 316 insertions(+), 12 deletions(-) diff --git a/crates/aether-ai/formats/src/formats/registry.rs b/crates/aether-ai/formats/src/formats/registry.rs index 61e1de1e2..8428c8a09 100644 --- a/crates/aether-ai/formats/src/formats/registry.rs +++ b/crates/aether-ai/formats/src/formats/registry.rs @@ -4049,6 +4049,86 @@ mod tests { assert_eq!(converted["messages"][1]["tool_call_id"], "call_lookup_1"); } + #[test] + fn pure_gemini_idless_parallel_tool_history_stays_paired_for_standard_targets() { + let body = json!({ + "model": "gemini-source", + "contents": [{ + "role": "model", + "parts": [ + {"functionCall": {"name": "lookup", "args": {"q": "first"}}}, + {"functionCall": {"name": "lookup", "args": {"q": "second"}}} + ] + }, { + "role": "user", + "parts": [ + {"functionResponse": {"name": "lookup", "response": {"result": "one"}}}, + {"functionResponse": {"name": "lookup", "response": {"result": "two"}}} + ] + }] + }); + + let chat = convert_request_pure("gemini:generate_content", "openai:chat", &body) + .expect("Gemini history should convert to Chat") + .value; + let chat_call_ids = chat["messages"][0]["tool_calls"] + .as_array() + .expect("Chat tool calls") + .iter() + .map(|call| call["id"].as_str().expect("Chat call ID")) + .collect::>(); + let chat_result_ids = chat["messages"] + .as_array() + .expect("Chat messages") + .iter() + .filter(|message| message["role"] == "tool") + .map(|message| { + message["tool_call_id"] + .as_str() + .expect("Chat result call ID") + }) + .collect::>(); + assert_eq!(chat_result_ids, chat_call_ids); + + let responses = convert_request_pure("gemini:generate_content", "openai:responses", &body) + .expect("Gemini history should convert to Responses") + .value; + let response_items = responses["input"].as_array().expect("Responses input"); + let response_call_ids = response_items + .iter() + .filter(|item| item["type"] == "function_call") + .map(|item| item["call_id"].as_str().expect("Responses call ID")) + .collect::>(); + let response_result_ids = response_items + .iter() + .filter(|item| item["type"] == "function_call_output") + .map(|item| item["call_id"].as_str().expect("Responses result call ID")) + .collect::>(); + assert_eq!(response_result_ids, response_call_ids); + + let claude = convert_request_pure("gemini:generate_content", "claude:messages", &body) + .expect("Gemini history should convert to Claude Messages") + .value; + let claude_messages = claude["messages"].as_array().expect("Claude messages"); + let claude_call_ids = claude_messages + .iter() + .flat_map(|message| message["content"].as_array().into_iter().flatten()) + .filter(|block| block["type"] == "tool_use") + .map(|block| block["id"].as_str().expect("Claude tool use ID")) + .collect::>(); + let claude_result_ids = claude_messages + .iter() + .flat_map(|message| message["content"].as_array().into_iter().flatten()) + .filter(|block| block["type"] == "tool_result") + .map(|block| { + block["tool_use_id"] + .as_str() + .expect("Claude tool result ID") + }) + .collect::>(); + assert_eq!(claude_result_ids, claude_call_ids); + } + #[test] fn pure_claude_to_openai_chat_maps_disable_parallel_tool_use() { let body = json!({ diff --git a/crates/aether-ai/formats/src/protocol/canonical.rs b/crates/aether-ai/formats/src/protocol/canonical.rs index db127d50b..20db96ae1 100644 --- a/crates/aether-ai/formats/src/protocol/canonical.rs +++ b/crates/aether-ai/formats/src/protocol/canonical.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; @@ -1118,6 +1118,25 @@ pub(crate) fn gemini_contents_to_canonical_messages( }; let contents = contents.as_array()?; let mut messages = Vec::new(); + let mut reserved_tool_call_ids = contents + .iter() + .filter_map(Value::as_object) + .filter_map(|content| content.get("parts")) + .filter_map(Value::as_array) + .flatten() + .filter_map(Value::as_object) + .filter_map(|part| { + part.get("functionCall") + .or_else(|| part.get("function_call")) + .or_else(|| part.get("functionResponse")) + .or_else(|| part.get("function_response")) + .and_then(Value::as_object) + .and_then(gemini_explicit_function_id) + .map(ToOwned::to_owned) + }) + .collect::>(); + let mut pending_tool_calls = VecDeque::<(String, String)>::new(); + let mut next_generated_tool_call_index = 0usize; for content in contents { let content_object = content.as_object()?; let role = match content_object @@ -1136,7 +1155,77 @@ pub(crate) fn gemini_contents_to_canonical_messages( let parts = content_object.get("parts").and_then(Value::as_array)?; let mut blocks = Vec::new(); for (index, part) in parts.iter().enumerate() { - blocks.push(gemini_part_to_canonical_block(part, index)?); + let mut block = gemini_part_to_canonical_block(part, index)?; + match &mut block { + CanonicalContentBlock::ToolUse { id, name, .. } => { + let has_explicit_id = part + .as_object() + .and_then(|part| { + part.get("functionCall") + .or_else(|| part.get("function_call")) + }) + .and_then(Value::as_object) + .and_then(gemini_explicit_function_id) + .is_some(); + if !has_explicit_id { + loop { + let generated = format!("call_auto_{next_generated_tool_call_index}"); + next_generated_tool_call_index += 1; + if reserved_tool_call_ids.insert(generated.clone()) { + *id = generated; + break; + } + } + } + pending_tool_calls.push_back((id.clone(), name.clone())); + } + CanonicalContentBlock::ToolResult { + tool_use_id, name, .. + } => { + let explicit_response_id = part + .as_object() + .and_then(|part| { + part.get("functionResponse") + .or_else(|| part.get("function_response")) + }) + .and_then(Value::as_object) + .and_then(gemini_explicit_function_id); + let matched_position = explicit_response_id + .and_then(|response_id| { + pending_tool_calls + .iter() + .position(|(call_id, _)| call_id == response_id) + }) + .or_else(|| { + if explicit_response_id.is_none() { + name.as_deref().and_then(|response_name| { + pending_tool_calls + .iter() + .position(|(_, call_name)| call_name == response_name) + }) + } else { + None + } + }); + let matched_call = matched_position + .and_then(|position| pending_tool_calls.remove(position)) + .or_else(|| { + if explicit_response_id.is_none() && name.is_none() { + pending_tool_calls.pop_front() + } else { + None + } + }); + if let Some((call_id, call_name)) = matched_call { + *tool_use_id = call_id; + if name.is_none() { + *name = Some(call_name); + } + } + } + _ => {} + } + blocks.push(block); } if blocks.is_empty() { continue; @@ -1205,11 +1294,7 @@ pub(crate) fn gemini_part_to_canonical_block( .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; - let id = function_call - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) + let id = gemini_explicit_function_id(function_call) .map(ToOwned::to_owned) .unwrap_or_else(|| format!("call_auto_{index}")); return Some(CanonicalContentBlock::ToolUse { @@ -1233,11 +1318,7 @@ pub(crate) fn gemini_part_to_canonical_block( .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); - let tool_use_id = function_response - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) + let tool_use_id = gemini_explicit_function_id(function_response) .map(ToOwned::to_owned) .or_else(|| name.clone()) .unwrap_or_else(|| format!("toolu_response_{index}")); @@ -1267,6 +1348,16 @@ pub(crate) fn gemini_part_to_canonical_block( }) } +fn gemini_explicit_function_id(function: &Map) -> Option<&str> { + ["id", "call_id", "callId"].iter().find_map(|field| { + function + .get(*field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + }) +} + pub(crate) fn gemini_inline_data_to_canonical_block( inline_data: &Map, part_object: &Map, @@ -8458,6 +8549,139 @@ mod tests { assert_eq!(canonical.usage.as_ref().unwrap().cache_write_tokens, 5); } + #[test] + fn gemini_request_pairs_parallel_idless_function_responses_by_order() { + let contents = json!([ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "lookup", "args": {"q": "first"}}}, + {"functionCall": {"name": "lookup", "args": {"q": "second"}}} + ] + }, + { + "role": "user", + "parts": [ + {"functionResponse": {"name": "lookup", "response": {"result": "one"}}}, + {"functionResponse": {"name": "lookup", "response": {"result": "two"}}} + ] + } + ]); + + let messages = super::gemini_contents_to_canonical_messages(Some(&contents)) + .expect("Gemini contents should parse"); + let call_ids = messages[0] + .content + .iter() + .map(|block| match block { + CanonicalContentBlock::ToolUse { id, .. } => id.as_str(), + _ => panic!("expected tool use"), + }) + .collect::>(); + let result_ids = messages[1] + .content + .iter() + .map(|block| match block { + CanonicalContentBlock::ToolResult { tool_use_id, .. } => tool_use_id.as_str(), + _ => panic!("expected tool result"), + }) + .collect::>(); + + assert_ne!(call_ids[0], call_ids[1]); + assert_eq!(result_ids, call_ids); + } + + #[test] + fn gemini_request_pairs_idless_function_responses_by_name() { + let contents = json!([{ + "role": "model", + "parts": [ + {"functionCall": {"name": "first", "args": {}}}, + {"functionCall": {"name": "second", "args": {}}} + ] + }, { + "role": "user", + "parts": [ + {"functionResponse": {"name": "second", "response": {"result": 2}}}, + {"functionResponse": {"name": "first", "response": {"result": 1}}} + ] + }]); + + let messages = super::gemini_contents_to_canonical_messages(Some(&contents)) + .expect("Gemini contents should parse"); + let call_ids = messages[0] + .content + .iter() + .map(|block| match block { + CanonicalContentBlock::ToolUse { id, .. } => id.as_str(), + _ => panic!("expected tool use"), + }) + .collect::>(); + let result_ids = messages[1] + .content + .iter() + .map(|block| match block { + CanonicalContentBlock::ToolResult { tool_use_id, .. } => tool_use_id.as_str(), + _ => panic!("expected tool result"), + }) + .collect::>(); + + assert_eq!(result_ids, vec![call_ids[1], call_ids[0]]); + } + + #[test] + fn gemini_request_generated_function_call_ids_avoid_explicit_ids() { + let contents = json!([{ + "role": "model", + "parts": [ + {"functionCall": {"name": "first", "args": {}}}, + {"functionCall": {"id": "call_auto_0", "name": "second", "args": {}}} + ] + }]); + + let messages = super::gemini_contents_to_canonical_messages(Some(&contents)) + .expect("Gemini contents should parse"); + let call_ids = messages[0] + .content + .iter() + .map(|block| match block { + CanonicalContentBlock::ToolUse { id, .. } => id.as_str(), + _ => panic!("expected tool use"), + }) + .collect::>(); + + assert_eq!(call_ids, vec!["call_auto_1", "call_auto_0"]); + } + + #[test] + fn gemini_request_generated_function_call_ids_avoid_explicit_response_ids() { + let contents = json!([{ + "role": "model", + "parts": [{"functionCall": {"name": "generated", "args": {}}}] + }, { + "role": "user", + "parts": [{ + "functionResponse": { + "id": "call_auto_0", + "name": "external", + "response": {"result": "done"} + } + }] + }]); + + let messages = super::gemini_contents_to_canonical_messages(Some(&contents)) + .expect("Gemini contents should parse"); + let CanonicalContentBlock::ToolUse { id: call_id, .. } = &messages[0].content[0] else { + panic!("expected tool use"); + }; + let CanonicalContentBlock::ToolResult { tool_use_id, .. } = &messages[1].content[0] else { + panic!("expected tool result"); + }; + + assert_eq!(call_id, "call_auto_1"); + assert_eq!(tool_use_id, "call_auto_0"); + } + #[test] fn gemini_request_adapter_preserves_thinking_tools_media_and_extensions() { let request = json!({