group_model_allowlist.go 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. package middleware
  2. import (
  3. "errors"
  4. "fmt"
  5. "net/http"
  6. "strings"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/requestmodel"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "github.com/gin-gonic/gin"
  11. )
  12. // GroupModelAllowlist 是分组级模型白名单准入中间件。
  13. //
  14. // 挂载位置:每条网关链的 apiKeyAuth 之后、compositeTarget 之前——
  15. // 保证校验发生在合成路由改写与调度之前,且只看客户端书写的公开模型名。
  16. //
  17. // 行为:
  18. // - 快速路径:未绑定分组或白名单未开启时直接放行,不读请求体。
  19. // - Responses WebSocket 入口跳过(首帧与后续 turn 由 ResponsesWebSocket 逐帧
  20. // 校验);Grok Realtime 的升级请求模型固定在查询参数里,仍走中间件校验,
  21. // 其他路由伪造 Upgrade 头不得绕过校验。
  22. // - GET/DELETE:从路由参数(model / modelAction)或查询参数(/realtime 的
  23. // model)提取模型;提取不到放行,由 handler 决定是否报「model is required」。
  24. // - 带请求体的方法:读体(PrereadBody 回填,下游零拷贝)、提取 JSON
  25. // `model`/`session.model` 或 multipart `model`/`session` 后回填请求体。
  26. // - 拒绝:按入口协议格式返回 404,并标记运维业务限流原因
  27. // local_model_configuration 与 ingress 拒绝原因 model_not_allowed。
  28. func GroupModelAllowlist() gin.HandlerFunc {
  29. return func(c *gin.Context) {
  30. apiKey, ok := GetAPIKeyFromContext(c)
  31. if !ok || apiKey == nil || apiKey.Group == nil || !apiKey.Group.ModelAllowlistEnabled() {
  32. c.Next()
  33. return
  34. }
  35. allowlist := apiKey.Group.ModelAllowlist
  36. if c.Request == nil {
  37. c.Next()
  38. return
  39. }
  40. if isResponsesWebSocketRoute(c) {
  41. // Responses WS 长连接由 ResponsesWebSocket 校验首帧与每个 response.create。
  42. c.Next()
  43. return
  44. }
  45. // models 收集该请求全部可被下游解析器绑定到的模型值(路径参数/查询参数
  46. // 单值;请求体候选集含重复键与大小写变体),逐一校验,任一未命中即拒绝。
  47. var models []string
  48. if model := groupModelAllowlistModelFromParams(c); model != "" {
  49. models = []string{model}
  50. } else {
  51. switch c.Request.Method {
  52. case http.MethodPost, http.MethodPut, http.MethodPatch:
  53. candidates, done := groupModelAllowlistModelsFromBody(c)
  54. if !done {
  55. // 请求体读取失败(如超限 413)已写出响应。
  56. return
  57. }
  58. models = candidates
  59. }
  60. if len(models) == 0 {
  61. // Grok Realtime 升级请求把模型固定在查询参数里。
  62. if model := strings.TrimSpace(c.Query("model")); model != "" {
  63. models = []string{model}
  64. }
  65. }
  66. }
  67. blocked := ""
  68. for _, candidate := range models {
  69. if !allowlist.Allows(candidate) {
  70. blocked = candidate
  71. break
  72. }
  73. }
  74. if blocked == "" {
  75. c.Next()
  76. return
  77. }
  78. service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
  79. MarkIngressRejected(c, IngressRejectModelNotAllowed)
  80. groupModelAllowlistErrorWriter(c)(c, http.StatusNotFound, fmt.Sprintf("Model %q is not available for this group", blocked))
  81. c.Abort()
  82. }
  83. }
  84. // isResponsesWebSocketRoute 判断当前请求是否命中 OpenAI Responses WebSocket
  85. // 入口。只有这三条路由的模型在升级后的帧里,交给 handler 逐帧校验;其他路由
  86. // 即使携带 WebSocket Upgrade 头也必须经过本中间件校验。
  87. func isResponsesWebSocketRoute(c *gin.Context) bool {
  88. if c.Request == nil || c.Request.Method != http.MethodGet {
  89. return false
  90. }
  91. switch c.FullPath() {
  92. case "/v1/responses", "/responses", "/backend-api/codex/responses":
  93. return true
  94. }
  95. return false
  96. }
  97. // groupModelAllowlistModelsFromBody 读取请求体并提取客户端模型名,随后把请求体
  98. // 回填(PrereadBody),保证后续 handler 零拷贝重读。读取失败按现有合成中间件
  99. // 的方式返回 400/413(返回 false 表示已写出响应并 Abort)。
  100. //
  101. // 下游同时存在 gjson(首个、大小写敏感)、encoding/json 绑定(末值、大小写
  102. // 不敏感)与 multipart 表单(首/末字段)三类解析器,这里返回「任一解析器可能
  103. // 绑定到的全部模型值」,调用方必须逐一校验,任一未命中即拒绝。
  104. func groupModelAllowlistModelsFromBody(c *gin.Context) ([]string, bool) {
  105. body, err := httputil.ReadRequestBodyWithPrealloc(c.Request)
  106. if err != nil {
  107. status := http.StatusBadRequest
  108. message := "Failed to read request body"
  109. var maxErr *http.MaxBytesError
  110. if errors.As(err, &maxErr) {
  111. status = http.StatusRequestEntityTooLarge
  112. message = "Request body is too large"
  113. }
  114. c.JSON(status, gin.H{"error": gin.H{"type": "invalid_request_error", "message": message}})
  115. c.Abort()
  116. return nil, false
  117. }
  118. requestmodel.ResetRequestBody(c.Request, body)
  119. return requestmodel.FromBodyCandidates(c.FullPath(), c.GetHeader("Content-Type"), body), true
  120. }
  121. // groupModelAllowlistModelFromParams 从路由参数提取模型名:Gemini 原生 URL 的
  122. // `model` / `modelAction`(去掉 `:action` 后缀)。提取不到返回空串,
  123. // 由调用方决定是否继续读请求体或查询参数。
  124. func groupModelAllowlistModelFromParams(c *gin.Context) string {
  125. if model := strings.TrimSpace(c.Param("model")); model != "" {
  126. return model
  127. }
  128. if modelAction := strings.TrimSpace(c.Param("modelAction")); modelAction != "" {
  129. modelAction = strings.TrimPrefix(modelAction, "/")
  130. if idx := strings.LastIndex(modelAction, ":"); idx >= 0 {
  131. return strings.TrimSpace(modelAction[:idx])
  132. }
  133. return modelAction
  134. }
  135. return ""
  136. }
  137. // groupModelAllowlistErrorWriter 按入口协议选择错误格式:
  138. // Gemini 原生(/v1beta、/antigravity/v1beta)用 Google 格式;
  139. // Messages 入口(含根路径别名与 /antigravity/v1)用 Anthropic 格式;
  140. // 其余(OpenAI 兼容入口)用 OpenAI 格式。
  141. func groupModelAllowlistErrorWriter(c *gin.Context) GatewayErrorWriter {
  142. path := ""
  143. if c.Request != nil && c.Request.URL != nil {
  144. path = c.Request.URL.Path
  145. }
  146. switch {
  147. case strings.HasPrefix(path, "/v1beta") || strings.HasPrefix(path, "/antigravity/v1beta"):
  148. return GoogleErrorWriter
  149. case strings.Contains(path, "/messages"):
  150. return AnthropicErrorWriter
  151. default:
  152. return OpenAIErrorWriter
  153. }
  154. }