feat(hosted,web): a coding agent may opt into a model's smaller context window, per agent and as the default

Merged
DA
8d ago
+887-74

Summary

Dean asked for a Context choice in the model picker (like 300K/1M) for models that support it. This PR is the service and wire half. The desktop picker is stacked on top of it in the next PR.

Which models offer more than one window, and how it is decided. AI Gateway's catalog (/v1/models) gives one context_window per model and no "selectable windows" field. It does give price tiers, pricing.input_tiers / output_tiers, with the boundary past which the provider bills a prompt at a higher rate. So the service reads it like this: a model offers two windows when its input is priced differently past a boundary inside its window. The boundary is the smaller "standard" window and the whole window is the "long" one. A model priced the same across its whole window gets one window and no control. There's no hard-coded model list. As the live catalog stands today (2026-10-12):

Model(s)WindowsLong-context premium (from the catalog tiers)
OpenAI GPT-5.4, 5.4-pro, 5.5, 5.5-pro, 5.6 Luna/Sol/Terra, GPT-6 Luna/Sol/Astra, GPT-6.1 Sol272K / 1M (1.05M)2× input, 1.5× output past 272K (OpenAI pricing)
Claude Haiku 5.5100K / 1M5× input and output past 100K (Anthropic pricing)
Claude Opus 5.5 / 5 / 4.6–4.8, Sonnet 5.5 / 5 / 4.6, Fable 5 / 5.11M onlynone: 1M is the default and maximum at standard price, no beta header
Claude Sonnet 4 / 4.5(200K / 1M behind the context-1m-2025-08-07 beta)not offered: the catalog lists no effort for them, so Luke already leaves them out

No request option or header is needed for any model Luke offers. OpenAI's 1M is the model's own limit, and Haiku 5.5's 1M is its default. Anthropic's 1M beta header only matters for Sonnet 4/4.5, which aren't offered. So the choice changes one thing: the window eve compacts against (modelContextWindowTokens) and that the context ring reads. On the default whole window an agent can run to about 945K. On the opted-in standard window it compacts at about 245K, under OpenAI's premium.

Default: the largest window the model offers (per Dean). The smaller standard window (272K or 100K) is an opt-in. That's also what every agent already ran on, because the coder host told eve the catalog's full context_window. So existing agents and account defaults keep their window with no special-casing: no grandfathering or stamping, and no agent starts compacting at 272K mid-task.

Changes

  • Wire (@sidecar/hosted):
    • catalogModelSchema gets an optional contextWindows { standardTokens, longTokens, longInputPriceFactor?, longOutputPriceFactor? }.
    • modelChoiceSchema, the Start request and the agent summary get an optional contextWindow: "standard" | "long" (CONTEXT_WINDOW).
    • A missing contextWindow means the model's whole window, which is what older desktops already get. The desktop only sends "standard" when the smaller window is opted into, so a service that predates this never sees a key it would refuse on the default path. The summary names the window only when it's the standard one.
  • Catalog (model-catalog.ts):
    • Reads the tiers on their own, so a malformed tier costs a model its long window and not its price.
    • validateModelChoice keeps standard only for a two-window model. On a one-window model it's accepted and dropped, as long (the default) always is.
  • Store:
    • Migration 0077 adds coding_agent.context_window (text NOT NULL DEFAULT 'long', so every existing row keeps the whole window) and account_preference.coding_agent_context_window (nullable; null means the whole window).
    • Start, PATCH and the account default read and write the window.
  • Coder host: contextWindowOf(offered, model, window) picks the chosen window for a step and for the transcript's context. A change applies from the agent's next step, like a model or effort change.
  • Workers that an agent delegates to run on their model's whole window, as before.

No behavior change for anything that exists. Existing agents, new agents that name no window, workers, and older desktops all run on the model's whole window, as every agent did before this PR. Only an explicit opt-in to the standard window changes anything.

Evidence

  • Platform-independent checks: ./scripts/check.sh exit 0 (on the stacked head).
  • macOS Electron verification (./scripts/verify.sh): not run. This is a Linux cloud VM and CI can't verify the Mac. This PR has no UI. LUKE-159 is the manual Mac pass.
  • New tests, each failing without the change:
    • model-catalog.test.ts (+2): catalog tiers → windows and factors (OpenAI-shaped 272K, Haiku-shaped 100001 boundary, a flat Opus, a boundary at the window's edge, an unreadable tier); standard kept or dropped by validation.
    • coder-host.test.ts (+1): the step's modelContextWindowTokens and the transcript's context.windowTokens (the ring) follow the row's window: 1.05M by default, 272K once the standard window is chosen, and 1.05M again on a change that names none.
    • coding-agent-store.test.ts (+1, PGlite): Start, change and read of the window; a new agent defaults to long.
    • hosted-account-store.test.ts (+1, PGlite): the default round-trips the window.
    • coding-agents-app.test.ts (+1): Start/PATCH round-trip over the wire, the default carries it, a one-window model drops it, a window without a model or an unknown window is refused.
  • Test count: web +6, @sidecar/hosted schema goldens +2.

Visual evidence

None here: no UI in this PR. Screenshots are on the desktop PR stacked on top.

Invariants

  • ./scripts/check.sh green; renderer/UI PRs also ./scripts/verify.sh with evidence inspected. (CI) (no UI in this PR)
  • JSON Schema goldens: re-recorded with LUKE_UPDATE_FIXTURES=1. The diff is the new optional contextWindow property, ["standard","long"], on modelChoiceSchema, the Start request, the agent summary and everything that embeds them (view params and answers, the default read/write), plus the optional contextWindows object on catalogModelSchema / modelsAnswerSchema, and two new goldens (contextWindowSchema, contextWindowsSchema). None of these schemas is shown to a model (the voice tool's start_coding_agent reads modelChoiceSchema.fields.effort alone, which is unchanged), so no prompt cache moves.
  • Every behavior change has, in this PR, the test that fails without it; no change-detector test added.
  • Gateway envelope goldens unchanged. (CI)
  • No new as type assertion outside the allowlisted files. (CI via anti-slop)
  • No Effect.runPromise/runSync/runFork outside the runtime edges. (CI)
  • No new raw async primitives. (CI)
  • No Effect, Stream, or Layer written as a statement and dropped. (CI)
  • Test count equals the pre-PR count or the delta is listed. (+6 web, +2 hosted goldens)
  • Each hand-rolled file the PR replaces is deleted: n/a.
  • Every CLAUDE.md/AGENTS.md sentence naming a changed part is edited: none names it. apps/web/server/README.md's preferences and models sections are updated.
  • PRIVACY.md unchanged.
  • Package package.json deps match what the sources import. (CI)
  • Barrel/door rule unchanged. (CI)

Notes

  • Usage is still costed at a model's base price. The service aggregates tokens per turn, not per request, so it can't apply the long-context tier. A long-window agent past the boundary is under-costed in the menu's "This agent: … $" line. That's a follow-up if wanted.
  • Stacked: the desktop picker PR builds on this branch.

🤖 Generated with Claude Code