Skip to content

local provider: no way to pass server-specific body params (Qwen3 enable_thinking) #12

Description

@Vallhalen

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:

  1. 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.
  2. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions