Merge pull request #7595 from wucm667/feat/issue-7587-allowlist-glob

feat(groups): support wildcards anywhere in model allowlists
This commit is contained in:
Wesley Liddick
2026-09-28 09:55:55 +08:00
committed by GitHub
8 changed files with 113 additions and 53 deletions
@@ -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) {
@@ -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
@@ -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)
@@ -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'
}
},
@@ -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: '该条目已存在'
}
},
+2 -2
View File
@@ -838,7 +838,7 @@
<span class="min-w-0 flex-1 break-all text-sm text-gray-700 dark:text-gray-300">
{{ item.id }}
<span
v-if="item.id.endsWith('*')"
v-if="item.id.includes('*')"
class="ml-1 rounded bg-primary-50 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>
{{ t("admin.groups.modelAllowlist.wildcardTag") }}
@@ -2478,7 +2478,7 @@
<span class="min-w-0 flex-1 break-all text-sm text-gray-700 dark:text-gray-300">
{{ item.id }}
<span
v-if="item.id.endsWith('*')"
v-if="item.id.includes('*')"
class="ml-1 rounded bg-primary-50 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>
{{ t("admin.groups.modelAllowlist.wildcardTag") }}
@@ -149,9 +149,12 @@ describe("addCustomModelAllowlistItem", () => {
expect(addCustomModelAllowlistItem(state(), " ")).toBe<ModelAllowlistAddError>("empty");
});
it("rejects wildcards that are not trailing", () => {
expect(addCustomModelAllowlistItem(state(), "gpt-*-5.4")).toBe<ModelAllowlistAddError>("invalid_wildcard");
expect(addCustomModelAllowlistItem(state(), "gpt-*-codex-*")).toBe<ModelAllowlistAddError>("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", () => {
@@ -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<ModelAllowlistConfig> | 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())