/ utils / model / modelAllowlist.ts
modelAllowlist.ts
  1  import { getSettings_DEPRECATED } from '../settings/settings.js'
  2  import { isModelAlias, isModelFamilyAlias } from './aliases.js'
  3  import { parseUserSpecifiedModel } from './model.js'
  4  import { resolveOverriddenModel } from './modelStrings.js'
  5  
  6  /**
  7   * Check if a model belongs to a given family by checking if its name
  8   * (or resolved name) contains the family identifier.
  9   */
 10  function modelBelongsToFamily(model: string, family: string): boolean {
 11    if (model.includes(family)) {
 12      return true
 13    }
 14    // Resolve aliases like "best" → "claude-opus-4-6" to check family membership
 15    if (isModelAlias(model)) {
 16      const resolved = parseUserSpecifiedModel(model).toLowerCase()
 17      return resolved.includes(family)
 18    }
 19    return false
 20  }
 21  
 22  /**
 23   * Check if a model name starts with a prefix at a segment boundary.
 24   * The prefix must match up to the end of the name or a "-" separator.
 25   * e.g. "claude-opus-4-5" matches "claude-opus-4-5-20251101" but not "claude-opus-4-50".
 26   */
 27  function prefixMatchesModel(modelName: string, prefix: string): boolean {
 28    if (!modelName.startsWith(prefix)) {
 29      return false
 30    }
 31    return modelName.length === prefix.length || modelName[prefix.length] === '-'
 32  }
 33  
 34  /**
 35   * Check if a model matches a version-prefix entry in the allowlist.
 36   * Supports shorthand like "opus-4-5" (mapped to "claude-opus-4-5") and
 37   * full prefixes like "claude-opus-4-5". Resolves input aliases before matching.
 38   */
 39  function modelMatchesVersionPrefix(model: string, entry: string): boolean {
 40    // Resolve the input model to a full name if it's an alias
 41    const resolvedModel = isModelAlias(model)
 42      ? parseUserSpecifiedModel(model).toLowerCase()
 43      : model
 44  
 45    // Try the entry as-is (e.g. "claude-opus-4-5")
 46    if (prefixMatchesModel(resolvedModel, entry)) {
 47      return true
 48    }
 49    // Try with "claude-" prefix (e.g. "opus-4-5" → "claude-opus-4-5")
 50    if (
 51      !entry.startsWith('claude-') &&
 52      prefixMatchesModel(resolvedModel, `claude-${entry}`)
 53    ) {
 54      return true
 55    }
 56    return false
 57  }
 58  
 59  /**
 60   * Check if a family alias is narrowed by more specific entries in the allowlist.
 61   * When the allowlist contains both "opus" and "opus-4-5", the specific entry
 62   * takes precedence — "opus" alone would be a wildcard, but "opus-4-5" narrows
 63   * it to only that version.
 64   */
 65  function familyHasSpecificEntries(
 66    family: string,
 67    allowlist: string[],
 68  ): boolean {
 69    for (const entry of allowlist) {
 70      if (isModelFamilyAlias(entry)) {
 71        continue
 72      }
 73      // Check if entry is a version-qualified variant of this family
 74      // e.g., "opus-4-5" or "claude-opus-4-5-20251101" for the "opus" family
 75      // Must match at a segment boundary (followed by '-' or end) to avoid
 76      // false positives like "opusplan" matching "opus"
 77      const idx = entry.indexOf(family)
 78      if (idx === -1) {
 79        continue
 80      }
 81      const afterFamily = idx + family.length
 82      if (afterFamily === entry.length || entry[afterFamily] === '-') {
 83        return true
 84      }
 85    }
 86    return false
 87  }
 88  
 89  /**
 90   * Check if a model is allowed by the availableModels allowlist in settings.
 91   * If availableModels is not set, all models are allowed.
 92   *
 93   * Matching tiers:
 94   * 1. Family aliases ("opus", "sonnet", "haiku") — wildcard for the entire family,
 95   *    UNLESS more specific entries for that family also exist (e.g., "opus-4-5").
 96   *    In that case, the family wildcard is ignored and only the specific entries apply.
 97   * 2. Version prefixes ("opus-4-5", "claude-opus-4-5") — any build of that version
 98   * 3. Full model IDs ("claude-opus-4-5-20251101") — exact match only
 99   */
100  export function isModelAllowed(model: string): boolean {
101    const settings = getSettings_DEPRECATED() || {}
102    const { availableModels } = settings
103    if (!availableModels) {
104      return true // No restrictions
105    }
106    if (availableModels.length === 0) {
107      return false // Empty allowlist blocks all user-specified models
108    }
109  
110    const resolvedModel = resolveOverriddenModel(model)
111    const normalizedModel = resolvedModel.trim().toLowerCase()
112    const normalizedAllowlist = availableModels.map(m => m.trim().toLowerCase())
113  
114    // Direct match (alias-to-alias or full-name-to-full-name)
115    // Skip family aliases that have been narrowed by specific entries —
116    // e.g., "opus" in ["opus", "opus-4-5"] should NOT directly match,
117    // because the admin intends to restrict to opus 4.5 only.
118    if (normalizedAllowlist.includes(normalizedModel)) {
119      if (
120        !isModelFamilyAlias(normalizedModel) ||
121        !familyHasSpecificEntries(normalizedModel, normalizedAllowlist)
122      ) {
123        return true
124      }
125    }
126  
127    // Family-level aliases in the allowlist match any model in that family,
128    // but only if no more specific entries exist for that family.
129    // e.g., ["opus"] allows all opus, but ["opus", "opus-4-5"] only allows opus 4.5.
130    for (const entry of normalizedAllowlist) {
131      if (
132        isModelFamilyAlias(entry) &&
133        !familyHasSpecificEntries(entry, normalizedAllowlist) &&
134        modelBelongsToFamily(normalizedModel, entry)
135      ) {
136        return true
137      }
138    }
139  
140    // For non-family entries, do bidirectional alias resolution
141    // If model is an alias, resolve it and check if the resolved name is in the list
142    if (isModelAlias(normalizedModel)) {
143      const resolved = parseUserSpecifiedModel(normalizedModel).toLowerCase()
144      if (normalizedAllowlist.includes(resolved)) {
145        return true
146      }
147    }
148  
149    // If any non-family alias in the allowlist resolves to the input model
150    for (const entry of normalizedAllowlist) {
151      if (!isModelFamilyAlias(entry) && isModelAlias(entry)) {
152        const resolved = parseUserSpecifiedModel(entry).toLowerCase()
153        if (resolved === normalizedModel) {
154          return true
155        }
156      }
157    }
158  
159    // Version-prefix matching: "opus-4-5" or "claude-opus-4-5" matches
160    // "claude-opus-4-5-20251101" at a segment boundary
161    for (const entry of normalizedAllowlist) {
162      if (!isModelFamilyAlias(entry) && !isModelAlias(entry)) {
163        if (modelMatchesVersionPrefix(normalizedModel, entry)) {
164          return true
165        }
166      }
167    }
168  
169    return false
170  }