diff --git a/.env.example b/.env.example index 75d8b5a16..fe033f869 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,10 @@ APP_PORT=23000 APP_URL= # 应用访问地址(留空自动检测,生产环境建议显式配置) # 示例:https://your-domain.com 或 http://192.168.1.100:23000 +# API 测试配置 +# API 测试请求超时时间(毫秒),范围 5000-120000。未设置时默认 15000。 +API_TEST_TIMEOUT_MS=15000 + # Cookie 安全策略 # 功能说明:控制是否强制 HTTPS Cookie(设置 cookie 的 secure 属性) # - true (默认):仅允许 HTTPS 传输 Cookie,浏览器会自动放行 localhost 的 HTTP diff --git a/AGENTS.md b/AGENTS.md index 0d0c9e7c8..44678bb4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,44 +1,157 @@ -# Repository Guidelines +# CLAUDE.md -## Project Structure & Module Organization +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -- `src/app` holds Next.js routes and API handlers; UI primitives live in `src/components`. -- Shared server logic sits in `src/actions`, `src/lib`, and `src/repository`. -- Drizzle migrations and schema snapshots live under `drizzle/` with settings in `drizzle.config.ts`. -- Static assets stay in `public/`; deployment helpers live in `deploy/`, `docker-compose.yaml`, and the `Makefile`. -- Docker volumes write into `data/`; treat it as runtime-only. +## Project Overview -## Build, Test, and Development Commands +Claude Code Hub is an AI API proxy platform built with Next.js 15 + Hono + PostgreSQL + Redis. It provides multi-provider management, intelligent load balancing, real-time monitoring, and OpenAPI documentation for Claude/OpenAI compatible APIs. -- `bun run dev` — starts Next.js (port 13500) with Turbo for local work. -- `bun run build` / `bun run start` — compiles the standalone production bundle and serves it. -- `bun run lint`, `bun run typecheck`, `bun run format:check` — run ESLint 9, TypeScript `--noEmit`, and Prettier verification; treat failures as blockers. -- `bun run db:generate`, `bun run db:migrate`, `bun run db:push`, `bun run db:studio` — Drizzle Kit for schema evolution. -- `docker compose up -d` — spins up the full stack (app, Postgres, Redis) for parity testing. +## Development Commands -## Coding Style & Naming Conventions +```bash +# Install dependencies +bun install -- 2-space indentation, trailing commas, and single quotes follow Prettier and `eslint.config.mjs`. -- React components use PascalCase (`UsageChart.tsx`); hooks and utilities stay camelCase; route folders remain kebab-case. -- Prefer `async/await`, keep server actions inside `src/actions`, and co-locate Tailwind classes with the JSX they style. -- Run `bun run format` before submitting wide-ranging edits. +# Development server (port 13500 with Turbo) +bun run dev -## Testing Guidelines +# Build for production +bun run build -- Today we rely on `bun run lint` and `bun run typecheck` plus manual checks through `/admin`. -- Smoke API changes with `curl -H "Authorization: Bearer " http://localhost:13500/api/providers`. -- New automated tests should follow `*.spec.ts` naming, live next to the feature, and wire into `package.json` scripts. -- Note any seed data or feature flags in the PR description so reviewers can reproduce your scenario. +# Type checking +bun run typecheck -## Commit & Pull Request Guidelines +# Linting +bun run lint -- Follow Conventional commits (`fix:`, `chore:`, `feat:`) as seen in `git log`; keep subjects under 72 characters. -- Body text should note user impact plus migration or environment changes. -- PRs must include a short summary, screenshots or JSON samples for UI/API updates, links to issues, and migration callouts. -- Rebase onto `main`, run `bun run lint && bun run typecheck`, and flag anything that blocks deploy parity. +# Format code +bun run format -## Security & Configuration Tips +# Database commands +bun run db:generate # Generate Drizzle migrations +bun run db:migrate # Run migrations +bun run db:push # Push schema changes +bun run db:studio # Open Drizzle Studio +``` -- Start from `.env.example`, rotate `ADMIN_TOKEN` before sharing previews, and scope provider keys to least privilege. -- Keep Redis, Postgres, and upstream tokens in secrets management—never in Git commits. -- Prefer `bun run dev` with mock providers when debugging rather than production credentials. +### Local Development with Docker + +```bash +cd dev +make dev # Start PostgreSQL + Redis + bun dev +make db # Start only database and Redis +make migrate # Run database migrations +make clean # Clean all resources +``` + +## Architecture + +### Request Flow + +``` +Client Request → Next.js API Route (/v1) + → ProxySession (context creation) + → GuardPipeline (auth → version → session → sensitive → rateLimit → provider) + → ProxyForwarder (request forwarding with format conversion) + → ResponseHandler (streaming/non-streaming response) +``` + +### Key Directories + +- `src/app/v1/_lib/` - Proxy core: handlers, guards, converters, forwarders +- `src/actions/` - Server Actions (business logic, exposed via OpenAPI) +- `src/repository/` - Database queries (Drizzle ORM) +- `src/lib/` - Shared utilities: rate-limit, circuit-breaker, session, logger +- `src/drizzle/` - Database schema and migrations +- `src/app/api/actions/` - OpenAPI documentation generation + +### Provider Types + +The system supports multiple provider types via `providerType` field: + +- `claude` - Anthropic API (standard) +- `claude-auth` - Claude relay services (Bearer auth only) +- `codex` - Codex CLI (Response API) +- `gemini-cli` - Gemini CLI +- `openai-compatible` - OpenAI Compatible APIs + +### Format Converters + +Located in `src/app/v1/_lib/converters/`, these handle bidirectional conversion between: + +- Claude Messages API +- OpenAI Chat Completions API +- Codex Response API +- Gemini CLI format + +### Guard Pipeline + +The `GuardPipelineBuilder` in `src/app/v1/_lib/proxy/guard-pipeline.ts` orchestrates request processing: + +1. `auth` - API key validation +2. `version` - Client version check +3. `probe` - Handle probe requests +4. `session` - Session management (5-min context caching) +5. `sensitive` - Content filtering +6. `rateLimit` - Multi-dimensional rate limiting (RPM, USD limits) +7. `provider` - Provider selection (weight + priority + circuit breaker) +8. `messageContext` - Request logging + +### Database Schema + +Core tables in `src/drizzle/schema.ts`: + +- `users` - User accounts with quota limits +- `keys` - API keys with per-key limits +- `providers` - Upstream provider configurations +- `messageRequest` - Request logs with token/cost tracking +- `modelPrices` - Model pricing data (LiteLLM sync) +- `errorRules` - Error classification rules +- `sensitiveWords` - Content filtering rules + +### OpenAPI Documentation + +Server Actions are automatically exposed as REST endpoints via `src/app/api/actions/[...route]/route.ts`: + +- Swagger UI: `/api/actions/docs` +- Scalar UI: `/api/actions/scalar` +- OpenAPI JSON: `/api/actions/openapi.json` + +## Configuration + +Key environment variables (see `.env.example`): + +- `ADMIN_TOKEN` - Admin login token (required) +- `DSN` - PostgreSQL connection string +- `REDIS_URL` - Redis for rate limiting and sessions +- `AUTO_MIGRATE` - Enable automatic DB migrations +- `ENABLE_RATE_LIMIT` - Enable rate limiting features +- `SESSION_TTL` - Session cache duration (default 300s) + +## Important Patterns + +### Path Alias + +All imports use `@/*` alias mapping to `./src/*`. + +### i18n + +Internationalization via `next-intl` with messages in `/messages/{locale}/`. + +### Rate Limiting + +Redis Lua scripts ensure atomic operations. Fail-open strategy when Redis unavailable. + +### Circuit Breaker + +Per-provider circuit breaker with configurable thresholds. States: CLOSED → OPEN → HALF_OPEN. + +### Session Stickiness + +5-minute session caching to maintain provider consistency within conversations. + +## PR Guidelines + +- All PRs must target `dev` branch (never `main` directly) +- Run `bun run lint && bun run typecheck` before committing +- Follow Conventional Commits format (feat/fix/chore/refactor/test) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5476acab..d233bab95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,7 @@ All notable changes to this project will be documented in this file. - Add real-time monitoring big screen dashboard with live metrics, 24h trends, provider slots status, and activity stream (#184) @ding113 - Add dark mode support with theme switcher in Dashboard and settings pages (#171) @ding113 -- Add dark mode support to provider quota management page (#170) @ding113 - -### Changed - -- Merge dev to main with internationalization improvements (Japanese, Russian, Traditional Chinese) and UI enhancements for daily limit dialogs (#182) @ding113 -- Refactor provider quota management page from card layout to compact list layout with circular progress indicators, search, and sorting capabilities (#170) @ding113 +- Add MCP (Model Context Protocol) passthrough functionality to forward tool calls to third-party AI services (#193) @ding113 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 0ed3b3690..7e655ed1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,914 +2,153 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## 项目简介 +## Project Overview -Claude Code Hub 是一个 Claude Code API 代理中转服务平台,用于统一管理多个 AI 服务提供商(支持 Claude Code 格式和 OpenAI 兼容格式),提供智能负载均衡、用户权限管理、使用统计和实时监控功能。 +Claude Code Hub is an AI API proxy platform built with Next.js 15 + Hono + PostgreSQL + Redis. It provides multi-provider management, intelligent load balancing, real-time monitoring, and OpenAPI documentation for Claude/OpenAI compatible APIs. -本项目基于 [zsio/claude-code-hub](https://github.com/zsio/claude-code-hub) 进行了增强,新增了: - -- **自动化 API 文档生成**(OpenAPI 3.1.0 + Swagger/Scalar UI 双界面,39 个 REST API 端点) -- **价格表分页查询**(支持大规模数据,搜索防抖,SQL 层面性能优化) -- 详细日志记录、并发控制、多时段限流、熔断保护、决策链追踪、OpenAI 兼容等功能 - -使用中文和用户沟通。 - -## 常用命令 - -### 开发命令 - -```bash -bun run dev # 启动开发服务器 (http://localhost:13500, 使用 Turbopack) -bun run build # 构建生产版本 (自动复制 VERSION 文件) -bun run start # 启动生产服务器 -bun run lint # 运行 ESLint -bun run typecheck # TypeScript 类型检查 -bun run format # 格式化代码 -bun run format:check # 检查代码格式 -``` - -### 数据库命令 - -```bash -bun run db:generate # 生成 Drizzle 迁移文件 -bun run db:migrate # 执行数据库迁移 -bun run db:push # 直接推送 schema 到数据库(开发环境) -bun run db:studio # 启动 Drizzle Studio 可视化管理界面 -``` - -### Docker 部署 - -```bash -docker compose up -d # 启动所有服务(后台运行) -docker compose logs -f # 查看所有服务日志 -docker compose logs -f app # 仅查看应用日志 -docker compose restart app # 重启应用 -docker compose pull && docker compose up -d # 升级到最新版本 -docker compose down # 停止并删除容器 -``` - -### 本地开发工具(推荐) - -本项目提供了完整的本地开发工具集(位于 `dev/` 目录),可以快速启动开发环境、测试部署流程和清理资源。 - -**快速开始**: - -```bash -cd dev -make help # 查看所有可用命令 -make dev # 一键启动完整开发环境 -``` - -**常用命令**: +## Development Commands ```bash -# 环境管理 -make dev # 启动完整开发环境 (DB + bun dev) -make db # 仅启动数据库和 Redis -make stop # 停止所有服务 -make status # 查看服务状态 - -# 镜像构建和测试 -make build # 构建 Docker 镜像 -make compose # 启动三容器完整编排 - -# 数据库操作 -make migrate # 执行数据库迁移 -make db-shell # 进入 PostgreSQL shell -make redis-shell # 进入 Redis CLI - -# 日志查看 -make logs # 查看所有服务日志 -make logs-app # 查看应用日志 - -# 清理和重置 -make clean # 一键清理所有资源 -make reset # 完全重置 (clean + dev) -``` - -**开发环境配置**: - -- PostgreSQL: `localhost:5433` (避免与本地 5432 冲突) -- Redis: `localhost:6380` (避免与本地 6379 冲突) -- 应用: `http://localhost:13500` (Turbopack 开发服务器) -- 管理员 Token: `dev-admin-token` - -**完整文档**: 详见 `dev/README.md` - -### API 文档 - -```bash -# 访问 API 文档(需要先登录管理后台) -open http://localhost:13500/api/actions/scalar # Scalar UI(推荐) -open http://localhost:13500/api/actions/docs # Swagger UI - -# 获取 OpenAPI 规范 -curl http://localhost:13500/api/actions/openapi.json > openapi.json - -# 健康检查 -curl http://localhost:13500/api/actions/health -``` - -## 核心技术栈 - -- **Next.js 15** (App Router) + **React 19** + **TypeScript** -- **Hono** - 用于 API 路由处理 -- **Drizzle ORM** + **PostgreSQL** - 数据持久化 -- **Redis** + **ioredis** - 限流、会话追踪、熔断器 -- **Tailwind CSS v4** + **Shadcn UI** (orange 主题) - UI 框架 -- **Pino** - 结构化日志 -- **包管理器**: Bun 1.3+ - -## 架构概览 - -### 目录结构 - -``` -src/ -├── app/ # Next.js App Router -│ ├── v1/ # API 代理核心逻辑 -│ │ ├── _lib/ -│ │ │ ├── proxy/ # Claude Code 格式代理 (guards, session, forwarder) -│ │ │ ├── codex/ # OpenAI 兼容层 (chat/completions) -│ │ │ └── proxy-handler.ts # 代理请求主入口 -│ │ └── [...route]/route.ts # 动态路由处理器 -│ ├── dashboard/ # 仪表盘 (统计、日志、排行榜、实时监控) -│ ├── settings/ # 设置页面 (用户、供应商、价格、系统配置) -│ │ └── prices/ # 价格表页面(支持分页查询) -│ └── api/ # 内部 API -│ ├── actions/[...route]/ # 自动化 API 文档系统 (OpenAPI 3.1.0) -│ ├── prices/ # 价格表分页 API -│ └── auth, admin, ... # 认证、管理、排行榜、版本等 -├── lib/ # 核心业务逻辑 -│ ├── api/ -│ │ └── action-adapter-openapi.ts # OpenAPI 自动生成核心适配器 -│ ├── hooks/ -│ │ └── use-debounce.ts # 搜索防抖 Hook -│ ├── circuit-breaker.ts # 熔断器 (内存实现) -│ ├── session-manager.ts # Session 追踪和缓存 -│ ├── rate-limit/ # 限流服务 (Redis + Lua 脚本) -│ ├── redis/ # Redis 客户端和工具 -│ ├── proxy-status-tracker.ts # 实时代理状态追踪 -│ └── price-sync.ts # LiteLLM 价格同步 -├── repository/ # 数据访问层 (Drizzle ORM) -│ └── model-price.ts # 模型价格查询(含分页方法) -├── drizzle/ # 数据库 schema 和迁移 -├── types/ # TypeScript 类型定义 -└── components/ # React UI 组件 -``` - -### 代理请求处理流程 - -代理请求经过以下步骤 (参见 `src/app/v1/_lib/proxy-handler.ts`): - -1. **认证检查** (`ProxyAuthenticator`) - 验证 API Key -2. **Session 分配** (`ProxySessionGuard`) - 并发 Session 限制检查 -3. **限流检查** (`ProxyRateLimitGuard`) - RPM + 金额限制 (5小时/周/月) -4. **供应商选择** (`ProxyProviderResolver`) - 智能选择和故障转移 - - Session 复用(5分钟缓存) - - 权重 + 优先级 + 分组 - - 熔断器状态检查 - - 并发限制检查(原子性操作) - - 故障转移循环(最多 3 次重试) -5. **消息服务** (`ProxyMessageService`) - 创建消息上下文和日志记录 -6. **请求转发** (`ProxyForwarder`) - 转发到上游供应商 -7. **响应处理** (`ProxyResponseHandler`) - 流式/非流式响应处理 -8. **错误处理** (`ProxyErrorHandler`) - 统一错误处理和熔断器记录 - -### OpenAI 兼容层 - -支持 `/v1/chat/completions` 端点 (参见 `src/app/v1/_lib/codex/chat-completions-handler.ts`): - -- 自动检测 OpenAI 格式 (`messages`) 和 Response API 格式 (`input`) -- OpenAI → Response API 转换 (`RequestTransformer`) -- Codex CLI instructions 注入 (`adaptForCodexCLI`) -- Response API → OpenAI 转换 (`ResponseTransformer`) -- 支持 `tools`、`reasoning`、`stream` 等功能 - -### 熔断器机制 - -内存实现的熔断器 (`src/lib/circuit-breaker.ts`): - -- **状态机**: Closed → Open → Half-Open → Closed -- **阈值**: 失败 5 次后打开,持续 30 分钟 -- **半开状态**: 成功 2 次后关闭 -- 自动记录失败并打开熔断器 -- 供应商选择时跳过已打开的熔断器 - -### 限流策略 - -多层限流 (`src/lib/rate-limit/service.ts`): - -1. **RPM 限流** - 用户级别每分钟请求数 -2. **金额限流** - 用户/密钥/供应商级别的 5小时/周/月 限制 -3. **并发 Session 限流** - 用户/供应商级别的并发会话数 -4. **Redis Lua 脚本** - 原子性检查和递增(解决竞态条件) -5. **Fail Open 策略** - Redis 不可用时降级,不影响服务 - -### Redis Key 架构 - -#### 日限额 Redis Key 命名规范 - -本系统使用不同的 Redis 数据结构来实现固定窗口和滚动窗口的日限额追踪。理解这些命名规范对于调试、监控和故障排查至关重要。 +# Install dependencies +bun install -**核心设计原则**: +# Development server (port 13500 with Turbo) +bun run dev -- 固定窗口使用 STRING 类型,支持自定义重置时间 -- 滚动窗口使用 ZSET 类型,提供精确的时间窗口计算 +# Build for production +bun run build -#### Key 命名模式 +# Type checking +bun run typecheck -**1. 固定时间窗口 (Fixed Mode)** +# Linting +bun run lint -格式:`{type}:{id}:cost_daily_{suffix}` +# Format code +bun run format +# Database commands +bun run db:generate # Generate Drizzle migrations +bun run db:migrate # Run migrations +bun run db:push # Push schema changes +bun run db:studio # Open Drizzle Studio ``` -示例: - key:123:cost_daily_1800 # Key ID 123,每天 18:00 重置 - key:456:cost_daily_0000 # Key ID 456,每天 00:00 重置 - provider:789:cost_daily_0930 # Provider ID 789,每天 09:30 重置 -``` - -**特性**: - -- Redis 类型:STRING -- 操作命令:INCRBYFLOAT(累加)、GET(查询) -- Suffix 规则:重置时间去掉冒号(HH:mm → HHmm) -- TTL 策略:动态计算到下一个重置时间的秒数 -- 用例:支持不同用户/供应商的自定义重置时间 - -**为什么需要 Suffix?** - -不同用户可能配置不同的重置时间: - -- 用户 A 配置 18:00 重置 → `key:1:cost_daily_1800` -- 用户 B 配置 00:00 重置 → `key:2:cost_daily_0000` - -如果省略 suffix,两个用户会使用相同的 key 模式,导致 TTL 冲突和数据混乱。 - -**2. 滚动时间窗口 (Rolling Mode)** - -格式:`{type}:{id}:cost_daily_rolling` - -``` -示例: - key:123:cost_daily_rolling # Key ID 123,滚动 24 小时窗口 - provider:456:cost_daily_rolling # Provider ID 456,滚动 24 小时窗口 -``` - -**特性**: - -- Redis 类型:ZSET(Sorted Set) -- 操作命令:Lua 脚本(ZADD + ZREMRANGEBYSCORE + ZRANGE) -- Suffix 规则:固定使用 `rolling`,无时间后缀 -- TTL 策略:固定 24 小时(86400 秒) -- 用例:真正的滚动窗口,统计"过去 24 小时"的消费 -**为什么不需要 Suffix?** - -滚动窗口没有固定的重置时间点: - -- 每次查询都是"当前时间往前推 24 小时" -- 所有用户使用相同的窗口计算逻辑 -- TTL 固定为 24 小时,无需区分重置时间 - -**3. 其他时间周期** - -``` -5 小时滚动窗口(ZSET): - key:123:cost_5h_rolling - provider:456:cost_5h_rolling - -周限额(STRING,每周一 00:00 重置): - key:123:cost_weekly - provider:456:cost_weekly - -月限额(STRING,每月 1 号 00:00 重置): - key:123:cost_monthly - provider:456:cost_monthly -``` - -#### 数据结构对比 - -| 模式 | Redis 类型 | 命名示例 | TTL 策略 | 时间精度 | 操作复杂度 | -| -------- | ---------- | ---------------------------- | ------------------ | -------- | ---------- | -| 固定窗口 | STRING | `cost_daily_1800` | 动态(到重置时间) | 分钟级 | 简单 | -| 滚动窗口 | ZSET | `cost_daily_rolling` | 固定(24h) | 毫秒级 | 中等 | -| 5h 滚动 | ZSET | `cost_5h_rolling` | 固定(5h) | 毫秒级 | 中等 | -| 周/月 | STRING | `cost_weekly`/`cost_monthly` | 动态(到下周期) | 分钟级 | 简单 | - -#### 实现细节 - -**固定窗口操作流程**: - -```typescript -// 1. 写入消费数据(累加) -const key = `key:${keyId}:cost_daily_1800`; -const ttl = calculateTTLToNextReset("18:00"); // 计算到下一个 18:00 的秒数 -await redis.incrbyfloat(key, cost); -await redis.expire(key, ttl); - -// 2. 查询当前消费 -const current = await redis.get(key); -``` - -**滚动窗口操作流程**: - -```typescript -// 1. 写入消费数据(使用 Lua 脚本) -const key = `key:${keyId}:cost_daily_rolling`; -const now = Date.now(); -const window = 24 * 60 * 60 * 1000; // 24 小时 -await redis.eval(TRACK_COST_DAILY_ROLLING_WINDOW, 1, key, cost, now, window); - -// 2. 查询当前消费(使用 Lua 脚本) -const current = await redis.eval(GET_COST_DAILY_ROLLING_WINDOW, 1, key, now, window); -``` - -**Lua 脚本优势**: - -- 原子性:查询、清理过期数据、累加在一个操作内完成 -- 精确性:基于毫秒级时间戳,避免边界问题 -- 性能:减少网络往返次数 - -#### 调试和监控 - -**检查 Redis Key**: +### Local Development with Docker ```bash -# 查看所有日限额 key(固定窗口) -redis-cli KEYS "*:cost_daily_*" - -# 查看滚动窗口 key -redis-cli KEYS "*:cost_daily_rolling" - -# 查看具体 key 的值 -redis-cli GET "key:123:cost_daily_1800" - -# 查看 ZSET 的详细数据 -redis-cli ZRANGE "key:123:cost_daily_rolling" 0 -1 WITHSCORES -``` - -**常见问题排查**: - -1. **Key 不存在**: - - 原因:Redis 重启导致数据丢失 - - 解决:系统会自动从数据库恢复(Cache Warming) - -2. **TTL 异常**: - - 检查:`redis-cli TTL "key:123:cost_daily_1800"` - - 预期:固定窗口为动态值,滚动窗口为 86400 - -3. **消费统计不准确**: - - 固定窗口:检查重置时间配置是否正确 - - 滚动窗口:检查 ZSET 中的时间戳范围 - -#### 相关文件 - -- **核心实现**:`src/lib/rate-limit/service.ts`(包含详细注释) -- **Lua 脚本**:`src/lib/redis/lua-scripts.ts` -- **时间工具**:`src/lib/rate-limit/time-utils.ts` -- **数据库层**:`src/repository/statistics.ts` - -### Session 管理 - -Session 追踪和缓存 (`src/lib/session-manager.ts`): - -- **5 分钟上下文缓存** - 避免频繁切换供应商 -- **并发 Session 计数** - Redis 原子性追踪 -- **决策链记录** - 完整的供应商选择和失败切换记录 -- **自动清理** - TTL 过期自动清理 - -### 代理支持 - -供应商级别的代理配置 (`src/lib/proxy-agent.ts`): - -- **支持协议**: HTTP、HTTPS、SOCKS4、SOCKS5 -- **配置粒度**: 每个供应商独立配置代理 -- **自动检测**: 根据 URL 协议自动选择代理类型(HTTP/HTTPS 使用 undici ProxyAgent,SOCKS 使用 socks-proxy-agent) -- **故障降级**: 可配置代理失败时是否降级到直连(`proxy_fallback_to_direct` 字段) -- **连接测试**: UI 提供测试按钮,使用 HEAD 请求验证代理配置 -- **安全性**: 日志中自动脱敏代理密码 - -**配置方式**: - -在供应商管理页面的"代理配置"部分: - -1. **代理地址** (`proxy_url`): 支持以下格式 - - HTTP: `http://proxy.example.com:8080` - - HTTPS: `https://proxy.example.com:8080` - - SOCKS4: `socks4://127.0.0.1:1080` - - SOCKS5: `socks5://user:password@proxy.example.com:1080` - -2. **降级策略** (`proxy_fallback_to_direct`): - - 启用: 代理连接失败时自动尝试直连 - - 禁用: 代理失败直接报错,不降级 - -3. **测试连接**: 点击"测试连接"按钮验证配置,显示: - - 连接成功/失败状态 - - HTTP 状态码 - - 响应时间 - - 是否使用代理 - - 错误详情(如果失败) - -**技术实现**: - -```typescript -// 代理工厂函数(src/lib/proxy-agent.ts) -export function createProxyAgentForProvider( - provider: Provider, - targetUrl: string -): ProxyConfig | null { - // 自动检测协议并创建对应的 ProxyAgent 或 SocksProxyAgent - // 返回 { agent, fallbackToDirect, proxyUrl } -} - -// 请求转发层集成(src/app/v1/_lib/proxy/forwarder.ts) -const proxyConfig = createProxyAgentForProvider(provider, proxyUrl); -if (proxyConfig) { - init.dispatcher = proxyConfig.agent; // undici dispatcher - - // 代理失败降级逻辑 - if (proxyError && proxyConfig.fallbackToDirect) { - delete init.dispatcher; - response = await fetch(proxyUrl, init); // 直连重试 - } -} +cd dev +make dev # Start PostgreSQL + Redis + bun dev +make db # Start only database and Redis +make migrate # Run database migrations +make clean # Clean all resources ``` -**使用场景**: - -- 中国大陆访问海外 API 服务,改善连接性 -- 企业内网环境,需要通过公司代理访问外网 -- IP 限制场景,通过代理绕过 IP 封锁 - -### API 连通性测试 - -供应商管理页面提供 API 连通性测试功能,用于验证供应商 API 的可用性和配置正确性。 - -**功能位置**: - -- 路径:设置 → 供应商管理 → 编辑/创建供应商 -- 权限:仅管理员可执行 - -**支持的 API 格式**: - -1. **Anthropic Messages API** (`/v1/messages`) - - 标准 Anthropic 格式 - - 支持 cache tokens 统计 - -2. **OpenAI Chat Completions API** (`/v1/chat/completions`) - - OpenAI 聊天完成格式 - - 支持 reasoning tokens 统计 - -3. **OpenAI Responses API** (`/v1/responses`) - - OpenAI Response API 格式(Claude 4 Sonnet 同款) - - 支持 input/output tokens 统计 - -**测试内容**: - -- API 连通性(网络连接、认证) -- 响应时间测量(超时 10 秒) -- Token 用量统计 -- 模型可用性验证 -- 代理配置测试(如果配置了代理) - -**安全限制**: - -- 🔒 仅管理员可执行测试(需要登录管理后台) -- 🔒 阻止访问内部网络地址(防止 SSRF 攻击) - - localhost, 127.0.0.1 - - 内网地址段:10.x.x.x, 172.16-31.x.x, 192.168.x.x - - 链路本地地址:169.254.x.x, fe80::/10 -- 🔒 阻止访问危险端口 - - SSH (22), Telnet (23) - - 数据库端口:MySQL (3306), PostgreSQL (5432), MongoDB (27017), Redis (6379) - - 内部服务端口 - -**使用场景**: - -1. **新增供应商**:验证 API 配置是否正确 -2. **故障排查**:诊断供应商连接问题 -3. **代理测试**:验证代理配置是否生效 -4. **模型验证**:确认供应商支持的模型 - -**测试流程**: - -1. 填写供应商基本信息(URL、API 密钥、模型) -2. 选择 API 格式(根据供应商类型自动选择) -3. 点击"测试连接"按钮 -4. 等待测试结果(最长 10 秒) -5. 查看详细结果(响应时间、Token 用量、响应内容) - -**注意事项**: - -- 测试会消耗少量 Token(约 100 tokens) -- 测试失败不影响供应商保存 -- 建议在保存前先测试,确保配置正确 - -### 数据库 Schema +## Architecture -核心表结构 (`src/drizzle/schema.ts`): +### Request Flow -- **users** - 用户管理 (RPM 限制、每日额度、供应商分组) -- **keys** - API 密钥 (金额限流、并发限制、过期时间) -- **providers** - 供应商管理 (权重、优先级、成本倍数、模型重定向、并发限制) -- **messages** - 消息日志 (请求/响应、Token 使用、成本计算、决策链) -- **model_prices** - 模型价格 (支持 Claude 和 OpenAI 格式、缓存 Token 定价) -- **statistics** - 统计数据 (小时级别聚合) - -## 模型重定向详解 - -### 功能定义 - -**模型重定向**是供应商级别的配置功能,允许将 Claude Code 客户端请求的 Claude 模型名称自动重定向到上游供应商实际支持的模型。 - -### 工作原理 - -``` -Claude Code 客户端请求: claude-sonnet-4-5-20250929 - ↓ -[CCH 模型重定向] - ↓ -实际转发到上游供应商: glm-4.6 (智谱) / gemini-pro (Google) ``` - -**关键点**: - -- **源模型**(用户请求):必须是 Claude 模型(如 `claude-sonnet-4-5-20250929`、`claude-opus-4`) -- **目标模型**(实际转发):可以是任何上游供应商支持的模型(如 `glm-4.6`、`gemini-pro`、`gpt-4o`) -- **计费基准**:始终使用**源模型**(用户请求的模型)进行计费,保持用户端费用透明 - -### 配置方式 - -在**设置 → 供应商管理 → 编辑供应商**页面的"模型重定向"部分: - -1. **用户请求的模型**:输入 Claude Code 客户端请求的模型(如 `claude-sonnet-4-5-20250929`) -2. **实际转发的模型**:输入上游供应商支持的模型(如 `glm-4.6`) -3. 点击"添加"按钮保存规则 - -**配置示例**: - -```json -{ - "claude-sonnet-4-5-20250929": "glm-4.6", - "claude-opus-4": "gemini-2.5-pro", - "claude-3-5-sonnet-20241022": "gpt-4o" -} +Client Request → Next.js API Route (/v1) + → ProxySession (context creation) + → GuardPipeline (auth → version → session → sensitive → rateLimit → provider) + → ProxyForwarder (request forwarding with format conversion) + → ResponseHandler (streaming/non-streaming response) ``` -### 使用场景 - -1. **接入第三方 AI 服务** - - Claude Code 客户端只认 Anthropic 模型 - - 通过重定向,可以将请求转发到智谱、Google、OpenAI 等第三方服务 - - 用户无需修改客户端配置 - -2. **成本优化** - - 将昂贵的 Claude 模型重定向到性能相近但更便宜的第三方模型 - - 示例:`claude-opus-4` → `gemini-2.5-pro`(假设 Gemini 更便宜) - -3. **供应商切换** - - 快速切换不同供应商而不影响客户端 - - 支持 A/B 测试不同模型的效果 - -4. **模型升级管理** - - 自动将旧版本模型升级到新版本 - - 示例:`claude-3-opus` → `claude-opus-4` - -### 计费说明 - -**重要**:系统使用**源模型**(用户请求的 Claude 模型)进行计费,而不是重定向后的目标模型。 - -- **用户请求**:`claude-sonnet-4-5-20250929` -- **实际转发**:`glm-4.6` -- **计费依据**:`claude-sonnet-4-5-20250929` 的价格表 -- **数据库记录**: - - `message_request.original_model` = `claude-sonnet-4-5-20250929`(计费) - - `message_request.model` = `glm-4.6`(实际使用) - -### 技术实现 - -**数据存储**: - -- 表字段:`providers.model_redirects` (JSONB) -- 数据格式:`{ "源模型": "目标模型" }` 的键值对 - -**执行时机**: - -1. 供应商选择完成后 -2. 请求转发前 -3. `ModelRedirector.apply()` 检查并应用重定向规则(参见 `src/app/v1/_lib/proxy/model-redirector.ts`) - -**日志追踪**: - -- 重定向会在请求日志中显示"已重定向"标记 -- 详细信息包含源模型和目标模型 -- Session note 记录完整的重定向路径 +### Key Directories -### 注意事项 +- `src/app/v1/_lib/` - Proxy core: handlers, guards, converters, forwarders +- `src/actions/` - Server Actions (business logic, exposed via OpenAPI) +- `src/repository/` - Database queries (Drizzle ORM) +- `src/lib/` - Shared utilities: rate-limit, circuit-breaker, session, logger +- `src/drizzle/` - Database schema and migrations +- `src/app/api/actions/` - OpenAPI documentation generation -1. **模型兼容性**:确保目标模型的能力与源模型匹配(如支持 tools、thinking 等功能) -2. **价格配置**:需要在价格表中配置源模型的价格,用于正确计费 -3. **供应商类型**:建议配置 `joinClaudePool = true`,允许非 Anthropic 供应商加入 Claude 调度池 -4. **测试验证**:配置后建议先测试,确保重定向生效且响应格式正确 +### Provider Types -## 环境变量 +The system supports multiple provider types via `providerType` field: -关键环境变量 (参见 `.env.example`): - -```bash -# 管理员认证 -ADMIN_TOKEN=change-me # 管理后台登录令牌(必须修改) - -# 数据库配置 -DSN="postgres://..." # PostgreSQL 连接字符串 -AUTO_MIGRATE=true # 启动时自动执行迁移 - -# Redis 配置 -# - TCP:使用 redis:// -# - TLS(Upstash 等云服务):使用 rediss://,客户端会显式启用 tls: {} -REDIS_URL=redis://localhost:6379 # Redis 连接地址(本地/容器) -# 例:Upstash TLS 连接(请替换密码和主机) -# REDIS_URL=rediss://default:your_password@your-subdomain.upstash.io:6379 -ENABLE_RATE_LIMIT=true # 启用限流功能 - -# Session 配置 -SESSION_TTL=300 # Session 缓存过期时间(秒) -STORE_SESSION_MESSAGES=false # 是否存储请求 messages(用于实时监控) - -# 熔断器配置 -ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS=false # 网络错误是否计入熔断器(默认:false) - # false: 仅 HTTP 4xx/5xx 错误计入熔断器 - # true: 网络错误(DNS 失败、连接超时等)也计入熔断器 - -# Cookie 安全策略 -ENABLE_SECURE_COOKIES=true # 是否强制 HTTPS Cookie(默认:true) - # 设置为 false 允许 HTTP 访问,但会降低安全性 - -# Codex Instructions 注入(已弃用,建议使用供应商级别配置) -# ⚠️ DEPRECATED: 请在供应商管理页面配置 "Codex Instructions 策略" 替代全局开关 -# 供应商级别策略提供更精细的控制:auto(智能缓存)、force_official、keep_original -ENABLE_CODEX_INSTRUCTIONS_INJECTION=false # 是否强制替换 Codex 请求的 instructions(默认:false) - # false: 使用供应商级别策略(推荐) - # true: 全局强制使用官方 instructions(向后兼容,不推荐) - # 注意:供应商未配置策略时,此环境变量作为 fallback - -# 应用配置 -APP_PORT=23000 # 应用端口 -APP_URL= # 应用访问地址(留空自动检测,生产环境建议显式配置) - # 示例:https://your-domain.com 或 http://192.168.1.100:23000 - # 用于 OpenAPI 文档的 server URL 配置 -NODE_ENV=production # 环境模式 -TZ=Asia/Shanghai # 时区设置 -LOG_LEVEL=info # 日志级别 -``` - -### 环境变量配置注意事项 - -#### 布尔值配置的正确方式 - -**重要**: 所有布尔类型的环境变量(如 `ENABLE_SECURE_COOKIES`, `AUTO_MIGRATE`, `ENABLE_RATE_LIMIT` 等)必须使用以下值: - -- **表示 `true`**: `true`, `1`, `yes`, `on` 或任何非 `false`/`0` 的值 -- **表示 `false`**: `false`, `0` - -**常见错误**: - -```bash -# ❌ 错误 - 字符串 "false" 会被解析为 true! -ENABLE_SECURE_COOKIES="false" # 错误:引号导致字符串被当作 true - -# 正确 - 不带引号 -ENABLE_SECURE_COOKIES=false # 正确:直接写 false -ENABLE_SECURE_COOKIES=0 # 正确:也可以用 0 -``` - -**技术原因**: 项目使用 Zod 的自定义 transform 逻辑处理布尔值,而不是默认的 `z.coerce.boolean()`,因为后者会将任何非空字符串(包括 `"false"`)都强制转换为 `true`。详见 `src/lib/config/env.schema.ts:20-22` 的注释说明。 - -#### Cookie 安全策略说明 - -当通过 HTTP(非 HTTPS)访问系统时: - -1. **localhost 访问** (`http://localhost` 或 `http://127.0.0.1`) - - 即使 `ENABLE_SECURE_COOKIES=true`,现代浏览器也允许设置 Secure Cookie - - 这是浏览器的安全例外,用于方便本地开发 - -2. **远程 IP/域名访问** (`http://192.168.x.x` 或 `http://example.com`) - - 如果 `ENABLE_SECURE_COOKIES=true`,浏览器会**拒绝**设置 Cookie,导致无法登录 - - 必须设置 `ENABLE_SECURE_COOKIES=false` 才能正常使用 - - 或者配置 HTTPS 反向代理(推荐) - -#### OpenAPI 文档地址配置 - -OpenAPI 文档(`/api/actions/scalar` 和 `/api/actions/docs`)中的 server URL 配置: - -**配置方式**: - -- **生产环境(推荐)**:显式设置 `APP_URL` 环境变量 - - ```bash - APP_URL=https://your-domain.com # HTTPS 域名 - APP_URL=http://192.168.1.100:23000 # HTTP IP + 端口 - ``` - -- **开发环境**:留空即可,自动使用 `http://localhost:13500` - -**效果**: - -- 配置后,OpenAPI 文档中的 "Try it out" 功能会自动使用正确的地址 -- 避免生产环境显示 `http://localhost`,导致 API 测试失败 - -## 开发注意事项 - -### 1. Redis 依赖和降级策略 - -- **Fail Open 策略**: Redis 不可用时自动降级,限流功能失效但服务仍可用 -- 所有 Redis 操作都有 try-catch 和降级逻辑 -- 不要在 Redis 操作失败时抛出错误,应该记录日志并继续 - -### 2. 并发控制和竞态条件 - -- **原子性操作**: 使用 Redis Lua 脚本进行检查并递增(`src/lib/redis/lua-scripts.ts`) -- **Session 分配**: 先检查并追踪,失败时尝试其他供应商 -- 避免在没有原子性保证的情况下进行并发限制检查 - -### 3. 数据库迁移 - -- 使用 `bun run db:generate` 生成迁移文件 -- 生产环境使用 `AUTO_MIGRATE=true` 自动执行迁移 -- `bun run db:push` (开发) 或 `bun run db:migrate` (生产) -- 索引优化: 所有查询都有对应的复合索引(参见 schema.ts 中的 index 定义) -- 时区处理: 所有 timestamp 字段使用 `withTimezone: true` - -### 4. 时区处理 - -- 数据库统计查询使用 `AT TIME ZONE 'Asia/Shanghai'` 转换 -- 前端显示使用 `date-fns` 和 `timeago.js` -- 环境变量 `TZ` 和 `PGTZ` 统一设置为 `Asia/Shanghai` - -### 5. 成本计算 - -- 支持 Claude 格式 (`input_tokens`, `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens`) -- 支持 OpenAI 格式 (`prompt_tokens`, `completion_tokens`) -- 价格单位: USD/M tokens (百万 tokens) -- 成本倍数: 供应商级别的 `cost_multiplier` - -### 6. 日志记录 - -- 使用 Pino 结构化日志 (`src/lib/logger.ts`) -- 日志级别: `fatal` > `error` > `warn` > `info` > `debug` > `trace` -- 开发环境使用 `pino-pretty` 美化输出 -- 关键业务逻辑必须有 info 级别日志 - -### 7. 代码风格 - -- 使用 ESLint + Prettier -- 提交前运行 `bun run typecheck` 确保类型正确 -- 遵循现有代码风格(参考 `src/app/v1/_lib/proxy/` 中的代码) - -### 8. 添加新的 API 端点 - -当需要将新的 Server Action 暴露为 REST API 时: - -1. 在 `src/app/api/actions/[...route]/route.ts` 中注册: - - ```typescript - const { route, handler } = createActionRoute( - "module", - "actionName", - moduleActions.actionName, - { - requestSchema: YourZodSchema, // 可选 - responseSchema: z.object(...), // 可选 - description: "端点描述", - tags: ["标签"], - requiredRole: "admin", // 可选 - } - ); - app.openapi(route, handler); - ``` - -2. OpenAPI 文档自动更新,无需手动维护 - -3. 测试端点:访问 `/api/actions/scalar` 查看并测试 - -**核心特性**: - -- 使用 `createActionRoute()` 自动转换 Server Action 为 OpenAPI 端点 -- 复用现有 Zod schemas 进行参数验证 -- 自动生成 OpenAPI 3.1.0 规范文档 -- 统一的 `ActionResult` 响应格式 - -### 9. 价格表数据库查询优化 - -分页查询使用窗口函数和 CTE,注意: - -- `findAllLatestPricesPaginated()` - 分页版本(推荐用于大数据量) -- `findAllLatestPrices()` - 非分页版本(向后兼容,小数据量) -- 搜索使用 SQL 层面的 `ILIKE`,性能优于客户端过滤 -- 分页参数:`page`(页码)、`pageSize`(每页大小)、`search`(搜索关键词) - -**实现要点**: - -```typescript -// 使用 ROW_NUMBER() 窗口函数获取最新价格 -WITH latest_prices AS ( - SELECT model_name, MAX(created_at) as max_created_at - FROM model_prices - WHERE model_name ILIKE '%search%' - GROUP BY model_name -) -SELECT ... LIMIT 50 OFFSET 0; -``` +- `claude` - Anthropic API (standard) +- `claude-auth` - Claude relay services (Bearer auth only) +- `codex` - Codex CLI (Response API) +- `gemini` - Gemini API +- `gemini-cli` - Gemini CLI +- `openai-compatible` - OpenAI Compatible APIs -### 10. Database MCP +### Format Converters -Objective: -You are required to use the `db` MCP server to interact with a database. +Located in `src/app/v1/_lib/converters/`, these handle bidirectional conversion between: -Capabilities: -With this server, you can perform the following actions: +- Claude Messages API +- OpenAI Chat Completions API +- Codex Response API +- Gemini CLI format -- View the structure of database tables. -- Query and inspect data within the tables. +### Guard Pipeline -Prerequisite: -Before performing any operations, you must first consult the database schema definition to understand its structure. The schema is defined in the following file: @src/drizzle/schema.ts. +The `GuardPipelineBuilder` in `src/app/v1/_lib/proxy/guard-pipeline.ts` orchestrates request processing: -## 常见任务 +1. `auth` - API key validation +2. `version` - Client version check +3. `probe` - Handle probe requests +4. `session` - Session management (5-min context caching) +5. `sensitive` - Content filtering +6. `rateLimit` - Multi-dimensional rate limiting (RPM, USD limits) +7. `provider` - Provider selection (weight + priority + circuit breaker) +8. `messageContext` - Request logging -### 添加新的供应商类型 +### Database Schema -1. 在 `src/drizzle/schema.ts` 中扩展 `providerType` 枚举 -2. 在 `src/app/v1/_lib/proxy/provider-selector.ts` 中添加类型过滤逻辑 -3. 如需格式转换,在 `src/app/v1/_lib/codex/transformers/` 中添加转换器 +Core tables in `src/drizzle/schema.ts`: -### 添加新的限流维度 +- `users` - User accounts with quota limits +- `keys` - API keys with per-key limits +- `providers` - Upstream provider configurations +- `messageRequest` - Request logs with token/cost tracking +- `modelPrices` - Model pricing data (LiteLLM sync) +- `errorRules` - Error classification rules +- `sensitiveWords` - Content filtering rules -1. 在 `src/lib/rate-limit/service.ts` 中添加新的限流方法 -2. 在 `src/lib/redis/lua-scripts.ts` 中添加对应的 Lua 脚本 -3. 在 `src/app/v1/_lib/proxy/rate-limit-guard.ts` 中集成新的检查 +### OpenAPI Documentation -### 添加新的统计维度 +Server Actions are automatically exposed as REST endpoints via `src/app/api/actions/[...route]/route.ts`: -1. 在 `src/drizzle/schema.ts` 中扩展 `statistics` 表 -2. 在 `src/repository/statistics.ts` 中添加查询方法 -3. 在 `src/app/dashboard/_components/` 中添加可视化组件 +- Swagger UI: `/api/actions/docs` +- Scalar UI: `/api/actions/scalar` +- OpenAPI JSON: `/api/actions/openapi.json` -### 修改数据库 Schema +## Configuration -1. 修改 `src/drizzle/schema.ts` -2. 运行 `bun run db:generate` 生成迁移文件 -3. 检查生成的 SQL 文件 (`drizzle/` 目录) -4. 运行 `bun run db:push` (开发) 或 `bun run db:migrate` (生产) +Key environment variables (see `.env.example`): -## 故障排查 +- `ADMIN_TOKEN` - Admin login token (required) +- `DSN` - PostgreSQL connection string +- `REDIS_URL` - Redis for rate limiting and sessions +- `AUTO_MIGRATE` - Enable automatic DB migrations +- `ENABLE_RATE_LIMIT` - Enable rate limiting features +- `SESSION_TTL` - Session cache duration (default 300s) -### 数据库连接失败 +## Important Patterns -- 检查 `DSN` 环境变量格式 -- Docker 部署: 确保 postgres 服务已启动 (`docker compose ps`) -- 本地开发: 检查 PostgreSQL 服务是否运行 +### Path Alias -### Redis 连接失败 +All imports use `@/*` alias mapping to `./src/*`. -- 服务仍然可用(Fail Open 策略) -- 检查 `REDIS_URL` 环境变量 -- 查看日志中的 Redis 连接错误 -- Docker 部署: `docker compose exec redis redis-cli ping` +### i18n -### 熔断器误触发 +Internationalization via `next-intl` with messages in `/messages/{locale}/`. -- 查看日志中的 `[CircuitBreaker]` 记录 -- 检查供应商健康状态(Dashboard → 供应商管理) -- 等待 30 分钟自动恢复或手动重启应用重置状态 +### Rate Limiting -### 供应商选择失败 +Redis Lua scripts ensure atomic operations. Fail-open strategy when Redis unavailable. -- 检查供应商是否启用 (`is_enabled = true`) -- 检查熔断器状态(日志中的 `circuitState`) -- 检查并发限制配置(`limit_concurrent_sessions`) -- 查看决策链记录(日志详情页面) +### Circuit Breaker -### 代理连接失败 +Per-provider circuit breaker with configurable thresholds. States: CLOSED → OPEN → HALF_OPEN. -- 使用"测试连接"按钮验证代理配置 -- 检查代理地址格式(必须包含协议前缀:http://, https://, socks4://, socks5://) -- 检查代理服务器是否可访问(防火墙、端口) -- 检查代理认证信息(用户名/密码) -- 查看日志中的详细错误信息: - - `ProxyError`: 代理服务器连接失败 - - `Timeout`: 连接超时(默认 5 秒) - - `NetworkError`: 网络错误或 DNS 解析失败 -- 如启用了"降级到直连",检查是否自动降级成功 -- 验证目标供应商 URL 是否正确 +### Session Stickiness -## 参考资源 +5-minute session caching to maintain provider consistency within conversations. -- [Next.js 15 文档](https://nextjs.org/docs) -- [Hono 文档](https://hono.dev/) -- [Drizzle ORM 文档](https://orm.drizzle.team/) -- [Shadcn UI 文档](https://ui.shadcn.com/) -- [LiteLLM 价格表](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json) -- 请使用 production 环境构建。 +## PR Guidelines diff --git a/README.en.md b/README.en.md index de546c09a..92a86832d 100644 --- a/README.en.md +++ b/README.en.md @@ -251,6 +251,7 @@ Docker Compose is the **preferred deployment method** — it automatically provi | `ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS` | `false` | When `true`, network errors also trip the circuit breaker for quicker isolation. | | `APP_PORT` | `23000` | Production port (override via container or process manager). | | `APP_URL` | empty | Populate to expose correct `servers` entries in OpenAPI docs. | +| `API_TEST_TIMEOUT_MS` | `15000` | Timeout (ms) for provider API connectivity tests. Accepts 5000-120000 for regional tuning. | > Boolean values should be `true/false` or `1/0` without quotes; otherwise Zod may coerce strings incorrectly. See `.env.example` for the full list. diff --git a/README.md b/README.md index 0fe09d64c..c42472de8 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,7 @@ Docker Compose 是**首选部署方式**,自动配置数据库、Redis 和应 | `ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS` | `false` | 是否将网络错误计入熔断器;开启后能更激进地阻断异常线路。 | | `APP_PORT` | `23000` | 生产端口,可被容器或进程管理器覆盖。 | | `APP_URL` | 空 | 设置后 OpenAPI 文档 `servers` 将展示正确域名/端口。 | +| `API_TEST_TIMEOUT_MS` | `15000` | 供应商 API 测试超时时间(毫秒,范围 5000-120000),跨境网络可适当提高。 | > 布尔变量请直接写 `true/false` 或 `1/0`,勿加引号,避免被 Zod 转换为真值。更多字段参考 `.env.example`。 diff --git a/deploy/Dockerfile b/deploy/Dockerfile index da5465c46..fc7211eee 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -21,6 +21,8 @@ ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION # 这些是占位符,实际运行时会被真实值覆盖 ENV DSN="postgres://placeholder:placeholder@localhost:5432/placeholder" ENV REDIS_URL="redis://localhost:6379" +# 标记为 CI 环境,跳过 instrumentation.ts 中的数据库连接 +ENV CI=true RUN bun run build diff --git a/drizzle/0022_simple_stardust.sql b/drizzle/0022_simple_stardust.sql deleted file mode 100644 index 962f2cb27..000000000 --- a/drizzle/0022_simple_stardust.sql +++ /dev/null @@ -1,11 +0,0 @@ --- 安全创建枚举类型 (如果不存在则创建) -DO $$ BEGIN - CREATE TYPE "public"."daily_reset_mode" AS ENUM('fixed', 'rolling'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; ---> statement-breakpoint -ALTER TABLE "keys" ALTER COLUMN "daily_reset_mode" SET DEFAULT 'fixed'::"public"."daily_reset_mode";--> statement-breakpoint -ALTER TABLE "keys" ALTER COLUMN "daily_reset_mode" SET DATA TYPE "public"."daily_reset_mode" USING "daily_reset_mode"::"public"."daily_reset_mode";--> statement-breakpoint -ALTER TABLE "providers" ALTER COLUMN "daily_reset_mode" SET DEFAULT 'fixed'::"public"."daily_reset_mode";--> statement-breakpoint -ALTER TABLE "providers" ALTER COLUMN "daily_reset_mode" SET DATA TYPE "public"."daily_reset_mode" USING "daily_reset_mode"::"public"."daily_reset_mode"; \ No newline at end of file diff --git a/drizzle/0023_cheerful_shocker.sql b/drizzle/0023_cheerful_shocker.sql new file mode 100644 index 000000000..c43711aa0 --- /dev/null +++ b/drizzle/0023_cheerful_shocker.sql @@ -0,0 +1 @@ +ALTER TABLE "providers" ALTER COLUMN "streaming_idle_timeout_ms" SET DEFAULT 300000; \ No newline at end of file diff --git a/drizzle/0023_safe_christian_walker.sql b/drizzle/0023_safe_christian_walker.sql new file mode 100644 index 000000000..afe993ba1 --- /dev/null +++ b/drizzle/0023_safe_christian_walker.sql @@ -0,0 +1,2 @@ +ALTER TABLE "providers" ADD COLUMN "mcp_passthrough_type" varchar(20) DEFAULT 'none' NOT NULL;--> statement-breakpoint +ALTER TABLE "providers" ADD COLUMN "mcp_passthrough_url" varchar(512); \ No newline at end of file diff --git a/drizzle/0024_update_provider_timeout_defaults.sql b/drizzle/0024_update_provider_timeout_defaults.sql new file mode 100644 index 000000000..728cb26fb --- /dev/null +++ b/drizzle/0024_update_provider_timeout_defaults.sql @@ -0,0 +1,18 @@ +-- 修改供应商超时配置默认值为 0(不限制) +-- 并批量更新流式静默期超时:小于 60s 的改为 60s + +-- 1. 修改默认值为 0(不限制超时) +ALTER TABLE "providers" ALTER COLUMN "first_byte_timeout_streaming_ms" SET DEFAULT 0; +ALTER TABLE "providers" ALTER COLUMN "streaming_idle_timeout_ms" SET DEFAULT 0; +ALTER TABLE "providers" ALTER COLUMN "request_timeout_non_streaming_ms" SET DEFAULT 0; + +-- 2. 批量更新流式静默期超时 +-- 规则: +-- - 小于 60000ms (60s) 且大于 0 的 → 改为 60000 +-- - 等于 0(不限制)的 → 不操作 +-- - 大于等于 60000 的 → 不操作 +UPDATE "providers" +SET "streaming_idle_timeout_ms" = 60000 +WHERE "streaming_idle_timeout_ms" > 0 + AND "streaming_idle_timeout_ms" < 60000 + AND "deleted_at" IS NULL; diff --git a/drizzle/meta/0021_snapshot.json b/drizzle/meta/0021_snapshot.json new file mode 100644 index 000000000..9753cfee5 --- /dev/null +++ b/drizzle/meta/0021_snapshot.json @@ -0,0 +1,1526 @@ +{ + "id": "a0d7b238-d013-4831-9f8c-02e0532bf035", + "prevId": "2cca68f8-d8c7-4298-9f24-c8fd493d700e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "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()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "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_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "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'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "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 + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30000 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 600000 + }, + "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 + }, + "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 + }, + "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/0022_snapshot.json b/drizzle/meta/0023_snapshot.json similarity index 98% rename from drizzle/meta/0022_snapshot.json rename to drizzle/meta/0023_snapshot.json index 0ac66f729..7cb067c16 100644 --- a/drizzle/meta/0022_snapshot.json +++ b/drizzle/meta/0023_snapshot.json @@ -1,6 +1,6 @@ { - "id": "2f530870-7533-4f3d-b34d-895e61d7b83b", - "prevId": "2cca68f8-d8c7-4298-9f24-c8fd493d700e", + "id": "301f797d-6e88-4b88-8cf4-a1ba480ea35c", + "prevId": "8039ff75-655e-4ae6-a4a3-2fd0d285fad7", "version": "7", "dialect": "postgresql", "tables": { @@ -955,6 +955,19 @@ "notNull": false, "default": "'auto'" }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, "limit_5h_usd": { "name": "limit_5h_usd", "type": "numeric(10, 2)", @@ -1047,7 +1060,7 @@ "type": "integer", "primaryKey": false, "notNull": true, - "default": 10000 + "default": 300000 }, "request_timeout_non_streaming_ms": { "name": "request_timeout_non_streaming_ms", diff --git a/drizzle/meta/0024_snapshot.json b/drizzle/meta/0024_snapshot.json new file mode 100644 index 000000000..1fe1fb193 --- /dev/null +++ b/drizzle/meta/0024_snapshot.json @@ -0,0 +1,1583 @@ +{ + "id": "301f797d-6e88-4b88-8cf4-a1ba480ea35c", + "prevId": "8039ff75-655e-4ae6-a4a3-2fd0d285fad7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "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()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "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 + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "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_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "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'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "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 + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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 + }, + "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 + }, + "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": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + } + }, + "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 0578897c7..0e3371ee4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -159,8 +159,22 @@ { "idx": 22, "version": "7", - "when": 1763739167236, - "tag": "0022_simple_stardust", + "when": 1763955126094, + "tag": "0023_cheerful_shocker", + "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1764056229573, + "tag": "0023_safe_christian_walker", + "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1764206400000, + "tag": "0024_update_provider_timeout_defaults", "breakpoints": true } ] diff --git a/messages/en/settings.json b/messages/en/settings.json index 51a8cac30..433685074 100644 --- a/messages/en/settings.json +++ b/messages/en/settings.json @@ -149,7 +149,6 @@ }, "data": { "cleanup": { - "descriptionWarning": "Clean up historical log data to free up database storage. Note: Statistics data will be retained, but log details will be permanently deleted.", "rangeLabel": "Cleanup Range", "range": { "7days": "Logs older than 1 week (7 days)", @@ -179,16 +178,17 @@ "cleaning": "Cleaning...", "successMessage": "Successfully cleaned {count} log records ({batches} batches, took {duration}s)", "failed": "Cleanup failed", - "error": "Failed to clean logs" + "error": "Failed to clean logs", + "descriptionWarning": "Clean up historical log data to free up database storage. Note: Statistics data will be retained, but log details will be permanently deleted." }, "description": "Manage database backup and recovery with full data import/export and log cleanup.", "export": { - "descriptionFull": "Export complete database backup file (.dump format) for data migration or recovery. Backup uses PostgreSQL custom format, auto-compressed and compatible with different database versions.", "button": "Export Database", "exporting": "Exporting...", "successMessage": "Database exported successfully!", "failed": "Export failed", - "error": "Failed to export database" + "error": "Failed to export database", + "descriptionFull": "Export complete database backup file (.dump format) for data migration or recovery. Backup uses PostgreSQL custom format, auto-compressed and compatible with different database versions." }, "guide": { "title": "Usage Instructions and Precautions", @@ -220,7 +220,6 @@ } }, "import": { - "descriptionFull": "Restore database from backup file. Supports PostgreSQL custom format (.dump) backup files.", "selectFileLabel": "Select Backup File", "fileSelected": "Selected: {name} ({size} MB)", "fileError": "Please select .dump format backup file", @@ -240,10 +239,18 @@ "cancel": "Cancel", "confirm": "Confirm Import", "successMessage": "Data import completed!", - "failedMessage": "Data import failed, check detailed logs", + "successCleanModeDesc": "All data has been successfully restored. Refresh your browser if the page displays incorrectly.", + "successMergeModeDesc": "Data has been successfully imported and merged. Refresh your browser if the page displays incorrectly.", + "successWithWarnings": "Data import completed (with warnings)", + "successWithWarningsDesc": "Data has been successfully imported, but some existing objects were skipped. Refresh your browser or restart the application if the page displays incorrectly.", + "failedMessage": "Data import failed", "error": "Failed to import database", "streamError": "Cannot read response stream", - "errorUnknown": "Unknown error" + "streamInterrupted": "Data stream unexpectedly interrupted", + "streamInterruptedDesc": "Import progress did not complete normally. Please check the logs and verify data integrity. Re-import if needed.", + "parseError": "Failed to parse response data", + "errorUnknown": "Unknown error", + "descriptionFull": "Restore database from backup file. Supports PostgreSQL custom format (.dump) backup files." }, "status": { "loading": "Loading...", @@ -268,7 +275,8 @@ "description": "Export complete database backup file (.dump format) for data migration or recovery." }, "import": { - "title": "Data Import" + "title": "Data Import", + "description": "Restore database from backup file. Supports PostgreSQL custom format (.dump) backup files." } } }, @@ -599,6 +607,7 @@ "testModel": "Test model", "testModelDesc": "Leave empty to use the default model or type one manually", "model": "Model", + "responseModel": "Response model", "responseTime": "Response time", "usage": "Token usage", "response": "Response preview", @@ -608,8 +617,27 @@ "copySuccess": "Copied to clipboard", "copyFailed": "Failed to copy", "copyResult": "Copy Result", + "close": "Close", "success": "Success", - "failed": "Failed" + "failed": "Failed", + "streamInfo": "Stream response info", + "chunksReceived": "Chunks received", + "streamFormat": "Stream format", + "streamResponse": "Stream response", + "chunksCount": "Received {count} chunks ({format})", + "truncatedPreview": "Showing first {length} characters, copy to see full content", + "truncatedBrief": "Showing first {length} characters, click \"View Details\" for more", + "copyFormat": { + "testResult": "Test result", + "message": "Message", + "errorDetails": "Error details" + }, + "disclaimer": { + "title": "Notice", + "realRequest": "This test sends a real request to the provider and may consume a small quota", + "resultReference": "Results may vary by provider and are for reference only", + "confirmConfig": "Please verify provider URL, API key, and model configuration" + } }, "proxyTest": { "fillUrlFirst": "Please fill in provider URL first", @@ -704,6 +732,22 @@ "codexStrategyForceDesc": "Always use official Codex CLI instructions (~4000+ chars)", "codexStrategyForceLabel": "Force Official", "codexStrategyHint": "Hint: Some strict Codex gateways (e.g. 88code, foxcode) require official instructions. Choose \"Auto\" or \"Force Official\" strategy", + "mcpPassthroughConfig": "MCP Passthrough Configuration", + "mcpPassthroughConfigMinimax": "Minimax", + "mcpPassthroughConfigGlm": "GLM", + "mcpPassthroughConfigCustom": "Custom (Reserved)", + "mcpPassthroughConfigNone": "Disabled", + "mcpPassthroughDesc": "When enabled, pass through MCP tool calls to specified AI provider (e.g. minimax for image recognition, web search)", + "mcpPassthroughSelect": "Passthrough Type", + "mcpPassthroughNoneLabel": "Disabled", + "mcpPassthroughNoneDesc": "Do not enable MCP passthrough (default)", + "mcpPassthroughMinimaxLabel": "Minimax", + "mcpPassthroughMinimaxDesc": "Pass through to minimax MCP service (supports image recognition, web search, etc.)", + "mcpPassthroughGlmLabel": "GLM", + "mcpPassthroughGlmDesc": "Pass through to GLM MCP service (supports image analysis, video analysis, etc.)", + "mcpPassthroughCustomLabel": "Custom", + "mcpPassthroughCustomDesc": "Pass through to custom MCP service (reserved, not implemented yet)", + "mcpPassthroughHint": "Hint: MCP passthrough allows Claude Code client to use tool capabilities provided by third-party AI providers (e.g. image recognition, web search)", "codexStrategyKeepDesc": "Always pass through client instructions, no auto retry (for lenient gateways)", "codexStrategyKeepLabel": "Keep Original", "codexStrategySelect": "Strategy Selection", @@ -1080,8 +1124,7 @@ "title": "Provider Model Test", "summary": "Verify provider & model connectivity", "desc": "Validate whether the selected provider type and model respond correctly. Defaults to the routing configuration unless overridden.", - "testLabel": "Provider Model Test", - "notice": "Note: This sends a real non-streaming request and may consume a small quota. Confirm provider URL, API key, and model before running." + "testLabel": "Provider Model Test" }, "codexStrategy": { "title": "Codex Instructions Policy", @@ -1376,10 +1419,10 @@ "disable": "Error rule disabled", "toggleFailed": "Toggle failed", "toggleFailedError": "Toggle failed:", - "refreshCache": "Refresh Cache", - "refreshCacheSuccess": "Cache refreshed successfully, loaded {count} error rules", - "refreshCacheFailed": "Failed to refresh cache", - "cacheStats": "Cache stats: Enabled({enabledCount}) Disabled({disabledCount}) Default({defaultCount})", + "refreshCache": "Sync Rules", + "refreshCacheSuccess": "Rules synced successfully, loaded {count} error rules", + "refreshCacheFailed": "Failed to sync rules", + "cacheStats": "Cached {totalCount} error rules", "emptyState": "No error rules yet. Click 'Add Error Rule' in the top right to start configuration.", "confirmDelete": "Are you sure you want to delete error rule \"{pattern}\"?", "dialog": { @@ -1478,5 +1521,25 @@ "cannotDelete": "Default rules cannot be deleted", "cannotDisable": "Recommend keeping default rules enabled" } - } + }, + "mcpPassthroughConfig": "MCP Passthrough Configuration", + "mcpPassthroughConfigNone": "Disabled", + "mcpPassthroughConfigMinimax": "Minimax", + "mcpPassthroughConfigGlm": "GLM", + "mcpPassthroughConfigCustom": "Custom (Reserved)", + "mcpPassthroughDesc": "When enabled, pass through MCP tool calls to specified AI provider (e.g. minimax for image recognition, web search)", + "mcpPassthroughSelect": "Passthrough Type", + "mcpPassthroughNoneLabel": "Disabled", + "mcpPassthroughNoneDesc": "Do not enable MCP passthrough (default)", + "mcpPassthroughMinimaxLabel": "Minimax", + "mcpPassthroughMinimaxDesc": "Pass through to minimax MCP service (supports image recognition, web search, etc.)", + "mcpPassthroughGlmLabel": "GLM", + "mcpPassthroughGlmDesc": "Pass through to GLM MCP service (supports image analysis, video analysis, etc.)", + "mcpPassthroughCustomLabel": "Custom", + "mcpPassthroughCustomDesc": "Pass through to custom MCP service (reserved, not implemented yet)", + "mcpPassthroughHint": "Hint: MCP passthrough allows Claude Code client to use tool capabilities provided by third-party AI providers (e.g. image recognition, web search)", + "mcpPassthroughUrlLabel": "MCP Passthrough URL", + "mcpPassthroughUrlPlaceholder": "https://api.minimaxi.com", + "mcpPassthroughUrlDesc": "MCP service base URL. Leave empty to auto-extract from provider URL", + "mcpPassthroughUrlAuto": "Auto-extracted: {url}" } diff --git a/messages/ja/settings.json b/messages/ja/settings.json index c4cfb21f0..7a087c486 100644 --- a/messages/ja/settings.json +++ b/messages/ja/settings.json @@ -149,7 +149,6 @@ }, "data": { "cleanup": { - "descriptionWarning": "履歴ログデータをクリーンアップしてデータベースストレージを解放します。注:統計データは保持されますが、ログ詳細は完全に削除されます。", "rangeLabel": "クリーンアップ範囲", "range": { "7days": "1週間前のログ(7日)", @@ -179,16 +178,17 @@ "cleaning": "クリーンアップ中...", "successMessage": "{count}件のログレコードをクリーンアップしました({batches}バッチ、所要時間{duration}秒)", "failed": "クリーンアップ失敗", - "error": "ログのクリーンアップに失敗しました" + "error": "ログのクリーンアップに失敗しました", + "descriptionWarning": "履歴ログデータをクリーンアップしてデータベースストレージを解放します。注:統計データは保持されますが、ログ詳細は完全に削除されます。" }, "description": "データベースのバックアップと復元を管理し、完全なインポート/エクスポートとログクリーンアップをサポートします。", "export": { - "descriptionFull": "完全なデータベースバックアップファイル(.dump形式)をエクスポートし、データ移行または復旧に使用できます。バックアップはPostgreSQL custom formatを使用し、自動圧縮され、異なるデータベースバージョンと互換性があります。", "button": "データベースをエクスポート", "exporting": "エクスポート中...", "successMessage": "データベースのエクスポートに成功しました!", "failed": "エクスポート失敗", - "error": "データベースのエクスポートに失敗しました" + "error": "データベースのエクスポートに失敗しました", + "descriptionFull": "完全なデータベースバックアップファイル(.dump形式)をエクスポートし、データ移行または復旧に使用できます。バックアップはPostgreSQL custom formatを使用し、自動圧縮され、異なるデータベースバージョンと互換性があります。" }, "guide": { "title": "使用説明と注意事項", @@ -220,7 +220,6 @@ } }, "import": { - "descriptionFull": "バックアップファイルからデータベースを復元します。PostgreSQL custom format(.dump)形式のバックアップファイルをサポートします。", "selectFileLabel": "バックアップファイルを選択", "fileSelected": "選択済み:{name}({size} MB)", "fileError": ".dump形式のバックアップファイルを選択してください", @@ -243,7 +242,8 @@ "failedMessage": "データインポート失敗、詳細ログを確認してください", "error": "データベースのインポートに失敗しました", "streamError": "レスポンスストリームを読み取れません", - "errorUnknown": "不明なエラー" + "errorUnknown": "不明なエラー", + "descriptionFull": "バックアップファイルからデータベースを復元します。PostgreSQL custom format(.dump)形式のバックアップファイルをサポートします。" }, "status": { "loading": "読み込み中...", @@ -268,7 +268,8 @@ "description": "完全なデータベースバックアップファイル(.dump形式)をエクスポートし、データ移行または復旧に使用できます。" }, "import": { - "title": "データインポート" + "title": "データインポート", + "description": "バックアップファイルからデータベースを復元します。PostgreSQL custom format(.dump)形式のバックアップファイルをサポートします。" } } }, @@ -1319,10 +1320,10 @@ "disable": "エラールールが無効になりました", "toggleFailed": "切り替えに失敗しました", "toggleFailedError": "切り替えに失敗しました:", - "refreshCache": "キャッシュを更新", - "refreshCacheSuccess": "キャッシュが正常に更新され、{count} 個のエラールールがロードされました", - "refreshCacheFailed": "キャッシュの更新に失敗しました", - "cacheStats": "キャッシュ統計: 有効({enabledCount}) 無効({disabledCount}) デフォルト({defaultCount})", + "refreshCache": "ルールを同期", + "refreshCacheSuccess": "ルールが正常に同期され、{count} 個のエラールールがロードされました", + "refreshCacheFailed": "ルールの同期に失敗しました", + "cacheStats": "キャッシュ: {totalCount}件のルール", "emptyState": "エラールールがまだありません。右上の「エラールールを追加」をクリックして設定を開始してください。", "confirmDelete": "エラールール \"{pattern}\" を削除してもよろしいですか?", "dialog": { @@ -1421,5 +1422,25 @@ "cannotDelete": "デフォルトルールは削除できません", "cannotDisable": "デフォルトルールは有効のままにすることをお勧めします" } - } + }, + "mcpPassthroughConfig": "MCP パススルー設定", + "mcpPassthroughConfigNone": "無効", + "mcpPassthroughConfigMinimax": "Minimax", + "mcpPassthroughConfigGlm": "GLM", + "mcpPassthroughConfigCustom": "カスタム (予約)", + "mcpPassthroughDesc": "有効にすると、MCP ツール呼び出しを指定された AI プロバイダにパススルーします(例:minimax の画像認識、Web 検索)", + "mcpPassthroughSelect": "パススルータイプ", + "mcpPassthroughNoneLabel": "無効", + "mcpPassthroughNoneDesc": "MCP パススルーを有効にしません(デフォルト)", + "mcpPassthroughMinimaxLabel": "Minimax", + "mcpPassthroughMinimaxDesc": "minimax MCP サービスにパススルー(画像認識、Web 検索などをサポート)", + "mcpPassthroughGlmLabel": "GLM", + "mcpPassthroughGlmDesc": "GLM MCP サービスにパススルー(画像分析、動画分析などをサポート)", + "mcpPassthroughCustomLabel": "カスタム", + "mcpPassthroughCustomDesc": "カスタム MCP サービスにパススルー(予約、未実装)", + "mcpPassthroughHint": "ヒント: MCP パススルーにより、Claude Code クライアントは第三者の AI プロバイダ提供的ツール機能(画像認識、Web 検索など)を使用できます", + "mcpPassthroughUrlLabel": "MCP パススルー URL", + "mcpPassthroughUrlPlaceholder": "https://api.minimaxi.com", + "mcpPassthroughUrlDesc": "MCP サービスベース URL。空のままにすると、プロバイダ URL から自動的に抽出されます", + "mcpPassthroughUrlAuto": "自動抽出: {url}" } diff --git a/messages/ru/settings.json b/messages/ru/settings.json index 6169e95e5..2819c6123 100644 --- a/messages/ru/settings.json +++ b/messages/ru/settings.json @@ -149,7 +149,6 @@ }, "data": { "cleanup": { - "descriptionWarning": "Очистка исторических данных логов для освобождения дискового пространства базы данных. Примечание: Статистические данные будут сохранены, но детали логов будут удалены навсегда.", "rangeLabel": "Диапазон очистки", "range": { "7days": "Логи старше 1 недели (7 дней)", @@ -179,16 +178,17 @@ "cleaning": "Очистка...", "successMessage": "Успешно очищено {count} записей логов ({batches} пакетов, заняло {duration}с)", "failed": "Очистка не удалась", - "error": "Не удалось очистить логи" + "error": "Не удалось очистить логи", + "descriptionWarning": "Очистка исторических данных логов для освобождения дискового пространства базы данных. Примечание: Статистические данные будут сохранены, но детали логов будут удалены навсегда." }, "description": "Управление резервной копией и восстановлением БД с полным импортом/экспортом и очисткой логов.", "export": { - "descriptionFull": "Экспорт полного файла резервной копии базы данных (формат .dump) для миграции или восстановления данных. Резервная копия использует формат PostgreSQL custom format, автоматически сжимается и совместима с разными версиями базы данных.", "button": "Экспортировать базу данных", "exporting": "Экспорт...", "successMessage": "База данных успешно экспортирована!", "failed": "Экспорт не удался", - "error": "Не удалось экспортировать базу данных" + "error": "Не удалось экспортировать базу данных", + "descriptionFull": "Экспорт полного файла резервной копии базы данных (формат .dump) для миграции или восстановления данных. Резервная копия использует формат PostgreSQL custom format, автоматически сжимается и совместима с разными версиями базы данных." }, "guide": { "title": "Инструкции и меры предосторожности", @@ -220,7 +220,6 @@ } }, "import": { - "descriptionFull": "Восстановление базы данных из файла резервной копии. Поддерживает файлы резервных копий в формате PostgreSQL custom format (.dump).", "selectFileLabel": "Выбрать файл резервной копии", "fileSelected": "Выбрано: {name} ({size} МБ)", "fileError": "Пожалуйста, выберите файл резервной копии в формате .dump", @@ -243,7 +242,8 @@ "failedMessage": "Импорт данных не удался, проверьте подробные логи", "error": "Не удалось импортировать базу данных", "streamError": "Не удается прочитать поток ответа", - "errorUnknown": "Неизвестная ошибка" + "errorUnknown": "Неизвестная ошибка", + "descriptionFull": "Восстановление базы данных из файла резервной копии. Поддерживает файлы резервных копий в формате PostgreSQL custom format (.dump)." }, "status": { "loading": "Загрузка...", @@ -268,7 +268,8 @@ "description": "Экспорт полного файла резервной копии базы данных (формат .dump) для миграции или восстановления данных." }, "import": { - "title": "Импорт данных" + "title": "Импорт данных", + "description": "Восстановление базы данных из файла резервной копии. Поддерживает файлы резервных копий в формате PostgreSQL custom format (.dump)." } } }, @@ -1319,10 +1320,10 @@ "disable": "Правило ошибки отключено", "toggleFailed": "Переключение не удалось", "toggleFailedError": "Переключение не удалось:", - "refreshCache": "Обновить кэш", - "refreshCacheSuccess": "Кэш успешно обновлен, загружено {count} правил ошибок", - "refreshCacheFailed": "Не удалось обновить кэш", - "cacheStats": "Статистика кэша: Включено({enabledCount}) Отключено({disabledCount}) По умолчанию({defaultCount})", + "refreshCache": "Синхронизировать правила", + "refreshCacheSuccess": "Правила успешно синхронизированы, загружено {count} правил ошибок", + "refreshCacheFailed": "Не удалось синхронизировать правила", + "cacheStats": "Кэшировано: {totalCount} правил", "emptyState": "Правил ошибок пока нет. Нажмите 'Добавить правило ошибки' в правом верхнем углу, чтобы начать настройку.", "confirmDelete": "Вы уверены, что хотите удалить правило ошибки \"{pattern}\"?", "dialog": { @@ -1421,5 +1422,25 @@ "cannotDelete": "Правила по умолчанию не могут быть удалены", "cannotDisable": "Рекомендуется сохранить правила по умолчанию включенными" } - } + }, + "mcpPassthroughConfig": "Конфигурация сквозной передачи MCP", + "mcpPassthroughConfigNone": "Отключено", + "mcpPassthroughConfigMinimax": "Minimax", + "mcpPassthroughConfigGlm": "GLM", + "mcpPassthroughConfigCustom": "Пользовательский (Зарезервировано)", + "mcpPassthroughDesc": "При включении передаёт вызовы инструментов MCP указанному AI-провайдеру (например, minimax для распознавания изображений, веб-поиска)", + "mcpPassthroughSelect": "Тип сквозной передачи", + "mcpPassthroughNoneLabel": "Отключено", + "mcpPassthroughNoneDesc": "Не включать сквозную передачу MCP (по умолчанию)", + "mcpPassthroughMinimaxLabel": "Minimax", + "mcpPassthroughMinimaxDesc": "Сквозная передача в сервис minimax MCP (поддержка распознавания изображений, веб-поиска и т.д.)", + "mcpPassthroughGlmLabel": "GLM", + "mcpPassthroughGlmDesc": "Сквозная передача в сервис GLM MCP (поддержка анализа изображений, видео и т.д.)", + "mcpPassthroughCustomLabel": "Пользовательский", + "mcpPassthroughCustomDesc": "Сквозная передача в пользовательский сервис MCP (зарезервировано, не реализовано)", + "mcpPassthroughHint": "Подсказка: сквозная передача MCP позволяет клиенту Claude Code использовать возможности инструментов, предоставляемых сторонними AI-провайдерами (например, распознавание изображений, веб-поиск)", + "mcpPassthroughUrlLabel": "URL сквозной передачи MCP", + "mcpPassthroughUrlPlaceholder": "https://api.minimaxi.com", + "mcpPassthroughUrlDesc": "Базовый URL сервиса MCP. Оставьте пустым для автоматического извлечения из URL провайдера", + "mcpPassthroughUrlAuto": "Автоматически извлечено: {url}" } diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index 0d73e2715..4b49530d3 100644 --- a/messages/zh-CN/settings.json +++ b/messages/zh-CN/settings.json @@ -233,6 +233,7 @@ "testModel": "测试模型", "testModelDesc": "可手动输入,不填写则使用默认模型", "model": "模型", + "responseModel": "响应模型", "responseTime": "响应时间", "usage": "Token 用量", "response": "响应内容", @@ -242,8 +243,27 @@ "copySuccess": "已复制到剪贴板", "copyFailed": "复制失败", "copyResult": "复制结果", + "close": "关闭", "success": "成功", - "failed": "失败" + "failed": "失败", + "streamInfo": "流式响应信息", + "chunksReceived": "接收到的数据块", + "streamFormat": "流式格式", + "streamResponse": "流式响应", + "chunksCount": "接收 {count} 个数据块 ({format})", + "truncatedPreview": "显示前 {length} 字符,完整内容请复制查看", + "truncatedBrief": "显示前 {length} 字符,完整内容请点击「查看详情」", + "copyFormat": { + "testResult": "测试结果", + "message": "消息", + "errorDetails": "错误详情" + }, + "disclaimer": { + "title": "注意", + "realRequest": "测试将向供应商发送真实请求,可能消耗少量额度", + "resultReference": "因各家供应商情况不同,测试结果仅供参考,不代表实际调用效果", + "confirmConfig": "请确认供应商 URL、API 密钥及模型配置正确" + } }, "urlPreview": { "title": "URL 拼接预览", @@ -693,8 +713,7 @@ "title": "供应商模型测试", "summary": "验证供应商与模型连通性", "desc": "测试供应商模型是否可用,默认与路由配置中选择的供应商类型保持一致。", - "testLabel": "供应商模型测试", - "notice": "注意:测试将向供应商发送真实请求(非流式),可能消耗少量额度。请确认供应商 URL、API 密钥及模型配置正确。" + "testLabel": "供应商模型测试" }, "codexStrategy": { "title": "Codex Instructions 策略", @@ -721,6 +740,41 @@ "placeholder": "选择策略" }, "hint": "提示: 部分严格的 Codex 中转站(如 88code、foxcode)需要官方 instructions,选择\"自动\"或\"强制官方\"策略" + }, + "mcpPassthrough": { + "title": "MCP 透传配置", + "summary": { + "none": "不启用", + "minimax": "Minimax", + "glm": "智谱 GLM", + "custom": "自定义 (预留)" + }, + "desc": "启用后,将 MCP 工具调用透传到指定的 AI 服务商(如 minimax 的图片识别、联网搜索)", + "select": { + "label": "透传类型", + "none": { + "label": "不启用", + "desc": "不启用 MCP 透传功能(默认)" + }, + "minimax": { + "label": "Minimax", + "desc": "透传到 minimax MCP 服务(支持图片识别、联网搜索等工具)" + }, + "glm": { + "label": "智谱 GLM", + "desc": "透传到智谱 MCP 服务(支持图片分析、视频分析等工具)" + }, + "custom": { + "label": "自定义", + "desc": "透传到自定义 MCP 服务(预留,暂未实现)" + }, + "placeholder": "选择透传类型" + }, + "hint": "提示: MCP 透传功能允许 Claude Code 客户端使用第三方 AI 服务商提供的工具能力(如图片识别、联网搜索)", + "urlLabel": "MCP 透传 URL", + "urlPlaceholder": "https://api.minimaxi.com", + "urlDesc": "MCP 服务的基础 URL。留空则自动从提供商 URL 提取基础域名", + "urlAuto": "自动提取: {url}" } }, "providerTypes": { @@ -1119,7 +1173,6 @@ "tables": "{count} 个表" }, "cleanup": { - "descriptionWarning": "清理历史日志数据以释放数据库存储空间。注意:统计数据将被保留,但日志详情将被永久删除。", "rangeLabel": "清理范围", "range": { "7days": "一周前的日志 (7 天)", @@ -1149,18 +1202,18 @@ "cleaning": "正在清理...", "successMessage": "成功清理 {count} 条日志记录({batches} 批次,耗时 {duration}s)", "failed": "清理失败", - "error": "清理日志失败" + "error": "清理日志失败", + "descriptionWarning": "清理历史日志数据以释放数据库存储空间。注意:统计数据将被保留,但日志详情将被永久删除。" }, "export": { - "descriptionFull": "导出完整的数据库备份文件(.dump 格式),可用于数据迁移或恢复。备份文件使用 PostgreSQL custom format,自动压缩且兼容不同版本的数据库结构。", "button": "导出数据库", "exporting": "正在导出...", "successMessage": "数据库导出成功!", "failed": "导出失败", - "error": "导出数据库失败" + "error": "导出数据库失败", + "descriptionFull": "导出完整的数据库备份文件(.dump 格式),可用于数据迁移或恢复。备份文件使用 PostgreSQL custom format,自动压缩且兼容不同版本的数据库结构。" }, "import": { - "descriptionFull": "从备份文件恢复数据库。支持 PostgreSQL custom format (.dump) 格式的备份文件。", "selectFileLabel": "选择备份文件", "fileSelected": "已选择: {name} ({size} MB)", "fileError": "请选择 .dump 格式的备份文件", @@ -1180,10 +1233,18 @@ "cancel": "取消", "confirm": "确认导入", "successMessage": "数据导入完成!", - "failedMessage": "数据导入失败,请查看详细日志", + "successCleanModeDesc": "所有数据已成功恢复。如果页面显示异常,请刷新浏览器。", + "successMergeModeDesc": "数据已成功导入并合并。如果页面显示异常,请刷新浏览器。", + "successWithWarnings": "数据导入完成(有警告)", + "successWithWarningsDesc": "数据已成功导入,但跳过了一些已存在的对象。如果页面显示异常,请刷新浏览器或重启应用。", + "failedMessage": "数据导入失败", "error": "导入数据库失败", "streamError": "无法读取响应流", - "errorUnknown": "未知错误" + "streamInterrupted": "数据流意外中断", + "streamInterruptedDesc": "导入进度未正常完成,请检查日志并验证数据完整性。如有问题,请重新导入。", + "parseError": "解析响应数据失败", + "errorUnknown": "未知错误", + "descriptionFull": "从备份文件恢复数据库。支持 PostgreSQL custom format (.dump) 格式的备份文件。" }, "guide": { "title": "使用说明与注意事项", @@ -1228,7 +1289,8 @@ "description": "导出完整的数据库备份文件(.dump 格式),可用于数据迁移或恢复。" }, "import": { - "title": "数据导入" + "title": "数据导入", + "description": "从备份文件恢复数据库。支持 PostgreSQL custom format (.dump) 格式的备份文件。" } } }, @@ -1376,10 +1438,10 @@ "disable": "错误规则已禁用", "toggleFailed": "状态切换失败", "toggleFailedError": "状态切换失败:", - "refreshCache": "刷新缓存", - "refreshCacheSuccess": "缓存刷新成功,已加载 {count} 个错误规则", - "refreshCacheFailed": "刷新缓存失败", - "cacheStats": "缓存统计: 启用({enabledCount}) 禁用({disabledCount}) 默认({defaultCount})", + "refreshCache": "同步规则", + "refreshCacheSuccess": "规则同步成功,已加载 {count} 个错误规则", + "refreshCacheFailed": "同步规则失败", + "cacheStats": "缓存: 共 {totalCount} 条规则", "emptyState": "暂无错误规则,点击右上角\"添加错误规则\"开始配置。", "confirmDelete": "确定要删除错误规则\"{pattern}\"吗?", "dialog": { @@ -1478,5 +1540,25 @@ "cannotDelete": "默认规则无法删除", "cannotDisable": "建议保留默认规则启用状态" } - } + }, + "mcpPassthroughConfig": "MCP 透传配置", + "mcpPassthroughConfigNone": "不启用", + "mcpPassthroughConfigMinimax": "Minimax", + "mcpPassthroughConfigGlm": "智谱 GLM", + "mcpPassthroughConfigCustom": "自定义 (预留)", + "mcpPassthroughDesc": "启用后,将 MCP 工具调用透传到指定的 AI 服务商(如 minimax 的图片识别、联网搜索)", + "mcpPassthroughSelect": "透传类型", + "mcpPassthroughNoneLabel": "不启用", + "mcpPassthroughNoneDesc": "不启用 MCP 透传功能(默认)", + "mcpPassthroughMinimaxLabel": "Minimax", + "mcpPassthroughMinimaxDesc": "透传到 minimax MCP 服务(支持图片识别、联网搜索等工具)", + "mcpPassthroughGlmLabel": "智谱 GLM", + "mcpPassthroughGlmDesc": "透传到智谱 GLM MCP 服务(支持图片分析、视频分析等工具)", + "mcpPassthroughCustomLabel": "自定义", + "mcpPassthroughCustomDesc": "透传到自定义 MCP 服务(预留,暂未实现)", + "mcpPassthroughHint": "提示: MCP 透传功能允许 Claude Code 客户端使用第三方 AI 服务商提供的工具能力(如图片识别、联网搜索)", + "mcpPassthroughUrlLabel": "MCP 透传 URL", + "mcpPassthroughUrlPlaceholder": "https://api.minimaxi.com", + "mcpPassthroughUrlDesc": "MCP 服务的基础 URL。留空则自动从提供商 URL 提取基础域名", + "mcpPassthroughUrlAuto": "自动提取: {url}" } diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index 2abac1326..81805ee67 100644 --- a/messages/zh-TW/settings.json +++ b/messages/zh-TW/settings.json @@ -149,7 +149,6 @@ }, "data": { "cleanup": { - "descriptionWarning": "清理歷史日誌資料以釋放資料庫儲存空間。注意:統計資料將被保留,但日誌詳情將被永久刪除。", "rangeLabel": "清理範圍", "range": { "7days": "一週前的日誌 (7 天)", @@ -179,16 +178,17 @@ "cleaning": "正在清理...", "successMessage": "成功清理 {count} 筆日誌記錄({batches} 批次,耗時 {duration}秒)", "failed": "清理失敗", - "error": "清理日誌失敗" + "error": "清理日誌失敗", + "descriptionWarning": "清理歷史日誌資料以釋放資料庫儲存空間。注意:統計資料將被保留,但日誌詳情將被永久刪除。" }, "description": "管理資料庫的備份與恢復,支援完整資料匯入匯出和日誌清理。", "export": { - "descriptionFull": "匯出完整的資料庫備份檔案(.dump 格式),可用於資料遷移或還原。備份檔案使用 PostgreSQL custom format,自動壓縮且相容不同版本的資料庫結構。", "button": "匯出資料庫", "exporting": "正在匯出...", "successMessage": "資料庫匯出成功!", "failed": "匯出失敗", - "error": "匯出資料庫失敗" + "error": "匯出資料庫失敗", + "descriptionFull": "匯出完整的資料庫備份檔案(.dump 格式),可用於資料遷移或還原。備份檔案使用 PostgreSQL custom format,自動壓縮且相容不同版本的資料庫結構。" }, "guide": { "title": "使用說明與注意事項", @@ -220,7 +220,6 @@ } }, "import": { - "descriptionFull": "從備份檔案還原資料庫。支援 PostgreSQL custom format (.dump) 格式的備份檔案。", "selectFileLabel": "選擇備份檔案", "fileSelected": "已選擇:{name}({size} MB)", "fileError": "請選擇 .dump 格式的備份檔案", @@ -243,7 +242,8 @@ "failedMessage": "資料匯入失敗,請查看詳細日誌", "error": "匯入資料庫失敗", "streamError": "無法讀取回應串流", - "errorUnknown": "未知錯誤" + "errorUnknown": "未知錯誤", + "descriptionFull": "從備份檔案還原資料庫。支援 PostgreSQL custom format (.dump) 格式的備份檔案。" }, "status": { "loading": "載入中...", @@ -268,7 +268,8 @@ "description": "匯出完整的資料庫備份檔案(.dump 格式),可用於資料遷移或還原。" }, "import": { - "title": "資料匯入" + "title": "資料匯入", + "description": "從備份檔案還原資料庫。支援 PostgreSQL custom format (.dump) 格式的備份檔案。" } } }, @@ -591,6 +592,7 @@ "testModel": "測試模型", "testModelDesc": "可手動輸入,留空則使用預設模型", "model": "模型", + "responseModel": "回應模型", "responseTime": "回應時間", "usage": "Token 用量", "response": "回應內容", @@ -600,8 +602,15 @@ "copySuccess": "已複製到剪貼簿", "copyFailed": "複製失敗", "copyResult": "複製結果", + "close": "關閉", "success": "成功", - "failed": "失敗" + "failed": "失敗", + "disclaimer": { + "title": "注意", + "realRequest": "測試將向供應商發送真實請求,可能消耗少量額度", + "resultReference": "因各家供應商情況不同,測試結果僅供參考,不代表實際呼叫效果", + "confirmConfig": "請確認供應商 URL、API 金鑰及模型設定正確" + } }, "urlPreview": { "title": "URL 拼接預覽", @@ -990,6 +999,12 @@ }, "disableHint": "設為 0 表示禁用該超時(僅用於灰度回退場景,不推薦)" }, + "apiTest": { + "title": "供應商模型測試", + "summary": "驗證供應商與模型連通性", + "desc": "測試供應商模型是否可用,預設與路由設定中選擇的供應商類型保持一致。", + "testLabel": "供應商模型測試" + }, "codexStrategy": { "title": "Codex Instructions 策略", "summary": { @@ -1350,10 +1365,10 @@ "disable": "錯誤規則已停用", "toggleFailed": "狀態切換失敗", "toggleFailedError": "狀態切換失敗:", - "refreshCache": "重新整理快取", - "refreshCacheSuccess": "快取重新整理成功,已載入 {count} 個錯誤規則", - "refreshCacheFailed": "重新整理快取失敗", - "cacheStats": "快取統計: 啟用({enabledCount}) 停用({disabledCount}) 預設({defaultCount})", + "refreshCache": "同步規則", + "refreshCacheSuccess": "規則同步成功,已載入 {count} 個錯誤規則", + "refreshCacheFailed": "同步規則失敗", + "cacheStats": "快取: 共 {totalCount} 條規則", "emptyState": "目前沒有錯誤規則,點擊右上角「新增錯誤規則」開始設定。", "confirmDelete": "確定要刪除錯誤規則「{pattern}」嗎?", "dialog": { @@ -1452,5 +1467,25 @@ "cannotDelete": "預設規則無法刪除", "cannotDisable": "建議保留預設規則啟用狀態" } - } + }, + "mcpPassthroughConfig": "MCP 透傳配置", + "mcpPassthroughConfigNone": "不啟用", + "mcpPassthroughConfigMinimax": "Minimax", + "mcpPassthroughConfigGlm": "智譜 GLM", + "mcpPassthroughConfigCustom": "自定義 (預留)", + "mcpPassthroughDesc": "啟用後,將 MCP 工具調用透傳到指定的 AI 服務商(如 minimax 的圖片識別、聯網搜索)", + "mcpPassthroughSelect": "透傳類型", + "mcpPassthroughNoneLabel": "不啟用", + "mcpPassthroughNoneDesc": "不啟用 MCP 透傳功能(默認)", + "mcpPassthroughMinimaxLabel": "Minimax", + "mcpPassthroughMinimaxDesc": "透傳到 minimax MCP 服務(支持圖片識別、聯網搜索等工具)", + "mcpPassthroughGlmLabel": "智譜 GLM", + "mcpPassthroughGlmDesc": "透傳到智譜 GLM MCP 服務(支持圖片分析、視頻分析等工具)", + "mcpPassthroughCustomLabel": "自定義", + "mcpPassthroughCustomDesc": "透傳到自定義 MCP 服務(預留,暫未實現)", + "mcpPassthroughHint": "提示: MCP 透傳功能允許 Claude Code 客戶端使用第三方 AI 服務商提供的工具能力(如圖片識別、聯網搜索)", + "mcpPassthroughUrlLabel": "MCP 透傳 URL", + "mcpPassthroughUrlPlaceholder": "https://api.minimaxi.com", + "mcpPassthroughUrlDesc": "MCP 服務的基礎 URL。留空則自動從提供商 URL 提取基礎域名", + "mcpPassthroughUrlAuto": "自動提取: {url}" } diff --git a/next.config.ts b/next.config.ts index 8377358b5..b9243756c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -12,7 +12,16 @@ const nextConfig: NextConfig = { // 排除服务端专用包(避免打包到客户端) // bull 和相关依赖只在服务端使用,包含 Node.js 原生模块 - serverExternalPackages: ["bull", "bullmq", "@bull-board/api", "@bull-board/express", "ioredis"], + // postgres 和 drizzle-orm 包含 Node.js 原生模块(net, tls, crypto, stream, perf_hooks) + serverExternalPackages: [ + "bull", + "bullmq", + "@bull-board/api", + "@bull-board/express", + "ioredis", + "postgres", + "drizzle-orm", + ], // 强制包含 undici 到 standalone 输出 // Next.js 依赖追踪无法正确追踪动态导入和类型导入的传递依赖 @@ -28,6 +37,25 @@ const nextConfig: NextConfig = { bodySizeLimit: "500mb", }, }, + + // Webpack 配置:显式标记 Node.js 内置模块为 external + // 修复 CI 构建时 postgres 包导入 net/tls/crypto 等模块的问题 + webpack: (config, { isServer }) => { + if (isServer) { + // 排除 Node.js 内置模块,避免打包到服务端 bundle + config.externals.push({ + net: "commonjs net", + tls: "commonjs tls", + crypto: "commonjs crypto", + stream: "commonjs stream", + perf_hooks: "commonjs perf_hooks", + fs: "commonjs fs", + path: "commonjs path", + os: "commonjs os", + }); + } + return config; + }, }; // Wrap the Next.js config with next-intl plugin diff --git a/scripts/init-error-rules.ts b/scripts/init-error-rules.ts index 0d116a392..5ac69794f 100644 --- a/scripts/init-error-rules.ts +++ b/scripts/init-error-rules.ts @@ -1,23 +1,25 @@ #!/usr/bin/env bun /** - * Initialize default error rules + * Sync default error rules * * Usage: bun run scripts/init-error-rules.ts * - * This script inserts 7 default error rules into the error_rules table. - * It uses ON CONFLICT DO NOTHING to ensure idempotency. + * This script syncs DEFAULT_ERROR_RULES to the database: + * - Deletes all existing default rules (isDefault=true) + * - Re-inserts the latest default rules + * - User-created rules (isDefault=false) are preserved */ -import { initializeDefaultErrorRules } from "@/repository/error-rules"; +import { syncDefaultErrorRules } from "@/repository/error-rules"; async function main() { - console.log("Initializing default error rules..."); + console.log("Syncing default error rules..."); try { - await initializeDefaultErrorRules(); - console.log("✓ Default error rules initialized successfully"); + const count = await syncDefaultErrorRules(); + console.log(`✓ Default error rules synced successfully (${count} rules)`); } catch (error) { - console.error("✗ Failed to initialize default error rules:", error); + console.error("✗ Failed to sync default error rules:", error); process.exit(1); } } diff --git a/src/actions/error-rules.ts b/src/actions/error-rules.ts index 954adce05..dbeecd0b6 100644 --- a/src/actions/error-rules.ts +++ b/src/actions/error-rules.ts @@ -173,36 +173,51 @@ export async function updateErrorRuleAction( }; } + // 获取当前规则以确定最终的 matchType 和 pattern + const currentRule = await repo.getErrorRuleById(id); + if (!currentRule) { + return { + ok: false, + error: "错误规则不存在", + }; + } + + // 计算最终的 pattern 和 matchType + const finalPattern = updates.pattern ?? currentRule.pattern; + const finalMatchType = updates.matchType ?? currentRule.matchType; + // ReDoS (Regular Expression Denial of Service) 风险检测 - // 仅当更新了 pattern 且 matchType 是 regex 时检查 - if (updates.pattern) { - const matchType = updates.matchType || "regex"; - if (matchType === "regex") { - if (!safeRegex(updates.pattern)) { - return { - ok: false, - error: "正则表达式存在 ReDoS 风险,请简化模式", - }; - } - - // 验证正则表达式语法 - try { - new RegExp(updates.pattern); - } catch { - return { - ok: false, - error: "无效的正则表达式", - }; - } + // 当最终结果是 regex 类型时,需要检查 pattern 安全性 + // 这覆盖了两种情况: + // 1. 更新 pattern 到一个 regex 规则 + // 2. 将 matchType 从 contains/exact 改为 regex + if (finalMatchType === "regex") { + if (!safeRegex(finalPattern)) { + return { + ok: false, + error: "正则表达式存在 ReDoS 风险,请简化模式", + }; + } + + // 验证正则表达式语法 + try { + new RegExp(finalPattern); + } catch { + return { + ok: false, + error: "无效的正则表达式", + }; } } const result = await repo.updateErrorRule(id, updates); + // 注意:result 为 null 的情况已在上方 getErrorRuleById 检查时处理 + // 这里保留检查作为防御性编程,应对并发删除场景 if (!result) { return { ok: false, - error: "错误规则不存在", + error: "错误规则不存在或已被删除", }; } @@ -282,9 +297,14 @@ export async function deleteErrorRuleAction(id: number): Promise { /** * 手动刷新缓存 + * + * 同时同步默认规则到数据库: + * - 删除所有已有的默认规则(isDefault=true) + * - 重新插入最新的默认规则 + * - 用户自定义规则(isDefault=false)保持不变 */ export async function refreshCacheAction(): Promise< - ActionResult<{ stats: ReturnType }> + ActionResult<{ stats: ReturnType; syncedCount: number }> > { try { const session = await getSession(); @@ -295,24 +315,32 @@ export async function refreshCacheAction(): Promise< }; } + // 1. 同步默认规则到数据库 + const syncedCount = await repo.syncDefaultErrorRules(); + + // 2. 重新加载缓存(syncDefaultErrorRules 已经触发了 eventEmitter,但显式调用确保同步) await errorRuleDetector.reload(); const stats = errorRuleDetector.getStats(); - logger.info("[ErrorRulesAction] Cache refreshed", { + logger.info("[ErrorRulesAction] Default rules synced and cache refreshed", { + syncedCount, stats, userId: session.user.id, }); + // 3. 刷新页面数据 + revalidatePath("/settings/error-rules"); + return { ok: true, - data: { stats }, + data: { stats, syncedCount }, }; } catch (error) { - logger.error("[ErrorRulesAction] Failed to refresh cache:", error); + logger.error("[ErrorRulesAction] Failed to sync rules and refresh cache:", error); return { ok: false, - error: "刷新缓存失败", + error: "同步规则失败", }; } } diff --git a/src/actions/notifications.ts b/src/actions/notifications.ts index 09a58ef36..47469358a 100644 --- a/src/actions/notifications.ts +++ b/src/actions/notifications.ts @@ -25,7 +25,7 @@ function isInternalUrl(urlString: string): boolean { // 解析 IPv4 地址 const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (ipv4Match) { - const [, a, b, c] = ipv4Match.map(Number); + const [, a, b] = ipv4Match.map(Number); // 私有 IP 范围 if (a === 127) return true; // 127.0.0.0/8 (loopback range) if (a === 10) return true; // 10.0.0.0/8 diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 7b63cfdaa..548b539aa 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -30,14 +30,55 @@ import { isClientAbortError } from "@/app/v1/_lib/proxy/errors"; import { PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; import { GeminiAuth } from "@/app/v1/_lib/gemini/auth"; +const API_TEST_TIMEOUT_LIMITS = { + DEFAULT: 15000, + MIN: 5000, + MAX: 120000, +} as const; + +function resolveApiTestTimeoutMs(): number { + const rawValue = process.env.API_TEST_TIMEOUT_MS?.trim(); + if (!rawValue) { + return API_TEST_TIMEOUT_LIMITS.DEFAULT; + } + + const parsed = Number.parseInt(rawValue, 10); + if (!Number.isFinite(parsed)) { + logger.warn("API test timeout env is invalid, falling back to default", { + envValue: rawValue, + defaultTimeout: API_TEST_TIMEOUT_LIMITS.DEFAULT, + }); + return API_TEST_TIMEOUT_LIMITS.DEFAULT; + } + + if (parsed < API_TEST_TIMEOUT_LIMITS.MIN || parsed > API_TEST_TIMEOUT_LIMITS.MAX) { + logger.warn("API test timeout env is out of supported range", { + envValue: parsed, + min: API_TEST_TIMEOUT_LIMITS.MIN, + max: API_TEST_TIMEOUT_LIMITS.MAX, + defaultTimeout: API_TEST_TIMEOUT_LIMITS.DEFAULT, + }); + return API_TEST_TIMEOUT_LIMITS.DEFAULT; + } + + return parsed; +} + // API 测试配置常量 const API_TEST_CONFIG = { - TIMEOUT_MS: 10000, // 10 秒超时 - MAX_RESPONSE_PREVIEW_LENGTH: 100, // 响应内容预览最大长度 + TIMEOUT_MS: resolveApiTestTimeoutMs(), + MAX_RESPONSE_PREVIEW_LENGTH: 500, // 响应内容预览最大长度(增加到 500 字符以显示更多内容) TEST_MAX_TOKENS: 100, // 测试请求的最大 token 数 TEST_PROMPT: "Hello", // 测试请求的默认提示词 + // 流式响应资源限制(防止 DoS 攻击) + MAX_STREAM_CHUNKS: 1000, // 最大数据块数量 + MAX_STREAM_BUFFER_SIZE: 10 * 1024 * 1024, // 10MB 最大缓冲区大小 + MAX_STREAM_ITERATIONS: 10000, // 最大迭代次数(防止无限循环) } as const; +const PROXY_RETRY_STATUS_CODES = new Set([502, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530]); +const CLOUDFLARE_ERROR_STATUS_CODES = new Set([520, 521, 522, 523, 524, 525, 526, 527, 530]); + // 获取服务商数据 export async function getProviders(): Promise { try { @@ -135,6 +176,8 @@ export async function getProviders(): Promise { allowedModels: provider.allowedModels, joinClaudePool: provider.joinClaudePool, codexInstructionsStrategy: provider.codexInstructionsStrategy, + mcpPassthroughType: provider.mcpPassthroughType, + mcpPassthroughUrl: provider.mcpPassthroughUrl, limit5hUsd: provider.limit5hUsd, limitDailyUsd: provider.limitDailyUsd, dailyResetMode: provider.dailyResetMode, @@ -209,6 +252,8 @@ export async function addProvider(data: { request_timeout_non_streaming_ms?: number; website_url?: string | null; codex_instructions_strategy?: "auto" | "force_official" | "keep_original"; + mcp_passthrough_type?: "none" | "minimax" | "glm" | "custom"; + mcp_passthrough_url?: string | null; tpm: number | null; rpm: number | null; rpd: number | null; @@ -352,6 +397,8 @@ export async function editProvider( request_timeout_non_streaming_ms?: number; website_url?: string | null; codex_instructions_strategy?: "auto" | "force_official" | "keep_original"; + mcp_passthrough_type?: "none" | "minimax" | "glm" | "custom"; + mcp_passthrough_url?: string | null; tpm?: number | null; rpm?: number | null; rpd?: number | null; @@ -829,6 +876,10 @@ type ProviderApiTestResult = ActionResult< model?: string; usage?: Record; content?: string; + streamInfo?: { + chunksReceived: number; + format: "sse" | "ndjson"; + }; }; } | { @@ -979,6 +1030,497 @@ function clipText(value: unknown, maxLength?: number): string | undefined { return typeof value === "string" ? value.substring(0, limit) : undefined; } +function sanitizeErrorTextForLogging(text: string, maxLength = 500): string { + if (!text) { + return text; + } + + let sanitized = text; + sanitized = sanitized.replace(/\b(?:sk|rk|pk)-[a-zA-Z0-9]{16,}\b/giu, "[REDACTED_KEY]"); + sanitized = sanitized.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[EMAIL]"); + sanitized = sanitized.replace(/Bearer\s+[A-Za-z0-9._\-]+/gi, "Bearer [REDACTED]"); + sanitized = sanitized.replace( + /(password|token|secret)\s*[:=]\s*['\"]?[^'"\s]+['\"]?/gi, + "$1:***" + ); + sanitized = sanitized.replace(/\/[\w.-]+\.(?:env|ya?ml|json|conf|ini)/gi, "[PATH]"); + + if (sanitized.length > maxLength) { + return `${sanitized.slice(0, maxLength)}... (truncated)`; + } + + return sanitized; +} + +function extractErrorMessage(errorJson: unknown): string | undefined { + if (!errorJson || typeof errorJson !== "object") { + return undefined; + } + + const candidates: Array<(obj: Record) => unknown> = [ + (obj) => (obj.error as Record | undefined)?.message, + (obj) => obj.message, + (obj) => (obj as { error_message?: unknown }).error_message, + (obj) => obj.detail, + (obj) => (obj.error as Record | undefined)?.error, + (obj) => obj.error, + ]; + + for (const getter of candidates) { + let value: unknown; + try { + value = getter(errorJson as Record); + } catch { + continue; + } + + const normalized = normalizeErrorValue(value); + if (normalized) { + return normalized; + } + } + + return undefined; +} + +function normalizeErrorValue(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (value && typeof value === "object") { + try { + const serialized = JSON.stringify(value); + const trimmed = serialized.trim(); + return trimmed === "{}" || trimmed === "[]" ? undefined : trimmed; + } catch { + return undefined; + } + } + + return undefined; +} + +function detectCloudflareGatewayError(response: Response): boolean { + const cfRay = response.headers.get("cf-ray"); + const cfCacheStatus = response.headers.get("cf-cache-status"); + const server = response.headers.get("server"); + const via = response.headers.get("via"); + + const headerIndicatesCloudflare = Boolean( + cfRay || + cfCacheStatus || + (server && server.toLowerCase().includes("cloudflare")) || + (via && via.toLowerCase().includes("cloudflare")) + ); + + return headerIndicatesCloudflare && CLOUDFLARE_ERROR_STATUS_CODES.has(response.status); +} + +/** + * 流式响应解析结果 + */ +type StreamParseResult = { + data: ProviderApiResponse; + chunksReceived: number; + format: "sse" | "ndjson"; +}; + +/** + * 解析 SSE 文本格式的流式响应 + */ +function parseSSEText(text: string): StreamParseResult { + // 验证输入大小(防止 DoS) + if (text.length > API_TEST_CONFIG.MAX_STREAM_BUFFER_SIZE) { + throw new Error(`SSE 文本超过最大大小 (${API_TEST_CONFIG.MAX_STREAM_BUFFER_SIZE} 字节)`); + } + + const lines = text.split("\n"); + + // 防止过多行数(防止 DoS) + if (lines.length > API_TEST_CONFIG.MAX_STREAM_ITERATIONS) { + throw new Error(`SSE 超过最大行数 (${API_TEST_CONFIG.MAX_STREAM_ITERATIONS})`); + } + + const chunks: ProviderApiResponse[] = []; + let currentData = ""; + let skippedChunks = 0; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith("data:")) { + const dataContent = trimmed.slice(5).trim(); + + // 跳过 [DONE] 标记 + if (dataContent === "[DONE]") { + continue; + } + + if (dataContent) { + currentData = dataContent; + } + } else if (trimmed === "" && currentData) { + // 防止过多数据块(防止 DoS) + if (chunks.length >= API_TEST_CONFIG.MAX_STREAM_CHUNKS) { + logger.warn("SSE 解析达到最大数据块限制", { + maxChunks: API_TEST_CONFIG.MAX_STREAM_CHUNKS, + skipped: skippedChunks, + }); + break; + } + + // 空行表示一个完整的 SSE 事件结束 + try { + const parsed = JSON.parse(currentData) as ProviderApiResponse; + chunks.push(parsed); + currentData = ""; + } catch (parseError) { + // 记录解析失败的 chunk(用于调试) + skippedChunks++; + logger.warn("SSE chunk 解析失败", { + chunkPreview: clipText(currentData, 100), + error: parseError instanceof Error ? parseError.message : "Unknown", + }); + currentData = ""; + } + } + } + + // 处理最后一个未结束的 data + if (currentData && chunks.length < API_TEST_CONFIG.MAX_STREAM_CHUNKS) { + try { + const parsed = JSON.parse(currentData) as ProviderApiResponse; + chunks.push(parsed); + } catch (parseError) { + skippedChunks++; + logger.warn("SSE 最后一个 chunk 解析失败", { + chunkPreview: clipText(currentData, 100), + error: parseError instanceof Error ? parseError.message : "Unknown", + }); + } + } + + if (chunks.length === 0) { + throw new Error( + `未能从 SSE 响应中解析出有效数据${skippedChunks > 0 ? `(跳过 ${skippedChunks} 个无效 chunk)` : ""}` + ); + } + + logger.info("SSE 文本解析完成", { + totalChunks: chunks.length, + skippedChunks, + textLength: text.length, + }); + + // 合并所有 chunks 为完整响应 + const mergedResponse = mergeStreamChunks(chunks); + + return { + data: mergedResponse, + chunksReceived: chunks.length, + format: "sse", + }; +} + +/** + * 解析流式响应(从 Response 对象读取) + */ +async function parseStreamResponse(response: Response): Promise { + if (!response.body) { + throw new Error("响应体为空"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const chunks: ProviderApiResponse[] = []; + + let buffer = ""; + let currentData = ""; + let skippedChunks = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + + // 保留最后一行(可能不完整) + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith("data:")) { + const dataContent = trimmed.slice(5).trim(); + + // 跳过 [DONE] 标记 + if (dataContent === "[DONE]") { + continue; + } + + if (dataContent) { + currentData = dataContent; + } + } else if (trimmed === "" && currentData) { + // 空行表示一个完整的 SSE 事件结束 + try { + const parsed = JSON.parse(currentData) as ProviderApiResponse; + chunks.push(parsed); + currentData = ""; + } catch (parseError) { + // 记录解析失败的 chunk + skippedChunks++; + logger.warn("流式响应 chunk 解析失败", { + chunkPreview: clipText(currentData, 100), + error: parseError instanceof Error ? parseError.message : "Unknown", + }); + currentData = ""; + } + } + } + } + + // 处理剩余的 buffer + if (buffer.trim()) { + const trimmed = buffer.trim(); + if (trimmed.startsWith("data:")) { + const dataContent = trimmed.slice(5).trim(); + if (dataContent && dataContent !== "[DONE]") { + try { + const parsed = JSON.parse(dataContent) as ProviderApiResponse; + chunks.push(parsed); + } catch (parseError) { + skippedChunks++; + logger.warn("流式响应剩余 buffer 解析失败", { + chunkPreview: clipText(dataContent, 100), + error: parseError instanceof Error ? parseError.message : "Unknown", + }); + } + } + } + } + + // 处理最后一个未结束的 data + if (currentData) { + try { + const parsed = JSON.parse(currentData) as ProviderApiResponse; + chunks.push(parsed); + } catch (parseError) { + skippedChunks++; + logger.warn("流式响应最后一个 chunk 解析失败", { + chunkPreview: clipText(currentData, 100), + error: parseError instanceof Error ? parseError.message : "Unknown", + }); + } + } + } catch (error) { + // 在错误路径中取消 reader,防止资源泄漏 + await reader.cancel(); + throw error; + } finally { + reader.releaseLock(); + } + + if (chunks.length === 0) { + throw new Error( + `未能从流式响应中解析出有效数据${skippedChunks > 0 ? `(跳过 ${skippedChunks} 个无效 chunk)` : ""}` + ); + } + + logger.info("流式响应解析完成", { + totalChunks: chunks.length, + skippedChunks, + }); + + // 合并所有 chunks 为完整响应 + const mergedResponse = mergeStreamChunks(chunks); + + return { + data: mergedResponse, + chunksReceived: chunks.length, + format: "sse", + }; +} + +/** + * 合并流式 chunks 为完整响应 + */ +function mergeStreamChunks(chunks: ProviderApiResponse[]): ProviderApiResponse { + if (chunks.length === 0) { + throw new Error("没有可合并的 chunks"); + } + + // 使用第一个 chunk 作为基础 + const base = { ...chunks[0] }; + + // 合并 usage 信息(取最后一个非空的) + for (let i = chunks.length - 1; i >= 0; i--) { + const chunk = chunks[i]; + // Anthropic/OpenAI Chat/OpenAI Responses + if ("usage" in chunk && chunk.usage) { + if ("usage" in base) { + (base as AnthropicMessagesResponse | OpenAIChatResponse | OpenAIResponsesResponse).usage = + chunk.usage as ( + | AnthropicMessagesResponse + | OpenAIChatResponse + | OpenAIResponsesResponse + )["usage"]; + } + break; + } + // Gemini + if ("usageMetadata" in chunk && chunk.usageMetadata) { + (base as GeminiResponse).usageMetadata = chunk.usageMetadata; + break; + } + } + + // 合并文本内容 + let mergedText = ""; + + for (const chunk of chunks) { + // Anthropic Messages API + if ("content" in chunk && Array.isArray(chunk.content)) { + for (const content of chunk.content) { + if (content.type === "text" && "text" in content) { + mergedText += content.text; + } + } + } + + // OpenAI Chat Completions API (流式响应有 delta 字段) + if ("choices" in chunk && Array.isArray(chunk.choices)) { + const firstChoice = chunk.choices[0]; + // 流式响应使用 delta + if (firstChoice && "delta" in firstChoice) { + const delta = firstChoice.delta as { content?: string }; + if (delta.content) { + mergedText += delta.content; + } + } + // 非流式响应使用 message + else if (firstChoice?.message?.content) { + mergedText += firstChoice.message.content; + } + } + + // OpenAI Responses API + if ("output" in chunk && Array.isArray(chunk.output)) { + const firstOutput = chunk.output[0]; + if (firstOutput?.type === "message" && Array.isArray(firstOutput.content)) { + for (const content of firstOutput.content) { + if (content.type === "output_text" && "text" in content) { + mergedText += content.text; + } + } + } + } + + // Gemini API + if ("candidates" in chunk && Array.isArray(chunk.candidates)) { + const firstCandidate = chunk.candidates[0]; + if (firstCandidate?.content?.parts) { + for (const part of firstCandidate.content.parts) { + if (part.text) { + mergedText += part.text; + } + } + } + } + } + + // 将合并后的文本写回到响应对象 + if (mergedText) { + // Anthropic Messages API + if ("content" in base && Array.isArray(base.content)) { + base.content = [{ type: "text", text: mergedText }]; + } + + // OpenAI Chat Completions API + if ("choices" in base && Array.isArray(base.choices)) { + // 类型守卫:确保 base.choices[0] 存在 + const firstChoice = base.choices[0]; + if (firstChoice) { + base.choices = [ + { + ...firstChoice, + message: { role: "assistant", content: mergedText }, + finish_reason: "stop", + }, + ]; + } else { + // 如果没有 choices,创建一个默认的 + base.choices = [ + { + index: 0, + message: { role: "assistant", content: mergedText }, + finish_reason: "stop", + }, + ]; + } + } + + // OpenAI Responses API + if ("output" in base && Array.isArray(base.output)) { + const firstOutput = base.output[0]; + // 类型守卫:确保这是 OpenAI Responses 格式 + if ( + "id" in base && + typeof base.id === "string" && + "type" in base && + base.type === "response" + ) { + (base as OpenAIResponsesResponse).output = [ + { + type: "message", + id: firstOutput?.id || "msg_" + Date.now(), + status: firstOutput?.status || "completed", + role: "assistant", + content: [{ type: "output_text", text: mergedText }], + }, + ]; + } + } + + // Gemini API + if ("candidates" in base && Array.isArray(base.candidates)) { + const firstCandidate = base.candidates[0]; + // 类型守卫:确保这是 Gemini 格式 + if (firstCandidate && "content" in firstCandidate) { + (base as GeminiResponse).candidates = [ + { + ...firstCandidate, + content: { + parts: [{ text: mergedText }], + }, + finishReason: "STOP", + }, + ]; + } else { + // 如果没有 candidates,创建一个默认的 + (base as GeminiResponse).candidates = [ + { + content: { + parts: [{ text: mergedText }], + }, + finishReason: "STOP", + }, + ]; + } + } + } + + return base; +} + type ProviderUrlValidationError = { message: string; details: { @@ -1070,7 +1612,7 @@ async function executeProviderApiTest( options: { path: string | ((model: string, apiKey: string) => string); defaultModel: string; - headers: (apiKey: string) => Record; + headers: (apiKey: string, context: { providerUrl: string }) => Record; body: (model: string) => unknown; successMessage: string; extract: (result: ProviderApiResponse) => { @@ -1137,8 +1679,14 @@ async function executeProviderApiTest( const init: UndiciFetchOptions = { method: "POST", headers: { - ...options.headers(data.apiKey), + ...options.headers(data.apiKey, { providerUrl: normalizedProviderUrl }), + // 使用更完整的请求头,模拟真实 Claude CLI 行为 + // 避免被 Cloudflare Bot 检测拦截 "User-Agent": "claude-cli/2.0.33 (external, cli)", + Accept: "application/json, text/event-stream", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + Connection: "keep-alive", }, body: JSON.stringify(options.body(model)), signal: AbortSignal.timeout(API_TEST_CONFIG.TIMEOUT_MS), @@ -1148,24 +1696,103 @@ async function executeProviderApiTest( init.dispatcher = proxyConfig.agent; } - const response = await fetch(url, init); - const responseTime = Date.now() - startTime; + let response = await fetch(url, init); + let responseTime = Date.now() - startTime; + + const shouldAttemptDirectRetry = + Boolean(proxyConfig?.fallbackToDirect) && PROXY_RETRY_STATUS_CODES.has(response.status); + + if (shouldAttemptDirectRetry) { + const isCloudflareError = detectCloudflareGatewayError(response); + + logger.warn("Provider API test: Proxy returned error, falling back to direct connection", { + providerId: tempProvider.id, + providerName: tempProvider.name, + proxyStatus: response.status, + proxyUrl: proxyConfig?.proxyUrl, + fallbackReason: isCloudflareError ? "cloudflare" : "proxy-error", + }); + + const fallbackInit = { ...init }; + delete fallbackInit.dispatcher; + + const fallbackStartTime = Date.now(); + try { + response = await fetch(url, fallbackInit); + responseTime = Date.now() - fallbackStartTime; + + logger.info("Provider API test: Direct connection succeeded after proxy failure", { + providerId: tempProvider.id, + providerName: tempProvider.name, + directStatus: response.status, + directResponseTime: responseTime, + fallbackReason: isCloudflareError ? "cloudflare" : "proxy-error", + }); + } catch (directError) { + const directResponseTime = Date.now() - fallbackStartTime; + logger.error("Provider API test: Direct connection also failed", { + providerId: tempProvider.id, + error: directError, + fallbackReason: isCloudflareError ? "cloudflare" : "proxy-error", + }); + + return { + ok: true, + data: { + success: false, + message: `代理和直连均失败`, + details: { + responseTime: directResponseTime, + error: `代理错误: HTTP ${response.status} (${isCloudflareError ? "Cloudflare" : "Proxy"})\n直连错误: ${ + directError instanceof Error ? directError.message : String(directError) + }`, + }, + }, + }; + } + } if (!response.ok) { const errorText = await response.text(); + const sanitizedErrorText = sanitizeErrorTextForLogging(errorText); + + // 添加 trace 日志记录原始错误响应 + logger.trace("Provider API test raw error response", { + providerUrl: normalizedProviderUrl.replace(/:\/\/[^@]*@/, "://***@"), + status: response.status, + rawErrorText: sanitizedErrorText, + rawErrorLength: errorText.length, + }); + let errorDetail: string | undefined; try { const errorJson = JSON.parse(errorText); - errorDetail = errorJson.error?.message || errorJson.message; - } catch { + errorDetail = extractErrorMessage(errorJson); + + logger.trace("Provider API test parsed error", { + providerUrl: normalizedProviderUrl.replace(/:\/\/[^@]*@/, "://***@"), + extractedDetail: errorDetail, + errorJsonKeys: + errorJson && typeof errorJson === "object" ? Object.keys(errorJson) : undefined, + }); + } catch (parseError) { + logger.trace("Provider API test failed to parse error JSON", { + providerUrl: normalizedProviderUrl.replace(/:\/\/[^@]*@/, "://***@"), + parseError: parseError instanceof Error ? parseError.message : "Unknown parse error", + }); errorDetail = undefined; } + // 使用 errorDetail 或 errorText 的前 200 字符作为错误详情 + // 添加防御性检查,避免空字符串产生误导性错误消息 + const finalErrorDetail = + errorDetail ?? (errorText ? clipText(errorText, 200) : "No error details available"); + logger.error("Provider API test failed", { providerUrl: normalizedProviderUrl.replace(/:\/\/[^@]*@/, "://***@"), path: typeof options.path === "string" ? options.path : "dynamic", status: response.status, - errorDetail: errorDetail ?? clipText(errorText, 200), + errorDetail: finalErrorDetail, }); return { @@ -1175,13 +1802,142 @@ async function executeProviderApiTest( message: `API 返回错误: HTTP ${response.status}`, details: { responseTime, - error: "API 请求失败,查看日志以获得更多信息", + error: finalErrorDetail, + }, + }, + }; + } + + // 检查响应是否为流式响应(SSE) + const contentType = response.headers.get("content-type") || ""; + const isStreamResponse = + contentType.includes("text/event-stream") || contentType.includes("application/x-ndjson"); + + if (isStreamResponse) { + // 流式响应:读取并解析流式数据 + logger.info("Provider API test received streaming response", { + providerUrl: normalizedProviderUrl.replace(/:\/\/[^@]*@/, "://***@"), + contentType, + }); + + try { + const streamResult = await parseStreamResponse(response); + const extracted = options.extract(streamResult.data); + + return { + ok: true, + data: { + success: true, + message: `${options.successMessage}(流式响应)`, + details: { + responseTime, + ...extracted, + streamInfo: { + chunksReceived: streamResult.chunksReceived, + format: streamResult.format, + }, + }, + }, + }; + } catch (streamError) { + logger.error("Provider API test stream parsing failed", { + providerUrl: normalizedProviderUrl.replace(/:\/\/[^@]*@/, "://***@"), + error: streamError instanceof Error ? streamError.message : String(streamError), + }); + + return { + ok: true, + data: { + success: false, + message: "流式响应解析失败", + details: { + responseTime, + error: streamError instanceof Error ? streamError.message : "无法解析流式响应数据", + }, + }, + }; + } + } + + // 先读取响应文本,然后尝试解析 JSON + const responseText = await response.text(); + + // 检查是否为 SSE 格式(即使 Content-Type 未正确设置) + // 使用正则表达式进行更健壮的检测 + const ssePattern = /^(event:|data:)|\n\n(event:|data:)/; + const isLikelySSE = ssePattern.test(responseText); + + if (isLikelySSE) { + logger.info("Provider API test received SSE response without proper Content-Type", { + providerUrl: normalizedProviderUrl.replace(/:\/\/[^@]*@/, "://***@"), + contentType, + responsePreview: clipText(responseText, 100), + }); + + try { + const streamResult = parseSSEText(responseText); + const extracted = options.extract(streamResult.data); + + return { + ok: true, + data: { + success: true, + message: `${options.successMessage}(流式响应,Content-Type 未正确设置)`, + details: { + responseTime, + ...extracted, + streamInfo: { + chunksReceived: streamResult.chunksReceived, + format: streamResult.format, + }, + }, + }, + }; + } catch (streamError) { + logger.error("Provider API test SSE text parsing failed", { + providerUrl: normalizedProviderUrl.replace(/:\/\/[^@]*@/, "://***@"), + error: streamError instanceof Error ? streamError.message : String(streamError), + }); + + return { + ok: true, + data: { + success: false, + message: "流式响应解析失败", + details: { + responseTime, + error: streamError instanceof Error ? streamError.message : "无法解析 SSE 格式数据", + }, + }, + }; + } + } + + // 尝试解析 JSON + let result: ProviderApiResponse; + try { + result = JSON.parse(responseText) as ProviderApiResponse; + } catch (jsonError) { + logger.error("Provider API test JSON parse failed", { + providerUrl: normalizedProviderUrl.replace(/:\/\/[^@]*@/, "://***@"), + contentType, + responsePreview: clipText(responseText, 100), + jsonError: jsonError instanceof Error ? jsonError.message : String(jsonError), + }); + + return { + ok: true, + data: { + success: false, + message: "响应格式无效: 无法解析 JSON", + details: { + responseTime, + error: `JSON 解析失败: ${jsonError instanceof Error ? jsonError.message : "未知错误"}`, }, }, }; } - const result = (await response.json()) as ProviderApiResponse; const extracted = options.extract(result); return { @@ -1221,20 +1977,54 @@ async function executeProviderApiTest( /** * 测试 Anthropic Messages API 连通性 */ +function getHostnameFromUrl(url: string): string | null { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return null; + } +} + +function resolveAnthropicAuthHeaders(apiKey: string, providerUrl: string): Record { + const headers: Record = { + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + }; + + const hostname = getHostnameFromUrl(providerUrl); + const isOfficialAnthropic = hostname + ? hostname.endsWith("anthropic.com") || hostname.endsWith("claude.ai") + : false; + const looksLikeProxy = hostname + ? /proxy|relay|gateway|router|openai|api2d|openrouter|worker|gpt/i.test(hostname) + : false; + + if (isOfficialAnthropic) { + headers["x-api-key"] = apiKey; + return headers; + } + + if (looksLikeProxy) { + headers.Authorization = `Bearer ${apiKey}`; + return headers; + } + + headers["x-api-key"] = apiKey; + headers.Authorization = `Bearer ${apiKey}`; + return headers; +} + export async function testProviderAnthropicMessages( data: ProviderApiTestArgs ): Promise { return executeProviderApiTest(data, { path: "/v1/messages", defaultModel: "claude-sonnet-4-5-20250929", - headers: (apiKey) => ({ - "Content-Type": "application/json", - "anthropic-version": "2023-06-01", - "x-api-key": apiKey, - }), + headers: (apiKey, context) => resolveAnthropicAuthHeaders(apiKey, context.providerUrl), body: (model) => ({ model, max_tokens: API_TEST_CONFIG.TEST_MAX_TOKENS, + stream: false, // 显式禁用流式响应,避免 Cloudflare 520 错误 messages: [{ role: "user", content: API_TEST_CONFIG.TEST_PROMPT }], }), successMessage: "Anthropic Messages API 测试成功", @@ -1255,10 +2045,13 @@ export async function testProviderOpenAIChatCompletions( return executeProviderApiTest(data, { path: "/v1/chat/completions", defaultModel: "gpt-5.1-codex", - headers: (apiKey) => ({ - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }), + headers: (apiKey, context) => { + void context; + return { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }; + }, body: (model) => ({ model, max_tokens: API_TEST_CONFIG.TEST_MAX_TOKENS, @@ -1285,14 +2078,29 @@ export async function testProviderOpenAIResponses( return executeProviderApiTest(data, { path: "/v1/responses", defaultModel: "gpt-5.1-codex", - headers: (apiKey) => ({ - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }), + headers: (apiKey, context) => { + void context; + return { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }; + }, body: (model) => ({ model, - max_output_tokens: API_TEST_CONFIG.TEST_MAX_TOKENS, - input: "讲一个简短的故事", + // 注意:不包含 max_output_tokens,因为某些中转服务不支持此参数 + // input 必须是数组格式,符合 OpenAI Responses API 规范 + input: [ + { + type: "message", // ⭐ 修复 #189: Response API 要求 input 数组中的每个元素必须包含 type 字段 + role: "user", + content: [ + { + type: "input_text", + text: API_TEST_CONFIG.TEST_PROMPT, + }, + ], + }, + ], }), successMessage: "OpenAI Responses API 测试成功", extract: (result) => ({ @@ -1332,7 +2140,8 @@ export async function testProviderGemini( return `/v1beta/models/${model}:generateContent`; }, defaultModel: "gemini-1.5-pro", - headers: (apiKey) => { + headers: (apiKey, context) => { + void context; const headers: Record = { "Content-Type": "application/json", }; @@ -1341,12 +2150,15 @@ export async function testProviderGemini( } return headers; }, - body: (model) => ({ - contents: [{ parts: [{ text: API_TEST_CONFIG.TEST_PROMPT }] }], - generationConfig: { - maxOutputTokens: API_TEST_CONFIG.TEST_MAX_TOKENS, - }, - }), + body: (model) => { + void model; + return { + contents: [{ parts: [{ text: API_TEST_CONFIG.TEST_PROMPT }] }], + generationConfig: { + maxOutputTokens: API_TEST_CONFIG.TEST_MAX_TOKENS, + }, + }; + }, successMessage: "Gemini API 测试成功", extract: (result) => { const geminiResult = result as GeminiResponse; diff --git a/src/app/[locale]/dashboard/_components/user-quick-overview.tsx b/src/app/[locale]/dashboard/_components/user-quick-overview.tsx index 2a5eb9eea..76655b078 100644 --- a/src/app/[locale]/dashboard/_components/user-quick-overview.tsx +++ b/src/app/[locale]/dashboard/_components/user-quick-overview.tsx @@ -1,7 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { ArrowRight, Users, Key, TrendingUp, Clock } from "lucide-react"; import { Link } from "@/i18n/routing"; diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.tsx index 19dfaa837..9939d41af 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.tsx @@ -89,11 +89,49 @@ export function ErrorDetailsDialog({ } }, [open, sessionId]); - const getStatusBadgeVariant = () => { - if (isInProgress) return "outline"; // 请求中使用 outline 样式 - if (isSuccess) return "default"; - if (isError) return "destructive"; - return "secondary"; + /** + * 根据 HTTP 状态码返回对应的 Badge 样式类名 + * 参考:new-api 和 gpt-load 的颜色方案,使用更明显的颜色区分 + * + * 颜色方案: + * - 2xx (成功) - 绿色 + * - 3xx (重定向) - 蓝色 + * - 4xx (客户端错误) - 黄色 + * - 5xx (服务器错误) - 红色 + * - 进行中 - 灰色 + */ + const getStatusBadgeClassName = () => { + if (isInProgress) { + // 进行中 - 灰色 + return "bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600"; + } + + if (!statusCode) { + return "bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600"; + } + + // 2xx - 成功 (绿色) + if (statusCode >= 200 && statusCode < 300) { + return "bg-green-100 text-green-700 border-green-300 dark:bg-green-900/30 dark:text-green-400 dark:border-green-700"; + } + + // 3xx - 重定向 (蓝色) + if (statusCode >= 300 && statusCode < 400) { + return "bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-700"; + } + + // 4xx - 客户端错误 (黄色) + if (statusCode >= 400 && statusCode < 500) { + return "bg-yellow-100 text-yellow-700 border-yellow-300 dark:bg-yellow-900/30 dark:text-yellow-400 dark:border-yellow-700"; + } + + // 5xx - 服务器错误 (红色) + if (statusCode >= 500) { + return "bg-red-100 text-red-700 border-red-300 dark:bg-red-900/30 dark:text-red-400 dark:border-red-700"; + } + + // 其他 - 灰色 + return "bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600"; }; return ( @@ -103,7 +141,7 @@ export function ErrorDetailsDialog({ variant="ghost" className="h-auto p-0 font-normal hover:bg-transparent" > - + {isInProgress ? t("logs.details.inProgress") : statusCode} diff --git a/src/app/[locale]/settings/data/_components/database-import.tsx b/src/app/[locale]/settings/data/_components/database-import.tsx index dc31f0212..8d93a7720 100644 --- a/src/app/[locale]/settings/data/_components/database-import.tsx +++ b/src/app/[locale]/settings/data/_components/database-import.tsx @@ -88,10 +88,23 @@ export function DatabaseImport() { throw new Error(t('streamError')); } + let hasReceivedCompletion = false; + while (true) { const { done, value } = await reader.read(); if (done) { + // 流正常结束,检查是否收到完成事件 + if (!hasReceivedCompletion) { + setProgressMessages(prev => [ + ...prev, + `⚠️ ${t('streamInterrupted')}` + ]); + toast.warning(t('streamInterrupted'), { + description: t('streamInterruptedDesc'), + duration: 6000, + }); + } break; } @@ -106,14 +119,37 @@ export function DatabaseImport() { if (data.type === 'progress') { setProgressMessages(prev => [...prev, data.message]); } else if (data.type === 'complete') { - setProgressMessages(prev => [...prev, `${data.message}`]); - toast.success(t('successMessage')); + hasReceivedCompletion = true; + setProgressMessages(prev => [...prev, `✅ ${data.message}`]); + + // 检查是否有警告(exitCode 非 0 表示有可忽略错误) + if (data.exitCode && data.exitCode !== 0) { + toast.success(t('successWithWarnings'), { + description: t('successWithWarningsDesc'), + duration: 6000, + }); + } else { + toast.success(t('successMessage'), { + description: cleanFirst ? t('successCleanModeDesc') : t('successMergeModeDesc'), + duration: 5000, + }); + } } else if (data.type === 'error') { + hasReceivedCompletion = true; setProgressMessages(prev => [...prev, `❌ ${data.message}`]); - toast.error(t('failedMessage')); + + // 显示详细错误信息 + toast.error(t('failedMessage'), { + description: data.message, + duration: 8000, + }); } } catch (parseError) { console.error('Parse SSE error:', parseError); + // 解析错误也要通知用户 + toast.error(t('parseError'), { + description: String(parseError), + }); } } } diff --git a/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx b/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx index f6fda3d33..f17893c2f 100644 --- a/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx @@ -38,6 +38,7 @@ type ApiFormat = "anthropic-messages" | "openai-chat" | "openai-responses" | "ge // UI 配置常量 const API_TEST_UI_CONFIG = { MAX_PREVIEW_LENGTH: 500, // 响应内容预览最大长度 + BRIEF_PREVIEW_LENGTH: 200, // 简要预览最大长度 TOAST_SUCCESS_DURATION: 3000, // 成功 toast 显示时长(毫秒) TOAST_ERROR_DURATION: 5000, // 错误 toast 显示时长(毫秒) } as const; @@ -126,6 +127,10 @@ export function ApiTestButton({ usage?: Record | string | number; content?: string; error?: string; + streamInfo?: { + chunksReceived: number; + format: "sse" | "ndjson"; + }; }; } | null>(null); @@ -249,7 +254,7 @@ export function ApiTestButton({ const model = details?.model || t("unknown"); toast.success(t("testSuccess"), { - description: `${t("model")}: ${model} | ${t("responseTime")}: ${responseTime}`, + description: `${t("responseModel")}: ${model} | ${t("responseTime")}: ${responseTime}`, duration: API_TEST_UI_CONFIG.TOAST_SUCCESS_DURATION, }); } else { @@ -261,7 +266,7 @@ export function ApiTestButton({ }); } } catch (error) { - console.error("测试 API 连通性失败:", error); + console.error("API test failed:", error); toast.error(t("testFailedRetry")); } finally { setIsTesting(false); @@ -310,22 +315,24 @@ export function ApiTestButton({ if (!testResult) return; const resultText = [ - `测试结果: ${testResult.success ? "成功" : "失败"}`, - `消息: ${testResult.message}`, - testResult.details?.model && `模型: ${testResult.details.model}`, + `${t("copyFormat.testResult")}: ${testResult.success ? t("success") : t("failed")}`, + `${t("copyFormat.message")}: ${testResult.message}`, + testResult.details?.model && `${t("responseModel")}: ${testResult.details.model}`, testResult.details?.responseTime !== undefined && - `响应时间: ${testResult.details.responseTime}ms`, + `${t("responseTime")}: ${testResult.details.responseTime}ms`, testResult.details?.usage && - `Token 用量: ${ + `${t("usage")}: ${ typeof testResult.details.usage === "object" ? JSON.stringify(testResult.details.usage, null, 2) : String(testResult.details.usage) }`, testResult.details?.content && - `响应内容: ${testResult.details.content.slice(0, API_TEST_UI_CONFIG.MAX_PREVIEW_LENGTH)}${ + `${t("response")}: ${testResult.details.content.slice(0, API_TEST_UI_CONFIG.MAX_PREVIEW_LENGTH)}${ testResult.details.content.length > API_TEST_UI_CONFIG.MAX_PREVIEW_LENGTH ? "..." : "" }`, - testResult.details?.error && `错误详情: ${testResult.details.error}`, + testResult.details?.streamInfo && + `${t("streamResponse")}: ${t("chunksCount", { count: testResult.details.streamInfo.chunksReceived, format: testResult.details.streamInfo.format.toUpperCase() })}`, + testResult.details?.error && `${t("copyFormat.errorDetails")}: ${testResult.details.error}`, ] .filter(Boolean) .join("\n"); @@ -334,7 +341,7 @@ export function ApiTestButton({ await navigator.clipboard.writeText(resultText); toast.success(t("copySuccess")); } catch (error) { - console.error("复制失败:", error); + console.error("Copy failed:", error); toast.error(t("copyFailed")); } }; @@ -385,6 +392,16 @@ export function ApiTestButton({
{t("testModelDesc")}
+ {/* 免责声明 */} +
+
⚠️ {t("disclaimer.title")}
+
+
• {t("disclaimer.realRequest")}
+
• {t("disclaimer.resultReference")}
+
• {t("disclaimer.confirmConfig")}
+
+
+
)} + {/* 流式响应信息 */} + {testResult.details.streamInfo && ( +
+

{t("streamInfo")}

+
+
+
+ {t("chunksReceived")}:{" "} + {testResult.details.streamInfo.chunksReceived} +
+
+ {t("streamFormat")}:{" "} + {testResult.details.streamInfo.format.toUpperCase()} +
+
+
+
+ )} + {/* 错误详情 */} {testResult.details.error && (
@@ -502,17 +539,28 @@ export function ApiTestButton({
)} - {/* 复制按钮 */} - + {/* 操作按钮 */} +
+ + +
@@ -533,7 +581,8 @@ export function ApiTestButton({
{testResult.details.model && (
- {t("model")}: {testResult.details.model} + {t("responseModel")}:{" "} + {testResult.details.model}
)} {testResult.details.responseTime !== undefined && ( @@ -563,20 +612,34 @@ export function ApiTestButton({
-                      {testResult.details.content.slice(
-                        0,
-                        Math.min(200, testResult.details.content.length)
-                      )}
-                      {testResult.details.content.length > 200 && "..."}
+                      {testResult.details.content.slice(0, API_TEST_UI_CONFIG.BRIEF_PREVIEW_LENGTH)}
+                      {testResult.details.content.length >
+                        API_TEST_UI_CONFIG.BRIEF_PREVIEW_LENGTH && "..."}
                     
- {testResult.details.content.length > 200 && ( + {testResult.details.content.length > + API_TEST_UI_CONFIG.BRIEF_PREVIEW_LENGTH && (
- 显示前 200 字符,完整内容请点击“查看详情” + {t("truncatedBrief", { length: API_TEST_UI_CONFIG.BRIEF_PREVIEW_LENGTH })}
)}
)} + {testResult.details.streamInfo && ( +
+
+ {t("streamResponse")}: +
+
+
+ {t("chunksCount", { + count: testResult.details.streamInfo.chunksReceived, + format: testResult.details.streamInfo.format.toUpperCase(), + })} +
+
+
+ )} {testResult.details.error && (
diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx index ebd53351a..d48c8f451 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx @@ -26,8 +26,13 @@ import { AlertDialogTitle as AlertTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; -import type { ProviderDisplay, ProviderType, CodexInstructionsStrategy } from "@/types/provider"; -import { validateNumericField, isValidUrl } from "@/lib/utils/validation"; +import type { + ProviderDisplay, + ProviderType, + CodexInstructionsStrategy, + McpPassthroughType, +} from "@/types/provider"; +import { validateNumericField, isValidUrl, extractBaseUrl } from "@/lib/utils/validation"; import { PROVIDER_DEFAULTS, PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; import { toast } from "sonner"; import { ModelMultiSelect } from "../model-multi-select"; @@ -162,6 +167,14 @@ export function ProviderForm({ const [codexInstructionsStrategy, setCodexInstructionsStrategy] = useState(sourceProvider?.codexInstructionsStrategy ?? "auto"); + // MCP 透传配置 + const [mcpPassthroughType, setMcpPassthroughType] = useState( + sourceProvider?.mcpPassthroughType ?? "none" + ); + const [mcpPassthroughUrl, setMcpPassthroughUrl] = useState( + sourceProvider?.mcpPassthroughUrl || "" + ); + // 折叠区域状态管理 type SectionKey = | "routing" @@ -170,7 +183,8 @@ export function ProviderForm({ | "proxy" | "timeout" | "apiTest" - | "codexStrategy"; + | "codexStrategy" + | "mcpPassthrough"; const [openSections, setOpenSections] = useState>({ routing: false, rateLimit: false, @@ -179,6 +193,7 @@ export function ProviderForm({ timeout: false, apiTest: false, codexStrategy: false, + mcpPassthrough: false, }); // 从 localStorage 加载折叠偏好 @@ -223,6 +238,7 @@ export function ProviderForm({ timeout: true, apiTest: true, codexStrategy: true, + mcpPassthrough: true, }); }; @@ -236,6 +252,7 @@ export function ProviderForm({ timeout: false, apiTest: false, codexStrategy: false, + mcpPassthrough: false, }); }; @@ -292,6 +309,8 @@ export function ProviderForm({ request_timeout_non_streaming_ms?: number; website_url?: string | null; codex_instructions_strategy?: CodexInstructionsStrategy; + mcp_passthrough_type?: McpPassthroughType; + mcp_passthrough_url?: string | null; tpm?: number | null; rpm?: number | null; rpd?: number | null; @@ -334,6 +353,8 @@ export function ProviderForm({ : undefined, website_url: websiteUrl.trim() || null, codex_instructions_strategy: codexInstructionsStrategy, + mcp_passthrough_type: mcpPassthroughType, + mcp_passthrough_url: mcpPassthroughUrl.trim() || null, tpm: null, rpm: null, rpd: null, @@ -390,6 +411,8 @@ export function ProviderForm({ : PROVIDER_TIMEOUT_DEFAULTS.REQUEST_TIMEOUT_NON_STREAMING_MS, website_url: websiteUrl.trim() || null, codex_instructions_strategy: codexInstructionsStrategy, + mcp_passthrough_type: mcpPassthroughType, + mcp_passthrough_url: mcpPassthroughUrl.trim() || null, tpm: null, rpm: null, rpd: null, @@ -1109,7 +1132,7 @@ export function ProviderForm({ {/* 超时配置 */} - toggleSection("timeout")}> + toggleSection("timeout")}>
@@ -1486,6 +1508,126 @@ export function ProviderForm({ )} + {/* MCP 透传配置 */} + toggleSection("mcpPassthrough")} + > + + + + +
+
+

{t("sections.mcpPassthrough.desc")}

+
+ +
+ + +

{t("sections.mcpPassthrough.hint")}

+
+ + {/* MCP 透传 URL 配置 */} + {mcpPassthroughType !== "none" && ( +
+ + setMcpPassthroughUrl(e.target.value)} + placeholder={t("sections.mcpPassthrough.urlPlaceholder")} + disabled={isPending} + /> +

+ {t("sections.mcpPassthrough.urlDesc")} +

+ {!mcpPassthroughUrl && url && ( +

+ {t("sections.mcpPassthrough.urlAuto", { + url: extractBaseUrl(url), + })} +

+ )} +
+ )} +
+
+
+ {isEdit ? (
diff --git a/src/app/[locale]/usage-doc/page.tsx b/src/app/[locale]/usage-doc/page.tsx index 8bd7e3896..690efa481 100644 --- a/src/app/[locale]/usage-doc/page.tsx +++ b/src/app/[locale]/usage-doc/page.tsx @@ -724,14 +724,6 @@ source ${shellConfig.split(" ")[0]}`} * 渲染 Gemini CLI 配置 */ const renderGeminiConfiguration = (os: OS) => { - const configPath = os === "windows" ? "%USERPROFILE%\\.gemini" : "~/.gemini"; - const shellConfig = - os === "linux" - ? "~/.bashrc 或 ~/.zshrc" - : os === "macos" - ? "~/.zshrc 或 ~/.bash_profile" - : ""; - return (

{t("gemini.configuration.configFile.title")}

diff --git a/src/app/v1/_lib/codex/chat-completions-handler.ts b/src/app/v1/_lib/codex/chat-completions-handler.ts index ba715cdff..20f9d48f3 100644 --- a/src/app/v1/_lib/codex/chat-completions-handler.ts +++ b/src/app/v1/_lib/codex/chat-completions-handler.ts @@ -197,6 +197,7 @@ export async function handleChatCompletions(c: Context): Promise { await updateMessageRequestDetails(session.messageContext.id, { statusCode: providerUnavailable.status, errorMessage: JSON.stringify(errorBody?.error || { message: errorMessage }), + model: session.getCurrentModel() ?? undefined, }); } diff --git a/src/app/v1/_lib/proxy/error-handler.ts b/src/app/v1/_lib/proxy/error-handler.ts index 4498dd0b5..4dee974d6 100644 --- a/src/app/v1/_lib/proxy/error-handler.ts +++ b/src/app/v1/_lib/proxy/error-handler.ts @@ -153,6 +153,7 @@ export class ProxyErrorHandler { errorMessage: finalErrorMessage, providerChain: session.getProviderChain(), statusCode: statusCode, + model: session.getCurrentModel() ?? undefined, }); // 记录请求结束 diff --git a/src/app/v1/_lib/proxy/errors.ts b/src/app/v1/_lib/proxy/errors.ts index b5c78ec53..fac316585 100644 --- a/src/app/v1/_lib/proxy/errors.ts +++ b/src/app/v1/_lib/proxy/errors.ts @@ -167,34 +167,16 @@ export enum ErrorCategory { } 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; - } - } - // 兼容智谱等供应商的 FastAPI/Pydantic 验证错误格式:{ "detail": [{ "msg": "..." }] } - if (Array.isArray(parsed.detail)) { - for (const item of parsed.detail) { - if (item && typeof item === "object") { - const detailObj = item as Record; - if (typeof detailObj.msg === "string") { - message = detailObj.msg; - break; - } - } - } - } + // 确定要检测的内容 + // 优先使用整个响应体,这样规则可以匹配响应中的任何内容 + let contentToCheck = error.message; + + if (error instanceof ProxyError && error.upstreamError?.body) { + contentToCheck = error.upstreamError.body; } // 使用 ErrorRuleDetector 检测规则,支持数据库驱动的动态规则 - return errorRuleDetector.detect(message).matched; + return errorRuleDetector.detect(contentToCheck).matched; } /** diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 81c0f194a..b0cf65e5d 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -21,10 +21,17 @@ import { CodexInstructionsCache } from "@/lib/codex-instructions-cache"; import { createProxyAgentForProvider } from "@/lib/proxy-agent"; import type { Dispatcher } from "undici"; import { getEnvConfig } from "@/lib/config/env.schema"; -import { GeminiAdapter } from "../gemini/adapter"; import { GEMINI_PROTOCOL } from "../gemini/protocol"; import { GeminiAuth } from "../gemini/auth"; +const STANDARD_ENDPOINTS = [ + "/v1/messages", + "/v1/messages/count_tokens", + "/v1/responses", + "/v1/chat/completions", + "/v1/models", +]; + const MAX_ATTEMPTS_PER_PROVIDER = 2; // 每个供应商最多尝试次数(首次 + 1次重试) const MAX_PROVIDER_SWITCHES = 20; // 保险栓:最多切换 20 次供应商(防止无限循环) @@ -733,15 +740,76 @@ export class ProxyForwarder { }); } + // ⭐ MCP 透传处理:检测是否为 MCP 请求,并使用相应的 URL + let effectiveBaseUrl = provider.url; + + // 检测是否为 MCP 请求(非标准 Claude/Codex/OpenAI 端点) + const requestPath = session.requestUrl.pathname; + // pathname does not include query params, so exact match is sufficient + const isStandardRequest = STANDARD_ENDPOINTS.includes(requestPath); + const isMcpRequest = !isStandardRequest; + + if (isMcpRequest && provider.mcpPassthroughType && provider.mcpPassthroughType !== "none") { + // MCP 透传已启用,且当前是 MCP 请求 + if (provider.mcpPassthroughUrl) { + // 使用配置的 MCP URL + effectiveBaseUrl = provider.mcpPassthroughUrl; + logger.debug("ProxyForwarder: Using configured MCP passthrough URL", { + providerId: provider.id, + providerName: provider.name, + mcpType: provider.mcpPassthroughType, + configuredUrl: provider.mcpPassthroughUrl, + requestPath, + }); + } else { + // 自动从 provider.url 提取基础域名(去掉路径部分) + // 例如:https://api.minimaxi.com/anthropic -> https://api.minimaxi.com + try { + const baseUrlObj = new URL(provider.url); + effectiveBaseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`; + logger.debug("ProxyForwarder: Extracted base domain for MCP passthrough", { + providerId: provider.id, + providerName: provider.name, + mcpType: provider.mcpPassthroughType, + originalUrl: provider.url, + extractedBaseDomain: effectiveBaseUrl, + requestPath, + }); + } catch (error) { + logger.error("ProxyForwarder: Invalid provider URL for MCP passthrough", { + providerId: provider.id, + providerUrl: provider.url, + error, + }); + throw new ProxyError(`Invalid provider URL configuration: ${provider.url}`, 500); + } + } + } else if ( + isMcpRequest && + (!provider.mcpPassthroughType || provider.mcpPassthroughType === "none") + ) { + // MCP 请求但未启用 MCP 透传 + logger.debug( + "ProxyForwarder: MCP request but passthrough not enabled, using provider URL", + { + providerId: provider.id, + providerName: provider.name, + requestPath, + } + ); + } + // ⭐ 直接使用原始请求路径,让 buildProxyUrl() 智能处理路径拼接 // 移除了强制 /v1/responses 路径重写,解决 Issue #139 // buildProxyUrl() 会检测 base_url 是否已包含完整路径,避免重复拼接 - proxyUrl = buildProxyUrl(provider.url, session.requestUrl); + proxyUrl = buildProxyUrl(effectiveBaseUrl, session.requestUrl); logger.debug("ProxyForwarder: Final proxy URL", { url: proxyUrl, originalPath: session.requestUrl.pathname, providerType: provider.providerType, + mcpPassthroughType: provider.mcpPassthroughType, + usedBaseUrl: effectiveBaseUrl, }); const hasBody = session.method !== "GET" && session.method !== "HEAD"; diff --git a/src/app/v1/_lib/proxy/mcp-passthrough-handler.ts b/src/app/v1/_lib/proxy/mcp-passthrough-handler.ts new file mode 100644 index 000000000..55392da14 --- /dev/null +++ b/src/app/v1/_lib/proxy/mcp-passthrough-handler.ts @@ -0,0 +1,319 @@ +/** + * MCP 透传处理器 + * + * 检测并处理 MCP 工具调用,将其透传到配置的第三方 AI 服务商 + * 例如:将 web_search、understand_image 等工具调用透传到 minimax + * 例如:将 analyze_image、analyze_video 等工具调用透传到 GLM + */ + +import type { Provider } from "@/types/provider"; +import { logger } from "@/lib/logger"; +import { MinimaxMcpClient } from "@/lib/mcp/minimax-client"; +import { GlmMcpClient } from "@/lib/mcp/glm-client"; +import type { McpClientConfig } from "@/lib/mcp/types"; +import { McpError } from "@/lib/mcp/types"; + +/** + * MCP 工具调用信息 + */ +interface McpToolCall { + type: "tool_use"; + id: string; + name: string; + input: Record; +} + +/** + * MCP 工具响应 + */ +interface McpToolResponse { + type: "tool_result"; + tool_use_id: string; + content: string | Array<{ type: string; text?: string }>; + is_error?: boolean; +} + +/** + * MCP 透传处理器 + */ +export class McpPassthroughHandler { + /** + * 检查是否应该处理该工具调用 + * + * @param provider - 供应商配置 + * @param toolName - 工具名称 + * @returns 是否应该处理 + */ + static shouldHandle(provider: Provider, toolName: string): boolean { + // 检查供应商是否配置了 MCP 透传 + if (!provider.mcpPassthroughType || provider.mcpPassthroughType === "none") { + return false; + } + + // 检查工具名称是否支持 + const supportedTools = this.getSupportedTools(provider.mcpPassthroughType); + return supportedTools.includes(toolName); + } + + /** + * 获取支持的工具列表 + * + * @param mcpType - MCP 透传类型 + * @returns 支持的工具名称列表 + */ + private static getSupportedTools(mcpType: string): string[] { + switch (mcpType) { + case "minimax": + return ["web_search", "understand_image"]; + case "glm": + // 智谱 GLM 支持的工具:图片分析和视频分析 + return ["analyze_image", "analyze_video"]; + case "custom": + // 预留:自定义 MCP 服务支持的工具 + return []; + default: + return []; + } + } + + /** + * 处理工具调用 + * + * @param provider - 供应商配置 + * @param toolCall - 工具调用信息 + * @returns 工具响应 + */ + static async handleToolCall(provider: Provider, toolCall: McpToolCall): Promise { + logger.info("[McpPassthroughHandler] Handling tool call", { + providerId: provider.id, + providerName: provider.name, + mcpType: provider.mcpPassthroughType, + toolName: toolCall.name, + toolId: toolCall.id, + }); + + try { + // 根据 MCP 类型选择客户端 + switch (provider.mcpPassthroughType) { + case "minimax": + return await this.handleMinimaxToolCall(provider, toolCall); + case "glm": + return await this.handleGlmToolCall(provider, toolCall); + case "custom": + throw new McpError("Custom MCP passthrough is not implemented yet"); + default: + throw new McpError(`Unsupported MCP type: ${provider.mcpPassthroughType}`); + } + } catch (error) { + logger.error("[McpPassthroughHandler] Tool call failed", { + providerId: provider.id, + toolName: toolCall.name, + toolId: toolCall.id, + error: error instanceof Error ? error.message : String(error), + }); + + // 返回错误响应 + return { + type: "tool_result", + tool_use_id: toolCall.id, + content: `MCP tool call failed: ${error instanceof Error ? error.message : String(error)}`, + is_error: true, + }; + } + } + + /** + * 处理 Minimax 工具调用 + * + * @param provider - 供应商配置 + * @param toolCall - 工具调用信息 + * @returns 工具响应 + */ + private static async handleMinimaxToolCall( + provider: Provider, + toolCall: McpToolCall + ): Promise { + // 创建 Minimax 客户端 + const config: McpClientConfig = { + baseUrl: provider.url, + apiKey: provider.key, + }; + const client = new MinimaxMcpClient(config); + + // 根据工具名称调用对应方法 + switch (toolCall.name) { + case "web_search": { + const query = toolCall.input.query; + if (!query || typeof query !== "string") { + throw new McpError("Invalid parameter: query must be a non-empty string"); + } + + const response = await client.webSearch(query); + + // 格式化响应 + return { + type: "tool_result", + tool_use_id: toolCall.id, + content: [ + { + type: "text", + text: JSON.stringify(response.data?.results || [], null, 2), + }, + ], + }; + } + + case "understand_image": { + const imageUrl = toolCall.input.image_url; + const prompt = toolCall.input.prompt; + + if (!imageUrl || typeof imageUrl !== "string") { + throw new McpError("Invalid parameter: image_url must be a non-empty string"); + } + if (!prompt || typeof prompt !== "string") { + throw new McpError("Invalid parameter: prompt must be a non-empty string"); + } + + const response = await client.understandImage(imageUrl, prompt); + + // 格式化响应 + return { + type: "tool_result", + tool_use_id: toolCall.id, + content: [ + { + type: "text", + text: response.data?.analysis || "No analysis result", + }, + ], + }; + } + + default: + throw new McpError(`Unsupported tool: ${toolCall.name}`); + } + } + + /** + * 处理 GLM 工具调用 + * + * @param provider - 供应商配置 + * @param toolCall - 工具调用信息 + * @returns 工具响应 + */ + private static async handleGlmToolCall( + provider: Provider, + toolCall: McpToolCall + ): Promise { + // 创建 GLM 客户端 + const config: McpClientConfig = { + baseUrl: provider.url, + apiKey: provider.key, + }; + const client = new GlmMcpClient(config); + + // 根据工具名称调用对应方法 + switch (toolCall.name) { + case "analyze_image": { + const imageSource = toolCall.input.image_source; + const prompt = toolCall.input.prompt; + + if (!imageSource || typeof imageSource !== "string") { + throw new McpError("Invalid parameter: image_source must be a non-empty string"); + } + if (!prompt || typeof prompt !== "string") { + throw new McpError("Invalid parameter: prompt must be a non-empty string"); + } + + const response = await client.analyzeImage(imageSource, prompt); + + // 格式化响应 + return { + type: "tool_result", + tool_use_id: toolCall.id, + content: [ + { + type: "text", + text: response.result, + }, + ], + }; + } + + case "analyze_video": { + const videoSource = toolCall.input.video_source; + const prompt = toolCall.input.prompt; + + if (!videoSource || typeof videoSource !== "string") { + throw new McpError("Invalid parameter: video_source must be a non-empty string"); + } + if (!prompt || typeof prompt !== "string") { + throw new McpError("Invalid parameter: prompt must be a non-empty string"); + } + + const response = await client.analyzeVideo(videoSource, prompt); + + // 格式化响应 + return { + type: "tool_result", + tool_use_id: toolCall.id, + content: [ + { + type: "text", + text: response.result, + }, + ], + }; + } + + default: + throw new McpError(`Unsupported tool: ${toolCall.name}`); + } + } + + /** + * 从请求中提取工具调用 + * + * @param requestBody - 请求体 + * @returns 工具调用列表(如果有) + */ + static extractToolCalls(requestBody: unknown): McpToolCall[] | null { + if (!requestBody || typeof requestBody !== "object") { + return null; + } + + const body = requestBody as Record; + + // 检查是否包含 messages 数组 + if (!Array.isArray(body.messages)) { + return null; + } + + // 查找包含 tool_use 的消息 + const toolCalls: McpToolCall[] = []; + + for (const message of body.messages) { + if (typeof message !== "object" || !message) { + continue; + } + + const msg = message as Record; + + // 检查 content 数组 + if (Array.isArray(msg.content)) { + for (const content of msg.content) { + if ( + typeof content === "object" && + content && + "type" in content && + content.type === "tool_use" + ) { + toolCalls.push(content as McpToolCall); + } + } + } + } + + return toolCalls.length > 0 ? toolCalls : null; + } +} diff --git a/src/app/v1/_lib/proxy/message-service.ts b/src/app/v1/_lib/proxy/message-service.ts index 40e333120..f78b59fb9 100644 --- a/src/app/v1/_lib/proxy/message-service.ts +++ b/src/app/v1/_lib/proxy/message-service.ts @@ -21,6 +21,16 @@ export class ProxyMessageService { // Extract endpoint from URL pathname (nullable) const endpoint = session.getEndpoint() ?? undefined; + // ⭐ 修复模型重定向记录问题: + // 由于 ensureContext 在模型重定向之前被调用(guard-pipeline 阶段), + // 此时 session.getOriginalModel() 可能返回 null。 + // 因此需要在这里提前保存当前模型作为 original_model, + // 如果后续发生重定向,ModelRedirector.apply() 会再次调用 setOriginalModel()(幂等性保护) + const currentModel = session.request.model; + if (currentModel && !session.getOriginalModel()) { + session.setOriginalModel(currentModel); + } + const messageRequest = await createMessageRequest({ provider_id: provider.id, user_id: authState.user.id, diff --git a/src/app/v1/_lib/proxy/model-redirector.ts b/src/app/v1/_lib/proxy/model-redirector.ts index 4c4154366..f9015e947 100644 --- a/src/app/v1/_lib/proxy/model-redirector.ts +++ b/src/app/v1/_lib/proxy/model-redirector.ts @@ -26,28 +26,64 @@ export class ModelRedirector { // 获取原始模型名称 const originalModel = session.request.model; if (!originalModel) { - logger.debug("[ModelRedirector] No model found in request, skipping redirect"); + logger.debug("[ModelRedirector] No model in request, skipping redirect", { + providerId: provider.id, + providerName: provider.name, + }); return false; } // 检查是否有该模型的重定向配置 const redirectedModel = provider.modelRedirects[originalModel]; if (!redirectedModel) { - logger.debug( - `[ModelRedirector] No redirect configured for model "${originalModel}" in provider ${provider.id}` - ); + logger.debug("[ModelRedirector] No redirect configured for model", { + model: originalModel, + providerId: provider.id, + providerName: provider.name, + }); return false; } // 执行重定向 - logger.info( - `[ModelRedirector] Redirecting model: "${originalModel}" → "${redirectedModel}" (provider ${provider.id})` - ); + logger.info("[ModelRedirector] Model redirected", { + originalModel, + redirectedModel, + providerId: provider.id, + providerName: provider.name, + providerType: provider.providerType, + }); // 保存原始模型(用于计费,必须在修改 request.model 之前) session.setOriginalModel(originalModel); - // 修改 message 对象中的模型 + // Gemini 特殊处理:修改 URL 路径中的模型名称 + // Gemini API 的模型名称通过 URL 路径传递,不是通过 request body + // 例如:/v1internal/models/gemini-2.5-flash:generateContent + if (provider.providerType === "gemini" || provider.providerType === "gemini-cli") { + const originalPath = session.requestUrl.pathname; + // 替换 URL 中的模型名称 + // 匹配模式:/models/{model}:action 或 /models/{model} + const newPath = originalPath.replace( + /\/models\/([^/:]+)(:[^/]+)?$/, + `/models/${redirectedModel}$2` + ); + + if (newPath !== originalPath) { + // 创建新的 URL 对象并修改路径 + const newUrl = new URL(session.requestUrl.toString()); + newUrl.pathname = newPath; + session.requestUrl = newUrl; + + logger.debug(`[ModelRedirector] Updated Gemini URL path`, { + originalPath, + newPath, + originalModel, + redirectedModel, + }); + } + } + + // 修改 message 对象中的模型(对 Claude/OpenAI 有效,对 Gemini 无效但不影响) session.request.message.model = redirectedModel; // 更新缓存的 model 字段 @@ -70,9 +106,11 @@ export class ModelRedirector { redirectedModel: redirectedModel, billingModel: originalModel, // 始终使用原始模型计费 }; - logger.debug( - `[ModelRedirector] Added modelRedirect to provider chain for provider ${provider.id}` - ); + logger.debug("[ModelRedirector] Added modelRedirect to provider chain", { + providerId: provider.id, + originalModel, + redirectedModel, + }); } return true; diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index ec71eb4b4..4e4e41f4c 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/src/app/v1/_lib/proxy/provider-selector.ts @@ -467,17 +467,29 @@ export class ProxyProviderResolver { .map((g) => g.trim()) .filter(Boolean); - // 检查供应商的 groupTag 是否在用户的分组列表中 - if (provider.groupTag && !userGroups.includes(provider.groupTag)) { - logger.warn("ProviderSelector: Session provider not in user groups", { - sessionId: session.sessionId, - providerId: provider.id, - providerName: provider.name, - providerGroup: provider.groupTag, - userGroups: userGroups.join(","), - message: "Strict group isolation: rejecting cross-group session reuse", - }); - return null; // 不允许复用,重新选择 + // 检查供应商的 groupTag 与用户的分组是否有交集 + // 修复 #190: 支持供应商多标签(如 "cli,chat")与用户单标签(如 "cli")的匹配 + if (provider.groupTag) { + // 将供应商的 groupTag 拆分成标签数组 + const providerTags = provider.groupTag + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); + + // 检查是否有交集 + const hasIntersection = providerTags.some((tag) => userGroups.includes(tag)); + + if (!hasIntersection) { + logger.warn("ProviderSelector: Session provider not in user groups", { + sessionId: session.sessionId, + providerId: provider.id, + providerName: provider.name, + providerTags: providerTags.join(","), + userGroups: userGroups.join(","), + message: "Strict group isolation: rejecting cross-group session reuse", + }); + return null; // 不允许复用,重新选择 + } } } // 全局用户(userGroup 为空)可以复用任何供应商 @@ -625,10 +637,20 @@ export class ProxyProviderResolver { .map((g) => g.trim()) .filter(Boolean); - // 过滤:供应商的 groupTag 在用户的分组列表中 - const groupFiltered = enabledProviders.filter( - (p) => p.groupTag && userGroups.includes(p.groupTag) - ); + // 过滤:供应商的 groupTag 与用户的分组有交集 + // 修复 #190: 支持供应商多标签(如 "cli,chat")与用户单标签(如 "cli")的匹配 + const groupFiltered = enabledProviders.filter((p) => { + if (!p.groupTag) return false; + + // 将供应商的 groupTag 拆分成标签数组 + const providerTags = p.groupTag + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); + + // 检查是否有交集:用户的分组中是否有任意一个标签在供应商的标签列表中 + return providerTags.some((tag) => userGroups.includes(tag)); + }); if (groupFiltered.length > 0) { candidateProviders = groupFiltered; diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 8f4b84fc3..bcdb5e487 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -205,6 +205,7 @@ export class ProxyResponseHandler { await updateMessageRequestDetails(messageContext.id, { statusCode: statusCode, providerChain: session.getProviderChain(), + model: session.getCurrentModel() ?? undefined, // ⭐ 更新重定向后的模型 }); const tracker = ProxyStatusTracker.getInstance(); tracker.endRequest(messageContext.user.id, messageContext.id); @@ -323,6 +324,7 @@ export class ProxyResponseHandler { cacheCreationInputTokens: usageMetrics?.cache_creation_input_tokens, cacheReadInputTokens: usageMetrics?.cache_read_input_tokens, providerChain: session.getProviderChain(), + model: session.getCurrentModel() ?? undefined, // ⭐ 更新重定向后的模型 }); // 记录请求结束 @@ -587,7 +589,7 @@ export class ProxyResponseHandler { const openAIChunk = GeminiAdapter.transformResponse(geminiResponse, true); const output = `data: ${JSON.stringify(openAIChunk)}\n\n`; controller.enqueue(new TextEncoder().encode(output)); - } catch (e) { + } catch { // Ignore parse errors } } @@ -821,6 +823,7 @@ export class ProxyResponseHandler { cacheCreationInputTokens: usageForCost?.cache_creation_input_tokens, cacheReadInputTokens: usageForCost?.cache_read_input_tokens, providerChain: session.getProviderChain(), + model: session.getCurrentModel() ?? undefined, // ⭐ 更新重定向后的模型 }); }; @@ -1411,6 +1414,7 @@ async function finalizeRequestStats( await updateMessageRequestDetails(messageContext.id, { statusCode: statusCode, providerChain: session.getProviderChain(), + model: session.getCurrentModel() ?? undefined, }); return; } @@ -1465,6 +1469,7 @@ async function finalizeRequestStats( cacheCreationInputTokens: usageMetrics.cache_creation_input_tokens, cacheReadInputTokens: usageMetrics.cache_read_input_tokens, providerChain: session.getProviderChain(), + model: session.getCurrentModel() ?? undefined, }); } @@ -1552,6 +1557,7 @@ async function persistRequestFailure(options: { statusCode, errorMessage, providerChain: session.getProviderChain(), + model: session.getCurrentModel() ?? undefined, }); logger.info("ResponseHandler: Successfully persisted request failure", { diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 55ad3ab00..aabd36cae 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -39,7 +39,7 @@ interface RequestBodyResult { export class ProxySession { readonly startTime: number; readonly method: string; - readonly requestUrl: URL; + requestUrl: URL; // 非 readonly,允许模型重定向修改 Gemini URL 路径 readonly headers: Headers; readonly headerLog: string; readonly request: ProxyRequestPayload; diff --git a/src/app/v1beta/[...route]/route.ts b/src/app/v1beta/[...route]/route.ts index c4a9f9f91..afbe5fddf 100644 --- a/src/app/v1beta/[...route]/route.ts +++ b/src/app/v1beta/[...route]/route.ts @@ -1,6 +1,5 @@ import "@/lib/polyfills/file"; import { Hono } from "hono"; -import { logger } from "@/lib/logger"; import { handle } from "hono/vercel"; import { handleProxyRequest } from "@/app/v1/_lib/proxy-handler"; diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index cabd28aac..e85c1ebb0 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -26,7 +26,7 @@ export const users = pgTable('users', { rpmLimit: integer('rpm_limit').default(60), dailyLimitUsd: numeric('daily_limit_usd', { precision: 10, scale: 2 }).default('100.00'), providerGroup: varchar('provider_group', { length: 50 }), - + // New user-level quota fields (nullable for backward compatibility) limit5hUsd: numeric('limit_5h_usd', { precision: 10, scale: 2 }), limitWeeklyUsd: numeric('limit_weekly_usd', { precision: 10, scale: 2 }), @@ -126,6 +126,21 @@ export const providers = pgTable('providers', { .default('auto') .$type<'auto' | 'force_official' | 'keep_original'>(), + // MCP 透传类型:控制是否启用 MCP 透传功能 + // - 'none' (默认): 不启用 MCP 透传 + // - 'minimax': 透传到 minimax MCP 服务(图片识别、联网搜索) + // - 'glm': 透传到智谱 MCP 服务(预留) + // - 'custom': 自定义 MCP 服务(预留) + mcpPassthroughType: varchar('mcp_passthrough_type', { length: 20 }) + .notNull() + .default('none') + .$type<'none' | 'minimax' | 'glm' | 'custom'>(), + + // MCP 透传 URL:MCP 服务的基础 URL + // 如果未配置,则自动从 provider.url 提取基础域名 + // 例如:https://api.minimaxi.com/anthropic -> https://api.minimaxi.com + mcpPassthroughUrl: varchar('mcp_passthrough_url', { length: 512 }), + // 金额限流配置 limit5hUsd: numeric('limit_5h_usd', { precision: 10, scale: 2 }), limitDailyUsd: numeric('limit_daily_usd', { precision: 10, scale: 2 }), @@ -154,15 +169,16 @@ export const providers = pgTable('providers', { // - firstByteTimeoutStreamingMs: 流式请求首字节超时(默认 30 秒,0 = 禁用)⭐ 核心 // 覆盖从请求开始到收到首字节的全过程:DNS + TCP + TLS + 请求发送 + 首字节接收 // 解决流式请求重试缓慢问题 - // - streamingIdleTimeoutMs: 流式请求静默期超时(默认 10 秒,0 = 禁用)⭐ 核心 + // - streamingIdleTimeoutMs: 流式请求静默期超时(默认 0 = 不限制)⭐ 核心 // 解决流式中途卡住问题 - // - requestTimeoutNonStreamingMs: 非流式请求总超时(默认 600 秒,0 = 禁用)⭐ 核心 + // 注意:配置非 0 值时,最小必须为 60 秒 + // - requestTimeoutNonStreamingMs: 非流式请求总超时(默认 0 = 不限制)⭐ 核心 // 防止长请求无限挂起 - firstByteTimeoutStreamingMs: integer('first_byte_timeout_streaming_ms').notNull().default(30000), - streamingIdleTimeoutMs: integer('streaming_idle_timeout_ms').notNull().default(10000), + firstByteTimeoutStreamingMs: integer('first_byte_timeout_streaming_ms').notNull().default(0), + streamingIdleTimeoutMs: integer('streaming_idle_timeout_ms').notNull().default(0), requestTimeoutNonStreamingMs: integer('request_timeout_non_streaming_ms') .notNull() - .default(600000), + .default(0), // 供应商官网地址(用于快速跳转管理) websiteUrl: text('website_url'), diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 7e4613df7..e3f58b0a9 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -5,6 +5,29 @@ import { logger } from "@/lib/logger"; +/** + * 同步错误规则并初始化检测器 + * 提取为独立函数以避免代码重复 + * + * 每次启动都会同步 DEFAULT_ERROR_RULES 到数据库: + * - 删除所有预置规则(isDefault=true) + * - 重新插入最新的预置规则 + * - 用户自定义规则(isDefault=false)保持不变 + * + * 注意: 此函数会传播关键错误,调用者应决定是否需要优雅降级 + */ +async function syncErrorRulesAndInitializeDetector(): Promise { + // 同步默认错误规则到数据库 - 每次启动都完整同步 + const { syncDefaultErrorRules } = await import("@/repository/error-rules"); + const syncedCount = await syncDefaultErrorRules(); + logger.info(`Default error rules synced successfully (${syncedCount} rules)`); + + // 加载错误规则缓存 - 让关键错误传播 + const { errorRuleDetector } = await import("@/lib/error-rule-detector"); + await errorRuleDetector.reload(); + logger.info("Error rule detector cache loaded successfully"); +} + export async function register() { // 仅在服务器端执行 if (process.env.NEXT_RUNTIME === "nodejs") { @@ -36,13 +59,15 @@ export async function register() { const { ensurePriceTable } = await import("@/lib/price-sync/seed-initializer"); await ensurePriceTable(); - // 初始化默认错误规则 - const { initializeDefaultErrorRules } = await import("@/repository/error-rules"); + // 同步错误规则并初始化检测器(非关键功能,允许优雅降级) try { - await initializeDefaultErrorRules(); - logger.info("Default error rules initialized successfully"); + await syncErrorRulesAndInitializeDetector(); } catch (error) { - logger.error("Failed to initialize default error rules:", error); + logger.error( + "[Instrumentation] Non-critical: Error rule detector initialization failed", + error + ); + // 继续启动 - 错误检测不是核心功能的关键依赖 } // 初始化日志清理任务队列(如果启用) @@ -72,13 +97,15 @@ export async function register() { const { ensurePriceTable } = await import("@/lib/price-sync/seed-initializer"); await ensurePriceTable(); - // 初始化默认错误规则 - const { initializeDefaultErrorRules } = await import("@/repository/error-rules"); + // 同步错误规则并初始化检测器(非关键功能,允许优雅降级) try { - await initializeDefaultErrorRules(); - logger.info("Default error rules initialized successfully"); + await syncErrorRulesAndInitializeDetector(); } catch (error) { - logger.error("Failed to initialize default error rules:", error); + logger.error( + "[Instrumentation] Non-critical: Error rule detector initialization failed", + error + ); + // 继续启动 - 错误检测不是核心功能的关键依赖 } // ⚠️ 开发环境禁用通知队列(Bull + Turbopack 不兼容) diff --git a/src/lib/constants/provider.constants.ts b/src/lib/constants/provider.constants.ts index 2c312ca9c..8c4527cda 100644 --- a/src/lib/constants/provider.constants.ts +++ b/src/lib/constants/provider.constants.ts @@ -21,24 +21,27 @@ export const PROVIDER_DEFAULTS = { /** * 供应商超时配置常量(毫秒) + * + * 注意:0 表示禁用超时(Infinity),不受 MIN/MAX 限制 */ export const PROVIDER_TIMEOUT_LIMITS = { - // 流式请求首字节超时:1-120 秒(1000-120000 毫秒) + // 流式请求首字节超时:1-180 秒(1000-180000 毫秒) // 核心:解决流式请求重试缓慢问题 - FIRST_BYTE_TIMEOUT_STREAMING_MS: { MIN: 1000, MAX: 120000 }, - // 流式请求静默期超时:1-120 秒(1000-120000 毫秒) + FIRST_BYTE_TIMEOUT_STREAMING_MS: { MIN: 1000, MAX: 180000 }, + // 流式请求静默期超时:60-600 秒(60000-600000 毫秒) // 核心:解决流式中途卡住问题 - STREAMING_IDLE_TIMEOUT_MS: { MIN: 1000, MAX: 120000 }, - // 非流式请求总超时:60-1200 秒(60000-1200000 毫秒) + // 注意:配置非 0 值时,最小必须为 60 秒 + STREAMING_IDLE_TIMEOUT_MS: { MIN: 60000, MAX: 600000 }, + // 非流式请求总超时:60-1800 秒(60000-1800000 毫秒) // 核心:防止长请求无限挂起 - REQUEST_TIMEOUT_NON_STREAMING_MS: { MIN: 60000, MAX: 1200000 }, + REQUEST_TIMEOUT_NON_STREAMING_MS: { MIN: 60000, MAX: 1800000 }, } as const; export const PROVIDER_TIMEOUT_DEFAULTS = { - // 流式首字节超时默认 30 秒(快速失败) - FIRST_BYTE_TIMEOUT_STREAMING_MS: 30000, - // 流式静默期超时默认 10 秒(防止中途卡住) - STREAMING_IDLE_TIMEOUT_MS: 10000, - // 非流式总超时默认 600 秒(10 分钟) - REQUEST_TIMEOUT_NON_STREAMING_MS: 600000, + // 流式首字节超时默认 0(不限制) + FIRST_BYTE_TIMEOUT_STREAMING_MS: 0, + // 流式静默期超时默认 0(不限制) + STREAMING_IDLE_TIMEOUT_MS: 0, + // 非流式总超时默认 0(不限制) + REQUEST_TIMEOUT_NON_STREAMING_MS: 0, } as const; diff --git a/src/lib/database-backup/docker-executor.ts b/src/lib/database-backup/docker-executor.ts index 21f8f1d5c..c548ae57d 100644 --- a/src/lib/database-backup/docker-executor.ts +++ b/src/lib/database-backup/docker-executor.ts @@ -248,22 +248,25 @@ export function executePgRestore( }); // 进程结束 - pgProcess.on("close", (code: number | null) => { + pgProcess.on("close", async (code: number | null) => { // 智能错误分析 const analysis = analyzeRestoreErrors(errorLines); + // 判断是否需要执行迁移(成功或可忽略错误) + const shouldRunMigrations = + code === 0 || (code === 1 && !analysis.hasFatalErrors && analysis.ignorableCount > 0); + if (code === 0) { logger.info({ action: "pg_restore_complete", database: dbConfig.database, }); - const completeMessage = `data: ${JSON.stringify({ - type: "complete", + const progressMessage = `data: ${JSON.stringify({ + type: "progress", message: "数据导入成功!", - exitCode: code, })}\n\n`; - controller.enqueue(encoder.encode(completeMessage)); + controller.enqueue(encoder.encode(progressMessage)); } else if (code === 1 && !analysis.hasFatalErrors && analysis.ignorableCount > 0) { // 特殊处理:退出代码 1 但只有可忽略错误(对象已存在) logger.warn({ @@ -274,13 +277,11 @@ export function executePgRestore( analysis: analysis.summary, }); - const completeMessage = `data: ${JSON.stringify({ - type: "complete", + const progressMessage = `data: ${JSON.stringify({ + type: "progress", message: analysis.summary, - exitCode: code, - warningCount: analysis.ignorableCount, })}\n\n`; - controller.enqueue(encoder.encode(completeMessage)); + controller.enqueue(encoder.encode(progressMessage)); } else { // 真正的失败 logger.error({ @@ -298,6 +299,63 @@ export function executePgRestore( errorCount: analysis.fatalCount || errorLines.length, })}\n\n`; controller.enqueue(encoder.encode(errorMessage)); + controller.close(); + return; + } + + // 如果数据导入成功,自动执行数据库迁移 + if (shouldRunMigrations) { + try { + logger.info({ + action: "pg_restore_running_migrations", + database: dbConfig.database, + }); + + const migrationsMessage = `data: ${JSON.stringify({ + type: "progress", + message: "正在执行数据库迁移以同步 schema...", + })}\n\n`; + controller.enqueue(encoder.encode(migrationsMessage)); + + // 动态导入迁移函数 + const { runMigrations } = await import("@/lib/migrate"); + await runMigrations(); + + logger.info({ + action: "pg_restore_migrations_complete", + database: dbConfig.database, + }); + + const migrationSuccessMessage = `data: ${JSON.stringify({ + type: "progress", + message: "数据库迁移完成!", + })}\n\n`; + controller.enqueue(encoder.encode(migrationSuccessMessage)); + + // 发送最终完成消息 + const completeMessage = `data: ${JSON.stringify({ + type: "complete", + message: "数据导入和迁移全部完成!", + exitCode: code, + warningCount: analysis.ignorableCount || undefined, + })}\n\n`; + controller.enqueue(encoder.encode(completeMessage)); + } catch (migrationError) { + logger.error({ + action: "pg_restore_migrations_error", + database: dbConfig.database, + error: + migrationError instanceof Error ? migrationError.message : String(migrationError), + }); + + const errorMessage = `data: ${JSON.stringify({ + type: "error", + message: `数据库迁移失败: ${ + migrationError instanceof Error ? migrationError.message : String(migrationError) + }`, + })}\n\n`; + controller.enqueue(encoder.encode(errorMessage)); + } } controller.close(); diff --git a/src/lib/error-rule-detector.ts b/src/lib/error-rule-detector.ts index a5b1ec870..9e77bc21d 100644 --- a/src/lib/error-rule-detector.ts +++ b/src/lib/error-rule-detector.ts @@ -61,13 +61,10 @@ class ErrorRuleDetector { private exactPatterns: Map = new Map(); private lastReloadTime: number = 0; private isLoading: boolean = false; + private isInitialized: boolean = false; // 跟踪初始化状态 + private initializationPromise: Promise | null = null; // 防止并发初始化竞态 constructor() { - // 初始化时立即加载缓存(异步,不阻塞构造函数) - this.reload().catch((error) => { - logger.error("[ErrorRuleDetector] Failed to initialize cache:", error); - }); - // 监听数据库变更事件,自动刷新缓存 eventEmitter.on("errorRulesUpdated", () => { this.reload().catch((error) => { @@ -76,6 +73,25 @@ class ErrorRuleDetector { }); } + /** + * 确保规则已加载(懒加载,首次使用时或显式 reload 时调用) + * 避免在数据库未准备好时过早加载 + * 使用 Promise 合并模式防止并发请求时的竞态条件 + */ + private async ensureInitialized(): Promise { + if (this.isInitialized) { + return; + } + + if (!this.initializationPromise) { + this.initializationPromise = this.reload().finally(() => { + this.initializationPromise = null; + }); + } + + await this.initializationPromise; + } + /** * 从数据库重新加载错误规则 */ @@ -102,6 +118,7 @@ class ErrorRuleDetector { "[ErrorRuleDetector] error_rules table does not exist yet (migration pending), using empty rules" ); this.lastReloadTime = Date.now(); + this.isLoading = false; // 关键:early return 时必须清除 isLoading,否则后续 reload 会被永久阻塞 return; } // 其他数据库错误继续抛出 @@ -169,6 +186,7 @@ class ErrorRuleDetector { } this.lastReloadTime = Date.now(); + this.isInitialized = true; // 标记为已初始化 logger.info( `[ErrorRuleDetector] Loaded ${rules.length} error rules: ` + @@ -184,7 +202,22 @@ class ErrorRuleDetector { } /** - * 检测错误消息是否匹配任何规则 + * 异步检测错误消息(推荐使用) + * 确保规则已加载后再进行检测 + * + * @param errorMessage - 错误消息 + * @returns 检测结果 + */ + async detectAsync(errorMessage: string): Promise { + await this.ensureInitialized(); + return this.detect(errorMessage); + } + + /** + * 检测错误消息是否匹配任何规则(同步版本) + * + * 注意:如果规则未初始化,会记录警告并返回 false + * 推荐使用 detectAsync() 以确保规则已加载 * * 检测顺序(性能优先): * 1. 包含匹配(最快,O(n*m)) @@ -199,6 +232,13 @@ class ErrorRuleDetector { return { matched: false }; } + // 如果未初始化,记录警告 + if (!this.isInitialized && !this.isLoading) { + logger.warn( + "[ErrorRuleDetector] detect() called before initialization, results may be incomplete. Consider using detectAsync() instead." + ); + } + const lowerMessage = errorMessage.toLowerCase(); const trimmedMessage = lowerMessage.trim(); diff --git a/src/lib/logger.ts b/src/lib/logger.ts index a72ed0d89..e4bbc1d2f 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -47,6 +47,11 @@ const pinoInstance = pino({ }, } : undefined, + // 生产环境格式化时间戳为 ISO 8601 格式 + // timestamp 是顶级配置项,返回格式化的时间字符串 + timestamp: enablePrettyTransport + ? undefined // pino-pretty 会处理时间格式 + : pino.stdTimeFunctions.isoTime, formatters: { level: (label) => { return { level: label }; diff --git a/src/lib/mcp/glm-client.ts b/src/lib/mcp/glm-client.ts new file mode 100644 index 000000000..12a56cdc2 --- /dev/null +++ b/src/lib/mcp/glm-client.ts @@ -0,0 +1,176 @@ +/** + * GLM MCP 客户端 + * 实现图片分析和视频分析功能 + */ + +import { logger } from "@/lib/logger"; +import type { + McpClientConfig, + McpGlmImageAnalyzeRequest, + McpGlmImageAnalyzeResponse, + McpGlmVideoAnalyzeRequest, + McpGlmVideoAnalyzeResponse, +} from "./types"; +import { McpAuthError, McpRequestError } from "./types"; + +/** + * GLM MCP 客户端 + * 提供图片和视频分析功能 + */ +export class GlmMcpClient { + private baseUrl: string; + private apiKey: string; + + constructor(config: McpClientConfig) { + this.baseUrl = config.baseUrl; + this.apiKey = config.apiKey; + } + + /** + * 分析图片 + * @param imageSource 图片源(本地路径或远程 URL) + * @param prompt 提示词 + * @returns 图片分析结果 + */ + async analyzeImage(imageSource: string, prompt: string): Promise { + if (!imageSource) { + throw new McpRequestError("Image source is required"); + } + if (!prompt) { + throw new McpRequestError("Prompt is required"); + } + + const payload: McpGlmImageAnalyzeRequest = { + image_source: imageSource, + prompt, + }; + + logger.info("[GlmMcpClient] analyzeImage", { imageSource, prompt }); + + try { + // GLM 使用多模态接口处理图片分析 + // 这里模拟 GLM MCP 工具的调用方式 + const response = await this.makeRequest( + "/api/chat/completions", + payload + ); + + logger.info("[GlmMcpClient] analyzeImage success", { + imageSource, + prompt, + }); + + return response; + } catch (error) { + logger.error("[GlmMcpClient] analyzeImage failed", { + imageSource, + prompt, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * 分析视频 + * @param videoSource 视频源(本地路径或远程 URL) + * @param prompt 提示词 + * @returns 视频分析结果 + */ + async analyzeVideo(videoSource: string, prompt: string): Promise { + if (!videoSource) { + throw new McpRequestError("Video source is required"); + } + if (!prompt) { + throw new McpRequestError("Prompt is required"); + } + + const payload: McpGlmVideoAnalyzeRequest = { + video_source: videoSource, + prompt, + }; + + logger.info("[GlmMcpClient] analyzeVideo", { videoSource, prompt }); + + try { + // GLM 使用多模态接口处理视频分析 + const response = await this.makeRequest( + "/api/chat/completions", + payload + ); + + logger.info("[GlmMcpClient] analyzeVideo success", { + videoSource, + prompt, + }); + + return response; + } catch (error) { + logger.error("[GlmMcpClient] analyzeVideo failed", { + videoSource, + prompt, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * 发起 HTTP 请求 + * @param endpoint API 端点 + * @param payload 请求体 + * @returns 响应数据 + */ + private async makeRequest(endpoint: string, payload: unknown): Promise { + const url = `${this.baseUrl}${endpoint}`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + "GLM-API-Source": "Claude-Code-Hub-MCP", + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new McpRequestError( + `HTTP ${response.status}: ${response.statusText}`, + response.status, + response.headers.get("Trace-Id") ?? undefined + ); + } + + const data = (await response.json()) as T; + + // TODO: Implement GLM-specific error handling based on API docs + // For now, log response for debugging + logger.debug("[GlmMcpClient] API response", { endpoint, data }); + + return data; + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === "AbortError") { + throw new McpRequestError("Request timeout after 30 seconds"); + } + if (error instanceof TypeError) { + throw new McpRequestError( + `Network error: ${error.message}. Failed to connect to ${this.baseUrl}. Check base URL, network connectivity, and firewall settings.` + ); + } + if (error instanceof McpAuthError || error instanceof McpRequestError) { + throw error; + } + + throw new McpRequestError( + `Request failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + } +} diff --git a/src/lib/mcp/minimax-client.ts b/src/lib/mcp/minimax-client.ts new file mode 100644 index 000000000..f51581eed --- /dev/null +++ b/src/lib/mcp/minimax-client.ts @@ -0,0 +1,187 @@ +/** + * MiniMax MCP 客户端 + * 实现 Web 搜索和图片理解功能 + */ + +import { logger } from "@/lib/logger"; +import type { + McpClientConfig, + McpWebSearchRequest, + McpWebSearchResponse, + McpImageUnderstandRequest, + McpImageUnderstandResponse, +} from "./types"; +import { McpAuthError, McpRequestError } from "./types"; + +export class MinimaxMcpClient { + private baseUrl: string; + private apiKey: string; + + constructor(config: McpClientConfig) { + this.baseUrl = config.baseUrl; + this.apiKey = config.apiKey; + } + + /** + * Web 搜索 + * @param query 搜索查询 + * @returns 搜索结果 + */ + async webSearch(query: string): Promise { + if (!query) { + throw new McpRequestError("Query is required"); + } + + const payload: McpWebSearchRequest = { + q: query, + }; + + logger.info("[MinimaxMcpClient] webSearch", { query }); + + try { + const response = await this.makeRequest( + "/v1/coding_plan/search", + payload + ); + + logger.info("[MinimaxMcpClient] webSearch success", { + query, + resultsCount: response.data?.results?.length ?? 0, + }); + + return response; + } catch (error) { + logger.error("[MinimaxMcpClient] webSearch failed", { + query, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * 图片理解 + * @param imageUrl 图片 URL + * @param prompt 提示词 + * @returns 图片理解结果 + */ + async understandImage(imageUrl: string, prompt: string): Promise { + if (!imageUrl) { + throw new McpRequestError("Image URL is required"); + } + if (!prompt) { + throw new McpRequestError("Prompt is required"); + } + + const payload: McpImageUnderstandRequest = { + image_url: imageUrl, + prompt, + }; + + logger.info("[MinimaxMcpClient] understandImage", { imageUrl, prompt }); + + try { + const response = await this.makeRequest( + "/v1/coding_plan/vlm", + payload + ); + + logger.info("[MinimaxMcpClient] understandImage success", { + imageUrl, + prompt, + }); + + return response; + } catch (error) { + logger.error("[MinimaxMcpClient] understandImage failed", { + imageUrl, + prompt, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * 发起 HTTP 请求 + * @param endpoint API 端点 + * @param payload 请求体 + * @returns 响应数据 + */ + private async makeRequest(endpoint: string, payload: unknown): Promise { + const url = `${this.baseUrl}${endpoint}`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + "MM-API-Source": "Claude-Code-Hub-MCP", + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new McpRequestError( + `HTTP ${response.status}: ${response.statusText}`, + response.status, + response.headers.get("Trace-Id") ?? undefined + ); + } + + const data = (await response.json()) as T & { + base_resp?: { status_code: number; status_msg: string }; + }; + + // 检查 API 特定的错误码 + if (data.base_resp && data.base_resp.status_code !== 0) { + const { status_code, status_msg } = data.base_resp; + const traceId = response.headers.get("Trace-Id") ?? undefined; + + switch (status_code) { + case 1004: + throw new McpAuthError( + `API Error: ${status_msg}, please check your API key and API host. Trace-Id: ${traceId}`, + traceId + ); + case 2038: + throw new McpRequestError( + `API Error: ${status_msg}, should complete real-name verification on the open-platform(https://platform.minimaxi.com/user-center/basic-information). Trace-Id: ${traceId}`, + status_code, + traceId + ); + default: + throw new McpRequestError( + `API Error: ${status_code}-${status_msg} Trace-Id: ${traceId}`, + status_code, + traceId + ); + } + } + + return data; + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === "AbortError") { + throw new McpRequestError("Request timeout after 30 seconds"); + } + if (error instanceof TypeError) { + throw new McpRequestError( + `Network error: ${error.message}. Failed to connect to ${this.baseUrl}. Check base URL, network connectivity, and firewall settings.` + ); + } + if (error instanceof McpAuthError || error instanceof McpRequestError) { + throw error; + } + + throw new McpRequestError( + `Request failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + } +} diff --git a/src/lib/mcp/types.ts b/src/lib/mcp/types.ts new file mode 100644 index 000000000..f70de11d5 --- /dev/null +++ b/src/lib/mcp/types.ts @@ -0,0 +1,112 @@ +/** + * MCP (Model Context Protocol) 类型定义 + * 用于 MiniMax、GLM 等第三方 AI 服务的工具调用透传 + */ + +// MCP 客户端配置 +export interface McpClientConfig { + baseUrl: string; + apiKey: string; +} + +// ==================== MiniMax MCP 类型 ==================== + +// Web 搜索请求 +export interface McpWebSearchRequest { + q: string; // 搜索查询 +} + +// Web 搜索响应 +export interface McpWebSearchResponse { + base_resp: { + status_code: number; + status_msg: string; + }; + data?: { + results: Array<{ + title: string; + url: string; + snippet: string; + }>; + }; +} + +// 图片理解请求 +export interface McpImageUnderstandRequest { + image_url: string; // 图片 URL + prompt: string; // 提示词 +} + +// 图片理解响应 +export interface McpImageUnderstandResponse { + base_resp: { + status_code: number; + status_msg: string; + }; + data?: { + description: string; + analysis: string; + }; +} + +// ==================== GLM MCP 类型 ==================== + +// GLM 图片分析请求 +export interface McpGlmImageAnalyzeRequest { + image_source: string; // 本地文件路径或远程 URL + prompt: string; // 提示词 +} + +// GLM 图片分析响应 +export interface McpGlmImageAnalyzeResponse { + result: string; // 分析结果 + metadata?: { + image_source?: string; + prompt?: string; + model?: string; + }; +} + +// GLM 视频分析请求 +export interface McpGlmVideoAnalyzeRequest { + video_source: string; // 本地文件路径或远程 URL + prompt: string; // 提示词 +} + +// GLM 视频分析响应 +export interface McpGlmVideoAnalyzeResponse { + result: string; // 分析结果 + metadata?: { + video_source?: string; + prompt?: string; + model?: string; + }; +} + +// MCP 错误类型 +export class McpError extends Error { + constructor( + message: string, + public statusCode?: number, + public traceId?: string + ) { + super(message); + this.name = "McpError"; + } +} + +// MCP 认证错误 +export class McpAuthError extends McpError { + constructor(message: string, traceId?: string) { + super(message, 1004, traceId); + this.name = "McpAuthError"; + } +} + +// MCP 请求错误 +export class McpRequestError extends McpError { + constructor(message: string, statusCode?: number, traceId?: string) { + super(message, statusCode, traceId); + this.name = "McpRequestError"; + } +} diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index a2d9cab7e..df2d4c05a 100644 --- a/src/lib/rate-limit/service.ts +++ b/src/lib/rate-limit/service.ts @@ -77,7 +77,6 @@ import { } from "@/lib/redis/lua-scripts"; import { sumUserCostToday } from "@/repository/statistics"; import { - getTimeRangeForPeriod, getTimeRangeForPeriodWithMode, getTTLForPeriod, getTTLForPeriodWithMode, diff --git a/src/lib/utils/validation/index.ts b/src/lib/utils/validation/index.ts index d0300adf1..ed3b96a82 100644 --- a/src/lib/utils/validation/index.ts +++ b/src/lib/utils/validation/index.ts @@ -5,6 +5,7 @@ export { clampTpm, formatTpmDisplay, } from "./provider"; +import { logger } from "@/lib/logger"; /** * 验证URL格式 @@ -29,3 +30,23 @@ export function maskKey(key: string): string { const tail = 4; return `${key.slice(0, head)}••••••${key.slice(-tail)}`; } + +/** + * 从URL中提取基础域名 + * @param url - 完整的URL + * @returns 基础域名(包含协议和主机名,不含路径) + * @example + * extractBaseUrl("https://api.minimaxi.com/anthropic/v1/messages") // "https://api.minimaxi.com" + * extractBaseUrl("http://localhost:3000/api") // "http://localhost:3000" + */ +export function extractBaseUrl(url: string): string { + try { + const parsedUrl = new URL(url); + // 返回协议 + 主机名(包含端口) + return parsedUrl.origin; + } catch (error) { + logger.warn("Failed to parse URL", { url, error }); + // 如果URL解析失败,返回原始URL + return url; + } +} diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index ad9d9f8f8..82b4098eb 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -189,6 +189,38 @@ export const CreateProviderSchema = z.object({ .enum(["auto", "force_official", "keep_original"]) .optional() .default("auto"), + // MCP 透传配置 + mcp_passthrough_type: z.enum(["none", "minimax", "glm", "custom"]).optional().default("none"), + mcp_passthrough_url: z + .string() + .max(512, "MCP透传URL长度不能超过512个字符") + .url("请输入有效的URL地址") + .refine( + (url) => { + try { + const parsed = new URL(url); + const hostname = parsed.hostname; + // Block localhost + if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") + return false; + // Block private IP ranges + // 10.0.0.0/8 + if (hostname.startsWith("10.")) return false; + // 192.168.0.0/16 + if (hostname.startsWith("192.168.")) return false; + // 172.16.0.0/12 + if (hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)) return false; + // 169.254.0.0/16 (Link-local) + if (hostname.startsWith("169.254.")) return false; + return true; + } catch { + return false; + } + }, + { message: "不允许使用内部网络地址 (SSRF Protection)" } + ) + .nullable() + .optional(), // 金额限流配置 limit_5h_usd: z.coerce .number() @@ -263,7 +295,7 @@ export const CreateProviderSchema = z.object({ ) .max( PROVIDER_TIMEOUT_LIMITS.FIRST_BYTE_TIMEOUT_STREAMING_MS.MAX, - "流式首字节超时不能超过120秒" + "流式首字节超时不能超过180秒" ), ]) .optional(), @@ -273,8 +305,8 @@ export const CreateProviderSchema = z.object({ z.coerce .number() .int("流式静默期超时必须是整数") - .min(PROVIDER_TIMEOUT_LIMITS.STREAMING_IDLE_TIMEOUT_MS.MIN, "流式静默期超时不能少于1秒") - .max(PROVIDER_TIMEOUT_LIMITS.STREAMING_IDLE_TIMEOUT_MS.MAX, "流式静默期超时不能超过120秒"), + .min(PROVIDER_TIMEOUT_LIMITS.STREAMING_IDLE_TIMEOUT_MS.MIN, "流式静默期超时不能少于60秒") + .max(PROVIDER_TIMEOUT_LIMITS.STREAMING_IDLE_TIMEOUT_MS.MAX, "流式静默期超时不能超过600秒"), ]) .optional(), request_timeout_non_streaming_ms: z @@ -289,7 +321,7 @@ export const CreateProviderSchema = z.object({ ) .max( PROVIDER_TIMEOUT_LIMITS.REQUEST_TIMEOUT_NON_STREAMING_MS.MAX, - "非流式总超时不能超过1200秒" + "非流式总超时不能超过1800秒" ), ]) .optional(), @@ -339,6 +371,38 @@ export const UpdateProviderSchema = z allowed_models: z.array(z.string()).nullable().optional(), join_claude_pool: z.boolean().optional(), codex_instructions_strategy: z.enum(["auto", "force_official", "keep_original"]).optional(), + // MCP 透传配置 + mcp_passthrough_type: z.enum(["none", "minimax", "glm", "custom"]).optional(), + mcp_passthrough_url: z + .string() + .max(512, "MCP透传URL长度不能超过512个字符") + .url("请输入有效的URL地址") + .refine( + (url) => { + try { + const parsed = new URL(url); + const hostname = parsed.hostname; + // Block localhost + if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") + return false; + // Block private IP ranges + // 10.0.0.0/8 + if (hostname.startsWith("10.")) return false; + // 192.168.0.0/16 + if (hostname.startsWith("192.168.")) return false; + // 172.16.0.0/12 + if (hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)) return false; + // 169.254.0.0/16 (Link-local) + if (hostname.startsWith("169.254.")) return false; + return true; + } catch { + return false; + } + }, + { message: "不允许使用内部网络地址 (SSRF Protection)" } + ) + .nullable() + .optional(), // 金额限流配置 limit_5h_usd: z.coerce .number() @@ -411,7 +475,7 @@ export const UpdateProviderSchema = z ) .max( PROVIDER_TIMEOUT_LIMITS.FIRST_BYTE_TIMEOUT_STREAMING_MS.MAX, - "流式首字节超时不能超过120秒" + "流式首字节超时不能超过180秒" ), ]) .optional(), @@ -421,10 +485,10 @@ export const UpdateProviderSchema = z z.coerce .number() .int("流式静默期超时必须是整数") - .min(PROVIDER_TIMEOUT_LIMITS.STREAMING_IDLE_TIMEOUT_MS.MIN, "流式静默期超时不能少于1秒") + .min(PROVIDER_TIMEOUT_LIMITS.STREAMING_IDLE_TIMEOUT_MS.MIN, "流式静默期超时不能少于60秒") .max( PROVIDER_TIMEOUT_LIMITS.STREAMING_IDLE_TIMEOUT_MS.MAX, - "流式静默期超时不能超过120秒" + "流式静默期超时不能超过600秒" ), ]) .optional(), @@ -440,7 +504,7 @@ export const UpdateProviderSchema = z ) .max( PROVIDER_TIMEOUT_LIMITS.REQUEST_TIMEOUT_NON_STREAMING_MS.MAX, - "非流式总超时不能超过1200秒" + "非流式总超时不能超过1800秒" ), ]) .optional(), diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index fd2a2c6f8..f3d9a2cdd 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -49,6 +49,8 @@ export function toProvider(dbProvider: any): Provider { providerType: dbProvider?.providerType ?? "claude", modelRedirects: dbProvider?.modelRedirects ?? null, codexInstructionsStrategy: dbProvider?.codexInstructionsStrategy ?? "auto", + mcpPassthroughType: dbProvider?.mcpPassthroughType ?? "none", + mcpPassthroughUrl: dbProvider?.mcpPassthroughUrl ?? null, limit5hUsd: dbProvider?.limit5hUsd ? parseFloat(dbProvider.limit5hUsd) : null, limitDailyUsd: dbProvider?.limitDailyUsd ? parseFloat(dbProvider.limitDailyUsd) : null, dailyResetTime: dbProvider?.dailyResetTime ?? "00:00", diff --git a/src/repository/error-rules.ts b/src/repository/error-rules.ts index 41635b5fb..3241ce694 100644 --- a/src/repository/error-rules.ts +++ b/src/repository/error-rules.ts @@ -41,6 +41,32 @@ export async function getActiveErrorRules(): Promise { })); } +/** + * 根据 ID 获取单个错误规则 + */ +export async function getErrorRuleById(id: number): Promise { + const result = await db.query.errorRules.findFirst({ + where: eq(errorRules.id, id), + }); + + if (!result) { + return null; + } + + return { + id: result.id, + pattern: result.pattern, + matchType: result.matchType as "regex" | "contains" | "exact", + category: result.category, + description: result.description, + isEnabled: result.isEnabled, + isDefault: result.isDefault, + priority: result.priority, + createdAt: result.createdAt ?? new Date(), + updatedAt: result.updatedAt ?? new Date(), + }; +} + /** * 获取所有错误规则(包括禁用的) */ @@ -148,82 +174,129 @@ export async function deleteErrorRule(id: number): Promise { return result.length > 0; } +/** + * 默认错误规则定义 + */ +const DEFAULT_ERROR_RULES = [ + { + pattern: "prompt is too long.*maximum.*tokens", + category: "prompt_limit", + description: "Prompt token limit exceeded", + matchType: "regex" as const, + isDefault: true, + isEnabled: true, + priority: 100, + }, + { + pattern: "blocked by.*content filter", + category: "content_filter", + description: "Content blocked by safety filters", + matchType: "regex" as const, + isDefault: true, + isEnabled: true, + priority: 90, + }, + { + pattern: "PDF has too many pages|maximum of.*PDF pages", + category: "pdf_limit", + description: "PDF page limit exceeded", + matchType: "regex" as const, + isDefault: true, + isEnabled: true, + priority: 80, + }, + { + pattern: "thinking.*format.*invalid|Expected.*thinking.*but found", + category: "thinking_error", + description: "Invalid thinking block format", + matchType: "regex" as const, + isDefault: true, + isEnabled: true, + priority: 70, + }, + { + pattern: "Missing required parameter|Extra inputs.*not permitted", + category: "parameter_error", + description: "Request parameter validation failed", + matchType: "regex" as const, + isDefault: true, + isEnabled: true, + priority: 60, + }, + { + pattern: "非法请求|illegal request|invalid request", + category: "invalid_request", + description: "Invalid request format", + matchType: "regex" as const, + isDefault: true, + isEnabled: true, + priority: 50, + }, + { + pattern: "cache_control.*limit.*blocks", + category: "cache_limit", + description: "Cache control limit exceeded", + matchType: "regex" as const, + isDefault: true, + isEnabled: true, + priority: 40, + }, + { + pattern: "image exceeds.*maximum.*bytes", + category: "invalid_request", + description: "Image size exceeds maximum limit", + matchType: "regex" as const, + isDefault: true, + isEnabled: true, + priority: 35, + }, +]; + +/** + * 同步默认错误规则(推荐使用) + * + * 将代码中的默认规则同步到数据库: + * - 删除所有已有的默认规则(isDefault=true) + * - 重新插入最新的 DEFAULT_ERROR_RULES + * - 用户自定义规则(isDefault=false)保持不变 + * + * 使用场景: + * 1. 系统启动时自动同步(instrumentation.ts) + * 2. 用户点击"刷新缓存"按钮时手动同步 + * + * @returns 同步的规则数量 + */ +export async function syncDefaultErrorRules(): Promise { + await db.transaction(async (tx) => { + // 1. 删除所有默认规则 + await tx.delete(errorRules).where(eq(errorRules.isDefault, true)); + + // 2. 重新插入最新的默认规则 + for (const rule of DEFAULT_ERROR_RULES) { + await tx.insert(errorRules).values(rule); + } + }); + + // 通知 ErrorRuleDetector 重新加载缓存 + eventEmitter.emit("errorRulesUpdated"); + + return DEFAULT_ERROR_RULES.length; +} + /** * 初始化默认错误规则 * - * 使用 ON CONFLICT DO NOTHING 确保幂等性,避免重复插入 - * 从 src/app/v1/_lib/proxy/errors.ts 中提取的 7 条默认规则 + * @deprecated 请使用 syncDefaultErrorRules() 替代 + * + * 此函数使用 ON CONFLICT DO NOTHING,只能插入新规则,无法更新已存在的规则。 + * 当 DEFAULT_ERROR_RULES 更新时,数据库中的旧规则不会被同步。 + * + * syncDefaultErrorRules() 会删除所有预置规则并重新插入,确保每次都同步最新版本。 */ export async function initializeDefaultErrorRules(): Promise { - const defaultRules = [ - { - pattern: "prompt is too long.*maximum.*tokens", - category: "prompt_limit", - description: "Prompt token limit exceeded", - matchType: "regex" as const, - isDefault: true, - isEnabled: true, - priority: 100, - }, - { - pattern: "blocked by.*content filter", - category: "content_filter", - description: "Content blocked by safety filters", - matchType: "regex" as const, - isDefault: true, - isEnabled: true, - priority: 90, - }, - { - pattern: "PDF has too many pages.*maximum.*pages", - category: "pdf_limit", - description: "PDF page limit exceeded", - matchType: "regex" as const, - isDefault: true, - isEnabled: true, - priority: 80, - }, - { - pattern: "thinking.*format.*invalid|Expected.*thinking.*but found", - category: "thinking_error", - description: "Invalid thinking block format", - matchType: "regex" as const, - isDefault: true, - isEnabled: true, - priority: 70, - }, - { - pattern: "Missing required parameter|Extra inputs.*not permitted", - category: "parameter_error", - description: "Request parameter validation failed", - matchType: "regex" as const, - isDefault: true, - isEnabled: true, - priority: 60, - }, - { - pattern: "非法请求|illegal request|invalid request", - category: "invalid_request", - description: "Invalid request format", - matchType: "regex" as const, - isDefault: true, - isEnabled: true, - priority: 50, - }, - { - pattern: "cache_control.*limit.*blocks", - category: "cache_limit", - description: "Cache control limit exceeded", - matchType: "regex" as const, - isDefault: true, - isEnabled: true, - priority: 40, - }, - ]; - // 使用事务批量插入,ON CONFLICT DO NOTHING 保证幂等性 await db.transaction(async (tx) => { - for (const rule of defaultRules) { + for (const rule of DEFAULT_ERROR_RULES) { await tx.insert(errorRules).values(rule).onConflictDoNothing({ target: errorRules.pattern }); } }); diff --git a/src/repository/message.ts b/src/repository/message.ts index 709df3527..765c73698 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -19,6 +19,7 @@ export async function createMessageRequest( userId: data.user_id, key: data.key, model: data.model, + originalModel: data.original_model, // 原始模型(重定向前,用于计费和前端显示) durationMs: data.duration_ms, costUsd: formattedCost ?? undefined, costMultiplier: data.cost_multiplier?.toString() ?? undefined, // 供应商倍率(转为字符串) @@ -34,6 +35,7 @@ export async function createMessageRequest( userId: messageRequest.userId, key: messageRequest.key, model: messageRequest.model, + originalModel: messageRequest.originalModel, // 原始模型(重定向前) durationMs: messageRequest.durationMs, costUsd: messageRequest.costUsd, costMultiplier: messageRequest.costMultiplier, // 新增 @@ -96,6 +98,7 @@ export async function updateMessageRequestDetails( cacheReadInputTokens?: number; providerChain?: CreateMessageRequestData["provider_chain"]; errorMessage?: string; + model?: string; // ⭐ 新增:支持更新重定向后的模型名称 } ): Promise { const updateData: Record = { @@ -123,6 +126,9 @@ export async function updateMessageRequestDetails( if (details.errorMessage !== undefined) { updateData.errorMessage = details.errorMessage; } + if (details.model !== undefined) { + updateData.model = details.model; + } await db.update(messageRequest).set(updateData).where(eq(messageRequest.id, id)); } diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 5a5ed275c..c0e6d8762 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -24,6 +24,8 @@ export async function createProvider(providerData: CreateProviderData): Promise< allowedModels: providerData.allowed_models, joinClaudePool: providerData.join_claude_pool ?? false, codexInstructionsStrategy: providerData.codex_instructions_strategy ?? "auto", + mcpPassthroughType: providerData.mcp_passthrough_type ?? "none", + mcpPassthroughUrl: providerData.mcp_passthrough_url ?? null, limit5hUsd: providerData.limit_5h_usd != null ? providerData.limit_5h_usd.toString() : null, limitDailyUsd: providerData.limit_daily_usd != null ? providerData.limit_daily_usd.toString() : null, @@ -66,6 +68,8 @@ export async function createProvider(providerData: CreateProviderData): Promise< allowedModels: providers.allowedModels, joinClaudePool: providers.joinClaudePool, codexInstructionsStrategy: providers.codexInstructionsStrategy, + mcpPassthroughType: providers.mcpPassthroughType, + mcpPassthroughUrl: providers.mcpPassthroughUrl, limit5hUsd: providers.limit5hUsd, limitDailyUsd: providers.limitDailyUsd, dailyResetMode: providers.dailyResetMode, @@ -115,6 +119,8 @@ export async function findProviderList( allowedModels: providers.allowedModels, joinClaudePool: providers.joinClaudePool, codexInstructionsStrategy: providers.codexInstructionsStrategy, + mcpPassthroughType: providers.mcpPassthroughType, + mcpPassthroughUrl: providers.mcpPassthroughUrl, limit5hUsd: providers.limit5hUsd, limitDailyUsd: providers.limitDailyUsd, dailyResetMode: providers.dailyResetMode, @@ -171,6 +177,8 @@ export async function findProviderById(id: number): Promise { allowedModels: providers.allowedModels, joinClaudePool: providers.joinClaudePool, codexInstructionsStrategy: providers.codexInstructionsStrategy, + mcpPassthroughType: providers.mcpPassthroughType, + mcpPassthroughUrl: providers.mcpPassthroughUrl, limit5hUsd: providers.limit5hUsd, limitDailyUsd: providers.limitDailyUsd, dailyResetMode: providers.dailyResetMode, @@ -233,6 +241,10 @@ export async function updateProvider( dbData.joinClaudePool = providerData.join_claude_pool; if (providerData.codex_instructions_strategy !== undefined) dbData.codexInstructionsStrategy = providerData.codex_instructions_strategy; + if (providerData.mcp_passthrough_type !== undefined) + dbData.mcpPassthroughType = providerData.mcp_passthrough_type; + if (providerData.mcp_passthrough_url !== undefined) + dbData.mcpPassthroughUrl = providerData.mcp_passthrough_url; if (providerData.limit_5h_usd !== undefined) dbData.limit5hUsd = providerData.limit_5h_usd != null ? providerData.limit_5h_usd.toString() : null; @@ -293,6 +305,8 @@ export async function updateProvider( allowedModels: providers.allowedModels, joinClaudePool: providers.joinClaudePool, codexInstructionsStrategy: providers.codexInstructionsStrategy, + mcpPassthroughType: providers.mcpPassthroughType, + mcpPassthroughUrl: providers.mcpPassthroughUrl, limit5hUsd: providers.limit5hUsd, limitDailyUsd: providers.limitDailyUsd, dailyResetMode: providers.dailyResetMode, diff --git a/src/types/provider.ts b/src/types/provider.ts index 532419699..0730303ad 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -10,6 +10,9 @@ export type ProviderType = // Codex Instructions 策略枚举 export type CodexInstructionsStrategy = "auto" | "force_official" | "keep_original"; +// MCP 透传类型枚举 +export type McpPassthroughType = "none" | "minimax" | "glm" | "custom"; + export interface Provider { id: number; name: string; @@ -42,6 +45,18 @@ export interface Provider { // 仅对 providerType = 'codex' 的供应商有效 codexInstructionsStrategy: CodexInstructionsStrategy; + // MCP 透传类型:控制是否启用 MCP 透传功能 + // 'none': 不启用(默认) + // 'minimax': 透传到 minimax MCP 服务(图片识别、联网搜索) + // 'glm': 透传到智谱 MCP 服务(图片分析、视频分析) + // 'custom': 自定义 MCP 服务(预留) + mcpPassthroughType: McpPassthroughType; + + // MCP 透传 URL:MCP 服务的基础 URL + // 如果未配置,则自动从 provider.url 提取基础域名 + // 例如:https://api.minimaxi.com/anthropic -> https://api.minimaxi.com + mcpPassthroughUrl: string | null; + // 金额限流配置 limit5hUsd: number | null; limitDailyUsd: number | null; @@ -105,6 +120,10 @@ export interface ProviderDisplay { joinClaudePool: boolean; // Codex Instructions 策略 codexInstructionsStrategy: CodexInstructionsStrategy; + // MCP 透传类型 + mcpPassthroughType: McpPassthroughType; + // MCP 透传 URL + mcpPassthroughUrl: string | null; // 金额限流配置 limit5hUsd: number | null; limitDailyUsd: number | null; @@ -161,6 +180,8 @@ export interface CreateProviderData { allowed_models?: string[] | null; join_claude_pool?: boolean; codex_instructions_strategy?: CodexInstructionsStrategy; + mcp_passthrough_type?: McpPassthroughType; + mcp_passthrough_url?: string | null; // 金额限流配置 limit_5h_usd?: number | null; @@ -220,6 +241,8 @@ export interface UpdateProviderData { allowed_models?: string[] | null; join_claude_pool?: boolean; codex_instructions_strategy?: CodexInstructionsStrategy; + mcp_passthrough_type?: McpPassthroughType; + mcp_passthrough_url?: string | null; // 金额限流配置 limit_5h_usd?: number | null;