From a95422f4d1258364c70b5eaaddd611858219a7a1 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sun, 4 Jan 2026 17:06:49 +0000 Subject: [PATCH 1/3] fix(tools): sanitize inputs Signed-off-by: Ettore Di Giacinto --- core/http/endpoints/openai/chat.go | 4 +- core/http/endpoints/openai/inference.go | 5 +- pkg/functions/functions.go | 54 +++++++ pkg/functions/functions_test.go | 198 ++++++++++++++++++++++++ 4 files changed, 259 insertions(+), 2 deletions(-) diff --git a/core/http/endpoints/openai/chat.go b/core/http/endpoints/openai/chat.go index dd27edcb9ac7..3af9ebd2d830 100644 --- a/core/http/endpoints/openai/chat.go +++ b/core/http/endpoints/openai/chat.go @@ -622,7 +622,9 @@ func handleQuestion(config *config.ModelConfig, cl *config.ModelConfigLoader, in // Serialize tools and tool_choice to JSON strings toolsJSON := "" if len(input.Tools) > 0 { - toolsBytes, err := json.Marshal(input.Tools) + // Sanitize tools to remove null values from parameters.properties + sanitizedTools := functions.SanitizeTools(input.Tools) + toolsBytes, err := json.Marshal(sanitizedTools) if err == nil { toolsJSON = string(toolsBytes) } diff --git a/core/http/endpoints/openai/inference.go b/core/http/endpoints/openai/inference.go index 37b14c98bcfa..7db7707ed0a2 100644 --- a/core/http/endpoints/openai/inference.go +++ b/core/http/endpoints/openai/inference.go @@ -7,6 +7,7 @@ import ( "github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/pkg/functions" model "github.com/mudler/LocalAI/pkg/model" ) @@ -42,7 +43,9 @@ func ComputeChoices( // Serialize tools and tool_choice to JSON strings toolsJSON := "" if len(req.Tools) > 0 { - toolsBytes, err := json.Marshal(req.Tools) + // Sanitize tools to remove null values from parameters.properties + sanitizedTools := functions.SanitizeTools(req.Tools) + toolsBytes, err := json.Marshal(sanitizedTools) if err == nil { toolsJSON = string(toolsBytes) } diff --git a/pkg/functions/functions.go b/pkg/functions/functions.go index b76d1d0b06c4..f4a90636e7fe 100644 --- a/pkg/functions/functions.go +++ b/pkg/functions/functions.go @@ -102,3 +102,57 @@ func (f Functions) Select(name string) Functions { return funcs } + +// SanitizeTools removes null values from tool.parameters.properties and converts them to empty objects. +// This prevents Jinja template errors when processing tools with malformed parameter schemas. +func SanitizeTools(tools Tools) Tools { + if len(tools) == 0 { + return tools + } + + sanitized := make(Tools, 0, len(tools)) + for _, tool := range tools { + // Create a copy of the tool to avoid modifying the original + sanitizedTool := Tool{ + Type: tool.Type, + Function: tool.Function, + } + + // Check if the tool has parameters + if sanitizedTool.Function.Parameters != nil { + // Check if parameters has a "properties" field + if properties, ok := sanitizedTool.Function.Parameters["properties"]; ok { + // Try to cast properties to a map + if propertiesMap, ok := properties.(map[string]interface{}); ok { + // Create a new map for sanitized properties + sanitizedProperties := make(map[string]interface{}) + hasNullValues := false + + // Iterate through properties and remove null values + for key, value := range propertiesMap { + if value == nil { + // Convert null to empty object + sanitizedProperties[key] = map[string]interface{}{} + hasNullValues = true + xlog.Warn("Found null value in tool parameter properties, converting to empty object", + "tool", sanitizedTool.Function.Name, + "parameter", key) + } else { + // Preserve valid values + sanitizedProperties[key] = value + } + } + + // Update the properties if we found null values + if hasNullValues { + sanitizedTool.Function.Parameters["properties"] = sanitizedProperties + } + } + } + } + + sanitized = append(sanitized, sanitizedTool) + } + + return sanitized +} diff --git a/pkg/functions/functions_test.go b/pkg/functions/functions_test.go index 2eb0946a6e8e..c82271bc9c02 100644 --- a/pkg/functions/functions_test.go +++ b/pkg/functions/functions_test.go @@ -82,4 +82,202 @@ var _ = Describe("LocalAI grammar functions", func() { Expect(functions[0].Name).To(Equal("create_event")) }) }) + Context("SanitizeTools()", func() { + It("returns empty slice when input is empty", func() { + tools := Tools{} + sanitized := SanitizeTools(tools) + Expect(len(sanitized)).To(Equal(0)) + }) + + It("converts null values in parameters.properties to empty objects", func() { + tools := Tools{ + { + Type: "function", + Function: Function{ + Name: "test_function", + Description: "A test function", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "valid_param": map[string]interface{}{ + "type": "string", + }, + "null_param": nil, + "another_valid": map[string]interface{}{ + "type": "integer", + }, + }, + }, + }, + }, + } + + sanitized := SanitizeTools(tools) + Expect(len(sanitized)).To(Equal(1)) + Expect(sanitized[0].Function.Name).To(Equal("test_function")) + + properties := sanitized[0].Function.Parameters["properties"].(map[string]interface{}) + Expect(properties["valid_param"]).NotTo(BeNil()) + Expect(properties["null_param"]).NotTo(BeNil()) + Expect(properties["null_param"]).To(Equal(map[string]interface{}{})) + Expect(properties["another_valid"]).NotTo(BeNil()) + }) + + It("preserves valid parameter structures unchanged", func() { + tools := Tools{ + { + Type: "function", + Function: Function{ + Name: "valid_function", + Description: "A function with valid parameters", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "param1": map[string]interface{}{ + "type": "string", + "description": "First parameter", + }, + "param2": map[string]interface{}{ + "type": "integer", + }, + }, + }, + }, + }, + } + + sanitized := SanitizeTools(tools) + Expect(len(sanitized)).To(Equal(1)) + Expect(sanitized[0].Function.Name).To(Equal("valid_function")) + + properties := sanitized[0].Function.Parameters["properties"].(map[string]interface{}) + Expect(properties["param1"].(map[string]interface{})["type"]).To(Equal("string")) + Expect(properties["param1"].(map[string]interface{})["description"]).To(Equal("First parameter")) + Expect(properties["param2"].(map[string]interface{})["type"]).To(Equal("integer")) + }) + + It("handles tools without parameters field", func() { + tools := Tools{ + { + Type: "function", + Function: Function{ + Name: "no_params_function", + Description: "A function without parameters", + }, + }, + } + + sanitized := SanitizeTools(tools) + Expect(len(sanitized)).To(Equal(1)) + Expect(sanitized[0].Function.Name).To(Equal("no_params_function")) + Expect(sanitized[0].Function.Parameters).To(BeNil()) + }) + + It("handles tools without properties field", func() { + tools := Tools{ + { + Type: "function", + Function: Function{ + Name: "no_properties_function", + Description: "A function without properties", + Parameters: map[string]interface{}{ + "type": "object", + }, + }, + }, + } + + sanitized := SanitizeTools(tools) + Expect(len(sanitized)).To(Equal(1)) + Expect(sanitized[0].Function.Name).To(Equal("no_properties_function")) + Expect(sanitized[0].Function.Parameters["type"]).To(Equal("object")) + }) + + It("handles multiple tools with mixed valid and null values", func() { + tools := Tools{ + { + Type: "function", + Function: Function{ + Name: "function_with_nulls", + Parameters: map[string]interface{}{ + "properties": map[string]interface{}{ + "valid": map[string]interface{}{ + "type": "string", + }, + "null1": nil, + "null2": nil, + }, + }, + }, + }, + { + Type: "function", + Function: Function{ + Name: "function_all_valid", + Parameters: map[string]interface{}{ + "properties": map[string]interface{}{ + "param1": map[string]interface{}{ + "type": "string", + }, + "param2": map[string]interface{}{ + "type": "integer", + }, + }, + }, + }, + }, + { + Type: "function", + Function: Function{ + Name: "function_no_params", + }, + }, + } + + sanitized := SanitizeTools(tools) + Expect(len(sanitized)).To(Equal(3)) + + // First tool should have nulls converted to empty objects + props1 := sanitized[0].Function.Parameters["properties"].(map[string]interface{}) + Expect(props1["valid"]).NotTo(BeNil()) + Expect(props1["null1"]).To(Equal(map[string]interface{}{})) + Expect(props1["null2"]).To(Equal(map[string]interface{}{})) + + // Second tool should remain unchanged + props2 := sanitized[1].Function.Parameters["properties"].(map[string]interface{}) + Expect(props2["param1"].(map[string]interface{})["type"]).To(Equal("string")) + Expect(props2["param2"].(map[string]interface{})["type"]).To(Equal("integer")) + + // Third tool should remain unchanged + Expect(sanitized[2].Function.Parameters).To(BeNil()) + }) + + It("does not modify the original tools slice", func() { + tools := Tools{ + { + Type: "function", + Function: Function{ + Name: "test_function", + Parameters: map[string]interface{}{ + "properties": map[string]interface{}{ + "null_param": nil, + }, + }, + }, + }, + } + + originalProperties := tools[0].Function.Parameters["properties"].(map[string]interface{}) + originalNullValue := originalProperties["null_param"] + + sanitized := SanitizeTools(tools) + + // Original should still have nil + Expect(originalNullValue).To(BeNil()) + + // Sanitized should have empty object + sanitizedProperties := sanitized[0].Function.Parameters["properties"].(map[string]interface{}) + Expect(sanitizedProperties["null_param"]).To(Equal(map[string]interface{}{})) + }) + }) }) From e0e904ff98dfab5e2c78263ac91f3cf529f9ec62 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sun, 4 Jan 2026 17:40:48 +0000 Subject: [PATCH 2/3] fixups Signed-off-by: Ettore Di Giacinto --- pkg/functions/functions.go | 73 +++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/pkg/functions/functions.go b/pkg/functions/functions.go index f4a90636e7fe..c1360b9944da 100644 --- a/pkg/functions/functions.go +++ b/pkg/functions/functions.go @@ -110,43 +110,60 @@ func SanitizeTools(tools Tools) Tools { return tools } + xlog.Debug("SanitizeTools: processing tools", "count", len(tools)) sanitized := make(Tools, 0, len(tools)) for _, tool := range tools { // Create a copy of the tool to avoid modifying the original sanitizedTool := Tool{ - Type: tool.Type, - Function: tool.Function, + Type: tool.Type, + Function: Function{ + Name: tool.Function.Name, + Description: tool.Function.Description, + Strict: tool.Function.Strict, + }, } - // Check if the tool has parameters - if sanitizedTool.Function.Parameters != nil { - // Check if parameters has a "properties" field - if properties, ok := sanitizedTool.Function.Parameters["properties"]; ok { - // Try to cast properties to a map - if propertiesMap, ok := properties.(map[string]interface{}); ok { - // Create a new map for sanitized properties - sanitizedProperties := make(map[string]interface{}) - hasNullValues := false - - // Iterate through properties and remove null values - for key, value := range propertiesMap { - if value == nil { - // Convert null to empty object - sanitizedProperties[key] = map[string]interface{}{} - hasNullValues = true - xlog.Warn("Found null value in tool parameter properties, converting to empty object", - "tool", sanitizedTool.Function.Name, - "parameter", key) - } else { - // Preserve valid values - sanitizedProperties[key] = value + // Deep copy and sanitize parameters + if tool.Function.Parameters != nil { + // Create a new Parameters map + sanitizedTool.Function.Parameters = make(map[string]interface{}) + + // Copy all parameters, sanitizing properties if present + for key, value := range tool.Function.Parameters { + if key == "properties" { + // Special handling for properties - sanitize null values + if propertiesMap, ok := value.(map[string]interface{}); ok { + // Create a new map for sanitized properties + sanitizedProperties := make(map[string]interface{}) + + // Iterate through properties and convert null values to empty objects + for propKey, propValue := range propertiesMap { + // Check for nil/null values (handles both Go nil and JSON null) + if propValue == nil { + // Convert null to empty object to prevent Jinja template errors + sanitizedProperties[propKey] = map[string]interface{}{} + xlog.Warn("Found null value in tool parameter properties, converting to empty object", + "tool", sanitizedTool.Function.Name, + "parameter", propKey) + } else { + // Check if value is a map/object - if so, ensure it's not null + if propValueMap, ok := propValue.(map[string]interface{}); ok { + // It's already a valid map, preserve it + sanitizedProperties[propKey] = propValueMap + } else { + // Preserve other valid values (strings, numbers, arrays, etc.) + sanitizedProperties[propKey] = propValue + } + } } - } - - // Update the properties if we found null values - if hasNullValues { sanitizedTool.Function.Parameters["properties"] = sanitizedProperties + } else { + // If properties is not a map, preserve as-is + sanitizedTool.Function.Parameters[key] = value } + } else { + // Copy other parameters as-is + sanitizedTool.Function.Parameters[key] = value } } } From 93d3e4257a59092294b38df8b605c793628f2d3a Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sun, 4 Jan 2026 18:00:14 +0000 Subject: [PATCH 3/3] recursive Signed-off-by: Ettore Di Giacinto --- backend/cpp/llama-cpp/grpc-server.cpp | 2 + pkg/functions/functions.go | 128 +++++++++++++++----------- 2 files changed, 75 insertions(+), 55 deletions(-) diff --git a/backend/cpp/llama-cpp/grpc-server.cpp b/backend/cpp/llama-cpp/grpc-server.cpp index 1009d36fd7df..24d2f7ed2925 100644 --- a/backend/cpp/llama-cpp/grpc-server.cpp +++ b/backend/cpp/llama-cpp/grpc-server.cpp @@ -293,6 +293,8 @@ json parse_options(bool streaming, const backend::PredictOptions* predict, const return data; } +// Sanitize tools JSON to remove null values from tool.parameters.properties +// This prevents Jinja template errors when processing tools with malformed parameter schemas const std::vector kv_cache_types = { GGML_TYPE_F32, diff --git a/pkg/functions/functions.go b/pkg/functions/functions.go index c1360b9944da..9152d4517046 100644 --- a/pkg/functions/functions.go +++ b/pkg/functions/functions.go @@ -2,6 +2,7 @@ package functions import ( "encoding/json" + "fmt" "github.com/mudler/xlog" ) @@ -103,72 +104,89 @@ func (f Functions) Select(name string) Functions { return funcs } +// sanitizeValue recursively sanitizes null values in a JSON structure, converting them to empty objects. +// It handles maps, slices, and nested structures. +func sanitizeValue(value interface{}, path string) interface{} { + if value == nil { + // Convert null to empty object + xlog.Debug("SanitizeTools: found null value, converting to empty object", "path", path) + return map[string]interface{}{} + } + + switch v := value.(type) { + case map[string]interface{}: + // Recursively sanitize map values + sanitized := make(map[string]interface{}) + for key, val := range v { + newPath := path + if newPath != "" { + newPath += "." + } + newPath += key + sanitized[key] = sanitizeValue(val, newPath) + } + return sanitized + + case []interface{}: + // Recursively sanitize slice elements + sanitized := make([]interface{}, len(v)) + for i, val := range v { + newPath := fmt.Sprintf("%s[%d]", path, i) + sanitized[i] = sanitizeValue(val, newPath) + } + return sanitized + + default: + // For primitive types (string, number, bool), return as-is + return value + } +} + // SanitizeTools removes null values from tool.parameters.properties and converts them to empty objects. // This prevents Jinja template errors when processing tools with malformed parameter schemas. +// It works by marshaling to JSON, recursively sanitizing the JSON structure, and unmarshaling back. func SanitizeTools(tools Tools) Tools { if len(tools) == 0 { return tools } xlog.Debug("SanitizeTools: processing tools", "count", len(tools)) - sanitized := make(Tools, 0, len(tools)) - for _, tool := range tools { - // Create a copy of the tool to avoid modifying the original - sanitizedTool := Tool{ - Type: tool.Type, - Function: Function{ - Name: tool.Function.Name, - Description: tool.Function.Description, - Strict: tool.Function.Strict, - }, - } - // Deep copy and sanitize parameters - if tool.Function.Parameters != nil { - // Create a new Parameters map - sanitizedTool.Function.Parameters = make(map[string]interface{}) - - // Copy all parameters, sanitizing properties if present - for key, value := range tool.Function.Parameters { - if key == "properties" { - // Special handling for properties - sanitize null values - if propertiesMap, ok := value.(map[string]interface{}); ok { - // Create a new map for sanitized properties - sanitizedProperties := make(map[string]interface{}) - - // Iterate through properties and convert null values to empty objects - for propKey, propValue := range propertiesMap { - // Check for nil/null values (handles both Go nil and JSON null) - if propValue == nil { - // Convert null to empty object to prevent Jinja template errors - sanitizedProperties[propKey] = map[string]interface{}{} - xlog.Warn("Found null value in tool parameter properties, converting to empty object", - "tool", sanitizedTool.Function.Name, - "parameter", propKey) - } else { - // Check if value is a map/object - if so, ensure it's not null - if propValueMap, ok := propValue.(map[string]interface{}); ok { - // It's already a valid map, preserve it - sanitizedProperties[propKey] = propValueMap - } else { - // Preserve other valid values (strings, numbers, arrays, etc.) - sanitizedProperties[propKey] = propValue - } - } - } - sanitizedTool.Function.Parameters["properties"] = sanitizedProperties - } else { - // If properties is not a map, preserve as-is - sanitizedTool.Function.Parameters[key] = value - } - } else { - // Copy other parameters as-is - sanitizedTool.Function.Parameters[key] = value - } - } + // Marshal to JSON to work with the actual JSON representation + toolsJSON, err := json.Marshal(tools) + if err != nil { + xlog.Warn("SanitizeTools: failed to marshal tools to JSON", "error", err) + return tools + } + + // Parse JSON into a generic structure + var toolsData []map[string]interface{} + if err := json.Unmarshal(toolsJSON, &toolsData); err != nil { + xlog.Warn("SanitizeTools: failed to unmarshal tools JSON", "error", err) + return tools + } + + // Recursively sanitize the JSON structure + for i, tool := range toolsData { + if function, ok := tool["function"].(map[string]interface{}); ok { + // Recursively sanitize the entire tool structure + tool["function"] = sanitizeValue(function, fmt.Sprintf("tools[%d].function", i)) } + toolsData[i] = tool + } - sanitized = append(sanitized, sanitizedTool) + // Marshal back to JSON + sanitizedJSON, err := json.Marshal(toolsData) + if err != nil { + xlog.Warn("SanitizeTools: failed to marshal sanitized tools", "error", err) + return tools + } + + // Unmarshal back into Tools structure + var sanitized Tools + if err := json.Unmarshal(sanitizedJSON, &sanitized); err != nil { + xlog.Warn("SanitizeTools: failed to unmarshal sanitized tools", "error", err) + return tools } return sanitized