Merge pull request #4616 from zhangxaochen/codex/openai-quota-error

fix(openai): return standard insufficient quota errors
This commit is contained in:
Wesley Liddick
2026-07-20 15:30:41 +08:00
committed by GitHub
3 changed files with 117 additions and 2 deletions
@@ -217,7 +217,7 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
// Key 状态检查
switch apiKey.Status {
case service.StatusAPIKeyQuotaExhausted:
AbortWithError(c, 429, "API_KEY_QUOTA_EXHAUSTED", "API key 额度已用完")
abortWithAPIKeyQuotaError(c)
return
case service.StatusAPIKeyExpired:
AbortWithError(c, 403, "API_KEY_EXPIRED", "API key 已过期")
@@ -230,7 +230,7 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
return
}
if apiKey.IsQuotaExhausted() {
AbortWithError(c, 429, "API_KEY_QUOTA_EXHAUSTED", "API key 额度已用完")
abortWithAPIKeyQuotaError(c)
return
}
@@ -305,6 +305,34 @@ func hasAPIKeyCredentialInput(c *gin.Context) bool {
c.GetHeader("x-goog-api-key") != ""
}
func abortWithAPIKeyQuotaError(c *gin.Context) {
const message = "API key 额度已用完"
if isOpenAICompatibleAPIKeyRequest(c) {
abortWithOpenAIQuotaError(c, http.StatusTooManyRequests, message)
return
}
AbortWithError(c, http.StatusTooManyRequests, "API_KEY_QUOTA_EXHAUSTED", message)
}
func isOpenAICompatibleAPIKeyRequest(c *gin.Context) bool {
if c == nil || c.Request == nil || c.Request.URL == nil {
return false
}
path := strings.TrimRight(c.Request.URL.Path, "/")
for _, root := range []string{
"/v1/responses",
"/openai/v1/responses",
"/responses",
"/backend-api/codex/responses",
} {
if path == root || strings.HasPrefix(path, root+"/") {
return true
}
}
return false
}
func isAsyncImageTaskRead(method, path string) bool {
if method != http.MethodGet {
return false
@@ -1427,6 +1427,78 @@ func TestAPIKeyAuthRejectsExhaustedBalance(t *testing.T) {
requireAPIKeyAuthError(t, w, "INSUFFICIENT_BALANCE", "Insufficient account balance")
}
func TestAPIKeyAuthOpenAIQuotaErrorFormat(t *testing.T) {
gin.SetMode(gin.TestMode)
user := &service.User{ID: 11, Role: service.RoleUser, Status: service.StatusActive, Balance: 10}
group := &service.Group{ID: 8, Platform: service.PlatformOpenAI, Status: service.StatusActive}
apiKey := &service.APIKey{
ID: 105, UserID: user.ID, Key: "openai-quota-exhausted", Status: service.StatusAPIKeyQuotaExhausted,
User: user, Group: group, GroupID: &group.ID,
}
apiKeyRepo := &stubApiKeyRepo{getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
if key != apiKey.Key {
return nil, service.ErrAPIKeyNotFound
}
clone := *apiKey
userClone := *user
clone.User = &userClone
return &clone, nil
}}
cfg := &config.Config{RunMode: config.RunModeStandard}
router := newAuthTestRouter(service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg), nil, cfg)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
req.Header.Set("x-api-key", apiKey.Key)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusTooManyRequests, w.Code)
var response struct {
Error struct {
Message string `json:"message"`
Type string `json:"type"`
Param *string `json:"param"`
Code string `json:"code"`
} `json:"error"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
require.Equal(t, "API key 额度已用完", response.Error.Message)
require.Equal(t, "insufficient_quota", response.Error.Type)
require.Nil(t, response.Error.Param)
require.Equal(t, "insufficient_quota", response.Error.Code)
}
func TestAPIKeyAuthQuotaErrorKeepsLegacyFormatOutsideResponses(t *testing.T) {
gin.SetMode(gin.TestMode)
user := &service.User{ID: 11, Role: service.RoleUser, Status: service.StatusActive, Balance: 10}
group := &service.Group{ID: 8, Platform: service.PlatformOpenAI, Status: service.StatusActive}
apiKey := &service.APIKey{
ID: 105, UserID: user.ID, Key: "openai-quota-exhausted", Status: service.StatusAPIKeyQuotaExhausted,
User: user, Group: group, GroupID: &group.ID,
}
apiKeyRepo := &stubApiKeyRepo{getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
if key != apiKey.Key {
return nil, service.ErrAPIKeyNotFound
}
clone := *apiKey
userClone := *user
clone.User = &userClone
return &clone, nil
}}
cfg := &config.Config{RunMode: config.RunModeStandard}
router := newAuthTestRouter(service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg), nil, cfg)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
req.Header.Set("x-api-key", apiKey.Key)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusTooManyRequests, w.Code)
requireAPIKeyAuthError(t, w, "API_KEY_QUOTA_EXHAUSTED", "API key 额度已用完")
}
func newAuthTestRouter(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) *gin.Engine {
router := gin.New()
router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, cfg)))
@@ -1434,6 +1506,8 @@ func newAuthTestRouter(apiKeyService *service.APIKeyService, subscriptionService
c.JSON(http.StatusOK, gin.H{"ok": true})
}
router.GET("/t", ok)
router.POST("/v1/responses", ok)
router.POST("/v1/messages", ok)
router.GET("/v1/usage", ok)
router.GET("/v1/sub2api/billing", ok)
return router
@@ -80,6 +80,19 @@ func AbortWithError(c *gin.Context, statusCode int, code, message string) {
c.Abort()
}
// abortWithOpenAIQuotaError writes the OpenAI-compatible insufficient quota response.
func abortWithOpenAIQuotaError(c *gin.Context, statusCode int, message string) {
c.JSON(statusCode, gin.H{
"error": gin.H{
"message": message,
"type": "insufficient_quota",
"param": nil,
"code": "insufficient_quota",
},
})
c.Abort()
}
// ──────────────────────────────────────────────────────────
// RequireGroupAssignment — 未分组 Key 拦截中间件
// ──────────────────────────────────────────────────────────