diff --git a/backend/internal/service/admin_service_group_model_allowlist_test.go b/backend/internal/service/admin_service_group_model_allowlist_test.go index fc5a14cfe2..afce719af8 100644 --- a/backend/internal/service/admin_service_group_model_allowlist_test.go +++ b/backend/internal/service/admin_service_group_model_allowlist_test.go @@ -29,7 +29,7 @@ func TestAdminService_CreateGroup_RejectsEmptyEnabledModelAllowlist(t *testing.T require.Nil(t, repo.created, "拒绝时不得落库") } -func TestAdminService_CreateGroup_RejectsInvalidAllowlistWildcard(t *testing.T) { +func TestAdminService_CreateGroup_AcceptsInteriorAllowlistWildcard(t *testing.T) { repo := &groupRepoStubForAdmin{createID: 51} svc := &adminServiceImpl{groupRepo: repo} @@ -40,11 +40,9 @@ func TestAdminService_CreateGroup_RejectsInvalidAllowlistWildcard(t *testing.T) ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4"}}, }) - require.Error(t, err) - appErr := infraerrors.FromError(err) - require.Equal(t, int32(http.StatusBadRequest), appErr.Code) - require.Equal(t, "INVALID_MODEL_ALLOWLIST", appErr.Reason) - require.Nil(t, repo.created, "拒绝时不得落库") + require.NoError(t, err) + require.NotNil(t, repo.created) + require.Equal(t, []string{"gpt-*-5.4"}, repo.created.ModelAllowlist.Models) } func TestAdminService_CreateGroup_NormalizesModelAllowlist(t *testing.T) { @@ -83,7 +81,7 @@ func TestAdminService_UpdateGroup_RejectsEmptyEnabledModelAllowlist(t *testing.T require.Nil(t, repo.updated, "拒绝时不得落库") } -func TestAdminService_UpdateGroup_RejectsInvalidAllowlistWildcard(t *testing.T) { +func TestAdminService_UpdateGroup_AcceptsInteriorAllowlistWildcard(t *testing.T) { existing := &Group{ID: 1, Name: "existing", Platform: PlatformOpenAI, Status: StatusActive} repo := &groupRepoStubForAdmin{getByID: existing} svc := &adminServiceImpl{groupRepo: repo} @@ -92,11 +90,9 @@ func TestAdminService_UpdateGroup_RejectsInvalidAllowlistWildcard(t *testing.T) ModelAllowlist: &GroupModelAllowlist{Enabled: true, Models: []string{"foo-*bar"}}, }) - require.Error(t, err) - appErr := infraerrors.FromError(err) - require.Equal(t, int32(http.StatusBadRequest), appErr.Code) - require.Equal(t, "INVALID_MODEL_ALLOWLIST", appErr.Reason) - require.Nil(t, repo.updated, "拒绝时不得落库") + require.NoError(t, err) + require.NotNil(t, repo.updated) + require.Equal(t, []string{"foo-*bar"}, repo.updated.ModelAllowlist.Models) } func TestAdminService_UpdateGroup_NormalizesAndResetsModelAllowlist(t *testing.T) { diff --git a/backend/internal/service/group_model_allowlist.go b/backend/internal/service/group_model_allowlist.go index 6ef6b41a85..4741e0330a 100644 --- a/backend/internal/service/group_model_allowlist.go +++ b/backend/internal/service/group_model_allowlist.go @@ -45,7 +45,7 @@ func supplementUnmappedOpenAIModels(accounts []Account, models []string) []strin } // normalizeGroupModelAllowlist 归一化管理端提交的分组模型白名单: -// 条目 TrimSpace、按小写去重保序;`*` 只允许出现在条目末尾; +// 条目 TrimSpace、按小写去重保序;`*` 可出现在任意位置; // enabled=true 且列表为空视为配置错误,返回 400 而不是运行时静默放行/拒绝。 func normalizeGroupModelAllowlist(cfg GroupModelAllowlist) (GroupModelAllowlist, error) { out := GroupModelAllowlist{Enabled: cfg.Enabled} @@ -63,9 +63,6 @@ func normalizeGroupModelAllowlist(cfg GroupModelAllowlist) (GroupModelAllowlist, 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 @@ -106,18 +103,8 @@ func (a GroupModelAllowlist) Allows(model string) bool { 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 { + if groupAllowlistPatternMatches(entry, candidate) { return true } } @@ -125,6 +112,29 @@ func (a GroupModelAllowlist) Allows(model string) bool { return false } +// groupAllowlistPatternMatches treats only * as a wildcard, including in the middle +// or at either end. Matching is anchored and case-insensitive. +func groupAllowlistPatternMatches(pattern, model string) bool { + pattern = strings.ToLower(pattern) + model = strings.ToLower(model) + parts := strings.Split(pattern, "*") + if len(parts) == 1 { + return pattern == model + } + if !strings.HasPrefix(model, parts[0]) { + return false + } + model = model[len(parts[0]):] + for _, part := range parts[1 : len(parts)-1] { + index := strings.Index(model, part) + if index < 0 { + return false + } + model = model[index+len(part):] + } + return strings.HasSuffix(model, parts[len(parts)-1]) +} + // groupModelAllowlistCandidates 返回客户端模型名在白名单匹配中的候选形式(均已小写)。 func groupModelAllowlistCandidates(model string) []string { candidates := make([]string, 0, 4) @@ -151,6 +161,9 @@ func groupModelAllowlistCandidates(model string) []string { // FilterForListing 按白名单条目顺序生成模型列表输出。 // 精确条目沿用既有规则:只有出现在 source(账号映射键 ∪ 平台默认列表)的模式 // 集合中才输出;通配条目展开为 source 中所有匹配项并保持 source 顺序;全局去重。 +// 当 source 是前缀通配模式且白名单模式的固定前缀位于该前缀之内时, +// 可直接输出白名单模式:它的所有实例都属于 source。其他未处理的无限 +// 交集不会被枚举为模型 ID,列表可能省略仍可请求的模型。 func (a GroupModelAllowlist) FilterForListing(source []string) []string { if !a.Enabled { return source @@ -188,12 +201,14 @@ func (a GroupModelAllowlist) FilterForListing(source []string) []string { if entry == "" { continue } - if strings.HasSuffix(entry, "*") { - // 空 前缀(裸 `*` 条目)匹配全部来源,与 Allows 的全放行语义一致。 - prefix := strings.ToLower(strings.TrimSuffix(entry, "*")) + if strings.Contains(entry, "*") { for _, pattern := range patterns { - if strings.HasPrefix(strings.ToLower(pattern), prefix) { + if groupAllowlistPatternMatches(entry, pattern) { add(pattern) + } else if strings.HasSuffix(pattern, "*") && + !strings.Contains(strings.TrimSuffix(pattern, "*"), "*") && + strings.HasPrefix(strings.ToLower(entry), strings.ToLower(strings.TrimSuffix(pattern, "*"))) { + add(entry) } } continue diff --git a/backend/internal/service/group_model_allowlist_test.go b/backend/internal/service/group_model_allowlist_test.go index 0a8988112d..9d3ecf95e6 100644 --- a/backend/internal/service/group_model_allowlist_test.go +++ b/backend/internal/service/group_model_allowlist_test.go @@ -41,9 +41,9 @@ func TestNormalizeGroupModelAllowlist(t *testing.T) { want: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.5-*"}}, }, { - name: "wildcard in the middle is rejected", - in: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4"}}, - wantErr: "INVALID_MODEL_ALLOWLIST", + name: "wildcards at any position are accepted", + in: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4", "*codex", "gpt-*-codex-*"}}, + want: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4", "*codex", "gpt-*-codex-*"}}, }, { name: "bare wildcard is accepted as allow-all", @@ -52,9 +52,9 @@ func TestNormalizeGroupModelAllowlist(t *testing.T) { wantErr: "", }, { - name: "disabled config with invalid wildcard still rejected", - in: GroupModelAllowlist{Enabled: false, Models: []string{"foo-*bar"}}, - wantErr: "INVALID_MODEL_ALLOWLIST", + name: "disabled config preserves interior wildcard", + in: GroupModelAllowlist{Enabled: false, Models: []string{"foo-*bar"}}, + want: GroupModelAllowlist{Enabled: false, Models: []string{"foo-*bar"}}, }, } @@ -106,6 +106,7 @@ func TestGroupModelAllowlistAllows(t *testing.T) { {name: "openai reasoning suffix on unlisted model", model: "gpt-4.1-low", want: false}, {name: "trailing wildcard prefix match", model: "grok-4.6", want: true}, {name: "trailing wildcard requires prefix", model: "grok", want: false}, + {name: "interior and suffix wildcard", model: "gpt-5-codex-fast", want: false}, {name: "empty model passes (handler decides required-ness)", model: "", want: true}, } @@ -117,6 +118,20 @@ func TestGroupModelAllowlistAllows(t *testing.T) { }) } + t.Run("wildcards match at any position and remain anchored", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex-*", "*sonnet*", "gemini-?.*-pro"}} + for _, model := range []string{"gpt-5-codex-high", "CLAUDE-SONNET-4.5", "models/claude-sonnet-4.5"} { + if !cfg.Allows(model) { + t.Fatalf("expected %q to match", model) + } + } + for _, model := range []string{"xgpt-5-codex-high", "gpt-5-codex", "gemini-2.5-pro"} { + if cfg.Allows(model) { + t.Fatalf("expected %q not to match", model) + } + } + }) + t.Run("disabled allowlist allows everything", func(t *testing.T) { disabled := GroupModelAllowlist{Enabled: false, Models: []string{"only-model"}} if !disabled.Allows("anything") { @@ -191,6 +206,42 @@ func TestGroupModelAllowlistFilterForListing(t *testing.T) { } }) + t.Run("interior and leading wildcards expand source models", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex", "*sonnet*"}} + got := cfg.FilterForListing(source) + want := []string{"gpt-5.5-codex", "claude-sonnet-4.5"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("got %#v want %#v", got, want) + } + }) + + t.Run("source prefix wildcard retains only a safe infinite intersection", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex"}} + if !cfg.Allows("gpt-5-codex") || cfg.Allows("gpt-5-mini") { + t.Fatal("request admission must distinguish allowed and denied models") + } + got := cfg.FilterForListing([]string{"gpt-*"}) + if strings.Join(got, ",") != "gpt-*-codex" { + t.Fatalf("expected safe allowlist pattern, got %#v", got) + } + if groupAllowlistPatternMatches(got[0], "gpt-5-mini") { + t.Fatal("listing pattern must not expose unauthorized models") + } + }) + + t.Run("unrepresentable wildcard intersections do not leak source patterns", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex"}} + for _, source := range [][]string{{"gpt-5*"}, {"*-codex"}} { + if got := cfg.FilterForListing(source); len(got) != 0 { + t.Fatalf("source %#v would expose unauthorized models: %#v", source, got) + } + } + leadingWildcard := GroupModelAllowlist{Enabled: true, Models: []string{"*codex"}} + if got := leadingWildcard.FilterForListing([]string{"gpt-*"}); len(got) != 0 { + t.Fatalf("source prefix would expose unauthorized models: %#v", got) + } + }) + t.Run("mixed exact and wildcard dedupes globally", func(t *testing.T) { cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*", "gpt-5.4"}} got := cfg.FilterForListing(source) diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 8c04ed51e3..97f9ae9553 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -1143,19 +1143,18 @@ export default { }, modelAllowlist: { title: 'Model Allowlist', - hint: 'When enabled, models outside the allowlist are rejected with 404 model_not_found, and model listing endpoints only show allowlisted models. Entries support exact model IDs and trailing * wildcards. Note: Claude Code probes with haiku-family models for titles/summaries and /messages/count_tokens is also allowlist-controlled, so make sure the small models you need are selected too.', + hint: 'When enabled, models outside the allowlist are rejected with 404 model_not_found, and model listing endpoints only show allowlisted models. Entries support exact model IDs and * wildcards anywhere (e.g., gpt-*-codex). Note: Claude Code probes with haiku-family models for titles/summaries and /messages/count_tokens is also allowlist-controlled, so make sure the small models you need are selected too.', loading: 'Loading candidate models...', empty: 'No candidate models; add custom entries below', selectedSummary: 'Selected {selected} / {total}', selectAll: 'Select all', invertSelection: 'Invert', wildcardTag: 'wildcard', - customPlaceholder: 'Custom entry, e.g. claude-* or gpt-5.5-codex', + customPlaceholder: 'Custom entry, e.g. gpt-*-codex or claude-*', addCustom: 'Add', emptySelectionError: 'The model allowlist is enabled; select or add at least one model entry', errors: { empty: 'Please enter a model entry', - invalidWildcard: 'Wildcard * is only allowed at the end of an entry', duplicate: 'This entry already exists' } }, diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 1ec1b74283..1e8a9887c5 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1140,19 +1140,18 @@ export default { }, modelAllowlist: { title: '模型白名单', - hint: '开启后,不在白名单中的模型会被拒绝(404 model_not_found),模型列表接口也只展示白名单内的模型。条目支持精确模型 ID 与末尾 * 通配。注意:Claude Code 会用 haiku 系小模型做标题/摘要等探测,/messages/count_tokens 同样受白名单控制,请一并勾选所需的小模型。', + hint: '开启后,不在白名单中的模型会被拒绝(404 model_not_found),模型列表接口也只展示白名单内的模型。条目支持精确模型 ID 与任意位置的 * 通配(如 gpt-*-codex)。注意:Claude Code 会用 haiku 系小模型做标题/摘要等探测,/messages/count_tokens 同样受白名单控制,请一并勾选所需的小模型。', loading: '正在加载候选模型...', empty: '暂无候选模型,可在下方手工添加条目', selectedSummary: '已选 {selected} / {total}', selectAll: '全选', invertSelection: '反选', wildcardTag: '通配', - customPlaceholder: '自定义条目,如 claude-* 或 gpt-5.5-codex', + customPlaceholder: '自定义条目,如 gpt-*-codex 或 claude-*', addCustom: '添加', emptySelectionError: '模型白名单已开启,请至少选择或添加一个模型条目', errors: { empty: '请输入模型条目', - invalidWildcard: '通配符 * 只能出现在条目末尾', duplicate: '该条目已存在' } }, diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 471c4be44c..66febc8bda 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -838,7 +838,7 @@ {{ item.id }} {{ t("admin.groups.modelAllowlist.wildcardTag") }} @@ -2478,7 +2478,7 @@ {{ item.id }} {{ t("admin.groups.modelAllowlist.wildcardTag") }} diff --git a/frontend/src/views/admin/__tests__/groupModelAllowlist.spec.ts b/frontend/src/views/admin/__tests__/groupModelAllowlist.spec.ts index b36212a8c0..7f575ab788 100644 --- a/frontend/src/views/admin/__tests__/groupModelAllowlist.spec.ts +++ b/frontend/src/views/admin/__tests__/groupModelAllowlist.spec.ts @@ -149,9 +149,12 @@ describe("addCustomModelAllowlistItem", () => { expect(addCustomModelAllowlistItem(state(), " ")).toBe("empty"); }); - it("rejects wildcards that are not trailing", () => { - expect(addCustomModelAllowlistItem(state(), "gpt-*-5.4")).toBe("invalid_wildcard"); - expect(addCustomModelAllowlistItem(state(), "gpt-*-codex-*")).toBe("invalid_wildcard"); + it("accepts wildcards at any position", () => { + const s = state(); + expect(addCustomModelAllowlistItem(s, "gpt-*-5.4")).toBeNull(); + expect(addCustomModelAllowlistItem(s, "*codex")).toBeNull(); + expect(addCustomModelAllowlistItem(s, "gpt-*-codex-*")).toBeNull(); + expect(buildModelAllowlistConfig(s).models).toEqual(["gpt-*-5.4", "*codex", "gpt-*-codex-*"]); }); it("rejects duplicates case-insensitively", () => { diff --git a/frontend/src/views/admin/groupModelAllowlist.ts b/frontend/src/views/admin/groupModelAllowlist.ts index 13b5a01919..c4d12e5e07 100644 --- a/frontend/src/views/admin/groupModelAllowlist.ts +++ b/frontend/src/views/admin/groupModelAllowlist.ts @@ -15,7 +15,7 @@ export interface ModelAllowlistState { } // 自定义条目校验错误码,由视图映射为 i18n 提示。 -export type ModelAllowlistAddError = 'empty' | 'invalid_wildcard' | 'duplicate' +export type ModelAllowlistAddError = 'empty' | 'duplicate' export const createModelAllowlistState = ( config?: Partial | null, @@ -106,7 +106,7 @@ export const moveModelAllowlistItem = ( } // addCustomModelAllowlistItem 把手工输入的条目追加到白名单末尾(选中状态)。 -// 去重;`*` 只允许出现在末尾。返回错误码或 null(成功)。 +// 去重;`*` 可出现在任意位置。返回错误码或 null(成功)。 export const addCustomModelAllowlistItem = ( state: ModelAllowlistState, raw: string, @@ -115,9 +115,6 @@ export const addCustomModelAllowlistItem = ( if (!entry) { return 'empty' } - if (entry.slice(0, -1).includes('*')) { - return 'invalid_wildcard' - } if ( state.items.some(item => item.id.toLowerCase() === entry.toLowerCase()) || state.savedModels.some(model => model.toLowerCase() === entry.toLowerCase())