openai_models_list_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "io"
  6. "net/http"
  7. "strings"
  8. "sync"
  9. "sync/atomic"
  10. "testing"
  11. "time"
  12. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  13. "github.com/stretchr/testify/assert"
  14. "github.com/stretchr/testify/require"
  15. )
  16. func ordinaryModelsUpstreamResponse(body string) *http.Response {
  17. return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}
  18. }
  19. func TestFetchOpenAIModelsListUsesStandardRequestAndIsolatesCodexCache(t *testing.T) {
  20. var calls atomic.Int32
  21. s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, accountID int64, concurrency int) (*http.Response, error) {
  22. calls.Add(1)
  23. assert.EqualValues(t, 2, accountID)
  24. assert.EqualValues(t, 3, concurrency)
  25. assert.Equal(t, "/v1/models", req.URL.Path)
  26. assert.Equal(t, "value", getHeaderRaw(req.Header, "x-account-header"))
  27. if req.URL.Query().Has("client_version") {
  28. return ordinaryModelsUpstreamResponse(`{"models":[{"slug":"codex-only"}]}`), nil
  29. }
  30. assert.Empty(t, req.Header.Get("Originator"))
  31. assert.Empty(t, req.Header.Get("Version"))
  32. return ordinaryModelsUpstreamResponse(`{"object":"list","data":[{"id":"special-upstream-model","owned_by":"provider","created":1234,"custom":true},{"id":"gpt-image-1"},{"id":"text-embedding-3-large"}]}`), nil
  33. }})
  34. account := newCodexModelsAPIKeyTestAccount("https://models.example/v1")
  35. account.Credentials["header_overrides"] = map[string]any{"X-Account-Header": "value"}
  36. account.Credentials["header_override_enabled"] = true
  37. response, err := s.FetchOpenAIModelsList(context.Background(), account)
  38. require.NoError(t, err)
  39. require.Contains(t, string(response.Body), `"id":"gpt-image-1"`)
  40. require.Contains(t, string(response.Body), `"id":"text-embedding-3-large"`)
  41. require.Contains(t, string(response.Body), `"created":1234`)
  42. require.Contains(t, string(response.Body), `"custom":true`)
  43. manifest, err := s.FetchCodexModelsManifest(context.Background(), account, CodexCanonicalClientVersion(), "")
  44. require.NoError(t, err)
  45. require.Contains(t, string(manifest.Body), `"slug":"codex-only"`)
  46. again, err := s.FetchOpenAIModelsList(context.Background(), account)
  47. require.NoError(t, err)
  48. require.Equal(t, string(response.Body), string(again.Body))
  49. require.EqualValues(t, 2, calls.Load())
  50. account.Credentials["api_key"] = "rotated-token"
  51. _, err = s.FetchOpenAIModelsList(context.Background(), account)
  52. require.NoError(t, err)
  53. require.EqualValues(t, 3, calls.Load(), "credentials must participate in the cache key")
  54. }
  55. func TestFetchOpenAIModelsListOAuthSharesManifestCache(t *testing.T) {
  56. _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"special-oauth-model"},{"slug":"gpt-image-1"}]}`)
  57. s := &OpenAIGatewayService{}
  58. account := newCodexModelsTestAccount()
  59. response, err := s.FetchOpenAIModelsList(context.Background(), account)
  60. require.NoError(t, err)
  61. require.JSONEq(t, `{"object":"list","data":[{"id":"special-oauth-model","object":"model","owned_by":"openai","created":0},{"id":"gpt-image-1","object":"model","owned_by":"openai","created":0}]}`, string(response.Body))
  62. manifest, err := s.FetchCodexModelsManifest(context.Background(), account, CodexCanonicalClientVersion(), "")
  63. require.NoError(t, err)
  64. require.Contains(t, string(manifest.Body), `"slug":"special-oauth-model"`)
  65. require.NotContains(t, string(manifest.Body), `"data"`)
  66. require.EqualValues(t, 1, calls.Load())
  67. _, err = s.FetchOpenAIModelsList(context.Background(), account)
  68. require.NoError(t, err)
  69. require.EqualValues(t, 1, calls.Load())
  70. }
  71. func TestFetchOpenAIModelsListCacheWindowsAndSingleflight(t *testing.T) {
  72. var calls atomic.Int32
  73. started := make(chan int32, 3)
  74. release := make(chan struct{}, 3)
  75. t.Cleanup(func() { close(release) })
  76. s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  77. call := calls.Add(1)
  78. if call > 1 && call < 4 {
  79. started <- call
  80. <-release
  81. }
  82. if call >= 4 {
  83. response := ordinaryModelsUpstreamResponse(`{"error":"unavailable"}`)
  84. response.StatusCode = http.StatusServiceUnavailable
  85. return response, nil
  86. }
  87. return ordinaryModelsUpstreamResponse(`{"data":[{"id":"version-` + string(rune('0'+call)) + `"}]}`), nil
  88. }})
  89. account := newCodexModelsAPIKeyTestAccount("https://models.example/v1")
  90. first, err := s.FetchOpenAIModelsList(context.Background(), account)
  91. require.NoError(t, err)
  92. for range 5 {
  93. _, err = s.FetchOpenAIModelsList(context.Background(), account)
  94. require.NoError(t, err)
  95. }
  96. require.EqualValues(t, 1, calls.Load())
  97. expireCodexModelsManifestCache(s, 2*time.Minute)
  98. stale, err := s.FetchOpenAIModelsList(context.Background(), account)
  99. require.NoError(t, err)
  100. require.Equal(t, string(first.Body), string(stale.Body))
  101. select {
  102. case call := <-started:
  103. require.EqualValues(t, 2, call)
  104. case <-time.After(2 * time.Second):
  105. t.Fatal("stale response did not start a background refresh")
  106. }
  107. for range 5 {
  108. cached, err := s.FetchOpenAIModelsList(context.Background(), account)
  109. require.NoError(t, err)
  110. require.Equal(t, string(first.Body), string(cached.Body))
  111. }
  112. require.EqualValues(t, 2, calls.Load(), "concurrent stale reads must share the refresh")
  113. release <- struct{}{}
  114. require.Eventually(t, func() bool {
  115. s.openAIModelsCache.mu.Lock()
  116. defer s.openAIModelsCache.mu.Unlock()
  117. for _, entry := range s.openAIModelsCache.entries {
  118. if strings.Contains(string(entry.manifest.Body), "version-2") {
  119. return true
  120. }
  121. }
  122. return false
  123. }, 2*time.Second, time.Millisecond)
  124. expireCodexModelsManifestCache(s, 6*time.Minute)
  125. responses := make(chan *OpenAIModelsResponse, 2)
  126. fetchErrors := make(chan error, 2)
  127. var wait sync.WaitGroup
  128. for range 2 {
  129. wait.Add(1)
  130. go func() {
  131. defer wait.Done()
  132. response, err := s.FetchOpenAIModelsList(context.Background(), account)
  133. responses <- response
  134. fetchErrors <- err
  135. }()
  136. }
  137. select {
  138. case call := <-started:
  139. require.EqualValues(t, 3, call)
  140. case <-time.After(2 * time.Second):
  141. t.Fatal("expired catalog did not start a synchronous refresh")
  142. }
  143. select {
  144. case <-responses:
  145. t.Fatal("expired catalog returned before the upstream refresh completed")
  146. case <-time.After(20 * time.Millisecond):
  147. }
  148. release <- struct{}{}
  149. wait.Wait()
  150. for range 2 {
  151. require.NoError(t, <-fetchErrors)
  152. require.Contains(t, string((<-responses).Body), "version-3")
  153. }
  154. require.EqualValues(t, 3, calls.Load())
  155. expireCodexModelsManifestCache(s, 6*time.Minute)
  156. response, err := s.FetchOpenAIModelsList(context.Background(), account)
  157. require.Error(t, err)
  158. require.Nil(t, response)
  159. require.Equal(t, http.StatusBadGateway, infraerrors.Code(err))
  160. }
  161. func TestFetchOpenAIModelsListRevalidatesUpstreamETag(t *testing.T) {
  162. var calls atomic.Int32
  163. s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  164. response := ordinaryModelsUpstreamResponse(`{"data":[{"id":"unchanged"}]}`)
  165. response.Header.Set("ETag", `"upstream"`)
  166. if calls.Add(1) > 1 {
  167. assert.Equal(t, `"upstream"`, req.Header.Get("If-None-Match"))
  168. response.StatusCode = http.StatusNotModified
  169. }
  170. return response, nil
  171. }})
  172. account := newCodexModelsAPIKeyTestAccount("https://models.example/v1")
  173. first, err := s.FetchOpenAIModelsList(context.Background(), account)
  174. require.NoError(t, err)
  175. expireCodexModelsManifestCache(s, 2*time.Minute)
  176. _, err = s.FetchOpenAIModelsList(context.Background(), account)
  177. require.NoError(t, err)
  178. require.Eventually(t, func() bool {
  179. s.openAIModelsCache.mu.Lock()
  180. defer s.openAIModelsCache.mu.Unlock()
  181. for _, entry := range s.openAIModelsCache.entries {
  182. return entry.expiresAt.After(time.Now())
  183. }
  184. return false
  185. }, 2*time.Second, time.Millisecond)
  186. again, err := s.FetchOpenAIModelsList(context.Background(), account)
  187. require.NoError(t, err)
  188. require.Equal(t, first.ETag, again.ETag)
  189. require.Equal(t, string(first.Body), string(again.Body))
  190. require.EqualValues(t, 2, calls.Load())
  191. }
  192. func TestProjectAccountModelsPassthroughIgnoresStaleMappings(t *testing.T) {
  193. account := newCodexModelsAPIKeyTestAccount("https://models.example/v1")
  194. account.Extra = map[string]any{"openai_passthrough": true}
  195. account.Credentials["model_mapping"] = map[string]any{"obsolete": "missing"}
  196. body := []byte(`{"object":"list","data":[{"id":"live-model"}]}`)
  197. projected, err := projectAccountModelsBody(body, account, nil, false)
  198. require.NoError(t, err)
  199. require.JSONEq(t, string(body), string(projected))
  200. }
  201. func TestFetchOpenAIModelsListEmptyAndMalformedResponses(t *testing.T) {
  202. for _, body := range []string{`{"data":[]}`, `{"data":null}`, `{}`, `{"data":{}}`, `{"data":[{}]}`, `{"data":[null]}`} {
  203. t.Run(body, func(t *testing.T) {
  204. s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  205. return ordinaryModelsUpstreamResponse(body), nil
  206. }})
  207. response, err := s.FetchOpenAIModelsList(context.Background(), newCodexModelsAPIKeyTestAccount("https://models.example/v1"))
  208. if body != `{"data":[]}` {
  209. require.Error(t, err)
  210. require.Equal(t, http.StatusBadGateway, infraerrors.Code(err))
  211. return
  212. }
  213. require.NoError(t, err)
  214. var result struct {
  215. Data []json.RawMessage `json:"data"`
  216. }
  217. require.NoError(t, json.Unmarshal(response.Body, &result))
  218. require.NotNil(t, result.Data)
  219. require.Empty(t, result.Data)
  220. })
  221. }
  222. }
  223. func TestPinnedOpenAIModelsListMixedAccountsShareColdCacheAcrossGroups(t *testing.T) {
  224. _, oauthCalls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"shared-model"},{"slug":"oauth-special"}]}`)
  225. var apiCalls atomic.Int32
  226. s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  227. apiCalls.Add(1)
  228. return ordinaryModelsUpstreamResponse(`{"data":[{"id":"shared-model","owned_by":"api-provider"},{"id":"api-special"}]}`), nil
  229. }})
  230. apiAccount := newCodexModelsAPIKeyTestAccount("https://models.example/v1")
  231. oauthAccount := newCodexModelsTestAccount()
  232. for _, account := range []*Account{apiAccount, oauthAccount} {
  233. account.Status, account.Schedulable = StatusActive, true
  234. }
  235. accounts := []Account{*apiAccount, *oauthAccount}
  236. s.accountRepo = splitCodexModelsAccountRepo{all: map[int64][]Account{10: accounts, 11: accounts}}
  237. groups := []*Group{
  238. {ID: 10, Platform: PlatformOpenAI, CodexModelsManifestConfig: GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2, 1}},
  239. ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"oauth-special", "shared-model"}}},
  240. {ID: 11, Platform: PlatformOpenAI, CodexModelsManifestConfig: GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2, 1}}},
  241. }
  242. type result struct {
  243. response *OpenAIModelsResponse
  244. account *Account
  245. err error
  246. }
  247. results := make([]result, len(groups))
  248. var wait sync.WaitGroup
  249. for i, group := range groups {
  250. wait.Add(1)
  251. go func() {
  252. defer wait.Done()
  253. results[i].response, results[i].account, results[i].err = s.FetchPinnedOpenAIModelsList(context.Background(), group, 3, "")
  254. }()
  255. }
  256. wait.Wait()
  257. for i, result := range results {
  258. require.NoError(t, result.err)
  259. require.EqualValues(t, 2, result.account.ID)
  260. var catalog struct {
  261. Data []struct {
  262. ID string `json:"id"`
  263. Owner string `json:"owned_by"`
  264. } `json:"data"`
  265. }
  266. require.NoError(t, json.Unmarshal(result.response.Body, &catalog))
  267. ids := make([]string, 0, len(catalog.Data))
  268. for _, model := range catalog.Data {
  269. ids = append(ids, model.ID)
  270. if model.ID == "shared-model" {
  271. require.Equal(t, "api-provider", model.Owner)
  272. }
  273. }
  274. if i == 0 {
  275. require.Equal(t, []string{"oauth-special", "shared-model"}, ids)
  276. } else {
  277. require.Equal(t, []string{"shared-model", "api-special", "oauth-special"}, ids)
  278. }
  279. }
  280. require.EqualValues(t, 1, apiCalls.Load())
  281. require.EqualValues(t, 1, oauthCalls.Load())
  282. }
  283. func TestFetchOpenAIModelsListResolvesShadowOAuthCredentials(t *testing.T) {
  284. _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"parent-model"}]}`)
  285. parent := newCodexModelsTestAccount()
  286. shadow := &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parent.ID}
  287. s := &OpenAIGatewayService{accountRepo: newStubCredRepo(parent)}
  288. response, err := s.FetchOpenAIModelsList(context.Background(), shadow)
  289. require.NoError(t, err)
  290. require.Contains(t, string(response.Body), `"id":"parent-model"`)
  291. require.EqualValues(t, 1, calls.Load())
  292. }
  293. func TestProjectAccountModelsCannotExposeCodexMediaThroughAlias(t *testing.T) {
  294. account := newCodexModelsAPIKeyTestAccount("https://models.example/v1")
  295. account.Credentials["model_mapping"] = map[string]any{"image-alias": "gpt-image-1", "auto-alias": "codex-auto-fast"}
  296. body := []byte(`{"models":[{"slug":"gpt-image-1"},{"slug":"codex-auto-fast"}]}`)
  297. projected, err := projectAccountModelsBody(body, account, &Group{}, true)
  298. require.NoError(t, err)
  299. require.JSONEq(t, `{"models":[]}`, string(projected))
  300. }
  301. func TestFetchOpenAIModelsListRejectsUnexpectedCold304(t *testing.T) {
  302. s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  303. return &http.Response{StatusCode: http.StatusNotModified, Header: make(http.Header), Body: http.NoBody}, nil
  304. }})
  305. response, err := s.FetchOpenAIModelsList(context.Background(), newCodexModelsAPIKeyTestAccount("https://models.example/v1"))
  306. require.Error(t, err)
  307. require.Nil(t, response)
  308. require.Equal(t, http.StatusBadGateway, infraerrors.Code(err))
  309. }
  310. func TestOpenAIModelsCacheSeparatesRepresentationsForIdenticalRequests(t *testing.T) {
  311. s := &OpenAIGatewayService{}
  312. request := openAIModelsRequest{url: "https://models.example/v1/models", accountID: 7}
  313. var calls atomic.Int32
  314. fetch := func(body string) func(context.Context, string) (*OpenAIModelsResponse, error) {
  315. return func(context.Context, string) (*OpenAIModelsResponse, error) {
  316. calls.Add(1)
  317. return &OpenAIModelsResponse{Body: []byte(body)}, nil
  318. }
  319. }
  320. manifestBody := `{"models":[{"slug":"shared"}]}`
  321. listBody := `{"object":"list","data":[{"id":"shared"}]}`
  322. _, err := s.fetchCachedOpenAIModels(context.Background(), request, fetch(manifestBody), "")
  323. require.NoError(t, err)
  324. request.standardModelsList = true
  325. list, err := s.fetchCachedOpenAIModels(context.Background(), request, fetch(listBody), "")
  326. require.NoError(t, err)
  327. require.JSONEq(t, listBody, string(list.Body))
  328. request.standardModelsList = false
  329. manifest, err := s.fetchCachedOpenAIModels(context.Background(), request, fetch(manifestBody), "")
  330. require.NoError(t, err)
  331. require.JSONEq(t, manifestBody, string(manifest.Body))
  332. require.EqualValues(t, 2, calls.Load())
  333. }