feat(models): support Anthropic runtime catalog options
This commit is contained in:
parent
3265920ec6
commit
7c0e3db0b7
5 changed files with 335 additions and 30 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]');
|
||||
|
|
|
|||
Loading…
Reference in a new issue