feat(models): support Anthropic runtime catalog options

This commit is contained in:
777genius 2026-06-01 23:42:22 +03:00
parent 3265920ec6
commit 7c0e3db0b7
5 changed files with 335 additions and 30 deletions

View file

@ -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<string>();
const seenLabels = new Set<string>();
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;
}

View file

@ -80,6 +80,8 @@ const ANTHROPIC_MODEL_ORDER = [
const TEAM_MODEL_LABEL_OVERRIDES: Record<string, string> = {
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();
}

View file

@ -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',

View file

@ -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 {

View file

@ -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]');