Replace bytes.Replace with gjson.GetBytes+sjson.SetBytes in passthrough mode to correctly handle JSON whitespace variations and avoid silently sending empty body on read errors. Also add log search count limit to prevent slow COUNT queries on large log tables.master
| @@ -0,0 +1,65 @@ | |||||
| --- | |||||
| allowed-tools: mcp__plugin_chrome-devtools-mcp_chrome-devtools__* | |||||
| description: 'Command: add-channel (imported from Claude Code)' | |||||
| name: cmd-add-channel | |||||
| --- | |||||
| ## 添加渠道 | |||||
| 通过 Chrome DevTools MCP 在指定环境中操作浏览器完成渠道添加。 | |||||
| ### 参数 | |||||
| 用户在 `$ARGUMENTS` 中传入 JSON 格式的渠道信息(字段均可选,缺失时会询问): | |||||
| ``` | |||||
| { | |||||
| "base_url": "https://api.example.com", | |||||
| "api_key": "sk-xxx", | |||||
| "name": "渠道名称", | |||||
| "type": "OpenAI", | |||||
| "target": "http://localhost:23000", | |||||
| "select_all_models": true | |||||
| } | |||||
| ``` | |||||
| | 字段 | 说明 | 默认值 | | |||||
| |------|------|--------| | |||||
| | `target` | 目标环境地址 | 询问用户 | | |||||
| | `base_url` | 渠道 API 地址 | 询问用户 | | |||||
| | `api_key` | 渠道密钥 | 询问用户 | | |||||
| | `name` | 渠道名称 | 自动从 base_url 提取域名 | | |||||
| | `type` | 渠道类型 | `OpenAI` | | |||||
| | `select_all_models` | 是否全选模型 | `true` | | |||||
| 如果 `$ARGUMENTS` 不是 JSON,则按顺序解析为 `base_url` `api_key`,其余使用默认值。 | |||||
| ### 操作流程 | |||||
| 1. **导航到目标页面** — `navigate_page` 到 `{target}/console/channel` | |||||
| 2. **检查登录状态** — `take_snapshot`,如果没有登录(看不到侧边栏菜单),提示用户先登录 | |||||
| 3. **点击添加渠道** — 找到并点击"添加渠道"按钮 | |||||
| 4. **填写表单** — 按 snap 快照中的元素 uid,依次 `fill`: | |||||
| - 名称(名称输入框) | |||||
| - 密钥(密钥输入框) | |||||
| - API地址(API地址输入框) | |||||
| 5. **获取并选择模型**: | |||||
| - 点击"填入所有模型"按钮 | |||||
| - 等待模型选择对话框出现 | |||||
| - 如果 `select_all_models` 为 true,点击全选 checkbox,然后点击 confirm | |||||
| 6. **提交** — 点击"提交"按钮 | |||||
| 7. **验证** — `take_snapshot` 确认渠道列表中出现了新建的渠道 | |||||
| ### 测试环境地址 | |||||
| | 环境 | 地址 | 说明 | | |||||
| |------|------|------| | |||||
| | 国内测试 | http://localhost:23000 | WSL 本地 | | |||||
| | 国外测试 | http://localhost:13000 | WSL 本地 | | |||||
| | 生产环境 | https://api.example.com | 按实际填写 | | |||||
| ### 要求 | |||||
| - 每步操作后用 `take_snapshot` 验证页面状态 | |||||
| - 如果某步操作失败(如获取模型列表超时),报告错误并询问用户是否继续 | |||||
| - 完成后向用户总结创建结果(渠道名称、类型、模型数量、状态) | |||||
| @@ -0,0 +1,142 @@ | |||||
| --- | |||||
| allowed-tools: Bash(wsl -- *) | |||||
| description: 'Command: claude-stats (imported from Claude Code)' | |||||
| name: cmd-claude-stats | |||||
| --- | |||||
| ## Claude 模型统计 | |||||
| 查询 Claude 模型的**错误率**和**缓存率**,自动排除 `relay-pulse` 检测令牌。 | |||||
| ### 参数 `$ARGUMENTS` | |||||
| 根据 `$ARGUMENTS` 确定日期范围: | |||||
| | 传入格式 | 含义 | | |||||
| |---------|------| | |||||
| | (空) | 今天 | | |||||
| | `2026-05-09` | 指定某一天 | | |||||
| | `2026-05-01 2026-05-09` | 指定日期范围(含首尾两天) | | |||||
| | `7d` | 最近 7 天 | | |||||
| ### 执行步骤 | |||||
| **第一步:根据参数计算日期过滤条件** | |||||
| - `created_at` 字段是秒级 Unix 时间戳(bigint),用 `UNIX_TIMESTAMP('YYYY-MM-DD')` 转换 | |||||
| - 今天:`created_at >= UNIX_TIMESTAMP(CURDATE())` | |||||
| - 某一天 `D`:`created_at >= UNIX_TIMESTAMP('D') AND created_at < UNIX_TIMESTAMP('D') + 86400` | |||||
| - 范围 `D1` 到 `D2`:`created_at >= UNIX_TIMESTAMP('D1') AND created_at < UNIX_TIMESTAMP('D2') + 86400` | |||||
| - 最近 N 天:`created_at >= UNIX_TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL N-1 DAY))` | |||||
| 将计算好的条件替换到下面 SQL 中的 `{DATE_FILTER}` 占位符。 | |||||
| **第二步:上传 SQL 文件到服务器** | |||||
| ```bash | |||||
| wsl -- bash -c " | |||||
| cat > /tmp/claude_stats_error.sql << 'ENDSQL' | |||||
| SELECT | |||||
| model_name, | |||||
| SUM(CASE WHEN type = 2 THEN 1 ELSE 0 END) as success, | |||||
| SUM(CASE WHEN type = 5 THEN 1 ELSE 0 END) as errors, | |||||
| COUNT(*) as total, | |||||
| ROUND(SUM(CASE WHEN type = 5 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) as error_rate_pct | |||||
| FROM logs | |||||
| WHERE {DATE_FILTER} | |||||
| AND model_name LIKE '%claude%' | |||||
| AND type IN (2, 5) | |||||
| AND token_name != 'relay-pulse' | |||||
| GROUP BY model_name | |||||
| ORDER BY total DESC; | |||||
| ENDSQL | |||||
| cat > /tmp/claude_stats_cache.sql << 'ENDSQL' | |||||
| SELECT | |||||
| model_name, | |||||
| COUNT(*) as total_requests, | |||||
| SUM(prompt_tokens) as prompt_tokens, | |||||
| SUM(completion_tokens) as completion_tokens, | |||||
| SUM(CAST(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(other, '$.cache_tokens')), 0) AS UNSIGNED)) as cache_read_tokens, | |||||
| SUM(CAST(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(other, '$.cache_creation_tokens')), 0) AS UNSIGNED)) as cache_creation_tokens, | |||||
| SUM(CASE WHEN CAST(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(other, '$.cache_tokens')), 0) AS UNSIGNED) > 0 THEN 1 ELSE 0 END) as cache_hit_requests, | |||||
| ROUND( | |||||
| SUM(CASE WHEN CAST(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(other, '$.cache_tokens')), 0) AS UNSIGNED) > 0 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), | |||||
| 2 | |||||
| ) as cache_hit_rate_pct, | |||||
| ROUND( | |||||
| (SUM(prompt_tokens) * 1.0 | |||||
| + SUM(CAST(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(other, '$.cache_tokens')), 0) AS UNSIGNED)) * 0.1 | |||||
| + SUM(CAST(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(other, '$.cache_creation_tokens')), 0) AS UNSIGNED)) * 1.25) | |||||
| * 100.0 | |||||
| / NULLIF( | |||||
| SUM(prompt_tokens) | |||||
| + SUM(CAST(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(other, '$.cache_tokens')), 0) AS UNSIGNED)) | |||||
| + SUM(CAST(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(other, '$.cache_creation_tokens')), 0) AS UNSIGNED)), | |||||
| 0), | |||||
| 2 | |||||
| ) as cost_pct_of_full, | |||||
| ROUND( | |||||
| SUM(CAST(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(other, '$.cache_creation_tokens')), 0) AS UNSIGNED)) * 1.0 | |||||
| / NULLIF(SUM(CAST(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(other, '$.cache_tokens')), 0) AS UNSIGNED)), 0), | |||||
| 3 | |||||
| ) as creation_to_read_ratio | |||||
| FROM logs | |||||
| WHERE {DATE_FILTER} | |||||
| AND type = 2 | |||||
| AND model_name LIKE '%claude%' | |||||
| AND other IS NOT NULL | |||||
| AND token_name != 'relay-pulse' | |||||
| GROUP BY model_name | |||||
| ORDER BY total_requests DESC; | |||||
| ENDSQL | |||||
| cat > /tmp/claude_stats_errcode.sql << 'ENDSQL' | |||||
| SELECT | |||||
| model_name, | |||||
| CAST(JSON_UNQUOTE(JSON_EXTRACT(other, '$.status_code')) AS UNSIGNED) as status_code, | |||||
| COUNT(*) as cnt, | |||||
| ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (PARTITION BY model_name), 1) as pct | |||||
| FROM logs | |||||
| WHERE {DATE_FILTER} | |||||
| AND type = 5 | |||||
| AND model_name LIKE '%claude%' | |||||
| AND token_name != 'relay-pulse' | |||||
| AND other IS NOT NULL | |||||
| GROUP BY model_name, status_code | |||||
| ORDER BY model_name, cnt DESC; | |||||
| ENDSQL | |||||
| scp /tmp/claude_stats_error.sql root@139.180.189.205:/tmp/claude_stats_error.sql | |||||
| scp /tmp/claude_stats_cache.sql root@139.180.189.205:/tmp/claude_stats_cache.sql | |||||
| scp /tmp/claude_stats_errcode.sql root@139.180.189.205:/tmp/claude_stats_errcode.sql | |||||
| " | |||||
| ``` | |||||
| **第三步:执行查询并展示结果** | |||||
| ```bash | |||||
| wsl -- bash -c "ssh -o ConnectTimeout=20 -o ServerAliveInterval=10 root@139.180.189.205 'echo \"=== 错误率 ===\"; docker exec -i mysql mysql -uroot -pLanqi123456 new-api < /tmp/claude_stats_error.sql; echo; echo \"=== 缓存 ===\"; docker exec -i mysql mysql -uroot -pLanqi123456 new-api < /tmp/claude_stats_cache.sql; echo; echo \"=== 错误状态码分布 ===\"; docker exec -i mysql mysql -uroot -pLanqi123456 new-api < /tmp/claude_stats_errcode.sql' 2>&1 | grep -v 'Warning'" | |||||
| ``` | |||||
| ### 字段说明 | |||||
| **错误率表:** | |||||
| - `success` — 正常请求数(type=2) | |||||
| - `errors` — 错误请求数(type=5) | |||||
| - `error_rate_pct` — 错误率 % | |||||
| **缓存率表:** | |||||
| - `cache_hit_rate_pct` — **次数缓存率** = 有缓存命中的请求数 / 总请求数(最能反映缓存策略是否生效) | |||||
| - `cost_pct_of_full` — **实际成本占比** = `(prompt × 1 + cache_read × 0.1 + cache_creation × 1.25) / (全量按原价计费)`,越低越省钱(Anthropic 标准定价:cache_read 是原价 10%,cache_creation 是原价 125%) | |||||
| - `creation_to_read_ratio` — **缓存创建/读取比** = `cache_creation_tokens / cache_read_tokens`,比值高说明缓存频繁失效再重建,命中复用差 | |||||
| **错误状态码分布表:** | |||||
| - `status_code` — HTTP 状态码(504=上游超时,429=限流,400=请求参数错误) | |||||
| - `cnt` — 该状态码的错误次数 | |||||
| - `pct` — 占该模型全部错误的百分比 | |||||
| ### 注意事项 | |||||
| - 日期以 OV 服务器(UTC+0)的时区为准,北京时间当天 08:00 后才对应同一自然日 | |||||
| - `relay-pulse` 是自动探活令牌,已从统计中排除 | |||||
| @@ -0,0 +1,209 @@ | |||||
| --- | |||||
| allowed-tools: Bash(wsl -- *) | |||||
| description: 'Command: debug-prod (imported from Claude Code)' | |||||
| name: cmd-debug-prod | |||||
| --- | |||||
| ## 线上排查命令 | |||||
| 用户要求排查线上问题。根据 `$ARGUMENTS` 确定目标服务器,然后根据问题类型选择合适的操作。 | |||||
| ### 参数 | |||||
| `$ARGUMENTS` 中可能传入: | |||||
| - (空) — 默认排查国外服务器(OV,主节点) | |||||
| - `cn` — 只排查国内服务器 | |||||
| - `ov` — 只排查国外服务器 | |||||
| - `all` — 排查两台服务器 | |||||
| 如果用户没有明确指定,**默认排查 OV 服务器**(因为 MySQL 主库在 OV)。 | |||||
| --- | |||||
| ### 服务器配置 | |||||
| | 节点 | SSH | docker-compose 目录 | new-api 容器名 | MySQL 容器名 | | |||||
| |------|-----|---------------------|---------------|-------------| | |||||
| | 🇨🇳 国内 CN | `root@123.57.74.135` | `/root/new-api/` | `cn-new-api` | `ov-mysql` | | |||||
| | 🌏 国外 OV | `root@139.180.189.205` | `/root/new-api/new-api/` | `ov-new-api` | `mysql` | | |||||
| **重要**:两台服务器的 MySQL 实际都在 OV(国外节点)。CN 的 SQL_DSN 指向 `ov-mysql:3306`。所以查数据库统一用 OV 的 MySQL 容器 `mysql`。 | |||||
| MySQL 连接:`docker exec mysql mysql -uroot -pLanqi123456 new-api -e "SQL语句"` | |||||
| --- | |||||
| ### 所有命令执行方式 | |||||
| **必须通过 WSL 执行**(SSH 密钥在 WSL 中): | |||||
| ```bash | |||||
| # 单条命令 | |||||
| wsl -- bash -c "ssh root@HOST '命令'" | |||||
| # 多条命令(引号嵌套复杂时用 heredoc) | |||||
| wsl -- bash -c "ssh root@HOST 'cmd1 && cmd2 && cmd3'" | |||||
| ``` | |||||
| --- | |||||
| ### 操作指南 | |||||
| 根据用户描述的问题,选择合适的排查步骤。**不要一次性执行所有操作**,应该根据问题逐步排查。 | |||||
| #### 1. 服务状态检查 | |||||
| ```bash | |||||
| # 查看所有容器状态 | |||||
| wsl -- bash -c "ssh root@HOST 'cd COMPOSE_DIR && docker compose ps'" | |||||
| # 健康检查 | |||||
| wsl -- bash -c "ssh root@HOST 'curl -s http://localhost:3000/api/status'" | |||||
| # 系统资源(CPU/内存/磁盘) | |||||
| wsl -- bash -c "ssh root@HOST 'free -h && df -h / && docker stats --no-stream'" | |||||
| ``` | |||||
| #### 2. 应用日志 | |||||
| ```bash | |||||
| # 最近 N 行容器日志 | |||||
| wsl -- bash -c "ssh root@HOST 'docker logs --tail 100 CONTAINER_NAME'" | |||||
| # 实时跟踪日志(带超时,避免阻塞) | |||||
| wsl -- bash -c "ssh root@HOST 'timeout 5 docker logs --tail 50 --since 5m CONTAINER_NAME'" | |||||
| # 搜索特定关键词(如错误、某用户、某模型) | |||||
| wsl -- bash -c "ssh root@HOST 'docker logs --tail 500 CONTAINER_NAME 2>&1 | grep -i KEYWORD'" | |||||
| # 应用日志文件(oneapi-*.log) | |||||
| wsl -- bash -c "ssh root@HOST 'ls -lt COMPOSE_DIR/logs/ | head -5'" | |||||
| wsl -- bash -c "ssh root@HOST 'tail -100 COMPOSE_DIR/logs/oneapi-XXXX.log'" | |||||
| ``` | |||||
| #### 3. MySQL 查询 | |||||
| **连接方式(在 OV 服务器上执行)**: | |||||
| ```bash | |||||
| wsl -- bash -c "ssh root@139.180.189.205 'docker exec mysql mysql -uroot -pLanqi123456 new-api -e \"SQL语句\"'" | |||||
| ``` | |||||
| 注意:SQL 语句中的双引号需要用 `\"` 转义。 | |||||
| **常用查询模板**: | |||||
| ```sql | |||||
| -- 查用户信息 | |||||
| SELECT id, username, email, role, status, quota, used_quota, \`group\`, aff_code FROM users WHERE username = 'XXX'; | |||||
| -- 查用户最近的请求日志 | |||||
| SELECT id, created_at, type, model_name, quota, channel_id, content FROM logs WHERE user_id = XXX ORDER BY created_at DESC LIMIT 20; | |||||
| -- 查某时间段内的错误日志 | |||||
| SELECT id, created_at, type, content, model_name FROM logs WHERE created_at >= '2026-04-16 00:00:00' AND type = 2 ORDER BY created_at DESC LIMIT 50; | |||||
| -- 查渠道状态和余额 | |||||
| SELECT id, name, type, status, balance, used_quota, models, \`group\`, priority FROM channels WHERE status != 3; | |||||
| -- 查某渠道的最近请求 | |||||
| SELECT id, created_at, model_name, quota, content FROM logs WHERE channel_id = XXX ORDER BY created_at DESC LIMIT 20; | |||||
| -- 查 Token 信息 | |||||
| SELECT id, name, \`key\`, status, remain_quota, used_quota, expired_time FROM tokens WHERE user_id = XXX; | |||||
| -- 查最近的充值/支付记录 | |||||
| SELECT id, user_id, amount, money, trade_no, status, created_at FROM topups ORDER BY created_at DESC LIMIT 20; | |||||
| -- 查订阅订单 | |||||
| SELECT id, user_id, plan_id, status, amount, created_at FROM subscription_orders ORDER BY created_at DESC LIMIT 20; | |||||
| -- 查同步记录(主从同步问题) | |||||
| SELECT * FROM pending_sync_records ORDER BY id DESC LIMIT 20; | |||||
| SELECT * FROM quota_sync_logs ORDER BY id DESC LIMIT 20; | |||||
| -- 查 Midjourney 任务状态 | |||||
| SELECT id, user_id, action, status, progress, fail_reason, created_at FROM midjourneys ORDER BY created_at DESC LIMIT 20; | |||||
| -- 查系统配置 | |||||
| SELECT \`key\`, value FROM options WHERE \`key\` IN ('ServerAddress', 'TopUpLink', 'QuotaForNewUser', 'QuotaForInviter', 'QuotaForInvitee'); | |||||
| ``` | |||||
| **日志类型 (type 字段)**: | |||||
| - `1` — 消耗 (consume) | |||||
| - `2` — 充值 (recharge/topup) | |||||
| - `3` — 管理员操作 | |||||
| - `4` — 系统 | |||||
| #### 4. Relay 抓包日志(请求体/响应体完整记录) | |||||
| 对开启了 `capture_relay` 的用户,系统会将完整的请求体和响应体写入容器内文件。适用于需要查看客户端实际发送的请求体内容的场景(如验证字段是否正确、排查请求格式问题)。 | |||||
| **文件路径**(容器内):`/data/data/relay-capture/{YYYY-MM-DD}/{requestID}.log` | |||||
| ```bash | |||||
| # 列出某天的抓包文件(最新 20 个) | |||||
| wsl -- bash -c 'ssh root@HOST "docker exec CONTAINER_NAME ls -lt /data/data/relay-capture/2026-05-14/ | head -20"' | |||||
| # 查看特定请求的抓包内容(requestID 从日志错误行中获取) | |||||
| wsl -- bash -c 'ssh root@HOST "docker exec CONTAINER_NAME cat /data/data/relay-capture/2026-05-14/REQUEST_ID.log"' | |||||
| # 文件很大时,只看请求头 + 请求体前 2000 字节 | |||||
| wsl -- bash -c 'ssh root@HOST "docker exec CONTAINER_NAME head -c 2000 /data/data/relay-capture/2026-05-14/REQUEST_ID.log"' | |||||
| ``` | |||||
| **抓包文件格式**: | |||||
| ``` | |||||
| === REQUEST 2026-05-14T09:09:51.80665026Z === | |||||
| POST /v1/chat/completions | |||||
| user_id: 44 | |||||
| X-Forwarded-For: 184.73.225.134 | |||||
| User-Agent: Cursor/1.0 | |||||
| Content-Type: application/json | |||||
| ... | |||||
| {"stream":true,"model":"openai/gpt-5.4","messages":[...]} | |||||
| === RESPONSE === | |||||
| {"error":{"message":"field messages is required",...}} | |||||
| === END duration_ms=6 === | |||||
| ``` | |||||
| **使用场景**: | |||||
| - 客户端发送了错误格式的请求体(如用 `input` 代替 `messages`) | |||||
| - 需要确认请求头、User-Agent、请求体字段 | |||||
| - 排查特定 request ID 的完整请求/响应链路 | |||||
| **注意**: | |||||
| - 只有开启了 `capture_relay` 的用户才有抓包文件,未开启的用户查不到 | |||||
| - 请求体可能很大(100KB+),建议先用 `head -c` 限制输出大小 | |||||
| - 抓包文件名即 request ID,可从容器的 `[ERR]` 日志行中提取 | |||||
| #### 5. 容器内操作 | |||||
| ```bash | |||||
| # 进入容器执行命令(如检查网络连通性) | |||||
| wsl -- bash -c "ssh root@HOST 'docker exec CONTAINER_NAME wget -qO- http://localhost:3000/api/status'" | |||||
| # 重启某个服务(谨慎操作,先确认用户意图) | |||||
| wsl -- bash -c "ssh root@HOST 'cd COMPOSE_DIR && docker compose restart new-api'" | |||||
| ``` | |||||
| --- | |||||
| ### 排查流程建议 | |||||
| 1. **先问清楚问题**:什么现象?什么时候开始的?影响哪些用户? | |||||
| 2. **服务状态** → 检查容器是否正常运行 | |||||
| 3. **应用日志** → 搜索错误关键词、时间范围 | |||||
| 4. **数据库查询** → 根据日志中的线索查相关数据 | |||||
| 5. **Relay 抓包** → 如果日志中只有错误摘要但看不清请求体细节,检查用户是否开启了 `capture_relay`,若有则读取抓包文件查看完整请求/响应 | |||||
| 6. **综合分析** → 给出问题原因和修复建议 | |||||
| ### 注意事项 | |||||
| - **不要执行任何写入操作**(INSERT/UPDATE/DELETE),除非用户明确要求 | |||||
| - MySQL 查询要加 `LIMIT`,避免返回过多数据 | |||||
| - 查询时间范围要合理,不要全表扫描 | |||||
| - 如果发现需要重启服务,先告知用户再操作 | |||||
| - 所有输出向用户展示时,用中文解释含义 | |||||
| @@ -0,0 +1,98 @@ | |||||
| --- | |||||
| allowed-tools: Bash | |||||
| description: 'Command: deploy-local (imported from Claude Code)' | |||||
| name: cmd-deploy-local | |||||
| --- | |||||
| ## 启动本机开发环境 | |||||
| 在 Windows 上直接运行 `go run main.go`,数据库使用 WSL 中的 MySQL 和 Redis。 | |||||
| ### 环境信息 | |||||
| | 项目 | 连接信息 | | |||||
| |------|---------| | |||||
| | MySQL | `localhost:33306` root/Lanqi123456 db:new-api | | |||||
| | Redis | `localhost:36379` | | |||||
| | 后端 | `localhost:3000` | | |||||
| | 前端 | `localhost:5173`(dev server) | | |||||
| | 管理员 | root / 12345678 | | |||||
| ### 参数 | |||||
| 用户可能在 `$ARGUMENTS` 中传入: | |||||
| - (空) — 启动全部(MySQL/Redis + 前端 + 后端) | |||||
| - `--backend-only` — 只启动后端(MySQL/Redis + 后端) | |||||
| - `--frontend-only` — 只启动前端 dev server | |||||
| - `--stop` — 停止所有服务(后端进程 + 前端进程) | |||||
| ### 执行步骤 | |||||
| **步骤 1:确保 WSL 中 MySQL 和 Redis 运行** | |||||
| ```bash | |||||
| wsl -- bash -c "cd /root/new-api-dev && docker compose up -d" | |||||
| ``` | |||||
| 等待 MySQL 就绪(最多 30 秒): | |||||
| ```bash | |||||
| until wsl -- bash -c "mysql -h 127.0.0.1 -P 3306 -u root -pLanqi123456 -e 'SELECT 1'" 2>/dev/null; do sleep 2; done | |||||
| ``` | |||||
| **步骤 2:杀掉旧进程(如果端口被占用)** | |||||
| 检查并杀掉占用 3000 端口的进程: | |||||
| ```bash | |||||
| netstat -ano | grep ":3000 " | grep LISTENING | awk '{print $5}' | sort -u | while read pid; do taskkill //PID $pid //F 2>/dev/null; done | |||||
| ``` | |||||
| 检查并杀掉占用 5173 端口的进程(如果需要启动前端): | |||||
| ```bash | |||||
| netstat -ano | grep ":5173 " | grep LISTENING | awk '{print $5}' | sort -u | while read pid; do taskkill //PID $pid //F 2>/dev/null; done | |||||
| ``` | |||||
| **步骤 3:启动服务** | |||||
| 前端(后台,仅在非 `--backend-only` 时): | |||||
| ```bash | |||||
| cd web && bun run dev | |||||
| ``` | |||||
| 后端(后台,仅在非 `--frontend-only` 时),带环境变量: | |||||
| ```bash | |||||
| export SQL_DSN="root:Lanqi123456@tcp(localhost:33306)/new-api" | |||||
| export REDIS_CONN_STRING="redis://localhost:36379" | |||||
| export SESSION_SECRET="dev-secret-do-not-use-in-prod" | |||||
| export SESSION_NAME="session_dev" | |||||
| export DEBUG=1 | |||||
| export ERROR_LOG_ENABLED=true | |||||
| export BATCH_UPDATE_ENABLED=true | |||||
| export TZ=Asia/Shanghai | |||||
| go run main.go | |||||
| ``` | |||||
| **步骤 4:等待就绪并验证** | |||||
| 等待后端就绪: | |||||
| ```bash | |||||
| until curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/status | grep -q "200"; do sleep 2; done | |||||
| ``` | |||||
| **步骤 5:报告状态** | |||||
| 向用户报告: | |||||
| - 各服务状态(MySQL/Redis/后端/前端) | |||||
| - 访问地址 | |||||
| - 如果有服务启动失败,报告具体错误 | |||||
| ### 停止服务(`--stop`) | |||||
| 杀掉 3000 和 5173 端口上的进程即可。MySQL/Redis 容器保持运行(数据持久化)。 | |||||
| ### 要求 | |||||
| - 所有服务使用 `run_in_background` 启动 | |||||
| - 等待后端就绪后再报告 | |||||
| - 如果编译失败,报告具体错误 | |||||
| - 启动完成后提示用户可以使用 `curl` 或浏览器访问 | |||||
| @@ -0,0 +1,44 @@ | |||||
| --- | |||||
| allowed-tools: Bash(wsl -- *) | |||||
| description: 'Command: deploy-wsl-test (imported from Claude Code)' | |||||
| name: cmd-deploy-wsl-test | |||||
| --- | |||||
| ## 部署到测试环境 | |||||
| 将镜像构建并部署到 WSL 本地的国内/国外测试节点。 | |||||
| ### 测试环境信息 | |||||
| | 项目 | 国内节点 | 国外节点 | | |||||
| |------|---------|---------| | |||||
| | 目录 | /root/new-api-cn | /root/new-api-ov | | |||||
| | 端口 | 23000:3000 | 13000:3000 | | |||||
| | MySQL | 23306 | 13306 | | |||||
| | 容器名 | cn-new-api | ov-new-api | | |||||
| | SESSION | session_m (Master) | session_slave (Slave) | | |||||
| ### 参数 | |||||
| 用户可能在 `$ARGUMENTS` 中传入: | |||||
| - (空) — 构建镜像 + 推送 + 部署到两个测试节点 | |||||
| - `cn` — 只部署到国内测试节点 | |||||
| - `ov` — 只部署到国外测试节点 | |||||
| - `--no-build` — 跳过构建推送,使用本地最新镜像 | |||||
| - 可以组合,如 `--no-build cn` | |||||
| ### 执行 | |||||
| 使用以下命令在 WSL 中执行部署: | |||||
| ```bash | |||||
| wsl -- bash -c "cd /root && bash //mnt/d/code/new-api/scripts/deploy-test.sh $ARGUMENTS" | |||||
| ``` | |||||
| **注意**:路径必须使用 `//mnt/d/...`(双斜杠),否则 Git Bash 会将其转换为 Windows 路径导致 WSL 找不到脚本。 | |||||
| ### 要求 | |||||
| - 等待命令完成,实时向用户报告进度 | |||||
| - 如果失败,报告具体错误信息 | |||||
| - 完成后向用户总结部署结果(镜像 tag、各节点状态) | |||||
| @@ -0,0 +1,34 @@ | |||||
| --- | |||||
| allowed-tools: Bash(wsl -- *) | |||||
| description: 'Command: deploy (imported from Claude Code)' | |||||
| name: cmd-deploy | |||||
| --- | |||||
| ## 部署命令 | |||||
| 通过 WSL 执行 `scripts/deploy.sh` 完成自动部署。 | |||||
| ### 参数 | |||||
| 用户可能在 `$ARGUMENTS` 中传入: | |||||
| - (空) — 构建镜像 + 推送 + 部署到两台服务器 | |||||
| - `cn` — 只部署到国内服务器 (123.57.74.135) | |||||
| - `ov` — 只部署到国外服务器 (139.180.189.205) | |||||
| - `--no-build` — 跳过构建推送,使用本地最新镜像 | |||||
| - 可以组合,如 `--no-build cn` | |||||
| ### 执行 | |||||
| 使用以下命令调用部署脚本: | |||||
| ```bash | |||||
| wsl -- bash -c "bash //mnt/d/code/new-api/scripts/deploy.sh $ARGUMENTS" | |||||
| ``` | |||||
| **注意**:路径必须使用 `//mnt/d/...`(双斜杠),否则 Git Bash 会将其转换为 Windows 路径导致 WSL 找不到脚本。 | |||||
| ### 要求 | |||||
| - 等待命令完成,实时向用户报告进度 | |||||
| - 如果失败,报告具体错误信息 | |||||
| - 完成后向用户总结部署结果(镜像 tag、各节点状态) | |||||
| @@ -0,0 +1,86 @@ | |||||
| --- | |||||
| allowed-tools: >- | |||||
| Bash(git add:*), Bash(git status:*), Bash(git diff:*), Bash(git log:*), | |||||
| Bash(git commit:*), Bash(mkdir:*), Read, Write, Edit, Glob, Grep | |||||
| description: 'Command: ob-commit (imported from Claude Code)' | |||||
| name: cmd-ob-commit | |||||
| --- | |||||
| ## Context | |||||
| - Current git status: !`git status` | |||||
| - Current git diff (staged and unstaged changes): !`git diff HEAD` | |||||
| - Current branch: !`git branch --show-current` | |||||
| - Recent commits: !`git log --oneline -10` | |||||
| ## Task | |||||
| Based on the above changes, create a git commit AND write a development log to Obsidian. | |||||
| ### Step 1: Create the commit | |||||
| 1. Analyze the changes and draft a commit message following conventional commits format. | |||||
| 2. Stage the relevant files and create the commit. | |||||
| ### Step 2: Write Obsidian dev log (MANDATORY) | |||||
| After the commit succeeds, you MUST write a development log. Follow these rules exactly: | |||||
| **Directory**: `D:\markdown\workspace-lanqi\new-api\{YYYY-MM-DD}\` (create if not exists) | |||||
| **File naming**: `{YYYY-MM-DD} {commit message short summary}.md` | |||||
| - If multiple commits on the same day, append a brief distinguishing suffix | |||||
| - Example: `2026-04-16 fix: 修复支付回调签名验证.md` | |||||
| **Log template** — write in Chinese: | |||||
| ```markdown | |||||
| --- | |||||
| created: YYYY-MM-DD | |||||
| tags: | |||||
| - dev-log | |||||
| - new-api | |||||
| branch: {branch name} | |||||
| commit: {short hash} | |||||
| --- | |||||
| # {commit message} | |||||
| > 分支:`{branch}` | Commit:`{short hash}` | 日期:YYYY-MM-DD | |||||
| ## 变更概述 | |||||
| {1-3 sentences summarizing purpose and impact} | |||||
| ## 新增文件 | |||||
| | 文件 | 说明 | | |||||
| |------|------| | |||||
| | `{path}` | {description} | | |||||
| ## 修改文件 | |||||
| | 文件 | 变更内容 | | |||||
| |------|---------| | |||||
| | `{path}` | {what changed and why} | | |||||
| ## 关键代码变更 | |||||
| {Detailed description of core logic changes, including function signature changes, new key functions, algorithm logic. Use code blocks for key snippets.} | |||||
| ## 数据库变更 | |||||
| {If table structure changed, list new tables, columns, indexes. Delete this section if none.} | |||||
| ## 备注 | |||||
| {Additional notes, design decisions, known issues. Delete this section if none.} | |||||
| ``` | |||||
| ### Rules | |||||
| - Delete sections with no content (数据库变更, 备注) | |||||
| - All log content in Chinese | |||||
| - Frontmatter MUST include: `created`, `tags` (at least `dev-log` and `new-api`), `branch`, `commit` | |||||
| - Include enough detail for someone unfamiliar with the change to understand it | |||||
| - You MUST use multiple tools in a single response. Stage, commit, then write the log file. | |||||
| @@ -0,0 +1,103 @@ | |||||
| --- | |||||
| allowed-tools: mcp__plugin_chrome-devtools-mcp_chrome-devtools__* | |||||
| description: 'Command: set-channel-pricing (imported from Claude Code)' | |||||
| name: cmd-set-channel-pricing | |||||
| --- | |||||
| ## 设置渠道定价 | |||||
| 通过 Chrome DevTools MCP 在指定环境中操作浏览器,为模型配置渠道定价并设置默认通道。 | |||||
| ### 参数 | |||||
| 用户在 `$ARGUMENTS` 中传入 JSON 格式的参数(字段均可选,缺失时会询问): | |||||
| ``` | |||||
| { | |||||
| "target": "http://localhost:23000", | |||||
| "model_name": "deepseek-ai/DeepSeek-V3-0324", | |||||
| "default_channel": "AtlasCloud", | |||||
| "channels": { | |||||
| "AtlasCloud": { "model_ratio": 1, "completion_ratio": 1 }, | |||||
| "atlascloud": { "model_ratio": 1, "completion_ratio": 1 } | |||||
| } | |||||
| } | |||||
| ``` | |||||
| | 字段 | 说明 | 默认值 | | |||||
| |------|------|--------| | |||||
| | `target` | 目标环境地址 | 询问用户 | | |||||
| | `model_name` | 要配置定价的模型名称 | 询问用户 | | |||||
| | `default_channel` | 设为默认的渠道名称 | 不设置默认 | | |||||
| | `channels` | 各渠道的定价配置 | 所有渠道统一设置 model_ratio=1, completion_ratio=1 | | |||||
| 如果 `$ARGUMENTS` 不是 JSON,按顺序解析为 `model_name`,其余使用默认值。 | |||||
| ### 操作流程 | |||||
| 1. **导航到渠道定价页面** | |||||
| - `navigate_page` 到 `{target}/console/setting?tab=ratio` | |||||
| 2. **切换到渠道定价视图** | |||||
| - `take_snapshot` 确认页面加载 | |||||
| - 找到并点击"分组与模型定价设置"标签页(如果 URL 未直接跳转) | |||||
| - 点击"可视化倍率设置"子标签 | |||||
| - 点击"渠道定价"单选按钮 | |||||
| 3. **搜索目标模型** | |||||
| - 在搜索框中输入 `model_name` | |||||
| - 等待列表过滤出目标模型 | |||||
| 4. **展开模型行** | |||||
| - 点击目标模型行的展开按钮(chevron_right) | |||||
| 5. **为每个渠道设置定价** | |||||
| - 对展开后每个未配置的渠道行,点击编辑按钮(edit icon) | |||||
| - 在弹出的编辑对话框中: | |||||
| - 确认模型名称正确 | |||||
| - 选择计费类型(默认"按量计费") | |||||
| - 填写模型倍率(`model_ratio`) | |||||
| - 填写补全倍率(`completion_ratio`) | |||||
| - 点击确认(confirm)保存 | |||||
| - 如果渠道已有定价但需要修改,同样点击编辑按钮更新 | |||||
| 6. **设置默认通道** | |||||
| - 在展开的模型行中,找到 `default_channel` 对应的渠道 | |||||
| - 点击该渠道行的星标按钮(star icon) | |||||
| - 验证出现"已设为默认通道"提示和"默认"文字标签 | |||||
| 7. **验证结果** | |||||
| - `take_snapshot` 确认: | |||||
| - 所有渠道都显示正确的倍率 | |||||
| - 默认渠道旁显示黄色星标 + "默认"标签 | |||||
| - 非默认渠道的星标为灰色 | |||||
| - 向用户总结配置结果 | |||||
| ### 关键 UI 元素识别 | |||||
| | 元素 | 识别方式 | | |||||
| |------|---------| | |||||
| | 分组与模型定价设置 tab | 文本匹配 "分组与模型定价设置" | | |||||
| | 可视化倍率设置 tab | 文本匹配 "可视化倍率设置" | | |||||
| | 渠道定价 radio | 文本匹配 "渠道定价" | | |||||
| | 模型行展开按钮 | 模型名称旁的 chevron_right 按钮 | | |||||
| | 渠道编辑按钮 | 渠道行操作列的 edit 按钮 | | |||||
| | 模型倍率输入框 | spinbutton,label 含 "模型倍率" | | |||||
| | 补全倍率输入框 | spinbutton,label 含 "补全倍率" | | |||||
| | 确认按钮 | 对话框底部的 confirm 按钮 | | |||||
| | 默认星标按钮 | 渠道行"默认"列的 star 按钮 | | |||||
| ### 测试环境地址 | |||||
| | 环境 | 地址 | 说明 | | |||||
| |------|------|------| | |||||
| | 国内测试 | http://localhost:23000 | WSL 本地 | | |||||
| | 国外测试 | http://localhost:13000 | WSL 本地 | | |||||
| | 生产环境 | https://api.example.com | 按实际填写 | | |||||
| ### 要求 | |||||
| - 每步操作后用 `take_snapshot` 验证页面状态 | |||||
| - 如果模型不存在于列表中,报告错误并询问用户是否需要先添加渠道 | |||||
| - 如果渠道未在列表中显示(模型未关联到该渠道),提示用户该渠道不支持此模型 | |||||
| - 完成后向用户总结配置结果(模型名称、各渠道倍率、默认通道) | |||||
| @@ -0,0 +1,15 @@ | |||||
| # 本地开发环境变量,与 docker/dev/docker-compose.yml 配套 | |||||
| # 用法(WSL / bash): | |||||
| # source .env.dev && go run main.go | |||||
| # 用法(PowerShell): | |||||
| # $env:SQL_DSN="root:Lanqi123456@tcp(localhost:33306)/new-api"; $env:REDIS_CONN_STRING="redis://localhost:36379"; go run main.go | |||||
| export SQL_DSN="root:Lanqi123456@tcp(localhost:33306)/new-api" | |||||
| export REDIS_CONN_STRING="redis://localhost:36379" | |||||
| export SESSION_SECRET="dev-secret-do-not-use-in-prod" | |||||
| export SESSION_NAME="session_dev" | |||||
| export DEBUG=1 | |||||
| export ERROR_LOG_ENABLED=true | |||||
| export BATCH_UPDATE_ENABLED=true | |||||
| export TZ=Asia/Shanghai | |||||
| export ATLASCLOUD_API_KEY=apikey-ba28bd46b2bf470aa8a6718db4b6839a | |||||
| @@ -0,0 +1,123 @@ | |||||
| # Alan 用户 ClaudeCode 令牌 429 错误 upstream_id 列表 | |||||
| # 用户: Alan (user_id=47), 令牌: ClaudeCode (token_id=14) | |||||
| # 时间范围: 2026-05-19 23:47:19 ~ 2026-05-20 09:03:54 | |||||
| # 总计: 112 条 429 错误,全部来自 channel_id=1 | |||||
| # 模型分布: claude-opus-4.7 (90条), claude-haiku-4.5 (22条) | |||||
| # | |||||
| # 格式: upstream_id | model_name | time | |||||
| # === claude-haiku-4.5-20251001 (22条) === | |||||
| 8a4c62b4-1686-4f02-901e-7e7da6aac605 anthropic/claude-haiku-4.5-20251001 2026-05-20 09:00:12 | |||||
| 259035f9-eb62-484a-9e7f-13a3885a6626 anthropic/claude-haiku-4.5-20251001 2026-05-20 09:00:17 | |||||
| 72745e0f-6a18-4093-93ed-9d9d1149ef7a anthropic/claude-haiku-4.5-20251001 2026-05-20 09:00:20 | |||||
| 2f6faaf6-5511-4e22-8ce2-044346c175dc anthropic/claude-haiku-4.5-20251001 2026-05-20 09:00:27 | |||||
| ec6e5677-8422-457a-870f-485a7c297790 anthropic/claude-haiku-4.5-20251001 2026-05-20 09:00:36 | |||||
| a2523ccf-7241-4a61-ae8a-1e1d1db20daf anthropic/claude-haiku-4.5-20251001 2026-05-20 09:00:50 | |||||
| 429e40ab-058b-4999-be18-246d1d8a4926 anthropic/claude-haiku-4.5-20251001 2026-05-20 09:01:12 | |||||
| de03173d-c6e4-4327-8339-45cfa734066b anthropic/claude-haiku-4.5-20251001 2026-05-20 09:01:54 | |||||
| f01c38ab-a216-4da2-8823-e2107e54839d anthropic/claude-haiku-4.5-20251001 2026-05-20 09:02:33 | |||||
| 9a7dbef1-dd8b-426b-a63f-cbbb5195682a anthropic/claude-haiku-4.5-20251001 2026-05-20 09:03:15 | |||||
| 82a4bbf6-bc89-4617-8514-a4ac2cf5174f anthropic/claude-haiku-4.5-20251001 2026-05-20 09:03:54 | |||||
| 40c0b99d-4c34-474c-a942-e2f42785646a anthropic/claude-haiku-4.5-20251001 2026-05-19 23:51:01 | |||||
| 5fcf397a-1345-4174-9f92-a4f1e0607b1b anthropic/claude-haiku-4.5-20251001 2026-05-19 23:50:23 | |||||
| b22f2e85-07f7-4ffa-89de-2fa9ba5bb6a7 anthropic/claude-haiku-4.5-20251001 2026-05-19 23:49:47 | |||||
| c8f0f203-0253-41ae-b72e-80d8667dd017 anthropic/claude-haiku-4.5-20251001 2026-05-19 23:49:00 | |||||
| 4ec13969-7f2a-41d6-83cf-b4f1829e8fae anthropic/claude-haiku-4.5-20251001 2026-05-19 23:48:22 | |||||
| aa3dd5ef-c73f-4527-b4b6-0ded7d445d92 anthropic/claude-haiku-4.5-20251001 2026-05-19 23:48:01 | |||||
| 0435b06b-c437-4bc2-9d8a-b86a41110520 anthropic/claude-haiku-4.5-20251001 2026-05-19 23:47:47 | |||||
| b9c49d19-e532-4386-b932-e72c3964b10c anthropic/claude-haiku-4.5-20251001 2026-05-19 23:47:38 | |||||
| 97bcded4-ce94-4ba5-833d-fd5f74675ec8 anthropic/claude-haiku-4.5-20251001 2026-05-19 23:47:31 | |||||
| 38066d1a-0749-424b-99b6-3f9dafc344cc anthropic/claude-haiku-4.5-20251001 2026-05-19 23:47:25 | |||||
| 0a3365ed-a54b-421d-8125-f384e6a18d5b anthropic/claude-haiku-4.5-20251001 2026-05-19 23:47:19 | |||||
| # === claude-opus-4.7 (90条) === | |||||
| 8937e45a-090a-4f92-baa3-802ebdbadf9f anthropic/claude-opus-4.7 2026-05-20 06:19:10 | |||||
| 2577f038-cb35-4b9c-9039-aa1a34ceced5 anthropic/claude-opus-4.7 2026-05-20 06:19:30 | |||||
| d6180a7a-4dda-489e-8f4e-ac32ecb8d960 anthropic/claude-opus-4.7 2026-05-20 06:19:48 | |||||
| 9d105cfb-2bc8-4f54-80a8-03d8c02c0e4e anthropic/claude-opus-4.7 2026-05-20 06:20:10 | |||||
| fb1ce184-b582-4ce8-877e-4ff9b8c86c50 anthropic/claude-opus-4.7 2026-05-20 06:20:42 | |||||
| a3248024-75fb-431b-909b-6832f5f47800 anthropic/claude-opus-4.7 2026-05-20 06:21:13 | |||||
| 5adca109-c90a-495c-8900-8168a0d0e1b3 anthropic/claude-opus-4.7 2026-05-20 06:21:54 | |||||
| 3fc887f4-14a9-4ead-9473-de5f3befa5f4 anthropic/claude-opus-4.7 2026-05-20 06:22:45 | |||||
| 9b385114-e385-4bf6-93f5-65d29d695ada anthropic/claude-opus-4.7 2026-05-20 06:23:37 | |||||
| 12d1f1a4-963c-4710-aaa9-aedcec883d54 anthropic/claude-opus-4.7 2026-05-20 06:24:40 | |||||
| dd70e7e1-5658-48c5-ae41-bcc3648e0f3f anthropic/claude-opus-4.7 2026-05-20 06:25:41 | |||||
| 97e7aaa9-c797-44d0-b241-39f4eb8ab314 anthropic/claude-opus-4.7 2026-05-20 06:32:03 | |||||
| dd3c3de5-bad7-4819-a736-0112589bc3a8 anthropic/claude-opus-4.7 2026-05-20 06:32:22 | |||||
| a599b1b9-0c60-4219-a7ab-0a0f595c425a anthropic/claude-opus-4.7 2026-05-20 06:32:40 | |||||
| d763ca6b-e38e-4d51-b6f2-d27d6816ad9a anthropic/claude-opus-4.7 2026-05-20 06:33:07 | |||||
| 5131c19d-87f7-44f0-9fd7-70642c8fd1ec anthropic/claude-opus-4.7 2026-05-20 06:33:46 | |||||
| 3515ded8-b04e-4af3-9509-6dab3e20a8a7 anthropic/claude-opus-4.7 2026-05-20 06:34:16 | |||||
| 1ddcd66b-10be-4017-a9f1-2e678e95455a anthropic/claude-opus-4.7 2026-05-20 06:35:03 | |||||
| 8425c233-7892-4dc9-b240-a4c7c9d7ece3 anthropic/claude-opus-4.7 2026-05-20 06:36:58 | |||||
| 9690cf62-abbe-4e9d-ab8d-4e6a66ee384e anthropic/claude-opus-4.7 2026-05-20 06:39:16 | |||||
| c16b6d6d-52d9-4512-b864-6b7610bd200f anthropic/claude-opus-4.7 2026-05-20 06:43:08 | |||||
| 0b1e715b-7dbf-499c-815a-837a50860631 anthropic/claude-opus-4.7 2026-05-20 06:44:19 | |||||
| 7223d596-9dde-40e8-898a-c9f8a28c94ca anthropic/claude-opus-4.7 2026-05-20 06:44:51 | |||||
| cf39ec6b-c982-4c1d-9af7-8d0bc0a4a740 anthropic/claude-opus-4.7 2026-05-20 06:49:34 | |||||
| 341e944e-0475-44e6-a316-d3ab69748d9d anthropic/claude-opus-4.7 2026-05-20 06:50:19 | |||||
| 44b66588-71f0-45ba-add9-8326d89727a5 anthropic/claude-opus-4.7 2026-05-20 06:54:02 | |||||
| 8fbf4a48-30cb-4928-95ac-d63570ac4e50 anthropic/claude-opus-4.7 2026-05-20 06:56:08 | |||||
| ba638c4b-fe4b-49e0-af48-61338523a16a anthropic/claude-opus-4.7 2026-05-20 06:57:18 | |||||
| 2ed8faa7-c444-43e3-936c-e4254c12a9e7 anthropic/claude-opus-4.7 2026-05-20 06:57:48 | |||||
| 8d3f86c8-a736-4f47-8b48-d6de8f0aed5b anthropic/claude-opus-4.7 2026-05-20 07:03:48 | |||||
| 9b7858f5-e763-4ecf-836a-b66ec78b6e01 anthropic/claude-opus-4.7 2026-05-20 07:04:41 | |||||
| 9b4eaf36-651e-4348-b9a0-d85d19e941e3 anthropic/claude-opus-4.7 2026-05-20 07:05:06 | |||||
| 2147af82-839e-4e84-820f-abeb23c71289 anthropic/claude-opus-4.7 2026-05-20 07:05:32 | |||||
| 34ed7bb3-99db-46f0-992f-3479223c168d anthropic/claude-opus-4.7 2026-05-20 07:06:00 | |||||
| 050bd83b-10bb-4eef-8ba6-2ba80802bac8 anthropic/claude-opus-4.7 2026-05-20 07:06:44 | |||||
| 2686785e-d2d5-4418-a8aa-f162de8924b4 anthropic/claude-opus-4.7 2026-05-20 07:07:21 | |||||
| 12c8fd4f-d492-4fe1-8290-3e66523fcf56 anthropic/claude-opus-4.7 2026-05-20 07:07:59 | |||||
| f8231f97-4a77-49c9-aaf7-55c89ea170d6 anthropic/claude-opus-4.7 2026-05-20 07:08:56 | |||||
| bb4f79e7-7c35-413a-8d17-5968399133a6 anthropic/claude-opus-4.7 2026-05-20 07:10:07 | |||||
| a07961a7-41cd-41dd-9fce-49b244328a33 anthropic/claude-opus-4.7 2026-05-20 07:11:09 | |||||
| 33d1d725-a065-49dd-a371-31983ccf58c8 anthropic/claude-opus-4.7 2026-05-20 07:12:06 | |||||
| 8b5e2509-2512-4bf0-96ea-115e318143df anthropic/claude-opus-4.7 2026-05-20 07:15:13 | |||||
| 51c579f4-80d1-43c1-897e-20c6dc69465d anthropic/claude-opus-4.7 2026-05-20 07:15:26 | |||||
| 471666f2-2f73-46c5-bd76-13620b0fd3c9 anthropic/claude-opus-4.7 2026-05-20 07:15:47 | |||||
| abc8755a-4d56-4d36-8ca6-62ed68d2e979 anthropic/claude-opus-4.7 2026-05-20 07:16:05 | |||||
| dca16c52-2c99-43a9-8f9c-96d910beaba5 anthropic/claude-opus-4.7 2026-05-20 07:16:43 | |||||
| daa220d4-a311-4c33-bacd-72265ccf83cb anthropic/claude-opus-4.7 2026-05-20 07:17:05 | |||||
| 3474a1ed-6f7a-48aa-8213-c201fca65c91 anthropic/claude-opus-4.7 2026-05-20 07:17:52 | |||||
| 8f0f37f4-58dd-4b45-a0b4-53bddf35d1fe anthropic/claude-opus-4.7 2026-05-20 07:18:39 | |||||
| 6cec0036-6c27-4e59-91a1-097c3aad49ab anthropic/claude-opus-4.7 2026-05-20 07:19:27 | |||||
| 6b08f0cb-dfb7-4745-abb4-79f05b8d7b76 anthropic/claude-opus-4.7 2026-05-20 07:22:13 | |||||
| 6408a6be-22a9-4a48-9b23-944e8e2ebdbe anthropic/claude-opus-4.7 2026-05-20 07:22:30 | |||||
| 76f1a72b-4c91-4ca5-a889-f64c0e641353 anthropic/claude-opus-4.7 2026-05-20 07:22:47 | |||||
| 8ecf0adc-c60f-434d-91e3-c3ec3f3e47f9 anthropic/claude-opus-4.7 2026-05-20 07:29:48 | |||||
| 63283fcf-84cd-4778-b138-be9155ed2773 anthropic/claude-opus-4.7 2026-05-20 07:30:09 | |||||
| ae5c146f-127e-4cfa-a664-1496e25c9208 anthropic/claude-opus-4.7 2026-05-20 07:30:46 | |||||
| f3cf76fc-3741-4525-b7a3-dbcaf07651d4 anthropic/claude-opus-4.7 2026-05-20 07:31:09 | |||||
| 075f9154-f857-4b59-b774-b85f4cb81a60 anthropic/claude-opus-4.7 2026-05-20 07:31:37 | |||||
| 873a7295-d47c-4e3d-af8f-502d4a7e6a36 anthropic/claude-opus-4.7 2026-05-20 07:32:43 | |||||
| 083f7117-4539-4bbc-8585-a0d520c8ad6b anthropic/claude-opus-4.7 2026-05-20 07:33:27 | |||||
| a12a28fe-50a0-4572-b23a-6f833939014c anthropic/claude-opus-4.7 2026-05-20 07:34:20 | |||||
| 4c5b9328-e370-4ed9-835b-151701076447 anthropic/claude-opus-4.7 2026-05-20 07:35:20 | |||||
| 3b9bb953-bdf3-4585-bc79-146a48d34a21 anthropic/claude-opus-4.7 2026-05-20 07:36:19 | |||||
| ffabd406-f4a8-41be-be59-099e30613fc1 anthropic/claude-opus-4.7 2026-05-20 07:37:15 | |||||
| 61411ecc-5abb-4c47-bc15-055f27a51374 anthropic/claude-opus-4.7 2026-05-20 07:38:09 | |||||
| a898a6a4-3ee5-41d1-a1ef-2b47cd2d7501 anthropic/claude-opus-4.7 2026-05-20 07:38:23 | |||||
| f93ef5b5-4401-45e7-8ed8-59280b7b7ceb anthropic/claude-opus-4.7 2026-05-20 07:38:34 | |||||
| 3f736901-0b44-4a7b-b1ff-9abea5688b9c anthropic/claude-opus-4.7 2026-05-20 07:38:44 | |||||
| 09b55670-82ba-49e1-aa9f-376538a6d778 anthropic/claude-opus-4.7 2026-05-20 07:38:56 | |||||
| 8efd0a1a-2cf3-44e6-976e-f99b6efb850d anthropic/claude-opus-4.7 2026-05-20 07:39:14 | |||||
| a2431acd-8f1c-4f54-ac48-0b9000629f21 anthropic/claude-opus-4.7 2026-05-20 07:39:40 | |||||
| e98d8348-7ed1-4677-90ee-51d180b863a8 anthropic/claude-opus-4.7 2026-05-20 07:40:43 | |||||
| 10eb0a90-082e-47f2-a52c-a9cb64b518f7 anthropic/claude-opus-4.7 2026-05-20 08:43:09 | |||||
| 0057a591-3727-402a-851f-b1e8610e3fca anthropic/claude-opus-4.7 2026-05-20 08:43:34 | |||||
| e3ded410-5c25-4160-a325-4b4f7a341c19 anthropic/claude-opus-4.7 2026-05-20 08:43:53 | |||||
| b3e197b9-0277-4e96-a533-b976effa9d64 anthropic/claude-opus-4.7 2026-05-20 08:44:34 | |||||
| 241e074f-c084-405f-a46a-c4184b99b90a anthropic/claude-opus-4.7 2026-05-20 08:45:20 | |||||
| 583b0e49-93f9-4138-84e1-e9fc2c4abaa3 anthropic/claude-opus-4.7 2026-05-20 08:45:51 | |||||
| f1381633-4701-4d2b-ab00-4e15ac1e4024 anthropic/claude-opus-4.7 2026-05-20 08:50:19 | |||||
| 063bf354-28be-4e3e-9f5c-9f88c929acdc anthropic/claude-opus-4.7 2026-05-20 08:50:35 | |||||
| d44bc890-2756-4cd5-8ee4-60ba0543c8c4 anthropic/claude-opus-4.7 2026-05-20 08:51:16 | |||||
| 628f9ce9-8cfe-4966-a33b-1b2df10b10d8 anthropic/claude-opus-4.7 2026-05-20 08:52:26 | |||||
| 8884ae5d-d22a-4ea4-9247-818c4b6be648 anthropic/claude-opus-4.7 2026-05-20 08:52:44 | |||||
| b73a8fa5-4cb4-4481-90d8-33d485077a45 anthropic/claude-opus-4.7 2026-05-20 08:53:05 | |||||
| d3594203-fc5a-4858-8f8b-7f81f2ee52d5 anthropic/claude-opus-4.7 2026-05-20 08:54:28 | |||||
| 71c44932-cf4a-423e-a8cc-14d18fbd0e42 anthropic/claude-opus-4.7 2026-05-20 08:55:20 | |||||
| 183be15a-ab5d-470a-87b2-10b0032b5bc1 anthropic/claude-opus-4.7 2026-05-20 08:56:11 | |||||
| 1e24fa4b-aeae-42a0-89c7-a08982edcbfb anthropic/claude-opus-4.7 2026-05-20 08:57:00 | |||||
| 33191ba1-8252-4d81-beb8-0c39877408bc anthropic/claude-opus-4.7 2026-05-20 08:58:01 | |||||
| 0293f22d-5042-42a3-837f-21df0cd1b53b anthropic/claude-opus-4.7 2026-05-19 23:56:18 | |||||
| @@ -0,0 +1,23 @@ | |||||
| { | |||||
| "contents": { | |||||
| "role": "USER", | |||||
| "parts": [ | |||||
| { | |||||
| "text": "Create a Tom and Jerry Poster." | |||||
| } | |||||
| ] | |||||
| }, | |||||
| "generationConfig": { | |||||
| "responseModalities": [ | |||||
| "IMAGE" | |||||
| ], | |||||
| "imageConfig": { | |||||
| "aspectRatio": "16:9" | |||||
| } | |||||
| }, | |||||
| "safetySettings": { | |||||
| "method": "PROBABILITY", | |||||
| "category": "HARM_CATEGORY_DANGEROUS_CONTENT", | |||||
| "threshold": "BLOCK_MEDIUM_AND_ABOVE" | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,295 @@ | |||||
| # Token API 参考文档 | |||||
| > 本文档以仓库当前实现为准。`controller.GetTokenStatus` 存在,但未在路由中注册;当前可公开调用的 token 相关端点见下文。 | |||||
| ## 通用约定 | |||||
| ### 基础信息 | |||||
| | 项目 | 说明 | | |||||
| |------|------| | |||||
| | Base URL | `{host}/api` | | |||||
| | 认证方式 | 除 `/api/usage/token/` 外,均使用登录态鉴权 | | |||||
| | 响应格式 | JSON | | |||||
| | 字符编码 | UTF-8 | | |||||
| ### 鉴权说明 | |||||
| | 场景 | 说明 | | |||||
| |------|------| | |||||
| | `/api/token/*` | 需要登录态。优先使用 session cookie;若无 session,也可用系统 `access_token` 放在 `Authorization` 头里,但仍必须携带 `New-Api-User` | | |||||
| | `/api/usage/token/` | 只需要 `Authorization: Bearer sk-...`,不需要 session 和 `New-Api-User` | | |||||
| > session cookie 名由服务端 `SESSION_NAME` 决定,默认是 `session`。 | |||||
| > | |||||
| > `New-Api-User` 必须与当前登录身份的用户 ID 一致,否则返回: | |||||
| > ```json | |||||
| > {"success": false, "message": "无权进行此操作,未提供 New-Api-User"} | |||||
| > ``` | |||||
| > ```json | |||||
| > {"success": false, "message": "无权进行此操作,New-Api-User 与登录用户不匹配"} | |||||
| > ``` | |||||
| ### 统一响应 | |||||
| 大多数接口成功时返回: | |||||
| ```json | |||||
| {"success": true, "message": "", "data": {}} | |||||
| ``` | |||||
| 业务失败通常返回: | |||||
| ```json | |||||
| {"success": false, "message": "错误描述"} | |||||
| ``` | |||||
| > 注意:鉴权失败、限流等中间件可能直接返回 `401/403/429`,不一定是 `200`。 | |||||
| ### 分页参数 | |||||
| | 参数 | 别名 | 说明 | 默认 | 上限 | | |||||
| |------|------|------|------|------| | |||||
| | `p` | - | 页码,从 1 开始 | 1 | - | | |||||
| | `page_size` | `ps` / `size` | 每页条数 | 10 | 100 | | |||||
| ### Token 数据模型 | |||||
| > `key` 为原始密钥,不含 `sk-` 前缀;创建接口不回传密钥,列表/详情/搜索会返回该原始值。 | |||||
| ```json | |||||
| { | |||||
| "id": 123, | |||||
| "user_id": 44, | |||||
| "key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", | |||||
| "status": 1, | |||||
| "name": "my-token", | |||||
| "created_time": 1716163200, | |||||
| "accessed_time": 1716249600, | |||||
| "expired_time": -1, | |||||
| "remain_quota": 500000, | |||||
| "unlimited_quota": false, | |||||
| "model_limits_enabled": true, | |||||
| "model_limits": "gpt-4o,claude-3-5-sonnet", | |||||
| "allow_ips": "192.168.1.0/24\n10.0.0.1", | |||||
| "used_quota": 120000, | |||||
| "group": "default", | |||||
| "cross_group_retry": false | |||||
| } | |||||
| ``` | |||||
| | 字段 | 说明 | | |||||
| |------|------| | |||||
| | `status` | `1` 启用,`2` 禁用,`3` 过期,`4` 耗尽 | | |||||
| | `expired_time` | Unix 秒级时间戳,`-1` 表示永不过期 | | |||||
| | `remain_quota` | 剩余额度,单位为内部 quota | | |||||
| | `model_limits` | 逗号分隔的模型列表 | | |||||
| | `allow_ips` | 换行分隔的 IP 白名单,空则不限制 | | |||||
| | `group` | 空字符串表示使用用户默认分组 | | |||||
| | `cross_group_retry` | 仅 `auto` 分组有意义 | | |||||
| ## API 端点 | |||||
| ### 1. 获取令牌列表 | |||||
| ``` | |||||
| GET /api/token/ | |||||
| ``` | |||||
| #### 请求 | |||||
| | 参数 | 说明 | | |||||
| |------|------| | |||||
| | `p` | 页码 | | |||||
| | `page_size` / `ps` / `size` | 每页条数 | | |||||
| #### 响应 | |||||
| `data` 为分页结构;其中每个 token 的 `key` 是原始密钥,不带 `sk-` 前缀。 | |||||
| ### 2. 搜索令牌 | |||||
| ``` | |||||
| GET /api/token/search | |||||
| ``` | |||||
| #### 请求 | |||||
| | 参数 | 说明 | | |||||
| |------|------| | |||||
| | `keyword` | 按名称搜索 | | |||||
| | `token` | 按密钥搜索,`sk-` 前缀可选 | | |||||
| | `p` | 页码 | | |||||
| | `page_size` / `ps` / `size` | 每页条数 | | |||||
| #### 搜索规则 | |||||
| - 不含 `%` 时为精确匹配 | |||||
| - 含 `%` 时为模糊匹配 | |||||
| - 最多允许 2 个 `%` | |||||
| - 不允许连续 `%%` | |||||
| - 含 `%` 时,去掉 `%` 后长度必须至少 2 | |||||
| - 令牌数量超过上限时,只允许精确搜索 | |||||
| ### 3. 获取单个令牌 | |||||
| ``` | |||||
| GET /api/token/:id | |||||
| ``` | |||||
| 返回单个 token 对象,`key` 同样是原始密钥。 | |||||
| ### 4. 创建令牌 | |||||
| ``` | |||||
| POST /api/token/ | |||||
| ``` | |||||
| #### 请求体 | |||||
| | 字段 | 说明 | | |||||
| |------|------| | |||||
| | `name` | 必填,最长 50 字符 | | |||||
| | `remain_quota` | 可选,默认 0 | | |||||
| | `unlimited_quota` | 可选,默认 false | | |||||
| | `expired_time` | 可选,默认 -1 | | |||||
| | `model_limits_enabled` | 可选,默认 false | | |||||
| | `model_limits` | 可选,逗号分隔字符串 | | |||||
| | `allow_ips` | 可选,换行分隔字符串 | | |||||
| | `group` | 可选,空字符串表示用户默认分组 | | |||||
| | `cross_group_retry` | 可选,默认 false | | |||||
| #### 说明 | |||||
| - 后端自动生成 `key` | |||||
| - 创建成功不返回 `key` | |||||
| - 创建成功后通常去列表页复制原始 `key` | |||||
| ### 5. 更新令牌 | |||||
| ``` | |||||
| PUT /api/token/ | |||||
| ``` | |||||
| #### 请求 | |||||
| | 参数 | 说明 | | |||||
| |------|------| | |||||
| | `status_only=true` | 只更新状态 | | |||||
| | 字段 | 说明 | | |||||
| |------|------| | |||||
| | `id` | 必填 | | |||||
| | `name` | 可选 | | |||||
| | `status` | 仅 `status_only=true` 时生效 | | |||||
| | `remain_quota` | 可选 | | |||||
| | `unlimited_quota` | 可选 | | |||||
| | `expired_time` | 可选 | | |||||
| | `model_limits_enabled` | 可选 | | |||||
| | `model_limits` | 可选 | | |||||
| | `allow_ips` | 可选 | | |||||
| | `group` | 可选 | | |||||
| | `cross_group_retry` | 可选 | | |||||
| #### 业务规则 | |||||
| - 已过期且过期时间已到的 token 不能重新启用 | |||||
| - 已耗尽且非无限额度的 token 不能重新启用 | |||||
| - 非无限额度时,`remain_quota` 不能为负,也不能超过 `10^9 × QuotaPerUnit` | |||||
| - 不带 `status_only=true` 时,`status` 字段会被忽略 | |||||
| #### 响应 | |||||
| 返回更新后的 token 对象。 | |||||
| ### 6. 删除令牌 | |||||
| ``` | |||||
| DELETE /api/token/:id | |||||
| ``` | |||||
| 软删除,只能删除自己的 token。 | |||||
| ### 7. 批量删除令牌 | |||||
| ``` | |||||
| POST /api/token/batch | |||||
| ``` | |||||
| #### 请求体 | |||||
| ```json | |||||
| {"ids":[1,2,3]} | |||||
| ``` | |||||
| #### 响应 | |||||
| `data` 为实际删除数量。 | |||||
| ### 8. 查询令牌用量 | |||||
| ``` | |||||
| GET /api/usage/token/ | |||||
| ``` | |||||
| #### 请求头 | |||||
| ```bash | |||||
| Authorization: Bearer sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx | |||||
| ``` | |||||
| #### 响应 | |||||
| ```json | |||||
| { | |||||
| "code": true, | |||||
| "message": "ok", | |||||
| "data": { | |||||
| "object": "token_usage", | |||||
| "name": "my-api-key", | |||||
| "total_granted": 620000, | |||||
| "total_used": 120000, | |||||
| "total_available": 500000, | |||||
| "unlimited_quota": false, | |||||
| "model_limits": { | |||||
| "gpt-4o": true | |||||
| }, | |||||
| "model_limits_enabled": true, | |||||
| "expires_at": 0 | |||||
| } | |||||
| } | |||||
| ``` | |||||
| `expires_at` 单位是秒,`0` 表示永不过期。 | |||||
| ## 错误消息速查 | |||||
| | 场景 | message | | |||||
| |------|---------| | |||||
| | 未提供 `New-Api-User` | `无权进行此操作,未提供 New-Api-User` | | |||||
| | `New-Api-User` 格式错误 | `无权进行此操作,New-Api-User 格式错误` | | |||||
| | `New-Api-User` 不匹配 | `无权进行此操作,New-Api-User 与登录用户不匹配` | | |||||
| | 名称过长 | `令牌名称过长` | | |||||
| | 额度为负 | `额度值不能为负数` | | |||||
| | 额度超限 | `额度值超出有效范围,最大值为 {{.Max}}` | | |||||
| | 数量达上限 | `已达到最大令牌数量限制 (N)` | | |||||
| | 生成失败 | `生成令牌失败` | | |||||
| | 过期不能启用 | `令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期` | | |||||
| | 耗尽不能启用 | `令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度` | | |||||
| | 搜索通配符过多 | `搜索模式中最多允许包含 2 个 % 通配符` | | |||||
| | 连续 `%` | `搜索模式中不允许包含连续的 % 通配符` | | |||||
| | 模糊搜索太短 | `使用模糊搜索时,关键词长度至少为 2 个字符` | | |||||
| | 超量用户模糊搜索 | `令牌数量超过上限,仅允许精确搜索,请勿使用 % 通配符` | | |||||
| | 无效令牌 | `无效的令牌` | | |||||
| | 令牌过期 | `该令牌已过期` | | |||||
| | 令牌耗尽 | `该令牌额度已用尽 ...` | | |||||
| ## 状态枚举 | |||||
| | 值 | 常量名 | 含义 | | |||||
| |----|--------|------| | |||||
| | 1 | `TokenStatusEnabled` | 已启用 | | |||||
| | 2 | `TokenStatusDisabled` | 已禁用 | | |||||
| | 3 | `TokenStatusExpired` | 已过期 | | |||||
| | 4 | `TokenStatusExhausted` | 已耗尽 | | |||||
| @@ -0,0 +1,648 @@ | |||||
| # 重试机制技术文档 | |||||
| > 本文档详细描述 new-api 的请求重试机制,包括重试流程、优先级降级、权重选择、状态码判断、跨组重试、渠道亲和性、计费退款、自动禁用等完整链路。 | |||||
| --- | |||||
| ## 目录 | |||||
| 1. [整体流程概述](#1-整体流程概述) | |||||
| 2. [重试次数与配置](#2-重试次数与配置) | |||||
| 3. [优先级降级机制](#3-优先级降级机制) | |||||
| 4. [权重选择机制](#4-权重选择机制) | |||||
| 5. [重试判断规则](#5-重试判断规则) | |||||
| 6. [可重试状态码范围](#6-可重试状态码范围) | |||||
| 7. [Auto-Group 跨组重试](#7-auto-group-跨组重试) | |||||
| 8. [渠道亲和性与重试](#8-渠道亲和性与重试) | |||||
| 9. [计费与退款](#9-计费与退款) | |||||
| 10. [自动禁用渠道](#10-自动禁用渠道) | |||||
| 11. [Task Relay 重试](#11-task-relay-重试) | |||||
| 12. [RetryParam 状态管理](#12-retryparam-状态管理) | |||||
| --- | |||||
| ## 1. 整体流程概述 | |||||
| ### 请求完整链路 | |||||
| ``` | |||||
| HTTP Request | |||||
| → router (SetApiRouter) | |||||
| → middleware/auth.go (认证) | |||||
| → middleware/distributor.go (初始渠道选择,retry=0) | |||||
| → middleware/rate_limit.go (限流) | |||||
| → controller/relay.go Relay() | |||||
| → PreConsumeBilling() (预扣费) | |||||
| → retry loop (retry=0 ~ RetryTimes): | |||||
| → getChannel() (retry=0 用中间件选好的,retry≥1 重新选择) | |||||
| → restore request body | |||||
| → relayHandler() (发送到上游) | |||||
| → 成功则 return | |||||
| → 失败则 processChannelError() + shouldRetry() 判断 | |||||
| → 全部失败 → BillingSession.Refund() (退款) | |||||
| ``` | |||||
| ### 关键文件与行号 | |||||
| | 文件 | 函数/逻辑 | 行号 | | |||||
| |------|----------|------| | |||||
| | `controller/relay.go` | `Relay()` 重试循环 | 181-234 | | |||||
| | `controller/relay.go` | `getChannel()` 渠道获取 | 283-313 | | |||||
| | `controller/relay.go` | `shouldRetry()` 重试判断 | 315-339 | | |||||
| | `controller/relay.go` | `processChannelError()` 错误处理 | 341-392 | | |||||
| | `controller/relay.go` | `shouldRetryTaskRelay()` 任务重试判断 | 604-641 | | |||||
| | `model/channel_cache.go` | `GetRandomSatisfiedChannel()` 优先级+权重选择 | 96-191 | | |||||
| | `service/channel_select.go` | `CacheGetRandomSatisfiedChannel()` 跨组重试编排 | 83-162 | | |||||
| | `service/channel_select.go` | `RetryParam` 结构体 | 14-46 | | |||||
| | `middleware/distributor.go` | 初始渠道选择 | 60-137 | | |||||
| | `setting/operation_setting/status_code_ranges.go` | 状态码范围配置 | 1-197 | | |||||
| | `service/channel_affinity.go` | 渠道亲和性重试判断 | 493-532 | | |||||
| | `service/billing_session.go` | 计费会话 | 1-115 | | |||||
| | `service/channel.go` | `ShouldDisableChannel()` 自动禁用 | 47-102 | | |||||
| ### 重试循环核心代码 | |||||
| `controller/relay.go:181-234`: | |||||
| ```go | |||||
| retryParam := &service.RetryParam{ | |||||
| Ctx: c, | |||||
| TokenGroup: relayInfo.TokenGroup, | |||||
| ModelName: relayInfo.OriginModelName, | |||||
| Retry: common.GetPointer(0), | |||||
| } | |||||
| for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() { | |||||
| channel, channelErr := getChannel(c, relayInfo, retryParam) | |||||
| // ... 渠道选择、body 恢复、请求转发 ... | |||||
| if newAPIError == nil { | |||||
| return // 成功,直接返回 | |||||
| } | |||||
| processChannelError(c, ...) // 记录错误 + 可能自动禁用 | |||||
| if !shouldRetry(c, newAPIError, common.RetryTimes-retryParam.GetRetry()) { | |||||
| break // 不可重试,退出循环 | |||||
| } | |||||
| } | |||||
| // 循环结束,defer 执行退款 | |||||
| ``` | |||||
| --- | |||||
| ## 2. 重试次数与配置 | |||||
| ### RetryTimes 配置 | |||||
| | 属性 | 值 | | |||||
| |------|---| | |||||
| | 变量 | `common.RetryTimes` | | |||||
| | 定义 | `common/constants.go:122` | | |||||
| | 默认值 | **0**(不重试,只做 1 次初始尝试) | | |||||
| | 存储位置 | 数据库 `options` 表,key=`RetryTimes` | | |||||
| | 加载方式 | `model/option.go:496-497`,`strconv.Atoi(value)` | | |||||
| | 配置入口 | 管理后台 → 运营设置 | | |||||
| ### 循环次数计算 | |||||
| ``` | |||||
| 总尝试次数 = RetryTimes + 1 | |||||
| ``` | |||||
| 循环条件 `retryParam.GetRetry() <= common.RetryTimes`: | |||||
| | RetryTimes 值 | 循环范围 | 总尝试次数 | | |||||
| |:---:|:---:|:---:| | |||||
| | 0 | retry=0 | 1 次(仅初始尝试) | | |||||
| | 1 | retry=0, 1 | 2 次 | | |||||
| | 3 | retry=0, 1, 2, 3 | 4 次 | | |||||
| | 5 | retry=0, 1, 2, 3, 4, 5 | 6 次 | | |||||
| ### 与优先级的关系 | |||||
| `shouldRetry()` 的 `retryTimes` 参数 = `common.RetryTimes - retryParam.GetRetry()`,表示剩余重试次数。当 `retryTimes <= 0` 时不会重试。 | |||||
| --- | |||||
| ## 3. 优先级降级机制 | |||||
| ### 核心算法 | |||||
| `model/channel_cache.go:125-142`: | |||||
| ```go | |||||
| // 1. 收集所有唯一优先级值 | |||||
| uniquePriorities := make(map[int]bool) | |||||
| for _, channelId := range channels { | |||||
| uniquePriorities[int(channel.GetPriority())] = true | |||||
| } | |||||
| // 2. 降序排序(最高优先级在前) | |||||
| sort.Sort(sort.Reverse(sort.IntSlice(sortedUniquePriorities))) | |||||
| // 3. retry index 映射到优先级层级 | |||||
| if retry >= len(uniquePriorities) { | |||||
| retry = len(uniquePriorities) - 1 // 限制为最低优先级 | |||||
| } | |||||
| targetPriority := int64(sortedUniquePriorities[retry]) | |||||
| ``` | |||||
| ### 映射规则 | |||||
| ``` | |||||
| retry=0 → 最高优先级(sortedUniquePriorities[0]) | |||||
| retry=1 → 次高优先级(sortedUniquePriorities[1]) | |||||
| ... | |||||
| retry ≥ 层级数 → 降为最低优先级(不再降级) | |||||
| ``` | |||||
| ### 示例场景 | |||||
| 假设某模型有 5 个渠道: | |||||
| | 渠道 | 优先级 | 权重 | | |||||
| |------|:---:|:---:| | |||||
| | A | 100 | 50 | | |||||
| | B | 100 | 30 | | |||||
| | C | 50 | 80 | | |||||
| | D | 10 | 20 | | |||||
| | E | 10 | 10 | | |||||
| 唯一优先级(降序):`[100, 50, 10]` | |||||
| | 尝试 | retry 值 | 选择优先级 | 候选渠道 | | |||||
| |:---:|:---:|:---:|:---:| | |||||
| | 第 1 次 | 0 | 100 | A, B | | |||||
| | 第 2 次 | 1 | 50 | C | | |||||
| | 第 3 次 | 2 | 10 | D, E | | |||||
| | 第 4 次+ | 超出范围,降为 2(最低优先级) | 10 | D, E | | |||||
| --- | |||||
| ## 4. 权重选择机制 | |||||
| ### 加权随机算法 | |||||
| `model/channel_cache.go:145-188`: | |||||
| 在同一优先级的候选渠道中,使用加权随机选择: | |||||
| ```go | |||||
| totalWeight := sumWeight * smoothingFactor | |||||
| randomWeight := rand.Intn(totalWeight) | |||||
| for _, channel := range targetChannels { | |||||
| randomWeight -= channel.GetWeight()*smoothingFactor + smoothingAdjustment | |||||
| if randomWeight < 0 { | |||||
| return channel, nil | |||||
| } | |||||
| } | |||||
| ``` | |||||
| ### 权重特殊处理 | |||||
| `model/channel_cache.go:162-174`: | |||||
| | 场景 | 处理 | | |||||
| |------|------| | |||||
| | 所有渠道 weight=0 | 每个 channel 的 effective weight = 100(均等选择) | | |||||
| | 平均 weight < 10 | 乘以 smoothingFactor=100,提高区分度 | | |||||
| | 正常 weight | 直接使用配置的 weight 值 | | |||||
| ### 示例 | |||||
| 渠道 A(weight=70)和 渠道 B(weight=30)在同一优先级: | |||||
| - totalWeight = 100 | |||||
| - randomWeight ∈ [0, 100) | |||||
| - A 被选中的概率 = 70% | |||||
| - B 被选中的概率 = 30% | |||||
| E2E 测试验证:30 次请求中,21 次到 A(70.0%),9 次到 B(30.0%),与配置完全一致。 | |||||
| --- | |||||
| ## 5. 重试判断规则 | |||||
| ### shouldRetry() 完整判断链 | |||||
| `controller/relay.go:315-339`: | |||||
| ``` | |||||
| shouldRetry(c, openaiErr, retryTimes) | |||||
| │ | |||||
| ├─ openaiErr == nil? → 不重试 (false) | |||||
| │ | |||||
| ├─ 渠道亲和性跳过? → 不重试 (false) | |||||
| │ ShouldSkipRetryAfterChannelAffinityFailure(c) | |||||
| │ | |||||
| ├─ 是 channel error? → 必须重试 (true) ← 优先级最高 | |||||
| │ IsChannelError(openaiErr) 错误码以 "channel:" 开头 | |||||
| │ | |||||
| ├─ 是 skip-retry error? → 不重试 (false) | |||||
| │ IsSkipRetryError(openaiErr) skipRetry=true 标记 | |||||
| │ | |||||
| ├─ retryTimes <= 0? → 不重试 (false) | |||||
| │ 剩余重试次数用完 | |||||
| │ | |||||
| ├─ 2xx 状态码? → 不重试 (false) | |||||
| │ code >= 200 && code < 300 | |||||
| │ | |||||
| ├─ 非 HTTP 状态码? → 重试 (true) | |||||
| │ code < 100 || code > 599 连接/传输层错误 | |||||
| │ | |||||
| └─ 按状态码范围判断 → ShouldRetryByStatusCode(code) | |||||
| ``` | |||||
| ### Channel Error(必重试) | |||||
| 以下错误码**始终触发重试**,无论其他条件: | |||||
| | 错误码 | 说明 | | |||||
| |--------|------| | |||||
| | `channel:no_available_key` | 渠道无可用 API Key | | |||||
| | `channel:param_override_invalid` | 参数覆写无效 | | |||||
| | `channel:header_override_invalid` | Header 覆写无效 | | |||||
| | `channel:model_mapped_error` | 模型映射错误 | | |||||
| | `channel:aws_client_error` | AWS 客户端错误 | | |||||
| | `channel:invalid_key` | 无效的 API Key | | |||||
| | `channel:response_time_exceeded` | 响应超时 | | |||||
| ### Skip-Retry Error(不重试) | |||||
| 以下场景标记为 `skipRetry=true`,不触发重试: | |||||
| - 请求体读取失败 | |||||
| - 渠道未找到 | |||||
| - 计费/预扣费失败 | |||||
| - 敏感词检测 | |||||
| - 各种内部错误 | |||||
| --- | |||||
| ## 6. 可重试状态码范围 | |||||
| ### 默认配置 | |||||
| `setting/operation_setting/status_code_ranges.go:19-32`: | |||||
| ```go | |||||
| AutomaticRetryStatusCodeRanges = []StatusCodeRange{ | |||||
| {Start: 100, End: 199}, // 1xx 信息响应 | |||||
| {Start: 300, End: 399}, // 3xx 重定向 | |||||
| {Start: 401, End: 407}, // 认证错误 | |||||
| {Start: 409, End: 499}, // 409-499 客户端错误 | |||||
| {Start: 500, End: 503}, // 500-503 服务端错误 | |||||
| {Start: 505, End: 523}, // 505-523 服务端错误 | |||||
| {Start: 525, End: 599}, // 525-599 服务端错误 | |||||
| } | |||||
| alwaysSkipRetryStatusCodes = {504, 524} // 超时不重试 | |||||
| ``` | |||||
| ### 完整状态码速查表 | |||||
| | 状态码范围 | 是否重试 | 说明 | | |||||
| |-----------|:---:|------| | |||||
| | 100-199 | 重试 | 信息响应 | | |||||
| | 200-299 | **不重试** | 成功响应 | | |||||
| | 300-399 | 重试 | 重定向 | | |||||
| | 400 | **不重试** | 不在范围内 | | |||||
| | 401-407 | 重试 | 认证错误 | | |||||
| | 408 | **不重试** | Request Timeout(不在范围内) | | |||||
| | 409-499 | 重试 | 各种客户端错误 | | |||||
| | 500-503 | 重试 | 服务端错误 | | |||||
| | **504** | **不重试** | Gateway Timeout(always-skip) | | |||||
| | 505-523 | 重试 | 服务端错误 | | |||||
| | **524** | **不重试** | Cloudflare Timeout(always-skip) | | |||||
| | 525-599 | 重试 | 服务端错误 | | |||||
| | <100 或 >599 | 重试 | 非 HTTP 状态(传输层错误) | | |||||
| ### 自定义配置 | |||||
| 管理员可通过运营设置修改 `AutomaticRetryStatusCodeRanges`,格式为逗号分隔的范围: | |||||
| ``` | |||||
| "500-503,525-599" # 范围表示 | |||||
| "429" # 单个状态码 | |||||
| "500-503,429,525-599" # 混合使用 | |||||
| ``` | |||||
| 解析函数 `ParseHTTPStatusCodeRanges()` 会自动排序、合并重叠范围。 | |||||
| --- | |||||
| ## 7. Auto-Group 跨组重试 | |||||
| ### 触发条件 | |||||
| 当 Token 的 `group` 字段为 `"auto"` 时启用。系统会在多个渠道组之间依次尝试,每个组内耗尽所有优先级后才切换到下一个组。 | |||||
| ### 跨组重试开关 | |||||
| 需要在 Token 上启用 `cross_group_retry`,存储在 context key `ContextKeyTokenCrossGroupRetry` 中。**未启用时只在第一个匹配的组内重试。** | |||||
| ### 状态跟踪 | |||||
| 通过 Gin Context 中的三个 key 跟踪状态: | |||||
| | Context Key | 说明 | | |||||
| |-------------|------| | |||||
| | `ContextKeyAutoGroupIndex` | 当前正在尝试的组索引 | | |||||
| | `ContextKeyAutoGroupRetryIndex` | 切换到当前组时的全局 retry 计数 | | |||||
| | `ContextKeyAutoGroup` | 当前选中的组名 | | |||||
| ### 示例流程 | |||||
| 2 个组,每组 2 个优先级,`RetryTimes=3`: | |||||
| ``` | |||||
| Retry=0: GroupA, priority0 (startRetryIndex=0, priorityRetry=0) | |||||
| → GroupA 最高优先级渠道 | |||||
| Retry=1: GroupA, priority1 (startRetryIndex=0, priorityRetry=1) | |||||
| → GroupA 次高优先级渠道 | |||||
| Retry=2: GroupA 优先级用完 → GroupB, priority0 (startRetryIndex=2, priorityRetry=0) | |||||
| → GroupB 最高优先级渠道 | |||||
| Retry=3: GroupB, priority1 (startRetryIndex=2, priorityRetry=1) | |||||
| → GroupB 次高优先级渠道 | |||||
| ``` | |||||
| ### 组切换逻辑 | |||||
| `service/channel_select.go:106-154`: | |||||
| ```go | |||||
| for i := startGroupIndex; i < len(autoGroups); i++ { | |||||
| priorityRetry := param.GetRetry() | |||||
| if i > startGroupIndex { | |||||
| priorityRetry = 0 // 新组从最高优先级开始 | |||||
| } | |||||
| channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry) | |||||
| if channel == nil { | |||||
| // 当前组没有该模型/优先级的可用渠道,切换到下一组 | |||||
| common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1) | |||||
| param.SetRetry(0) | |||||
| continue | |||||
| } | |||||
| // 跨组重试:当前组重试次数耗尽,准备切换 | |||||
| if crossGroupRetry && priorityRetry >= common.RetryTimes { | |||||
| common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1) | |||||
| param.SetRetry(0) | |||||
| param.ResetRetryNextTry() // 防止外层循环再 +1 | |||||
| } | |||||
| break | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## 8. 渠道亲和性与重试 | |||||
| ### 亲和性原理 | |||||
| 渠道亲和性(Channel Affinity)是一种缓存机制:对于相同用户+模型的请求,优先使用上次成功的渠道,以保持会话连续性(如 Claude Code 的对话上下文)。 | |||||
| ### 对重试的影响 | |||||
| `service/channel_affinity.go:493-506`: | |||||
| 当亲和性选择的渠道请求失败时,可能**跳过重试**: | |||||
| ``` | |||||
| ShouldSkipRetryAfterChannelAffinityFailure(c) | |||||
| → 检查 ginKeyChannelAffinitySkipRetry 标记 | |||||
| → true = 不重试(直接返回错误) | |||||
| → false = 正常重试(默认行为) | |||||
| ``` | |||||
| ### 亲和性标记设置 | |||||
| `service/channel_affinity.go:508-532`: | |||||
| ```go | |||||
| func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int) { | |||||
| // ... | |||||
| c.Set(ginKeyChannelAffinitySkipRetry, meta.SkipRetry) | |||||
| // ... | |||||
| } | |||||
| ``` | |||||
| 标记值来自亲和性规则的 `SkipRetryOnFailure` 字段。 | |||||
| ### 默认规则 | |||||
| | 规则名 | 匹配模型 | 端点 | 亲和性 Key | SkipRetryOnFailure | | |||||
| |--------|---------|------|-----------|:---:| | |||||
| | codex trace | `^gpt-.*$` | `/v1/responses` | `prompt_cache_key` | **false** | | |||||
| | claude code trace | `^claude-.*$` | `/v1/messages` | `metadata.user_id` | **false** | | |||||
| 默认两条规则都**不阻止重试**。只有管理员创建了 `SkipRetryOnFailure=true` 的规则时,亲和性失败才会跳过重试。 | |||||
| ### 亲和性记录时机 | |||||
| - **记录**:请求成功(status < 400)后,`middleware/distributor.go:135` 调用 `RecordChannelAffinity()` | |||||
| - **查询**:初始渠道选择时,`distributor.go:86-107` 调用 `GetPreferredChannelByAffinity()` | |||||
| --- | |||||
| ## 9. 计费与退款 | |||||
| ### 计费生命周期 | |||||
| ``` | |||||
| PreConsumeBilling() ← 预扣费(retry 循环之前) | |||||
| │ | |||||
| ├─ Retry 0: 尝试请求 ─── 成功 → Settle() 结算 → 结束 | |||||
| │ └─ 失败 → 继续重试 | |||||
| ├─ Retry 1: 尝试请求 ─── 成功 → Settle() 结算 → 结束 | |||||
| │ └─ 失败 → 继续重试 | |||||
| ├─ ... | |||||
| │ | |||||
| └─ 全部失败 → BillingSession.Refund() ← defer 函数,只执行一次 | |||||
| ``` | |||||
| ### 关键特性 | |||||
| 1. **预扣费只一次**:在 retry 循环开始前执行 `PreConsumeBilling()`(`controller/relay.go:164`) | |||||
| 2. **多次重试复用同一 BillingSession**:不重复扣费 | |||||
| 3. **退款只一次**:通过 `defer` 函数(`controller/relay.go:170-179`),仅在 `newAPIError != nil` 时触发 | |||||
| 4. **退款幂等**:`BillingSession.Refund()` 检查 `settled`、`refunded`、`fundingSettled` 标记 | |||||
| 5. **异步执行**:退款操作通过 `gopool.Go()` 异步执行 | |||||
| ### 退款流程代码 | |||||
| ```go | |||||
| defer func() { | |||||
| if newAPIError != nil { | |||||
| newAPIError = service.NormalizeViolationFeeError(newAPIError) | |||||
| if relayInfo.Billing != nil { | |||||
| relayInfo.Billing.Refund(c) // 退还预扣额度 | |||||
| } | |||||
| service.ChargeViolationFeeIfNeeded(c, relayInfo, newAPIError) | |||||
| } | |||||
| }() | |||||
| ``` | |||||
| ### 信任用户优化 | |||||
| 高余额用户(Trust Quota Bypass)跳过预扣费,`preConsumedQuota` 为 0,直接在 Settle 时扣费。 | |||||
| --- | |||||
| ## 10. 自动禁用渠道 | |||||
| ### 触发时机 | |||||
| 每次 relay 失败后调用 `processChannelError()`(`controller/relay.go:229`),其中包含自动禁用逻辑。 | |||||
| ### 判断条件 | |||||
| `service/channel.go:47-102` `ShouldDisableChannel()`: | |||||
| | 条件 | 说明 | | |||||
| |------|------| | |||||
| | `AutomaticDisableChannelEnabled = false` | 全局开关关闭则不触发 | | |||||
| | `IsChannelError(err)` | `channel:*` 错误码,始终触发 | | |||||
| | `ShouldDisableByStatusCode(code)` | 默认 401 触发 | | |||||
| | 403 + Gemini 渠道 | Gemini 特有 | | |||||
| | 错误码匹配 | `invalid_api_key`、`account_deactivated`、`billing_not_active`、`Arrearage` 等 | | |||||
| | 错误类型匹配 | `insufficient_quota`、`authentication_error`、`permission_error`、`forbidden` | | |||||
| | 关键词匹配 | `AutomaticDisableKeywords` 中的关键词 | | |||||
| ### 执行方式 | |||||
| ```go | |||||
| if service.ShouldDisableChannel(channelType, err) && channelError.AutoBan { | |||||
| gopool.Go(func() { | |||||
| service.DisableChannel(channelError, err.ErrorWithStatusCode()) | |||||
| }) | |||||
| } | |||||
| ``` | |||||
| - **异步执行**:通过 `gopool.Go()` 不阻塞请求处理 | |||||
| - **AutoBan 控制**:每个渠道可配置是否允许自动禁用(`auto_ban` 字段) | |||||
| - **自动恢复**:当 `AutomaticEnableChannelEnabled=true` 时,渠道后续请求成功会自动重新启用 | |||||
| ### 自动禁用状态码范围 | |||||
| ```go | |||||
| AutomaticDisableStatusCodeRanges = []StatusCodeRange{{Start: 401, End: 401}} | |||||
| ``` | |||||
| 默认只有 401(未授权)触发自动禁用。可自定义。 | |||||
| --- | |||||
| ## 11. Task Relay 重试 | |||||
| ### 适用场景 | |||||
| 异步任务提交(Midjourney、Suno 等非实时请求),使用 `RelayTask()` 函数(`controller/relay.go:476`)。 | |||||
| ### 独立的重试判断 | |||||
| `shouldRetryTaskRelay()`(`controller/relay.go:604-641`)与主 `shouldRetry()` 的区别: | |||||
| | 条件 | shouldRetry() | shouldRetryTaskRelay() | | |||||
| |------|:---:|:---:| | |||||
| | channel error | 必重试 | 不检查 | | |||||
| | skip-retry 标记 | 不重试 | 不检查 | | |||||
| | 429 | 按状态码范围判断 | **必重试** | | |||||
| | 307 | 按状态码范围判断 | **必重试** | | |||||
| | 5xx | 按状态码范围判断 | 重试(排除 504/524) | | |||||
| | 400 | 不在默认范围,不重试 | **明确不重试** | | |||||
| | 408 | 不在默认范围,不重试 | **明确不重试**(Azure 超时) | | |||||
| | LocalError | 不适用 | **不重试** | | |||||
| | 渠道亲和性 | 检查 | 检查 | | |||||
| | 2xx | 不重试 | 不重试 | | |||||
| | 其他 | 委托 ShouldRetryByStatusCode | **重试**(兜底) | | |||||
| ### Locked Channel | |||||
| Task Relay 支持 `LockedChannel`:如果 relayInfo 中有锁定的渠道,所有重试都使用该渠道(不切换)。 | |||||
| --- | |||||
| ## 12. RetryParam 状态管理 | |||||
| ### 结构体定义 | |||||
| `service/channel_select.go:14-20`: | |||||
| ```go | |||||
| type RetryParam struct { | |||||
| Ctx *gin.Context | |||||
| TokenGroup string | |||||
| ModelName string | |||||
| Retry *int // 当前重试计数 | |||||
| resetNextTry bool // 标记下一次 IncreaseRetry 是否跳过 | |||||
| } | |||||
| ``` | |||||
| ### 方法 | |||||
| | 方法 | 说明 | | |||||
| |------|------| | |||||
| | `GetRetry() int` | 返回当前 retry 值,nil 时返回 0 | | |||||
| | `SetRetry(retry int)` | 直接设置 retry 值 | | |||||
| | `IncreaseRetry()` | retry + 1;若 `resetNextTry=true` 则跳过并重置标记 | | |||||
| | `ResetRetryNextTry()` | 设置 `resetNextTry=true` | | |||||
| ### resetNextTry 机制 | |||||
| 用于 Auto-Group 跨组重试时的状态管理,防止 retry 计数器被双重递增: | |||||
| ``` | |||||
| 场景:GroupA 重试用完,准备切换到 GroupB | |||||
| 1. CacheGetRandomSatisfiedChannel 检测到 priorityRetry >= RetryTimes | |||||
| 2. 调用 param.SetRetry(0) ← 重置计数器 | |||||
| 3. 调用 param.ResetRetryNextTry() ← 标记下次跳过 | |||||
| 4. 外层 for 循环调用 IncreaseRetry() | |||||
| 5. 检测到 resetNextTry=true → 跳过递增,重置标记 | |||||
| 6. 结果:retry 保持为 0,GroupB 从最高优先级开始 | |||||
| ``` | |||||
| 如果不跳过,retry 会从 0 变成 1,导致 GroupB 直接从次高优先级开始,跳过了最高优先级的渠道。 | |||||
| ### Context Key | |||||
| `constant/context_key.go`: | |||||
| ```go | |||||
| ContextKeyAutoGroup // 当前 auto-group 名称 | |||||
| ContextKeyAutoGroupIndex // 当前组索引 | |||||
| ContextKeyAutoGroupRetryIndex // 当前组开始时的全局 retry 数 | |||||
| ContextKeyTokenCrossGroupRetry // 是否启用跨组重试 | |||||
| ``` | |||||
| --- | |||||
| ## 附录:重试链路追踪 | |||||
| ### 日志 | |||||
| 重试过程中使用了多个渠道时,循环结束后会打印重试链路: | |||||
| ```go | |||||
| useChannel := c.GetStringSlice("use_channel") | |||||
| if len(useChannel) > 1 { | |||||
| retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(...)) | |||||
| logger.LogInfo(c, retryLogStr) | |||||
| } | |||||
| ``` | |||||
| 输出示例:`重试:5->12->8`(渠道 ID 5 失败后重试到 12,再到 8)。 | |||||
| ### 错误日志 | |||||
| 每次失败的渠道信息都会记录到数据库 `logs` 表(需开启 `ErrorLogEnabled`),包含: | |||||
| - 渠道 ID、名称、类型 | |||||
| - 状态码、错误类型、错误码 | |||||
| - 上游请求 ID | |||||
| - `use_channel` 数组(重试链路) | |||||
| - 渠道亲和性信息 | |||||
| @@ -0,0 +1,12 @@ | |||||
| | Commit | 问题 | 位置 | 影响 | | |||||
| |---|---|---|---| | |||||
| | `1cc75aa9` / `8bb2883d` | 法律页路由重复,`Terms` / `UsagePolicy` 不可达 | `web/src/App.jsx:359-372` | 页面实际不会渲染新组件 | | |||||
| | `67e78f2c` | Metrics label 全为空字符串 | `common/metrics/middleware.go:14-22` | 指标维度丢失 | | |||||
| | `123f0d95` | `/metrics` 直接暴露,无鉴权 | `router/relay-router.go:23-25` | 运维数据可被未授权访问 | | |||||
| | `1912dd3b` | 验证码只保护发邮箱验证码,不保护注册本身 | `controller/user.go:133-161` | 开关语义不一致,注册可绕过 | | |||||
| | `f7ff684d` | `formatDiscount()` 对 `0.95` 返回空串 | `controller/pricing.go:176-186` | 折扣显示异常 | | |||||
| | `967309fb` | 邮箱后缀规则大小写不一致导致重复/覆盖 | `model/email_quota_rule.go:10-18, 25-55, 126-132` | DB 可存重复规则,缓存会互相覆盖 | | |||||
| | `714d8239` | `capture_relay` 明文落盘请求头/body | `middleware/relay_capture.go:101-127` | API Key / prompt / 上传内容泄露风险 | | |||||
| | `714d8239` | 非 Redis 模式下用户模型 RPM 可能沿用旧值 | `middleware/user-model-rate-limit.go:25-55`, `controller/user_rate_limit.go:58-66` | 配置更新后短时间不生效 | | |||||
| | `79f8a18c` | login widget 仍依赖 `credentials: 'include'` | `web/widget/index.js:26-30` | 仍受跨站 Cookie 限制 | | |||||
| | `54573991` / `49c67556` | 头部导航 `docs/about` 是死配置 | `web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx:220-237`, `web/src/hooks/common/useNavigation.js:33-70` | 后台能配,前台不生效 | | |||||
| @@ -0,0 +1,23 @@ | |||||
| { | |||||
| "contents": { | |||||
| "role": "USER", | |||||
| "parts": [ | |||||
| { | |||||
| "text": "Create a Tom and Jerry Poster." | |||||
| } | |||||
| ] | |||||
| }, | |||||
| "generationConfig": { | |||||
| "responseModalities": [ | |||||
| "IMAGE" | |||||
| ], | |||||
| "imageConfig": { | |||||
| "aspectRatio": "16:9" | |||||
| } | |||||
| }, | |||||
| "safetySettings": { | |||||
| "method": "PROBABILITY", | |||||
| "category": "HARM_CATEGORY_DANGEROUS_CONTENT", | |||||
| "threshold": "BLOCK_MEDIUM_AND_ABOVE" | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1 @@ | |||||
| {"contents":[{"role":"USER","parts":[{"text":"Create a Tom and Jerry Poster."}]}],"generationConfig":{"responseModalities":["IMAGE"],"imageConfig":{"aspectRatio":"16:9"}}} | |||||
| @@ -0,0 +1 @@ | |||||
| {"contents":{"role":"USER","parts":[{"text":"Create a Tom and Jerry Poster."}]},"generationConfig":{"responseModalities":["IMAGE"],"imageConfig":{"aspectRatio":"16:9"}}} | |||||
| @@ -0,0 +1 @@ | |||||
| {"contents":{"role":"USER","parts":[{"text":"Create a Tom and Jerry Poster."}]},"generationConfig":{"responseModalities":["IMAGE"],"imageConfig":{"aspectRatio":"16:9"}}} | |||||
| @@ -0,0 +1 @@ | |||||
| {"model":"google/gemini-3-pro-image-preview","messages":[{"role":"user","content":"Create a Tom and Jerry Poster."}],"max_tokens":256} | |||||
| @@ -69,7 +69,7 @@ func formatUserLogs(logs []*Log, startIdx int) { | |||||
| } | } | ||||
| func GetLogByTokenId(tokenId int) (logs []*Log, err error) { | func GetLogByTokenId(tokenId int) (logs []*Log, err error) { | ||||
| err = LOG_DB.Model(&Log{}).Where("token_id = ?", tokenId).Order("id desc").Limit(common.MaxRecentItems).Find(&logs).Error | |||||
| err = LOG_DB.Model(&Log{}).Where("token_id = ?", tokenId).Order("created_at desc, id desc").Limit(common.MaxRecentItems).Find(&logs).Error | |||||
| formatUserLogs(logs, 0) | formatUserLogs(logs, 0) | ||||
| return logs, err | return logs, err | ||||
| } | } | ||||
| @@ -291,11 +291,11 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName | |||||
| if group != "" { | if group != "" { | ||||
| tx = tx.Where("logs."+logGroupCol+" = ?", group) | tx = tx.Where("logs."+logGroupCol+" = ?", group) | ||||
| } | } | ||||
| err = tx.Model(&Log{}).Count(&total).Error | |||||
| err = tx.Model(&Log{}).Limit(logSearchCountLimit).Count(&total).Error | |||||
| if err != nil { | if err != nil { | ||||
| return nil, 0, err | return nil, 0, err | ||||
| } | } | ||||
| err = tx.Order("logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error | |||||
| err = tx.Order("logs.created_at desc, logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error | |||||
| if err != nil { | if err != nil { | ||||
| return nil, 0, err | return nil, 0, err | ||||
| } | } | ||||
| @@ -343,7 +343,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName | |||||
| return logs, total, err | return logs, total, err | ||||
| } | } | ||||
| const logSearchCountLimit = 10000 | |||||
| const logSearchCountLimit = 100000 | |||||
| func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string, chatId string, upstreamId string) (logs []*Log, total int64, err error) { | func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string, chatId string, upstreamId string) (logs []*Log, total int64, err error) { | ||||
| var tx *gorm.DB | var tx *gorm.DB | ||||
| @@ -386,7 +386,7 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int | |||||
| common.SysError("failed to count user logs: " + err.Error()) | common.SysError("failed to count user logs: " + err.Error()) | ||||
| return nil, 0, errors.New("查询日志失败") | return nil, 0, errors.New("查询日志失败") | ||||
| } | } | ||||
| err = tx.Order("logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error | |||||
| err = tx.Order("logs.created_at desc, logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error | |||||
| if err != nil { | if err != nil { | ||||
| common.SysError("failed to search user logs: " + err.Error()) | common.SysError("failed to search user logs: " + err.Error()) | ||||
| return nil, 0, errors.New("查询日志失败") | return nil, 0, errors.New("查询日志失败") | ||||
| @@ -19,6 +19,8 @@ import ( | |||||
| "github.com/QuantumNous/new-api/types" | "github.com/QuantumNous/new-api/types" | ||||
| "github.com/gin-gonic/gin" | "github.com/gin-gonic/gin" | ||||
| "github.com/tidwall/gjson" | |||||
| "github.com/tidwall/sjson" | |||||
| ) | ) | ||||
| func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { | func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { | ||||
| @@ -142,12 +144,13 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ | |||||
| // In passthrough mode, fix thinking.type in the raw JSON body. | // In passthrough mode, fix thinking.type in the raw JSON body. | ||||
| if request.Thinking != nil && request.Thinking.Type == "adaptive" { | if request.Thinking != nil && request.Thinking.Type == "adaptive" { | ||||
| bodyBytes, readErr := io.ReadAll(storage) | bodyBytes, readErr := io.ReadAll(storage) | ||||
| if readErr == nil { | |||||
| bodyBytes = bytes.Replace(bodyBytes, []byte(`"thinking":{"type":"enabled"`), []byte(`"thinking":{"type":"adaptive"`), 1) | |||||
| requestBody = bytes.NewBuffer(bodyBytes) | |||||
| } else { | |||||
| requestBody = bytes.NewBuffer(nil) | |||||
| if readErr != nil { | |||||
| return types.NewErrorWithStatusCode(readErr, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) | |||||
| } | |||||
| if gjson.GetBytes(bodyBytes, "thinking.type").String() == "enabled" { | |||||
| bodyBytes, _ = sjson.SetBytes(bodyBytes, "thinking.type", "adaptive") | |||||
| } | } | ||||
| requestBody = bytes.NewBuffer(bodyBytes) | |||||
| } else { | } else { | ||||
| requestBody = common.ReaderOnly(storage) | requestBody = common.ReaderOnly(storage) | ||||
| } | } | ||||
| @@ -0,0 +1 @@ | |||||
| import{e as v,c as O,g,k as P,h as p,j as w,l as c,m as A,n as x,t as N,o as E}from"./_baseUniq-CIM1KewH.js";import{b7 as b,aw as F,b8 as M,b9 as _,ba as $,bb as I,bc as B,bd as T,be as y,bf as S}from"./index-gPcw8Cof.js";var G=/\s/;function H(n){for(var r=n.length;r--&&G.test(n.charAt(r)););return r}var L=/^\s+/;function R(n){return n&&n.slice(0,H(n)+1).replace(L,"")}var m=NaN,q=/^[-+]0x[0-9a-f]+$/i,z=/^0b[01]+$/i,C=/^0o[0-7]+$/i,K=parseInt;function W(n){if(typeof n=="number")return n;if(v(n))return m;if(b(n)){var r=typeof n.valueOf=="function"?n.valueOf():n;n=b(r)?r+"":r}if(typeof n!="string")return n===0?n:+n;n=R(n);var t=z.test(n);return t||C.test(n)?K(n.slice(2),t?2:8):q.test(n)?m:+n}var o=1/0,X=17976931348623157e292;function Y(n){if(!n)return n===0?n:0;if(n=W(n),n===o||n===-o){var r=n<0?-1:1;return r*X}return n===n?n:0}function D(n){var r=Y(n),t=r%1;return r===r?t?r-t:r:0}function fn(n){var r=n==null?0:n.length;return r?O(n):[]}var l=Object.prototype,J=l.hasOwnProperty,dn=F(function(n,r){n=Object(n);var t=-1,i=r.length,e=i>2?r[2]:void 0;for(e&&M(r[0],r[1],e)&&(i=1);++t<i;)for(var f=r[t],a=_(f),s=-1,d=a.length;++s<d;){var u=a[s],h=n[u];(h===void 0||$(h,l[u])&&!J.call(n,u))&&(n[u]=f[u])}return n});function un(n){var r=n==null?0:n.length;return r?n[r-1]:void 0}function Q(n){return function(r,t,i){var e=Object(r);if(!I(r)){var f=g(t);r=P(r),t=function(s){return f(e[s],s,e)}}var a=n(r,t,i);return a>-1?e[f?r[a]:a]:void 0}}var U=Math.max;function Z(n,r,t){var i=n==null?0:n.length;if(!i)return-1;var e=t==null?0:D(t);return e<0&&(e=U(i+e,0)),p(n,g(r),e)}var hn=Q(Z);function V(n,r){var t=-1,i=I(n)?Array(n.length):[];return w(n,function(e,f,a){i[++t]=r(e,f,a)}),i}function bn(n,r){var t=B(n)?c:V;return t(n,g(r))}var j=Object.prototype,k=j.hasOwnProperty;function nn(n,r){return n!=null&&k.call(n,r)}function gn(n,r){return n!=null&&A(n,r,nn)}function rn(n,r){return n<r}function tn(n,r,t){for(var i=-1,e=n.length;++i<e;){var f=n[i],a=r(f);if(a!=null&&(s===void 0?a===a&&!v(a):t(a,s)))var s=a,d=f}return d}function mn(n){return n&&n.length?tn(n,T,rn):void 0}function en(n,r,t,i){if(!b(n))return n;r=x(r,n);for(var e=-1,f=r.length,a=f-1,s=n;s!=null&&++e<f;){var d=N(r[e]),u=t;if(d==="__proto__"||d==="constructor"||d==="prototype")return n;if(e!=a){var h=s[d];u=void 0,u===void 0&&(u=b(h)?h:y(r[e+1])?[]:{})}S(s,d,u),s=s[d]}return n}function on(n,r,t){for(var i=-1,e=r.length,f={};++i<e;){var a=r[i],s=E(n,a);t(s,a)&&en(f,x(a,n),s)}return f}export{rn as a,tn as b,V as c,on as d,mn as e,fn as f,hn as g,gn as h,dn as i,D as j,un as l,bn as m,Y as t}; | |||||
| @@ -0,0 +1 @@ | |||||
| import{a3 as ln,a4 as an,a5 as g,a6 as tn,a7 as H,a8 as q,a9 as _,aa as un,ab as rn,ac as L,ad as o,ae as B,af as sn,ag as on,ah as fn}from"./index-gPcw8Cof.js";function cn(l){return l.innerRadius}function gn(l){return l.outerRadius}function yn(l){return l.startAngle}function dn(l){return l.endAngle}function mn(l){return l&&l.padAngle}function pn(l,h,I,D,v,A,C,a){var O=I-l,i=D-h,n=C-v,d=a-A,u=d*O-n*i;if(!(u*u<g))return u=(n*(h-A)-d*(l-v))/u,[l+u*O,h+u*i]}function W(l,h,I,D,v,A,C){var a=l-I,O=h-D,i=(C?A:-A)/L(a*a+O*O),n=i*O,d=-i*a,u=l+n,s=h+d,f=I+n,c=D+d,F=(u+f)/2,t=(s+c)/2,m=f-u,y=c-s,R=m*m+y*y,T=v-A,P=u*c-f*s,S=(y<0?-1:1)*L(fn(0,T*T*R-P*P)),j=(P*y-m*S)/R,z=(-P*m-y*S)/R,w=(P*y+m*S)/R,p=(-P*m+y*S)/R,x=j-F,e=z-t,r=w-F,G=p-t;return x*x+e*e>r*r+G*G&&(j=w,z=p),{cx:j,cy:z,x01:-n,y01:-d,x11:j*(v/T-1),y11:z*(v/T-1)}}function hn(){var l=cn,h=gn,I=B(0),D=null,v=yn,A=dn,C=mn,a=null,O=ln(i);function i(){var n,d,u=+l.apply(this,arguments),s=+h.apply(this,arguments),f=v.apply(this,arguments)-an,c=A.apply(this,arguments)-an,F=un(c-f),t=c>f;if(a||(a=n=O()),s<u&&(d=s,s=u,u=d),!(s>g))a.moveTo(0,0);else if(F>tn-g)a.moveTo(s*H(f),s*q(f)),a.arc(0,0,s,f,c,!t),u>g&&(a.moveTo(u*H(c),u*q(c)),a.arc(0,0,u,c,f,t));else{var m=f,y=c,R=f,T=c,P=F,S=F,j=C.apply(this,arguments)/2,z=j>g&&(D?+D.apply(this,arguments):L(u*u+s*s)),w=_(un(s-u)/2,+I.apply(this,arguments)),p=w,x=w,e,r;if(z>g){var G=sn(z/u*q(j)),M=sn(z/s*q(j));(P-=G*2)>g?(G*=t?1:-1,R+=G,T-=G):(P=0,R=T=(f+c)/2),(S-=M*2)>g?(M*=t?1:-1,m+=M,y-=M):(S=0,m=y=(f+c)/2)}var J=s*H(m),K=s*q(m),N=u*H(T),Q=u*q(T);if(w>g){var U=s*H(y),V=s*q(y),X=u*H(R),Y=u*q(R),E;if(F<rn)if(E=pn(J,K,X,Y,U,V,N,Q)){var Z=J-E[0],$=K-E[1],b=U-E[0],k=V-E[1],nn=1/q(on((Z*b+$*k)/(L(Z*Z+$*$)*L(b*b+k*k)))/2),en=L(E[0]*E[0]+E[1]*E[1]);p=_(w,(u-en)/(nn-1)),x=_(w,(s-en)/(nn+1))}else p=x=0}S>g?x>g?(e=W(X,Y,J,K,s,x,t),r=W(U,V,N,Q,s,x,t),a.moveTo(e.cx+e.x01,e.cy+e.y01),x<w?a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,s,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),!t),a.arc(r.cx,r.cy,x,o(r.y11,r.x11),o(r.y01,r.x01),!t))):(a.moveTo(J,K),a.arc(0,0,s,m,y,!t)):a.moveTo(J,K),!(u>g)||!(P>g)?a.lineTo(N,Q):p>g?(e=W(N,Q,U,V,u,-p,t),r=W(J,K,X,Y,u,-p,t),a.lineTo(e.cx+e.x01,e.cy+e.y01),p<w?a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,u,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),t),a.arc(r.cx,r.cy,p,o(r.y11,r.x11),o(r.y01,r.x01),!t))):a.arc(0,0,u,T,R,t)}if(a.closePath(),n)return a=null,n+""||null}return i.centroid=function(){var n=(+l.apply(this,arguments)+ +h.apply(this,arguments))/2,d=(+v.apply(this,arguments)+ +A.apply(this,arguments))/2-rn/2;return[H(d)*n,q(d)*n]},i.innerRadius=function(n){return arguments.length?(l=typeof n=="function"?n:B(+n),i):l},i.outerRadius=function(n){return arguments.length?(h=typeof n=="function"?n:B(+n),i):h},i.cornerRadius=function(n){return arguments.length?(I=typeof n=="function"?n:B(+n),i):I},i.padRadius=function(n){return arguments.length?(D=n==null?null:typeof n=="function"?n:B(+n),i):D},i.startAngle=function(n){return arguments.length?(v=typeof n=="function"?n:B(+n),i):v},i.endAngle=function(n){return arguments.length?(A=typeof n=="function"?n:B(+n),i):A},i.padAngle=function(n){return arguments.length?(C=typeof n=="function"?n:B(+n),i):C},i.context=function(n){return arguments.length?(a=n??null,i):a},i}export{hn as d}; | |||||
| @@ -0,0 +1 @@ | |||||
| import{at as o,au as n}from"./index-gPcw8Cof.js";const t=(a,r)=>o.lang.round(n.parse(a)[r]);export{t as c}; | |||||
| @@ -0,0 +1 @@ | |||||
| import{_ as l}from"./index-gPcw8Cof.js";function m(e,c){var i,t,o;e.accDescr&&((i=c.setAccDescription)==null||i.call(c,e.accDescr)),e.accTitle&&((t=c.setAccTitle)==null||t.call(c,e.accTitle)),e.title&&((o=c.setDiagramTitle)==null||o.call(c,e.title))}l(m,"populateCommonDb");export{m as p}; | |||||
| @@ -0,0 +1 @@ | |||||
| import{_ as a,e}from"./index-gPcw8Cof.js";var g=a((o,t)=>{let n;return t==="sandbox"&&(n=e("#i"+o)),(t==="sandbox"?e(n.nodes()[0].contentDocument.body):e("body")).select(`[id="${o}"]`)},"getDiagramElement");export{g}; | |||||
| @@ -0,0 +1,15 @@ | |||||
| import{_ as e}from"./index-gPcw8Cof.js";var l=e(()=>` | |||||
| /* Font Awesome icon styling - consolidated */ | |||||
| .label-icon { | |||||
| display: inline-block; | |||||
| height: 1em; | |||||
| overflow: visible; | |||||
| vertical-align: -0.125em; | |||||
| } | |||||
| .node .label-icon path { | |||||
| fill: currentColor; | |||||
| stroke: revert; | |||||
| stroke-width: revert; | |||||
| } | |||||
| `,"getIconStyles");export{l as g}; | |||||
| @@ -0,0 +1 @@ | |||||
| import{_ as n,e as l,X as d,k as o}from"./index-gPcw8Cof.js";var x=n((r,t)=>{const e=r.append("rect");if(e.attr("x",t.x),e.attr("y",t.y),e.attr("fill",t.fill),e.attr("stroke",t.stroke),e.attr("width",t.width),e.attr("height",t.height),t.name&&e.attr("name",t.name),t.rx&&e.attr("rx",t.rx),t.ry&&e.attr("ry",t.ry),t.attrs!==void 0)for(const s in t.attrs)e.attr(s,t.attrs[s]);return t.class&&e.attr("class",t.class),e},"drawRect"),p=n((r,t)=>{const e={x:t.startx,y:t.starty,width:t.stopx-t.startx,height:t.stopy-t.starty,fill:t.fill,stroke:t.stroke,class:"rect"};x(r,e).lower()},"drawBackgroundRect"),y=n((r,t)=>{const e=t.text.replace(d," "),s=r.append("text");s.attr("x",t.x),s.attr("y",t.y),s.attr("class","legend"),s.style("text-anchor",t.anchor),t.class&&s.attr("class",t.class);const a=s.append("tspan");return a.attr("x",t.x+t.textMargin*2),a.text(e),s},"drawText"),m=n((r,t,e,s)=>{const a=r.append("image");a.attr("x",t),a.attr("y",e);const i=o(s);a.attr("xlink:href",i)},"drawImage"),g=n((r,t,e,s)=>{const a=r.append("use");a.attr("x",t),a.attr("y",e);const i=o(s);a.attr("xlink:href",`#${i}`)},"drawEmbeddedImage"),h=n(()=>({x:0,y:0,width:100,height:100,fill:"#EDF2AE",stroke:"#666",anchor:"start",rx:0,ry:0}),"getNoteRect"),f=n(()=>({x:0,y:0,width:100,height:100,"text-anchor":"start",style:"#666",textMargin:0,rx:0,ry:0,tspan:!0}),"getTextObj"),w=n(()=>{let r=l(".mermaidTooltip");return r.empty()&&(r=l("body").append("div").attr("class","mermaidTooltip").style("opacity",0).style("position","absolute").style("text-align","center").style("max-width","200px").style("padding","2px").style("font-size","12px").style("background","#ffffde").style("border","1px solid #333").style("border-radius","2px").style("pointer-events","none").style("z-index","100")),r},"createTooltip");export{p as a,f as b,w as c,x as d,g as e,m as f,h as g,y as h}; | |||||
| @@ -0,0 +1 @@ | |||||
| import{_ as a,f as w,l as x}from"./index-gPcw8Cof.js";var d=a((e,t,i,o)=>{e.attr("class",i);const{width:r,height:h,x:n,y:c}=u(e,t);w(e,h,r,o);const s=l(n,c,r,h,t);e.attr("viewBox",s),x.debug(`viewBox configured: ${s} with padding: ${t}`)},"setupViewPortForSVG"),u=a((e,t)=>{var o;const i=((o=e.node())==null?void 0:o.getBBox())||{width:0,height:0,x:0,y:0};return{width:i.width+t*2,height:i.height+t*2,x:i.x,y:i.y}},"calculateDimensionsWithPadding"),l=a((e,t,i,o,r)=>`${e-r} ${t-r} ${i} ${o}`,"createViewBox");export{d as s}; | |||||