diff --git a/src/servers/api/chat/list-models.test.ts b/src/servers/api/chat/list-models.test.ts new file mode 100644 index 00000000..60e6a098 --- /dev/null +++ b/src/servers/api/chat/list-models.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'bun:test'; +import { listClaudeCodeModels } from './list-models'; + +/** + * The Claude tier list, and the one property the rest of the system depends on: the id survives the split. + * + * `claude-manager` derives what it hands the SDK with `params.model?.split('/')[1]`, so a bracketed alias + * only works if the id has exactly one `/` and the brackets come through untouched. That is easy to break + * by "tidying" an id into `claude-code/opus/1m` or url-encoding the brackets, and it would fail as the + * harness silently running the wrong model rather than as an error — the same class of silent mismatch the + * mid-conversation switch work existed to remove. + */ +const ids = () => listClaudeCodeModels().map((m) => m.id); + +describe('claude-code tiers', () => { + test('every alias the installed CLI accepts is offered', () => { + // Read out of the CLI binary rather than recalled — see the note in list-models.ts. `mythos` is + // deliberately excluded (Project Glasswing only), so its absence is part of the claim. + expect(ids()).toEqual([ + 'claude-code/fable', + 'claude-code/fable[1m]', + 'claude-code/opus', + 'claude-code/opus[1m]', + 'claude-code/opusplan', + 'claude-code/opusplan[1m]', + 'claude-code/sonnet', + 'claude-code/sonnet[1m]', + 'claude-code/haiku', + ]); + }); + + test('the alias survives `split("/")[1]` with its brackets intact', () => { + for (const id of ids()) { + const parts = id.split('/'); + expect(parts).toHaveLength(2); + expect(parts[1]).not.toContain('%'); + } + expect('claude-code/opus[1m]'.split('/')[1]).toBe('opus[1m]'); + }); + + test('a 1M variant declares a 1M window, and nothing else does', () => { + for (const model of listClaudeCodeModels()) { + expect(model.contextWindow).toBe(model.id.endsWith('[1m]') ? 1_000_000 : 200_000); + } + }); + + test('every tier is routed to the Claude sidecar', () => { + // `isClaudeModel` is a `startsWith` on the harness prefix; an id that misses it silently dispatches to + // OpenCode, which is the wrong-harness bug the model-switch guard refuses at runtime. + for (const id of ids()) expect(id.startsWith('claude-code')).toBe(true); + }); +}); diff --git a/src/servers/api/chat/list-models.ts b/src/servers/api/chat/list-models.ts index 3cf8cfb4..efb9c089 100644 --- a/src/servers/api/chat/list-models.ts +++ b/src/servers/api/chat/list-models.ts @@ -1,37 +1,68 @@ import type { ModelInfo } from './types'; import { getOpenCodeServerUrl } from './opencode/sidecar-server'; -// The Claude harness runs the `claude` CLI, so its tiers are a fixed set. -const CLAUDE_CODE_MODELS: ModelInfo[] = [ - { - id: 'claude-code/opus', - name: 'opus', - provider: 'claude-code', - contextWindow: 200000, - maxTokens: 16000, - reasoning: true, - images: true, - }, - { - id: 'claude-code/sonnet', - name: 'sonnet', - provider: 'claude-code', - contextWindow: 200000, - maxTokens: 16000, - reasoning: true, - images: true, - }, - { - id: 'claude-code/haiku', - name: 'haiku', - provider: 'claude-code', - contextWindow: 200000, - maxTokens: 8192, - reasoning: false, - images: true, - }, +/* + The Claude harness runs the `claude` CLI, so its tiers are a fixed set — but the set is bigger than the + three this listed until 2026-08-17. + + ── Where this list comes from ── + + The CLI takes either an ALIAS for the latest model in a family or a full model name; `claude --help` says + so on `--model`. The aliases below are the ones the installed CLI actually accepts, read out of its own + binary (`strings`) rather than recalled: `default`, `opus`, `sonnet`, `haiku`, `fable`, `mythos`, plus + `opusplan` and a `[1m]` suffix on the families that offer a 1M-context variant. Verified against + 2.1.233. + + Aliases rather than full names (`claude-opus-5`) on purpose: an alias follows the latest model in its + family, so this file does not need editing every time one ships. The cost is that what `opus` resolves to + changes under you — which is the right trade for a picker, and the wrong one for a reproducible eval. + + ── Why it is still hardcoded ── + + The SDK can answer this properly: `Query.supportedModels()` returns the harness's own list with display + names, descriptions and effort levels. It is a CONTROL REQUEST, so it needs a live session — and this + endpoint is what the composer calls BEFORE there is one. Deriving the catalogue from the harness means + either holding a session open just to ask, or caching an answer from the last session, and neither is + worth it while the set changes once a release. `[open]` If a `supportedModels()` cache ever lands, this + array becomes its fallback rather than its source. + + ── mythos is deliberately absent ── + + `claude-mythos-5` is Project Glasswing only. Listing it would offer a model most installs cannot reach, + and a picker entry that 401s is worse than one that is missing. Add it here if this machine has access. +*/ +type ClaudeTier = { alias: string; label: string; long?: boolean; reasoning?: boolean; maxTokens?: number }; + +const CLAUDE_CODE_TIERS: ClaudeTier[] = [ + { alias: 'fable', label: 'fable' }, + { alias: 'fable[1m]', label: 'fable (1M)', long: true }, + { alias: 'opus', label: 'opus' }, + { alias: 'opus[1m]', label: 'opus (1M)', long: true }, + // opus plans, sonnet executes. A real alias, and the one entry that is a MODE rather than a model — + // worth listing because it is the cheapest way to get opus-quality planning on a long task. + { alias: 'opusplan', label: 'opus plan → sonnet' }, + { alias: 'opusplan[1m]', label: 'opus plan → sonnet (1M)', long: true }, + { alias: 'sonnet', label: 'sonnet' }, + { alias: 'sonnet[1m]', label: 'sonnet (1M)', long: true }, + { alias: 'haiku', label: 'haiku', reasoning: false, maxTokens: 8192 }, ]; +const CLAUDE_CODE_MODELS: ModelInfo[] = CLAUDE_CODE_TIERS.map((tier) => ({ + id: `claude-code/${tier.alias}`, + name: tier.label, + provider: 'claude-code', + // `[1m]` is the caller ASKING for the 1M-context variant, so it is the one case where the window is + // known from the alias. Everything else keeps the 200K this file has always claimed — a floor, not a + // measurement, since an alias resolves to whatever the CLI currently points it at. + contextWindow: tier.long ? 1_000_000 : 200_000, + maxTokens: tier.maxTokens ?? 16000, + reasoning: tier.reasoning ?? true, + images: true, +})); + +/** The Claude tiers alone, so a test can assert the alias set without reaching the OpenCode sidecar. */ +export const listClaudeCodeModels = (): ModelInfo[] => CLAUDE_CODE_MODELS; + // Cache the OpenCode catalog; it's stable for a session. let openCodeCache: ModelInfo[] | null = null;