Files
sub2api/backend/internal/service/group_model_allowlist.go
T
shaw 2031c1d5ad fix(groups): merge main and resolve model allowlist conflicts
Resolve three conflicts from main's #6680 (supplementUnmappedOpenAIModels):

1. gateway_handler.go — keep PR's allowlist refactoring (ModelAllowlistEnabled,
   writeAllowlistedModelsList, modelListingSource); main's OpenAI branch in
   writeModelsList was auto-merged.
2. group_models_list.go (modify/delete) — delete file per PR intent; relocate
   supplementUnmappedOpenAIModels to group_model_allowlist.go where gateway_service
   and openai_codex_models_service can still call it.
3. openai_codex_models_service.go — combine main's supplementUnmappedOpenAIModels
   wrapping with PR's ModelAllowlistEnabled() guard.

Also fix two auto-merged test files that still referenced the old
GroupModelsListConfig / ModelsListConfig types.
2026-09-07 19:44:03 +08:00

230 lines
7.4 KiB
Go

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
}