Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/cpp/llama-cpp/grpc-server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +296 to +297
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Orphaned comment with no associated implementation

These two comment lines describe sanitization logic that was never implemented in this file. They sit between the closing brace of parse_options and the kv_cache_types vector definition, unattached to any function. A future reader will look for the C++ implementation referenced by the comment and not find it, which could cause confusion about where null-sanitization actually occurs.


const std::vector<ggml_type> kv_cache_types = {
GGML_TYPE_F32,
Expand Down
4 changes: 3 additions & 1 deletion core/http/endpoints/openai/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robustness Medium

The error from json.Marshal(sanitizedTools) is silently ignored when err != nil, resulting in toolsJSON remaining an empty string and tools being silently dropped; the error should be logged.

Suggested fix
		toolsBytes, err := json.Marshal(sanitizedTools)
		if err != nil {
			log.Error("Failed to marshal sanitized tools", "error", err)
		} else {
			toolsJSON = string(toolsBytes)
		}
Prompt for AI assistance

Copy the prompt below and paste it into ChatGPT, Claude, or any LLM:

You are an expert go developer with deep knowledge of security, performance, and best practices.

### Context

File: core/http/endpoints/openai/chat.go
Lines: 627-627
Issue Type: robustness-medium
Severity: medium

Issue Description:
The error from `json.Marshal(sanitizedTools)` is silently ignored when `err != nil`, resulting in `toolsJSON` remaining an empty string and tools being silently dropped; the error should be logged.

Current Code:
		toolsBytes, err := json.Marshal(sanitizedTools)
		if err == nil {
			toolsJSON = string(toolsBytes)
		}

---

### Instructions

1. Fix the issue described above
2. Maintain the exact indentation and code style from the original
3. Follow go best practices and language-specific idioms
4. Ensure the fix addresses the root cause, not just the symptoms
5. Add brief inline comments explaining the fix if needed

### Constraints

- Do not change functionality beyond fixing the identified issue
- Preserve existing variable names and function signatures unless they are part of the problem
- Ensure the fix is production-ready

---


Like Dislike Create Issue Jira

if err == nil {
toolsJSON = string(toolsBytes)
}
Expand Down
5 changes: 4 additions & 1 deletion core/http/endpoints/openai/inference.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
Expand Down
89 changes: 89 additions & 0 deletions pkg/functions/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package functions

import (
"encoding/json"
"fmt"

"github.com/mudler/xlog"
)
Expand Down Expand Up @@ -102,3 +103,91 @@ 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))

// 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
}

// 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
}
Comment on lines +109 to +193
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 nil Parameters silently mutated to empty map

sanitizeValue is called on the entire function map, so when Function.Parameters is nil it is serialized to JSON null, then converted to map[string]interface{}{} by the nil-branch, and finally unmarshaled back as a non-nil empty map. The result is "parameters": {} instead of "parameters": null, which changes the wire format for every tool that has no parameters. The test "handles tools without parameters field" asserts Parameters.BeNil() but that assertion will fail under the current implementation — the value returned is an empty (non-nil) map.

Comment on lines +145 to +193
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant serialization round-trip on every inference call

SanitizeTools internally performs marshal → unmarshal → sanitize → marshal → unmarshal (5 JSON passes), and both callers (chat.go and inference.go) immediately call json.Marshal on the returned value, adding a 6th pass on every inference request that carries tools. The same effect could be achieved with a single pass by walking the Tools struct directly and deep-copying only the affected fields, avoiding unnecessary allocations on the hot path.

198 changes: 198 additions & 0 deletions pkg/functions/functions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Comment on lines +270 to +276
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functional Medium

The immutability test captures originalNullValue before calling SanitizeTools, so it will always be nil regardless of whether SanitizeTools mutates the original; the test does not actually verify that the original map entry was not modified in-place. To properly test immutability, re-read originalProperties["null_param"] after the call.

Suggested fix
			originalProperties := tools[0].Function.Parameters["properties"].(map[string]interface{})

			sanitized := SanitizeTools(tools)

			// Original should still have nil (re-read after the call to detect in-place mutation)
			Expect(originalProperties["null_param"]).To(BeNil())
Prompt for AI assistance

Copy the prompt below and paste it into ChatGPT, Claude, or any LLM:

You are an expert go developer with deep knowledge of security, performance, and best practices.

### Context

File: pkg/functions/functions_test.go
Lines: 270-276
Issue Type: functional-medium
Severity: medium

Issue Description:
The immutability test captures `originalNullValue` before calling `SanitizeTools`, so it will always be nil regardless of whether `SanitizeTools` mutates the original; the test does not actually verify that the original map entry was not modified in-place. To properly test immutability, re-read `originalProperties["null_param"]` after the call.

Current Code:
			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())

---

### Instructions

1. Fix the issue described above
2. Maintain the exact indentation and code style from the original
3. Follow go best practices and language-specific idioms
4. Ensure the fix addresses the root cause, not just the symptoms
5. Add brief inline comments explaining the fix if needed

### Constraints

- Do not change functionality beyond fixing the identified issue
- Preserve existing variable names and function signatures unless they are part of the problem
- Ensure the fix is production-ready

---


Like Dislike Create Issue Jira


// Sanitized should have empty object
sanitizedProperties := sanitized[0].Function.Parameters["properties"].(map[string]interface{})
Expect(sanitizedProperties["null_param"]).To(Equal(map[string]interface{}{}))
})
})
})