mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-12 23:40:40 +02:00
improve model provider selection algorithm
This commit is contained in:
@@ -10,7 +10,6 @@ import {
|
||||
type Transaction
|
||||
} from "@server/db";
|
||||
import { z } from "zod";
|
||||
import { modelKeysConflict } from "@server/lib/aiModelKeyMatch";
|
||||
|
||||
type DbOrTrx = Transaction | typeof db;
|
||||
|
||||
@@ -100,142 +99,6 @@ function normalizeAttachments(
|
||||
);
|
||||
}
|
||||
|
||||
type EffectiveAllowRow = {
|
||||
providerId: number;
|
||||
modelKey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure effective allow modelKeys do not conflict across attached providers.
|
||||
* inherit uses provider allows; select uses resource-selected allows (or the
|
||||
* optional override map). Block patterns are ignored for overlap checks.
|
||||
*/
|
||||
export async function assertNoOverlappingModelKeys(
|
||||
attachments: ResourceAiProviderAttachment[],
|
||||
options: {
|
||||
trx?: DbOrTrx;
|
||||
resourceId?: number;
|
||||
siteResourceId?: number;
|
||||
selectedAllowsByProvider?: Map<number, string[]>;
|
||||
} = {}
|
||||
): Promise<InferenceFieldsError | null> {
|
||||
const trx = options.trx ?? db;
|
||||
|
||||
const activeAttachments = attachments.filter((a) => a.enabled);
|
||||
|
||||
if (activeAttachments.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inheritProviderIds = activeAttachments
|
||||
.filter((a) => a.accessMode === "inherit")
|
||||
.map((a) => a.providerId);
|
||||
const selectProviderIds = activeAttachments
|
||||
.filter((a) => a.accessMode === "select")
|
||||
.map((a) => a.providerId);
|
||||
|
||||
const effectiveAllows: EffectiveAllowRow[] = [];
|
||||
|
||||
if (inheritProviderIds.length > 0) {
|
||||
const providerAllows = await trx
|
||||
.select({
|
||||
providerId: aiModels.providerId,
|
||||
modelKey: aiModels.modelKey
|
||||
})
|
||||
.from(aiModels)
|
||||
.where(
|
||||
and(
|
||||
inArray(aiModels.providerId, inheritProviderIds),
|
||||
eq(aiModels.enabled, true),
|
||||
eq(aiModels.listType, "allow")
|
||||
)
|
||||
);
|
||||
effectiveAllows.push(...providerAllows);
|
||||
}
|
||||
|
||||
if (selectProviderIds.length > 0) {
|
||||
if (options.selectedAllowsByProvider) {
|
||||
for (const providerId of selectProviderIds) {
|
||||
const keys =
|
||||
options.selectedAllowsByProvider.get(providerId) ?? [];
|
||||
for (const modelKey of keys) {
|
||||
effectiveAllows.push({ providerId, modelKey });
|
||||
}
|
||||
}
|
||||
} else if (options.resourceId !== undefined) {
|
||||
const rows = await trx
|
||||
.select({
|
||||
providerId: aiModels.providerId,
|
||||
modelKey: aiModels.modelKey
|
||||
})
|
||||
.from(resourceAiModels)
|
||||
.innerJoin(
|
||||
aiModels,
|
||||
eq(resourceAiModels.modelId, aiModels.modelId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceAiModels.resourceId, options.resourceId),
|
||||
inArray(aiModels.providerId, selectProviderIds),
|
||||
eq(resourceAiModels.listType, "allow"),
|
||||
eq(aiModels.enabled, true)
|
||||
)
|
||||
);
|
||||
effectiveAllows.push(...rows);
|
||||
} else if (options.siteResourceId !== undefined) {
|
||||
const rows = await trx
|
||||
.select({
|
||||
providerId: aiModels.providerId,
|
||||
modelKey: aiModels.modelKey
|
||||
})
|
||||
.from(siteResourceAiModels)
|
||||
.innerJoin(
|
||||
aiModels,
|
||||
eq(siteResourceAiModels.modelId, aiModels.modelId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
siteResourceAiModels.siteResourceId,
|
||||
options.siteResourceId
|
||||
),
|
||||
inArray(aiModels.providerId, selectProviderIds),
|
||||
eq(siteResourceAiModels.listType, "allow"),
|
||||
eq(aiModels.enabled, true)
|
||||
)
|
||||
);
|
||||
effectiveAllows.push(...rows);
|
||||
}
|
||||
}
|
||||
|
||||
const conflictPairs: string[] = [];
|
||||
for (let i = 0; i < effectiveAllows.length; i++) {
|
||||
for (let j = i + 1; j < effectiveAllows.length; j++) {
|
||||
const left = effectiveAllows[i];
|
||||
const right = effectiveAllows[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (conflictPairs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
conflictPairs.sort();
|
||||
return {
|
||||
error: `Model keys must be unique across providers on a resource. Overlapping keys: ${conflictPairs.join(", ")}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate provider attachments for an org.
|
||||
*/
|
||||
@@ -243,8 +106,6 @@ export async function resolveProviderAttachments(input: {
|
||||
orgId: string;
|
||||
attachments: ResourceAiProviderInput[];
|
||||
requireAtLeastOne: boolean;
|
||||
resourceId?: number;
|
||||
siteResourceId?: number;
|
||||
}): Promise<ResourceAiProviderAttachment[] | InferenceFieldsError> {
|
||||
const attachments = normalizeAttachments(input.attachments);
|
||||
|
||||
@@ -286,14 +147,6 @@ export async function resolveProviderAttachments(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const overlapError = await assertNoOverlappingModelKeys(attachments, {
|
||||
resourceId: input.resourceId,
|
||||
siteResourceId: input.siteResourceId
|
||||
});
|
||||
if (overlapError) {
|
||||
return overlapError;
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
@@ -830,7 +683,6 @@ async function assertModelEntriesValid(input: {
|
||||
const catalogRows = await db
|
||||
.select({
|
||||
modelId: aiModels.modelId,
|
||||
modelKey: aiModels.modelKey,
|
||||
listType: aiModels.listType,
|
||||
providerId: aiModels.providerId,
|
||||
enabled: aiModels.enabled
|
||||
@@ -850,7 +702,6 @@ async function assertModelEntriesValid(input: {
|
||||
}
|
||||
|
||||
const catalogById = new Map(catalogRows.map((row) => [row.modelId, row]));
|
||||
const selectedAllowsByProvider = new Map<number, string[]>();
|
||||
for (const entry of input.modelEntries) {
|
||||
const catalog = catalogById.get(entry.modelId);
|
||||
if (!catalog) {
|
||||
@@ -862,18 +713,6 @@ async function assertModelEntriesValid(input: {
|
||||
if (!catalog.enabled) {
|
||||
return `Model ${entry.modelId} is disabled on its provider`;
|
||||
}
|
||||
if (entry.listType === "allow") {
|
||||
const keys = selectedAllowsByProvider.get(catalog.providerId) ?? [];
|
||||
keys.push(catalog.modelKey);
|
||||
selectedAllowsByProvider.set(catalog.providerId, keys);
|
||||
}
|
||||
}
|
||||
|
||||
const overlapError = await assertNoOverlappingModelKeys(input.attachments, {
|
||||
selectedAllowsByProvider
|
||||
});
|
||||
if (overlapError) {
|
||||
return overlapError.error;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -61,29 +61,6 @@ export function compareModelKeySpecificity(a: string, b: string): number {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-layer policy: empty allowlist denies all. Blocklist only applies
|
||||
* after an allow match.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
aiModelCatalog,
|
||||
getCatalogProviderForType,
|
||||
type CatalogProvider
|
||||
} from "@server/lib/aiModelCatalog";
|
||||
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
|
||||
|
||||
function stripVendorPrefix(modelId: string): string | null {
|
||||
const idx = modelId.indexOf("/");
|
||||
if (idx === -1 || idx === modelId.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return modelId.slice(idx + 1);
|
||||
}
|
||||
|
||||
function modelKeysToTry(modelId: string): string[] {
|
||||
const keys = [modelId];
|
||||
const stripped = stripVendorPrefix(modelId);
|
||||
if (stripped) {
|
||||
keys.push(stripped);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function catalogOwnsModel(
|
||||
catalogProvider: CatalogProvider,
|
||||
modelId: string
|
||||
): boolean {
|
||||
for (const key of modelKeysToTry(modelId)) {
|
||||
if (aiModelCatalog.get(catalogProvider, key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function modelKnownInAnyCatalog(modelId: string): boolean {
|
||||
for (const key of modelKeysToTry(modelId)) {
|
||||
if (aiModelCatalog.listByKey(key).length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* How strongly a provider "owns" a requested model id via the known catalog.
|
||||
*
|
||||
* 2 - Typed provider whose catalog contains the model
|
||||
* 1 - Aggregator/custom that can proxy a catalog-known model
|
||||
* 0 - No ownership signal (typed miss, or unknown model on aggregator/custom)
|
||||
*/
|
||||
export function catalogOwnershipScore(
|
||||
type: AiProviderType,
|
||||
modelId: string
|
||||
): number {
|
||||
const catalogProvider = getCatalogProviderForType(type);
|
||||
if (catalogProvider != null) {
|
||||
return catalogOwnsModel(catalogProvider, modelId) ? 2 : 0;
|
||||
}
|
||||
return modelKnownInAnyCatalog(modelId) ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer native vendor providers over aggregators over custom when catalog
|
||||
* ownership is tied.
|
||||
*
|
||||
* 2 - Native typed provider (openai, anthropic, gemini, ...)
|
||||
* 1 - Aggregator gateway (openRouter, vercelAiGateway)
|
||||
* 0 - Custom
|
||||
*/
|
||||
export function providerClassRank(type: AiProviderType): number {
|
||||
if (type === "custom") {
|
||||
return 0;
|
||||
}
|
||||
if (type === "openRouter" || type === "vercelAiGateway") {
|
||||
return 1;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
export function keepBestScored<T>(
|
||||
items: T[],
|
||||
scoreFn: (item: T) => number
|
||||
): T[] {
|
||||
if (items.length <= 1) {
|
||||
return items;
|
||||
}
|
||||
let best = Number.NEGATIVE_INFINITY;
|
||||
for (const item of items) {
|
||||
const score = scoreFn(item);
|
||||
if (score > best) {
|
||||
best = score;
|
||||
}
|
||||
}
|
||||
return items.filter((item) => scoreFn(item) === best);
|
||||
}
|
||||
Reference in New Issue
Block a user