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:
Delicious233
2026-03-30 17:19:02 +08:00
committed by GitHub
parent 01481be511
commit fa25a501a8
11 changed files with 720 additions and 3 deletions
+1
View File
@@ -133,6 +133,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
globalBlockedBrands: [] as string[],
globalAllowedModels: [] as string[],
codexResponsesWebsocketBeta: parseOptionalSecret(env.CODEX_RESPONSES_WEBSOCKET_BETA) || 'responses_websockets=2026-02-06',
codexHeaderDefaults: {
userAgent: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_USER_AGENT),
+50
View File
@@ -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 地址');
+40 -3
View File
@@ -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,
};
});
+13
View File
@@ -956,10 +956,23 @@ export async function rebuildTokenRoutesFromAvailability() {
// Load global brand filter
const blockedBrandRules = getBlockedBrandRules(config.globalBlockedBrands);
// Load global allowed models whitelist
const globalAllowedModels = new Set(
config.globalAllowedModels.map((m) => m.toLowerCase().trim()).filter(Boolean),
);
function isModelAllowedByWhitelist(modelName: string): boolean {
// If whitelist is empty, allow all models (backward compatible)
if (globalAllowedModels.size === 0) return true;
// Check if model is in whitelist (case-insensitive)
return globalAllowedModels.has(modelName.toLowerCase().trim());
}
const modelCandidates = new Map<string, Map<string, { accountId: number; tokenId: number | null }>>();
const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => {
const modelName = (modelNameRaw || '').trim();
if (!modelName) return;
if (!isModelAllowedByWhitelist(modelName)) return;
if (isModelDisabledForSite(siteId, modelName)) return;
if (blockedBrandRules.length > 0 && isModelBlockedByBrand(modelName, blockedBrandRules)) return;
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());