Skip to content
Closed
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
36 changes: 25 additions & 11 deletions core/http/middleware/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import (
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/functions"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/utils"
"github.com/mudler/xlog"
Expand Down Expand Up @@ -225,7 +224,7 @@ func (re *RequestExtractor) SetOpenAIRequest(c echo.Context) error {
input.Context = ctxWithCorrelationID
input.Cancel = cancel

err := mergeOpenAIRequestAndModelConfig(cfg, input)
err := MergeOpenAIRequestConfig(cfg, input)
if err != nil {
return err
}
Expand All @@ -241,7 +240,7 @@ func (re *RequestExtractor) SetOpenAIRequest(c echo.Context) error {
return nil
}

func mergeOpenAIRequestAndModelConfig(config *config.ModelConfig, input *schema.OpenAIRequest) error {
func MergeOpenAIRequestConfig(config *config.ModelConfig, input *schema.OpenAIRequest) error {
if input.Echo {
config.Echo = input.Echo
}
Expand Down Expand Up @@ -320,17 +319,32 @@ func mergeOpenAIRequestAndModelConfig(config *config.ModelConfig, input *schema.
}

if input.ToolsChoice != nil {
var toolChoice functions.Tool

switch content := input.ToolsChoice.(type) {
case string:
_ = json.Unmarshal([]byte(content), &toolChoice)
// "required" and "none" need explicit mode flags; "auto" is the
// default and must remain a no-op to avoid polluting FunctionToCall().
switch content {
case "required", "none":
input.FunctionCall = content
}
// "auto" — leave FunctionCall unset; model decides.
case map[string]any:
dat, _ := json.Marshal(content)
_ = json.Unmarshal(dat, &toolChoice)
}
input.FunctionCall = map[string]any{
"name": toolChoice.Function.Name,
// Specific tool. OpenAI spec nests the function name under "function":
// {"type":"function", "function":{"name":"..."}}
// Legacy/Anthropic-compat form puts it at the top level:
// {"type":"function", "name":"..."}
if tcType, ok := content["type"].(string); ok && tcType == "function" {
var name string
if fn, ok := content["function"].(map[string]any); ok {
name, _ = fn["name"].(string)
}
if name == "" {
name, _ = content["name"].(string)
}
if name != "" {
input.FunctionCall = map[string]any{"name": name}
}
}
}
}

Expand Down
146 changes: 146 additions & 0 deletions core/http/middleware/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,3 +306,149 @@ var _ = Describe("MergeOpenResponsesConfig tool_choice parsing", func() {
})
})
})

// ---------------------------------------------------------------------------
// MergeOpenAIRequestConfig — tool_choice parsing (/v1/chat/completions)
// ---------------------------------------------------------------------------
//
// Mirrors the MergeOpenResponsesConfig suite above but exercises the OpenAI
// chat/completions path. The bug: the old code tried to json.Unmarshal the
// string "required" into a functions.Tool (always fails), then unconditionally
// set FunctionCall to {"name":""}, so "required" mode was silently dropped and
// SetFunctionCallNameString("") was called instead of SetFunctionCallString("required").
var _ = Describe("MergeOpenAIRequestConfig tool_choice parsing", func() {
var cfg *config.ModelConfig

BeforeEach(func() {
cfg = &config.ModelConfig{}
})

Context("string tool_choice", func() {
It("applies \"required\" mode", func() {
req := &schema.OpenAIRequest{ToolsChoice: "required"}
Expect(MergeOpenAIRequestConfig(cfg, req)).To(Succeed())

Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
Expect(cfg.ShouldUseFunctions()).To(BeTrue())
})

It("applies \"none\" mode", func() {
req := &schema.OpenAIRequest{ToolsChoice: "none"}
Expect(MergeOpenAIRequestConfig(cfg, req)).To(Succeed())

Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
Expect(cfg.ShouldUseFunctions()).To(BeFalse())
})

It("leaves config untouched for \"auto\"", func() {
req := &schema.OpenAIRequest{ToolsChoice: "auto"}
Expect(MergeOpenAIRequestConfig(cfg, req)).To(Succeed())

Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
Expect(cfg.FunctionToCall()).To(Equal(""))
})
})

Context("specific-function tool_choice (OpenAI spec shape)", func() {
It("parses {type:function, function:{name:...}} and sets the specific-function name", func() {
req := &schema.OpenAIRequest{
ToolsChoice: map[string]any{
"type": "function",
"function": map[string]any{"name": "get_weather"},
},
}
Expect(MergeOpenAIRequestConfig(cfg, req)).To(Succeed())

Expect(cfg.ShouldCallSpecificFunction()).To(BeTrue())
Expect(cfg.FunctionToCall()).To(Equal("get_weather"))
})

It("prefers nested function.name over a stray top-level name", func() {
req := &schema.OpenAIRequest{
ToolsChoice: map[string]any{
"type": "function",
"function": map[string]any{"name": "correct_name"},
"name": "legacy_name",
},
}
Expect(MergeOpenAIRequestConfig(cfg, req)).To(Succeed())

Expect(cfg.FunctionToCall()).To(Equal("correct_name"))
})
})

Context("specific-function tool_choice (legacy flat shape)", func() {
It("parses {type:function, name:...} and sets the specific-function name", func() {
req := &schema.OpenAIRequest{
ToolsChoice: map[string]any{
"type": "function",
"name": "get_weather",
},
}
Expect(MergeOpenAIRequestConfig(cfg, req)).To(Succeed())

Expect(cfg.ShouldCallSpecificFunction()).To(BeTrue())
Expect(cfg.FunctionToCall()).To(Equal("get_weather"))
})
})

Context("malformed tool_choice", func() {
It("is a no-op when type is missing", func() {
req := &schema.OpenAIRequest{
ToolsChoice: map[string]any{
"function": map[string]any{"name": "get_weather"},
},
}
Expect(MergeOpenAIRequestConfig(cfg, req)).To(Succeed())

Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
})

It("is a no-op when type is not \"function\"", func() {
req := &schema.OpenAIRequest{
ToolsChoice: map[string]any{
"type": "object",
"function": map[string]any{"name": "get_weather"},
},
}
Expect(MergeOpenAIRequestConfig(cfg, req)).To(Succeed())

Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
})

It("is a no-op when name is missing from both shapes", func() {
req := &schema.OpenAIRequest{
ToolsChoice: map[string]any{
"type": "function",
"function": map[string]any{},
},
}
Expect(MergeOpenAIRequestConfig(cfg, req)).To(Succeed())

Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
Expect(cfg.FunctionToCall()).To(Equal(""))
})

It("is a no-op when name is empty string", func() {
req := &schema.OpenAIRequest{
ToolsChoice: map[string]any{
"type": "function",
"function": map[string]any{"name": ""},
},
}
Expect(MergeOpenAIRequestConfig(cfg, req)).To(Succeed())

Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
})
})

Context("nil tool_choice", func() {
It("is a no-op", func() {
req := &schema.OpenAIRequest{ToolsChoice: nil}
Expect(MergeOpenAIRequestConfig(cfg, req)).To(Succeed())

Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
Expect(cfg.FunctionToCall()).To(Equal(""))
})
})
})