Skip to content

Commit b864231

Browse files
committed
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]
1 parent d833d2d commit b864231

2 files changed

Lines changed: 105 additions & 34 deletions

File tree

core/http/middleware/request.go

Lines changed: 57 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,28 @@ func (re *RequestExtractor) SetOpenAIRequest(c echo.Context) error {
240240
return nil
241241
}
242242

243+
// extractToolChoiceFunctionName parses a tool_choice map and returns the
244+
// specific function name. Accepts both the OpenAI-spec nested shape
245+
// ({type:function, function:{name:...}}) and the legacy/Anthropic-compat
246+
// flat shape ({type:function, name:...}); the nested form wins when both
247+
// are present. Returns "" for malformed input or when the shape names a
248+
// mode rather than a specific tool.
249+
func extractToolChoiceFunctionName(m map[string]any) string {
250+
tcType, ok := m["type"].(string)
251+
if !ok || tcType != "function" {
252+
return ""
253+
}
254+
if fn, ok := m["function"].(map[string]any); ok {
255+
if n, ok := fn["name"].(string); ok && n != "" {
256+
return n
257+
}
258+
}
259+
if n, ok := m["name"].(string); ok {
260+
return n
261+
}
262+
return ""
263+
}
264+
243265
func mergeOpenAIRequestAndModelConfig(config *config.ModelConfig, input *schema.OpenAIRequest) error {
244266
if input.Echo {
245267
config.Echo = input.Echo
@@ -319,53 +341,54 @@ func mergeOpenAIRequestAndModelConfig(config *config.ModelConfig, input *schema.
319341
}
320342

321343
if input.ToolsChoice != nil {
322-
// OpenAI tool_choice has three valid shapes:
344+
// OpenAI tool_choice has three valid shapes plus one tolerated
345+
// non-spec form seen in the wild:
323346
//
324-
// 1. string mode: "auto" | "none" | "required"
325-
// 2. specific tool: {"type":"function", "function":{"name":"..."}} (current spec)
326-
// 3. legacy: {"type":"function", "name":"..."} (older / Anthropic-compat)
347+
// 1. string mode: "auto" | "none" | "required"
348+
// 2. specific tool: {"type":"function", "function":{"name":"..."}} (current spec)
349+
// 3. legacy: {"type":"function", "name":"..."} (older / Anthropic-compat)
350+
// 4. double-encoded: "{\"type\":\"function\", ...}" (some clients serialize the object)
327351
//
328-
// The previous code unmarshalled all three into functions.Tool via
329-
// json.Unmarshal and then unconditionally set input.FunctionCall =
330-
// {"name": toolChoice.Function.Name}. That had two consequences:
352+
// The pre-#9559 code unmarshalled the string case through
353+
// json.Unmarshal([]byte(content), &functions.Tool{}), which:
354+
// - failed for plain string modes (so "required" / "none" were
355+
// silently ignored and tools stayed enabled regardless), but
356+
// - happened to handle shape 4 by accident.
357+
// It also could not parse shape 3 because functions.Tool has no
358+
// flat top-level Name field.
331359
//
332-
// - For string modes, json.Unmarshal([]byte("required"), &Tool{}) fails;
333-
// the error was silently discarded, name was "", and downstream
334-
// SetFunctionCallNameString("") meant the requested mode never applied.
335-
// - For the OpenAI-spec map shape, the json keys did not match
336-
// functions.Tool's field tags, so name was "" again.
337-
//
338-
// Mirror the parsing pattern from MergeOpenResponsesConfig (#9509) and
360+
// Mirror the parsing pattern from MergeOpenResponsesConfig (#9509),
339361
// route results through the existing input.FunctionCall string/map
340362
// dispatch downstream (see the switch on input.FunctionCall in this
341-
// same function). Tracked in #9508; sibling fix in #9526.
363+
// same function), and preserve the shape-4 fallback so non-spec
364+
// clients don't silently break. Tracked in #9508; sibling fix in #9526.
342365
switch content := input.ToolsChoice.(type) {
343366
case string:
344367
// "auto" is the default and needs no override. "none" and "required"
345368
// both reach SetFunctionCallString via the input.FunctionCall string
346369
// branch below; ShouldUseFunctions() then returns false for "none"
347-
// (tools disabled) and true for "required" (mode engaged), matching
348-
// the OpenAI spec. Before this fix "none" was silently ignored
349-
// (json.Unmarshal(`none`, &Tool{}) failed) so tools stayed enabled.
350-
if content != "" && content != "auto" {
351-
input.FunctionCall = content
370+
// (tools disabled) and true for "required" (mode engaged).
371+
//
372+
// If the string looks like a JSON object, try shape 4 first: parse
373+
// it as a tool_choice map and use the resulting name. Falling back
374+
// to mode-string handling when the parse yields no usable name keeps
375+
// genuinely-malformed input from accidentally engaging a mode.
376+
if content == "" || content == "auto" {
377+
break
352378
}
353-
case map[string]any:
354-
if tcType, ok := content["type"].(string); ok && tcType == "function" {
355-
var name string
356-
if fn, ok := content["function"].(map[string]any); ok {
357-
if n, ok := fn["name"].(string); ok {
358-
name = n
359-
}
360-
}
361-
if name == "" {
362-
if n, ok := content["name"].(string); ok {
363-
name = n
379+
if strings.HasPrefix(strings.TrimSpace(content), "{") {
380+
var nested map[string]any
381+
if err := json.Unmarshal([]byte(content), &nested); err == nil {
382+
if name := extractToolChoiceFunctionName(nested); name != "" {
383+
input.FunctionCall = map[string]any{"name": name}
384+
break
364385
}
365386
}
366-
if name != "" {
367-
input.FunctionCall = map[string]any{"name": name}
368-
}
387+
}
388+
input.FunctionCall = content
389+
case map[string]any:
390+
if name := extractToolChoiceFunctionName(content); name != "" {
391+
input.FunctionCall = map[string]any{"name": name}
369392
}
370393
}
371394
}

core/http/middleware/request_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,54 @@ var _ = Describe("SetModelAndConfig tool_choice parsing (chat completions)", fun
452452
})
453453
})
454454

455+
// Some non-spec clients send the object form serialized as a JSON string.
456+
// The pre-#9559 code accepted that by accident; this Context locks in
457+
// continued tolerance so those clients do not silently regress.
458+
Context("double-encoded tool_choice (JSON string of an object, non-spec)", func() {
459+
It("parses a serialized OpenAI-spec nested object", func() {
460+
// tool_choice value is itself a JSON-encoded string containing the
461+
// object form. Use json.Marshal of the inner blob so the escapes
462+
// are correct regardless of the test reader.
463+
inner := `{"type":"function","function":{"name":"get_weather"}}`
464+
encoded, err := json.Marshal(inner)
465+
Expect(err).ToNot(HaveOccurred())
466+
rec := postJSON(app, "/v1/chat/completions", chatReq(string(encoded)))
467+
468+
Expect(rec.Code).To(Equal(http.StatusOK))
469+
Expect(capturedConfig).ToNot(BeNil())
470+
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeTrue())
471+
Expect(capturedConfig.FunctionToCall()).To(Equal("get_weather"))
472+
})
473+
474+
It("parses a serialized legacy/Anthropic flat object", func() {
475+
inner := `{"type":"function","name":"get_weather"}`
476+
encoded, err := json.Marshal(inner)
477+
Expect(err).ToNot(HaveOccurred())
478+
rec := postJSON(app, "/v1/chat/completions", chatReq(string(encoded)))
479+
480+
Expect(rec.Code).To(Equal(http.StatusOK))
481+
Expect(capturedConfig).ToNot(BeNil())
482+
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeTrue())
483+
Expect(capturedConfig.FunctionToCall()).To(Equal("get_weather"))
484+
})
485+
486+
It("falls back to mode-string handling when the JSON string parses but has no usable name", func() {
487+
// A JSON-string that decodes to a map without a function name
488+
// should not engage specific-function forcing. We expect it to
489+
// fall through to the mode-string path; the resulting mode is
490+
// the raw blob (nonsense), but ShouldCallSpecificFunction stays
491+
// false - the invariant that matters.
492+
inner := `{"type":"function"}`
493+
encoded, err := json.Marshal(inner)
494+
Expect(err).ToNot(HaveOccurred())
495+
rec := postJSON(app, "/v1/chat/completions", chatReq(string(encoded)))
496+
497+
Expect(rec.Code).To(Equal(http.StatusOK))
498+
Expect(capturedConfig).ToNot(BeNil())
499+
Expect(capturedConfig.ShouldCallSpecificFunction()).To(BeFalse())
500+
})
501+
})
502+
455503
Context("malformed tool_choice", func() {
456504
It("is a no-op when type is missing", func() {
457505
rec := postJSON(app, "/v1/chat/completions",

0 commit comments

Comments
 (0)