init claude-code
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import type { PermissionMode } from '../permissions/PermissionMode.js'
|
||||
import { capitalize } from '../stringUtils.js'
|
||||
import { MODEL_ALIASES, type ModelAlias } from './aliases.js'
|
||||
import { applyBedrockRegionPrefix, getBedrockRegionPrefix } from './bedrock.js'
|
||||
import {
|
||||
getCanonicalName,
|
||||
getRuntimeMainLoopModel,
|
||||
parseUserSpecifiedModel,
|
||||
} from './model.js'
|
||||
import { getAPIProvider } from './providers.js'
|
||||
|
||||
export const AGENT_MODEL_OPTIONS = [...MODEL_ALIASES, 'inherit'] as const
|
||||
export type AgentModelAlias = (typeof AGENT_MODEL_OPTIONS)[number]
|
||||
|
||||
export type AgentModelOption = {
|
||||
value: AgentModelAlias
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default subagent model. Returns 'inherit' so subagents inherit
|
||||
* the model from the parent thread.
|
||||
*/
|
||||
export function getDefaultSubagentModel(): string {
|
||||
return 'inherit'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective model string for an agent.
|
||||
*
|
||||
* For Bedrock, if the parent model uses a cross-region inference prefix (e.g., "eu.", "us."),
|
||||
* that prefix is inherited by subagents using alias models (e.g., "sonnet", "haiku", "opus").
|
||||
* This ensures subagents use the same region as the parent, which is necessary when
|
||||
* IAM permissions are scoped to specific cross-region inference profiles.
|
||||
*/
|
||||
export function getAgentModel(
|
||||
agentModel: string | undefined,
|
||||
parentModel: string,
|
||||
toolSpecifiedModel?: ModelAlias,
|
||||
permissionMode?: PermissionMode,
|
||||
): string {
|
||||
if (process.env.CLAUDE_CODE_SUBAGENT_MODEL) {
|
||||
return parseUserSpecifiedModel(process.env.CLAUDE_CODE_SUBAGENT_MODEL)
|
||||
}
|
||||
|
||||
// Extract Bedrock region prefix from parent model to inherit for subagents.
|
||||
// This ensures subagents use the same cross-region inference profile (e.g., "eu.", "us.")
|
||||
// as the parent, which is required when IAM permissions only allow specific regions.
|
||||
const parentRegionPrefix = getBedrockRegionPrefix(parentModel)
|
||||
|
||||
// Helper to apply parent region prefix for Bedrock models.
|
||||
// `originalSpec` is the raw model string before resolution (alias or full ID).
|
||||
// If the user explicitly specified a full model ID that already carries its own
|
||||
// region prefix (e.g., "eu.anthropic.…"), we preserve it instead of overwriting
|
||||
// with the parent's prefix. This prevents silent data-residency violations when
|
||||
// an agent config intentionally pins to a different region than the parent.
|
||||
const applyParentRegionPrefix = (
|
||||
resolvedModel: string,
|
||||
originalSpec: string,
|
||||
): string => {
|
||||
if (parentRegionPrefix && getAPIProvider() === 'bedrock') {
|
||||
if (getBedrockRegionPrefix(originalSpec)) return resolvedModel
|
||||
return applyBedrockRegionPrefix(resolvedModel, parentRegionPrefix)
|
||||
}
|
||||
return resolvedModel
|
||||
}
|
||||
|
||||
// Prioritize tool-specified model if provided
|
||||
if (toolSpecifiedModel) {
|
||||
if (aliasMatchesParentTier(toolSpecifiedModel, parentModel)) {
|
||||
return parentModel
|
||||
}
|
||||
const model = parseUserSpecifiedModel(toolSpecifiedModel)
|
||||
return applyParentRegionPrefix(model, toolSpecifiedModel)
|
||||
}
|
||||
|
||||
const agentModelWithExp = agentModel ?? getDefaultSubagentModel()
|
||||
|
||||
if (agentModelWithExp === 'inherit') {
|
||||
// Apply runtime model resolution for inherit to get the effective model
|
||||
// This ensures agents using 'inherit' get opusplan→Opus resolution in plan mode
|
||||
return getRuntimeMainLoopModel({
|
||||
permissionMode: permissionMode ?? 'default',
|
||||
mainLoopModel: parentModel,
|
||||
exceeds200kTokens: false,
|
||||
})
|
||||
}
|
||||
|
||||
if (aliasMatchesParentTier(agentModelWithExp, parentModel)) {
|
||||
return parentModel
|
||||
}
|
||||
const model = parseUserSpecifiedModel(agentModelWithExp)
|
||||
return applyParentRegionPrefix(model, agentModelWithExp)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a bare family alias (opus/sonnet/haiku) matches the parent model's
|
||||
* tier. When it does, the subagent inherits the parent's exact model string
|
||||
* instead of resolving the alias to a provider default.
|
||||
*
|
||||
* Prevents surprising downgrades: a Vertex user on Opus 4.6 (via /model) who
|
||||
* spawns a subagent with `model: opus` should get Opus 4.6, not whatever
|
||||
* getDefaultOpusModel() returns for 3P.
|
||||
* See https://github.com/anthropics/claude-code/issues/30815.
|
||||
*
|
||||
* Only bare family aliases match. `opus[1m]`, `best`, `opusplan` fall through
|
||||
* since they carry semantics beyond "same tier as parent".
|
||||
*/
|
||||
function aliasMatchesParentTier(alias: string, parentModel: string): boolean {
|
||||
const canonical = getCanonicalName(parentModel)
|
||||
switch (alias.toLowerCase()) {
|
||||
case 'opus':
|
||||
return canonical.includes('opus')
|
||||
case 'sonnet':
|
||||
return canonical.includes('sonnet')
|
||||
case 'haiku':
|
||||
return canonical.includes('haiku')
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getAgentModelDisplay(model: string | undefined): string {
|
||||
// When model is omitted, getDefaultSubagentModel() returns 'inherit' at runtime
|
||||
if (!model) return 'Inherit from parent (default)'
|
||||
if (model === 'inherit') return 'Inherit from parent'
|
||||
return capitalize(model)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available model options for agents
|
||||
*/
|
||||
export function getAgentModelOptions(): AgentModelOption[] {
|
||||
return [
|
||||
{
|
||||
value: 'sonnet',
|
||||
label: 'Sonnet',
|
||||
description: 'Balanced performance - best for most agents',
|
||||
},
|
||||
{
|
||||
value: 'opus',
|
||||
label: 'Opus',
|
||||
description: 'Most capable for complex reasoning tasks',
|
||||
},
|
||||
{
|
||||
value: 'haiku',
|
||||
label: 'Haiku',
|
||||
description: 'Fast and efficient for simple tasks',
|
||||
},
|
||||
{
|
||||
value: 'inherit',
|
||||
label: 'Inherit from parent',
|
||||
description: 'Use the same model as the main conversation',
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export const MODEL_ALIASES = [
|
||||
'sonnet',
|
||||
'opus',
|
||||
'haiku',
|
||||
'best',
|
||||
'sonnet[1m]',
|
||||
'opus[1m]',
|
||||
'opusplan',
|
||||
] as const
|
||||
export type ModelAlias = (typeof MODEL_ALIASES)[number]
|
||||
|
||||
export function isModelAlias(modelInput: string): modelInput is ModelAlias {
|
||||
return MODEL_ALIASES.includes(modelInput as ModelAlias)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare model family aliases that act as wildcards in the availableModels allowlist.
|
||||
* When "opus" is in the allowlist, ANY opus model is allowed (opus 4.5, 4.6, etc.).
|
||||
* When a specific model ID is in the allowlist, only that exact version is allowed.
|
||||
*/
|
||||
export const MODEL_FAMILY_ALIASES = ['sonnet', 'opus', 'haiku'] as const
|
||||
|
||||
export function isModelFamilyAlias(model: string): boolean {
|
||||
return (MODEL_FAMILY_ALIASES as readonly string[]).includes(model)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
||||
import type { EffortLevel } from '../effort.js'
|
||||
|
||||
export type AntModel = {
|
||||
alias: string
|
||||
model: string
|
||||
label: string
|
||||
description?: string
|
||||
defaultEffortValue?: number
|
||||
defaultEffortLevel?: EffortLevel
|
||||
contextWindow?: number
|
||||
defaultMaxTokens?: number
|
||||
upperMaxTokensLimit?: number
|
||||
/** Model defaults to adaptive thinking and rejects `thinking: { type: 'disabled' }`. */
|
||||
alwaysOnThinking?: boolean
|
||||
}
|
||||
|
||||
export type AntModelSwitchCalloutConfig = {
|
||||
modelAlias?: string
|
||||
description: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export type AntModelOverrideConfig = {
|
||||
defaultModel?: string
|
||||
defaultModelEffortLevel?: EffortLevel
|
||||
defaultSystemPromptSuffix?: string
|
||||
antModels?: AntModel[]
|
||||
switchCallout?: AntModelSwitchCalloutConfig
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Update tengu_ant_model_override with new ant-only models
|
||||
// @[MODEL LAUNCH]: Add the codename to scripts/excluded-strings.txt to prevent it from leaking to external builds.
|
||||
export function getAntModelOverrideConfig(): AntModelOverrideConfig | null {
|
||||
if (process.env.USER_TYPE !== 'ant') {
|
||||
return null
|
||||
}
|
||||
return getFeatureValue_CACHED_MAY_BE_STALE<AntModelOverrideConfig | null>(
|
||||
'tengu_ant_model_override',
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
export function getAntModels(): AntModel[] {
|
||||
if (process.env.USER_TYPE !== 'ant') {
|
||||
return []
|
||||
}
|
||||
return getAntModelOverrideConfig()?.antModels ?? []
|
||||
}
|
||||
|
||||
export function resolveAntModel(
|
||||
model: string | undefined,
|
||||
): AntModel | undefined {
|
||||
if (process.env.USER_TYPE !== 'ant') {
|
||||
return undefined
|
||||
}
|
||||
if (model === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const lower = model.toLowerCase()
|
||||
return getAntModels().find(
|
||||
m => m.alias === model || lower.includes(m.model.toLowerCase()),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { refreshAndGetAwsCredentials } from '../auth.js'
|
||||
import { getAWSRegion, isEnvTruthy } from '../envUtils.js'
|
||||
import { logError } from '../log.js'
|
||||
import { getAWSClientProxyConfig } from '../proxy.js'
|
||||
|
||||
export const getBedrockInferenceProfiles = memoize(async function (): Promise<
|
||||
string[]
|
||||
> {
|
||||
const [client, { ListInferenceProfilesCommand }] = await Promise.all([
|
||||
createBedrockClient(),
|
||||
import('@aws-sdk/client-bedrock'),
|
||||
])
|
||||
const allProfiles = []
|
||||
let nextToken: string | undefined
|
||||
|
||||
try {
|
||||
do {
|
||||
const command = new ListInferenceProfilesCommand({
|
||||
...(nextToken && { nextToken }),
|
||||
typeEquals: 'SYSTEM_DEFINED',
|
||||
})
|
||||
const response = await client.send(command)
|
||||
|
||||
if (response.inferenceProfileSummaries) {
|
||||
allProfiles.push(...response.inferenceProfileSummaries)
|
||||
}
|
||||
|
||||
nextToken = response.nextToken
|
||||
} while (nextToken)
|
||||
|
||||
// Filter for Anthropic models (SYSTEM_DEFINED filtering handled in query)
|
||||
return allProfiles
|
||||
.filter(profile => profile.inferenceProfileId?.includes('anthropic'))
|
||||
.map(profile => profile.inferenceProfileId)
|
||||
.filter(Boolean) as string[]
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
export function findFirstMatch(
|
||||
profiles: string[],
|
||||
substring: string,
|
||||
): string | null {
|
||||
return profiles.find(p => p.includes(substring)) ?? null
|
||||
}
|
||||
|
||||
async function createBedrockClient() {
|
||||
const { BedrockClient } = await import('@aws-sdk/client-bedrock')
|
||||
// Match the Anthropic Bedrock SDK's region behavior exactly:
|
||||
// - Reads AWS_REGION or AWS_DEFAULT_REGION env vars (not AWS config files)
|
||||
// - Falls back to 'us-east-1' if neither is set
|
||||
// This ensures we query profiles from the same region the client will use
|
||||
const region = getAWSRegion()
|
||||
|
||||
const skipAuth = isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)
|
||||
|
||||
const clientConfig: ConstructorParameters<typeof BedrockClient>[0] = {
|
||||
region,
|
||||
...(process.env.ANTHROPIC_BEDROCK_BASE_URL && {
|
||||
endpoint: process.env.ANTHROPIC_BEDROCK_BASE_URL,
|
||||
}),
|
||||
...(await getAWSClientProxyConfig()),
|
||||
...(skipAuth && {
|
||||
requestHandler: new (
|
||||
await import('@smithy/node-http-handler')
|
||||
).NodeHttpHandler(),
|
||||
httpAuthSchemes: [
|
||||
{
|
||||
schemeId: 'smithy.api#noAuth',
|
||||
identityProvider: () => async () => ({}),
|
||||
signer: new (await import('@smithy/core')).NoAuthSigner(),
|
||||
},
|
||||
],
|
||||
httpAuthSchemeProvider: () => [{ schemeId: 'smithy.api#noAuth' }],
|
||||
}),
|
||||
}
|
||||
|
||||
if (!skipAuth && !process.env.AWS_BEARER_TOKEN_BEDROCK) {
|
||||
// Only refresh credentials if not using API key authentication
|
||||
const cachedCredentials = await refreshAndGetAwsCredentials()
|
||||
if (cachedCredentials) {
|
||||
clientConfig.credentials = {
|
||||
accessKeyId: cachedCredentials.accessKeyId,
|
||||
secretAccessKey: cachedCredentials.secretAccessKey,
|
||||
sessionToken: cachedCredentials.sessionToken,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new BedrockClient(clientConfig)
|
||||
}
|
||||
|
||||
export async function createBedrockRuntimeClient() {
|
||||
const { BedrockRuntimeClient } = await import(
|
||||
'@aws-sdk/client-bedrock-runtime'
|
||||
)
|
||||
const region = getAWSRegion()
|
||||
const skipAuth = isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)
|
||||
|
||||
const clientConfig: ConstructorParameters<typeof BedrockRuntimeClient>[0] = {
|
||||
region,
|
||||
...(process.env.ANTHROPIC_BEDROCK_BASE_URL && {
|
||||
endpoint: process.env.ANTHROPIC_BEDROCK_BASE_URL,
|
||||
}),
|
||||
...(await getAWSClientProxyConfig()),
|
||||
...(skipAuth && {
|
||||
// BedrockRuntimeClient defaults to HTTP/2 without fallback
|
||||
// proxy servers may not support this, so we explicitly force HTTP/1.1
|
||||
requestHandler: new (
|
||||
await import('@smithy/node-http-handler')
|
||||
).NodeHttpHandler(),
|
||||
httpAuthSchemes: [
|
||||
{
|
||||
schemeId: 'smithy.api#noAuth',
|
||||
identityProvider: () => async () => ({}),
|
||||
signer: new (await import('@smithy/core')).NoAuthSigner(),
|
||||
},
|
||||
],
|
||||
httpAuthSchemeProvider: () => [{ schemeId: 'smithy.api#noAuth' }],
|
||||
}),
|
||||
}
|
||||
|
||||
if (!skipAuth && !process.env.AWS_BEARER_TOKEN_BEDROCK) {
|
||||
// Only refresh credentials if not using API key authentication
|
||||
const cachedCredentials = await refreshAndGetAwsCredentials()
|
||||
if (cachedCredentials) {
|
||||
clientConfig.credentials = {
|
||||
accessKeyId: cachedCredentials.accessKeyId,
|
||||
secretAccessKey: cachedCredentials.secretAccessKey,
|
||||
sessionToken: cachedCredentials.sessionToken,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new BedrockRuntimeClient(clientConfig)
|
||||
}
|
||||
|
||||
export const getInferenceProfileBackingModel = memoize(async function (
|
||||
profileId: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const [client, { GetInferenceProfileCommand }] = await Promise.all([
|
||||
createBedrockClient(),
|
||||
import('@aws-sdk/client-bedrock'),
|
||||
])
|
||||
const command = new GetInferenceProfileCommand({
|
||||
inferenceProfileIdentifier: profileId,
|
||||
})
|
||||
const response = await client.send(command)
|
||||
|
||||
if (!response.models || response.models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use the first model as the primary backing model for cost calculation
|
||||
// In practice, application inference profiles typically load balance between
|
||||
// similar models with the same cost structure
|
||||
const primaryModel = response.models[0]
|
||||
if (!primaryModel?.modelArn) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract model name from ARN
|
||||
// ARN format: arn:aws:bedrock:region:account:foundation-model/model-name
|
||||
const lastSlashIndex = primaryModel.modelArn.lastIndexOf('/')
|
||||
return lastSlashIndex >= 0
|
||||
? primaryModel.modelArn.substring(lastSlashIndex + 1)
|
||||
: primaryModel.modelArn
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if a model ID is a foundation model (e.g., "anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
*/
|
||||
export function isFoundationModel(modelId: string): boolean {
|
||||
return modelId.startsWith('anthropic.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-region inference profile prefixes for Bedrock.
|
||||
* These prefixes allow routing requests to models in specific regions.
|
||||
*/
|
||||
const BEDROCK_REGION_PREFIXES = ['us', 'eu', 'apac', 'global'] as const
|
||||
|
||||
/**
|
||||
* Extract the model/inference profile ID from a Bedrock ARN.
|
||||
* If the input is not an ARN, returns it unchanged.
|
||||
*
|
||||
* ARN format: arn:aws:bedrock:<region>:<account>:inference-profile/<profile-id>
|
||||
* Also handles: arn:aws:bedrock:<region>:<account>:application-inference-profile/<profile-id>
|
||||
* And foundation model ARNs: arn:aws:bedrock:<region>::foundation-model/<model-id>
|
||||
*/
|
||||
export function extractModelIdFromArn(modelId: string): string {
|
||||
if (!modelId.startsWith('arn:')) {
|
||||
return modelId
|
||||
}
|
||||
const lastSlashIndex = modelId.lastIndexOf('/')
|
||||
if (lastSlashIndex === -1) {
|
||||
return modelId
|
||||
}
|
||||
return modelId.substring(lastSlashIndex + 1)
|
||||
}
|
||||
|
||||
export type BedrockRegionPrefix = (typeof BEDROCK_REGION_PREFIXES)[number]
|
||||
|
||||
/**
|
||||
* Extract the region prefix from a Bedrock cross-region inference model ID.
|
||||
* Handles both plain model IDs and full ARN format.
|
||||
* For example:
|
||||
* - "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" → "eu"
|
||||
* - "us.anthropic.claude-3-7-sonnet-20250219-v1:0" → "us"
|
||||
* - "arn:aws:bedrock:ap-northeast-2:123:inference-profile/global.anthropic.claude-opus-4-6-v1" → "global"
|
||||
* - "anthropic.claude-3-5-sonnet-20241022-v2:0" → undefined (foundation model)
|
||||
* - "claude-sonnet-4-5-20250929" → undefined (first-party format)
|
||||
*/
|
||||
export function getBedrockRegionPrefix(
|
||||
modelId: string,
|
||||
): BedrockRegionPrefix | undefined {
|
||||
// Extract the inference profile ID from ARN format if present
|
||||
// ARN format: arn:aws:bedrock:<region>:<account>:inference-profile/<profile-id>
|
||||
const effectiveModelId = extractModelIdFromArn(modelId)
|
||||
|
||||
for (const prefix of BEDROCK_REGION_PREFIXES) {
|
||||
if (effectiveModelId.startsWith(`${prefix}.anthropic.`)) {
|
||||
return prefix
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a region prefix to a Bedrock model ID.
|
||||
* If the model already has a different region prefix, it will be replaced.
|
||||
* If the model is a foundation model (anthropic.*), the prefix will be added.
|
||||
* If the model is not a Bedrock model, it will be returned as-is.
|
||||
*
|
||||
* For example:
|
||||
* - applyBedrockRegionPrefix("us.anthropic.claude-sonnet-4-5-v1:0", "eu") → "eu.anthropic.claude-sonnet-4-5-v1:0"
|
||||
* - applyBedrockRegionPrefix("anthropic.claude-sonnet-4-5-v1:0", "eu") → "eu.anthropic.claude-sonnet-4-5-v1:0"
|
||||
* - applyBedrockRegionPrefix("claude-sonnet-4-5-20250929", "eu") → "claude-sonnet-4-5-20250929" (not a Bedrock model)
|
||||
*/
|
||||
export function applyBedrockRegionPrefix(
|
||||
modelId: string,
|
||||
prefix: BedrockRegionPrefix,
|
||||
): string {
|
||||
// Check if it already has a region prefix and replace it
|
||||
const existingPrefix = getBedrockRegionPrefix(modelId)
|
||||
if (existingPrefix) {
|
||||
return modelId.replace(`${existingPrefix}.`, `${prefix}.`)
|
||||
}
|
||||
|
||||
// Check if it's a foundation model (anthropic.*) and add the prefix
|
||||
if (isFoundationModel(modelId)) {
|
||||
return `${prefix}.${modelId}`
|
||||
}
|
||||
|
||||
// Not a Bedrock model format, return as-is
|
||||
return modelId
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { OverageDisabledReason } from 'src/services/claudeAiLimits.js'
|
||||
import { isClaudeAISubscriber } from '../auth.js'
|
||||
import { getGlobalConfig } from '../config.js'
|
||||
import { is1mContextDisabled } from '../context.js'
|
||||
|
||||
/**
|
||||
* Check if extra usage is enabled based on the cached disabled reason.
|
||||
* Extra usage is considered enabled if there's no disabled reason,
|
||||
* or if the disabled reason indicates it's provisioned but temporarily unavailable.
|
||||
*/
|
||||
function isExtraUsageEnabled(): boolean {
|
||||
const reason = getGlobalConfig().cachedExtraUsageDisabledReason
|
||||
// undefined = no cache yet, treat as not enabled (conservative)
|
||||
if (reason === undefined) {
|
||||
return false
|
||||
}
|
||||
// null = no disabled reason from API, extra usage is enabled
|
||||
if (reason === null) {
|
||||
return true
|
||||
}
|
||||
// Check which disabled reasons still mean "provisioned"
|
||||
switch (reason as OverageDisabledReason) {
|
||||
// Provisioned but credits depleted — still counts as enabled
|
||||
case 'out_of_credits':
|
||||
return true
|
||||
// Not provisioned or actively disabled
|
||||
case 'overage_not_provisioned':
|
||||
case 'org_level_disabled':
|
||||
case 'org_level_disabled_until':
|
||||
case 'seat_tier_level_disabled':
|
||||
case 'member_level_disabled':
|
||||
case 'seat_tier_zero_credit_limit':
|
||||
case 'group_zero_credit_limit':
|
||||
case 'member_zero_credit_limit':
|
||||
case 'org_service_level_disabled':
|
||||
case 'org_service_zero_credit_limit':
|
||||
case 'no_limits_configured':
|
||||
case 'unknown':
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Add check if the new model supports 1M context
|
||||
export function checkOpus1mAccess(): boolean {
|
||||
if (is1mContextDisabled()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isClaudeAISubscriber()) {
|
||||
// Subscribers have access if extra usage is enabled for their account
|
||||
return isExtraUsageEnabled()
|
||||
}
|
||||
|
||||
// Non-subscribers (API/PAYG) have access
|
||||
return true
|
||||
}
|
||||
|
||||
export function checkSonnet1mAccess(): boolean {
|
||||
if (is1mContextDisabled()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isClaudeAISubscriber()) {
|
||||
// Subscribers have access if extra usage is enabled for their account
|
||||
return isExtraUsageEnabled()
|
||||
}
|
||||
|
||||
// Non-subscribers (API/PAYG) have access
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { ModelName } from './model.js'
|
||||
import type { APIProvider } from './providers.js'
|
||||
|
||||
export type ModelConfig = Record<APIProvider, ModelName>
|
||||
|
||||
// @[MODEL LAUNCH]: Add a new CLAUDE_*_CONFIG constant here. Double check the correct model strings
|
||||
// here since the pattern may change.
|
||||
|
||||
export const CLAUDE_3_7_SONNET_CONFIG = {
|
||||
firstParty: 'claude-3-7-sonnet-20250219',
|
||||
bedrock: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0',
|
||||
vertex: 'claude-3-7-sonnet@20250219',
|
||||
foundry: 'claude-3-7-sonnet',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_3_5_V2_SONNET_CONFIG = {
|
||||
firstParty: 'claude-3-5-sonnet-20241022',
|
||||
bedrock: 'anthropic.claude-3-5-sonnet-20241022-v2:0',
|
||||
vertex: 'claude-3-5-sonnet-v2@20241022',
|
||||
foundry: 'claude-3-5-sonnet',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_3_5_HAIKU_CONFIG = {
|
||||
firstParty: 'claude-3-5-haiku-20241022',
|
||||
bedrock: 'us.anthropic.claude-3-5-haiku-20241022-v1:0',
|
||||
vertex: 'claude-3-5-haiku@20241022',
|
||||
foundry: 'claude-3-5-haiku',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_HAIKU_4_5_CONFIG = {
|
||||
firstParty: 'claude-haiku-4-5-20251001',
|
||||
bedrock: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||
vertex: 'claude-haiku-4-5@20251001',
|
||||
foundry: 'claude-haiku-4-5',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_SONNET_4_CONFIG = {
|
||||
firstParty: 'claude-sonnet-4-20250514',
|
||||
bedrock: 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||
vertex: 'claude-sonnet-4@20250514',
|
||||
foundry: 'claude-sonnet-4',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_SONNET_4_5_CONFIG = {
|
||||
firstParty: 'claude-sonnet-4-5-20250929',
|
||||
bedrock: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||
vertex: 'claude-sonnet-4-5@20250929',
|
||||
foundry: 'claude-sonnet-4-5',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_OPUS_4_CONFIG = {
|
||||
firstParty: 'claude-opus-4-20250514',
|
||||
bedrock: 'us.anthropic.claude-opus-4-20250514-v1:0',
|
||||
vertex: 'claude-opus-4@20250514',
|
||||
foundry: 'claude-opus-4',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_OPUS_4_1_CONFIG = {
|
||||
firstParty: 'claude-opus-4-1-20250805',
|
||||
bedrock: 'us.anthropic.claude-opus-4-1-20250805-v1:0',
|
||||
vertex: 'claude-opus-4-1@20250805',
|
||||
foundry: 'claude-opus-4-1',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_OPUS_4_5_CONFIG = {
|
||||
firstParty: 'claude-opus-4-5-20251101',
|
||||
bedrock: 'us.anthropic.claude-opus-4-5-20251101-v1:0',
|
||||
vertex: 'claude-opus-4-5@20251101',
|
||||
foundry: 'claude-opus-4-5',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_OPUS_4_6_CONFIG = {
|
||||
firstParty: 'claude-opus-4-6',
|
||||
bedrock: 'us.anthropic.claude-opus-4-6-v1',
|
||||
vertex: 'claude-opus-4-6',
|
||||
foundry: 'claude-opus-4-6',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_SONNET_4_6_CONFIG = {
|
||||
firstParty: 'claude-sonnet-4-6',
|
||||
bedrock: 'us.anthropic.claude-sonnet-4-6',
|
||||
vertex: 'claude-sonnet-4-6',
|
||||
foundry: 'claude-sonnet-4-6',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
// @[MODEL LAUNCH]: Register the new config here.
|
||||
export const ALL_MODEL_CONFIGS = {
|
||||
haiku35: CLAUDE_3_5_HAIKU_CONFIG,
|
||||
haiku45: CLAUDE_HAIKU_4_5_CONFIG,
|
||||
sonnet35: CLAUDE_3_5_V2_SONNET_CONFIG,
|
||||
sonnet37: CLAUDE_3_7_SONNET_CONFIG,
|
||||
sonnet40: CLAUDE_SONNET_4_CONFIG,
|
||||
sonnet45: CLAUDE_SONNET_4_5_CONFIG,
|
||||
sonnet46: CLAUDE_SONNET_4_6_CONFIG,
|
||||
opus40: CLAUDE_OPUS_4_CONFIG,
|
||||
opus41: CLAUDE_OPUS_4_1_CONFIG,
|
||||
opus45: CLAUDE_OPUS_4_5_CONFIG,
|
||||
opus46: CLAUDE_OPUS_4_6_CONFIG,
|
||||
} as const satisfies Record<string, ModelConfig>
|
||||
|
||||
export type ModelKey = keyof typeof ALL_MODEL_CONFIGS
|
||||
|
||||
/** Union of all canonical first-party model IDs, e.g. 'claude-opus-4-6' | 'claude-sonnet-4-5-20250929' | … */
|
||||
export type CanonicalModelId =
|
||||
(typeof ALL_MODEL_CONFIGS)[ModelKey]['firstParty']
|
||||
|
||||
/** Runtime list of canonical model IDs — used by comprehensiveness tests. */
|
||||
export const CANONICAL_MODEL_IDS = Object.values(ALL_MODEL_CONFIGS).map(
|
||||
c => c.firstParty,
|
||||
) as [CanonicalModelId, ...CanonicalModelId[]]
|
||||
|
||||
/** Map canonical ID → internal short key. Used to apply settings-based modelOverrides. */
|
||||
export const CANONICAL_ID_TO_KEY: Record<CanonicalModelId, ModelKey> =
|
||||
Object.fromEntries(
|
||||
(Object.entries(ALL_MODEL_CONFIGS) as [ModelKey, ModelConfig][]).map(
|
||||
([key, cfg]) => [cfg.firstParty, key],
|
||||
),
|
||||
) as Record<CanonicalModelId, ModelKey>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { checkOpus1mAccess, checkSonnet1mAccess } from './check1mAccess.js'
|
||||
import { getUserSpecifiedModelSetting } from './model.js'
|
||||
|
||||
// @[MODEL LAUNCH]: Add a branch for the new model if it supports a 1M context upgrade path.
|
||||
/**
|
||||
* Get available model upgrade for more context
|
||||
* Returns null if no upgrade available or user already has max context
|
||||
*/
|
||||
function getAvailableUpgrade(): {
|
||||
alias: string
|
||||
name: string
|
||||
multiplier: number
|
||||
} | null {
|
||||
const currentModelSetting = getUserSpecifiedModelSetting()
|
||||
if (currentModelSetting === 'opus' && checkOpus1mAccess()) {
|
||||
return {
|
||||
alias: 'opus[1m]',
|
||||
name: 'Opus 1M',
|
||||
multiplier: 5,
|
||||
}
|
||||
} else if (currentModelSetting === 'sonnet' && checkSonnet1mAccess()) {
|
||||
return {
|
||||
alias: 'sonnet[1m]',
|
||||
name: 'Sonnet 1M',
|
||||
multiplier: 5,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upgrade message for different contexts
|
||||
*/
|
||||
export function getUpgradeMessage(context: 'warning' | 'tip'): string | null {
|
||||
const upgrade = getAvailableUpgrade()
|
||||
if (!upgrade) return null
|
||||
|
||||
switch (context) {
|
||||
case 'warning':
|
||||
return `/model ${upgrade.alias}`
|
||||
case 'tip':
|
||||
return `Tip: You have access to ${upgrade.name} with ${upgrade.multiplier}x more context`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Model deprecation utilities
|
||||
*
|
||||
* Contains information about deprecated models and their retirement dates.
|
||||
*/
|
||||
|
||||
import { type APIProvider, getAPIProvider } from './providers.js'
|
||||
|
||||
type DeprecatedModelInfo = {
|
||||
isDeprecated: true
|
||||
modelName: string
|
||||
retirementDate: string
|
||||
}
|
||||
|
||||
type NotDeprecatedInfo = {
|
||||
isDeprecated: false
|
||||
}
|
||||
|
||||
type DeprecationInfo = DeprecatedModelInfo | NotDeprecatedInfo
|
||||
|
||||
type DeprecationEntry = {
|
||||
/** Human-readable model name */
|
||||
modelName: string
|
||||
/** Retirement dates by provider (null = not deprecated for that provider) */
|
||||
retirementDates: Record<APIProvider, string | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecated models and their retirement dates by provider.
|
||||
* Keys are substrings to match in model IDs (case-insensitive).
|
||||
* To add a new deprecated model, add an entry to this object.
|
||||
*/
|
||||
const DEPRECATED_MODELS: Record<string, DeprecationEntry> = {
|
||||
'claude-3-opus': {
|
||||
modelName: 'Claude 3 Opus',
|
||||
retirementDates: {
|
||||
firstParty: 'January 5, 2026',
|
||||
bedrock: 'January 15, 2026',
|
||||
vertex: 'January 5, 2026',
|
||||
foundry: 'January 5, 2026',
|
||||
},
|
||||
},
|
||||
'claude-3-7-sonnet': {
|
||||
modelName: 'Claude 3.7 Sonnet',
|
||||
retirementDates: {
|
||||
firstParty: 'February 19, 2026',
|
||||
bedrock: 'April 28, 2026',
|
||||
vertex: 'May 11, 2026',
|
||||
foundry: 'February 19, 2026',
|
||||
},
|
||||
},
|
||||
'claude-3-5-haiku': {
|
||||
modelName: 'Claude 3.5 Haiku',
|
||||
retirementDates: {
|
||||
firstParty: 'February 19, 2026',
|
||||
bedrock: null,
|
||||
vertex: null,
|
||||
foundry: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is deprecated and get its deprecation info
|
||||
*/
|
||||
function getDeprecatedModelInfo(modelId: string): DeprecationInfo {
|
||||
const lowercaseModelId = modelId.toLowerCase()
|
||||
const provider = getAPIProvider()
|
||||
|
||||
for (const [key, value] of Object.entries(DEPRECATED_MODELS)) {
|
||||
const retirementDate = value.retirementDates[provider]
|
||||
if (!lowercaseModelId.includes(key) || !retirementDate) {
|
||||
continue
|
||||
}
|
||||
return {
|
||||
isDeprecated: true,
|
||||
modelName: value.modelName,
|
||||
retirementDate,
|
||||
}
|
||||
}
|
||||
|
||||
return { isDeprecated: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a deprecation warning message for a model, or null if not deprecated
|
||||
*/
|
||||
export function getModelDeprecationWarning(
|
||||
modelId: string | null,
|
||||
): string | null {
|
||||
if (!modelId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const info = getDeprecatedModelInfo(modelId)
|
||||
if (!info.isDeprecated) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `⚠ ${info.modelName} will be retired on ${info.retirementDate}. Consider switching to a newer model.`
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
/**
|
||||
* Ensure that any model codenames introduced here are also added to
|
||||
* scripts/excluded-strings.txt to avoid leaking them. Wrap any codename string
|
||||
* literals with process.env.USER_TYPE === 'ant' for Bun to remove the codenames
|
||||
* during dead code elimination
|
||||
*/
|
||||
import { getMainLoopModelOverride } from '../../bootstrap/state.js'
|
||||
import {
|
||||
getSubscriptionType,
|
||||
isClaudeAISubscriber,
|
||||
isMaxSubscriber,
|
||||
isProSubscriber,
|
||||
isTeamPremiumSubscriber,
|
||||
} from '../auth.js'
|
||||
import {
|
||||
has1mContext,
|
||||
is1mContextDisabled,
|
||||
modelSupports1M,
|
||||
} from '../context.js'
|
||||
import { isEnvTruthy } from '../envUtils.js'
|
||||
import { getModelStrings, resolveOverriddenModel } from './modelStrings.js'
|
||||
import { formatModelPricing, getOpus46CostTier } from '../modelCost.js'
|
||||
import { getSettings_DEPRECATED } from '../settings/settings.js'
|
||||
import type { PermissionMode } from '../permissions/PermissionMode.js'
|
||||
import { getAPIProvider } from './providers.js'
|
||||
import { LIGHTNING_BOLT } from '../../constants/figures.js'
|
||||
import { isModelAllowed } from './modelAllowlist.js'
|
||||
import { type ModelAlias, isModelAlias } from './aliases.js'
|
||||
import { capitalize } from '../stringUtils.js'
|
||||
|
||||
export type ModelShortName = string
|
||||
export type ModelName = string
|
||||
export type ModelSetting = ModelName | ModelAlias | null
|
||||
|
||||
export function getSmallFastModel(): ModelName {
|
||||
return process.env.ANTHROPIC_SMALL_FAST_MODEL || getDefaultHaikuModel()
|
||||
}
|
||||
|
||||
export function isNonCustomOpusModel(model: ModelName): boolean {
|
||||
return (
|
||||
model === getModelStrings().opus40 ||
|
||||
model === getModelStrings().opus41 ||
|
||||
model === getModelStrings().opus45 ||
|
||||
model === getModelStrings().opus46
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the model from /model (including via /config), the --model flag, environment variable,
|
||||
* or the saved settings. The returned value can be a model alias if that's what the user specified.
|
||||
* Undefined if the user didn't configure anything, in which case we fall back to
|
||||
* the default (null).
|
||||
*
|
||||
* Priority order within this function:
|
||||
* 1. Model override during session (from /model command) - highest priority
|
||||
* 2. Model override at startup (from --model flag)
|
||||
* 3. ANTHROPIC_MODEL environment variable
|
||||
* 4. Settings (from user's saved settings)
|
||||
*/
|
||||
export function getUserSpecifiedModelSetting(): ModelSetting | undefined {
|
||||
let specifiedModel: ModelSetting | undefined
|
||||
|
||||
const modelOverride = getMainLoopModelOverride()
|
||||
if (modelOverride !== undefined) {
|
||||
specifiedModel = modelOverride
|
||||
} else {
|
||||
const settings = getSettings_DEPRECATED() || {}
|
||||
specifiedModel = process.env.ANTHROPIC_MODEL || settings.model || undefined
|
||||
}
|
||||
|
||||
// Ignore the user-specified model if it's not in the availableModels allowlist.
|
||||
if (specifiedModel && !isModelAllowed(specifiedModel)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return specifiedModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the main loop model to use for the current session.
|
||||
*
|
||||
* Model Selection Priority Order:
|
||||
* 1. Model override during session (from /model command) - highest priority
|
||||
* 2. Model override at startup (from --model flag)
|
||||
* 3. ANTHROPIC_MODEL environment variable
|
||||
* 4. Settings (from user's saved settings)
|
||||
* 5. Built-in default
|
||||
*
|
||||
* @returns The resolved model name to use
|
||||
*/
|
||||
export function getMainLoopModel(): ModelName {
|
||||
const model = getUserSpecifiedModelSetting()
|
||||
if (model !== undefined && model !== null) {
|
||||
return parseUserSpecifiedModel(model)
|
||||
}
|
||||
return getDefaultMainLoopModel()
|
||||
}
|
||||
|
||||
export function getBestModel(): ModelName {
|
||||
return getDefaultOpusModel()
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Update the default Opus model (3P providers may lag so keep defaults unchanged).
|
||||
export function getDefaultOpusModel(): ModelName {
|
||||
if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) {
|
||||
return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
}
|
||||
// 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch
|
||||
// even when values match, since 3P availability lags firstParty and
|
||||
// these will diverge again at the next model launch.
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
return getModelStrings().opus46
|
||||
}
|
||||
return getModelStrings().opus46
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Update the default Sonnet model (3P providers may lag so keep defaults unchanged).
|
||||
export function getDefaultSonnetModel(): ModelName {
|
||||
if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) {
|
||||
return process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
}
|
||||
// Default to Sonnet 4.5 for 3P since they may not have 4.6 yet
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
return getModelStrings().sonnet45
|
||||
}
|
||||
return getModelStrings().sonnet46
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Update the default Haiku model (3P providers may lag so keep defaults unchanged).
|
||||
export function getDefaultHaikuModel(): ModelName {
|
||||
if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) {
|
||||
return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
}
|
||||
|
||||
// Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex)
|
||||
return getModelStrings().haiku45
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model to use for runtime, depending on the runtime context.
|
||||
* @param params Subset of the runtime context to determine the model to use.
|
||||
* @returns The model to use
|
||||
*/
|
||||
export function getRuntimeMainLoopModel(params: {
|
||||
permissionMode: PermissionMode
|
||||
mainLoopModel: string
|
||||
exceeds200kTokens?: boolean
|
||||
}): ModelName {
|
||||
const { permissionMode, mainLoopModel, exceeds200kTokens = false } = params
|
||||
|
||||
// opusplan uses Opus in plan mode without [1m] suffix.
|
||||
if (
|
||||
getUserSpecifiedModelSetting() === 'opusplan' &&
|
||||
permissionMode === 'plan' &&
|
||||
!exceeds200kTokens
|
||||
) {
|
||||
return getDefaultOpusModel()
|
||||
}
|
||||
|
||||
// sonnetplan by default
|
||||
if (getUserSpecifiedModelSetting() === 'haiku' && permissionMode === 'plan') {
|
||||
return getDefaultSonnetModel()
|
||||
}
|
||||
|
||||
return mainLoopModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default main loop model setting.
|
||||
*
|
||||
* This handles the built-in default:
|
||||
* - Opus for Max and Team Premium users
|
||||
* - Sonnet 4.6 for all other users (including Team Standard, Pro, Enterprise)
|
||||
*
|
||||
* @returns The default model setting to use
|
||||
*/
|
||||
export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias {
|
||||
// Ants default to defaultModel from flag config, or Opus 1M if not configured
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
return (
|
||||
getAntModelOverrideConfig()?.defaultModel ??
|
||||
getDefaultOpusModel() + '[1m]'
|
||||
)
|
||||
}
|
||||
|
||||
// Max users get Opus as default
|
||||
if (isMaxSubscriber()) {
|
||||
return getDefaultOpusModel() + (isOpus1mMergeEnabled() ? '[1m]' : '')
|
||||
}
|
||||
|
||||
// Team Premium gets Opus (same as Max)
|
||||
if (isTeamPremiumSubscriber()) {
|
||||
return getDefaultOpusModel() + (isOpus1mMergeEnabled() ? '[1m]' : '')
|
||||
}
|
||||
|
||||
// PAYG (1P and 3P), Enterprise, Team Standard, and Pro get Sonnet as default
|
||||
// Note that PAYG (3P) may default to an older Sonnet model
|
||||
return getDefaultSonnetModel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous operation to get the default main loop model to use
|
||||
* (bypassing any user-specified values).
|
||||
*/
|
||||
export function getDefaultMainLoopModel(): ModelName {
|
||||
return parseUserSpecifiedModel(getDefaultMainLoopModelSetting())
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Add a canonical name mapping for the new model below.
|
||||
/**
|
||||
* Pure string-match that strips date/provider suffixes from a first-party model
|
||||
* name. Input must already be a 1P-format ID (e.g. 'claude-3-7-sonnet-20250219',
|
||||
* 'us.anthropic.claude-opus-4-6-v1:0'). Does not touch settings, so safe at
|
||||
* module top-level (see MODEL_COSTS in modelCost.ts).
|
||||
*/
|
||||
export function firstPartyNameToCanonical(name: ModelName): ModelShortName {
|
||||
name = name.toLowerCase()
|
||||
// Special cases for Claude 4+ models to differentiate versions
|
||||
// Order matters: check more specific versions first (4-5 before 4)
|
||||
if (name.includes('claude-opus-4-6')) {
|
||||
return 'claude-opus-4-6'
|
||||
}
|
||||
if (name.includes('claude-opus-4-5')) {
|
||||
return 'claude-opus-4-5'
|
||||
}
|
||||
if (name.includes('claude-opus-4-1')) {
|
||||
return 'claude-opus-4-1'
|
||||
}
|
||||
if (name.includes('claude-opus-4')) {
|
||||
return 'claude-opus-4'
|
||||
}
|
||||
if (name.includes('claude-sonnet-4-6')) {
|
||||
return 'claude-sonnet-4-6'
|
||||
}
|
||||
if (name.includes('claude-sonnet-4-5')) {
|
||||
return 'claude-sonnet-4-5'
|
||||
}
|
||||
if (name.includes('claude-sonnet-4')) {
|
||||
return 'claude-sonnet-4'
|
||||
}
|
||||
if (name.includes('claude-haiku-4-5')) {
|
||||
return 'claude-haiku-4-5'
|
||||
}
|
||||
// Claude 3.x models use a different naming scheme (claude-3-{family})
|
||||
if (name.includes('claude-3-7-sonnet')) {
|
||||
return 'claude-3-7-sonnet'
|
||||
}
|
||||
if (name.includes('claude-3-5-sonnet')) {
|
||||
return 'claude-3-5-sonnet'
|
||||
}
|
||||
if (name.includes('claude-3-5-haiku')) {
|
||||
return 'claude-3-5-haiku'
|
||||
}
|
||||
if (name.includes('claude-3-opus')) {
|
||||
return 'claude-3-opus'
|
||||
}
|
||||
if (name.includes('claude-3-sonnet')) {
|
||||
return 'claude-3-sonnet'
|
||||
}
|
||||
if (name.includes('claude-3-haiku')) {
|
||||
return 'claude-3-haiku'
|
||||
}
|
||||
const match = name.match(/(claude-(\d+-\d+-)?\w+)/)
|
||||
if (match && match[1]) {
|
||||
return match[1]
|
||||
}
|
||||
// Fall back to the original name if no pattern matches
|
||||
return name
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a full model string to a shorter canonical version that's unified across 1P and 3P providers.
|
||||
* For example, 'claude-3-5-haiku-20241022' and 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
|
||||
* would both be mapped to 'claude-3-5-haiku'.
|
||||
* @param fullModelName The full model name (e.g., 'claude-3-5-haiku-20241022')
|
||||
* @returns The short name (e.g., 'claude-3-5-haiku') if found, or the original name if no mapping exists
|
||||
*/
|
||||
export function getCanonicalName(fullModelName: ModelName): ModelShortName {
|
||||
// Resolve overridden model IDs (e.g. Bedrock ARNs) back to canonical names.
|
||||
// resolved is always a 1P-format ID, so firstPartyNameToCanonical can handle it.
|
||||
return firstPartyNameToCanonical(resolveOverriddenModel(fullModelName))
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Update the default model description strings shown to users.
|
||||
export function getClaudeAiUserDefaultModelDescription(
|
||||
fastMode = false,
|
||||
): string {
|
||||
if (isMaxSubscriber() || isTeamPremiumSubscriber()) {
|
||||
if (isOpus1mMergeEnabled()) {
|
||||
return `Opus 4.6 with 1M context · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`
|
||||
}
|
||||
return `Opus 4.6 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`
|
||||
}
|
||||
return 'Sonnet 4.6 · Best for everyday tasks'
|
||||
}
|
||||
|
||||
export function renderDefaultModelSetting(
|
||||
setting: ModelName | ModelAlias,
|
||||
): string {
|
||||
if (setting === 'opusplan') {
|
||||
return 'Opus 4.6 in plan mode, else Sonnet 4.6'
|
||||
}
|
||||
return renderModelName(parseUserSpecifiedModel(setting))
|
||||
}
|
||||
|
||||
export function getOpus46PricingSuffix(fastMode: boolean): string {
|
||||
if (getAPIProvider() !== 'firstParty') return ''
|
||||
const pricing = formatModelPricing(getOpus46CostTier(fastMode))
|
||||
const fastModeIndicator = fastMode ? ` (${LIGHTNING_BOLT})` : ''
|
||||
return ` ·${fastModeIndicator} ${pricing}`
|
||||
}
|
||||
|
||||
export function isOpus1mMergeEnabled(): boolean {
|
||||
if (
|
||||
is1mContextDisabled() ||
|
||||
isProSubscriber() ||
|
||||
getAPIProvider() !== 'firstParty'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Fail closed when a subscriber's subscription type is unknown. The VS Code
|
||||
// config-loading subprocess can have OAuth tokens with valid scopes but no
|
||||
// subscriptionType field (stale or partial refresh). Without this guard,
|
||||
// isProSubscriber() returns false for such users and the merge leaks
|
||||
// opus[1m] into the model dropdown — the API then rejects it with a
|
||||
// misleading "rate limit reached" error.
|
||||
if (isClaudeAISubscriber() && getSubscriptionType() === null) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function renderModelSetting(setting: ModelName | ModelAlias): string {
|
||||
if (setting === 'opusplan') {
|
||||
return 'Opus Plan'
|
||||
}
|
||||
if (isModelAlias(setting)) {
|
||||
return capitalize(setting)
|
||||
}
|
||||
return renderModelName(setting)
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Add display name cases for the new model (base + [1m] variant if applicable).
|
||||
/**
|
||||
* Returns a human-readable display name for known public models, or null
|
||||
* if the model is not recognized as a public model.
|
||||
*/
|
||||
export function getPublicModelDisplayName(model: ModelName): string | null {
|
||||
switch (model) {
|
||||
case getModelStrings().opus46:
|
||||
return 'Opus 4.6'
|
||||
case getModelStrings().opus46 + '[1m]':
|
||||
return 'Opus 4.6 (1M context)'
|
||||
case getModelStrings().opus45:
|
||||
return 'Opus 4.5'
|
||||
case getModelStrings().opus41:
|
||||
return 'Opus 4.1'
|
||||
case getModelStrings().opus40:
|
||||
return 'Opus 4'
|
||||
case getModelStrings().sonnet46 + '[1m]':
|
||||
return 'Sonnet 4.6 (1M context)'
|
||||
case getModelStrings().sonnet46:
|
||||
return 'Sonnet 4.6'
|
||||
case getModelStrings().sonnet45 + '[1m]':
|
||||
return 'Sonnet 4.5 (1M context)'
|
||||
case getModelStrings().sonnet45:
|
||||
return 'Sonnet 4.5'
|
||||
case getModelStrings().sonnet40:
|
||||
return 'Sonnet 4'
|
||||
case getModelStrings().sonnet40 + '[1m]':
|
||||
return 'Sonnet 4 (1M context)'
|
||||
case getModelStrings().sonnet37:
|
||||
return 'Sonnet 3.7'
|
||||
case getModelStrings().sonnet35:
|
||||
return 'Sonnet 3.5'
|
||||
case getModelStrings().haiku45:
|
||||
return 'Haiku 4.5'
|
||||
case getModelStrings().haiku35:
|
||||
return 'Haiku 3.5'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function maskModelCodename(baseName: string): string {
|
||||
// Mask only the first dash-separated segment (the codename), preserve the rest
|
||||
// e.g. capybara-v2-fast → cap*****-v2-fast
|
||||
const [codename = '', ...rest] = baseName.split('-')
|
||||
const masked =
|
||||
codename.slice(0, 3) + '*'.repeat(Math.max(0, codename.length - 3))
|
||||
return [masked, ...rest].join('-')
|
||||
}
|
||||
|
||||
export function renderModelName(model: ModelName): string {
|
||||
const publicName = getPublicModelDisplayName(model)
|
||||
if (publicName) {
|
||||
return publicName
|
||||
}
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
const resolved = parseUserSpecifiedModel(model)
|
||||
const antModel = resolveAntModel(model)
|
||||
if (antModel) {
|
||||
const baseName = antModel.model.replace(/\[1m\]$/i, '')
|
||||
const masked = maskModelCodename(baseName)
|
||||
const suffix = has1mContext(resolved) ? '[1m]' : ''
|
||||
return masked + suffix
|
||||
}
|
||||
if (resolved !== model) {
|
||||
return `${model} (${resolved})`
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a safe author name for public display (e.g., in git commit trailers).
|
||||
* Returns "Claude {ModelName}" for publicly known models, or "Claude ({model})"
|
||||
* for unknown/internal models so the exact model name is preserved.
|
||||
*
|
||||
* @param model The full model name
|
||||
* @returns "Claude {ModelName}" for public models, or "Claude ({model})" for non-public models
|
||||
*/
|
||||
export function getPublicModelName(model: ModelName): string {
|
||||
const publicName = getPublicModelDisplayName(model)
|
||||
if (publicName) {
|
||||
return `Claude ${publicName}`
|
||||
}
|
||||
return `Claude (${model})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a full model name for use in this session, possibly after resolving
|
||||
* a model alias.
|
||||
*
|
||||
* This function intentionally does not support version numbers to align with
|
||||
* the model switcher.
|
||||
*
|
||||
* Supports [1m] suffix on any model alias (e.g., haiku[1m], sonnet[1m]) to enable
|
||||
* 1M context window without requiring each variant to be in MODEL_ALIASES.
|
||||
*
|
||||
* @param modelInput The model alias or name provided by the user.
|
||||
*/
|
||||
export function parseUserSpecifiedModel(
|
||||
modelInput: ModelName | ModelAlias,
|
||||
): ModelName {
|
||||
const modelInputTrimmed = modelInput.trim()
|
||||
const normalizedModel = modelInputTrimmed.toLowerCase()
|
||||
|
||||
const has1mTag = has1mContext(normalizedModel)
|
||||
const modelString = has1mTag
|
||||
? normalizedModel.replace(/\[1m]$/i, '').trim()
|
||||
: normalizedModel
|
||||
|
||||
if (isModelAlias(modelString)) {
|
||||
switch (modelString) {
|
||||
case 'opusplan':
|
||||
return getDefaultSonnetModel() + (has1mTag ? '[1m]' : '') // Sonnet is default, Opus in plan mode
|
||||
case 'sonnet':
|
||||
return getDefaultSonnetModel() + (has1mTag ? '[1m]' : '')
|
||||
case 'haiku':
|
||||
return getDefaultHaikuModel() + (has1mTag ? '[1m]' : '')
|
||||
case 'opus':
|
||||
return getDefaultOpusModel() + (has1mTag ? '[1m]' : '')
|
||||
case 'best':
|
||||
return getBestModel()
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Opus 4/4.1 are no longer available on the first-party API (same as
|
||||
// Claude.ai) — silently remap to the current Opus default. The 'opus'
|
||||
// alias already resolves to 4.6, so the only users on these explicit
|
||||
// strings pinned them in settings/env/--model/SDK before 4.5 launched.
|
||||
// 3P providers may not yet have 4.6 capacity, so pass through unchanged.
|
||||
if (
|
||||
getAPIProvider() === 'firstParty' &&
|
||||
isLegacyOpusFirstParty(modelString) &&
|
||||
isLegacyModelRemapEnabled()
|
||||
) {
|
||||
return getDefaultOpusModel() + (has1mTag ? '[1m]' : '')
|
||||
}
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
const has1mAntTag = has1mContext(normalizedModel)
|
||||
const baseAntModel = normalizedModel.replace(/\[1m]$/i, '').trim()
|
||||
|
||||
const antModel = resolveAntModel(baseAntModel)
|
||||
if (antModel) {
|
||||
const suffix = has1mAntTag ? '[1m]' : ''
|
||||
return antModel.model + suffix
|
||||
}
|
||||
|
||||
// Fall through to the alias string if we cannot load the config. The API calls
|
||||
// will fail with this string, but we should hear about it through feedback and
|
||||
// can tell the user to restart/wait for flag cache refresh to get the latest values.
|
||||
}
|
||||
|
||||
// Preserve original case for custom model names (e.g., Azure Foundry deployment IDs)
|
||||
// Only strip [1m] suffix if present, maintaining case of the base model
|
||||
if (has1mTag) {
|
||||
return modelInputTrimmed.replace(/\[1m\]$/i, '').trim() + '[1m]'
|
||||
}
|
||||
return modelInputTrimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a skill's `model:` frontmatter against the current model, carrying
|
||||
* the `[1m]` suffix over when the target family supports it.
|
||||
*
|
||||
* A skill author writing `model: opus` means "use opus-class reasoning" — not
|
||||
* "downgrade to 200K". If the user is on opus[1m] at 230K tokens and invokes a
|
||||
* skill with `model: opus`, passing the bare alias through drops the effective
|
||||
* context window from 1M to 200K, which trips autocompact at 23% apparent usage
|
||||
* and surfaces "Context limit reached" even though nothing overflowed.
|
||||
*
|
||||
* We only carry [1m] when the target actually supports it (sonnet/opus). A skill
|
||||
* with `model: haiku` on a 1M session still downgrades — haiku has no 1M variant,
|
||||
* so the autocompact that follows is correct. Skills that already specify [1m]
|
||||
* are left untouched.
|
||||
*/
|
||||
export function resolveSkillModelOverride(
|
||||
skillModel: string,
|
||||
currentModel: string,
|
||||
): string {
|
||||
if (has1mContext(skillModel) || !has1mContext(currentModel)) {
|
||||
return skillModel
|
||||
}
|
||||
// modelSupports1M matches on canonical IDs ('claude-opus-4-6', 'claude-sonnet-4');
|
||||
// a bare 'opus' alias falls through getCanonicalName unmatched. Resolve first.
|
||||
if (modelSupports1M(parseUserSpecifiedModel(skillModel))) {
|
||||
return skillModel + '[1m]'
|
||||
}
|
||||
return skillModel
|
||||
}
|
||||
|
||||
const LEGACY_OPUS_FIRSTPARTY = [
|
||||
'claude-opus-4-20250514',
|
||||
'claude-opus-4-1-20250805',
|
||||
'claude-opus-4-0',
|
||||
'claude-opus-4-1',
|
||||
]
|
||||
|
||||
function isLegacyOpusFirstParty(model: string): boolean {
|
||||
return LEGACY_OPUS_FIRSTPARTY.includes(model)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-out for the legacy Opus 4.0/4.1 → current Opus remap.
|
||||
*/
|
||||
export function isLegacyModelRemapEnabled(): boolean {
|
||||
return !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP)
|
||||
}
|
||||
|
||||
export function modelDisplayString(model: ModelSetting): string {
|
||||
if (model === null) {
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
return `Default for Ants (${renderDefaultModelSetting(getDefaultMainLoopModelSetting())})`
|
||||
} else if (isClaudeAISubscriber()) {
|
||||
return `Default (${getClaudeAiUserDefaultModelDescription()})`
|
||||
}
|
||||
return `Default (${getDefaultMainLoopModel()})`
|
||||
}
|
||||
const resolvedModel = parseUserSpecifiedModel(model)
|
||||
return model === resolvedModel ? resolvedModel : `${model} (${resolvedModel})`
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Add a marketing name mapping for the new model below.
|
||||
export function getMarketingNameForModel(modelId: string): string | undefined {
|
||||
if (getAPIProvider() === 'foundry') {
|
||||
// deployment ID is user-defined in Foundry, so it may have no relation to the actual model
|
||||
return undefined
|
||||
}
|
||||
|
||||
const has1m = modelId.toLowerCase().includes('[1m]')
|
||||
const canonical = getCanonicalName(modelId)
|
||||
|
||||
if (canonical.includes('claude-opus-4-6')) {
|
||||
return has1m ? 'Opus 4.6 (with 1M context)' : 'Opus 4.6'
|
||||
}
|
||||
if (canonical.includes('claude-opus-4-5')) {
|
||||
return 'Opus 4.5'
|
||||
}
|
||||
if (canonical.includes('claude-opus-4-1')) {
|
||||
return 'Opus 4.1'
|
||||
}
|
||||
if (canonical.includes('claude-opus-4')) {
|
||||
return 'Opus 4'
|
||||
}
|
||||
if (canonical.includes('claude-sonnet-4-6')) {
|
||||
return has1m ? 'Sonnet 4.6 (with 1M context)' : 'Sonnet 4.6'
|
||||
}
|
||||
if (canonical.includes('claude-sonnet-4-5')) {
|
||||
return has1m ? 'Sonnet 4.5 (with 1M context)' : 'Sonnet 4.5'
|
||||
}
|
||||
if (canonical.includes('claude-sonnet-4')) {
|
||||
return has1m ? 'Sonnet 4 (with 1M context)' : 'Sonnet 4'
|
||||
}
|
||||
if (canonical.includes('claude-3-7-sonnet')) {
|
||||
return 'Claude 3.7 Sonnet'
|
||||
}
|
||||
if (canonical.includes('claude-3-5-sonnet')) {
|
||||
return 'Claude 3.5 Sonnet'
|
||||
}
|
||||
if (canonical.includes('claude-haiku-4-5')) {
|
||||
return 'Haiku 4.5'
|
||||
}
|
||||
if (canonical.includes('claude-3-5-haiku')) {
|
||||
return 'Claude 3.5 Haiku'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function normalizeModelStringForAPI(model: string): string {
|
||||
return model.replace(/\[(1|2)m\]/gi, '')
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { getSettings_DEPRECATED } from '../settings/settings.js'
|
||||
import { isModelAlias, isModelFamilyAlias } from './aliases.js'
|
||||
import { parseUserSpecifiedModel } from './model.js'
|
||||
import { resolveOverriddenModel } from './modelStrings.js'
|
||||
|
||||
/**
|
||||
* Check if a model belongs to a given family by checking if its name
|
||||
* (or resolved name) contains the family identifier.
|
||||
*/
|
||||
function modelBelongsToFamily(model: string, family: string): boolean {
|
||||
if (model.includes(family)) {
|
||||
return true
|
||||
}
|
||||
// Resolve aliases like "best" → "claude-opus-4-6" to check family membership
|
||||
if (isModelAlias(model)) {
|
||||
const resolved = parseUserSpecifiedModel(model).toLowerCase()
|
||||
return resolved.includes(family)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model name starts with a prefix at a segment boundary.
|
||||
* The prefix must match up to the end of the name or a "-" separator.
|
||||
* e.g. "claude-opus-4-5" matches "claude-opus-4-5-20251101" but not "claude-opus-4-50".
|
||||
*/
|
||||
function prefixMatchesModel(modelName: string, prefix: string): boolean {
|
||||
if (!modelName.startsWith(prefix)) {
|
||||
return false
|
||||
}
|
||||
return modelName.length === prefix.length || modelName[prefix.length] === '-'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model matches a version-prefix entry in the allowlist.
|
||||
* Supports shorthand like "opus-4-5" (mapped to "claude-opus-4-5") and
|
||||
* full prefixes like "claude-opus-4-5". Resolves input aliases before matching.
|
||||
*/
|
||||
function modelMatchesVersionPrefix(model: string, entry: string): boolean {
|
||||
// Resolve the input model to a full name if it's an alias
|
||||
const resolvedModel = isModelAlias(model)
|
||||
? parseUserSpecifiedModel(model).toLowerCase()
|
||||
: model
|
||||
|
||||
// Try the entry as-is (e.g. "claude-opus-4-5")
|
||||
if (prefixMatchesModel(resolvedModel, entry)) {
|
||||
return true
|
||||
}
|
||||
// Try with "claude-" prefix (e.g. "opus-4-5" → "claude-opus-4-5")
|
||||
if (
|
||||
!entry.startsWith('claude-') &&
|
||||
prefixMatchesModel(resolvedModel, `claude-${entry}`)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a family alias is narrowed by more specific entries in the allowlist.
|
||||
* When the allowlist contains both "opus" and "opus-4-5", the specific entry
|
||||
* takes precedence — "opus" alone would be a wildcard, but "opus-4-5" narrows
|
||||
* it to only that version.
|
||||
*/
|
||||
function familyHasSpecificEntries(
|
||||
family: string,
|
||||
allowlist: string[],
|
||||
): boolean {
|
||||
for (const entry of allowlist) {
|
||||
if (isModelFamilyAlias(entry)) {
|
||||
continue
|
||||
}
|
||||
// Check if entry is a version-qualified variant of this family
|
||||
// e.g., "opus-4-5" or "claude-opus-4-5-20251101" for the "opus" family
|
||||
// Must match at a segment boundary (followed by '-' or end) to avoid
|
||||
// false positives like "opusplan" matching "opus"
|
||||
const idx = entry.indexOf(family)
|
||||
if (idx === -1) {
|
||||
continue
|
||||
}
|
||||
const afterFamily = idx + family.length
|
||||
if (afterFamily === entry.length || entry[afterFamily] === '-') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is allowed by the availableModels allowlist in settings.
|
||||
* If availableModels is not set, all models are allowed.
|
||||
*
|
||||
* Matching tiers:
|
||||
* 1. Family aliases ("opus", "sonnet", "haiku") — wildcard for the entire family,
|
||||
* UNLESS more specific entries for that family also exist (e.g., "opus-4-5").
|
||||
* In that case, the family wildcard is ignored and only the specific entries apply.
|
||||
* 2. Version prefixes ("opus-4-5", "claude-opus-4-5") — any build of that version
|
||||
* 3. Full model IDs ("claude-opus-4-5-20251101") — exact match only
|
||||
*/
|
||||
export function isModelAllowed(model: string): boolean {
|
||||
const settings = getSettings_DEPRECATED() || {}
|
||||
const { availableModels } = settings
|
||||
if (!availableModels) {
|
||||
return true // No restrictions
|
||||
}
|
||||
if (availableModels.length === 0) {
|
||||
return false // Empty allowlist blocks all user-specified models
|
||||
}
|
||||
|
||||
const resolvedModel = resolveOverriddenModel(model)
|
||||
const normalizedModel = resolvedModel.trim().toLowerCase()
|
||||
const normalizedAllowlist = availableModels.map(m => m.trim().toLowerCase())
|
||||
|
||||
// Direct match (alias-to-alias or full-name-to-full-name)
|
||||
// Skip family aliases that have been narrowed by specific entries —
|
||||
// e.g., "opus" in ["opus", "opus-4-5"] should NOT directly match,
|
||||
// because the admin intends to restrict to opus 4.5 only.
|
||||
if (normalizedAllowlist.includes(normalizedModel)) {
|
||||
if (
|
||||
!isModelFamilyAlias(normalizedModel) ||
|
||||
!familyHasSpecificEntries(normalizedModel, normalizedAllowlist)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Family-level aliases in the allowlist match any model in that family,
|
||||
// but only if no more specific entries exist for that family.
|
||||
// e.g., ["opus"] allows all opus, but ["opus", "opus-4-5"] only allows opus 4.5.
|
||||
for (const entry of normalizedAllowlist) {
|
||||
if (
|
||||
isModelFamilyAlias(entry) &&
|
||||
!familyHasSpecificEntries(entry, normalizedAllowlist) &&
|
||||
modelBelongsToFamily(normalizedModel, entry)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// For non-family entries, do bidirectional alias resolution
|
||||
// If model is an alias, resolve it and check if the resolved name is in the list
|
||||
if (isModelAlias(normalizedModel)) {
|
||||
const resolved = parseUserSpecifiedModel(normalizedModel).toLowerCase()
|
||||
if (normalizedAllowlist.includes(resolved)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// If any non-family alias in the allowlist resolves to the input model
|
||||
for (const entry of normalizedAllowlist) {
|
||||
if (!isModelFamilyAlias(entry) && isModelAlias(entry)) {
|
||||
const resolved = parseUserSpecifiedModel(entry).toLowerCase()
|
||||
if (resolved === normalizedModel) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Version-prefix matching: "opus-4-5" or "claude-opus-4-5" matches
|
||||
// "claude-opus-4-5-20251101" at a segment boundary
|
||||
for (const entry of normalizedAllowlist) {
|
||||
if (!isModelFamilyAlias(entry) && !isModelAlias(entry)) {
|
||||
if (modelMatchesVersionPrefix(normalizedModel, entry)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import isEqual from 'lodash-es/isEqual.js'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { join } from 'path'
|
||||
import { z } from 'zod/v4'
|
||||
import { OAUTH_BETA_HEADER } from '../../constants/oauth.js'
|
||||
import { getAnthropicClient } from '../../services/api/client.js'
|
||||
import { isClaudeAISubscriber } from '../auth.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { getClaudeConfigHomeDir } from '../envUtils.js'
|
||||
import { safeParseJSON } from '../json.js'
|
||||
import { lazySchema } from '../lazySchema.js'
|
||||
import { isEssentialTrafficOnly } from '../privacyLevel.js'
|
||||
import { jsonStringify } from '../slowOperations.js'
|
||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './providers.js'
|
||||
|
||||
// .strip() — don't persist internal-only fields (mycro_deployments etc.) to disk
|
||||
const ModelCapabilitySchema = lazySchema(() =>
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
max_input_tokens: z.number().optional(),
|
||||
max_tokens: z.number().optional(),
|
||||
})
|
||||
.strip(),
|
||||
)
|
||||
|
||||
const CacheFileSchema = lazySchema(() =>
|
||||
z.object({
|
||||
models: z.array(ModelCapabilitySchema()),
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
)
|
||||
|
||||
export type ModelCapability = z.infer<ReturnType<typeof ModelCapabilitySchema>>
|
||||
|
||||
function getCacheDir(): string {
|
||||
return join(getClaudeConfigHomeDir(), 'cache')
|
||||
}
|
||||
|
||||
function getCachePath(): string {
|
||||
return join(getCacheDir(), 'model-capabilities.json')
|
||||
}
|
||||
|
||||
function isModelCapabilitiesEligible(): boolean {
|
||||
if (process.env.USER_TYPE !== 'ant') return false
|
||||
if (getAPIProvider() !== 'firstParty') return false
|
||||
if (!isFirstPartyAnthropicBaseUrl()) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Longest-id-first so substring match prefers most specific; secondary key for stable isEqual
|
||||
function sortForMatching(models: ModelCapability[]): ModelCapability[] {
|
||||
return [...models].sort(
|
||||
(a, b) => b.id.length - a.id.length || a.id.localeCompare(b.id),
|
||||
)
|
||||
}
|
||||
|
||||
// Keyed on cache path so tests that set CLAUDE_CONFIG_DIR get a fresh read
|
||||
const loadCache = memoize(
|
||||
(path: string): ModelCapability[] | null => {
|
||||
try {
|
||||
// eslint-disable-next-line custom-rules/no-sync-fs -- memoized; called from sync getContextWindowForModel
|
||||
const raw = readFileSync(path, 'utf-8')
|
||||
const parsed = CacheFileSchema().safeParse(safeParseJSON(raw, false))
|
||||
return parsed.success ? parsed.data.models : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
path => path,
|
||||
)
|
||||
|
||||
export function getModelCapability(model: string): ModelCapability | undefined {
|
||||
if (!isModelCapabilitiesEligible()) return undefined
|
||||
const cached = loadCache(getCachePath())
|
||||
if (!cached || cached.length === 0) return undefined
|
||||
const m = model.toLowerCase()
|
||||
const exact = cached.find(c => c.id.toLowerCase() === m)
|
||||
if (exact) return exact
|
||||
return cached.find(c => m.includes(c.id.toLowerCase()))
|
||||
}
|
||||
|
||||
export async function refreshModelCapabilities(): Promise<void> {
|
||||
if (!isModelCapabilitiesEligible()) return
|
||||
if (isEssentialTrafficOnly()) return
|
||||
|
||||
try {
|
||||
const anthropic = await getAnthropicClient({ maxRetries: 1 })
|
||||
const betas = isClaudeAISubscriber() ? [OAUTH_BETA_HEADER] : undefined
|
||||
const parsed: ModelCapability[] = []
|
||||
for await (const entry of anthropic.models.list({ betas })) {
|
||||
const result = ModelCapabilitySchema().safeParse(entry)
|
||||
if (result.success) parsed.push(result.data)
|
||||
}
|
||||
if (parsed.length === 0) return
|
||||
|
||||
const path = getCachePath()
|
||||
const models = sortForMatching(parsed)
|
||||
if (isEqual(loadCache(path), models)) {
|
||||
logForDebugging('[modelCapabilities] cache unchanged, skipping write')
|
||||
return
|
||||
}
|
||||
|
||||
await mkdir(getCacheDir(), { recursive: true })
|
||||
await writeFile(path, jsonStringify({ models, timestamp: Date.now() }), {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
})
|
||||
loadCache.cache.delete(path)
|
||||
logForDebugging(`[modelCapabilities] cached ${models.length} models`)
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`[modelCapabilities] fetch failed: ${error instanceof Error ? error.message : 'unknown'}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
import { getInitialMainLoopModel } from '../../bootstrap/state.js'
|
||||
import {
|
||||
isClaudeAISubscriber,
|
||||
isMaxSubscriber,
|
||||
isTeamPremiumSubscriber,
|
||||
} from '../auth.js'
|
||||
import { getModelStrings } from './modelStrings.js'
|
||||
import {
|
||||
COST_TIER_3_15,
|
||||
COST_HAIKU_35,
|
||||
COST_HAIKU_45,
|
||||
formatModelPricing,
|
||||
} from '../modelCost.js'
|
||||
import { getSettings_DEPRECATED } from '../settings/settings.js'
|
||||
import { checkOpus1mAccess, checkSonnet1mAccess } from './check1mAccess.js'
|
||||
import { getAPIProvider } from './providers.js'
|
||||
import { isModelAllowed } from './modelAllowlist.js'
|
||||
import {
|
||||
getCanonicalName,
|
||||
getClaudeAiUserDefaultModelDescription,
|
||||
getDefaultSonnetModel,
|
||||
getDefaultOpusModel,
|
||||
getDefaultHaikuModel,
|
||||
getDefaultMainLoopModelSetting,
|
||||
getMarketingNameForModel,
|
||||
getUserSpecifiedModelSetting,
|
||||
isOpus1mMergeEnabled,
|
||||
getOpus46PricingSuffix,
|
||||
renderDefaultModelSetting,
|
||||
type ModelSetting,
|
||||
} from './model.js'
|
||||
import { has1mContext } from '../context.js'
|
||||
import { getGlobalConfig } from '../config.js'
|
||||
|
||||
// @[MODEL LAUNCH]: Update all the available and default model option strings below.
|
||||
|
||||
export type ModelOption = {
|
||||
value: ModelSetting
|
||||
label: string
|
||||
description: string
|
||||
descriptionForModel?: string
|
||||
}
|
||||
|
||||
export function getDefaultOptionForUser(fastMode = false): ModelOption {
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
const currentModel = renderDefaultModelSetting(
|
||||
getDefaultMainLoopModelSetting(),
|
||||
)
|
||||
return {
|
||||
value: null,
|
||||
label: 'Default (recommended)',
|
||||
description: `Use the default model for Ants (currently ${currentModel})`,
|
||||
descriptionForModel: `Default model (currently ${currentModel})`,
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribers
|
||||
if (isClaudeAISubscriber()) {
|
||||
return {
|
||||
value: null,
|
||||
label: 'Default (recommended)',
|
||||
description: getClaudeAiUserDefaultModelDescription(fastMode),
|
||||
}
|
||||
}
|
||||
|
||||
// PAYG
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
return {
|
||||
value: null,
|
||||
label: 'Default (recommended)',
|
||||
description: `Use the default model (currently ${renderDefaultModelSetting(getDefaultMainLoopModelSetting())})${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`,
|
||||
}
|
||||
}
|
||||
|
||||
function getCustomSonnetOption(): ModelOption | undefined {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
const customSonnetModel = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
// When a 3P user has a custom sonnet model string, show it directly
|
||||
if (is3P && customSonnetModel) {
|
||||
const is1m = has1mContext(customSonnetModel)
|
||||
return {
|
||||
value: 'sonnet',
|
||||
label:
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME ?? customSonnetModel,
|
||||
description:
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION ??
|
||||
`Custom Sonnet model${is1m ? ' (1M context)' : ''}`,
|
||||
descriptionForModel: `${process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION ?? `Custom Sonnet model${is1m ? ' with 1M context' : ''}`} (${customSonnetModel})`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Update or add model option functions (getSonnetXXOption, getOpusXXOption, etc.)
|
||||
// with the new model's label and description. These appear in the /model picker.
|
||||
function getSonnet46Option(): ModelOption {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
return {
|
||||
value: is3P ? getModelStrings().sonnet46 : 'sonnet',
|
||||
label: 'Sonnet',
|
||||
description: `Sonnet 4.6 · Best for everyday tasks${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`,
|
||||
descriptionForModel:
|
||||
'Sonnet 4.6 - best for everyday tasks. Generally recommended for most coding tasks',
|
||||
}
|
||||
}
|
||||
|
||||
function getCustomOpusOption(): ModelOption | undefined {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
const customOpusModel = process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
// When a 3P user has a custom opus model string, show it directly
|
||||
if (is3P && customOpusModel) {
|
||||
const is1m = has1mContext(customOpusModel)
|
||||
return {
|
||||
value: 'opus',
|
||||
label: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME ?? customOpusModel,
|
||||
description:
|
||||
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION ??
|
||||
`Custom Opus model${is1m ? ' (1M context)' : ''}`,
|
||||
descriptionForModel: `${process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION ?? `Custom Opus model${is1m ? ' with 1M context' : ''}`} (${customOpusModel})`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getOpus41Option(): ModelOption {
|
||||
return {
|
||||
value: 'opus',
|
||||
label: 'Opus 4.1',
|
||||
description: `Opus 4.1 · Legacy`,
|
||||
descriptionForModel: 'Opus 4.1 - legacy version',
|
||||
}
|
||||
}
|
||||
|
||||
function getOpus46Option(fastMode = false): ModelOption {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
return {
|
||||
value: is3P ? getModelStrings().opus46 : 'opus',
|
||||
label: 'Opus',
|
||||
description: `Opus 4.6 · Most capable for complex work${getOpus46PricingSuffix(fastMode)}`,
|
||||
descriptionForModel: 'Opus 4.6 - most capable for complex work',
|
||||
}
|
||||
}
|
||||
|
||||
export function getSonnet46_1MOption(): ModelOption {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
return {
|
||||
value: is3P ? getModelStrings().sonnet46 + '[1m]' : 'sonnet[1m]',
|
||||
label: 'Sonnet (1M context)',
|
||||
description: `Sonnet 4.6 for long sessions${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`,
|
||||
descriptionForModel:
|
||||
'Sonnet 4.6 with 1M context window - for long sessions with large codebases',
|
||||
}
|
||||
}
|
||||
|
||||
export function getOpus46_1MOption(fastMode = false): ModelOption {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
return {
|
||||
value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]',
|
||||
label: 'Opus (1M context)',
|
||||
description: `Opus 4.6 for long sessions${getOpus46PricingSuffix(fastMode)}`,
|
||||
descriptionForModel:
|
||||
'Opus 4.6 with 1M context window - for long sessions with large codebases',
|
||||
}
|
||||
}
|
||||
|
||||
function getCustomHaikuOption(): ModelOption | undefined {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
const customHaikuModel = process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
// When a 3P user has a custom haiku model string, show it directly
|
||||
if (is3P && customHaikuModel) {
|
||||
return {
|
||||
value: 'haiku',
|
||||
label: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME ?? customHaikuModel,
|
||||
description:
|
||||
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION ??
|
||||
'Custom Haiku model',
|
||||
descriptionForModel: `${process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION ?? 'Custom Haiku model'} (${customHaikuModel})`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getHaiku45Option(): ModelOption {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
return {
|
||||
value: 'haiku',
|
||||
label: 'Haiku',
|
||||
description: `Haiku 4.5 · Fastest for quick answers${is3P ? '' : ` · ${formatModelPricing(COST_HAIKU_45)}`}`,
|
||||
descriptionForModel:
|
||||
'Haiku 4.5 - fastest for quick answers. Lower cost but less capable than Sonnet 4.6.',
|
||||
}
|
||||
}
|
||||
|
||||
function getHaiku35Option(): ModelOption {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
return {
|
||||
value: 'haiku',
|
||||
label: 'Haiku',
|
||||
description: `Haiku 3.5 for simple tasks${is3P ? '' : ` · ${formatModelPricing(COST_HAIKU_35)}`}`,
|
||||
descriptionForModel:
|
||||
'Haiku 3.5 - faster and lower cost, but less capable than Sonnet. Use for simple tasks.',
|
||||
}
|
||||
}
|
||||
|
||||
function getHaikuOption(): ModelOption {
|
||||
// Return correct Haiku option based on provider
|
||||
const haikuModel = getDefaultHaikuModel()
|
||||
return haikuModel === getModelStrings().haiku45
|
||||
? getHaiku45Option()
|
||||
: getHaiku35Option()
|
||||
}
|
||||
|
||||
function getMaxOpusOption(fastMode = false): ModelOption {
|
||||
return {
|
||||
value: 'opus',
|
||||
label: 'Opus',
|
||||
description: `Opus 4.6 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function getMaxSonnet46_1MOption(): ModelOption {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
const billingInfo = isClaudeAISubscriber() ? ' · Billed as extra usage' : ''
|
||||
return {
|
||||
value: 'sonnet[1m]',
|
||||
label: 'Sonnet (1M context)',
|
||||
description: `Sonnet 4.6 with 1M context${billingInfo}${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function getMaxOpus46_1MOption(fastMode = false): ModelOption {
|
||||
const billingInfo = isClaudeAISubscriber() ? ' · Billed as extra usage' : ''
|
||||
return {
|
||||
value: 'opus[1m]',
|
||||
label: 'Opus (1M context)',
|
||||
description: `Opus 4.6 with 1M context${billingInfo}${getOpus46PricingSuffix(fastMode)}`,
|
||||
}
|
||||
}
|
||||
|
||||
function getMergedOpus1MOption(fastMode = false): ModelOption {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
return {
|
||||
value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]',
|
||||
label: 'Opus (1M context)',
|
||||
description: `Opus 4.6 with 1M context · Most capable for complex work${!is3P && fastMode ? getOpus46PricingSuffix(fastMode) : ''}`,
|
||||
descriptionForModel:
|
||||
'Opus 4.6 with 1M context - most capable for complex work',
|
||||
}
|
||||
}
|
||||
|
||||
const MaxSonnet46Option: ModelOption = {
|
||||
value: 'sonnet',
|
||||
label: 'Sonnet',
|
||||
description: 'Sonnet 4.6 · Best for everyday tasks',
|
||||
}
|
||||
|
||||
const MaxHaiku45Option: ModelOption = {
|
||||
value: 'haiku',
|
||||
label: 'Haiku',
|
||||
description: 'Haiku 4.5 · Fastest for quick answers',
|
||||
}
|
||||
|
||||
function getOpusPlanOption(): ModelOption {
|
||||
return {
|
||||
value: 'opusplan',
|
||||
label: 'Opus Plan Mode',
|
||||
description: 'Use Opus 4.6 in plan mode, Sonnet 4.6 otherwise',
|
||||
}
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Update the model picker lists below to include/reorder options for the new model.
|
||||
// Each user tier (ant, Max/Team Premium, Pro/Team Standard/Enterprise, PAYG 1P, PAYG 3P) has its own list.
|
||||
function getModelOptionsBase(fastMode = false): ModelOption[] {
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
// Build options from antModels config
|
||||
const antModelOptions: ModelOption[] = getAntModels().map(m => ({
|
||||
value: m.alias,
|
||||
label: m.label,
|
||||
description: m.description ?? `[ANT-ONLY] ${m.label} (${m.model})`,
|
||||
}))
|
||||
|
||||
return [
|
||||
getDefaultOptionForUser(),
|
||||
...antModelOptions,
|
||||
getMergedOpus1MOption(fastMode),
|
||||
getSonnet46Option(),
|
||||
getSonnet46_1MOption(),
|
||||
getHaiku45Option(),
|
||||
]
|
||||
}
|
||||
|
||||
if (isClaudeAISubscriber()) {
|
||||
if (isMaxSubscriber() || isTeamPremiumSubscriber()) {
|
||||
// Max and Team Premium users: Opus is default, show Sonnet as alternative
|
||||
const premiumOptions = [getDefaultOptionForUser(fastMode)]
|
||||
if (!isOpus1mMergeEnabled() && checkOpus1mAccess()) {
|
||||
premiumOptions.push(getMaxOpus46_1MOption(fastMode))
|
||||
}
|
||||
|
||||
premiumOptions.push(MaxSonnet46Option)
|
||||
if (checkSonnet1mAccess()) {
|
||||
premiumOptions.push(getMaxSonnet46_1MOption())
|
||||
}
|
||||
|
||||
premiumOptions.push(MaxHaiku45Option)
|
||||
return premiumOptions
|
||||
}
|
||||
|
||||
// Pro/Team Standard/Enterprise users: Sonnet is default, show Opus as alternative
|
||||
const standardOptions = [getDefaultOptionForUser(fastMode)]
|
||||
if (checkSonnet1mAccess()) {
|
||||
standardOptions.push(getMaxSonnet46_1MOption())
|
||||
}
|
||||
|
||||
if (isOpus1mMergeEnabled()) {
|
||||
standardOptions.push(getMergedOpus1MOption(fastMode))
|
||||
} else {
|
||||
standardOptions.push(getMaxOpusOption(fastMode))
|
||||
if (checkOpus1mAccess()) {
|
||||
standardOptions.push(getMaxOpus46_1MOption(fastMode))
|
||||
}
|
||||
}
|
||||
|
||||
standardOptions.push(MaxHaiku45Option)
|
||||
return standardOptions
|
||||
}
|
||||
|
||||
// PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.6 + Opus 1M + Haiku
|
||||
if (getAPIProvider() === 'firstParty') {
|
||||
const payg1POptions = [getDefaultOptionForUser(fastMode)]
|
||||
if (checkSonnet1mAccess()) {
|
||||
payg1POptions.push(getSonnet46_1MOption())
|
||||
}
|
||||
if (isOpus1mMergeEnabled()) {
|
||||
payg1POptions.push(getMergedOpus1MOption(fastMode))
|
||||
} else {
|
||||
payg1POptions.push(getOpus46Option(fastMode))
|
||||
if (checkOpus1mAccess()) {
|
||||
payg1POptions.push(getOpus46_1MOption(fastMode))
|
||||
}
|
||||
}
|
||||
payg1POptions.push(getHaiku45Option())
|
||||
return payg1POptions
|
||||
}
|
||||
|
||||
// PAYG 3P: Default (Sonnet 4.5) + Sonnet (3P custom) or Sonnet 4.6/1M + Opus (3P custom) or Opus 4.1/Opus 4.6/Opus1M + Haiku + Opus 4.1
|
||||
const payg3pOptions = [getDefaultOptionForUser(fastMode)]
|
||||
|
||||
const customSonnet = getCustomSonnetOption()
|
||||
if (customSonnet !== undefined) {
|
||||
payg3pOptions.push(customSonnet)
|
||||
} else {
|
||||
// Add Sonnet 4.6 since Sonnet 4.5 is the default
|
||||
payg3pOptions.push(getSonnet46Option())
|
||||
if (checkSonnet1mAccess()) {
|
||||
payg3pOptions.push(getSonnet46_1MOption())
|
||||
}
|
||||
}
|
||||
|
||||
const customOpus = getCustomOpusOption()
|
||||
if (customOpus !== undefined) {
|
||||
payg3pOptions.push(customOpus)
|
||||
} else {
|
||||
// Add Opus 4.1, Opus 4.6 and Opus 4.6 1M
|
||||
payg3pOptions.push(getOpus41Option()) // This is the default opus
|
||||
payg3pOptions.push(getOpus46Option(fastMode))
|
||||
if (checkOpus1mAccess()) {
|
||||
payg3pOptions.push(getOpus46_1MOption(fastMode))
|
||||
}
|
||||
}
|
||||
const customHaiku = getCustomHaikuOption()
|
||||
if (customHaiku !== undefined) {
|
||||
payg3pOptions.push(customHaiku)
|
||||
} else {
|
||||
payg3pOptions.push(getHaikuOption())
|
||||
}
|
||||
return payg3pOptions
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Add the new model ID to the appropriate family pattern below
|
||||
// so the "newer version available" hint works correctly.
|
||||
/**
|
||||
* Map a full model name to its family alias and the marketing name of the
|
||||
* version the alias currently resolves to. Used to detect when a user has
|
||||
* a specific older version pinned and a newer one is available.
|
||||
*/
|
||||
function getModelFamilyInfo(
|
||||
model: string,
|
||||
): { alias: string; currentVersionName: string } | null {
|
||||
const canonical = getCanonicalName(model)
|
||||
|
||||
// Sonnet family
|
||||
if (
|
||||
canonical.includes('claude-sonnet-4-6') ||
|
||||
canonical.includes('claude-sonnet-4-5') ||
|
||||
canonical.includes('claude-sonnet-4-') ||
|
||||
canonical.includes('claude-3-7-sonnet') ||
|
||||
canonical.includes('claude-3-5-sonnet')
|
||||
) {
|
||||
const currentName = getMarketingNameForModel(getDefaultSonnetModel())
|
||||
if (currentName) {
|
||||
return { alias: 'Sonnet', currentVersionName: currentName }
|
||||
}
|
||||
}
|
||||
|
||||
// Opus family
|
||||
if (canonical.includes('claude-opus-4')) {
|
||||
const currentName = getMarketingNameForModel(getDefaultOpusModel())
|
||||
if (currentName) {
|
||||
return { alias: 'Opus', currentVersionName: currentName }
|
||||
}
|
||||
}
|
||||
|
||||
// Haiku family
|
||||
if (
|
||||
canonical.includes('claude-haiku') ||
|
||||
canonical.includes('claude-3-5-haiku')
|
||||
) {
|
||||
const currentName = getMarketingNameForModel(getDefaultHaikuModel())
|
||||
if (currentName) {
|
||||
return { alias: 'Haiku', currentVersionName: currentName }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a ModelOption for a known Anthropic model with a human-readable
|
||||
* label, and an upgrade hint if a newer version is available via the alias.
|
||||
* Returns null if the model is not recognized.
|
||||
*/
|
||||
function getKnownModelOption(model: string): ModelOption | null {
|
||||
const marketingName = getMarketingNameForModel(model)
|
||||
if (!marketingName) return null
|
||||
|
||||
const familyInfo = getModelFamilyInfo(model)
|
||||
if (!familyInfo) {
|
||||
return {
|
||||
value: model,
|
||||
label: marketingName,
|
||||
description: model,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the alias currently resolves to a different (newer) version
|
||||
if (marketingName !== familyInfo.currentVersionName) {
|
||||
return {
|
||||
value: model,
|
||||
label: marketingName,
|
||||
description: `Newer version available · select ${familyInfo.alias} for ${familyInfo.currentVersionName}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Same version as the alias — just show the friendly name
|
||||
return {
|
||||
value: model,
|
||||
label: marketingName,
|
||||
description: model,
|
||||
}
|
||||
}
|
||||
|
||||
export function getModelOptions(fastMode = false): ModelOption[] {
|
||||
const options = getModelOptionsBase(fastMode)
|
||||
|
||||
// Add the custom model from the ANTHROPIC_CUSTOM_MODEL_OPTION env var
|
||||
const envCustomModel = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION
|
||||
if (
|
||||
envCustomModel &&
|
||||
!options.some(existing => existing.value === envCustomModel)
|
||||
) {
|
||||
options.push({
|
||||
value: envCustomModel,
|
||||
label: process.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME ?? envCustomModel,
|
||||
description:
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION ??
|
||||
`Custom model (${envCustomModel})`,
|
||||
})
|
||||
}
|
||||
|
||||
// Append additional model options fetched during bootstrap
|
||||
for (const opt of getGlobalConfig().additionalModelOptionsCache ?? []) {
|
||||
if (!options.some(existing => existing.value === opt.value)) {
|
||||
options.push(opt)
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom model from either the current model value or the initial one
|
||||
// if it is not already in the options.
|
||||
let customModel: ModelSetting = null
|
||||
const currentMainLoopModel = getUserSpecifiedModelSetting()
|
||||
const initialMainLoopModel = getInitialMainLoopModel()
|
||||
if (currentMainLoopModel !== undefined && currentMainLoopModel !== null) {
|
||||
customModel = currentMainLoopModel
|
||||
} else if (initialMainLoopModel !== null) {
|
||||
customModel = initialMainLoopModel
|
||||
}
|
||||
if (customModel === null || options.some(opt => opt.value === customModel)) {
|
||||
return filterModelOptionsByAllowlist(options)
|
||||
} else if (customModel === 'opusplan') {
|
||||
return filterModelOptionsByAllowlist([...options, getOpusPlanOption()])
|
||||
} else if (customModel === 'opus' && getAPIProvider() === 'firstParty') {
|
||||
return filterModelOptionsByAllowlist([
|
||||
...options,
|
||||
getMaxOpusOption(fastMode),
|
||||
])
|
||||
} else if (customModel === 'opus[1m]' && getAPIProvider() === 'firstParty') {
|
||||
return filterModelOptionsByAllowlist([
|
||||
...options,
|
||||
getMergedOpus1MOption(fastMode),
|
||||
])
|
||||
} else {
|
||||
// Try to show a human-readable label for known Anthropic models, with an
|
||||
// upgrade hint if the alias now resolves to a newer version.
|
||||
const knownOption = getKnownModelOption(customModel)
|
||||
if (knownOption) {
|
||||
options.push(knownOption)
|
||||
} else {
|
||||
options.push({
|
||||
value: customModel,
|
||||
label: customModel,
|
||||
description: 'Custom model',
|
||||
})
|
||||
}
|
||||
return filterModelOptionsByAllowlist(options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter model options by the availableModels allowlist.
|
||||
* Always preserves the "Default" option (value: null).
|
||||
*/
|
||||
function filterModelOptionsByAllowlist(options: ModelOption[]): ModelOption[] {
|
||||
const settings = getSettings_DEPRECATED() || {}
|
||||
if (!settings.availableModels) {
|
||||
return options // No restrictions
|
||||
}
|
||||
return options.filter(
|
||||
opt =>
|
||||
opt.value === null || (opt.value !== null && isModelAllowed(opt.value)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
getModelStrings as getModelStringsState,
|
||||
setModelStrings as setModelStringsState,
|
||||
} from 'src/bootstrap/state.js'
|
||||
import { logError } from '../log.js'
|
||||
import { sequential } from '../sequential.js'
|
||||
import { getInitialSettings } from '../settings/settings.js'
|
||||
import { findFirstMatch, getBedrockInferenceProfiles } from './bedrock.js'
|
||||
import {
|
||||
ALL_MODEL_CONFIGS,
|
||||
CANONICAL_ID_TO_KEY,
|
||||
type CanonicalModelId,
|
||||
type ModelKey,
|
||||
} from './configs.js'
|
||||
import { type APIProvider, getAPIProvider } from './providers.js'
|
||||
|
||||
/**
|
||||
* Maps each model version to its provider-specific model ID string.
|
||||
* Derived from ALL_MODEL_CONFIGS — adding a model there extends this type.
|
||||
*/
|
||||
export type ModelStrings = Record<ModelKey, string>
|
||||
|
||||
const MODEL_KEYS = Object.keys(ALL_MODEL_CONFIGS) as ModelKey[]
|
||||
|
||||
function getBuiltinModelStrings(provider: APIProvider): ModelStrings {
|
||||
const out = {} as ModelStrings
|
||||
for (const key of MODEL_KEYS) {
|
||||
out[key] = ALL_MODEL_CONFIGS[key][provider]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function getBedrockModelStrings(): Promise<ModelStrings> {
|
||||
const fallback = getBuiltinModelStrings('bedrock')
|
||||
let profiles: string[] | undefined
|
||||
try {
|
||||
profiles = await getBedrockInferenceProfiles()
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
return fallback
|
||||
}
|
||||
if (!profiles?.length) {
|
||||
return fallback
|
||||
}
|
||||
// Each config's firstParty ID is the canonical substring we search for in the
|
||||
// user's inference profile list (e.g. "claude-opus-4-6" matches
|
||||
// "eu.anthropic.claude-opus-4-6-v1"). Fall back to the hardcoded bedrock ID
|
||||
// when no matching profile is found.
|
||||
const out = {} as ModelStrings
|
||||
for (const key of MODEL_KEYS) {
|
||||
const needle = ALL_MODEL_CONFIGS[key].firstParty
|
||||
out[key] = findFirstMatch(profiles, needle) || fallback[key]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer user-configured modelOverrides (from settings.json) on top of the
|
||||
* provider-derived model strings. Overrides are keyed by canonical first-party
|
||||
* model ID (e.g. "claude-opus-4-6") and map to arbitrary provider-specific
|
||||
* strings — typically Bedrock inference profile ARNs.
|
||||
*/
|
||||
function applyModelOverrides(ms: ModelStrings): ModelStrings {
|
||||
const overrides = getInitialSettings().modelOverrides
|
||||
if (!overrides) {
|
||||
return ms
|
||||
}
|
||||
const out = { ...ms }
|
||||
for (const [canonicalId, override] of Object.entries(overrides)) {
|
||||
const key = CANONICAL_ID_TO_KEY[canonicalId as CanonicalModelId]
|
||||
if (key && override) {
|
||||
out[key] = override
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an overridden model ID (e.g. a Bedrock ARN) back to its canonical
|
||||
* first-party model ID. If the input doesn't match any current override value,
|
||||
* it is returned unchanged. Safe to call during module init (no-ops if settings
|
||||
* aren't loaded yet).
|
||||
*/
|
||||
export function resolveOverriddenModel(modelId: string): string {
|
||||
let overrides: Record<string, string> | undefined
|
||||
try {
|
||||
overrides = getInitialSettings().modelOverrides
|
||||
} catch {
|
||||
return modelId
|
||||
}
|
||||
if (!overrides) {
|
||||
return modelId
|
||||
}
|
||||
for (const [canonicalId, override] of Object.entries(overrides)) {
|
||||
if (override === modelId) {
|
||||
return canonicalId
|
||||
}
|
||||
}
|
||||
return modelId
|
||||
}
|
||||
|
||||
const updateBedrockModelStrings = sequential(async () => {
|
||||
if (getModelStringsState() !== null) {
|
||||
// Already initialized. Doing the check here, combined with
|
||||
// `sequential`, allows the test suite to reset the state
|
||||
// between tests while still preventing multiple API calls
|
||||
// in production.
|
||||
return
|
||||
}
|
||||
try {
|
||||
const ms = await getBedrockModelStrings()
|
||||
setModelStringsState(ms)
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
}
|
||||
})
|
||||
|
||||
function initModelStrings(): void {
|
||||
const ms = getModelStringsState()
|
||||
if (ms !== null) {
|
||||
// Already initialized
|
||||
return
|
||||
}
|
||||
// Initial with default values for non-Bedrock providers
|
||||
if (getAPIProvider() !== 'bedrock') {
|
||||
setModelStringsState(getBuiltinModelStrings(getAPIProvider()))
|
||||
return
|
||||
}
|
||||
// On Bedrock, update model strings in the background without blocking.
|
||||
// Don't set the state in this case so that we can use `sequential` on
|
||||
// `updateBedrockModelStrings` and check for existing state on multiple
|
||||
// calls.
|
||||
void updateBedrockModelStrings()
|
||||
}
|
||||
|
||||
export function getModelStrings(): ModelStrings {
|
||||
const ms = getModelStringsState()
|
||||
if (ms === null) {
|
||||
initModelStrings()
|
||||
// Bedrock path falls through here while the profile fetch runs in the
|
||||
// background — still honor overrides on the interim defaults.
|
||||
return applyModelOverrides(getBuiltinModelStrings(getAPIProvider()))
|
||||
}
|
||||
return applyModelOverrides(ms)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure model strings are fully initialized.
|
||||
* For Bedrock users, this waits for the profile fetch to complete.
|
||||
* Call this before generating model options to ensure correct region strings.
|
||||
*/
|
||||
export async function ensureModelStringsInitialized(): Promise<void> {
|
||||
const ms = getModelStringsState()
|
||||
if (ms !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
// For non-Bedrock, initialize synchronously
|
||||
if (getAPIProvider() !== 'bedrock') {
|
||||
setModelStringsState(getBuiltinModelStrings(getAPIProvider()))
|
||||
return
|
||||
}
|
||||
|
||||
// For Bedrock, wait for the profile fetch
|
||||
await updateBedrockModelStrings()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { getAPIProvider } from './providers.js'
|
||||
|
||||
export type ModelCapabilityOverride =
|
||||
| 'effort'
|
||||
| 'max_effort'
|
||||
| 'thinking'
|
||||
| 'adaptive_thinking'
|
||||
| 'interleaved_thinking'
|
||||
|
||||
const TIERS = [
|
||||
{
|
||||
modelEnvVar: 'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
capabilitiesEnvVar: 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
|
||||
},
|
||||
{
|
||||
modelEnvVar: 'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
capabilitiesEnvVar: 'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
|
||||
},
|
||||
{
|
||||
modelEnvVar: 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
capabilitiesEnvVar: 'ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES',
|
||||
},
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Check whether a 3p model capability override is set for a model that matches one of
|
||||
* the pinned ANTHROPIC_DEFAULT_*_MODEL env vars.
|
||||
*/
|
||||
export const get3PModelCapabilityOverride = memoize(
|
||||
(model: string, capability: ModelCapabilityOverride): boolean | undefined => {
|
||||
if (getAPIProvider() === 'firstParty') {
|
||||
return undefined
|
||||
}
|
||||
const m = model.toLowerCase()
|
||||
for (const tier of TIERS) {
|
||||
const pinned = process.env[tier.modelEnvVar]
|
||||
const capabilities = process.env[tier.capabilitiesEnvVar]
|
||||
if (!pinned || capabilities === undefined) continue
|
||||
if (m !== pinned.toLowerCase()) continue
|
||||
return capabilities
|
||||
.toLowerCase()
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.includes(capability)
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
(model, capability) => `${model.toLowerCase()}:${capability}`,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/index.js'
|
||||
import { isEnvTruthy } from '../envUtils.js'
|
||||
|
||||
export type APIProvider = 'firstParty' | 'bedrock' | 'vertex' | 'foundry'
|
||||
|
||||
export function getAPIProvider(): APIProvider {
|
||||
return isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)
|
||||
? 'bedrock'
|
||||
: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)
|
||||
? 'vertex'
|
||||
: isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)
|
||||
? 'foundry'
|
||||
: 'firstParty'
|
||||
}
|
||||
|
||||
export function getAPIProviderForStatsig(): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS {
|
||||
return getAPIProvider() as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ANTHROPIC_BASE_URL is a first-party Anthropic API URL.
|
||||
* Returns true if not set (default API) or points to api.anthropic.com
|
||||
* (or api-staging.anthropic.com for ant users).
|
||||
*/
|
||||
export function isFirstPartyAnthropicBaseUrl(): boolean {
|
||||
const baseUrl = process.env.ANTHROPIC_BASE_URL
|
||||
if (!baseUrl) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const host = new URL(baseUrl).host
|
||||
const allowedHosts = ['api.anthropic.com']
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
allowedHosts.push('api-staging.anthropic.com')
|
||||
}
|
||||
return allowedHosts.includes(host)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
import { MODEL_ALIASES } from './aliases.js'
|
||||
import { isModelAllowed } from './modelAllowlist.js'
|
||||
import { getAPIProvider } from './providers.js'
|
||||
import { sideQuery } from '../sideQuery.js'
|
||||
import {
|
||||
NotFoundError,
|
||||
APIError,
|
||||
APIConnectionError,
|
||||
AuthenticationError,
|
||||
} from '@anthropic-ai/sdk'
|
||||
import { getModelStrings } from './modelStrings.js'
|
||||
|
||||
// Cache valid models to avoid repeated API calls
|
||||
const validModelCache = new Map<string, boolean>()
|
||||
|
||||
/**
|
||||
* Validates a model by attempting an actual API call.
|
||||
*/
|
||||
export async function validateModel(
|
||||
model: string,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
const normalizedModel = model.trim()
|
||||
|
||||
// Empty model is invalid
|
||||
if (!normalizedModel) {
|
||||
return { valid: false, error: 'Model name cannot be empty' }
|
||||
}
|
||||
|
||||
// Check against availableModels allowlist before any API call
|
||||
if (!isModelAllowed(normalizedModel)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Model '${normalizedModel}' is not in the list of available models`,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a known alias (these are always valid)
|
||||
const lowerModel = normalizedModel.toLowerCase()
|
||||
if ((MODEL_ALIASES as readonly string[]).includes(lowerModel)) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// Check if it matches ANTHROPIC_CUSTOM_MODEL_OPTION (pre-validated by the user)
|
||||
if (normalizedModel === process.env.ANTHROPIC_CUSTOM_MODEL_OPTION) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (validModelCache.has(normalizedModel)) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
|
||||
// Try to make an actual API call with minimal parameters
|
||||
try {
|
||||
await sideQuery({
|
||||
model: normalizedModel,
|
||||
max_tokens: 1,
|
||||
maxRetries: 0,
|
||||
querySource: 'model_validation',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hi',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// If we got here, the model is valid
|
||||
validModelCache.set(normalizedModel, true)
|
||||
return { valid: true }
|
||||
} catch (error) {
|
||||
return handleValidationError(error, normalizedModel)
|
||||
}
|
||||
}
|
||||
|
||||
function handleValidationError(
|
||||
error: unknown,
|
||||
modelName: string,
|
||||
): { valid: boolean; error: string } {
|
||||
// NotFoundError (404) means the model doesn't exist
|
||||
if (error instanceof NotFoundError) {
|
||||
const fallback = get3PFallbackSuggestion(modelName)
|
||||
const suggestion = fallback ? `. Try '${fallback}' instead` : ''
|
||||
return {
|
||||
valid: false,
|
||||
error: `Model '${modelName}' not found${suggestion}`,
|
||||
}
|
||||
}
|
||||
|
||||
// For other API errors, provide context-specific messages
|
||||
if (error instanceof APIError) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Authentication failed. Please check your API credentials.',
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof APIConnectionError) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Network error. Please check your internet connection.',
|
||||
}
|
||||
}
|
||||
|
||||
// Check error body for model-specific errors
|
||||
const errorBody = error.error as unknown
|
||||
if (
|
||||
errorBody &&
|
||||
typeof errorBody === 'object' &&
|
||||
'type' in errorBody &&
|
||||
errorBody.type === 'not_found_error' &&
|
||||
'message' in errorBody &&
|
||||
typeof errorBody.message === 'string' &&
|
||||
errorBody.message.includes('model:')
|
||||
) {
|
||||
return { valid: false, error: `Model '${modelName}' not found` }
|
||||
}
|
||||
|
||||
// Generic API error
|
||||
return { valid: false, error: `API error: ${error.message}` }
|
||||
}
|
||||
|
||||
// For unknown errors, be safe and reject
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
valid: false,
|
||||
error: `Unable to validate model: ${errorMessage}`,
|
||||
}
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Add a fallback suggestion chain for the new model → previous version
|
||||
/**
|
||||
* Suggest a fallback model for 3P users when the selected model is unavailable.
|
||||
*/
|
||||
function get3PFallbackSuggestion(model: string): string | undefined {
|
||||
if (getAPIProvider() === 'firstParty') {
|
||||
return undefined
|
||||
}
|
||||
const lowerModel = model.toLowerCase()
|
||||
if (lowerModel.includes('opus-4-6') || lowerModel.includes('opus_4_6')) {
|
||||
return getModelStrings().opus41
|
||||
}
|
||||
if (lowerModel.includes('sonnet-4-6') || lowerModel.includes('sonnet_4_6')) {
|
||||
return getModelStrings().sonnet45
|
||||
}
|
||||
if (lowerModel.includes('sonnet-4-5') || lowerModel.includes('sonnet_4_5')) {
|
||||
return getModelStrings().sonnet40
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
Reference in New Issue
Block a user