feat(models): add Opus 4.8 selector support

This commit is contained in:
777genius 2026-05-31 22:20:29 +03:00
parent e215c09af0
commit 61418cf2f2
10 changed files with 205 additions and 11 deletions

View file

@ -95,6 +95,8 @@ function isKnownAnthropicReasoningModel(model: string | null | undefined): boole
return (
normalized === 'opus' ||
normalized === 'sonnet' ||
normalized === 'claude-opus-4-8' ||
normalized.startsWith('claude-opus-4-8-') ||
normalized === 'claude-opus-4-7' ||
normalized.startsWith('claude-opus-4-7-') ||
normalized === 'claude-opus-4-6' ||

View file

@ -148,6 +148,7 @@ const OPENCODE_MODEL_GRID_MAX_HEIGHT_PX = 400;
const OPENCODE_MODEL_VIRTUALIZATION_THRESHOLD = 80;
const OPENCODE_MODEL_GROUP_HEADING_ESTIMATE_PX = 28;
const OPENCODE_MODEL_ROW_ESTIMATE_PX = 92;
const ANTHROPIC_OPUS_48_NEW_BADGE_EXPIRES_AT_MS = Date.UTC(2026, 5, 12);
const PROVIDERS: ProviderDef[] = [
{ id: 'anthropic', label: 'Anthropic', comingSoon: false },
@ -421,6 +422,24 @@ function isFreeOpenCodeModelRoute(model: string): boolean {
);
}
function isAnthropicOpus48NewBadgeVisible(
providerId: TeamProviderId,
model: string,
nowMs = Date.now()
): boolean {
if (providerId !== 'anthropic' || nowMs >= ANTHROPIC_OPUS_48_NEW_BADGE_EXPIRES_AT_MS) {
return false;
}
const normalized = model.trim().toLowerCase();
return (
normalized === 'opus' ||
normalized === 'opus[1m]' ||
normalized === 'claude-opus-4-8' ||
normalized === 'claude-opus-4-8[1m]'
);
}
function hasFreeOpenCodeModelRoute(providerStatus: CliProviderStatus | null | undefined): boolean {
if (providerStatus?.providerId !== 'opencode') {
return false;
@ -825,13 +844,13 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
'anthropic',
getAnthropicDefaultTeamModel(false),
runtimeProviderStatus
) ?? 'Opus 4.7 (1M)';
) ?? 'Opus 4.8 (1M)';
const defaultLimitedContextModel =
getRuntimeAwareProviderScopedTeamModelLabel(
'anthropic',
getAnthropicDefaultTeamModel(true),
runtimeProviderStatus
) ?? 'Opus 4.7';
) ?? 'Opus 4.8';
return t('modelSelector.defaultTooltip.anthropic', {
longContextModel: defaultLongContextModel,
@ -1420,6 +1439,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
const openCodeProofState = openCodeRouteMetadata?.proofState ?? null;
const modelButtonTitle =
modelStatusMessage ?? (opt.value === '' ? defaultModelTooltip : undefined);
const showNewRibbon = isAnthropicOpus48NewBadgeVisible(effectiveProviderId, opt.value);
return (
<button
@ -1429,7 +1449,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
aria-disabled={!modelSelectable}
title={modelButtonTitle}
className={cn(
'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border bg-[var(--color-surface)] px-3 py-2 text-center text-xs font-medium transition-[background-color,border-color,color,box-shadow] duration-150',
'relative flex min-h-[44px] items-center justify-center gap-1.5 overflow-hidden rounded-md border bg-[var(--color-surface)] px-3 py-2 text-center text-xs font-medium transition-[background-color,border-color,color,box-shadow] duration-150',
hasBlockingModelIssue && normalizedValue === opt.value
? 'border-red-500/60 bg-red-500/10 text-red-100 shadow-sm'
: hasBlockingModelIssue
@ -1609,6 +1629,11 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
</span>
)}
</span>
{showNewRibbon ? (
<span className="pointer-events-none absolute right-[-22px] top-1.5 w-[72px] rotate-45 border border-emerald-300/35 bg-emerald-400/15 py-0.5 text-center text-[8px] font-bold uppercase leading-none tracking-[0.14em] text-emerald-100 shadow-sm">
New
</span>
) : null}
</button>
);
};

View file

@ -47,12 +47,17 @@ const TEAM_PROVIDER_LABELS: Record<SupportedProviderId, string> = {
};
const ANTHROPIC_ALIAS_LABELS = {
opus: 'Opus 4.7',
opus: 'Opus 4.8',
sonnet: 'Sonnet 4.6',
haiku: 'Haiku 4.5',
} as const;
const ANTHROPIC_VISIBLE_MODEL_FALLBACKS = ['claude-opus-4-7', 'claude-opus-4-7[1m]'] as const;
const ANTHROPIC_VISIBLE_MODEL_FALLBACKS = [
'claude-opus-4-8',
'claude-opus-4-8[1m]',
'claude-opus-4-7',
'claude-opus-4-7[1m]',
] as const;
const ANTHROPIC_MODEL_ORDER = [
'haiku',
@ -60,6 +65,8 @@ const ANTHROPIC_MODEL_ORDER = [
'claude-haiku-4-5',
'opus',
'opus[1m]',
'claude-opus-4-8',
'claude-opus-4-8[1m]',
'claude-opus-4-7',
'claude-opus-4-7[1m]',
'claude-opus-4-6',
@ -73,6 +80,8 @@ const ANTHROPIC_MODEL_ORDER = [
const TEAM_MODEL_LABEL_OVERRIDES: Record<string, string> = {
default: 'Default',
...ANTHROPIC_ALIAS_LABELS,
'claude-opus-4-8': 'Opus 4.8',
'claude-opus-4-8[1m]': 'Opus 4.8 (1M)',
'claude-opus-4-7': 'Opus 4.7',
'claude-opus-4-7[1m]': 'Opus 4.7 (1M)',
'claude-sonnet-4-6': 'Sonnet 4.6',
@ -99,7 +108,8 @@ const TEAM_PROVIDER_MODEL_OPTIONS: Record<SupportedProviderId, readonly TeamProv
{
anthropic: [
{ value: '', label: 'Default', badgeLabel: 'Default' },
{ value: 'opus', label: 'Opus 4.7', badgeLabel: 'Opus 4.7' },
{ value: 'opus', label: 'Opus 4.8', badgeLabel: 'Opus 4.8' },
{ value: 'claude-opus-4-7', label: 'Opus 4.7', badgeLabel: 'Opus 4.7' },
{ value: 'claude-opus-4-6', label: 'Opus 4.6', badgeLabel: 'Opus 4.6' },
{ value: 'sonnet', label: 'Sonnet 4.6', badgeLabel: 'Sonnet 4.6' },
{ value: 'haiku', label: 'Haiku 4.5', badgeLabel: 'Haiku 4.5' },
@ -208,6 +218,8 @@ const SUPPORTED_ANTHROPIC_TEAM_MODELS = new Set<string>([
'sonnet',
'sonnet[1m]',
'haiku',
'claude-opus-4-8',
'claude-opus-4-8[1m]',
'claude-opus-4-7',
'claude-opus-4-7[1m]',
'claude-opus-4-6',
@ -397,6 +409,11 @@ export function getRuntimeAwareProviderScopedTeamModelLabel(
model: string | undefined,
providerStatus?: RuntimeAwareProviderStatus | null
): string | undefined {
const trimmed = model?.trim();
if (providerId === 'anthropic' && (trimmed === 'opus' || trimmed === 'opus[1m]')) {
return getProviderScopedTeamModelLabel(providerId, trimmed);
}
const runtimeModel = getRuntimeCatalogModel(providerId, model, providerStatus);
const runtimeLabel = runtimeModel?.displayName?.trim();
if (runtimeLabel) {
@ -411,6 +428,11 @@ export function getRuntimeAwareTeamModelBadgeLabel(
model: string | undefined,
providerStatus?: RuntimeAwareProviderStatus | null
): string | undefined {
const trimmed = model?.trim();
if (providerId === 'anthropic' && (trimmed === 'opus' || trimmed === 'opus[1m]')) {
return getTeamModelBadgeLabel(providerId, trimmed);
}
const runtimeModel = getRuntimeCatalogModel(providerId, model, providerStatus);
if (runtimeModel?.badgeLabel?.trim()) {
return runtimeModel.badgeLabel.trim();

View file

@ -109,6 +109,12 @@ describe('contextMetrics', () => {
});
it('infers Anthropic native 1M windows from current raw model ids', () => {
expect(
inferContextWindowTokens({
providerId: 'anthropic',
modelName: 'claude-opus-4-8',
})
).toBe(1_000_000);
expect(
inferContextWindowTokens({
providerId: 'anthropic',

View file

@ -112,6 +112,7 @@ function isAnthropicNativeLongContextModel(modelName: string | undefined): boole
}
return (
normalized.startsWith('claude-opus-4-8') ||
normalized.startsWith('claude-opus-4-7') ||
normalized.startsWith('claude-opus-4-6') ||
normalized.startsWith('claude-sonnet-4-6') ||

View file

@ -20,7 +20,13 @@ describe('formatTeamModelSummary', () => {
);
});
it('formats current Anthropic Opus model ids with the latest 4.7 label', () => {
it('formats current Anthropic Opus model ids with the latest 4.8 label', () => {
expect(formatTeamModelSummary('anthropic', 'claude-opus-4-8', 'high')).toBe(
'Anthropic · Opus 4.8 · High'
);
expect(formatTeamModelSummary('codex', 'claude-opus-4-8', 'medium')).toBe(
'Opus 4.8 · via Codex · Medium'
);
expect(formatTeamModelSummary('anthropic', 'claude-opus-4-7', 'high')).toBe(
'Anthropic · Opus 4.7 · High'
);
@ -122,6 +128,7 @@ describe('formatTeamModelSummary', () => {
expect(getTeamModelSelectionError('codex', 'gpt-5.4')).toBeNull();
expect(getTeamModelSelectionError('codex', '')).toBeNull();
expect(getTeamModelSelectionError('anthropic', 'opus')).toBeNull();
expect(getTeamModelSelectionError('anthropic', 'claude-opus-4-8')).toBeNull();
expect(getTeamModelSelectionError('anthropic', 'claude-opus-4-7')).toBeNull();
});
});
@ -237,6 +244,7 @@ describe('computeEffectiveTeamModel', () => {
expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus');
expect(computeEffectiveTeamModel('opus[1m][1m]', true, 'anthropic')).toBe('opus');
expect(computeEffectiveTeamModel('', true, 'anthropic')).toBe('opus');
expect(computeEffectiveTeamModel('claude-opus-4-8[1m]', true, 'anthropic')).toBe('opus');
expect(computeEffectiveTeamModel('claude-opus-4-7[1m]', true, 'anthropic')).toBe('opus');
expect(computeEffectiveTeamModel('claude-sonnet-4-6', true, 'anthropic')).toBe('sonnet');
});

View file

@ -270,6 +270,68 @@ describe('TeamModelSelector disabled Codex models', () => {
});
});
it('shows a temporary New ribbon for Opus 4.8 during the launch window', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(Date.UTC(2026, 4, 31));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'anthropic',
onProviderChange: () => undefined,
value: 'opus',
onValueChange: () => undefined,
})
);
await Promise.resolve();
});
const opus48Button = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.trim().startsWith('Opus 4.8')
);
expect(opus48Button?.textContent).toContain('New');
await act(async () => {
root.unmount();
await Promise.resolve();
});
dateNowSpy.mockRestore();
});
it('hides the Opus 4.8 New ribbon after the launch window expires', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(Date.UTC(2026, 5, 12));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'anthropic',
onProviderChange: () => undefined,
value: 'opus',
onValueChange: () => undefined,
})
);
await Promise.resolve();
});
const opus48Button = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.trim().startsWith('Opus 4.8')
);
expect(opus48Button?.textContent).not.toContain('New');
await act(async () => {
root.unmount();
await Promise.resolve();
});
dateNowSpy.mockRestore();
});
it('uses the runtime-reported Codex list and clears stale unsupported selections', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {

View file

@ -544,7 +544,7 @@ describe('teamModelAvailability', () => {
expect(getTeamModelSelectionError('anthropic', 'opus')).toBeNull();
});
it('keeps both Anthropic Opus 4.7 and explicit Opus 4.6 in the fallback selector options', () => {
it('keeps Anthropic Opus 4.8, explicit 4.7, and explicit 4.6 in the fallback selector options', () => {
expect(getAvailableTeamProviderModelOptions('anthropic')).toEqual([
{
value: '',
@ -555,6 +555,13 @@ describe('teamModelAvailability', () => {
},
{
value: 'opus',
label: 'Opus 4.8',
badgeLabel: 'Opus 4.8',
availabilityStatus: 'available',
availabilityReason: null,
},
{
value: 'claude-opus-4-7',
label: 'Opus 4.7',
badgeLabel: 'Opus 4.7',
availabilityStatus: 'available',
@ -584,12 +591,68 @@ describe('teamModelAvailability', () => {
]);
});
it('does not let stale first-party Anthropic runtime labels downgrade the Opus alias', () => {
const providerStatus: TeamModelRuntimeProviderStatus = {
providerId: 'anthropic',
models: ['opus', 'claude-opus-4-7'],
authMethod: 'oauth',
backend: null,
authenticated: true,
supported: true,
modelVerificationState: 'idle',
modelAvailability: [],
modelCatalog: {
schemaVersion: 1,
providerId: 'anthropic',
source: 'anthropic-models-api',
status: 'ready',
fetchedAt: '2026-05-31T00:00:00.000Z',
staleAt: '2026-05-31T00:10:00.000Z',
defaultModelId: 'opus',
defaultLaunchModel: 'opus',
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
models: [
{
id: 'opus',
launchModel: 'opus',
displayName: 'Opus 4.7',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high', 'max'],
defaultReasoningEffort: 'high',
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: true,
upgrade: false,
source: 'anthropic-models-api',
},
],
},
};
const options = getAvailableTeamProviderModelOptions('anthropic', providerStatus);
expect(options.find((option) => option.value === 'opus')).toMatchObject({
label: 'Opus 4.8',
badgeLabel: 'Opus 4.8',
});
expect(options.find((option) => option.value === 'claude-opus-4-7')).toMatchObject({
label: 'Opus 4.7',
badgeLabel: 'Opus 4.7',
});
});
it('keeps known Anthropic full model ids selectable without runtime verification', () => {
expect(normalizeTeamModelForUi('anthropic', 'claude-opus-4-8')).toBe('claude-opus-4-8');
expect(normalizeTeamModelForUi('anthropic', 'claude-opus-4-8[1m]')).toBe('claude-opus-4-8[1m]');
expect(normalizeTeamModelForUi('anthropic', 'claude-opus-4-7')).toBe('claude-opus-4-7');
expect(normalizeTeamModelForUi('anthropic', 'claude-opus-4-7[1m]')).toBe('claude-opus-4-7[1m]');
expect(normalizeTeamModelForUi('anthropic', 'claude-haiku-4-5-20251001')).toBe(
'claude-haiku-4-5-20251001'
);
expect(getTeamModelSelectionError('anthropic', 'claude-opus-4-8')).toBeNull();
expect(getTeamModelSelectionError('anthropic', 'claude-opus-4-7')).toBeNull();
expect(getTeamModelSelectionError('anthropic', 'claude-haiku-4-5-20251001')).toBeNull();
});

View file

@ -22,7 +22,7 @@ describe('teamModelCatalog', () => {
).toEqual(['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2', 'gpt-5.1-codex-max']);
});
it('adds curated Anthropic Opus 4.7 badges when the runtime list only reports legacy Opus variants', () => {
it('adds curated Anthropic Opus 4.8 badges when the runtime list only reports legacy Opus variants', () => {
expect(
getVisibleTeamProviderModels('anthropic', [
'claude-haiku-4-5-20251001',
@ -33,6 +33,8 @@ describe('teamModelCatalog', () => {
])
).toEqual([
'claude-haiku-4-5-20251001',
'claude-opus-4-8',
'claude-opus-4-8[1m]',
'claude-opus-4-7',
'claude-opus-4-7[1m]',
'claude-opus-4-6',
@ -301,6 +303,8 @@ describe('teamModelCatalog', () => {
it('detects 1M Anthropic selections and native 1M launch ids', () => {
expect(isAnthropicOneMillionContextTeamModel('sonnet')).toBe(false);
expect(isAnthropicOneMillionContextTeamModel('sonnet[1m]')).toBe(true);
expect(isAnthropicOneMillionContextTeamModel('claude-opus-4-8')).toBe(true);
expect(isAnthropicOneMillionContextTeamModel('claude-opus-4-8[1m]')).toBe(true);
expect(isAnthropicOneMillionContextTeamModel('claude-opus-4-7')).toBe(true);
expect(isAnthropicOneMillionContextTeamModel('claude-opus-4-7[1m]')).toBe(true);
expect(isAnthropicOneMillionContextTeamModel('claude-sonnet-4-6')).toBe(true);

View file

@ -1,9 +1,8 @@
import { describe, expect, it } from 'vitest';
import {
getTeamModelRecommendation,
isTeamModelRecommended,
} from '@renderer/utils/teamModelRecommendations';
import { describe, expect, it } from 'vitest';
describe('getTeamModelRecommendation', () => {
it('marks all visible Codex Agent Teams models as recommended', () => {
@ -32,6 +31,8 @@ describe('getTeamModelRecommendation', () => {
'claude-haiku-4-5-20251001',
'claude-sonnet-4-6',
'claude-sonnet-4-6[1m]',
'claude-opus-4-8',
'claude-opus-4-8[1m]',
'claude-opus-4-7',
'claude-opus-4-7[1m]',
'claude-opus-4-6',