Merge pull request #1772 from KnowSky404/fix/openai-test-state-reconciliation
[codex] reconcile OpenAI admin test rate-limit state
This commit is contained in:
@@ -538,6 +538,9 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
|||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
|
s.reconcileOpenAI429State(ctx, account, resp.Header, body)
|
||||||
|
}
|
||||||
// 401 Unauthorized: 标记账号为永久错误
|
// 401 Unauthorized: 标记账号为永久错误
|
||||||
if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil {
|
if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil {
|
||||||
errMsg := fmt.Sprintf("Authentication failed (401): %s", string(body))
|
errMsg := fmt.Sprintf("Authentication failed (401): %s", string(body))
|
||||||
@@ -550,6 +553,39 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
|||||||
return s.processOpenAIStream(c, resp.Body)
|
return s.processOpenAIStream(c, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AccountTestService) reconcileOpenAI429State(ctx context.Context, account *Account, headers http.Header, body []byte) {
|
||||||
|
if s == nil || s.accountRepo == nil || account == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var resetAt *time.Time
|
||||||
|
if calculated := calculateOpenAI429ResetTime(headers); calculated != nil {
|
||||||
|
resetAt = calculated
|
||||||
|
} else if unixTs := parseOpenAIRateLimitResetTime(body); unixTs != nil {
|
||||||
|
t := time.Unix(*unixTs, 0)
|
||||||
|
resetAt = &t
|
||||||
|
}
|
||||||
|
if resetAt == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.accountRepo.SetRateLimited(ctx, account.ID, *resetAt); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
account.RateLimitedAt = &now
|
||||||
|
account.RateLimitResetAt = resetAt
|
||||||
|
|
||||||
|
if account.Status == StatusError {
|
||||||
|
if err := s.accountRepo.ClearError(ctx, account.ID); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
account.Status = StatusActive
|
||||||
|
account.ErrorMessage = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// testGeminiAccountConnection tests a Gemini account's connection
|
// testGeminiAccountConnection tests a Gemini account's connection
|
||||||
func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
|
func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|||||||
@@ -61,9 +61,12 @@ func newTestContext() (*gin.Context, *httptest.ResponseRecorder) {
|
|||||||
|
|
||||||
type openAIAccountTestRepo struct {
|
type openAIAccountTestRepo struct {
|
||||||
mockAccountRepoForGemini
|
mockAccountRepoForGemini
|
||||||
updatedExtra map[string]any
|
updatedExtra map[string]any
|
||||||
rateLimitedID int64
|
rateLimitedID int64
|
||||||
rateLimitedAt *time.Time
|
rateLimitedAt *time.Time
|
||||||
|
clearedErrorID int64
|
||||||
|
setErrorID int64
|
||||||
|
setErrorMsg string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *openAIAccountTestRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error {
|
func (r *openAIAccountTestRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error {
|
||||||
@@ -77,6 +80,17 @@ func (r *openAIAccountTestRepo) SetRateLimited(_ context.Context, id int64, rese
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *openAIAccountTestRepo) ClearError(_ context.Context, id int64) error {
|
||||||
|
r.clearedErrorID = id
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *openAIAccountTestRepo) SetError(_ context.Context, id int64, errorMsg string) error {
|
||||||
|
r.setErrorID = id
|
||||||
|
r.setErrorMsg = errorMsg
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing.T) {
|
func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
ctx, recorder := newTestContext()
|
ctx, recorder := newTestContext()
|
||||||
@@ -111,11 +125,11 @@ func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing.
|
|||||||
require.Contains(t, recorder.Body.String(), "test_complete")
|
require.Contains(t, recorder.Body.String(), "test_complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAccountTestService_OpenAI429PersistsSnapshotWithoutRateLimit(t *testing.T) {
|
func TestAccountTestService_OpenAI429PersistsSnapshotAndRateLimitState(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
ctx, _ := newTestContext()
|
ctx, _ := newTestContext()
|
||||||
|
|
||||||
resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached"}}`)
|
resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_at":1777283883}}`)
|
||||||
resp.Header.Set("x-codex-primary-used-percent", "100")
|
resp.Header.Set("x-codex-primary-used-percent", "100")
|
||||||
resp.Header.Set("x-codex-primary-reset-after-seconds", "604800")
|
resp.Header.Set("x-codex-primary-reset-after-seconds", "604800")
|
||||||
resp.Header.Set("x-codex-primary-window-minutes", "10080")
|
resp.Header.Set("x-codex-primary-window-minutes", "10080")
|
||||||
@@ -130,6 +144,7 @@ func TestAccountTestService_OpenAI429PersistsSnapshotWithoutRateLimit(t *testing
|
|||||||
ID: 88,
|
ID: 88,
|
||||||
Platform: PlatformOpenAI,
|
Platform: PlatformOpenAI,
|
||||||
Type: AccountTypeOAuth,
|
Type: AccountTypeOAuth,
|
||||||
|
Status: StatusError,
|
||||||
Concurrency: 1,
|
Concurrency: 1,
|
||||||
Credentials: map[string]any{"access_token": "test-token"},
|
Credentials: map[string]any{"access_token": "test-token"},
|
||||||
}
|
}
|
||||||
@@ -138,7 +153,123 @@ func TestAccountTestService_OpenAI429PersistsSnapshotWithoutRateLimit(t *testing
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.NotEmpty(t, repo.updatedExtra)
|
require.NotEmpty(t, repo.updatedExtra)
|
||||||
require.Equal(t, 100.0, repo.updatedExtra["codex_5h_used_percent"])
|
require.Equal(t, 100.0, repo.updatedExtra["codex_5h_used_percent"])
|
||||||
|
require.Equal(t, account.ID, repo.rateLimitedID)
|
||||||
|
require.NotNil(t, repo.rateLimitedAt)
|
||||||
|
require.Equal(t, account.ID, repo.clearedErrorID)
|
||||||
|
require.Equal(t, StatusActive, account.Status)
|
||||||
|
require.Empty(t, account.ErrorMessage)
|
||||||
|
require.NotNil(t, account.RateLimitResetAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountTestService_OpenAI429BodyOnlyPersistsRateLimitAndClearsStaleError(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
ctx, _ := newTestContext()
|
||||||
|
|
||||||
|
resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_at":"1777283883"}}`)
|
||||||
|
|
||||||
|
repo := &openAIAccountTestRepo{}
|
||||||
|
upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
|
||||||
|
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
|
||||||
|
account := &Account{
|
||||||
|
ID: 77,
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Status: StatusError,
|
||||||
|
ErrorMessage: "Access forbidden (403): account may be suspended or lack permissions",
|
||||||
|
Concurrency: 1,
|
||||||
|
Credentials: map[string]any{"access_token": "test-token"},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, account.ID, repo.rateLimitedID)
|
||||||
|
require.NotNil(t, repo.rateLimitedAt)
|
||||||
|
require.Equal(t, account.ID, repo.clearedErrorID)
|
||||||
|
require.Equal(t, StatusActive, account.Status)
|
||||||
|
require.Empty(t, account.ErrorMessage)
|
||||||
|
require.NotNil(t, account.RateLimitResetAt)
|
||||||
|
require.Empty(t, repo.updatedExtra)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountTestService_OpenAI429ActiveAccountDoesNotClearError(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
ctx, _ := newTestContext()
|
||||||
|
|
||||||
|
resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_in_seconds":3600}}`)
|
||||||
|
|
||||||
|
repo := &openAIAccountTestRepo{}
|
||||||
|
upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
|
||||||
|
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
|
||||||
|
account := &Account{
|
||||||
|
ID: 78,
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Status: StatusActive,
|
||||||
|
Concurrency: 1,
|
||||||
|
Credentials: map[string]any{"access_token": "test-token"},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, account.ID, repo.rateLimitedID)
|
||||||
|
require.NotNil(t, repo.rateLimitedAt)
|
||||||
|
require.Zero(t, repo.clearedErrorID)
|
||||||
|
require.Equal(t, StatusActive, account.Status)
|
||||||
|
require.NotNil(t, account.RateLimitResetAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountTestService_OpenAI429WithoutResetSignalDoesNotMutateRuntimeState(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
ctx, _ := newTestContext()
|
||||||
|
|
||||||
|
resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached"}}`)
|
||||||
|
|
||||||
|
repo := &openAIAccountTestRepo{}
|
||||||
|
upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
|
||||||
|
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
|
||||||
|
account := &Account{
|
||||||
|
ID: 79,
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Status: StatusError,
|
||||||
|
ErrorMessage: "stale 403",
|
||||||
|
Concurrency: 1,
|
||||||
|
Credentials: map[string]any{"access_token": "test-token"},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "")
|
||||||
|
require.Error(t, err)
|
||||||
require.Zero(t, repo.rateLimitedID)
|
require.Zero(t, repo.rateLimitedID)
|
||||||
require.Nil(t, repo.rateLimitedAt)
|
require.Nil(t, repo.rateLimitedAt)
|
||||||
|
require.Zero(t, repo.clearedErrorID)
|
||||||
|
require.Equal(t, StatusError, account.Status)
|
||||||
|
require.Equal(t, "stale 403", account.ErrorMessage)
|
||||||
|
require.Nil(t, account.RateLimitResetAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountTestService_OpenAI401SetsPermanentErrorOnly(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
ctx, _ := newTestContext()
|
||||||
|
|
||||||
|
resp := newJSONResponse(http.StatusUnauthorized, `{"error":"bad token"}`)
|
||||||
|
|
||||||
|
repo := &openAIAccountTestRepo{}
|
||||||
|
upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
|
||||||
|
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
|
||||||
|
account := &Account{
|
||||||
|
ID: 80,
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Status: StatusActive,
|
||||||
|
Concurrency: 1,
|
||||||
|
Credentials: map[string]any{"access_token": "test-token"},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, account.ID, repo.setErrorID)
|
||||||
|
require.Contains(t, repo.setErrorMsg, "Authentication failed (401)")
|
||||||
|
require.Zero(t, repo.rateLimitedID)
|
||||||
|
require.Zero(t, repo.clearedErrorID)
|
||||||
require.Nil(t, account.RateLimitResetAt)
|
require.Nil(t, account.RateLimitResetAt)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -931,7 +931,7 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
|
|||||||
|
|
||||||
// calculateOpenAI429ResetTime 从 OpenAI 429 响应头计算正确的重置时间
|
// calculateOpenAI429ResetTime 从 OpenAI 429 响应头计算正确的重置时间
|
||||||
// 返回 nil 表示无法从响应头中确定重置时间
|
// 返回 nil 表示无法从响应头中确定重置时间
|
||||||
func (s *RateLimitService) calculateOpenAI429ResetTime(headers http.Header) *time.Time {
|
func calculateOpenAI429ResetTime(headers http.Header) *time.Time {
|
||||||
snapshot := ParseCodexRateLimitHeaders(headers)
|
snapshot := ParseCodexRateLimitHeaders(headers)
|
||||||
if snapshot == nil {
|
if snapshot == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -977,6 +977,10 @@ func (s *RateLimitService) calculateOpenAI429ResetTime(headers http.Header) *tim
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *RateLimitService) calculateOpenAI429ResetTime(headers http.Header) *time.Time {
|
||||||
|
return calculateOpenAI429ResetTime(headers)
|
||||||
|
}
|
||||||
|
|
||||||
// anthropic429Result holds the parsed Anthropic 429 rate-limit information.
|
// anthropic429Result holds the parsed Anthropic 429 rate-limit information.
|
||||||
type anthropic429Result struct {
|
type anthropic429Result struct {
|
||||||
resetAt time.Time // The correct reset time to use for SetRateLimited
|
resetAt time.Time // The correct reset time to use for SetRateLimited
|
||||||
|
|||||||
Reference in New Issue
Block a user