diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go index bcec39760..a595fbdb3 100644 --- a/backend/internal/server/middleware/api_key_auth.go +++ b/backend/internal/server/middleware/api_key_auth.go @@ -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 diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index 4d6d9b524..b6bad315e 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -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 diff --git a/backend/internal/server/middleware/middleware.go b/backend/internal/server/middleware/middleware.go index 0556be2a5..bb89c819f 100644 --- a/backend/internal/server/middleware/middleware.go +++ b/backend/internal/server/middleware/middleware.go @@ -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 拦截中间件 // ──────────────────────────────────────────────────────────