fable, and every model alias the CLI actually takes

the claude tier list had three entries — opus, sonnet, haiku — and the CLI accepts nine. `--help` says
`--model` takes either an alias for the latest model in a family or a full name, and the aliases the
INSTALLED cli accepts came out of its own binary with `strings` rather than from memory: default, opus,
sonnet, haiku, fable, mythos, opusplan, and a [1m] suffix on the families with a 1M-context variant.
verified against 2.1.233.

aliases rather than full ids (claude-opus-5) so a new model in a family needs no edit here. the cost is
that what `opus` resolves to moves under you — right for a picker, wrong for a reproducible eval.

mythos is deliberately left out: claude-mythos-5 is Project Glasswing only, and a picker entry that 401s
is worse than a missing one. the comment says how to add it.

opusplan is the one entry that is a MODE rather than a model — opus plans, sonnet executes.

NOT derived from the harness, though it could be: Query.supportedModels() returns the real list with
display names and effort levels, but it is a control request and needs a live session, and this endpoint
is what the composer calls before there is one. noted as open in the file.

the test pins the alias set and the property everything else rests on: claude-manager derives what it
hands the SDK with `split('/')[1]`, so a bracketed alias only works while the id has exactly one slash and
the brackets are unencoded. tidying an id into claude-code/opus/1m would fail as the harness quietly
running the wrong model, not as an error.
This commit is contained in:
2026-08-17 14:28:13 +00:00
parent 4c24df93c1
commit 57bbcca9ae
2 changed files with 112 additions and 29 deletions
+52
View File
@@ -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);
});
});