diff --git a/apps/aether-gateway/src/tests/control/admin/provider_query.rs b/apps/aether-gateway/src/tests/control/admin/provider_query.rs index 61ca5bf2c..e18848467 100644 --- a/apps/aether-gateway/src/tests/control/admin/provider_query.rs +++ b/apps/aether-gateway/src/tests/control/admin/provider_query.rs @@ -4670,8 +4670,7 @@ async fn gateway_hydrates_antigravity_project_id_from_load_code_assist_for_test_ .lock() .expect("mutex should lock") .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.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!( *seen_urls.lock().expect("mutex should lock"), 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(), ] ); diff --git a/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs b/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs index b5ba03277..8be83d2dc 100644 --- a/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs +++ b/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs @@ -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 { fn canonical_messages_to_gemini_contents(messages: &[CanonicalMessage]) -> Option> { 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::>(); + 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, ) -> Option> { let mut parts = Vec::new(); diff --git a/crates/aether-ai/formats/src/formats/registry.rs b/crates/aether-ai/formats/src/formats/registry.rs index 42e16c170..61e1de1e2 100644 --- a/crates/aether-ai/formats/src/formats/registry.rs +++ b/crates/aether-ai/formats/src/formats/registry.rs @@ -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!({ diff --git a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs index 56ed25cd7..bd1dca6f2 100644 --- a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs @@ -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!( diff --git a/crates/aether-ai/formats/src/protocol/canonical.rs b/crates/aether-ai/formats/src/protocol/canonical.rs index d6318775d..db127d50b 100644 --- a/crates/aether-ai/formats/src/protocol/canonical.rs +++ b/crates/aether-ai/formats/src/protocol/canonical.rs @@ -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::(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) -> boo == Some(CLAUDE_TOOL_RESULT_SOURCE_MARKER) } +pub(crate) fn is_cross_format_tool_result(extensions: &BTreeMap) -> 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) -> bool { extensions .get(AETHER_EXTENSION_NAMESPACE) diff --git a/crates/aether-model-fetch/src/strategy.rs b/crates/aether-model-fetch/src/strategy.rs index 64f8ab1b8..1412f7331 100644 --- a/crates/aether-model-fetch/src/strategy.rs +++ b/crates/aether-model-fetch/src/strategy.rs @@ -2631,14 +2631,13 @@ mod tests { } #[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 runtime = OAuthRoutingTestRuntime { executed_urls: Arc::clone(&executed_urls), routes: vec![ ( - "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" - .to_string(), + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist".to_string(), Ok(( 200, json!({ @@ -2679,7 +2678,7 @@ mod tests { assert_eq!( 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", ] ); diff --git a/crates/aether-model-fetch/src/transport.rs b/crates/aether-model-fetch/src/transport.rs index 7527ac750..15fa42366 100644 --- a/crates/aether-model-fetch/src/transport.rs +++ b/crates/aether-model-fetch/src/transport.rs @@ -285,7 +285,7 @@ pub async fn build_antigravity_load_code_assist_plan( transport, ModelFetchExecutionPlanRequest { 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, content_type: Some("application/json".to_string()), body: RequestBody::from_json(json!({ @@ -1212,7 +1212,7 @@ mod tests { assert_eq!(plan.method, "POST"); assert_eq!( plan.url, - "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" ); assert_eq!( plan.headers.get("authorization").map(String::as_str),