| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229 |
- package service
- import (
- "net/http"
- "slices"
- "strings"
- "github.com/Wei-Shaw/sub2api/internal/domain"
- "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
- )
- // GroupModelAllowlist 是 service 层的分组模型白名单(与 domain.GroupModelAllowlist
- // 字段一致,ent 持久化用 domain 类型,边界处显式转换)。
- type GroupModelAllowlist struct {
- Enabled bool `json:"enabled"`
- Models []string `json:"models,omitempty"`
- }
- // DomainGroupModelAllowlist 把 service 白名单转换为 ent 持久化使用的 domain 类型。
- func DomainGroupModelAllowlist(cfg GroupModelAllowlist) domain.GroupModelAllowlist {
- return domain.GroupModelAllowlist{Enabled: cfg.Enabled, Models: cfg.Models}
- }
- // GroupModelAllowlistFromDomain 把 ent 读出的 domain 白名单转换为 service 类型。
- func GroupModelAllowlistFromDomain(cfg domain.GroupModelAllowlist) GroupModelAllowlist {
- return GroupModelAllowlist{Enabled: cfg.Enabled, Models: cfg.Models}
- }
- // supplementUnmappedOpenAIModels ensures a partial mapping catalog does not
- // hide models from unmapped OpenAI accounts. An empty catalog is left unchanged
- // so callers retain their existing discovery fallback.
- func supplementUnmappedOpenAIModels(accounts []Account, models []string) []string {
- if len(models) == 0 {
- return models
- }
- for i := range accounts {
- account := &accounts[i]
- if account.Platform == PlatformOpenAI && len(account.GetModelMapping()) == 0 {
- return dedupeAndSortModelIDs(slices.Concat(models, openai.DefaultModelIDs()))
- }
- }
- return models
- }
- // normalizeGroupModelAllowlist 归一化管理端提交的分组模型白名单:
- // 条目 TrimSpace、按小写去重保序;`*` 只允许出现在条目末尾;
- // enabled=true 且列表为空视为配置错误,返回 400 而不是运行时静默放行/拒绝。
- func normalizeGroupModelAllowlist(cfg GroupModelAllowlist) (GroupModelAllowlist, error) {
- out := GroupModelAllowlist{Enabled: cfg.Enabled}
- if len(cfg.Models) == 0 {
- if out.Enabled {
- return out, infraerrors.New(http.StatusBadRequest, "INVALID_MODEL_ALLOWLIST", "model allowlist cannot be enabled with an empty model list")
- }
- return out, nil
- }
- seen := make(map[string]struct{}, len(cfg.Models))
- out.Models = make([]string, 0, len(cfg.Models))
- for _, model := range cfg.Models {
- model = strings.TrimSpace(model)
- if model == "" {
- continue
- }
- if strings.Contains(strings.TrimSuffix(model, "*"), "*") {
- return out, infraerrors.New(http.StatusBadRequest, "INVALID_MODEL_ALLOWLIST", `wildcard "*" is only allowed at the end of an allowlist entry`)
- }
- key := strings.ToLower(model)
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- out.Models = append(out.Models, model)
- }
- if len(out.Models) == 0 {
- if out.Enabled {
- return out, infraerrors.New(http.StatusBadRequest, "INVALID_MODEL_ALLOWLIST", "model allowlist cannot be enabled with an empty model list")
- }
- out.Models = nil
- }
- return out, nil
- }
- // ModelAllowlistEnabled 报告该分组是否启用了模型白名单。
- // 开启后所有携带模型的网关请求与模型列表接口都受白名单约束。
- func (g *Group) ModelAllowlistEnabled() bool {
- return g != nil && g.ModelAllowlist.Enabled
- }
- // Allows 判断客户端请求的模型是否命中白名单。
- // 准入只看客户端书写的模型名,与账号映射、渠道映射、合成路由改写无关;
- // 候选形式覆盖代码中已有的模型名等价规则(Gemini models/ 前缀、
- // Antigravity/Claude -thinking 宽容规则、OpenAI 推理后缀),不做模糊匹配。
- func (a GroupModelAllowlist) Allows(model string) bool {
- if !a.Enabled {
- return true
- }
- model = strings.TrimSpace(model)
- if model == "" {
- return true
- }
- candidates := groupModelAllowlistCandidates(model)
- for _, entry := range a.Models {
- entry = strings.TrimSpace(entry)
- if entry == "" {
- continue
- }
- entry = strings.ToLower(entry)
- if strings.HasSuffix(entry, "*") {
- prefix := strings.TrimSuffix(entry, "*")
- for _, candidate := range candidates {
- if strings.HasPrefix(candidate, prefix) {
- return true
- }
- }
- continue
- }
- for _, candidate := range candidates {
- if candidate == entry {
- return true
- }
- }
- }
- return false
- }
- // groupModelAllowlistCandidates 返回客户端模型名在白名单匹配中的候选形式(均已小写)。
- func groupModelAllowlistCandidates(model string) []string {
- candidates := make([]string, 0, 4)
- add := func(value string) {
- value = strings.ToLower(strings.TrimSpace(value))
- if value == "" {
- return
- }
- for _, existing := range candidates {
- if existing == value {
- return
- }
- }
- candidates = append(candidates, value)
- }
- add(model)
- add(strings.TrimPrefix(model, "models/"))
- add(claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking")))
- add(NormalizeOpenAICompatRequestedModel(model))
- return candidates
- }
- // FilterForListing 按白名单条目顺序生成模型列表输出。
- // 精确条目沿用既有规则:只有出现在 source(账号映射键 ∪ 平台默认列表)的模式
- // 集合中才输出;通配条目展开为 source 中所有匹配项并保持 source 顺序;全局去重。
- func (a GroupModelAllowlist) FilterForListing(source []string) []string {
- if !a.Enabled {
- return source
- }
- if len(a.Models) == 0 {
- // 开启但为空的配置在管理端已被拒绝;对遗留脏数据保持“看到什么 = 能调什么”,
- // 列表与准入同时返回空。
- return nil
- }
- patterns := make([]string, 0, len(source))
- for _, pattern := range source {
- pattern = strings.TrimSpace(pattern)
- if pattern != "" {
- patterns = append(patterns, pattern)
- }
- }
- if len(patterns) == 0 {
- return nil
- }
- seen := make(map[string]struct{}, len(a.Models)+len(patterns))
- filtered := make([]string, 0, len(patterns))
- add := func(model string) {
- key := strings.ToLower(model)
- if _, ok := seen[key]; ok {
- return
- }
- seen[key] = struct{}{}
- filtered = append(filtered, model)
- }
- for _, entry := range a.Models {
- entry = strings.TrimSpace(entry)
- if entry == "" {
- continue
- }
- if strings.HasSuffix(entry, "*") {
- // 空 前缀(裸 `*` 条目)匹配全部来源,与 Allows 的全放行语义一致。
- prefix := strings.ToLower(strings.TrimSuffix(entry, "*"))
- for _, pattern := range patterns {
- if strings.HasPrefix(strings.ToLower(pattern), prefix) {
- add(pattern)
- }
- }
- continue
- }
- if allowlistSourcePatternAllowsModel(patterns, entry) {
- add(entry)
- }
- }
- return filtered
- }
- // allowlistSourcePatternAllowsModel 沿用 filterModelsByCustomList 时代的匹配规则:
- // 精确相等、source 通配模式的前缀匹配,以及 Claude 归一化(-thinking 后缀)
- // 后的精确匹配。比较与 Allows 一律大小写不敏感,保证「准入允许 ⇒ 列表可见」。
- func allowlistSourcePatternAllowsModel(patterns []string, model string) bool {
- for _, pattern := range patterns {
- if strings.EqualFold(pattern, model) {
- return true
- }
- if strings.HasSuffix(pattern, "*") && strings.HasPrefix(strings.ToLower(model), strings.ToLower(strings.TrimSuffix(pattern, "*"))) {
- return true
- }
- }
- normalizedClaudeModel := claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking"))
- if !strings.EqualFold(normalizedClaudeModel, model) {
- for _, pattern := range patterns {
- if strings.EqualFold(pattern, normalizedClaudeModel) {
- return true
- }
- }
- }
- return false
- }
|