From 36cb569e366f8afc6f5409d5f443419cda9df074 Mon Sep 17 00:00:00 2001 From: filip <44206832+filipkujawa@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:08:14 -0700 Subject: [PATCH] fix(providers): rewrite oneOf to anyOf in tool schemas for OpenAI-compatible backends (#10571) --- .../src/formats/databricks.rs | 48 +------- .../src/formats/openai.rs | 108 ++++++++++++++---- 2 files changed, 86 insertions(+), 70 deletions(-) diff --git a/crates/goose-provider-types/src/formats/databricks.rs b/crates/goose-provider-types/src/formats/databricks.rs index 8e9d5c2d0..cfffabaa8 100644 --- a/crates/goose-provider-types/src/formats/databricks.rs +++ b/crates/goose-provider-types/src/formats/databricks.rs @@ -7,7 +7,7 @@ use crate::model::ModelConfig; use crate::formats::openai::{ extract_reasoning_effort, is_openai_responses_model, is_valid_function_name, - openai_reasoning_effort_for_thinking, sanitize_function_name, + openai_reasoning_effort_for_thinking, sanitize_function_name, validate_tool_schemas, }; use crate::images::{convert_image, detect_image_path, load_image_file, ImageFormat}; use anyhow::{anyhow, Error}; @@ -560,52 +560,6 @@ pub fn apply_cache_control_for_claude(payload: &mut Value) { } } -/// Validates and fixes tool schemas to ensure they have proper parameter structure. -/// If parameters exist, ensures they have properties and required fields, or removes parameters entirely. -pub fn validate_tool_schemas(tools: &mut [Value]) { - for tool in tools.iter_mut() { - if let Some(function) = tool.get_mut("function") { - if let Some(parameters) = function.get_mut("parameters") { - if parameters.is_object() { - ensure_valid_json_schema(parameters); - } - } - } - } -} - -/// Ensures that the given JSON value follows the expected JSON Schema structure. -fn ensure_valid_json_schema(schema: &mut Value) { - if let Some(params_obj) = schema.as_object_mut() { - // Check if this is meant to be an object type schema - let is_object_type = params_obj - .get("type") - .and_then(|t| t.as_str()) - .is_none_or(|t| t == "object"); // Default to true if no type is specified - - // Only apply full schema validation to object types - if is_object_type { - // Ensure required fields exist with default values - params_obj.entry("properties").or_insert_with(|| json!({})); - params_obj.entry("required").or_insert_with(|| json!([])); - params_obj.entry("type").or_insert_with(|| json!("object")); - - // Recursively validate properties if it exists - if let Some(properties) = params_obj.get_mut("properties") { - if let Some(properties_obj) = properties.as_object_mut() { - for (_key, prop) in properties_obj.iter_mut() { - if prop.is_object() - && prop.get("type").and_then(|t| t.as_str()) == Some("object") - { - ensure_valid_json_schema(prop); - } - } - } - } - } - } -} - #[allow(clippy::too_many_lines)] pub fn create_request( model_config: &ModelConfig, diff --git a/crates/goose-provider-types/src/formats/openai.rs b/crates/goose-provider-types/src/formats/openai.rs index 5d46576e1..f03411b24 100644 --- a/crates/goose-provider-types/src/formats/openai.rs +++ b/crates/goose-provider-types/src/formats/openai.rs @@ -884,31 +884,55 @@ pub fn validate_tool_schemas(tools: &mut [Value]) { /// Ensures that the given JSON value follows the expected JSON Schema structure. fn ensure_valid_json_schema(schema: &mut Value) { if let Some(params_obj) = schema.as_object_mut() { - // Check if this is meant to be an object type schema - let is_object_type = params_obj - .get("type") - .and_then(|t| t.as_str()) - .is_none_or(|t| t == "object"); // Default to true if no type is specified + if !params_obj.contains_key("type") { + params_obj.insert("type".to_string(), json!("object")); + } + } + sanitize_schema_node(schema); +} - // Only apply full schema validation to object types - if is_object_type { - // Ensure required fields exist with default values - params_obj.entry("properties").or_insert_with(|| json!({})); - params_obj.entry("required").or_insert_with(|| json!([])); - params_obj.entry("type").or_insert_with(|| json!("object")); +fn sanitize_schema_node(node: &mut Value) { + if let Some(obj) = node.as_object_mut() { + // Moonshot's walle validator rejects `oneOf` behind a `$ref` as + // "infinite recursion" because its termination check only traverses + // `anyOf`. The two are interchangeable for tool-argument schemas, so + // emit the more widely supported form. + if !obj.contains_key("anyOf") { + if let Some(one_of) = obj.remove("oneOf") { + obj.insert("anyOf".to_string(), one_of); + } + } + } - // Recursively validate properties if it exists - if let Some(properties) = params_obj.get_mut("properties") { - if let Some(properties_obj) = properties.as_object_mut() { - for (_key, prop) in properties_obj.iter_mut() { - normalize_nullable(prop); - if prop.is_object() - && prop.get("type").and_then(|t| t.as_str()) == Some("object") - { - ensure_valid_json_schema(prop); - } - } - } + normalize_nullable(node); + + let Some(obj) = node.as_object_mut() else { + return; + }; + + if obj.get("type").and_then(|t| t.as_str()) == Some("object") { + obj.entry("properties").or_insert_with(|| json!({})); + obj.entry("required").or_insert_with(|| json!([])); + } + + for key in ["properties", "$defs", "definitions"] { + if let Some(children) = obj.get_mut(key).and_then(Value::as_object_mut) { + for child in children.values_mut() { + sanitize_schema_node(child); + } + } + } + for key in ["anyOf", "allOf", "prefixItems"] { + if let Some(children) = obj.get_mut(key).and_then(Value::as_array_mut) { + for child in children.iter_mut() { + sanitize_schema_node(child); + } + } + } + for key in ["items", "additionalProperties"] { + if let Some(child) = obj.get_mut(key) { + if child.is_object() { + sanitize_schema_node(child); } } } @@ -1710,6 +1734,44 @@ mod tests { assert!(!timeout_schema["type"].is_array()); } + #[test] + fn test_validate_tool_schemas_sanitizes_defs() { + let mut tools = vec![json!({ + "type": "function", + "function": { + "name": "cache", + "description": "manage cache", + "parameters": { + "type": "object", + "$defs": { + "CacheCommand": { + "oneOf": [ + { "description": "List cached files", "type": "string", "const": "list" }, + { "description": "Clear cached files", "type": "string", "const": "clear" } + ] + }, + "TextStyle": { + "type": "object", + "properties": { + "size": { "type": ["integer", "null"], "format": "int32" } + } + } + }, + "properties": { + "command": { "$ref": "#/$defs/CacheCommand" }, + "style": { "$ref": "#/$defs/TextStyle" } + }, + "required": ["command"] + } + } + })]; + validate_tool_schemas(&mut tools); + let defs = &tools[0]["function"]["parameters"]["$defs"]; + assert!(defs["CacheCommand"].get("oneOf").is_none()); + assert_eq!(defs["CacheCommand"]["anyOf"].as_array().unwrap().len(), 2); + assert_eq!(defs["TextStyle"]["properties"]["size"]["type"], "integer"); + } + const OPENAI_TOOL_USE_RESPONSE: &str = r#"{ "choices": [{ "role": "assistant",