group_model_allowlist_test.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. package service
  2. import (
  3. "strings"
  4. "testing"
  5. )
  6. func TestNormalizeGroupModelAllowlist(t *testing.T) {
  7. tests := []struct {
  8. name string
  9. in GroupModelAllowlist
  10. want GroupModelAllowlist
  11. wantErr string
  12. }{
  13. {
  14. name: "trims and dedupes case-insensitively preserving order",
  15. in: GroupModelAllowlist{
  16. Enabled: true,
  17. Models: []string{" gpt-5.4 ", "GPT-5.4", "claude-sonnet-4.5", ""},
  18. },
  19. want: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.4", "claude-sonnet-4.5"}},
  20. },
  21. {
  22. name: "disabled with empty list is fine",
  23. in: GroupModelAllowlist{Enabled: false},
  24. want: GroupModelAllowlist{Enabled: false},
  25. },
  26. {
  27. name: "enabled with empty list is rejected",
  28. in: GroupModelAllowlist{Enabled: true},
  29. wantErr: "INVALID_MODEL_ALLOWLIST",
  30. },
  31. {
  32. name: "enabled with only blank entries is rejected",
  33. in: GroupModelAllowlist{Enabled: true, Models: []string{" ", ""}},
  34. wantErr: "INVALID_MODEL_ALLOWLIST",
  35. },
  36. {
  37. name: "trailing wildcard is accepted",
  38. in: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.5-*"}},
  39. want: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.5-*"}},
  40. },
  41. {
  42. name: "wildcard in the middle is rejected",
  43. in: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4"}},
  44. wantErr: "INVALID_MODEL_ALLOWLIST",
  45. },
  46. {
  47. name: "bare wildcard is accepted as allow-all",
  48. in: GroupModelAllowlist{Enabled: true, Models: []string{"*"}},
  49. want: GroupModelAllowlist{Enabled: true, Models: []string{"*"}},
  50. wantErr: "",
  51. },
  52. {
  53. name: "disabled config with invalid wildcard still rejected",
  54. in: GroupModelAllowlist{Enabled: false, Models: []string{"foo-*bar"}},
  55. wantErr: "INVALID_MODEL_ALLOWLIST",
  56. },
  57. }
  58. for _, tt := range tests {
  59. t.Run(tt.name, func(t *testing.T) {
  60. got, err := normalizeGroupModelAllowlist(tt.in)
  61. if tt.wantErr != "" {
  62. if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
  63. t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
  64. }
  65. return
  66. }
  67. if err != nil {
  68. t.Fatalf("unexpected error: %v", err)
  69. }
  70. if got.Enabled != tt.want.Enabled {
  71. t.Fatalf("enabled mismatch: got %v want %v", got.Enabled, tt.want.Enabled)
  72. }
  73. if len(got.Models) != len(tt.want.Models) {
  74. t.Fatalf("models mismatch: got %#v want %#v", got.Models, tt.want.Models)
  75. }
  76. for i := range tt.want.Models {
  77. if got.Models[i] != tt.want.Models[i] {
  78. t.Fatalf("models[%d] mismatch: got %q want %q", i, got.Models[i], tt.want.Models[i])
  79. }
  80. }
  81. })
  82. }
  83. }
  84. func TestGroupModelAllowlistAllows(t *testing.T) {
  85. allowlist := GroupModelAllowlist{
  86. Enabled: true,
  87. Models: []string{"claude-sonnet-4.5", "gemini-2.5-pro", "gpt-5.5", "grok-*"},
  88. }
  89. tests := []struct {
  90. name string
  91. model string
  92. want bool
  93. }{
  94. {name: "exact match", model: "claude-sonnet-4.5", want: true},
  95. {name: "case-insensitive entry match", model: "Claude-Sonnet-4.5", want: true},
  96. {name: "not listed", model: "claude-opus-4.6", want: false},
  97. {name: "thinking suffix tolerated via claude normalization", model: "claude-sonnet-4.5-thinking", want: true},
  98. {name: "gemini models/ prefix stripped", model: "models/gemini-2.5-pro", want: true},
  99. {name: "gemini models/ prefix not in list", model: "models/gemini-2.5-flash", want: false},
  100. {name: "openai reasoning suffix normalizes to base model", model: "gpt-5.5-codex-high", want: true},
  101. {name: "openai reasoning suffix on unlisted model", model: "gpt-4.1-low", want: false},
  102. {name: "trailing wildcard prefix match", model: "grok-4.6", want: true},
  103. {name: "trailing wildcard requires prefix", model: "grok", want: false},
  104. {name: "empty model passes (handler decides required-ness)", model: "", want: true},
  105. }
  106. for _, tt := range tests {
  107. t.Run(tt.name, func(t *testing.T) {
  108. if got := allowlist.Allows(tt.model); got != tt.want {
  109. t.Fatalf("Allows(%q) = %v, want %v", tt.model, got, tt.want)
  110. }
  111. })
  112. }
  113. t.Run("disabled allowlist allows everything", func(t *testing.T) {
  114. disabled := GroupModelAllowlist{Enabled: false, Models: []string{"only-model"}}
  115. if !disabled.Allows("anything") {
  116. t.Fatal("disabled allowlist must allow all models")
  117. }
  118. })
  119. t.Run("enabled empty allowlist denies everything", func(t *testing.T) {
  120. empty := GroupModelAllowlist{Enabled: true}
  121. if empty.Allows("anything") {
  122. t.Fatal("enabled empty allowlist must deny all models")
  123. }
  124. })
  125. t.Run("bare wildcard allows everything", func(t *testing.T) {
  126. all := GroupModelAllowlist{Enabled: true, Models: []string{"*"}}
  127. if !all.Allows("claude-opus-4.6") || !all.Allows("models/gemini-2.5-pro") {
  128. t.Fatal("bare wildcard must allow all models")
  129. }
  130. })
  131. }
  132. func TestGroupModelAllowlistEnabled(t *testing.T) {
  133. var nilGroup *Group
  134. if nilGroup.ModelAllowlistEnabled() {
  135. t.Fatal("nil group must not report enabled")
  136. }
  137. group := &Group{}
  138. if group.ModelAllowlistEnabled() {
  139. t.Fatal("default group must not report enabled")
  140. }
  141. group.ModelAllowlist = GroupModelAllowlist{Enabled: true, Models: []string{"m"}}
  142. if !group.ModelAllowlistEnabled() {
  143. t.Fatal("enabled group must report enabled")
  144. }
  145. }
  146. func TestGroupModelAllowlistFilterForListing(t *testing.T) {
  147. source := []string{"claude-opus-4.6", "claude-sonnet-4.5", "gpt-5.4", "gpt-5.5-codex", "gpt-5.5-mini", "grok-4.6"}
  148. t.Run("passthrough when disabled", func(t *testing.T) {
  149. disabled := GroupModelAllowlist{Enabled: false, Models: []string{"gpt-5.4"}}
  150. got := disabled.FilterForListing(source)
  151. if len(got) != len(source) {
  152. t.Fatalf("disabled allowlist must not filter, got %#v", got)
  153. }
  154. })
  155. t.Run("exact entries keep entry order and require source membership", func(t *testing.T) {
  156. cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.5-codex", "claude-sonnet-4.5", "claude-haiku-4.5"}}
  157. got := cfg.FilterForListing(source)
  158. want := []string{"gpt-5.5-codex", "claude-sonnet-4.5"}
  159. if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
  160. t.Fatalf("got %#v want %#v", got, want)
  161. }
  162. })
  163. t.Run("bare wildcard expands to the full source list", func(t *testing.T) {
  164. cfg := GroupModelAllowlist{Enabled: true, Models: []string{"*"}}
  165. got := cfg.FilterForListing([]string{"gpt-5.4"})
  166. if strings.Join(got, ",") != "gpt-5.4" {
  167. t.Fatalf("bare wildcard must expose the full source, got %#v", got)
  168. }
  169. })
  170. t.Run("wildcard entries expand to source order", func(t *testing.T) {
  171. cfg := GroupModelAllowlist{Enabled: true, Models: []string{"claude-*", "gpt-5.5-*"}}
  172. got := cfg.FilterForListing(source)
  173. want := []string{"claude-opus-4.6", "claude-sonnet-4.5", "gpt-5.5-codex", "gpt-5.5-mini"}
  174. if strings.Join(got, ",") != strings.Join(want, ",") {
  175. t.Fatalf("got %#v want %#v", got, want)
  176. }
  177. })
  178. t.Run("mixed exact and wildcard dedupes globally", func(t *testing.T) {
  179. cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*", "gpt-5.4"}}
  180. got := cfg.FilterForListing(source)
  181. want := []string{"gpt-5.4", "gpt-5.5-codex", "gpt-5.5-mini"}
  182. if strings.Join(got, ",") != strings.Join(want, ",") {
  183. t.Fatalf("got %#v want %#v", got, want)
  184. }
  185. })
  186. t.Run("thinking-tolerant exact entries resolve against source", func(t *testing.T) {
  187. cfg := GroupModelAllowlist{Enabled: true, Models: []string{"claude-sonnet-4.5-thinking"}}
  188. got := cfg.FilterForListing(source)
  189. want := []string{"claude-sonnet-4.5-thinking"}
  190. if strings.Join(got, ",") != strings.Join(want, ",") {
  191. t.Fatalf("got %#v want %#v", got, want)
  192. }
  193. })
  194. t.Run("exact entries match case-insensitively against source", func(t *testing.T) {
  195. // 归一化保留首次出现的拼写:条目大写、来源小写也必须命中,
  196. // 与 Allows 的大小写不敏感语义保持「准入允许 ⇒ 列表可见」。
  197. cfg := GroupModelAllowlist{Enabled: true, Models: []string{"GPT-5.4"}}
  198. got := cfg.FilterForListing([]string{"gpt-5.4"})
  199. want := []string{"GPT-5.4"}
  200. if strings.Join(got, ",") != strings.Join(want, ",") {
  201. t.Fatalf("got %#v want %#v", got, want)
  202. }
  203. })
  204. t.Run("wildcard source patterns match case-insensitively", func(t *testing.T) {
  205. cfg := GroupModelAllowlist{Enabled: true, Models: []string{"claude-sonnet-4.20250514"}}
  206. got := cfg.FilterForListing([]string{"Claude-Sonnet-4*"})
  207. want := []string{"claude-sonnet-4.20250514"}
  208. if strings.Join(got, ",") != strings.Join(want, ",") {
  209. t.Fatalf("got %#v want %#v", got, want)
  210. }
  211. })
  212. t.Run("wildcard source patterns whitelist exact entries", func(t *testing.T) {
  213. cfg := GroupModelAllowlist{Enabled: true, Models: []string{"claude-sonnet-4.20250514"}}
  214. got := cfg.FilterForListing([]string{"claude-sonnet-4*"})
  215. want := []string{"claude-sonnet-4.20250514"}
  216. if strings.Join(got, ",") != strings.Join(want, ",") {
  217. t.Fatalf("got %#v want %#v", got, want)
  218. }
  219. })
  220. t.Run("empty source yields empty output", func(t *testing.T) {
  221. cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.4"}}
  222. if got := cfg.FilterForListing(nil); len(got) != 0 {
  223. t.Fatalf("expected empty output, got %#v", got)
  224. }
  225. })
  226. t.Run("enabled empty config yields empty output", func(t *testing.T) {
  227. cfg := GroupModelAllowlist{Enabled: true}
  228. if got := cfg.FilterForListing(source); len(got) != 0 {
  229. t.Fatalf("expected empty output for enabled empty config, got %#v", got)
  230. }
  231. })
  232. }