fix: strip additionalProperties from google schemas (#8964)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-04-28 21:59:45 +02:00
committed by GitHub
parent 70b90c41dc
commit 77d9a53423
3 changed files with 278 additions and 51 deletions
+165 -2
View File
@@ -651,14 +651,54 @@ pub fn sanitize_schema_for_google(value: &mut serde_json::Value) {
"exclusiveMaximum",
"const",
"multipleOf",
"unevaluatedItems",
"unevaluatedProperties",
];
const SINGLE_SCHEMA_FIELDS: &[&str] = &[
"items",
"additionalItems",
"contains",
"not",
"if",
"then",
"else",
"contentSchema",
];
const ARRAY_SCHEMA_FIELDS: &[&str] = &["oneOf", "anyOf", "allOf", "prefixItems"];
const MAP_SCHEMA_FIELDS: &[&str] = &[
"properties",
"patternProperties",
"$defs",
"definitions",
"dependentSchemas",
"dependencies",
];
if let Some(obj) = value.as_object_mut() {
for field in UNSUPPORTED {
obj.remove(*field);
}
for v in obj.values_mut() {
sanitize_schema_for_google(v);
for &key in SINGLE_SCHEMA_FIELDS {
if let Some(schema) = obj.get_mut(key) {
sanitize_schema_for_google(schema);
}
}
for &key in ARRAY_SCHEMA_FIELDS {
if let Some(schemas) = obj.get_mut(key).and_then(serde_json::Value::as_array_mut) {
for schema in schemas {
sanitize_schema_for_google(schema);
}
}
}
for &key in MAP_SCHEMA_FIELDS {
if let Some(schemas) = obj.get_mut(key).and_then(serde_json::Value::as_object_mut) {
for schema in schemas.values_mut() {
sanitize_schema_for_google(schema);
}
}
}
} else if let Some(arr) = value.as_array_mut() {
for v in arr.iter_mut() {
@@ -834,4 +874,127 @@ mod tests {
assert!(schema["properties"]["staticInputs"]["propertyNames"].is_null());
assert!(schema["properties"]["staticInputs"]["additionalProperties"].is_null());
}
#[test]
fn sanitize_schema_for_google_preserves_keyword_property_names() {
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"default": {
"type": "string",
"default": "fallback"
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"const": {
"type": "string",
"const": "value"
}
},
"required": ["const"]
}
},
"required": ["default", "additionalProperties"]
});
sanitize_schema_for_google(&mut schema);
assert!(schema["properties"]["default"].is_object());
assert_eq!(schema["properties"]["default"]["type"], "string");
assert!(schema["properties"]["default"]["default"].is_null());
assert!(schema["properties"]["additionalProperties"].is_object());
assert!(schema["properties"]["additionalProperties"]["additionalProperties"].is_null());
assert!(schema["properties"]["additionalProperties"]["properties"]["const"].is_object());
assert!(
schema["properties"]["additionalProperties"]["properties"]["const"]["const"].is_null()
);
assert_eq!(
schema["required"],
serde_json::json!(["default", "additionalProperties"])
);
}
#[test]
fn sanitize_schema_for_google_recurses_through_schema_containers() {
let mut schema = serde_json::json!({
"type": "object",
"not": {
"type": "object",
"additionalProperties": false,
"properties": {
"default": {
"type": "string",
"default": "fallback"
}
}
},
"if": {
"type": "object",
"const": { "kind": "a" }
},
"then": {
"type": "object",
"properties": {
"value": {
"type": "number",
"multipleOf": 2
}
}
},
"prefixItems": [
{
"type": "string",
"default": "first"
}
],
"contentSchema": {
"type": "object",
"default": {}
},
"unevaluatedItems": {
"type": "string",
"default": "item"
},
"unevaluatedProperties": false,
"patternProperties": {
"^x-": {
"type": "string",
"const": "fixed"
}
},
"dependentSchemas": {
"credit_card": {
"type": "object",
"additionalProperties": false
}
},
"dependencies": {
"billing_address": {
"type": "object",
"default": {}
}
}
});
sanitize_schema_for_google(&mut schema);
assert!(schema["not"]["additionalProperties"].is_null());
assert!(schema["not"]["properties"]["default"].is_object());
assert!(schema["not"]["properties"]["default"]["default"].is_null());
assert!(schema["if"]["const"].is_null());
assert!(schema["then"]["properties"]["value"]["multipleOf"].is_null());
assert!(schema["prefixItems"][0]["default"].is_null());
assert!(schema["contentSchema"]["default"].is_null());
assert!(schema["unevaluatedItems"].is_null());
assert!(schema["unevaluatedProperties"].is_null());
assert!(schema["patternProperties"]["^x-"].is_object());
assert!(schema["patternProperties"]["^x-"]["const"].is_null());
assert!(schema["dependentSchemas"]["credit_card"].is_object());
assert!(schema["dependentSchemas"]["credit_card"]["additionalProperties"].is_null());
assert!(schema["dependencies"]["billing_address"].is_object());
assert!(schema["dependencies"]["billing_address"]["default"].is_null());
}
}
+52 -48
View File
@@ -15,13 +15,11 @@ pub struct McpToolSource {
pub resource_path: String,
}
use crate::ai_providers::{empty_string_as_none, AIProvider};
use windmill_common::{
db::DB,
error::Error,
flow_status::AgentAction,
flows::FlowModule,
use crate::{
ai_google::sanitize_schema_for_google,
ai_providers::{empty_string_as_none, AIProvider},
};
use windmill_common::{db::DB, error::Error, flow_status::AgentAction, flows::FlowModule};
use windmill_parser::Typ;
use windmill_types::s3::S3Object;
@@ -802,51 +800,20 @@ impl OpenAPISchema {
/// Sanitizes this schema for Google AI's API by removing unsupported fields.
/// See https://github.com/windmill-labs/windmill/issues/7759
pub fn sanitize_for_google(&mut self) {
self.schema_url = None;
self.default = None;
self.exclusive_minimum = None;
self.exclusive_maximum = None;
self.r#const = None;
self.multiple_of = None;
// Recursively sanitize nested schemas
if let Some(ref mut items) = self.items {
items.sanitize_for_google();
}
if let Some(ref mut properties) = self.properties {
for prop in properties.values_mut() {
prop.sanitize_for_google();
let mut schema_value = match serde_json::to_value(&*self) {
Ok(value) => value,
Err(err) => {
tracing::error!("Failed to serialize OpenAPISchema for Google AI: {err}");
return;
}
}
};
if let Some(ref mut one_of) = self.one_of {
for schema in one_of.iter_mut() {
schema.sanitize_for_google();
}
}
sanitize_schema_for_google(&mut schema_value);
if let Some(ref mut any_of) = self.any_of {
for schema in any_of.iter_mut() {
schema.sanitize_for_google();
}
}
if let Some(ref mut all_of) = self.all_of {
for schema in all_of.iter_mut() {
schema.sanitize_for_google();
}
}
if let Some(ref mut defs) = self.defs {
for schema in defs.values_mut() {
schema.sanitize_for_google();
}
}
if let Some(ref mut definitions) = self.definitions {
for schema in definitions.values_mut() {
schema.sanitize_for_google();
match serde_json::from_value(schema_value) {
Ok(schema) => *self = schema,
Err(err) => {
tracing::error!("Failed to deserialize sanitized Google AI OpenAPISchema: {err}");
}
}
}
@@ -1610,6 +1577,43 @@ mod tests {
assert!(schema.multiple_of.is_none(), "multipleOf should be removed");
}
#[test]
fn test_sanitize_for_google_removes_additional_properties() {
let nested = OpenAPISchema {
r#type: Some(SchemaType::Single("object".to_string())),
additional_properties: Some(AdditionalProperties::Bool(true)),
..Default::default()
};
let mut schema = OpenAPISchema {
r#type: Some(SchemaType::Single("object".to_string())),
additional_properties: Some(AdditionalProperties::Schema(Box::new(OpenAPISchema {
r#type: Some(SchemaType::Single("string".to_string())),
default: Some(serde_json::json!("fallback")),
..Default::default()
}))),
properties: Some(
vec![("nested".to_string(), Box::new(nested))]
.into_iter()
.collect(),
),
..Default::default()
};
schema.sanitize_for_google();
assert!(
schema.additional_properties.is_none(),
"root additionalProperties should be removed"
);
let nested = schema.properties.as_ref().unwrap().get("nested").unwrap();
assert!(
nested.additional_properties.is_none(),
"nested additionalProperties should be removed"
);
}
#[test]
fn test_sanitize_for_google_removes_unsupported_fields_recursively() {
let nested = OpenAPISchema {
@@ -11,7 +11,7 @@ Tests AI agent tool calling with different tool types:
import pytest
from .conftest import AIAgentTestClient, create_ai_agent_flow, create_rawscript_tool, create_script_tool
from .providers import ALL_PROVIDERS, ANTHROPIC, GOOGLE_AI, OPENAI
from .providers import ALL_PROVIDERS, ANTHROPIC, GOOGLE_AI, OPENAI, make_provider_input_transform
def get_provider_ids(providers: list) -> list[str]:
@@ -26,6 +26,15 @@ export function main(a: number, b: number): number {
}
"""
GOOGLE_AI_GEMINI_3 = {
"name": "google_ai_gemini_3",
"input_transform": make_provider_input_transform(
kind="googleai",
model="gemini-3-flash-preview",
resource_path="u/admin/googleai",
),
}
class TestToolCalling:
"""Test AI agent tool calling with different tool types."""
@@ -110,6 +119,57 @@ class TestToolCalling:
assert "23" in result_str, f"Expected '23' in result: {result}"
print(f"Workspace script tool result from {provider_config['name']}: {result}")
def test_nested_ai_agent_tool_with_gemini_3(
self,
client: AIAgentTestClient,
setup_providers,
):
"""
Test that a Gemini agent can call another Gemini AI agent as a tool.
"""
nested_ai_agent_tool = {
"id": "delegate_agent",
"summary": "delegate_agent",
"value": {
"tool_type": "flowmodule",
"type": "aiagent",
"input_transforms": {
"provider": GOOGLE_AI_GEMINI_3["input_transform"],
"system_prompt": {
"type": "static",
"value": "You are a concise arithmetic helper. Return only the numeric answer.",
},
"user_message": {"type": "ai"},
"output_type": {"type": "static", "value": "text"},
},
"tools": [],
},
}
flow_value = create_ai_agent_flow(
provider_input_transform=GOOGLE_AI_GEMINI_3["input_transform"],
system_prompt="You are a coordinator. Use delegate_agent for arithmetic before answering.",
tools=[nested_ai_agent_tool],
output_type="text",
)
result = client.run_preview_flow(
flow_value=flow_value,
args={"user_message": "Ask delegate_agent what 13 + 29 is, then tell me the result."},
)
assert result is not None
result_str = str(result)
assert "42" in result_str, f"Expected '42' in result: {result}"
messages = result.get("messages", [])
assert any(
tool_call.get("function", {}).get("name") == "delegate_agent"
for message in messages
for tool_call in message.get("tool_calls", [])
), f"Expected delegate_agent tool call in messages: {messages}"
print(f"Nested AI agent tool result from {GOOGLE_AI_GEMINI_3['name']}: {result}")
@pytest.mark.parametrize(
"provider_config",
ALL_PROVIDERS,