Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"prepublishOnly": "bun run build:web"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/claude-agent-sdk": "^0.3.258",
"@google/generative-ai": "^0.24.1",
"@grammyjs/auto-retry": "^2.0.2",
"@highflame/codeoid-core": "^0.4.0",
Expand Down
36 changes: 30 additions & 6 deletions src/daemon/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ export interface ModelDescriptor {
*/
export const MODEL_CATALOG: readonly ModelDescriptor[] = [
{
id: "claude-opus-4-8",
id: "claude-opus-5",
alias: "opus",
label: "Opus 4.8",
label: "Opus 5",
contextWindow: 1_000_000,
tier: "premium",
description: "Deepest reasoning. Best for planning, refactoring, and hard problems.",
Expand Down Expand Up @@ -169,12 +169,33 @@ export function fallbackModelInfos(): ModelInfo[] {
}));
}

/**
* Strip a context-window variant suffix: `opus[1m]` → `opus`.
*
* The backend advertises variants as a bracketed suffix on the VALUE, not the
* display name — the live list reports `opus[1m]` / "Opus (1M context)". A
* user (or a stored session) typing the bare alias `opus` must still match it,
* or resolution silently falls through to the baked-in catalog below and
* pins them to whatever point release that catalog last knew about.
*
* Exported for unit testing.
*/
export function stripVariantSuffix(value: string): string {
const i = value.indexOf("[");
return i === -1 ? value : value.slice(0, i);
}

/**
* Resolve user input → a canonical model value against a (live or fallback)
* `ModelInfo[]`. Matches by exact `value` or case-insensitive `displayName`
* (so `opus` matches the backend's "Opus"), with a `claude-*` passthrough.
* Returns null when the input matches nothing — the caller surfaces the set
* of valid values.
* `ModelInfo[]`. Matches by exact `value`, by `value` with its variant suffix
* stripped (so `opus` matches the backend's `opus[1m]`), or by
* case-insensitive `displayName` (so `fable` matches "Fable"), with a
* `claude-*` passthrough. Returns null when the input matches nothing — the
* caller surfaces the set of valid values.
*
* Exact matches are checked across the WHOLE list before any suffix-stripped
* match, so a backend offering both `opus` and `opus[1m]` resolves `opus` to
* the exact entry rather than to whichever came first.
*/
export function resolveAgainstList(
input: string,
Expand All @@ -185,6 +206,9 @@ export function resolveAgainstList(
const lower = t.toLowerCase();
for (const m of models) {
if (m.value.toLowerCase() === lower) return m.value;
}
for (const m of models) {
if (stripVariantSuffix(m.value).toLowerCase() === lower) return m.value;
if (m.displayName.toLowerCase() === lower) return m.value;
}
if (/^claude-/i.test(t)) return t;
Expand Down
52 changes: 48 additions & 4 deletions src/tests/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,69 @@ import {
DEFAULT_MODEL_ALIAS,
fallbackModelInfos,
resolveAgainstList,
stripVariantSuffix,
} from "../daemon/models.js";
import { Store } from "../daemon/store.js";
import { SessionManager, DEFAULT_PROVIDER_ID } from "../daemon/session-manager.js";
import { TranscriptStore } from "../daemon/transcript.js";

describe("resolveAgainstList (live-backend resolution)", () => {
// Verbatim from `supportedModels()` on claude-agent-sdk 0.3.258. The
// previous fixture said the Opus entry's displayName was "Opus", which made
// the alias resolve by display-name match; the backend actually reports
// "Opus (1M context)", so `opus` matched nothing and silently fell through
// to the baked-in catalog. Keep this fixture honest to the real payload.
const live = [
{ value: "default", displayName: "Default (recommended)", isDefault: true },
{ value: "opus[1m]", displayName: "Opus" },
{ value: "opus[1m]", displayName: "Opus (1M context)" },
{ value: "claude-fable-5-1[1m]", displayName: "Fable" },
{ value: "sonnet", displayName: "Sonnet" },
{ value: "haiku", displayName: "Haiku" },
];

it("matches an exact value", () => {
expect(resolveAgainstList("opus[1m]", live)).toBe("opus[1m]");
expect(resolveAgainstList("sonnet", live)).toBe("sonnet");
});
it("matches a display name case-insensitively (alias-like)", () => {
it("matches a bare alias against a variant-suffixed value", () => {
expect(resolveAgainstList("opus", live)).toBe("opus[1m]");
expect(resolveAgainstList("OPUS", live)).toBe("opus[1m]");
});
it("passes through a full claude-* id", () => {
expect(resolveAgainstList("claude-fable-5[1m]", live)).toBe("claude-fable-5[1m]");
it("matches a display name case-insensitively", () => {
expect(resolveAgainstList("fable", live)).toBe("claude-fable-5-1[1m]");
expect(resolveAgainstList("Default (recommended)", live)).toBe("default");
});
it("prefers an exact value over a suffix-stripped match", () => {
const both = [{ value: "opus[1m]", displayName: "Opus (1M context)" }, { value: "opus", displayName: "Opus" }];
expect(resolveAgainstList("opus", both)).toBe("opus");
});
it("matches a bare full id against its variant-suffixed entry", () => {
// Typing the plain id resolves to the 1M variant the backend actually
// offers, rather than falling through to the claude-* passthrough.
expect(resolveAgainstList("claude-fable-5-1", live)).toBe("claude-fable-5-1[1m]");
});
it("passes through a claude-* id the backend didn't advertise", () => {
// The catalog and the live list both go stale between releases; the
// passthrough is what keeps a brand-new id reachable in the meantime.
expect(resolveAgainstList("claude-opus-6", live)).toBe("claude-opus-6");
});
it("returns null for an unknown value", () => {
expect(resolveAgainstList("o", live)).toBeNull();
expect(resolveAgainstList("", live)).toBeNull();
});
});

describe("stripVariantSuffix", () => {
it("strips a bracketed context-window variant", () => {
expect(stripVariantSuffix("opus[1m]")).toBe("opus");
expect(stripVariantSuffix("claude-fable-5[1m]")).toBe("claude-fable-5");
});
it("leaves an unsuffixed value alone", () => {
expect(stripVariantSuffix("sonnet")).toBe("sonnet");
expect(stripVariantSuffix("")).toBe("");
});
});

describe("fallbackModelInfos", () => {
it("renders the built-in catalog as ModelInfo with a default", () => {
const infos = fallbackModelInfos();
Expand All @@ -55,6 +90,15 @@ describe("fallbackModelInfos", () => {
});

describe("MODEL_CATALOG shape", () => {
// The catalog is only the pre-first-report fallback, but a stale entry here
// is not harmless: it silently pins the alias to a superseded model on every
// path that misses the live list. `opus` sat on claude-opus-4-8 well after
// Opus 5 shipped. This asserts the generation, not the point release, so a
// real bump stays a one-line edit while a whole generation going stale fails.
it("maps the premium alias to the current Opus generation", () => {
expect(resolveModelId("opus")).toBe("claude-opus-5");
});

it("covers the three canonical tiers", () => {
expect(MODEL_CATALOG.length).toBeGreaterThanOrEqual(3);
const tiers = new Set(MODEL_CATALOG.map((m) => m.tier));
Expand Down
Loading