From 5416616fd30736e3261d41a4dfb86f9268da2ef8 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 25 Jul 2026 18:35:00 -0300 Subject: [PATCH] fix: default an unknown model's context window to 1M, not 200k MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Opus 5 session read 6% of its context as 30% in the footer. The window the percent is measured against came from a table listing the 1M models; `opus-5` was not in it, so the read fell through to inferring a window from the token count — the smallest standard size the session still fit inside, which is 200k until a session passes 200k tokens. Adding the missing row would have fixed this launch and left the next one broken. The table's default was the actual bug: it enumerated the large windows and assumed small for anything unlisted, so every model release needed a release here to read right. Inverted. Haiku is the 200k exception and everything else is 1M, which is what a model released after the build reads with no new version. The bet is that an unknown id is a model newer than the binary rather than a legacy one, and it holds — every Claude generation since 4.6 ships at 1M. A token count past 200k disproves the Haiku guess (a window cannot hold more than its size) and widens, so a future Haiku at 1M corrects itself. That collapsed `modelWindows`, `contextWindows` and `contextWindowFor` into four lines. The deprecated ids the old table carried went with them: nothing in a provider's model picker offers them today, and the entries were noise. The tradeoff is that a session on a still-live 200k model outside the exception (Sonnet 4.5, Opus 4.5) now reads low — the widen guard only ever widens. Two tests changed because the contract did, not to stay green: an unlisted model now takes 1M instead of the smallest window that fits. The deprecated-model rows were dropped for the same reason as the code. The `CLAUDE.md` ceiling for this went too. That section carries what breaks if nobody knows it; an approximate readout does not qualify, and the mechanism now lives in the comment on `windowForModel` where it is read. Not done here, deliberately: reading the exact window per session. It exists only in the provider's status-line payload — a slot the user's own status line owns — and the Models API's `max_input_tokens` needs auth and network and still cannot see a window capped below the model's native size. --- CHANGELOG.md | 10 +++++ CLAUDE.md | 11 ----- internal/terminal/usage_claude.go | 59 ++++++-------------------- internal/terminal/usage_claude_test.go | 32 ++++++++------ 4 files changed, 42 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84787c19a..c64b28cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **A model newer than your lich no longer reads its context window as 200k.** + Opus 5 sessions showed five times their real usage in the footer, because the + window came from a table of the 1M models and anything missing from it fell + back to the smallest window that fit — so every model launch needed a release + here. The rule is now the other way round: Haiku is the 200k exception and + everything else is 1M, which is what a model released after your build reads + without a new version. + ## [0.18.0] - 2026-07-25 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index babb5f2f2..9e45cec1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,17 +97,6 @@ Deliberate limits and shortcuts — one line of *what and where*; the mechanism failed read degrades to the session's start directory. Tracks the direct child only, not nested shells. - **git status is polled** — one shared poller per repository path (`frontend/src/lib/git/git-status-store.ts`); the lich plugin's `session-touched` hook nudges an immediate refresh. An fs watcher is the upgrade path. -- **Context-window usage is read off the transcript** (`internal/terminal/usage_claude.go`): on a turn-boundary - status hook, the tail of `~/.claude/projects//.jsonl` is parsed for the last main-thread assistant - `usage` (sidechain sub-agent lines skipped), shown in the footer for the active session. The JSONL layout is - Claude-internal and unstable across releases — the read fails soft (the readout keeps its last number). The - percent is taken against the model's native window from a small `model → window` table (`modelWindows`: current - Opus/Sonnet/Fable are 1M, Haiku and pre-4.6 are 200k) — the transcript records the model but not the window; an - unlisted model falls back to inferring the window from the token count. Two deliberate ceilings: the table goes - stale as models ship (Models API `max_input_tokens` is the upgrade path), and it assumes the model's *native* - window — a session that runs a 1M-capable model at 200k reads double its true percent, because the exact window - lives only in the statusLine JSON (`context_window.context_window_size`), never in the transcript. The reader is - Claude-only (the sole provider reporting a session id today), isolated for a per-provider selection later. - **Persistence is hybrid**: UI prefs in the page's localStorage (`lich.*` keys — the reason the listener port is pinned at 47821; `LICH_LISTEN_PORT` overrides it, `LICH_PORT` is the distinct per-session hook variable), the workspace in SQLite (`/lich/lich.db`, `internal/store`). Closing a session deletes its row; keeping a diff --git a/internal/terminal/usage_claude.go b/internal/terminal/usage_claude.go index daaeddd5f..4089eef36 100644 --- a/internal/terminal/usage_claude.go +++ b/internal/terminal/usage_claude.go @@ -9,55 +9,22 @@ import ( "strings" ) -// modelWindows maps a Claude model to its native context window — the transcript -// records the model but not the window, so this is what the percent is taken -// against. Current Opus/Sonnet/Fable are natively 1M (no beta); Haiku and pre-4.6 -// models are 200k. Matched by substring so a dated id ("…-4-5-20251001") still -// hits. ponytail: this table goes stale as models ship — the Models API's -// `max_input_tokens` is the authoritative upgrade path; an unlisted model falls -// back to inferring the window from the token count (contextWindowFor). -var modelWindows = []struct { - match string - tokens int -}{ - {"opus-4-6", 1_000_000}, - {"opus-4-7", 1_000_000}, - {"opus-4-8", 1_000_000}, - {"sonnet-4-6", 1_000_000}, - {"sonnet-5", 1_000_000}, - {"fable-5", 1_000_000}, - {"mythos-5", 1_000_000}, - {"haiku-4-5", 200_000}, - // Older models (opus-4-5, sonnet-4-5, haiku-3-5, …) are unlisted and take - // the 200k fallback below — their native window. -} - -// contextWindows are the standard sizes the fallback picks between when a model -// is unlisted, smallest first. -var contextWindows = []int{200_000, 1_000_000} - -// contextWindowFor returns the window for a token count when the model is -// unknown: the smallest standard window it still fits inside, the largest once -// it exceeds them all — exact the moment a session passes 200k (a 200k window -// cannot hold more than that). -func contextWindowFor(tokens int) int { - for _, w := range contextWindows { - if tokens <= w { - return w - } - } - return contextWindows[len(contextWindows)-1] -} +const ( + smallWindow = 200_000 + defaultWindow = 1_000_000 +) -// windowForModel returns a model's native context window from modelWindows, or -// falls back to inferring it from the token count for an unlisted model. +// windowForModel resolves the context window the percent is taken against. The +// transcript records the model but not its window, so it is named: Haiku is the +// only Claude model still on 200k, everything else is 1M — naming the exception +// rather than the rule is what lets a model released after this build read right. +// A count past 200k disproves the guess (a window cannot hold more than its size), +// so it widens. Matched by substring: a dated id ("…-4-5-20251001") still hits. func windowForModel(model string, tokens int) int { - for _, w := range modelWindows { - if strings.Contains(model, w.match) { - return w.tokens - } + if strings.Contains(model, "haiku") && tokens <= smallWindow { + return smallWindow } - return contextWindowFor(tokens) + return defaultWindow } // usageTailBytes bounds how much of a transcript's end is scanned for the last diff --git a/internal/terminal/usage_claude_test.go b/internal/terminal/usage_claude_test.go index 9493f134c..7d01fac2b 100644 --- a/internal/terminal/usage_claude_test.go +++ b/internal/terminal/usage_claude_test.go @@ -54,18 +54,21 @@ func TestParseContextUsage(t *testing.T) { wantPercent: 20, }, { - name: "an unlisted model past 200k infers a 1M window", - tail: assistantLine(modelNew, 0, 300000, 0) + "\n", + // Contract change: an unlisted model takes the 1M window at any + // token count. It used to infer the smallest window that fit, which + // read a new 1M model as five times fuller than it was. + name: "an unlisted model defaults to the 1M window", + tail: assistantLine(modelNew, 2, 40000, 0) + "\n", wantOK: true, - wantTokens: 300000, - wantPercent: 30, // 300000 * 100 / 1_000_000 + wantTokens: 40002, + wantPercent: 4, // 40002 * 100 / 1_000_000 }, { - name: "an unlisted model under 200k infers a 200k window", - tail: assistantLine(modelNew, 2, 40000, 0) + "\n", + name: "a small-window model past 200k widens to fit", + tail: assistantLine(modelHaiku, 0, 300000, 0) + "\n", wantOK: true, - wantTokens: 40002, - wantPercent: 20, + wantTokens: 300000, + wantPercent: 30, // 300000 * 100 / 1_000_000 }, { name: "caps percent at 100 past a full window", @@ -148,11 +151,14 @@ func TestWindowForModel(t *testing.T) { want int }{ {modelOpus, 50_000, 1_000_000}, - {modelHaiku, 50_000, 200_000}, - {"claude-sonnet-5", 50_000, 1_000_000}, - {"claude-fable-5", 50_000, 1_000_000}, - {modelNew, 50_000, 200_000}, // unlisted, fits 200k - {modelNew, 500_000, 1_000_000}, // unlisted, exceeds 200k + {"claude-opus-5", 50_000, 1_000_000}, + {modelHaiku, 50_000, 200_000}, // dated id, matched by substring + // Contract change: a model this build has never heard of now takes the + // 1M window instead of the smallest one that fits, so a model released + // later reads right without a new release. + {modelNew, 50_000, 1_000_000}, + // A count past 200k disproves the Haiku guess — it widens. + {modelHaiku, 500_000, 1_000_000}, } for _, tt := range tests { if got := windowForModel(tt.model, tt.tokens); got != tt.want {