From 61418cf2f253ce30850d1ce0823163e62464d9b4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 31 May 2026 22:20:29 +0300 Subject: [PATCH] feat(models): add Opus 4.8 selector support --- .../domain/resolveAnthropicRuntimeProfile.ts | 2 + .../team/dialogs/TeamModelSelector.tsx | 31 ++++++++- src/renderer/utils/teamModelCatalog.ts | 28 +++++++- .../utils/__tests__/contextMetrics.test.ts | 6 ++ src/shared/utils/contextMetrics.ts | 1 + .../components/team/TeamModelSelector.test.ts | 10 ++- .../TeamModelSelectorDisabledState.test.ts | 62 ++++++++++++++++++ .../utils/teamModelAvailability.test.ts | 65 ++++++++++++++++++- test/renderer/utils/teamModelCatalog.test.ts | 6 +- .../utils/teamModelRecommendations.test.ts | 5 +- 10 files changed, 205 insertions(+), 11 deletions(-) diff --git a/src/features/anthropic-runtime-profile/core/domain/resolveAnthropicRuntimeProfile.ts b/src/features/anthropic-runtime-profile/core/domain/resolveAnthropicRuntimeProfile.ts index 67027607..35f466de 100644 --- a/src/features/anthropic-runtime-profile/core/domain/resolveAnthropicRuntimeProfile.ts +++ b/src/features/anthropic-runtime-profile/core/domain/resolveAnthropicRuntimeProfile.ts @@ -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' || diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 9533d6f5..52014274 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -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 = ({ '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 = ({ const openCodeProofState = openCodeRouteMetadata?.proofState ?? null; const modelButtonTitle = modelStatusMessage ?? (opt.value === '' ? defaultModelTooltip : undefined); + const showNewRibbon = isAnthropicOpus48NewBadgeVisible(effectiveProviderId, opt.value); return ( ); }; diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index 11af54a4..0b00dec5 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -47,12 +47,17 @@ const TEAM_PROVIDER_LABELS: Record = { }; 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 = { 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([ '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(); diff --git a/src/shared/utils/__tests__/contextMetrics.test.ts b/src/shared/utils/__tests__/contextMetrics.test.ts index 73405d9d..1ac9fb6f 100644 --- a/src/shared/utils/__tests__/contextMetrics.test.ts +++ b/src/shared/utils/__tests__/contextMetrics.test.ts @@ -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', diff --git a/src/shared/utils/contextMetrics.ts b/src/shared/utils/contextMetrics.ts index f37ff56f..2b676551 100644 --- a/src/shared/utils/contextMetrics.ts +++ b/src/shared/utils/contextMetrics.ts @@ -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') || diff --git a/test/renderer/components/team/TeamModelSelector.test.ts b/test/renderer/components/team/TeamModelSelector.test.ts index 9b216efa..9e7ad2cc 100644 --- a/test/renderer/components/team/TeamModelSelector.test.ts +++ b/test/renderer/components/team/TeamModelSelector.test.ts @@ -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'); }); diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index 2f4b206a..b1d12dc0 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -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 = { diff --git a/test/renderer/utils/teamModelAvailability.test.ts b/test/renderer/utils/teamModelAvailability.test.ts index 04316b4b..0be2d349 100644 --- a/test/renderer/utils/teamModelAvailability.test.ts +++ b/test/renderer/utils/teamModelAvailability.test.ts @@ -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(); }); diff --git a/test/renderer/utils/teamModelCatalog.test.ts b/test/renderer/utils/teamModelCatalog.test.ts index b2476477..e5350516 100644 --- a/test/renderer/utils/teamModelCatalog.test.ts +++ b/test/renderer/utils/teamModelCatalog.test.ts @@ -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); diff --git a/test/renderer/utils/teamModelRecommendations.test.ts b/test/renderer/utils/teamModelRecommendations.test.ts index 6bf1c676..8485eb85 100644 --- a/test/renderer/utils/teamModelRecommendations.test.ts +++ b/test/renderer/utils/teamModelRecommendations.test.ts @@ -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',