Skip to content

provider: opencode-go route-dependent context limits cause unparseable HTTP 400 in long sessions #50761

Open

Description

@4Liberty

Summary

opencode-go/deepseek-v4.1-flash is served by at least two upstream providers behind the Go gateway
with different context limits, so long sessions fail intermittently with HTTP 400 depending on
which upstream a request is routed to. The failing route returns a 31-byte body
({"model":"deepseek-v4.1-flash"}) with no message, so the client can only surface
Provider request failed with HTTP 400, and because that 4xx is not classified as a context overflow,
the documented compact-and-retry recovery never runs and the session keeps failing.

Related: #50446 (same class of failure on qwen3.8-flash, deterministic there), #50574 (compaction
ceiling and overflow classification).

Environment

  • opencode version: 2.0.14 (desktop app; background server opencode-cli serve 2.0.14)
  • OS: Darwin 25.6.0 (macOS, arm64)
  • Terminal: desktop app (Electron); TERM=xterm-256color, TERM_PROGRAM unset
  • Shell: /bin/zsh
  • Install/channel: desktop app, channel latest
  • Active plugins: local plugins only (normalize-eol.js, protected-mobile-guard.js under
    ~/.config/opencode/plugins/); unrelated to this issue
  • Provider/model: opencode-go/deepseek-v4.1-flash, endpoint https://opencode.ai/zen/go/v1,
    protocol openai-compatible

Reproduction

Observed organically in a long session, then reduced to direct gateway requests.

Direct (no OpenCode involved): send the same request shape to
POST https://opencode.ai/zen/go/v1/chat/completions with a stable x-opencode-session header and
read x-opencode-endpoint-id from the response.

{
  "model": "deepseek-v4.1-flash",
  "messages": [
    { "role": "system", "content": "You are a coding agent harness request. Ignore filler." },
    { "role": "user", "content": "Only reply with the single word: ok\n\n<filler to reach N tokens>" }
  ],
  "max_tokens": 16
}

Measured results (filler text, same shape for every row):

route (x-opencode-endpoint-id) upstream model id prompt tokens result
deepseek deepseek-flash 969,752 200
deepseek deepseek-flash 1,163,547 400 with a full message (see below)
novita-deepseek deepseek/deepseek-v4.1-flash 323,100 / 409,358 / 517,290 / 538,578 / 582,016 200
novita-deepseek deepseek/deepseek-v4.1-flash ~612,000 / 969,752 400, body: {"model":"deepseek-v4.1-flash"} (31 bytes)

The deepseek route reports its real ceiling itself:

{"error":{"param":null,"type":"invalid_request_error","code":"invalid_request_error","message":"Upstream request failed: [invalid_request_error] This model's maximum context length is 1048576 tokens. However, you requested 1163563 tokens (1163547 in the messages, 16 in the completion). Please reduce the length of the messages or completion."}}

The novita-deepseek route rejects somewhere between 582,016 and ~612,000 tokens and returns only:

{"model":"deepseek-v4.1-flash"}

Example failing request header: x-opencode-endpoint-id: novita-deepseek,
x-opencode-log-id: d924591e-61a3-4073-bfcf-d6843328250b.

Routing appears session-pinned (x-session-affinity): one session id consistently resolved to
novita-deepseek across small/300k/400k/500k/525k/550k/600k/950k requests, while two other session
ids consistently resolved to deepseek.

In the affected OpenCode session:

  1. Use opencode-go/deepseek-v4.1-flash with default compaction settings.
  2. Keep working until the session's prompt input reaches roughly 590k tokens and beyond (this session
    reached ~893k–900k tokens; the model catalog advertises a 1,000,000-token context, so the
    preflight compaction ceiling is 968,000 and never fires).
  3. Continue the session. Steps alternate between success and failure: success when a request is
    routed to deepseek, the generic HTTP 400 when it is routed to novita-deepseek.
  4. "Continue from where you left" sometimes succeeds (different route) and sometimes fails again, so
    the session makes slow, unreliable progress rather than being fully bricked.

Expected Behavior

One of:

  1. The gateway accepts for a given model whatever the catalog advertises (1,000,000 tokens here), or
    the catalog/limits reflect the smallest limit among the upstreams a request can be routed to.
  2. When an upstream rejects a request, its response body is surfaced so the client can classify the
    failure and run the documented overflow -> compact -> retry once recovery.

Actual Behavior

  • Requests routed to the smaller-limit upstream fail with HTTP 400 and an unparseable body.

  • The client stores:

    {"finish":"error","error":{"type":"provider.invalid-request","message":"Provider request failed with HTTP 400","status":400}}
  • No classification:"context-overflow" is produced (the body has no recognizable overflow text),
    so no compaction/retry recovery is attempted (time_compacting stays null, no
    session.compaction.* events).

  • The failure is route-dependent, so the same step can succeed on a retry with no changes.

Additional Context

  • Client-side cause of the generic message: the error extractor looks for error.message /
    message in the parsed body and otherwise falls back to Provider request failed with HTTP ${status}.
  • Client-side cause of the missing recovery: the 4xx fallback returns a plain
    AI.Error.InvalidRequest (provider.invalid-request) when no overflow pattern matches, and the
    overflow classifier only treats a 4xx as overflow when the body/message matches overflow patterns
    or is empty.
  • Workaround used: manual /compact on the affected session (the summary request itself failed once
    when it landed on the smaller-limit route, then succeeded on retry).
  • Filler content in the probes is synthetic; no user data was sent.

Activity

  1. Guillote29 commented on Sep 25, 2026

    @Guillote29

    very fastidiuos bug

  2. 627-hub commented on Sep 27, 2026

    @627-hub

    +1 with evidence from the CLI build (the report is on the desktop 2.0.14 build).

    Environment

    • opencode-ai 1.18.32 (CLI), macOS arm64, zsh
    • Provider/model: opencode-go/deepseek-v4.1-flash, endpoint https://opencode.ai/zen/go/v1
    • No extra plugins; agent build, mode primary

    Same signature

    Both sessions returned the identical 31-byte body on the novita-deepseek route:

    HTTP 400
    {"model":"deepseek-v4.1-flash"}
    

    Response headers from one failure:

    x-opencode-endpoint-id: novita-deepseek
    x-opencode-upstream-model-id: deepseek/deepseek-v4.1-flash
    x-opencode-log-id: 664fc8ae-4956-424f-8f1a-b9d0a41b120d
    x-zen-model: deepseek-v4.1-flash
    cf-placement: remote-ORD
    
    • Session ses_f1cc3346fffeaXY6T9nv1JwKoF: 1 failure (14:59:09Z).
    • Session ses_f31c32216ffe5FQkd7ZhbRJoTg: 8 failures over ~64 min on 2026-09-27 (12:56:42Z → 14:00:13Z), then the user aborted.

    Context sizes at each f31c failure (previous turn's total tokens)

    396,963 / 418,969 / 422,150 / 438,563 / 439,614 / 441,231 / 471,630 / 483,156
    

    The same session then succeeded at ~494k–505k tokens (14:10–14:14Z), so this is not a pure message-size ceiling — consistent with "messages + requested completion budget" crossing the novita route limit, and/or a fluctuating route limit.

    Direct gateway probes

    Sending plain-text filler to /zen/go/v1/chat/completions with max_tokens: 16 and a stable x-opencode-session returned 200 on novita-deepseek for inputs of ~1.2M / 1.8M / 2.4M characters. So a large message list alone does not reproduce it; the real harness request shape (large requested completion budget and/or tool calls + streaming) seems required. Corroborates the "route-dependent context limit" theory in the title.

    Impact

    The 400 comes back as AI_APICallError: Bad Request with isRetryable: false, so the documented compact-and-retry recovery never runs; the session keeps failing until manually aborted.

  3. z99733543-cell commented on Oct 17, 2026

    @z99733543-cell

    Still reproducible as of 2026-10-17 on OpenCode Desktop 2.0.32 (opencode-go/deepseek-v4.1-flash#max), now with two distinct route-dependent symptoms. Both wedge the session the same way (full history resent; every request keeps failing until a manual POST /api/session/{id}/compact, which recovers it):

    1. Limits: one session got 400 [unsupported_request] This text request is too large for the model's configured input limit. right after a 270,421-token success (fit estimate ≈276k prompt tokens at rejection), then — after a manual compaction — stalled again right after a 298,182-token success (≈302k). Meanwhile multiple sessions on the same model/day ran successfully at 272k–900k tokens (max observed 899,929; one prompt at 884,448 fully uncached), so the wall clearly depends on the upstream route.

    2. Capabilities: on the failing route, any request containing an image/file part is rejected with [unsupported_request] Message content supports text content only. Images, audio and files are not enabled. — while the catalog advertises capabilities.input: ["text","image"], and another session on the same model that day ran fine with 6 image attachments in context. So at least some upstreams behind the Go gateway have non-text input disabled.

    Same recovery gap as the original 31-byte-body case: these 400s aren't classified for compact-and-retry, so the session stays stuck until manual compaction. Sibling report with a mock-reproduced version of that gap: #56296.

  4. aanbrn commented on Oct 28, 2026

    @aanbrn

    Adding measurements from #51201 — my report, which is being closed as a duplicate of this issue — because my failures
    sit below both route ceilings measured here and I did not record which route served them.

    What I measured

    • On opencode-go/deepseek-v4.1-flash (opencode 2.0.18, macOS 27.0, /zen/go/v1/chat/completions), 400s arrived at
      ~350–535k prompt tokens. The two routes measured in this issue accept more than that (novita-deepseek 582,016,
      deepseek 969,752), so I cannot tell from my data whether your route explanation covers these. A 534,912-token and a
      535,040-token request both passed minutes either side of one failure.
    • The same session reached 967,680 cached prompt tokens in a request that succeeded (2026-09-25 13:08), so the
      successes above 582k are real, and consistent with landing on deepseek.
    • One failure as low as ~350k is well under both figures. The top of my range (~535k) is only ~8% below
      novita-deepseek's 582,016 acceptance, which is why I am not claiming a ceiling cannot explain some of them.

    Measurements from another client

    • JohnnyHolmes reports in provider: opencode-go 400 stub on long sessions varies per attempt (identical bytes replay 200/400/200) #51201: Claude Code 2.1.306 against /zen/go/v1/messages — a different client over a
      different protocol on the same model — 18 × 400 / 184 requests in one day, contexts 86k–160k tokens, ~268 KB body.
    • Their controlled replay of 30 requests gave 9 × 400, 20 × 200 and one 60 s connection drop. Failure windows lasted
      3.7–8.9 s and were separated by 5.8–95.6 s gaps, and the 400s answered faster than the 200s (median 3.5 s vs
      11.4 s).

    Identical bytes, different verdict

    • Replaying the exact captured request byte-for-byte — same endpoint, headers and credential — returned 200/400/200
      across three consecutive attempts, then 5×200 + 1×400 across a later six-attempt loop.
    • The 31-byte stub was captured client-side on 2.0.15. That version's persisted error record kept only
      {"type":"provider.invalid-request","message":"Provider request failed with HTTP 400","status":400}.
    • A minimal two-message control with the same headers returned 200 in all 22 attempts.

    On retry

    • Re-framing the exact stub signature as retryable recovered the turn in a test with two injected stubs, retries at
      3 s and 10 s.
    • In JohnnyHolmes's report, six retries at 12:14:19Z all returned the stub, and the next attempt at 12:14:35Z returned
      200.
    • Both suggest the retry mechanism is worth having. Neither settles a real recovery schedule, and my stubs were
      injected rather than organic.

    Caveat I want to keep explicit: I have not established a cause. The same bytes produced different results on the
    same endpoint, and one failure carried as little as ~350k tokens — well under either route ceiling measured here.
    Whether that is a transient rejection, a third route with a lower limit, or something else, my data does not decide.

    Full details and the mitigation test are in #51201.

  5. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed measurements. I couldn't reproduce this here because it needs a real OpenCode Go account, which this sandbox doesn't have. The failing part is in the Go gateway: the novita-deepseek route rejects prompts well below the advertised 1M context and replies with only {"model":"deepseek-v4.1-flash"}. That isn't something we can see from the client code in this repository.

    On the client side, from reading the code:

    • Current versions include the provider's response body in the error message, so you'll see Provider request failed with HTTP 400: {"model":"deepseek-v4.1-flash"} (provider: opencode-go 400 stub on long sessions varies per attempt (identical bytes replay 200/400/200) #51201 reports it being saved since 2.0.21).
    • The client only treats a 400 as a context overflow when the response says so, with an overflow error code or wording. This stub body says nothing about size, so compact-and-retry doesn't start. The likely fix is for the gateway to return a proper context-length error, or to advertise the smallest limit among its upstreams.

    The log id you gave (d924591e-61a3-4073-bfcf-d6843328250b) should help a maintainer with gateway access trace the failing route. Related issues:

  6. aanbrn commented on Oct 28, 2026

    @aanbrn

    Two corrections to my comment above. Both make the evidence weaker rather than stronger.

    • Version attribution. I said the persisted record I quoted —
      {"type":"provider.invalid-request","message":"Provider request failed with HTTP 400","status":400} — was 2.0.15's.
      It is the 2.0.18 record, reported in provider: opencode-go 400 stub on long sessions varies per attempt (identical bytes replay 200/400/200) #51201. On 2.0.15 the observation is only that the 31-byte body "is dropped
      when the error is persisted", with no record quoted.
    • The 12:14:35Z attempt. It was not a byte-identical retry: JohnnyHolmes reports it as made "with a slightly changed
      body". The byte-identical evidence is our replay series — 200/400/200, then 5×200 + 1×400 — not that attempt.

    Neither changes what I concluded, but both are what a reader checking the thread will find, so they belong here rather
    than left to be discovered.

  7. aanbrn commented on Oct 28, 2026

    @aanbrn

    Adding gateway trace IDs from JohnnyHolmes's report in #51201 for the 86k–160k-token failures over
    /zen/go/v1/messages.

    From the reported failing burst:

    • 01a15eb9-f630-7363-8e73-3e4b511c40e6
    • 01a15ebf-06f7-7456-9360-8926f6d1937f

    Two earlier ones from the same client:

    • 01a15e7a-f7dc-7606-8c26-3e3be33f20f5
    • 01a15e7a-ffdd-7d83-9d0b-66386b54d1f1

    The full burst list — nine IDs — is in #51201. The serving route was not recorded. These are from that report, not
    from my byte-identical replay series.

    Could these be traced alongside the original d924591e-61a3-4073-bfcf-d6843328250b? Whether these 86k–160k failures
    were themselves context-limit rejections is not established: the model-only body does not distinguish them.

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

Metadata

Metadata

Assignees

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