This commit is contained in:
ZheFox
2026-08-29 00:00:13 +08:00
7 changed files with 855 additions and 24 deletions
@@ -102,6 +102,59 @@ fn builds_openai_chat_cross_format_request_body_from_openai_responses_source() {
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
}
#[test]
fn maps_openai_responses_additional_tools_without_message_name() {
let body_json = json!({
"model": "gpt-5",
"input": [
{
"type": "additional_tools",
"role": "developer",
"tools": [{
"type": "function",
"name": "get_weather",
"description": "Get the weather",
"parameters": {
"type": "object",
"properties": {}
}
}]
},
{
"role": "user",
"content": "What is the weather?"
}
]
});
let provider_request_body = build_cross_format_openai_responses_request_body(
&body_json,
"gpt-5-upstream",
"openai:responses",
"openai:chat",
false,
false,
"openai",
None,
None,
&http::HeaderMap::new(),
false,
)
.expect("Responses additional tools should map to a Chat request body");
assert_eq!(
provider_request_body["messages"].as_array().map(Vec::len),
Some(1)
);
assert_eq!(provider_request_body["messages"][0]["role"], "user");
assert!(provider_request_body["messages"][0].get("name").is_none());
assert_eq!(provider_request_body["tools"][0]["type"], "function");
assert_eq!(
provider_request_body["tools"][0]["function"]["name"],
"get_weather"
);
}
#[test]
fn local_openai_responses_wrapper_preserves_body_order_after_edits() {
let body_json: Value = serde_json::from_str(
@@ -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()
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{json, Map, Value};
@@ -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,
@@ -187,9 +189,76 @@ pub fn to_raw(
) -> Option<Value> {
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, mapped_model)?;
}
Some(output)
}
fn canonical_has_raw_gemini_tools(canonical: &CanonicalRequest) -> bool {
canonical
.extensions
.get("gemini")
.and_then(Value::as_object)
.is_some_and(|gemini| gemini.contains_key("raw_tools"))
}
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")
.or_else(|| tool.get("function_declarations"))
.and_then(Value::as_array)
.is_some_and(|declarations| !declarations.is_empty())
})
});
let has_builtin_tools = tools.iter().any(|tool| {
tool.as_object().is_some_and(|tool| {
tool.keys().any(|key| {
!matches!(
key.as_str(),
"functionDeclarations" | "function_declarations"
)
})
})
});
has_function_declarations && has_builtin_tools
}
fn canonical_to_gemini_request_body(
canonical: &CanonicalRequest,
mapped_model: &str,
@@ -920,24 +989,194 @@ fn insert_f64(output: &mut Map<String, Value>, key: &str, value: Option<f64>) {
}
fn clean_gemini_schema(value: &mut Value) {
match value {
Value::Object(object) => {
for inner in object.values_mut() {
clean_gemini_schema(inner);
}
if object.get("type").and_then(Value::as_str) == Some("object")
&& !object.contains_key("properties")
{
object.insert("properties".to_string(), Value::Object(Map::new()));
let root = value.clone();
*value = json_schema_to_gemini_schema(&root, &root, &mut BTreeSet::new());
}
fn json_schema_to_gemini_schema(
value: &Value,
root: &Value,
resolving_refs: &mut BTreeSet<String>,
) -> Value {
let Some(object) = value.as_object() else {
return json!({});
};
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
if let Some(pointer) = reference.strip_prefix('#') {
if resolving_refs.insert(reference.to_string()) {
if let Some(resolved) = root.pointer(pointer).and_then(Value::as_object) {
let mut merged = resolved.clone();
for (key, value) in object {
if key != "$ref" {
merged.insert(key.clone(), value.clone());
}
}
let schema = clean_gemini_schema_object(&merged, root, resolving_refs);
resolving_refs.remove(reference);
return Value::Object(schema);
}
resolving_refs.remove(reference);
}
}
Value::Array(items) => {
for item in items {
clean_gemini_schema(item);
}
Value::Object(clean_gemini_schema_object(object, root, resolving_refs))
}
fn clean_gemini_schema_object(
object: &Map<String, Value>,
root: &Value,
resolving_refs: &mut BTreeSet<String>,
) -> Map<String, Value> {
let mut schema = Map::new();
for key in ["title", "description", "format", "pattern"] {
if let Some(value) = object.get(key).filter(|value| value.is_string()) {
schema.insert(key.to_string(), value.clone());
}
}
for key in ["default", "example"] {
if let Some(value) = object.get(key) {
schema.insert(key.to_string(), value.clone());
}
}
for key in ["minimum", "maximum"] {
if let Some(value) = object.get(key).filter(|value| value.is_number()) {
schema.insert(key.to_string(), value.clone());
}
}
for key in [
"minItems",
"maxItems",
"minLength",
"maxLength",
"minProperties",
"maxProperties",
] {
if let Some(value) = object.get(key).and_then(gemini_int64_string) {
schema.insert(key.to_string(), Value::String(value));
}
}
if let Some(value) = object.get("nullable").filter(|value| value.is_boolean()) {
schema.insert("nullable".to_string(), value.clone());
}
for key in ["required", "propertyOrdering"] {
if let Some(values) = object.get(key).and_then(gemini_string_array) {
schema.insert(key.to_string(), values);
}
}
if let Some(values) = object.get("enum").and_then(Value::as_array) {
let values = values
.iter()
.filter(|value| value.is_string())
.cloned()
.collect::<Vec<_>>();
if !values.is_empty() {
schema.insert("enum".to_string(), Value::Array(values));
}
}
if let Some(properties) = object.get("properties").and_then(Value::as_object) {
schema.insert(
"properties".to_string(),
Value::Object(
properties
.iter()
.map(|(name, value)| {
(
name.clone(),
json_schema_to_gemini_schema(value, root, resolving_refs),
)
})
.collect(),
),
);
}
if let Some(items) = object.get("items") {
schema.insert(
"items".to_string(),
json_schema_to_gemini_schema(items, root, resolving_refs),
);
}
let explicit_any_of = object
.get("anyOf")
.or_else(|| object.get("oneOf"))
.and_then(Value::as_array)
.map(|items| {
Value::Array(
items
.iter()
.map(|item| json_schema_to_gemini_schema(item, root, resolving_refs))
.collect(),
)
});
if let Some(any_of) = explicit_any_of {
schema.insert("anyOf".to_string(), any_of);
}
match object.get("type") {
Some(Value::String(schema_type)) => {
schema.insert("type".to_string(), Value::String(schema_type.clone()));
}
Some(Value::Array(types)) => {
let mut non_null_types = types
.iter()
.filter_map(Value::as_str)
.filter(|schema_type| *schema_type != "null")
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
let mut seen_types = BTreeSet::new();
non_null_types.retain(|schema_type| seen_types.insert(schema_type.clone()));
let nullable = types.iter().any(|value| value.as_str() == Some("null"));
match non_null_types.as_slice() {
[schema_type] => {
schema.insert("type".to_string(), Value::String(schema_type.clone()));
}
[] if nullable => {
schema.insert("type".to_string(), Value::String("null".to_string()));
}
[] => {}
_ if !schema.contains_key("anyOf") => {
schema.insert(
"anyOf".to_string(),
Value::Array(
non_null_types
.iter()
.map(|schema_type| json!({ "type": schema_type }))
.collect(),
),
);
}
_ => {}
}
if nullable && !non_null_types.is_empty() {
schema.insert("nullable".to_string(), Value::Bool(true));
}
}
_ => {}
}
if schema.get("type").and_then(Value::as_str) == Some("object")
&& !schema.contains_key("properties")
{
schema.insert("properties".to_string(), Value::Object(Map::new()));
}
schema
}
fn gemini_int64_string(value: &Value) -> Option<String> {
match value {
Value::String(value) => Some(value.clone()),
Value::Number(value) => Some(value.to_string()),
_ => None,
}
}
fn gemini_string_array(value: &Value) -> Option<Value> {
let values = value.as_array()?;
values.iter().all(Value::is_string).then(|| value.clone())
}
#[cfg(test)]
@@ -945,6 +1184,79 @@ mod tests {
use super::*;
use crate::CanonicalContentBlock;
#[test]
fn canonical_tool_declaration_sanitizes_json_schema_for_gemini() {
let declaration = canonical_tool_to_gemini_declaration(&CanonicalToolDefinition {
name: "inspect".to_string(),
description: None,
parameters: Some(json!({
"$defs": {
"Target": {
"type": "object",
"properties": {
"secret": {
"type": "string",
"encrypted": true
}
},
"required": ["secret"],
"additionalProperties": false
}
},
"type": "object",
"properties": {
"target": {
"oneOf": [
{"$ref": "#/$defs/Target"},
{"type": "null"}
]
},
"mode": {
"type": ["string", "null"],
"enum": [1, "fast"]
},
"value": {
"type": ["string", "integer"]
}
}
})),
strict: None,
extensions: BTreeMap::new(),
});
assert_eq!(
declaration["parameters"],
json!({
"type": "object",
"properties": {
"target": {
"anyOf": [
{
"type": "object",
"properties": {
"secret": {"type": "string"}
},
"required": ["secret"]
},
{"type": "null"}
]
},
"mode": {
"type": "string",
"nullable": true,
"enum": ["fast"]
},
"value": {
"anyOf": [
{"type": "string"},
{"type": "integer"}
]
}
}
})
);
}
#[test]
fn canonical_tool_result_to_gemini_request_preserves_function_response_id() {
let mut tool_name_by_id = BTreeMap::new();
@@ -977,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());
}
}
@@ -120,12 +120,15 @@ pub fn convert_request_pure_with_context(
ctx: &FormatContext,
) -> Result<Converted<Value>, FormatError> {
let pure_ctx = ctx.without_runtime_request_edits();
let request = parse_request(source_format, body, &pure_ctx)?;
validate_openai_responses_target_contract(target_format, body)?;
let source = parse_format(source_format)?;
let target = parse_format(target_format)?;
let normalized_body = normalize_openai_responses_to_chat_body(source, target, body)?;
let request = parse_request(source_format, &normalized_body, &pure_ctx)?;
validate_openai_responses_target_contract(target_format, &normalized_body)?;
validate_request_conversion(
source_format,
target_format,
body,
&normalized_body,
&request,
ctx.mapped_model.as_deref(),
)?;
@@ -158,12 +161,13 @@ pub fn convert_request(
None
};
let body = expanded_body.as_ref().unwrap_or(body);
validate_openai_responses_target_contract(target_format, body)?;
let mut request = parse_request(source_format, body, ctx)?;
let normalized_body = normalize_openai_responses_to_chat_body(source, target, body)?;
validate_openai_responses_target_contract(target_format, &normalized_body)?;
let mut request = parse_request(source_format, &normalized_body, ctx)?;
validate_runtime_request_conversion(
source,
target,
body,
&normalized_body,
&request,
ctx.mapped_model.as_deref(),
)?;
@@ -177,6 +181,81 @@ pub fn convert_request(
emit_request_inner(target_format, &request, ctx)
}
fn normalize_openai_responses_to_chat_body(
source: FormatId,
target: FormatId,
body: &Value,
) -> Result<Value, FormatError> {
if !matches!(
source,
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact
) || target != FormatId::OpenAiChat
{
return Ok(body.clone());
}
let Some(body_object) = body.as_object() else {
return Ok(body.clone());
};
let Some(input) = body_object.get("input").and_then(Value::as_array) else {
return Ok(body.clone());
};
let additional_tools_count = input
.iter()
.take_while(|item| is_openai_responses_additional_tools_item(item))
.count();
if additional_tools_count == 0 {
return Ok(body.clone());
}
if body_object
.get("tools")
.is_some_and(|tools| !tools.is_array())
{
return Ok(body.clone());
}
let mut normalized = body.clone();
let normalized_object = normalized
.as_object_mut()
.expect("Responses request body object was checked above");
let normalized_input = normalized_object
.get_mut("input")
.and_then(Value::as_array_mut)
.expect("Responses request input array was checked above");
let additional_tools = normalized_input.drain(..additional_tools_count);
let mut tools = Vec::new();
for additional_tools in additional_tools {
tools.extend(
additional_tools["tools"]
.as_array()
.expect("additional_tools item was checked above")
.iter()
.cloned(),
);
}
if let Some(existing_tools) = normalized_object.get("tools").and_then(Value::as_array) {
tools.extend(existing_tools.iter().cloned());
}
normalized_object.insert("tools".to_string(), Value::Array(tools));
Ok(normalized)
}
fn is_openai_responses_additional_tools_item(value: &Value) -> bool {
let Some(object) = value.as_object() else {
return false;
};
object
.get("type")
.and_then(Value::as_str)
.is_some_and(|item_type| item_type.eq_ignore_ascii_case("additional_tools"))
&& object.get("role").and_then(Value::as_str) == Some("developer")
&& object.get("tools").is_some_and(Value::is_array)
&& object
.keys()
.all(|key| matches!(key.as_str(), "type" | "role" | "tools"))
}
fn validate_runtime_request_conversion(
source: FormatId,
target: FormatId,
@@ -184,6 +263,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 +517,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 +551,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,
@@ -1498,8 +1607,11 @@ fn validate_request_extension_namespace(
});
};
for key in object.keys() {
if request_extension_key_is_cross_format_safe(source, target, location, namespace, key)
{
if openai_responses_custom_tool_key_is_cross_format_safe(
source, target, location, namespace, object, key,
) || request_extension_key_is_cross_format_safe(
source, target, location, namespace, key,
) {
continue;
}
return Err(FormatError::LossyConversionBlocked {
@@ -1514,6 +1626,29 @@ fn validate_request_extension_namespace(
Ok(())
}
fn openai_responses_custom_tool_key_is_cross_format_safe(
source: FormatId,
target: FormatId,
location: &str,
namespace: &str,
extension: &Map<String, Value>,
key: &str,
) -> bool {
matches!(
(source, target, location, namespace),
(
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact,
FormatId::OpenAiChat,
"tools[]",
"openai_responses" | "openai_cli"
)
) && extension
.get("type")
.and_then(Value::as_str)
.is_some_and(|tool_type| tool_type.eq_ignore_ascii_case("custom"))
&& matches!(key, "type" | "name" | "description" | "format" | "custom")
}
fn request_extension_key_is_cross_format_safe(
source: FormatId,
target: FormatId,
@@ -2579,7 +2714,7 @@ fn validate_openai_responses_to_chat(
.unwrap_or("function")
.trim()
.to_ascii_lowercase();
if !matches!(tool_type.as_str(), "function" | "namespace") {
if !matches!(tool_type.as_str(), "function" | "custom" | "namespace") {
return Err(FormatError::LossyConversionBlocked {
source_format: FormatId::OpenAiResponses.as_str().to_string(),
target_format: FormatId::OpenAiChat.as_str().to_string(),
@@ -3459,6 +3594,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!({
@@ -5114,6 +5280,130 @@ mod tests {
));
}
#[test]
fn openai_responses_additional_tools_prefix_maps_to_chat_tools() {
let body = json!({
"model": "gpt-5.6-sol",
"input": [
{
"type": "additional_tools",
"role": "developer",
"tools": [{
"type": "function",
"name": "lookup",
"description": "Look up a value",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
},
"strict": true
}, {
"type": "custom",
"name": "shell_command",
"description": "Run a shell command",
"format": {"type": "text"}
}]
},
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}
],
"tools": [{
"type": "function",
"name": "existing",
"parameters": {"type": "object"}
}]
});
for converted in [
convert_request_pure("openai:responses", "openai:chat", &body)
.expect("pure conversion should map additional tools")
.value,
convert_request(
"openai:responses",
"openai:chat",
&body,
&FormatContext::default(),
)
.expect("runtime conversion should map additional tools"),
] {
assert_eq!(converted["tools"][0]["type"], "function");
assert_eq!(converted["tools"][0]["function"]["name"], "lookup");
assert_eq!(converted["tools"][0]["function"]["strict"], true);
assert_eq!(converted["tools"][1]["type"], "custom");
assert_eq!(converted["tools"][1]["custom"]["name"], "shell_command");
assert_eq!(converted["tools"][2]["function"]["name"], "existing");
assert_eq!(converted["messages"][0]["role"], "user");
assert_eq!(converted["messages"].as_array().map(Vec::len), Some(1));
}
}
#[test]
fn openai_responses_additional_tools_prefix_rejects_unmapped_tool() {
let body = json!({
"model": "gpt-5.6-sol",
"input": [{
"type": "additional_tools",
"role": "developer",
"tools": [{"type": "tool_search", "execution": "client"}]
}]
});
let error = convert_request_pure("openai:responses", "openai:chat", &body)
.expect_err("Chat cannot represent client tool_search");
assert!(matches!(
error,
super::FormatError::LossyConversionBlocked { ref field, .. }
if field == "tools"
));
}
#[test]
fn openai_responses_additional_tools_prefix_rejects_unknown_fields() {
let body = json!({
"model": "gpt-5.6-sol",
"input": [{
"type": "additional_tools",
"role": "developer",
"tools": [],
"future_field": true
}]
});
let error = convert_request_pure("openai:responses", "openai:chat", &body)
.expect_err("unknown additional_tools fields must not be dropped");
assert!(matches!(
error,
super::FormatError::LossyConversionBlocked { ref field, .. }
if field == "input[0]"
));
}
#[test]
fn openai_responses_additional_tools_is_only_consumed_as_a_leading_prefix() {
let body = json!({
"model": "gpt-5.6-sol",
"input": [
{"type": "message", "role": "user", "content": "hello"},
{
"type": "additional_tools",
"role": "developer",
"tools": [{"type": "function", "name": "lookup", "parameters": {}}]
}
]
});
let error = convert_request_pure("openai:responses", "openai:chat", &body)
.expect_err("additional_tools after conversation history must remain unsupported");
assert!(matches!(
error,
super::FormatError::LossyConversionBlocked { ref field, .. }
if field == "input[1]"
));
}
#[test]
fn runtime_openai_responses_cross_format_rejects_unknown_content_block() {
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()
@@ -1783,7 +1783,7 @@ mod tests {
let converted = build_standard_request_body(
&request,
"claude:messages",
"gemini-2.5-pro",
"gemini-3-flash-preview",
"google",
"gemini:generate_content",
"/v1/messages",
@@ -2029,4 +2029,52 @@ mod tests {
"surface conversion should preserve the Claude tool schema before transport envelopes"
);
}
#[test]
fn openai_responses_builtin_and_function_tools_enable_gemini_server_invocations() {
let request = json!({
"model": "gpt-5",
"input": "Search first, then save the result.",
"tools": [
{"type": "web_search_preview"},
{
"type": "function",
"name": "save_result",
"description": "Save a search result",
"parameters": {
"type": "object",
"properties": {
"result": {"type": "string"}
},
"required": ["result"]
}
}
],
"tool_choice": "required"
});
let gemini = build_standard_request_body(
&request,
"openai:responses",
"gemini-3-flash-preview",
"google",
"gemini:generate_content",
"/v1/responses",
true,
None,
None,
)
.expect("openai responses should convert to gemini generate content");
assert_eq!(gemini["tools"][0]["googleSearch"], json!({}));
assert_eq!(
gemini["tools"][1]["functionDeclarations"][0]["name"],
"save_result"
);
assert_eq!(
gemini["toolConfig"]["includeServerSideToolInvocations"],
true
);
assert_eq!(gemini["toolConfig"]["functionCallingConfig"]["mode"], "ANY");
}
}
@@ -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"