Skip to content

feat: make context windows and auto-compaction per-model - #231

Merged
quickbeard merged 2 commits into
mainfrom
feat/model-aware-context-windows
Aug 2, 2026
Merged

feat: make context windows and auto-compaction per-model#231
quickbeard merged 2 commits into
mainfrom
feat/model-aware-context-windows

Conversation

@quickbeard

Copy link
Copy Markdown
Owner

What

The gateway serves models with very different windows — MiniMax/MiniMax-M3 at ~1M tokens and zai-org/GLM-4.7-cc at 200K — but every agent was configured from one flat 196608 constant. This makes windows and compaction triggers per-model.

Targets: M3 compacts at 800K, GLM-4.7-cc at 160K.

src/lib/model-limits.ts owns {context, trigger, output?} per model and resolves remote → static table → 200K/160K default. The four writers in configure.ts translate it into each agent's own knob and hold no window constants of their own; GATEWAY_CONTEXT_WINDOW / GATEWAY_COMPACT_* are gone from const.ts.

trigger is explicit rather than a percentage — the gap between window and trigger is a per-model judgement call, not a constant.

Bug fixed: OpenCode/CoDev Code have been compacting early since #171

The threshold, decompiled from the shipped binaries (identical in opencode and codev):

const reserved = cfg.compaction?.reserved ?? Math.min(20000, maxOutputTokens(model));
return model.limit.input
  ? Math.max(0, model.limit.input - reserved)      // reserved IS used
  : Math.max(0, ctx - maxOutputTokens(model));     // reserved is DISCARDED

CoDev wrote limit: {context, output} with no input, so the else-branch ran and reserved: 29491 was dead. The real trigger was 196608 − maxOutputTokens131072 (67%), not the intended 167117 (85%).

Writing limit.input also solves the multi-model problem. reserved is a single top-level value with no per-model variant, so it alone cannot put a 1M and a 200K model on different triggers: sized for the big one it drives the small one's trigger negative; sized for the small one the big one fires at ~96%. Setting input = trigger + reserved lands each model exactly on target off one shared reserve, while limit.context stays the true window that the TUI's "% context used" gauge divides by. Clamped to context so the budget is never overstated.

Verified against the shipped binaries, not https://opencode.ai/config.json — the published schema documents reserved only as "token buffer for compaction" and says nothing about the limit.input branch that decides whether it's read at all.

Per-agent

Agent Knob
Claude Code CLAUDE_CODE_AUTO_COMPACT_WINDOW + CLAUDE_AUTOCOMPACT_PCT_OVERRIDE (single-model, so exact)
Codex model_context_window + model_auto_compact_token_limit
Continue per-model defaultCompletionOptions.contextLength / maxTokensnew, it previously declared no window at all
OpenCode / CoDev Code per-model limit.context (true) + limit.input (= trigger + reserved), one global compaction.reserved

Verified end-to-end

Real configs written to a throwaway $HOME with all three models selected:

CoDev Code   compaction: { auto: true, reserved: 40000 }
  MiniMax/MiniMax-M3     context 1000000  input 840000  → compacts at 800000
  zai-org/GLM-4.7-cc     context  200000  input 200000  → compacts at 160000
  MiniMax/MiniMax-M2.7   context  200000  input 200000  → compacts at 160000

Codex        model_context_window = 1_000_000, model_auto_compact_token_limit = 800_000
Claude Code  WINDOW=1000000, PCT=80                     → compacts at 800000
Continue     contextLength 1000000 / 200000 / 200000

Also in this PR

  • FALLBACK_MODELMiniMax/MiniMax-M3 (was MiniMax-M2.7).
  • Remote source wired but currently inert. backend.ts#fetchModelWindows reads LiteLLM's /model_group/info (gateway root, next to /key/info, not under /v1). The live gateway reports max_input_tokens: null for every model, so it returns {} and the static table carries everything — the moment an admin populates the field, that model becomes gateway-driven with no CoDev release. Unlike fetchModels it never throws: a window is an optimization over a sane default, and install must not break because a metadata endpoint 404s. ModelSelect refreshes it fire-and-forget so it can never delay or fail the picker.
  • Cached in a new top-level model_limits block in auth.json with its own save/load, deliberately kept out of the api-key block so saveApiKey's whole-block rewrite can't clear it.
  • logout() was dropping provider_id / provider_name — it rebuilds the surviving file field-by-field, and the provider pair wasn't listed, so signing out silently reverted a manually-named provider to netGate on the next config write. Fixed, with a test.
  • Test hygiene: two ModelSelect tests were about to issue real HTTPS requests to my-gw.example.com, and nothing there stubs $HOME — a non-empty result would have written to the developer's actual ~/.codev-hub/auth.json. Stubbed in all four affected files.
  • AGENTS.md: new "Context windows and auto-compaction" section covering the per-agent shapes, the limit.input finding, and the verify-against-the-binary rule.

Migration

None. Existing installs keep 196608 until their next install / config / model switch or launch-time key refresh, all of which rewrite the config.

Validation

pnpm fix, pnpm typecheck, pnpm test (1326 passed, 2 skipped, 74 files), pnpm build && node dist/index.js --version0.5.4. New: tests/lib/model-limits.test.ts, fetchModelWindows cases in backend.test.ts, per-model pins for all four writers, and the two logout() preservation tests.

🤖 Generated with Claude Code

quickbeard and others added 2 commits August 2, 2026 09:40
The gateway now serves models with very different windows (MiniMax-M3 at
1M, GLM-4.7-cc at 200K), but every agent was configured from one flat
196608-token constant. Replace it with a per-model source of truth.

src/lib/model-limits.ts owns `{context, trigger, output?}` per model and
resolves remote -> static table -> 200K/160K default. The four writers in
configure.ts translate it into each agent's own knob and hold no window
constants of their own; GATEWAY_CONTEXT_WINDOW / GATEWAY_COMPACT_* are
gone from const.ts.

- Claude Code: WINDOW + PCT_OVERRIDE (single-model, so exact)
- Codex:      model_context_window + model_auto_compact_token_limit
- Continue:   per-model defaultCompletionOptions.contextLength (new -
              it previously declared no window at all)
- OpenCode/CoDev Code: per-model limit.input, see below

Fixes a live bug in the OpenCode family. Its threshold is
`limit.input - compaction.reserved`, falling back to
`limit.context - maxOutputTokens` when limit.input is absent - a branch
that discards `reserved` entirely. CoDev wrote {context, output} only, so
the configured reserve had been dead since #171 and compaction fired at
~67% of the window rather than the intended 85%.

Writing limit.input also solves the multi-model problem: `reserved` is a
single top-level value with no per-model variant, so it alone cannot put
a 1M and a 200K model on different triggers. Setting input to
`trigger + reserved` lands each model exactly on target off one shared
reserve, while limit.context stays the true window that the TUI's
"% context used" gauge divides by. Clamped to context so the budget is
never overstated. Verified against the shipped opencode and codev
binaries, not the published schema, which documents neither branch.

Also:
- FALLBACK_MODEL -> MiniMax/MiniMax-M3
- backend.ts#fetchModelWindows reads LiteLLM /model_group/info so windows
  become gateway-driven once an admin populates max_input_tokens (null on
  every model today). Never throws - a window is an optimization over a
  sane default, and install must not break on a metadata endpoint.
- Cached in a new top-level `model_limits` block in auth.json, kept out
  of the api-key block so saveApiKey's whole-block rewrite can't clear it
- logout() was dropping provider_id/provider_name, silently reverting a
  manually-named provider to netGate on the next config write

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t them

Verified CLAUDE_CODE_AUTO_COMPACT_WINDOW and CLAUDE_AUTOCOMPACT_PCT_OVERRIDE
against the shipped binary (2.1.220). Both are real and read on the
production path, but three undocumented ceilings mean the per-model
windows cannot reach Claude Code the way they reach the other agents.

1. `nc()` resolves the window as `Math.min(nativeWindow, envValue)`, so
   the env var can only ever SHRINK it. An unrecognized model - every
   model the gateway serves - falls through `w37()` to 200000. Writing
   1000000 for MiniMax-M3 was silently reduced to 200000.
2. `Rzq = Math.min(T - round(T * precomputeBufferFraction), qB6(T, opts))`
   with the fraction defaulting to 0.2, so the trigger is capped at 80% of
   the effective window and any percentage above 80 is discarded.
3. Pinning a window BELOW 200000 sets `source: "env"`, which puts `aiK` on
   the branch reading `if (window < 200000) return false` - auto-compaction
   stops firing entirely. The pre-existing 196608 was tripping exactly
   this, so Claude Code has not been auto-compacting at all.

claudeWindow() therefore pins 200000 and returns null below the ceiling so
the writer omits the variable (source stays "auto", which skips the gate
and resolves to the same 200000). claudeCompactPct() takes the percentage
against the clamped window rather than the model's true one - 800000/200000
is 400, not 80 - and bounds it to [1, 80].

Net: Claude Code compacts at 0.8 x (200000 - min(modelMaxOutput, 20000)),
~144-160K regardless of the model. `S$H` (the model's max output) is not
statically resolvable in the binary, so the exact point in that range is
unverified and documented as such.

Also fixes logout() dropping provider_id/provider_name, and documents all
of the above plus the OpenCode limit.input finding in AGENTS.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard

Copy link
Copy Markdown
Owner Author

Follow-up: CLAUDE_CODE_AUTO_COMPACT_WINDOW / CLAUDE_AUTOCOMPACT_PCT_OVERRIDE verified

Checked both against the shipped binary (Claude Code 2.1.220). They are real and read on the production path — but three undocumented ceilings mean the per-model windows cannot reach Claude Code the way they reach the other three agents. Pushed a follow-up commit; the Claude row of the table in the PR description is superseded by this.

1. The window env var can only ever shrink the window

function nc(H,_){
  let O = QM(H, ZJ());                       // the model's native window
  if (process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW){
    let Y = s7H(..., kzq /*1e5*/, baK /*1e6*/);
    if (Y.status !== "invalid"){
      let A = Math.max(kzq, Y.effective);
      return { window: Math.min(O, A), configured: A, source: "env" };   // ← clamp
    }
  } ... }
function w37(H,_){ /* model-identity checks returning 1e6 */ ; return _Z_; }  // _Z_ = 200000

An unrecognized model — every model the gateway serves — falls through to _Z_ = 200000. So CLAUDE_CODE_AUTO_COMPACT_WINDOW=1000000 for MiniMax-M3 was being silently reduced to 200000. No escape hatch: CLAUDE_CODE_MAX_CONTEXT_TOKENS is read only when DISABLE_COMPACT is set, which turns compaction off.

2. The trigger is capped at 80%, so a higher percentage is inert

function Rzq(H,_){ return Math.min(H - Math.round(H * _.precomputeBufferFraction), qB6(H,_)) }
// precomputeBufferFraction defaults to Gzq = 0.2

Math.min discards anything above 80%. The percentage scale itself is correct (0–100, parseFloat), so our "80" was right — it just happens to be the maximum rather than a choice.

3. Pinning a window below 200000 disables auto-compaction outright

function aiK(H,_,q){ ...
  if (!no() && !T3_(_,q)) return H >= Rzq(T,K);
  let { window: $ } = nc(_,O);
  if ($ < ct) return !1;        // ct = 200000
  return H >= Rzq(T,K); }

T3_ is true when the source is "env", so setting the variable puts us on the second branch. The pre-existing 196608 was tripping exactly this — Claude Code has not been auto-compacting at all since #128. Omitting the variable leaves source: "auto", which skips the gate and already resolves to the same 200000.

What the follow-up does

claudeWindow() pins 200000 at or above the ceiling and returns null below it so the writer omits the variable entirely. claudeCompactPct() takes the percentage against the clamped window rather than the true one — 800000/200000 is 400, not 80 — bounded to [1, 80] (Claude Code's own guard is K > 0 && K <= 100, so a 0 would be ignored silently).

Net effect: Claude Code compacts at 0.8 × (200000 − min(modelMaxOutput, 20000)), i.e. ~144–160K regardless of the model. S$H (the model's max output) is not statically resolvable in the binary, so the exact point inside that range is unverified and is documented as such rather than asserted.

CLAUDE_AUTOCOMPACT_PCT_OVERRIDE is also read into a field named testPctOverride — honored in production, but the name says test hook, so it's documented as unsupported and liable to disappear.

Codex, OpenCode/CoDev Code and Continue are unaffected. Those were verified against real generated configs and their own consumption logic and still land exactly on target (M3 at 800000, GLM at 160000).

7 new tests in model-limits.test.ts cover all three ceilings, including that a 1M model still writes 200000 for Claude, so nobody re-raises it assuming it took effect.

@quickbeard
quickbeard merged commit 0eaa3e4 into main Aug 2, 2026
4 checks passed
@quickbeard
quickbeard deleted the feat/model-aware-context-windows branch August 2, 2026 05:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant