From 7c0e3db0b75ff3a719a9ef6c9a886d94b2347dc6 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 1 Jun 2026 23:42:22 +0300 Subject: [PATCH] feat(models): support Anthropic runtime catalog options --- src/renderer/utils/teamModelAvailability.ts | 147 ++++++++++++++++-- src/renderer/utils/teamModelCatalog.ts | 103 +++++++++++- .../utils/__tests__/contextMetrics.test.ts | 12 ++ src/shared/utils/contextMetrics.ts | 18 ++- .../utils/teamModelAvailability.test.ts | 85 ++++++++++ 5 files changed, 335 insertions(+), 30 deletions(-) diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index 64a32bfd..f34578ed 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -280,6 +280,16 @@ function hasAnthropicCompatibleRuntimeCatalog( ); } +function hasAnthropicFirstPartyRuntimeCatalog( + providerStatus?: TeamModelRuntimeProviderStatus | null +): boolean { + return ( + providerStatus?.modelCatalog?.providerId === 'anthropic' && + providerStatus.modelCatalog.source === 'anthropic-models-api' && + providerStatus.modelCatalog.status !== 'unavailable' + ); +} + export function isAnthropicCompatibleRuntime( providerStatus?: TeamModelRuntimeProviderStatus | null ): boolean { @@ -323,6 +333,76 @@ export function canUseCustomAnthropicCompatibleModel( return catalog.status !== 'ready' || !hasVisibleAnthropicCompatibleCatalogModels(providerStatus); } +function getVisibleRuntimeCatalogModels( + providerStatus?: TeamModelRuntimeProviderStatus | null +): string[] | null { + if (!providerStatus?.modelCatalog) { + return null; + } + + const models = providerStatus.modelCatalog.models + .filter((model) => !model.hidden) + .map((model) => model.launchModel.trim() || model.id.trim()) + .filter(Boolean); + return models.length > 0 ? models : null; +} + +function getAnthropicFirstPartyRuntimeModels( + providerStatus?: TeamModelRuntimeProviderStatus | null +): string[] | null { + if (!hasAnthropicFirstPartyRuntimeCatalog(providerStatus)) { + return null; + } + + return getVisibleRuntimeCatalogModels(providerStatus); +} + +function getAnthropicFirstPartySelectorModels( + providerStatus?: TeamModelRuntimeProviderStatus | null +): string[] | null { + const catalogModels = getAnthropicFirstPartyRuntimeModels(providerStatus); + if (!catalogModels) { + return null; + } + + const catalogModelSet = new Set(catalogModels); + const seenModels = new Set(); + const seenLabels = new Set(); + const merged: string[] = []; + + const appendModel = (model: string, skipDuplicateLabel: boolean): void => { + const trimmed = model.trim(); + if (!trimmed || seenModels.has(trimmed)) { + return; + } + + const label = + getRuntimeAwareProviderScopedTeamModelLabel('anthropic', trimmed, providerStatus) ?? trimmed; + const labelKey = label.trim().toLowerCase(); + if (skipDuplicateLabel && labelKey && seenLabels.has(labelKey)) { + return; + } + + seenModels.add(trimmed); + if (labelKey) { + seenLabels.add(labelKey); + } + merged.push(trimmed); + }; + + for (const option of getTeamProviderModelOptions('anthropic')) { + if (option.value && !catalogModelSet.has(option.value)) { + continue; + } + appendModel(option.value, false); + } + for (const model of catalogModels) { + appendModel(model, true); + } + + return merged; +} + function getAnthropicCatalogModel( model: string, providerStatus?: TeamModelRuntimeProviderStatus | null @@ -350,15 +430,7 @@ function getRuntimeCatalogModels( return null; } - if (!providerStatus?.modelCatalog) { - return null; - } - - const models = providerStatus.modelCatalog.models - .filter((model) => !model.hidden) - .map((model) => model.launchModel.trim() || model.id.trim()) - .filter(Boolean); - return models.length > 0 ? models : null; + return getVisibleRuntimeCatalogModels(providerStatus); } function getRuntimeCatalogModelOption( @@ -368,7 +440,7 @@ function getRuntimeCatalogModelOption( ): TeamRuntimeModelOption | null { const canUseCatalog = (providerId === 'codex' && providerStatus?.modelCatalog?.providerId === 'codex') || - (providerId === 'anthropic' && hasAnthropicCompatibleRuntimeCatalog(providerStatus)); + (providerId === 'anthropic' && hasAnthropicRuntimeCatalog(providerStatus)); if (!canUseCatalog || !providerStatus?.modelCatalog) { return null; } @@ -384,10 +456,11 @@ function getRuntimeCatalogModelOption( return { value: launchModel, label: + getRuntimeAwareProviderScopedTeamModelLabel(providerId, launchModel, providerStatus) ?? getProviderScopedTeamModelLabel(providerId, catalogModel.displayName) ?? catalogModel.displayName, badgeLabel: - catalogModel.badgeLabel ?? + getRuntimeAwareTeamModelBadgeLabel(providerId, launchModel, providerStatus) ?? (getTeamProviderModelOptions(providerId).some((option) => option.value === model) ? undefined : 'New'), @@ -505,7 +578,9 @@ function getRuntimeModelAvailability( return isSupportedAnthropicTeamModel(model) ? 'available' : null; } - return getAnthropicCatalogModel(model, providerStatus) ? 'available' : null; + return getAnthropicCatalogModel(model, providerStatus) || isSupportedAnthropicTeamModel(model) + ? 'available' + : null; } if (!providerStatus) { @@ -537,7 +612,8 @@ export function getTeamProviderModelVerificationCounts( if (providerId === 'anthropic') { const visibleAnthropicModels = isAnthropicCompatibleRuntime(providerStatus) ? getRuntimeSelectorModels(providerId, providerStatus) - : getFallbackTeamProviderModels(providerId); + : (getAnthropicFirstPartySelectorModels(providerStatus) ?? + getFallbackTeamProviderModels(providerId)); return { checkedCount: visibleAnthropicModels.length, totalCount: visibleAnthropicModels.length, @@ -565,8 +641,16 @@ export function getAvailableTeamProviderModels( ); } - return getFallbackTeamProviderModels(providerId).filter( - (model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available' + const visibleAnthropicModels = + getAnthropicFirstPartySelectorModels(providerStatus) ?? + getFallbackTeamProviderModels(providerId); + + return sortTeamProviderModels( + providerId, + visibleAnthropicModels.filter( + (model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available' + ), + providerStatus ); } @@ -607,6 +691,34 @@ export function getAvailableTeamProviderModelOptions( ]; } + const firstPartyModels = getAnthropicFirstPartySelectorModels(providerStatus); + if (firstPartyModels) { + const fallbackOptions = getFallbackTeamProviderModelOptions(providerId, providerStatus); + const fallbackOptionByValue = new Map( + fallbackOptions.map((option) => [option.value, option]) + ); + + return [ + fallbackOptionByValue.get('') ?? { value: '', label: 'Default', badgeLabel: 'Default' }, + ...firstPartyModels.map((model) => { + const catalogOption = getRuntimeCatalogModelOption(providerId, model, providerStatus); + const fallbackOption = fallbackOptionByValue.get(model); + const option = catalogOption ?? + fallbackOption ?? { + value: model, + label: getProviderScopedTeamModelLabel(providerId, model) ?? model, + badgeLabel: getRuntimeAwareTeamModelBadgeLabel(providerId, model, providerStatus), + }; + + return { + ...option, + availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus), + availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus), + }; + }), + ]; + } + return getFallbackTeamProviderModelOptions(providerId, providerStatus).map((option) => ({ ...option, availabilityStatus: @@ -691,7 +803,10 @@ export function isTeamModelAvailableForUi( ); } - if (!isSupportedAnthropicTeamModel(trimmed)) { + if ( + !isSupportedAnthropicTeamModel(trimmed) && + !getAnthropicCatalogModel(trimmed, providerStatus) + ) { return false; } diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index 0b00dec5..22d86baf 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -80,6 +80,8 @@ const ANTHROPIC_MODEL_ORDER = [ const TEAM_MODEL_LABEL_OVERRIDES: Record = { default: 'Default', ...ANTHROPIC_ALIAS_LABELS, + 'opus[1m]': 'Opus 4.8 (1M)', + 'sonnet[1m]': 'Sonnet 4.6 (1M)', 'claude-opus-4-8': 'Opus 4.8', 'claude-opus-4-8[1m]': 'Opus 4.8 (1M)', 'claude-opus-4-7': 'Opus 4.7', @@ -343,6 +345,81 @@ function getRuntimeCatalogModel( return getRuntimeCatalogModelIndex(providerStatus.modelCatalog).get(trimmed) ?? null; } +function getAnthropicAliasFamily(model: string | undefined): 'opus' | 'sonnet' | 'haiku' | null { + const baseModel = + model + ?.trim() + .toLowerCase() + .replace(/\[1m\]$/i, '') ?? ''; + if (baseModel === 'opus' || baseModel === 'sonnet' || baseModel === 'haiku') { + return baseModel; + } + return null; +} + +function readAnthropicDisplayVersion( + label: string | undefined, + family: 'opus' | 'sonnet' | 'haiku' +): { major: number; minor: number | null } | null { + const pattern = new RegExp(`\\b${family}\\s+(\\d+)(?:\\.(\\d+))?\\b`, 'i'); + const match = pattern.exec(label ?? ''); + if (!match) { + return null; + } + + const major = Number.parseInt(match[1], 10); + const minor = match[2] == null ? null : Number.parseInt(match[2], 10); + if (!Number.isFinite(major) || (minor !== null && !Number.isFinite(minor))) { + return null; + } + + return { major, minor }; +} + +function compareAnthropicDisplayVersions( + left: { major: number; minor: number | null }, + right: { major: number; minor: number | null } +): number { + if (left.major !== right.major) { + return left.major - right.major; + } + return (left.minor ?? 0) - (right.minor ?? 0); +} + +function getRuntimeSafeAnthropicAliasLabel(params: { + model: string | undefined; + runtimeLabel?: string | null; + fallbackLabel?: string; +}): string | null { + const family = getAnthropicAliasFamily(params.model); + if (!family) { + return null; + } + + const fallbackLabel = + params.fallbackLabel ?? getProviderScopedTeamModelLabel('anthropic', params.model); + if (!fallbackLabel) { + return null; + } + + const runtimeLabel = params.runtimeLabel?.trim(); + if (!runtimeLabel) { + return fallbackLabel; + } + + const runtimeVersion = readAnthropicDisplayVersion(runtimeLabel, family); + const fallbackVersion = readAnthropicDisplayVersion(fallbackLabel, family); + if ( + runtimeVersion && + fallbackVersion && + compareAnthropicDisplayVersions(runtimeVersion, fallbackVersion) >= 0 + ) { + return getProviderScopedTeamModelLabel('anthropic', runtimeLabel) ?? runtimeLabel; + } + + return fallbackLabel; +} + export function getTeamModelBadgeLabel( providerId: SupportedProviderId, model: string | undefined @@ -410,12 +487,16 @@ export function getRuntimeAwareProviderScopedTeamModelLabel( 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(); + const safeAnthropicAliasLabel = + providerId === 'anthropic' + ? getRuntimeSafeAnthropicAliasLabel({ model: trimmed, runtimeLabel }) + : null; + if (safeAnthropicAliasLabel) { + return safeAnthropicAliasLabel; + } + if (runtimeLabel) { return getProviderScopedTeamModelLabel(providerId, runtimeLabel) ?? runtimeLabel; } @@ -429,11 +510,19 @@ export function getRuntimeAwareTeamModelBadgeLabel( 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); + const safeAnthropicAliasLabel = + providerId === 'anthropic' + ? getRuntimeSafeAnthropicAliasLabel({ + model: trimmed, + runtimeLabel: runtimeModel?.badgeLabel?.trim() || runtimeModel?.displayName?.trim(), + fallbackLabel: getTeamModelBadgeLabel(providerId, trimmed), + }) + : null; + if (safeAnthropicAliasLabel) { + return safeAnthropicAliasLabel; } - 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 1ac9fb6f..f9aec7b4 100644 --- a/src/shared/utils/__tests__/contextMetrics.test.ts +++ b/src/shared/utils/__tests__/contextMetrics.test.ts @@ -133,6 +133,12 @@ describe('contextMetrics', () => { modelName: 'claude-sonnet-4-6', }) ).toBe(1_000_000); + expect( + inferContextWindowTokens({ + providerId: 'anthropic', + modelName: 'claude-opus-4-9', + }) + ).toBe(1_000_000); }); it('keeps older raw Anthropic models at 200K unless 1M is explicitly requested', () => { @@ -142,6 +148,12 @@ describe('contextMetrics', () => { modelName: 'claude-sonnet-4-5-20250929', }) ).toBe(200_000); + expect( + inferContextWindowTokens({ + providerId: 'anthropic', + modelName: 'claude-opus-4-20250514', + }) + ).toBe(200_000); expect( inferContextWindowTokens({ providerId: 'anthropic', diff --git a/src/shared/utils/contextMetrics.ts b/src/shared/utils/contextMetrics.ts index 2b676551..04c3bf6f 100644 --- a/src/shared/utils/contextMetrics.ts +++ b/src/shared/utils/contextMetrics.ts @@ -111,13 +111,17 @@ function isAnthropicNativeLongContextModel(modelName: string | undefined): boole return false; } - 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') || - normalized.startsWith('claude-mythos') - ); + if (normalized.startsWith('claude-mythos')) { + return true; + } + + const claude4MinorMatch = /^claude-(opus|sonnet)-4-(\d{1,2})(?:-|$)/.exec(normalized); + if (!claude4MinorMatch) { + return false; + } + + const minorVersion = Number.parseInt(claude4MinorMatch[2], 10); + return Number.isFinite(minorVersion) && minorVersion >= 6; } function hasOpenAiPromptDetails(usage: ContextUsageLike): boolean { diff --git a/test/renderer/utils/teamModelAvailability.test.ts b/test/renderer/utils/teamModelAvailability.test.ts index 0be2d349..63fe8f8f 100644 --- a/test/renderer/utils/teamModelAvailability.test.ts +++ b/test/renderer/utils/teamModelAvailability.test.ts @@ -644,6 +644,91 @@ describe('teamModelAvailability', () => { }); }); + it('merges first-party Anthropic catalog models with curated safety fallbacks', () => { + const providerStatus: TeamModelRuntimeProviderStatus = { + providerId: 'anthropic', + models: ['opus', 'claude-sonnet-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-06-20T00:00:00.000Z', + staleAt: '2026-06-20T00:10:00.000Z', + defaultModelId: 'opus', + defaultLaunchModel: 'opus', + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + models: [ + { + id: 'opus', + launchModel: 'opus', + displayName: 'Opus 4.9', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'max'], + defaultReasoningEffort: 'high', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'anthropic-models-api', + }, + { + id: 'claude-sonnet-4-7', + launchModel: 'claude-sonnet-4-7', + displayName: 'Sonnet 4.7', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'max'], + defaultReasoningEffort: 'high', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + }, + { + id: 'claude-sonnet-4-7[1m]', + launchModel: 'claude-sonnet-4-7[1m]', + displayName: 'Sonnet 4.7 (1M)', + hidden: true, + supportedReasoningEfforts: ['low', 'medium', 'high', 'max'], + defaultReasoningEffort: 'high', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + }, + }; + + const options = getAvailableTeamProviderModelOptions('anthropic', providerStatus); + const values = options.map((option) => option.value); + + expect(options.find((option) => option.value === 'opus')).toMatchObject({ + label: 'Opus 4.9', + badgeLabel: 'Opus 4.9', + }); + expect(values).toContain('claude-sonnet-4-7'); + expect(values).toContain('claude-opus-4-7'); + expect(values).not.toContain('claude-sonnet-4-7[1m]'); + expect(normalizeTeamModelForUi('anthropic', 'claude-sonnet-4-7', providerStatus)).toBe( + 'claude-sonnet-4-7' + ); + expect( + getTeamModelSelectionError('anthropic', 'claude-sonnet-4-7', providerStatus) + ).toBeNull(); + }); + 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]');