Skip to content

Droid BYOK drops reasoning_content for DeepSeek V4 Flash (tool-call turns → 400 error) #1091

@RealNarcissus

Description

@RealNarcissus

Summary of the error

When using DeepSeek V4 Flash via BYOK (generic-chat-completion-api provider), the second and subsequent turns in a tool-call conversation fail with:

BYOK Error: 400 Error from provider (DeepSeek): The `reasoning_content` in the thinking mode must be passed back to the API.

DeepSeek V4 Flash with thinking mode enabled returns reasoning_content on assistant messages. When Droid replays the message history on the next API call, it strips this field. DeepSeek's API requires reasoning_content on every assistant message for tool-call turns, and rejects requests where it's missing.

Works: Single-turn queries, model responses without tool calls, models that don't generate reasoning_content (e.g. MiniMax, Kimi).

Fails: DeepSeek V4 Flash + tool calls + follow-up turn. V4 Pro also affected but intermittently.

Root Cause

In Droid's BYOK generic-chat-completion-api provider, when building the api_messages array for the next API request, the provider copies known fields (role, content, tool_calls) from stored messages but does not preserve reasoning_content. This is fine for most providers, but DeepSeek V4's thinking mode enforces a strict contract: reasoning_content from every assistant turn must be echoed back.

DeepSeek's own docs: https://api-docs.deepseek.com/guides/thinking_mode#tool-calls

"If your code does not correctly pass back reasoning_content, the API will return a 400 error."

Exact Fix (reference: Hermes Agent #16137, resolved)

Hermes Agent (NousResearch/hermes-agent) had this exact bug and fixed it with ~25 lines. The fix is in run_agent.py and consists of two parts:

Part 1: Detection (_needs_deepseek_tool_reasoning())

def _needs_deepseek_tool_reasoning(self) -> bool:
    provider = (self.provider or "").lower()
    model = (self.model or "").lower()
    return (
        provider == "deepseek"
        or "deepseek" in model
        or base_url_host_matches(self.base_url, "api.deepseek.com")
    )

For Droid's case, this should also check for OpenCode Go's endpoint since that's the proxy used by many users:

// Droid equivalent
function needsDeepseekToolReasoning(provider: string, model: string, baseUrl: string): boolean {
  return (
    provider === "deepseek" ||
    model.includes("deepseek") ||
    baseUrl.includes("api.deepseek.com") ||
    baseUrl.includes("opencode.ai/zen/go/v1") // OpenCode Go users
  );
}

Part 2: Preservation in message-building loop

The call site (Hermes Agent line ~10397):

api_messages = []
for msg in messages:
    api_msg = msg.copy()
    self._copy_reasoning_content_for_api(msg, api_msg)  # THIS IS THE KEY LINE
    # ... strip internal fields ...
    api_messages.append(api_msg)

Where _copy_reasoning_content_for_api (simplified):

def _copy_reasoning_content_for_api(self, source_msg, api_msg):
    if source_msg.get("role") != "assistant":
        return
    existing = source_msg.get("reasoning_content")
    if isinstance(existing, str):
        # Preserve verbatim (empty string → space to avoid rejection)
        api_msg["reasoning_content"] = existing or " "

Droid-specific fix location

In Droid's source, this needs to go wherever the BYOK generic-chat-completion-api provider builds the chat completion request body from stored messages. Likely in the buildRequestBody or convertToApiMessages function in the BYOK provider code path.

The fix is literally:

// In the message conversion loop, after copying msg to apiMsg:
+ if (needsDeepseekToolReasoning(provider, model, baseUrl) && msg.role === 'assistant' && msg.reasoning_content) {
+   apiMsg.reasoning_content = msg.reasoning_content;
+ }

Related Issues

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