Repository navigation
Description
[opencode-go] /chat/completions streams for deepseek-v4.1-flash end mid-generation without finish_reason or [DONE] (clean EOF), ~6–9% of calls
Summary
Between 2026-10-19 15:41 and 17:05 (UTC+8) three concurrent agent sessions using the opencode Go gateway (https://opencode.ai/inference/go/v1/chat/completions, model deepseek-v4.1-flash) had 42 chat-completion streams end after emitting content but without the terminal finish_reason chunk and without data: [DONE] (6.3 %–8.6 % of all opencode-go calls per session). HTTP status was 200, the response ended normally at the HTTP level (HTTP/1.1 + Transfer-Encoding: chunked), and no error frame was sent. Strict OpenAI-compatible clients hard-fail on this (OpenAI completions stream closed before a finish_reason was received); tolerant clients mis-record it as an empty/unknown finish and stop silently (see #41469).
What the client observes
POST https://opencode.ai/inference/go/v1/chat/completions,stream: true,stream_options.include_usage: true,reasoning_effort: max, tools attached →200 OK,content-type: text/event-stream,server: cloudflare,x-zen-model: deepseek-v4.1-flash.- Content deltas (thinking and/or text, sometimes partial tool-call arguments) are delivered normally.
- The response then ends: no chunk with a non-null
choices[0].finish_reason, nodata: [DONE], no aggregatedusage, no error event.
Why this is not a client bug or a network cut (verified locally)
The client is omp 18.12.2 on Bun 1.4.3 (Fedora 44). Its guard only fires when no finish_reason chunk and no [DONE] were seen although content was produced (its [DONE]-aware variant; the earlier false-positive class was fixed upstream in can1357/oh-my-pi#9434).
| Case | Local experiment result (same runtime) | Client message |
|---|---|---|
| client aborts mid-stream | reader.read() throws (Error: probe-abort) |
Request was aborted / Interrupted by user |
| socket cut / chunked body without terminator | reader.read() throws ECONNRESET: The socket connection was closed unexpectedly... |
network error surfaced as such |
| server completes the HTTP response without terminal frames | reader.read() returns done=true (clean EOF) |
OpenAI completions stream closed before a finish_reason was received ← this report |
Since the response is chunked HTTP/1.1, a clean EOF requires the server to send the terminating chunk — i.e. the response was finished by the server side, not torn down. Stalled-stream watchdog timeouts in the client produce a different message (... stream stalled while waiting for the next event, default 300 s), and the failures here happened 5–49 s in, with tokens flowing throughout.
Rates and samples (gateway only; official DeepSeek API not involved in this data)
| Session (started, UTC+8) | opencode-go calls | truncated | rate |
|---|---|---|---|
| A (14:37) | 253 | 16 | 6.3 % |
| B (16:17) | 106 | 9 | 8.5 % |
| C (16:21) | 151 | 13 | 8.6 % |
| time (UTC+8) | duration | ttft | content already streamed | usage | gateway response id |
|---|---|---|---|---|---|
| 16:41:51 | 14.6 s | 2.5 s | thinking 3 655 chars | none | gw-a697b26c5509-520a69 |
| 16:48:42 | 40.7 s | 1.3 s | thinking 5 182 + text 2 503 chars | none | gw-a697b26c5509-58d0e8 |
| 16:52:09 | 11.4 s | 1.7 s | thinking 2 489 chars | none | gw-a697b26c5509-5940d7 |
| 17:02:33 | 12.8 s | 2.1 s | thinking 6 170 + text 220 chars | none | gw-a697b26c5509-59fb34 |
| 17:04:50 | 13.4 s | 2.0 s | thinking 4 139 + text 152 + tool call (args never completed) | none | gw-a697b26c5509-5a2ad8 |
All 42 events were auto-retried by the client (same model, plain recovery) and recovered, so the user-visible effect is a wasted call plus a red error line — but a client that treats a clean EOF as success (like opencode's own loop, see #41469) silently stops mid-task instead.
Control: same model family via the official DeepSeek API does not reproduce
To rule out «the DeepSeek upstream is just like that», we injected the same kind of load into https://api.deepseek.com/chat/completions with deepseek-flash (= DeepSeek-V4.1-Flash) and into the Go gateway, in parallel, with one script:
| arm | official requests | official truncated | gateway requests | gateway truncated |
|---|---|---|---|---|
| short prompt, concurrency 3 | 30 | 0 | 30 | 0 |
| keep-alive single connection, growing multi-turn, ~1 M-token cache-warm prompt, tools | 6 | 0 | 6 | 0 |
| 1.7 MB uncached prompt + 12 tool schemas | 3 | 0 | 3 | 0 |
| smoke | 3 | 0 | 3 | 0 |
Both channels' successful streams end identically — final chunk with non-null finish_reason plus usage, then data: [DONE] — which matches the documented DeepSeek streaming contract. So the truncated shape is an anomaly, not a provider dialect we should tolerate.
Questions / requests
- Can the Go gateway itself end a stream without a terminal frame — e.g. an upstream timeout in the middle of a stream, an upstream 4xx/5xx after the first byte, node hand-off/rebalancing, admission shedding, or a response budget?
- When the upstream truncates, does the gateway relay the truncation as a clean end of the SSE stream? If so, could it instead emit an SSE error event (or close abortively) so strict clients do not parse a truncated turn as a completed one?
- Please look up the failing requests by
x-opencode-log-idfor 2026-10-19 15:30–17:10 (UTC+8). We have exact timestamps, durations and response ids (gw-…, listed above) and can re-capture per-request headers with our debug flag if you tell us which identifier you need. - Which upstream serves
deepseek-v4.1-flashon the Go plan, and does that path go throughapi.deepseek.com? (We ask because the official API showed 0/42 while the gateway showed 6–9 %.)
Related: #41469 (clean EOF → finish unknown, silent stops), #43617 (Zen/Go gateway missing stop_reason on the Anthropic-compat path), can1357/oh-my-pi#9433, earendil-works/pi#8460.
Environment
- client: omp 18.12.2 (
@oh-my-pi/pi-coding-agent, npmlatest) on Bun 1.4.3, Fedora 44 x86_64 - gateway:
https://opencode.ai/inference/go/v1, Cloudflare edge; plan quota not exhausted (5 h 13 %, weekly 6 %, monthly 60 % used) - window: 2026-10-19 15:41–17:05 UTC+8, 42 events across 3 sessions
Reproduction script (Python 3, stdlib only)
#!/usr/bin/env python3
"""SSE termination probe for DeepSeek-family chat endpoints.
Detects streams that end without the two terminal frames the DeepSeek streaming
contract requires: (a) a final chunk with non-null choices[0].finish_reason
(carrying the aggregated usage), and (b) the `data: [DONE]` sentinel.
Usage:
DS_API_KEY=sk-... OPENCODE_GO_API_KEY=oc_sk-... \
python3 ds_stream_probe.py --arm both --mode small --count 10 --concurrency 2
DS_API_KEY=sk-... python3 ds_stream_probe.py --arm official --mode big --count 3
DS_API_KEY=sk-... python3 ds_stream_probe.py --arm official --mode reuse --count 6
Notes:
* mode=small -> short prompt, mode=big -> ~1.7 MB uncached prompt,
mode=reuse -> one keep-alive connection, growing multi-turn conversation.
* raw response headers + tail bytes of every request are written to --out.
"""
import argparse, http.client, json, os, threading, time
ARMS = {
"official": dict(host="api.deepseek.com", path="/chat/completions",
model="deepseek-flash", key_env="DS_API_KEY", extra={}),
"gateway": dict(host="opencode.ai", path="/inference/go/v1/chat/completions",
model="deepseek-v4.1-flash", key_env="OPENCODE_GO_API_KEY",
extra={"x-stainless-timeout": "300"}),
}
ASK = ("Think step by step at length, then answer: compute 37*941, 128*256, "
"999*999, 1234*5678 and verify each result by a second method.")
FILLER = "\n".join(
f"src/app/mod{i%71}/file{i}.ts: export const v{i} = {i}; // component {i}"
for i in range(18000))
TOOLS = [dict(type="function", function=dict(
name=f"tool_{i}", description="Probe tool. " + "detail " * 30,
parameters=dict(type="object", required=["path"], properties=dict(
path=dict(type="string"), mode=dict(type="string", enum=["r", "w"])))))
for i in range(12)]
STATS, LOCK = [], threading.Lock()
def body_for(mode, model):
if mode == "small":
msgs, mx = [dict(role="user", content=ASK)], 8000
elif mode == "big":
msgs, mx = [dict(role="user", content=FILLER + "\n\n" + ASK)], 20000
else: # reuse
msgs, mx = [dict(role="system", content=FILLER)], 4000
return dict(model=model, messages=msgs, stream=True,
stream_options=dict(include_usage=True),
reasoning_effort="max", max_tokens=mx, tools=TOOLS)
def open_stream(arm, mode, model, conc_idx=1):
cfg = ARMS[arm]
key = os.environ.get(cfg["key_env"], "")
assert key, f"missing {cfg['key_env']}"
conn = http.client.HTTPSConnection(cfg["host"], timeout=900)
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json",
"Accept": "text/event-stream", "User-Agent": "omp/18.12.2",
"x-opencode-session": f"dsprobe-{mode}-{int(time.time())}-{conc_idx}",
**{k: v for k, v in cfg["extra"].items() if v is not None}}
payload = json.dumps(body_for(mode, model)).encode()
conn.request("POST", cfg["path"], body=payload, headers=headers)
return conn, conn.getresponse()
def read_stream(resp, t0):
"""Consume SSE; return (chunks, finish_reasons, saw_done, usage, ttft, tail_bytes)."""
chunks, fin, done, usage, ttft, tail = 0, [], False, None, None, bytearray()
while True:
line = resp.readline()
if not line:
break
tail.extend(line)
del tail[:-2000]
if not line.endswith(b"\n"): # response cut inside an SSE frame
return chunks, fin, done, usage, ttft, bytes(tail)
s = line.strip()
if not s.startswith(b"data:"):
continue
p = s[5:].strip()
if p == b"[DONE]":
done = True
continue
try:
j = json.loads(p)
except Exception:
continue
if j.get("usage"):
usage = j["usage"]
ch = (j.get("choices") or [{}])[0]
if ch.get("finish_reason"):
fin.append(ch["finish_reason"])
if ch.get("delta"):
chunks += 1
if ttft is None:
ttft = time.time() - t0
return chunks, fin, done, usage, ttft, bytes(tail)
def probe(arm, mode, idx, out):
cfg, t0 = ARMS[arm], time.time()
rec = dict(arm=arm, mode=mode, idx=idx, t0=time.strftime("%H:%M:%S"))
try:
conn, resp = open_stream(arm, mode, cfg["model"], idx)
rec["status"] = resp.status
rec["headers"] = {k.lower(): v for k, v in resp.getheaders()}
if resp.status != 200:
rec["tail"], rec["err"] = resp.read(500), f"HTTP {resp.status}"
else:
(rec["chunks"], rec["finish"], rec["done"], rec["usage"],
rec["ttft"], rec["tail"]) = read_stream(resp, t0)
conn.close()
except Exception as e: # transport level failure
rec["err"] = f"{type(e).__name__}: {e}"
rec["dur"] = time.time() - t0
rec["truncated"] = bool(not rec.get("done") and not rec.get("finish")
and rec.get("chunks"))
with LOCK:
STATS.append(rec)
with open(f"{out}/{arm}-{mode}-{idx:02d}.log", "wb") as fh:
fh.write(f"# {arm} {mode} #{idx} {rec['t0']} status={rec.get('status')}\n".encode())
fh.write(json.dumps(rec.get("headers", {}), indent=1).encode() + b"\n")
fh.write(f"# chunks={rec.get('chunks')} ttft={rec.get('ttft')} "
f"finish={rec.get('finish')} DONE={rec.get('done')} "
f"err={rec.get('err')}\n".encode())
fh.write(b"# tail (last 2 KiB):\n" + rec.get("tail", b""))
print(f"[{arm}/{mode} #{idx}] {rec['dur']:6.1f}s ttft={(rec.get('ttft') or -1):5.1f}s "
f"chunks={rec.get('chunks')} finish={rec.get('finish')} DONE={rec.get('done')} "
f"truncated={rec['truncated']} err={rec.get('err')}", flush=True)
return rec
def run_reuse(arm, count, out):
"""Sequential requests over ONE keep-alive connection; the conversation grows."""
cfg = ARMS[arm]
conn = http.client.HTTPSConnection(cfg["host"], timeout=900)
msgs = [dict(role="system", content=FILLER),
dict(role="user", content=ASK + " Use a tool if you need more data.")]
for i in range(1, count + 1):
t0 = time.time()
rec = dict(arm=arm, mode="reuse", idx=i, t0=time.strftime("%H:%M:%S"))
try:
key = os.environ[cfg["key_env"]]
payload = dict(body_for("reuse", cfg["model"]), messages=msgs)
conn.request("POST", cfg["path"], body=json.dumps(payload).encode(), headers={
"Authorization": f"Bearer {key}", "Content-Type": "application/json",
"Accept": "text/event-stream", "User-Agent": "omp/18.12.2",
"x-opencode-session": f"dsprobe-reuse-{int(time.time())}-{i}",
**{k: v for k, v in cfg["extra"].items() if v is not None}})
resp = conn.getresponse()
rec["status"] = resp.status
rec["headers"] = {k.lower(): v for k, v in resp.getheaders()}
(rec["chunks"], rec["finish"], rec["done"], rec["usage"],
rec["ttft"], rec["tail"]) = read_stream(resp, t0)
except Exception as e:
rec["err"] = f"{type(e).__name__}: {e}"
rec["dur"] = time.time() - t0
rec["truncated"] = bool(not rec.get("done") and not rec.get("finish")
and rec.get("chunks"))
with LOCK:
STATS.append(rec)
with open(f"{out}/{arm}-reuse-{i:02d}.log", "wb") as fh:
fh.write(f"# {arm} reuse #{i} {rec['t0']} status={rec.get('status')}\n".encode())
fh.write(json.dumps(rec.get("headers", {}), indent=1).encode() + b"\n")
fh.write(f"# chunks={rec.get('chunks')} ttft={rec.get('ttft')} "
f"finish={rec.get('finish')} DONE={rec.get('done')} "
f"usage={json.dumps(rec.get('usage'))}\n".encode())
fh.write(b"# tail (last 2 KiB):\n" + rec.get("tail", b""))
print(f"[{arm}/reuse #{i}] {rec['dur']:6.1f}s ttft={(rec.get('ttft') or -1):5.1f}s "
f"chunks={rec.get('chunks')} finish={rec.get('finish')} DONE={rec.get('done')} "
f"truncated={rec['truncated']} err={rec.get('err')}", flush=True)
if rec.get("done"):
msgs += [dict(role="assistant", content=f"Turn {i}: noted."),
dict(role="user", content=f"Continue: verify item {i} briefly.")]
def worker(arm, mode, queue, out, qlock):
while True:
with qlock:
if not queue:
return
idx = queue.pop(0)
probe(arm, mode, idx, out)
time.sleep(1)
def run_arm(arm, mode, count, conc, out):
q, qlock = list(range(1, count + 1)), threading.Lock()
ts = [threading.Thread(target=worker, args=(arm, mode, q, out, qlock))
for _ in range(conc)]
[t.start() for t in ts]
[t.join() for t in ts]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--arm", default="both", choices=["official", "gateway", "both"])
ap.add_argument("--mode", default="small", choices=["small", "big", "reuse"])
ap.add_argument("--count", type=int, default=10)
ap.add_argument("--concurrency", type=int, default=2)
ap.add_argument("--out", default="./dsprobe-out")
a = ap.parse_args()
os.makedirs(a.out, exist_ok=True)
arms = list(ARMS) if a.arm == "both" else [a.arm]
if a.mode == "reuse":
th = [threading.Thread(target=run_reuse, args=(x, a.count, a.out)) for x in arms]
else:
th = [threading.Thread(target=run_arm,
args=(x, a.mode, a.count, a.concurrency, a.out)) for x in arms]
[t.start() for t in th]
[t.join() for t in th]
print("\n=== SUMMARY (truncated = content streamed, no finish_reason, no [DONE]) ===")
for x in arms:
rows = [r for r in STATS if r["arm"] == x]
bad = [r for r in rows if r["truncated"]]
print(f"{x}: {len(rows)} requests, truncated={len(bad)}, "
f"transport_errors={sum(1 for r in rows if r.get('err'))}")
for r in bad:
print(f" !! #{r['idx']} {r['t0']} dur={r['dur']:.1f}s chunks={r['chunks']} "
f"trace={r['headers'].get('x-ds-trace-id') or r['headers'].get('cf-ray')} "
f"log={r['headers'].get('x-opencode-log-id')}")
if __name__ == "__main__":
main()
Activity
opencode-agent commentedon Oct 19, 2026
Thanks for the detailed report and the probe data. Streams that end without a terminal frame, at 6–9% of calls, are clearly a problem for strict clients.
This is about the OpenCode Go gateway itself, not the OpenCode app. Looking up the failing requests in the gateway logs is something only the team can do, and we don't handle that through GitHub issues. Please email [email protected] with the time window, the
gw-…response ids and anyx-opencode-log-idvalues you have. If you've already emailed, the team will get to it, especially now that we've seen it here. Please keep API keys, email addresses and account details out of this public issue and put them only in the email.Related reports, for reference:
finish_reasonfor muse-spark models (on every request, not intermittently)gpt-5.6-lunaon OpenCode Goox-alpha-freestreams cut off withoutfinish_reason; this one came from the upstream providerstop_reasonon the Anthropic-compatible endpointThe bot will close this issue in 1 day.
FFFengMJL commentedon Oct 19, 2026
Update after the bot's guidance — one new, checkable finding before this goes to email:
Every truncated response today carried a
gw-…response id; responses with the upstream-style numeric id (021792…) never truncated.gw-…responsesgw-…)gw-…)gw-…)The switch is per session/prefix, not global: session A ran 118 numeric-id requests before moving to
gw-…ids and began failing ~3 min later; sessions B and C switched within their first few requests and failed at contexts as small as ~34 K tokens. My injected probes (short / 1.7 MB uncached / 1 M-token cache-warm keep-alive / 4–6-way concurrency, ~90 requests) always got routed to the numeric-id path and produced 0 truncations — which is also why I could not reproduce it externally.Raw shape of a failed stream (captured response id
gw-a697b26c5509-5a2ad8, 2026-10-19 17:04:50 UTC+8): content deltas (thinking 4 139 + text 152 chars + a tool call whose arguments never completed) → HTTP body ends; nofinish_reasonchunk, nodata: [DONE], nousage, no error frame.Full per-event list (46 ids today, 7 older ones) and the 42-request official-API control (0 truncations) are going to [email protected] as the bot asked. I am also running an interactive capture session with response-header logging to obtain
x-opencode-log-id/cf-rayfor a failing request and will add it here if it lands.opencode-agent commentedon Oct 19, 2026
Thanks, that's a really useful finding. All the truncations having
gw-…ids, while the numeric-id responses never truncated, points to a specific gateway routing path rather than DeepSeek itself. It also explains why your external probes couldn't reproduce it.This still needs the team to check the gateway logs, so sending the full per-event list and the official-API control to [email protected] is the right next step. If your capture gets an
x-opencode-log-id/cf-rayfor a failing request, put it in the email too. Posting those ids here is fine. Please keep API keys and account details out of this public issue.As mentioned above, the bot will still close this issue as planned. The email is where this will be followed up.
FFFengMJL commentedon Oct 19, 2026
Closing the loop on the header capture, with two results that narrow the routing question:
1.
x-opencode-sessiondoes not select the path. I replayed an identical request with (a) the live agent session's ownx-opencode-sessionvalue and (b) a random value: both returned upstream-style numeric ids (021792…) and both ended cleanly. So thegw-…vs numeric split is not keyed on that header from the client side.2. The split is time-windowed for a given session, and only
gw-…responses fail. The live session alternated:gw-…ids from 15:41 to 17:57 (a truncation every few minutes; 116 events total today), then numeric ids from 17:57 onward with no failures. Injected load (~130 requests, including 1 M-token cache-warm and 1.7 MB uncached prompts) never landed on thegw-…path, which is why external reproduction kept failing.I did reproduce the truncation once inside a forked copy of the live session at 17:48:32 (
gw-a70a2abc01d3-5f578f), but that fork's own cleanup deleted the response dump before I could read its headers; subsequent forks landed on the numeric path again.Practical consequence for triage: the 116
gw-…ids in the email are the join key on your side. If a per-requestx-opencode-log-idis required, say which window you want captured and I will run a capture session tuned to it (or any probe you specify against the Go plan).opencode-agent commentedon Oct 19, 2026
Thanks for following up. Ruling out
x-opencode-sessionas what picks the path, and finding thegw-…path active in time windows, both help pin down where it happens.I can't tell from here which identifiers or capture window the gateway team will need. Only they can check their logs. The 116
gw-…ids with timestamps are a good starting point, and if they need a targeted capture they can ask you for it in the email thread at [email protected]. Please send these two new findings there as well, so everything is in one place.As mentioned above, the bot will still close this issue as planned; the email is where this will be followed up.
opencode-agent commentedon Oct 20, 2026
Closing this now, as mentioned earlier. Thanks again, @FFFengMJL, for the detailed investigation; the finding that only
gw-…responses truncate is especially useful.The gateway investigation continues by email. For anything still open, including the
gw-…ids, timestamps and any targeted capture the team asks for, please use [email protected]. Please keep API keys and account details out of public issues.