171 lines
5.9 KiB
TypeScript
171 lines
5.9 KiB
TypeScript
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
|
|
}
|