gateway_models_test.go 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317
  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/pkg/claude"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  10. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. )
  15. type gatewayModelsAccountRepoStub struct {
  16. service.AccountRepository
  17. byGroup map[int64][]service.Account
  18. }
  19. type gatewayModelsResponseForTest struct {
  20. Object string `json:"object"`
  21. Data []gatewayModelItemForTest `json:"data"`
  22. }
  23. type codexModelsResponseForTest struct {
  24. Models []struct {
  25. Slug string `json:"slug"`
  26. SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"`
  27. InputModalities []string `json:"input_modalities"`
  28. ModelMessages map[string]json.RawMessage `json:"model_messages"`
  29. TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"`
  30. AvailabilityNUX json.RawMessage `json:"availability_nux"`
  31. Upgrade json.RawMessage `json:"upgrade"`
  32. } `json:"models"`
  33. }
  34. type codexReasoningLevelForTest struct {
  35. Effort string `json:"effort"`
  36. }
  37. type gatewayModelItemForTest struct {
  38. ID string `json:"id"`
  39. Object string `json:"object"`
  40. Created int64 `json:"created"`
  41. OwnedBy string `json:"owned_by"`
  42. CreatedAt string `json:"created_at"`
  43. SupportsReasoningEffort bool `json:"supportsReasoningEffort"`
  44. ReasoningEffort string `json:"reasoningEffort"`
  45. ReasoningEfforts []gatewayReasoningEffortOptionForTest `json:"reasoningEfforts"`
  46. }
  47. type gatewayReasoningEffortOptionForTest struct {
  48. Value string `json:"value"`
  49. Label string `json:"label"`
  50. Default bool `json:"default"`
  51. }
  52. func (s *gatewayModelsAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]service.Account, error) {
  53. accounts, ok := s.byGroup[groupID]
  54. if !ok {
  55. return nil, nil
  56. }
  57. out := make([]service.Account, len(accounts))
  58. copy(out, accounts)
  59. return out, nil
  60. }
  61. func (s *gatewayModelsAccountRepoStub) ListByGroup(ctx context.Context, groupID int64) ([]service.Account, error) {
  62. return s.ListSchedulableByGroupID(ctx, groupID)
  63. }
  64. func (s *gatewayModelsAccountRepoStub) ListModelAvailabilityCandidates(ctx context.Context, groupID *int64, _ []string, _ bool) ([]service.Account, error) {
  65. if groupID == nil {
  66. return nil, nil
  67. }
  68. return s.ListSchedulableByGroupID(ctx, *groupID)
  69. }
  70. func newGatewayModelsHandlerForTest(repo service.AccountRepository) *GatewayHandler {
  71. return &GatewayHandler{
  72. gatewayService: service.NewGatewayService(
  73. repo,
  74. nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
  75. nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
  76. ),
  77. }
  78. }
  79. func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) {
  80. antigravityIDs := defaultModelIDsForPlatform(service.PlatformAntigravity)
  81. require.NotEmpty(t, antigravityIDs)
  82. compositeIDs := defaultModelIDsForPlatform(service.PlatformComposite)
  83. require.Contains(t, compositeIDs, antigravityIDs[0])
  84. }
  85. // Scenario: Anthropic defaults contain only Claude while Antigravity keeps its own Gemini models.
  86. func TestDefaultModelIDsForAnthropicExcludeAntigravityGemini(t *testing.T) {
  87. anthropicIDs := defaultModelIDsForPlatform(service.PlatformAnthropic)
  88. require.Contains(t, anthropicIDs, "claude-opus-4-6")
  89. require.NotContains(t, anthropicIDs, "gemini-2.5-flash")
  90. antigravityIDs := defaultModelIDsForPlatform(service.PlatformAntigravity)
  91. require.Contains(t, antigravityIDs, "gemini-2.5-flash")
  92. }
  93. // Scenario: non-OpenAI groups return a Codex manifest instead of a standard model list.
  94. func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) {
  95. tests := []struct {
  96. name string
  97. platform string
  98. model string
  99. efforts []string
  100. modalities []string
  101. }{
  102. {
  103. name: "Grok",
  104. platform: service.PlatformGrok,
  105. model: "grok-4.6",
  106. efforts: []string{"low", "medium", "high", "xhigh"},
  107. modalities: []string{"text", "image"},
  108. },
  109. {
  110. name: "DeepSeek",
  111. platform: service.PlatformDeepseek,
  112. model: "deepseek-v4-pro",
  113. efforts: []string{"low", "high", "max"},
  114. modalities: []string{"text"},
  115. },
  116. {
  117. name: "provider-qualified Claude",
  118. platform: service.PlatformAnthropic,
  119. model: "anthropic/claude-sonnet-4-6",
  120. efforts: []string{"low", "medium", "high", "max"},
  121. modalities: []string{"text"},
  122. },
  123. }
  124. for index, tt := range tests {
  125. t.Run(tt.name, func(t *testing.T) {
  126. gin.SetMode(gin.TestMode)
  127. groupID := int64(100 + index)
  128. h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
  129. byGroup: map[int64][]service.Account{
  130. groupID: {
  131. {
  132. ID: 1,
  133. Platform: tt.platform,
  134. Credentials: map[string]any{
  135. "model_mapping": map[string]any{tt.model: tt.model},
  136. },
  137. },
  138. },
  139. },
  140. })
  141. rec := httptest.NewRecorder()
  142. c, _ := gin.CreateTestContext(rec)
  143. c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
  144. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  145. Group: &service.Group{ID: groupID, Platform: tt.platform},
  146. })
  147. h.CodexModels(c)
  148. require.Equal(t, http.StatusOK, rec.Code)
  149. var got codexModelsResponseForTest
  150. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  151. require.Len(t, got.Models, 1)
  152. require.Equal(t, tt.model, got.Models[0].Slug)
  153. require.NotEmpty(t, got.Models[0].ModelMessages)
  154. require.NotEmpty(t, got.Models[0].TruncationPolicy)
  155. require.NotNil(t, got.Models[0].AvailabilityNUX)
  156. require.NotNil(t, got.Models[0].Upgrade)
  157. require.Equal(t, tt.efforts, codexReasoningEffortsForTest(got.Models[0].SupportedReasoningLevels))
  158. require.Equal(t, tt.modalities, got.Models[0].InputModalities)
  159. })
  160. }
  161. }
  162. // Composite manifests include defaults from unmapped accounts and explicit mappings.
  163. func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) {
  164. gin.SetMode(gin.TestMode)
  165. const groupID int64 = 120
  166. h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
  167. byGroup: map[int64][]service.Account{
  168. groupID: {
  169. {
  170. ID: 3,
  171. Platform: service.PlatformOpenAI,
  172. Status: service.StatusActive,
  173. Schedulable: true,
  174. Credentials: map[string]any{},
  175. },
  176. {
  177. ID: 1,
  178. Platform: service.PlatformOpenAI,
  179. Credentials: map[string]any{
  180. "model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"},
  181. },
  182. },
  183. {
  184. ID: 2,
  185. Platform: service.PlatformGrok,
  186. Credentials: map[string]any{
  187. "model_mapping": map[string]any{"grok-4.6": "grok-4.6"},
  188. },
  189. },
  190. },
  191. },
  192. })
  193. rec := httptest.NewRecorder()
  194. c, _ := gin.CreateTestContext(rec)
  195. c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
  196. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  197. Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
  198. })
  199. h.CodexModels(c)
  200. require.Equal(t, http.StatusOK, rec.Code)
  201. var got codexModelsResponseForTest
  202. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  203. want := service.FilterCodexModelIDsForGroup(openai.DefaultModelIDs(), nil)
  204. require.ElementsMatch(t, append(want, "grok-4.6"), codexModelSlugsForTest(got.Models))
  205. }
  206. func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T) {
  207. gin.SetMode(gin.TestMode)
  208. const groupID int64 = 28
  209. const sparkModel = "gpt-5.3-codex-spark"
  210. const alias = "team-coder"
  211. parentID := int64(1)
  212. accounts := []service.Account{
  213. {ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth},
  214. {
  215. ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
  216. ParentAccountID: &parentID, QuotaDimension: "spark",
  217. Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}},
  218. },
  219. {
  220. ID: 3, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
  221. Credentials: map[string]any{"model_mapping": map[string]any{alias: "gpt-5.6-sol"}},
  222. },
  223. }
  224. tests := []struct {
  225. name string
  226. accounts []service.Account
  227. config service.GroupModelAllowlist
  228. want []string
  229. }{
  230. {
  231. name: "unmapped parent and Spark shadow retain defaults and aliases",
  232. accounts: accounts,
  233. want: append(openai.DefaultModelIDs(), alias),
  234. },
  235. {
  236. name: "unmapped API key account also contributes defaults",
  237. accounts: append([]service.Account{{ID: 4, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey}}, accounts[1:]...),
  238. want: append(openai.DefaultModelIDs(), alias),
  239. },
  240. {
  241. name: "unmapped accounts alone retain default response shape",
  242. accounts: accounts[:1],
  243. want: openai.DefaultModelIDs(),
  244. },
  245. {
  246. name: "custom list can select defaults and aliases",
  247. accounts: accounts,
  248. config: service.GroupModelAllowlist{Enabled: true, Models: []string{alias, "gpt-5.6-sol", sparkModel, "unknown-model"}},
  249. want: []string{alias, "gpt-5.6-sol", sparkModel},
  250. },
  251. {
  252. name: "unavailable custom selection remains empty",
  253. accounts: accounts,
  254. config: service.GroupModelAllowlist{Enabled: true, Models: []string{"unknown-model"}},
  255. want: []string{},
  256. },
  257. {
  258. name: "mapped accounts alone do not gain defaults",
  259. accounts: accounts[1:],
  260. want: []string{sparkModel, alias},
  261. },
  262. {
  263. name: "unmapped accounts from another platform do not add defaults",
  264. accounts: append([]service.Account{{ID: 4, Platform: service.PlatformAnthropic}}, accounts[1:]...),
  265. want: []string{sparkModel, alias},
  266. },
  267. }
  268. for _, tt := range tests {
  269. t.Run(tt.name, func(t *testing.T) {
  270. h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
  271. byGroup: map[int64][]service.Account{groupID: tt.accounts},
  272. })
  273. for range 2 {
  274. rec := httptest.NewRecorder()
  275. c, _ := gin.CreateTestContext(rec)
  276. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  277. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  278. Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, ModelAllowlist: tt.config},
  279. })
  280. h.Models(c)
  281. require.Equal(t, http.StatusOK, rec.Code)
  282. var got gatewayModelsResponseForTest
  283. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  284. require.Equal(t, "list", got.Object)
  285. require.ElementsMatch(t, tt.want, modelIDsForTest(got.Data))
  286. for _, model := range got.Data {
  287. require.Equal(t, "model", model.Object, model.ID)
  288. require.Positive(t, model.Created, model.ID)
  289. require.Equal(t, "openai", model.OwnedBy, model.ID)
  290. require.Empty(t, model.CreatedAt, model.ID)
  291. }
  292. if tt.config.Enabled {
  293. require.Equal(t, tt.want, modelIDsForTest(got.Data))
  294. }
  295. }
  296. })
  297. }
  298. require.Empty(t, accounts[0].GetModelMapping())
  299. require.True(t, accounts[0].IsModelSupported("gpt-future-model"))
  300. require.False(t, accounts[1].IsModelSupported("gpt-5.6-sol"))
  301. }
  302. func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) {
  303. gin.SetMode(gin.TestMode)
  304. const groupID int64 = 122
  305. h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
  306. byGroup: map[int64][]service.Account{
  307. groupID: {{
  308. ID: 1,
  309. Platform: service.PlatformDeepseek,
  310. Credentials: map[string]any{
  311. "model_mapping": map[string]any{"deepseek-v4-pro": "deepseek-v4-pro"},
  312. },
  313. }},
  314. },
  315. })
  316. group := &service.Group{ID: groupID, Platform: service.PlatformDeepseek}
  317. first := httptest.NewRecorder()
  318. firstContext, _ := gin.CreateTestContext(first)
  319. firstContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
  320. firstContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
  321. h.CodexModels(firstContext)
  322. require.Equal(t, http.StatusOK, first.Code)
  323. etag := first.Header().Get("ETag")
  324. require.NotEmpty(t, etag)
  325. require.Equal(t, service.CodexModelsManifestETag(first.Body.Bytes()), etag)
  326. second := httptest.NewRecorder()
  327. secondContext, _ := gin.CreateTestContext(second)
  328. secondContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
  329. secondContext.Request.Header.Set("If-None-Match", "W/"+etag)
  330. secondContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
  331. h.CodexModels(secondContext)
  332. require.Equal(t, http.StatusNotModified, second.Code)
  333. require.Empty(t, second.Body.Bytes())
  334. require.Equal(t, etag, second.Header().Get("ETag"))
  335. }
  336. // Scenario: group model_allowlist limits the generated Codex manifest.
  337. func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) {
  338. gin.SetMode(gin.TestMode)
  339. const groupID int64 = 121
  340. h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
  341. byGroup: map[int64][]service.Account{
  342. groupID: {
  343. {
  344. ID: 1,
  345. Platform: service.PlatformOpenAI,
  346. Credentials: map[string]any{
  347. "model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"},
  348. },
  349. },
  350. {
  351. ID: 2,
  352. Platform: service.PlatformGrok,
  353. Credentials: map[string]any{
  354. "model_mapping": map[string]any{"grok-4.6": "grok-4.6"},
  355. },
  356. },
  357. },
  358. },
  359. })
  360. rec := httptest.NewRecorder()
  361. c, _ := gin.CreateTestContext(rec)
  362. c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
  363. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  364. Group: &service.Group{
  365. ID: groupID,
  366. Platform: service.PlatformComposite,
  367. ModelAllowlist: service.GroupModelAllowlist{
  368. Enabled: true,
  369. Models: []string{"grok-4.6"},
  370. },
  371. },
  372. })
  373. h.CodexModels(c)
  374. require.Equal(t, http.StatusOK, rec.Code)
  375. var got codexModelsResponseForTest
  376. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  377. require.Equal(t, []string{"grok-4.6"}, codexModelSlugsForTest(got.Models))
  378. }
  379. func codexModelSlugsForTest(models []struct {
  380. Slug string `json:"slug"`
  381. SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"`
  382. InputModalities []string `json:"input_modalities"`
  383. ModelMessages map[string]json.RawMessage `json:"model_messages"`
  384. TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"`
  385. AvailabilityNUX json.RawMessage `json:"availability_nux"`
  386. Upgrade json.RawMessage `json:"upgrade"`
  387. }) []string {
  388. slugs := make([]string, 0, len(models))
  389. for _, model := range models {
  390. slugs = append(slugs, model.Slug)
  391. }
  392. return slugs
  393. }
  394. func codexReasoningEffortsForTest(levels []codexReasoningLevelForTest) []string {
  395. efforts := make([]string, 0, len(levels))
  396. for _, level := range levels {
  397. efforts = append(efforts, level.Effort)
  398. }
  399. return efforts
  400. }
  401. func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) {
  402. gin.SetMode(gin.TestMode)
  403. groupID := int64(20)
  404. h := newGatewayModelsHandlerForTest(
  405. &gatewayModelsAccountRepoStub{
  406. byGroup: map[int64][]service.Account{
  407. groupID: {
  408. {ID: 1, Platform: service.PlatformGemini},
  409. },
  410. },
  411. },
  412. )
  413. rec := httptest.NewRecorder()
  414. c, _ := gin.CreateTestContext(rec)
  415. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  416. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  417. Group: &service.Group{ID: groupID, Platform: service.PlatformGemini},
  418. })
  419. h.Models(c)
  420. require.Equal(t, http.StatusOK, rec.Code)
  421. var got gatewayModelsResponseForTest
  422. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  423. require.Equal(t, "list", got.Object)
  424. require.Contains(t, modelIDsForTest(got.Data), "gemini-2.5-flash")
  425. require.NotContains(t, modelIDsForTest(got.Data), "claude-sonnet-4-6")
  426. }
  427. func TestGatewayModels_Grok45AdvertisesReasoningEffortForGrokBuild(t *testing.T) {
  428. assertGrokGatewayReasoningEfforts(t, 4409, "grok-4.5", []gatewayReasoningEffortOptionForTest{
  429. {Value: "low", Label: "Low"},
  430. {Value: "medium", Label: "Medium"},
  431. {Value: "high", Label: "High", Default: true},
  432. })
  433. }
  434. func TestGatewayModels_Grok46AdvertisesXHighReasoningEffortForGrokBuild(t *testing.T) {
  435. xhighEfforts := []gatewayReasoningEffortOptionForTest{
  436. {Value: "low", Label: "Low"},
  437. {Value: "medium", Label: "Medium"},
  438. {Value: "high", Label: "High", Default: true},
  439. {Value: "xhigh", Label: "xHigh"},
  440. }
  441. tests := []struct {
  442. groupID int64
  443. model string
  444. }{
  445. {groupID: 4410, model: "grok-4.6"},
  446. {groupID: 4411, model: "grok-4.6-latest"},
  447. }
  448. for _, tt := range tests {
  449. t.Run(tt.model, func(t *testing.T) {
  450. assertGrokGatewayReasoningEfforts(t, tt.groupID, tt.model, xhighEfforts)
  451. })
  452. }
  453. }
  454. func assertGrokGatewayReasoningEfforts(t *testing.T, groupID int64, modelID string, want []gatewayReasoningEffortOptionForTest) {
  455. t.Helper()
  456. gin.SetMode(gin.TestMode)
  457. h := newGatewayModelsHandlerForTest(
  458. &gatewayModelsAccountRepoStub{
  459. byGroup: map[int64][]service.Account{
  460. groupID: {
  461. {
  462. ID: 1,
  463. Platform: service.PlatformGrok,
  464. Credentials: map[string]any{
  465. "model_mapping": map[string]any{modelID: modelID},
  466. },
  467. },
  468. },
  469. },
  470. },
  471. )
  472. rec := httptest.NewRecorder()
  473. c, _ := gin.CreateTestContext(rec)
  474. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  475. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  476. Group: &service.Group{ID: groupID, Platform: service.PlatformGrok},
  477. })
  478. h.Models(c)
  479. require.Equal(t, http.StatusOK, rec.Code)
  480. var got gatewayModelsResponseForTest
  481. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  482. require.Len(t, got.Data, 1)
  483. model := got.Data[0]
  484. require.Equal(t, modelID, model.ID)
  485. require.True(t, model.SupportsReasoningEffort)
  486. require.Equal(t, "high", model.ReasoningEffort)
  487. require.Equal(t, want, model.ReasoningEfforts)
  488. }
  489. func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) {
  490. gin.SetMode(gin.TestMode)
  491. groupID := int64(21)
  492. h := newGatewayModelsHandlerForTest(
  493. &gatewayModelsAccountRepoStub{
  494. byGroup: map[int64][]service.Account{
  495. groupID: {
  496. {
  497. ID: 1,
  498. Platform: service.PlatformAnthropic,
  499. Credentials: map[string]any{
  500. "model_mapping": map[string]any{
  501. "claude-sonnet-4-6": "claude-sonnet-4-6",
  502. },
  503. },
  504. },
  505. {
  506. ID: 2,
  507. Platform: service.PlatformGemini,
  508. Credentials: map[string]any{
  509. "model_mapping": map[string]any{
  510. "gemini-2.5-flash": "gemini-2.5-flash",
  511. },
  512. },
  513. },
  514. },
  515. },
  516. },
  517. )
  518. rec := httptest.NewRecorder()
  519. c, _ := gin.CreateTestContext(rec)
  520. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  521. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  522. Group: &service.Group{ID: groupID, Platform: service.PlatformGemini},
  523. })
  524. h.Models(c)
  525. require.Equal(t, http.StatusOK, rec.Code)
  526. var got gatewayModelsResponseForTest
  527. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  528. require.Equal(t, []string{"gemini-2.5-flash"}, modelIDsForTest(got.Data))
  529. }
  530. // Scenario: a Composite group with only Anthropic accounts must not inherit Antigravity Gemini defaults.
  531. func TestGatewayCodexModels_CompositeAnthropicDoesNotAdvertiseAntigravityDefaults(t *testing.T) {
  532. gin.SetMode(gin.TestMode)
  533. groupID := int64(64)
  534. h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
  535. byGroup: map[int64][]service.Account{
  536. groupID: {{ID: 1, Platform: service.PlatformAnthropic}},
  537. },
  538. })
  539. rec := httptest.NewRecorder()
  540. c, _ := gin.CreateTestContext(rec)
  541. c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
  542. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  543. Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
  544. })
  545. h.CodexModels(c)
  546. require.Equal(t, http.StatusOK, rec.Code)
  547. var got codexModelsResponseForTest
  548. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  549. slugs := codexModelSlugsForTest(got.Models)
  550. require.Contains(t, slugs, "claude-opus-4-6")
  551. require.NotContains(t, slugs, "gemini-2.5-flash")
  552. }
  553. // Scenario: Antigravity retains its own Claude and Gemini defaults inside Composite groups.
  554. func TestGatewayModels_CompositeAntigravityAdvertisesAntigravityDefaults(t *testing.T) {
  555. gin.SetMode(gin.TestMode)
  556. groupID := int64(65)
  557. h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
  558. byGroup: map[int64][]service.Account{
  559. groupID: {{ID: 1, Platform: service.PlatformAntigravity}},
  560. },
  561. })
  562. rec := httptest.NewRecorder()
  563. c, _ := gin.CreateTestContext(rec)
  564. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  565. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  566. Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
  567. })
  568. h.Models(c)
  569. require.Equal(t, http.StatusOK, rec.Code)
  570. var got gatewayModelsResponseForTest
  571. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  572. ids := modelIDsForTest(got.Data)
  573. require.Contains(t, ids, "claude-opus-4-6")
  574. require.Contains(t, ids, "gemini-2.5-flash")
  575. }
  576. func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T) {
  577. gin.SetMode(gin.TestMode)
  578. groupID := int64(22)
  579. h := newGatewayModelsHandlerForTest(
  580. &gatewayModelsAccountRepoStub{
  581. byGroup: map[int64][]service.Account{
  582. groupID: {
  583. {
  584. ID: 1,
  585. Platform: service.PlatformOpenAI,
  586. Credentials: map[string]any{
  587. "model_mapping": map[string]any{
  588. "gpt-5.5": "gpt-5.5",
  589. "gpt-5.4": "gpt-5.4",
  590. },
  591. },
  592. },
  593. },
  594. },
  595. },
  596. )
  597. rec := httptest.NewRecorder()
  598. c, _ := gin.CreateTestContext(rec)
  599. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  600. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  601. Group: &service.Group{
  602. ID: groupID,
  603. Platform: service.PlatformOpenAI,
  604. ModelAllowlist: service.GroupModelAllowlist{
  605. Enabled: false,
  606. Models: []string{"gpt-5.5"},
  607. },
  608. },
  609. })
  610. h.Models(c)
  611. require.Equal(t, http.StatusOK, rec.Code)
  612. var got gatewayModelsResponseForTest
  613. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  614. require.Equal(t, []string{"gpt-5.4", "gpt-5.5"}, modelIDsForTest(got.Data))
  615. }
  616. func TestGatewayModels_CustomModelsListFiltersAndOrdersMappedModels(t *testing.T) {
  617. gin.SetMode(gin.TestMode)
  618. groupID := int64(23)
  619. h := newGatewayModelsHandlerForTest(
  620. &gatewayModelsAccountRepoStub{
  621. byGroup: map[int64][]service.Account{
  622. groupID: {
  623. {
  624. ID: 1,
  625. Platform: service.PlatformOpenAI,
  626. Credentials: map[string]any{
  627. "model_mapping": map[string]any{
  628. "gpt-5.4": "gpt-5.4",
  629. "gpt-5.5": "gpt-5.5",
  630. "legacy-gpt-2024": "legacy-gpt-2024",
  631. },
  632. },
  633. },
  634. },
  635. },
  636. },
  637. )
  638. rec := httptest.NewRecorder()
  639. c, _ := gin.CreateTestContext(rec)
  640. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  641. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  642. Group: &service.Group{
  643. ID: groupID,
  644. Platform: service.PlatformOpenAI,
  645. ModelAllowlist: service.GroupModelAllowlist{
  646. Enabled: true,
  647. Models: []string{"gpt-5.5", "missing-model", "gpt-5.4"},
  648. },
  649. },
  650. })
  651. h.Models(c)
  652. require.Equal(t, http.StatusOK, rec.Code)
  653. var got gatewayModelsResponseForTest
  654. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  655. require.Equal(t, []string{"gpt-5.5", "gpt-5.4"}, modelIDsForTest(got.Data))
  656. }
  657. func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t *testing.T) {
  658. gin.SetMode(gin.TestMode)
  659. groupID := int64(33)
  660. h := newGatewayModelsHandlerForTest(
  661. &gatewayModelsAccountRepoStub{
  662. byGroup: map[int64][]service.Account{
  663. groupID: {
  664. {
  665. ID: 1,
  666. Platform: service.PlatformOpenAI,
  667. Credentials: map[string]any{
  668. "model_mapping": map[string]any{
  669. "gpt-5.4": "gpt-5.4",
  670. "gpt-5.5": "gpt-5.5",
  671. },
  672. },
  673. },
  674. {
  675. ID: 2,
  676. Platform: service.PlatformGemini,
  677. Credentials: map[string]any{
  678. "model_mapping": map[string]any{
  679. "gemini-2.5-flash": "gemini-2.5-flash",
  680. },
  681. },
  682. },
  683. {
  684. ID: 3,
  685. Platform: service.PlatformAntigravity,
  686. Credentials: map[string]any{
  687. "model_mapping": map[string]any{
  688. "ag-custom-model": "ag-custom-model",
  689. },
  690. },
  691. },
  692. {
  693. ID: 4,
  694. Platform: service.PlatformKimi,
  695. Credentials: map[string]any{
  696. "model_mapping": map[string]any{"kimi-custom": "kimi-upstream"},
  697. },
  698. },
  699. {
  700. ID: 5,
  701. Platform: service.PlatformZhipu,
  702. Credentials: map[string]any{
  703. "model_mapping": map[string]any{"glm-custom": "glm-upstream"},
  704. },
  705. },
  706. {
  707. ID: 6,
  708. Platform: service.PlatformDeepseek,
  709. Credentials: map[string]any{
  710. "model_mapping": map[string]any{"deepseek-custom": "deepseek-upstream"},
  711. },
  712. },
  713. {
  714. ID: 7,
  715. Platform: service.PlatformMiniMax,
  716. Credentials: map[string]any{
  717. "model_mapping": map[string]any{"minimax-custom": "MiniMax-M3"},
  718. },
  719. },
  720. },
  721. },
  722. },
  723. )
  724. rec := httptest.NewRecorder()
  725. c, _ := gin.CreateTestContext(rec)
  726. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  727. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  728. Group: &service.Group{
  729. ID: groupID,
  730. Platform: service.PlatformComposite,
  731. ModelAllowlist: service.GroupModelAllowlist{
  732. Enabled: true,
  733. Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"},
  734. },
  735. },
  736. })
  737. h.Models(c)
  738. require.Equal(t, http.StatusOK, rec.Code)
  739. var got gatewayModelsResponseForTest
  740. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  741. require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"}, modelIDsForTest(got.Data))
  742. }
  743. func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t *testing.T) {
  744. gin.SetMode(gin.TestMode)
  745. groupID := int64(34)
  746. h := newGatewayModelsHandlerForTest(
  747. &gatewayModelsAccountRepoStub{
  748. byGroup: map[int64][]service.Account{
  749. groupID: {
  750. {ID: 1, Platform: service.PlatformOpenAI},
  751. {ID: 2, Platform: service.PlatformGrok},
  752. },
  753. },
  754. },
  755. )
  756. rec := httptest.NewRecorder()
  757. c, _ := gin.CreateTestContext(rec)
  758. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  759. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  760. Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
  761. })
  762. h.Models(c)
  763. require.Equal(t, http.StatusOK, rec.Code)
  764. var got gatewayModelsResponseForTest
  765. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  766. ids := modelIDsForTest(got.Data)
  767. require.Contains(t, ids, "gpt-5.5")
  768. require.Contains(t, ids, "grok-4.3")
  769. require.NotContains(t, ids, "claude-sonnet-4-6")
  770. require.NotContains(t, ids, "gemini-2.5-flash")
  771. }
  772. // CN 供应商没有静态默认模型列表:composite 下无映射的可调度 CN 账号不得把
  773. // defaultModelIDsForPlatform default 分支的 Claude 列表挂到 CN 平台名下。
  774. func TestGatewayModels_CompositeUnmappedCNAccountsContributeNoDefaults(t *testing.T) {
  775. gin.SetMode(gin.TestMode)
  776. groupID := int64(35)
  777. h := newGatewayModelsHandlerForTest(
  778. &gatewayModelsAccountRepoStub{
  779. byGroup: map[int64][]service.Account{
  780. groupID: {
  781. {ID: 1, Platform: service.PlatformOpenAI},
  782. {ID: 2, Platform: service.PlatformKimi},
  783. {ID: 3, Platform: service.PlatformZhipu},
  784. {ID: 4, Platform: service.PlatformDeepseek},
  785. {ID: 5, Platform: service.PlatformMiniMax},
  786. },
  787. },
  788. },
  789. )
  790. rec := httptest.NewRecorder()
  791. c, _ := gin.CreateTestContext(rec)
  792. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  793. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  794. Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
  795. })
  796. h.Models(c)
  797. require.Equal(t, http.StatusOK, rec.Code)
  798. var got gatewayModelsResponseForTest
  799. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  800. ids := modelIDsForTest(got.Data)
  801. require.Contains(t, ids, "gpt-5.5")
  802. require.NotContains(t, ids, "claude-sonnet-4-6")
  803. }
  804. // 独立 CN 分组沿用 default 分支的 Claude 默认列表(Claude Code 客户端请求的
  805. // 就是这些模型名并经账号 model_mapping 转换),composite 支持不得改变该回退。
  806. func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T) {
  807. want := make([]string, 0, len(claude.DefaultModels))
  808. for _, model := range claude.DefaultModels {
  809. want = append(want, model.ID)
  810. }
  811. for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
  812. require.Equal(t, want, defaultModelIDsForPlatform(platform), "platform=%s", platform)
  813. }
  814. }
  815. func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T) {
  816. require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek))
  817. require.Equal(t, []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}, defaultCodexModelIDsForPlatform(service.PlatformMiniMax))
  818. require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic))
  819. }
  820. func TestGatewayCodexModels_DeepSeekWithoutMappingUsesDeepSeekDefaults(t *testing.T) {
  821. gin.SetMode(gin.TestMode)
  822. const groupID int64 = 130
  823. h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
  824. byGroup: map[int64][]service.Account{
  825. groupID: {
  826. {
  827. ID: 1,
  828. Platform: service.PlatformDeepseek,
  829. Status: service.StatusActive,
  830. Schedulable: true,
  831. Credentials: map[string]any{},
  832. },
  833. },
  834. },
  835. })
  836. rec := httptest.NewRecorder()
  837. c, _ := gin.CreateTestContext(rec)
  838. c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil)
  839. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  840. Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek},
  841. })
  842. h.CodexModels(c)
  843. require.Equal(t, http.StatusOK, rec.Code)
  844. var got codexModelsResponseForTest
  845. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  846. slugs := make([]string, 0, len(got.Models))
  847. for _, model := range got.Models {
  848. slugs = append(slugs, model.Slug)
  849. }
  850. require.Contains(t, slugs, "deepseek-v4-pro")
  851. require.Contains(t, slugs, "deepseek-v4-flash")
  852. require.NotContains(t, slugs, "claude-sonnet-4-6")
  853. require.NotContains(t, slugs, "claude-opus-4-6")
  854. }
  855. func TestGatewayCodexModels_OmitsWildcardMappingKeys(t *testing.T) {
  856. gin.SetMode(gin.TestMode)
  857. const groupID int64 = 131
  858. h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
  859. byGroup: map[int64][]service.Account{
  860. groupID: {
  861. {
  862. ID: 1,
  863. Platform: service.PlatformDeepseek,
  864. Credentials: map[string]any{
  865. "model_mapping": map[string]any{
  866. "foo-*": "deepseek-v4-pro",
  867. "deepseek-v4-pro": "deepseek-v4-pro",
  868. },
  869. },
  870. },
  871. },
  872. },
  873. })
  874. rec := httptest.NewRecorder()
  875. c, _ := gin.CreateTestContext(rec)
  876. c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil)
  877. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  878. Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek},
  879. })
  880. h.CodexModels(c)
  881. require.Equal(t, http.StatusOK, rec.Code)
  882. var got codexModelsResponseForTest
  883. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  884. slugs := make([]string, 0, len(got.Models))
  885. for _, model := range got.Models {
  886. slugs = append(slugs, model.Slug)
  887. }
  888. require.Equal(t, []string{"deepseek-v4-pro"}, slugs)
  889. }
  890. func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMapping(t *testing.T) {
  891. gin.SetMode(gin.TestMode)
  892. groupID := int64(26)
  893. h := newGatewayModelsHandlerForTest(
  894. &gatewayModelsAccountRepoStub{
  895. byGroup: map[int64][]service.Account{
  896. groupID: {
  897. {
  898. ID: 1,
  899. Platform: service.PlatformAnthropic,
  900. Credentials: map[string]any{
  901. "model_mapping": map[string]any{
  902. "claude-*": "claude-sonnet-4-6",
  903. },
  904. },
  905. },
  906. },
  907. },
  908. },
  909. )
  910. rec := httptest.NewRecorder()
  911. c, _ := gin.CreateTestContext(rec)
  912. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  913. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  914. Group: &service.Group{
  915. ID: groupID,
  916. Platform: service.PlatformAnthropic,
  917. ModelAllowlist: service.GroupModelAllowlist{
  918. Enabled: true,
  919. Models: []string{"claude-sonnet-4-6"},
  920. },
  921. },
  922. })
  923. h.Models(c)
  924. require.Equal(t, http.StatusOK, rec.Code)
  925. var got gatewayModelsResponseForTest
  926. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  927. require.Equal(t, []string{"claude-sonnet-4-6"}, modelIDsForTest(got.Data))
  928. }
  929. func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeAndMappedDeepSeek(t *testing.T) {
  930. gin.SetMode(gin.TestMode)
  931. groupID := int64(28)
  932. h := newGatewayModelsHandlerForTest(
  933. &gatewayModelsAccountRepoStub{
  934. byGroup: map[int64][]service.Account{
  935. groupID: {
  936. {
  937. ID: 1,
  938. Platform: service.PlatformAnthropic,
  939. Type: service.AccountTypeOAuth,
  940. },
  941. {
  942. ID: 2,
  943. Platform: service.PlatformAnthropic,
  944. Type: service.AccountTypeAPIKey,
  945. Credentials: map[string]any{
  946. "model_mapping": map[string]any{
  947. "deepseek-v4-pro": "deepseek-v4-pro",
  948. },
  949. },
  950. },
  951. },
  952. },
  953. },
  954. )
  955. rec := httptest.NewRecorder()
  956. c, _ := gin.CreateTestContext(rec)
  957. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  958. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  959. Group: &service.Group{
  960. ID: groupID,
  961. Platform: service.PlatformAnthropic,
  962. ModelAllowlist: service.GroupModelAllowlist{
  963. Enabled: true,
  964. Models: []string{"claude-fable-5", "claude-opus-4-8", "deepseek-v4-pro"},
  965. },
  966. },
  967. })
  968. h.Models(c)
  969. require.Equal(t, http.StatusOK, rec.Code)
  970. var got gatewayModelsResponseForTest
  971. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  972. require.Equal(t, []string{"claude-fable-5", "claude-opus-4-8", "deepseek-v4-pro"}, modelIDsForTest(got.Data))
  973. }
  974. func TestGatewayModels_AnthropicCustomModelsListDisabledKeepsMappedModelList(t *testing.T) {
  975. gin.SetMode(gin.TestMode)
  976. groupID := int64(29)
  977. h := newGatewayModelsHandlerForTest(
  978. &gatewayModelsAccountRepoStub{
  979. byGroup: map[int64][]service.Account{
  980. groupID: {
  981. {
  982. ID: 1,
  983. Platform: service.PlatformAnthropic,
  984. Type: service.AccountTypeOAuth,
  985. },
  986. {
  987. ID: 2,
  988. Platform: service.PlatformAnthropic,
  989. Type: service.AccountTypeAPIKey,
  990. Credentials: map[string]any{
  991. "model_mapping": map[string]any{
  992. "deepseek-v4-pro": "deepseek-v4-pro",
  993. },
  994. },
  995. },
  996. },
  997. },
  998. },
  999. )
  1000. rec := httptest.NewRecorder()
  1001. c, _ := gin.CreateTestContext(rec)
  1002. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  1003. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  1004. Group: &service.Group{
  1005. ID: groupID,
  1006. Platform: service.PlatformAnthropic,
  1007. ModelAllowlist: service.GroupModelAllowlist{
  1008. Enabled: false,
  1009. Models: []string{"claude-fable-5", "deepseek-v4-pro"},
  1010. },
  1011. },
  1012. })
  1013. h.Models(c)
  1014. require.Equal(t, http.StatusOK, rec.Code)
  1015. var got gatewayModelsResponseForTest
  1016. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  1017. require.Equal(t, []string{"deepseek-v4-pro"}, modelIDsForTest(got.Data))
  1018. }
  1019. func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeWithoutMappings(t *testing.T) {
  1020. gin.SetMode(gin.TestMode)
  1021. groupID := int64(30)
  1022. h := newGatewayModelsHandlerForTest(
  1023. &gatewayModelsAccountRepoStub{
  1024. byGroup: map[int64][]service.Account{
  1025. groupID: {
  1026. {
  1027. ID: 1,
  1028. Platform: service.PlatformAnthropic,
  1029. Type: service.AccountTypeOAuth,
  1030. },
  1031. },
  1032. },
  1033. },
  1034. )
  1035. rec := httptest.NewRecorder()
  1036. c, _ := gin.CreateTestContext(rec)
  1037. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  1038. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  1039. Group: &service.Group{
  1040. ID: groupID,
  1041. Platform: service.PlatformAnthropic,
  1042. ModelAllowlist: service.GroupModelAllowlist{
  1043. Enabled: true,
  1044. Models: []string{"claude-opus-4-6-thinking", "claude-sonnet-4-5"},
  1045. },
  1046. },
  1047. })
  1048. h.Models(c)
  1049. require.Equal(t, http.StatusOK, rec.Code)
  1050. var got gatewayModelsResponseForTest
  1051. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  1052. require.Equal(t, []string{"claude-opus-4-6-thinking", "claude-sonnet-4-5"}, modelIDsForTest(got.Data))
  1053. }
  1054. func TestGatewayModels_CustomModelsListCanReturnEmptyWhenSelectionsUnavailable(t *testing.T) {
  1055. gin.SetMode(gin.TestMode)
  1056. groupID := int64(24)
  1057. h := newGatewayModelsHandlerForTest(
  1058. &gatewayModelsAccountRepoStub{
  1059. byGroup: map[int64][]service.Account{
  1060. groupID: {
  1061. {
  1062. ID: 1,
  1063. Platform: service.PlatformOpenAI,
  1064. Credentials: map[string]any{
  1065. "model_mapping": map[string]any{
  1066. "gpt-5.4": "gpt-5.4",
  1067. },
  1068. },
  1069. },
  1070. },
  1071. },
  1072. },
  1073. )
  1074. rec := httptest.NewRecorder()
  1075. c, _ := gin.CreateTestContext(rec)
  1076. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  1077. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  1078. Group: &service.Group{
  1079. ID: groupID,
  1080. Platform: service.PlatformOpenAI,
  1081. ModelAllowlist: service.GroupModelAllowlist{
  1082. Enabled: true,
  1083. Models: []string{"gpt-5.5"},
  1084. },
  1085. },
  1086. })
  1087. h.Models(c)
  1088. require.Equal(t, http.StatusOK, rec.Code)
  1089. var got gatewayModelsResponseForTest
  1090. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  1091. require.Empty(t, modelIDsForTest(got.Data))
  1092. }
  1093. func TestGatewayModels_CustomModelsListFiltersDefaultFallbackModels(t *testing.T) {
  1094. gin.SetMode(gin.TestMode)
  1095. groupID := int64(25)
  1096. h := newGatewayModelsHandlerForTest(
  1097. &gatewayModelsAccountRepoStub{
  1098. byGroup: map[int64][]service.Account{
  1099. groupID: {
  1100. {ID: 1, Platform: service.PlatformOpenAI},
  1101. },
  1102. },
  1103. },
  1104. )
  1105. rec := httptest.NewRecorder()
  1106. c, _ := gin.CreateTestContext(rec)
  1107. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  1108. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  1109. Group: &service.Group{
  1110. ID: groupID,
  1111. Platform: service.PlatformOpenAI,
  1112. ModelAllowlist: service.GroupModelAllowlist{
  1113. Enabled: true,
  1114. Models: []string{"gpt-5.5", "legacy-gpt-2024", "gpt-5.4"},
  1115. },
  1116. },
  1117. })
  1118. h.Models(c)
  1119. require.Equal(t, http.StatusOK, rec.Code)
  1120. var got gatewayModelsResponseForTest
  1121. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  1122. require.Equal(t, []string{"gpt-5.5", "gpt-5.4"}, modelIDsForTest(got.Data))
  1123. }
  1124. func TestGatewayModels_OpenAICustomModelsListKeepsOpenAIResponseShapeForDefaultFallback(t *testing.T) {
  1125. gin.SetMode(gin.TestMode)
  1126. groupID := int64(27)
  1127. h := newGatewayModelsHandlerForTest(
  1128. &gatewayModelsAccountRepoStub{
  1129. byGroup: map[int64][]service.Account{
  1130. groupID: {
  1131. {ID: 1, Platform: service.PlatformOpenAI},
  1132. },
  1133. },
  1134. },
  1135. )
  1136. rec := httptest.NewRecorder()
  1137. c, _ := gin.CreateTestContext(rec)
  1138. c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
  1139. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  1140. Group: &service.Group{
  1141. ID: groupID,
  1142. Platform: service.PlatformOpenAI,
  1143. ModelAllowlist: service.GroupModelAllowlist{
  1144. Enabled: true,
  1145. Models: []string{"gpt-5.5", "gpt-5.4"},
  1146. },
  1147. },
  1148. })
  1149. h.Models(c)
  1150. require.Equal(t, http.StatusOK, rec.Code)
  1151. var got gatewayModelsResponseForTest
  1152. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  1153. require.Equal(t, []string{"gpt-5.5", "gpt-5.4"}, modelIDsForTest(got.Data))
  1154. require.Equal(t, "model", got.Data[0].Object)
  1155. require.NotZero(t, got.Data[0].Created)
  1156. require.Equal(t, "openai", got.Data[0].OwnedBy)
  1157. require.Empty(t, got.Data[0].CreatedAt)
  1158. }
  1159. func modelIDsForTest(models []gatewayModelItemForTest) []string {
  1160. ids := make([]string, 0, len(models))
  1161. for _, model := range models {
  1162. ids = append(ids, model.ID)
  1163. }
  1164. return ids
  1165. }