| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960 |
- //go:build unit
- package handler
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
- "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/Wei-Shaw/sub2api/internal/service"
- coderws "github.com/coder/websocket"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- type grokCredentialHandlerRepo struct {
- service.AccountRepository
- mu sync.Mutex
- accounts []service.Account
- setErrorIDs []int64
- setTempIDs []int64
- rateLimitIDs []int64
- updateExtraIDs []int64
- selectionCalls int
- setErrorErr error
- setTempErr error
- missingOnGet map[int64]bool
- }
- func (r *grokCredentialHandlerRepo) ListSchedulableByPlatform(_ context.Context, platform string) ([]service.Account, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.selectionCalls++
- out := make([]service.Account, 0, len(r.accounts))
- for _, account := range r.accounts {
- if account.Platform == platform && account.IsSchedulable() {
- out = append(out, account)
- }
- }
- return out, nil
- }
- func (r *grokCredentialHandlerRepo) ListSchedulableByGroupIDAndPlatform(ctx context.Context, _ int64, platform string) ([]service.Account, error) {
- return r.ListSchedulableByPlatform(ctx, platform)
- }
- func (r *grokCredentialHandlerRepo) ListSchedulableUngroupedByPlatform(ctx context.Context, platform string) ([]service.Account, error) {
- return r.ListSchedulableByPlatform(ctx, platform)
- }
- func (r *grokCredentialHandlerRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- if r.missingOnGet[id] {
- return nil, nil
- }
- for _, account := range r.accounts {
- if account.ID == id {
- copy := account
- copy.Credentials = cloneCredentialMap(account.Credentials)
- return ©, nil
- }
- }
- return nil, nil
- }
- func (r *grokCredentialHandlerRepo) SetError(_ context.Context, id int64, message string) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.setErrorIDs = append(r.setErrorIDs, id)
- if r.setErrorErr != nil {
- return r.setErrorErr
- }
- for i := range r.accounts {
- if r.accounts[i].ID == id {
- r.accounts[i].Status = service.StatusError
- r.accounts[i].Schedulable = false
- r.accounts[i].ErrorMessage = message
- }
- }
- return nil
- }
- func (r *grokCredentialHandlerRepo) SetTempUnschedulable(_ context.Context, id int64, until time.Time, _ string) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.setTempIDs = append(r.setTempIDs, id)
- if r.setTempErr != nil {
- return r.setTempErr
- }
- for i := range r.accounts {
- if r.accounts[i].ID == id {
- value := until
- r.accounts[i].TempUnschedulableUntil = &value
- }
- }
- return nil
- }
- func (r *grokCredentialHandlerRepo) SetRateLimited(_ context.Context, id int64, resetAt time.Time) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.rateLimitIDs = append(r.rateLimitIDs, id)
- for i := range r.accounts {
- if r.accounts[i].ID != id {
- continue
- }
- now := time.Now()
- r.accounts[i].RateLimitedAt = &now
- value := resetAt
- r.accounts[i].RateLimitResetAt = &value
- }
- return nil
- }
- func (r *grokCredentialHandlerRepo) SetRateLimitedIfLater(ctx context.Context, id int64, resetAt time.Time) error {
- r.mu.Lock()
- for i := range r.accounts {
- if r.accounts[i].ID == id && r.accounts[i].RateLimitResetAt != nil && !resetAt.After(*r.accounts[i].RateLimitResetAt) {
- r.mu.Unlock()
- return nil
- }
- }
- r.mu.Unlock()
- return r.SetRateLimited(ctx, id, resetAt)
- }
- func (r *grokCredentialHandlerRepo) SetGrokCredentialErrorIfMatch(
- _ context.Context,
- id int64,
- snapshot service.GrokCredentialMutationSnapshot,
- message string,
- ) (bool, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- for i := range r.accounts {
- account := &r.accounts[i]
- if account.ID != id || !handlerGrokCredentialSnapshotMatches(account, snapshot) {
- continue
- }
- r.setErrorIDs = append(r.setErrorIDs, id)
- if r.setErrorErr != nil {
- return false, r.setErrorErr
- }
- account.Status = service.StatusError
- account.Schedulable = false
- account.ErrorMessage = message
- return true, nil
- }
- return false, nil
- }
- func (r *grokCredentialHandlerRepo) SetGrokCredentialTempUnschedulableIfMatch(
- _ context.Context,
- id int64,
- snapshot service.GrokCredentialMutationSnapshot,
- until time.Time,
- _ string,
- ) (bool, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- for i := range r.accounts {
- account := &r.accounts[i]
- if account.ID != id || !handlerGrokCredentialSnapshotMatches(account, snapshot) {
- continue
- }
- r.setTempIDs = append(r.setTempIDs, id)
- if r.setTempErr != nil {
- return false, r.setTempErr
- }
- value := until
- account.TempUnschedulableUntil = &value
- return true, nil
- }
- return false, nil
- }
- func handlerGrokCredentialSnapshotMatches(account *service.Account, snapshot service.GrokCredentialMutationSnapshot) bool {
- if account == nil {
- return false
- }
- credentialsJSON, err := json.Marshal(account.Credentials)
- return err == nil && account.IsGrokOAuth() && account.IsSchedulable() && string(credentialsJSON) == snapshot.CredentialsJSON &&
- handlerGrokCredentialProxyIDsEqual(account.ProxyID, snapshot.ProxyID)
- }
- func handlerGrokCredentialProxyIDsEqual(left, right *int64) bool {
- if left == nil || right == nil {
- return left == nil && right == nil
- }
- return *left == *right
- }
- func (r *grokCredentialHandlerRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.updateExtraIDs = append(r.updateExtraIDs, id)
- for i := range r.accounts {
- if r.accounts[i].ID != id {
- continue
- }
- if r.accounts[i].Extra == nil {
- r.accounts[i].Extra = map[string]any{}
- }
- for key, value := range updates {
- r.accounts[i].Extra[key] = value
- }
- }
- return nil
- }
- func (r *grokCredentialHandlerRepo) errorIDs() []int64 {
- r.mu.Lock()
- defer r.mu.Unlock()
- return append([]int64(nil), r.setErrorIDs...)
- }
- func (r *grokCredentialHandlerRepo) selectorCalls() int {
- r.mu.Lock()
- defer r.mu.Unlock()
- return r.selectionCalls
- }
- func (r *grokCredentialHandlerRepo) rateLimitedAccountIDs() []int64 {
- r.mu.Lock()
- defer r.mu.Unlock()
- return append([]int64(nil), r.rateLimitIDs...)
- }
- type grokCredentialHandlerTokenCache struct {
- service.GrokTokenCache
- mu sync.Mutex
- deleteErr error
- }
- func (c *grokCredentialHandlerTokenCache) GetAccessToken(context.Context, string) (string, error) {
- return "", errors.New("not cached")
- }
- func (c *grokCredentialHandlerTokenCache) SetAccessToken(context.Context, string, string, time.Duration) error {
- return nil
- }
- func (c *grokCredentialHandlerTokenCache) DeleteAccessToken(context.Context, string) error {
- c.mu.Lock()
- defer c.mu.Unlock()
- return c.deleteErr
- }
- func (c *grokCredentialHandlerTokenCache) AcquireRefreshLock(context.Context, string, time.Duration) (bool, error) {
- return true, nil
- }
- func (c *grokCredentialHandlerTokenCache) ReleaseRefreshLock(context.Context, string) error {
- return nil
- }
- func cloneCredentialMap(source map[string]any) map[string]any {
- cloned := make(map[string]any, len(source))
- for key, value := range source {
- cloned[key] = value
- }
- return cloned
- }
- type grokCredentialHandlerRefresher struct {
- mode string
- started chan struct{}
- once sync.Once
- }
- func (r *grokCredentialHandlerRefresher) CacheKey(account *service.Account) string {
- return service.GrokTokenCacheKey(account)
- }
- func (r *grokCredentialHandlerRefresher) CanRefresh(account *service.Account) bool {
- return account != nil && account.IsGrokOAuth()
- }
- func (r *grokCredentialHandlerRefresher) NeedsRefresh(account *service.Account, _ time.Duration) bool {
- return account != nil && (account.ID == 801 || r.mode == "all_revoked")
- }
- func (r *grokCredentialHandlerRefresher) Refresh(ctx context.Context, _ *service.Account) (map[string]any, error) {
- switch r.mode {
- case "revoked", "all_revoked", "mutation_set_error", "mutation_cache":
- return nil, infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_TOKEN_REFRESH_FAILED", "invalid_grant")
- case "provider":
- return nil, infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_TOKEN_REFRESH_FAILED", "invalid_client")
- case "cancel":
- r.once.Do(func() { close(r.started) })
- <-ctx.Done()
- return nil, ctx.Err()
- case "transient", "mutation_temp":
- return nil, errors.New("temporary refresh transport failure")
- default:
- return nil, nil
- }
- }
- type grokCredentialHandlerUpstream struct {
- service.HTTPUpstream
- mu sync.Mutex
- hits []int64
- requestURLs []string
- authorization []string
- failAccountID int64
- rateLimitIDs map[int64]bool
- failureStatus map[int64]int
- cancelRequest context.CancelFunc
- }
- func (u *grokCredentialHandlerUpstream) Do(req *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
- var requestBody []byte
- if req.Body != nil {
- requestBody, _ = io.ReadAll(req.Body)
- }
- u.mu.Lock()
- u.hits = append(u.hits, accountID)
- u.requestURLs = append(u.requestURLs, req.URL.String())
- u.authorization = append(u.authorization, req.Header.Get("Authorization"))
- failAccountID := u.failAccountID
- rateLimited := u.rateLimitIDs[accountID]
- failureStatus := u.failureStatus[accountID]
- cancelRequest := u.cancelRequest
- u.mu.Unlock()
- if rateLimited {
- return &http.Response{
- StatusCode: http.StatusTooManyRequests,
- Header: http.Header{
- "Content-Type": []string{"application/json"},
- "Retry-After": []string{"60"},
- },
- Body: io.NopCloser(bytes.NewBufferString(`{"error":{"message":"rate limited"}}`)),
- }, nil
- }
- if failureStatus > 0 {
- return &http.Response{
- StatusCode: failureStatus,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(bytes.NewBufferString(`{"error":{"message":"upstream unavailable"}}`)),
- }, nil
- }
- if accountID == failAccountID {
- if cancelRequest != nil {
- cancelRequest()
- }
- return &http.Response{
- StatusCode: http.StatusPaymentRequired,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(bytes.NewBufferString(`{"error":{"message":"payment required"}}`)),
- }, nil
- }
- if bytes.Contains(requestBody, []byte(`"stream":true`)) {
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(bytes.NewBufferString(
- "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_healthy\",\"model\":\"grok-4.5\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n",
- )),
- }, nil
- }
- if strings.Contains(req.URL.Path, "/chat/completions") {
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(bytes.NewBufferString(
- `{"id":"chatcmpl_healthy","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
- )),
- }, nil
- }
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(bytes.NewBufferString(
- `{"id":"resp_healthy","object":"response","model":"grok-4.5","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`,
- )),
- }, nil
- }
- func (u *grokCredentialHandlerUpstream) accountHits() []int64 {
- u.mu.Lock()
- defer u.mu.Unlock()
- return append([]int64(nil), u.hits...)
- }
- func (u *grokCredentialHandlerUpstream) requests() ([]string, []string) {
- u.mu.Lock()
- defer u.mu.Unlock()
- return append([]string(nil), u.requestURLs...), append([]string(nil), u.authorization...)
- }
- func TestResponsesCredentialFailoverLoop(t *testing.T) {
- gin.SetMode(gin.TestMode)
- t.Run("revoked account selects healthy account", func(t *testing.T) {
- h, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "revoked")
- defer cleanup()
- _ = h
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
- require.Contains(t, recorder.Body.String(), "resp_healthy")
- require.Equal(t, []int64{801}, repo.errorIDs())
- require.Equal(t, []int64{802}, upstream.accountHits())
- requestURLs, authorization := upstream.requests()
- require.Equal(t, []string{xai.DefaultCLIBaseURL + "/responses"}, requestURLs)
- require.Equal(t, []string{"Bearer healthy-access"}, authorization)
- })
- t.Run("provider configuration stops before healthy account", func(t *testing.T) {
- h, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "provider")
- defer cleanup()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
- require.Contains(t, recorder.Body.String(), service.GrokCredentialUnavailableClientMessage)
- require.Empty(t, repo.errorIDs())
- require.Empty(t, upstream.accountHits())
- require.Equal(t, 1, repo.selectorCalls())
- require.Zero(t, h.gatewayService.SnapshotOpenAIAccountSchedulerMetrics().RuntimeStatsAccountCount,
- "provider-scoped auth failure must not penalize the selected account")
- })
- t.Run("parent cancellation stops before healthy account", func(t *testing.T) {
- _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "cancel")
- defer cleanup()
- ctx, cancel := context.WithCancel(context.Background())
- req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`)).WithContext(ctx)
- req.Header.Set("Content-Type", "application/json")
- recorder := httptest.NewRecorder()
- done := make(chan struct{})
- go func() {
- defer close(done)
- router.ServeHTTP(recorder, req)
- }()
- select {
- case <-time.After(2 * time.Second):
- t.Fatal("credential refresh did not start")
- case <-findHandlerRefresherStarted(router):
- cancel()
- }
- select {
- case <-time.After(2 * time.Second):
- t.Fatal("handler did not stop after cancellation")
- case <-done:
- }
- require.Empty(t, repo.errorIDs())
- require.Empty(t, upstream.accountHits())
- })
- t.Run("post-mapping cancellation stops before scheduler mutation or reselection", func(t *testing.T) {
- h, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "postmap_cancel")
- defer cleanup()
- ctx, cancel := context.WithCancel(context.Background())
- upstream.mu.Lock()
- upstream.failAccountID = 801
- upstream.cancelRequest = cancel
- upstream.mu.Unlock()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`)).WithContext(ctx)
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, []int64{801}, upstream.accountHits())
- require.Empty(t, repo.errorIDs())
- require.Equal(t, 1, repo.selectorCalls())
- require.Zero(t, h.gatewayService.SnapshotOpenAIAccountSchedulerMetrics().RuntimeStatsAccountCount)
- })
- t.Run("pre-cancelled request never invokes an account selector", func(t *testing.T) {
- tests := []struct {
- name string
- method string
- path string
- body string
- }{
- {name: "responses", method: http.MethodPost, path: "/openai/v1/responses", body: `{"model":"grok","input":"hello","stream":false}`},
- {name: "messages", method: http.MethodPost, path: "/openai/v1/messages", body: `{"model":"grok","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`},
- {name: "chat completions", method: http.MethodPost, path: "/openai/v1/chat/completions", body: `{"model":"grok","messages":[{"role":"user","content":"hello"}],"stream":false}`},
- {name: "grok media", method: http.MethodGet, path: "/openai/v1/videos/request-1"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "revoked")
- defer cleanup()
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body)).WithContext(ctx)
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Zero(t, repo.selectorCalls())
- require.Empty(t, upstream.accountHits())
- })
- }
- })
- t.Run("credential state mutation failures stop before reselection", func(t *testing.T) {
- for _, mode := range []string{"mutation_set_error", "mutation_temp", "mutation_cache"} {
- t.Run(mode, func(t *testing.T) {
- _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, mode)
- defer cleanup()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String())
- require.Contains(t, recorder.Body.String(), service.GrokCredentialUnavailableClientMessage)
- require.Empty(t, upstream.accountHits())
- require.Equal(t, 1, repo.selectorCalls())
- })
- }
- })
- t.Run("missing credential provider stops before upstream or reselection", func(t *testing.T) {
- _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "nil_provider")
- defer cleanup()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String())
- require.Contains(t, recorder.Body.String(), service.GrokCredentialUnavailableClientMessage)
- require.Equal(t, 1, repo.selectorCalls())
- require.Empty(t, upstream.accountHits())
- require.Empty(t, repo.errorIDs())
- })
- }
- func TestResponsesGrok429FailoverIsBounded(t *testing.T) {
- gin.SetMode(gin.TestMode)
- t.Run("first rate limited account selects healthy account", func(t *testing.T) {
- _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "first_429")
- defer cleanup()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
- require.Contains(t, recorder.Body.String(), "resp_healthy")
- require.Equal(t, []int64{801, 802}, upstream.accountHits())
- require.Equal(t, []int64{801}, repo.rateLimitedAccountIDs())
- })
- t.Run("two rate limited accounts stop without sweeping the pool", func(t *testing.T) {
- _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "all_429")
- defer cleanup()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, http.StatusTooManyRequests, recorder.Code, recorder.Body.String())
- require.Equal(t, []int64{801, 802}, upstream.accountHits())
- require.Equal(t, []int64{801, 802}, repo.rateLimitedAccountIDs())
- require.NotContains(t, recorder.Body.String(), "expired")
- require.NotContains(t, recorder.Body.String(), "healthy-access")
- require.NotContains(t, recorder.Body.String(), "rate limited")
- })
- }
- func TestResponsesGrok402FailoverCooldown(t *testing.T) {
- gin.SetMode(gin.TestMode)
- _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "first_402")
- defer cleanup()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
- require.Contains(t, recorder.Body.String(), "resp_healthy")
- require.Equal(t, []int64{801, 802}, upstream.accountHits())
- require.Equal(t, []int64{801}, repo.setTempIDs)
- before := repo.selectorCalls()
- second := httptest.NewRecorder()
- secondReq := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"again","stream":false}`))
- secondReq.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(second, secondReq)
- require.Equal(t, http.StatusOK, second.Code, second.Body.String())
- require.Equal(t, before+1, repo.selectorCalls())
- require.Equal(t, []int64{801, 802, 802}, upstream.accountHits(), "cooldown must exclude the 402 account from later requests")
- }
- func TestResponsesGrok429FailoverHandlesMixedStatuses(t *testing.T) {
- gin.SetMode(gin.TestMode)
- t.Run("429 then 500 stops after the bounded followup", func(t *testing.T) {
- _, _, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "mixed_429_500")
- defer cleanup()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, http.StatusBadGateway, recorder.Code, recorder.Body.String())
- require.Equal(t, []int64{801, 802}, upstream.accountHits())
- require.NotContains(t, recorder.Body.String(), "upstream unavailable")
- })
- t.Run("500 then 429 permits one healthy followup", func(t *testing.T) {
- _, _, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "mixed_500_429")
- defer cleanup()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
- require.Equal(t, []int64{801, 802, 803}, upstream.accountHits())
- })
- t.Run("OAuth 429 then API-key failure cannot bypass the bound", func(t *testing.T) {
- _, _, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "oauth_429_apikey_500")
- defer cleanup()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, http.StatusBadGateway, recorder.Code, recorder.Body.String())
- require.Equal(t, []int64{801, 802}, upstream.accountHits())
- })
- }
- func TestGrokMedia429FailoverIsBounded(t *testing.T) {
- gin.SetMode(gin.TestMode)
- t.Run("first 429 selects one healthy followup", func(t *testing.T) {
- _, _, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "first_429")
- defer cleanup()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/openai/v1/videos/generations", bytes.NewBufferString(`{"model":"grok-imagine-video","prompt":"waves"}`))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
- require.Equal(t, []int64{801, 802}, upstream.accountHits())
- })
- t.Run("second 429 stops without sweeping a third account", func(t *testing.T) {
- _, _, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "all_429")
- defer cleanup()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/openai/v1/videos/generations", bytes.NewBufferString(`{"model":"grok-imagine-video","prompt":"waves"}`))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, http.StatusTooManyRequests, recorder.Code, recorder.Body.String())
- require.Equal(t, []int64{801, 802}, upstream.accountHits())
- require.NotContains(t, recorder.Body.String(), "rate limited")
- })
- }
- func TestGrokOAuthCredentialFailoverAcrossHTTPHandlers(t *testing.T) {
- gin.SetMode(gin.TestMode)
- endpoints := []struct {
- name string
- method string
- path string
- body string
- }{
- {name: "messages", method: http.MethodPost, path: "/openai/v1/messages", body: `{"model":"grok","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`},
- {name: "chat completions", method: http.MethodPost, path: "/openai/v1/chat/completions", body: `{"model":"grok","messages":[{"role":"user","content":"hello"}],"stream":false}`},
- {name: "chat completions raw fallback", method: http.MethodPost, path: "/openai/v1/chat/completions", body: `{"model":"grok","messages":[{"role":"user","content":"hello"}],"stop":["END"],"stream":false}`},
- {name: "grok media", method: http.MethodPost, path: "/openai/v1/videos/generations", body: `{"model":"grok-imagine-video","prompt":"waves"}`},
- }
- for _, endpoint := range endpoints {
- t.Run(endpoint.name+" revoked selects healthy", func(t *testing.T) {
- _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "revoked")
- defer cleanup()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(endpoint.method, endpoint.path, bytes.NewBufferString(endpoint.body))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
- require.Equal(t, []int64{801}, repo.errorIDs())
- require.Equal(t, []int64{802}, upstream.accountHits())
- })
- t.Run(endpoint.name+" all accounts exhausted safely", func(t *testing.T) {
- _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "all_revoked")
- defer cleanup()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(endpoint.method, endpoint.path, bytes.NewBufferString(endpoint.body))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String())
- require.Contains(t, recorder.Body.String(), service.GrokCredentialUnavailableClientMessage)
- require.NotContains(t, recorder.Body.String(), "revoked-refresh")
- require.NotContains(t, recorder.Body.String(), "healthy-refresh")
- require.Equal(t, []int64{801, 802}, repo.errorIDs())
- require.Empty(t, upstream.accountHits())
- })
- }
- }
- func TestGrokOAuthMissingSelectedRowRetriesHealthyAccountWithoutMutation(t *testing.T) {
- gin.SetMode(gin.TestMode)
- _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "missing_row")
- defer cleanup()
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(recorder, req)
- require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
- require.Equal(t, []int64{802}, upstream.accountHits())
- require.Empty(t, repo.errorIDs())
- require.Empty(t, repo.setTempIDs)
- }
- func TestResponsesWebSocketCredentialFailoverLoop(t *testing.T) {
- gin.SetMode(gin.TestMode)
- dial := func(t *testing.T, router *gin.Engine) (*coderws.Conn, func()) {
- t.Helper()
- server := httptest.NewServer(router)
- ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
- conn, _, err := coderws.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http")+"/openai/v1/responses", nil)
- cancel()
- require.NoError(t, err)
- return conn, func() {
- _ = conn.CloseNow()
- server.Close()
- }
- }
- writeFirst := func(t *testing.T, conn *coderws.Conn) {
- t.Helper()
- ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
- defer cancel()
- require.NoError(t, conn.Write(ctx, coderws.MessageText, []byte(`{"type":"response.create","model":"grok","input":"hello","stream":false}`)))
- }
- t.Run("revoked account selects healthy account", func(t *testing.T) {
- _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "revoked")
- defer cleanup()
- conn, closeConn := dial(t, router)
- defer closeConn()
- writeFirst(t, conn)
- readCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- _, payload, err := conn.Read(readCtx)
- cancel()
- require.NoError(t, err)
- require.Contains(t, string(payload), "resp_healthy")
- require.Equal(t, []int64{801}, repo.errorIDs())
- require.Equal(t, 2, repo.selectorCalls())
- require.Equal(t, []int64{802}, upstream.accountHits())
- })
- t.Run("provider configuration stops", func(t *testing.T) {
- _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "provider")
- defer cleanup()
- conn, closeConn := dial(t, router)
- defer closeConn()
- writeFirst(t, conn)
- readCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
- _, _, err := conn.Read(readCtx)
- cancel()
- var closeErr coderws.CloseError
- require.ErrorAs(t, err, &closeErr)
- require.Contains(t, closeErr.Reason, service.GrokCredentialUnavailableClientMessage)
- require.Equal(t, 1, repo.selectorCalls())
- require.Empty(t, upstream.accountHits())
- })
- t.Run("parent cancellation prevents reselection", func(t *testing.T) {
- _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "cancel")
- defer cleanup()
- conn, closeConn := dial(t, router)
- writeFirst(t, conn)
- select {
- case <-findHandlerRefresherStarted(router):
- case <-time.After(2 * time.Second):
- t.Fatal("credential refresh did not start")
- }
- closeConn()
- require.Eventually(t, func() bool { return repo.selectorCalls() == 1 }, 2*time.Second, 20*time.Millisecond)
- require.Empty(t, repo.errorIDs())
- require.Empty(t, upstream.accountHits())
- })
- }
- var handlerRefresherStarted sync.Map
- func findHandlerRefresherStarted(router *gin.Engine) <-chan struct{} {
- value, _ := handlerRefresherStarted.Load(router)
- return value.(chan struct{})
- }
- func newGrokCredentialFailoverHandler(t *testing.T, mode string) (*OpenAIGatewayHandler, *grokCredentialHandlerRepo, *grokCredentialHandlerUpstream, *gin.Engine, func()) {
- t.Helper()
- groupID := int64(901)
- accounts := []service.Account{
- {
- ID: 801, Name: "revoked", Platform: service.PlatformGrok, Type: service.AccountTypeOAuth,
- Status: service.StatusActive, Schedulable: true, Concurrency: 1, Priority: 1,
- Credentials: map[string]any{
- "access_token": "expired", "refresh_token": "revoked-refresh",
- "expires_at": time.Now().Add(-time.Minute).UTC().Format(time.RFC3339),
- },
- Extra: map[string]any{service.GrokMediaEligibleExtraKey: true},
- },
- {
- ID: 802, Name: "healthy", Platform: service.PlatformGrok, Type: service.AccountTypeOAuth,
- Status: service.StatusActive, Schedulable: true, Concurrency: 1, Priority: 2,
- Credentials: map[string]any{
- "access_token": "healthy-access", "refresh_token": "healthy-refresh",
- "expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
- },
- Extra: map[string]any{service.GrokMediaEligibleExtraKey: true},
- },
- }
- if mode == "postmap_cancel" || mode == "first_402" || mode == "first_429" || mode == "all_429" || mode == "mixed_429_500" || mode == "mixed_500_429" || mode == "oauth_429_apikey_500" {
- accounts[0].Credentials["expires_at"] = time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339)
- }
- if mode == "all_429" || mode == "mixed_429_500" || mode == "mixed_500_429" || mode == "oauth_429_apikey_500" {
- accounts = append(accounts, service.Account{
- ID: 803, Name: "untried-healthy", Platform: service.PlatformGrok, Type: service.AccountTypeOAuth,
- Status: service.StatusActive, Schedulable: true, Concurrency: 1, Priority: 3,
- Credentials: map[string]any{
- "access_token": "untried-healthy-access", "refresh_token": "untried-healthy-refresh",
- "expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
- },
- Extra: map[string]any{service.GrokMediaEligibleExtraKey: true},
- })
- }
- if mode == "oauth_429_apikey_500" {
- accounts[1].Type = service.AccountTypeAPIKey
- accounts[1].Credentials = map[string]any{"api_key": "third-party-key"}
- }
- if mode == "all_revoked" {
- accounts[1].Credentials["expires_at"] = time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)
- }
- repo := &grokCredentialHandlerRepo{accounts: accounts, missingOnGet: map[int64]bool{}}
- if mode == "missing_row" {
- repo.missingOnGet[801] = true
- }
- if mode == "mutation_set_error" {
- repo.setErrorErr = errors.New("database write failed")
- }
- if mode == "mutation_temp" {
- repo.setTempErr = errors.New("database write failed")
- }
- refresher := &grokCredentialHandlerRefresher{mode: mode, started: make(chan struct{})}
- tokenCache := &grokCredentialHandlerTokenCache{}
- if mode == "mutation_cache" {
- tokenCache.deleteErr = errors.New("cache delete failed")
- }
- var provider *service.GrokTokenProvider
- if mode != "nil_provider" {
- provider = service.NewGrokTokenProvider(repo, tokenCache)
- provider.SetRefreshAPI(service.NewOAuthRefreshAPI(repo, tokenCache), refresher)
- }
- upstream := &grokCredentialHandlerUpstream{}
- switch mode {
- case "first_402":
- upstream.failAccountID = 801
- case "first_429":
- upstream.rateLimitIDs = map[int64]bool{801: true}
- case "all_429":
- upstream.rateLimitIDs = map[int64]bool{801: true, 802: true}
- case "mixed_429_500":
- upstream.rateLimitIDs = map[int64]bool{801: true}
- upstream.failureStatus = map[int64]int{802: http.StatusInternalServerError}
- case "mixed_500_429":
- upstream.failureStatus = map[int64]int{801: http.StatusInternalServerError}
- upstream.rateLimitIDs = map[int64]bool{802: true}
- case "oauth_429_apikey_500":
- upstream.rateLimitIDs = map[int64]bool{801: true}
- upstream.failureStatus = map[int64]int{802: http.StatusInternalServerError}
- }
- cfg := &config.Config{RunMode: config.RunModeSimple}
- cfg.Gateway.MaxAccountSwitches = 3
- billingCache := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
- gateway := service.NewOpenAIGatewayService(
- repo, nil, nil, nil, nil, nil, nil, cfg, nil, nil,
- service.NewBillingService(cfg, nil), nil, billingCache, upstream,
- &service.DeferredService{}, nil, provider, nil, nil, nil, nil, nil,
- )
- cache := &concurrencyCacheMock{
- acquireUserSlotFn: func(context.Context, int64, int, string) (bool, error) { return true, nil },
- acquireAccountSlotFn: func(context.Context, int64, int, string) (bool, error) { return true, nil },
- }
- h := NewOpenAIGatewayHandler(gateway, service.NewConcurrencyService(cache), billingCache, &service.APIKeyService{}, nil, nil, nil, nil, cfg)
- apiKey := &service.APIKey{
- ID: 902, GroupID: &groupID,
- User: &service.User{ID: 903, Status: service.StatusActive},
- Group: &service.Group{ID: groupID, Platform: service.PlatformGrok, Status: service.StatusActive, AllowImageGeneration: true},
- }
- router := gin.New()
- router.Use(func(c *gin.Context) {
- c.Set(string(middleware.ContextKeyAPIKey), apiKey)
- c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.User.ID, Concurrency: 1})
- c.Next()
- })
- router.POST("/openai/v1/responses", h.Responses)
- router.GET("/openai/v1/responses", h.ResponsesWebSocket)
- router.POST("/openai/v1/messages", h.Messages)
- router.POST("/openai/v1/chat/completions", h.ChatCompletions)
- router.POST("/openai/v1/videos/generations", h.GrokVideoGeneration)
- router.GET("/openai/v1/videos/:request_id", h.GrokVideoStatus)
- handlerRefresherStarted.Store(router, refresher.started)
- cleanup := func() {
- handlerRefresherStarted.Delete(router)
- billingCache.Stop()
- }
- return h, repo, upstream, router, cleanup
- }
|