fix(gemini): pair idless tool history

This commit is contained in:
ZheFox
2026-08-28 02:15:00 +08:00
parent 4da8c57fe3
commit 8cdfa338e5
2 changed files with 316 additions and 12 deletions
@@ -4049,6 +4049,86 @@ mod tests {
assert_eq!(converted["messages"][1]["tool_call_id"], "call_lookup_1"); 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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
assert_eq!(claude_result_ids, claude_call_ids);
}
#[test] #[test]
fn pure_claude_to_openai_chat_maps_disable_parallel_tool_use() { fn pure_claude_to_openai_chat_maps_disable_parallel_tool_use() {
let body = json!({ let body = json!({
@@ -1,4 +1,4 @@
use std::collections::BTreeMap; use std::collections::{BTreeMap, BTreeSet, VecDeque};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
@@ -1118,6 +1118,25 @@ pub(crate) fn gemini_contents_to_canonical_messages(
}; };
let contents = contents.as_array()?; let contents = contents.as_array()?;
let mut messages = Vec::new(); 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::<BTreeSet<_>>();
let mut pending_tool_calls = VecDeque::<(String, String)>::new();
let mut next_generated_tool_call_index = 0usize;
for content in contents { for content in contents {
let content_object = content.as_object()?; let content_object = content.as_object()?;
let role = match content_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 parts = content_object.get("parts").and_then(Value::as_array)?;
let mut blocks = Vec::new(); let mut blocks = Vec::new();
for (index, part) in parts.iter().enumerate() { 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() { if blocks.is_empty() {
continue; continue;
@@ -1205,11 +1294,7 @@ pub(crate) fn gemini_part_to_canonical_block(
.and_then(Value::as_str) .and_then(Value::as_str)
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty())?; .filter(|value| !value.is_empty())?;
let id = function_call let id = gemini_explicit_function_id(function_call)
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
.unwrap_or_else(|| format!("call_auto_{index}")); .unwrap_or_else(|| format!("call_auto_{index}"));
return Some(CanonicalContentBlock::ToolUse { return Some(CanonicalContentBlock::ToolUse {
@@ -1233,11 +1318,7 @@ pub(crate) fn gemini_part_to_canonical_block(
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.map(ToOwned::to_owned); .map(ToOwned::to_owned);
let tool_use_id = function_response let tool_use_id = gemini_explicit_function_id(function_response)
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
.or_else(|| name.clone()) .or_else(|| name.clone())
.unwrap_or_else(|| format!("toolu_response_{index}")); .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<String, Value>) -> 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( pub(crate) fn gemini_inline_data_to_canonical_block(
inline_data: &Map<String, Value>, inline_data: &Map<String, Value>,
part_object: &Map<String, Value>, part_object: &Map<String, Value>,
@@ -8458,6 +8549,139 @@ mod tests {
assert_eq!(canonical.usage.as_ref().unwrap().cache_write_tokens, 5); 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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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] #[test]
fn gemini_request_adapter_preserves_thinking_tools_media_and_extensions() { fn gemini_request_adapter_preserves_thinking_tools_media_and_extensions() {
let request = json!({ let request = json!({