Skip to content

Commit f877942

Browse files
authored
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:{...}}.
1 parent f5eb13d commit f877942

2 files changed

Lines changed: 180 additions & 3 deletions

File tree

core/http/middleware/request.go

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -614,10 +614,31 @@ func MergeOpenResponsesConfig(config *config.ModelConfig, input *schema.OpenResp
614614
}
615615
// "auto" is default - let model decide
616616
case map[string]any:
617-
// Specific tool: {type:"function", name:"..."}
617+
// Specific tool. OpenAI spec nests the function name under "function":
618+
// {"type":"function", "function":{"name":"..."}}
619+
// Legacy/Anthropic-compat form puts it at the top level:
620+
// {"type":"function", "name":"..."}
621+
// The old code only handled the legacy shape AND used the wrong
622+
// setter (SetFunctionCallString writes the mode field; the
623+
// specific-function name lives in a separate field read by
624+
// ShouldCallSpecificFunction / FunctionToCall). Net effect: a
625+
// correctly-formed OpenAI tool_choice never engaged grammar-based
626+
// forcing, the model got the tools but no selection hint, and
627+
// streamed raw JSON as delta.content instead of delta.tool_calls.
618628
if tcType, ok := tc["type"].(string); ok && tcType == "function" {
619-
if name, ok := tc["name"].(string); ok {
620-
config.SetFunctionCallString(name)
629+
var name string
630+
if fn, ok := tc["function"].(map[string]any); ok {
631+
if n, ok := fn["name"].(string); ok {
632+
name = n
633+
}
634+
}
635+
if name == "" {
636+
if n, ok := tc["name"].(string); ok {
637+
name = n
638+
}
639+
}
640+
if name != "" {
641+
config.SetFunctionCallNameString(name)
621642
}
622643
}
623644
}

core/http/middleware/request_test.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,159 @@ var _ = Describe("SetModelAndConfig middleware", func() {
150150
})
151151
})
152152
})
153+
154+
// ---------------------------------------------------------------------------
155+
// MergeOpenResponsesConfig — tool_choice parsing
156+
// ---------------------------------------------------------------------------
157+
//
158+
// The OpenAI chat/completions spec nests the function name under "function":
159+
// {"type":"function", "function":{"name":"my_function"}}
160+
// The legacy Anthropic-compat shape puts it at the top level:
161+
// {"type":"function", "name":"my_function"}
162+
// Both need to reach SetFunctionCallNameString (not SetFunctionCallString,
163+
// which is the mode field "none"/"auto"/"required").
164+
//
165+
// These specs assert both shapes populate the specific-function name and that
166+
// downstream predicates (ShouldCallSpecificFunction, FunctionToCall) return
167+
// the expected values so grammar-based forcing actually engages.
168+
var _ = Describe("MergeOpenResponsesConfig tool_choice parsing", func() {
169+
var cfg *config.ModelConfig
170+
171+
BeforeEach(func() {
172+
cfg = &config.ModelConfig{}
173+
})
174+
175+
Context("string tool_choice", func() {
176+
It("sets mode to required for tool_choice=\"required\"", func() {
177+
req := &schema.OpenResponsesRequest{ToolChoice: "required"}
178+
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
179+
180+
// "required" is a mode, not a specific function.
181+
Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
182+
// ShouldUseFunctions must be true so tools are sent to the model.
183+
Expect(cfg.ShouldUseFunctions()).To(BeTrue())
184+
})
185+
186+
It("leaves config untouched for tool_choice=\"auto\"", func() {
187+
req := &schema.OpenResponsesRequest{ToolChoice: "auto"}
188+
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
189+
190+
Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
191+
Expect(cfg.FunctionToCall()).To(Equal(""))
192+
})
193+
194+
It("leaves config untouched for tool_choice=\"none\"", func() {
195+
req := &schema.OpenResponsesRequest{ToolChoice: "none"}
196+
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
197+
198+
Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
199+
Expect(cfg.FunctionToCall()).To(Equal(""))
200+
})
201+
})
202+
203+
Context("specific-function tool_choice (OpenAI spec shape)", func() {
204+
It("parses {type:function, function:{name:...}} and sets the specific-function name", func() {
205+
req := &schema.OpenResponsesRequest{
206+
ToolChoice: map[string]any{
207+
"type": "function",
208+
"function": map[string]any{"name": "get_weather"},
209+
},
210+
}
211+
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
212+
213+
// This is the key invariant the fix restores: a correctly-formed
214+
// OpenAI tool_choice must result in ShouldCallSpecificFunction()=true.
215+
Expect(cfg.ShouldCallSpecificFunction()).To(BeTrue())
216+
Expect(cfg.FunctionToCall()).To(Equal("get_weather"))
217+
})
218+
219+
It("prefers the nested function.name over a stray top-level name", func() {
220+
// Defense-in-depth: both shapes present, OpenAI spec wins.
221+
req := &schema.OpenResponsesRequest{
222+
ToolChoice: map[string]any{
223+
"type": "function",
224+
"function": map[string]any{"name": "correct_name"},
225+
"name": "legacy_name",
226+
},
227+
}
228+
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
229+
230+
Expect(cfg.FunctionToCall()).To(Equal("correct_name"))
231+
})
232+
})
233+
234+
Context("specific-function tool_choice (legacy Anthropic-compat shape)", func() {
235+
It("parses {type:function, name:...} and sets the specific-function name", func() {
236+
req := &schema.OpenResponsesRequest{
237+
ToolChoice: map[string]any{
238+
"type": "function",
239+
"name": "get_weather",
240+
},
241+
}
242+
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
243+
244+
Expect(cfg.ShouldCallSpecificFunction()).To(BeTrue())
245+
Expect(cfg.FunctionToCall()).To(Equal("get_weather"))
246+
})
247+
})
248+
249+
Context("malformed tool_choice", func() {
250+
It("is a no-op when type is missing", func() {
251+
req := &schema.OpenResponsesRequest{
252+
ToolChoice: map[string]any{
253+
"function": map[string]any{"name": "get_weather"},
254+
},
255+
}
256+
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
257+
258+
Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
259+
})
260+
261+
It("is a no-op when type is not \"function\"", func() {
262+
req := &schema.OpenResponsesRequest{
263+
ToolChoice: map[string]any{
264+
"type": "object",
265+
"function": map[string]any{"name": "get_weather"},
266+
},
267+
}
268+
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
269+
270+
Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
271+
})
272+
273+
It("is a no-op when name is missing from both shapes", func() {
274+
req := &schema.OpenResponsesRequest{
275+
ToolChoice: map[string]any{
276+
"type": "function",
277+
"function": map[string]any{},
278+
},
279+
}
280+
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
281+
282+
Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
283+
Expect(cfg.FunctionToCall()).To(Equal(""))
284+
})
285+
286+
It("is a no-op when name is empty string", func() {
287+
req := &schema.OpenResponsesRequest{
288+
ToolChoice: map[string]any{
289+
"type": "function",
290+
"function": map[string]any{"name": ""},
291+
},
292+
}
293+
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
294+
295+
Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
296+
})
297+
})
298+
299+
Context("nil tool_choice", func() {
300+
It("is a no-op", func() {
301+
req := &schema.OpenResponsesRequest{ToolChoice: nil}
302+
Expect(MergeOpenResponsesConfig(cfg, req)).To(Succeed())
303+
304+
Expect(cfg.ShouldCallSpecificFunction()).To(BeFalse())
305+
Expect(cfg.FunctionToCall()).To(Equal(""))
306+
})
307+
})
308+
})

0 commit comments

Comments
 (0)