anomalyco / anomalyco/opencode

`deepseek-v4-flash-free` (zen free tier): stream drops the final text delta before `tool_calls`; non-streaming is complete

Open
#40,959 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Description

deepseek-v4-flash-free via https://opencode.ai/zen/v1/chat/completions (free tier) drops the final text delta before tool_calls from streamed responses: the sentence before the tool call ends mid-word, no error, finish_reason: "tool_calls". The same request with stream: false returns the complete text. Non-deterministic — identical requests sometimes complete.

Plugins

None — raw HTTP, no client SDK involved.

OpenCode version

N/A (API-level; also reproduced via Vercel AI SDK streamText, pi's SDK streamSimple, and headless opencode run)

Steps to reproduce

repro.sh — curl + python3, no other deps:

#!/usr/bin/env bash
# Usage: ./repro.sh [runs]
set -u
KEY="${OPENCODE_API_KEY:-public}"          # free tier accepts "public"
N="${1:-8}"
PHRASE="The quick brown fox jumps over the lazy dog"

req() { # $1 = stream(true/false)
  curl -sS --max-time 90 \
    -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
    -d "{\"model\":\"deepseek-v4-flash-free\",\"messages\":[{\"role\":\"user\",\"content\":\"Say exactly '$PHRASE' then call the tool get_time with an empty object.\"}],\"stream\":$1,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_time\",\"parameters\":{\"type\":\"object\",\"properties\":{}}}}]}" \
    "https://opencode.ai/zen/v1/chat/completions"
}

stream_text() {
  python3 -c '
import sys, json
text = ""
for line in sys.stdin:
    line = line.strip()
    if not line.startswith("data:"): continue
    p = line[5:].strip()
    if p == "[DONE]": continue
    try: o = json.loads(p)
    except Exception: continue
    for c in o.get("choices", []):
        d = c.get("delta") or {}
        if isinstance(d.get("content"), str): text += d["content"]
print(text)'
}

trunc=0
for i in $(seq 1 "$N"); do
  body=$(req true)
  id=$(printf '%s' "$body" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
  text=$(printf '%s' "$body" | stream_text)
  if [[ "$text" == *"lazy dog"* ]]; then s="complete"
  elif [[ "$text" == *"fox"* ]]; then s="TRUNCATED"; trunc=$((trunc+1))
  else s="no-text"; fi
  printf 'run %2d  id=%-22s  %-9s  ...%s\n' "$i" "${id:0:22}" "$s" "${text: -32}"
  sleep 0.3
done
echo
echo "streaming: $trunc/$N truncated (expected tail: '$PHRASE')"
echo "--- non-streaming control (stream:false) ---"
req false | python3 -c 'import sys,json; o=json.load(sys.stdin); print("content:", o["choices"][0]["message"]["content"])'
Screenshot and/or share link

Output of one run:

run  1  id=router-c651e84ac345a25  TRUNCATED  ...e quick brown fox jumps over the
run  2  id=router-4b3abcc145d862d  TRUNCATED  ...e quick brown fox jumps over the
run  3  id=router-59f37695f6c301f  TRUNCATED  ...
run  4  id=router-906ca39fd5009d0  TRUNCATED  ...e quick brown fox jumps over the
run  5  id=router-4f990f930cbd9cd  TRUNCATED  ...e quick brown fox jumps over the
run  6  id=b7ea5f8c-1b71-4d5a-9d3  complete   ...rown fox jumps over the lazy dog
run  7  id=router-87d80b13af7041b  TRUNCATED  ...e quick brown fox jumps over the
run  8  id=router-b0831d8d0f852b9  TRUNCATED  ...e quick brown fox jumps over the

streaming: 7/8 truncated (expected tail: 'The quick brown fox jumps over the lazy dog')
--- non-streaming control (stream:false) ---
content: The quick brown fox jumps over the lazy dog

Second run: streaming: 6/6 truncated, non-streaming control complete.

Operating System

Linux (container); client-independent

Terminal

N/A

Additional evidence
  • Raw SSE of a truncated run (verbatim capture, no parser): content='The'content=' quick brown fox jumps over the' (last content delta) → tool_callsfinish_reason: tool_calls[DONE]. The tail ' lazy dog' never appears as a content delta. The full sentence IS present in the same stream's reasoning_content deltas and in the stream: false response — the model generated it; the SSE dropped it.
  • Truncation correlates with the response chunk id prefix. Across ~100 streamed runs with tools: every truncated response had a router-* id; uuid-prefixed responses were always complete. In the systematic matrix: 28/33 router-* runs truncated (~85%), 0/14 uuid runs.
  • Request parameters don't change it: reproduced with/without temperature, x-opencode-session, stream_options, max_tokens, and different tool-schema shapes.
  • Not a parsing artifact: with the Vercel AI SDK and pi's SDK, the SDK's emitted text matched the captured raw bytes byte-for-byte (both truncated).

Related: #40888 (streaming + tools truncated on the Go tier), #40176 (deepseek-v4-flash on Go drops the tool call after text — same text↔tool transition defect, opposite side).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the issue's repro.sh and compare the raw SSE for stream:true against the stream:false control, focusing on the content delta immediately before tool_calls. The issue is complete when the streamed response preserves the full text before the tool call across the affected router-* responses without regressing tool-call delivery.

Written by the indexing model from the issue text.

Assessment

Domain
api, backend-api-design
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
28/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.