mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 04:30:20 +08:00
Merge branch 'main' of https://github.com/zhefox/Aether
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user