mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 04:30:20 +08:00
fix(formats): gate mixed Gemini tools by model
This commit is contained in:
@@ -5855,6 +5855,100 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_sync_execution_runtime_preserves_gemini_tool_config_on_wire() {
|
||||
let listener = crate::test_support::bind_loopback_listener()
|
||||
.await
|
||||
.expect("listener should bind");
|
||||
let addr = listener.local_addr().expect("local addr should resolve");
|
||||
let captured_body = Arc::new(Mutex::new(None));
|
||||
let captured_body_for_handler = Arc::clone(&captured_body);
|
||||
let app = Router::new().route(
|
||||
"/generate",
|
||||
post(move |body: Bytes| {
|
||||
let captured_body = Arc::clone(&captured_body_for_handler);
|
||||
async move {
|
||||
*captured_body
|
||||
.lock()
|
||||
.expect("capture lock should not be poisoned") = Some(body.to_vec());
|
||||
Json(json!({"ok": true}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("test server should run");
|
||||
});
|
||||
|
||||
let result = DirectSyncExecutionRuntime::new()
|
||||
.execute_sync(&ExecutionPlan {
|
||||
request_id: "req-gemini-tool-config-wire".into(),
|
||||
candidate_id: Some("cand-gemini-tool-config-wire".into()),
|
||||
provider_name: Some("google".into()),
|
||||
provider_id: "prov-gemini-tool-config-wire".into(),
|
||||
endpoint_id: "ep-gemini-tool-config-wire".into(),
|
||||
key_id: "key-gemini-tool-config-wire".into(),
|
||||
method: "POST".into(),
|
||||
url: format!("http://{addr}/generate"),
|
||||
headers: BTreeMap::from([("content-type".into(), "application/json".into())]),
|
||||
content_type: Some("application/json".into()),
|
||||
content_encoding: None,
|
||||
body: RequestBody::from_json(json!({
|
||||
"model": "gemini-3-flash-preview",
|
||||
"contents": [{
|
||||
"role": "user",
|
||||
"parts": [{"text": "Search, then save the result."}]
|
||||
}],
|
||||
"tools": [
|
||||
{"googleSearch": {}},
|
||||
{"functionDeclarations": [{
|
||||
"name": "save_result",
|
||||
"parameters": {
|
||||
"type": "OBJECT",
|
||||
"properties": {"result": {"type": "STRING"}}
|
||||
}
|
||||
}]}
|
||||
],
|
||||
"toolConfig": {
|
||||
"includeServerSideToolInvocations": true,
|
||||
"functionCallingConfig": {"mode": "ANY"}
|
||||
}
|
||||
})),
|
||||
stream: false,
|
||||
client_api_format: "openai:responses".into(),
|
||||
provider_api_format: "gemini:generate_content".into(),
|
||||
model_name: Some("gemini-3-flash-preview".into()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: Some(ExecutionTimeouts {
|
||||
connect_ms: Some(5_000),
|
||||
total_ms: Some(LOCAL_HTTP_SUCCESS_TIMEOUT_MS),
|
||||
..ExecutionTimeouts::default()
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("sync execution should succeed");
|
||||
|
||||
server.abort();
|
||||
|
||||
assert_eq!(result.status_code, 200);
|
||||
let body = captured_body
|
||||
.lock()
|
||||
.expect("capture lock should not be poisoned")
|
||||
.take()
|
||||
.and_then(|body| serde_json::from_slice::<serde_json::Value>(&body).ok())
|
||||
.expect("upstream should receive a JSON body");
|
||||
assert_eq!(
|
||||
body["toolConfig"]["includeServerSideToolInvocations"],
|
||||
json!(true)
|
||||
);
|
||||
assert_eq!(body["toolConfig"]["functionCallingConfig"]["mode"], "ANY");
|
||||
assert!(body["toolConfig"]
|
||||
.get("include_server_side_tool_invocations")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_sync_execution_runtime_applies_non_stream_total_timeout_to_body() {
|
||||
let listener = crate::test_support::bind_loopback_listener()
|
||||
|
||||
@@ -9,7 +9,9 @@ use crate::{
|
||||
map_openai_reasoning_effort_to_gemini_budget,
|
||||
map_thinking_budget_to_openai_reasoning_effort,
|
||||
},
|
||||
shared::model_directives::{gemini_model_uses_thinking_level, ReasoningEffort},
|
||||
shared::model_directives::{
|
||||
gemini_model_supports_mixed_tools, gemini_model_uses_thinking_level, ReasoningEffort,
|
||||
},
|
||||
},
|
||||
protocol::canonical::{
|
||||
apply_gemini_request_extensions, canonical_extension_object_mut,
|
||||
@@ -188,7 +190,7 @@ pub fn to_raw(
|
||||
let mut output = canonical_to_gemini_request_body(canonical, mapped_model, upstream_is_stream)?;
|
||||
apply_gemini_request_extensions(&mut output, &canonical.extensions)?;
|
||||
if !canonical_has_raw_gemini_tools(canonical) {
|
||||
enable_server_side_tool_invocations_for_mixed_tools(&mut output)?;
|
||||
enable_server_side_tool_invocations_for_mixed_tools(&mut output, mapped_model)?;
|
||||
}
|
||||
Some(output)
|
||||
}
|
||||
@@ -201,12 +203,41 @@ fn canonical_has_raw_gemini_tools(canonical: &CanonicalRequest) -> bool {
|
||||
.is_some_and(|gemini| gemini.contains_key("raw_tools"))
|
||||
}
|
||||
|
||||
fn enable_server_side_tool_invocations_for_mixed_tools(output: &mut Value) -> Option<()> {
|
||||
fn enable_server_side_tool_invocations_for_mixed_tools(
|
||||
output: &mut Value,
|
||||
mapped_model: &str,
|
||||
) -> Option<()> {
|
||||
let output_object = output.as_object_mut()?;
|
||||
let tools = output_object.get("tools").and_then(Value::as_array);
|
||||
let Some(tools) = tools else {
|
||||
return Some(());
|
||||
};
|
||||
if !gemini_tools_are_mixed(tools) {
|
||||
return Some(());
|
||||
}
|
||||
if !gemini_model_supports_mixed_tools(mapped_model) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let tool_config = output_object
|
||||
.entry("toolConfig".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()))
|
||||
.as_object_mut()?;
|
||||
tool_config.remove("include_server_side_tool_invocations");
|
||||
tool_config.insert(
|
||||
"includeServerSideToolInvocations".to_string(),
|
||||
Value::Bool(true),
|
||||
);
|
||||
Some(())
|
||||
}
|
||||
|
||||
pub(crate) fn canonical_has_mixed_gemini_tools(canonical: &CanonicalRequest) -> bool {
|
||||
canonical_tools_to_gemini(canonical)
|
||||
.and_then(|tools| tools.as_array().cloned())
|
||||
.is_some_and(|tools| gemini_tools_are_mixed(&tools))
|
||||
}
|
||||
|
||||
fn gemini_tools_are_mixed(tools: &[Value]) -> bool {
|
||||
let has_function_declarations = tools.iter().any(|tool| {
|
||||
tool.as_object().is_some_and(|tool| {
|
||||
tool.get("functionDeclarations")
|
||||
@@ -225,20 +256,7 @@ fn enable_server_side_tool_invocations_for_mixed_tools(output: &mut Value) -> Op
|
||||
})
|
||||
})
|
||||
});
|
||||
if !has_function_declarations || !has_builtin_tools {
|
||||
return Some(());
|
||||
}
|
||||
|
||||
let tool_config = output_object
|
||||
.entry("toolConfig".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()))
|
||||
.as_object_mut()?;
|
||||
tool_config.remove("include_server_side_tool_invocations");
|
||||
tool_config.insert(
|
||||
"includeServerSideToolInvocations".to_string(),
|
||||
Value::Bool(true),
|
||||
);
|
||||
Some(())
|
||||
has_function_declarations && has_builtin_tools
|
||||
}
|
||||
|
||||
fn canonical_to_gemini_request_body(
|
||||
@@ -1271,4 +1289,26 @@ mod tests {
|
||||
serde_json::json!({"result": {"ok": true}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_builtin_and_function_tools_require_gemini_three() {
|
||||
let canonical = CanonicalRequest {
|
||||
model: "gemini-2.5-pro".to_string(),
|
||||
tools: vec![CanonicalToolDefinition {
|
||||
name: "save_result".to_string(),
|
||||
description: None,
|
||||
parameters: Some(json!({"type": "object"})),
|
||||
strict: None,
|
||||
extensions: BTreeMap::new(),
|
||||
}],
|
||||
extensions: BTreeMap::from([(
|
||||
"gemini".to_string(),
|
||||
json!({"builtin_tools": [{"googleSearch": {}}]}),
|
||||
)]),
|
||||
..CanonicalRequest::default()
|
||||
};
|
||||
|
||||
assert!(to_raw(&canonical, "gemini-2.5-pro", false).is_none());
|
||||
assert!(to_raw(&canonical, "gemini-3-flash-preview", false).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ fn validate_runtime_request_conversion(
|
||||
request: &CanonicalRequest,
|
||||
mapped_model: Option<&str>,
|
||||
) -> Result<(), FormatError> {
|
||||
validate_gemini_mixed_tool_model(source, target, request, mapped_model)?;
|
||||
validate_openai_cross_format_store(source, target, body)?;
|
||||
validate_openai_prompt_cache_contract(source, body, mapped_model)?;
|
||||
validate_openai_reasoning_effort(source, target, body, mapped_model)?;
|
||||
@@ -437,6 +438,7 @@ fn validate_request_conversion(
|
||||
) -> Result<(), FormatError> {
|
||||
let source = parse_format(source_format)?;
|
||||
let target = parse_format(target_format)?;
|
||||
validate_gemini_mixed_tool_model(source, target, request, mapped_model)?;
|
||||
validate_openai_prompt_cache_contract(source, body, mapped_model)?;
|
||||
validate_openai_reasoning_effort(source, target, body, mapped_model)?;
|
||||
if source == target {
|
||||
@@ -470,6 +472,34 @@ fn validate_request_conversion(
|
||||
validate_cross_format_request_extensions(source, target, request)
|
||||
}
|
||||
|
||||
fn validate_gemini_mixed_tool_model(
|
||||
source: FormatId,
|
||||
target: FormatId,
|
||||
request: &CanonicalRequest,
|
||||
mapped_model: Option<&str>,
|
||||
) -> Result<(), FormatError> {
|
||||
if source == target
|
||||
|| target != FormatId::GeminiGenerateContent
|
||||
|| !gemini_generate_content::request::canonical_has_mixed_gemini_tools(request)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let target_model = mapped_model
|
||||
.map(str::trim)
|
||||
.filter(|model| !model.is_empty())
|
||||
.unwrap_or(request.model.trim());
|
||||
if crate::formats::shared::model_directives::gemini_model_supports_mixed_tools(target_model) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(FormatError::InvalidTargetField {
|
||||
format: target.as_str().to_string(),
|
||||
field: "tools".to_string(),
|
||||
reason: format!(
|
||||
"model {target_model:?} does not support combining built-in tools with custom function declarations; use a Gemini 3 model"
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_openai_responses_cross_format_input(
|
||||
source: FormatId,
|
||||
target: FormatId,
|
||||
@@ -3459,6 +3489,37 @@ mod tests {
|
||||
.any(|field| field.field == "messages"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_responses_to_gemini_rejects_mixed_tools_for_gemini_two() {
|
||||
let body = json!({
|
||||
"model": "gpt-5",
|
||||
"input": "Search, then save the result.",
|
||||
"tools": [
|
||||
{"type": "web_search_preview"},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "save_result",
|
||||
"parameters": {"type": "object"}
|
||||
}
|
||||
]
|
||||
});
|
||||
let context = FormatContext::default().with_mapped_model("gemini-2.5-pro");
|
||||
|
||||
let error = convert_request(
|
||||
"openai:responses",
|
||||
"gemini:generate_content",
|
||||
&body,
|
||||
&context,
|
||||
)
|
||||
.expect_err("Gemini 2.5 mixed tools should fail before reaching the provider");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
FormatError::InvalidTargetField { ref field, ref reason, .. }
|
||||
if field == "tools" && reason.contains("Gemini 3")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_openai_chat_to_responses_preserves_explicit_tool_strict() {
|
||||
let body = json!({
|
||||
|
||||
@@ -641,6 +641,10 @@ pub fn claude_model_uses_adaptive_effort(model: &str) -> bool {
|
||||
}
|
||||
|
||||
pub fn gemini_model_uses_thinking_level(model: &str) -> bool {
|
||||
gemini_model_supports_mixed_tools(model)
|
||||
}
|
||||
|
||||
pub(crate) fn gemini_model_supports_mixed_tools(model: &str) -> bool {
|
||||
model
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
|
||||
@@ -2056,7 +2056,7 @@ mod tests {
|
||||
let gemini = build_standard_request_body(
|
||||
&request,
|
||||
"openai:responses",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3-flash-preview",
|
||||
"google",
|
||||
"gemini:generate_content",
|
||||
"/v1/responses",
|
||||
|
||||
@@ -177,6 +177,7 @@ mod tests {
|
||||
}
|
||||
},
|
||||
"toolConfig": {
|
||||
"includeServerSideToolInvocations": true,
|
||||
"functionCallingConfig": {
|
||||
"mode": "VALIDATED"
|
||||
}
|
||||
@@ -245,6 +246,13 @@ mod tests {
|
||||
envelope["request"]["toolConfig"]["functionCallingConfig"]["mode"],
|
||||
"VALIDATED"
|
||||
);
|
||||
assert_eq!(
|
||||
envelope["request"]["toolConfig"]["includeServerSideToolInvocations"],
|
||||
true
|
||||
);
|
||||
assert!(envelope["request"]["toolConfig"]
|
||||
.get("include_server_side_tool_invocations")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
envelope["request"]["tools"][0]["functionDeclarations"][0]["name"],
|
||||
"run_command"
|
||||
|
||||
Reference in New Issue
Block a user