Skip to content

DeepSeek thinking: low/medium collapse to high, and third-party hosts never reach thinking at all #583

Description

@dosco

ax 23.0.16 — three DeepSeek/thinking fixes, with exact locations

All line numbers are from the generated Go in
github.com/ax-llm/ax/packages/go@v0.0.0-20260817012209-94681b903c12/axllm.go.
Per axir-provenance.json that file is emitted ("target": "go", "enforced": true), so the changes belong in the IR/source that generates it —
the Go references are for locating the logic, not for patching directly.

Verified empirically against api.deepseek.com (model deepseek-v4-flash) and
against Together AI serving the same weights as
deepseek-ai/DeepSeek-V4-Flash-0731.


Fix 1 — thinking support is gated on an exact model-name match

Highest leverage: this one fix cascades and also resolves the tool_choice
problem in Fix 3 for DeepSeek-family models.

Location — axllm.go:12762-12766

v_is_flash = _core_eq(v_model, "deepseek-v4-flash")
v_is_pro   = _core_eq(v_model, "deepseek-v4-pro")
v_supports_thinking = _core_or(v_is_flash, v_is_pro)
v_is_reasoner = _core_eq(v_model, "deepseek-reasoner")
v_unsupported_tool_choice_left = _core_or(v_supports_thinking, v_is_reasoner)

Problem

The test is exact string equality. Every third-party host namespaces the model
id, so none of them match:

host model id v_supports_thinking
api.deepseek.com deepseek-v4-flash true
Together AI deepseek-ai/DeepSeek-V4-Flash-0731 false
OpenRouter deepseek/deepseek-v4-flash false

When it is false, two things silently go wrong further down:

  1. The thinking block is never emitted (the branch at 12767 is skipped), so a
    reasoning model runs with reasoning off.
  2. v_unsupported_tool_choice_left is also false, so the strip at
    12804-12813 never runs and a forced tool_choice survives — which
    independently suppresses reasoning (see Fix 3).

Suggested change

Normalize before comparing: lowercase, drop any vendor prefix before /, and
drop a trailing date/version suffix, then match on the resulting base name.

normalize("deepseek-ai/DeepSeek-V4-Flash-0731") -> "deepseek-v4-flash"
normalize("deepseek/deepseek-v4-flash")         -> "deepseek-v4-flash"
normalize("deepseek-v4-flash")                  -> "deepseek-v4-flash"

A caller-declared override (e.g. model_config.supportsThinking) would also
work, and is more robust for hosts with unpredictable naming.

Verification

With GJ_AGENT_MODEL=deepseek-ai/DeepSeek-V4-Flash-0731 and a thinking budget
set, the outgoing payload should contain "thinking": {"type": "enabled"} and
must not contain tool_choice.


Fix 2 — low and medium collapse to high

Location — axllm.go:12782-12791

v_is_xhigh = _core_eq(v_reasoning, "xhigh")
v_is_max   = _core_eq(v_reasoning, "max")
v_reasoning_is_max  = _core_or(v_is_xhigh, v_is_max)
v_budget_is_highest = _core_eq(v_budget, "highest")
v_is_max_effort = _core_or(v_reasoning_is_max, v_budget_is_highest)
if coreTruthy(v_is_max_effort) {
    coreSet(v_payload, "reasoning_effort", "max")
} else {
    coreSet(v_payload, "reasoning_effort", "high")   // every non-max level
}

Problem

Both branches below max write "high", so the effective ladder is
off / high / max with no middle. Measured on 23.0.16, one row per run:

configured on the wire
high reasoning_effort: "high"
medium reasoning_effort: "high"
low reasoning_effort: "high"

Suggested change

Mirror the Grok adapter, which already implements this correctly —
_provider_apply_grok_chat_quirks, axllm.go:13031-13045:

if none    -> "none"
if lowish  -> "low"
if medium  -> "medium"
if highish -> "high"

Apply the same shape in the DeepSeek adapter, keeping the existing
xhigh|max|highest -> "max" rung on top.

Why it matters

At high, one 8-step agent episode averages 9.2 minutes and 141k tokens.
Across a 339-episode benchmark that is roughly a day of wall clock. A real
medium is the difference between a suite we can run nightly and one we can't.


Fix 3 — the forced __axOutput call defeats thinking

Location — axllm.go:20630-20634

v_no_user_functions = _core_eq(v_fn_count, 0)
if coreTruthy(v_no_user_functions) {
    v_forced_function = Object()
    coreSet(v_forced_function, "name", "__axOutput")
    v_mode = v_forced_function        // becomes request "function_call"
}

Problem

When a program registers no user functions — the normal case for callers that
run their own tool protocol — ax forces the synthetic output call. On hosts
where thinking and forced tool selection conflict, forcing silently disables
reasoning. Measured directly against Together, same prompt each time
(correct answer 17:41):

request reasoning tokens answer
plain, reasoning_effort: max 527 17:41 ✓
unforced tools + reasoning_effort: max 1469 17:41 ✓
forced via tool_choice none 18:35 ✗
response_format: json_object / json_schema none wrong

Note the model called the function on every attempt without being forced,
and reasoned more when unforced — so forcing buys nothing here and costs the
reasoning.

Suggested change

Leave function_call as auto when the target model supports thinking, rather
than pinning it to __axOutput. ax already computes exactly this condition one
adapter over (v_unsupported_tool_choice_left, axllm.go:12766); the synthetic
output path just doesn't consult it.

For the DeepSeek family specifically, Fix 1 makes the existing strip at
axllm.go:12804-12813 handle this automatically. Fix 3 is the general case for
every other provider where the same conflict exists.


Reproduction

KEY=<together key>
M=deepseek-ai/DeepSeek-V4-Flash-0731
P='A train leaves at 14:20 at 87 km/h. Another leaves the same station at 15:05 at 112 km/h on the same track. At what clock time does the second catch the first?'

# reasons correctly: reasoning_tokens > 0, answer 17:41
curl -s https://api.together.ai/v1/chat/completions \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d "{\"model\":\"$M\",\"messages\":[{\"role\":\"user\",\"content\":\"$P\"}],\"reasoning_effort\":\"max\"}"

# no reasoning at all: reasoning_tokens absent, answer wrong
curl -s https://api.together.ai/v1/chat/completions \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d "{\"model\":\"$M\",\"messages\":[{\"role\":\"user\",\"content\":\"$P\"}],\"reasoning_effort\":\"max\",\"response_format\":{\"type\":\"json_object\"}}"

Check usage.completion_tokens_details.reasoning_tokens and the presence of
choices[0].message.reasoning.


Why this matters downstream

We published a public benchmark row for DeepSeek V4 Flash at 0.177 recall that
was, unknown to us at the time, produced with thinking silently off. It read as
a weak model when it was a misconfiguration. Fixes 1 and 3 are both cases where
reasoning disappears without any error, warning, or field in the response to
indicate it — that silence is the expensive part.

The 23.0.16 config-propagation fix already resolved the earlier half of this
(a caller-supplied thinkingTokenBudget now reaches the request), and the
thinking-replay fix cut our per-episode cost from ~118k to ~71k tokens. Both
are much appreciated.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions