| 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 | |
| } | |