Skip to content

feat(gateway): forward reasoning_effort to vllm - #1613

Open
qdanik wants to merge 1 commit into
gonka-ai:gateway-v4from
qdanik:qd/reasoning-effort
Open

feat(gateway): forward reasoning_effort to vllm#1613
qdanik wants to merge 1 commit into
gonka-ai:gateway-v4from
qdanik:qd/reasoning-effort

Conversation

@qdanik

@qdanik qdanik commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • reasoning_effort was enum-validated and then stripped on every route, and max — the one value DeepSeek-V4 defines beyond the OpenAI set — was rejected outright, so the field could not reach deepseek-ai/DeepSeek-V4-Flash-0731, the routed model that actually reads it.

  • Forward the field on that route only; every other route keeps the strip.

  • Fill in max when the caller omits the field. An explicit level always survives, including levels weaker than the default.

  • Add max to ReasoningEffortValidator, matching the vLLM wire enum.

  • Record reasoning: {"enabled": false} as reasoning_effort: "none" instead of dropping it silently.

  • Add DefaultLiteralParameter — the write-only-if-absent counterpart to the existing ForceLiteralParameter, which can only overwrite.

  • Document the route in docs/chat-api/deepseek-v4-flash-0731.md, including the value mapping the engine applies:

    wire value rendered effort prompt prefix
    none thinking off
    minimal / low / medium low empty string
    high / xhigh high 476 chars
    max max 526 chars

Why

vLLM's build_chat_params merges reasoning_effort into chat_template_kwargs itself and separately derives enable_thinking from it, so the top-level field is sufficient on its own and needs no mirroring the way Kimi's thinking does. That same derivation is why the other routes keep the strip: forwarding the field to a template that declares enable_thinking would silently flip thinking on or off while the effort level itself went nowhere.

The levels are prompt prefixes rather than sampling knobs — REASONING_EFFORT_PROMPTS maps each to a literal instruction block injected once at message index 0. An omitted field resolves to high, which is the level the model's own discussion thread names in its reasoning-loop reports, so the route defaults to the strongest prefix the encoder defines rather than inheriting that fallback. One consequence worth knowing when reading the table above: minimal, low and medium all render an empty prefix, so sending them asks for less reasoning than sending nothing.

reasoning: {"enabled": false} had to stop being dropped. It deleted the wrapper and wrote nothing, leaving the request indistinguishable from one that never mentioned reasoning — which the new default reads as permission to fill the gap. Without this, a client explicitly disabling reasoning would have received maximum reasoning.

No max_tokens guard was added, deliberately. Reasoning tokens do count against max_tokens in vLLM (vllm-project/vllm#28266) and this route has no budget knob — the tokenizer defines none, thinking_token_budget is stripped off-Kimi, and it is a logits processor that vLLM discards under the --speculative-config this model's card recommends. The Kimi-style floor was still the wrong thing to copy: its 256 threshold sits next to a budget that can be clamped, which does not exist here, so the equivalent action would be disabling reasoning outright. Epoch 364 gives no reason to: empty-stream share is 2% on DeepSeek against 2% on MiniMax-M2.7 and 3% on Kimi-K2.6, measured while the engine was already defaulting this route to high. If that share moves after rollout there will be a measured threshold to fit instead of a borrowed one.

On the prompt side the prefix is not new — the engine was already injecting the high block while we stripped the field, so the change adds 50 characters (~12 tokens) against a 400k-tokens configured window. Admission is unaffected either way: ContextTotalHint is parsed out of the host's own rejection message, so the gateway reads the real prompt size from the engine rather than predicting it.

Tests

  • TestNormalizeChatRequestForwardsReasoningEffortToDeepSeek — all seven wire values survive on the DeepSeek route, and chat_template_kwargs is not written (vLLM performs that merge; mirroring would write the key twice).
  • TestNormalizeChatRequestStripsReasoningEffortOffTheReasoningRoute — every value, including max, is stripped on Kimi, MiniMax and the empty route.
  • TestNormalizeChatRequestDefaultsDeepSeekReasoningEffortToMax — an omitted field arrives as max.
  • TestNormalizeChatRequestKeepsAnExplicitDeepSeekReasoningEffort — the default never overrules a caller's level, weaker levels included.
  • TestNormalizeChatRequestReasoningDisabledSurvivesTheDeepSeekDefaultreasoning: {"enabled": false} lands as none, not max.
  • TestNormalizeChatRequestDoesNotDefaultReasoningEffortOffTheReasoningRoute — no default leaks onto routes that strip the field.
  • TestReasoningValidatorEnabledFalseRecordsTheRefusal — the refusal is written as none rather than dropped.
  • TestReasoningEffortValidatorAccepts / TestNormalizeChatRequestRejectsInvalidReasoningEffortmax accepted, unknown strings still rejected.

Mutation-tested: reverting the default, weakening it to high, letting it overwrite an explicit value, restoring the silent loss of enabled:false, moving the model scope to Kimi, reverting to the universal strip, and removing max from the enum are each killed by the above.

Test plan

  • go build ./... clean
  • go test ./cmd/devshardctl/... -count=1 green across all four packages
  • gofmt clean on every touched file
  • 9 mutants introduced, 9 killed
  • Value mapping, prefix sizes and version floor verified against vLLM sources and the model card, cited in docs/chat-api/references.md

…t, defaulting to max instead of inheriting the engine's high
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant