Skip to content

[Bug] DeepSeek V4 thinking-mode round-trip fails deterministically — root cause found: PrependThinkingToAssistant hook and ThinkingPrepender capability are fully implemented but never wired into the request pipeline #104

Description

@yingpinggan

Summary

When using deepseek-v4-pro with thinking/reasoning mode (the default), every request after the first one that involves tool use fails with:

The content[].thinking in the thinking mode must be passed back to the API.

This is 100% reproducible — either on the very first thinking-mode turn of a fresh conversation, or immediately after Codex compacts/rewrites its context window mid-session. Once it starts, every retry fails identically and the conversation never recovers without starting a new session.

I traced this to root cause in the source and believe I've found the exact gap: the code to fix this already exists, is unit-tested, and is simply never called.
Reproduction

Minimal repro — fresh conversation, req_messages=5:

15:25:49.891 INFO 流式请求完成 model=deepseek-v4-pro ... output_tokens=533 duration=7.87s ← turn 1 succeeds
15:25:50.791 ERROR 提供商错误 status=400 error="The content[].thinking..." req_messages=5 ← turn 2 fails immediately, then loops forever

Also reproducible mid-session, immediately after Codex compacts context (cache hit rate collapses from ~100% to 5.7%, input_total drops sharply — evidence of history rebuild):

15:15:02 input_total=243428 cache_hit_rate~100%
15:17:44 input_total=183257 input_fresh=172761 cache_hit_rate=5.7% ← compaction event
15:17:50 input_total=20878 duration=5.5s ← succeeds
15:17:51 status=400 error="thinking..." req_messages=22 ← fails immediately, loops forever

Disabling all reasoning-related config on the model (default_reasoning_level, supported_reasoning_levels, supports_reasoning_summaries, default_reasoning_summary) has no effect — identical failure. This confirms these are client-facing capability-advertisement fields only, not something that stops DeepSeek V4 Pro from reasoning by default. The fix is not available at the config level
Root cause trace

  1. internal/extension/plugin/capabilities.go:114-117 defines a ThinkingPrepender interface:

go
type ThinkingPrepender interface {
PrependThinkingForToolUse(messages []format.CoreMessage, toolCallID string, pendingSummary []openai.ReasoningItemSummary, sessionState any) []format.CoreMessage
PrependThinkingForAssistant(blocks []format.CoreContentBlock, pendingSummary []openai.ReasoningItemSummary, sessionState any) []format.CoreContentBlock
}

  1. internal/extension/deepseek_v4/plugin.go:190-223 — DSPlugin fully implements both methods, with real logic: check for a pending reasoning summary from the OpenAI Responses payload, fall back to per-tool-call/per-text cached thinking blocks in session state (state.go), and finally fall back to an empty placeholder (RequiredThinkingBlock()) with a warning log. This is solid, deliberate implementation — and has dedicated unit tests in deepseek_v4_test.go.

  2. internal/extension/plugin/registry.go:24-35 — the Registry struct maintains typed slices for every dispatchable capability (errorTransformers, sessionProviders, logConsumers, etc.) — but there is no thinkingPrependers []ThinkingPrepender field.

  3. internal/extension/plugin/registry.go:94-107 — the plugin-registration block does if v, ok := p.(X); ok { r.xSlice = append(...) } for every other capability interface — there is no equivalent check for ThinkingPrepender. A plugin implementing it is silently never collected.

  4. internal/format/adapter.go:194-196 — separately, CorePluginHooks defines a PrependThinkingToAssistant hook slot, matching the naming/shape of other hooks like MutateCoreRequest.

  5. internal/format/adapter.go:244-246 — WithDefaults() gives it a no-op default, same pattern as every other hook.

  6. Confirmed via full-repo search — hooks.PrependThinkingToAssistant( is never called anywhere in the codebase. Compare to its sibling hook MutateCoreRequest, which is correctly called from four separate protocol adapters at the point outbound requests are built:

internal/protocol/anthropic/adapter.go:262
internal/protocol/chat/adapter.go:68
internal/protocol/google/adapter.go:85
internal/protocol/openai/adapter.go:180

PrependThinkingToAssistant has zero call sites anywhere in the pipeline.

Conclusion

The DeepSeek V4 thinking round-trip logic is fully implemented, tested, and even has a dedicated hook slot reserved for it in the core plugin architecture — but it's disconnected at two separate points:

Registry never collects plugins implementing ThinkingPrepender (no slice, no registration check).
Even if it did, no protocol adapter calls hooks.PrependThinkingToAssistant(...), unlike its sibling MutateCoreRequest, which is wired correctly in 4 places.
Suggested fix

Mirror the existing TransformError/errorTransformers pattern for ThinkingPrepender:

Add thinkingPrependers []ThinkingPrepender to Registry (registry.go ~line 30).
Register it alongside the other capability checks (registry.go ~line 98-107):
go
if v, ok := p.(ThinkingPrepender); ok {
r.thinkingPrependers = append(r.thinkingPrependers, v)
}
Add a dispatch method on Registry, mirroring TransformError's shape, that calls PrependThinkingForAssistant/PrependThinkingForToolUse for each registered plugin enabled for the current model.
Wire hooks.PrependThinkingToAssistant (wherever CorePluginHooks gets constructed per-request/per-model) to call the new Registry dispatch method.
Call a.hooks.PrependThinkingToAssistant(ctx, req) in the same protocol adapters where MutateCoreRequest is already called — at minimum internal/protocol/openai/adapter.go (used by Codex) and internal/protocol/anthropic/adapter.go.

Given the DeepSeek plugin's side of this is already correct and tested, this looks like a contained fix — connecting existing, working pieces rather than writing new logic.

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