openai_models_handler_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. package handler
  2. import (
  3. "encoding/json"
  4. "net/http"
  5. "net/http/httptest"
  6. "testing"
  7. "time"
  8. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. )
  13. func performOrdinaryPinnedModelsRequest(t *testing.T, codex *OpenAIGatewayHandler, group *service.Group, path, etag string) *httptest.ResponseRecorder {
  14. t.Helper()
  15. h := &GatewayHandler{openAIGatewayService: codex.gatewayService, maxAccountSwitches: 3}
  16. recorder := httptest.NewRecorder()
  17. c, _ := gin.CreateTestContext(recorder)
  18. c.Request = httptest.NewRequest(http.MethodGet, path, nil)
  19. c.Request.Header.Set("If-None-Match", etag)
  20. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group})
  21. h.Models(c)
  22. return recorder
  23. }
  24. func ordinaryPinnedModelIDs(t *testing.T, recorder *httptest.ResponseRecorder) []string {
  25. t.Helper()
  26. var response gatewayModelsResponseForTest
  27. require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
  28. require.NotNil(t, response.Data, "an empty catalog must be an array, not null")
  29. return modelIDsForTest(response.Data)
  30. }
  31. func TestOrdinaryPinnedModelsUsesSelectedAccountsAndFinalETag(t *testing.T) {
  32. accounts := []service.Account{
  33. newPinnedCodexAccount(1, service.StatusActive, true, false),
  34. newPinnedCodexAccount(2, service.StatusActive, true, true),
  35. newPinnedCodexAccount(3, service.StatusActive, true, false),
  36. }
  37. upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
  38. 1: `{"data":[{"id":"unselected"}]}`,
  39. 2: `{"data":[{"id":"special-model","owned_by":"first","created":123},{"id":"gpt-image-1"}]}`,
  40. 3: `{"data":[{"id":"special-model","owned_by":"second"},{"id":"text-embedding-3-large"}]}`,
  41. }}
  42. h := newPinnedCodexTestHandler(accounts, upstream, 3)
  43. group := &service.Group{ID: 91, Platform: service.PlatformOpenAI,
  44. CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2, 3}}}
  45. first := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
  46. require.Equal(t, http.StatusOK, first.Code, first.Body.String())
  47. require.Equal(t, []string{"special-model", "gpt-image-1", "text-embedding-3-large"}, ordinaryPinnedModelIDs(t, first))
  48. var response gatewayModelsResponseForTest
  49. require.NoError(t, json.Unmarshal(first.Body.Bytes(), &response))
  50. require.Equal(t, "model", response.Data[0].Object)
  51. require.Equal(t, "first", response.Data[0].OwnedBy)
  52. require.EqualValues(t, 123, response.Data[0].Created)
  53. require.Equal(t, []int64{2, 3}, upstream.accountIDs())
  54. alias := performOrdinaryPinnedModelsRequest(t, h, group, "/models", first.Header().Get("ETag"))
  55. require.Equal(t, http.StatusNotModified, alias.Code)
  56. require.Empty(t, alias.Body.String())
  57. require.Equal(t, []int64{2, 3}, upstream.accountIDs(), "fresh requests must not hit upstream")
  58. otherGroup := *group
  59. otherGroup.ID++
  60. otherGroup.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: []string{"text-embedding-3-large", "special-model"}}
  61. filtered := performOrdinaryPinnedModelsRequest(t, h, &otherGroup, "/v1/models", first.Header().Get("ETag"))
  62. require.Equal(t, http.StatusOK, filtered.Code)
  63. require.Equal(t, []string{"text-embedding-3-large", "special-model"}, ordinaryPinnedModelIDs(t, filtered))
  64. require.NotEqual(t, first.Header().Get("ETag"), filtered.Header().Get("ETag"))
  65. again := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
  66. require.Equal(t, first.Body.String(), again.Body.String(), "group filters must not modify account cache")
  67. require.Equal(t, []int64{2, 3}, upstream.accountIDs())
  68. }
  69. func TestOrdinaryPinnedModelsFailureAndEmptyPolicies(t *testing.T) {
  70. for _, tc := range []struct {
  71. name string
  72. accountIDs []int64
  73. bodies map[int64]string
  74. statuses map[int64]int
  75. fallback bool
  76. selected []string
  77. wantStatus int
  78. wantIDs []string
  79. }{
  80. {name: "partial failure", accountIDs: []int64{2, 3}, bodies: map[int64]string{2: `{"data":[{"id":"ok"}]}`}, statuses: map[int64]int{3: 503}, wantStatus: 200, wantIDs: []string{"ok"}},
  81. {name: "all failed", accountIDs: []int64{2, 3}, statuses: map[int64]int{2: 429, 3: 504}, wantStatus: 502},
  82. {name: "missing members", accountIDs: []int64{99}, wantStatus: 503},
  83. {name: "empty config", wantStatus: 503},
  84. {name: "fallback from missing members", accountIDs: []int64{99}, fallback: true, wantStatus: 200, wantIDs: []string{"from-scheduler"}},
  85. {name: "fallback from failure", accountIDs: []int64{2}, statuses: map[int64]int{2: 503}, fallback: true, wantStatus: 200, wantIDs: []string{"from-scheduler"}},
  86. {name: "valid empty", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":[]}`}, fallback: true, wantStatus: 200},
  87. {name: "filtered empty", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":[{"id":"ok"}]}`}, selected: []string{"absent"}, fallback: true, wantStatus: 200},
  88. {name: "invalid envelope", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"error":"denied"}`}, wantStatus: 502},
  89. {name: "null data", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":null}`}, wantStatus: 502},
  90. } {
  91. t.Run(tc.name, func(t *testing.T) {
  92. accounts := []service.Account{
  93. newPinnedCodexAccount(1, service.StatusActive, true, false),
  94. newPinnedCodexAccount(2, service.StatusActive, true, false),
  95. newPinnedCodexAccount(3, service.StatusActive, true, false),
  96. }
  97. bodies := map[int64]string{1: `{"data":[{"id":"from-scheduler"}]}`}
  98. for id, body := range tc.bodies {
  99. bodies[id] = body
  100. }
  101. upstream := &codexModelsPinnedHTTPUpstream{bodies: bodies, statuses: tc.statuses}
  102. h := newPinnedCodexTestHandler(accounts, upstream, 3)
  103. group := &service.Group{ID: 92, Platform: service.PlatformOpenAI,
  104. ModelAllowlist: service.GroupModelAllowlist{Enabled: len(tc.selected) > 0, Models: tc.selected},
  105. CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: tc.accountIDs, FallbackToScheduler: tc.fallback}}
  106. recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
  107. require.Equal(t, tc.wantStatus, recorder.Code, recorder.Body.String())
  108. if tc.wantStatus == 200 {
  109. require.Equal(t, len(tc.wantIDs), len(ordinaryPinnedModelIDs(t, recorder)))
  110. if len(tc.wantIDs) > 0 {
  111. require.Equal(t, tc.wantIDs, ordinaryPinnedModelIDs(t, recorder))
  112. } else {
  113. require.NotContains(t, upstream.accountIDs(), int64(1), "empty success must not trigger fallback")
  114. }
  115. }
  116. })
  117. }
  118. }
  119. func TestOrdinaryPinnedModelsSkipsPersistentlyUnavailableAccounts(t *testing.T) {
  120. accounts := []service.Account{
  121. newPinnedCodexAccount(1, service.StatusActive, true, true),
  122. newPinnedCodexAccount(2, service.StatusActive, false, false),
  123. newPinnedCodexAccount(3, service.StatusDisabled, true, false),
  124. newPinnedCodexAccount(4, service.StatusActive, true, false),
  125. }
  126. expired := time.Now().Add(-time.Hour)
  127. accounts[3].ExpiresAt = &expired
  128. accounts[3].AutoPauseOnExpired = true
  129. overloaded := time.Now().Add(time.Hour)
  130. accounts[0].OverloadUntil = &overloaded
  131. accounts[0].TempUnschedulableUntil = &overloaded
  132. upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{1: `{"data":[{"id":"available"}]}`}}
  133. h := newPinnedCodexTestHandler(accounts, upstream, 3)
  134. group := &service.Group{ID: 93, Platform: service.PlatformOpenAI,
  135. CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1, 2, 3, 4, 99}}}
  136. recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
  137. require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
  138. require.Equal(t, []int64{1}, upstream.accountIDs())
  139. }
  140. func TestPinnedModelsAllowlistExpandsWildcardsForBothRepresentations(t *testing.T) {
  141. for _, codex := range []bool{false, true} {
  142. t.Run(map[bool]string{false: "ordinary", true: "codex"}[codex], func(t *testing.T) {
  143. accounts := []service.Account{newPinnedCodexAccount(1, service.StatusActive, true, false)}
  144. accounts[0].Credentials["model_mapping"] = map[string]any{
  145. "public-a": "gpt-5.5", "public-b": "gpt-5.5", "blocked": "gpt-5.5",
  146. }
  147. body := `{"data":[{"id":"gpt-5.5","owned_by":"provider"}]}`
  148. if codex {
  149. body = `{"models":[{"slug":"gpt-5.5","context_window":424242}]}`
  150. }
  151. upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{1: body}}
  152. h := newPinnedCodexTestHandler(accounts, upstream, 3)
  153. group := &service.Group{ID: 95, Platform: service.PlatformOpenAI,
  154. ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"public-b", "public-*"}},
  155. CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1}}}
  156. if codex {
  157. recorder := performPinnedCodexModelsRequest(t, h, group, "")
  158. require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
  159. require.Equal(t, []string{"public-b", "public-a"}, codexHandlerManifestSlugs(t, recorder))
  160. require.Contains(t, recorder.Body.String(), `"context_window":424242`)
  161. } else {
  162. recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
  163. require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
  164. require.Equal(t, []string{"public-b", "public-a"}, ordinaryPinnedModelIDs(t, recorder))
  165. require.Contains(t, recorder.Body.String(), `"owned_by":"provider"`)
  166. }
  167. })
  168. }
  169. }
  170. func TestPinnedModelsMappingFollowsUpstreamDiscoveryForBothRepresentations(t *testing.T) {
  171. for _, codex := range []bool{false, true} {
  172. t.Run(map[bool]string{false: "ordinary", true: "codex"}[codex], func(t *testing.T) {
  173. accounts := []service.Account{
  174. newPinnedCodexAccount(1, service.StatusActive, true, false),
  175. newPinnedCodexAccount(2, service.StatusActive, true, false),
  176. }
  177. accounts[0].Credentials["model_mapping"] = map[string]any{"unselected-alias": "gpt-5.5"}
  178. accounts[1].Credentials["model_mapping"] = map[string]any{
  179. "public-alias": "gpt-5.5", "missing-alias": "missing-upstream", "custom-*": "gpt-5.5",
  180. }
  181. body := `{"data":[{"id":"gpt-5.5","owned_by":"provider","created":123},{"id":"unmapped-upstream"}]}`
  182. if codex {
  183. body = `{"models":[{"slug":"gpt-5.5","context_window":424242},{"slug":"unmapped-upstream"}]}`
  184. }
  185. upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{2: body}}
  186. h := newPinnedCodexTestHandler(accounts, upstream, 3)
  187. group := &service.Group{ID: 94, Platform: service.PlatformOpenAI,
  188. ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"custom-concrete", "public-alias", "unselected-alias", "missing-alias"}},
  189. CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2}}}
  190. var recorder *httptest.ResponseRecorder
  191. if codex {
  192. recorder = performPinnedCodexModelsRequest(t, h, group, "")
  193. } else {
  194. recorder = performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
  195. }
  196. require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
  197. require.Equal(t, []int64{2}, upstream.accountIDs(), "mappings must not bypass pinned discovery")
  198. if codex {
  199. require.Equal(t, []string{"custom-concrete", "public-alias"}, codexHandlerManifestSlugs(t, recorder))
  200. require.Contains(t, recorder.Body.String(), `"context_window":424242`)
  201. } else {
  202. require.Equal(t, []string{"custom-concrete", "public-alias"}, ordinaryPinnedModelIDs(t, recorder))
  203. require.Contains(t, recorder.Body.String(), `"owned_by":"provider"`)
  204. }
  205. })
  206. }
  207. }