Skip to content
Open
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
21 changes: 21 additions & 0 deletions packages/shared/src/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getProviderOptionStringSelectionValue,
normalizeCustomModelSlug,
normalizeModelSlug,
resolveSelectableModel,
} from "./model.ts";

const codexCaps: ModelCapabilities = createModelCapabilities({
Expand Down Expand Up @@ -154,4 +155,24 @@ describe("model slug normalization", () => {
expect(normalizeModelSlug("opus", claude)).toBe("claude-opus-5");
expect(normalizeCustomModelSlug(" opus ")).toBe("opus");
});

it("resolves the bare Opus alias to the newest available Claude model", () => {
const claude = ProviderDriverKind.make("claudeAgent");
const currentModels = [
{ slug: "claude-opus-5", name: "Claude Opus 5" },
{ slug: "claude-opus-4-8", name: "Claude Opus 4.8" },
];
const olderModels = [
{ slug: "claude-opus-custom", name: "Custom Opus" },
{ slug: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ slug: "claude-sonnet-5", name: "Claude Sonnet 5" },
{ slug: "claude-opus-4-8", name: "Claude Opus 4.8" },
];
const customModels = [{ slug: "claude-opus-custom", name: "Custom Opus" }];

expect(resolveSelectableModel(claude, "opus", currentModels)).toBe("claude-opus-5");
expect(resolveSelectableModel(claude, "opus", olderModels)).toBe("claude-opus-4-8");
expect(resolveSelectableModel(claude, "opus-5", olderModels)).toBeNull();
expect(resolveSelectableModel(claude, "opus", customModels)).toBeNull();
});
});
25 changes: 24 additions & 1 deletion packages/shared/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ import {

const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex");

const CLAUDE_OPUS_FALLBACK_MODELS = [
"claude-opus-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
"claude-opus-4-5",
] as const;

export interface SelectableModelOption {
slug: string;
name: string;
Expand Down Expand Up @@ -287,7 +295,22 @@ export function resolveSelectableModel(
}

const resolved = options.find((option) => option.slug === normalized);
return resolved ? resolved.slug : null;
if (resolved) {
return resolved.slug;
}

// The bare Claude `opus` alias tracks the newest Opus model, but newer
// models can be omitted from the live catalog when the installed Claude
// Code version is too old. In that case, preserve the model family instead
// of falling through to the provider's unrelated default model. Use a fixed
// built-in priority so user-defined picker ordering and custom model slugs
// cannot change which fallback is selected.
if (provider === ProviderDriverKind.make("claudeAgent") && trimmed === "opus") {
const availableSlugs = new Set(options.map((option) => option.slug));
return CLAUDE_OPUS_FALLBACK_MODELS.find((slug) => availableSlugs.has(slug)) ?? null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fallback picks custom Opus slugs

Medium Severity

For bare opus, the new fallback treats any matching option slug in CLAUDE_OPUS_FALLBACK_MODELS as eligible, including custom models added via getAppModelOptionsForInstance. That can resolve opus to a user-defined claude-opus-* custom slug when no built-in Opus is in the catalog, instead of ignoring those slugs as described for this change.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 29a898e. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the mechanism is real, but the trigger is narrower than the description suggests, so I have left the behavior as-is for now.

The fallback compares slug strings, so it cannot distinguish a built-in entry from a custom one. I confirmed with a throwaway test that a catalog containing no built-in Opus plus a custom model whose slug is literally claude-opus-4-8 does resolve opus to that custom slug.

Two things bound it:

  1. normalizeCustomModelSlugs() in apps/web/src/modelSelection.ts already drops any custom slug that collides with a built-in model, so this requires a catalog where the colliding name is not currently built-in (an older Claude Code, on a custom instance, with a deliberately colliding name).
  2. Only the five canonical slugs in CLAUDE_OPUS_FALLBACK_MODELS are eligible. Arbitrary custom names such as claude-opus-custom are ignored, which is the case covered by the new test.

In that narrow scenario the result is a model the user explicitly named after a real Opus release, which seems closer to intent than falling through to the Sonnet default. An exact custom model named opus is also still preserved by the direct-slug match before any alias handling.

Fair hit on the PR wording though: "ignore custom claude-opus-* slugs" was looser than the code. I have corrected the description.

If maintainers would rather have this enforced strictly, the fix is to thread the existing isCustom flag into SelectableModelOption and skip custom entries in the fallback. That touches every call site that builds model options, so I kept it out of this two-file change — happy to add it here or as a follow-up if you prefer.

}

return null;
}

function resolveModelSlug(model: string | null | undefined, provider: ProviderDriverKind): string {
Expand Down
Loading