forked from boos/BoosAPI
feat: 实现全局模型白名单功能 (#301)
* feat: 实现全局模型白名单功能 ## 功能概述 实现全局模型白名单,允许管理员配置只启用特定模型的路由,实现精细化的模型访问控制。 ## 主要变更 ### 后端实现 - config.ts: 新增 globalAllowedModels 配置项 - settings.ts: - 新增 RuntimeSettingsBody 类型支持 - 实现数据库加载和持久化 - 配置变更自动触发路由重建 - modelService.ts: - 在路由重建中添加白名单过滤逻辑 - 大小写不敏感匹配,自动trim空格 - 空白名单时允许所有模型(向后兼容) - stats.ts: - 候选API过滤 models/modelsWithoutToken/modelsMissingTokenGroups ### 前端实现 - Settings.tsx: - 新增"全局模型白名单"配置卡片 - 支持手动输入和点击选择两种添加方式 - 绿色徽章显示已选模型,支持删除 - 保存后自动触发路由重建 ## 技术特性 - ✅ 向后兼容(默认为空数组,允许所有模型) - ✅ 大小写不敏感匹配 - ✅ 自动trim和去重 - ✅ 输入验证(类型检查、空值过滤) - ✅ 配置变更自动重建路由 ## 测试验证 - 白名单为空时所有模型可见 - 白名单有值时只显示白名单模型 - 路由重建正确过滤模型 - 候选API正确返回过滤结果 - 前端UI交互正常 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add getModelTokenCandidates mock to Settings tests * fix: add getModelTokenCandidates mock to proxy-transport test --------- Co-authored-by: Ding <ding@local> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,7 @@ interface RuntimeSettingsBody {
|
||||
proxyErrorKeywords?: string[] | string;
|
||||
proxyEmptyContentFailEnabled?: boolean;
|
||||
globalBlockedBrands?: string[];
|
||||
globalAllowedModels?: string[];
|
||||
}
|
||||
|
||||
interface DatabaseMigrationBody {
|
||||
@@ -505,6 +506,29 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'global_allowed_models': {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
if (Array.isArray(parsed)) {
|
||||
const nextModels = parsed.filter((m): m is string => typeof m === 'string').map((m) => m.trim()).filter(Boolean);
|
||||
const prev = JSON.stringify(config.globalAllowedModels);
|
||||
config.globalAllowedModels = nextModels;
|
||||
if (prev !== JSON.stringify(nextModels)) {
|
||||
startBackgroundTask(
|
||||
{
|
||||
type: 'maintenance',
|
||||
title: '模型白名单变更后重建路由',
|
||||
dedupeKey: 'refresh-models-and-rebuild-routes',
|
||||
},
|
||||
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'webhook_url': {
|
||||
if (typeof value !== 'string') return;
|
||||
config.webhookUrl = value.trim();
|
||||
@@ -685,6 +709,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
||||
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
|
||||
proxyTokenMasked: maskSecret(config.proxyToken),
|
||||
globalBlockedBrands: config.globalBlockedBrands,
|
||||
globalAllowedModels: config.globalAllowedModels,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1263,6 +1288,31 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
if (body.globalAllowedModels !== undefined) {
|
||||
if (!Array.isArray(body.globalAllowedModels)) {
|
||||
return reply.code(400).send({ error: 'globalAllowedModels must be an array of strings' });
|
||||
}
|
||||
const nextModels = body.globalAllowedModels.filter((m): m is string => typeof m === 'string').map((m) => m.trim()).filter(Boolean);
|
||||
const uniqueModels = Array.from(new Set(nextModels));
|
||||
const prev = JSON.stringify(config.globalAllowedModels);
|
||||
const next = JSON.stringify(uniqueModels);
|
||||
if (prev !== next) {
|
||||
changedLabels.push('全局模型白名单');
|
||||
}
|
||||
config.globalAllowedModels = uniqueModels;
|
||||
upsertSetting('global_allowed_models', JSON.stringify(uniqueModels));
|
||||
if (prev !== next) {
|
||||
startBackgroundTask(
|
||||
{
|
||||
type: 'maintenance',
|
||||
title: '模型白名单变更后重建路由',
|
||||
dedupeKey: 'refresh-models-and-rebuild-routes',
|
||||
},
|
||||
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (body.webhookUrl !== undefined) {
|
||||
if (String(body.webhookUrl || '').trim() !== config.webhookUrl) {
|
||||
changedLabels.push('Webhook 地址');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { and, desc, eq, gte, lt, sql } from 'drizzle-orm';
|
||||
import { config } from '../../config.js';
|
||||
import { refreshModelsForAccount } from '../../services/modelService.js';
|
||||
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||
import { buildModelAnalysis } from '../../services/modelAnalysisService.js';
|
||||
@@ -1165,6 +1166,11 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
return name;
|
||||
};
|
||||
|
||||
// Load global allowed models whitelist
|
||||
const globalAllowedModels = new Set(
|
||||
config.globalAllowedModels.map((m) => m.toLowerCase().trim()).filter(Boolean),
|
||||
);
|
||||
|
||||
const rows = await db.select().from(schema.tokenModelAvailability)
|
||||
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
|
||||
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
|
||||
@@ -1395,10 +1401,41 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply model whitelist filter if configured
|
||||
const filteredResult: typeof result = {};
|
||||
const filteredModelsWithoutToken: typeof modelsWithoutToken = {};
|
||||
const filteredModelsMissingTokenGroups: typeof modelsMissingTokenGroups = {};
|
||||
|
||||
if (globalAllowedModels.size > 0) {
|
||||
// Filter result
|
||||
for (const [modelName, candidates] of Object.entries(result)) {
|
||||
if (globalAllowedModels.has(modelName.toLowerCase().trim())) {
|
||||
filteredResult[modelName] = candidates;
|
||||
}
|
||||
}
|
||||
// Filter modelsWithoutToken
|
||||
for (const [modelName, accounts] of Object.entries(modelsWithoutToken)) {
|
||||
if (globalAllowedModels.has(modelName.toLowerCase().trim())) {
|
||||
filteredModelsWithoutToken[modelName] = accounts;
|
||||
}
|
||||
}
|
||||
// Filter modelsMissingTokenGroups
|
||||
for (const [modelName, accounts] of Object.entries(modelsMissingTokenGroups)) {
|
||||
if (globalAllowedModels.has(modelName.toLowerCase().trim())) {
|
||||
filteredModelsMissingTokenGroups[modelName] = accounts;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No whitelist configured, return all models (backward compatible)
|
||||
Object.assign(filteredResult, result);
|
||||
Object.assign(filteredModelsWithoutToken, modelsWithoutToken);
|
||||
Object.assign(filteredModelsMissingTokenGroups, modelsMissingTokenGroups);
|
||||
}
|
||||
|
||||
return {
|
||||
models: result,
|
||||
modelsWithoutToken,
|
||||
modelsMissingTokenGroups,
|
||||
models: filteredResult,
|
||||
modelsWithoutToken: filteredModelsWithoutToken,
|
||||
modelsMissingTokenGroups: filteredModelsMissingTokenGroups,
|
||||
endpointTypesByModel,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user