First, thank you for --provider local. I pointed quackd at a self-hosted vLLM
serving Qwen/Qwen3-32B-AWQ on my own machine and find-and-kick succeeded on
the first attempt, with no changes on my side. Tool calling was flawless with the
--enable-auto-tool-choice --tool-call-parser hermes pair your docs/local-llms.md
recommends. The scripted pilot and the MuJoCo body both worked out of the box too.
The problem is not correctness, it is that there is no way to turn off the model's
reasoning mode, and for Qwen3 that switch lives in the request body.
What I measured
Same goal (find-and-kick, microduck:sim2d), two pilots:
| pilot |
steps |
LLM calls |
output tokens |
wall time |
--provider fake (scripted) |
4 |
5 |
80 |
under a second |
--provider local, Qwen3-32B-AWQ |
4 |
6 |
3282 |
about 5 minutes |
Per-step latencies on the local run were 150.2 s, 64.6 s and 26.1 s. The output
token counts for those steps were 1717, 748 and 294. Almost all of it is visible
chain-of-thought that ends up in the transcript, for example:
Wait, the user's last message says to call exactly one tool per turn. But I need to do multiple actions here. However, the rules state that only one tool can be called per turn...
The decisions themselves were good. The model just deliberates at length before
each of them, and the loop calls it once per step.
Why it cannot be fixed on the server side
Qwen3 defaults to thinking mode, and the documented way to disable it is a
chat template argument, not a sampling parameter:
{"chat_template_kwargs": {"enable_thinking": false}}
vLLM accepts this in the request body. It is not a vllm serve flag, so I cannot
set it once at serve time, and it is not something a proxy can inject without
sitting in the middle of every call. It has to come from the client.
The same shape of problem exists for other local servers: llama.cpp and Ollama
each take their own body fields, and vLLM has more of them (guided decoding,
for instance).
Where it stops today
OpenAICompatProvider._params() builds the body from a fixed set of keys:
params: dict[str, Any] = {
"model": self.model,
"messages": render_messages(system, history),
"tools": render_tools(tools),
}
if self.tool_choice and self.tool_choice != "none":
params["tool_choice"] = self.tool_choice
if self.send_parallel_flag:
params["parallel_tool_calls"] = False
if self.reasoning_effort:
params["reasoning_effort"] = self.reasoning_effort
return params
LocalProvider.__init__ forwards base_url, tool_choice and vision, and
nothing else reaches the server. There is already precedent for a
provider-specific knob in reasoning_effort, but it is OpenAI's spelling and
does not help here.
Proposal
A single opaque passthrough, merged last so it can also override a default:
# QUACKD_EXTRA_BODY='{"chat_template_kwargs": {"enable_thinking": false}}'
if self.extra_body:
params["extra_body"] = {**params.get("extra_body", {}), **self.extra_body}
fed by an env var and/or a CLI flag:
quackd run find-and-kick --provider local \
--base-url http://gpu-box:8011/v1 --model Qwen/Qwen3-32B-AWQ \
--extra-body '{"chat_template_kwargs": {"enable_thinking": false}}'
The OpenAI Python SDK already supports extra_body on
chat.completions.create, so this is a passthrough rather than a new code path,
and it stays out of the way of every provider that does not use it.
Two details worth deciding, and I am happy to follow whichever you prefer:
- Scope. Local-only is enough for this problem, but putting it on
OpenAICompatProvider would cover the hosted providers too, at the cost of
letting people send arbitrary fields to a paid API.
- Responses API.
_params_responses() would need the same treatment for
QUACKD_OPENAI_API=responses, or the flag should error there rather than be
silently dropped.
I would rather not carry a fork for this, so if you are open to it I will send a
PR: the passthrough, a test asserting the field reaches the request, and a line
in docs/local-llms.md next to the Qwen3 example, which is exactly where
somebody hits this.
Environment
- quackd from PyPI via
uvx, 10.09.2026
- vLLM serving
Qwen/Qwen3-32B-AWQ, --enable-auto-tool-choice --tool-call-parser hermes, --max-model-len 16384
- NVIDIA GB10 (Grace Blackwell, arm64), client on Windows
microduck:sim2d and microduck:mujoco, both fine
First, thank you for
--provider local. I pointed quackd at a self-hosted vLLMserving
Qwen/Qwen3-32B-AWQon my own machine andfind-and-kicksucceeded onthe first attempt, with no changes on my side. Tool calling was flawless with the
--enable-auto-tool-choice --tool-call-parser hermespair yourdocs/local-llms.mdrecommends. The scripted pilot and the MuJoCo body both worked out of the box too.
The problem is not correctness, it is that there is no way to turn off the model's
reasoning mode, and for Qwen3 that switch lives in the request body.
What I measured
Same goal (
find-and-kick,microduck:sim2d), two pilots:--provider fake(scripted)--provider local, Qwen3-32B-AWQPer-step latencies on the local run were 150.2 s, 64.6 s and 26.1 s. The output
token counts for those steps were 1717, 748 and 294. Almost all of it is visible
chain-of-thought that ends up in the transcript, for example:
The decisions themselves were good. The model just deliberates at length before
each of them, and the loop calls it once per step.
Why it cannot be fixed on the server side
Qwen3 defaults to thinking mode, and the documented way to disable it is a
chat template argument, not a sampling parameter:
{"chat_template_kwargs": {"enable_thinking": false}}vLLM accepts this in the request body. It is not a
vllm serveflag, so I cannotset it once at serve time, and it is not something a proxy can inject without
sitting in the middle of every call. It has to come from the client.
The same shape of problem exists for other local servers: llama.cpp and Ollama
each take their own body fields, and vLLM has more of them (guided decoding,
for instance).
Where it stops today
OpenAICompatProvider._params()builds the body from a fixed set of keys:LocalProvider.__init__forwardsbase_url,tool_choiceandvision, andnothing else reaches the server. There is already precedent for a
provider-specific knob in
reasoning_effort, but it is OpenAI's spelling anddoes not help here.
Proposal
A single opaque passthrough, merged last so it can also override a default:
fed by an env var and/or a CLI flag:
The OpenAI Python SDK already supports
extra_bodyonchat.completions.create, so this is a passthrough rather than a new code path,and it stays out of the way of every provider that does not use it.
Two details worth deciding, and I am happy to follow whichever you prefer:
OpenAICompatProviderwould cover the hosted providers too, at the cost ofletting people send arbitrary fields to a paid API.
_params_responses()would need the same treatment forQUACKD_OPENAI_API=responses, or the flag should error there rather than besilently dropped.
I would rather not carry a fork for this, so if you are open to it I will send a
PR: the passthrough, a test asserting the field reaches the request, and a line
in
docs/local-llms.mdnext to the Qwen3 example, which is exactly wheresomebody hits this.
Environment
uvx, 10.09.2026Qwen/Qwen3-32B-AWQ,--enable-auto-tool-choice --tool-call-parser hermes,--max-model-len 16384microduck:sim2dandmicroduck:mujoco, both fine