Skip to content

[opencode-go] /chat/completions streams for deepseek-v4.1-flash end mid-generation without finish_reason or [DONE] (clean EOF), ~6-9% of calls #57033

Closed as not planned

Description

@FFFengMJL

[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, no data: [DONE], no aggregated usage, 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

  1. 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?
  2. 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?
  3. Please look up the failing requests by x-opencode-log-id for 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.
  4. Which upstream serves deepseek-v4.1-flash on the Go plan, and does that path go through api.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, npm latest) 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

  1. opencode-agent commented on Oct 19, 2026

    @opencode-agent
    Contributor

    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 any x-opencode-log-id values 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:

    The bot will close this issue in 1 day.

  2. FFFengMJL commented on Oct 19, 2026

    @FFFengMJL
    Author

    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.

    session numeric-id responses gw-… responses truncations
    A (253 gateway calls) 118 135 16 (all gw-…)
    B (106 calls) 4 111 11 (all gw-…)
    C (151 calls) 3 196 18 (all 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; no finish_reason chunk, no data: [DONE], no usage, 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-ray for a failing request and will add it here if it lands.

  3. opencode-agent commented on Oct 19, 2026

    @opencode-agent
    Contributor

    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-ray for 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.

  4. FFFengMJL commented on Oct 19, 2026

    @FFFengMJL
    Author

    Closing the loop on the header capture, with two results that narrow the routing question:

    1. x-opencode-session does not select the path. I replayed an identical request with (a) the live agent session's own x-opencode-session value and (b) a random value: both returned upstream-style numeric ids (021792…) and both ended cleanly. So the gw-… 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 the gw-… 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-request x-opencode-log-id is 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).

  5. opencode-agent commented on Oct 19, 2026

    @opencode-agent
    Contributor

    Thanks for following up. Ruling out x-opencode-session as what picks the path, and finding the gw-… 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.

  6. opencode-agent commented on Oct 20, 2026

    @opencode-agent
    Contributor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions