From eec532704cf5a1a0365c1cb30394172677a05bcb Mon Sep 17 00:00:00 2001 From: Tai An Date: Sun, 27 Sep 2026 15:52:51 -0700 Subject: [PATCH] fix(functions): honor function_arguments_key when building the tool grammar (#11677) * fix(functions): honor function_arguments_key when building the tool grammar All four call sites of `Functions.ToJSONStructure(name, args string)` pass `FunctionsConfig.FunctionNameKey` as *both* arguments, so `FunctionArgumentsKey` never reaches the grammar generator. `ToJSONStructure` writes the two properties into the same map: property[nameKey] = FunctionName{Const: function.Name} property[argsKey] = Argument{...} When `nameKey == argsKey` the second assignment overwrites the first, so a model configured with `function_name_key` gets a grammar carrying only the arguments object -- the `{"const": ""}` constraint is gone and the grammar can no longer express which function was called. With `function_name_key: function`, the generated property set collapses from {"function": {"const": "get_weather"}, "arguments": {...}} to {"function": {"type": "object", "properties": {...}}} Setting only `function_arguments_key` is equally broken in the other direction: the grammar keeps emitting `arguments` while `ParseFunctionCall` (pkg/functions/parse.go) looks up the configured key, so the parsed call comes back with its arguments empty. The default configuration is unaffected -- with both keys empty `ToJSONStructure` falls back to `name`/`arguments` for both parameters, which is why this went unnoticed. The existing `ToJSONStructure()` unit test already calls the helper with two distinct keys, so only the call sites were wrong. Extend that test with a case that keeps both custom keys distinct and asserts the two properties survive. Signed-off-by: Anai-Guo * test(functions): cover configured grammar keys Route grammar construction through FunctionsConfig so the regression test covers the key wiring used by every endpoint. Assisted-by: Codex:gpt-5 * chore: empty commit to trigger workflow approval Signed-off-by: Tai An --------- Signed-off-by: Anai-Guo Signed-off-by: Tai An Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> --- core/http/endpoints/openai/chat.go | 2 +- core/http/endpoints/openai/realtime_model.go | 2 +- .../http/endpoints/openresponses/responses.go | 2 +- .../http/endpoints/openresponses/websocket.go | 2 +- pkg/functions/functions.go | 5 ++++ pkg/functions/functions_test.go | 27 +++++++++++++++++++ 6 files changed, 36 insertions(+), 4 deletions(-) diff --git a/core/http/endpoints/openai/chat.go b/core/http/endpoints/openai/chat.go index fbf5924ea..76efbca28 100644 --- a/core/http/endpoints/openai/chat.go +++ b/core/http/endpoints/openai/chat.go @@ -451,7 +451,7 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator } // Update input grammar or json_schema based on use_llama_grammar option - jsStruct := funcs.ToJSONStructure(config.FunctionsConfig.FunctionNameKey, config.FunctionsConfig.FunctionNameKey) + jsStruct := config.FunctionsConfig.ToJSONStructure(funcs) g, err := jsStruct.Grammar(config.FunctionsConfig.GrammarOptions()...) if err == nil { config.Grammar = g diff --git a/core/http/endpoints/openai/realtime_model.go b/core/http/endpoints/openai/realtime_model.go index b525eee26..a02696a1f 100644 --- a/core/http/endpoints/openai/realtime_model.go +++ b/core/http/endpoints/openai/realtime_model.go @@ -293,7 +293,7 @@ func (m *wrappedModel) Predict(ctx context.Context, messages schema.Messages, im } // Generate grammar from function definitions - jsStruct := functions.Functions(funcs).ToJSONStructure(turnCfg.FunctionsConfig.FunctionNameKey, turnCfg.FunctionsConfig.FunctionNameKey) + jsStruct := turnCfg.FunctionsConfig.ToJSONStructure(functions.Functions(funcs)) g, err := jsStruct.Grammar(turnCfg.FunctionsConfig.GrammarOptions()...) if err == nil { turnCfg.Grammar = g diff --git a/core/http/endpoints/openresponses/responses.go b/core/http/endpoints/openresponses/responses.go index d62fa7534..528737273 100644 --- a/core/http/endpoints/openresponses/responses.go +++ b/core/http/endpoints/openresponses/responses.go @@ -204,7 +204,7 @@ func ResponsesEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, eval } // Generate grammar to constrain model output to valid function calls - jsStruct := funcsWithNoAction.ToJSONStructure(cfg.FunctionsConfig.FunctionNameKey, cfg.FunctionsConfig.FunctionNameKey) + jsStruct := cfg.FunctionsConfig.ToJSONStructure(funcsWithNoAction) g, err := jsStruct.Grammar(cfg.FunctionsConfig.GrammarOptions()...) if err == nil { cfg.Grammar = g diff --git a/core/http/endpoints/openresponses/websocket.go b/core/http/endpoints/openresponses/websocket.go index 3a92f275a..f0cb4b331 100644 --- a/core/http/endpoints/openresponses/websocket.go +++ b/core/http/endpoints/openresponses/websocket.go @@ -381,7 +381,7 @@ func handleWSResponseCreate(connCtx context.Context, conn *lockedConn, connectio funcsWithNoAction = funcsWithNoAction.Select(cfg.FunctionToCall()) } - jsStruct := funcsWithNoAction.ToJSONStructure(cfg.FunctionsConfig.FunctionNameKey, cfg.FunctionsConfig.FunctionNameKey) + jsStruct := cfg.FunctionsConfig.ToJSONStructure(funcsWithNoAction) g, err := jsStruct.Grammar(cfg.FunctionsConfig.GrammarOptions()...) if err == nil { cfg.Grammar = g diff --git a/pkg/functions/functions.go b/pkg/functions/functions.go index 0686e4f9d..3e604d9df 100644 --- a/pkg/functions/functions.go +++ b/pkg/functions/functions.go @@ -89,6 +89,11 @@ func (f Functions) ToJSONStructure(name, args string) JSONFunctionStructure { return js } +// ToJSONStructure converts functions using the configured property keys. +func (c FunctionsConfig) ToJSONStructure(functions Functions) JSONFunctionStructure { + return functions.ToJSONStructure(c.FunctionNameKey, c.FunctionArgumentsKey) +} + // Select returns a list of functions containing the function with the given name func (f Functions) Select(name string) Functions { var funcs Functions diff --git a/pkg/functions/functions_test.go b/pkg/functions/functions_test.go index e0952c13f..7c10da473 100644 --- a/pkg/functions/functions_test.go +++ b/pkg/functions/functions_test.go @@ -65,6 +65,33 @@ var _ = Describe("LocalAI grammar functions", func() { Expect(fnName.Const).To(Equal("search")) Expect(fnArgs.Properties["query"].(map[string]any)["type"]).To(Equal("string")) }) + + It("keeps the name and the arguments in separate properties when both keys are customized", func() { + var functions Functions = []Function{ + { + Name: "get_weather", + Parameters: map[string]any{ + "properties": map[string]any{ + "city": map[string]any{ + "type": "string", + }, + }, + }, + }, + } + + config := FunctionsConfig{ + FunctionNameKey: "function", + FunctionArgumentsKey: "parameters", + } + js := config.ToJSONStructure(functions) + Expect(js.OneOf[0].Properties).To(HaveLen(2)) + + fnName := js.OneOf[0].Properties["function"].(FunctionName) + fnArgs := js.OneOf[0].Properties["parameters"].(Argument) + Expect(fnName.Const).To(Equal("get_weather")) + Expect(fnArgs.Properties["city"].(map[string]any)["type"]).To(Equal("string")) + }) }) Context("Select()", func() { It("selects one of the functions and returns a list containing only the selected one", func() {