Model Selection:可用模型白名单的深度匹配与“Default”回退机制
它解决了什么问题
在 Claude Code 的配置体系中,availableModels(可用模型列表)并非简单的字符串过滤,而是一套集成了家族别名展开、版本前缀匹配与默认模型保护的精密裁决机制。
这套装配项的本质是约束“用户的显式选择行为”,而非彻底锁定 CLI 的模型调用。它控制的是 /model 命令、--model 启动参数、ANTHROPIC_MODEL 环境变量以及 Settings 中的 model 字段。一个核心的设计原则是:availableModels 永远不会屏蔽 Default(默认模型)选项。这意味着即便管理员配置了一个空列表,用户依然可以运行 Claude Code,只是失去了手动切换到特定版本模型的权限。
从源码看实现
这一约束的核心逻辑封装在 claude-code-opensource/src/utils/model/modelAllowlist.ts 中。
1. 三层匹配算法
当系统校验一个模型是否“允许”时,它会按顺序执行三层逻辑,而非简单的 includes 检查:
- 家族别名(Family Alias)匹配:系统识别
sonnet、opus、haiku等家族关键词。如果你在白名单中写了opus,它默认会作为通配符允许所有的 Opus 变体。但这里有一个“收窄规则”:一旦白名单中出现了更具体的条目(如["opus", "opus-4-5"]),原有的opus通配符就会失效,系统将只允许指定的 4.5 版本。 - 版本前缀(Version Prefix)匹配:支持形如
claude-3-5-sonnet的前缀匹配。源码中的modelMatchesVersionPrefix()确保匹配发生在段落边界(Segment Boundary),防止因字符串包含而产生的误伤(例如防止sonnet-pro被sonnet误匹配)。 - 全量 ID 匹配:最基础的完整模型 ID 对比。
2. 别名的双向解析
Claude Code 的匹配器非常智能,它不要求白名单与用户输入格式完全一致。系统会通过 parseUserSpecifiedModel() 将用户输入解析为 Provider 下的真实模型 ID,同时也会将白名单中的别名反向解析。这种“双向展开”保证了无论用户输入的是 sonnet 还是 claude-3-5-sonnet-latest,都能正确命中白名单中的 sonnet 条目。
3. “Default”的免疫地位
在 claude-code-opensource/src/utils/model/modelOptions.ts 中,当系统为 /model 菜单生成选项列表时,会调用 filterModelOptionsByAllowlist()。该函数有一个显式的特判:如果选项的值为 null(代表 Default),则无条件保留。
而在实际的模型解析链(model.ts)中,如果检测到用户指定的模型不在白名单内,getUserSpecifiedModelSetting() 会直接返回 undefined。这会导致系统自动回退到 getDefaultMainLoopModel(),即当前组织或 Provider 推荐的默认模型(通常是 Sonnet)。
4. 跨层级的数组合并
作为一个数组类型的配置项,availableModels 遵循 settings.ts 中的“拼接并去重”规则。这意味着如果你在 userSettings 里加了 opus,在 projectSettings 里加了 haiku,最终生效的白名单将包含这两者。因此,要实现真正的组织级硬管控,必须将限制项放在 policySettings 层级,因为那一层具有绝对的抢占权。
5. 模型别名与 1M 上下文(Model Aliases & 1M)
在 claude-code-opensource/src/utils/model/aliases.ts 中,系统预定义了一系列快捷别名:
- 常用家族:
sonnet、opus、haiku。 - 增强版本:
sonnet[1m]、opus[1m]。带有[1m]后缀的别名会触发has1mContext()逻辑,尝试申请 100 万 token 的超大上下文窗口。 - 功能特定:
best(自动选择当前最强模型)、opusplan(专门为/plan模式优化的 Opus 配置)。 系统通过parseUserSpecifiedModel将这些别名动态映射到真实的 API 模型 ID(如claude-3-7-sonnet-20250219),并处理[1m]后缀的剥离与能力校验。
6. Provider 路由机制(Provider Routing)
Claude Code 支持 Anthropic 原生 API,也支持通过云厂商进行路由。在 claude-code-opensource/src/utils/model/providers.ts 中,系统根据环境变量决定 APIProvider:
- firstParty:默认模式,直接连接
api.anthropic.com。 - bedrock / vertex / foundry:分别对应 AWS Bedrock、Google Vertex AI 和企业私有 Foundry 部署。
- 环境变量驱动:例如设置
CLAUDE_CODE_USE_BEDROCK=true会将所有请求路由至 Bedrock。不同 Provider 下的模型名称可能会被normalizeModelStringForAPI进行适配化处理,以符合各家云厂商的命名规范。
别踩这些坑
- 白名单不锁死
Default。即便白名单为空,用户依然可以使用默认模型。如果你想完全禁止用户使用 CLI,应该通过策略限制其登录或权限,而非仅仅修改模型白名单。 - 别名通配符的失效场景。记住
["sonnet"]允许所有 Sonnet,但["sonnet", "sonnet-20241022"]仅允许那个特定日期版本。这是为了防止管理员在进行特定版本锁定测试时,意外放行了全族模型。 - 1M 上下文的准入限制。即便设置了
sonnet[1m],系统仍会通过checkSonnet1mAccess()校验用户的订阅等级或组织权限。如果校验失败,系统会给出详细的升级建议链接。 - 环境变量的优先级。
ANTHROPIC_MODEL虽然优先级很高,但它依然要过isModelAllowed()的审计。如果环境变量指定的模型不在白名单内,系统会静默回退到默认模型并给出提示。 - Provider 的排他性:目前
getAPIProvider采用简单的if-else链条,这意味着你不能在同一个会话中同时启用多个云厂商路由。
延伸阅读
- 如果你想了解不同 Provider 下的模型定价差异,请看
modelCost.ts。 - 如果你想了解如何为自定义模型配置 1M 上下文能力,建议分析
check1mAccess.ts。 - 如果你在开发需要根据当前模型动态调整 UI 的功能,应重点研究
useMainLoopModel.tsReact Hook。
源码锚点
claude-code-opensource/src/utils/model/modelAllowlist.ts: 核心匹配逻辑、家族别名展开与收窄规则。
📄 src/utils/model/modelAllowlist.ts — 核心匹配逻辑、家族别名展开与收窄规则。
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
}claude-code-opensource/src/utils/model/model.ts: 模型解析链条、优先级处理与[1m]后缀解析。
📄 src/utils/model/model.ts — 模型解析链条、优先级处理与 `[1m]` 后缀解析。
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()
}claude-code-opensource/src/utils/model/aliases.ts: 定义官方支持的家族别名、1M 变体与双向映射表。
📄 src/utils/model/aliases.ts — 定义官方支持的家族别名、1M 变体与双向映射表。
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)
}claude-code-opensource/src/utils/model/providers.ts:APIProvider路由逻辑,支持 Bedrock、Vertex 和 Foundry。
📄 src/utils/model/providers.ts — `APIProvider` 路由逻辑,支持 Bedrock、Vertex 和 Foundry。
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'
}claude-code-opensource/src/utils/model/check1mAccess.ts: 1M 大上下文权限校验逻辑。
📄 src/utils/model/check1mAccess.ts — 1M 大上下文权限校验逻辑。
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:claude-code-opensource/src/utils/settings/settings.ts: 处理availableModels数组跨 Scope 合并的底层逻辑。
📄 src/utils/settings/settings.ts — 处理 `availableModels` 数组跨 Scope 合并的底层逻辑。
type EditableSettingSource,
getEnabledSettingSources,
type SettingSource,
} from './constants.js'
import { markInternalWrite } from './internalWrites.js'