gateway_model_allowlist_listing_test.go 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. package handler
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "testing"
  8. "github.com/Wei-Shaw/sub2api/internal/service"
  9. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. )
  13. // 通配条目在 /v1/models 中展开为候选来源中所有匹配项,保持来源顺序。
  14. func TestGatewayModels_ModelAllowlistWildcardExpandsAgainstSource(t *testing.T) {
  15. gin.SetMode(gin.TestMode)
  16. groupID := int64(31)
  17. h := newGatewayModelsHandlerForTest(
  18. &gatewayModelsAccountRepoStub{
  19. byGroup: map[int64][]service.Account{
  20. groupID: {
  21. {
  22. ID: 1,
  23. Platform: service.PlatformOpenAI,
  24. Credentials: map[string]any{
  25. "model_mapping": map[string]any{
  26. "gpt-5.4": "gpt-5.4",
  27. "gpt-5.5-codex": "gpt-5.5-codex",
  28. "gpt-5.5-mini": "gpt-5.5-mini",
  29. "other-foo": "other-foo",
  30. },
  31. },
  32. },
  33. },
  34. },
  35. },
  36. )
  37. rec := httptest.NewRecorder()
  38. c, _ := gin.CreateTestContext(rec)
  39. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  40. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  41. Group: &service.Group{
  42. ID: groupID,
  43. Platform: service.PlatformOpenAI,
  44. ModelAllowlist: service.GroupModelAllowlist{
  45. Enabled: true,
  46. Models: []string{"gpt-5.5-*", "gpt-5.4"},
  47. },
  48. },
  49. })
  50. h.Models(c)
  51. require.Equal(t, http.StatusOK, rec.Code)
  52. var got gatewayModelsResponseForTest
  53. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  54. require.Equal(t, []string{"gpt-5.5-codex", "gpt-5.5-mini", "gpt-5.4"}, modelIDsForTest(got.Data))
  55. }
  56. // geminiAllowlistAccountRepoStub 在 gatewayModelsAccountRepoStub 之上补充
  57. // Gemini 兼容层用到的按平台过滤查询(分组内无任何账号,触发 fallback 列表)。
  58. type geminiAllowlistAccountRepoStub struct {
  59. gatewayModelsAccountRepoStub
  60. }
  61. func (s *geminiAllowlistAccountRepoStub) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]service.Account, error) {
  62. allowed := make(map[string]struct{}, len(platforms))
  63. for _, platform := range platforms {
  64. allowed[platform] = struct{}{}
  65. }
  66. accounts := s.byGroup[groupID]
  67. filtered := make([]service.Account, 0, len(accounts))
  68. for _, account := range accounts {
  69. if _, ok := allowed[account.Platform]; ok {
  70. filtered = append(filtered, account)
  71. }
  72. }
  73. return filtered, nil
  74. }
  75. // Gemini 原生 /v1beta/models:白名单开启时过滤 fallback 列表的 models[].name。
  76. func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) {
  77. gin.SetMode(gin.TestMode)
  78. repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{
  79. byGroup: map[int64][]service.Account{
  80. // 只有 antigravity 账号:Gemini 选号失败后回落静态模型列表。
  81. 41: {{ID: 2, Platform: service.PlatformAntigravity, Status: service.StatusActive, Schedulable: true}},
  82. },
  83. }}
  84. h := &GatewayHandler{
  85. geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil),
  86. }
  87. rec := httptest.NewRecorder()
  88. c, _ := gin.CreateTestContext(rec)
  89. c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
  90. geminiGroupID := int64(41)
  91. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  92. GroupID: &geminiGroupID,
  93. Group: &service.Group{
  94. ID: geminiGroupID,
  95. Platform: service.PlatformGemini,
  96. ModelAllowlist: service.GroupModelAllowlist{
  97. Enabled: true,
  98. Models: []string{"gemini-2.5-pro", "gemini-3-*"},
  99. },
  100. },
  101. })
  102. h.GeminiV1BetaListModels(c)
  103. require.Equal(t, http.StatusOK, rec.Code)
  104. var got struct {
  105. Models []struct {
  106. Name string `json:"name"`
  107. } `json:"models"`
  108. }
  109. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  110. names := make([]string, 0, len(got.Models))
  111. for _, model := range got.Models {
  112. names = append(names, model.Name)
  113. }
  114. // models/ 前缀的候选形式也应命中条目。
  115. require.Contains(t, names, "models/gemini-2.5-pro")
  116. require.Contains(t, names, "models/gemini-3-pro-preview")
  117. require.NotContains(t, names, "models/gemini-2.5-flash")
  118. require.NotContains(t, names, "models/gemini-2.0-flash")
  119. }
  120. // Antigravity /antigravity/models:白名单开启时按条目过滤静态列表。
  121. func TestAntigravityModels_FiltersByAllowlist(t *testing.T) {
  122. gin.SetMode(gin.TestMode)
  123. h := &GatewayHandler{}
  124. rec := httptest.NewRecorder()
  125. c, _ := gin.CreateTestContext(rec)
  126. c.Request = httptest.NewRequest(http.MethodGet, "/antigravity/models", nil)
  127. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  128. Group: &service.Group{
  129. ID: 51,
  130. Platform: service.PlatformAntigravity,
  131. ModelAllowlist: service.GroupModelAllowlist{
  132. Enabled: true,
  133. Models: []string{"gemini-2.5-flash"},
  134. },
  135. },
  136. })
  137. h.AntigravityModels(c)
  138. require.Equal(t, http.StatusOK, rec.Code)
  139. var got struct {
  140. Object string `json:"object"`
  141. Data []struct {
  142. ID string `json:"id"`
  143. } `json:"data"`
  144. }
  145. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  146. require.Equal(t, "list", got.Object)
  147. // -thinking 后缀的宽容规则:gemini-2.5-flash-thinking 也命中 gemini-2.5-flash 条目。
  148. require.Len(t, got.Data, 2)
  149. require.Equal(t, "gemini-2.5-flash", got.Data[0].ID)
  150. require.Equal(t, "gemini-2.5-flash-thinking", got.Data[1].ID)
  151. }
  152. func TestFilterUpstreamGeminiModelsBody(t *testing.T) {
  153. allowlist := service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-2.5-pro"}}
  154. t.Run("full hit returns original body with dropped=false", func(t *testing.T) {
  155. body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-pro"}]}`)
  156. filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
  157. require.True(t, ok)
  158. require.False(t, dropped, "full hit must keep the original response so headers pass through")
  159. require.Equal(t, string(body), string(filtered))
  160. })
  161. t.Run("partial hit filters models and preserves envelope fields", func(t *testing.T) {
  162. body := []byte(`{"nextPageToken":"tok","models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-flash"}],"other":"kept"}`)
  163. filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
  164. require.True(t, ok)
  165. require.True(t, dropped)
  166. require.JSONEq(t, `{"nextPageToken":"tok","other":"kept","models":[{"name":"models/gemini-2.5-pro"}]}`, string(filtered))
  167. })
  168. t.Run("models prefix stripped candidate form matches entry", func(t *testing.T) {
  169. body := []byte(`{"models":[{"name":"gemini-2.5-pro"}]}`)
  170. filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
  171. require.True(t, ok)
  172. require.False(t, dropped)
  173. require.Equal(t, string(body), string(filtered))
  174. })
  175. t.Run("missing models field passes through", func(t *testing.T) {
  176. body := []byte(`{"error":"odd upstream"}`)
  177. _, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
  178. require.True(t, ok)
  179. require.False(t, dropped)
  180. })
  181. t.Run("invalid JSON signals passthrough", func(t *testing.T) {
  182. _, dropped, ok := filterUpstreamGeminiModelsBody([]byte(`not-json`), allowlist)
  183. require.False(t, ok)
  184. require.False(t, dropped)
  185. })
  186. }
  187. func TestFilterBatchImageModelsByAllowlist(t *testing.T) {
  188. models := []service.BatchImagePublicModel{
  189. {ID: "gemini-2.5-flash-image", Object: "image.batch.model", Provider: "gemini_api"},
  190. {ID: "gemini-3-pro-image", Object: "image.batch.model", Provider: "vertex"},
  191. {ID: "gpt-image-1", Object: "image.batch.model", Provider: "openai"},
  192. }
  193. t.Run("disabled allowlist keeps everything", func(t *testing.T) {
  194. got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{Enabled: false})
  195. require.Equal(t, models, got)
  196. })
  197. t.Run("exact and wildcard entries filter by model id", func(t *testing.T) {
  198. got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{
  199. Enabled: true,
  200. Models: []string{"gemini-3-*", "gpt-image-1"},
  201. })
  202. require.Equal(t, []string{"gemini-3-pro-image", "gpt-image-1"}, []string{got[0].ID, got[1].ID})
  203. require.Len(t, got, 2)
  204. })
  205. t.Run("no match yields empty list", func(t *testing.T) {
  206. got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{
  207. Enabled: true,
  208. Models: []string{"claude-*"},
  209. })
  210. require.Empty(t, got)
  211. })
  212. }