From e09542d26b9874bdffd18575d83ea2ec20c21e89 Mon Sep 17 00:00:00 2001 From: dofastted <278058887+dofastted@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:23:04 +0800 Subject: [PATCH 1/6] feat: route auto group by request path --- middleware/distributor.go | 15 ++++++++++++++ middleware/distributor_test.go | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 middleware/distributor_test.go diff --git a/middleware/distributor.go b/middleware/distributor.go index 258aebb57037..c03edeb675d9 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -101,6 +101,11 @@ func Distribute() func(c *gin.Context) { } } + if routedGroup, ok := autoGroupForRequestPath(usingGroup, c.Request.URL.Path); ok { + usingGroup = routedGroup + common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup) + } + if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { affinityUsable := false preferred, err := model.CacheGetChannel(preferredChannelID) @@ -167,6 +172,16 @@ func Distribute() func(c *gin.Context) { } } +func autoGroupForRequestPath(usingGroup string, requestPath string) (string, bool) { + if usingGroup != "auto" { + return usingGroup, false + } + if strings.Contains(requestPath, "/v1/chat/completions") { + return "codex-completions", true + } + return usingGroup, false +} + // getModelFromRequest 从请求中读取模型信息 // 根据 Content-Type 自动处理: // - application/json diff --git a/middleware/distributor_test.go b/middleware/distributor_test.go new file mode 100644 index 000000000000..4059cfbe67ea --- /dev/null +++ b/middleware/distributor_test.go @@ -0,0 +1,36 @@ +package middleware + +import "testing" + +func TestAutoGroupForRequestPathRoutesChatCompletions(t *testing.T) { + got, changed := autoGroupForRequestPath("auto", "/v1/chat/completions") + + if got != "codex-completions" { + t.Fatalf("expected codex-completions, got %q", got) + } + if !changed { + t.Fatal("expected chat completions path to change auto group") + } +} + +func TestAutoGroupForRequestPathKeepsResponsesAuto(t *testing.T) { + got, changed := autoGroupForRequestPath("auto", "/v1/responses") + + if got != "auto" { + t.Fatalf("expected auto, got %q", got) + } + if changed { + t.Fatal("expected responses path to keep auto group") + } +} + +func TestAutoGroupForRequestPathKeepsExplicitGroup(t *testing.T) { + got, changed := autoGroupForRequestPath("codex", "/v1/chat/completions") + + if got != "codex" { + t.Fatalf("expected explicit group, got %q", got) + } + if changed { + t.Fatal("expected explicit group to stay unchanged") + } +} From 003d7c0089a887d2e52247cfcf75c003bd579d54 Mon Sep 17 00:00:00 2001 From: dofastted <278058887+dofastted@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:32:44 +0800 Subject: [PATCH 2/6] fix: address auto route review feedback --- middleware/distributor.go | 2 +- middleware/distributor_test.go | 70 +++++++++++++++++++++------------- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/middleware/distributor.go b/middleware/distributor.go index c03edeb675d9..44a21e66f696 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -176,7 +176,7 @@ func autoGroupForRequestPath(usingGroup string, requestPath string) (string, boo if usingGroup != "auto" { return usingGroup, false } - if strings.Contains(requestPath, "/v1/chat/completions") { + if strings.HasPrefix(requestPath, "/v1/chat/completions") { return "codex-completions", true } return usingGroup, false diff --git a/middleware/distributor_test.go b/middleware/distributor_test.go index 4059cfbe67ea..fe3e20cc3bfc 100644 --- a/middleware/distributor_test.go +++ b/middleware/distributor_test.go @@ -1,36 +1,52 @@ package middleware -import "testing" +import ( + "testing" -func TestAutoGroupForRequestPathRoutesChatCompletions(t *testing.T) { - got, changed := autoGroupForRequestPath("auto", "/v1/chat/completions") + "github.com/stretchr/testify/require" +) - if got != "codex-completions" { - t.Fatalf("expected codex-completions, got %q", got) +func TestAutoGroupForRequestPath(t *testing.T) { + tests := []struct { + name string + usingGroup string + requestPath string + expectedGroup string + expectedChanged bool + }{ + { + name: "routes chat completions", + usingGroup: "auto", + requestPath: "/v1/chat/completions", + expectedGroup: "codex-completions", + expectedChanged: true, + }, + { + name: "keeps responses auto", + usingGroup: "auto", + requestPath: "/v1/responses", + expectedGroup: "auto", + }, + { + name: "keeps explicit group", + usingGroup: "codex", + requestPath: "/v1/chat/completions", + expectedGroup: "codex", + }, + { + name: "ignores embedded chat completions fragment", + usingGroup: "auto", + requestPath: "/proxy/v1/chat/completions", + expectedGroup: "auto", + }, } - if !changed { - t.Fatal("expected chat completions path to change auto group") - } -} - -func TestAutoGroupForRequestPathKeepsResponsesAuto(t *testing.T) { - got, changed := autoGroupForRequestPath("auto", "/v1/responses") - - if got != "auto" { - t.Fatalf("expected auto, got %q", got) - } - if changed { - t.Fatal("expected responses path to keep auto group") - } -} -func TestAutoGroupForRequestPathKeepsExplicitGroup(t *testing.T) { - got, changed := autoGroupForRequestPath("codex", "/v1/chat/completions") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, changed := autoGroupForRequestPath(tt.usingGroup, tt.requestPath) - if got != "codex" { - t.Fatalf("expected explicit group, got %q", got) - } - if changed { - t.Fatal("expected explicit group to stay unchanged") + require.Equal(t, tt.expectedGroup, got) + require.Equal(t, tt.expectedChanged, changed) + }) } } From 87e52e831afeda07a320943a7cc03e26ca32ccba Mon Sep 17 00:00:00 2001 From: dofastted <278058887+dofastted@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:41:39 +0800 Subject: [PATCH 3/6] fix: tighten auto route endpoint matching --- middleware/distributor.go | 2 +- middleware/distributor_test.go | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/middleware/distributor.go b/middleware/distributor.go index 44a21e66f696..73ce03e9041c 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -176,7 +176,7 @@ func autoGroupForRequestPath(usingGroup string, requestPath string) (string, boo if usingGroup != "auto" { return usingGroup, false } - if strings.HasPrefix(requestPath, "/v1/chat/completions") { + if requestPath == "/v1/chat/completions" || strings.HasPrefix(requestPath, "/v1/chat/completions/") { return "codex-completions", true } return usingGroup, false diff --git a/middleware/distributor_test.go b/middleware/distributor_test.go index fe3e20cc3bfc..6ef52e848492 100644 --- a/middleware/distributor_test.go +++ b/middleware/distributor_test.go @@ -3,7 +3,7 @@ package middleware import ( "testing" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" ) func TestAutoGroupForRequestPath(t *testing.T) { @@ -39,14 +39,20 @@ func TestAutoGroupForRequestPath(t *testing.T) { requestPath: "/proxy/v1/chat/completions", expectedGroup: "auto", }, + { + name: "ignores similar chat completions prefix", + usingGroup: "auto", + requestPath: "/v1/chat/completions-extra", + expectedGroup: "auto", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, changed := autoGroupForRequestPath(tt.usingGroup, tt.requestPath) - require.Equal(t, tt.expectedGroup, got) - require.Equal(t, tt.expectedChanged, changed) + assert.Equal(t, tt.expectedGroup, got) + assert.Equal(t, tt.expectedChanged, changed) }) } } From 35e302a569c5b1bd6ad014f4c478630a6da0bdb3 Mon Sep 17 00:00:00 2001 From: dofastted <278058887+dofastted@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:13:02 +0800 Subject: [PATCH 4/6] feat: add custom landing page and safety docs --- docs/input_safety_handoff.md | 549 +++++++++++++++++ docs/input_safety_rules.md | 1106 ++++++++++++++++++++++++++++++++++ main.go | 6 + router/web-router.go | 6 + web/custom/home.html | 204 +++++++ 5 files changed, 1871 insertions(+) create mode 100644 docs/input_safety_handoff.md create mode 100644 docs/input_safety_rules.md create mode 100644 web/custom/home.html diff --git a/docs/input_safety_handoff.md b/docs/input_safety_handoff.md new file mode 100644 index 000000000000..f6aac6877a04 --- /dev/null +++ b/docs/input_safety_handoff.md @@ -0,0 +1,549 @@ +# new-api 输入安全拦截配置与 Codex 交接说明 + +本文档给 VPS 上运行的 Codex 使用,用于在 new-api 中实现本地输入安全审查。目标是:不依赖 OpenAI moderation,直接在网关侧拦截用户请求中的 cyber abuse、NSFW、隐私窃取、诈骗等明显不合规输入。 + +相关规则详见:`docs/input_safety_rules.md`。 + +## 1. 是否可以由 VPS 上的 Codex 直接完成 + +可以。该功能是纯 Go 后端改动,不需要外部 SaaS 权限,也不需要 OpenAI 审查模型。 + +VPS Codex 需要具备: + +```text +仓库源码读写权限 +Go 工具链 +能运行目标 Go 测试 +能编辑环境变量或 docker-compose 配置 +``` + +不需要: + +```text +OpenAI API Key +数据库迁移 +前端改动 +第三方模型部署 +``` + +## 2. 目标行为 + +只审查用户可控输入: + +```text +/v1/chat/completions messages 中 role=user 的 content +/v1/completions prompt +/v1/responses input 中用户文本 +/v1/images/generations prompt +/v1/images/edits multipart form 的 prompt +/v1/messages Claude messages 中 role=user 的 content +/v1beta/models/* Gemini contents 中 role=user 或空 role 的 parts[].text +/v1/models/* Gemini contents 中 role=user 或空 role 的 parts[].text +``` + +不审查: + +```text +system prompt +developer prompt +assistant 历史输出 +tool 输出 +平台内部拼接内容 +模型响应内容 +``` + +命中高风险规则后,统一阻断请求并返回 OpenAI 兼容错误。 + +## 3. 对外返回格式 + +建议 HTTP 状态码:`400`。 + +```json +{ + "error": { + "message": "请求内容不符合输入安全规则,请修改 prompt 后重试。", + "type": "invalid_request_error", + "param": "messages[0].content", + "code": "input_safety_blocked" + } +} +``` + +说明: + +```text +message: 固定通用提示,不暴露关键词 +param: 被拦截的用户输入字段路径 +code: 固定 input_safety_blocked +type: 使用 invalid_request_error,保持 OpenAI 兼容风格 +``` + +不要对外返回: + +```text +rule_id +命中关键词 +score +正则表达式 +内部分类细节 +``` + +内部日志可以记录: + +```text +request_id +user_id +ip_hash +endpoint +model +param +category +rule_id +score +action +request_hash +``` + +## 4. 推荐配置项 + +使用环境变量,避免新增数据库迁移。 + +```text +INPUT_REVIEW_ENABLED=false +INPUT_REVIEW_MODE=log +INPUT_REVIEW_BLOCK_SCORE=40 +INPUT_REVIEW_REVIEW_SCORE=20 +INPUT_REVIEW_MAX_CHARS=8000 +INPUT_REVIEW_RETURN_MESSAGE=请求内容不符合输入安全规则,请修改 prompt 后重试。 +``` + +配置含义: + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `INPUT_REVIEW_ENABLED` | `false` | 是否启用输入安全审查 | +| `INPUT_REVIEW_MODE` | `log` | `log` 只记录不阻断;`block` 阻断高风险请求 | +| `INPUT_REVIEW_BLOCK_SCORE` | `40` | 达到该分数阻断 | +| `INPUT_REVIEW_REVIEW_SCORE` | `20` | 达到该分数记录为中风险 | +| `INPUT_REVIEW_MAX_CHARS` | `8000` | 单段输入最多审查字符数,超出按高风险处理或截断后审查 | +| `INPUT_REVIEW_RETURN_MESSAGE` | 中文默认提示 | 对外返回 message | + +上线建议: + +```text +第一阶段:INPUT_REVIEW_ENABLED=true, INPUT_REVIEW_MODE=log +第二阶段:观察误杀后切 INPUT_REVIEW_MODE=block +``` + +## 5. 推荐代码结构 + +新增文件: + +```text +middleware/input_safety.go +middleware/input_safety_rules.go +middleware/input_safety_test.go +``` + +可选新增文件: + +```text +middleware/input_safety_extract.go +``` + +不要新增前端文件。 +不要新增数据库表。 +不要修改规则文档中的项目保护信息。 + +## 6. 接入点 + +当前 relay 路由入口:`router/relay-router.go`。 + +现有链路: + +```go +relayV1Router := router.Group("/v1") +relayV1Router.Use(middleware.RouteTag("relay")) +relayV1Router.Use(middleware.SystemPerformanceCheck()) +relayV1Router.Use(middleware.TokenAuth()) +relayV1Router.Use(middleware.ModelRequestRateLimit()) + +httpRouter := relayV1Router.Group("") +httpRouter.Use(middleware.Distribute()) +``` + +建议把输入审查放在 `Distribute()` 之前: + +```go +httpRouter := relayV1Router.Group("") +httpRouter.Use(middleware.InputSafetyReview()) +httpRouter.Use(middleware.Distribute()) +``` + +原因: + +```text +先拦截,再分发渠道,避免为违规请求做渠道选择和上游准备 +TokenAuth 已完成,可记录 user_id +ModelRequestRateLimit 已完成,可保留现有限流语义 +common.GetRequestBody / common.UnmarshalBodyReusable 会缓存并复位请求体,后续 Distribute 和 Relay 仍可读取 +``` + +注意:`/v1/realtime` 是 WebSocket,不在本次范围。 + +## 7. 请求体读取要求 + +必须复用项目现有工具: + +```go +common.UnmarshalBodyReusable(c, &request) +common.ParseMultipartFormReusable(c) +``` + +不要直接使用: + +```go +encoding/json.Unmarshal +json.NewDecoder +io.ReadAll(c.Request.Body) 后不复位 +``` + +项目规则要求 JSON marshal/unmarshal 使用 `common/json.go` 包装函数。 + +## 8. 提取策略 + +### 8.1 OpenAI Chat Completions + +最小结构: + +```go +type inputSafetyOpenAIRequest struct { + Messages []struct { + Role string `json:"role"` + Content any `json:"content"` + } `json:"messages"` + Prompt any `json:"prompt"` + Input any `json:"input"` +} +``` + +提取: + +```text +messages[i].role == "user" -> content +prompt -> prompt +input -> input +``` + +Content 可能是: + +```text +string +[]object,其中 type == "text" 的 text 字段 +``` + +### 8.2 Responses API + +`input` 可能是: + +```text +string +array,包含 role/content +array,包含 type=input_text 的 text +``` + +只提取用户输入文本。 + +### 8.3 Images + +JSON: + +```text +prompt +``` + +multipart: + +```text +form.Value["prompt"] +``` + +### 8.4 Claude + +提取: + +```text +messages[i].role == "user" 的 content +``` + +content 可能是 string 或 blocks。只取 text block。 + +### 8.5 Gemini + +提取: + +```text +contents[i].role == "user" 或 role == "" 的 parts[j].text +``` + +不提取: + +```text +systemInstruction +functionCall +functionResponse +inlineData +``` + +## 9. 规则引擎要求 + +第一版用内置规则,不读取外部文件,降低部署复杂度。 + +建议结构: + +```go +type inputSafetyRule struct { + ID string + Category string + Score int + Any []string + All [][]string +} + +type inputSafetyFinding struct { + Param string + Category string + RuleID string + Score int +} +``` + +匹配方式: + +```text +Any: 任一关键词命中即加分 +All: 每个组合组都至少命中一个词才加分 +``` + +例如: + +```text +[窃取|盗取|steal|dump] + [cookie|token|密码|凭据] +``` + +强制 block 规则: + +```text +NSFW_SEXUAL_MINORS_001 +NSFW_NON_CONSENSUAL_001 +CYBER_CREDENTIAL_THEFT_001 +CYBER_MALWARE_001 +CYBER_EVASION_001 +CYBER_PHISHING_001 +CYBER_PAYMENT_FRAUD_001 +``` + +规则来源:`docs/input_safety_rules.md`。 + +## 10. 文本归一化 + +实现函数: + +```go +func normalizeInputSafetyText(s string) string +``` + +至少处理: + +```text +strings.ToLower +strings.TrimSpace +连续空白合并 +移除零宽字符:\u200b、\u200c、\u200d、\ufeff +全角 ASCII 转半角 +URL QueryUnescape 一次,失败则保留原文 +``` + +不要做昂贵或不确定的深度解码。 +不要递归 base64 解码。 + +## 11. 错误构造 + +项目已有: + +```go +types.OpenAIError +``` + +字段: + +```go +type OpenAIError struct { + Message string `json:"message"` + Type string `json:"type"` + Param string `json:"param"` + Code any `json:"code"` +} +``` + +中间件里可直接返回: + +```go +c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "error": types.OpenAIError{ + Message: inputSafetyReturnMessage(), + Type: "invalid_request_error", + Param: finding.Param, + Code: "input_safety_blocked", + }, +}) +``` + +如果后续希望纳入 `types.ErrorCode`,可新增: + +```go +ErrorCodeInputSafetyBlocked ErrorCode = "input_safety_blocked" +``` + +但第一版中间件直接返回即可,改动更小。 + +## 12. 日志要求 + +使用项目 logger。日志不要包含完整原文。 + +建议记录: + +```text +input safety blocked param=messages[0].content category=cyber_abuse rule=CYBER_MALWARE_001 score=100 path=/v1/chat/completions +``` + +不要记录: + +```text +完整 prompt +完整 token +完整 cookie +完整 URL query 中的密钥 +``` + +如需要 request_hash,使用 SHA-256 对原始片段求 hash。 + +## 13. 测试要求 + +新增 `middleware/input_safety_test.go`。 + +必须覆盖: + +1. `INPUT_REVIEW_ENABLED=false` 时放行。 +2. `INPUT_REVIEW_MODE=log` 命中规则也放行。 +3. Chat Completions 中 `role=user` 命中 malware 组合时 400。 +4. Chat Completions 中 `system` 命中但 user 正常时放行。 +5. `prompt` 命中 NSFW 图片生成规则时 400。 +6. Claude user message 命中凭据窃取时 400。 +7. Gemini user part 命中钓鱼规则时 400。 +8. 返回体包含: + - `error.message` + - `error.type == "invalid_request_error"` + - `error.param` + - `error.code == "input_safety_blocked"` +9. 返回体不包含: + - `rule_id` + - `score` + - 具体关键词 + +测试只跑新增或相关测试即可。 + +## 14. 验证命令 + +在仓库根目录执行: + +```bash +go test ./middleware +``` + +如果改动触及 `router/relay-router.go`,再执行: + +```bash +go test ./router ./middleware +``` + +最终建议执行: + +```bash +go test ./relay/helper ./middleware +``` + +不要跑前端构建;本任务不涉及前端。 + +## 15. VPS 环境变量示例 + +Docker Compose 可添加: + +```yaml +environment: + - INPUT_REVIEW_ENABLED=true + - INPUT_REVIEW_MODE=block + - INPUT_REVIEW_BLOCK_SCORE=40 + - INPUT_REVIEW_REVIEW_SCORE=20 + - INPUT_REVIEW_MAX_CHARS=8000 + - INPUT_REVIEW_RETURN_MESSAGE=请求内容不符合输入安全规则,请修改 prompt 后重试。 +``` + +灰度期建议: + +```yaml +environment: + - INPUT_REVIEW_ENABLED=true + - INPUT_REVIEW_MODE=log +``` + +## 16. 给 VPS Codex 的执行提示词 + +可直接复制给服务器上的 Codex: + +```text +你在 new-api 仓库中工作。请实现本地输入安全审查,不依赖 OpenAI moderation。 + +必须先阅读: +- AGENTS.md +- docs/input_safety_rules.md +- docs/input_safety_handoff.md + +目标: +- 只审查用户输入字段:chat messages role=user content、prompt、responses input、image prompt、Claude role=user content、Gemini user contents parts text。 +- 不审查 system/developer/assistant/tool/model output。 +- 命中高风险 cyber abuse、NSFW、privacy、fraud 规则时,在 relay 前阻断。 +- 返回 OpenAI 兼容错误:type=invalid_request_error, code=input_safety_blocked, param=被拦截字段路径, message=请求内容不符合输入安全规则,请修改 prompt 后重试。 +- 不向用户返回 rule_id、score、关键词。 + +实现要求: +- 新增 middleware/input_safety.go、middleware/input_safety_rules.go、middleware/input_safety_test.go。 +- 在 router/relay-router.go 中把 middleware.InputSafetyReview() 加到 httpRouter 的 Distribute() 之前。 +- 使用 common.UnmarshalBodyReusable / common.ParseMultipartFormReusable 读取请求体,不能破坏后续读取。 +- JSON 解析必须使用 common 包装函数,不要直接调用 encoding/json 的 Marshal/Unmarshal。 +- 配置使用环境变量:INPUT_REVIEW_ENABLED、INPUT_REVIEW_MODE、INPUT_REVIEW_BLOCK_SCORE、INPUT_REVIEW_REVIEW_SCORE、INPUT_REVIEW_MAX_CHARS、INPUT_REVIEW_RETURN_MESSAGE。 +- 默认 INPUT_REVIEW_ENABLED=false,避免未配置时改变现有行为。 +- 日志不记录完整 prompt,只记录 param、category、rule_id、score、path。 + +测试: +- 添加 middleware/input_safety_test.go。 +- 覆盖 disabled 放行、log 模式放行、block 模式拦截、system 命中不拦截、OpenAI/Claude/Gemini/Image 提取、错误返回不泄露规则细节。 +- 运行 go test ./middleware。 +- 如果修改 router,运行 go test ./router ./middleware。 + +不要修改前端。不要新增数据库迁移。不要提交 git,除非用户另行要求。 +``` + +## 17. 完成标准 + +Codex 完成后应提供: + +```text +修改文件列表 +新增配置项 +命中的测试用例 +测试命令和结果 +是否需要重启服务 +``` + +服务端启用时,只需重启 new-api 进程或容器,使环境变量生效。 diff --git a/docs/input_safety_rules.md b/docs/input_safety_rules.md new file mode 100644 index 000000000000..4750980b1d78 --- /dev/null +++ b/docs/input_safety_rules.md @@ -0,0 +1,1106 @@ +# 输入安全拦截规则建议 + +本文档用于在网关侧部署本地输入安全规则。目标是只审查用户提交的 `input` / `prompt` / `user message` 内容,拦截明显不合规的 cyber abuse、NSFW、隐私窃取和诈骗类请求。 + +> 说明:本规则是硬限制与关键词组合方案,不等同于模型审查或法律合规结论。上线前建议先使用 `log` 模式观察误杀,再切换高置信规则为 `block`。 + +## 1. 拦截返回样式 + +符合项目现有 OpenAI 兼容错误格式。建议 HTTP 状态码使用 `400`。 + +```json +{ + "error": { + "message": "请求内容不符合输入安全规则,请修改 prompt 后重试。", + "type": "invalid_request_error", + "param": "input", + "code": "input_safety_blocked" + } +} +``` + +英文部署可使用: + +```json +{ + "error": { + "message": "Your request was blocked by the input safety policy. Please revise your prompt and try again.", + "type": "invalid_request_error", + "param": "input", + "code": "input_safety_blocked" + } +} +``` + +对外不要返回命中的规则 ID、关键词或分类,避免用户按提示绕过规则。 + +## 2. 审查范围 + +只审查用户可控输入。 + +| 接口类型 | 需要审查 | 不审查 | +| --- | --- | --- | +| Chat Completions | `messages[].role == "user"` 的 `content` | `system`、`developer`、`assistant`、`tool` | +| Responses | `input` 中用户文本;`role == "user"` 的内容 | 系统指令、平台注入内容、模型输出 | +| Completions | `prompt` | 内部拼接模板 | +| Images | `prompt` | 生成结果 | +| Claude | `role == "user"` 的文本内容 | `system`、assistant 历史 | +| Gemini | 用户 role 的文本内容 | system instruction、model 历史 | + +## 3. 预处理规则 + +匹配前建议执行: + +1. 转小写。 +2. 全角转半角。 +3. 合并连续空白字符。 +4. URL decode 一次。 +5. 移除零宽字符。 +6. 仅保存原文 hash,不默认保存原文。 +7. 超长 base64 或不可读大块文本直接按可疑输入处理。 + +建议限制: + +```text +INPUT_REVIEW_MAX_CHARS=8000 +INPUT_REVIEW_BLOCK_SCORE=40 +INPUT_REVIEW_REVIEW_SCORE=20 +INPUT_REVIEW_MODE=log|block +``` + +## 4. 动作分级 + +| 分数 | 动作 | 说明 | +| --- | --- | --- | +| `>= 100` | `block` | 强违规,例如未成年人性内容、凭据窃取、恶意软件生成 | +| `>= 40` | `block` | 高置信组合命中 | +| `>= 20` | `review` / `log` | 中风险或上下文不足 | +| `< 20` | `allow` | 放行 | + +如果没有人工复核流程,建议只对 `>= 40` 的规则启用拦截,其余先记录。 + +## 5. Cyber Abuse 规则 + +### 5.1 凭据、令牌、会话窃取 + +- `rule_id`: `CYBER_CREDENTIAL_THEFT_001` +- `category`: `cyber_abuse.credential_theft` +- `score`: `100` +- `action`: `block` + +命中逻辑:`动词组 A` + `目标组 B`。 + +英文动词组 A: + +```text +steal +extract +dump +exfiltrate +grab +harvest +scrape +leak +bypass login +session hijack +account takeover +``` + +英文目标组 B: + +```text +password +credential +cookie +session +token +api key +secret key +ssh key +private key +jwt +oauth token +refresh token +access token +2fa code +otp +``` + +中文动词组 A: + +```text +窃取 +盗取 +偷取 +抓取 +导出 +提取 +泄露 +批量获取 +劫持 +接管账号 +绕过登录 +``` + +中文目标组 B: + +```text +密码 +凭据 +cookie +会话 +token +令牌 +api key +密钥 +私钥 +ssh key +jwt +oauth +刷新令牌 +访问令牌 +验证码 +动态码 +二次验证 +``` + +示例命中: + +```text +窃取 cookie +抓取用户 token +write a script to dump saved passwords +extract oauth token from browser +``` + +允许例外: + +```text +如何保护 cookie +token 泄露后如何轮换 +detect credential leakage in logs +``` + +### 5.2 恶意软件生成 + +- `rule_id`: `CYBER_MALWARE_001` +- `category`: `cyber_abuse.malware` +- `score`: `100` +- `action`: `block` + +命中逻辑:`生成动词` + `恶意软件词`。 + +英文生成动词: + +```text +write +create +build +generate +make +develop +implement +code +``` + +英文恶意软件词: + +```text +malware +ransomware +keylogger +trojan +backdoor +rootkit +worm +botnet +infostealer +stealer +cryptominer +reverse shell +persistence mechanism +process injection +dll injection +credential stealer +``` + +中文生成动词: + +```text +写 +生成 +制作 +开发 +实现 +编写 +构建 +``` + +中文恶意软件词: + +```text +恶意软件 +勒索软件 +键盘记录器 +木马 +后门 +rootkit +蠕虫 +僵尸网络 +盗号木马 +信息窃取器 +挖矿木马 +反弹 shell +持久化 +进程注入 +dll 注入 +凭据窃取 +``` + +允许例外: + +```text +恶意软件检测规则 +勒索软件应急响应 +keylogger 防护方案 +reverse shell 是什么以及如何检测 +``` + +### 5.3 规避检测、免杀、绕过风控 + +- `rule_id`: `CYBER_EVASION_001` +- `category`: `cyber_abuse.evasion` +- `score`: `100` +- `action`: `block` + +命中逻辑:`规避词 A` + `检测/风控目标 B`。 + +英文规避词 A: + +```text +bypass +evade +avoid detection +undetectable +obfuscate +hide from +disable +turn off +circumvent +``` + +英文目标 B: + +```text +antivirus +edr +xdr +firewall +waf +ids +ips +sandbox +defender +security tool +detection +rate limit +captcha +risk control +``` + +中文规避词 A: + +```text +绕过 +规避 +免杀 +隐藏 +躲避 +逃避检测 +关闭 +禁用 +对抗 +绕开 +``` + +中文目标 B: + +```text +杀毒 +防病毒 +edr +xdr +防火墙 +waf +入侵检测 +沙箱 +defender +安全软件 +检测 +风控 +验证码 +限流 +``` + +允许例外: + +```text +如何加强验证码防护 +WAF 绕过检测日志分析 +EDR 告警排查 +``` + +### 5.4 真实目标攻击 + +- `rule_id`: `CYBER_REAL_TARGET_ATTACK_001` +- `category`: `cyber_abuse.real_target_attack` +- `score`: `80` +- `action`: `block` + +命中逻辑:`攻击动词` + `真实目标特征`。 + +英文攻击动词: + +```text +exploit +attack +hack +breach +penetrate +gain access +unauthorized access +take over +scan +enumerate +bruteforce +brute force +``` + +中文攻击动词: + +```text +攻击 +入侵 +黑掉 +拿下 +渗透 +未授权访问 +接管 +扫描 +爆破 +撞库 +枚举 +``` + +真实目标特征: + +```regex +\b(?:\d{1,3}\.){3}\d{1,3}\b +https?://[^\s]+\b +\b[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b +``` + +中文目标词: + +```text +真实网站 +公网 ip +公司网络 +生产服务器 +目标域名 +受害者 +后台 +管理面板 +数据库 +``` + +英文目标词: + +```text +real website +public ip +company network +production server +target domain +victim +admin panel +database +``` + +允许例外: + +```text +授权渗透测试报告模板 +CTF 靶场题目分析 +本地靶机漏洞复现 +``` + +建议白名单词: + +```text +ctf +靶场 +lab +localhost +127.0.0.1 +本地环境 +授权 +defensive +防御 +修复 +``` + +### 5.5 钓鱼、仿冒登录、诈骗脚本 + +- `rule_id`: `CYBER_PHISHING_001` +- `category`: `cyber_abuse.phishing` +- `score`: `100` +- `action`: `block` + +英文: + +```text +phishing page +fake login +credential harvesting +clone login page +spoof email +scam script +bank login clone +paypal login clone +microsoft login clone +google login clone +``` + +中文: + +```text +钓鱼页面 +仿冒登录 +假登录页 +克隆登录页 +盗号页面 +收集账号密码 +诈骗脚本 +仿冒银行 +仿冒支付 +仿冒微软登录 +仿冒谷歌登录 +``` + +组合增强: + +```text +create/build/generate/write/制作/生成/编写 + 上述词 => block +``` + +### 5.6 DDoS 和流量攻击 + +- `rule_id`: `CYBER_DDOS_001` +- `category`: `cyber_abuse.ddos` +- `score`: `100` +- `action`: `block` + +英文: + +```text +ddos +dos attack +booter +stresser +udp flood +tcp flood +syn flood +http flood +slowloris +amplification attack +stress a website +take down website +``` + +中文: + +```text +ddos +拒绝服务 +打流量 +流量攻击 +压垮网站 +打死网站 +洪水攻击 +udp flood +tcp flood +syn flood +http flood +慢速攻击 +放大攻击 +``` + +允许例外: + +```text +DDoS 防护 +限流设计 +高并发压测自己的服务 +流量攻击日志分析 +``` + +### 5.7 批量注册、垃圾信息、平台滥用 + +- `rule_id`: `CYBER_PLATFORM_ABUSE_001` +- `category`: `cyber_abuse.platform_abuse` +- `score`: `60` +- `action`: `block` + +英文组合: + +```text +mass signup + bypass captcha +bulk account creation + fake identity +spam bot + send messages +scrape users + send dm +auto register + proxy +``` + +中文组合: + +```text +批量注册 + 绕过验证码 +批量养号 + 代理 +自动注册 + 风控 +群发私信 + 爬取用户 +垃圾消息 + 机器人 +``` + +### 5.8 支付欺诈、卡密、盗刷 + +- `rule_id`: `CYBER_PAYMENT_FRAUD_001` +- `category`: `fraud.payment` +- `score`: `100` +- `action`: `block` + +英文: + +```text +carding +stolen credit card +credit card dump +bin attack +chargeback fraud +payment bypass +fake payment proof +``` + +中文: + +```text +盗刷 +黑卡 +信用卡料 +卡料 +跑卡 +撞库支付 +绕过支付 +伪造付款截图 +拒付欺诈 +``` + +## 6. NSFW 规则 + +### 6.1 未成年人性内容 + +- `rule_id`: `NSFW_SEXUAL_MINORS_001` +- `category`: `nsfw.sexual_minors` +- `score`: `200` +- `action`: `block` + +命中逻辑:`未成年人词` + `性内容词`。 + +英文未成年人词: + +```text +minor +underage +child +children +kid +teen +schoolgirl +schoolboy +preteen +lolita +loli +shota +``` + +中文未成年人词: + +```text +未成年 +儿童 +小孩 +孩子 +幼女 +幼童 +萝莉 +正太 +学生妹 +小学生 +初中生 +未满18 +未满十八 +``` + +英文性内容词: + +```text +sex +sexual +nude +naked +porn +erotic +explicit +lewd +strip +seduce +incest +``` + +中文性内容词: + +```text +性 +色情 +裸露 +裸体 +成人内容 +露骨 +淫秽 +挑逗 +脱衣 +乱伦 +``` + +任意组合命中即拦截。 + +### 6.2 露骨色情生成 + +- `rule_id`: `NSFW_EXPLICIT_SEXUAL_001` +- `category`: `nsfw.explicit_sexual` +- `score`: `80` +- `action`: `block` + +英文: + +```text +explicit sex +pornographic +hardcore porn +graphic sexual +sexual act +oral sex +anal sex +masturbation +fetish porn +``` + +中文: + +```text +露骨色情 +色情描写 +成人影片 +成人视频 +性交 +口交 +肛交 +自慰 +性癖色情 +``` + +组合增强: + +```text +write/generate/create/生成/写/制作 + 上述词 => block +``` + +允许例外: + +```text +性教育 +医学解释 +性健康咨询 +法律合规讨论 +``` + +### 6.3 非自愿、性暴力、胁迫性内容 + +- `rule_id`: `NSFW_NON_CONSENSUAL_001` +- `category`: `nsfw.non_consensual` +- `score`: `200` +- `action`: `block` + +英文: + +```text +non-consensual +without consent +rape +sexual assault +forced sex +coerced +blackmail sex +revenge porn +hidden camera +upskirt +``` + +中文: + +```text +非自愿 +未经同意 +强奸 +性侵 +强迫发生关系 +胁迫 +偷拍视频 +偷拍裙底 +报复性色情 +裸照威胁 +``` + +### 6.4 裸露或色情图片生成提示词 + +- `rule_id`: `NSFW_IMAGE_PROMPT_001` +- `category`: `nsfw.image_prompt` +- `score`: `80` +- `action`: `block` + +命中逻辑:`图片生成词` + `裸露/色情词`。 + +英文图片生成词: + +```text +generate image +create image +image prompt +stable diffusion +midjourney +photorealistic +render +``` + +中文图片生成词: + +```text +生成图片 +画一张 +图片提示词 +绘图提示词 +文生图 +图生图 +写真 +渲染 +``` + +英文裸露/色情词: + +```text +nude +naked + topless +see-through +lingerie +porn +explicit +spread legs +``` + +中文裸露/色情词: + +```text +裸体 +裸露 +裸照 +上空 +透视 +内衣 +色情 +露骨 +大尺度 +``` + +允许例外: + +```text +医学解剖图 +艺术史分析 +服装设计说明 +``` + +## 7. 隐私与人肉搜索规则 + +### 7.1 个人隐私泄露 + +- `rule_id`: `PRIVACY_DANGEROUS_DISCLOSURE_001` +- `category`: `privacy.dangerous_disclosure` +- `score`: `80` +- `action`: `block` + +英文动词: + +```text +find address +track location +dox +lookup personal info +find phone number +find real identity +``` + +中文动词: + +```text +查地址 +定位某人 +人肉 +开盒 +查手机号 +查身份证 +查真实身份 +查家庭住址 +``` + +敏感字段: + +```text +身份证 +手机号 +家庭住址 +住址 +银行卡 +护照 +社保号 +phone number +home address +id number +passport +bank account +ssn +``` + +允许例外: + +```text +隐私保护建议 +个人信息脱敏 +合规数据处理 +``` + +## 8. 自残与暴力规则 + +### 8.1 自残方法指导 + +- `rule_id`: `SELF_HARM_INSTRUCTIONS_001` +- `category`: `self_harm.instructions` +- `score`: `100` +- `action`: `block` + +英文: + +```text +how to kill myself +suicide method +self harm instructions +painless suicide +best way to die +``` + +中文: + +```text +怎么自杀 +自杀方法 +自残教程 +无痛自杀 +怎么死不痛苦 +``` + +允许并应放行的求助类表达: + +```text +我想自杀怎么办 +如何帮助有自杀想法的人 +心理危机求助 +``` + +说明:求助类不要拦截为违规,应该交给上游模型提供安全支持或转人工。 + +### 8.2 暴力伤害指导 + +- `rule_id`: `VIOLENCE_INSTRUCTIONS_001` +- `category`: `violence.instructions` +- `score`: `80` +- `action`: `block` + +英文: + +```text +how to hurt someone +how to kill someone +make a weapon +hide a weapon +violent attack plan +``` + +中文: + +```text +如何伤害别人 +如何杀人 +制作武器 +藏匿武器 +暴力袭击计划 +``` + +## 9. 组合规则建议 + +### 9.1 强制 block 组合 + +```text +未成年人词 + 性内容词 => block +生成动词 + 恶意软件词 => block +规避词 + 安全检测目标 => block +窃取动词 + 凭据目标 => block +攻击动词 + 真实目标特征 => block +图片生成词 + 裸露/色情词 => block +非自愿词 + 性内容词 => block +``` + +### 9.2 降低误杀白名单 + +出现以下词时,可降低 20 分,但不得覆盖强制 block 规则: + +英文: + +```text +defense +defensive +protect +mitigate +patch +fix +detect +monitor +incident response +ctf +lab +localhost +training +education +medical +legal compliance +``` + +中文: + +```text +防御 +保护 +缓解 +修复 +补丁 +检测 +监控 +应急响应 +靶场 +本地环境 +授权 +培训 +教育 +医学 +合规 +法律讨论 +``` + +不得白名单覆盖: + +```text +sexual_minors +credential_theft +malware_generation +non_consensual_sexual +payment_fraud +``` + +## 10. 建议配置结构 + +```json +{ + "enabled": true, + "mode": "block", + "block_score": 40, + "review_score": 20, + "max_input_chars": 8000, + "return_message": "请求内容不符合输入安全规则,请修改 prompt 后重试。", + "rules": [ + { + "id": "CYBER_CREDENTIAL_THEFT_001", + "category": "cyber_abuse.credential_theft", + "score": 100, + "action": "block" + }, + { + "id": "NSFW_SEXUAL_MINORS_001", + "category": "nsfw.sexual_minors", + "score": 200, + "action": "block" + } + ] +} +``` + +## 11. 日志字段 + +建议记录: + +```text +request_id +user_id +ip_hash +endpoint +model +category +rule_id +score +action +request_hash +created_at +``` + +不建议默认记录: + +```text +完整原文 +完整图片 URL +完整 token +完整 cookie +完整密钥 +``` + +## 12. 上线顺序 + +1. `log` 模式上线全部规则。 +2. 观察 1 到 3 天误杀。 +3. 先启用强制 block: + - `NSFW_SEXUAL_MINORS_001` + - `NSFW_NON_CONSENSUAL_001` + - `CYBER_CREDENTIAL_THEFT_001` + - `CYBER_MALWARE_001` + - `CYBER_EVASION_001` + - `CYBER_PHISHING_001` + - `CYBER_PAYMENT_FRAUD_001` +4. 对真实目标攻击、DDoS、平台滥用启用 `review` 或较高阈值 block。 +5. 后续如可用,再接入模型审查作为二级判断。 diff --git a/main.go b/main.go index 3361b8ce9338..532da5f7c9ba 100644 --- a/main.go +++ b/main.go @@ -47,6 +47,9 @@ var classicBuildFS embed.FS //go:embed web/classic/dist/index.html var classicIndexPage []byte +//go:embed web/custom/home.html +var customHomePage []byte + func main() { startTime := time.Now() @@ -195,6 +198,7 @@ func main() { DefaultIndexPage: indexPage, ClassicBuildFS: classicBuildFS, ClassicIndexPage: classicIndexPage, + CustomHomePage: customHomePage, }) var port = os.Getenv("PORT") if port == "" { @@ -229,6 +233,7 @@ func InjectUmamiAnalytics() { placeholder := []byte("\n") indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject) classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject) + customHomePage = bytes.ReplaceAll(customHomePage, placeholder, analyticsInject) } func InjectGoogleAnalytics() { @@ -253,6 +258,7 @@ func InjectGoogleAnalytics() { placeholder := []byte("\n") indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject) classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject) + customHomePage = bytes.ReplaceAll(customHomePage, placeholder, analyticsInject) } func InitResources() error { diff --git a/router/web-router.go b/router/web-router.go index 0d475e90d54d..b1a593d9dcaf 100644 --- a/router/web-router.go +++ b/router/web-router.go @@ -19,6 +19,7 @@ type ThemeAssets struct { DefaultIndexPage []byte ClassicBuildFS embed.FS ClassicIndexPage []byte + CustomHomePage []byte } func SetWebRouter(router *gin.Engine, assets ThemeAssets) { @@ -29,6 +30,11 @@ func SetWebRouter(router *gin.Engine, assets ThemeAssets) { router.Use(gzip.Gzip(gzip.DefaultCompression)) router.Use(middleware.GlobalWebRateLimit()) router.Use(middleware.Cache()) + router.GET("/", func(c *gin.Context) { + c.Set(middleware.RouteTagKey, "web") + c.Header("Cache-Control", "no-cache") + c.Data(http.StatusOK, "text/html; charset=utf-8", assets.CustomHomePage) + }) router.Use(static.Serve("/", themeFS)) router.NoRoute(func(c *gin.Context) { c.Set(middleware.RouteTagKey, "web") diff --git a/web/custom/home.html b/web/custom/home.html new file mode 100644 index 000000000000..fb23e46c8bf3 --- /dev/null +++ b/web/custom/home.html @@ -0,0 +1,204 @@ + + + + + + + fkcodex - Codex API Service + + + + + + + +
+
+
+
官方 API 服务 · Practical Codex API plans
+

Codex API
省钱省心,
接入更直接。

+

多种方案可选,价格清楚,接入简单。你只管调用 API,额度、密钥、用量和杂事交给 fkcodex 处理。

+ +
官方 API 服务多种方案可选用量清楚少点麻烦
+
+ +
+ +
+
API Service

买 API,就要简单直接。

fkcodex 只把关键事讲清楚:怎么买、怎么调、用了多少。首页保持轻量,控制台继续由 new-api 原前端承接。

+
+ +

多种方案可选

小用量先试,稳定调用再升级,特殊请求量再单独谈。

  • 入门
  • 专业
  • 定制
+

用量和密钥清楚

密钥、请求、Token 和剩余额度都放在控制台里看。

  • 创建密钥
  • 查看用量
  • 控制费用
+
+
+ +
+

“这个页面只负责讲清楚 API 服务价值;用户进入控制台后,仍然使用 new-api 原生的登录、定价、模型和密钥管理流程。”

独立 landing HTML · 保留原 SPA 能力
/独立首页
SPA其他路径保留
0额外后端依赖
+
+ +
Ready when you are

选方案。拿密钥。调用 Codex。

fkcodex 卖的是 Codex API 服务,不是复杂平台。首页轻量独立,管理能力继续交给 new-api。

+
+ + + + From a4a78df1d6af8a61111ff534950991fbca8d7599 Mon Sep 17 00:00:00 2001 From: dofastted <278058887+dofastted@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:47:01 +0800 Subject: [PATCH 5/6] fix: route auto completions retries to completions group --- middleware/distributor.go | 15 +++++++++++---- middleware/distributor_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/middleware/distributor.go b/middleware/distributor.go index 73ce03e9041c..4fe6059ead11 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -101,10 +101,7 @@ func Distribute() func(c *gin.Context) { } } - if routedGroup, ok := autoGroupForRequestPath(usingGroup, c.Request.URL.Path); ok { - usingGroup = routedGroup - common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup) - } + usingGroup = routeAutoGroupForRequestPath(c, usingGroup) if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { affinityUsable := false @@ -172,6 +169,16 @@ func Distribute() func(c *gin.Context) { } } +func routeAutoGroupForRequestPath(c *gin.Context, usingGroup string) string { + routedGroup, ok := autoGroupForRequestPath(usingGroup, c.Request.URL.Path) + if !ok { + return usingGroup + } + common.SetContextKey(c, constant.ContextKeyUsingGroup, routedGroup) + common.SetContextKey(c, constant.ContextKeyTokenGroup, routedGroup) + return routedGroup +} + func autoGroupForRequestPath(usingGroup string, requestPath string) (string, bool) { if usingGroup != "auto" { return usingGroup, false diff --git a/middleware/distributor_test.go b/middleware/distributor_test.go index 6ef52e848492..88c76a49a97f 100644 --- a/middleware/distributor_test.go +++ b/middleware/distributor_test.go @@ -1,8 +1,13 @@ package middleware import ( + "net/http" + "net/http/httptest" "testing" + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" ) @@ -56,3 +61,31 @@ func TestAutoGroupForRequestPath(t *testing.T) { }) } } + +func TestRouteAutoGroupForRequestPathUpdatesRetryTokenGroup(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + common.SetContextKey(c, constant.ContextKeyUsingGroup, "auto") + common.SetContextKey(c, constant.ContextKeyTokenGroup, "auto") + + routedGroup := routeAutoGroupForRequestPath(c, "auto") + + assert.Equal(t, "codex-completions", routedGroup) + assert.Equal(t, "codex-completions", common.GetContextKeyString(c, constant.ContextKeyUsingGroup)) + assert.Equal(t, "codex-completions", common.GetContextKeyString(c, constant.ContextKeyTokenGroup)) +} + +func TestRouteAutoGroupForRequestPathKeepsResponsesRetryAuto(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + common.SetContextKey(c, constant.ContextKeyUsingGroup, "auto") + common.SetContextKey(c, constant.ContextKeyTokenGroup, "auto") + + routedGroup := routeAutoGroupForRequestPath(c, "auto") + + assert.Equal(t, "auto", routedGroup) + assert.Equal(t, "auto", common.GetContextKeyString(c, constant.ContextKeyUsingGroup)) + assert.Equal(t, "auto", common.GetContextKeyString(c, constant.ContextKeyTokenGroup)) +} From 17a08e5d6625cc9bba1655f06667fc269fc22b69 Mon Sep 17 00:00:00 2001 From: dofastted <278058887+dofastted@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:53:27 +0800 Subject: [PATCH 6/6] fix: stop auto route retry amplification --- constant/context_key.go | 1 + controller/relay.go | 7 +++++ controller/relay_retry_test.go | 54 ++++++++++++++++++++++++++++++++++ middleware/distributor.go | 31 ++++++++++++++----- middleware/distributor_test.go | 1 + service/channel_select.go | 2 +- service/group.go | 28 ++++++++++++++++++ service/group_test.go | 31 +++++++++++++++++++ types/error.go | 14 +++++++++ 9 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 controller/relay_retry_test.go create mode 100644 service/group_test.go diff --git a/constant/context_key.go b/constant/context_key.go index b856bc3dda14..2d5dc8fbd40c 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -41,6 +41,7 @@ const ( ContextKeyAutoGroup ContextKey = "auto_group" ContextKeyAutoGroupIndex ContextKey = "auto_group_index" ContextKeyAutoGroupRetryIndex ContextKey = "auto_group_retry_index" + ContextKeyRouteAutoGroups ContextKey = "route_auto_groups" /* user related keys */ ContextKeyUserId ContextKey = "id" diff --git a/controller/relay.go b/controller/relay.go index 1d14dcc6f880..7f993781b6db 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -227,6 +227,10 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { newAPIError = service.NormalizeViolationFeeError(newAPIError) relayInfo.LastError = newAPIError + if types.IsClientCanceledError(newAPIError) { + logger.LogInfo(c, fmt.Sprintf("client canceled request, skip channel retry: %s", common.LocalLogPreview(newAPIError.Error()))) + break + } processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError) @@ -325,6 +329,9 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b if openaiErr == nil { return false } + if types.IsClientCanceledError(openaiErr) { + return false + } if service.ShouldSkipRetryAfterChannelAffinityFailure(c) { return false } diff --git a/controller/relay_retry_test.go b/controller/relay_retry_test.go new file mode 100644 index 000000000000..2d4e470d211a --- /dev/null +++ b/controller/relay_retry_test.go @@ -0,0 +1,54 @@ +package controller + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestShouldRetrySkipsClientCanceledErrors(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + err *types.NewAPIError + }{ + { + name: "wrapped context canceled", + err: types.NewErrorWithStatusCode( + fmt.Errorf("request context done: %w", context.Canceled), + types.ErrorCodeBadResponse, + http.StatusInternalServerError, + ), + }, + { + name: "client gone stream marker", + err: types.NewErrorWithStatusCode( + fmt.Errorf("stream ended: reason=client_gone end_error=%q", context.Canceled.Error()), + types.ErrorCodeBadResponse, + http.StatusInternalServerError, + ), + }, + { + name: "channel-coded cancellation", + err: types.NewErrorWithStatusCode( + fmt.Errorf("request context done: %w", context.Canceled), + types.ErrorCodeChannelInvalidKey, + http.StatusInternalServerError, + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + + assert.False(t, shouldRetry(ctx, tt.err, 3)) + }) + } +} diff --git a/middleware/distributor.go b/middleware/distributor.go index 4fe6059ead11..9a5c09a23a90 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -109,7 +109,7 @@ func Distribute() func(c *gin.Context) { if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled { if usingGroup == "auto" { userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) - autoGroups := service.GetUserAutoGroup(userGroup) + autoGroups := service.GetRequestAutoGroup(c, userGroup) for _, g := range autoGroups { if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) { selectGroup = g @@ -170,25 +170,42 @@ func Distribute() func(c *gin.Context) { } func routeAutoGroupForRequestPath(c *gin.Context, usingGroup string) string { - routedGroup, ok := autoGroupForRequestPath(usingGroup, c.Request.URL.Path) - if !ok { + if usingGroup != "auto" { return usingGroup } - common.SetContextKey(c, constant.ContextKeyUsingGroup, routedGroup) - common.SetContextKey(c, constant.ContextKeyTokenGroup, routedGroup) - return routedGroup + + requestPath := c.Request.URL.Path + if isChatCompletionsPath(requestPath) { + const routedGroup = "codex-completions" + common.SetContextKey(c, constant.ContextKeyUsingGroup, routedGroup) + common.SetContextKey(c, constant.ContextKeyTokenGroup, routedGroup) + return routedGroup + } + + if isResponsesPath(requestPath) { + common.SetContextKey(c, constant.ContextKeyRouteAutoGroups, []string{"codex", "codex-pro"}) + } + return usingGroup } func autoGroupForRequestPath(usingGroup string, requestPath string) (string, bool) { if usingGroup != "auto" { return usingGroup, false } - if requestPath == "/v1/chat/completions" || strings.HasPrefix(requestPath, "/v1/chat/completions/") { + if isChatCompletionsPath(requestPath) { return "codex-completions", true } return usingGroup, false } +func isChatCompletionsPath(requestPath string) bool { + return requestPath == "/v1/chat/completions" || strings.HasPrefix(requestPath, "/v1/chat/completions/") +} + +func isResponsesPath(requestPath string) bool { + return requestPath == "/v1/responses" || strings.HasPrefix(requestPath, "/v1/responses/") +} + // getModelFromRequest 从请求中读取模型信息 // 根据 Content-Type 自动处理: // - application/json diff --git a/middleware/distributor_test.go b/middleware/distributor_test.go index 88c76a49a97f..aaba3a1b2259 100644 --- a/middleware/distributor_test.go +++ b/middleware/distributor_test.go @@ -88,4 +88,5 @@ func TestRouteAutoGroupForRequestPathKeepsResponsesRetryAuto(t *testing.T) { assert.Equal(t, "auto", routedGroup) assert.Equal(t, "auto", common.GetContextKeyString(c, constant.ContextKeyUsingGroup)) assert.Equal(t, "auto", common.GetContextKeyString(c, constant.ContextKeyTokenGroup)) + assert.Equal(t, []string{"codex", "codex-pro"}, common.GetContextKeyStringSlice(c, constant.ContextKeyRouteAutoGroups)) } diff --git a/service/channel_select.go b/service/channel_select.go index a3710ef8cec3..7c93b9b3f72f 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -90,7 +90,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, if len(setting.GetAutoGroups()) == 0 { return nil, selectGroup, errors.New("auto groups is not enabled") } - autoGroups := GetUserAutoGroup(userGroup) + autoGroups := GetRequestAutoGroup(param.Ctx, userGroup) // startGroupIndex: the group index to start searching from // startGroupIndex: 开始搜索的分组索引 diff --git a/service/group.go b/service/group.go index a73642c3eb1a..158c6e8402e2 100644 --- a/service/group.go +++ b/service/group.go @@ -3,8 +3,11 @@ package service import ( "strings" + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/gin-gonic/gin" ) func GetUserUsableGroups(userGroup string) map[string]string { @@ -53,6 +56,31 @@ func GetUserAutoGroup(userGroup string) []string { return autoGroups } +func GetRequestAutoGroup(c *gin.Context, userGroup string) []string { + autoGroups := GetUserAutoGroup(userGroup) + routeGroups := common.GetContextKeyStringSlice(c, constant.ContextKeyRouteAutoGroups) + if len(routeGroups) == 0 { + return autoGroups + } + + filteredGroups := make([]string, 0, len(routeGroups)) + for _, group := range autoGroups { + if containsGroup(routeGroups, group) { + filteredGroups = append(filteredGroups, group) + } + } + return filteredGroups +} + +func containsGroup(groups []string, group string) bool { + for _, item := range groups { + if item == group { + return true + } + } + return false +} + // GetUserGroupRatio 获取用户使用某个分组的倍率 // userGroup 用户分组 // group 需要获取倍率的分组 diff --git a/service/group_test.go b/service/group_test.go new file mode 100644 index 000000000000..953a10dc891e --- /dev/null +++ b/service/group_test.go @@ -0,0 +1,31 @@ +package service + +import ( + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/setting" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestGetRequestAutoGroupFiltersRouteScopedGroups(t *testing.T) { + gin.SetMode(gin.TestMode) + oldAutoGroups := setting.AutoGroups2JsonString() + oldUserGroups := setting.UserUsableGroups2JSONString() + t.Cleanup(func() { + require.NoError(t, setting.UpdateAutoGroupsByJsonString(oldAutoGroups)) + require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(oldUserGroups)) + }) + require.NoError(t, setting.UpdateAutoGroupsByJsonString(`["codex","codex-pro","codex-completions"]`)) + require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"codex":"Codex","codex-pro":"Codex Pro","codex-completions":"Codex Completions"}`)) + + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + common.SetContextKey(ctx, constant.ContextKeyRouteAutoGroups, []string{"codex", "codex-pro"}) + + groups := GetRequestAutoGroup(ctx, "default") + + require.Equal(t, []string{"codex", "codex-pro"}, groups) +} diff --git a/types/error.go b/types/error.go index 9717401ae7b2..3087be661e26 100644 --- a/types/error.go +++ b/types/error.go @@ -1,6 +1,7 @@ package types import ( + "context" "encoding/json" "errors" "fmt" @@ -378,6 +379,19 @@ func IsSkipRetryError(err *NewAPIError) bool { return err.skipRetry } +func IsClientCanceledError(err *NewAPIError) bool { + if err == nil { + return false + } + if errors.Is(err.Err, context.Canceled) { + return true + } + lowerMessage := strings.ToLower(err.Error()) + return strings.Contains(lowerMessage, "context canceled") || + strings.Contains(lowerMessage, "request context done") || + strings.Contains(lowerMessage, "client_gone") +} + func ErrOptionWithSkipRetry() NewAPIErrorOptions { return func(e *NewAPIError) { e.skipRetry = true