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
@@ -4670,8 +4670,7 @@ async fn gateway_hydrates_antigravity_project_id_from_load_code_assist_for_test_
.lock() .lock()
.expect("mutex should lock") .expect("mutex should lock")
.push(plan.url.clone()); .push(plan.url.clone());
if plan.url == "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" if plan.url == "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" {
{
assert_eq!(plan.model_name.as_deref(), Some("loadCodeAssist")); assert_eq!(plan.model_name.as_deref(), Some("loadCodeAssist"));
assert_eq!( assert_eq!(
plan.headers.get("authorization").map(String::as_str), plan.headers.get("authorization").map(String::as_str),
@@ -4827,7 +4826,7 @@ async fn gateway_hydrates_antigravity_project_id_from_load_code_assist_for_test_
assert_eq!( assert_eq!(
*seen_urls.lock().expect("mutex should lock"), *seen_urls.lock().expect("mutex should lock"),
vec![ vec![
"https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist".to_string(), "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist".to_string(),
"https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent".to_string(), "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent".to_string(),
] ]
); );
@@ -18,9 +18,9 @@ use crate::{
gemini_generation_config_extra, gemini_google_search_grounding, gemini_generation_config_extra, gemini_google_search_grounding,
gemini_response_format_to_canonical, gemini_system_to_canonical_instructions, gemini_response_format_to_canonical, gemini_system_to_canonical_instructions,
gemini_thinking_to_canonical, gemini_tool_choice_to_canonical, gemini_tools_to_canonical, gemini_thinking_to_canonical, gemini_tool_choice_to_canonical, gemini_tools_to_canonical,
gemini_value_by_case, CanonicalContentBlock, CanonicalMessage, CanonicalRequest, gemini_value_by_case, is_cross_format_tool_result, CanonicalContentBlock, CanonicalMessage,
CanonicalResponseFormat, CanonicalRole, CanonicalToolChoice, CanonicalToolDefinition, CanonicalRequest, CanonicalResponseFormat, CanonicalRole, CanonicalToolChoice,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE, 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>> { fn canonical_messages_to_gemini_contents(messages: &[CanonicalMessage]) -> Option<Vec<Value>> {
let mut contents = Vec::new(); let mut contents = Vec::new();
let mut tool_name_by_id = BTreeMap::new(); let mut tool_name_by_id = BTreeMap::new();
for message in messages { let mut pending_tool_use_ids = Vec::new();
let role = match message.role { let mut message_index = 0;
while message_index < messages.len() {
let role = match messages[message_index].role {
CanonicalRole::Assistant => "model", CanonicalRole::Assistant => "model",
CanonicalRole::System | CanonicalRole::Developer => continue, CanonicalRole::System | CanonicalRole::Developer => {
message_index += 1;
continue;
}
CanonicalRole::Tool | CanonicalRole::User | CanonicalRole::Unknown => "user", 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() { if parts.is_empty() {
continue; continue;
} }
@@ -262,8 +300,74 @@ fn canonical_messages_to_gemini_contents(messages: &[CanonicalMessage]) -> Optio
Some(contents) 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( fn canonical_blocks_to_gemini_parts(
blocks: &[CanonicalContentBlock], blocks: &[&CanonicalContentBlock],
tool_name_by_id: &mut BTreeMap<String, String>, tool_name_by_id: &mut BTreeMap<String, String>,
) -> Option<Vec<Value>> { ) -> Option<Vec<Value>> {
let mut parts = Vec::new(); let mut parts = Vec::new();
+157 -13
View File
@@ -1367,19 +1367,21 @@ fn mapped_namespace_request_extensions(
source, source,
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact
) || target != FormatId::OpenAiChat ) || target != FormatId::OpenAiChat
|| !openai_chat::request::raw_tool_choice_extension_is_representable_for_openai_chat(
request,
)
{ {
return mapped; 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"] { for provider_namespace in ["openai_responses", "openai_cli"] {
let should_remove_namespace = mapped let should_remove_namespace = mapped
.get_mut(provider_namespace) .get_mut(provider_namespace)
.and_then(Value::as_object_mut) .and_then(Value::as_object_mut)
.is_some_and(|fields| { .is_some_and(|fields| {
fields.remove("tool_choice"); fields.remove("include");
if remove_tool_choice {
fields.remove("tool_choice");
}
fields.is_empty() fields.is_empty()
}); });
if should_remove_namespace { if should_remove_namespace {
@@ -2517,7 +2519,6 @@ fn validate_openai_responses_to_chat(
return Ok(()); return Ok(());
}; };
for field in [ for field in [
"include",
"previous_response_id", "previous_response_id",
"truncation", "truncation",
"prompt", "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] #[test]
fn pure_gemini_to_openai_responses_blocks_thought_part_loss() { fn pure_gemini_to_openai_responses_blocks_thought_part_loss() {
let body = json!({ let body = json!({
@@ -4437,20 +4555,23 @@ mod tests {
} }
#[test] #[test]
fn pure_openai_responses_to_chat_blocks_responses_only_fields() { fn pure_openai_responses_to_chat_drops_include() {
let body = json!({ let body = json!({
"model": "gpt-source", "model": "gpt-source",
"input": [{"role": "user", "content": "hello"}], "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) let converted = convert_request_pure("openai:responses", "openai:chat", &body)
.expect_err("lossy field should fail closed"); .expect("Responses include should be safely omitted for Chat")
.value;
assert!(matches!( assert_eq!(converted["model"], "gpt-source");
error, assert_eq!(converted["messages"][0]["content"], "hello");
super::FormatError::LossyConversionBlocked { ref field, .. } if field == "include" assert!(converted.get("include").is_none());
));
} }
#[test] #[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] #[test]
fn converts_openai_embedding_to_jina_without_chat_fields() { fn converts_openai_embedding_to_jina_without_chat_fields() {
let body = json!({ let body = json!({
@@ -1410,6 +1410,14 @@ mod tests {
assert!(!input_json.contains("sig_123")); assert!(!input_json.contains("sig_123"));
continue; continue;
} }
if provider_api_format == "gemini:generate_content" {
assert_eq!(
converted["contents"][2]["parts"][0]["functionResponse"]["response"]
["result"],
json!({"ok": true})
);
continue;
}
let legacy = let legacy =
legacy_claude_request_body(&request, provider_api_format, upstream_is_stream); legacy_claude_request_body(&request, provider_api_format, upstream_is_stream);
assert_eq!( assert_eq!(
@@ -1817,13 +1817,7 @@ pub(crate) fn openai_message_content_blocks(
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
name: None, name: None,
output: match message.get("content") { output: message.get("content").cloned(),
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,
},
content_text: Some(if text.is_empty() { content_text: Some(if text.is_empty() {
message message
.get("content") .get("content")
@@ -2114,7 +2108,7 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
generated generated
}); });
let raw_output = item_object.get("output"); 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( let mut extensions = openai_responses_extensions(
item_object, item_object,
&[ &[
@@ -2550,7 +2544,7 @@ pub(crate) fn openai_responses_output_to_canonical(
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
.unwrap_or_else(|| format!("call_auto_{index}")); .unwrap_or_else(|| format!("call_auto_{index}"));
let raw_output = item_object.get("output"); 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( let mut extensions = openai_responses_extensions(
item_object, item_object,
&[ &[
@@ -3504,6 +3498,12 @@ pub(crate) fn is_claude_tool_result(extensions: &BTreeMap<String, Value>) -> boo
== Some(CLAUDE_TOOL_RESULT_SOURCE_MARKER) == 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 { fn is_openai_responses_tool_result(extensions: &BTreeMap<String, Value>) -> bool {
extensions extensions
.get(AETHER_EXTENSION_NAMESPACE) .get(AETHER_EXTENSION_NAMESPACE)
+3 -4
View File
@@ -2631,14 +2631,13 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn antigravity_model_fetch_hydrates_project_from_daily_load_code_assist() { async fn antigravity_model_fetch_hydrates_project_from_prod_load_code_assist() {
let executed_urls = Arc::new(Mutex::new(Vec::new())); let executed_urls = Arc::new(Mutex::new(Vec::new()));
let runtime = OAuthRoutingTestRuntime { let runtime = OAuthRoutingTestRuntime {
executed_urls: Arc::clone(&executed_urls), executed_urls: Arc::clone(&executed_urls),
routes: vec![ routes: vec![
( (
"https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist".to_string(),
.to_string(),
Ok(( Ok((
200, 200,
json!({ json!({
@@ -2679,7 +2678,7 @@ mod tests {
assert_eq!( assert_eq!(
urls.as_slice(), urls.as_slice(),
&[ &[
"https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
"https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels",
] ]
); );
+2 -2
View File
@@ -285,7 +285,7 @@ pub async fn build_antigravity_load_code_assist_plan(
transport, transport,
ModelFetchExecutionPlanRequest { ModelFetchExecutionPlanRequest {
method: "POST".to_string(), method: "POST".to_string(),
url: "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist".to_string(), url: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist".to_string(),
headers, headers,
content_type: Some("application/json".to_string()), content_type: Some("application/json".to_string()),
body: RequestBody::from_json(json!({ body: RequestBody::from_json(json!({
@@ -1212,7 +1212,7 @@ mod tests {
assert_eq!(plan.method, "POST"); assert_eq!(plan.method, "POST");
assert_eq!( assert_eq!(
plan.url, plan.url,
"https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"
); );
assert_eq!( assert_eq!(
plan.headers.get("authorization").map(String::as_str), plan.headers.get("authorization").map(String::as_str),