Skip to content

compaction: 1M-context sessions never auto-compact before the provider returns HTTP 400, and the 400 is not treated as context overflow #50574

Open

Description

@zjingsh90

Summary

With 1M-context models (e.g. deepseek-v4.1-flash, qwen3.8-flash, glm-5.3-flash), automatic
compaction effectively never fires: the trigger ceiling is contextLimit - max(min(outputLimit, 32000), buffer)
= 1000000 - 32000 = 968,000 tokens. The provider starts rejecting the request with HTTP 400 at
about 940k tokens, i.e. before the ceiling. Worse, that 400 is not classified as a context overflow,
so the documented overflow recovery (model call -> overflow -> compact -> retry once) never runs and
the session is stuck.

Environment

  • opencode version: 2.0.12 (desktop app @opencode-aidesktop, channel latest)
  • OS: Windows_NT 10.0.19041 (win32 x64)
  • Terminal: desktop app (Electron); TERM=xterm-256color, TERM_PROGRAM unset
  • Shell: C:\Windows\system32\cmd.exe
  • Install/channel: desktop app / latest (auto-update)
  • Active plugins: none (config has one MCP server, no plugins)

Reproduction

  1. Select a model advertising a 1,000,000-token context, e.g. opencode-go/deepseek-v4.1-flash
    (also reproducible with qwen3.8-flash and glm-5.3-flash, and with the
    alibaba-token-plan-cn provider).
  2. Work in a single long session until the model's reported cache/input usage reaches roughly
    900k–940k tokens.
  3. Send another message.

Expected Behavior

Automatic compaction should create a checkpoint before the request exceeds the provider's real
usable limit. At minimum, when the provider rejects the request as too large, OpenCode should
recognize a context overflow and perform the single automatic compact-and-retry described in the
compaction docs.

Actual Behavior

No automatic compaction occurs while the session grows from ~140k to ~940k tokens. At ~941k input
tokens the provider repeatedly returns HTTP 400 and the run fails. The stored assistant message is a
generic error, not the recognizable context-overflow error:

{"finish":"error","error":{"type":"provider.invalid-request",
 "message":"Provider request failed with HTTP 400","status":400}}

Compare with the intended friendly message produced by the overflow classifier:
Context overflow: <...>. Please start a new session or use /compact to reduce context.

Evidence

Defaults extracted from the opencode-cli 2.0.12 embedded bundle:

  • compaction buffer: buffer = 20000 (h$)
  • retained recent tokens: keep.tokens = 15000 (A$)
  • output reserve cap: 32000 (x$)
  • ceiling: tokens >= min(inputLimit - buffer, contextLimit - max(min(outputLimit, 32000), buffer))
  • isContextOverflowError matches only (case-insensitive) context overflow,
    prompt is too long, prompt too long, or (tokens AND maximum).

Verified against two existing sessions:

Model catalog context computed ceiling observed behavior
opencode/big-pickle 200,000 (input 160,000) 160000 − 20000 = 140,000 all 13 historical compactions fired at 140k–143k
opencode-go/deepseek-v4.1-flash 1,000,000 1000000 − 32000 = 968,000 grew to 941,114 / 861,534 with zero compactions

The big-pickle column is exact agreement between the formula and 13 real compaction events, which
isolates the 1M-model ceiling as the regression source.

The session that reached 941k then produced the generic HTTP 400 above; the classifier did not match,
so no overflow recovery was attempted. (Assistant reasoning in that session even noted
"The user keeps getting HTTP 400 — context is very large.")

Additional Context

Suggested fixes, in priority order:

  1. Overflow classification gap (bug). The provider response body is not surfaced into the error
    message, so isContextOverflowError cannot match and the documented compact-and-retry recovery is
    skipped. Propagating the provider responseBody into the classifier, and/or treating a generic 400
    at very high context usage as a possible overflow, would restore recovery.
  2. Default ceiling for large-context models. Hard-capping the output reserve at 32,000 while a
    model advertises a 384,000 output limit, combined with a fixed 20,000 buffer, yields a ceiling at
    96.8% of context for 1M models — too close to the real limit. Scaling the reserve with context (or
    not capping the output reserve) would help.
  3. Catalog vs. provider reality. models.dev advertises 1,000,000 context for these models, but the
    gateway accepted 861k and rejected 941k. Treating the catalog value as a hard budget is optimistic;
    a configurable safety factor would help.

Workaround that resolves it locally: override the model's context budget per model so the ceiling
lands lower, e.g.

{
  "providers": {
    "opencode-go": {
      "models": {
        "deepseek-v4.1-flash": { "limit": { "context": 800000, "output": 384000 } },
        "qwen3.8-flash":       { "limit": { "context": 800000, "output": 131072 } },
        "glm-5.3-flash":       { "limit": { "context": 800000, "output": 131072 } }
      }
    }
  }
}

With context = 800000 the ceiling becomes 800000 - 32000 = 768000, which fires well before the
provider rejects. Confirmed to load in 2.0.12 (the resolved model reports limit.context = 800000).

Workaround for already-stuck sessions: manual /compact, or
POST /api/session/<sessionID>/compact, as documented on the compaction page.


Appendix A — Session measurement raw data

Sessions live in ~/.local/share/opencode/opencode.db (session_message table; each row's data
is JSON). "usage" = input + cache.read + cache.write + output + reasoning of the latest assistant
message with usage, which is exactly the quantity the compaction estimator starts from.

Session A (ses_f5823c8a0ffeWgJItubT2KzhMN) — compaction events (all reason=auto, all status=completed):

seq=51   2026-09-16 02:34:30  model=big-pickle  usage before=142,467
seq=139  2026-09-16 03:03:01  model=big-pickle  usage before=150,033
seq=263  2026-09-16 07:16:42  model=big-pickle  usage before=140,411
seq=402  2026-09-16 07:52:18  model=big-pickle  usage before=140,071
(no further compaction for the next 6 days)

Model/usage progression after the last compaction (note the model switch to 1M-context models):

seq 404   09-16 07:53  usage=43,440     model=big-pickle
seq 445   09-16 08:07  usage=117,255    model=glm-5.3-flash      <- 1M context from here on
seq 484   09-16 08:22  usage=150,274    model=glm-5.3-flash      (already past the old 140k ceiling)
seq 691   09-17 03:33  usage=264,492    model=qwen3.8-flash
seq 1353  09-21 05:17  usage=767,706    model=qwen3.8-flash
seq 2062  09-21 09:01  usage=812,593    model=qwen3.8-flash
seq 2852  09-22 01:39  usage=813,047    model=deepseek-v4.1-flash
seq 4147  09-22 08:19  usage=941,272    model=deepseek-v4.1-flash  <- last success
seq 4156  09-22 08:19  ERROR: provider HTTP 400

Session B (ses_0491f1787ffeH4whjLI26XNfDH) — same pattern:

9 compactions while on big-pickle, last one 2026-09-18 03:25:29 at usage 140,447
then switched to qwen3.8-flash / deepseek-v4.1-flash (1M), grew to 861,534 with no compaction

Reproduce the measurement (Python 3, read-only):

import sqlite3, os, json
db = os.path.expanduser("~/.local/share/opencode/opencode.db")
con = sqlite3.connect("file:%s?mode=ro" % db.replace("\\", "/"), uri=True)
con.row_factory = sqlite3.Row
sid = "ses_f5823c8a0ffeWgJItubT2KzhMN"
for r in con.execute("SELECT seq,type,time_created,data FROM session_message WHERE session_id=? ORDER BY seq", (sid,)):
    d = json.loads(r["data"])
    if r["type"] == "compaction":
        print("COMPACT", r["seq"], d.get("reason"), d.get("status"))
    if r["type"] == "assistant" and d.get("tokens"):
        t = d["tokens"]
        total = (t.get("input", 0) + t.get("cache", {}).get("read", 0)
                 + t.get("cache", {}).get("write", 0) + t.get("output", 0) + t.get("reasoning", 0))
        print("usage", total, d.get("model", {}).get("id"))

Appendix B — Server log lines for the failures

~/.local/share/opencode/log/opencode.log (run id redacted), repeated every ~1–3 minutes from
2026-09-22 08:05 to 08:19 UTC:

level=ERROR message="Failed to drain Session" cause="AI.Error: Provider request failed with HTTP 400
    at SessionStep.attempt (...)
  [cause]: AI.Error.InvalidRequest: Provider request failed with HTTP 400" sessionID=ses_f5823c8a0ffeWgJItubT2KzhMN

The response body is not included in the logged error, which is why the classifier cannot match it.

Appendix C — Decompiled snippets from opencode-cli 2.0.12

Defaults and ceiling (h$=buffer, A$=keep.tokens, x$=output cap):

var h$ = 20000, A$ = 15000, x$ = 32000

N = (T) => {
  let I = i.get();                                  // { auto, buffer, tokens, native }
  if (!I.auto) return false;
  let P = T.messages.at(-1);
  if (P?.type === "compaction" && P.status === "completed") return false;
  if (T.messages.findLastIndex(K5) < T.messages.findLastIndex(By)) return false;
  let U = T.resolved.limit, Y = U.context;
  if (Y <= 0) return false;
  let te = Math.min(U.output, x$);                  // output reserve capped at 32000
  let ee = Math.min(
    U.input === undefined ? Number.POSITIVE_INFINITY : U.input - I.buffer,
    Y - Math.max(te, I.buffer)
  );
  return G$(T) >= ee;
}

Overflow classifier (isContextOverflowError):

isContextOverflowError() {
  if (this.statusCode !== 400) return false;
  let t = this.message?.toLowerCase() || "";
  return t.includes("context overflow") || t.includes("prompt is too long")
      || t.includes("prompt too long") || (t.includes("tokens") && t.includes("maximum"));
}

Generic-message fallback that drops the provider body (name-mangled pn):

pn = (e, o) => {
  let a = o === undefined ? undefined : Decode(dn(o));
  return [a?.error?.message, a?.message].find((s) => s?.trim())
      ?? `Provider request failed with HTTP ${e}`;
}

Config schema confirms the per-model workaround exists
(Config.Model.Limit = { context?: number, input?: number, output?: number }, nested under
providers.<id>.models.<model>.limit).

Appendix D — Config workaround applied

~/.config/opencode/opencode.jsonc (verified via the running server's /api/model; resolved
limit.context is now 800000 for these models while big-pickle is untouched at 200000/160000/32000):

{
  "$schema": "https://opencode.ai/config.json",
  "providers": {
    "opencode-go": {
      "models": {
        "deepseek-v4.1-flash": { "limit": { "context": 800000, "output": 384000 } },
        "qwen3.8-flash":       { "limit": { "context": 800000, "output": 131072 } },
        "glm-5.3-flash":       { "limit": { "context": 800000, "output": 131072 } }
      }
    },
    "alibaba-token-plan-cn": {
      "models": {
        "deepseek-v4.1-flash": { "limit": { "context": 800000, "output": 384000 } },
        "qwen3.8-flash":       { "limit": { "context": 800000, "output": 131072 } }
      }
    },
    "mcp": {
      "chrome-devtools": { "type": "local", "command": ["npx", "-y", "chrome-devtools-mcp@latest"] }
    }
  }
}

Activity

  1. github-actions commented on Sep 22, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    The two bugs you've identified (overflow classification gap + ceiling too close to real provider limit for 1M-context models) are covered by those reports. Your issue adds useful detail on the decompiled ceiling formula and the systematic session data, so it may still be worth keeping open as a tracking issue — but please cross-reference the above.

  2. Kouskous56 commented on Oct 2, 2026

    @Kouskous56

    Adds a measurement that changes the diagnosis: the preflight ceiling is not merely close to the limit, it is unreachable on token-dense payloads, because the signal being compared against it under-counts by up to ~3.2x.

    Token.estimate (packages/core/src/util/token.ts) is a fixed chars / 4. Measured against this provider's own reported input counts on the same endpoint (muse-spark-1.3-contributor-free):

    content chars provider-reported tokens chars/token vs chars/4
    random word list 3,745,727 458,658 8.17 2.04x over-count
    random hex 1,600,000 over ceiling (400) ~1.24 ~3.2x under-count

    A fixed divisor is wrong at both ends. At the observed failure boundary the estimate is ~400,000 while context - max(output, buffer) = 1,048,576 - 131,072 = 917,504, so compactIfNeeded returns false by construction — no tuning of the buffer default can make preflight compaction fire. (Worth noting buffer: 20000 is entirely absorbed by max(output, buffer) on this model, since it advertises output: 131072.)

    Bracket from the same runs (single hex message, compaction.auto: false, 2.0.21):

    payload chars estimate result
    1,200,000 300,000 200 OK
    1,600,000 400,000 400 invalid_request_error
    2,000,000 500,000 400 invalid_request_error
    4,600,000 (auto on) 1,150,000 200 OK — compacted, rebuilt to 8,198

    A real-world instance of the same thing: a 467-message agent session with 291 tool calls and 2,427,013 chars of stored conversation text reached 94% of the catalog window (opencode's own compaction record reports 984,313 input tokens) with zero compaction events. It only compacted after a model switch moved the ceiling from 917,504 down to 872,000 — the estimate happened to land in that ~45k window.

    This suggests the fix belongs in the estimator as much as in the reserve: either tokenize for the preflight check, or derive density from the provider's last reported input for the session, so the ceiling is expressed in the same unit the limit is actually enforced in.

    Related: #46564 covers the opaque 400 not being classified as overflow.

  3. Suixinlei commented on Oct 3, 2026

    @Suixinlei

    Additional concrete reproduction of this issue:

    • OpenCode: v1.18.34 (aec0b9a6d8898f68f923aaf08b7306d931fd9d76)
    • Provider: Zhipu Coding Plan
    • Model: glm-5.3-flash
    • After a long tool-heavy session, the provider returns HTTP 400:
    {"error":{"code":"1261","message":"Prompt exceeds max length"}}

    The current classifier does not match this message, so the response is treated as a generic provider error and the context-overflow recovery path is skipped. This is the provider-specific case tracked in #52961. A focused fix would recognize Prompt exceeds max(imum) length while excluding max_tokens errors and rate limits.

  4. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the very detailed report. I couldn't reproduce this here: it depends on the exact error body the OpenCode Go gateway sends with its HTTP 400 at ~940k tokens, and that needs a real provider account.

    One correction to the analysis: even in 2.0.12, OpenCode checks the raw response body for overflow wording, not just the short message (classifyProviderFailure in packages/ai/src/provider-error.ts scans both). So the 400 wasn't treated as an overflow because the gateway's body didn't contain any of the overflow phrases or error codes OpenCode recognises (or it was empty). 2.0.12 only left the body out of the message you saw.

    Newer releases add the provider's response body to the error message (Provider request failed with HTTP 400: <body>) and recognise more overflow messages, including one from OpenCode Go. It would help a lot if you could:

    1. Update to the latest release.
    2. Let a session hit the 400 again (or send one oversized request).
    3. Paste the full error text here, including the body.

    With that, a maintainer can tell whether this is an overflow message OpenCode should recognise. A maintainer with OpenCode Go access may also look at it directly.

    About the 968k threshold for 1M-context models: that is how the compaction formula is designed to work. Making the safety margin larger or configurable for large-context models would be a feature request. Until then, your limit.context override and manual /compact are the right workarounds.

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