Summary
Sessions replayed against a custom OpenAI-compatible model that reports no context limit (limit.context = 0) can grow past the backend's actual window and then die on every resume with a 400 Bad Request that opencode swallows (exit 0, no output). The failure is deterministic and unrecoverable for that session id.
Reported from production in ranxianglei/billion-context#317 (2 sessions in 24h; full analysis there). This issue tracks the opencode-acp-side fixes.
Repro (observed in production)
- opencode 1.14.46, plugin
opencode-acp@latest (stable 1.14.19), model vllm-qwen/qwen3.8-27b defined in opencode.json without a limit, backend sglang with real window 262,144.
- opencode's
/config/providers reports "limit": {"context": 0, "output": 0} for the model (verified live).
- Long-lived headless session, per-message resume (
opencode run --session <ses>), compaction.auto: false.
Failure sequence (from ~/.local/share/opencode/log/2026-08-28T142830.log, session ses_7fb2e8b92c3eZ6yUJKdhkjVe1Z):
state.modelContextLimit is undefined — lib/state/model-limits.ts record() drops limit <= 0, so the catalog never gets an entry, and hooks.ts:100 (if (state && input.model?.limit?.context)) never sets it. The persisted state file has no modelContextLimit key at all.
- All percentage thresholds resolve to
undefined (parseLimitValue in lib/messages/inject/utils.ts: percent + undefined window → undefined): minContextLimit "45%", maxContextLimit "55%", emergencyThresholdPercent 98% → overMaxLimit/overMinLimit permanently false.
- Only the absolute growth nudge (50K from baseline) fires — advisory text only. Observed: nudge at 206,326 tokens; model voluntarily compressed just 37,178 tokens total (3 blocks); live replay grew to ~230K.
- Request: input 230,527 +
max_tokens 32,000 (opencode's fallback for limit.output = 0; user's options.maxTokens: 16384 is not honored — it leaks into the body as a raw maxTokens key sglang ignores) = 262,527 > 262,144 → sglang: Requested token count exceeds the model's maximum context length of 262144 tokens (400, isRetryable: false).
- opencode logs
session.processor error=Bad Request, publishes session.error, goes idle, exits 0 with no output. Daemon retries 3× (inputs 230,275 / 230,433 / 230,527 — grow ~150 tokens per retry as the nudge message is appended). Fresh session id works immediately.
Note: with max_tokens = 16,384 the same request would have fit (246,911 < 262,144) — the 32,000 fallback pushed it over by 383 tokens.
Impact
- Any custom-provider deployment (local vLLM/sglang/LM Studio, etc.) where the model has no catalog entry: the percentage-based protection system is silently disabled. Sessions accumulate until replay + completion reserve exceeds the real window, then the session is permanently stuck (every resume 400s; only a new session id heals it).
- No error surface: exit 0 + empty output. Operators see "agent stopped replying" with nothing in the logs except a buried
AI_APICallError.
lastCompaction: 0 in state files is a red herring (it only tracks opencode native compaction, which is disabled via compaction.auto: false) — do not chase it.
Immediate workaround (no code)
- Declare the real window in
opencode.json (schema supports per-model limit; both keys required):
- Optional: absolute thresholds in
acp.jsonc (numbers pass through parseLimitValue untouched, no model window needed):
Both are partial: nudges stay advisory — a small local model can still under-compress.
Proposed fixes (opencode-acp)
- Loud failure instead of silent blindness (small, do first): when
limit.context <= 0 / state.modelContextLimit === undefined persists across N requests, log a prominent one-time WARN per session: "model reports no context window; percentage thresholds (min/max/emergency) are disabled — set limit in opencode.json or absolute compress.maxContextLimit/minContextLimit". Today this state is invisible, which is why the blindness went unnoticed for a long-lived session.
- Request-side hard guard (the real fix; the "auto-fallback to a compacted rebuild" asked for in billion-context#317): in
messages.transform, estimate the wire size (tokenization already exists for nudges) and if it exceeds safeBudget = knownWindow − completionReserve (knownWindow = state.modelContextLimit or absolute maxContextLimit; completionReserve = configured limit.output or a conservative default), deterministically prune-to-fit: drop/summarize the oldest compressible (non-protected) tool outputs until the estimate fits — independent of model cooperation. This converts a hard 400 into a degraded-but-working turn.
- Learn the real window from 400s (design note / blocked): sglang's error text states the exact limit (
maximum context length of 262144); parsing it and writing state.modelContextLimit would self-heal calibration. Blocked today because opencode exposes no response-error hook to plugins (only experimental.chat.system.transform, experimental.chat.messages.transform, experimental.text.complete). Track upstream; if a hook lands, implement auto-recalibration.
Related upstream issue (opencode)
Filed separately against anomalyco/opencode: opencode run exits 0 with no output on provider 400 (should exit non-zero + print the error), and options.maxTokens is not honored for custom models (32,000 fallback used instead; the configured value leaks as a raw maxTokens body key).
Summary
Sessions replayed against a custom OpenAI-compatible model that reports no context limit (
limit.context = 0) can grow past the backend's actual window and then die on every resume with a 400 Bad Request that opencode swallows (exit 0, no output). The failure is deterministic and unrecoverable for that session id.Reported from production in ranxianglei/billion-context#317 (2 sessions in 24h; full analysis there). This issue tracks the opencode-acp-side fixes.
Repro (observed in production)
opencode-acp@latest(stable 1.14.19), modelvllm-qwen/qwen3.8-27bdefined inopencode.jsonwithout alimit, backend sglang with real window 262,144./config/providersreports"limit": {"context": 0, "output": 0}for the model (verified live).opencode run --session <ses>),compaction.auto: false.Failure sequence (from
~/.local/share/opencode/log/2026-08-28T142830.log, sessionses_7fb2e8b92c3eZ6yUJKdhkjVe1Z):state.modelContextLimitisundefined—lib/state/model-limits.tsrecord()dropslimit <= 0, so the catalog never gets an entry, andhooks.ts:100(if (state && input.model?.limit?.context)) never sets it. The persisted state file has nomodelContextLimitkey at all.undefined(parseLimitValueinlib/messages/inject/utils.ts: percent + undefined window → undefined):minContextLimit "45%",maxContextLimit "55%",emergencyThresholdPercent 98%→overMaxLimit/overMinLimitpermanently false.max_tokens32,000 (opencode's fallback forlimit.output = 0; user'soptions.maxTokens: 16384is not honored — it leaks into the body as a rawmaxTokenskey sglang ignores) = 262,527 > 262,144 → sglang:Requested token count exceeds the model's maximum context length of 262144 tokens(400,isRetryable: false).session.processor error=Bad Request, publishessession.error, goes idle, exits 0 with no output. Daemon retries 3× (inputs 230,275 / 230,433 / 230,527 — grow ~150 tokens per retry as the nudge message is appended). Fresh session id works immediately.Note: with
max_tokens = 16,384the same request would have fit (246,911 < 262,144) — the 32,000 fallback pushed it over by 383 tokens.Impact
AI_APICallError.lastCompaction: 0in state files is a red herring (it only tracks opencode native compaction, which is disabled viacompaction.auto: false) — do not chase it.Immediate workaround (no code)
opencode.json(schema supports per-modellimit; both keys required):acp.jsonc(numbers pass throughparseLimitValueuntouched, no model window needed):{ "compress": { "maxContextLimit": 150000, "minContextLimit": 100000 } }Both are partial: nudges stay advisory — a small local model can still under-compress.
Proposed fixes (opencode-acp)
limit.context <= 0/state.modelContextLimit === undefinedpersists across N requests, log a prominent one-time WARN per session: "model reports no context window; percentage thresholds (min/max/emergency) are disabled — setlimitin opencode.json or absolutecompress.maxContextLimit/minContextLimit". Today this state is invisible, which is why the blindness went unnoticed for a long-lived session.messages.transform, estimate the wire size (tokenization already exists for nudges) and if it exceedssafeBudget = knownWindow − completionReserve(knownWindow =state.modelContextLimitor absolutemaxContextLimit; completionReserve = configuredlimit.outputor a conservative default), deterministically prune-to-fit: drop/summarize the oldest compressible (non-protected) tool outputs until the estimate fits — independent of model cooperation. This converts a hard 400 into a degraded-but-working turn.maximum context length of 262144); parsing it and writingstate.modelContextLimitwould self-heal calibration. Blocked today because opencode exposes no response-error hook to plugins (onlyexperimental.chat.system.transform,experimental.chat.messages.transform,experimental.text.complete). Track upstream; if a hook lands, implement auto-recalibration.Related upstream issue (opencode)
Filed separately against anomalyco/opencode:
opencode runexits 0 with no output on provider 400 (should exit non-zero + print the error), andoptions.maxTokensis not honored for custom models (32,000 fallback used instead; the configured value leaks as a rawmaxTokensbody key).