fix(providers): rewrite oneOf to anyOf in tool schemas for OpenAI-compatible backends (#10571)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user