fix(ai): align Gemini and Responses compatibility

This commit is contained in:
ZheFox
2026-08-27 22:13:20 +08:00
parent 7892aa9485
commit 4da8c57fe3
7 changed files with 293 additions and 39 deletions
@@ -18,9 +18,9 @@ use crate::{
gemini_generation_config_extra, gemini_google_search_grounding,
gemini_response_format_to_canonical, gemini_system_to_canonical_instructions,
gemini_thinking_to_canonical, gemini_tool_choice_to_canonical, gemini_tools_to_canonical,
gemini_value_by_case, CanonicalContentBlock, CanonicalMessage, CanonicalRequest,
CanonicalResponseFormat, CanonicalRole, CanonicalToolChoice, CanonicalToolDefinition,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
gemini_value_by_case, is_cross_format_tool_result, CanonicalContentBlock, CanonicalMessage,
CanonicalRequest, CanonicalResponseFormat, CanonicalRole, CanonicalToolChoice,
CanonicalToolDefinition, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
};
@@ -244,13 +244,51 @@ fn canonical_system_instruction(canonical: &CanonicalRequest) -> Option<Value> {
fn canonical_messages_to_gemini_contents(messages: &[CanonicalMessage]) -> Option<Vec<Value>> {
let mut contents = Vec::new();
let mut tool_name_by_id = BTreeMap::new();
for message in messages {
let role = match message.role {
let mut pending_tool_use_ids = Vec::new();
let mut message_index = 0;
while message_index < messages.len() {
let role = match messages[message_index].role {
CanonicalRole::Assistant => "model",
CanonicalRole::System | CanonicalRole::Developer => continue,
CanonicalRole::System | CanonicalRole::Developer => {
message_index += 1;
continue;
}
CanonicalRole::Tool | CanonicalRole::User | CanonicalRole::Unknown => "user",
};
let parts = canonical_blocks_to_gemini_parts(&message.content, &mut tool_name_by_id)?;
let mut blocks = Vec::new();
while message_index < messages.len() {
let next_role = match messages[message_index].role {
CanonicalRole::Assistant => Some("model"),
CanonicalRole::Tool | CanonicalRole::User | CanonicalRole::Unknown => Some("user"),
CanonicalRole::System | CanonicalRole::Developer => None,
};
match next_role {
Some(next_role) if next_role == role => {
blocks.extend(messages[message_index].content.iter());
message_index += 1;
}
None => message_index += 1,
Some(_) => break,
}
}
let blocks = if role == "user" {
let aligned = align_gemini_tool_results(blocks, &pending_tool_use_ids);
pending_tool_use_ids.clear();
aligned
} else {
pending_tool_use_ids = blocks
.iter()
.filter_map(|block| match block {
CanonicalContentBlock::ToolUse { id, .. } if !id.trim().is_empty() => {
Some(id.clone())
}
_ => None,
})
.collect();
blocks
};
let parts = canonical_blocks_to_gemini_parts(&blocks, &mut tool_name_by_id)?;
if parts.is_empty() {
continue;
}
@@ -262,8 +300,74 @@ fn canonical_messages_to_gemini_contents(messages: &[CanonicalMessage]) -> Optio
Some(contents)
}
fn align_gemini_tool_results<'a>(
blocks: Vec<&'a CanonicalContentBlock>,
pending_tool_use_ids: &[String],
) -> Vec<&'a CanonicalContentBlock> {
if pending_tool_use_ids.is_empty() {
return blocks;
}
let result_indexes = blocks
.iter()
.enumerate()
.filter_map(|(index, block)| match block {
CanonicalContentBlock::ToolResult { extensions, .. }
if is_cross_format_tool_result(extensions) =>
{
Some(index)
}
CanonicalContentBlock::ToolResult { .. } => None,
_ => None,
})
.collect::<Vec<_>>();
if result_indexes.len() != pending_tool_use_ids.len()
|| blocks
.iter()
.filter(|block| matches!(block, CanonicalContentBlock::ToolResult { .. }))
.count()
!= result_indexes.len()
{
return blocks;
}
let mut ordered = Vec::with_capacity(blocks.len());
let mut used = vec![false; result_indexes.len()];
for pending_id in pending_tool_use_ids {
if pending_id.trim().is_empty() {
return blocks;
}
let Some((result_position, block_index)) =
result_indexes
.iter()
.enumerate()
.find(|(result_position, block_index)| {
if used[*result_position] {
return false;
}
matches!(
blocks[**block_index],
CanonicalContentBlock::ToolResult { ref tool_use_id, .. }
if tool_use_id == pending_id
)
})
else {
return blocks;
};
used[result_position] = true;
ordered.push(blocks[*block_index]);
}
ordered.extend(
blocks
.iter()
.copied()
.filter(|block| !matches!(block, CanonicalContentBlock::ToolResult { .. })),
);
ordered
}
fn canonical_blocks_to_gemini_parts(
blocks: &[CanonicalContentBlock],
blocks: &[&CanonicalContentBlock],
tool_name_by_id: &mut BTreeMap<String, String>,
) -> Option<Vec<Value>> {
let mut parts = Vec::new();
+157 -13
View File
@@ -1367,19 +1367,21 @@ fn mapped_namespace_request_extensions(
source,
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact
) || target != FormatId::OpenAiChat
|| !openai_chat::request::raw_tool_choice_extension_is_representable_for_openai_chat(
request,
)
{
return mapped;
}
let remove_tool_choice =
openai_chat::request::raw_tool_choice_extension_is_representable_for_openai_chat(request);
for provider_namespace in ["openai_responses", "openai_cli"] {
let should_remove_namespace = mapped
.get_mut(provider_namespace)
.and_then(Value::as_object_mut)
.is_some_and(|fields| {
fields.remove("tool_choice");
fields.remove("include");
if remove_tool_choice {
fields.remove("tool_choice");
}
fields.is_empty()
});
if should_remove_namespace {
@@ -2517,7 +2519,6 @@ fn validate_openai_responses_to_chat(
return Ok(());
};
for field in [
"include",
"previous_response_id",
"truncation",
"prompt",
@@ -4241,6 +4242,123 @@ mod tests {
);
}
#[test]
fn pure_openai_chat_to_gemini_preserves_json_tool_output_as_string() {
let body = json!({
"model": "gpt-source",
"messages": [{
"role": "assistant",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "read_file", "arguments": "{}"}
}]
}, {
"role": "tool",
"tool_call_id": "call_1",
"content": "{\"key\":\"value\",\"items\":[1,2,3]}"
}]
});
let converted = convert_request_pure("openai:chat", "gemini:generate_content", &body)
.expect("pure conversion should succeed")
.value;
assert_eq!(
converted["contents"][1]["parts"][0]["functionResponse"]["response"]["result"],
"{\"key\":\"value\",\"items\":[1,2,3]}"
);
}
#[test]
fn pure_openai_responses_to_gemini_aligns_parallel_tool_outputs() {
let body = json!({
"model": "gpt-source",
"input": [{
"type": "function_call",
"call_id": "call_1",
"name": "read_file",
"arguments": "{\"path\":\"one\"}"
}, {
"type": "function_call",
"call_id": "call_2",
"name": "read_file",
"arguments": "{\"path\":\"two\"}"
}, {
"type": "function_call_output",
"call_id": "call_2",
"output": "two"
}, {
"type": "function_call_output",
"call_id": "call_1",
"output": "one"
}]
});
let converted = convert_request_pure("openai:responses", "gemini:generate_content", &body)
.expect("pure conversion should succeed")
.value;
let parts = converted["contents"][1]["parts"]
.as_array()
.expect("tool response parts");
assert_eq!(parts[0]["functionResponse"]["id"], "call_1");
assert_eq!(parts[0]["functionResponse"]["response"]["result"], "one");
assert_eq!(parts[1]["functionResponse"]["id"], "call_2");
assert_eq!(parts[1]["functionResponse"]["response"]["result"], "two");
}
#[test]
fn pure_claude_to_gemini_aligns_parallel_tool_results_before_text() {
let body = json!({
"model": "claude-source",
"max_tokens": 64,
"messages": [{
"role": "assistant",
"content": [{
"type": "tool_use",
"id": "call_1",
"name": "read_file",
"input": {"path": "one"}
}, {
"type": "tool_use",
"id": "call_2",
"name": "read_file",
"input": {"path": "two"}
}]
}, {
"role": "user",
"content": [{
"type": "text",
"text": "Results follow."
}, {
"type": "tool_result",
"tool_use_id": "call_2",
"content": "two"
}, {
"type": "tool_result",
"tool_use_id": "call_1",
"content": "one"
}, {
"type": "text",
"text": "Continue."
}]
}]
});
let converted = convert_request_pure("claude:messages", "gemini:generate_content", &body)
.expect("pure conversion should succeed")
.value;
let parts = converted["contents"][1]["parts"]
.as_array()
.expect("tool response parts");
assert_eq!(parts[0]["functionResponse"]["id"], "call_1");
assert_eq!(parts[1]["functionResponse"]["id"], "call_2");
assert_eq!(parts[2]["text"], "Results follow.");
assert_eq!(parts[3]["text"], "Continue.");
}
#[test]
fn pure_gemini_to_openai_responses_blocks_thought_part_loss() {
let body = json!({
@@ -4437,20 +4555,23 @@ mod tests {
}
#[test]
fn pure_openai_responses_to_chat_blocks_responses_only_fields() {
fn pure_openai_responses_to_chat_drops_include() {
let body = json!({
"model": "gpt-source",
"input": [{"role": "user", "content": "hello"}],
"include": ["reasoning.encrypted_content"]
"include": [
"reasoning.encrypted_content",
"file_search_call.results"
]
});
let error = convert_request_pure("openai:responses", "openai:chat", &body)
.expect_err("lossy field should fail closed");
let converted = convert_request_pure("openai:responses", "openai:chat", &body)
.expect("Responses include should be safely omitted for Chat")
.value;
assert!(matches!(
error,
super::FormatError::LossyConversionBlocked { ref field, .. } if field == "include"
));
assert_eq!(converted["model"], "gpt-source");
assert_eq!(converted["messages"][0]["content"], "hello");
assert!(converted.get("include").is_none());
}
#[test]
@@ -6147,6 +6268,29 @@ mod tests {
));
}
#[test]
fn runtime_openai_responses_to_chat_drops_include() {
let body = json!({
"model": "gpt-source",
"input": [{"role": "user", "content": "hello"}],
"include": ["reasoning.encrypted_content"],
"stream": true
});
let converted = convert_request(
"openai:responses",
"openai:chat",
&body,
&FormatContext::default().with_upstream_stream(true),
)
.expect("runtime conversion should safely omit Responses include");
assert_eq!(converted["model"], "gpt-source");
assert_eq!(converted["messages"][0]["content"], "hello");
assert_eq!(converted["stream"], true);
assert!(converted.get("include").is_none());
}
#[test]
fn converts_openai_embedding_to_jina_without_chat_fields() {
let body = json!({
@@ -1410,6 +1410,14 @@ mod tests {
assert!(!input_json.contains("sig_123"));
continue;
}
if provider_api_format == "gemini:generate_content" {
assert_eq!(
converted["contents"][2]["parts"][0]["functionResponse"]["response"]
["result"],
json!({"ok": true})
);
continue;
}
let legacy =
legacy_claude_request_body(&request, provider_api_format, upstream_is_stream);
assert_eq!(
@@ -1817,13 +1817,7 @@ pub(crate) fn openai_message_content_blocks(
.unwrap_or_default()
.to_string(),
name: None,
output: match message.get("content") {
Some(Value::String(raw)) => serde_json::from_str::<Value>(raw)
.ok()
.or_else(|| Some(Value::String(raw.clone()))),
Some(value) => Some(value.clone()),
None => None,
},
output: message.get("content").cloned(),
content_text: Some(if text.is_empty() {
message
.get("content")
@@ -2114,7 +2108,7 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
generated
});
let raw_output = item_object.get("output");
let output = Some(parse_jsonish_value(raw_output));
let output = Some(raw_output.cloned().unwrap_or_else(|| json!({})));
let mut extensions = openai_responses_extensions(
item_object,
&[
@@ -2550,7 +2544,7 @@ pub(crate) fn openai_responses_output_to_canonical(
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("call_auto_{index}"));
let raw_output = item_object.get("output");
let output = Some(parse_jsonish_value(raw_output));
let output = Some(raw_output.cloned().unwrap_or_else(|| json!({})));
let mut extensions = openai_responses_extensions(
item_object,
&[
@@ -3504,6 +3498,12 @@ pub(crate) fn is_claude_tool_result(extensions: &BTreeMap<String, Value>) -> boo
== Some(CLAUDE_TOOL_RESULT_SOURCE_MARKER)
}
pub(crate) fn is_cross_format_tool_result(extensions: &BTreeMap<String, Value>) -> bool {
is_claude_tool_result(extensions)
|| is_openai_chat_tool_result(extensions)
|| is_openai_responses_tool_result(extensions)
}
fn is_openai_responses_tool_result(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)