group_model_allowlist.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. package service
  2. import (
  3. "net/http"
  4. "slices"
  5. "strings"
  6. "github.com/Wei-Shaw/sub2api/internal/domain"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
  8. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  10. )
  11. // GroupModelAllowlist 是 service 层的分组模型白名单(与 domain.GroupModelAllowlist
  12. // 字段一致,ent 持久化用 domain 类型,边界处显式转换)。
  13. type GroupModelAllowlist struct {
  14. Enabled bool `json:"enabled"`
  15. Models []string `json:"models,omitempty"`
  16. }
  17. // DomainGroupModelAllowlist 把 service 白名单转换为 ent 持久化使用的 domain 类型。
  18. func DomainGroupModelAllowlist(cfg GroupModelAllowlist) domain.GroupModelAllowlist {
  19. return domain.GroupModelAllowlist{Enabled: cfg.Enabled, Models: cfg.Models}
  20. }
  21. // GroupModelAllowlistFromDomain 把 ent 读出的 domain 白名单转换为 service 类型。
  22. func GroupModelAllowlistFromDomain(cfg domain.GroupModelAllowlist) GroupModelAllowlist {
  23. return GroupModelAllowlist{Enabled: cfg.Enabled, Models: cfg.Models}
  24. }
  25. // supplementUnmappedOpenAIModels ensures a partial mapping catalog does not
  26. // hide models from unmapped OpenAI accounts. An empty catalog is left unchanged
  27. // so callers retain their existing discovery fallback.
  28. func supplementUnmappedOpenAIModels(accounts []Account, models []string) []string {
  29. if len(models) == 0 {
  30. return models
  31. }
  32. for i := range accounts {
  33. account := &accounts[i]
  34. if account.Platform == PlatformOpenAI && len(account.GetModelMapping()) == 0 {
  35. return dedupeAndSortModelIDs(slices.Concat(models, openai.DefaultModelIDs()))
  36. }
  37. }
  38. return models
  39. }
  40. // normalizeGroupModelAllowlist 归一化管理端提交的分组模型白名单:
  41. // 条目 TrimSpace、按小写去重保序;`*` 只允许出现在条目末尾;
  42. // enabled=true 且列表为空视为配置错误,返回 400 而不是运行时静默放行/拒绝。
  43. func normalizeGroupModelAllowlist(cfg GroupModelAllowlist) (GroupModelAllowlist, error) {
  44. out := GroupModelAllowlist{Enabled: cfg.Enabled}
  45. if len(cfg.Models) == 0 {
  46. if out.Enabled {
  47. return out, infraerrors.New(http.StatusBadRequest, "INVALID_MODEL_ALLOWLIST", "model allowlist cannot be enabled with an empty model list")
  48. }
  49. return out, nil
  50. }
  51. seen := make(map[string]struct{}, len(cfg.Models))
  52. out.Models = make([]string, 0, len(cfg.Models))
  53. for _, model := range cfg.Models {
  54. model = strings.TrimSpace(model)
  55. if model == "" {
  56. continue
  57. }
  58. if strings.Contains(strings.TrimSuffix(model, "*"), "*") {
  59. return out, infraerrors.New(http.StatusBadRequest, "INVALID_MODEL_ALLOWLIST", `wildcard "*" is only allowed at the end of an allowlist entry`)
  60. }
  61. key := strings.ToLower(model)
  62. if _, ok := seen[key]; ok {
  63. continue
  64. }
  65. seen[key] = struct{}{}
  66. out.Models = append(out.Models, model)
  67. }
  68. if len(out.Models) == 0 {
  69. if out.Enabled {
  70. return out, infraerrors.New(http.StatusBadRequest, "INVALID_MODEL_ALLOWLIST", "model allowlist cannot be enabled with an empty model list")
  71. }
  72. out.Models = nil
  73. }
  74. return out, nil
  75. }
  76. // ModelAllowlistEnabled 报告该分组是否启用了模型白名单。
  77. // 开启后所有携带模型的网关请求与模型列表接口都受白名单约束。
  78. func (g *Group) ModelAllowlistEnabled() bool {
  79. return g != nil && g.ModelAllowlist.Enabled
  80. }
  81. // Allows 判断客户端请求的模型是否命中白名单。
  82. // 准入只看客户端书写的模型名,与账号映射、渠道映射、合成路由改写无关;
  83. // 候选形式覆盖代码中已有的模型名等价规则(Gemini models/ 前缀、
  84. // Antigravity/Claude -thinking 宽容规则、OpenAI 推理后缀),不做模糊匹配。
  85. func (a GroupModelAllowlist) Allows(model string) bool {
  86. if !a.Enabled {
  87. return true
  88. }
  89. model = strings.TrimSpace(model)
  90. if model == "" {
  91. return true
  92. }
  93. candidates := groupModelAllowlistCandidates(model)
  94. for _, entry := range a.Models {
  95. entry = strings.TrimSpace(entry)
  96. if entry == "" {
  97. continue
  98. }
  99. entry = strings.ToLower(entry)
  100. if strings.HasSuffix(entry, "*") {
  101. prefix := strings.TrimSuffix(entry, "*")
  102. for _, candidate := range candidates {
  103. if strings.HasPrefix(candidate, prefix) {
  104. return true
  105. }
  106. }
  107. continue
  108. }
  109. for _, candidate := range candidates {
  110. if candidate == entry {
  111. return true
  112. }
  113. }
  114. }
  115. return false
  116. }
  117. // groupModelAllowlistCandidates 返回客户端模型名在白名单匹配中的候选形式(均已小写)。
  118. func groupModelAllowlistCandidates(model string) []string {
  119. candidates := make([]string, 0, 4)
  120. add := func(value string) {
  121. value = strings.ToLower(strings.TrimSpace(value))
  122. if value == "" {
  123. return
  124. }
  125. for _, existing := range candidates {
  126. if existing == value {
  127. return
  128. }
  129. }
  130. candidates = append(candidates, value)
  131. }
  132. add(model)
  133. add(strings.TrimPrefix(model, "models/"))
  134. add(claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking")))
  135. add(NormalizeOpenAICompatRequestedModel(model))
  136. return candidates
  137. }
  138. // FilterForListing 按白名单条目顺序生成模型列表输出。
  139. // 精确条目沿用既有规则:只有出现在 source(账号映射键 ∪ 平台默认列表)的模式
  140. // 集合中才输出;通配条目展开为 source 中所有匹配项并保持 source 顺序;全局去重。
  141. func (a GroupModelAllowlist) FilterForListing(source []string) []string {
  142. if !a.Enabled {
  143. return source
  144. }
  145. if len(a.Models) == 0 {
  146. // 开启但为空的配置在管理端已被拒绝;对遗留脏数据保持“看到什么 = 能调什么”,
  147. // 列表与准入同时返回空。
  148. return nil
  149. }
  150. patterns := make([]string, 0, len(source))
  151. for _, pattern := range source {
  152. pattern = strings.TrimSpace(pattern)
  153. if pattern != "" {
  154. patterns = append(patterns, pattern)
  155. }
  156. }
  157. if len(patterns) == 0 {
  158. return nil
  159. }
  160. seen := make(map[string]struct{}, len(a.Models)+len(patterns))
  161. filtered := make([]string, 0, len(patterns))
  162. add := func(model string) {
  163. key := strings.ToLower(model)
  164. if _, ok := seen[key]; ok {
  165. return
  166. }
  167. seen[key] = struct{}{}
  168. filtered = append(filtered, model)
  169. }
  170. for _, entry := range a.Models {
  171. entry = strings.TrimSpace(entry)
  172. if entry == "" {
  173. continue
  174. }
  175. if strings.HasSuffix(entry, "*") {
  176. // 空 前缀(裸 `*` 条目)匹配全部来源,与 Allows 的全放行语义一致。
  177. prefix := strings.ToLower(strings.TrimSuffix(entry, "*"))
  178. for _, pattern := range patterns {
  179. if strings.HasPrefix(strings.ToLower(pattern), prefix) {
  180. add(pattern)
  181. }
  182. }
  183. continue
  184. }
  185. if allowlistSourcePatternAllowsModel(patterns, entry) {
  186. add(entry)
  187. }
  188. }
  189. return filtered
  190. }
  191. // allowlistSourcePatternAllowsModel 沿用 filterModelsByCustomList 时代的匹配规则:
  192. // 精确相等、source 通配模式的前缀匹配,以及 Claude 归一化(-thinking 后缀)
  193. // 后的精确匹配。比较与 Allows 一律大小写不敏感,保证「准入允许 ⇒ 列表可见」。
  194. func allowlistSourcePatternAllowsModel(patterns []string, model string) bool {
  195. for _, pattern := range patterns {
  196. if strings.EqualFold(pattern, model) {
  197. return true
  198. }
  199. if strings.HasSuffix(pattern, "*") && strings.HasPrefix(strings.ToLower(model), strings.ToLower(strings.TrimSuffix(pattern, "*"))) {
  200. return true
  201. }
  202. }
  203. normalizedClaudeModel := claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking"))
  204. if !strings.EqualFold(normalizedClaudeModel, model) {
  205. for _, pattern := range patterns {
  206. if strings.EqualFold(pattern, normalizedClaudeModel) {
  207. return true
  208. }
  209. }
  210. }
  211. return false
  212. }