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:
@@ -0,0 +1,253 @@
|
||||
# 模型白名单功能 - PR 准备文档
|
||||
|
||||
## 功能概述
|
||||
|
||||
实现全局模型白名单功能,允许管理员配置只启用特定模型的路由。当白名单配置后,不在白名单中的模型路由会被自动移除,实现精细化的模型访问控制。
|
||||
|
||||
## 业务场景
|
||||
|
||||
- **成本控制**: 只允许使用价格合理的模型
|
||||
- **合规要求**: 限制只使用审批通过的模型
|
||||
- **简化管理**: 隐藏不需要的模型,减少用户困惑
|
||||
- **测试验证**: 在特定场景下只开放测试模型
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 1. 配置层 (src/server/config.ts)
|
||||
|
||||
```typescript
|
||||
globalAllowedModels: [] as string[]
|
||||
```
|
||||
|
||||
- 默认为空数组(向后兼容)
|
||||
- 存储在内存中,启动时从数据库加载
|
||||
|
||||
### 2. 数据持久化 (src/server/routes/api/settings.ts)
|
||||
|
||||
**数据库存储:**
|
||||
- Key: `global_allowed_models`
|
||||
- Value: JSON 字符串数组
|
||||
|
||||
**API 接口:**
|
||||
|
||||
#### GET /api/settings/runtime
|
||||
返回当前白名单配置
|
||||
|
||||
#### PUT /api/settings/runtime
|
||||
```json
|
||||
{
|
||||
"globalAllowedModels": ["gpt-4", "gpt-4o", "claude-3-sonnet"]
|
||||
}
|
||||
```
|
||||
|
||||
**处理流程:**
|
||||
1. 验证输入(必须是字符串数组)
|
||||
2. 去重、trim、过滤空值
|
||||
3. 比较新旧配置
|
||||
4. 更新数据库和内存配置
|
||||
5. 如果配置变更,自动触发路由重建
|
||||
|
||||
### 3. 路由重建 (src/server/services/modelService.ts)
|
||||
|
||||
**核心函数:** `rebuildTokenRoutesFromAvailability()`
|
||||
|
||||
```typescript
|
||||
// 加载白名单
|
||||
const globalAllowedModels = new Set(
|
||||
config.globalAllowedModels.map(m => m.toLowerCase().trim()).filter(Boolean)
|
||||
);
|
||||
|
||||
// 判断模型是否允许
|
||||
function isModelAllowedByWhitelist(modelName: string): boolean {
|
||||
if (globalAllowedModels.size === 0) return true; // 向后兼容
|
||||
return globalAllowedModels.has(modelName.toLowerCase().trim());
|
||||
}
|
||||
|
||||
// 添加模型候选时过滤
|
||||
const addModelCandidate = (modelNameRaw, accountId, tokenId, siteId) => {
|
||||
const modelName = (modelNameRaw || '').trim();
|
||||
if (!modelName) return;
|
||||
if (!isModelAllowedByWhitelist(modelName)) return; // 白名单过滤
|
||||
// ... 其他过滤逻辑
|
||||
};
|
||||
```
|
||||
|
||||
**处理逻辑:**
|
||||
1. 从 `token_model_availability` 加载所有可用模型
|
||||
2. 通过 `addModelCandidate()` 过滤,只保留白名单中的模型
|
||||
3. 创建/更新路由和渠道
|
||||
4. 删除不在白名单中的旧路由
|
||||
|
||||
### 4. 模型候选API (src/server/routes/api/stats.ts)
|
||||
|
||||
**接口:** GET /api/models/token-candidates
|
||||
|
||||
**返回字段:**
|
||||
- `models`: 有token的模型
|
||||
- `modelsWithoutToken`: 无token的模型
|
||||
- `modelsMissingTokenGroups`: 缺少token分组的模型
|
||||
|
||||
**过滤逻辑:**
|
||||
```typescript
|
||||
if (globalAllowedModels.size > 0) {
|
||||
// 白名单模式:只返回白名单中的模型
|
||||
for (const [modelName, candidates] of Object.entries(result)) {
|
||||
if (globalAllowedModels.has(modelName.toLowerCase().trim())) {
|
||||
filteredResult[modelName] = candidates;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 向后兼容:返回所有模型
|
||||
Object.assign(filteredResult, result);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 前端UI (src/web/pages/Settings.tsx)
|
||||
|
||||
**新增卡片:** "全局模型白名单"
|
||||
|
||||
**功能特性:**
|
||||
- 输入框手动添加模型名称(支持回车)
|
||||
- 可用模型列表展示(点击快速添加)
|
||||
- 已选模型徽章显示(绿色徽章)
|
||||
- 点击 × 删除模型
|
||||
- "保存模型白名单"按钮
|
||||
- 保存成功后显示提示并自动刷新页面
|
||||
|
||||
**交互流程:**
|
||||
1. 加载当前白名单配置
|
||||
2. 加载可用模型列表
|
||||
3. 用户添加/删除模型
|
||||
4. 点击保存触发API调用
|
||||
5. 后端自动重建路由
|
||||
6. 前端显示成功提示
|
||||
|
||||
## 数据流程图
|
||||
|
||||
```
|
||||
用户输入 → Settings API
|
||||
↓
|
||||
数据库存储 (global_allowed_models)
|
||||
↓
|
||||
内存配置更新 (config.globalAllowedModels)
|
||||
↓
|
||||
触发路由重建 (异步后台任务)
|
||||
↓
|
||||
加载 token_model_availability
|
||||
↓
|
||||
白名单过滤 (isModelAllowedByWhitelist)
|
||||
↓
|
||||
创建/更新/删除路由
|
||||
↓
|
||||
候选API过滤 (token-candidates)
|
||||
↓
|
||||
前端显示过滤后的模型列表
|
||||
```
|
||||
|
||||
## 向后兼容性
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 白名单为空 | 允许所有模型(原有行为) |
|
||||
| 白名单有值 | 只允许白名单中的模型 |
|
||||
| 数据库无配置 | 默认为空数组 |
|
||||
| API未传该字段 | 不修改现有配置 |
|
||||
|
||||
## 测试验证
|
||||
|
||||
### 功能测试清单
|
||||
|
||||
- [x] 白名单为空时,所有模型可见
|
||||
- [x] 设置白名单后,只显示白名单模型
|
||||
- [x] 大小写不敏感匹配(GPT-4 = gpt-4)
|
||||
- [x] 自动trim空格
|
||||
- [x] 保存后自动触发路由重建
|
||||
- [x] 路由重建成功后,路由正确过滤
|
||||
- [x] 候选API正确过滤三个返回字段
|
||||
- [ ] 前端UI交互正常
|
||||
- [ ] 输入框支持回车添加
|
||||
- [ ] 可用模型列表正确显示
|
||||
- [ ] 点击模型快速添加
|
||||
- [ ] 删除模型功能正常
|
||||
- [ ] 保存成功提示显示
|
||||
|
||||
### API 测试
|
||||
|
||||
```bash
|
||||
# 查看白名单
|
||||
curl -H "Authorization: Bearer test-admin-token" \
|
||||
http://localhost:4000/api/settings/runtime
|
||||
|
||||
# 设置白名单
|
||||
curl -X PUT \
|
||||
-H "Authorization: Bearer test-admin-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"globalAllowedModels": ["gpt-4", "gpt-4o"]}' \
|
||||
http://localhost:4000/api/settings/runtime
|
||||
|
||||
# 查看模型候选
|
||||
curl -H "Authorization: Bearer test-admin-token" \
|
||||
http://localhost:4000/api/models/token-candidates
|
||||
```
|
||||
|
||||
## 文件修改列表
|
||||
|
||||
| 文件 | 修改内容 | 行数 |
|
||||
|------|---------|-----|
|
||||
| src/server/config.ts | 新增 globalAllowedModels 配置 | 1 |
|
||||
| src/server/routes/api/settings.ts | 类型定义、加载、保存、触发重建 | ~80 |
|
||||
| src/server/services/modelService.ts | 白名单过滤逻辑 | ~20 |
|
||||
| src/server/routes/api/stats.ts | 候选API过滤 | ~30 |
|
||||
| src/web/pages/Settings.tsx | 前端UI组件 | ~150 |
|
||||
|
||||
**总计:** 约 280 行代码新增/修改
|
||||
|
||||
## 性能影响
|
||||
|
||||
- **内存:** 新增一个 Set 数据结构,大小为白名单模型数量
|
||||
- **路由重建:** 无额外开销(仅增加一个 O(1) 的 Set 查找)
|
||||
- **候选API:** 增加过滤循环,复杂度 O(n),n为模型数量
|
||||
|
||||
**结论:** 性能影响可忽略不计
|
||||
|
||||
## 安全考虑
|
||||
|
||||
- ✅ 仅管理员可配置(需要 admin token)
|
||||
- ✅ 输入验证(类型检查、trim、去重)
|
||||
- ✅ 无SQL注入风险(使用 ORM)
|
||||
- ✅ 配置变更自动记录日志
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. **批量导入**: 支持从文件批量导入白名单
|
||||
2. **预设模板**: 提供常用模型组合模板
|
||||
3. **分组管理**: 支持多个白名单分组
|
||||
4. **权限控制**: 不同用户看到不同白名单
|
||||
5. **审计日志**: 记录白名单变更历史
|
||||
|
||||
## 已知限制
|
||||
|
||||
1. 白名单只支持精确匹配,不支持通配符或正则
|
||||
2. 模型名称大小写不敏感,但保留原始大小写显示
|
||||
3. 白名单全局生效,不支持按站点或账号单独配置
|
||||
|
||||
## 相关 Issue
|
||||
|
||||
- Issue #XXX: 模型访问控制需求
|
||||
- Issue #XXX: 简化模型列表显示
|
||||
|
||||
## 测试环境
|
||||
|
||||
- Node.js: v20.x
|
||||
- pnpm: 8.x
|
||||
- SQLite: 3.x
|
||||
- 测试服务器: http://localhost:4000
|
||||
- 真实模型数据已验证
|
||||
|
||||
## 下一步计划
|
||||
|
||||
1. ✅ 完成功能开发和测试
|
||||
2. ✅ 编写PR文档
|
||||
3. ⏳ 等待用户前端测试验证
|
||||
4. ⏳ 合并到主仓库
|
||||
5. ⏳ 开始第二个功能:新站点跳转选择
|
||||
@@ -0,0 +1,208 @@
|
||||
# 本地测试环境搭建指南
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
D:\Code\Projects\Metapi\
|
||||
├── metapi # 主仓库(upstream)
|
||||
├── metapi-routing-ux-optimization # 开发 worktree (模型白名单功能)
|
||||
├── metapi-upstream-latest # 其他 worktree
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 快速启动测试服务器
|
||||
|
||||
### 1. 进入开发 worktree
|
||||
|
||||
```bash
|
||||
cd D:/Code/Projects/Metapi/metapi-routing-ux-optimization
|
||||
```
|
||||
|
||||
### 2. 安装依赖(首次运行)
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 3. 构建项目
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 4. 启动测试服务器
|
||||
|
||||
```bash
|
||||
DATA_DIR="./tmp/test-db" node dist/server/index.js
|
||||
```
|
||||
|
||||
服务器将在 http://localhost:4000 启动
|
||||
|
||||
**测试Token:** `test-admin-token`
|
||||
|
||||
### 5. 访问前端
|
||||
|
||||
打开浏览器访问: http://localhost:4000
|
||||
|
||||
登录使用 Token: `test-admin-token`
|
||||
|
||||
## 测试数据准备
|
||||
|
||||
### 方式1: 导入真实站点
|
||||
|
||||
在前端"站点管理"中添加真实的 new-api 站点:
|
||||
- 站点URL: 真实的 new-api 地址
|
||||
- 平台: new-api
|
||||
- 添加账号和token
|
||||
|
||||
然后触发模型发现:
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer test-admin-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"refreshModels": true, "wait": true}' \
|
||||
http://localhost:4000/api/routes/rebuild
|
||||
```
|
||||
|
||||
### 方式2: 使用测试脚本(可选)
|
||||
|
||||
```bash
|
||||
DATA_DIR="./tmp/test-db" node scripts/seed-test-data.js
|
||||
```
|
||||
|
||||
## 测试模型白名单功能
|
||||
|
||||
### 1. 进入设置页面
|
||||
|
||||
点击左侧导航"设置",滚动到"全局模型白名单"卡片
|
||||
|
||||
### 2. 测试功能
|
||||
|
||||
#### 添加模型到白名单
|
||||
- **方式1**: 在输入框输入模型名称,按回车
|
||||
- **方式2**: 点击"可用模型列表"中的模型
|
||||
|
||||
#### 删除模型
|
||||
- 点击已选模型徽章上的 × 按钮
|
||||
|
||||
#### 保存配置
|
||||
- 点击"保存模型白名单"按钮
|
||||
- 系统自动触发路由重建
|
||||
|
||||
### 3. 验证效果
|
||||
|
||||
#### 查看路由
|
||||
进入"令牌路由"页面,确认只有白名单中的模型有路由
|
||||
|
||||
#### 查看模型候选
|
||||
```bash
|
||||
curl -H "Authorization: Bearer test-admin-token" \
|
||||
http://localhost:4000/api/models/token-candidates | python -m json.tool
|
||||
```
|
||||
|
||||
应该只返回白名单中的模型
|
||||
|
||||
### 4. API 测试命令
|
||||
|
||||
#### 查看当前白名单
|
||||
```bash
|
||||
curl -H "Authorization: Bearer test-admin-token" \
|
||||
http://localhost:4000/api/settings/runtime | python -m json.tool | grep -A 5 "globalAllowedModels"
|
||||
```
|
||||
|
||||
#### 设置白名单
|
||||
```bash
|
||||
curl -X PUT \
|
||||
-H "Authorization: Bearer test-admin-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"globalAllowedModels": ["gpt-4", "gpt-4o"]}' \
|
||||
http://localhost:4000/api/settings/runtime | python -m json.tool
|
||||
```
|
||||
|
||||
#### 清空白名单(允许所有模型)
|
||||
```bash
|
||||
curl -X PUT \
|
||||
-H "Authorization: Bearer test-admin-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"globalAllowedModels": []}' \
|
||||
http://localhost:4000/api/settings/runtime | python -m json.tool
|
||||
```
|
||||
|
||||
## 停止测试服务器
|
||||
|
||||
### 查找进程
|
||||
```bash
|
||||
# Windows
|
||||
netstat -ano | grep ":4000" | grep "LISTENING"
|
||||
|
||||
# Linux/Mac
|
||||
lsof -i :4000
|
||||
```
|
||||
|
||||
### 停止进程
|
||||
```bash
|
||||
# Windows (替换 PID)
|
||||
taskkill //F //PID <PID>
|
||||
|
||||
# Linux/Mac
|
||||
kill <PID>
|
||||
```
|
||||
|
||||
## 开发工作流
|
||||
|
||||
### 修改代码后重新构建
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### 提交代码
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: 实现模型白名单功能"
|
||||
git push origin routing-ux-optimization
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 路由列表为空?
|
||||
A: 检查 token_model_availability 表是否有数据,触发模型发现重新加载
|
||||
|
||||
### Q: 白名单保存后路由消失?
|
||||
A: 正常现象,白名单会过滤掉不在列表中的模型路由
|
||||
|
||||
### Q: 如何恢复所有模型?
|
||||
A: 清空白名单(设置为空数组 [])即可
|
||||
|
||||
## 数据库位置
|
||||
|
||||
测试数据库存储在: `./tmp/test-db/hub.db`
|
||||
|
||||
查看数据库:
|
||||
```bash
|
||||
sqlite3 ./tmp/test-db/hub.db
|
||||
```
|
||||
|
||||
常用查询:
|
||||
```sql
|
||||
-- 查看所有站点
|
||||
SELECT id, name, url, platform FROM sites WHERE status = 'active';
|
||||
|
||||
-- 查看所有账号
|
||||
SELECT id, site_id, username, status FROM accounts;
|
||||
|
||||
-- 查看所有token
|
||||
SELECT id, account_id, name, value_status FROM account_tokens;
|
||||
|
||||
-- 查看模型可用性
|
||||
SELECT COUNT(*) FROM token_model_availability;
|
||||
|
||||
-- 查看路由
|
||||
SELECT COUNT(*) FROM token_routes;
|
||||
```
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -62,6 +62,7 @@ type RuntimeSettings = {
|
||||
adminIpAllowlist?: string[];
|
||||
currentAdminIp?: string;
|
||||
globalBlockedBrands?: string[];
|
||||
globalAllowedModels?: string[];
|
||||
};
|
||||
|
||||
type SystemProxyTestState =
|
||||
@@ -220,6 +221,10 @@ export default function Settings() {
|
||||
const [allBrandNames, setAllBrandNames] = useState<string[] | null>(null);
|
||||
const [blockedBrands, setBlockedBrands] = useState<string[]>([]);
|
||||
const [savingBrandFilter, setSavingBrandFilter] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<string[] | null>(null);
|
||||
const [allowedModels, setAllowedModels] = useState<string[]>([]);
|
||||
const [allowedModelsInput, setAllowedModelsInput] = useState('');
|
||||
const [savingAllowedModels, setSavingAllowedModels] = useState(false);
|
||||
const [savingSecurity, setSavingSecurity] = useState(false);
|
||||
const [adminIpAllowlistText, setAdminIpAllowlistText] = useState('');
|
||||
const [clearingCache, setClearingCache] = useState(false);
|
||||
@@ -445,8 +450,10 @@ export default function Settings() {
|
||||
: [],
|
||||
currentAdminIp: typeof runtimeInfo.currentAdminIp === 'string' ? runtimeInfo.currentAdminIp : '',
|
||||
globalBlockedBrands: Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : [],
|
||||
globalAllowedModels: Array.isArray(runtimeInfo.globalAllowedModels) ? runtimeInfo.globalAllowedModels : [],
|
||||
});
|
||||
setBlockedBrands(Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : []);
|
||||
setAllowedModels(Array.isArray(runtimeInfo.globalAllowedModels) ? runtimeInfo.globalAllowedModels : []);
|
||||
setProxyErrorKeywordsText(
|
||||
Array.isArray(runtimeInfo.proxyErrorKeywords)
|
||||
? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n')
|
||||
@@ -493,6 +500,14 @@ export default function Settings() {
|
||||
api.getBrandList()
|
||||
.then((res: any) => setAllBrandNames(Array.isArray(res?.brands) ? res.brands : []))
|
||||
.catch(() => setAllBrandNames([]));
|
||||
// Load available models in background (non-blocking, best-effort)
|
||||
api.getModelTokenCandidates()
|
||||
.then((res: any) => {
|
||||
const models = res?.models || {};
|
||||
const modelNames = Object.keys(models);
|
||||
setAvailableModels(modelNames.sort());
|
||||
})
|
||||
.catch(() => setAvailableModels([]));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -864,6 +879,27 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAllowedModels = async () => {
|
||||
setSavingAllowedModels(true);
|
||||
try {
|
||||
const res = await api.updateRuntimeSettings({ globalAllowedModels: allowedModels });
|
||||
const resolved = Array.isArray(res?.globalAllowedModels) ? res.globalAllowedModels : allowedModels;
|
||||
setRuntime((prev) => ({ ...prev, globalAllowedModels: resolved }));
|
||||
setAllowedModels(resolved);
|
||||
toast.success('模型白名单设置已保存');
|
||||
try {
|
||||
await api.rebuildRoutes(false);
|
||||
toast.success('路由已重建');
|
||||
} catch {
|
||||
toast.error('模型白名单已保存,但路由重建失败,请手动重建');
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '保存模型白名单设置失败');
|
||||
} finally {
|
||||
setSavingAllowedModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSecuritySettings = async () => {
|
||||
setSavingSecurity(true);
|
||||
try {
|
||||
@@ -1589,7 +1625,118 @@ export default function Settings() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Global Allowed Models Whitelist */}
|
||||
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>全局模型白名单</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.6 }}>
|
||||
配置白名单后,路由重建和候选生成将只针对白名单中的模型。留空表示允许所有模型(向后兼容)。保存后自动触发路由重建。
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入模型名称,如:gpt-4"
|
||||
value={allowedModelsInput}
|
||||
onChange={(e) => setAllowedModelsInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && allowedModelsInput.trim()) {
|
||||
const model = allowedModelsInput.trim();
|
||||
if (!allowedModels.includes(model)) {
|
||||
setAllowedModels((prev) => [...prev, model]);
|
||||
}
|
||||
setAllowedModelsInput('');
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1, ...inputStyle }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (allowedModelsInput.trim()) {
|
||||
const model = allowedModelsInput.trim();
|
||||
if (!allowedModels.includes(model)) {
|
||||
setAllowedModels((prev) => [...prev, model]);
|
||||
}
|
||||
setAllowedModelsInput('');
|
||||
}
|
||||
}}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', fontSize: 12, padding: '6px 12px' }}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
{availableModels && availableModels.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||
或从当前可用模型中选择:
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 120, overflowY: 'auto', border: '1px solid var(--color-border)', padding: 8, borderRadius: 4 }}>
|
||||
{availableModels.map((model) => {
|
||||
const isAllowed = allowedModels.includes(model);
|
||||
return (
|
||||
<button
|
||||
key={model}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isAllowed) {
|
||||
setAllowedModels((prev) => prev.filter((m) => m !== model));
|
||||
} else {
|
||||
setAllowedModels((prev) => [...prev, model]);
|
||||
}
|
||||
}}
|
||||
className={`badge ${isAllowed ? 'badge-success' : 'badge-muted'}`}
|
||||
style={{
|
||||
fontSize: 11, cursor: 'pointer', border: 'none', padding: '4px 10px',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{model}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{allowedModels.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||
已选择 {allowedModels.length} 个模型:
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{allowedModels.map((model) => (
|
||||
<div
|
||||
key={model}
|
||||
className="badge badge-success"
|
||||
style={{ fontSize: 11, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
{model}
|
||||
<button
|
||||
onClick={() => setAllowedModels((prev) => prev.filter((m) => m !== model))}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
fontSize: 14,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
title="移除"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={handleSaveAllowedModels} disabled={savingAllowedModels} className="btn btn-primary" style={{ fontSize: 12, padding: '6px 16px' }}>
|
||||
{savingAllowedModels ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存模型白名单'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card animate-slide-up stagger-8" style={{ padding: 20 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>数据库迁移(SQLite / MySQL / PostgreSQL)</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。
|
||||
|
||||
@@ -13,6 +13,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getRuntimeDatabaseConfig: vi.fn(),
|
||||
getBrandList: vi.fn(),
|
||||
factoryReset: vi.fn(),
|
||||
getModelTokenCandidates: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -85,6 +86,7 @@ describe('Settings factory reset', () => {
|
||||
restartRequired: false,
|
||||
});
|
||||
apiMock.factoryReset.mockResolvedValue({ success: true });
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||
|
||||
storage = createStorage({
|
||||
auth_token: 'before-reset-token',
|
||||
|
||||
@@ -13,6 +13,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getRuntimeDatabaseConfig: vi.fn(),
|
||||
getBrandList: vi.fn(),
|
||||
updateRuntimeSettings: vi.fn(),
|
||||
getModelTokenCandidates: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -76,6 +77,7 @@ describe('Settings proxy transport', () => {
|
||||
proxySessionChannelConcurrencyLimit: 6,
|
||||
proxySessionChannelQueueWaitMs: 4200,
|
||||
});
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -15,6 +15,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getBrandList: vi.fn(),
|
||||
updateRuntimeSettings: vi.fn(),
|
||||
triggerCheckinAll: vi.fn(),
|
||||
getModelTokenCandidates: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -70,6 +71,7 @@ describe('Settings log cleanup schedule', () => {
|
||||
restartRequired: false,
|
||||
});
|
||||
apiMock.updateRuntimeSettings.mockResolvedValue({ success: true });
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getBrandList: vi.fn(),
|
||||
updateRuntimeSettings: vi.fn(),
|
||||
testSystemProxy: vi.fn(),
|
||||
getModelTokenCandidates: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -80,6 +81,7 @@ describe('Settings system proxy', () => {
|
||||
statusCode: 204,
|
||||
latencyMs: 321,
|
||||
});
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
Reference in New Issue
Block a user