support patterns in model key

This commit is contained in:
miloschwartz
2026-08-06 14:27:08 -04:00
parent 36b8ef5fba
commit c5d68675c9
4 changed files with 162 additions and 36 deletions
+3 -3
View File
@@ -1759,13 +1759,13 @@
"aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.", "aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.",
"aiProviderErrorNoUpdate": "AI provider is not available to update", "aiProviderErrorNoUpdate": "AI provider is not available to update",
"aiProviderModels": "Models", "aiProviderModels": "Models",
"aiProviderModelsDescription": "Define model names available on this provider. Requests must use one of these model keys.", "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": "Type a model name and press Enter", "aiProviderModelsPlaceholder": "Model name or pattern (e.g. gpt-4*)",
"aiProviderModelsUpdated": "Models updated", "aiProviderModelsUpdated": "Models updated",
"aiProviderModelsErrorUpdate": "Failed to update models", "aiProviderModelsErrorUpdate": "Failed to update models",
"aiResourceProviders": "Providers", "aiResourceProviders": "Providers",
"aiResourceProvidersDescription": "Choose which AI providers this inference resource can use", "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", "aiResourceProvidersSelect": "Select providers",
"aiResourceProvidersEmpty": "No AI providers found", "aiResourceProvidersEmpty": "No AI providers found",
"aiResourceProvidersUpdated": "Providers updated", "aiResourceProvidersUpdated": "Providers updated",
+23 -13
View File
@@ -10,6 +10,7 @@ import {
type Transaction type Transaction
} from "@server/db"; } from "@server/db";
import { z } from "zod"; import { z } from "zod";
import { modelKeysConflict } from "@server/lib/aiModelKeyMatch";
type DbOrTrx = Transaction | typeof db; 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. * Catalog attachments contribute all enabled models on the provider.
* Allowlist attachments contribute nothing until models are allowlisted * Allowlist attachments contribute nothing until models are allowlisted
* (those are checked when the allowlist is set). * (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( export async function assertNoOverlappingModelKeys(
attachments: ResourceAiProviderAttachment[], attachments: ResourceAiProviderAttachment[],
@@ -85,25 +89,31 @@ export async function assertNoOverlappingModelKeys(
) )
); );
const keyToProviders = new Map<string, number[]>(); const conflictPairs: string[] = [];
for (const model of models) { for (let i = 0; i < models.length; i++) {
const existing = keyToProviders.get(model.modelKey) ?? []; for (let j = i + 1; j < models.length; j++) {
if (!existing.includes(model.providerId)) { const left = models[i];
existing.push(model.providerId); 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( if (conflictPairs.length === 0) {
([, providerIds]) => providerIds.length > 1
);
if (overlaps.length === 0) {
return null; return null;
} }
const keys = overlaps.map(([key]) => key).sort(); conflictPairs.sort();
return { 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(", ")}`
}; };
} }
+85
View File
@@ -0,0 +1,85 @@
const modelKeyRegexCache = new Map<string, RegExp>();
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);
}
+56 -25
View File
@@ -39,6 +39,10 @@ import { localCache } from "@server/lib/cache";
import logger from "@server/logger"; import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import type { ModelAccessMode } from "@server/lib/aiInferenceResource"; import type { ModelAccessMode } from "@server/lib/aiInferenceResource";
import {
compareModelKeySpecificity,
modelKeyMatches
} from "@server/lib/aiModelKeyMatch";
import { aiGatewayUpstreamFetch } from "@server/lib/aiGatewayUpstreamFetch"; import { aiGatewayUpstreamFetch } from "@server/lib/aiGatewayUpstreamFetch";
// Short-lived local caches so a burst of requests from the same IP/user // Short-lived local caches so a burst of requests from the same IP/user
@@ -369,56 +373,83 @@ async function selectProvider(
}; };
} }
const matchingModels = await db const providerModels = await db
.select({ .select({
modelId: aiModels.modelId, modelId: aiModels.modelId,
providerId: aiModels.providerId, providerId: aiModels.providerId,
modelKey: aiModels.modelKey,
enabled: aiModels.enabled enabled: aiModels.enabled
}) })
.from(aiModels) .from(aiModels)
.where( .where(inArray(aiModels.providerId, providerIds));
and(
inArray(aiModels.providerId, providerIds), type ModelCandidate = {
eq(aiModels.modelKey, requestedModel) 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); const attachment = providerById.get(model.providerId);
if (!attachment) { if (!attachment) {
continue; continue;
} }
if (attachment.modelAccessMode === "catalog") { if (attachment.modelAccessMode === "catalog") {
if (model.enabled) { candidates.push({
candidates.push(attachment.provider); provider: attachment.provider,
} modelKey: model.modelKey
});
continue; continue;
} }
if (allowlistedModelIds.has(model.modelId)) { if (allowlistedModelIds.has(model.modelId)) {
candidates.push(attachment.provider); candidates.push({
provider: attachment.provider,
modelKey: model.modelKey
});
} }
} }
if (candidates.length === 1) { if (candidates.length === 0) {
return { ok: true, provider: candidates[0] };
}
if (candidates.length > 1) {
return {
ok: false,
status: HttpCode.FORBIDDEN,
message: `Model "${requestedModel}" is ambiguous across multiple AI providers on this resource`
};
}
return { return {
ok: false, ok: false,
status: HttpCode.FORBIDDEN, status: HttpCode.FORBIDDEN,
message: `Model "${requestedModel}" is not permitted 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<number, AiProvider>();
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 ambiguous across multiple AI providers on this resource`
};
} }
export async function handleAiGatewayProxy( export async function handleAiGatewayProxy(