diff --git a/.github/CI_TRIGGER.md b/.github/CI_TRIGGER.md new file mode 100644 index 000000000..b4fc3199b --- /dev/null +++ b/.github/CI_TRIGGER.md @@ -0,0 +1,3 @@ +# CI Trigger + +This file is used to trigger CI checks. diff --git a/docs/api-documentation.md b/docs/api-documentation.md deleted file mode 100644 index 016d2e176..000000000 --- a/docs/api-documentation.md +++ /dev/null @@ -1,582 +0,0 @@ -# API 文档使用指南 - -## 概述 - -Claude Code Hub 提供了完整的 REST API 接口,支持通过 HTTP 请求进行所有管理操作。所有 API 基于 OpenAPI 3.1.0 规范自动生成,确保文档与实现完全同步。 - -## 文档访问 - -### Scalar UI(推荐) - -访问:`http://localhost:23000/api/actions/scalar` - -**特性**: - -- 🎨 现代化紫色主题 -- 🔍 智能搜索和分类 -- 🧪 交互式 API 测试 -- 📱 响应式布局 -- 💡 清晰的请求/响应示例 - -### Swagger UI(传统) - -访问:`http://localhost:23000/api/actions/docs` - -**特性**: - -- 📚 传统 Swagger 界面 -- 🧪 完整的 Try it out 功能 -- 📄 标准 OpenAPI 格式 -- 🔧 强大的调试工具 - -### OpenAPI 规范 - -访问:`http://localhost:23000/api/actions/openapi.json` - -**用途**: - -- 生成客户端 SDK(TypeScript、Python、Go 等) -- 导入到 Postman、Insomnia 等工具 -- 自动化测试集成 -- API 网关配置 - -## 认证方式 - -所有 API 端点使用 **Cookie 认证**: - -1. 通过 Web UI 登录获取 session cookie -2. 在请求中包含 cookie: - ```bash - curl -X POST http://localhost:23000/api/actions/users/getUsers \ - -H "Cookie: session=your-session-cookie" - ``` - -**在浏览器中获取 Cookie**: - -1. 登录管理后台 -2. 打开浏览器开发者工具(F12) -3. 进入 Application/存储 → Cookies -4. 复制 `session` cookie 的值 - -**在代码中使用**: - -```typescript -// 使用 fetch API -const response = await fetch("/api/actions/users/getUsers", { - method: "POST", - credentials: "include", // 自动包含 cookie -}); - -// 使用 axios -const response = await axios.post( - "/api/actions/users/getUsers", - {}, - { - withCredentials: true, - } -); -``` - -## 权限系统 - -- **管理员**(admin):拥有完整的系统管理权限 -- **普通用户**(user):可查看自己的数据和使用统计 - -标记为 `(管理员)` 的端点需要管理员权限。 - -## API 模块 - -### 1. 用户管理 (5 个端点) - -**基础路径**:`/api/actions/users/` - -- `POST /getUsers` - 获取所有用户列表(管理员) -- `POST /addUser` - 创建新用户(管理员) -- `POST /editUser` - 编辑用户信息(管理员) -- `POST /removeUser` - 删除用户(管理员) -- `POST /getUserLimitUsage` - 获取用户限额使用情况 - -**示例:创建用户** - -```bash -curl -X POST http://localhost:23000/api/actions/users/addUser \ - -H "Content-Type: application/json" \ - -H "Cookie: session=your-session-cookie" \ - -d '{ - "name": "Alice", - "description": "测试用户", - "rpm": 60, - "dailyQuota": 10 - }' -``` - -**响应示例**: - -```json -{ - "ok": true, - "data": { - "id": 1, - "name": "Alice", - "description": "测试用户", - "rpm": 60, - "dailyQuota": 10 - } -} -``` - -### 2. 密钥管理 (5 个端点) - -**基础路径**:`/api/actions/keys/` - -- `POST /getKeys` - 获取用户的密钥列表 -- `POST /addKey` - 创建新密钥 -- `POST /editKey` - 编辑密钥信息 -- `POST /removeKey` - 删除密钥 -- `POST /getKeyLimitUsage` - 获取密钥限额使用情况 - -**示例:创建密钥** - -```bash -curl -X POST http://localhost:23000/api/actions/keys/addKey \ - -H "Content-Type: application/json" \ - -H "Cookie: session=your-session-cookie" \ - -d '{ - "userId": 1, - "name": "Production Key", - "expiresAt": "2025-12-31T23:59:59Z" - }' -``` - -### 3. 供应商管理 (7 个端点) - -**基础路径**:`/api/actions/providers/` - -- `POST /getProviders` - 获取所有供应商列表(管理员) -- `POST /addProvider` - 创建新供应商(管理员) -- `POST /editProvider` - 编辑供应商信息(管理员) -- `POST /removeProvider` - 删除供应商(管理员) -- `POST /getProvidersHealthStatus` - 获取熔断器健康状态 -- `POST /resetProviderCircuit` - 重置熔断器状态(管理员) -- `POST /getProviderLimitUsage` - 获取供应商限额使用情况 - -**示例:添加供应商** - -```bash -curl -X POST http://localhost:23000/api/actions/providers/addProvider \ - -H "Content-Type: application/json" \ - -H "Cookie: session=your-session-cookie" \ - -d '{ - "name": "GLM Provider", - "baseUrl": "https://api.provider.com/v1", - "apiKey": "sk-xxx", - "type": "claude", - "weight": 10, - "priority": 1, - "isEnabled": true - }' -``` - -### 4. 模型价格 (5 个端点) - -**基础路径**:`/api/actions/model-prices/` - -- `POST /getModelPrices` - 获取所有模型价格 -- `POST /getModelPricesPaginated` - 获取模型价格(分页) -- `POST /uploadPriceTable` - 上传价格表(管理员) -- `POST /syncLiteLLMPrices` - 同步 LiteLLM 价格表(管理员) -- `POST /getAvailableModelsByProviderType` - 获取可用模型列表 -- `POST /hasPriceTable` - 检查是否有价格表 - -**示例:分页获取价格** - -```bash -curl -X POST http://localhost:23000/api/actions/model-prices/getModelPricesPaginated \ - -H "Content-Type: application/json" \ - -H "Cookie: session=your-session-cookie" \ - -d '{ - "page": 1, - "pageSize": 50, - "search": "claude" - }' -``` - -**响应示例**: - -```json -{ - "ok": true, - "data": { - "prices": [ - { - "id": 1, - "modelName": "claude-3-5-sonnet-20241022", - "inputPrice": 3, - "outputPrice": 15, - "cacheCreationInputPrice": 3.75, - "cacheReadInputPrice": 0.3, - "createdAt": "2025-01-01T00:00:00Z" - } - ], - "total": 150, - "page": 1, - "pageSize": 50, - "totalPages": 3 - } -} -``` - -### 5. 统计数据 (1 个端点) - -**基础路径**:`/api/actions/statistics/` - -- `POST /getUserStatistics` - 获取用户统计数据 - -### 6. 使用日志 (3 个端点) - -**基础路径**:`/api/actions/usage-logs/` - -- `POST /getUsageLogs` - 获取使用日志 -- `POST /getModelList` - 获取日志中的模型列表 -- `POST /getStatusCodeList` - 获取日志中的状态码列表 - -**示例:获取日志** - -```bash -curl -X POST http://localhost:23000/api/actions/usage-logs/getUsageLogs \ - -H "Content-Type: application/json" \ - -H "Cookie: session=your-session-cookie" \ - -d '{ - "startDate": "2025-01-01", - "endDate": "2025-01-31", - "limit": 100 - }' -``` - -### 7. 概览数据 (1 个端点) - -**基础路径**:`/api/actions/overview/` - -- `POST /getOverviewData` - 获取首页概览数据 - -### 8. 敏感词管理 (6 个端点) - -**基础路径**:`/api/actions/sensitive-words/` - -- `POST /listSensitiveWords` - 获取敏感词列表(管理员) -- `POST /createSensitiveWordAction` - 创建敏感词(管理员) -- `POST /updateSensitiveWordAction` - 更新敏感词(管理员) -- `POST /deleteSensitiveWordAction` - 删除敏感词(管理员) -- `POST /refreshCacheAction` - 手动刷新缓存(管理员) -- `POST /getCacheStats` - 获取缓存统计信息 - -### 9. Session 管理 (3 个端点) - -**基础路径**:`/api/actions/active-sessions/` - -- `POST /getActiveSessions` - 获取活跃 Session 列表 -- `POST /getSessionDetails` - 获取 Session 详情 -- `POST /getSessionMessages` - 获取 Session 的 messages 内容 - -### 10. 通知管理 (3 个端点) - -**基础路径**:`/api/actions/notifications/` - -- `POST /getNotificationSettingsAction` - 获取通知设置(管理员) -- `POST /updateNotificationSettingsAction` - 更新通知设置(管理员) -- `POST /testWebhookAction` - 测试 Webhook 配置(管理员) - -## 响应格式 - -所有 API 响应遵循统一格式: - -### 成功响应 - -```json -{ - "ok": true, - "data": { - // 响应数据 - } -} -``` - -### 失败响应 - -```json -{ - "ok": false, - "error": "错误消息" -} -``` - -### HTTP 状态码 - -- `200`: 操作成功 -- `400`: 请求错误(参数验证失败或业务逻辑错误) -- `401`: 未认证(需要登录) -- `403`: 权限不足 -- `500`: 服务器内部错误 - -## 客户端 SDK 生成 - -使用 OpenAPI 规范自动生成客户端代码: - -### TypeScript - -```bash -npm install -g @openapitools/openapi-generator-cli - -openapi-generator-cli generate \ - -i http://localhost:23000/api/actions/openapi.json \ - -g typescript-fetch \ - -o ./sdk/typescript -``` - -### Python - -```bash -openapi-generator-cli generate \ - -i http://localhost:23000/api/actions/openapi.json \ - -g python \ - -o ./sdk/python -``` - -### Go - -```bash -openapi-generator-cli generate \ - -i http://localhost:23000/api/actions/openapi.json \ - -g go \ - -o ./sdk/go -``` - -### 其他语言 - -支持 30+ 种编程语言,详见 [OpenAPI Generator 文档](https://openapi-generator.tech/docs/generators)。 - -## 工具集成 - -### Postman - -1. 访问 `http://localhost:23000/api/actions/openapi.json` -2. 复制 JSON 内容 -3. 在 Postman 中选择 Import → Raw text -4. 粘贴并导入 - -### Insomnia - -1. 下载 OpenAPI JSON 文件 -2. 在 Insomnia 中选择 Import/Export → Import Data → From File -3. 选择下载的 JSON 文件 - -### VS Code REST Client - -创建 `.http` 文件: - -```http -### 获取用户列表 -POST http://localhost:23000/api/actions/users/getUsers -Content-Type: application/json -Cookie: session=your-session-cookie - -{} - -### 创建用户 -POST http://localhost:23000/api/actions/users/addUser -Content-Type: application/json -Cookie: session=your-session-cookie - -{ - "name": "Bob", - "rpm": 60, - "dailyQuota": 5 -} -``` - -## 错误处理最佳实践 - -```typescript -async function callAPI(endpoint: string, data: any): Promise { - try { - const response = await fetch(`/api/actions/${endpoint}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - credentials: "include", // 自动包含 cookie - }); - - const result = await response.json(); - - if (!result.ok) { - throw new Error(result.error); - } - - return result.data as T; - } catch (error) { - console.error("API 调用失败:", error); - throw error; - } -} - -// 使用示例 -try { - const users = await callAPI("users/getUsers", {}); - console.log("用户列表:", users); -} catch (error) { - alert(`获取用户列表失败: ${error.message}`); -} -``` - -## 性能优化建议 - -### 1. 使用分页接口 - -对于大数据量查询(如价格表、日志),优先使用分页接口: - -```typescript -// ❌ 不推荐:一次性获取所有数据 -const allPrices = await callAPI("model-prices/getModelPrices", {}); - -// ✅ 推荐:分页获取 -const pagedPrices = await callAPI("model-prices/getModelPricesPaginated", { - page: 1, - pageSize: 50, - search: "claude", -}); -``` - -### 2. 缓存响应 - -对于不常变化的数据(如模型价格、供应商列表),可在客户端缓存: - -```typescript -const cache = new Map(); - -async function cachedCallAPI( - endpoint: string, - data: any, - ttl = 60000 // 1分钟 -): Promise { - const cacheKey = `${endpoint}:${JSON.stringify(data)}`; - const cached = cache.get(cacheKey); - - if (cached && Date.now() < cached.expiry) { - return cached.data as T; - } - - const result = await callAPI(endpoint, data); - cache.set(cacheKey, { data: result, expiry: Date.now() + ttl }); - - return result; -} -``` - -### 3. 批量操作 - -尽量使用批量接口减少请求次数(如果可用)。 - -### 4. 并发控制 - -避免同时发送大量请求,可能触发限流: - -```typescript -// ❌ 不推荐:并发 100 个请求 -const promises = userIds.map((id) => callAPI("users/getUserLimitUsage", { userId: id })); -await Promise.all(promises); - -// ✅ 推荐:限制并发数为 5 -async function* chunks(arr: T[], n: number) { - for (let i = 0; i < arr.length; i += n) { - yield arr.slice(i, i + n); - } -} - -for await (const chunk of chunks(userIds, 5)) { - await Promise.all(chunk.map((id) => callAPI("users/getUserLimitUsage", { userId: id }))); -} -``` - -## 常见问题 - -### 如何处理 Cookie 认证? - -在浏览器环境中,使用 `credentials: 'include'`: - -```typescript -fetch("/api/actions/users/getUsers", { - method: "POST", - credentials: "include", // 自动包含 cookie -}); -``` - -在非浏览器环境(如 Node.js),需要手动管理 cookie: - -```typescript -import { CookieJar } from "tough-cookie"; -import fetch from "node-fetch"; - -const jar = new CookieJar(); - -// 登录后保存 cookie -const loginResponse = await fetch("http://localhost:23000/api/auth/login", { - method: "POST", - body: JSON.stringify({ token: "admin-token" }), -}); - -const cookies = loginResponse.headers.raw()["set-cookie"]; -cookies.forEach((cookie) => jar.setCookieSync(cookie, "http://localhost:23000")); - -// 后续请求使用 cookie -const usersResponse = await fetch("http://localhost:23000/api/actions/users/getUsers", { - method: "POST", - headers: { - Cookie: jar.getCookiesSync("http://localhost:23000").join("; "), - }, -}); -``` - -### API 端点返回 401 未认证? - -检查: - -1. 是否已通过 Web UI 登录 -2. Cookie 是否正确传递 -3. Cookie 是否过期(默认 7 天) - -### 如何调试 API 请求? - -1. 在 Scalar/Swagger UI 中直接测试 -2. 使用浏览器开发者工具查看网络请求 -3. 在服务端查看日志:`docker compose logs -f app` - -### 是否支持 API Key 认证(而非 Cookie)? - -当前版本仅支持 Cookie 认证。如需 API Key 认证,可以: - -1. 在 GitHub Issues 提出需求 -2. 自行扩展 `src/app/api/actions/[...route]/route.ts` 添加认证中间件 - -## 技术栈 - -- **Next.js 15** + App Router -- **Hono 4.10.2** + `@hono/zod-openapi` -- **Zod** - Runtime validation -- **OpenAPI 3.1.0** - API 规范 -- **Swagger UI** + **Scalar** - 文档界面 - -## 参考资源 - -- [OpenAPI 3.1.0 规范](https://spec.openapis.org/oas/v3.1.0) -- [Hono 文档](https://hono.dev/) -- [Zod 文档](https://zod.dev/) -- [Swagger UI](https://swagger.io/tools/swagger-ui/) -- [Scalar API Reference](https://github.com/scalar/scalar) -- [OpenAPI Generator](https://openapi-generator.tech/) - -## 反馈与贡献 - -如有问题或建议,请访问: - -- [GitHub Issues](https://github.com/ding113/claude-code-hub/issues) -- [功能建议](https://github.com/ding113/claude-code-hub/issues/new) diff --git a/docs/api-implementation-summary.md b/docs/api-implementation-summary.md deleted file mode 100644 index 2ea0feb84..000000000 --- a/docs/api-implementation-summary.md +++ /dev/null @@ -1,282 +0,0 @@ -# Actions API 自动化实施总结 - -## 🎯 实施完成 - -成功将 49 个 Server Actions 自动暴露为 REST API 端点,并集成了自动文档生成。 - ---- - -## ✅ 已完成的工作 - -### 1. 核心基础设施 ✅ - -**文件**: `src/lib/api/action-adapter-openapi.ts` (300+ 行) - -**功能**: - -- ✅ 通用 `createActionRoute()` 函数 - 将任意 Server Action 转换为 OpenAPI 端点 -- ✅ 自动包装非 ActionResult 格式的返回值 -- ✅ 统一的错误处理和日志记录 -- ✅ 参数验证 (集成 Zod schemas) -- ✅ OpenAPI schema 自动生成 - -**特性**: - -```typescript -// 使用方式 -const { route, handler } = createActionRoute("users", "addUser", userActions.addUser, { - requestSchema: CreateUserSchema, // 复用现有 Zod schema! - description: "创建新用户", - tags: ["用户管理"], -}); - -app.openapi(route, handler); -``` - -### 2. API 路由注册 ✅ - -**文件**: `src/app/api/actions/[...route]/route.ts` (750+ 行) - -**已注册的模块**: - -1. ✅ 用户管理 (5 个端点) -2. ✅ 密钥管理 (5 个端点) -3. ✅ 供应商管理 (7 个端点) -4. ✅ 模型价格 (5 个端点) -5. ✅ 统计数据 (1 个端点) -6. ✅ 使用日志 (3 个端点) -7. ✅ 概览数据 (1 个端点) -8. ✅ 敏感词管理 (6 个端点) -9. ✅ Session 管理 (3 个端点) -10. ✅ 通知管理 (3 个端点) - -**总计**: **39 个端点** (覆盖所有关键 actions) - -### 3. OpenAPI 文档生成 ✅ - -**集成的工具**: - -- ✅ `@hono/zod-openapi` - OpenAPI 3.1.0 规范生成 -- ✅ `@hono/swagger-ui` - Swagger UI 界面 -- ✅ `@scalar/hono-api-reference` - Scalar UI (现代风格) - -**文档端点**: - -- 📄 `GET /api/actions/openapi.json` - OpenAPI 规范 (JSON) -- 📚 `GET /api/actions/docs` - Swagger UI -- 🎨 `GET /api/actions/scalar` - Scalar UI (推荐) -- 🔍 `GET /api/actions/health` - 健康检查 - -### 4. 类型安全 ✅ - -- ✅ 通过 TypeScript 编译 (0 错误) -- ✅ 自动从 Zod schemas 生成 OpenAPI types -- ✅ 参数验证自动化 - ---- - -## 📊 代码减少对比 - -| 方案 | 文件数 | 代码行数 | 维护成本 | -| ----------------------- | ------ | --------- | --------------------------- | -| **手动方案 (PR #33)** | 36 个 | ~1,080 行 | 极高 (每个 action 改 N 次) | -| **Hono OpenAPI (当前)** | 2 个 | ~1,050 行 | 极低 (新增 action 1 行代码) | - -**关键区别**: - -- ❌ 手动方案: 36 个几乎相同的文件,重复代码极多 -- ✅ 自动化方案: 核心逻辑集中,复用现有 schemas,自动生成文档 - ---- - -## 🔧 如何使用 - -### 1. 访问文档 - -**Swagger UI** (传统风格): - -``` -http://localhost:13500/api/actions/docs -``` - -**Scalar UI** (现代风格,推荐): - -``` -http://localhost:13500/api/actions/scalar -``` - -**OpenAPI JSON**: - -``` -http://localhost:13500/api/actions/openapi.json -``` - -### 2. 调用 API - -**端点格式**: - -``` -POST /api/actions/{module}/{actionName} -``` - -**示例**: - -```bash -curl -X POST http://localhost:13500/api/actions/users/addUser \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Alice", - "rpm": 60, - "dailyQuota": 10 - }' -``` - -**响应格式**: - -```json -{ - "ok": true, - "data": { ... } -} -``` - -### 3. 新增 Action 端点 - -只需在 `route.ts` 中添加 3 行代码: - -```typescript -const { route, handler } = createActionRoute( - "module-name", - "actionName", - moduleActions.actionName, - { - requestSchema: YourZodSchema, // 可选 - description: "端点描述", - tags: ["标签"], - } -); -app.openapi(route, handler); -``` - -**文档自动更新** - 无需手动维护! - ---- - -## ⚠️ 注意事项 - -### 1. 认证保护 - -当前文档端点被应用的认证中间件保护。 - -**建议**: 将文档端点设为公开访问 (或仅在开发环境开放) - -**方法**: 在应用的 middleware 或 auth 配置中添加豁免路径: - -```typescript -// 豁免 API 文档路径 -const publicPaths = [ - "/api/actions/openapi.json", - "/api/actions/docs", - "/api/actions/scalar", - "/api/actions/health", -]; -``` - -### 2. 请求验证 - -所有请求体会通过 Zod schema 自动验证。验证失败返回 400 错误。 - -### 3. 兼容性 - -- ✅ 支持返回 `ActionResult` 的标准 actions -- ✅ 支持直接返回数据的旧式 actions (自动包装) - ---- - -## 📈 性能影响 - -- **编译时间**: 增加 ~0.5 秒 (OpenAPI schema 生成) -- **运行时开销**: 几乎为 0 (Hono 非常快) -- **内存占用**: 增加 ~5 MB (文档数据) - ---- - -## 🚀 下一步工作 - -### 立即可做 - -1. ✅ **配置认证豁免** - 允许公开访问文档 -2. ⏳ **测试所有端点** - 确保所有 actions 正常工作 -3. ⏳ **前端集成** - 创建类型安全的客户端封装 - -### 未来增强 - -4. ⏳ **添加示例代码** - 在文档中展示多语言调用示例 -5. ⏳ **添加 Rate Limiting** - API 级别的限流保护 -6. ⏳ **添加 API Key 认证** - 支持外部系统调用 -7. ⏳ **添加 Webhook** - 事件通知机制 -8. ⏳ **添加 OpenAPI Client 生成** - 自动生成前端 SDK - ---- - -## 📝 技术栈 - -- **Next.js 15** + App Router -- **Hono 4.10.2** + `@hono/zod-openapi` -- **Zod** - Runtime validation -- **OpenAPI 3.1.0** - API 规范 -- **Swagger UI** + **Scalar** - 文档界面 - ---- - -## 🎉 成果总结 - -### 数字对比 - -| 指标 | 手动方案 | 自动化方案 | 改进 | -| ---------------- | --------- | ---------- | -------- | -| 代码行数 | ~1,080 | ~1,050 | **持平** | -| 文件数量 | 36 | 2 | **-94%** | -| 新增 action 成本 | ~30 行/个 | 3 行/个 | **-90%** | -| 文档维护 | 手动 | 自动 | **100%** | -| 类型安全 | 部分 | 完整 | **100%** | - -### 质量提升 - -- ✅ **自动文档生成** - Swagger + Scalar 双界面 -- ✅ **类型安全** - TypeScript + Zod + OpenAPI -- ✅ **统一错误处理** - 标准化的错误响应 -- ✅ **日志追踪** - 完整的请求日志 -- ✅ **参数验证** - 自动化的 schema 验证 -- ✅ **可扩展性** - 新增 action 只需 3 行代码 - ---- - -## 📚 相关文件 - -### 核心文件 - -- `src/lib/api/action-adapter-openapi.ts` - 核心 adapter -- `src/app/api/actions/[...route]/route.ts` - 路由注册 -- `src/lib/validation/schemas.ts` - Zod schemas (已存在) - -### 文档文件 - -- `docs/api-implementation-summary.md` - 本文档 -- `src/app/api/actions/[...route]/route.ts` (L630-706) - OpenAPI 配置 - ---- - -## 🔗 有用的链接 - -- [Hono Documentation](https://hono.dev/) -- [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi) -- [OpenAPI 3.1.0 Specification](https://spec.openapis.org/oas/v3.1.0) -- [Swagger UI](https://swagger.io/tools/swagger-ui/) -- [Scalar API Reference](https://github.com/scalar/scalar) - ---- - -**实施完成时间**: 2025-11-01 -**实施人**: Claude Code -**版本**: 1.0.0 diff --git a/drizzle/0016_curious_paladin.sql b/drizzle/0016_curious_paladin.sql new file mode 100644 index 000000000..18317f8cf --- /dev/null +++ b/drizzle/0016_curious_paladin.sql @@ -0,0 +1,2 @@ +ALTER TABLE "providers" ADD COLUMN "website_url" text;--> statement-breakpoint +ALTER TABLE "providers" ADD COLUMN "favicon_url" text; \ No newline at end of file diff --git a/drizzle/meta/0016_snapshot.json b/drizzle/meta/0016_snapshot.json new file mode 100644 index 000000000..100dd6616 --- /dev/null +++ b/drizzle/meta/0016_snapshot.json @@ -0,0 +1,1306 @@ +{ + "id": "737141af-297c-4dc8-af99-55bd4ca86035", + "prevId": "d466c36c-deaa-487f-a5ec-46a804d72dde", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false, + "default": "'100.00'" + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8f0a36ecb..6db1e8ce1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1762333515926, "tag": "0015_narrow_gunslinger", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1762449880213, + "tag": "0016_curious_paladin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/actions/keys.ts b/src/actions/keys.ts index b8e6f4da6..9053ff162 100644 --- a/src/actions/keys.ts +++ b/src/actions/keys.ts @@ -57,12 +57,16 @@ export async function addKey(data: { const generatedKey = "sk-" + randomBytes(16).toString("hex"); + // 转换 expiresAt: undefined → null(永不过期),string → Date(设置日期) + const expiresAt = + validatedData.expiresAt === undefined ? null : new Date(validatedData.expiresAt); + await createKey({ user_id: data.userId, name: validatedData.name, key: generatedKey, is_enabled: true, - expires_at: validatedData.expiresAt ? new Date(validatedData.expiresAt) : undefined, + expires_at: expiresAt, can_login_web_ui: validatedData.canLoginWebUi, limit_5h_usd: validatedData.limit5hUsd, limit_weekly_usd: validatedData.limitWeeklyUsd, @@ -112,9 +116,13 @@ export async function editKey( const validatedData = KeyFormSchema.parse(data); + // 转换 expiresAt: undefined → null(清除日期),string → Date(设置日期) + const expiresAt = + validatedData.expiresAt === undefined ? null : new Date(validatedData.expiresAt); + await updateKey(keyId, { name: validatedData.name, - expires_at: validatedData.expiresAt ? new Date(validatedData.expiresAt) : undefined, + expires_at: expiresAt, can_login_web_ui: validatedData.canLoginWebUi, limit_5h_usd: validatedData.limit5hUsd, limit_weekly_usd: validatedData.limitWeeklyUsd, diff --git a/src/actions/overview.ts b/src/actions/overview.ts index 491276921..73a0b753d 100644 --- a/src/actions/overview.ts +++ b/src/actions/overview.ts @@ -3,6 +3,8 @@ import { getOverviewMetrics as getOverviewMetricsFromDB } from "@/repository/overview"; import { getConcurrentSessions as getConcurrentSessionsCount } from "./concurrent-sessions"; import { getActiveSessions as getActiveSessionsFromManager } from "./active-sessions"; +import { getSession } from "@/lib/auth"; +import { getSystemSettings } from "@/repository/system-config"; import { logger } from "@/lib/logger"; import type { ActionResult } from "./types"; import type { ActiveSessionInfo } from "@/types/session"; @@ -25,9 +27,23 @@ export interface OverviewData { /** * 获取概览数据(首页实时面板使用) + * ✅ 权限控制:管理员或 allowGlobalUsageView=true 时显示全站数据 */ export async function getOverviewData(): Promise> { try { + // 获取用户 session 和系统设置 + const session = await getSession(); + if (!session) { + return { + ok: false, + error: "未登录", + }; + } + + const settings = await getSystemSettings(); + const isAdmin = session.user.role === "admin"; + const canViewGlobalData = isAdmin || settings.allowGlobalUsageView; + // 并行查询所有数据 const [concurrentResult, metricsData, sessionsResult] = await Promise.all([ getConcurrentSessionsCount(), @@ -35,12 +51,40 @@ export async function getOverviewData(): Promise> { getActiveSessionsFromManager(), ]); - // 处理并发数(失败时返回0) - const concurrentSessions = concurrentResult.ok ? concurrentResult.data : 0; + // 根据权限决定显示范围 + if (!canViewGlobalData) { + // 普通用户且无权限:仅显示自己的活跃 Session,全站指标设为 0 + const recentSessions = sessionsResult.ok ? sessionsResult.data.slice(0, 10) : []; + + logger.debug("Overview: User without global view permission", { + userId: session.user.id, + userName: session.user.name, + ownSessionsCount: recentSessions.length, + }); - // 处理Session列表(失败时返回空数组,最多取10个) + return { + ok: true, + data: { + concurrentSessions: 0, // 无权限时不显示全站并发数 + todayRequests: 0, // 无权限时不显示全站请求数 + todayCost: 0, // 无权限时不显示全站消耗 + avgResponseTime: 0, // 无权限时不显示全站平均响应时间 + recentSessions, // 仅显示自己的活跃 Session(getActiveSessions 已做权限过滤) + }, + }; + } + + // 管理员或有权限:显示全站数据 + const concurrentSessions = concurrentResult.ok ? concurrentResult.data : 0; const recentSessions = sessionsResult.ok ? sessionsResult.data.slice(0, 10) : []; + logger.debug("Overview: User with global view permission", { + userId: session.user.id, + userName: session.user.name, + isAdmin, + allowGlobalUsageView: settings.allowGlobalUsageView, + }); + return { ok: true, data: { diff --git a/src/actions/providers.ts b/src/actions/providers.ts index db3ce8f8c..b96df485e 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -130,6 +130,8 @@ export async function getProviders(): Promise { circuitBreakerHalfOpenSuccessThreshold: provider.circuitBreakerHalfOpenSuccessThreshold, proxyUrl: provider.proxyUrl, proxyFallbackToDirect: provider.proxyFallbackToDirect, + websiteUrl: provider.websiteUrl, + faviconUrl: provider.faviconUrl, tpm: provider.tpm, rpm: provider.rpm, rpd: provider.rpd, @@ -179,6 +181,7 @@ export async function addProvider(data: { circuit_breaker_half_open_success_threshold?: number; proxy_url?: string | null; proxy_fallback_to_direct?: boolean; + website_url?: string | null; codex_instructions_strategy?: "auto" | "force_official" | "keep_original"; tpm: number | null; rpm: number | null; @@ -209,6 +212,23 @@ export async function addProvider(data: { const validated = CreateProviderSchema.parse(data); logger.trace("addProvider:validated", { name: validated.name }); + // 获取 favicon URL + let faviconUrl: string | null = null; + if (validated.website_url) { + try { + const url = new URL(validated.website_url); + const domain = url.hostname; + faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; + logger.trace("addProvider:favicon_generated", { domain, faviconUrl }); + } catch (error) { + logger.warn("addProvider:favicon_fetch_failed", { + websiteUrl: validated.website_url, + error: error instanceof Error ? error.message : String(error), + }); + // Favicon 获取失败不影响主流程 + } + } + const payload = { ...validated, limit_5h_usd: validated.limit_5h_usd ?? null, @@ -221,6 +241,8 @@ export async function addProvider(data: { validated.circuit_breaker_half_open_success_threshold ?? 2, proxy_url: validated.proxy_url ?? null, proxy_fallback_to_direct: validated.proxy_fallback_to_direct ?? false, + website_url: validated.website_url ?? null, + favicon_url: faviconUrl, tpm: validated.tpm ?? null, rpm: validated.rpm ?? null, rpd: validated.rpd ?? null, @@ -286,6 +308,7 @@ export async function editProvider( circuit_breaker_half_open_success_threshold?: number; proxy_url?: string | null; proxy_fallback_to_direct?: boolean; + website_url?: string | null; codex_instructions_strategy?: "auto" | "force_official" | "keep_original"; tpm?: number | null; rpm?: number | null; @@ -308,7 +331,34 @@ export async function editProvider( } const validated = UpdateProviderSchema.parse(data); - const provider = await updateProvider(providerId, validated); + + // 如果 website_url 被更新,重新生成 favicon URL + let faviconUrl: string | null | undefined = undefined; // undefined 表示不更新 + if (validated.website_url !== undefined) { + if (validated.website_url) { + try { + const url = new URL(validated.website_url); + const domain = url.hostname; + faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; + logger.trace("editProvider:favicon_generated", { domain, faviconUrl }); + } catch (error) { + logger.warn("editProvider:favicon_fetch_failed", { + websiteUrl: validated.website_url, + error: error instanceof Error ? error.message : String(error), + }); + faviconUrl = null; + } + } else { + faviconUrl = null; // website_url 被清空时也清空 favicon + } + } + + const payload = { + ...validated, + ...(faviconUrl !== undefined && { favicon_url: faviconUrl }), + }; + + const provider = await updateProvider(providerId, payload); if (!provider) { return { ok: false, error: "供应商不存在" }; @@ -658,3 +708,36 @@ export async function testProviderProxy(data: { return { ok: false, error: message }; } } + +/** + * 获取供应商的未脱敏密钥(仅管理员) + * 用于安全展示和复制完整 API Key + */ +export async function getUnmaskedProviderKey(id: number): Promise> { + "use server"; + + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "权限不足:仅管理员可查看完整密钥" }; + } + + const provider = await findProviderById(id); + if (!provider) { + return { ok: false, error: "供应商不存在" }; + } + + // 记录查看行为(不记录密钥内容) + logger.info("Admin viewed provider key", { + userId: session.user.id, + providerId: id, + providerName: provider.name, + }); + + return { ok: true, data: { key: provider.key } }; + } catch (error) { + logger.error("获取供应商密钥失败:", error); + const message = error instanceof Error ? error.message : "获取供应商密钥失败"; + return { ok: false, error: message }; + } +} diff --git a/src/actions/users.ts b/src/actions/users.ts index 03aef67d7..bc3051cb5 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -179,19 +179,49 @@ export async function editUser( ): Promise { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { - return { ok: false, error: "无权限执行此操作" }; + if (!session) { + return { ok: false, error: "未登录" }; } - const validatedData = UpdateUserSchema.parse(data); + // 定义敏感字段列表(仅管理员可修改) + const sensitiveFields = ["rpm", "dailyQuota", "providerGroup"] as const; + const hasSensitiveFields = sensitiveFields.some((field) => data[field] !== undefined); - await updateUser(userId, { - name: validatedData.name, - description: validatedData.note, - providerGroup: validatedData.providerGroup, - rpm: validatedData.rpm, - dailyQuota: validatedData.dailyQuota, - }); + // 权限检查:区分三种情况 + if (session.user.role === "admin") { + // 管理员可以修改所有用户的所有字段 + const validatedData = UpdateUserSchema.parse(data); + + await updateUser(userId, { + name: validatedData.name, + description: validatedData.note, + providerGroup: validatedData.providerGroup, + rpm: validatedData.rpm, + dailyQuota: validatedData.dailyQuota, + }); + } else if (session.user.id === userId) { + // 普通用户修改自己的信息 + if (hasSensitiveFields) { + return { + ok: false, + error: "普通用户不能修改账户限额和供应商分组", + }; + } + + // 仅允许修改非敏感字段(name, description) + const validatedData = UpdateUserSchema.parse({ + name: data.name, + note: data.note, + }); + + await updateUser(userId, { + name: validatedData.name, + description: validatedData.note, + }); + } else { + // 普通用户尝试修改他人信息 + return { ok: false, error: "无权限执行此操作" }; + } revalidatePath("/dashboard"); return { ok: true }; diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index ca490fc50..fb541a273 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -1,25 +1,55 @@ import { NextRequest, NextResponse } from "next/server"; import { logger } from "@/lib/logger"; import { getLeaderboardWithCache } from "@/lib/redis"; +import type { LeaderboardScope } from "@/lib/redis/leaderboard-cache"; import { getSystemSettings } from "@/repository/system-config"; import { formatCurrency } from "@/lib/utils"; +import { getSession } from "@/lib/auth"; // 需要数据库连接 export const runtime = "nodejs"; /** * 获取排行榜数据 - * GET /api/leaderboard?period=daily|monthly + * GET /api/leaderboard?period=daily|monthly&scope=user|provider * - * 无需认证,公开访问 + * 需要认证,普通用户需要 allowGlobalUsageView 权限 * 实时计算 + Redis 乐观缓存(60 秒 TTL) */ export async function GET(request: NextRequest) { try { + // 获取用户 session + const session = await getSession(); + if (!session) { + logger.warn("Leaderboard API: Unauthorized access attempt"); + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + // 获取系统配置 + const systemSettings = await getSystemSettings(); + + // 检查权限:管理员或开启了全站使用量查看权限 + const isAdmin = session.user.role === "admin"; + const hasPermission = isAdmin || systemSettings.allowGlobalUsageView; + + if (!hasPermission) { + logger.warn("Leaderboard API: Access denied", { + userId: session.user.id, + userName: session.user.name, + isAdmin, + allowGlobalUsageView: systemSettings.allowGlobalUsageView, + }); + return NextResponse.json( + { error: "无权限访问排行榜,请联系管理员开启全站使用量查看权限" }, + { status: 403 } + ); + } + + // 验证参数 const searchParams = request.nextUrl.searchParams; const period = searchParams.get("period") || "daily"; + const scope = (searchParams.get("scope") as LeaderboardScope) || "user"; // 向后兼容:默认 user - // 验证参数 if (period !== "daily" && period !== "monthly") { return NextResponse.json( { error: "参数 period 必须是 'daily' 或 'monthly'" }, @@ -27,11 +57,20 @@ export async function GET(request: NextRequest) { ); } - // 获取系统配置(货币显示单位) - const systemSettings = await getSystemSettings(); + if (scope !== "user" && scope !== "provider") { + return NextResponse.json( + { error: "参数 scope 必须是 'user' 或 'provider'" }, + { status: 400 } + ); + } + + // 供应商榜仅管理员可见 + if (scope === "provider" && !isAdmin) { + return NextResponse.json({ error: "仅管理员可查看供应商排行榜" }, { status: 403 }); + } // 使用 Redis 乐观缓存获取数据 - const rawData = await getLeaderboardWithCache(period, systemSettings.currencyDisplay); + const rawData = await getLeaderboardWithCache(period, systemSettings.currencyDisplay, scope); // 格式化金额字段 const data = rawData.map((entry) => ({ @@ -39,6 +78,15 @@ export async function GET(request: NextRequest) { totalCostFormatted: formatCurrency(entry.totalCost, systemSettings.currencyDisplay), })); + logger.info("Leaderboard API: Access granted", { + userId: session.user.id, + userName: session.user.name, + isAdmin: session.user.role === "admin", + period, + scope, + entriesCount: data.length, + }); + return NextResponse.json(data, { headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=120", diff --git a/src/app/dashboard/leaderboard/_components/leaderboard-table.tsx b/src/app/dashboard/leaderboard/_components/leaderboard-table.tsx index bc66f60e3..9bb06ac6a 100644 --- a/src/app/dashboard/leaderboard/_components/leaderboard-table.tsx +++ b/src/app/dashboard/leaderboard/_components/leaderboard-table.tsx @@ -11,15 +11,27 @@ import { import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Trophy, Medal, Award } from "lucide-react"; -import type { LeaderboardEntry } from "@/repository/leaderboard"; -import { formatTokenAmount } from "@/lib/utils"; -interface LeaderboardTableProps { - data: LeaderboardEntry[]; +// 支持动态列定义 +export interface ColumnDef { + header: string; + className?: string; + cell: (row: T, index: number) => React.ReactNode; +} + +interface LeaderboardTableProps { + data: T[]; period: "daily" | "monthly"; + columns: ColumnDef[]; // 不包含“排名”列,组件会自动添加 + getRowKey?: (row: T, index: number) => string | number; } -export function LeaderboardTable({ data, period }: LeaderboardTableProps) { +export function LeaderboardTable({ + data, + period, + columns, + getRowKey, +}: LeaderboardTableProps) { if (data.length === 0) { return ( @@ -73,34 +85,29 @@ export function LeaderboardTable({ data, period }: LeaderboardTableProps) { 排名 - 用户 - 请求数 - Token 数 - 消耗金额 + {columns.map((col, idx) => ( + + {col.header} + + ))} - {data.map((entry, index) => { + {data.map((row, index) => { const rank = index + 1; const isTopThree = rank <= 3; return ( - + {getRankBadge(rank)} - - {entry.userName} - - - {entry.totalRequests.toLocaleString()} - - - {formatTokenAmount(entry.totalTokens)} - - - {"totalCostFormatted" in entry - ? (entry as { totalCostFormatted: string }).totalCostFormatted - : entry.totalCost} - + {columns.map((col, idx) => ( + + {col.cell(row, index)} + + ))} ); })} diff --git a/src/app/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/dashboard/leaderboard/_components/leaderboard-view.tsx index 9aa168be7..13fb4dbfd 100644 --- a/src/app/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -1,24 +1,35 @@ "use client"; import { useState, useEffect } from "react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent } from "@/components/ui/card"; -import { LeaderboardTable } from "./leaderboard-table"; -import type { LeaderboardEntry } from "@/repository/leaderboard"; +import { LeaderboardTable, type ColumnDef } from "./leaderboard-table"; +import type { LeaderboardEntry, ProviderLeaderboardEntry } from "@/repository/leaderboard"; +import { formatTokenAmount } from "@/lib/utils"; -export function LeaderboardView() { - const [dailyData, setDailyData] = useState([]); - const [monthlyData, setMonthlyData] = useState([]); +interface LeaderboardViewProps { + isAdmin: boolean; +} + +type UserEntry = LeaderboardEntry & { totalCostFormatted?: string }; +type ProviderEntry = ProviderLeaderboardEntry & { totalCostFormatted?: string }; + +export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { + const [scope, setScope] = useState<"user" | "provider">("user"); + const [period, setPeriod] = useState<"daily" | "monthly">("daily"); + const [dailyData, setDailyData] = useState>([]); + const [monthlyData, setMonthlyData] = useState>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { + let cancelled = false; const fetchData = async () => { try { setLoading(true); const [dailyRes, monthlyRes] = await Promise.all([ - fetch("/api/leaderboard?period=daily"), - fetch("/api/leaderboard?period=monthly"), + fetch(`/api/leaderboard?period=daily&scope=${scope}`), + fetch(`/api/leaderboard?period=monthly&scope=${scope}`), ]); if (!dailyRes.ok || !monthlyRes.ok) { @@ -27,19 +38,24 @@ export function LeaderboardView() { const [daily, monthly] = await Promise.all([dailyRes.json(), monthlyRes.json()]); - setDailyData(daily); - setMonthlyData(monthly); - setError(null); + if (!cancelled) { + setDailyData(daily); + setMonthlyData(monthly); + setError(null); + } } catch (err) { console.error("获取排行榜数据失败:", err); - setError(err instanceof Error ? err.message : "获取排行榜数据失败"); + if (!cancelled) setError(err instanceof Error ? err.message : "获取排行榜数据失败"); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }; fetchData(); - }, []); + return () => { + cancelled = true; + }; + }, [scope]); if (loading) { return ( @@ -61,20 +77,107 @@ export function LeaderboardView() { ); } + // 列定义(根据 scope 动态切换) + const userColumns: ColumnDef[] = [ + { + header: "用户", + cell: (row, index) => ( + {(row as UserEntry).userName} + ), + }, + { + header: "请求数", + className: "text-right", + cell: (row) => (row as UserEntry).totalRequests.toLocaleString(), + }, + { + header: "Token 数", + className: "text-right", + cell: (row) => formatTokenAmount((row as UserEntry).totalTokens), + }, + { + header: "消耗金额", + className: "text-right font-mono font-semibold", + cell: (row) => { + const r = row as UserEntry & { totalCostFormatted?: string }; + return r.totalCostFormatted ?? r.totalCost; + }, + }, + ]; + + const providerColumns: ColumnDef[] = [ + { + header: "供应商", + cell: (row, index) => ( + + {(row as ProviderEntry).providerName} + + ), + }, + { + header: "请求数", + className: "text-right", + cell: (row) => (row as ProviderEntry).totalRequests.toLocaleString(), + }, + { + header: "成本", + className: "text-right font-mono font-semibold", + cell: (row) => { + const r = row as ProviderEntry & { totalCostFormatted?: string }; + return r.totalCostFormatted ?? r.totalCost; + }, + }, + { + header: "Token 数", + className: "text-right", + cell: (row) => formatTokenAmount((row as ProviderEntry).totalTokens), + }, + { + header: "成功率", + className: "text-right", + cell: (row) => `${(((row as ProviderEntry).successRate || 0) * 100).toFixed(1)}%`, + }, + { + header: "平均响应时间", + className: "text-right", + cell: (row) => + `${Math.round((row as ProviderEntry).avgResponseTime || 0).toLocaleString()} ms`, + }, + ]; + + const columns = ( + scope === "user" + ? (userColumns as ColumnDef[]) + : (providerColumns as ColumnDef[]) + ) as ColumnDef[]; + const rowKey = (row: UserEntry | ProviderEntry) => + scope === "user" ? (row as UserEntry).userId : (row as ProviderEntry).providerId; + + const displayData = period === "daily" ? dailyData : monthlyData; + return ( - - - 今日排行 - 本月排行 - - - - - - - - - - +
+ {/* 单行双 toggle:scope + period */} +
+ setScope(v as typeof scope)}> + + 用户排行 + {isAdmin && 供应商排行} + + + + setPeriod(v as typeof period)}> + + 今日排行 + 本月排行 + + +
+ + {/* 数据表格 */} +
+ +
+
); } diff --git a/src/app/dashboard/leaderboard/page.tsx b/src/app/dashboard/leaderboard/page.tsx index 840a1f8bf..8abd1a60b 100644 --- a/src/app/dashboard/leaderboard/page.tsx +++ b/src/app/dashboard/leaderboard/page.tsx @@ -1,13 +1,65 @@ import { Section } from "@/components/section"; import { LeaderboardView } from "./_components/leaderboard-view"; +import { getSession } from "@/lib/auth"; +import { getSystemSettings } from "@/repository/system-config"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { AlertCircle } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import Link from "next/link"; export const dynamic = "force-dynamic"; export default async function LeaderboardPage() { + // 获取用户 session 和系统设置 + const session = await getSession(); + const systemSettings = await getSystemSettings(); + + // 检查权限 + const isAdmin = session?.user.role === "admin"; + const hasPermission = isAdmin || systemSettings.allowGlobalUsageView; + + // 无权限时显示友好提示 + if (!hasPermission) { + return ( +
+
+ + + + + 需要权限 + + + + + + 访问受限 + + 排行榜功能需要管理员开启 "允许查看全站使用量" 权限。 + {isAdmin && ( + + 请前往{" "} + + 系统设置 + {" "} + 开启此权限。 + + )} + {!isAdmin && 请联系管理员开启此权限。} + + + + +
+
+ ); + } + + // 有权限时渲染排行榜 return (
- +
); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 5a1193082..cc3f5176c 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -27,9 +27,12 @@ export default async function DashboardPage() { getSystemSettings(), ]); + // 检查是否是 admin 用户 + const isAdmin = session?.user?.role === "admin"; + return (
- +
(null); + // 获取初始数据源:编辑模式用 provider,创建模式用 cloneProvider(如果有) const sourceProvider = isEdit ? provider : cloneProvider; @@ -107,10 +112,77 @@ export function ProviderForm({ sourceProvider?.proxyFallbackToDirect ?? false ); + // 供应商官网地址 + const [websiteUrl, setWebsiteUrl] = useState(sourceProvider?.websiteUrl ?? ""); + // Codex Instructions 策略配置 const [codexInstructionsStrategy, setCodexInstructionsStrategy] = useState(sourceProvider?.codexInstructionsStrategy ?? "auto"); + // 折叠区域状态管理 + type SectionKey = "routing" | "rateLimit" | "circuitBreaker" | "proxy" | "codexStrategy"; + const [openSections, setOpenSections] = useState>({ + routing: false, + rateLimit: false, + circuitBreaker: false, + proxy: false, + codexStrategy: false, + }); + + // 从 localStorage 加载折叠偏好 + useEffect(() => { + const saved = localStorage.getItem("provider-form-sections"); + if (saved) { + try { + const parsed = JSON.parse(saved); + setOpenSections(parsed); + } catch (e) { + console.error("Failed to parse saved sections state:", e); + } + } + }, []); + + // 保存折叠状态到 localStorage + useEffect(() => { + localStorage.setItem("provider-form-sections", JSON.stringify(openSections)); + }, [openSections]); + + // 自动聚焦名称输入框 + useEffect(() => { + // 延迟聚焦,确保 Dialog 动画完成 + const timer = setTimeout(() => { + nameInputRef.current?.focus(); + }, 100); + return () => clearTimeout(timer); + }, []); + + // 折叠区域切换函数 + const toggleSection = (key: SectionKey) => { + setOpenSections((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + // 展开全部高级配置 + const expandAll = () => { + setOpenSections({ + routing: true, + rateLimit: true, + circuitBreaker: true, + proxy: true, + codexStrategy: true, + }); + }; + + // 折叠全部高级配置 + const collapseAll = () => { + setOpenSections({ + routing: false, + rateLimit: false, + circuitBreaker: false, + proxy: false, + codexStrategy: false, + }); + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -119,7 +191,13 @@ export function ProviderForm({ } if (!isValidUrl(url.trim())) { - toast.error("请输入有效的URL地址"); + toast.error("请输入有效的 API 地址"); + return; + } + + // 验证 websiteUrl(可选,但如果填写了必须是有效 URL) + if (websiteUrl.trim() && !isValidUrl(websiteUrl.trim())) { + toast.error("请输入有效的供应商官网地址"); return; } @@ -150,6 +228,7 @@ export function ProviderForm({ circuit_breaker_half_open_success_threshold?: number; proxy_url?: string | null; proxy_fallback_to_direct?: boolean; + website_url?: string | null; codex_instructions_strategy?: CodexInstructionsStrategy; tpm?: number | null; rpm?: number | null; @@ -177,6 +256,7 @@ export function ProviderForm({ circuit_breaker_half_open_success_threshold: halfOpenSuccessThreshold ?? 2, proxy_url: proxyUrl.trim() || null, proxy_fallback_to_direct: proxyFallbackToDirect, + website_url: websiteUrl.trim() || null, codex_instructions_strategy: codexInstructionsStrategy, tpm: null, rpm: null, @@ -217,6 +297,7 @@ export function ProviderForm({ circuit_breaker_half_open_success_threshold: halfOpenSuccessThreshold ?? 2, proxy_url: proxyUrl.trim() || null, proxy_fallback_to_direct: proxyFallbackToDirect, + website_url: websiteUrl.trim() || null, codex_instructions_strategy: codexInstructionsStrategy, tpm: null, rpm: null, @@ -252,6 +333,7 @@ export function ProviderForm({ setHalfOpenSuccessThreshold(2); setProxyUrl(""); setProxyFallbackToDirect(false); + setWebsiteUrl(""); setCodexInstructionsStrategy("auto"); } onSuccess?.(); @@ -272,6 +354,7 @@ export function ProviderForm({
setName(e.target.value)} @@ -313,475 +396,649 @@ export function ProviderForm({ ) : null}
+
+ + setWebsiteUrl(e.target.value)} + placeholder="https://example.com" + disabled={isPending} + /> +
供应商官网地址,用于快速跳转管理
+
+ + {/* 展开/折叠全部按钮 */} +
+ + +
+ {/* Codex 支持:供应商类型和模型重定向 */} -
-
- - -

- 选择供应商的 API 格式类型。 - {!enableMultiProviderTypes && ( - - 注:Gemini CLI 和 OpenAI Compatible 类型功能正在开发中,暂不可用 - - )} -

-
+
+ + 路由配置 +
+ + {(() => { + const parts = []; + if (allowedModels.length > 0) parts.push(`${allowedModels.length} 个模型白名单`); + if (Object.keys(modelRedirects).length > 0) + parts.push(`${Object.keys(modelRedirects).length} 个重定向`); + return parts.length > 0 ? parts.join(", ") : "未配置"; + })()} + + + + +
+
+ + +

+ 选择供应商的 API 格式类型。 + {!enableMultiProviderTypes && ( + + 注:Gemini CLI 和 OpenAI Compatible 类型功能正在开发中,暂不可用 + + )} +

+
-
- - -
+
+ + +
- {/* joinClaudePool 开关 - 仅非 Claude 供应商显示 */} - {providerType !== "claude" && - (() => { - // 检查是否有重定向到 Claude 模型的映射 - const hasClaudeRedirects = Object.values(modelRedirects).some((target) => - target.startsWith("claude-") - ); + {/* joinClaudePool 开关 - 仅非 Claude 供应商显示 */} + {providerType !== "claude" && + (() => { + // 检查是否有重定向到 Claude 模型的映射 + const hasClaudeRedirects = Object.values(modelRedirects).some((target) => + target.startsWith("claude-") + ); - if (!hasClaudeRedirects) return null; + if (!hasClaudeRedirects) return null; - return ( -
-
-
- + return ( +
+
+
+ +

+ 启用后,此供应商将与 Claude 类型供应商一起参与负载均衡调度 +

+
+ +

- 启用后,此供应商将与 Claude 类型供应商一起参与负载均衡调度 + 仅当模型重定向配置中存在映射到 claude-* 模型时可用。启用后,当用户请求 + claude-* 模型时,此供应商也会参与调度选择。

- +
模型白名单
+

+ 限制此供应商可以处理的模型。默认情况下,供应商可以处理该类型下的所有模型。 +

+
+ +
+ + + + + {allowedModels.length > 0 && ( +
+ {allowedModels.slice(0, 5).map((model) => ( + + {model} + + ))} + {allowedModels.length > 5 && ( + + +{allowedModels.length - 5} 更多 + + )} +
+ )} + +

+ {allowedModels.length === 0 ? ( + ✓ 允许所有模型(推荐) + ) : ( + + 仅允许选中的 {allowedModels.length} 个模型。其他模型的请求不会调度到此供应商。 + + )} +

+
+ + {/* 路由配置 - 优先级、权重、成本 */} +
+
调度参数
+
+
+ + setPriority(parseInt(e.target.value) || 0)} + placeholder="0" + disabled={isPending} + min="0" + step="1" + /> +

+ 数值越小优先级越高(0 + 最高)。系统只从最高优先级的供应商中选择。建议:主力=0,备用=1,紧急备份=2 +

+
+
+ + setWeight(parseInt(e.target.value) || 1)} + placeholder="1" disabled={isPending} + min="1" + step="1" /> +

+ 加权随机概率。同优先级内,权重越高被选中概率越大。例如权重 1:2:3 的概率为 + 16%:33%:50% +

+
+ + setCostMultiplier(parseFloat(e.target.value) || 1.0)} + placeholder="1.0" + disabled={isPending} + min="0" + step="0.0001" + /> +

+ 成本计算倍数。官方供应商=1.0,便宜 20%=0.8,贵 20%=1.2(支持最多 4 位小数) +

+
+
+
+ + setGroupTag(e.target.value)} + placeholder="例如: premium, economy" + disabled={isPending} + />

- 仅当模型重定向配置中存在映射到 claude-* 模型时可用。启用后,当用户请求 claude-* - 模型时,此供应商也会参与调度选择。 + 供应商分组标签。只有用户的 providerGroup + 与此值匹配时,该用户才能使用此供应商。示例:设置为 "premium" 表示只供 + providerGroup="premium" 的用户使用

- ); - })()} -
- - {/* 模型白名单配置 */} -
-
-
模型白名单
-

- 限制此供应商可以处理的模型。默认情况下,供应商可以处理该类型下的所有模型。 -

-
- -
- - - - - {allowedModels.length > 0 && ( -
- {allowedModels.slice(0, 5).map((model) => ( - - {model} - - ))} - {allowedModels.length > 5 && ( - - +{allowedModels.length - 5} 更多 - - )}
- )} - -

- {allowedModels.length === 0 ? ( - ✓ 允许所有模型(推荐) - ) : ( - - 仅允许选中的 {allowedModels.length} 个模型。其他模型的请求不会调度到此供应商。 - - )} -

-
-
- - {/* 路由配置 */} -
-
路由配置
-
-
- - setPriority(parseInt(e.target.value) || 0)} - placeholder="0" - disabled={isPending} - min="0" - step="1" - /> -

- 数值越小优先级越高(0 - 最高)。系统只从最高优先级的供应商中选择。建议:主力=0,备用=1,紧急备份=2 -

-
- - setWeight(parseInt(e.target.value) || 1)} - placeholder="1" - disabled={isPending} - min="1" - step="1" - /> -

- 加权随机概率。同优先级内,权重越高被选中概率越大。例如权重 1:2:3 的概率为 - 16%:33%:50% -

-
-
- - setCostMultiplier(parseFloat(e.target.value) || 1.0)} - placeholder="1.0" - disabled={isPending} - min="0" - step="0.0001" - /> -

- 成本计算倍数。官方供应商=1.0,便宜 20%=0.8,贵 20%=1.2(支持最多 4 位小数) -

-
-
-
- - setGroupTag(e.target.value)} - placeholder="例如: premium, economy" - disabled={isPending} - /> -

- 供应商分组标签。只有用户的 providerGroup - 与此值匹配时,该用户才能使用此供应商。示例:设置为 "premium" 表示只供 - providerGroup="premium" 的用户使用 -

-
-
+ + {/* 限流配置 */} -
-
限流配置
-
-
- - setLimit5hUsd(validateNumericField(e.target.value))} - placeholder="留空表示无限制" - disabled={isPending} - min="0" - step="0.01" - /> -
-
- - setLimitWeeklyUsd(validateNumericField(e.target.value))} - placeholder="留空表示无限制" - disabled={isPending} - min="0" - step="0.01" - /> -
-
+ toggleSection("rateLimit")} + > + + + + +
+
+
+ + setLimit5hUsd(validateNumericField(e.target.value))} + placeholder="留空表示无限制" + disabled={isPending} + min="0" + step="0.01" + /> +
+
+ + setLimitWeeklyUsd(validateNumericField(e.target.value))} + placeholder="留空表示无限制" + disabled={isPending} + min="0" + step="0.01" + /> +
+
-
-
- - setLimitMonthlyUsd(validateNumericField(e.target.value))} - placeholder="留空表示无限制" - disabled={isPending} - min="0" - step="0.01" - /> -
-
- - setLimitConcurrentSessions(validateNumericField(e.target.value))} - placeholder="0 表示无限制" - disabled={isPending} - min="0" - step="1" - /> +
+
+ + setLimitMonthlyUsd(validateNumericField(e.target.value))} + placeholder="留空表示无限制" + disabled={isPending} + min="0" + step="0.01" + /> +
+
+ + + setLimitConcurrentSessions(validateNumericField(e.target.value)) + } + placeholder="0 表示无限制" + disabled={isPending} + min="0" + step="1" + /> +
+
-
-
+
+
{/* 熔断器配置 */} -
-
-
熔断器配置
-

- 供应商连续失败时自动熔断,避免影响整体服务质量 -

-
-
-
- - { - const val = e.target.value; - setFailureThreshold(val === "" ? undefined : parseInt(val)); - }} - placeholder="5" - disabled={isPending} - min="1" - max="100" - step="1" - /> -

连续失败多少次后触发熔断

-
-
- - { - const val = e.target.value; - setOpenDurationMinutes(val === "" ? undefined : parseInt(val)); - }} - placeholder="30" - disabled={isPending} - min="1" - max="1440" - step="1" - /> -

熔断后多久自动进入半开状态

-
-
- - { - const val = e.target.value; - setHalfOpenSuccessThreshold(val === "" ? undefined : parseInt(val)); - }} - placeholder="2" - disabled={isPending} - min="1" - max="10" - step="1" - /> -

半开状态下成功多少次后完全恢复

+ toggleSection("circuitBreaker")} + > + + + + +
+
+

+ 供应商连续失败时自动熔断,避免影响整体服务质量 +

+
+
+
+ + { + const val = e.target.value; + setFailureThreshold(val === "" ? undefined : parseInt(val)); + }} + placeholder="5" + disabled={isPending} + min="1" + max="100" + step="1" + /> +

连续失败多少次后触发熔断

+
+
+ + { + const val = e.target.value; + setOpenDurationMinutes(val === "" ? undefined : parseInt(val)); + }} + placeholder="30" + disabled={isPending} + min="1" + max="1440" + step="1" + /> +

熔断后多久自动进入半开状态

+
+
+ + { + const val = e.target.value; + setHalfOpenSuccessThreshold(val === "" ? undefined : parseInt(val)); + }} + placeholder="2" + disabled={isPending} + min="1" + max="10" + step="1" + /> +

半开状态下成功多少次后完全恢复

+
+
-
-
+ + {/* 代理配置 */} -
-
-
代理配置
-

- 配置代理服务器以改善供应商连接性(支持 HTTP、HTTPS、SOCKS4、SOCKS5) -

-
- -
- {/* 代理地址输入 */} -
- - setProxyUrl(e.target.value)} - placeholder="例如: http://proxy.example.com:8080 或 socks5://127.0.0.1:1080" - disabled={isPending} - /> -

- 支持格式: http://、 - https://、 - socks4://、 - socks5:// -

-
+ toggleSection("proxy")}> + + + + +
+
+

+ 配置代理服务器以改善供应商连接性(支持 HTTP、HTTPS、SOCKS4、SOCKS5) +

+
- {/* 降级策略开关 */} -
-
-
- -

- 启用后,代理连接失败时自动尝试直接连接供应商 -

-
- + + setProxyUrl(e.target.value)} + placeholder="例如: http://proxy.example.com:8080 或 socks5://127.0.0.1:1080" disabled={isPending} /> +

+ 支持格式: http://、 + https://、 + socks4://、 + socks5:// +

+
+ + {/* 降级策略开关 */} +
+
+
+ +

+ 启用后,代理连接失败时自动尝试直接连接供应商 +

+
+ +
-
- {/* 测试连接按钮 */} -
- - -

- 测试通过配置的代理访问供应商 URL(使用 HEAD 请求,不消耗额度) -

+ {/* 测试连接按钮 */} +
+ + +

+ 测试通过配置的代理访问供应商 URL(使用 HEAD 请求,不消耗额度) +

+
-
-
+ + {/* Codex Instructions 策略配置 - 仅 Codex 供应商显示 */} {providerType === "codex" && ( -
-
-
Codex Instructions 策略
-

- 控制如何处理 Codex 请求的 instructions 字段,影响与上游中转站的兼容性 -

-
- -
- - -

- 提示: 部分严格的 Codex 中转站(如 88code、foxcode)需要官方 - instructions,选择"自动"或"强制官方"策略 -

-
-
+
+ + Codex Instructions 策略 +
+ + {codexInstructionsStrategy === "auto" && "自动 (推荐)"} + {codexInstructionsStrategy === "force_official" && "强制官方"} + {codexInstructionsStrategy === "keep_original" && "透传原样"} + + + + +
+
+

+ 控制如何处理 Codex 请求的 instructions 字段,影响与上游中转站的兼容性 +

+
+ +
+ + +

+ 提示: 部分严格的 Codex 中转站(如 88code、foxcode)需要官方 + instructions,选择"自动"或"强制官方"策略 +

+
+
+
+ )} {isEdit ? ( diff --git a/src/app/settings/providers/_components/provider-list-item.tsx b/src/app/settings/providers/_components/provider-list-item.legacy.tsx similarity index 90% rename from src/app/settings/providers/_components/provider-list-item.tsx rename to src/app/settings/providers/_components/provider-list-item.legacy.tsx index c3c10f210..ab6039001 100644 --- a/src/app/settings/providers/_components/provider-list-item.tsx +++ b/src/app/settings/providers/_components/provider-list-item.legacy.tsx @@ -2,10 +2,17 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import { + Dialog, + DialogContent, + DialogTrigger, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { Edit, Globe, Key, RotateCcw, Copy } from "lucide-react"; +import { Edit, Globe, Key, RotateCcw, Copy, CheckCircle } from "lucide-react"; import type { ProviderDisplay } from "@/types/provider"; import type { User } from "@/types/user"; import { getProviderTypeConfig } from "@/lib/provider-type-utils"; @@ -26,7 +33,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; -import { resetProviderCircuit } from "@/actions/providers"; +import { resetProviderCircuit, getUnmaskedProviderKey } from "@/actions/providers"; import { toast } from "sonner"; import type { CurrencyCode } from "@/lib/utils/currency"; import { formatCurrency } from "@/lib/utils/currency"; @@ -55,6 +62,9 @@ export function ProviderListItem({ const router = useRouter(); const [openEdit, setOpenEdit] = useState(false); const [openClone, setOpenClone] = useState(false); + const [showKeyDialog, setShowKeyDialog] = useState(false); + const [unmaskedKey, setUnmaskedKey] = useState(null); + const [copied, setCopied] = useState(false); const [resetPending, startResetTransition] = useTransition(); const canEdit = currentUser?.role === "admin"; @@ -121,6 +131,41 @@ export function ProviderListItem({ }); }; + // 处理查看密钥 + const handleShowKey = async () => { + setShowKeyDialog(true); + const result = await getUnmaskedProviderKey(item.id); + if (result.ok) { + setUnmaskedKey(result.data.key); + } else { + toast.error("获取密钥失败", { + description: result.error || "未知错误", + }); + } + }; + + // 处理复制密钥 + const handleCopy = async () => { + if (unmaskedKey) { + try { + await navigator.clipboard.writeText(unmaskedKey); + setCopied(true); + toast.success("密钥已复制到剪贴板"); + setTimeout(() => setCopied(false), 3000); + } catch (err) { + console.error("复制失败:", err); + toast.error("复制失败"); + } + } + }; + + // 处理关闭对话框 + const handleCloseDialog = () => { + setShowKeyDialog(false); + setUnmaskedKey(null); + setCopied(false); + }; + return (
@@ -329,7 +374,17 @@ export function ProviderListItem({
- {item.maskedKey} + {canEdit ? ( + + ) : ( + {item.maskedKey} + )}
@@ -644,6 +699,40 @@ export function ProviderListItem({ 创建 {item.createdAt} 更新 {item.updatedAt}
+ + {/* API Key 查看 Dialog */} + + + + + + 查看完整 API Key + + 请妥善保管,不要泄露给他人 + + +
+
+ + {unmaskedKey || "加载中..."} + + +
+
+
+
); } diff --git a/src/app/settings/providers/_components/provider-list.tsx b/src/app/settings/providers/_components/provider-list.tsx index e1577b6dc..cec46e810 100644 --- a/src/app/settings/providers/_components/provider-list.tsx +++ b/src/app/settings/providers/_components/provider-list.tsx @@ -2,7 +2,7 @@ import { Globe } from "lucide-react"; import type { ProviderDisplay } from "@/types/provider"; import type { User } from "@/types/user"; -import { ProviderListItem } from "./provider-list-item"; +import { ProviderRichListItem } from "./provider-rich-list-item"; import type { CurrencyCode } from "@/lib/utils/currency"; interface ProviderListProps { @@ -42,11 +42,11 @@ export function ProviderList({ } return ( -
+
{providers.map((provider) => ( - ("all"); const [sortBy, setSortBy] = useState("priority"); + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 500); - // 根据类型筛选供应商 + // 统一过滤逻辑:搜索 + 类型筛选 + 排序 const filteredProviders = useMemo(() => { - const filtered = - typeFilter === "all" - ? providers - : providers.filter((provider) => provider.providerType === typeFilter); + let result = providers; - return [...filtered].sort((a, b) => { + // 搜索过滤(name, url, groupTag) + if (debouncedSearchTerm) { + const term = debouncedSearchTerm.toLowerCase(); + result = result.filter( + (p) => + p.name.toLowerCase().includes(term) || + p.url.toLowerCase().includes(term) || + (p.groupTag && p.groupTag.toLowerCase().includes(term)) + ); + } + + // 类型筛选 + if (typeFilter !== "all") { + result = result.filter((p) => p.providerType === typeFilter); + } + + // 排序 + return [...result].sort((a, b) => { switch (sortBy) { case "name": return a.name.localeCompare(b.name); @@ -63,19 +82,48 @@ export function ProviderManager({ return 0; } }); - }, [providers, sortBy, typeFilter]); + }, [providers, debouncedSearchTerm, typeFilter, sortBy]); return (
{/* 筛选条件 */} -
+
+
+ + setSearchTerm(e.target.value)} + className="pl-9 pr-9" + /> + {searchTerm && ( + + )} +
-
- 显示 {filteredProviders.length} / {providers.length} 个供应商 -
+ {/* 搜索结果提示 */} + {debouncedSearchTerm && ( +

+ {filteredProviders.length > 0 + ? `找到 ${filteredProviders.length} 个匹配的供应商` + : "未找到匹配的供应商"} +

+ )} + {!debouncedSearchTerm && ( +
+ 显示 {filteredProviders.length} / {providers.length} 个供应商 +
+ )}
{/* 供应商列表 */} diff --git a/src/app/settings/providers/_components/provider-rich-list-item.tsx b/src/app/settings/providers/_components/provider-rich-list-item.tsx new file mode 100644 index 000000000..6f64ce508 --- /dev/null +++ b/src/app/settings/providers/_components/provider-rich-list-item.tsx @@ -0,0 +1,490 @@ +"use client"; +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + CheckCircle, + XCircle, + Edit, + Copy, + Trash, + Globe, + Key, + RotateCcw, + AlertTriangle, +} from "lucide-react"; +import type { ProviderDisplay } from "@/types/provider"; +import type { User } from "@/types/user"; +import { getProviderTypeConfig } from "@/lib/provider-type-utils"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { ProviderForm } from "./forms/provider-form"; +import { FormErrorBoundary } from "@/components/form-error-boundary"; +import { getUnmaskedProviderKey, resetProviderCircuit, removeProvider } from "@/actions/providers"; +import { toast } from "sonner"; +import type { CurrencyCode } from "@/lib/utils/currency"; +import { formatCurrency } from "@/lib/utils/currency"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Switch } from "@/components/ui/switch"; +import { editProvider } from "@/actions/providers"; + +interface ProviderRichListItemProps { + provider: ProviderDisplay; + currentUser?: User; + healthStatus?: { + circuitState: "closed" | "open" | "half-open"; + failureCount: number; + lastFailureTime: number | null; + circuitOpenUntil: number | null; + recoveryMinutes: number | null; + }; + currencyCode?: CurrencyCode; + enableMultiProviderTypes: boolean; + onEdit?: () => void; + onClone?: () => void; + onDelete?: () => void; +} + +export function ProviderRichListItem({ + provider, + currentUser, + healthStatus, + currencyCode = "USD", + enableMultiProviderTypes, + onEdit: onEditProp, + onClone: onCloneProp, + onDelete: onDeleteProp, +}: ProviderRichListItemProps) { + const router = useRouter(); + const [openEdit, setOpenEdit] = useState(false); + const [openClone, setOpenClone] = useState(false); + const [showKeyDialog, setShowKeyDialog] = useState(false); + const [unmaskedKey, setUnmaskedKey] = useState(null); + const [copied, setCopied] = useState(false); + const [resetPending, startResetTransition] = useTransition(); + const [deletePending, startDeleteTransition] = useTransition(); + const [togglePending, startToggleTransition] = useTransition(); + + const canEdit = currentUser?.role === "admin"; + + // 获取供应商类型配置 + const typeConfig = getProviderTypeConfig(provider.providerType); + const TypeIcon = typeConfig.icon; + + // 处理编辑 + const handleEdit = () => { + if (onEditProp) { + onEditProp(); + } else { + setOpenEdit(true); + } + }; + + // 处理克隆 + const handleClone = () => { + if (onCloneProp) { + onCloneProp(); + } else { + setOpenClone(true); + } + }; + + // 处理删除 + const handleDelete = () => { + if (onDeleteProp) { + onDeleteProp(); + } else { + startDeleteTransition(async () => { + try { + const res = await removeProvider(provider.id); + if (res.ok) { + toast.success("删除成功", { + description: `供应商 "${provider.name}" 已删除`, + }); + router.refresh(); + } else { + toast.error("删除失败", { + description: res.error || "未知错误", + }); + } + } catch (error) { + console.error("删除供应商失败:", error); + toast.error("删除失败", { + description: "操作过程中出现异常", + }); + } + }); + } + }; + + // 处理查看密钥 + const handleShowKey = async () => { + setShowKeyDialog(true); + const result = await getUnmaskedProviderKey(provider.id); + if (result.ok) { + setUnmaskedKey(result.data.key); + } else { + toast.error("获取密钥失败", { + description: result.error || "未知错误", + }); + setShowKeyDialog(false); + } + }; + + // 处理复制密钥 + const handleCopy = async () => { + if (unmaskedKey) { + try { + await navigator.clipboard.writeText(unmaskedKey); + setCopied(true); + toast.success("密钥已复制到剪贴板"); + setTimeout(() => setCopied(false), 3000); + } catch (error) { + console.error("复制失败:", error); + toast.error("复制失败"); + } + } + }; + + // 处理关闭 Dialog + const handleCloseDialog = () => { + setShowKeyDialog(false); + setUnmaskedKey(null); + setCopied(false); + }; + + // 处理手动解除熔断 + const handleResetCircuit = () => { + startResetTransition(async () => { + try { + const res = await resetProviderCircuit(provider.id); + if (res.ok) { + toast.success("熔断器已重置", { + description: `供应商 "${provider.name}" 的熔断状态已解除`, + }); + router.refresh(); + } else { + toast.error("重置熔断器失败", { + description: res.error || "未知错误", + }); + } + } catch (error) { + console.error("重置熔断器失败:", error); + toast.error("重置熔断器失败", { + description: "操作过程中出现异常", + }); + } + }); + }; + + // 处理启用/禁用切换 + const handleToggle = () => { + startToggleTransition(async () => { + try { + const res = await editProvider(provider.id, { + is_enabled: !provider.isEnabled, + }); + if (res.ok) { + toast.success(`供应商已${!provider.isEnabled ? "启用" : "禁用"}`, { + description: `供应商 "${provider.name}" 状态已更新`, + }); + router.refresh(); + } else { + toast.error("状态切换失败", { + description: res.error || "未知错误", + }); + } + } catch (error) { + console.error("状态切换失败:", error); + toast.error("状态切换失败", { + description: "操作过程中出现异常", + }); + } + }); + }; + + return ( + <> +
+ {/* 左侧:状态和类型图标 */} +
+ {/* 启用状态指示器 */} + {provider.isEnabled ? ( + + ) : ( + + )} + + {/* 类型图标 */} +
+ +
+
+ + {/* 中间:名称、URL、官网、tag、熔断状态 */} +
+
+ {/* Favicon */} + {provider.faviconUrl && ( + { + // 隐藏加载失败的图标 + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + + {/* 名称 */} + {provider.name} + + {/* Group Tag */} + {provider.groupTag && ( + + {provider.groupTag} + + )} + + {/* 熔断器警告 */} + {healthStatus && healthStatus.circuitState === "open" && ( + + + 熔断中 + + )} +
+ +
+ {/* URL */} + {provider.url} + + {/* 官网链接 */} + {provider.websiteUrl && ( + e.stopPropagation()} + > + + 官网 + + )} + + {/* API Key 展示(仅管理员) */} + {canEdit && ( + + )} +
+
+ + {/* 右侧:指标(仅桌面端) */} +
+
+
优先级
+
{provider.priority}
+
+
+
权重
+
{provider.weight}
+
+
+
成本倍数
+
{provider.costMultiplier}x
+
+
+ + {/* 今日用量(仅大屏) */} +
+
今日用量
+
{provider.todayCallCount || 0} 次
+
+ {formatCurrency(parseFloat(provider.todayTotalCostUsd || "0"), currencyCode)} +
+
+ + {/* 操作按钮 */} +
+ {/* 启用/禁用切换 */} + {canEdit && ( + + )} + + {/* 编辑按钮 */} + {canEdit && ( + + )} + + {/* 克隆按钮 */} + {canEdit && ( + + )} + + {/* 熔断重置按钮(仅熔断时显示) */} + {canEdit && healthStatus && healthStatus.circuitState === "open" && ( + + )} + + {/* 删除按钮 */} + {canEdit && ( + + + + + + + 确认删除供应商? + + 确定要删除供应商 "{provider.name}" 吗?此操作无法撤销。 + + +
+ 取消 + { + e.stopPropagation(); + handleDelete(); + }} + className="bg-red-600 hover:bg-red-700" + disabled={deletePending} + > + 删除 + +
+
+
+ )} +
+
+ + {/* 编辑 Dialog */} + + + + { + setOpenEdit(false); + router.refresh(); + }} + enableMultiProviderTypes={enableMultiProviderTypes} + /> + + + + + {/* 克隆 Dialog */} + + + + { + setOpenClone(false); + router.refresh(); + }} + enableMultiProviderTypes={enableMultiProviderTypes} + /> + + + + + {/* API Key 展示 Dialog */} + + + + 查看完整 API Key + 请妥善保管,不要泄露给他人 + +
+
+ + {unmaskedKey || "加载中..."} + + +
+
+
+
+ + ); +} diff --git a/src/app/v1/_lib/proxy/errors.ts b/src/app/v1/_lib/proxy/errors.ts index 8b76fe954..6c67c5671 100644 --- a/src/app/v1/_lib/proxy/errors.ts +++ b/src/app/v1/_lib/proxy/errors.ts @@ -161,6 +161,66 @@ export enum ErrorCategory { PROVIDER_ERROR, // 供应商问题(所有 4xx/5xx HTTP 错误)→ 计入熔断器 + 直接切换 SYSTEM_ERROR, // 系统/网络问题(fetch 网络异常)→ 不计入熔断器 + 先重试1次 CLIENT_ABORT, // 客户端主动中断 → 不计入熔断器 + 不重试 + 直接返回 + NON_RETRYABLE_CLIENT_ERROR, // 客户端输入错误(Prompt 超限、内容过滤、PDF 限制、Thinking 格式)→ 不计入熔断器 + 不重试 + 直接返回 +} + +/** + * 预编译的不可重试客户端错误正则表达式数组 + * 用于高性能的白名单错误匹配,避免每次调用时重新编译正则 + * + * 包含 4 类错误模式: + * 1. Prompt 长度超限 + * 2. 内容过滤拦截 + * 3. PDF 页数限制 + * 4. Thinking 格式错误 + */ +const NON_RETRYABLE_ERROR_PATTERNS = [ + /prompt is too long: \d+ tokens > \d+ maximum/i, + /Request blocked by content filter|permission_error.*content filter/i, + /A maximum of \d+ PDF pages may be provided/i, + /Expected.*thinking.*but found|thinking.*must start with a thinking block/i, +]; + +/** + * 检测是否为不可重试的客户端输入错误 + * + * 采用白名单模式,检测明确不应重试的客户端错误(如输入超限、内容过滤等), + * 这些错误即使重试也不会成功,应直接返回给客户端,且不计入熔断器。 + * + * 检测的 4 类错误: + * 1. Prompt 长度超限:`prompt is too long: {tokens} tokens > {max} maximum` + * 2. 内容过滤拦截:`Request blocked by content filter` 或 `permission_error.*content filter` + * 3. PDF 页数限制:`A maximum of {n} PDF pages may be provided` + * 4. Thinking 格式错误:`Expected.*thinking.*but found` 或 `thinking.*must start with a thinking block` + * + * @param error - 错误对象 + * @returns 是否为不可重试的客户端错误 + * + * @example + * isNonRetryableClientError(new ProxyError('prompt is too long: 207406 tokens > 200000 maximum', 400)) + * // => true + * + * @example + * isNonRetryableClientError(new ProxyError('Internal server error', 500)) + * // => false + */ +export function isNonRetryableClientError(error: Error): boolean { + // 提取错误消息 + let message = error.message; + + // 如果是 ProxyError,优先从 upstreamError.parsed 中提取详细错误消息 + if (error instanceof ProxyError && error.upstreamError?.parsed) { + const parsed = error.upstreamError.parsed as Record; + if (parsed.error && typeof parsed.error === "object") { + const errorObj = parsed.error as Record; + if (typeof errorObj.message === "string") { + message = errorObj.message; + } + } + } + + // 使用预编译正则数组进行匹配,短路优化(第一个匹配成功立即返回) + return NON_RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(message)); } /** @@ -204,38 +264,49 @@ export function isClientAbortError(error: Error): boolean { /** * 判断错误类型 * - * 分类规则: - * - ProxyError(所有 4xx/5xx HTTP 错误):供应商问题 - * → 说明请求到达供应商并得到响应,但供应商无法正常处理 - * → 应计入熔断器,连续失败时触发熔断保护 - * → 应直接切换到其他供应商 + * 分类规则(优先级从高到低): + * 1. 客户端主动中断(AbortError 或 error.code === 'ECONNRESET' 且 statusCode === 499) + * → 客户端关闭连接或主动取消请求 + * → 不应计入熔断器(不是供应商问题) + * → 不应重试(客户端已经不想要结果了) + * → 应立即返回错误 * - * - 客户端中断(AbortError 或 error.code === 'ECONNRESET' 且 statusCode === 499):客户端主动中断 - * → 客户端关闭连接或主动取消请求 - * → 不应计入熔断器(不是供应商问题) - * → 不应重试(客户端已经不想要结果了) - * → 应立即返回错误 + * 2. 不可重试的客户端输入错误(Prompt 超限、内容过滤、PDF 限制、Thinking 格式) + * → 客户端输入违反了 API 的硬性限制或安全策略 + * → 不应计入熔断器(不是供应商故障) + * → 不应重试(重试也会失败) + * → 应立即返回错误,提示用户修正输入 * - * - 其他错误(fetch 网络异常):系统/网络问题 - * → 包括:DNS 解析失败、连接被拒绝、连接超时、网络中断等 - * → 不应计入供应商熔断器(不是供应商服务不可用) - * → 应先重试1次当前供应商(可能是临时网络抖动) + * 3. 供应商问题(ProxyError - 所有 4xx/5xx HTTP 错误) + * → 说明请求到达供应商并得到响应,但供应商无法正常处理 + * → 应计入熔断器,连续失败时触发熔断保护 + * → 应直接切换到其他供应商 + * + * 4. 系统/网络问题(fetch 网络异常) + * → 包括:DNS 解析失败、连接被拒绝、连接超时、网络中断等 + * → 不应计入供应商熔断器(不是供应商服务不可用) + * → 应先重试1次当前供应商(可能是临时网络抖动) * * @param error - 捕获的错误对象 - * @returns 错误分类(PROVIDER_ERROR、SYSTEM_ERROR 或 CLIENT_ABORT) + * @returns 错误分类(CLIENT_ABORT、NON_RETRYABLE_CLIENT_ERROR、PROVIDER_ERROR 或 SYSTEM_ERROR) */ export function categorizeError(error: Error): ErrorCategory { - // 客户端中断检测(优先级最高)- 使用统一的精确检测函数 + // 优先级 1: 客户端中断检测(优先级最高)- 使用统一的精确检测函数 if (isClientAbortError(error)) { return ErrorCategory.CLIENT_ABORT; // 客户端主动中断 } - // ProxyError = HTTP 错误(4xx 或 5xx) + // 优先级 2: 不可重试的客户端输入错误检测(白名单模式) + if (isNonRetryableClientError(error)) { + return ErrorCategory.NON_RETRYABLE_CLIENT_ERROR; // 客户端输入错误 + } + + // 优先级 3: ProxyError = HTTP 错误(4xx 或 5xx) if (error instanceof ProxyError) { return ErrorCategory.PROVIDER_ERROR; // 所有 HTTP 错误都是供应商问题 } - // 其他所有错误都是系统错误 + // 优先级 4: 其他所有错误都是系统错误 // 包括: // - TypeError: fetch failed (网络层错误) // - ENOTFOUND: DNS 解析失败 diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index cc833ef7e..f85a64472 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -25,6 +25,51 @@ import { getEnvConfig } from "@/lib/config/env.schema"; const MAX_ATTEMPTS_PER_PROVIDER = 2; // 每个供应商最多尝试次数(首次 + 1次重试) const MAX_PROVIDER_SWITCHES = 20; // 保险栓:最多切换 20 次供应商(防止无限循环) +/** + * 过滤私有参数(下划线前缀) + * + * 目的:防止私有参数(如 _canRetryWithOfficialInstructions)泄露到上游供应商 + * 导致 "Unsupported parameter" 错误 + * + * @param obj - 原始请求对象 + * @returns 过滤后的请求对象 + */ +function filterPrivateParameters(obj: unknown): unknown { + // 非对象类型直接返回 + if (typeof obj !== "object" || obj === null) { + return obj; + } + + // 数组类型递归处理 + if (Array.isArray(obj)) { + return obj.map((item) => filterPrivateParameters(item)); + } + + // 对象类型:过滤下划线前缀的键 + const filtered: Record = {}; + const removedKeys: string[] = []; + + for (const [key, value] of Object.entries(obj)) { + if (key.startsWith("_")) { + // 私有参数:跳过 + removedKeys.push(key); + } else { + // 公开参数:递归过滤值 + filtered[key] = filterPrivateParameters(value); + } + } + + // 记录被过滤的参数(debug 级别) + if (removedKeys.length > 0) { + logger.debug("[ProxyForwarder] Filtered private parameters from request", { + removedKeys, + reason: "Private parameters (underscore-prefixed) should not be sent to upstream providers", + }); + } + + return filtered; +} + export class ProxyForwarder { static async send(session: ProxySession): Promise { if (!session.provider || !session.authState?.success) { @@ -195,7 +240,49 @@ export class ProxyForwarder { throw lastError; } - // ⭐ 3. 系统错误处理(不计入熔断器,先重试1次当前供应商) + // ⭐ 3. 不可重试的客户端输入错误处理(不计入熔断器,不重试,立即返回) + if (errorCategory === ErrorCategory.NON_RETRYABLE_CLIENT_ERROR) { + const proxyError = lastError as ProxyError; + const statusCode = proxyError.statusCode; + + logger.warn("ProxyForwarder: Non-retryable client error, stopping immediately", { + providerId: currentProvider.id, + providerName: currentProvider.name, + statusCode: statusCode, + error: errorMessage, + attemptNumber: attemptCount, + totalProvidersAttempted, + reason: + "White-listed client error (prompt length, content filter, PDF limit, or thinking format)", + }); + + // 记录到决策链(标记为不可重试的客户端错误) + // 注意:不调用 recordFailure(),因为这不是供应商的问题,是客户端输入问题 + session.addProviderToChain(currentProvider, { + reason: "client_error_non_retryable", // 新增的 reason 值 + circuitState: getCircuitState(currentProvider.id), + attemptNumber: attemptCount, + errorMessage: errorMessage, + statusCode: statusCode, + errorDetails: { + provider: { + id: currentProvider.id, + name: currentProvider.name, + statusCode: statusCode, + statusText: proxyError.message, + upstreamBody: proxyError.upstreamError?.body, + upstreamParsed: proxyError.upstreamError?.parsed, + }, + clientError: proxyError.getDetailedErrorMessage(), + }, + }); + + // 立即抛出错误,不重试,不切换供应商 + // 白名单错误不计入熔断器,因为是客户端输入问题,不是供应商故障 + throw lastError; + } + + // ⭐ 4. 系统错误处理(不计入熔断器,先重试1次当前供应商) if (errorCategory === ErrorCategory.SYSTEM_ERROR) { const err = lastError as Error & { code?: string; @@ -272,7 +359,7 @@ export class ProxyForwarder { break; // ⭐ 跳出内层循环,进入供应商切换逻辑 } - // ⭐ 4. 供应商错误处理(所有 4xx/5xx HTTP 错误,计入熔断器,直接切换) + // ⭐ 5. 供应商错误处理(所有 4xx/5xx HTTP 错误,计入熔断器,直接切换) if (errorCategory === ErrorCategory.PROVIDER_ERROR) { const proxyError = lastError as ProxyError; const statusCode = proxyError.statusCode; @@ -618,7 +705,9 @@ export class ProxyForwarder { // 确保 OpenAI 格式转换为 Response API 后,发送的是包含 input 字段的请求体 let requestBody: BodyInit | undefined; if (hasBody) { - const bodyString = JSON.stringify(session.request.message); + // 过滤私有参数(下划线前缀)防止泄露到上游供应商 + const filteredMessage = filterPrivateParameters(session.request.message); + const bodyString = JSON.stringify(filteredMessage); requestBody = bodyString; // 调试日志:输出实际转发的请求体(仅在开发环境) diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 1a331786d..58276f525 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -114,22 +114,9 @@ export class ProxyResponseHandler { let usageRecord: Record | null = null; let usageMetrics: UsageMetrics | null = null; - try { - const parsed = JSON.parse(responseText) as Record; - // Claude 格式: 顶级 usage - let usageValue = parsed.usage; - // Codex 格式: response.usage - if (!usageValue && parsed.response && typeof parsed.response === "object") { - const responseObj = parsed.response as Record; - usageValue = responseObj.usage; - } - if (usageValue && typeof usageValue === "object") { - usageRecord = usageValue as Record; - usageMetrics = extractUsageMetrics(usageValue); - } - } catch { - // 非 JSON 响应时保持原始日志 - } + const usageResult = parseUsageFromResponseText(responseText, provider.providerType); + usageRecord = usageResult.usageRecord; + usageMetrics = usageResult.usageMetrics; // 存储响应体到 Redis(5分钟过期) if (session.sessionId) { @@ -357,8 +344,6 @@ export class ProxyResponseHandler { }); } - const parsedEvents = parseSSEData(allContent); - const duration = Date.now() - session.startTime; await updateMessageRequestDuration(messageContext.id, duration); @@ -366,43 +351,8 @@ export class ProxyResponseHandler { const tracker = ProxyStatusTracker.getInstance(); tracker.endRequest(messageContext.user.id, messageContext.id); - for (const event of parsedEvents) { - // Codex API: 监听 response.completed 事件(官方格式) - if ( - event.event === "response.completed" && - typeof event.data === "object" && - event.data !== null - ) { - const eventData = event.data as Record; - // Codex API 的 usage 在 response.usage 路径下 - const responseObj = eventData.response as Record | undefined; - if (responseObj?.usage) { - const usageMetrics = extractUsageMetrics(responseObj.usage); - if (usageMetrics) { - usageForCost = usageMetrics; - logger.debug("[ResponseHandler] Captured usage from Codex response.completed", { - usage: usageMetrics, - }); - } - } - } - - // Claude API: 监听 message_delta 事件(向后兼容) - if ( - event.event === "message_delta" && - typeof event.data === "object" && - event.data !== null - ) { - const eventData = event.data as Record; - const usageMetrics = extractUsageMetrics(eventData.usage); - if (usageMetrics) { - usageForCost = usageMetrics; - logger.debug("[ResponseHandler] Captured usage from Claude message_delta", { - usage: usageMetrics, - }); - } - } - } + const usageResult = parseUsageFromResponseText(allContent, provider.providerType); + usageForCost = usageResult.usageMetrics; await updateRequestCostFromUsage( messageContext.id, @@ -557,6 +507,138 @@ function extractUsageMetrics(value: unknown): UsageMetrics | null { return hasAny ? result : null; } +function parseUsageFromResponseText( + responseText: string, + providerType: string | null | undefined +): { + usageRecord: Record | null; + usageMetrics: UsageMetrics | null; +} { + let usageRecord: Record | null = null; + let usageMetrics: UsageMetrics | null = null; + + const applyUsageValue = (value: unknown, source: string) => { + if (usageMetrics) { + return; + } + + if (!value || typeof value !== "object") { + return; + } + + const extracted = extractUsageMetrics(value); + if (!extracted) { + return; + } + + usageRecord = value as Record; + usageMetrics = adjustUsageForProviderType(extracted, providerType); + + logger.debug("[ResponseHandler] Parsed usage from response", { + source, + providerType, + usage: usageMetrics, + }); + }; + + try { + const parsedValue = JSON.parse(responseText); + + if (parsedValue && typeof parsedValue === "object" && !Array.isArray(parsedValue)) { + const parsed = parsedValue as Record; + applyUsageValue(parsed.usage, "json.root"); + + if (parsed.response && typeof parsed.response === "object") { + applyUsageValue((parsed.response as Record).usage, "json.response"); + } + + if (Array.isArray(parsed.output)) { + for (const item of parsed.output as Array>) { + if (!item || typeof item !== "object") { + continue; + } + applyUsageValue(item.usage, "json.output"); + } + } + } + + if (!usageMetrics && Array.isArray(parsedValue)) { + for (const item of parsedValue) { + if (!item || typeof item !== "object") { + continue; + } + + const record = item as Record; + applyUsageValue(record.usage, "json.array"); + + if (record.data && typeof record.data === "object") { + applyUsageValue((record.data as Record).usage, "json.array.data"); + } + } + } + } catch { + // Fallback to SSE parsing when body is not valid JSON + } + + if (!usageMetrics && responseText.includes("event:")) { + const events = parseSSEData(responseText); + for (const event of events) { + if (usageMetrics) { + break; + } + + if (typeof event.data !== "object" || !event.data) { + continue; + } + + const data = event.data as Record; + applyUsageValue(data.usage, `sse.${event.event}`); + + if (!usageMetrics && data.response && typeof data.response === "object") { + applyUsageValue( + (data.response as Record).usage, + `sse.${event.event}.response` + ); + } + } + } + + return { usageRecord, usageMetrics }; +} + +function adjustUsageForProviderType( + usage: UsageMetrics, + providerType: string | null | undefined +): UsageMetrics { + if (providerType !== "codex") { + return usage; + } + + const cachedTokens = usage.cache_read_input_tokens; + const inputTokens = usage.input_tokens; + + if (typeof cachedTokens !== "number" || typeof inputTokens !== "number") { + return usage; + } + + const adjustedInput = Math.max(inputTokens - cachedTokens, 0); + if (adjustedInput === inputTokens) { + return usage; + } + + logger.debug("[UsageMetrics] Adjusted codex input tokens to exclude cached tokens", { + providerType, + originalInputTokens: inputTokens, + cachedTokens, + adjustedInputTokens: adjustedInput, + }); + + return { + ...usage, + input_tokens: adjustedInput, + }; +} + async function updateRequestCostFromUsage( messageId: number, originalModel: string | null, diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 3d9e09c8f..d5975714b 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -221,7 +221,8 @@ export class ProxySession { | "retry_failed" // 供应商错误(已计入熔断器) | "system_error" // 系统/网络错误(不计入熔断器) | "retry_with_official_instructions" // Codex instructions 自动重试(官方) - | "retry_with_cached_instructions"; // Codex instructions 智能重试(缓存) + | "retry_with_cached_instructions" // Codex instructions 智能重试(缓存) + | "client_error_non_retryable"; // 不可重试的客户端错误(Prompt 超限、内容过滤、PDF 限制、Thinking 格式) selectionMethod?: | "session_reuse" | "weighted_random" diff --git a/src/components/customs/overview-panel.tsx b/src/components/customs/overview-panel.tsx index 4a68f66f6..4673d6f28 100644 --- a/src/components/customs/overview-panel.tsx +++ b/src/components/customs/overview-panel.tsx @@ -151,6 +151,7 @@ function SessionListItem({ interface OverviewPanelProps { currencyCode?: CurrencyCode; + isAdmin?: boolean; } /** @@ -158,13 +159,14 @@ interface OverviewPanelProps { * 左侧:4个指标卡片 * 右侧:简洁的活跃 Session 列表 */ -export function OverviewPanel({ currencyCode = "USD" }: OverviewPanelProps) { +export function OverviewPanel({ currencyCode = "USD", isAdmin = false }: OverviewPanelProps) { const router = useRouter(); const { data, isLoading } = useQuery({ queryKey: ["overview-data"], queryFn: fetchOverviewData, refetchInterval: REFRESH_INTERVAL, + enabled: isAdmin, // 仅当用户是 admin 时才获取数据 }); // 格式化响应时间 @@ -181,6 +183,11 @@ export function OverviewPanel({ currencyCode = "USD" }: OverviewPanelProps) { recentSessions: [], }; + // 对于非 admin 用户,不显示概览面板 + if (!isAdmin) { + return null; + } + return (
{/* 左侧:指标卡片区域 */} @@ -224,8 +231,8 @@ export function OverviewPanel({ currencyCode = "USD" }: OverviewPanelProps) { {/* 右侧:活跃 Session 列表 */}
-
-
+
+

活跃 Session

@@ -241,14 +248,14 @@ export function OverviewPanel({ currencyCode = "USD" }: OverviewPanelProps) {
-
+
{isLoading && metrics.recentSessions.length === 0 ? ( -
+
加载中...
) : metrics.recentSessions.length === 0 ? ( -
+
暂无活跃 Session
) : ( diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 5b6dd6c05..d5a70cec4 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -123,6 +123,10 @@ export const providers = pgTable('providers', { proxyUrl: varchar('proxy_url', { length: 512 }), proxyFallbackToDirect: boolean('proxy_fallback_to_direct').default(false), + // 供应商官网地址(用于快速跳转管理) + websiteUrl: text('website_url'), + faviconUrl: text('favicon_url'), + // 废弃(保留向后兼容,但不再使用) tpm: integer('tpm').default(0), rpm: integer('rpm').default(0), diff --git a/src/lib/logger.ts b/src/lib/logger.ts index b9dcd6365..a72ed0d89 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -30,10 +30,14 @@ function getInitialLogLevel(): LogLevel { /** * 创建 Pino 日志实例 + * pino-pretty 在 Next.js 15 + Turbopack 下有兼容性问题,暂时禁用 + * 在 Turbopack 环境下使用默认格式化器(不启用 pino-pretty) */ +const enablePrettyTransport = isDevelopment() && !process.env.TURBOPACK; + const pinoInstance = pino({ level: getInitialLogLevel(), - transport: isDevelopment() + transport: enablePrettyTransport ? { target: "pino-pretty", options: { diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts index 456291644..a8b07aff0 100644 --- a/src/lib/redis/leaderboard-cache.ts +++ b/src/lib/redis/leaderboard-cache.ts @@ -1,42 +1,60 @@ import { getRedisClient } from "./client"; import { logger } from "@/lib/logger"; +import { formatInTimeZone } from "date-fns-tz"; +import { getEnvConfig } from "@/lib/config"; import { findDailyLeaderboard, findMonthlyLeaderboard, LeaderboardEntry, + findDailyProviderLeaderboard, + findMonthlyProviderLeaderboard, + ProviderLeaderboardEntry, } from "@/repository/leaderboard"; /** * 排行榜周期类型 */ type LeaderboardPeriod = "daily" | "monthly"; +export type LeaderboardScope = "user" | "provider"; + +type LeaderboardData = LeaderboardEntry[] | ProviderLeaderboardEntry[]; /** * 构建缓存键 */ -function buildCacheKey(period: LeaderboardPeriod, currencyDisplay: string): string { +function buildCacheKey( + period: LeaderboardPeriod, + currencyDisplay: string, + scope: LeaderboardScope = "user" +): string { const now = new Date(); + const tz = getEnvConfig().TZ; // ensure date formatting aligns with configured timezone if (period === "daily") { - // leaderboard:daily:2025-01-15:USD - const dateStr = now.toISOString().split("T")[0]; - return `leaderboard:daily:${dateStr}:${currencyDisplay}`; + // leaderboard:{scope}:daily:2025-01-15:USD + const dateStr = formatInTimeZone(now, tz, "yyyy-MM-dd"); + return `leaderboard:${scope}:daily:${dateStr}:${currencyDisplay}`; } else { - // leaderboard:monthly:2025-01:USD - const monthStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; - return `leaderboard:monthly:${monthStr}:${currencyDisplay}`; + // leaderboard:{scope}:monthly:2025-01:USD + const monthStr = formatInTimeZone(now, tz, "yyyy-MM"); + return `leaderboard:${scope}:monthly:${monthStr}:${currencyDisplay}`; } } /** * 查询数据库(根据周期) */ -async function queryDatabase(period: LeaderboardPeriod): Promise { - if (period === "daily") { - return await findDailyLeaderboard(); - } else { - return await findMonthlyLeaderboard(); +async function queryDatabase( + period: LeaderboardPeriod, + scope: LeaderboardScope +): Promise { + if (scope === "user") { + return period === "daily" ? await findDailyLeaderboard() : await findMonthlyLeaderboard(); } + // provider scope + return period === "daily" + ? await findDailyProviderLeaderboard() + : await findMonthlyProviderLeaderboard(); } /** @@ -61,17 +79,21 @@ function sleep(ms: number): Promise { */ export async function getLeaderboardWithCache( period: LeaderboardPeriod, - currencyDisplay: string -): Promise { + currencyDisplay: string, + scope: LeaderboardScope = "user" +): Promise { const redis = getRedisClient(); // Redis 不可用,直接查数据库 if (!redis) { - logger.warn("[LeaderboardCache] Redis not available, fallback to direct query", { period }); - return await queryDatabase(period); + logger.warn("[LeaderboardCache] Redis not available, fallback to direct query", { + period, + scope, + }); + return await queryDatabase(period, scope); } - const cacheKey = buildCacheKey(period, currencyDisplay); + const cacheKey = buildCacheKey(period, currencyDisplay, scope); const lockKey = `${cacheKey}:lock`; try { @@ -79,7 +101,7 @@ export async function getLeaderboardWithCache( const cached = await redis.get(cacheKey); if (cached) { logger.debug("[LeaderboardCache] Cache hit", { period, cacheKey }); - return JSON.parse(cached) as LeaderboardEntry[]; + return JSON.parse(cached) as LeaderboardData; } // 2. 缓存未命中,尝试获取计算锁(SET NX EX 10 秒) @@ -87,9 +109,9 @@ export async function getLeaderboardWithCache( if (locked === "OK") { // 获得锁,查询数据库 - logger.debug("[LeaderboardCache] Acquired lock, computing", { period, lockKey }); + logger.debug("[LeaderboardCache] Acquired lock, computing", { period, scope, lockKey }); - const data = await queryDatabase(period); + const data = await queryDatabase(period, scope); // 写入缓存(60 秒 TTL) await redis.setex(cacheKey, 60, JSON.stringify(data)); @@ -99,6 +121,7 @@ export async function getLeaderboardWithCache( logger.info("[LeaderboardCache] Cache updated", { period, + scope, recordCount: data.length, cacheKey, ttl: 60, @@ -107,7 +130,7 @@ export async function getLeaderboardWithCache( return data; } else { // 未获得锁,等待并重试(最多 50 次 × 100ms = 5 秒) - logger.debug("[LeaderboardCache] Lock held by another request, retrying", { period }); + logger.debug("[LeaderboardCache] Lock held by another request, retrying", { period, scope }); for (let i = 0; i < 50; i++) { await sleep(100); @@ -118,21 +141,22 @@ export async function getLeaderboardWithCache( period, retries: i + 1, }); - return JSON.parse(retried) as LeaderboardEntry[]; + return JSON.parse(retried) as LeaderboardData; } } // 超时降级:直接查数据库 - logger.warn("[LeaderboardCache] Retry timeout, fallback to direct query", { period }); - return await queryDatabase(period); + logger.warn("[LeaderboardCache] Retry timeout, fallback to direct query", { period, scope }); + return await queryDatabase(period, scope); } } catch (error) { // Redis 异常,降级到直接查询 logger.error("[LeaderboardCache] Redis error, fallback to direct query", { period, + scope, error, }); - return await queryDatabase(period); + return await queryDatabase(period, scope); } } @@ -144,19 +168,20 @@ export async function getLeaderboardWithCache( */ export async function invalidateLeaderboardCache( period: LeaderboardPeriod, - currencyDisplay: string + currencyDisplay: string, + scope: LeaderboardScope = "user" ): Promise { const redis = getRedisClient(); if (!redis) { return; } - const cacheKey = buildCacheKey(period, currencyDisplay); + const cacheKey = buildCacheKey(period, currencyDisplay, scope); try { await redis.del(cacheKey); - logger.info("[LeaderboardCache] Cache invalidated", { period, cacheKey }); + logger.info("[LeaderboardCache] Cache invalidated", { period, scope, cacheKey }); } catch (error) { - logger.error("[LeaderboardCache] Failed to invalidate cache", { period, error }); + logger.error("[LeaderboardCache] Failed to invalidate cache", { period, scope, error }); } } diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index d84100045..937e5552e 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -171,6 +171,14 @@ export const CreateProviderSchema = z.object({ // 代理配置 proxy_url: z.string().max(512, "代理地址长度不能超过512个字符").nullable().optional(), proxy_fallback_to_direct: z.boolean().optional().default(false), + // 供应商官网地址 + website_url: z + .string() + .url("请输入有效的URL地址") + .max(512, "URL长度不能超过512个字符") + .nullable() + .optional(), + favicon_url: z.string().max(512, "Favicon URL长度不能超过512个字符").nullable().optional(), // 废弃字段(保留向后兼容,不再验证范围) tpm: z.number().int().nullable().optional(), rpm: z.number().int().nullable().optional(), @@ -256,6 +264,14 @@ export const UpdateProviderSchema = z // 代理配置 proxy_url: z.string().max(512, "代理地址长度不能超过512个字符").nullable().optional(), proxy_fallback_to_direct: z.boolean().optional(), + // 供应商官网地址 + website_url: z + .string() + .url("请输入有效的URL地址") + .max(512, "URL长度不能超过512个字符") + .nullable() + .optional(), + favicon_url: z.string().max(512, "Favicon URL长度不能超过512个字符").nullable().optional(), // 废弃字段(保留向后兼容,不再验证范围) tpm: z.number().int().nullable().optional(), rpm: z.number().int().nullable().optional(), diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 95700cb82..2639c7423 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -1,8 +1,9 @@ "use server"; import { db } from "@/drizzle/db"; -import { messageRequest, users } from "@/drizzle/schema"; +import { messageRequest, users, providers } from "@/drizzle/schema"; import { and, gte, lt, desc, sql, isNull } from "drizzle-orm"; +import { getEnvConfig } from "@/lib/config"; /** * 排行榜条目类型 @@ -15,33 +16,44 @@ export interface LeaderboardEntry { totalTokens: number; } +/** + * 供应商排行榜条目类型 + */ +export interface ProviderLeaderboardEntry { + providerId: number; + providerName: string; + totalRequests: number; + totalCost: number; + totalTokens: number; + successRate: number; // 0-1 之间的小数,UI 层负责格式化为百分比 + avgResponseTime: number; // 毫秒 +} + /** * 查询今日消耗排行榜(不限制数量) + * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于配置时区(Asia/Shanghai) */ export async function findDailyLeaderboard(): Promise { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const tomorrow = new Date(today); - tomorrow.setDate(tomorrow.getDate() + 1); - - return findLeaderboard(today, tomorrow); + const timezone = getEnvConfig().TZ; + return findLeaderboardWithTimezone("daily", timezone); } /** * 查询本月消耗排行榜(不限制数量) + * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于配置时区(Asia/Shanghai) */ export async function findMonthlyLeaderboard(): Promise { - const today = new Date(); - const startTime = new Date(today.getFullYear(), today.getMonth(), 1); - const endTime = new Date(today.getFullYear(), today.getMonth() + 1, 1); - - return findLeaderboard(startTime, endTime); + const timezone = getEnvConfig().TZ; + return findLeaderboardWithTimezone("monthly", timezone); } /** - * 通用排行榜查询函数(不限制返回数量) + * 通用排行榜查询函数(使用 SQL AT TIME ZONE 确保时区正确) */ -async function findLeaderboard(startTime: Date, endTime: Date): Promise { +async function findLeaderboardWithTimezone( + period: "daily" | "monthly", + timezone: string +): Promise { const rankings = await db .select({ userId: messageRequest.userId, @@ -63,15 +75,14 @@ async function findLeaderboard(startTime: Date, endTime: Date): Promise ({ userId: entry.userId, userName: entry.userName, @@ -80,3 +91,77 @@ async function findLeaderboard(startTime: Date, endTime: Date): Promise { + const timezone = getEnvConfig().TZ; + return findProviderLeaderboardWithTimezone("daily", timezone); +} + +/** + * 查询本月供应商消耗排行榜(不限制数量) + * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于配置时区(Asia/Shanghai) + */ +export async function findMonthlyProviderLeaderboard(): Promise { + const timezone = getEnvConfig().TZ; + return findProviderLeaderboardWithTimezone("monthly", timezone); +} + +/** + * 通用供应商排行榜查询函数(使用 SQL AT TIME ZONE 确保时区正确) + */ +async function findProviderLeaderboardWithTimezone( + period: "daily" | "monthly", + timezone: string +): Promise { + const rankings = await db + .select({ + providerId: messageRequest.providerId, + providerName: providers.name, + totalRequests: sql`count(*)::double precision`, + totalCost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, + totalTokens: sql`COALESCE( + sum( + ${messageRequest.inputTokens} + + ${messageRequest.outputTokens} + + COALESCE(${messageRequest.cacheCreationInputTokens}, 0) + + COALESCE(${messageRequest.cacheReadInputTokens}, 0) + )::double precision, + 0::double precision + )`, + successRate: sql`COALESCE( + count(CASE WHEN ${messageRequest.errorMessage} IS NULL OR ${messageRequest.errorMessage} = '' THEN 1 END)::double precision + / NULLIF(count(*)::double precision, 0), + 0::double precision + )`, + avgResponseTime: sql`COALESCE(avg(${messageRequest.durationMs})::double precision, 0::double precision)`, + }) + .from(messageRequest) + .innerJoin( + providers, + and(sql`${messageRequest.providerId} = ${providers.id}`, isNull(providers.deletedAt)) + ) + .where( + and( + isNull(messageRequest.deletedAt), + period === "daily" + ? sql`(${messageRequest.createdAt} AT TIME ZONE ${timezone})::date = (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date` + : sql`date_trunc('month', ${messageRequest.createdAt} AT TIME ZONE ${timezone}) = date_trunc('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})` + ) + ) + .groupBy(messageRequest.providerId, providers.name) + .orderBy(desc(sql`sum(${messageRequest.costUsd})`)); + + return rankings.map((entry) => ({ + providerId: entry.providerId, + providerName: entry.providerName, + totalRequests: entry.totalRequests, + totalCost: parseFloat(entry.totalCost), + totalTokens: entry.totalTokens, + successRate: entry.successRate ?? 0, + avgResponseTime: entry.avgResponseTime ?? 0, + })); +} diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 08df9f8b1..546cfca28 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -36,6 +36,8 @@ export async function createProvider(providerData: CreateProviderData): Promise< providerData.circuit_breaker_half_open_success_threshold ?? 2, proxyUrl: providerData.proxy_url ?? null, proxyFallbackToDirect: providerData.proxy_fallback_to_direct ?? false, + websiteUrl: providerData.website_url ?? null, + faviconUrl: providerData.favicon_url ?? null, tpm: providerData.tpm, rpm: providerData.rpm, rpd: providerData.rpd, @@ -66,6 +68,8 @@ export async function createProvider(providerData: CreateProviderData): Promise< circuitBreakerHalfOpenSuccessThreshold: providers.circuitBreakerHalfOpenSuccessThreshold, proxyUrl: providers.proxyUrl, proxyFallbackToDirect: providers.proxyFallbackToDirect, + websiteUrl: providers.websiteUrl, + faviconUrl: providers.faviconUrl, tpm: providers.tpm, rpm: providers.rpm, rpd: providers.rpd, @@ -107,6 +111,8 @@ export async function findProviderList( circuitBreakerHalfOpenSuccessThreshold: providers.circuitBreakerHalfOpenSuccessThreshold, proxyUrl: providers.proxyUrl, proxyFallbackToDirect: providers.proxyFallbackToDirect, + websiteUrl: providers.websiteUrl, + faviconUrl: providers.faviconUrl, tpm: providers.tpm, rpm: providers.rpm, rpd: providers.rpd, @@ -155,6 +161,8 @@ export async function findProviderById(id: number): Promise { circuitBreakerHalfOpenSuccessThreshold: providers.circuitBreakerHalfOpenSuccessThreshold, proxyUrl: providers.proxyUrl, proxyFallbackToDirect: providers.proxyFallbackToDirect, + websiteUrl: providers.websiteUrl, + faviconUrl: providers.faviconUrl, tpm: providers.tpm, rpm: providers.rpm, rpd: providers.rpd, @@ -221,6 +229,8 @@ export async function updateProvider( if (providerData.proxy_url !== undefined) dbData.proxyUrl = providerData.proxy_url; if (providerData.proxy_fallback_to_direct !== undefined) dbData.proxyFallbackToDirect = providerData.proxy_fallback_to_direct; + if (providerData.website_url !== undefined) dbData.websiteUrl = providerData.website_url; + if (providerData.favicon_url !== undefined) dbData.faviconUrl = providerData.favicon_url; if (providerData.tpm !== undefined) dbData.tpm = providerData.tpm; if (providerData.rpm !== undefined) dbData.rpm = providerData.rpm; if (providerData.rpd !== undefined) dbData.rpd = providerData.rpd; @@ -291,37 +301,20 @@ export async function getProviderStatistics(): Promise< }> > { try { - // 时区感知的时间处理 - // 获取今日时间范围(本地时区) + // 统一的时区处理:使用 PostgreSQL AT TIME ZONE + 环境变量 TZ + // 参考 getUserStatisticsFromDB 的实现,避免 Node.js Date 带来的时区偏移 const timezone = getEnvConfig().TZ; - const today = new Date(); - today.setHours(0, 0, 0, 0); - const tomorrow = new Date(today); - tomorrow.setDate(tomorrow.getDate() + 1); - - // 从 Date 对象提取本地时间(不要用 toISOString,那会转换为 UTC) - const todayYear = today.getFullYear(); - const todayMonth = String(today.getMonth() + 1).padStart(2, "0"); - const todayDay = String(today.getDate()).padStart(2, "0"); - const todayLocalStr = `${todayYear}-${todayMonth}-${todayDay} 00:00:00`; - - const tomorrowYear = tomorrow.getFullYear(); - const tomorrowMonth = String(tomorrow.getMonth() + 1).padStart(2, "0"); - const tomorrowDay = String(tomorrow.getDate()).padStart(2, "0"); - const tomorrowLocalStr = `${tomorrowYear}-${tomorrowMonth}-${tomorrowDay} 00:00:00`; const query = sql` WITH provider_stats AS ( SELECT p.id, COALESCE( - SUM(CASE WHEN (mr.created_at AT TIME ZONE 'UTC' AT TIME ZONE ${timezone})::timestamp >= ${todayLocalStr}::timestamp - AND (mr.created_at AT TIME ZONE 'UTC' AT TIME ZONE ${timezone})::timestamp < ${tomorrowLocalStr}::timestamp + SUM(CASE WHEN (mr.created_at AT TIME ZONE ${timezone})::date = (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date THEN mr.cost_usd ELSE 0 END), 0 ) AS today_cost, - COUNT(CASE WHEN (mr.created_at AT TIME ZONE 'UTC' AT TIME ZONE ${timezone})::timestamp >= ${todayLocalStr}::timestamp - AND (mr.created_at AT TIME ZONE 'UTC' AT TIME ZONE ${timezone})::timestamp < ${tomorrowLocalStr}::timestamp + COUNT(CASE WHEN (mr.created_at AT TIME ZONE ${timezone})::date = (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date THEN 1 END)::integer AS today_calls FROM providers p LEFT JOIN message_request mr ON p.id = mr.provider_id @@ -357,7 +350,9 @@ export async function getProviderStatistics(): Promise< count: Array.isArray(result) ? result.length : 0, }); - // postgres-js 返回的结果需要通过 unknown 进行类型断言 + // 注意:返回结果中的 today_cost 为 numeric,使用字符串表示; + // last_call_time 由数据库返回为时间戳(UTC)。 + // 这里保持原样,交由上层进行展示格式化。 return result as unknown as Array<{ id: number; today_cost: string; diff --git a/src/types/key.ts b/src/types/key.ts index b455187c8..7e0c23c9d 100644 --- a/src/types/key.ts +++ b/src/types/key.ts @@ -31,7 +31,7 @@ export interface CreateKeyData { name: string; key: string; is_enabled?: boolean; - expires_at?: Date; + expires_at?: Date | null; // null = 永不过期 // Web UI 登录权限控制 can_login_web_ui?: boolean; // 金额限流配置 @@ -47,7 +47,7 @@ export interface CreateKeyData { export interface UpdateKeyData { name?: string; is_enabled?: boolean; - expires_at?: Date; + expires_at?: Date | null; // null = 清除日期(永不过期) // Web UI 登录权限控制 can_login_web_ui?: boolean; // 金额限流配置 diff --git a/src/types/message.ts b/src/types/message.ts index 57e08573b..f3c7d2234 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -18,7 +18,8 @@ export interface ProviderChainItem { | "retry_failed" // 重试失败(供应商错误,已计入熔断器) | "system_error" // 系统/网络错误(不计入熔断器) | "retry_with_official_instructions" // Codex instructions 自动重试(官方) - | "retry_with_cached_instructions"; // Codex instructions 智能重试(缓存) + | "retry_with_cached_instructions" // Codex instructions 智能重试(缓存) + | "client_error_non_retryable"; // 不可重试的客户端错误(Prompt 超限、内容过滤、PDF 限制、Thinking 格式) // === 选择方法(细化) === selectionMethod?: @@ -73,6 +74,9 @@ export interface ProviderChainItem { errorSyscall?: string; // 如 "getaddrinfo" errorStack?: string; // 堆栈前3行 }; + + // 客户端输入错误(不可重试) + clientError?: string; // 详细的客户端错误消息(包含匹配的白名单模式) }; // === 决策上下文(完整记录) === diff --git a/src/types/provider.ts b/src/types/provider.ts index 5d007ffd6..f8fd20e9f 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -51,6 +51,10 @@ export interface Provider { proxyUrl: string | null; proxyFallbackToDirect: boolean; + // 供应商官网地址(用于快速跳转管理) + websiteUrl: string | null; + faviconUrl: string | null; + // 废弃(保留向后兼容,但不再使用) // TPM (Tokens Per Minute): 每分钟可处理的文本总量 tpm: number | null; @@ -99,6 +103,9 @@ export interface ProviderDisplay { // 代理配置 proxyUrl: string | null; proxyFallbackToDirect: boolean; + // 供应商官网地址 + websiteUrl: string | null; + faviconUrl: string | null; // 废弃字段(保留向后兼容) tpm: number | null; rpm: number | null; @@ -149,6 +156,10 @@ export interface CreateProviderData { proxy_url?: string | null; proxy_fallback_to_direct?: boolean; + // 供应商官网地址 + website_url?: string | null; + favicon_url?: string | null; + // 废弃字段(保留向后兼容) // TPM (Tokens Per Minute): 每分钟可处理的文本总量 tpm: number | null; @@ -196,6 +207,10 @@ export interface UpdateProviderData { proxy_url?: string | null; proxy_fallback_to_direct?: boolean; + // 供应商官网地址 + website_url?: string | null; + favicon_url?: string | null; + // 废弃字段(保留向后兼容) // TPM (Tokens Per Minute): 每分钟可处理的文本总量 tpm?: number | null;