Skip to content

relay: opencode/deepseek-v4-flash-free sends assistant message with missing content key (DeepSeek HTTP 400) #41165

Description

@jjeremycai

Summary

Long agentic sessions against DeepSeek via the opencode relay (opencode/deepseek-v4-flash-free) fail with HTTP 400 Invalid assistant message: content or tool_calls must be set after updating to next-16998+. The relay/serializer appears to emit an assistant message with the content key missing (not merely empty), which DeepSeek rejects. The identical session history runs cleanly through OpenRouter's DeepSeek endpoint, so this is specific to the opencode relay's DeepSeek serialization path. This is a regression — the same sessions worked on next-16974.

Environment

  • opencode version: 0.0.0-next-17002 (first failing build observed: 0.0.0-next-16998; last known-good: 0.0.0-next-16974)
  • OS: Darwin 27.0.0 (darwin arm64)
  • Terminal: TERM_PROGRAM=ghostty, TERM=xterm-ghostty, COLORTERM=truecolor
  • Shell: /bin/zsh
  • Install/channel: next (npm @opencode-ai/cli)
  • Active plugins: confirmo-plugin.ts, done-sound.js, herdr-agent-state.js, taskmaster.js
  • Model: opencode/deepseek-v4-flash-free (the opencode relay's alias for deepseek-v4-flash)

Reproduction

  1. Run a long agentic session on opencode/deepseek-v4-flash-free that accumulates many steps including reasoning-only assistant turns (ideally with at least one auto-compaction).
  2. Continue the session (e.g. POST /api/session/{id}/prompt with {"text":"continue"}).
  3. Every subsequent provider request fails with the same 400.

Minimal API repro on an affected session:

opencode2 api post /api/session/ses_0256611d1ffezr3HNUHWLSf3uJ/prompt --data '{"text":"continue"}'

Expected Behavior

The session continues normally. The relay should not send an assistant message with a missing content key to DeepSeek — reasoning-only or otherwise-empty assistant turns should be skipped, merged, or given a content value before sending.

Actual Behavior

Every provider request fails:

AI.Error: RequestExecutor.execute: Provider request failed with HTTP 400:
{"error":{"message":"Invalid assistant message: content or tool_calls must be set","type":"invalid_request_error","param":null,"code":"invalid_request_error"}}

A second affected session (4 auto-compactions, 194 assistant messages) produced the related variant:

HTTP 400: {"error":{"type":"invalid_request_error","message":"the message at position 159 with role 'assistant' must not be empty"}}

Stack (top frames):

at <anonymous> (../ai/src/route/executor.ts:232:25)
at SessionRunner.callModel (../core/src/session/runner/llm.ts:187:32)
at SessionRunner.runSteps (../core/src/session/runner/llm.ts:129:16)

Root-cause isolation (direct DeepSeek API tests)

I tested DeepSeek's API directly (https://api.deepseek.com/v1/chat/completions) to pin down exactly what it rejects:

Payload DeepSeek response
{"role":"assistant","content":""} (empty string) ✅ accepted
{"role":"assistant"} (content key missing) exact error above
{"role":"assistant","content":null} exact error above
{"role":"assistant","reasoning_content":"..."} (no content) exact error above
reasoning_content + content both present ✅ accepted

So DeepSeek requires the content key to be present and non-null (or tool_calls) on every assistant message — stricter than OpenAI. The opencode relay is emitting an assistant message with content missing or null (most likely a reasoning-only step serialized as {"role":"assistant","reasoning_content":"..."} with no content).

Cross-check: the same failing sessions, switched to openrouter/deepseek/deepseek-v4-flash (OpenRouter's DeepSeek endpoint), drained cleanly with zero errors — confirming the malformed payload is specific to the opencode relay path, not the underlying model.

Additional Context

  • Regression window: sessions worked at 2026-08-07 ~13:47 UTC on next-16974; first failure at 19:19 UTC on next-16998. Persists through next-17002.
  • Not a data problem: inspected the local SQLite store for the affected session — every assistant message has non-empty text or tool parts; no empty assistant message is stored. Parts breakdown: reasoning 28, step-start 32, step-finish 31, text 24, tool 38.
  • Likely cause: the empty message is created at serialization time, not stored. The 28 reasoning parts are the prime suspect — a reasoning-only step likely serializes to {role:"assistant"} without content/tool_calls for the DeepSeek/OpenAI-compatible path.
  • Not config-related: user config only sets model, default_agent, permissions, lsp, mcp.servers, and two unrelated custom providers (cloudflare-workers-ai, meta). No custom serialization config.
  • Scope: 4 sessions affected; the 2 still in the DB reproduced 100% of the time after a service restart. Same window also surfaced Failed to read deepseek/openai-compatible-chat stream and Failed to read opencode/openai-compatible-chat stream errors, suggesting a shared OpenAI-compatible serialization path changed.
  • Impact: affected sessions become unrecoverable via the UI/API against the opencode relay — every retry replays the same invalid history. Recoverable by switching the session to openrouter/deepseek/deepseek-v4-flash (e.g. POST /api/session/{id}/model) and resuming with opencode2 run --session <id> --model openrouter/deepseek/deepseek-v4-flash "continue".
  • Workaround: use OpenRouter's DeepSeek endpoint instead of the opencode relay for DeepSeek models.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions