init claude-code

This commit is contained in:
2026-04-01 17:32:37 +02:00
commit 73b208c009
1902 changed files with 513237 additions and 0 deletions
+157
View File
@@ -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',
},
]
}
+25
View File
@@ -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)
}
+64
View File
@@ -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()),
)
}
+265
View File
@@ -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
}
+72
View File
@@ -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
}
+118
View File
@@ -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>
+47
View File
@@ -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
}
}
+101
View File
@@ -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.`
}
+618
View File
@@ -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, '')
}
+170
View File
@@ -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
}
+118
View File
@@ -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'}`,
)
}
}
+540
View File
@@ -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)),
)
}
+166
View File
@@ -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()
}
+50
View File
@@ -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}`,
)
+40
View File
@@ -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
}
}
+159
View File
@@ -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
}