mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
fix(ai): align Gemini and Responses compatibility
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user