mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-04 08:43:20 +00:00
上游对单次请求下发 error.code=cyber_policy 硬阻断时,网关在所有端点 (/v1/responses、/v1/chat/completions、/v1/messages、WebSocket)及流式/ 非流式路径下,将该结果原样透传给客户端,绝不 failover、换号或同步拦截; 命中后异步完成审计与计费: - 风控中心记录 cyber_policy 留痕并发送通知邮件,落库先于发信,SMTP 阻塞 不影响留痕 - ops 错误请求记录,状态码对齐客户端实际接收(流式 200 / 非流式 400) - 用量明细标记 request_type=cyber,按上游真实 token 计费,HTTP 与 WebSocket 计费口径统一,零 token 命中不误扣 - 会话级自动屏蔽(管理员开关,默认关):命中的会话在可配 TTL 内本地拦截 不再发往上游,仅屏蔽该会话不影响同 Key 其他会话 - 封号计数排除开关:可选让 cyber 命中不计入自动封号,命中当次不判定且 历史行在违规计数中一并排除 WebSocket 多轮连接下 cyber 标记按 turn 生命周期管理,逐轮独立检测与记录; 透传的错误响应不被兜底逻辑追加内容污染。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
89 lines
3.5 KiB
Go
89 lines
3.5 KiB
Go
package service
|
||
|
||
import (
|
||
"errors"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/tidwall/gjson"
|
||
)
|
||
|
||
// opsCyberPolicyKey 在 gin context 中携带 cyber_policy 命中标记。
|
||
// 由 gateway 服务层在检测到上游 error.code=="cyber_policy" 时设置,
|
||
// handler 在 Forward 返回后读取以触发风控记录、邮件与 tokens=0 用量行。
|
||
const opsCyberPolicyKey = "ops_cyber_policy"
|
||
|
||
// errOpenAICyberPolicyForwarded 表示 cyber_policy 已按当前端点格式透传给客户端
|
||
// (error 已写出/下发)。compat 路径 ForwardAsChatCompletions / ForwardAsAnthropic 出口
|
||
// 据此丢弃 result 并返回该哨兵,使 handler 落入 tokens=0 免费用量行(对齐 /v1/responses),
|
||
// 既不计费、也不 failover、不重复写响应。
|
||
var errOpenAICyberPolicyForwarded = errors.New("openai cyber_policy forwarded to client")
|
||
|
||
// CyberPolicyMark 记录一次 cyber_policy 硬阻断的上游证据。
|
||
type CyberPolicyMark struct {
|
||
Code string // 固定 "cyber_policy"
|
||
Message string // 上游 error.message
|
||
Body string // 上游 response.failed / 400 原始 body(已截断;未脱敏,ops_error 落库由 sanitizeErrorBodyForStorage、风控日志由 redactContentModerationSecrets 统一脱敏)
|
||
UpstreamStatus int // 上游 HTTP 状态(流式=200,非流式=400)
|
||
UpstreamInTok int // 上游已报 input tokens(如有)
|
||
UpstreamOutTok int // 上游已报 output tokens(如有)
|
||
}
|
||
|
||
// MarkOpsCyberPolicy 记录 cyber 标记;首个写入生效,后续忽略(同一 turn 只记一次)。
|
||
// WS 多轮场景由 handler 在每个 turn 结束后调用 ClearOpsCyberPolicy 重置。
|
||
func MarkOpsCyberPolicy(c *gin.Context, mark CyberPolicyMark) {
|
||
if c == nil {
|
||
return
|
||
}
|
||
if GetOpsCyberPolicy(c) != nil {
|
||
return
|
||
}
|
||
mark.Code = "cyber_policy"
|
||
mark.Message = strings.TrimSpace(mark.Message)
|
||
mark.Body = strings.TrimSpace(mark.Body)
|
||
c.Set(opsCyberPolicyKey, &mark)
|
||
}
|
||
|
||
// GetOpsCyberPolicy 返回 cyber 标记,未命中(或已被 Clear)返回 nil。
|
||
func GetOpsCyberPolicy(c *gin.Context) *CyberPolicyMark {
|
||
if c == nil {
|
||
return nil
|
||
}
|
||
if v, ok := c.Get(opsCyberPolicyKey); ok {
|
||
if m, ok := v.(*CyberPolicyMark); ok && m != nil {
|
||
return m
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ClearOpsCyberPolicy 清除 cyber 标记(typed-nil 覆盖;gin context 无并发安全的
|
||
// 删除原语,Set 走内部锁,与异步 GetOpsCyberPolicy 不构成 data race)。
|
||
// 仅 WS 多轮路径在 turn 收尾调用;HTTP 单请求路径不调用(context 随请求销毁,
|
||
// 且中间件 shouldSkipOpsErrorLogForCyber 依赖标记防双写)。
|
||
// WS 路径 clear 发生在中间件收尾之前,连接响应状态为 101,不触发中间件 status>=400
|
||
// 落库分支,故无双写/漏写。
|
||
func ClearOpsCyberPolicy(c *gin.Context) {
|
||
if c == nil {
|
||
return
|
||
}
|
||
c.Set(opsCyberPolicyKey, (*CyberPolicyMark)(nil))
|
||
}
|
||
|
||
// detectOpenAICyberPolicy 精确识别 cyber_policy(对齐 codex api_bridge.rs:145 /
|
||
// sse/responses.rs:529)。命中返回 (true, "cyber_policy", message)。
|
||
func detectOpenAICyberPolicy(payload []byte) (bool, string, string) {
|
||
code := gjson.GetBytes(payload, "error.code").String()
|
||
if code == "" {
|
||
code = gjson.GetBytes(payload, "response.error.code").String()
|
||
}
|
||
if !strings.EqualFold(strings.TrimSpace(code), "cyber_policy") {
|
||
return false, "", ""
|
||
}
|
||
msg := gjson.GetBytes(payload, "error.message").String()
|
||
if msg == "" {
|
||
msg = gjson.GetBytes(payload, "response.error.message").String()
|
||
}
|
||
return true, "cyber_policy", strings.TrimSpace(msg)
|
||
}
|