Repository navigation
fix(server): accept tool_choice and parallel_tool_calls on the MLX chat path - #729
Merged
Merged
Conversation
…at path The MLX chat route accepted `tools` but refused `tool_choice` and `parallel_tool_calls` as unsupported fields, so an agent client that sends `tool_choice: "auto"` on every tools turn failed at its first turn on Apple Silicon. mlx_lm.server reads neither field (checked at v0.30.7, the bundle floor, and at v0.31.3 and v0.32.0): it renders every declared tool into the prompt and returns every call the model emits. The route now resolves both fields itself and proxies neither: - `auto` and an omitted choice forward the tools unchanged. - `none` withholds the tools from the child, so the prompt offers none. - `parallel_tool_calls: true` matches what mlx_lm does and is accepted. - `required`, a named function, and `parallel_tool_calls: false` ask for control mlx_lm cannot give, so they answer 400 `unsupported_field` naming the field, before any model load.
added 2 commits
October 13, 2026 18:16
…n MLX chat responses mlx_lm.server parses tool calls out of the model's output whether or not the request offered tools (the tokenizer decides, not the request), and it never limits how many calls it returns. Withholding tools from the prompt therefore does not guarantee a call-free answer, and the route could not honor `parallel_tool_calls: false` at all, so it refused the field. The MLX route now checks what the child sends back, as the other chat routes do: - `parallel_tool_calls: false` is accepted and dropped from the proxied body. A choice that carries a second call fails closed with 502 `upstream_error`; a stream ends with an error event after the first call. - A tool call in a response whose request offered the child no tools (`tool_choice: "none"`, or no `tools`) fails the same way. - The refusal of a forced `tool_choice` names the device rather than the MLX backend, because the same validation runs on every non-CUDA device.
Contributor
Author
|
Review round 2, head Gaps, both fixed with tests:
Risks:
Validation at this head: module 133 passed, and 12 of the new cases fail against |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On Apple Silicon the
/v1/chat/completionsroute proxies to anmlx_lm.serverchild. It acceptedtoolsbut refusedtool_choiceandparallel_tool_callswith400 unsupported_field. OpenCode, like most agent clients, sendstool_choice: "auto"on every turn that carries tools, so it failed at its first turn on a Mac. The SGLang route accepts the field, so the same client works on NVIDIA.What mlx_lm does with these fields
mlx_lm.serverreads neither field. Checked inmlx_lm/server.pyat v0.30.7 (thesglangbundle's floor,mlx-lm>=0.30.7), v0.31.3 and v0.32.0 (the latest release, and the version the bundle resolves today). It readstoolsfrom the body and renders every declared tool into the prompt through the chat template. It ignores unknown body keys, so it has no way to force a call or to cap the number of calls in a turn.It also detects tool calls from the model's tokenizer, not from the request: the text state machine switches into its tool state on the tokenizer's tool-call markers, and the collected calls are parsed and returned whether or not the request sent
tools. So withholding the tools from the prompt makes a call unlikely, not impossible, and a turn can carry any number of calls.Change
The MLX route accepts both fields, resolves them itself, proxies neither to the child, and checks what the child sends back:
tool_choiceomitted or"auto"parallel_tool_calls: trueparallel_tool_calls: falsetool_choice: "none", or notoolstoolswithheld from the child; a response that still carries a call fails closedtool_choice: "required"or a named function400 unsupported_field,param: tool_choice, before any model load"Fails closed" is
502 upstream_erroron a buffered response. On a stream, the calls already within the limit are delivered, then an error event and[DONE], and the child stream is closed. This matches the remote chat route, which raises when calls arrive undertool_choice: "none"or when more than one arrives underparallel_tool_calls: false. The local queued route enforces the same two rules: it withholds tools undernone, and its tool-call parser stops a stream at a second call.The
noneand no-toolscases are one rule, because the child receives the same body for both. Thetool_choicerefusal names the device rather than the MLX backend, because the same validation runs on every non-CUDA device. A malformedtool_choice(for examplerequiredwith no tools, or a function name that is not declared) still answersinvalid_request, as before.The CUDA (SGLang) and remote chat routes are unchanged.
Tests
packages/sie_server/tests/api/test_openai_local.pydrives the real route throughTestClient, so_validate_mlx_chat_body,_prepare_mlx_bodyand both response paths run unmodified. Only themlx_lm.serverchild is faked, as anhttpx.MockTransport. Its streams use mlx_lm's shape: one delta per completed call, carryingindex, then the finish event.auto,auto+parallel_tool_calls: true, andparallel_tool_calls: truealone: the child receives the tools and neither field, and both returned calls reach the client as a validChatCompletion. A streamingautoturn delivers both calls as validChatCompletionChunks.parallel_tool_calls: falsewith one returned call, buffered and streamed: the child receives the tools without the field, and the call reaches the client.none: the child receives neithertoolsnortool_choice.parallel_tool_calls: false, one call undertool_choice: "none", one call with notools. A buffered response answers the exact 502 envelope. A stream delivers only the calls within the limit, then the error event, and closes the child stream.requiredand a named function, onmpsand oncpu: the exact 400 envelope naming the field and the device, the child is never reached and no model is touched.The 12 new cases that touch the changed behaviour fail against the previous head and pass now. The module passes 133.
mise run test -- packages/sie_server/testsgives 11380 passed. Its 5 failures are in host-filesystem tests (config/test_serving_artifacts.py,core/test_model_loader_cache.py) that this change does not touch.mise run lintandmise run typecheckpass. Merged locally with #728, which also edits this route, the module passes 146.Live run on Apple Silicon
mise run serve -- -b sglangon an M3 Max, modelQwen/Qwen3.5-4B(MLX repomlx-community/Qwen3.5-4B-4bit), childmlx_lm0.32.0, throughsie_sdk.SIEClient,temperature: 0:"None after a tool turn" sends an earlier assistant
get_weathercall and its result, then asks for another city, withtoolsdeclared andtool_choice: "none". The child got no tools, yet the model emitted aget_weathercall and mlx_lm returned it. Before this change the route passed that call to the client.The agent-loop conformance check from the coding-agent example (#727), run against the same server and model:
tool_choice_auto,streaming_tool_call_deltas,parallel_tool_calls,tool_result_round_tripand the finish-reason and usage cases pass (8 pass).tool_choice_requiredis recorded as refused, which that check treats as an honest answer. The one failure isinclude_reasoning, a field this PR does not touch.Summary by CodeRabbit