mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-07 21:19:03 +02:00
support patterns in model key
This commit is contained in:
+3
-3
@@ -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",
|
||||||
|
|||||||
@@ -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(", ")}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user