From 91cd3ffe7c8aad59712c932f4fe2eef913a33a8f Mon Sep 17 00:00:00 2001 From: Jonathan Borgwing Date: Mon, 6 Jul 2026 21:37:47 -0400 Subject: [PATCH 1/2] feat: on-demand context-window variants in the /model picker Register each model once per context window it supports (1M and/or 200K) as a distinct, labeled picker entry, so the window is chosen on demand from OMP's native /model list instead of via a global config edit + reload. - models.ts: add parseVariantId; make claudeCodeModelId variant-aware (a "-1m"/"-200k" suffixed id forces that window; the unsuffixed id follows the config default). Replace applyLongContext with buildVariantModels: default-first ordering, "(1M)"/"(200K)" labels, and each entry's contextWindow set to its true window so the status bar and auto-compaction threshold stay accurate. - index.ts: register buildVariantModels(MODELS, longContextSettings). - provider.contextWindow (auto|1m|200k) now only picks which window is the unsuffixed default; it no longer hides models. Both windows stay in the picker wherever a runtime exists, so existing modelRoles / enabledModels ids keep working. - Rewrite the context-window unit tests (10/10) and README accordingly. --- README.md | 74 +++++++----- ...07-06_on-demand-context-window-variants.md | 87 ++++++++++++++ src/index.ts | 4 +- src/models.ts | 75 ++++++++++-- tests/unit-context-window.mjs | 113 ++++++++++++++---- 5 files changed, 285 insertions(+), 68 deletions(-) create mode 100644 documentation/completed/features/2026-07-06_on-demand-context-window-variants.md diff --git a/README.md b/README.md index a275d30..d7c0f57 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,16 @@ To delegate from another provider instead, just ask: *"Ask Claude to review this ## Context window controls -Claude Code serves different context windows depending on the exact model id it receives (e.g. bare `claude-fable-5` serves 200K, while `claude-fable-5[1m]` serves 1M). `omp-claude-bridge` turns that into a single switch. +Claude Code serves different context windows depending on the exact model id it receives (e.g. bare `claude-fable-5` serves 200K, while `claude-fable-5[1m]` serves 1M). `omp-claude-bridge` exposes both as **separate entries in the `/model` picker**, so you choose the window on demand: -Set it once in `~/.omp/agent/claude-bridge.json`: +- `claude-bridge/claude-opus-4-8` → **Opus 4.8 (1M)** +- `claude-bridge/claude-opus-4-8-200k` → **Opus 4.8 (200K)** + +Switching window is just picking the other entry — no config edit, no reload. Every model appears once per window it supports, the `(1M)` / `(200K)` label is always shown, and each entry reports its true window so OMP's status bar and auto-compaction threshold stay accurate. + +### Default window + +The **unsuffixed** id (e.g. `claude-opus-4-8`) maps to a default window; the other window gets a `-1m` / `-200k` suffixed id. `provider.contextWindow` in `~/.omp/agent/claude-bridge.json` picks that default — it no longer hides models, it only decides which window is unsuffixed: ```json { @@ -97,49 +104,56 @@ Set it once in `~/.omp/agent/claude-bridge.json`: } ``` -| Mode | Behavior | +| Mode | Default (unsuffixed) window | | ---- | -------- | -| `"auto"` *(default)* | Per-model policy based on measured SDK behavior. Respects `plan` and `longContextExtraUsage`. | -| `"1m"` | Force the 1M window. Only 1M-capable models are registered; models with no 1M runtime are hidden. | -| `"200k"` | Force the 200K window. Only 200K-capable models are registered; models with no 200K runtime are hidden. | +| `"auto"` *(default)* | Per-model measured default. Respects `plan` and `longContextExtraUsage`. | +| `"1m"` | 1M where the model has a 1M runtime, else its only window. | +| `"200k"` | 200K where the model has a 200K runtime, else its only window. | -The registered window always matches what the bridge actually requests, so OMP's status bar and auto-compaction threshold stay accurate. +Both windows stay in the picker regardless of this setting (wherever a runtime exists); it only changes which one is the plain, unsuffixed id. So `modelRoles` / `enabledModels` that reference `claude-bridge/claude-opus-4-8` keep working and follow the default. -### Per-model context window +### Windows offered per model -| Model | `auto` | `1m` | `200k` | -| ----- | :----: | :--: | :----: | -| `claude-opus-4-8` | 1M | 1M | 200K | -| `claude-opus-4-7` | 1M | 1M | — hidden | -| `claude-opus-4-6` | 200K¹ | 1M | 200K | -| `claude-fable-5` | 200K | 1M | 200K | -| `claude-sonnet-5` | 1M | 1M | 200K | -| `claude-sonnet-4-6` | 200K² | 1M | 200K | -| `claude-haiku-4-5` | 200K | — hidden | 200K | +| Model | 200K entry | 1M entry | `auto` default | +| ----- | :--------: | :------: | :------------: | +| `claude-opus-4-8` | ✓ | ✓ | 1M | +| `claude-opus-4-7` | — | ✓ | 1M | +| `claude-opus-4-6` | ✓ | ✓ | 200K¹ | +| `claude-fable-5` | ✓ | ✓ | 200K | +| `claude-sonnet-5` | ✓ | ✓ | 1M | +| `claude-sonnet-4-6` | ✓ | ✓ | 200K² | +| `claude-haiku-4-5` | ✓ | — | 200K | -¹ Opus 4.6 serves 1M in `auto` when `plan: "max"` or `longContextExtraUsage: true`. -² Sonnet 4.6 serves 1M in `auto` when `longContextExtraUsage: true`. +¹ Opus 4.6's `auto` default is 1M when `plan: "max"` or `longContextExtraUsage: true`. +² Sonnet 4.6's `auto` default is 1M when `longContextExtraUsage: true`. + +The suffixed alternate exists only for the window that isn't the default — e.g. under `auto` you get `claude-opus-4-8` (1M) + `claude-opus-4-8-200k`, and under `"200k"` you get `claude-opus-4-8` (200K) + `claude-opus-4-8-1m`. + +> Forcing 1M is a *request*: some models may still be **served** 200K by your subscription entitlement. Set `CLAUDE_BRIDGE_DEBUG=1` to log the served window (see [Debugging](#debugging)). > An invalid `contextWindow` value logs a warning and falls back to `"auto"`, so a typo never breaks startup. ## Models -Select any of these from `/model`: +Pick any of these from `/model` — each entry shows a `(1M)` or `(200K)` label. The exact ids below assume the default `contextWindow: "auto"`; which id is unsuffixed vs `-1m` / `-200k` follows your configured [default window](#default-window). -| Picker id | Notes | -| --------- | ----- | -| `claude-bridge/claude-fable-5` | Fast, 200K by default | -| `claude-bridge/claude-opus-4-8` | Flagship, 1M by default | -| `claude-bridge/claude-opus-4-7` | 1M by default | +| Picker id (auto) | Window | +| --------- | ------ | +| `claude-bridge/claude-fable-5` | 200K | +| `claude-bridge/claude-fable-5-1m` | 1M | +| `claude-bridge/claude-opus-4-8` | 1M | +| `claude-bridge/claude-opus-4-8-200k` | 200K | +| `claude-bridge/claude-opus-4-7` | 1M | | `claude-bridge/claude-opus-4-6` | 200K (1M on Max / Extra Usage) | -| `claude-bridge/claude-sonnet-5` | 1M by default, supports `xhigh` | -| `claude-bridge/claude-sonnet-4-6` | 200K (1M on Extra Usage), supports `xhigh` | -| `claude-bridge/claude-haiku-4-5` | 200K, cheapest | +| `claude-bridge/claude-opus-4-6-1m` | 1M | +| `claude-bridge/claude-sonnet-5` | 1M (supports `xhigh`) | +| `claude-bridge/claude-sonnet-5-200k` | 200K | +| `claude-bridge/claude-sonnet-4-6` | 200K (supports `xhigh`) | +| `claude-bridge/claude-sonnet-4-6-1m` | 1M | +| `claude-bridge/claude-haiku-4-5` | 200K (cheapest) | Bash commands issued by Claude Code get a 120-second default timeout (matching Claude Code's default), since OMP's bash has no timeout by default. -**1M context notes:** Opus 4.7 and Opus 4.8 get 1M in `auto` by default. Opus 4.6 needs `plan: "max"` or `longContextExtraUsage: true`; Sonnet 4.6 needs `longContextExtraUsage: true`. Or just set `contextWindow: "1m"` to force it everywhere. - ## AskClaude tool Available whenever the active provider is **not** claude-bridge. Your current model can hand work to Claude Code and wait for the result: diff --git a/documentation/completed/features/2026-07-06_on-demand-context-window-variants.md b/documentation/completed/features/2026-07-06_on-demand-context-window-variants.md new file mode 100644 index 0000000..66171c9 --- /dev/null +++ b/documentation/completed/features/2026-07-06_on-demand-context-window-variants.md @@ -0,0 +1,87 @@ +# Task: On-demand context-window model variants (Option A) +**Date Started**: 2026-07-06 +**Status**: Complete +**Agent Lead**: 🏗️/⚙️ + +## Plan + +### Objective +Let the user choose a Claude model's context window **on demand** from OMP's native `/model` picker, by registering each model at every window it supports (1M and/or 200K) as a distinct, clearly-labeled entry. Switching window = pick the other entry. Keep `provider.contextWindow` from config as the **default** (it decides which window the plain, unsuffixed model id maps to). No new UI, no runtime command. + +### Current behavior (why there's nothing to select today) +- Context window is a single **global** choice in `~/.omp/agent/claude-bridge.json` (`provider.contextWindow: auto|1m|200k`), read **once at load**; changing it needs a `/reload`. Not per-model, not on-demand. +- OMP models carry a fixed `contextWindow`; there is no native per-model window knob (unlike thinking level's `setThinkingLevel`). Varying the window therefore requires registering variant models. +- `src/models.ts` today: `applyLongContext` filters to **one** variant per model from the global mode; `claudeCodeModelId(model, settings)` maps a pi model id → Claude Code cli id using the global settings; `resolveForcedOneMRuntimeModel` / `resolveForcedTwoHundredKRuntimeModel` already hold each model's 1M/200K cli id (or `null` when unavailable). + +### Approach — variant scheme (per model, in MODEL_IDS_IN_ORDER) +- **Available windows** = union of { 1M if `resolveForcedOneMRuntimeModel` ≠ null, 200K if `resolveForcedTwoHundredKRuntimeModel` ≠ null }. Independent of config, so both are pickable wherever a runtime exists. +- **Default window** = the window `resolveClaudeCodeRuntimeModel(id, settings)` returns for the current config (auto respects `plan`/`longContextExtraUsage`; `1m`/`200k` force), falling back to the model's only available window when the preferred one has no runtime. +- **Unsuffixed id** (e.g. `claude-opus-4-8`) = the default-window variant → preserves existing ids, roles, and the user's `config.yml`. +- **Each other available window** gets a suffixed id: `${baseId}-1m` / `${baseId}-200k`. +- **Display name** = `${baseName} (1M)` / `${baseName} (200K)` on every entry, for unambiguous labeling. +- **`contextWindow`** on each entry = its true window (keeps status bar + auto-compaction accurate). + +Resulting picker (config = `auto`, Pro plan): + +| Label | Picker id | CC cli id | Window | +| --- | --- | --- | --- | +| Opus 4.8 (1M) | `claude-opus-4-8` | `claude-opus-4-8[1m]` | 1M (default) | +| Opus 4.8 (200K) | `claude-opus-4-8-200k` | `claude-opus-4-8` | 200K | +| Opus 4.7 (1M) | `claude-opus-4-7` | `claude-opus-4-7` | 1M (only) | +| Opus 4.6 (200K) | `claude-opus-4-6` | `claude-opus-4-6` | 200K (default) | +| Opus 4.6 (1M) | `claude-opus-4-6-1m` | `claude-opus-4-6[1m]` | 1M | +| Fable 5 (200K) | `claude-fable-5` | `claude-fable-5` | 200K (default) | +| Fable 5 (1M) | `claude-fable-5-1m` | `claude-fable-5[1m]` | 1M | +| Sonnet 5 (1M) | `claude-sonnet-5` | `claude-sonnet-5[1m]` | 1M (default) | +| Sonnet 5 (200K) | `claude-sonnet-5-200k` | `claude-sonnet-5` | 200K | +| Sonnet 4.6 (200K) | `claude-sonnet-4-6` | `claude-sonnet-4-6` | 200K (default) | +| Sonnet 4.6 (1M) | `claude-sonnet-4-6-1m` | `claude-sonnet-4-6[1m]` | 1M | +| Haiku 4.5 (200K) | `claude-haiku-4-5` | `claude-haiku-4-5` | 200K (only) | + +Unsuffixed ids all keep their current default-per-config behavior, so `modelRoles.default: claude-bridge/claude-opus-4-8` still resolves and still gets 1M. + +### Code changes +1. **`src/models.ts`** + - Add `parseVariantId(id)` → `{ baseId, forced?: "1m" | "200k" }` (suffix-aware; base ids never collide with `-1m`/`-200k`). + - Update `claudeCodeModelId(model, settings)`: if the id is suffixed, return the forced runtime's cli id (ignore global mode); else keep the existing default-per-config path. + - Add `buildVariantModels(models, settings)` that expands each model into its window variants (default-first ordering, labels, `contextWindow`). Replace `applyLongContext`; remove it (no back-compat shim). +2. **`src/index.ts`** + - Line ~1634: register `buildVariantModels(MODELS, longContextSettings)` instead of `applyLongContext(...)`. + - The three `claudeCodeModelId` call sites (≈366 compact, ≈1251 streaming, ≈1463 AskClaude) stay unchanged — variant parsing is internal. +3. **`tests/unit-*.mjs`** — expansion (ids/windows/cli ids/labels), default selection per config (auto/1m/200k × plan/extraUsage), fallbacks (opus-4-7 under `200k`, haiku under `1m`), no duplicate windows, `claudeCodeModelId` round-trips for suffixed + unsuffixed ids. +4. **`README.md`** — update "Context window controls" + "Models": variants in the picker, config sets the default, keep the per-model table; note the entitlement caveat. + +### Tasks +- [ ] models.ts: `parseVariantId` + variant-aware `claudeCodeModelId` +- [ ] models.ts: `buildVariantModels` (default-first, labels, contextWindow); remove `applyLongContext` +- [ ] index.ts: swap registration to `buildVariantModels` +- [ ] Unit tests: expansion + id mapping + config defaults + fallbacks +- [ ] README updates +- [ ] `bun run typecheck` + `bun run test` green; manual `/model` check in OMP after `/reload` + +### Risks / notes +- The default window has no suffixed alias (it's the unsuffixed id); a role hardcoded to that suffix breaks only when config flips that window to default. Common case (`auto`) is stable. Documented. +- Entitlement: forced-1M variants may be **served** at 200K by the subscription (issue #18); `logServedContextWindow` already surfaces the gap. The picker offers the request; runtime may differ. +- Picker grows 7 → ~12 entries; grouped default-first, acceptable. +- `resolveModel` partial-match (AskClaude short names) unchanged: `"opus"` → first opus = default variant. Bonus: AskClaude can target `-200k` / `-1m` explicitly. +- Single `registerProvider` retained → subagent re-registration guard unaffected. +- User `config.yml` (`modelRoles.default`, `enabledModels: claude-bridge/*`) keeps working: unsuffixed id preserved, wildcard enables all variants. + +### Out of scope (MVP) +- `/ctx` runtime command (Option B). +- Removing the config knob (kept as default, per decision). +- Per-window cost/thinking differences (none exist today). + +## Implementation Log + +### 2026-07-06 — Implemented (Option A), all checks green +- **`src/models.ts`**: added exported `parseVariantId(id)` splitting `-1m`/`-200k` suffixes; made `claudeCodeModelId` variant-aware (suffixed id → forced runtime, ignores config; unsuffixed → config default). Replaced `applyLongContext` with `buildVariantModels` + a `variantName` helper — labels each entry `(1M)`/`(200K)`, sets the true `contextWindow`, orders default-first, offers both windows wherever a runtime exists, and makes the unsuffixed id the config default (falling back to the sole available window). +- **`src/index.ts`**: import + registration now call `buildVariantModels(MODELS, longContextSettings)`. The three `claudeCodeModelId` call sites (compact ~366, streaming ~1251, AskClaude ~1463) unchanged — parsing is internal. `applyLongContext` fully removed (grep-confirmed no remaining refs). +- **`tests/unit-context-window.mjs`**: rewritten for variants — 10 tests covering auto/Pro expansion (12 entries; ids/windows/labels), default-first ordering, auto/Max default flip (Opus 4.6), `200k` & `1m` config defaults + fallbacks (Opus 4.7, Haiku), the no-duplicate-window invariant, `parseVariantId`, and `claudeCodeModelId` round-trips (unsuffixed follows config, suffixed forces window, throws for impossible combos). +- **`README.md`**: "Context window controls" + "Models" rewritten — variants in `/model`, `provider.contextWindow` now sets the *default* (no longer hides models), added "Windows offered per model" table + entitlement caveat, updated the picker-id table. +- **Verification**: `bun run typecheck` clean; `bun run test` → 10/10 pass. +- **Deployment**: the plugin is symlinked into OMP (`~/.omp/plugins/node_modules/omp-claude-bridge` → this repo), so the change goes live after a `/reload` in the OMP TUI. + +### Notes / follow-ups +- Not done (out of scope, no explicit ask): version bump + CHANGELOG entry (still `0.7.0`). Adding variants changes the registered model set, so consider `0.8.0` before publishing. +- Manual `/model` visual check must be done in the running OMP after `/reload` (can't drive the TUI from here). diff --git a/src/index.ts b/src/index.ts index d98e501..1db7560 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ import { appendFileSync, mkdirSync, realpathSync, statSync } from "fs"; import { homedir } from "os"; import { dirname, join } from "path"; import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js"; -import { applyLongContext, buildModels, claudeCodeModelId, type ContextWindowMode, type LongContextSettings, resolveModel as _resolveModel } from "./models.js"; +import { buildVariantModels, buildModels, claudeCodeModelId, type ContextWindowMode, type LongContextSettings, resolveModel as _resolveModel } from "./models.js"; import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, extractSkillsBlock } from "./skills.js"; import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js"; import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js"; @@ -1631,7 +1631,7 @@ export default function (pi: ExtensionAPI) { longContextExtraUsage: providerSettings.longContextExtraUsage ?? false, contextWindow, }; - const registeredModels = applyLongContext(MODELS, longContextSettings); + const registeredModels = buildVariantModels(MODELS, longContextSettings); // Reset shared session on pi session lifecycle events const clearSession = (event: string) => { diff --git a/src/models.ts b/src/models.ts index bf373c6..f7b1439 100644 --- a/src/models.ts +++ b/src/models.ts @@ -141,10 +141,25 @@ function resolveForcedTwoHundredKRuntimeModel(modelId: string): ClaudeCodeRuntim } } +// Split a registered picker id into its base model id and the forced window it +// encodes. Variant ids carry a "-1m"/"-200k" suffix (see buildVariantModels); the +// unsuffixed id maps to the config default. Base ids never end in those suffixes, +// so the split is unambiguous. +export function parseVariantId(id: string): { baseId: string; forced?: "1m" | "200k" } { + if (id.endsWith("-1m")) return { baseId: id.slice(0, -3), forced: "1m" }; + if (id.endsWith("-200k")) return { baseId: id.slice(0, -5), forced: "200k" }; + return { baseId: id }; +} + export function claudeCodeModelId(model: { id: string }, settings: LongContextSettings): string { - const runtimeModel = resolveClaudeCodeRuntimeModel(model.id, settings); + const { baseId, forced } = parseVariantId(model.id); + const runtimeModel = forced === "1m" + ? resolveForcedOneMRuntimeModel(baseId) + : forced === "200k" + ? resolveForcedTwoHundredKRuntimeModel(baseId) + : resolveClaudeCodeRuntimeModel(baseId, settings); if (runtimeModel == null) { - throw new Error(`claude-bridge: model ${model.id} is unavailable when provider.contextWindow=${settings.contextWindow}`); + throw new Error(`claude-bridge: model ${model.id} has no Claude Code runtime (contextWindow=${settings.contextWindow})`); } return runtimeModel.cliModelId; } @@ -154,22 +169,56 @@ export function resolveModel(models: T[], input: strin return models.find((m) => m.id === lower || m.id.includes(lower)); } -// Produce the model metadata registered with OMP. The registered contextWindow must -// match the window the bridge actually requests from Claude Code, or OMP's status -// bar and auto-compaction threshold will misreport. The runtime policy is based -// on measured SDK behavior. Models with no runtime for the selected forced mode -// are filtered out so they never appear in the picker. -export function applyLongContext( +function variantName(baseName: string, contextWindow: number): string { + const label = contextWindow === ONE_M_CONTEXT ? "1M" : "200K"; + // Strip any window hint pi-ai already baked into the name so we don't double it. + const base = baseName.replace(/\s*(?:\((?:1M|200K)\)|\b1M\b)\s*$/i, "").trimEnd(); + return `${base} (${label})`; +} + +// Expand each model into one registered entry per context window it supports, so +// the user picks the window on demand from OMP's model picker. The unsuffixed id +// (e.g. claude-opus-4-8) maps to the config default window; every other available +// window gets a "-1m"/"-200k" suffixed id. Each entry's contextWindow must match +// the window the bridge actually requests (see claudeCodeModelId), or OMP's status +// bar and auto-compaction threshold will misreport. Both windows stay pickable +// regardless of provider.contextWindow, which only picks the default. +export function buildVariantModels( models: T[], settings: LongContextSettings, ): T[] { const result: T[] = []; for (const m of models) { - const runtimeModel = resolveClaudeCodeRuntimeModel(m.id, settings); - if (runtimeModel == null) continue; - const { contextWindow } = runtimeModel; - const name = contextWindow > TWO_HUNDRED_K_CONTEXT && !/\b1M\b/i.test(m.name) ? `${m.name} 1M` : m.name; - result.push(contextWindow === m.contextWindow && name === m.name ? m : { ...m, contextWindow, name }); + const available: Array<{ kind: "1m" | "200k"; contextWindow: number }> = []; + if (resolveForcedOneMRuntimeModel(m.id) != null) available.push({ kind: "1m", contextWindow: ONE_M_CONTEXT }); + if (resolveForcedTwoHundredKRuntimeModel(m.id) != null) available.push({ kind: "200k", contextWindow: TWO_HUNDRED_K_CONTEXT }); + + // Unknown model (not in the runtime tables): keep a single default-path entry. + if (available.length === 0) { + const runtimeModel = resolveClaudeCodeRuntimeModel(m.id, settings); + if (runtimeModel != null) result.push({ ...m, contextWindow: runtimeModel.contextWindow, name: variantName(m.name, runtimeModel.contextWindow) }); + continue; + } + + // The config default decides which window is unsuffixed; fall back to the sole + // available window when the preferred one has no runtime (e.g. Haiku under + // "1m", Opus 4.7 under "200k"). + const defaultRuntime = resolveClaudeCodeRuntimeModel(m.id, settings); + const preferredKind: "1m" | "200k" | undefined = defaultRuntime == null + ? undefined + : defaultRuntime.contextWindow === ONE_M_CONTEXT ? "1m" : "200k"; + const defaultKind = preferredKind != null && available.some((a) => a.kind === preferredKind) + ? preferredKind + : available[0].kind; + + const ordered = [ + ...available.filter((a) => a.kind === defaultKind), + ...available.filter((a) => a.kind !== defaultKind), + ]; + for (const { kind, contextWindow } of ordered) { + const id = kind === defaultKind ? m.id : `${m.id}-${kind}`; + result.push({ ...m, id, contextWindow, name: variantName(m.name, contextWindow) }); + } } return result; } diff --git a/tests/unit-context-window.mjs b/tests/unit-context-window.mjs index 0805e27..8bbf7b9 100644 --- a/tests/unit-context-window.mjs +++ b/tests/unit-context-window.mjs @@ -1,9 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { applyLongContext, claudeCodeModelId } from "../src/models.ts"; +import { buildVariantModels, claudeCodeModelId, parseVariantId } from "../src/models.ts"; -// Minimal stand-ins for pi-ai model entries (only the fields applyLongContext reads). +// Minimal stand-ins for pi-ai model entries (buildVariantModels reads id, name, +// contextWindow and spreads the rest through to each variant). const MODELS = [ { id: "claude-fable-5", name: "Fable 5", contextWindow: 1_000_000 }, { id: "claude-opus-4-8", name: "Opus 4.8", contextWindow: 200_000 }, @@ -14,39 +15,105 @@ const MODELS = [ { id: "claude-haiku-4-5", name: "Haiku 4.5", contextWindow: 200_000 }, ]; -const settings = (contextWindow) => ({ plan: "pro", longContextExtraUsage: false, contextWindow }); -const registered = (contextWindow) => applyLongContext(MODELS, settings(contextWindow)); -const byId = (contextWindow) => Object.fromEntries(registered(contextWindow).map((m) => [m.id, m])); +const settings = (contextWindow, extra = {}) => ({ plan: "pro", longContextExtraUsage: false, contextWindow, ...extra }); +const variants = (contextWindow, extra) => buildVariantModels(MODELS, settings(contextWindow, extra)); +const byId = (contextWindow, extra) => Object.fromEntries(variants(contextWindow, extra).map((m) => [m.id, m])); -test("auto: Fable 5 registers at 200K, Sonnet 5 at 1M, Haiku at 200K, Opus 4.7 present", () => { - const models = byId("auto"); - assert.equal(models["claude-fable-5"].contextWindow, 200_000); - assert.equal(models["claude-sonnet-5"].contextWindow, 1_000_000); - assert.equal(models["claude-haiku-4-5"].contextWindow, 200_000); - assert.ok(models["claude-opus-4-7"], "opus-4-7 should be available in auto"); +test("auto (Pro): each model expands to its available windows with correct ids, windows, and labels", () => { + const m = byId("auto"); + // Opus 4.8: default 1M unsuffixed + 200K alternate. + assert.equal(m["claude-opus-4-8"].contextWindow, 1_000_000); + assert.equal(m["claude-opus-4-8"].name, "Opus 4.8 (1M)"); + assert.equal(m["claude-opus-4-8-200k"].contextWindow, 200_000); + assert.equal(m["claude-opus-4-8-200k"].name, "Opus 4.8 (200K)"); + // Opus 4.7: 1M only, no 200K variant. + assert.equal(m["claude-opus-4-7"].contextWindow, 1_000_000); + assert.ok(!m["claude-opus-4-7-200k"], "opus-4-7 has no 200K runtime"); + // Opus 4.6 (Pro): default 200K unsuffixed + 1M alternate. + assert.equal(m["claude-opus-4-6"].contextWindow, 200_000); + assert.equal(m["claude-opus-4-6-1m"].contextWindow, 1_000_000); + // Fable 5: default 200K + 1M alternate. + assert.equal(m["claude-fable-5"].contextWindow, 200_000); + assert.equal(m["claude-fable-5-1m"].contextWindow, 1_000_000); + // Sonnet 5: default 1M + 200K alternate. + assert.equal(m["claude-sonnet-5"].contextWindow, 1_000_000); + assert.equal(m["claude-sonnet-5-200k"].contextWindow, 200_000); + // Sonnet 4.6: default 200K + 1M alternate. + assert.equal(m["claude-sonnet-4-6"].contextWindow, 200_000); + assert.equal(m["claude-sonnet-4-6-1m"].contextWindow, 1_000_000); + // Haiku 4.5: 200K only, no 1M variant. + assert.equal(m["claude-haiku-4-5"].contextWindow, 200_000); + assert.ok(!m["claude-haiku-4-5-1m"], "haiku has no 1M runtime"); }); -test("200k mode: Opus 4.7 is hidden and every registered model is 200K", () => { - const models = registered("200k"); - assert.ok(!models.some((m) => m.id === "claude-opus-4-7"), "opus-4-7 has no 200K runtime"); - for (const m of models) assert.equal(m.contextWindow, 200_000, `${m.id} should be 200K`); +test("auto (Pro): 12 entries, and no base model emits two entries for the same window", () => { + const list = variants("auto"); + assert.equal(list.length, 12); + for (const base of MODELS.map((mm) => mm.id)) { + const windows = list.filter((v) => v.id === base || v.id.startsWith(`${base}-`)).map((v) => v.contextWindow); + assert.equal(new Set(windows).size, windows.length, `${base} has duplicate windows`); + } }); -test("1m mode: Haiku 4.5 is hidden and every registered model is 1M", () => { - const models = registered("1m"); - assert.ok(!models.some((m) => m.id === "claude-haiku-4-5"), "haiku-4-5 has no 1M runtime"); - for (const m of models) assert.equal(m.contextWindow, 1_000_000, `${m.id} should be 1M`); +test("the default variant is listed before its suffixed alternate", () => { + const ids = variants("auto").map((m) => m.id); + assert.ok(ids.indexOf("claude-opus-4-8") < ids.indexOf("claude-opus-4-8-200k")); + assert.ok(ids.indexOf("claude-sonnet-5") < ids.indexOf("claude-sonnet-5-200k")); + assert.ok(ids.indexOf("claude-fable-5") < ids.indexOf("claude-fable-5-1m")); }); -test("cliModelId: bare id in auto/200k, [1m] suffix when forcing 1M", () => { +test("auto (Max): Opus 4.6 default flips to 1M and 200K becomes the suffixed alternate", () => { + const m = byId("auto", { plan: "max" }); + assert.equal(m["claude-opus-4-6"].contextWindow, 1_000_000); + assert.equal(m["claude-opus-4-6-200k"].contextWindow, 200_000); + assert.ok(!m["claude-opus-4-6-1m"], "1M is the default (unsuffixed) under Max"); +}); + +test("200k config: unsuffixed prefers 200K, 1M stays available as -1m, Opus 4.7 falls back to 1M", () => { + const m = byId("200k"); + assert.equal(m["claude-opus-4-8"].contextWindow, 200_000); + assert.equal(m["claude-opus-4-8-1m"].contextWindow, 1_000_000); + assert.ok(!m["claude-opus-4-8-200k"], "200K is the default here, so no suffixed duplicate"); + // Opus 4.7 has no 200K runtime, so its only window (1M) becomes the unsuffixed default. + assert.equal(m["claude-opus-4-7"].contextWindow, 1_000_000); + assert.ok(!m["claude-opus-4-7-1m"]); + assert.equal(m["claude-haiku-4-5"].contextWindow, 200_000); +}); + +test("1m config: unsuffixed prefers 1M, 200K stays available as -200k, Haiku falls back to 200K", () => { + const m = byId("1m"); + assert.equal(m["claude-opus-4-8"].contextWindow, 1_000_000); + assert.equal(m["claude-opus-4-8-200k"].contextWindow, 200_000); + assert.ok(!m["claude-opus-4-8-1m"], "1M is the default here, so no suffixed duplicate"); + // Haiku has no 1M runtime, so 200K stays the unsuffixed default. + assert.equal(m["claude-haiku-4-5"].contextWindow, 200_000); + assert.ok(!m["claude-haiku-4-5-200k"]); + assert.equal(m["claude-opus-4-7"].contextWindow, 1_000_000); +}); + +test("parseVariantId splits window suffixes and leaves base ids intact", () => { + assert.deepEqual(parseVariantId("claude-opus-4-8"), { baseId: "claude-opus-4-8" }); + assert.deepEqual(parseVariantId("claude-opus-4-8-200k"), { baseId: "claude-opus-4-8", forced: "200k" }); + assert.deepEqual(parseVariantId("claude-fable-5-1m"), { baseId: "claude-fable-5", forced: "1m" }); +}); + +test("claudeCodeModelId: unsuffixed id follows the config default", () => { assert.equal(claudeCodeModelId({ id: "claude-fable-5" }, settings("auto")), "claude-fable-5"); - assert.equal(claudeCodeModelId({ id: "claude-fable-5" }, settings("200k")), "claude-fable-5"); assert.equal(claudeCodeModelId({ id: "claude-fable-5" }, settings("1m")), "claude-fable-5[1m]"); - assert.equal(claudeCodeModelId({ id: "claude-opus-4-8" }, settings("200k")), "claude-opus-4-8"); assert.equal(claudeCodeModelId({ id: "claude-opus-4-8" }, settings("auto")), "claude-opus-4-8[1m]"); + assert.equal(claudeCodeModelId({ id: "claude-opus-4-8" }, settings("200k")), "claude-opus-4-8"); +}); + +test("claudeCodeModelId: a suffixed id forces its window regardless of config", () => { + assert.equal(claudeCodeModelId({ id: "claude-opus-4-8-200k" }, settings("auto")), "claude-opus-4-8"); + assert.equal(claudeCodeModelId({ id: "claude-opus-4-8-200k" }, settings("1m")), "claude-opus-4-8"); + assert.equal(claudeCodeModelId({ id: "claude-fable-5-1m" }, settings("200k")), "claude-fable-5[1m]"); + assert.equal(claudeCodeModelId({ id: "claude-opus-4-6-1m" }, settings("auto")), "claude-opus-4-6[1m]"); }); -test("requesting a hidden model directly throws", () => { +test("claudeCodeModelId throws when a model has no runtime for the requested window", () => { + assert.throws(() => claudeCodeModelId({ id: "claude-haiku-4-5-1m" }, settings("auto"))); + assert.throws(() => claudeCodeModelId({ id: "claude-opus-4-7-200k" }, settings("auto"))); assert.throws(() => claudeCodeModelId({ id: "claude-haiku-4-5" }, settings("1m"))); assert.throws(() => claudeCodeModelId({ id: "claude-opus-4-7" }, settings("200k"))); }); From 72b0f477d4c2c414e7e306cd9db5c19874cb5a89 Mon Sep 17 00:00:00 2001 From: Jonathan Borgwing Date: Mon, 6 Jul 2026 21:45:06 -0400 Subject: [PATCH 2/2] fix: address review feedback on models.ts - claudeCodeModelId: report the actually-requested window in the "no runtime" error (the forced suffix when present, else the config mode) instead of always the config mode. - buildVariantModels: detect unknown model ids up front via MODEL_IDS_IN_ORDER, before probing the forced resolvers, so an unrecognized id no longer triggers their "hiding it" console.error logs for a model the fast-path actually keeps. --- src/models.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/models.ts b/src/models.ts index f7b1439..3eff3e3 100644 --- a/src/models.ts +++ b/src/models.ts @@ -159,7 +159,8 @@ export function claudeCodeModelId(model: { id: string }, settings: LongContextSe ? resolveForcedTwoHundredKRuntimeModel(baseId) : resolveClaudeCodeRuntimeModel(baseId, settings); if (runtimeModel == null) { - throw new Error(`claude-bridge: model ${model.id} has no Claude Code runtime (contextWindow=${settings.contextWindow})`); + const requested = forced ?? settings.contextWindow; + throw new Error(`claude-bridge: model ${model.id} has no Claude Code runtime (contextWindow=${requested})`); } return runtimeModel.cliModelId; } @@ -189,17 +190,20 @@ export function buildVariantModels = []; - if (resolveForcedOneMRuntimeModel(m.id) != null) available.push({ kind: "1m", contextWindow: ONE_M_CONTEXT }); - if (resolveForcedTwoHundredKRuntimeModel(m.id) != null) available.push({ kind: "200k", contextWindow: TWO_HUNDRED_K_CONTEXT }); - - // Unknown model (not in the runtime tables): keep a single default-path entry. - if (available.length === 0) { + // Unknown model (not in the model tables): keep one default-path entry. + // Done before the forced-resolver probes below, which log "hiding it" on + // unknown ids — misleading noise for a model we actually keep. + if (!MODEL_IDS_IN_ORDER.includes(m.id)) { const runtimeModel = resolveClaudeCodeRuntimeModel(m.id, settings); if (runtimeModel != null) result.push({ ...m, contextWindow: runtimeModel.contextWindow, name: variantName(m.name, runtimeModel.contextWindow) }); continue; } + // Known models always have at least one available window. + const available: Array<{ kind: "1m" | "200k"; contextWindow: number }> = []; + if (resolveForcedOneMRuntimeModel(m.id) != null) available.push({ kind: "1m", contextWindow: ONE_M_CONTEXT }); + if (resolveForcedTwoHundredKRuntimeModel(m.id) != null) available.push({ kind: "200k", contextWindow: TWO_HUNDRED_K_CONTEXT }); + // The config default decides which window is unsuffixed; fall back to the sole // available window when the preferred one has no runtime (e.g. Haiku under // "1m", Opus 4.7 under "200k").