2026-03-31 08:48:21 +00:00
|
|
|
package middleware_test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"github.com/labstack/echo/v4"
|
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
|
|
|
. "github.com/mudler/LocalAI/core/http/middleware"
|
|
|
|
|
"github.com/mudler/LocalAI/core/schema"
|
|
|
|
|
"github.com/mudler/LocalAI/pkg/model"
|
|
|
|
|
"github.com/mudler/LocalAI/pkg/system"
|
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
|
|
|
. "github.com/onsi/gomega"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// newRequestApp creates a minimal Echo app with SetModelAndConfig middleware.
|
|
|
|
|
func newRequestApp(re *RequestExtractor) *echo.Echo {
|
|
|
|
|
e := echo.New()
|
|
|
|
|
e.POST("/v1/chat/completions",
|
|
|
|
|
func(c echo.Context) error {
|
|
|
|
|
return c.String(http.StatusOK, "ok")
|
|
|
|
|
},
|
|
|
|
|
re.SetModelAndConfig(func() schema.LocalAIRequest {
|
|
|
|
|
return new(schema.OpenAIRequest)
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
return e
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func postJSON(e *echo.Echo, path, body string) *httptest.ResponseRecorder {
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
e.ServeHTTP(rec, req)
|
|
|
|
|
return rec
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var _ = Describe("SetModelAndConfig middleware", func() {
|
|
|
|
|
var (
|
|
|
|
|
app *echo.Echo
|
|
|
|
|
modelDir string
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
var err error
|
|
|
|
|
modelDir, err = os.MkdirTemp("", "localai-test-models-*")
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
|
|
|
|
|
ss := &system.SystemState{
|
|
|
|
|
Model: system.Model{ModelsPath: modelDir},
|
|
|
|
|
}
|
|
|
|
|
appConfig := config.NewApplicationConfig()
|
|
|
|
|
appConfig.SystemState = ss
|
|
|
|
|
|
|
|
|
|
mcl := config.NewModelConfigLoader(modelDir)
|
|
|
|
|
ml := model.NewModelLoader(ss)
|
|
|
|
|
|
|
|
|
|
re := NewRequestExtractor(mcl, ml, appConfig)
|
|
|
|
|
app = newRequestApp(re)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
AfterEach(func() {
|
|
|
|
|
os.RemoveAll(modelDir)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when the model does not exist", func() {
|
|
|
|
|
It("returns 404 with a helpful error message", func() {
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions",
|
|
|
|
|
`{"model":"nonexistent-model","messages":[{"role":"user","content":"hi"}]}`)
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusNotFound))
|
|
|
|
|
|
|
|
|
|
var resp schema.ErrorResponse
|
|
|
|
|
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
|
|
|
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
|
|
|
Expect(resp.Error.Message).To(ContainSubstring("nonexistent-model"))
|
|
|
|
|
Expect(resp.Error.Message).To(ContainSubstring("not found"))
|
|
|
|
|
Expect(resp.Error.Type).To(Equal("invalid_request_error"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when the model exists as a config file", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
cfgContent := []byte("name: test-model\nbackend: llama-cpp\n")
|
|
|
|
|
err := os.WriteFile(filepath.Join(modelDir, "test-model.yaml"), cfgContent, 0644)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("passes through to the handler", func() {
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions",
|
|
|
|
|
`{"model":"test-model","messages":[{"role":"user","content":"hi"}]}`)
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when the model exists as a pre-loaded config", func() {
|
|
|
|
|
var mcl *config.ModelConfigLoader
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
// Simulate a model installed via gallery: config is loaded in memory
|
|
|
|
|
// (not just a YAML file on disk). Recreate the app with the pre-loaded config.
|
|
|
|
|
ss := &system.SystemState{
|
|
|
|
|
Model: system.Model{ModelsPath: modelDir},
|
|
|
|
|
}
|
|
|
|
|
appConfig := config.NewApplicationConfig()
|
|
|
|
|
appConfig.SystemState = ss
|
|
|
|
|
|
|
|
|
|
mcl = config.NewModelConfigLoader(modelDir)
|
|
|
|
|
// Pre-load a config as if installed via gallery
|
|
|
|
|
cfgContent := []byte("name: gallery-model\nbackend: llama-cpp\nmodel: gallery-model\n")
|
|
|
|
|
err := os.WriteFile(filepath.Join(modelDir, "gallery-model.yaml"), cfgContent, 0644)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(mcl.ReadModelConfig(filepath.Join(modelDir, "gallery-model.yaml"))).To(Succeed())
|
|
|
|
|
|
|
|
|
|
ml := model.NewModelLoader(ss)
|
|
|
|
|
re := NewRequestExtractor(mcl, ml, appConfig)
|
|
|
|
|
app = newRequestApp(re)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("passes through to the handler", func() {
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions",
|
|
|
|
|
`{"model":"gallery-model","messages":[{"role":"user","content":"hi"}]}`)
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when the model name contains a slash (HuggingFace ID)", func() {
|
|
|
|
|
It("skips the existence check and passes through", func() {
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions",
|
|
|
|
|
`{"model":"stabilityai/stable-diffusion-xl-base-1.0","messages":[{"role":"user","content":"hi"}]}`)
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when no model is specified", func() {
|
|
|
|
|
It("passes through without checking", func() {
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions",
|
|
|
|
|
`{"messages":[{"role":"user","content":"hi"}]}`)
|
|
|
|
|
|
|
|
|
|
// No model name → middleware doesn't reject, handler runs
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
fix(openresponses): parse OpenAI-spec nested tool_choice + use correct setter (#9509)
Two bugs in MergeOpenResponsesConfig (/v1/responses + WebSocket, *not*
/v1/chat/completions — that has a separate, working path via Tool
unmarshal + SetFunctionCallNameString):
1. **Shape mismatch.** OpenAI's specific-function tool_choice nests the
name under "function":
{"type": "function", "function": {"name": "my_function"}}
The legacy flat shape was:
{"type": "function", "name": "my_function"}
Only the flat shape was handled. OpenAI-compliant clients that reach
/v1/responses (openai-python with the Responses API, Stainless-generated
SDKs, …) silently failed to force the function.
2. **Wrong setter.** The code called SetFunctionCallString(name), which
writes the mode field (functionCallString: "none"/"auto"/"required").
The specific-function name lives in a separate field
(functionCallNameString), read by ShouldCallSpecificFunction and
FunctionToCall. Net effect: a correctly-formed tool_choice never
engaged grammar-based forcing.
The fix preserves backward compatibility by accepting both shapes
(nested preferred, flat as fallback) and routes to the correct setter.
Note: The same "wrong setter" pattern appears at three other sites —
anthropic/messages.go:883, openai/realtime_model.go:171, and
openresponses/responses.go:776 — and /v1/chat/completions has its own
issue parsing tool_choice="required" as a string (json.Unmarshal on a
raw string fails silently). Those are filed as a tracking issue rather
than bundled here to keep this PR focused.
## Test plan
9 new Ginkgo specs under "MergeOpenResponsesConfig tool_choice parsing":
- string modes: "required" / "auto" / "none"
- OpenAI-spec nested shape: {type:function, function:{name}}
- Legacy Anthropic-compat flat shape: {type:function, name}
- Shape-preference: nested wins over flat when both present
- Malformed: missing type, wrong type, missing name, empty name, nil
$ go test ./core/http/middleware/ -count=1 -run TestMiddleware
Ran 28 of 28 Specs in 0.003 seconds -- PASS
## Repro (against /v1/responses)
curl -N http://localai/v1/responses \
-H 'Content-Type: application/json' \
-d '{"model":"qwen3.6-35b-a3b-apex",
"input":"Weather in Berlin?",
"tools":[{"type":"function","name":"get_weather",
"parameters":{"type":"object",
"properties":{"city":{"type":"string"}},
"required":["city"]}}],
"tool_choice":{"type":"function",
"function":{"name":"get_weather"}}}'
Before: grammar-based forcing silently inactive; model free-texts.
After : grammar forces get_weather invocation; output contains
tool_calls with function:{name:"get_weather", arguments:{...}}.
2026-04-23 16:30:05 +00:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// MergeOpenResponsesConfig — tool_choice parsing
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
//
|
|
|
|
|
// The OpenAI chat/completions spec nests the function name under "function":
|
|
|
|
|
// {"type":"function", "function":{"name":"my_function"}}
|
|
|
|
|
// The legacy Anthropic-compat shape puts it at the top level:
|
|
|
|
|
// {"type":"function", "name":"my_function"}
|
|
|
|
|
// Both need to reach SetFunctionCallNameString (not SetFunctionCallString,
|
|
|
|
|
// which is the mode field "none"/"auto"/"required").
|
|
|
|
|
//
|
|
|
|
|
// These specs assert both shapes populate the specific-function name and that
|
|
|
|
|
// downstream predicates (ShouldCallSpecificFunction, FunctionToCall) return
|
|
|
|
|
// the expected values so grammar-based forcing actually engages.
|
|
|
|
|
var _ = Describe("MergeOpenResponsesConfig tool_choice parsing", func() {
|
|
|
|
|
var cfg *config.ModelConfig
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
cfg = &config.ModelConfig{}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("string tool_choice", func() {
|
|
|
|
|
It("sets mode to required for tool_choice=\"required\"", func() {
|
|
|
|
|
req := &schema.OpenResponsesRequest{ToolChoice: "required"}
|
|
|
|
|
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
|
|
|
|
|
|
|
|
|
|
// "required" is a mode, not a specific function.
|
|
|
|
|
Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
|
|
|
|
|
// ShouldUseFunctions must be true so tools are sent to the model.
|
|
|
|
|
Expect(cfg.ShouldUseFunctions()).To(BeTrue())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("leaves config untouched for tool_choice=\"auto\"", func() {
|
|
|
|
|
req := &schema.OpenResponsesRequest{ToolChoice: "auto"}
|
|
|
|
|
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
|
|
|
|
|
|
|
|
|
|
Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
|
|
|
|
|
Expect(cfg.FunctionToCall()).To(Equal(""))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("leaves config untouched for tool_choice=\"none\"", func() {
|
|
|
|
|
req := &schema.OpenResponsesRequest{ToolChoice: "none"}
|
|
|
|
|
Expect(MergeOpenResponsesConfig(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.OpenResponsesRequest{
|
|
|
|
|
ToolChoice: map[string]any{
|
|
|
|
|
"type": "function",
|
|
|
|
|
"function": map[string]any{"name": "get_weather"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
|
|
|
|
|
|
|
|
|
|
// This is the key invariant the fix restores: a correctly-formed
|
|
|
|
|
// OpenAI tool_choice must result in ShouldCallSpecificFunction()=true.
|
|
|
|
|
Expect(cfg.ShouldCallSpecificFunction()).To(BeTrue())
|
|
|
|
|
Expect(cfg.FunctionToCall()).To(Equal("get_weather"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("prefers the nested function.name over a stray top-level name", func() {
|
|
|
|
|
// Defense-in-depth: both shapes present, OpenAI spec wins.
|
|
|
|
|
req := &schema.OpenResponsesRequest{
|
|
|
|
|
ToolChoice: map[string]any{
|
|
|
|
|
"type": "function",
|
|
|
|
|
"function": map[string]any{"name": "correct_name"},
|
|
|
|
|
"name": "legacy_name",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
|
|
|
|
|
|
|
|
|
|
Expect(cfg.FunctionToCall()).To(Equal("correct_name"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("specific-function tool_choice (legacy Anthropic-compat shape)", func() {
|
|
|
|
|
It("parses {type:function, name:...} and sets the specific-function name", func() {
|
|
|
|
|
req := &schema.OpenResponsesRequest{
|
|
|
|
|
ToolChoice: map[string]any{
|
|
|
|
|
"type": "function",
|
|
|
|
|
"name": "get_weather",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
Expect(MergeOpenResponsesConfig(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.OpenResponsesRequest{
|
|
|
|
|
ToolChoice: map[string]any{
|
|
|
|
|
"function": map[string]any{"name": "get_weather"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
|
|
|
|
|
|
|
|
|
|
Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("is a no-op when type is not \"function\"", func() {
|
|
|
|
|
req := &schema.OpenResponsesRequest{
|
|
|
|
|
ToolChoice: map[string]any{
|
|
|
|
|
"type": "object",
|
|
|
|
|
"function": map[string]any{"name": "get_weather"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
|
|
|
|
|
|
|
|
|
|
Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("is a no-op when name is missing from both shapes", func() {
|
|
|
|
|
req := &schema.OpenResponsesRequest{
|
|
|
|
|
ToolChoice: map[string]any{
|
|
|
|
|
"type": "function",
|
|
|
|
|
"function": map[string]any{},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
Expect(MergeOpenResponsesConfig(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.OpenResponsesRequest{
|
|
|
|
|
ToolChoice: map[string]any{
|
|
|
|
|
"type": "function",
|
|
|
|
|
"function": map[string]any{"name": ""},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
|
|
|
|
|
|
|
|
|
|
Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("nil tool_choice", func() {
|
|
|
|
|
It("is a no-op", func() {
|
|
|
|
|
req := &schema.OpenResponsesRequest{ToolChoice: nil}
|
|
|
|
|
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
|
|
|
|
|
|
|
|
|
|
Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
|
|
|
|
|
Expect(cfg.FunctionToCall()).To(Equal(""))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
fix(middleware): parse OpenAI-spec tool_choice in /v1/chat/completions (#9559)
* fix(middleware): parse OpenAI-spec tool_choice in /v1/chat/completions
Follows up on #9526 (the 3-site setter fix) by addressing the remaining
clause in #9508 — string mode and OpenAI-spec specific-function shape both
silently failed in the /v1/chat/completions parsing path.
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
* fix(middleware): restore LF endings and cover tool_choice parsing with specs
The previous commit on this branch saved core/http/middleware/request.go
with CRLF line endings, ballooning the diff against master to 684 / 651
for what is in reality a ~50-line parsing change. Restore LF (matches
.editorconfig end_of_line = lf).
Add 11 Ginkgo specs under "SetModelAndConfig tool_choice parsing
(chat completions)" that parallel the existing MergeOpenResponsesConfig
specs from #9509. They drive the full middleware chain (SetModelAndConfig
+ SetOpenAIRequest) and assert:
* "required" -> ShouldUseFunctions=true, no specific name
* "none" -> ShouldUseFunctions=false (tools disabled per OpenAI spec)
* "auto" -> default, tools available, no specific name
* {type:function, function:{name:X}} (spec) -> X is forced
* {type:function, name:X} (legacy) -> X is forced
* nested wins when both forms are present
* malformed shapes (no type, wrong type, no name, empty name) are no-ops
Update the inline comment on the string case to describe the actual
mechanism: "none" reaches SetFunctionCallString("none") downstream and
is then honored by ShouldUseFunctions() returning false. Before this PR
json.Unmarshal([]byte("none"), &functions.Tool{}) failed silently, so
"none" was ignored - making "none" actually work is a real behavior fix
this PR brings.
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:opus-4-7 [Claude Code]
* fix(middleware): preserve pre-#9559 support for JSON-string-encoded tool_choice
Some non-spec clients send tool_choice as a JSON-encoded string of an
object form, e.g. "{\"type\":\"function\",\"function\":{\"name\":\"X\"}}".
The pre-#9559 code accepted this by accident: its case string: branch
ran json.Unmarshal([]byte(content), &functions.Tool{}), which succeeded
for that double-encoded shape even though it failed for the legitimate
plain string modes "auto" / "none" / "required".
The first version of this PR routed every string straight to
SetFunctionCallString as a mode, which fixed the plain-string cases but
silently regressed the double-encoded one (funcs.Select("{...}") returns
nothing). Restore the fallback: when a string looks like a JSON object,
try parsing it as a tool_choice map first; fall through to mode-string
handling only when no usable name comes out.
Factor the map-name extraction into a small helper
(extractToolChoiceFunctionName) so the string-fallback and the regular
map case go through identical code, and accept both the OpenAI-spec
nested shape and the legacy/Anthropic flat shape from either entry
point.
Add 3 Ginkgo specs covering the double-encoded case (nested form, legacy
form, and the fall-through when the JSON has no usable name).
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:opus-4-7 [Claude Code]
* test(middleware): silence errcheck on AfterEach os.RemoveAll
The new tool_choice parsing tests added a second AfterEach that calls
os.RemoveAll(modelDir) without checking the error; errcheck flagged it.
Suppress with the standard _ = idiom. The pre-existing AfterEach on the
earlier Describe still elides the check the same way it did before -
leaving that untouched to keep this commit minimal.
Assisted-by: Claude:opus-4-7 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
---------
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-05-13 22:14:38 +00:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// SetModelAndConfig + SetOpenAIRequest - /v1/chat/completions tool_choice parsing
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
//
|
|
|
|
|
// Parallel to the MergeOpenResponsesConfig specs above, but for the chat
|
|
|
|
|
// completions path. The parsing block lives in mergeOpenAIRequestAndModelConfig
|
|
|
|
|
// (called from SetOpenAIRequest), so these tests drive the full middleware
|
|
|
|
|
// chain the way the production /v1/chat/completions route does.
|
|
|
|
|
//
|
|
|
|
|
// What we assert per shape:
|
|
|
|
|
// - "required" -> ShouldUseFunctions=true, no specific name
|
|
|
|
|
// - "none" -> ShouldUseFunctions=false (tools disabled)
|
|
|
|
|
// - "auto" -> ShouldUseFunctions=true, no specific name
|
|
|
|
|
// - {type:function, function:{name:"X"}} (spec) -> ShouldCallSpecificFunction=true, FunctionToCall="X"
|
|
|
|
|
// - {type:function, name:"X"} (legacy) -> ShouldCallSpecificFunction=true, FunctionToCall="X"
|
|
|
|
|
// - nested+flat both present -> nested wins
|
|
|
|
|
// - malformed (no type / no name) -> no-op
|
|
|
|
|
var _ = Describe("SetModelAndConfig tool_choice parsing (chat completions)", func() {
|
|
|
|
|
var (
|
|
|
|
|
app *echo.Echo
|
|
|
|
|
modelDir string
|
|
|
|
|
capturedConfig *config.ModelConfig
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
var err error
|
|
|
|
|
modelDir, err = os.MkdirTemp("", "localai-test-models-*")
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
|
|
|
|
|
cfgContent := []byte("name: test-model\nbackend: llama-cpp\n")
|
|
|
|
|
Expect(os.WriteFile(filepath.Join(modelDir, "test-model.yaml"), cfgContent, 0644)).To(Succeed())
|
|
|
|
|
|
|
|
|
|
ss := &system.SystemState{
|
|
|
|
|
Model: system.Model{ModelsPath: modelDir},
|
|
|
|
|
}
|
|
|
|
|
appConfig := config.NewApplicationConfig()
|
|
|
|
|
appConfig.SystemState = ss
|
|
|
|
|
|
|
|
|
|
mcl := config.NewModelConfigLoader(modelDir)
|
|
|
|
|
ml := model.NewModelLoader(ss)
|
|
|
|
|
re := NewRequestExtractor(mcl, ml, appConfig)
|
|
|
|
|
|
|
|
|
|
capturedConfig = nil
|
|
|
|
|
app = echo.New()
|
|
|
|
|
app.POST("/v1/chat/completions",
|
|
|
|
|
func(c echo.Context) error {
|
|
|
|
|
if cfg, ok := c.Get(CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig); ok {
|
|
|
|
|
capturedConfig = cfg
|
|
|
|
|
}
|
|
|
|
|
return c.String(http.StatusOK, "ok")
|
|
|
|
|
},
|
|
|
|
|
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }),
|
|
|
|
|
func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
|
|
|
return func(c echo.Context) error {
|
|
|
|
|
if err := re.SetOpenAIRequest(c); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return next(c)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
AfterEach(func() {
|
|
|
|
|
_ = os.RemoveAll(modelDir)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// chatReq wraps a tool_choice JSON fragment in a minimal valid chat-completions
|
|
|
|
|
// payload. The tools array is non-empty so downstream code paths that gate on
|
|
|
|
|
// len(input.Functions) see something to work with.
|
|
|
|
|
chatReq := func(toolChoiceJSON string) string {
|
|
|
|
|
return `{"model":"test-model",` +
|
|
|
|
|
`"messages":[{"role":"user","content":"hi"}],` +
|
|
|
|
|
`"tools":[{"type":"function","function":{"name":"get_weather"}}],` +
|
|
|
|
|
`"tool_choice":` + toolChoiceJSON + `}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Context("string tool_choice", func() {
|
|
|
|
|
It("engages mode for tool_choice=\"required\"", func() {
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions", chatReq(`"required"`))
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(capturedConfig).ToNot(BeNil())
|
|
|
|
|
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeFalse())
|
|
|
|
|
Expect(capturedConfig.ShouldUseFunctions()).To(BeTrue())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("disables tools for tool_choice=\"none\"", func() {
|
|
|
|
|
// Before #9559 this was a silent no-op (json.Unmarshal of "none"
|
|
|
|
|
// into functions.Tool failed); now "none" is honored per OpenAI spec.
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions", chatReq(`"none"`))
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(capturedConfig).ToNot(BeNil())
|
|
|
|
|
Expect(capturedConfig.ShouldUseFunctions()).To(BeFalse())
|
|
|
|
|
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeFalse())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("leaves config untouched for tool_choice=\"auto\"", func() {
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions", chatReq(`"auto"`))
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(capturedConfig).ToNot(BeNil())
|
|
|
|
|
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeFalse())
|
|
|
|
|
// "auto" is the default: tools available, model decides.
|
|
|
|
|
Expect(capturedConfig.ShouldUseFunctions()).To(BeTrue())
|
|
|
|
|
Expect(capturedConfig.FunctionToCall()).To(Equal(""))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("specific-function tool_choice (OpenAI spec shape)", func() {
|
|
|
|
|
It("parses {type:function, function:{name:...}} and forces the named function", func() {
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions",
|
|
|
|
|
chatReq(`{"type":"function","function":{"name":"get_weather"}}`))
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(capturedConfig).ToNot(BeNil())
|
|
|
|
|
// Key invariant: a correctly-formed OpenAI tool_choice must engage
|
|
|
|
|
// grammar-based forcing via SetFunctionCallNameString.
|
|
|
|
|
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeTrue())
|
|
|
|
|
Expect(capturedConfig.FunctionToCall()).To(Equal("get_weather"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("prefers the nested function.name over a stray top-level name", func() {
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions",
|
|
|
|
|
chatReq(`{"type":"function","function":{"name":"correct_name"},"name":"legacy_name"}`))
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(capturedConfig).ToNot(BeNil())
|
|
|
|
|
Expect(capturedConfig.FunctionToCall()).To(Equal("correct_name"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("specific-function tool_choice (legacy Anthropic-compat shape)", func() {
|
|
|
|
|
It("parses {type:function, name:...} and forces the named function", func() {
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions",
|
|
|
|
|
chatReq(`{"type":"function","name":"get_weather"}`))
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(capturedConfig).ToNot(BeNil())
|
|
|
|
|
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeTrue())
|
|
|
|
|
Expect(capturedConfig.FunctionToCall()).To(Equal("get_weather"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Some non-spec clients send the object form serialized as a JSON string.
|
|
|
|
|
// The pre-#9559 code accepted that by accident; this Context locks in
|
|
|
|
|
// continued tolerance so those clients do not silently regress.
|
|
|
|
|
Context("double-encoded tool_choice (JSON string of an object, non-spec)", func() {
|
|
|
|
|
It("parses a serialized OpenAI-spec nested object", func() {
|
|
|
|
|
// tool_choice value is itself a JSON-encoded string containing the
|
|
|
|
|
// object form. Use json.Marshal of the inner blob so the escapes
|
|
|
|
|
// are correct regardless of the test reader.
|
|
|
|
|
inner := `{"type":"function","function":{"name":"get_weather"}}`
|
|
|
|
|
encoded, err := json.Marshal(inner)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions", chatReq(string(encoded)))
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(capturedConfig).ToNot(BeNil())
|
|
|
|
|
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeTrue())
|
|
|
|
|
Expect(capturedConfig.FunctionToCall()).To(Equal("get_weather"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("parses a serialized legacy/Anthropic flat object", func() {
|
|
|
|
|
inner := `{"type":"function","name":"get_weather"}`
|
|
|
|
|
encoded, err := json.Marshal(inner)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions", chatReq(string(encoded)))
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(capturedConfig).ToNot(BeNil())
|
|
|
|
|
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeTrue())
|
|
|
|
|
Expect(capturedConfig.FunctionToCall()).To(Equal("get_weather"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("falls back to mode-string handling when the JSON string parses but has no usable name", func() {
|
|
|
|
|
// A JSON-string that decodes to a map without a function name
|
|
|
|
|
// should not engage specific-function forcing. We expect it to
|
|
|
|
|
// fall through to the mode-string path; the resulting mode is
|
|
|
|
|
// the raw blob (nonsense), but ShouldCallSpecificFunction stays
|
|
|
|
|
// false - the invariant that matters.
|
|
|
|
|
inner := `{"type":"function"}`
|
|
|
|
|
encoded, err := json.Marshal(inner)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions", chatReq(string(encoded)))
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(capturedConfig).ToNot(BeNil())
|
|
|
|
|
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeFalse())
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("malformed tool_choice", func() {
|
|
|
|
|
It("is a no-op when type is missing", func() {
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions",
|
|
|
|
|
chatReq(`{"function":{"name":"get_weather"}}`))
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(capturedConfig).ToNot(BeNil())
|
|
|
|
|
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeFalse())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("is a no-op when type is not \"function\"", func() {
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions",
|
|
|
|
|
chatReq(`{"type":"object","function":{"name":"get_weather"}}`))
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(capturedConfig).ToNot(BeNil())
|
|
|
|
|
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeFalse())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("is a no-op when name is missing from both shapes", func() {
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions",
|
|
|
|
|
chatReq(`{"type":"function","function":{}}`))
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(capturedConfig).ToNot(BeNil())
|
|
|
|
|
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeFalse())
|
|
|
|
|
Expect(capturedConfig.FunctionToCall()).To(Equal(""))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("is a no-op when name is empty string", func() {
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions",
|
|
|
|
|
chatReq(`{"type":"function","function":{"name":""}}`))
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(capturedConfig).ToNot(BeNil())
|
|
|
|
|
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeFalse())
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("nil tool_choice", func() {
|
|
|
|
|
It("is a no-op", func() {
|
|
|
|
|
rec := postJSON(app, "/v1/chat/completions",
|
|
|
|
|
`{"model":"test-model","messages":[{"role":"user","content":"hi"}]}`)
|
|
|
|
|
|
|
|
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(capturedConfig).ToNot(BeNil())
|
|
|
|
|
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeFalse())
|
|
|
|
|
Expect(capturedConfig.FunctionToCall()).To(Equal(""))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|