diff --git a/messages/en-US.json b/messages/en-US.json index f5de37878..e994c823c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1759,13 +1759,13 @@ "aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.", "aiProviderErrorNoUpdate": "AI provider is not available to update", "aiProviderModels": "Models", - "aiProviderModelsDescription": "Define model names available on this provider. Requests must use one of these model keys.", - "aiProviderModelsPlaceholder": "Type a model name and press Enter", + "aiProviderModelsDescription": "Define model names available on this provider. Requests must match one of these keys. Use * and ? as wildcards (for example gpt-4* or claude-?).", + "aiProviderModelsPlaceholder": "Model name or pattern (e.g. gpt-4*)", "aiProviderModelsUpdated": "Models updated", "aiProviderModelsErrorUpdate": "Failed to update models", "aiResourceProviders": "Providers", "aiResourceProvidersDescription": "Choose which AI providers this inference resource can use", - "aiResourceProvidersHelp": "Models must be defined on each provider. Model names cannot overlap across selected providers.", + "aiResourceProvidersHelp": "Models must be defined on each provider. Exact names and patterns that conflict (identical keys, or an exact key matching another provider's pattern) are not allowed across selected providers.", "aiResourceProvidersSelect": "Select providers", "aiResourceProvidersEmpty": "No AI providers found", "aiResourceProvidersUpdated": "Providers updated", diff --git a/server/lib/aiInferenceResource.ts b/server/lib/aiInferenceResource.ts index 6e0f490d8..d01b30dd9 100644 --- a/server/lib/aiInferenceResource.ts +++ b/server/lib/aiInferenceResource.ts @@ -10,6 +10,7 @@ import { type Transaction } from "@server/db"; import { z } from "zod"; +import { modelKeysConflict } from "@server/lib/aiModelKeyMatch"; type DbOrTrx = Transaction | typeof db; @@ -55,10 +56,13 @@ function normalizeAttachments( } /** - * Ensure enabled catalog modelKeys are unique across attached providers. + * Ensure enabled catalog modelKeys do not conflict across attached providers. * Catalog attachments contribute all enabled models on the provider. * Allowlist attachments contribute nothing until models are allowlisted * (those are checked when the allowlist is set). + * + * Conflicts: identical keys, or an exact key that matches another provider's + * pattern. Full glob intersections are left to runtime ambiguity errors. */ export async function assertNoOverlappingModelKeys( attachments: ResourceAiProviderAttachment[], @@ -85,25 +89,31 @@ export async function assertNoOverlappingModelKeys( ) ); - const keyToProviders = new Map(); - for (const model of models) { - const existing = keyToProviders.get(model.modelKey) ?? []; - if (!existing.includes(model.providerId)) { - existing.push(model.providerId); + const conflictPairs: string[] = []; + for (let i = 0; i < models.length; i++) { + for (let j = i + 1; j < models.length; j++) { + const left = models[i]; + const right = models[j]; + if (left.providerId === right.providerId) { + continue; + } + if (!modelKeysConflict(left.modelKey, right.modelKey)) { + continue; + } + const pair = [left.modelKey, right.modelKey].sort().join(" vs "); + if (!conflictPairs.includes(pair)) { + conflictPairs.push(pair); + } } - keyToProviders.set(model.modelKey, existing); } - const overlaps = [...keyToProviders.entries()].filter( - ([, providerIds]) => providerIds.length > 1 - ); - if (overlaps.length === 0) { + if (conflictPairs.length === 0) { return null; } - const keys = overlaps.map(([key]) => key).sort(); + conflictPairs.sort(); return { - error: `Model keys must be unique across providers on a resource. Overlapping keys: ${keys.join(", ")}` + error: `Model keys must be unique across providers on a resource. Overlapping keys: ${conflictPairs.join(", ")}` }; } diff --git a/server/lib/aiModelKeyMatch.ts b/server/lib/aiModelKeyMatch.ts new file mode 100644 index 000000000..fa27be56f --- /dev/null +++ b/server/lib/aiModelKeyMatch.ts @@ -0,0 +1,85 @@ +const modelKeyRegexCache = new Map(); + +export function isModelKeyPattern(key: string): boolean { + return key.includes("*") || key.includes("?"); +} + +function getModelKeyRegex(pattern: string): RegExp { + let regex = modelKeyRegexCache.get(pattern); + if (!regex) { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + regex = new RegExp( + `^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$` + ); + modelKeyRegexCache.set(pattern, regex); + } + return regex; +} + +export function modelKeyMatches( + pattern: string, + requestedModel: string +): boolean { + return getModelKeyRegex(pattern).test(requestedModel); +} + +function wildcardCharCount(key: string): number { + let count = 0; + for (const char of key) { + if (char === "*" || char === "?") { + count += 1; + } + } + return count; +} + +function literalLength(key: string): number { + return key.replace(/[*?]/g, "").length; +} + +/** + * Sort comparator: more specific patterns sort before less specific ones + * (negative when `a` is more specific than `b`). + * + * 1. Exact keys beat patterns + * 2. Fewer wildcard characters win + * 3. Longer literal length wins + */ +export function compareModelKeySpecificity(a: string, b: string): number { + const aIsPattern = isModelKeyPattern(a); + const bIsPattern = isModelKeyPattern(b); + + if (aIsPattern !== bIsPattern) { + return aIsPattern ? 1 : -1; + } + + const wildcardDiff = wildcardCharCount(a) - wildcardCharCount(b); + if (wildcardDiff !== 0) { + return wildcardDiff; + } + + return literalLength(b) - literalLength(a); +} + +/** + * Attach-time conflict check. Detects identical keys and exact-vs-pattern + * matches. Does not attempt full glob intersection. + */ +export function modelKeysConflict(a: string, b: string): boolean { + if (a === b) { + return true; + } + + const aIsPattern = isModelKeyPattern(a); + const bIsPattern = isModelKeyPattern(b); + + if (aIsPattern === bIsPattern) { + return false; + } + + if (aIsPattern) { + return modelKeyMatches(a, b); + } + + return modelKeyMatches(b, a); +} diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index bdc4b0eb5..247e55b4c 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -39,6 +39,10 @@ import { localCache } from "@server/lib/cache"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import type { ModelAccessMode } from "@server/lib/aiInferenceResource"; +import { + compareModelKeySpecificity, + modelKeyMatches +} from "@server/lib/aiModelKeyMatch"; import { aiGatewayUpstreamFetch } from "@server/lib/aiGatewayUpstreamFetch"; // Short-lived local caches so a burst of requests from the same IP/user @@ -369,55 +373,82 @@ async function selectProvider( }; } - const matchingModels = await db + const providerModels = await db .select({ modelId: aiModels.modelId, providerId: aiModels.providerId, + modelKey: aiModels.modelKey, enabled: aiModels.enabled }) .from(aiModels) - .where( - and( - inArray(aiModels.providerId, providerIds), - eq(aiModels.modelKey, requestedModel) - ) - ); + .where(inArray(aiModels.providerId, providerIds)); + + type ModelCandidate = { + provider: AiProvider; + modelKey: string; + }; + + const candidates: ModelCandidate[] = []; + for (const model of providerModels) { + if (!model.enabled) { + continue; + } + + if (!modelKeyMatches(model.modelKey, requestedModel)) { + continue; + } - const candidates: AiProvider[] = []; - for (const model of matchingModels) { const attachment = providerById.get(model.providerId); if (!attachment) { continue; } if (attachment.modelAccessMode === "catalog") { - if (model.enabled) { - candidates.push(attachment.provider); - } + candidates.push({ + provider: attachment.provider, + modelKey: model.modelKey + }); continue; } if (allowlistedModelIds.has(model.modelId)) { - candidates.push(attachment.provider); + candidates.push({ + provider: attachment.provider, + modelKey: model.modelKey + }); } } - if (candidates.length === 1) { - return { ok: true, provider: candidates[0] }; - } - - if (candidates.length > 1) { + if (candidates.length === 0) { return { ok: false, status: HttpCode.FORBIDDEN, - message: `Model "${requestedModel}" is ambiguous across multiple AI providers on this resource` + message: `Model "${requestedModel}" is not permitted on this resource` }; } + candidates.sort((a, b) => + compareModelKeySpecificity(a.modelKey, b.modelKey) + ); + + const bestSpecificity = candidates[0].modelKey; + const topCandidates = candidates.filter( + (c) => compareModelKeySpecificity(c.modelKey, bestSpecificity) === 0 + ); + + const uniqueProviders = new Map(); + for (const candidate of topCandidates) { + uniqueProviders.set(candidate.provider.providerId, candidate.provider); + } + + if (uniqueProviders.size === 1) { + return { ok: true, provider: [...uniqueProviders.values()][0] }; + } + return { ok: false, status: HttpCode.FORBIDDEN, - message: `Model "${requestedModel}" is not permitted on this resource` + message: `Model "${requestedModel}" is ambiguous across multiple AI providers on this resource` }; }