From 716fcc6f3d6591e602924edcc2a8cc0054b49692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= <2829624376@qq.com> Date: Wed, 15 Jul 2026 20:59:56 +0800 Subject: [PATCH 01/26] fix(openai): reject malformed WS v2 events --- .../handler/openai_gateway_handler_test.go | 37 +++++ .../openai_sse_concatenated_json_test.go | 132 ++++++++++++++++++ .../service/openai_ws_forwarder_v2.go | 20 +++ 3 files changed, 189 insertions(+) diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index d855f364a7..66bffe5a83 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -221,6 +221,43 @@ func TestOpenAIEnsureForwardErrorResponse_ResponsesRouteAfterWrittenEmitsRespons assert.Contains(t, body, "Upstream request failed") } +func TestOpenAIEnsureForwardErrorResponse_AfterDeltaAppendsSingleValidResponseFailed(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil) + + delta := `{"type":"response.output_text.delta","delta":"ok","sequence_number":1}` + _, err := c.Writer.WriteString("event: response.output_text.delta\ndata: " + delta + "\n\n") + require.NoError(t, err) + + h := &OpenAIGatewayHandler{} + require.True(t, h.ensureForwardErrorResponse(c, true)) + + frames := strings.Split(strings.TrimSuffix(w.Body.String(), "\n\n"), "\n\n") + require.Len(t, frames, 2) + errorEvents := 0 + for _, frame := range frames { + lines := strings.Split(frame, "\n") + require.Len(t, lines, 2) + require.True(t, strings.HasPrefix(lines[0], "event: ")) + require.True(t, strings.HasPrefix(lines[1], "data: ")) + + eventType := strings.TrimPrefix(lines[0], "event: ") + data := strings.TrimPrefix(lines[1], "data: ") + require.True(t, json.Valid([]byte(data)), "each downstream SSE frame must contain valid JSON") + var event struct { + Type string `json:"type"` + } + require.NoError(t, json.Unmarshal([]byte(data), &event)) + require.Equal(t, eventType, event.Type) + if eventType == "response.failed" { + errorEvents++ + } + } + require.Equal(t, 1, errorEvents) +} + func TestOpenAIEnsureForwardErrorResponse_ImageJSONKeepaliveWritesSingleJSONFallback(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() diff --git a/backend/internal/service/openai_sse_concatenated_json_test.go b/backend/internal/service/openai_sse_concatenated_json_test.go index 9b06ef9a39..27c057157e 100644 --- a/backend/internal/service/openai_sse_concatenated_json_test.go +++ b/backend/internal/service/openai_sse_concatenated_json_test.go @@ -90,6 +90,138 @@ func TestOpenAIWSv2StreamingRepairsConcatenatedJSONDocumentsInSingleMessage(t *t }) } +func TestOpenAIWSv2RejectsMalformedTypedEventBeforeWritingDownstream(t *testing.T) { + largeInProgress, _, _ := openAIConcatenatedJSONTestEvents(t) + testOpenAIWSv2RejectsMalformedEventBeforeWritingDownstream(t, []byte(largeInProgress+"unexpected-tail")) +} + +func TestOpenAIWSv2RejectsMalformedUntypedMessageBeforeWritingDownstream(t *testing.T) { + testOpenAIWSv2RejectsMalformedEventBeforeWritingDownstream(t, []byte("not-json")) +} + +func TestOpenAIWSv2RejectsMalformedEventAfterWritingDownstream(t *testing.T) { + gin.SetMode(gin.TestMode) + + outputTextDelta := `{"type":"response.output_text.delta","delta":"ok","sequence_number":1}` + malformedMessage := `{"type":"response.in_progress"}unexpected-tail` + captureConn := &openAIWSCaptureConn{events: [][]byte{ + []byte(outputTextDelta), + []byte(malformedMessage), + }} + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8 + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn}) + svc := &OpenAIGatewayService{ + cfg: cfg, + cache: &stubGatewayCache{}, + httpUpstream: &httpUpstreamRecorder{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + openaiWSPool: pool, + toolCorrector: NewCodexToolCorrector(), + } + account := &Account{ + ID: 5, + Name: "ws-malformed-event-after-output", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"responses_websockets_v2_enabled": true}, + } + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + groupID := int64(1) + c.Set("api_key", &APIKey{GroupID: &groupID}) + + result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.6-sol","stream":true,"input":"hello"}`)) + require.Error(t, err) + require.Contains(t, err.Error(), "after downstream output") + require.Nil(t, result) + require.True(t, captureConn.closed) + require.Contains(t, recorder.Body.String(), `"delta":"ok"`) + require.NotContains(t, recorder.Body.String(), "unexpected-tail") + require.NotContains(t, recorder.Body.String(), "response.in_progress") + assertOpenAISSEFrames(t, recorder.Body.String(), []string{"response.output_text.delta"}) +} + +func testOpenAIWSv2RejectsMalformedEventBeforeWritingDownstream(t *testing.T, malformedMessage []byte) { + t.Helper() + gin.SetMode(gin.TestMode) + + _, _, completed := openAIConcatenatedJSONTestEvents(t) + outputTextDelta := `{"type":"response.output_text.delta","delta":"ok","sequence_number":3}` + captureConn := &openAIWSCaptureConn{events: [][]byte{ + malformedMessage, + []byte(outputTextDelta), + []byte(completed), + }} + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8 + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn}) + svc := &OpenAIGatewayService{ + cfg: cfg, + cache: &stubGatewayCache{}, + httpUpstream: &httpUpstreamRecorder{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + openaiWSPool: pool, + toolCorrector: NewCodexToolCorrector(), + } + account := &Account{ + ID: 4, + Name: "ws-malformed-event", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"responses_websockets_v2_enabled": true}, + } + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + groupID := int64(1) + c.Set("api_key", &APIKey{GroupID: &groupID}) + + result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.6-sol","stream":true,"input":"hello"}`)) + require.Error(t, err) + var fallbackErr *openAIWSFallbackError + require.ErrorAs(t, err, &fallbackErr) + require.Equal(t, "invalid_event_json", fallbackErr.Reason) + require.Nil(t, result) + require.Empty(t, recorder.Body.String()) + require.True(t, captureConn.closed) +} + func TestSplitOpenAIConcatenatedJSONDocumentsRejectsPayloadOverRepairLimit(t *testing.T) { first := `{"type":"response.in_progress","padding":"` + strings.Repeat("x", 16*1024*1024) + `"}` second := `{"type":"response.completed"}` diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 9054e60502..eefadbb82d 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "encoding/json" "errors" "fmt" "net/http" @@ -454,6 +455,25 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } } } + if readErr == nil && !json.Valid(message) { + eventType, _, _ := parseOpenAIWSEventEnvelope(message) + if eventType == "" { + eventType = "unknown" + } + lease.MarkBroken() + logOpenAIWSModeInfo( + "invalid_event_json account_id=%d conn_id=%s event_type=%s bytes=%d wrote_downstream=%v", + account.ID, + truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(eventType, openAIWSLogValueMaxLen), + len(message), + wroteDownstream, + ) + if !wroteDownstream { + return nil, wrapOpenAIWSFallback("invalid_event_json", errors.New("upstream websocket returned malformed Responses event JSON")) + } + return nil, errors.New("upstream websocket returned malformed Responses event JSON after downstream output") + } if readErr != nil { lease.MarkBroken() closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr) From 858f3e4a78ecc974b3fedd6af27e0afe7d5b69cc Mon Sep 17 00:00:00 2001 From: drgnchan Date: Wed, 15 Jul 2026 21:57:08 +0800 Subject: [PATCH 02/26] fix(frontend): preserve horizontal table scrolling on tablets --- .../src/components/layout/TablePageLayout.vue | 4 --- .../layout/__tests__/TablePageLayout.spec.ts | 26 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/layout/__tests__/TablePageLayout.spec.ts diff --git a/frontend/src/components/layout/TablePageLayout.vue b/frontend/src/components/layout/TablePageLayout.vue index e4d3d447d1..3620697add 100644 --- a/frontend/src/components/layout/TablePageLayout.vue +++ b/frontend/src/components/layout/TablePageLayout.vue @@ -100,10 +100,6 @@ onUnmounted(() => { @apply flex-none min-h-fit; } -.table-page-layout.mobile-mode .table-scroll-container :deep(.table-wrapper) { - @apply overflow-visible; -} - .table-page-layout.mobile-mode .table-scroll-container :deep(table) { @apply flex-none; display: table; diff --git a/frontend/src/components/layout/__tests__/TablePageLayout.spec.ts b/frontend/src/components/layout/__tests__/TablePageLayout.spec.ts new file mode 100644 index 0000000000..3631d09313 --- /dev/null +++ b/frontend/src/components/layout/__tests__/TablePageLayout.spec.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +const componentPath = resolve(dirname(fileURLToPath(import.meta.url)), '../TablePageLayout.vue') +const componentSource = readFileSync(componentPath, 'utf8') + +describe('TablePageLayout responsive table scrolling', () => { + it('does not disable the table horizontal scroll container in mobile mode', () => { + const tableWrapperBlocks = Array.from( + componentSource.matchAll(/([^{}]*:deep\(\.table-wrapper\)[^{}]*)\{([^{}]*)\}/g) + ) + + expect(tableWrapperBlocks.length).toBeGreaterThan(0) + + const baseBlock = tableWrapperBlocks.find(([selector]) => !selector.includes('.mobile-mode')) + const mobileBlocks = tableWrapperBlocks.filter(([selector]) => selector.includes('.mobile-mode')) + + expect(baseBlock?.[2]).toContain('overflow-x-auto') + expect(mobileBlocks.every(([, , declarations]) => !declarations.includes('overflow-visible'))).toBe( + true + ) + }) +}) From 134179085c1df63c8e51a32d39a273882bfec1aa Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Wed, 15 Jul 2026 22:13:37 +0800 Subject: [PATCH 03/26] feat: add async image generation tasks --- backend/cmd/server/wire_gen.go | 7 +- backend/go.sum | 2 + backend/internal/handler/endpoint.go | 3 + backend/internal/handler/endpoint_test.go | 2 + backend/internal/handler/handler.go | 1 + .../internal/handler/image_task_handler.go | 270 ++++++++++++++++++ .../handler/image_task_handler_test.go | 107 +++++++ backend/internal/handler/wire.go | 3 + .../internal/repository/image_task_store.go | 48 ++++ .../repository/image_task_store_test.go | 43 +++ backend/internal/repository/wire.go | 1 + .../server/middleware/api_key_auth.go | 17 +- .../api_key_auth_image_task_test.go | 15 + backend/internal/server/routes/gateway.go | 6 + .../internal/server/routes/gateway_test.go | 20 ++ backend/internal/service/image_task.go | 210 ++++++++++++++ backend/internal/service/image_task_test.go | 91 ++++++ backend/internal/service/wire.go | 2 + 18 files changed, 843 insertions(+), 5 deletions(-) create mode 100644 backend/internal/handler/image_task_handler.go create mode 100644 backend/internal/handler/image_task_handler_test.go create mode 100644 backend/internal/repository/image_task_store.go create mode 100644 backend/internal/repository/image_task_store_test.go create mode 100644 backend/internal/server/middleware/api_key_auth_image_task_test.go create mode 100644 backend/internal/service/image_task.go create mode 100644 backend/internal/service/image_task_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 0c466a6e8a..996db5b7c1 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -70,7 +70,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig) schedulerCache := repository.ProvideSchedulerCache(redisClient, configConfig) accountRepository := repository.NewAccountRepository(client, db, schedulerCache) - adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache) concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig) apiKeyService := service.ProvideAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, userGroupRateRepository, apiKeyCache, configConfig, billingCacheService, concurrencyService) apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService) @@ -174,6 +173,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { leaderLockCache := repository.NewLeaderLockCache(redisClient) dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, leaderLockCache, db, configConfig) dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService) + adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache) proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) proxyLatencyCache := repository.NewProxyLatencyCache(redisClient) adminService := service.NewAdminService(userRepository, groupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService) @@ -261,6 +261,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService) paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry) availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService) + imageTaskStore := repository.NewImageTaskStore(redisClient) + imageTaskService := service.NewImageTaskService(imageTaskStore) + asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler) batchImageRepository := repository.NewBatchImageRepository(db) batchImageQueue := repository.NewBatchImageQueue(redisClient, configConfig) batchImageModelPricingResolver := service.ProvideBatchImageModelPricingResolver(modelPricingResolver) @@ -271,7 +274,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { batchImageHandler := handler.NewBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService) idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig) - handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService) + handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService) apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig) diff --git a/backend/go.sum b/backend/go.sum index 3d9989bb43..104dc56a4e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -166,6 +166,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go index 5e4d84ba72..0e0d28bb81 100644 --- a/backend/internal/handler/endpoint.go +++ b/backend/internal/handler/endpoint.go @@ -23,6 +23,7 @@ const ( EndpointResponsesCompact = "/v1/responses/compact" EndpointImagesGenerations = "/v1/images/generations" EndpointImagesEdits = "/v1/images/edits" + EndpointImageTasks = "/v1/images/tasks" EndpointVideosGenerations = "/v1/videos/generations" EndpointVideosEdits = "/v1/videos/edits" EndpointVideosExtensions = "/v1/videos/extensions" @@ -88,6 +89,8 @@ func NormalizeInboundEndpoint(path string) string { return EndpointImagesGenerations case strings.Contains(path, EndpointImagesEdits) || strings.Contains(path, "/images/edits"): return EndpointImagesEdits + case strings.Contains(path, EndpointImageTasks) || strings.Contains(path, "/images/tasks/"): + return EndpointImageTasks case strings.Contains(path, EndpointVideosGenerations) || strings.Contains(path, "/videos/generations"): return EndpointVideosGenerations case strings.Contains(path, EndpointVideosEdits) || strings.Contains(path, "/videos/edits"): diff --git a/backend/internal/handler/endpoint_test.go b/backend/internal/handler/endpoint_test.go index dc8ad728c8..c8ae566ad5 100644 --- a/backend/internal/handler/endpoint_test.go +++ b/backend/internal/handler/endpoint_test.go @@ -31,6 +31,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) { {"/v1/responses/compact/detail", EndpointResponsesCompact}, {"/v1/images/generations", EndpointImagesGenerations}, {"/v1/images/edits", EndpointImagesEdits}, + {"/v1/images/tasks/imgtask_123", EndpointImageTasks}, {"/v1/videos/generations", EndpointVideosGenerations}, {"/v1/videos/req_123", EndpointVideos}, {"/v1beta/models", EndpointGeminiModels}, @@ -52,6 +53,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) { {"/responses/compact", EndpointResponsesCompact}, {"/responses/compact/detail", EndpointResponsesCompact}, {"/alpha/search", EndpointAlphaSearch}, + {"/images/tasks/imgtask_123", EndpointImageTasks}, // Bare Codex direct alias route — root vs. compact. {"/backend-api/codex/responses", EndpointResponses}, diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 58c524889c..d8ffdb2865 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -58,6 +58,7 @@ type Handlers struct { Payment *PaymentHandler PaymentWebhook *PaymentWebhookHandler AvailableChannel *AvailableChannelHandler + AsyncImage *AsyncImageHandler BatchImage *BatchImageHandler } diff --git a/backend/internal/handler/image_task_handler.go b/backend/internal/handler/image_task_handler.go new file mode 100644 index 0000000000..adb46d2531 --- /dev/null +++ b/backend/internal/handler/image_task_handler.go @@ -0,0 +1,270 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type AsyncImageHandler struct { + tasks *service.ImageTaskService + openAI *OpenAIGatewayHandler + execute func(platform string, c *gin.Context) +} + +func NewAsyncImageHandler(tasks *service.ImageTaskService, openAI *OpenAIGatewayHandler) *AsyncImageHandler { + h := &AsyncImageHandler{tasks: tasks, openAI: openAI} + h.execute = h.executeWithGateway + return h +} + +// Submit accepts the same payload as the synchronous Images endpoint and +// returns before the upstream image generation begins. +func (h *AsyncImageHandler) Submit(c *gin.Context) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok || apiKey == nil || apiKey.UserID <= 0 || apiKey.ID <= 0 { + imageTaskError(c, service.ErrImageTaskForbidden) + return + } + platform := "" + if apiKey.Group != nil { + platform = apiKey.Group.Platform + } + if platform != service.PlatformOpenAI && platform != service.PlatformGrok { + imageTaskJSONError(c, http.StatusNotFound, "not_found_error", "Images API is not supported for this platform") + return + } + if !service.GroupAllowsImageGeneration(apiKey.Group) { + imageTaskJSONError(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage()) + return + } + if h == nil || h.tasks == nil || h.execute == nil { + imageTaskError(c, service.ErrImageTaskUnavailable) + return + } + + body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + if err != nil { + if maxErr, ok := extractMaxBytesError(err); ok { + imageTaskJSONError(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) + return + } + imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + return + } + if len(body) == 0 { + imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty") + return + } + if asyncImageRequestStreams(c.GetHeader("Content-Type"), body) { + imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", "streaming image requests cannot be submitted as asynchronous tasks") + return + } + if err := h.validateRequest(c, platform, body); err != nil { + imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } + + taskCtx, recorder, cancel := newAsyncImageContext(c, body, h.tasks.ExecutionTimeout()) + task, err := h.tasks.Create(c.Request.Context(), service.ImageTaskOwner{UserID: apiKey.UserID, APIKeyID: apiKey.ID}) + if err != nil { + cancel() + imageTaskError(c, err) + return + } + + pollURL := imageTaskPollURL(c.Request.URL.Path, task.ID) + c.Header("Cache-Control", "no-store") + c.Header("Location", pollURL) + c.Header("Retry-After", "3") + c.JSON(http.StatusAccepted, gin.H{ + "id": task.ID, + "task_id": task.TaskID, + "object": task.Object, + "status": task.Status, + "created_at": task.CreatedAt, + "expires_at": task.ExpiresAt, + "poll_url": pollURL, + }) + + go h.run(task.ID, platform, taskCtx, recorder, cancel) +} + +func (h *AsyncImageHandler) Get(c *gin.Context) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok || apiKey == nil || apiKey.UserID <= 0 || apiKey.ID <= 0 { + imageTaskError(c, service.ErrImageTaskForbidden) + return + } + if h == nil || h.tasks == nil { + imageTaskError(c, service.ErrImageTaskUnavailable) + return + } + task, err := h.tasks.Get(c.Request.Context(), service.ImageTaskOwner{UserID: apiKey.UserID, APIKeyID: apiKey.ID}, c.Param("task_id")) + if err != nil { + imageTaskError(c, err) + return + } + c.Header("Cache-Control", "no-store") + if task.Status == service.ImageTaskStatusProcessing { + c.Header("Retry-After", "3") + } + c.JSON(http.StatusOK, task) +} + +func (h *AsyncImageHandler) validateRequest(c *gin.Context, platform string, body []byte) error { + if h.openAI == nil || h.openAI.gatewayService == nil { + return nil + } + if platform == service.PlatformGrok { + parsed := service.ParseGrokMediaRequest(c.GetHeader("Content-Type"), body) + if strings.TrimSpace(parsed.Model) == "" { + return errors.New("model is required") + } + return nil + } + parsed, err := h.openAI.gatewayService.ParseOpenAIImagesRequest(c, body) + if err != nil { + return err + } + if parsed.Stream { + return errors.New("streaming image requests cannot be submitted as asynchronous tasks") + } + return nil +} + +func (h *AsyncImageHandler) executeWithGateway(platform string, c *gin.Context) { + if h.openAI == nil { + imageTaskJSONError(c, http.StatusServiceUnavailable, "api_error", "image gateway is unavailable") + return + } + if platform == service.PlatformGrok { + h.openAI.GrokImages(c) + return + } + h.openAI.Images(c) +} + +func (h *AsyncImageHandler) run(taskID, platform string, taskCtx *gin.Context, recorder *httptest.ResponseRecorder, cancel context.CancelFunc) { + defer cancel() + defer func() { + if recovered := recover(); recovered != nil { + logger.L().Error("image_task.execution_panicked", zap.String("task_id", taskID), zap.Any("panic", recovered)) + h.failTask(taskID, http.StatusInternalServerError, imageTaskErrorPayload("api_error", "image generation task panicked")) + } + }() + + h.execute(platform, taskCtx) + body := bytes.TrimSpace(recorder.Body.Bytes()) + if err := taskCtx.Request.Context().Err(); err != nil && len(body) == 0 { + h.failTask(taskID, http.StatusGatewayTimeout, imageTaskErrorPayload("timeout_error", "image generation task timed out")) + return + } + statusCode := recorder.Code + if statusCode == 0 { + statusCode = http.StatusOK + } + if statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices { + if len(body) == 0 || !json.Valid(body) { + h.failTask(taskID, http.StatusBadGateway, imageTaskErrorPayload("api_error", "upstream returned an invalid image response")) + return + } + if err := h.tasks.Complete(context.Background(), taskID, statusCode, json.RawMessage(body)); err != nil { + logger.L().Error("image_task.complete_store_failed", zap.String("task_id", taskID), zap.Error(err)) + } + return + } + h.failTask(taskID, statusCode, extractImageTaskError(body)) +} + +func (h *AsyncImageHandler) failTask(taskID string, statusCode int, taskErr json.RawMessage) { + if err := h.tasks.Fail(context.Background(), taskID, statusCode, taskErr); err != nil { + logger.L().Error("image_task.failure_store_failed", zap.String("task_id", taskID), zap.Error(err)) + } +} + +func newAsyncImageContext(c *gin.Context, body []byte, timeoutDuration time.Duration) (*gin.Context, *httptest.ResponseRecorder, context.CancelFunc) { + base := context.WithoutCancel(c.Request.Context()) + executionCtx, cancel := context.WithTimeout(base, timeoutDuration) + request := c.Request.Clone(executionCtx) + request.Body = io.NopCloser(bytes.NewReader(body)) + request.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(body)), nil + } + request.ContentLength = int64(len(body)) + request.URL.Path = strings.TrimSuffix(request.URL.Path, "/async") + + taskCtx := c.Copy() + recorder := httptest.NewRecorder() + recorderCtx, _ := gin.CreateTestContext(recorder) + taskCtx.Writer = recorderCtx.Writer + taskCtx.Request = request + return taskCtx, recorder, cancel +} + +func asyncImageRequestStreams(contentType string, body []byte) bool { + if isMultipartImagesContentType(contentType) { + return false + } + var envelope struct { + Stream bool `json:"stream"` + } + return json.Unmarshal(body, &envelope) == nil && envelope.Stream +} + +func imageTaskPollURL(submitPath, taskID string) string { + if strings.HasPrefix(submitPath, "/v1/") { + return "/v1/images/tasks/" + taskID + } + return "/images/tasks/" + taskID +} + +func extractImageTaskError(body []byte) json.RawMessage { + if json.Valid(body) { + var envelope struct { + Error json.RawMessage `json:"error"` + } + if json.Unmarshal(body, &envelope) == nil && len(envelope.Error) > 0 && json.Valid(envelope.Error) { + return envelope.Error + } + return json.RawMessage(body) + } + return imageTaskErrorPayload("api_error", "image generation failed") +} + +func imageTaskErrorPayload(errorType, message string) json.RawMessage { + data, _ := json.Marshal(gin.H{"type": errorType, "message": message}) + return data +} + +func imageTaskError(c *gin.Context, err error) { + status := infraerrors.Code(err) + code := infraerrors.Reason(err) + message := infraerrors.Message(err) + if status <= 0 { + status = http.StatusInternalServerError + } + if strings.TrimSpace(code) == "" { + code = "IMAGE_TASK_ERROR" + } + imageTaskJSONError(c, status, code, message) +} + +func imageTaskJSONError(c *gin.Context, status int, code, message string) { + c.Header("Cache-Control", "no-store") + c.JSON(status, gin.H{"error": gin.H{"type": code, "code": code, "message": message}}) +} diff --git a/backend/internal/handler/image_task_handler_test.go b/backend/internal/handler/image_task_handler_test.go new file mode 100644 index 0000000000..84002b7ce9 --- /dev/null +++ b/backend/internal/handler/image_task_handler_test.go @@ -0,0 +1,107 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type asyncImageMemoryStore struct { + mu sync.RWMutex + tasks map[string]*service.ImageTaskRecord +} + +func (s *asyncImageMemoryStore) Save(_ context.Context, task *service.ImageTaskRecord, _ time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + copy := *task + copy.Result = append(json.RawMessage(nil), task.Result...) + copy.Error = append(json.RawMessage(nil), task.Error...) + s.tasks[task.ID] = © + return nil +} + +func (s *asyncImageMemoryStore) Get(_ context.Context, id string) (*service.ImageTaskRecord, error) { + s.mu.RLock() + defer s.mu.RUnlock() + task := s.tasks[id] + if task == nil { + return nil, service.ErrImageTaskNotFound + } + copy := *task + copy.Result = append(json.RawMessage(nil), task.Result...) + copy.Error = append(json.RawMessage(nil), task.Error...) + return ©, nil +} + +func TestAsyncImageHandlerSubmitAndPoll(t *testing.T) { + gin.SetMode(gin.TestMode) + store := &asyncImageMemoryStore{tasks: make(map[string]*service.ImageTaskRecord)} + tasks := service.NewImageTaskServiceWithOptions(store, time.Hour, time.Minute) + release := make(chan struct{}) + h := &AsyncImageHandler{tasks: tasks} + h.execute = func(_ string, c *gin.Context) { + <-release + c.JSON(http.StatusOK, gin.H{"created": 123, "data": []gin.H{{"url": "https://example.test/image.png"}}}) + } + + router := gin.New() + router.Use(func(c *gin.Context) { + groupID := int64(3) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + ID: 9, + UserID: 7, + GroupID: &groupID, + Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, AllowImageGeneration: true}, + }) + c.Next() + }) + router.POST("/v1/images/generations/async", h.Submit) + router.GET("/v1/images/tasks/:task_id", h.Get) + + requestCtx, cancelRequest := context.WithCancel(context.Background()) + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations/async", strings.NewReader(`{"model":"gpt-image-1","prompt":"cat"}`)).WithContext(requestCtx) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusAccepted, w.Code) + require.Equal(t, "no-store", w.Header().Get("Cache-Control")) + require.Equal(t, "3", w.Header().Get("Retry-After")) + + var accepted struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + PollURL string `json:"poll_url"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &accepted)) + require.Equal(t, service.ImageTaskStatusProcessing, accepted.Status) + require.Equal(t, "/v1/images/tasks/"+accepted.TaskID, accepted.PollURL) + require.Equal(t, accepted.PollURL, w.Header().Get("Location")) + + // The detached background request must survive completion of/cancellation + // from the short submission request. + cancelRequest() + close(release) + require.Eventually(t, func() bool { + got, err := tasks.Get(context.Background(), service.ImageTaskOwner{UserID: 7, APIKeyID: 9}, accepted.TaskID) + return err == nil && got.Status == service.ImageTaskStatusCompleted + }, time.Second, 10*time.Millisecond) + + pollReq := httptest.NewRequest(http.MethodGet, accepted.PollURL, nil) + pollWriter := httptest.NewRecorder() + router.ServeHTTP(pollWriter, pollReq) + require.Equal(t, http.StatusOK, pollWriter.Code) + require.Equal(t, "no-store", pollWriter.Header().Get("Cache-Control")) + require.Empty(t, pollWriter.Header().Get("Retry-After")) + require.Contains(t, pollWriter.Body.String(), "https://example.test/image.png") +} diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 67380fed33..4831aaf70e 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -115,6 +115,7 @@ func ProvideHandlers( paymentHandler *PaymentHandler, paymentWebhookHandler *PaymentWebhookHandler, availableChannelHandler *AvailableChannelHandler, + asyncImageHandler *AsyncImageHandler, batchImageHandler *BatchImageHandler, _ *service.IdempotencyCoordinator, _ *service.IdempotencyCleanupService, @@ -136,6 +137,7 @@ func ProvideHandlers( Payment: paymentHandler, PaymentWebhook: paymentWebhookHandler, AvailableChannel: availableChannelHandler, + AsyncImage: asyncImageHandler, BatchImage: batchImageHandler, } } @@ -158,6 +160,7 @@ var ProviderSet = wire.NewSet( NewPaymentHandler, NewPaymentWebhookHandler, NewAvailableChannelHandler, + NewAsyncImageHandler, NewBatchImageHandler, // Admin handlers diff --git a/backend/internal/repository/image_task_store.go b/backend/internal/repository/image_task_store.go new file mode 100644 index 0000000000..000346154a --- /dev/null +++ b/backend/internal/repository/image_task_store.go @@ -0,0 +1,48 @@ +package repository + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/redis/go-redis/v9" +) + +const imageTaskKeyPrefix = "image_task:" + +type imageTaskStore struct { + rdb *redis.Client +} + +func NewImageTaskStore(rdb *redis.Client) service.ImageTaskStore { + return &imageTaskStore{rdb: rdb} +} + +func (s *imageTaskStore) Save(ctx context.Context, task *service.ImageTaskRecord, ttl time.Duration) error { + data, err := json.Marshal(task) + if err != nil { + return err + } + return s.rdb.Set(ctx, imageTaskKey(task.ID), data, ttl).Err() +} + +func (s *imageTaskStore) Get(ctx context.Context, id string) (*service.ImageTaskRecord, error) { + data, err := s.rdb.Get(ctx, imageTaskKey(id)).Bytes() + if err != nil { + if err == redis.Nil { + return nil, service.ErrImageTaskNotFound + } + return nil, err + } + var task service.ImageTaskRecord + if err := json.Unmarshal(data, &task); err != nil { + return nil, err + } + return &task, nil +} + +func imageTaskKey(id string) string { + return imageTaskKeyPrefix + strings.TrimSpace(id) +} diff --git a/backend/internal/repository/image_task_store_test.go b/backend/internal/repository/image_task_store_test.go new file mode 100644 index 0000000000..dc4ace5a63 --- /dev/null +++ b/backend/internal/repository/image_task_store_test.go @@ -0,0 +1,43 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +func TestImageTaskStoreRoundTripAndTTL(t *testing.T) { + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + store := NewImageTaskStore(rdb) + task := &service.ImageTaskRecord{ + ID: "imgtask_123", + UserID: 7, + APIKeyID: 9, + Status: service.ImageTaskStatusProcessing, + CreatedAt: 100, + ExpiresAt: 200, + } + + require.NoError(t, store.Save(context.Background(), task, 24*time.Hour)) + got, err := store.Get(context.Background(), task.ID) + require.NoError(t, err) + require.Equal(t, task, got) + require.Equal(t, 24*time.Hour, mr.TTL(imageTaskKey(task.ID))) +} + +func TestImageTaskStoreMissing(t *testing.T) { + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + store := NewImageTaskStore(rdb) + + _, err := store.Get(context.Background(), "imgtask_missing") + require.ErrorIs(t, err, service.ErrImageTaskNotFound) +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 0ee91d146b..d7fb702b39 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -117,6 +117,7 @@ var ProviderSet = wire.NewSet( NewRedeemCache, NewUpdateCache, NewGeminiTokenCache, + NewImageTaskStore, NewBatchImageQueue, NewBatchImageDownloadLimiter, NewLeaderLockCache, diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go index 082766bfd5..a512d8bcd7 100644 --- a/backend/internal/server/middleware/api_key_auth.go +++ b/backend/internal/server/middleware/api_key_auth.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/http" "strings" "github.com/Wei-Shaw/sub2api/internal/config" @@ -25,7 +26,7 @@ func NewAPIKeyAuthMiddleware(apiKeyService *service.APIKeyService, subscriptionS // - 鉴权(Authentication):验证 Key 有效性、用户状态、IP 限制 —— 始终执行 // - 计费执行(Billing Enforcement):过期/配额/订阅/余额检查 —— skipBilling 时整块跳过 // -// /v1/usage 端点只需鉴权,不需要计费执行(允许过期/配额耗尽的 Key 查询自身用量)。 +// /v1/usage 与异步生图任务查询只需鉴权,不需要计费执行(允许已耗尽额度的 Key 查询自身结果)。 func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { // ── 1. 提取 API Key ────────────────────────────────────────── @@ -146,8 +147,11 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti // ── 5. 加载订阅(订阅模式时始终加载) ─────────────────────── - // skipBilling: /v1/usage 只需鉴权,跳过所有计费执行 - skipBilling := c.Request.URL.Path == "/v1/usage" + // skipBilling: usage and async image task polling only read data that + // already belongs to the authenticated key. In particular, polling must + // remain available after the completed generation consumes the key's + // remaining balance. + skipBilling := c.Request.URL.Path == "/v1/usage" || isAsyncImageTaskRead(c.Request.Method, c.Request.URL.Path) var subscription *service.UserSubscription isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType() @@ -243,6 +247,13 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti } } +func isAsyncImageTaskRead(method, path string) bool { + if method != http.MethodGet { + return false + } + return strings.HasPrefix(path, "/v1/images/tasks/") || strings.HasPrefix(path, "/images/tasks/") +} + // GetAPIKeyFromContext 从上下文中获取API key func GetAPIKeyFromContext(c *gin.Context) (*service.APIKey, bool) { value, exists := c.Get(string(ContextKeyAPIKey)) diff --git a/backend/internal/server/middleware/api_key_auth_image_task_test.go b/backend/internal/server/middleware/api_key_auth_image_task_test.go new file mode 100644 index 0000000000..653fd8ba3c --- /dev/null +++ b/backend/internal/server/middleware/api_key_auth_image_task_test.go @@ -0,0 +1,15 @@ +package middleware + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsAsyncImageTaskRead(t *testing.T) { + require.True(t, isAsyncImageTaskRead(http.MethodGet, "/v1/images/tasks/imgtask_123")) + require.True(t, isAsyncImageTaskRead(http.MethodGet, "/images/tasks/imgtask_123")) + require.False(t, isAsyncImageTaskRead(http.MethodPost, "/v1/images/tasks/imgtask_123")) + require.False(t, isAsyncImageTaskRead(http.MethodGet, "/v1/images/generations")) +} diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index f73379981e..e79cd4a56e 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -191,6 +191,9 @@ func RegisterGatewayRoutes( }) gateway.POST("/images/generations", imagesHandler) gateway.POST("/images/edits", imagesHandler) + gateway.POST("/images/generations/async", h.AsyncImage.Submit) + gateway.POST("/images/edits/async", h.AsyncImage.Submit) + gateway.GET("/images/tasks/:task_id", h.AsyncImage.Get) gateway.POST("/images/batches", h.BatchImage.Submit) gateway.GET("/images/batches", h.BatchImage.List) gateway.GET("/images/batches/models", h.BatchImage.Models) @@ -271,6 +274,9 @@ func RegisterGatewayRoutes( }) r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, imagesHandler) r.POST("/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, imagesHandler) + r.POST("/images/generations/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.AsyncImage.Submit) + r.POST("/images/edits/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.AsyncImage.Submit) + r.GET("/images/tasks/:task_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.AsyncImage.Get) r.POST("/videos/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, videoGenerationHandler) r.POST("/videos/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, videoEditHandler) r.POST("/videos/extensions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, videoExtensionHandler) diff --git a/backend/internal/server/routes/gateway_test.go b/backend/internal/server/routes/gateway_test.go index 65c6824440..f8abdb4062 100644 --- a/backend/internal/server/routes/gateway_test.go +++ b/backend/internal/server/routes/gateway_test.go @@ -28,6 +28,7 @@ func newGatewayRoutesTestRouter(platform ...string) *gin.Engine { &handler.Handlers{ Gateway: &handler.GatewayHandler{}, OpenAIGateway: &handler.OpenAIGatewayHandler{}, + AsyncImage: handler.NewAsyncImageHandler(nil, nil), }, servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) { groupID := int64(1) @@ -113,6 +114,25 @@ func TestGatewayRoutesOpenAIImagesPathsAreRegistered(t *testing.T) { } } +func TestGatewayRoutesAsyncImagesPathsAreRegistered(t *testing.T) { + router := newGatewayRoutesTestRouter() + registered := make(map[string]bool) + for _, route := range router.Routes() { + registered[route.Method+" "+route.Path] = true + } + + for _, route := range []string{ + "POST /v1/images/generations/async", + "POST /v1/images/edits/async", + "GET /v1/images/tasks/:task_id", + "POST /images/generations/async", + "POST /images/edits/async", + "GET /images/tasks/:task_id", + } { + require.True(t, registered[route], "%s should be registered", route) + } +} + func TestGatewayRoutesGrokImagesAndVideosPathsAreRegistered(t *testing.T) { router := newGatewayRoutesTestRouter(service.PlatformGrok) diff --git a/backend/internal/service/image_task.go b/backend/internal/service/image_task.go new file mode 100644 index 0000000000..e41b73dd6f --- /dev/null +++ b/backend/internal/service/image_task.go @@ -0,0 +1,210 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/google/uuid" +) + +const ( + ImageTaskStatusProcessing = "processing" + ImageTaskStatusCompleted = "completed" + ImageTaskStatusFailed = "failed" + + defaultImageTaskTTL = 24 * time.Hour + defaultImageTaskExecutionTimeout = 30 * time.Minute +) + +var ( + ErrImageTaskNotFound = infraerrors.New(http.StatusNotFound, "IMAGE_TASK_NOT_FOUND", "image task not found") + ErrImageTaskForbidden = infraerrors.New(http.StatusForbidden, "IMAGE_TASK_FORBIDDEN", "image task does not belong to this API key") + ErrImageTaskUnavailable = infraerrors.New(http.StatusServiceUnavailable, "IMAGE_TASK_UNAVAILABLE", "image task storage is unavailable") +) + +// ImageTaskRecord is the private Redis representation of an asynchronous image +// request. Ownership fields are intentionally omitted from the public view. +type ImageTaskRecord struct { + ID string `json:"id"` + UserID int64 `json:"user_id"` + APIKeyID int64 `json:"api_key_id"` + Status string `json:"status"` + HTTPStatus int `json:"http_status,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error json.RawMessage `json:"error,omitempty"` + CreatedAt int64 `json:"created_at"` + CompletedAt *int64 `json:"completed_at,omitempty"` + ExpiresAt int64 `json:"expires_at"` +} + +// ImageTask is the API-safe task representation returned to callers. +type ImageTask struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Object string `json:"object"` + Status string `json:"status"` + HTTPStatus int `json:"http_status,omitempty"` + ImageURL string `json:"image_url,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error json.RawMessage `json:"error,omitempty"` + CreatedAt int64 `json:"created_at"` + CompletedAt *int64 `json:"completed_at,omitempty"` + ExpiresAt int64 `json:"expires_at"` +} + +type ImageTaskOwner struct { + UserID int64 + APIKeyID int64 +} + +type ImageTaskStore interface { + Save(ctx context.Context, task *ImageTaskRecord, ttl time.Duration) error + Get(ctx context.Context, id string) (*ImageTaskRecord, error) +} + +type ImageTaskService struct { + store ImageTaskStore + ttl time.Duration + executionTimeout time.Duration +} + +func NewImageTaskService(store ImageTaskStore) *ImageTaskService { + return NewImageTaskServiceWithOptions(store, defaultImageTaskTTL, defaultImageTaskExecutionTimeout) +} + +func NewImageTaskServiceWithOptions(store ImageTaskStore, ttl, executionTimeout time.Duration) *ImageTaskService { + if ttl <= 0 { + ttl = defaultImageTaskTTL + } + if executionTimeout <= 0 { + executionTimeout = defaultImageTaskExecutionTimeout + } + return &ImageTaskService{store: store, ttl: ttl, executionTimeout: executionTimeout} +} + +func (s *ImageTaskService) ExecutionTimeout() time.Duration { + if s == nil || s.executionTimeout <= 0 { + return defaultImageTaskExecutionTimeout + } + return s.executionTimeout +} + +func (s *ImageTaskService) Create(ctx context.Context, owner ImageTaskOwner) (*ImageTask, error) { + if s == nil || s.store == nil { + return nil, ErrImageTaskUnavailable + } + now := time.Now().UTC() + task := &ImageTaskRecord{ + ID: "imgtask_" + strings.ReplaceAll(uuid.NewString(), "-", ""), + UserID: owner.UserID, + APIKeyID: owner.APIKeyID, + Status: ImageTaskStatusProcessing, + CreatedAt: now.Unix(), + ExpiresAt: now.Add(s.ttl).Unix(), + } + if err := s.store.Save(ctx, task, s.ttl); err != nil { + return nil, ErrImageTaskUnavailable.WithCause(err) + } + return imageTaskToPublic(task), nil +} + +func (s *ImageTaskService) Get(ctx context.Context, owner ImageTaskOwner, id string) (*ImageTask, error) { + if s == nil || s.store == nil { + return nil, ErrImageTaskUnavailable + } + task, err := s.store.Get(ctx, strings.TrimSpace(id)) + if err != nil { + if errors.Is(err, ErrImageTaskNotFound) { + return nil, ErrImageTaskNotFound + } + return nil, ErrImageTaskUnavailable.WithCause(err) + } + if task.UserID != owner.UserID || task.APIKeyID != owner.APIKeyID { + // Do not reveal whether a random task ID exists for another caller. + return nil, ErrImageTaskNotFound + } + return imageTaskToPublic(task), nil +} + +func (s *ImageTaskService) Complete(ctx context.Context, id string, statusCode int, result json.RawMessage) error { + if !json.Valid(result) { + return s.Fail(ctx, id, http.StatusBadGateway, imageTaskErrorJSON("api_error", "upstream returned a non-JSON image response")) + } + return s.finish(ctx, id, ImageTaskStatusCompleted, statusCode, result, nil) +} + +func (s *ImageTaskService) Fail(ctx context.Context, id string, statusCode int, taskErr json.RawMessage) error { + if !json.Valid(taskErr) { + taskErr = imageTaskErrorJSON("api_error", "image generation failed") + } + return s.finish(ctx, id, ImageTaskStatusFailed, statusCode, nil, taskErr) +} + +func (s *ImageTaskService) finish(ctx context.Context, id, status string, statusCode int, result, taskErr json.RawMessage) error { + if s == nil || s.store == nil { + return ErrImageTaskUnavailable + } + task, err := s.store.Get(ctx, id) + if err != nil { + if errors.Is(err, ErrImageTaskNotFound) { + return ErrImageTaskNotFound + } + return ErrImageTaskUnavailable.WithCause(err) + } + now := time.Now().UTC() + completedAt := now.Unix() + task.Status = status + task.HTTPStatus = statusCode + task.Result = result + task.Error = taskErr + task.CompletedAt = &completedAt + task.ExpiresAt = now.Add(s.ttl).Unix() + if err := s.store.Save(ctx, task, s.ttl); err != nil { + return ErrImageTaskUnavailable.WithCause(err) + } + return nil +} + +func imageTaskToPublic(task *ImageTaskRecord) *ImageTask { + if task == nil { + return nil + } + return &ImageTask{ + ID: task.ID, + TaskID: task.ID, + Object: "image.generation.task", + Status: task.Status, + HTTPStatus: task.HTTPStatus, + ImageURL: firstImageTaskURL(task.Result), + Result: task.Result, + Error: task.Error, + CreatedAt: task.CreatedAt, + CompletedAt: task.CompletedAt, + ExpiresAt: task.ExpiresAt, + } +} + +func firstImageTaskURL(result json.RawMessage) string { + if len(result) == 0 || !json.Valid(result) { + return "" + } + var response struct { + Data []struct { + URL string `json:"url"` + } `json:"data"` + } + if json.Unmarshal(result, &response) != nil || len(response.Data) == 0 { + return "" + } + return strings.TrimSpace(response.Data[0].URL) +} + +func imageTaskErrorJSON(errorType, message string) json.RawMessage { + data, _ := json.Marshal(map[string]string{"type": errorType, "message": message}) + return data +} diff --git a/backend/internal/service/image_task_test.go b/backend/internal/service/image_task_test.go new file mode 100644 index 0000000000..6eb9ce50c9 --- /dev/null +++ b/backend/internal/service/image_task_test.go @@ -0,0 +1,91 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type imageTaskMemoryStore struct { + task *ImageTaskRecord + ttl time.Duration + saveErr error + getErr error +} + +func (s *imageTaskMemoryStore) Save(_ context.Context, task *ImageTaskRecord, ttl time.Duration) error { + if s.saveErr != nil { + return s.saveErr + } + copy := *task + s.task = © + s.ttl = ttl + return nil +} + +func (s *imageTaskMemoryStore) Get(_ context.Context, _ string) (*ImageTaskRecord, error) { + if s.getErr != nil { + return nil, s.getErr + } + if s.task == nil { + return nil, ErrImageTaskNotFound + } + copy := *s.task + return ©, nil +} + +func TestImageTaskServiceLifecycleAndOwnership(t *testing.T) { + store := &imageTaskMemoryStore{} + svc := NewImageTaskServiceWithOptions(store, time.Hour, 10*time.Minute) + owner := ImageTaskOwner{UserID: 7, APIKeyID: 9} + + created, err := svc.Create(context.Background(), owner) + require.NoError(t, err) + require.Equal(t, ImageTaskStatusProcessing, created.Status) + require.Equal(t, created.ID, created.TaskID) + require.Equal(t, "image.generation.task", created.Object) + require.Equal(t, time.Hour, store.ttl) + require.Equal(t, owner.UserID, store.task.UserID) + require.Equal(t, owner.APIKeyID, store.task.APIKeyID) + + _, err = svc.Get(context.Background(), ImageTaskOwner{UserID: 7, APIKeyID: 10}, created.ID) + require.ErrorIs(t, err, ErrImageTaskNotFound) + + result := json.RawMessage(`{"created":123,"data":[{"url":"https://example.test/image.png"}]}`) + require.NoError(t, svc.Complete(context.Background(), created.ID, http.StatusOK, result)) + + completed, err := svc.Get(context.Background(), owner, created.ID) + require.NoError(t, err) + require.Equal(t, ImageTaskStatusCompleted, completed.Status) + require.Equal(t, http.StatusOK, completed.HTTPStatus) + require.Equal(t, "https://example.test/image.png", completed.ImageURL) + require.JSONEq(t, string(result), string(completed.Result)) + require.NotNil(t, completed.CompletedAt) +} + +func TestImageTaskServiceInvalidResultBecomesFailed(t *testing.T) { + store := &imageTaskMemoryStore{} + svc := NewImageTaskServiceWithOptions(store, time.Hour, time.Minute) + created, err := svc.Create(context.Background(), ImageTaskOwner{UserID: 1, APIKeyID: 2}) + require.NoError(t, err) + + require.NoError(t, svc.Complete(context.Background(), created.ID, http.StatusOK, json.RawMessage(`not-json`))) + got, err := svc.Get(context.Background(), ImageTaskOwner{UserID: 1, APIKeyID: 2}, created.ID) + require.NoError(t, err) + require.Equal(t, ImageTaskStatusFailed, got.Status) + require.Equal(t, http.StatusBadGateway, got.HTTPStatus) + require.Contains(t, string(got.Error), "non-JSON") +} + +func TestImageTaskServiceMapsStoreFailures(t *testing.T) { + store := &imageTaskMemoryStore{saveErr: errors.New("redis down")} + svc := NewImageTaskService(store) + + _, err := svc.Create(context.Background(), ImageTaskOwner{UserID: 1, APIKeyID: 2}) + require.ErrorIs(t, err, ErrImageTaskUnavailable) +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index ace69e0c34..6dd2c29abc 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -637,6 +637,7 @@ var ProviderSet = wire.NewSet( NewAdminService, NewGatewayService, NewOpenAIGatewayService, + NewImageTaskService, ProvideBatchImageModelPricingResolver, NewBatchImagePublicService, NewBatchImageDownloadService, @@ -646,6 +647,7 @@ var ProviderSet = wire.NewSet( NewOAuthService, ProvideOpenAIOAuthService, NewGrokOAuthService, + wire.Bind(new(GrokOAuthTokenService), new(*GrokOAuthService)), NewGeminiOAuthService, NewGeminiQuotaService, NewCompositeTokenCacheInvalidator, From df247b4364035b6399982785b39282b17fa4029d Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Wed, 15 Jul 2026 22:14:34 +0800 Subject: [PATCH 04/26] docs: document async image task API --- .gitignore | 1 + README.md | 6 +++ docs/ASYNC_IMAGE_TASKS.md | 111 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 docs/ASYNC_IMAGE_TASKS.md diff --git a/.gitignore b/.gitignore index d45d27f79e..8c7e67f7e2 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,7 @@ docs/* !docs/PAYMENT.md !docs/PAYMENT_CN.md !docs/ADMIN_PAYMENT_INTEGRATION_API.md +!docs/ASYNC_IMAGE_TASKS.md !docs/legal/ !docs/legal/*.md .serena/ diff --git a/README.md b/README.md index b06839745f..0d1fe23aee 100644 --- a/README.md +++ b/README.md @@ -688,6 +688,12 @@ Simple Mode is designed for individual developers or internal teams who want qui --- +## Asynchronous Image Tasks + +Long-running OpenAI/Grok image generation and editing can be submitted through `/v1/images/generations/async` or `/v1/images/edits/async`, then polled at `/v1/images/tasks/{task_id}` without holding a CDN connection open. See [Asynchronous Image Tasks](docs/ASYNC_IMAGE_TASKS.md) for request and response examples. + +--- + ## Grok / xAI Support Sub2API supports both Grok subscription accounts through xAI OAuth and standard xAI API-key accounts. Both account types forward OpenAI-compatible Responses traffic to xAI. diff --git a/docs/ASYNC_IMAGE_TASKS.md b/docs/ASYNC_IMAGE_TASKS.md new file mode 100644 index 0000000000..69db7b4932 --- /dev/null +++ b/docs/ASYNC_IMAGE_TASKS.md @@ -0,0 +1,111 @@ +# Asynchronous Image Tasks + +Asynchronous image tasks let clients submit long-running OpenAI-compatible image requests without keeping one HTTP connection open. This avoids proxy/CDN response timeouts such as Cloudflare 524 while preserving the existing image routing, billing, moderation, concurrency, and failover behavior. + +## Endpoints + +The authenticated gateway exposes both `/v1` paths and their existing no-prefix aliases: + +```text +POST /v1/images/generations/async +POST /v1/images/edits/async +GET /v1/images/tasks/{task_id} +``` + +The aliases are `/images/generations/async`, `/images/edits/async`, and `/images/tasks/{task_id}`. + +Only OpenAI and Grok groups are supported. Requests use the same JSON or multipart payload as the corresponding synchronous endpoint. Streaming image requests are rejected because a polled task returns one final JSON result. + +## Submit a task + +```bash +curl -i https://api.example.com/v1/images/generations/async \ + -H 'Authorization: Bearer sk-...' \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "gpt-image-1", + "prompt": "A lighthouse during a winter storm", + "size": "1536x1024" + }' +``` + +The server stores the initial task in Redis and responds with `202 Accepted`: + +```json +{ + "id": "imgtask_0123456789abcdef", + "task_id": "imgtask_0123456789abcdef", + "object": "image.generation.task", + "status": "processing", + "created_at": 1784092800, + "expires_at": 1784179200, + "poll_url": "/v1/images/tasks/imgtask_0123456789abcdef" +} +``` + +`Location` contains the polling path and `Retry-After: 3` provides the recommended polling interval. + +## Poll a task + +Use the same API key that submitted the task: + +```bash +curl https://api.example.com/v1/images/tasks/imgtask_0123456789abcdef \ + -H 'Authorization: Bearer sk-...' +``` + +While work is in progress: + +```json +{ + "id": "imgtask_0123456789abcdef", + "task_id": "imgtask_0123456789abcdef", + "object": "image.generation.task", + "status": "processing", + "created_at": 1784092800, + "expires_at": 1784179200 +} +``` + +On success, `result` is the unmodified JSON body from the synchronous image API, so URL and base64 response formats both remain supported: + +```json +{ + "id": "imgtask_0123456789abcdef", + "task_id": "imgtask_0123456789abcdef", + "object": "image.generation.task", + "status": "completed", + "http_status": 200, + "image_url": "https://...", + "result": { + "created": 1784092923, + "data": [{"url": "https://..."}] + }, + "created_at": 1784092800, + "completed_at": 1784092923, + "expires_at": 1784179323 +} +``` + +For URL responses, `image_url` mirrors the first `data[].url` for simple clients. On failure, the task reaches `failed` and exposes the original OpenAI-compatible error object where available: + +```json +{ + "id": "imgtask_0123456789abcdef", + "task_id": "imgtask_0123456789abcdef", + "object": "image.generation.task", + "status": "failed", + "http_status": 502, + "error": { + "type": "api_error", + "message": "Upstream request failed" + }, + "created_at": 1784092800, + "completed_at": 1784092923, + "expires_at": 1784179323 +} +``` + +All submit and poll responses include `Cache-Control: no-store`, preventing a CDN from caching the `processing` state. Tasks and results expire 24 hours after their latest state update. A task executes for at most 30 minutes. + +Task ownership is scoped to both user and API key. Unknown task IDs and IDs owned by another key both return `404`, avoiding task-existence disclosure. Polling remains available when the completed generation used the key's remaining balance; normal authentication, disabled-key, user, IP, and group checks still apply. From 4f641208a0a08e2b38df98ec226c6c1570077eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= <2829624376@qq.com> Date: Wed, 15 Jul 2026 22:05:53 +0800 Subject: [PATCH 05/26] fix(openai-ws): close ingress reads cleanly --- .../handler/openai_gateway_handler.go | 54 ++++--- .../handler/openai_gateway_handler_test.go | 64 ++++++-- .../internal/service/openai_ws_client_read.go | 70 +++++++++ .../service/openai_ws_client_read_test.go | 143 ++++++++++++++++++ .../service/openai_ws_forwarder_ingress.go | 22 ++- ...penai_ws_forwarder_ingress_session_test.go | 8 + .../service/openai_ws_http_bridge_test.go | 8 + 7 files changed, 322 insertions(+), 47 deletions(-) create mode 100644 backend/internal/service/openai_ws_client_read.go create mode 100644 backend/internal/service/openai_ws_client_read_test.go diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 83c4b4ac23..af4746531b 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1360,6 +1360,28 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { reqLog.Info("openai.websocket_ingress_started") clientIP := ip.GetClientIP(c) userAgent := strings.TrimSpace(c.GetHeader("User-Agent")) + ctx := c.Request.Context() + maxIngressConnections := 0 + if h.cfg != nil { + maxIngressConnections = h.cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey + } + ingressLease, ingressLeaseAcquired, ingressLeaseErr := h.concurrencyHelper.AcquireOpenAIWSIngressLease(ctx, apiKey.ID, maxIngressConnections) + if ingressLeaseErr != nil { + reqLog.Error("openai.websocket_ingress_lease_acquire_failed", zap.Error(ingressLeaseErr)) + h.errorResponse(c, http.StatusServiceUnavailable, "service_unavailable", "WebSocket ingress capacity is temporarily unavailable") + return + } + if !ingressLeaseAcquired { + reqLog.Info("openai.websocket_ingress_capacity_rejected", zap.Int("max_ingress_connections_per_api_key", maxIngressConnections)) + c.Header("Retry-After", "5") + h.errorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Too many open WebSocket connections, please retry later") + return + } + if ingressLease != nil { + defer ingressLease.Release() + ctx = ingressLease.Context() + c.Request = c.Request.WithContext(ctx) + } wsConn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{ CompressionMode: coderws.CompressionContextTakeover, @@ -1381,32 +1403,14 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { }() wsConn.SetReadLimit(service.ResolveOpenAIWSClientReadLimitBytes(h.cfg)) - ctx := c.Request.Context() - maxIngressConnections := 0 - if h.cfg != nil { - maxIngressConnections = h.cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey - } - ingressLease, ingressLeaseAcquired, ingressLeaseErr := h.concurrencyHelper.AcquireOpenAIWSIngressLease(ctx, apiKey.ID, maxIngressConnections) - if ingressLeaseErr != nil { - reqLog.Error("openai.websocket_ingress_lease_acquire_failed", zap.Error(ingressLeaseErr)) - closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "failed to reserve websocket ingress capacity") - return - } - if !ingressLeaseAcquired { - reqLog.Info("openai.websocket_ingress_capacity_rejected", zap.Int("max_ingress_connections_per_api_key", maxIngressConnections)) - closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "too many open websocket connections, please retry later") - return - } - if ingressLease != nil { - defer ingressLease.Release() - ctx = ingressLease.Context() - c.Request = c.Request.WithContext(ctx) - } - firstMessageTimeout := service.ResolveOpenAIWSClientFirstMessageTimeout(h.cfg) - readCtx, cancel := context.WithTimeout(ctx, firstMessageTimeout) - msgType, firstMessage, err := wsConn.Read(readCtx) - cancel() + msgType, firstMessage, err := service.ReadOpenAIWSClientMessage( + ctx, + wsConn, + firstMessageTimeout, + coderws.StatusPolicyViolation, + "missing first response.create message", + ) if err != nil { if errors.Is(context.Cause(ctx), service.ErrOpenAIWSIngressLeaseLost) { reqLog.Warn("openai.websocket_ingress_lease_lost_before_first_message", zap.Error(err)) diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index d855f364a7..b1eb260fe3 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -820,17 +820,36 @@ func TestOpenAIResponsesWebSocket_IngressCapacityRejected(t *testing.T) { defer wsServer.Close() dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) - clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http")+"/openai/v1/responses", nil) + clientConn, response, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http")+"/openai/v1/responses", nil) cancelDial() - require.NoError(t, err) - defer func() { _ = clientConn.CloseNow() }() + require.Error(t, err) + require.Nil(t, clientConn) + require.NotNil(t, response) + require.Equal(t, http.StatusTooManyRequests, response.StatusCode) + _ = response.Body.Close() +} - readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) - _, _, err = clientConn.Read(readCtx) - cancelRead() - var closeErr coderws.CloseError - require.ErrorAs(t, err, &closeErr) - require.Equal(t, coderws.StatusTryAgainLater, closeErr.Code) +func TestOpenAIResponsesWebSocket_IngressLeaseBackendUnavailableBeforeUpgrade(t *testing.T) { + gin.SetMode(gin.TestMode) + cache := &concurrencyCacheMock{ + acquireIngressLeaseFn: func(context.Context, int64, int, string) (bool, error) { + return false, errors.New("redis unavailable") + }, + } + h := newOpenAIHandlerForPreviousResponseIDValidation(t, cache) + h.cfg = &config.Config{} + h.cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey = 1 + wsServer := newOpenAIWSHandlerTestServer(t, h, middleware.AuthSubject{UserID: 1, Concurrency: 1}) + defer wsServer.Close() + + dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) + clientConn, response, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http")+"/openai/v1/responses", nil) + cancelDial() + require.Error(t, err) + require.Nil(t, clientConn) + require.NotNil(t, response) + require.Equal(t, http.StatusServiceUnavailable, response.StatusCode) + _ = response.Body.Close() } func TestOpenAIResponsesWebSocket_FirstMessageTimeoutUsesConfig(t *testing.T) { @@ -902,6 +921,33 @@ func TestOpenAIResponsesWebSocket_IngressLeaseReleasedOnEarlyReturn(t *testing.T }, time.Second, 10*time.Millisecond) } +func TestOpenAIResponsesWebSocket_IngressLeaseReleasedWhenUpgradeFails(t *testing.T) { + gin.SetMode(gin.TestMode) + cache := &concurrencyCacheMock{ + acquireIngressLeaseFn: func(context.Context, int64, int, string) (bool, error) { + return true, nil + }, + } + h := newOpenAIHandlerForPreviousResponseIDValidation(t, cache) + h.cfg = &config.Config{} + h.cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey = 1 + wsServer := newOpenAIWSHandlerTestServer(t, h, middleware.AuthSubject{UserID: 1, Concurrency: 1}) + defer wsServer.Close() + + req, err := http.NewRequest(http.MethodGet, wsServer.URL+"/openai/v1/responses", nil) + require.NoError(t, err) + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Sec-WebSocket-Version", "13") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + _ = resp.Body.Close() + require.NotEqual(t, http.StatusSwitchingProtocols, resp.StatusCode) + require.Eventually(t, func() bool { + return atomic.LoadInt32(&cache.releaseIngressCalled) == 1 + }, time.Second, 10*time.Millisecond) +} + func TestOpenAIResponsesWebSocket_RejectsMessageIDAsPreviousResponseID(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_ws_client_read.go b/backend/internal/service/openai_ws_client_read.go new file mode 100644 index 0000000000..da8d650be4 --- /dev/null +++ b/backend/internal/service/openai_ws_client_read.go @@ -0,0 +1,70 @@ +package service + +import ( + "context" + "errors" + "time" + + coderws "github.com/coder/websocket" +) + +type openAIWSClientReadResult struct { + messageType coderws.MessageType + payload []byte + err error +} + +// ReadOpenAIWSClientMessage keeps one reader alive while control events send +// their close frame, then closes the transport and joins that reader. +func ReadOpenAIWSClientMessage( + controlCtx context.Context, + conn *coderws.Conn, + timeout time.Duration, + timeoutStatus coderws.StatusCode, + timeoutReason string, +) (coderws.MessageType, []byte, error) { + if conn == nil { + return 0, nil, errors.New("openai websocket client connection is nil") + } + if controlCtx == nil { + controlCtx = context.Background() + } + + readDone := make(chan openAIWSClientReadResult, 1) + go func() { + messageType, payload, err := conn.Read(context.Background()) + readDone <- openAIWSClientReadResult{messageType: messageType, payload: payload, err: err} + }() + + var timeoutCh <-chan time.Time + var timer *time.Timer + if timeout > 0 { + timer = time.NewTimer(timeout) + timeoutCh = timer.C + defer timer.Stop() + } + + closeAndJoin := func(status coderws.StatusCode, reason string, cause error) (coderws.MessageType, []byte, error) { + _ = conn.Close(status, reason) + _ = conn.CloseNow() + <-readDone + return 0, nil, NewOpenAIWSClientCloseError(status, reason, cause) + } + + select { + case result := <-readDone: + return result.messageType, result.payload, result.err + case <-timeoutCh: + return closeAndJoin(timeoutStatus, timeoutReason, context.DeadlineExceeded) + case <-controlCtx.Done(): + cause := context.Cause(controlCtx) + if errors.Is(cause, ErrOpenAIWSIngressLeaseLost) { + return closeAndJoin( + coderws.StatusTryAgainLater, + "websocket ingress capacity lease lost; please reconnect", + cause, + ) + } + return closeAndJoin(coderws.StatusGoingAway, "websocket request canceled", cause) + } +} diff --git a/backend/internal/service/openai_ws_client_read_test.go b/backend/internal/service/openai_ws_client_read_test.go new file mode 100644 index 0000000000..82df19849b --- /dev/null +++ b/backend/internal/service/openai_ws_client_read_test.go @@ -0,0 +1,143 @@ +package service + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + coderws "github.com/coder/websocket" + "github.com/stretchr/testify/require" +) + +func TestReadOpenAIWSClientMessage_ControlCloseFrames(t *testing.T) { + tests := []struct { + name string + timeout time.Duration + timeoutStatus coderws.StatusCode + timeoutReason string + cancelCause error + wantStatus coderws.StatusCode + wantReason string + }{ + { + name: "inter-turn idle sends normal close", + timeout: 25 * time.Millisecond, + timeoutStatus: coderws.StatusNormalClosure, + timeoutReason: "websocket idle timeout", + wantStatus: coderws.StatusNormalClosure, + wantReason: "websocket idle timeout", + }, + { + name: "first message timeout sends policy close", + timeout: 25 * time.Millisecond, + timeoutStatus: coderws.StatusPolicyViolation, + timeoutReason: "missing first response.create message", + wantStatus: coderws.StatusPolicyViolation, + wantReason: "missing first response.create message", + }, + { + name: "lease loss sends retry close", + cancelCause: ErrOpenAIWSIngressLeaseLost, + wantStatus: coderws.StatusTryAgainLater, + wantReason: "websocket ingress capacity lease lost; please reconnect", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + controlCtx, cancelControl := context.WithCancelCause(context.Background()) + defer cancelControl(context.Canceled) + serverResult := make(chan error, 1) + readStarted := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, nil) + if err != nil { + serverResult <- err + return + } + defer func() { _ = conn.CloseNow() }() + close(readStarted) + _, _, err = ReadOpenAIWSClientMessage( + controlCtx, + conn, + tt.timeout, + tt.timeoutStatus, + tt.timeoutReason, + ) + serverResult <- err + })) + defer server.Close() + + dialCtx, cancelDial := context.WithTimeout(context.Background(), time.Second) + clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) + cancelDial() + require.NoError(t, err) + defer func() { _ = clientConn.CloseNow() }() + <-readStarted + if tt.cancelCause != nil { + cancelControl(tt.cancelCause) + } + + readCtx, cancelRead := context.WithTimeout(context.Background(), time.Second) + _, _, err = clientConn.Read(readCtx) + cancelRead() + var clientClose coderws.CloseError + require.ErrorAs(t, err, &clientClose) + require.Equal(t, tt.wantStatus, clientClose.Code) + require.Equal(t, tt.wantReason, clientClose.Reason) + + select { + case serverErr := <-serverResult: + var closeErr *OpenAIWSClientCloseError + require.ErrorAs(t, serverErr, &closeErr) + require.Equal(t, tt.wantStatus, closeErr.StatusCode()) + require.Equal(t, tt.wantReason, closeErr.Reason()) + case <-time.After(time.Second): + t.Fatal("server read goroutine did not exit after close handshake") + } + }) + } +} + +func TestReadOpenAIWSClientMessage_ParentCancellationStillJoinsRead(t *testing.T) { + controlCtx, cancelControl := context.WithCancelCause(context.Background()) + serverResult := make(chan error, 1) + readStarted := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, nil) + if err != nil { + serverResult <- err + return + } + defer func() { _ = conn.CloseNow() }() + close(readStarted) + _, _, err = ReadOpenAIWSClientMessage(controlCtx, conn, 0, 0, "") + serverResult <- err + })) + defer server.Close() + + dialCtx, cancelDial := context.WithTimeout(context.Background(), time.Second) + clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) + cancelDial() + require.NoError(t, err) + defer func() { _ = clientConn.CloseNow() }() + <-readStarted + cancelControl(errors.New("server shutting down")) + readCtx, cancelRead := context.WithTimeout(context.Background(), time.Second) + _, _, err = clientConn.Read(readCtx) + cancelRead() + var clientClose coderws.CloseError + require.ErrorAs(t, err, &clientClose) + require.Equal(t, coderws.StatusGoingAway, clientClose.Code) + require.Equal(t, "websocket request canceled", clientClose.Reason) + + select { + case <-serverResult: + case <-time.After(time.Second): + t.Fatal("server read goroutine leaked after parent cancellation") + } +} diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 51aace6419..0232e7a72d 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -382,22 +382,18 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } readClientMessage := func() ([]byte, error) { - readCtx := ctx idleTimeout := s.openAIWSIngressInterTurnIdleTimeout() - cancelRead := func() {} - if idleTimeout > 0 { - readCtx, cancelRead = context.WithTimeout(ctx, idleTimeout) - } - msgType, payload, readErr := clientConn.Read(readCtx) - cancelRead() + msgType, payload, readErr := ReadOpenAIWSClientMessage( + ctx, + clientConn, + idleTimeout, + coderws.StatusNormalClosure, + "websocket idle timeout", + ) if readErr != nil { - if idleTimeout > 0 && errors.Is(readErr, context.DeadlineExceeded) && ctx.Err() == nil { + var closeErr *OpenAIWSClientCloseError + if errors.As(readErr, &closeErr) && closeErr.StatusCode() == coderws.StatusNormalClosure { logOpenAIWSModeInfo("ingress_ws_inter_turn_idle_timeout account_id=%d timeout_seconds=%d", account.ID, int(idleTimeout.Seconds())) - return nil, NewOpenAIWSClientCloseError( - coderws.StatusNormalClosure, - "websocket idle timeout", - readErr, - ) } return nil, readErr } diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 3752c1bd0e..d2fc0ebae5 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -249,6 +249,14 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_IdleTimeoutRelea require.NoError(t, err) require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + closeReadCtx, cancelCloseRead := context.WithTimeout(context.Background(), 3*time.Second) + _, _, err = clientConn.Read(closeReadCtx) + cancelCloseRead() + var clientClose coderws.CloseError + require.ErrorAs(t, err, &clientClose) + require.Equal(t, coderws.StatusNormalClosure, clientClose.Code) + require.Equal(t, "websocket idle timeout", clientClose.Reason) + select { case proxyErr := <-serverErrCh: var closeErr *OpenAIWSClientCloseError diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index cbc11abcc2..808a63c6b1 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -775,6 +775,14 @@ func TestOpenAIWSHTTPBridge_IdleTimeoutClosesClientSession(t *testing.T) { require.NoError(t, err) require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + closeReadCtx, cancelCloseRead := context.WithTimeout(context.Background(), 3*time.Second) + _, _, err = clientConn.Read(closeReadCtx) + cancelCloseRead() + var clientClose coderws.CloseError + require.ErrorAs(t, err, &clientClose) + require.Equal(t, coderws.StatusNormalClosure, clientClose.Code) + require.Equal(t, "websocket idle timeout", clientClose.Reason) + select { case proxyErr := <-errCh: var closeErr *OpenAIWSClientCloseError From 2fe7df9b813a949bfbd9ef5bbb975773fd63049c Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 15 Jul 2026 22:22:51 +0800 Subject: [PATCH 06/26] fix(openai): reject image models on chat completions --- .../chat_completions_image_model_test.go | 96 +++++++++++++++++++ .../gateway_handler_chat_completions.go | 4 + .../handler/openai_chat_completions.go | 4 + backend/internal/service/openai_images.go | 7 +- 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 backend/internal/handler/chat_completions_image_model_test.go diff --git a/backend/internal/handler/chat_completions_image_model_test.go b/backend/internal/handler/chat_completions_image_model_test.go new file mode 100644 index 0000000000..06c0b45488 --- /dev/null +++ b/backend/internal/handler/chat_completions_image_model_test.go @@ -0,0 +1,96 @@ +package handler + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestChatCompletionsRejectsGPTImageModelsBeforeScheduling(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, model := range []string{"gpt-image-1", "gpt-image-1.5", "gpt-image-2"} { + for _, tc := range []struct { + name string + call func(*gin.Context) + }{ + { + name: "gateway", + call: (&GatewayHandler{}).ChatCompletions, + }, + { + name: "openai_gateway", + call: newOpenAIImageChatRejectionHandler(t).ChatCompletions, + }, + } { + t.Run(tc.name+"/"+model, func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + body := []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"draw"}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + setImageChatTestAuth(c) + + tc.call(c) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String()) + require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "Chat Completions") + _, selected := c.Get(opsAccountIDKey) + require.False(t, selected, "rejection must happen before account selection") + }) + } + } +} + +func TestOpenAIChatCompletionsImageModelRejectionDoesNotAcquireConcurrency(t *testing.T) { + var acquireCalls atomic.Int64 + cache := &concurrencyCacheMock{ + acquireUserSlotFn: func(context.Context, int64, int, string) (bool, error) { + acquireCalls.Add(1) + return true, nil + }, + } + h := newOpenAIImageChatRejectionHandlerWithCache(t, cache) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString( + `{"model":"gpt-image-2","messages":[{"role":"user","content":"draw"}]}`, + )) + setImageChatTestAuth(c) + + h.ChatCompletions(c) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Zero(t, acquireCalls.Load(), "rejection must happen before user/account concurrency and scheduling") +} + +func newOpenAIImageChatRejectionHandler(t *testing.T) *OpenAIGatewayHandler { + t.Helper() + return newOpenAIImageChatRejectionHandlerWithCache(t, &concurrencyCacheMock{}) +} + +func newOpenAIImageChatRejectionHandlerWithCache(t *testing.T, cache *concurrencyCacheMock) *OpenAIGatewayHandler { + t.Helper() + return &OpenAIGatewayHandler{ + gatewayService: &service.OpenAIGatewayService{}, + billingCacheService: &service.BillingCacheService{}, + apiKeyService: &service.APIKeyService{}, + concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(cache), SSEPingFormatNone, time.Second), + } +} + +func setImageChatTestAuth(c *gin.Context) { + apiKey := &service.APIKey{ID: 4348, UserID: 4348, User: &service.User{ID: 4348}} + c.Set(string(middleware.ContextKeyAPIKey), apiKey) + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 1}) +} diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index cc9ff14282..84456a9049 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -80,6 +80,10 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage) return } + if service.IsGPTImageGenerationModel(reqModel) { + h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "This model is not supported on the Chat Completions endpoint") + return + } reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) setOpsRequestContext(c, reqModel, reqStream) diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 62a2b70b59..37f532bb21 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -80,6 +80,10 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage) return } + if service.IsGPTImageGenerationModel(reqModel) { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "This model is not supported on the Chat Completions endpoint") + return + } reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index 081b5e741b..7a5d456d20 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -455,8 +455,13 @@ func applyOpenAIImagesDefaults(req *OpenAIImagesRequest) { } func isOpenAIImageGenerationModel(model string) bool { + return IsGPTImageGenerationModel(model) || isGrokImageGenerationModel(model) +} + +// IsGPTImageGenerationModel identifies the GPT native image-generation model family. +func IsGPTImageGenerationModel(model string) bool { model = strings.ToLower(strings.TrimSpace(model)) - return strings.HasPrefix(model, "gpt-image-") || isGrokImageGenerationModel(model) + return strings.HasPrefix(model, "gpt-image-") } func isGrokImageGenerationModel(model string) bool { From 56650d6aed98d9c307892f54548c03a3bdf0abe5 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 15 Jul 2026 22:23:57 +0800 Subject: [PATCH 07/26] fix(openai): normalize Responses Lite reasoning context --- .../service/openai_responses_lite_tools.go | 39 +++++++++++++--- .../openai_responses_lite_tools_test.go | 44 +++++++++++++++++++ ...penai_ws_forwarder_ingress_session_test.go | 7 +++ 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/backend/internal/service/openai_responses_lite_tools.go b/backend/internal/service/openai_responses_lite_tools.go index a15bc498bc..c5c16ecaa1 100644 --- a/backend/internal/service/openai_responses_lite_tools.go +++ b/backend/internal/service/openai_responses_lite_tools.go @@ -7,18 +7,23 @@ import ( "strings" ) -// normalizeOpenAIResponsesLiteTools converts private namespace declarations -// into the input.additional_tools carrier required by Responses Lite. Other -// top-level tools must belong to the small set accepted by the Lite endpoint; -// rejecting unsupported hosted tools is intentional because silently dropping -// them would change the client's requested behavior. +// normalizeOpenAIResponsesLiteTools applies the Responses Lite request +// contract: reasoning must cover all turns, and private namespace declarations +// use the input.additional_tools carrier. Other top-level tools must belong to +// the small set accepted by the Lite endpoint; rejecting unsupported hosted +// tools is intentional because silently dropping them would change behavior. func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { if reqBody == nil { return false, nil } + if rawReasoning, exists := reqBody["reasoning"]; exists && rawReasoning != nil { + if _, ok := rawReasoning.(map[string]any); !ok { + return false, fmt.Errorf("responses Lite requires reasoning to be an object") + } + } rawTools, exists := reqBody["tools"] if !exists || rawTools == nil { - return false, nil + return ensureOpenAIResponsesLiteReasoningContext(reqBody) } tools, ok := rawTools.([]any) if !ok { @@ -52,13 +57,16 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { } } if len(namespaceTools) == 0 { - return false, nil + return ensureOpenAIResponsesLiteReasoningContext(reqBody) } input, err := appendOpenAIResponsesLiteAdditionalTools(reqBody["input"], namespaceTools) if err != nil { return false, err } + if _, err := ensureOpenAIResponsesLiteReasoningContext(reqBody); err != nil { + return false, err + } reqBody["input"] = input if len(topLevelTools) == 0 { delete(reqBody, "tools") @@ -68,6 +76,23 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { return true, nil } +func ensureOpenAIResponsesLiteReasoningContext(reqBody map[string]any) (bool, error) { + rawReasoning, exists := reqBody["reasoning"] + if !exists || rawReasoning == nil { + reqBody["reasoning"] = map[string]any{"context": "all_turns"} + return true, nil + } + reasoning, ok := rawReasoning.(map[string]any) + if !ok { + return false, fmt.Errorf("responses Lite requires reasoning to be an object") + } + if context, ok := reasoning["context"].(string); ok && context == "all_turns" { + return false, nil + } + reasoning["context"] = "all_turns" + return true, nil +} + func appendOpenAIResponsesLiteAdditionalTools(input any, namespaceTools []any) ([]any, error) { var items []any switch typed := input.(type) { diff --git a/backend/internal/service/openai_responses_lite_tools_test.go b/backend/internal/service/openai_responses_lite_tools_test.go index aa03954463..b76e97e272 100644 --- a/backend/internal/service/openai_responses_lite_tools_test.go +++ b/backend/internal/service/openai_responses_lite_tools_test.go @@ -135,6 +135,7 @@ func TestNormalizeOpenAIResponsesLiteTools_ConvertsStringInput(t *testing.T) { func TestNormalizeOpenAIResponsesLiteTools_KeepsSupportedTopLevelTools(t *testing.T) { reqBody := map[string]any{ + "reasoning": map[string]any{"context": "all_turns"}, "tools": []any{ map[string]any{"type": "function", "name": "shell"}, map[string]any{"type": "custom", "name": "exec"}, @@ -150,6 +151,46 @@ func TestNormalizeOpenAIResponsesLiteTools_KeepsSupportedTopLevelTools(t *testin require.Len(t, reqBody["tools"], 4) } +func TestNormalizeOpenAIResponsesLiteTools_EnsuresReasoningContext(t *testing.T) { + tests := []struct { + name string + reasoning any + }{ + {name: "missing"}, + {name: "missing context", reasoning: map[string]any{"effort": "high"}}, + {name: "wrong context", reasoning: map[string]any{"effort": "medium", "context": "current_turn"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reqBody := map[string]any{"input": "hello"} + if tt.reasoning != nil { + reqBody["reasoning"] = tt.reasoning + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.True(t, changed) + reasoning := reqBody["reasoning"].(map[string]any) + require.Equal(t, "all_turns", reasoning["context"]) + if tt.name != "missing" { + require.Equal(t, tt.reasoning.(map[string]any)["effort"], reasoning["effort"]) + } + }) + } +} + +func TestNormalizeOpenAIResponsesLiteTools_RejectsNonObjectReasoning(t *testing.T) { + reqBody := map[string]any{"reasoning": "high"} + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.ErrorContains(t, err, "reasoning to be an object") + require.False(t, changed) + require.Equal(t, "high", reqBody["reasoning"]) +} + func TestNormalizeOpenAIResponsesLiteTools_RejectsUnsupportedTools(t *testing.T) { tests := []struct { name string @@ -240,6 +281,7 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes } body := []byte(`{ "model":"gpt-5.6-terra","stream":true,"instructions":"test", + "reasoning":{"effort":"high","context":"current_turn"}, "tools":[ {"type":"function","name":"shell","parameters":{"type":"object"}}, {"type":"custom","name":"exec"}, @@ -255,6 +297,8 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) + require.Equal(t, "high", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) + require.Equal(t, "all_turns", gjson.GetBytes(upstream.lastBody, "reasoning.context").String()) require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="namespace")`).Exists()) require.Equal(t, "shell", gjson.GetBytes(upstream.lastBody, `tools.#(type=="function").name`).String()) require.Equal(t, "exec", gjson.GetBytes(upstream.lastBody, `tools.#(type=="custom").name`).String()) diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 3752c1bd0e..2578c623da 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -531,6 +531,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge "model":"gpt-5.5", "stream":false, "previous_response_id":"resp_codex_image_bridge", + "reasoning":{"effort":"high"}, "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, "tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}], "input":[ @@ -583,6 +584,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.Equal(t, "png", gjson.Get(nonLitePayload, `tools.#(type=="image_generation").output_format`).String()) require.Equal(t, "auto", gjson.Get(nonLitePayload, "tool_choice").String()) require.Contains(t, gjson.Get(nonLitePayload, "instructions").String(), "image_generation") + require.False(t, gjson.Get(nonLitePayload, "reasoning.context").Exists()) litePayload := requestToJSONString(captureConn.writes[1]) require.False(t, gjson.Get(litePayload, `tools.#(type=="image_generation")`).Exists()) @@ -593,6 +595,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.Equal(t, "collaboration", gjson.Get(litePayload, `input.#(type=="additional_tools").tools.1.name`).String()) require.Equal(t, "namespace", gjson.Get(litePayload, "tool_choice.type").String()) require.Equal(t, "collaboration", gjson.Get(litePayload, "tool_choice.name").String()) + require.Equal(t, "high", gjson.Get(litePayload, "reasoning.effort").String()) + require.Equal(t, "all_turns", gjson.Get(litePayload, "reasoning.context").String()) functionPayload := requestToJSONString(captureConn.writes[2]) require.True(t, gjson.Get(functionPayload, `tools.#(name=="image_gen.imagegen")`).Exists()) @@ -976,6 +980,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade "model":"gpt-5.1", "stream":false, "prompt_cache_key":"pcache_passthrough", + "reasoning":{"effort":"medium","context":"current_turn"}, "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, "tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}], "input":[{"type":"message","role":"user","content":"hello"}], @@ -1009,6 +1014,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade require.Equal(t, "collaboration", gjson.Get(forwarded, `input.#(type=="additional_tools").tools.0.name`).String()) require.Equal(t, "namespace", gjson.Get(forwarded, "tool_choice.type").String()) require.Equal(t, "collaboration", gjson.Get(forwarded, "tool_choice.name").String()) + require.Equal(t, "medium", gjson.Get(forwarded, "reasoning.effort").String()) + require.Equal(t, "all_turns", gjson.Get(forwarded, "reasoning.context").String()) } func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRelaysHTTPStream(t *testing.T) { From 5e4da92dec788b31e01db30dbfc340c6eaf18dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= <2829624376@qq.com> Date: Wed, 15 Jul 2026 22:12:00 +0800 Subject: [PATCH 08/26] fix(openai): retry explicitly rejected Responses fields --- .../service/openai_gateway_forward.go | 16 +- .../openai_responses_rejected_field_retry.go | 133 ++++++++++ ...nai_responses_rejected_field_retry_test.go | 230 ++++++++++++++++++ 3 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 backend/internal/service/openai_responses_rejected_field_retry.go create mode 100644 backend/internal/service/openai_responses_rejected_field_retry_test.go diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 5259aa843c..c6adda595a 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -391,9 +391,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if maxOutputTokens.Exists() { switch account.Platform { case PlatformOpenAI: - if account.Type == AccountTypeAPIKey { - markPatchDelete("max_output_tokens") - } + // Preserve Responses-native output limits unless the selected upstream + // explicitly rejects the field in the bounded HTTP retry loop below. case PlatformAnthropic: decoded, decodeErr := ensureReqBody() if decodeErr != nil { @@ -744,6 +743,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco httpInvalidEncryptedContentRetryTried := false agentTaskRecoveryTried := false + rejectedFieldRetryState := newOpenAIResponsesRejectedFieldRetryState(body) for { // Build upstream request upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) @@ -830,11 +830,21 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco return nil, fmt.Errorf("serialize invalid_encrypted_content retry body: %w", err) } httpInvalidEncryptedContentRetryTried = true + rejectedFieldRetryState.remember(body) logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request once after invalid_encrypted_content (account: %s)", account.Name) continue } logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Skip non-WSv2 invalid_encrypted_content retry because encrypted reasoning items are missing (account: %s)", account.Name) } + if retryBody, reason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(resp.StatusCode, body, respBody); retryErr != nil { + return nil, fmt.Errorf("normalize rejected Responses field retry body: %w", retryErr) + } else if changed && rejectedFieldRetryState.Allow(retryBody) { + body = retryBody + requestView = newOpenAIRequestView(body) + reqBody = nil + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request after %s (account: %s)", reason, account.Name) + continue + } if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) { upstreamDetail := "" if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { diff --git a/backend/internal/service/openai_responses_rejected_field_retry.go b/backend/internal/service/openai_responses_rejected_field_retry.go new file mode 100644 index 0000000000..d8d2bb02bf --- /dev/null +++ b/backend/internal/service/openai_responses_rejected_field_retry.go @@ -0,0 +1,133 @@ +package service + +import ( + "crypto/sha256" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const maxOpenAIResponsesRejectedFieldRetries = 6 + +var ( + openAIResponsesRejectedNamespaceParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.namespace$`) + openAIResponsesRejectedMessageParamPattern = regexp.MustCompile(`(?i)(?:unknown|unsupported)[ _-]+parameter\s*(?::|=|is)?\s*["']?(max_output_tokens|input\[\d+\]\.namespace)(?:["']|\b)`) +) + +type openAIResponsesRejectedFieldRetryState struct { + attempts int + seenBodyHashes map[[sha256.Size]byte]struct{} +} + +func newOpenAIResponsesRejectedFieldRetryState(initialBody []byte) *openAIResponsesRejectedFieldRetryState { + state := &openAIResponsesRejectedFieldRetryState{ + seenBodyHashes: make(map[[sha256.Size]byte]struct{}, maxOpenAIResponsesRejectedFieldRetries+1), + } + state.remember(initialBody) + return state +} + +func (s *openAIResponsesRejectedFieldRetryState) Allow(nextBody []byte) bool { + if s == nil || len(nextBody) == 0 || s.attempts >= maxOpenAIResponsesRejectedFieldRetries { + return false + } + bodyHash := sha256.Sum256(nextBody) + if _, seen := s.seenBodyHashes[bodyHash]; seen { + return false + } + s.seenBodyHashes[bodyHash] = struct{}{} + s.attempts++ + return true +} + +func (s *openAIResponsesRejectedFieldRetryState) remember(body []byte) { + if s == nil || len(body) == 0 { + return + } + if s.seenBodyHashes == nil { + s.seenBodyHashes = make(map[[sha256.Size]byte]struct{}, maxOpenAIResponsesRejectedFieldRetries+1) + } + s.seenBodyHashes[sha256.Sum256(body)] = struct{}{} +} + +func normalizeOpenAIResponsesRejectedFieldRetryBody(statusCode int, body, responseBody []byte) ([]byte, string, bool, error) { + if statusCode != http.StatusBadRequest || len(body) == 0 || len(responseBody) == 0 { + return nil, "", false, nil + } + + code := strings.ToLower(strings.TrimSpace(extractUpstreamErrorCode(responseBody))) + message := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(responseBody))) + if !isExplicitOpenAIResponsesFieldRejection(code, message) { + return nil, "", false, nil + } + + param := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.param").String())) + if param == "" { + param = openAIResponsesRejectedParamFromMessage(message) + } + if index, ok := openAIResponsesRejectedNamespaceIndex(param); ok { + return removeOpenAIResponsesRejectedNamespaceAtIndex(body, index) + } + if param == "max_output_tokens" && gjson.GetBytes(body, "max_output_tokens").Exists() { + retryBody, err := sjson.DeleteBytes(body, "max_output_tokens") + if err != nil { + return nil, "", false, fmt.Errorf("delete rejected max_output_tokens: %w", err) + } + return retryBody, "max_output_tokens parameter rejection", true, nil + } + return nil, "", false, nil +} + +func isExplicitOpenAIResponsesFieldRejection(code, message string) bool { + switch strings.TrimSpace(code) { + case "unknown_parameter", "unsupported_parameter": + return true + } + return strings.Contains(message, "unknown parameter") || + strings.Contains(message, "unsupported parameter") +} + +func openAIResponsesRejectedParamFromMessage(message string) string { + match := openAIResponsesRejectedMessageParamPattern.FindStringSubmatch(strings.TrimSpace(message)) + if len(match) != 2 { + return "" + } + return strings.ToLower(strings.TrimSpace(match[1])) +} + +func openAIResponsesRejectedNamespaceIndex(param string) (int, bool) { + match := openAIResponsesRejectedNamespaceParamPattern.FindStringSubmatch(strings.TrimSpace(param)) + if len(match) != 2 { + return 0, false + } + index, err := strconv.Atoi(match[1]) + if err == nil && index >= 0 { + return index, true + } + return 0, false +} + +func removeOpenAIResponsesRejectedNamespaceAtIndex(body []byte, index int) ([]byte, string, bool, error) { + itemPath := fmt.Sprintf("input.%d", index) + itemType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, itemPath+".type").String())) + switch itemType { + case "function_call", "tool_call", "custom_tool_call", "mcp_tool_call": + default: + return nil, "", false, nil + } + + namespacePath := itemPath + ".namespace" + if !gjson.GetBytes(body, namespacePath).Exists() { + return nil, "", false, nil + } + retryBody, err := sjson.DeleteBytes(body, namespacePath) + if err != nil { + return nil, "", false, fmt.Errorf("delete rejected namespace at input[%d]: %w", index, err) + } + return retryBody, "indexed namespace parameter rejection", true, nil +} diff --git a/backend/internal/service/openai_responses_rejected_field_retry_test.go b/backend/internal/service/openai_responses_rejected_field_retry_test.go new file mode 100644 index 0000000000..96c20de508 --- /dev/null +++ b/backend/internal/service/openai_responses_rejected_field_retry_test.go @@ -0,0 +1,230 @@ +package service + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestOpenAIResponsesRejectedFieldRetryStateRejectsDuplicateBodyAndCap(t *testing.T) { + initialBody := []byte(`{"model":"gpt-5.5"}`) + state := newOpenAIResponsesRejectedFieldRetryState(initialBody) + + require.False(t, state.Allow(initialBody)) + for attempt := 0; attempt < maxOpenAIResponsesRejectedFieldRetries; attempt++ { + nextBody := []byte(fmt.Sprintf(`{"model":"gpt-5.5","variant":%d}`, attempt)) + require.True(t, state.Allow(nextBody)) + require.False(t, state.Allow(nextBody)) + } + require.False(t, state.Allow([]byte(`{"model":"gpt-5.5","variant":"overflow"}`))) +} + +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousErrors(t *testing.T) { + tests := []struct { + name string + body []byte + responseBody []byte + }{ + { + name: "namespace belongs to message", + body: []byte(`{"input":[{"type":"message","namespace":"keep"}]}`), + responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[0].namespace'.","param":"input[0].namespace"}}`), + }, + { + name: "max output tokens only mentioned", + body: []byte(`{"max_output_tokens":4096}`), + responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"max_output_tokens must be positive","param":"max_output_tokens"}}`), + }, + { + name: "structured param overrides namespace mention", + body: []byte(`{"input":[{"type":"function_call","namespace":"keep","arguments":"{}"}]}`), + responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[0].namespace'.","param":"tools"}}`), + }, + { + name: "nested max output tokens param is not top level", + body: []byte(`{"max_output_tokens":4096,"input":[{"type":"message","content":{"max_output_tokens":"keep"}}]}`), + responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: input[0].content.max_output_tokens","param":"input[0].content.max_output_tokens"}}`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody) + require.NoError(t, err) + require.False(t, changed) + require.Nil(t, retryBody) + }) + } +} + +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyFindsNamespacePathInMessage(t *testing.T) { + body := []byte(`{"input":[{"type":"function_call","namespace":"keep","arguments":"{}"},{"type":"function_call","namespace":"remove","arguments":"{}"}]}`) + responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"input[0] was accepted; Unknown parameter: 'input[1].namespace'."}}`) + + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.0.namespace").String()) + require.False(t, gjson.GetBytes(retryBody, "input.1.namespace").Exists()) +} + +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyBindsNamespacePathToRejectionPhrase(t *testing.T) { + body := []byte(`{"input":[{"type":"function_call","namespace":"keep","arguments":"{}"},{"type":"function_call","namespace":"remove","arguments":"{}"}]}`) + responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"input[0].namespace is supported; Unknown parameter: input[1].namespace."}}`) + + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.0.namespace").String()) + require.False(t, gjson.GetBytes(retryBody, "input.1.namespace").Exists()) +} + +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyDoesNotTreatMaxOutputTokensSuggestionAsRejection(t *testing.T) { + body := []byte(`{"max_tokens":4096,"max_output_tokens":2048}`) + responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: max_tokens. Use max_output_tokens instead."}}`) + + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody) + + require.NoError(t, err) + require.False(t, changed) + require.Nil(t, retryBody) +} + +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyBindsMaxOutputTokensToRejectionPhrase(t *testing.T) { + body := []byte(`{"max_output_tokens":2048}`) + responseBody := []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens."}}`) + + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody) + + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(retryBody, "max_output_tokens").Exists()) +} + +func TestOpenAIGatewayService_RetriesRejectedIndexedNamespaceField(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","name":"first","namespace":"keep","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove","input":"{}"}]}`) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].namespace'.","param":"input[1].namespace","type":"invalid_request_error"}}`), + newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`), + }} + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), + newOpenAIRejectedFieldTestContext(body), + newOpenAIRejectedFieldTestAccount(), + body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 2) + require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input.0.namespace").String()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1.namespace").Exists()) +} + +func TestOpenAIGatewayService_RetriesExplicitMaxOutputTokensRejection(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":4096,"input":[{"type":"message","role":"user","content":{"max_output_tokens":"keep"}}]}`) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens","param":"max_output_tokens","type":"invalid_request_error"}}`), + newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`), + }} + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), + newOpenAIRejectedFieldTestContext(body), + newOpenAIRejectedFieldTestAccount(), + body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 2) + require.Equal(t, int64(4096), gjson.GetBytes(upstream.bodies[0], "max_output_tokens").Int()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "max_output_tokens").Exists()) + require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input.0.content.max_output_tokens").String()) +} + +func TestOpenAIGatewayService_ComposesDistinctRejectedFieldRetries(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":2048,"input":[{"type":"function_call","name":"first","namespace":"keep","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove","input":"{}"}]}`) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].namespace'.","param":"input[1].namespace"}}`), + newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens","param":"max_output_tokens"}}`), + newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`), + }} + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), + newOpenAIRejectedFieldTestContext(body), + newOpenAIRejectedFieldTestAccount(), + body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 3) + require.True(t, gjson.GetBytes(upstream.bodies[0], "input.1.namespace").Exists()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1.namespace").Exists()) + require.Equal(t, int64(2048), gjson.GetBytes(upstream.bodies[1], "max_output_tokens").Int()) + require.False(t, gjson.GetBytes(upstream.bodies[2], "input.1.namespace").Exists()) + require.False(t, gjson.GetBytes(upstream.bodies[2], "max_output_tokens").Exists()) +} + +func newOpenAIRejectedFieldTestService(upstream *httpUpstreamRecorder) *OpenAIGatewayService { + return &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }}, + httpUpstream: upstream, + } +} + +func newOpenAIRejectedFieldTestContext(body []byte) *gin.Context { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Request.Header.Set("User-Agent", "curl/8.0") + return c +} + +func newOpenAIRejectedFieldTestAccount() *Account { + return &Account{ + ID: 5107, + Name: "responses-compatible", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://compat.example", + }, + Extra: map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeAuto), + openai_compat.ExtraKeyResponsesSupported: true, + }, + Status: StatusActive, + Schedulable: true, + } +} + +func newOpenAIRejectedFieldTestResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} From f59a6ed74c914b2980ec98999e346e8deea04104 Mon Sep 17 00:00:00 2001 From: Tian Lee <498756723@qq.com> Date: Wed, 15 Jul 2026 22:42:53 +0800 Subject: [PATCH 09/26] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20API=20Key=20?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=E5=80=8D=E7=8E=87=E8=87=AA=E7=9C=81=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/wire_gen.go | 2 +- backend/internal/handler/gateway_handler.go | 3 + .../internal/handler/gateway_key_billing.go | 108 ++++++ .../handler/gateway_key_billing_test.go | 311 ++++++++++++++++++ .../server/middleware/api_key_auth.go | 21 +- .../server/middleware/api_key_auth_test.go | 130 +++++++- backend/internal/server/routes/gateway.go | 1 + .../server/routes/gateway_key_billing_test.go | 172 ++++++++++ .../internal/service/gateway_usage_billing.go | 7 +- .../internal/service/openai_gateway_usage.go | 18 +- 10 files changed, 756 insertions(+), 17 deletions(-) create mode 100644 backend/internal/handler/gateway_key_billing.go create mode 100644 backend/internal/handler/gateway_key_billing_test.go create mode 100644 backend/internal/server/routes/gateway_key_billing_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 86721fbac0..3700e2257e 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -254,7 +254,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) - gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService) + gatewayHandler := handler.NewGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService) openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, configConfig) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService) totpHandler := handler.NewTotpHandler(totpService) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 1c51713656..7a23984d90 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -39,6 +39,7 @@ var gatewayCompatibilityMetricsLogCounter atomic.Uint64 // GatewayHandler handles API gateway requests type GatewayHandler struct { gatewayService *service.GatewayService + openAIGatewayService *service.OpenAIGatewayService geminiCompatService *service.GeminiMessagesCompatService antigravityGatewayService *service.AntigravityGatewayService userService *service.UserService @@ -59,6 +60,7 @@ type GatewayHandler struct { // NewGatewayHandler creates a new GatewayHandler func NewGatewayHandler( gatewayService *service.GatewayService, + openAIGatewayService *service.OpenAIGatewayService, geminiCompatService *service.GeminiMessagesCompatService, antigravityGatewayService *service.AntigravityGatewayService, userService *service.UserService, @@ -94,6 +96,7 @@ func NewGatewayHandler( return &GatewayHandler{ gatewayService: gatewayService, + openAIGatewayService: openAIGatewayService, geminiCompatService: geminiCompatService, antigravityGatewayService: antigravityGatewayService, userService: userService, diff --git a/backend/internal/handler/gateway_key_billing.go b/backend/internal/handler/gateway_key_billing.go new file mode 100644 index 0000000000..2c075d779d --- /dev/null +++ b/backend/internal/handler/gateway_key_billing.go @@ -0,0 +1,108 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +const keyBillingInfoSchemaVersion = 1 + +type keyBillingInfoResponse struct { + Object string `json:"object"` + SchemaVersion int `json:"schema_version"` + BillingScope string `json:"billing_scope"` + GroupRateMultiplier float64 `json:"group_rate_multiplier"` + UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"` + ResolvedRateMultiplier float64 `json:"resolved_rate_multiplier"` + PeakRateEnabled bool `json:"peak_rate_enabled"` + PeakStart *string `json:"peak_start,omitempty"` + PeakEnd *string `json:"peak_end,omitempty"` + PeakRateMultiplier *float64 `json:"peak_rate_multiplier,omitempty"` + AppliedPeakMultiplier *float64 `json:"applied_peak_multiplier,omitempty"` + EffectiveRateMultiplier float64 `json:"effective_rate_multiplier"` + Timezone *string `json:"timezone,omitempty"` + ObservedAt time.Time `json:"observed_at"` +} + +// KeyBillingInfo returns the token billing multiplier effective for the authenticated API key. +// GET /v1/sub2api/billing +func (h *GatewayHandler) KeyBillingInfo(c *gin.Context) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok { + h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") + return + } + if h.cfg != nil && h.cfg.RunMode == config.RunModeSimple { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Billing information is not supported in simple mode") + return + } + if apiKey.GroupID == nil { + h.errorResponse(c, http.StatusForbidden, "permission_error", "API key is not assigned to a group") + return + } + if apiKey.Group == nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Billing information is unavailable") + return + } + + resolvedRate, ok := h.resolveKeyBillingRate(c, apiKey) + if !ok { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Billing information is unavailable") + return + } + + c.Header("Cache-Control", "no-store") + c.JSON(http.StatusOK, buildKeyBillingInfo(apiKey, resolvedRate, timezone.Now())) +} + +func (h *GatewayHandler) resolveKeyBillingRate(c *gin.Context, apiKey *service.APIKey) (float64, bool) { + groupRate := apiKey.Group.RateMultiplier + switch apiKey.Group.Platform { + case service.PlatformOpenAI, service.PlatformGrok: + if h.openAIGatewayService == nil { + return 0, false + } + return h.openAIGatewayService.ResolveUserGroupRateMultiplier(c.Request.Context(), apiKey.UserID, *apiKey.GroupID, groupRate), true + default: + if h.gatewayService == nil { + return 0, false + } + return h.gatewayService.ResolveUserGroupRateMultiplier(c.Request.Context(), apiKey.UserID, *apiKey.GroupID, groupRate), true + } +} + +func buildKeyBillingInfo(apiKey *service.APIKey, resolvedRate float64, now time.Time) keyBillingInfoResponse { + groupRate := apiKey.Group.RateMultiplier + var userRate *float64 + if resolvedRate != groupRate { + userRate = &resolvedRate + } + appliedPeak := apiKey.Group.PeakMultiplierAt(now) + + response := keyBillingInfoResponse{ + Object: "sub2api.key_billing", + SchemaVersion: keyBillingInfoSchemaVersion, + BillingScope: "token", + GroupRateMultiplier: groupRate, + UserRateMultiplier: userRate, + ResolvedRateMultiplier: resolvedRate, + PeakRateEnabled: apiKey.Group.PeakRateEnabled, + EffectiveRateMultiplier: resolvedRate * appliedPeak, + ObservedAt: now.UTC(), + } + if apiKey.Group.PeakRateEnabled { + response.PeakStart = &apiKey.Group.PeakStart + response.PeakEnd = &apiKey.Group.PeakEnd + response.PeakRateMultiplier = &apiKey.Group.PeakRateMultiplier + response.AppliedPeakMultiplier = &appliedPeak + tz := timezone.Location().String() + response.Timezone = &tz + } + return response +} diff --git a/backend/internal/handler/gateway_key_billing_test.go b/backend/internal/handler/gateway_key_billing_test.go new file mode 100644 index 0000000000..ecd85bd56f --- /dev/null +++ b/backend/internal/handler/gateway_key_billing_test.go @@ -0,0 +1,311 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type keyBillingUserGroupRateRepo struct { + service.UserGroupRateRepository + rate *float64 + err error + gotUserID int64 + gotGroupID int64 + lookupCalls int +} + +func (r *keyBillingUserGroupRateRepo) GetByUserAndGroup(_ context.Context, userID, groupID int64) (*float64, error) { + r.gotUserID = userID + r.gotGroupID = groupID + r.lookupCalls++ + return r.rate, r.err +} + +func newKeyBillingHandler(repo service.UserGroupRateRepository) *GatewayHandler { + return &GatewayHandler{ + gatewayService: newKeyBillingGatewayService(repo), + openAIGatewayService: newKeyBillingOpenAIGatewayService(repo), + } +} + +func newKeyBillingGatewayService(repo service.UserGroupRateRepository) *service.GatewayService { + return service.NewGatewayService( + nil, nil, nil, nil, nil, nil, repo, nil, nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + ) +} + +func newKeyBillingOpenAIGatewayService(repo service.UserGroupRateRepository) *service.OpenAIGatewayService { + return service.NewOpenAIGatewayService( + nil, nil, nil, nil, nil, repo, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + ) +} + +func newKeyBillingContext(apiKey *service.APIKey) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil) + if apiKey != nil { + c.Set(string(middleware2.ContextKeyAPIKey), apiKey) + } + return c, w +} + +func TestGatewayHandlerKeyBillingInfoUsesGroupRate(t *testing.T) { + groupID := int64(7) + apiKey := &service.APIKey{ + UserID: 11, + GroupID: &groupID, + Key: "sk-sensitive-value", + Group: &service.Group{ + ID: groupID, + Name: "private-group-name", + RateMultiplier: 0.75, + }, + } + c, w := newKeyBillingContext(apiKey) + + newKeyBillingHandler(nil).KeyBillingInfo(c) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "no-store", w.Header().Get("Cache-Control")) + var got keyBillingInfoResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + require.Equal(t, "sub2api.key_billing", got.Object) + require.Equal(t, 1, got.SchemaVersion) + require.Equal(t, "token", got.BillingScope) + require.Equal(t, 0.75, got.GroupRateMultiplier) + require.Nil(t, got.UserRateMultiplier) + require.Equal(t, 0.75, got.ResolvedRateMultiplier) + require.False(t, got.PeakRateEnabled) + require.Nil(t, got.PeakStart) + require.Nil(t, got.PeakEnd) + require.Nil(t, got.PeakRateMultiplier) + require.Nil(t, got.AppliedPeakMultiplier) + require.Equal(t, 0.75, got.EffectiveRateMultiplier) + require.Nil(t, got.Timezone) + require.False(t, got.ObservedAt.IsZero()) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &fields)) + require.NotContains(t, fields, "user_rate_multiplier") + require.NotContains(t, fields, "peak_start") + require.NotContains(t, fields, "peak_end") + require.NotContains(t, fields, "peak_rate_multiplier") + require.NotContains(t, fields, "applied_peak_multiplier") + require.NotContains(t, fields, "timezone") + require.NotContains(t, w.Body.String(), apiKey.Key) + require.NotContains(t, w.Body.String(), apiKey.Group.Name) +} + +func TestGatewayHandlerKeyBillingInfoUsesUserOverride(t *testing.T) { + groupID := int64(7) + userRate := 0.5 + apiKey := &service.APIKey{ + UserID: 11, + GroupID: &groupID, + Group: &service.Group{ID: groupID, RateMultiplier: 0.75}, + } + c, w := newKeyBillingContext(apiKey) + repo := &keyBillingUserGroupRateRepo{rate: &userRate} + + newKeyBillingHandler(repo).KeyBillingInfo(c) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, 1, repo.lookupCalls) + require.Equal(t, apiKey.UserID, repo.gotUserID) + require.Equal(t, groupID, repo.gotGroupID) + var got keyBillingInfoResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + require.NotNil(t, got.UserRateMultiplier) + require.Equal(t, 0.5, *got.UserRateMultiplier) + require.Equal(t, 0.5, got.ResolvedRateMultiplier) + require.Equal(t, 0.5, got.EffectiveRateMultiplier) +} + +func TestBuildKeyBillingInfoAppliesPeakMultiplier(t *testing.T) { + groupID := int64(7) + apiKey := &service.APIKey{ + GroupID: &groupID, + Group: &service.Group{ + ID: groupID, + RateMultiplier: 1.2, + SubscriptionType: service.SubscriptionTypeSubscription, + PeakRateEnabled: true, + PeakStart: "09:00", + PeakEnd: "18:00", + PeakRateMultiplier: 1.5, + }, + } + now := time.Date(2026, time.July, 12, 10, 0, 0, 0, timezone.Location()) + userRate := 0.8 + + got := buildKeyBillingInfo(apiKey, userRate, now) + + require.Equal(t, 1.2, got.GroupRateMultiplier) + require.NotNil(t, got.UserRateMultiplier) + require.Equal(t, 0.8, *got.UserRateMultiplier) + require.Equal(t, 0.8, got.ResolvedRateMultiplier) + require.True(t, got.PeakRateEnabled) + require.NotNil(t, got.PeakStart) + require.Equal(t, "09:00", *got.PeakStart) + require.NotNil(t, got.PeakEnd) + require.Equal(t, "18:00", *got.PeakEnd) + require.NotNil(t, got.PeakRateMultiplier) + require.Equal(t, 1.5, *got.PeakRateMultiplier) + require.NotNil(t, got.AppliedPeakMultiplier) + require.Equal(t, 1.5, *got.AppliedPeakMultiplier) + require.InDelta(t, 1.2, got.EffectiveRateMultiplier, 1e-12) + require.NotNil(t, got.Timezone) + require.Equal(t, timezone.Location().String(), *got.Timezone) + require.Equal(t, now.UTC(), got.ObservedAt) + + encoded, err := json.Marshal(got) + require.NoError(t, err) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(encoded, &fields)) + for _, field := range []string{ + "user_rate_multiplier", + "peak_start", + "peak_end", + "peak_rate_multiplier", + "applied_peak_multiplier", + "timezone", + } { + require.Contains(t, fields, field) + } +} + +func TestKeyBillingInfoJSONKeepsZeroPeakMultiplierWhenEnabled(t *testing.T) { + groupID := int64(7) + apiKey := &service.APIKey{ + GroupID: &groupID, + Group: &service.Group{ + ID: groupID, + SubscriptionType: service.SubscriptionTypeSubscription, + PeakRateEnabled: true, + PeakStart: "00:00", + PeakEnd: "23:59", + PeakRateMultiplier: 0, + }, + } + now := time.Date(2026, time.July, 12, 12, 0, 0, 0, timezone.Location()) + encoded, err := json.Marshal(buildKeyBillingInfo(apiKey, apiKey.Group.RateMultiplier, now)) + require.NoError(t, err) + + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(encoded, &fields)) + require.JSONEq(t, "0", string(fields["peak_rate_multiplier"])) + require.JSONEq(t, "0", string(fields["applied_peak_multiplier"])) +} + +func TestGatewayHandlerKeyBillingInfoErrorsAreSafe(t *testing.T) { + t.Run("missing API key", func(t *testing.T) { + c, w := newKeyBillingContext(nil) + newKeyBillingHandler(nil).KeyBillingInfo(c) + require.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("ungrouped API key", func(t *testing.T) { + c, w := newKeyBillingContext(&service.APIKey{}) + newKeyBillingHandler(nil).KeyBillingInfo(c) + require.Equal(t, http.StatusForbidden, w.Code) + }) + + t.Run("missing billing service", func(t *testing.T) { + groupID := int64(7) + c, w := newKeyBillingContext(&service.APIKey{ + UserID: 11, + GroupID: &groupID, + Group: &service.Group{ID: groupID, RateMultiplier: 1}, + }) + (&GatewayHandler{}).KeyBillingInfo(c) + require.Equal(t, http.StatusInternalServerError, w.Code) + }) + + t.Run("rate lookup failure matches billing fallback", func(t *testing.T) { + groupID := int64(7) + c, w := newKeyBillingContext(&service.APIKey{ + UserID: 11, + GroupID: &groupID, + Group: &service.Group{ID: groupID, RateMultiplier: 1}, + }) + newKeyBillingHandler(&keyBillingUserGroupRateRepo{err: errors.New("database password leaked")}).KeyBillingInfo(c) + require.Equal(t, http.StatusOK, w.Code) + var got keyBillingInfoResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + require.Equal(t, 1.0, got.ResolvedRateMultiplier) + require.NotContains(t, w.Body.String(), "database password leaked") + }) +} + +func TestGatewayHandlerKeyBillingInfoSharesBillingResolverCacheByPlatform(t *testing.T) { + for _, tc := range []struct { + name string + platform string + openAI bool + }{ + {name: "anthropic", platform: service.PlatformAnthropic}, + {name: "openai", platform: service.PlatformOpenAI, openAI: true}, + {name: "grok", platform: service.PlatformGrok, openAI: true}, + } { + t.Run(tc.name, func(t *testing.T) { + groupID := int64(7) + oldRate, newRate := 0.5, 1.8 + repo := &keyBillingUserGroupRateRepo{rate: &oldRate} + gatewayService := newKeyBillingGatewayService(repo) + openAIGatewayService := newKeyBillingOpenAIGatewayService(repo) + h := &GatewayHandler{ + gatewayService: gatewayService, + openAIGatewayService: openAIGatewayService, + } + apiKey := &service.APIKey{ + UserID: 11, + GroupID: &groupID, + Group: &service.Group{ + ID: groupID, + Platform: tc.platform, + RateMultiplier: 0.75, + }, + } + + if tc.openAI { + require.Equal(t, oldRate, openAIGatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier)) + } else { + require.Equal(t, oldRate, gatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier)) + } + repo.rate = &newRate + + for range 2 { + c, w := newKeyBillingContext(apiKey) + h.KeyBillingInfo(c) + require.Equal(t, http.StatusOK, w.Code) + var got keyBillingInfoResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + require.Equal(t, oldRate, got.ResolvedRateMultiplier) + require.Equal(t, oldRate, got.EffectiveRateMultiplier) + } + + var billedRate float64 + if tc.openAI { + billedRate = openAIGatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier) + } else { + billedRate = gatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier) + } + require.Equal(t, oldRate, billedRate) + require.Equal(t, 1, repo.lookupCalls) + }) + } +} diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go index 082766bfd5..33bf65ea46 100644 --- a/backend/internal/server/middleware/api_key_auth.go +++ b/backend/internal/server/middleware/api_key_auth.go @@ -25,7 +25,8 @@ func NewAPIKeyAuthMiddleware(apiKeyService *service.APIKeyService, subscriptionS // - 鉴权(Authentication):验证 Key 有效性、用户状态、IP 限制 —— 始终执行 // - 计费执行(Billing Enforcement):过期/配额/订阅/余额检查 —— skipBilling 时整块跳过 // -// /v1/usage 端点只需鉴权,不需要计费执行(允许过期/配额耗尽的 Key 查询自身用量)。 +// /v1/usage 和 /v1/sub2api/billing 端点只需鉴权,不需要计费执行。 +// 前者允许过期/配额耗尽的 Key 查询自身用量,后者用于读取当前 Key 的倍率配置。 func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { // ── 1. 提取 API Key ────────────────────────────────────────── @@ -128,6 +129,8 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti } ctx := context.WithValue(c.Request.Context(), ctxkey.UserID, apiKey.User.ID) c.Request = c.Request.WithContext(ctx) + billingInfoRequest := c.Request.URL.Path == "/v1/sub2api/billing" + skipBilling := c.Request.URL.Path == "/v1/usage" || billingInfoRequest // ── 4. SimpleMode → early return ───────────────────────────── @@ -139,20 +142,20 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti }) c.Set(string(ContextKeyUserRole), apiKey.User.Role) setGroupContext(c, apiKey.Group) - _ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID) + if !billingInfoRequest { + _ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID) + } c.Next() return } - // ── 5. 加载订阅(订阅模式时始终加载) ─────────────────────── - - // skipBilling: /v1/usage 只需鉴权,跳过所有计费执行 - skipBilling := c.Request.URL.Path == "/v1/usage" + // ── 5. 按端点需要加载订阅 ─────────────────────────────────── var subscription *service.UserSubscription isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType() - if isSubscriptionType && subscriptionService != nil { + // 倍率自省不需要订阅数据;/v1/usage 仍保留原有订阅读取行为。 + if isSubscriptionType && subscriptionService != nil && !billingInfoRequest { sub, subErr := subscriptionService.GetActiveSubscription( c.Request.Context(), apiKey.User.ID, @@ -237,7 +240,9 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti }) c.Set(string(ContextKeyUserRole), apiKey.User.Role) setGroupContext(c, apiKey.Group) - _ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID) + if !billingInfoRequest { + _ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID) + } c.Next() } diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index d9b5c7efc1..2a2e2f5588 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -1066,6 +1066,129 @@ func TestAPIKeyAuthTouchesLastUsedInStandardMode(t *testing.T) { require.Equal(t, 1, touchCalls) } +func TestAPIKeyAuthBillingInfoSkipsBillingAndSideEffects(t *testing.T) { + gin.SetMode(gin.TestMode) + + group := &service.Group{ + ID: 42, + Name: "subscription", + Status: service.StatusActive, + Hydrated: true, + SubscriptionType: service.SubscriptionTypeSubscription, + } + user := &service.User{ + ID: 7, + Role: service.RoleUser, + Status: service.StatusActive, + Balance: 0, + Concurrency: 3, + } + expiredAt := time.Now().Add(-time.Hour) + apiKey := &service.APIKey{ + ID: 100, + UserID: user.ID, + Key: "billing-info-auth-only", + Status: service.StatusAPIKeyQuotaExhausted, + User: user, + GroupID: &group.ID, + Group: group, + Quota: 1, + QuotaUsed: 1, + ExpiresAt: &expiredAt, + } + + touchCalls := 0 + subscriptionCalls := 0 + apiKeyRepo := &stubApiKeyRepo{ + getByKey: func(context.Context, string) (*service.APIKey, error) { + clone := *apiKey + return &clone, nil + }, + updateLastUsed: func(context.Context, int64, time.Time) error { + touchCalls++ + return nil + }, + } + subscriptionRepo := &stubUserSubscriptionRepo{ + getActive: func(context.Context, int64, int64) (*service.UserSubscription, error) { + subscriptionCalls++ + return nil, service.ErrSubscriptionNotFound + }, + } + cfg := &config.Config{RunMode: config.RunModeStandard} + apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg) + subscriptionService := service.NewSubscriptionService(nil, subscriptionRepo, nil, nil, cfg) + t.Cleanup(subscriptionService.Stop) + router := newAuthTestRouter(apiKeyService, subscriptionService, cfg) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil) + req.Header.Set("x-api-key", apiKey.Key) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Zero(t, subscriptionCalls) + require.Zero(t, touchCalls) +} + +func TestAPIKeyAuthBillingInfoSkipsLastUsedInSimpleMode(t *testing.T) { + gin.SetMode(gin.TestMode) + + user := &service.User{ID: 7, Role: service.RoleUser, Status: service.StatusActive} + apiKey := &service.APIKey{ID: 100, UserID: user.ID, Key: "billing-info-simple", Status: service.StatusActive, User: user} + touchCalls := 0 + apiKeyRepo := &stubApiKeyRepo{ + getByKey: func(context.Context, string) (*service.APIKey, error) { + clone := *apiKey + return &clone, nil + }, + updateLastUsed: func(context.Context, int64, time.Time) error { + touchCalls++ + return nil + }, + } + cfg := &config.Config{RunMode: config.RunModeSimple} + apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg) + router := newAuthTestRouter(apiKeyService, nil, cfg) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil) + req.Header.Set("x-api-key", apiKey.Key) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Zero(t, touchCalls) +} + +func TestAPIKeyAuthUsageStillTouchesLastUsed(t *testing.T) { + gin.SetMode(gin.TestMode) + + user := &service.User{ID: 7, Role: service.RoleUser, Status: service.StatusActive, Balance: 10} + apiKey := &service.APIKey{ID: 100, UserID: user.ID, Key: "usage-touch", Status: service.StatusActive, User: user} + touchCalls := 0 + apiKeyRepo := &stubApiKeyRepo{ + getByKey: func(context.Context, string) (*service.APIKey, error) { + clone := *apiKey + return &clone, nil + }, + updateLastUsed: func(context.Context, int64, time.Time) error { + touchCalls++ + return nil + }, + } + cfg := &config.Config{RunMode: config.RunModeStandard} + apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg) + router := newAuthTestRouter(apiKeyService, nil, cfg) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/usage", nil) + req.Header.Set("x-api-key", apiKey.Key) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, 1, touchCalls) +} + func TestAPIKeyAuthAllowsBalanceBelowMinimumReserve(t *testing.T) { gin.SetMode(gin.TestMode) @@ -1155,9 +1278,12 @@ func TestAPIKeyAuthRejectsExhaustedBalance(t *testing.T) { func newAuthTestRouter(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) *gin.Engine { router := gin.New() router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, cfg))) - router.GET("/t", func(c *gin.Context) { + ok := func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) - }) + } + router.GET("/t", ok) + router.GET("/v1/usage", ok) + router.GET("/v1/sub2api/billing", ok) return router } diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index f73379981e..61b4c54ced 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -114,6 +114,7 @@ func RegisterGatewayRoutes( gateway.Use(opsErrorLogger) gateway.Use(endpointNorm) gateway.Use(gin.HandlerFunc(apiKeyAuth)) + gateway.GET("/sub2api/billing", h.Gateway.KeyBillingInfo) gateway.Use(requireGroupAnthropic) { // /v1/messages: auto-route based on group platform diff --git a/backend/internal/server/routes/gateway_key_billing_test.go b/backend/internal/server/routes/gateway_key_billing_test.go new file mode 100644 index 0000000000..ef69a5eb44 --- /dev/null +++ b/backend/internal/server/routes/gateway_key_billing_test.go @@ -0,0 +1,172 @@ +package routes + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/handler" + servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/Wei-Shaw/sub2api/internal/web" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type keyBillingRouteAPIKeyRepo struct { + service.APIKeyRepository + apiKey *service.APIKey +} + +func (r *keyBillingRouteAPIKeyRepo) GetByKeyForAuth(_ context.Context, key string) (*service.APIKey, error) { + if r.apiKey == nil || key != r.apiKey.Key { + return nil, service.ErrAPIKeyNotFound + } + clone := *r.apiKey + return &clone, nil +} + +type keyBillingRouteRateRepo struct { + service.UserGroupRateRepository + lookupCalls int +} + +func (r *keyBillingRouteRateRepo) GetByUserAndGroup(context.Context, int64, int64) (*float64, error) { + r.lookupCalls++ + return nil, nil +} + +func (r *keyBillingRouteRateRepo) GetRPMOverrideByUserAndGroup(context.Context, int64, int64) (*int, error) { + return nil, nil +} + +func newKeyBillingRouteTestRouter(runMode string) (*gin.Engine, *keyBillingRouteRateRepo, string) { + gin.SetMode(gin.TestMode) + group := &service.Group{ + ID: 42, + Status: service.StatusActive, + Hydrated: true, + Platform: service.PlatformOpenAI, + SubscriptionType: service.SubscriptionTypeStandard, + RateMultiplier: 0.75, + } + user := &service.User{ID: 7, Role: service.RoleUser, Status: service.StatusActive, Balance: 10} + var groupID *int64 + var apiKeyGroup *service.Group + if runMode != config.RunModeSimple { + groupID = &group.ID + apiKeyGroup = group + } + apiKey := &service.APIKey{ + ID: 100, + UserID: user.ID, + Key: "billing-route-test-key", + Status: service.StatusActive, + User: user, + GroupID: groupID, + Group: apiKeyGroup, + } + cfg := &config.Config{RunMode: runMode} + rateRepo := &keyBillingRouteRateRepo{} + apiKeyService := service.NewAPIKeyService( + &keyBillingRouteAPIKeyRepo{apiKey: apiKey}, nil, nil, nil, rateRepo, nil, cfg, + ) + gatewayService := service.NewGatewayService( + nil, nil, nil, nil, nil, nil, rateRepo, nil, cfg, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + ) + openAIGatewayService := service.NewOpenAIGatewayService( + nil, nil, nil, nil, nil, rateRepo, nil, cfg, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + ) + gatewayHandler := handler.NewGatewayHandler( + gatewayService, openAIGatewayService, nil, nil, nil, nil, nil, nil, + apiKeyService, nil, nil, nil, nil, cfg, nil, + ) + + router := gin.New() + if web.HasEmbeddedFrontend() { + router.Use(web.ServeEmbeddedFrontend()) + } + RegisterGatewayRoutes( + router, + &handler.Handlers{Gateway: gatewayHandler, OpenAIGateway: &handler.OpenAIGatewayHandler{}}, + servermiddleware.NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg), + apiKeyService, + nil, + nil, + nil, + cfg, + ) + return router, rateRepo, apiKey.Key +} + +func TestGatewayRoutesKeyBillingInfoPathIsRegistered(t *testing.T) { + router := newGatewayRoutesTestRouter() + + for _, route := range router.Routes() { + if route.Method == http.MethodGet && route.Path == "/v1/sub2api/billing" { + return + } + } + + t.Fatal("GET /v1/sub2api/billing should be registered") +} + +func TestGatewayRoutesKeyBillingInfoEndToEnd(t *testing.T) { + t.Run("missing credentials", func(t *testing.T) { + router, rateRepo, _ := newKeyBillingRouteTestRouter(config.RunModeStandard) + w := httptest.NewRecorder() + + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil)) + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Contains(t, w.Header().Get("Content-Type"), "application/json") + require.NotContains(t, strings.ToLower(w.Body.String()), "") + require.Zero(t, rateRepo.lookupCalls) + }) + + t.Run("standard mode", func(t *testing.T) { + router, rateRepo, key := newKeyBillingRouteTestRouter(config.RunModeStandard) + req := httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil) + req.Header.Set("Authorization", "Bearer "+key) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Header().Get("Content-Type"), "application/json") + require.Equal(t, "no-store", w.Header().Get("Cache-Control")) + require.NotContains(t, strings.ToLower(w.Body.String()), "") + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Equal(t, "sub2api.key_billing", body["object"]) + require.Equal(t, 0.75, body["effective_rate_multiplier"]) + require.Equal(t, 1, rateRepo.lookupCalls) + }) + + t.Run("simple mode", func(t *testing.T) { + router, rateRepo, key := newKeyBillingRouteTestRouter(config.RunModeSimple) + req := httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil) + req.Header.Set("x-api-key", key) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusNotFound, w.Code) + require.Contains(t, w.Header().Get("Content-Type"), "application/json") + require.NotContains(t, strings.ToLower(w.Body.String()), "") + require.JSONEq(t, `{ + "type": "error", + "error": { + "type": "not_found_error", + "message": "Billing information is not supported in simple mode" + } + }`, w.Body.String()) + require.Zero(t, rateRepo.lookupCalls) + }) +} diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 61ab3abd2f..71236b69bd 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -29,6 +29,11 @@ func (s *GatewayService) getUserGroupRateMultiplier(ctx context.Context, userID, return resolver.Resolve(ctx, userID, groupID, groupDefaultMultiplier) } +// ResolveUserGroupRateMultiplier resolves the same cached multiplier used by usage billing. +func (s *GatewayService) ResolveUserGroupRateMultiplier(ctx context.Context, userID, groupID int64, groupDefaultMultiplier float64) float64 { + return s.getUserGroupRateMultiplier(ctx, userID, groupID, groupDefaultMultiplier) +} + // RecordUsageInput 记录使用量的输入参数。 // 异步 worker 只接收计费所需快照,不能持有 ParsedRequest/RequestBodyRef 这类大请求体引用。 type RecordUsageInput struct { @@ -662,7 +667,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage } if apiKey.GroupID != nil && apiKey.Group != nil { groupDefault := apiKey.Group.RateMultiplier - multiplier = s.getUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, groupDefault) + multiplier = s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, groupDefault) } // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算, // 不并入上面的 getUserGroupRateMultiplier,以免污染 user:group 倍率缓存。 diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 410b4d944d..677239c3c6 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -98,6 +98,18 @@ func (s *OpenAIGatewayService) RecordCyberPolicyUsageLog(ctx context.Context, in } } +// ResolveUserGroupRateMultiplier resolves the same cached multiplier used by OpenAI usage billing. +func (s *OpenAIGatewayService) ResolveUserGroupRateMultiplier(ctx context.Context, userID, groupID int64, groupDefaultMultiplier float64) float64 { + if s == nil { + return groupDefaultMultiplier + } + resolver := s.userGroupRateResolver + if resolver == nil { + resolver = newUserGroupRateResolver(nil, nil, resolveUserGroupRateCacheTTL(s.cfg), nil, "service.openai_gateway") + } + return resolver.Resolve(ctx, userID, groupID, groupDefaultMultiplier) +} + // RecordUsage records usage and deducts balance func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRecordUsageInput) error { if input == nil { @@ -142,11 +154,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec multiplier = s.cfg.Default.RateMultiplier } if apiKey.GroupID != nil && apiKey.Group != nil { - resolver := s.userGroupRateResolver - if resolver == nil { - resolver = newUserGroupRateResolver(nil, nil, resolveUserGroupRateCacheTTL(s.cfg), nil, "service.openai_gateway") - } - multiplier = resolver.Resolve(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplier) + multiplier = s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplier) } // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算, // 不并入上面的 Resolve,以免污染 user:group 倍率缓存。 From 40b8f04a6af732e5ab0f71fe685a5c8429789060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= <2829624376@qq.com> Date: Wed, 15 Jul 2026 21:56:13 +0800 Subject: [PATCH 10/26] fix(openai): scope transient cooldowns by model --- backend/internal/handler/grok_media.go | 6 +- .../internal/handler/openai_alpha_search.go | 6 +- .../handler/openai_chat_completions.go | 8 +- backend/internal/handler/openai_embeddings.go | 6 +- .../handler/openai_gateway_handler.go | 26 +-- .../handler/openai_gateway_handler_test.go | 13 ++ backend/internal/handler/openai_images.go | 10 +- .../service/openai_account_model_transient.go | 177 ++++++++++++++++++ .../openai_account_model_transient_test.go | 130 +++++++++++++ .../openai_account_runtime_block_fastpath.go | 97 +++++++++- .../openai_account_runtime_transient_test.go | 116 ++++++++++++ .../service/openai_account_scheduler.go | 9 +- .../service/openai_account_scheduler_test.go | 30 ++- .../service/openai_gateway_passthrough.go | 6 +- .../service/openai_gateway_scheduling.go | 10 +- .../service/openai_gateway_service.go | 53 ++++-- .../service/openai_gateway_upstream_errors.go | 7 +- .../service/openai_ws_forwarder_ingress.go | 29 +-- ...penai_ws_forwarder_ingress_session_test.go | 8 +- .../openai_ws_forwarder_success_test.go | 51 +++++ .../service/openai_ws_forwarder_support.go | 94 +++++++++- .../service/openai_ws_forwarder_test.go | 79 ++++++++ .../service/openai_ws_forwarder_v2.go | 31 +-- .../internal/service/openai_ws_http_bridge.go | 29 +-- .../openai_ws_ratelimit_signal_test.go | 46 +++++ .../openai_ws_v2_passthrough_adapter.go | 48 +++-- 26 files changed, 1003 insertions(+), 122 deletions(-) create mode 100644 backend/internal/service/openai_account_model_transient.go create mode 100644 backend/internal/service/openai_account_model_transient_test.go create mode 100644 backend/internal/service/openai_account_runtime_transient_test.go diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index 01928028e6..61c32344b3 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -273,7 +273,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. return } if failoverErr.ShouldReportAccountScheduleFailure() { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), false, nil) } if c.Writer.Size() != writerSizeBeforeForward { h.handleFailoverExhausted(c, failoverErr, true) @@ -321,7 +321,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. ) continue } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), false, nil) if c.Writer.Size() == writerSizeBeforeForward { h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed") } @@ -332,7 +332,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. return } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), true, nil) if endpoint.IsGenerationRequest() && strings.TrimSpace(result.ResponseID) != "" { if err := h.gatewayService.BindGrokMediaVideoRequestAccount(requestCtx, apiKey.GroupID, result.ResponseID, account.ID); err != nil { reqLog.Warn("grok_media.bind_video_request_account_failed", diff --git a/backend/internal/handler/openai_alpha_search.go b/backend/internal/handler/openai_alpha_search.go index a1d185a1cb..e8a1429f03 100644 --- a/backend/internal/handler/openai_alpha_search.go +++ b/backend/internal/handler/openai_alpha_search.go @@ -163,7 +163,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, time.Since(forwardStart).Milliseconds()) if err == nil { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestedModel), true, nil) if result != nil { h.recordAlphaSearchUsage(c, apiKey, account, subscription, channelMapping, requestedModel, body, result, subject.UserID) } @@ -172,7 +172,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { var failoverErr *service.UpstreamFailoverError if !errors.As(err, &failoverErr) { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestedModel), false, nil) if c.Writer.Size() == writerSizeBeforeForward { h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed") } @@ -180,7 +180,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { return } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestedModel), false, nil) if c.Writer.Size() != writerSizeBeforeForward { h.handleFailoverExhausted(c, failoverErr, true) return diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 62a2b70b59..2a7e5703b4 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -251,7 +251,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { return } if failoverErr.ShouldReportAccountScheduleFailure() { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) } if !failoverErr.ShouldRetryNextAccount() { h.handleFailoverExhausted(c, failoverErr, streamStarted) @@ -296,7 +296,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { ) continue } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) upstreamErrorAlreadyCommunicated := openAIForwardErrorAlreadyCommunicated(c, writerSizeBeforeForward, err) wroteFallback := false if !upstreamErrorAlreadyCommunicated { @@ -312,9 +312,9 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { } } if result != nil { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, result.FirstTokenMs) } else { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, nil) } userAgent := c.GetHeader("User-Agent") diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go index 99f869f14c..5a159cf027 100644 --- a/backend/internal/handler/openai_embeddings.go +++ b/backend/internal/handler/openai_embeddings.go @@ -192,7 +192,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { h.handleFailoverExhausted(c, failoverErr, true) return } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) if failoverClientGone(c) { reqLog.Info("openai_embeddings.failover_aborted_client_disconnected", zap.Int64("account_id", account.ID), @@ -216,7 +216,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { ) continue } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) if c.Writer.Size() == writerSizeBeforeForward { h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed") } @@ -227,7 +227,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { return } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, nil) userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) inboundEndpoint := GetInboundEndpoint(c) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 83c4b4ac23..83a618fc58 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -42,6 +42,10 @@ type OpenAIGatewayHandler struct { const maxOpenAIFirstOutputTimeoutSwitches = 1 +func openAIForwardSucceededForScheduling(result *service.OpenAIForwardResult) bool { + return result.SucceededForScheduling() +} + func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedModel string) string { if apiKey == nil || apiKey.Group == nil { return "" @@ -481,7 +485,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { streamStarted = true } if failoverErr.ShouldReportAccountScheduleFailure() { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) } if !failoverErr.ShouldRetryNextAccount() { h.handleFailoverExhausted(c, failoverErr, streamStarted) @@ -541,7 +545,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { reqLog.Warn("openai.upstream_failover_switching", failoverSwitchFields...) continue } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) upstreamErrorAlreadyCommunicated := openAIForwardErrorAlreadyCommunicated(c, writerSizeBeforeForward, err) wroteFallback := false if !upstreamErrorAlreadyCommunicated { @@ -566,9 +570,9 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { if account.Type == service.AccountTypeOAuth && !account.IsShadow() { h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders) } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), result.FirstTokenMs) } else { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), nil) } // 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context) @@ -1009,7 +1013,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { return } if failoverErr.ShouldReportAccountScheduleFailure() { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), false, nil) } if !failoverErr.ShouldRetryNextAccount() { h.handleAnthropicFailoverExhausted(c, failoverErr, streamStarted) @@ -1061,7 +1065,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { ) return } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), false, nil) wroteFallback := h.ensureAnthropicErrorResponse(c, streamStarted) reqLog.Warn("openai_messages.forward_failed", zap.Int64("account_id", account.ID), @@ -1072,9 +1076,9 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { } } if result != nil { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), true, result.FirstTokenMs) } else { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), true, nil) } userAgent := c.GetHeader("User-Agent") @@ -1554,7 +1558,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { return false } if failoverErr.ShouldReportAccountScheduleFailure() { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) } releaseAccountSlot() if !failoverErr.ShouldRetryNextAccount() { @@ -1773,7 +1777,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { if account.Type == service.AccountTypeOAuth && !account.IsShadow() { h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, result.ResponseHeaders) } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), result.FirstTokenMs) inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) @@ -1853,7 +1857,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { return } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) closeStatus, closeReason := summarizeWSCloseErrorForLog(err) proxyFailedFields := []zap.Field{ zap.Int64("account_id", account.ID), diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index d855f364a7..21f5319092 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -96,6 +96,19 @@ func TestOpenAIHandleStreamingAwareError_JSONEscaping(t *testing.T) { } } +func TestOpenAIForwardSucceededForScheduling(t *testing.T) { + require.True(t, openAIForwardSucceededForScheduling(nil)) + require.True(t, openAIForwardSucceededForScheduling(&service.OpenAIForwardResult{})) + require.True(t, openAIForwardSucceededForScheduling(&service.OpenAIForwardResult{ + OpenAIWSMode: true, + UpstreamTerminalEvent: "response.completed", + })) + require.False(t, openAIForwardSucceededForScheduling(&service.OpenAIForwardResult{ + OpenAIWSMode: true, + UpstreamTerminalEvent: "response.failed", + })) +} + func TestResolveOpenAIMessagesMetadataSession_DoesNotDerivePromptCacheKey(t *testing.T) { body := []byte(`{"model":"claude-sonnet-4-5","metadata":{"user_id":"claude-code-session"},"messages":[{"role":"user","content":"hello"}]}`) diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go index f5709225d1..d585c159a8 100644 --- a/backend/internal/handler/openai_images.go +++ b/backend/internal/handler/openai_images.go @@ -253,7 +253,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { var imageUpstreamErr *service.OpenAIImagesUpstreamError if errors.As(err, &imageUpstreamErr) { retryableServerError := service.IsOpenAIImagesRetryableUpstreamError(imageUpstreamErr) - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, !retryableServerError, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), !retryableServerError, nil) logEvent := "openai.images.upstream_user_error" if retryableServerError { logEvent = "openai.images.upstream_server_error_after_flush" @@ -269,7 +269,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { } var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), false, nil) if service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c) != writerSizeBeforeForward { reqLog.Warn("openai.images.upstream_failover_skipped_after_flush", zap.Int64("account_id", account.ID), @@ -323,7 +323,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { ) continue } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), false, nil) upstreamErrorAlreadyCommunicated := openAIForwardErrorAlreadyCommunicated(c, writerSizeBeforeForward, err) wroteFallback := false if !upstreamErrorAlreadyCommunicated { @@ -348,9 +348,9 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { if account.Type == service.AccountTypeOAuth && !account.IsShadow() { h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders) } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), true, result.FirstTokenMs) } else { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), true, nil) } userAgent := c.GetHeader("User-Agent") diff --git a/backend/internal/service/openai_account_model_transient.go b/backend/internal/service/openai_account_model_transient.go new file mode 100644 index 0000000000..6ef710d23c --- /dev/null +++ b/backend/internal/service/openai_account_model_transient.go @@ -0,0 +1,177 @@ +package service + +import ( + "strings" + "sync" + "time" +) + +const ( + openAIModelTransientFailureWindow = time.Minute + openAIModelTransientShortCooldown = 10 * time.Second + openAIModelTransientLongCooldown = 45 * time.Second + openAIModelTransientDefaultMax = 4096 + openAIModelTransientMaxModelBytes = 512 +) + +type openAIAccountModelKey struct { + AccountID int64 + Model string +} + +type openAIAccountModelTransientEntry struct { + failureStreak int + lastFailure time.Time + blockUntil time.Time + lastTouched time.Time +} + +type openAIAccountModelTransientDecision struct { + FailureStreak int + Cooldown time.Duration + BlockUntil time.Time +} + +type openAIAccountModelTransientState struct { + mu sync.Mutex + entries map[openAIAccountModelKey]openAIAccountModelTransientEntry + maxEntries int +} + +func newOpenAIAccountModelTransientState(maxEntries int) *openAIAccountModelTransientState { + if maxEntries <= 0 { + maxEntries = openAIModelTransientDefaultMax + } + return &openAIAccountModelTransientState{ + entries: make(map[openAIAccountModelKey]openAIAccountModelTransientEntry), + maxEntries: maxEntries, + } +} + +func normalizeOpenAIAccountModelTransientModel(model string) string { + model = strings.TrimSpace(model) + if len(model) > openAIModelTransientMaxModelBytes { + return "" + } + return strings.ToLower(model) +} + +func openAIAccountModelTransientKey(accountID int64, model string) (openAIAccountModelKey, bool) { + model = normalizeOpenAIAccountModelTransientModel(model) + if accountID <= 0 || model == "" { + return openAIAccountModelKey{}, false + } + return openAIAccountModelKey{AccountID: accountID, Model: model}, true +} + +func (s *openAIAccountModelTransientState) recordFailure(accountID int64, model string, now time.Time) openAIAccountModelTransientDecision { + key, ok := openAIAccountModelTransientKey(accountID, model) + if s == nil || !ok { + return openAIAccountModelTransientDecision{} + } + if now.IsZero() { + now = time.Now() + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.entries == nil { + s.entries = make(map[openAIAccountModelKey]openAIAccountModelTransientEntry) + } + if s.maxEntries <= 0 { + s.maxEntries = openAIModelTransientDefaultMax + } + + entry, exists := s.entries[key] + if !exists { + s.evictOldestLocked() + } + if !exists || entry.lastFailure.IsZero() || now.Sub(entry.lastFailure) > openAIModelTransientFailureWindow || now.Before(entry.lastFailure) { + entry.failureStreak = 0 + entry.blockUntil = time.Time{} + } + entry.failureStreak++ + entry.lastFailure = now + entry.lastTouched = now + + cooldown := time.Duration(0) + switch { + case entry.failureStreak >= 3: + cooldown = openAIModelTransientLongCooldown + case entry.failureStreak == 2: + cooldown = openAIModelTransientShortCooldown + } + if cooldown > 0 { + entry.blockUntil = now.Add(cooldown) + } else { + entry.blockUntil = time.Time{} + } + s.entries[key] = entry + return openAIAccountModelTransientDecision{ + FailureStreak: entry.failureStreak, + Cooldown: cooldown, + BlockUntil: entry.blockUntil, + } +} + +func (s *openAIAccountModelTransientState) recordSuccess(accountID int64, model string) { + key, ok := openAIAccountModelTransientKey(accountID, model) + if s == nil || !ok { + return + } + s.mu.Lock() + delete(s.entries, key) + s.mu.Unlock() +} + +func (s *openAIAccountModelTransientState) isBlocked(accountID int64, model string, now time.Time) bool { + key, ok := openAIAccountModelTransientKey(accountID, model) + if s == nil || !ok { + return false + } + if now.IsZero() { + now = time.Now() + } + + s.mu.Lock() + defer s.mu.Unlock() + entry, exists := s.entries[key] + if !exists { + return false + } + if !entry.lastFailure.IsZero() && now.Sub(entry.lastFailure) > openAIModelTransientFailureWindow { + delete(s.entries, key) + return false + } + entry.lastTouched = now + s.entries[key] = entry + return !entry.blockUntil.IsZero() && now.Before(entry.blockUntil) +} + +func (s *openAIAccountModelTransientState) size() int { + if s == nil { + return 0 + } + s.mu.Lock() + defer s.mu.Unlock() + return len(s.entries) +} + +func (s *openAIAccountModelTransientState) evictOldestLocked() { + if len(s.entries) < s.maxEntries { + return + } + var oldestKey openAIAccountModelKey + var oldestTime time.Time + found := false + for key, entry := range s.entries { + if !found || entry.lastTouched.Before(oldestTime) { + oldestKey = key + oldestTime = entry.lastTouched + found = true + } + } + if found { + delete(s.entries, oldestKey) + } +} diff --git a/backend/internal/service/openai_account_model_transient_test.go b/backend/internal/service/openai_account_model_transient_test.go new file mode 100644 index 0000000000..bd8d92f4d2 --- /dev/null +++ b/backend/internal/service/openai_account_model_transient_test.go @@ -0,0 +1,130 @@ +package service + +import ( + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenAIModelTransient_FirstFailureDoesNotCreateLongBlock(t *testing.T) { + state := newOpenAIAccountModelTransientState(128) + now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC) + + decision := state.recordFailure(35, "gpt-5.5", now) + + assert.Equal(t, 1, decision.FailureStreak) + assert.Zero(t, decision.Cooldown) + assert.False(t, state.isBlocked(35, "gpt-5.5", now)) +} + +func TestOpenAIModelTransient_SecondFailureCreatesShortModelBlock(t *testing.T) { + state := newOpenAIAccountModelTransientState(128) + now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC) + state.recordFailure(35, "gpt-5.5", now) + + decision := state.recordFailure(35, "gpt-5.5", now.Add(time.Second)) + + assert.Equal(t, 2, decision.FailureStreak) + assert.Equal(t, openAIModelTransientShortCooldown, decision.Cooldown) + assert.True(t, state.isBlocked(35, "gpt-5.5", now.Add(2*time.Second))) + assert.False(t, state.isBlocked(35, "gpt-5.5", now.Add(openAIModelTransientShortCooldown+2*time.Second))) +} + +func TestOpenAIModelTransient_ThirdFailureCreatesFortyFiveSecondModelBlock(t *testing.T) { + state := newOpenAIAccountModelTransientState(128) + now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC) + state.recordFailure(35, "gpt-5.5", now) + state.recordFailure(35, "gpt-5.5", now.Add(time.Second)) + + decision := state.recordFailure(35, "gpt-5.5", now.Add(2*time.Second)) + + assert.Equal(t, 3, decision.FailureStreak) + assert.Equal(t, 45*time.Second, decision.Cooldown) + assert.True(t, state.isBlocked(35, "gpt-5.5", now.Add(40*time.Second))) + assert.False(t, state.isBlocked(35, "gpt-5.5", now.Add(48*time.Second))) +} + +func TestOpenAIModelTransient_BlockIsIsolatedByModel(t *testing.T) { + state := newOpenAIAccountModelTransientState(128) + now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC) + state.recordFailure(35, "gpt-5.6-terra", now) + state.recordFailure(35, "GPT-5.6-TERRA", now.Add(time.Second)) + + assert.True(t, state.isBlocked(35, "gpt-5.6-terra", now.Add(2*time.Second))) + assert.False(t, state.isBlocked(35, "gpt-5.5", now.Add(2*time.Second))) + assert.False(t, state.isBlocked(47, "gpt-5.6-terra", now.Add(2*time.Second))) +} + +func TestOpenAIModelTransient_SuccessClearsStreakAndBlock(t *testing.T) { + state := newOpenAIAccountModelTransientState(128) + now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC) + state.recordFailure(35, "gpt-5.5", now) + state.recordFailure(35, "gpt-5.5", now.Add(time.Second)) + require.True(t, state.isBlocked(35, "gpt-5.5", now.Add(2*time.Second))) + + state.recordSuccess(35, "gpt-5.5") + + assert.False(t, state.isBlocked(35, "gpt-5.5", now.Add(2*time.Second))) + decision := state.recordFailure(35, "gpt-5.5", now.Add(3*time.Second)) + assert.Equal(t, 1, decision.FailureStreak) + assert.Zero(t, decision.Cooldown) +} + +func TestOpenAIModelTransient_StaleStreakExpires(t *testing.T) { + state := newOpenAIAccountModelTransientState(128) + now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC) + state.recordFailure(35, "gpt-5.5", now) + + decision := state.recordFailure(35, "gpt-5.5", now.Add(openAIModelTransientFailureWindow+time.Second)) + + assert.Equal(t, 1, decision.FailureStreak) + assert.Zero(t, decision.Cooldown) +} + +func TestOpenAIModelTransient_IgnoresInvalidKeys(t *testing.T) { + state := newOpenAIAccountModelTransientState(128) + now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC) + + assert.Zero(t, state.recordFailure(0, "gpt-5.5", now).FailureStreak) + assert.Zero(t, state.recordFailure(35, " ", now).FailureStreak) + assert.False(t, state.isBlocked(0, "gpt-5.5", now)) + assert.False(t, state.isBlocked(35, "", now)) + assert.Equal(t, 0, state.size()) +} + +func TestOpenAIModelTransient_IgnoresOversizedModelKey(t *testing.T) { + state := newOpenAIAccountModelTransientState(128) + now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC) + model := strings.Repeat("m", openAIModelTransientMaxModelBytes+1) + + decision := state.recordFailure(35, model, now) + + assert.Zero(t, decision.FailureStreak) + assert.False(t, state.isBlocked(35, model, now)) + assert.Equal(t, 0, state.size()) +} + +func TestOpenAIModelTransient_StateIsBoundedAndConcurrencySafe(t *testing.T) { + const maxEntries = 16 + state := newOpenAIAccountModelTransientState(maxEntries) + now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC) + var wg sync.WaitGroup + + for i := 0; i < 128; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + model := fmt.Sprintf("gpt-test-%d", i) + state.recordFailure(int64(i+1), model, now.Add(time.Duration(i)*time.Millisecond)) + _ = state.isBlocked(int64(i+1), model, now.Add(time.Second)) + }(i) + } + wg.Wait() + + assert.LessOrEqual(t, state.size(), maxEntries) +} diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 00d971ba22..7b3df522f1 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -2,7 +2,9 @@ package service import ( "context" + "log/slog" "net/http" + "strings" "sync" "time" ) @@ -43,7 +45,9 @@ func isOpenAIAccount(account *Account) bool { return account != nil && (account.Platform == PlatformOpenAI || account.Platform == PlatformGrok) } -func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte, requestedModel ...string) bool { +// handleOpenAIAccountUpstreamError expects canonicalModel to be the model used +// for scheduling after applying account mapping exactly once. +func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte, canonicalModel ...string) bool { stateCtx, cancel := openAIAccountStateContext(ctx) defer cancel() @@ -64,16 +68,43 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont if s == nil || account == nil || s.rateLimitService == nil { return false } - if len(requestedModel) > 0 && s.rateLimitService.HandleUpstreamModelNotFound(stateCtx, account, requestedModel[0], statusCode, responseBody) { + if len(canonicalModel) > 0 && s.rateLimitService.HandleUpstreamModelNotFound(stateCtx, account, canonicalModel[0], statusCode, responseBody) { return true } shouldDisable := s.rateLimitService.HandleUpstreamError(stateCtx, account, statusCode, headers, responseBody) if shouldDisable { s.BlockAccountScheduling(account, time.Time{}, "upstream_disable") } + if !shouldDisable && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey && shouldCooldownOpenAITransientUpstreamError(statusCode, responseBody) { + model := "" + if len(canonicalModel) > 0 { + model = canonicalModel[0] + } + decision := s.recordOpenAIAccountModelTransientFailure(account, model, time.Now()) + if decision.FailureStreak > 0 { + slog.Warn("openai_model_transient_state", + "account_id", account.ID, + "model", openAIAccountModelTransientModel(model), + "failure_streak", decision.FailureStreak, + "cooldown_ms", decision.Cooldown.Milliseconds(), + "block_scope", "account_model", + ) + } + } return shouldDisable } +func shouldCooldownOpenAITransientUpstreamError(statusCode int, responseBody []byte) bool { + switch statusCode { + case http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, 520, 521, 522, 523, 524: + return true + case http.StatusBadRequest: + return isOpenAITransientProcessingError(statusCode, "", responseBody) + default: + return false + } +} + func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context, account *Account, headers http.Header, responseBody []byte) { if s == nil || !isOpenAIOAuthAccount(account) { return @@ -191,6 +222,68 @@ func (s *OpenAIGatewayService) isOpenAIAccountRuntimeBlocked(account *Account) b return false } +func (s *OpenAIGatewayService) getOpenAIAccountModelTransientState() *openAIAccountModelTransientState { + if s == nil { + return nil + } + s.openaiModelTransientOnce.Do(func() { + if s.openaiModelTransient == nil { + s.openaiModelTransient = newOpenAIAccountModelTransientState(openAIModelTransientDefaultMax) + } + }) + return s.openaiModelTransient +} + +func canonicalOpenAIAccountSchedulingModel(account *Account, requestedModel string) string { + model := strings.TrimSpace(requestedModel) + if account == nil || model == "" { + return model + } + if mapped := strings.TrimSpace(account.GetMappedModel(model)); mapped != "" { + return mapped + } + return model +} + +func openAIAccountModelTransientModel(canonicalModel string) string { + return normalizeOpenAIAccountModelTransientModel(canonicalModel) +} + +func (s *OpenAIGatewayService) recordOpenAIAccountModelTransientFailure(account *Account, canonicalModel string, now time.Time) openAIAccountModelTransientDecision { + if s == nil || account == nil { + return openAIAccountModelTransientDecision{} + } + state := s.getOpenAIAccountModelTransientState() + if state == nil { + return openAIAccountModelTransientDecision{} + } + return state.recordFailure(account.ID, openAIAccountModelTransientModel(canonicalModel), now) +} + +func (s *OpenAIGatewayService) clearOpenAIAccountModelTransientState(accountID int64, model string) { + state := s.getOpenAIAccountModelTransientState() + if state == nil { + return + } + state.recordSuccess(accountID, model) +} + +func (s *OpenAIGatewayService) isOpenAIAccountModelRuntimeBlocked(account *Account, requestedModel string) bool { + if s == nil || account == nil { + return false + } + state := s.getOpenAIAccountModelTransientState() + if state == nil { + return false + } + canonicalModel := canonicalOpenAIAccountSchedulingModel(account, requestedModel) + return state.isBlocked(account.ID, openAIAccountModelTransientModel(canonicalModel), time.Now()) +} + +func (s *OpenAIGatewayService) isOpenAIAccountRequestRuntimeBlocked(account *Account, requestedModel string) bool { + return s != nil && (s.isOpenAIAccountRuntimeBlocked(account) || s.isOpenAIAccountModelRuntimeBlocked(account, requestedModel)) +} + func (s *OpenAIGatewayService) recordOpenAIOAuth429() { if s == nil { return diff --git a/backend/internal/service/openai_account_runtime_transient_test.go b/backend/internal/service/openai_account_runtime_transient_test.go new file mode 100644 index 0000000000..140e15d490 --- /dev/null +++ b/backend/internal/service/openai_account_runtime_transient_test.go @@ -0,0 +1,116 @@ +package service + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type transientCooldownAccountRepo struct { + AccountRepository +} + +func (transientCooldownAccountRepo) SetOverloaded(context.Context, int64, time.Time) error { + return nil +} + +func TestHandleOpenAITransientError_BlocksOnlyRequestedModel(t *testing.T) { + svc := &OpenAIGatewayService{} + svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil) + account := &Account{ + ID: 5105, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + } + + firstShouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusBadGateway, http.Header{}, []byte(`{"error":{"message":"Upstream request failed","type":"upstream_error"}}`), "gpt-5.5") + secondShouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusBadGateway, http.Header{}, []byte(`{"error":{"message":"Upstream request failed","type":"upstream_error"}}`), "gpt-5.5") + + require.False(t, firstShouldDisable) + require.False(t, secondShouldDisable) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5")) + require.False(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.6-terra")) +} + +func TestHandleOpenAITransientError_TransientStatusesUseModelScope(t *testing.T) { + for _, statusCode := range []int{http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, 520, 521, 522, 523, 524} { + t.Run(http.StatusText(statusCode), func(t *testing.T) { + svc := &OpenAIGatewayService{} + svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil) + account := &Account{ + ID: int64(5100 + statusCode), + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + } + + firstShouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), account, statusCode, http.Header{}, []byte(`{"error":{"message":"temporary upstream failure"}}`), "gpt-5.5") + secondShouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), account, statusCode, http.Header{}, []byte(`{"error":{"message":"temporary upstream failure"}}`), "gpt-5.5") + + require.False(t, firstShouldDisable) + require.False(t, secondShouldDisable) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account), "status %d must not block the whole account", statusCode) + require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5"), "status %d should block the failing model", statusCode) + }) + } +} + +func TestHandleOpenAITransientError_529RemainsOverloadOnly(t *testing.T) { + require.False(t, shouldCooldownOpenAITransientUpstreamError(529, []byte(`{"error":{"message":"overloaded"}}`))) +} + +func TestHandleOpenAITransientError_CanonicalModelIsNotMappedTwice(t *testing.T) { + svc := &OpenAIGatewayService{} + svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil) + account := &Account{ + ID: 5107, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "public-alias": "upstream-a", + "upstream-a": "upstream-b", + }, + }, + } + canonicalModel := account.GetMappedModel("public-alias") + require.Equal(t, "upstream-a", canonicalModel) + + for range 2 { + svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusBadGateway, http.Header{}, []byte(`{"error":{"message":"temporary upstream failure"}}`), canonicalModel) + } + + require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "public-alias")) + svc.ReportOpenAIAccountScheduleResult(account.ID, canonicalModel, true, nil) + require.False(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "public-alias")) +} + +func TestHandleOpenAITransientError_DoesNotBlockParameter400(t *testing.T) { + svc := &OpenAIGatewayService{} + svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil) + account := &Account{ + ID: 5103, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + } + + shouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusBadRequest, http.Header{}, []byte(`{"error":{"message":"Invalid type for input[0].arguments"}}`), "gpt-5.5") + + require.False(t, shouldDisable) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.False(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5")) +} + +func TestHandleOpenAITransientError_HardDisableStillBlocksWholeAccount(t *testing.T) { + svc := &OpenAIGatewayService{} + account := &Account{ID: 5106, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + + svc.BlockAccountScheduling(account, time.Now().Add(time.Minute), "upstream_disable") + + require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.5")) + require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.6-sol")) +} diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index a0a4fbff3e..d85cf3e4dd 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -1108,7 +1108,7 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( if !account.IsSchedulable() || account.Platform != normalizeOpenAICompatiblePlatform(req.Platform) || !account.IsOpenAICompatible() { continue } - if s.service.isOpenAIAccountRuntimeBlocked(account) { + if s.service.isOpenAIAccountRequestRuntimeBlocked(account, req.RequestedModel) { continue } // require_privacy_set: 跳过 privacy 未设置的账号并标记异常 @@ -1366,7 +1366,7 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatible(ctx context.C if account == nil { return false } - if s != nil && s.service != nil && s.service.isOpenAIAccountRuntimeBlocked(account) { + if s != nil && s.service != nil && s.service.isOpenAIAccountRequestRuntimeBlocked(account, req.RequestedModel) { return false } // Quota auto-pause must be evaluated during the initial filter too. Without it the @@ -1841,7 +1841,10 @@ func (s *OpenAIGatewayService) isOpenAIAccountTransportCompatible(account *Accou return s.getOpenAIWSProtocolResolver().Resolve(account).Transport == requiredTransport } -func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(accountID int64, success bool, firstTokenMs *int) { +func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(accountID int64, model string, success bool, firstTokenMs *int) { + if success { + s.clearOpenAIAccountModelTransientState(accountID, normalizeOpenAIAccountModelTransientModel(model)) + } scheduler := s.getOpenAIAccountScheduler(context.Background()) if scheduler == nil { return diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 2ff7c25e5d..7252a4fcd2 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -1032,7 +1032,7 @@ func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics_DisabledNoOp(t *test svc := &OpenAIGatewayService{} ttft := 120 - svc.ReportOpenAIAccountScheduleResult(10, true, &ttft) + svc.ReportOpenAIAccountScheduleResult(10, "", true, &ttft) svc.RecordOpenAIAccountSwitch() snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics() @@ -2079,6 +2079,30 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_UsesAccountPriorityWith } } +func TestOpenAIAccountScheduler_SkipsAccountBlockedForRequestedModel(t *testing.T) { + now := time.Now() + account := &Account{ID: 21633, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + svc := &OpenAIGatewayService{openaiModelTransient: newOpenAIAccountModelTransientState(128)} + svc.openaiModelTransient.recordFailure(account.ID, "gpt-5.5", now) + svc.openaiModelTransient.recordFailure(account.ID, "gpt-5.5", now.Add(time.Millisecond)) + scheduler := &defaultOpenAIAccountScheduler{service: svc} + + require.False(t, scheduler.isAccountRequestCompatible(context.Background(), account, OpenAIAccountScheduleRequest{RequestedModel: "gpt-5.5"})) + require.True(t, scheduler.isAccountRequestCompatible(context.Background(), account, OpenAIAccountScheduleRequest{RequestedModel: "gpt-5.6-sol"})) +} + +func TestReportOpenAIAccountScheduleResult_SuccessClearsModelTransientState(t *testing.T) { + svc := &OpenAIGatewayService{openaiModelTransient: newOpenAIAccountModelTransientState(128)} + now := time.Now() + svc.openaiModelTransient.recordFailure(21636, "gpt-5.5", now) + svc.openaiModelTransient.recordFailure(21636, "gpt-5.5", now.Add(time.Millisecond)) + require.True(t, svc.openaiModelTransient.isBlocked(21636, "gpt-5.5", now.Add(2*time.Millisecond))) + + svc.ReportOpenAIAccountScheduleResult(21636, "gpt-5.5", true, nil) + + require.False(t, svc.openaiModelTransient.isBlocked(21636, "gpt-5.5", now.Add(2*time.Millisecond))) +} + func TestDefaultOpenAIAccountScheduler_ShouldEscapeStickyAccount_ThresholdBoundary(t *testing.T) { stats := newOpenAIAccountRuntimeStats() accountID := int64(21501) @@ -2534,7 +2558,7 @@ func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics(t *testing.T) { selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_metrics", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false) require.NoError(t, err) require.NotNil(t, selection) - svc.ReportOpenAIAccountScheduleResult(account.ID, true, intPtrForTest(120)) + svc.ReportOpenAIAccountScheduleResult(account.ID, "", true, intPtrForTest(120)) svc.RecordOpenAIAccountSwitch() snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics() @@ -2871,7 +2895,7 @@ func TestOpenAIGatewayService_SchedulerWrappersAndDefaults(t *testing.T) { svc := &OpenAIGatewayService{} ttft := 120 - svc.ReportOpenAIAccountScheduleResult(10, true, &ttft) + svc.ReportOpenAIAccountScheduleResult(10, "", true, &ttft) svc.RecordOpenAIAccountSwitch() snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics() require.Equal(t, OpenAIAccountSchedulerMetricsSnapshot{}, snapshot) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index bbf80c97c9..d82ee3d9fb 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -576,7 +576,8 @@ func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough( setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body) reqModel, _, _ := extractOpenAIRequestMetaFromBody(requestBody) - _ = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, reqModel) + canonicalModel := canonicalOpenAIAccountSchedulingModel(account, reqModel) + _ = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, canonicalModel) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ Platform: account.Platform, AccountID: account.ID, @@ -637,7 +638,8 @@ func (s *OpenAIGatewayService) handleErrorResponsePassthrough( // 刚被限流的账号。cyber 例外:不冷却账号。 if !cyberHit { reqModel, _, _ := extractOpenAIRequestMetaFromBody(requestBody) - _ = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, reqModel) + canonicalModel := canonicalOpenAIAccountSchedulingModel(account, reqModel) + _ = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, canonicalModel) } appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ Platform: account.Platform, diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index f318adc604..aff87faffe 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -659,7 +659,7 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) return nil } - if s.isOpenAIAccountRuntimeBlocked(account) { + if s.isOpenAIAccountRequestRuntimeBlocked(account, requestedModel) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) return nil } @@ -864,7 +864,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) } else if !openAIStickyAccountMatchesGroup(account, groupID) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) - } else if s.isOpenAIAccountRuntimeBlocked(account) { + } else if s.isOpenAIAccountRequestRuntimeBlocked(account, requestedModel) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) } else if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel, requireCompact) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) @@ -927,7 +927,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex if !parentHealthyForShadow(acc, parentLookupL2) { continue } - if s.isOpenAIAccountRuntimeBlocked(acc) { + if s.isOpenAIAccountRequestRuntimeBlocked(acc, requestedModel) { continue } if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, acc, requestedModel, requireCompact) { @@ -1162,7 +1162,7 @@ func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccount(ctx context. if !parentHealthyForShadow(fresh, s.parentAccountLookup(ctx)) { return nil } - if s.isOpenAIAccountRuntimeBlocked(fresh) { + if s.isOpenAIAccountRequestRuntimeBlocked(fresh, requestedModel) { return nil } return fresh @@ -1207,7 +1207,7 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Co if !parentHealthyForShadow(latest, s.parentAccountLookup(ctx)) { return nil } - if s.isOpenAIAccountRuntimeBlocked(latest) { + if s.isOpenAIAccountRequestRuntimeBlocked(latest, requestedModel) { return nil } return latest diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 956f167d1b..a7fc33b5e1 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -236,22 +236,25 @@ type OpenAIForwardResult struct { ServiceTier *string // ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix. // Stored for usage records display; nil means not provided / not applicable. - ReasoningEffort *string - Stream bool - OpenAIWSMode bool - ResponseHeaders http.Header - Duration time.Duration - FirstTokenMs *int - ClientDisconnect bool - ImageCount int - ImageSize string - ImageInputSize string - ImageOutputSize string - ImageOutputSizes []string - ImageSizeSource string - ImageSizeBreakdown map[string]int - VideoCount int - VideoResolution string + ReasoningEffort *string + Stream bool + OpenAIWSMode bool + // UpstreamTerminalEvent is the normalized terminal event observed on an + // upstream Responses WebSocket turn. Empty preserves legacy/non-WS success. + UpstreamTerminalEvent string + ResponseHeaders http.Header + Duration time.Duration + FirstTokenMs *int + ClientDisconnect bool + ImageCount int + ImageSize string + ImageInputSize string + ImageOutputSize string + ImageOutputSizes []string + ImageSizeSource string + ImageSizeBreakdown map[string]int + VideoCount int + VideoResolution string // VideoDurationSeconds 是提交时请求的生成时长(xAI 按输出秒数计费),已归一化到 1-15 秒。 VideoDurationSeconds int // WebSearchCalls 是 Codex alpha/search 网页搜索调用次数(每次成功请求为 1)。 @@ -262,6 +265,21 @@ type OpenAIForwardResult struct { wsReplayInputExists bool } +// SucceededForScheduling reports whether this result is an upstream success +// that may clear model-scoped transient state. The zero value remains a success +// for existing non-WS callers. +func (r *OpenAIForwardResult) SucceededForScheduling() bool { + if r == nil || !r.OpenAIWSMode || r.UpstreamTerminalEvent == "" { + return true + } + switch r.UpstreamTerminalEvent { + case "response.completed", "response.done": + return true + default: + return false + } +} + // SetActualOpenAIUpstreamEndpoint records the endpoint selected by the current // forwarding attempt. It covers error paths where no OpenAIForwardResult is // available for usage and operations logging. @@ -393,12 +411,14 @@ type OpenAIGatewayService struct { openaiWSStateStoreOnce sync.Once openaiSchedulerOnce sync.Once openaiWSPassthroughDialerOnce sync.Once + openaiModelTransientOnce sync.Once agentIdentityTaskMu sync.Mutex openaiWSPool *openAIWSConnPool openaiWSStateStore OpenAIWSStateStore openaiScheduler OpenAIAccountScheduler openaiWSPassthroughDialer openAIWSClientDialer openaiAccountStats *openAIAccountRuntimeStats + openaiModelTransient *openAIAccountModelTransientState openaiWSFallbackUntil sync.Map // key: int64(accountID), value: time.Time openaiAccountRuntimeBlockUntil sync.Map // key: int64(accountID), value: time.Time @@ -475,6 +495,7 @@ func NewOpenAIGatewayService( userPlatformQuotaRepo: userPlatformQuotaRepo, responseHeaderFilter: compileResponseHeaderFilter(cfg), codexSnapshotThrottle: newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval), + openaiModelTransient: newOpenAIAccountModelTransientState(openAIModelTransientDefaultMax), } if rateLimitService != nil { rateLimitService.SetAccountRuntimeBlocker(svc) diff --git a/backend/internal/service/openai_gateway_upstream_errors.go b/backend/internal/service/openai_gateway_upstream_errors.go index d8823fe4ed..239a34b595 100644 --- a/backend/internal/service/openai_gateway_upstream_errors.go +++ b/backend/internal/service/openai_gateway_upstream_errors.go @@ -262,9 +262,9 @@ func (s *OpenAIGatewayService) readUpstreamErrorBody(resp *http.Response) []byte return body } -func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, responseBody []byte, requestedModel ...string) { - if len(requestedModel) > 0 { - s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody, requestedModel[0]) +func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, responseBody []byte, canonicalModel ...string) { + if len(canonicalModel) > 0 { + s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody, canonicalModel[0]) return } s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody) @@ -385,6 +385,7 @@ func (s *OpenAIGatewayService) handleErrorResponse( } if reqModel == "" { reqModel, _, _ = extractOpenAIRequestMetaFromBody(requestBody) + reqModel = canonicalOpenAIAccountSchedulingModel(account, reqModel) } shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, reqModel) kind := "http_error" diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 51aace6419..8f12fe5ab2 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -674,6 +674,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return acquireTurnLease(turn, preferred, forcePreferredConn) } if acquireErr != nil { + canonicalModel := canonicalOpenAIAccountSchedulingModel(account, ingressSessionOriginalModel) + s.handleOpenAIWSDialTransientFailure(ctx, account, canonicalModel, acquireErr) dialStatus, dialClass, dialCloseStatus, dialCloseReason, dialRespServer, dialRespVia, dialRespCFRay, dialRespReqID := summarizeOpenAIWSDialError(acquireErr) logOpenAIWSModeInfo( "ingress_ws_upstream_acquire_fail account_id=%d turn=%d reason=%s dial_status=%d dial_class=%s dial_close_status=%s dial_close_reason=%s dial_resp_server=%s dial_resp_via=%s dial_resp_cf_ray=%s dial_resp_x_request_id=%s cause=%s preferred_conn_id=%s force_preferred_conn=%v ws_host=%s ws_path=%s proxy_enabled=%v", @@ -818,6 +820,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( lastEventType = eventType } if eventType == "error" { + canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel) + s.handleOpenAIWSErrorEventTransientFailure(ctx, account, canonicalModel, lease.HandshakeHeaders(), upstreamMessage) errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(upstreamMessage) s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), upstreamMessage, errCodeRaw, errTypeRaw, errMsgRaw) fallbackReason, _ := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) @@ -950,6 +954,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } } if isTerminalEvent { + canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel) + terminalEvent := s.handleOpenAIWSTerminalTransientFailure(ctx, account, canonicalModel, lease.HandshakeHeaders(), upstreamMessage) // 客户端已断连时,上游连接的 session 状态不可信,标记 broken 避免回池复用。 if clientDisconnected { lease.MarkBroken() @@ -977,17 +983,18 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } imageCount := imageCounter.Count() result := &OpenAIForwardResult{ - RequestID: responseID, - Usage: usage, - Model: originalModel, - UpstreamModel: mappedModel, - ServiceTier: extractOpenAIServiceTierFromBody(payload), - ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel), - Stream: reqStream, - OpenAIWSMode: true, - ResponseHeaders: lease.HandshakeHeaders(), - Duration: time.Since(turnStart), - FirstTokenMs: firstTokenMs, + RequestID: responseID, + Usage: usage, + Model: originalModel, + UpstreamModel: mappedModel, + ServiceTier: extractOpenAIServiceTierFromBody(payload), + ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel), + Stream: reqStream, + OpenAIWSMode: true, + UpstreamTerminalEvent: terminalEvent, + ResponseHeaders: lease.HandshakeHeaders(), + Duration: time.Since(turnStart), + FirstTokenMs: firstTokenMs, } if replayInput := replayCollector.Items(); len(replayInput) > 0 { result.wsReplayInput = replayInput diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 3752c1bd0e..cc47f3cafd 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -72,11 +72,11 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_KeepLeaseAcrossT } serverErrCh := make(chan error, 1) - turnWSModeCh := make(chan bool, 2) + turnTerminalCh := make(chan string, 2) hooks := &OpenAIWSIngressHooks{ AfterTurn: func(_ int, result *OpenAIForwardResult, turnErr error) { if turnErr == nil && result != nil { - turnWSModeCh <- result.OpenAIWSMode + turnTerminalCh <- result.UpstreamTerminalEvent } }, } @@ -146,8 +146,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_KeepLeaseAcrossT secondTurnEvent := readMessage() require.Equal(t, "response.completed", gjson.GetBytes(secondTurnEvent, "type").String()) require.Equal(t, "resp_ingress_turn_2", gjson.GetBytes(secondTurnEvent, "response.id").String()) - require.True(t, <-turnWSModeCh, "首轮 turn 应标记为 WS 模式") - require.True(t, <-turnWSModeCh, "第二轮 turn 应标记为 WS 模式") + require.Equal(t, "response.completed", <-turnTerminalCh, "首轮 turn 应保留成功终态") + require.Equal(t, "response.completed", <-turnTerminalCh, "第二轮 turn 应保留成功终态") _ = clientConn.Close(coderws.StatusNormalClosure, "done") diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index bb4ac2242c..37c68a4126 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -464,10 +464,61 @@ func TestOpenAIGatewayService_Forward_WSv2_RewriteModelAndToolCallsOnCompletedEv require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "resp_model_tool_1", result.RequestID) + require.Equal(t, "response.completed", result.UpstreamTerminalEvent) + require.True(t, result.SucceededForScheduling()) require.Equal(t, "custom-original-model", gjson.GetBytes(rec.Body.Bytes(), "model").String(), "响应模型应回写为原始请求模型") require.Equal(t, "edit", gjson.GetBytes(rec.Body.Bytes(), "tool_calls.0.function.name").String(), "工具名称应被修正为 OpenCode 规范") } +func TestOpenAIGatewayService_Forward_WSv2_ResponseFailedIsNotSchedulingSuccess(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0") + + cfg := newOpenAIWSV2TestConfig() + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + + captureConn := &openAIWSCaptureConn{events: [][]byte{ + []byte(`{"type":"response.failed","response":{"id":"resp_failed_1","model":"gpt-5.5","error":{"code":"server_error","message":"Internal error"}}}`), + }} + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn}) + + svc := &OpenAIGatewayService{ + cfg: cfg, + rateLimitService: NewRateLimitService(transientCooldownAccountRepo{}, nil, cfg, nil, nil), + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + account := &Account{ + ID: 1302, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"responses_websockets_v2_enabled": true}, + } + svc.recordOpenAIAccountModelTransientFailure(account, "gpt-5.5", time.Now()) + + result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.5","stream":false,"input":"hello"}`)) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "response.failed", result.UpstreamTerminalEvent) + require.False(t, result.SucceededForScheduling()) + require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5")) +} + func TestOpenAIWSPayloadString_OnlyAcceptsStringValues(t *testing.T) { payload := map[string]any{ "type": nil, diff --git a/backend/internal/service/openai_ws_forwarder_support.go b/backend/internal/service/openai_ws_forwarder_support.go index 31c63e214b..91b9ef5cb1 100644 --- a/backend/internal/service/openai_ws_forwarder_support.go +++ b/backend/internal/service/openai_ws_forwarder_support.go @@ -204,6 +204,98 @@ func isOpenAIWSTerminalEvent(eventType string) bool { } } +func normalizeOpenAIWSTerminalEvent(eventType string) string { + switch strings.TrimSpace(eventType) { + case "response.completed": + return "response.completed" + case "response.done": + return "response.done" + case "response.failed": + return "response.failed" + case "response.incomplete": + return "response.incomplete" + case "response.cancelled", "response.canceled": + return "response.cancelled" + default: + return "" + } +} + +func openAIWSPayloadTransientStatus(payload []byte) int { + if len(payload) == 0 { + return 0 + } + status := int(gjson.GetBytes(payload, "response.error.status_code").Int()) + if status == 0 { + status = int(gjson.GetBytes(payload, "response.error.status").Int()) + } + if status == 0 { + status = int(gjson.GetBytes(payload, "error.status_code").Int()) + } + if status == 0 { + status = int(gjson.GetBytes(payload, "error.status").Int()) + } + if shouldCooldownOpenAITransientUpstreamError(status, payload) { + return status + } + if status != 0 { + return 0 + } + code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String())) + errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.type").String())) + if code == "" { + code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String())) + } + if errType == "" { + errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String())) + } + switch { + case code == "server_is_overloaded", code == "slow_down": + return http.StatusServiceUnavailable + case strings.Contains(code, "server_error"), + strings.Contains(code, "internal_error"), + strings.Contains(code, "upstream_error"), + strings.Contains(errType, "server_error"), + strings.Contains(errType, "internal_error"), + strings.Contains(errType, "upstream_error"): + return http.StatusInternalServerError + default: + return 0 + } +} + +func (s *OpenAIGatewayService) handleOpenAIWSTerminalTransientFailure(ctx context.Context, account *Account, canonicalModel string, headers http.Header, payload []byte) string { + eventType, _, _ := parseOpenAIWSEventEnvelope(payload) + terminalEvent := normalizeOpenAIWSTerminalEvent(eventType) + if terminalEvent != "response.failed" { + return terminalEvent + } + status := openAIWSPayloadTransientStatus(payload) + if status != 0 { + s.handleOpenAIAccountUpstreamError(ctx, account, status, headers, payload, canonicalModel) + } + return terminalEvent +} + +func (s *OpenAIGatewayService) handleOpenAIWSErrorEventTransientFailure(ctx context.Context, account *Account, canonicalModel string, headers http.Header, payload []byte) { + eventType, _, _ := parseOpenAIWSEventEnvelope(payload) + if eventType != "error" { + return + } + status := openAIWSPayloadTransientStatus(payload) + if status != 0 { + s.handleOpenAIAccountUpstreamError(ctx, account, status, headers, payload, canonicalModel) + } +} + +func (s *OpenAIGatewayService) handleOpenAIWSDialTransientFailure(ctx context.Context, account *Account, canonicalModel string, err error) { + var dialErr *openAIWSDialError + if !errors.As(err, &dialErr) || dialErr == nil || !shouldCooldownOpenAITransientUpstreamError(dialErr.StatusCode, dialErr.ResponseBody) { + return + } + s.handleOpenAIAccountUpstreamError(ctx, account, dialErr.StatusCode, dialErr.ResponseHeaders, dialErr.ResponseBody, canonicalModel) +} + func isOpenAIWSTokenEvent(eventType string) bool { eventType = strings.TrimSpace(eventType) if eventType == "" { @@ -440,7 +532,7 @@ func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability( if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, latest); paused { return 0, nil, "", nil } - if s.isOpenAIAccountRuntimeBlocked(latest) { + if s.isOpenAIAccountRequestRuntimeBlocked(latest, requestedModel) { _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) return 0, nil, "", nil } diff --git a/backend/internal/service/openai_ws_forwarder_test.go b/backend/internal/service/openai_ws_forwarder_test.go index b9944e92fa..81c011f7a6 100644 --- a/backend/internal/service/openai_ws_forwarder_test.go +++ b/backend/internal/service/openai_ws_forwarder_test.go @@ -1,9 +1,13 @@ package service import ( + "context" + "net/http" "net/http/httptest" "testing" + "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -107,6 +111,81 @@ func TestOpenAIWSCyberPolicyMark_NonCyberPayload(t *testing.T) { require.False(t, hit, "detectOpenAICyberPolicy should return false for non-cyber_policy error code") } +func TestOpenAIForwardResultSucceededForScheduling_TerminalEvents(t *testing.T) { + tests := []struct { + name string + result *OpenAIForwardResult + expected bool + }{ + {name: "nil legacy result", result: nil, expected: true}, + {name: "non websocket zero value", result: &OpenAIForwardResult{}, expected: true}, + {name: "websocket legacy empty terminal", result: &OpenAIForwardResult{OpenAIWSMode: true}, expected: true}, + {name: "completed", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.completed"}, expected: true}, + {name: "done", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.done"}, expected: true}, + {name: "failed", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.failed"}, expected: false}, + {name: "incomplete", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.incomplete"}, expected: false}, + {name: "cancelled", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.cancelled"}, expected: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.result.SucceededForScheduling()) + }) + } +} + +func TestOpenAIWSTerminalEvent_ResponseFailedRecordsModelTransient(t *testing.T) { + svc := &OpenAIGatewayService{} + svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil) + account := &Account{ID: 5201, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + payload := []byte(`{"type":"response.failed","response":{"error":{"code":"server_error","message":"Internal error"}}}`) + + for range 2 { + terminalEvent := svc.handleOpenAIWSTerminalTransientFailure(context.Background(), account, "gpt-5.5", http.Header{}, payload) + require.Equal(t, "response.failed", terminalEvent) + } + + require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5")) +} + +func TestOpenAIWSErrorEvent_ServerErrorRecordsModelTransient(t *testing.T) { + svc := &OpenAIGatewayService{} + svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil) + account := &Account{ID: 5203, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + payload := []byte(`{"type":"error","error":{"code":"server_error","type":"server_error","message":"Internal error"}}`) + + for range 2 { + svc.handleOpenAIWSErrorEventTransientFailure(context.Background(), account, "gpt-5.5", http.Header{}, payload) + } + + require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5")) +} + +func TestOpenAIWSPayloadTransientStatus_Explicit529IsNotModelTransient(t *testing.T) { + payload := []byte(`{"type":"response.failed","response":{"error":{"status_code":529,"code":"server_error","message":"overloaded"}}}`) + + require.Zero(t, openAIWSPayloadTransientStatus(payload)) +} + +func TestOpenAIWSDial5xxRecordsModelTransient(t *testing.T) { + svc := &OpenAIGatewayService{} + svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil) + account := &Account{ID: 5202, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + dialErr := &openAIWSDialError{ + StatusCode: http.StatusBadGateway, + ResponseHeaders: http.Header{"X-Request-Id": []string{"req-ws-502"}}, + ResponseBody: []byte(`{"error":{"message":"bad gateway"}}`), + } + + for range 2 { + svc.handleOpenAIWSDialTransientFailure(context.Background(), account, "gpt-5.5", dialErr) + } + + require.Eventually(t, func() bool { + return svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5") + }, time.Second, 10*time.Millisecond) +} + // TestIsOpenAIWSTokenEvent_DisjointWithTerminal 守护「token 事件集合与终止事件集合互斥」的不变量。 // firstTokenMs 的计算依赖于 isTokenEvent && !isTerminalEvent; // 若两者再次出现交集,则 issue #2651 描述的 latency 误报会重现。 diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 9054e60502..77234757b0 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -199,6 +199,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } return nil, &agentIdentityTaskRecoveredError{} } + s.handleOpenAIWSDialTransientFailure(ctx, account, mappedModel, err) dialStatus, dialClass, dialCloseStatus, dialCloseReason, dialRespServer, dialRespVia, dialRespCFRay, dialRespReqID := summarizeOpenAIWSDialError(err) logOpenAIWSModeInfo( "acquire_fail account_id=%d account_type=%s transport=%s reason=%s dial_status=%d dial_class=%s dial_close_status=%s dial_close_reason=%s dial_resp_server=%s dial_resp_via=%s dial_resp_cf_ray=%s dial_resp_x_request_id=%s cause=%s preferred_conn_id=%s force_new_conn=%v ws_host=%s ws_path=%s proxy_enabled=%v", @@ -351,6 +352,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( flushedBufferedEventCount := 0 firstEventType := "" lastEventType := "" + upstreamTerminalEvent := "" var flusher http.Flusher if reqStream { @@ -552,6 +554,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } if eventType == "error" { + s.handleOpenAIWSErrorEventTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), message) errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(message) s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), message, errCodeRaw, errTypeRaw, errMsgRaw) errMsg := strings.TrimSpace(errMsgRaw) @@ -650,6 +653,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } if isTerminalEvent { + upstreamTerminalEvent = s.handleOpenAIWSTerminalTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), message) // A terminal event must be the final JSON document in its WS message. // Ignore any tail for the completed client turn, but never reuse the // ambiguous upstream connection for another request. @@ -721,19 +725,20 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( ) return &OpenAIForwardResult{ - RequestID: responseID, - Usage: *usage, - Model: originalModel, - UpstreamModel: mappedModel, - ImageCount: imageCounter.Count(), - ImageOutputSizes: imageCounter.Sizes(), - ServiceTier: extractOpenAIServiceTier(reqBody), - ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel), - Stream: reqStream, - OpenAIWSMode: true, - ResponseHeaders: lease.HandshakeHeaders(), - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: responseID, + Usage: *usage, + Model: originalModel, + UpstreamModel: mappedModel, + ImageCount: imageCounter.Count(), + ImageOutputSizes: imageCounter.Sizes(), + ServiceTier: extractOpenAIServiceTier(reqBody), + ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel), + Stream: reqStream, + OpenAIWSMode: true, + UpstreamTerminalEvent: upstreamTerminalEvent, + ResponseHeaders: lease.HandshakeHeaders(), + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, }, nil } diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 091b3592d1..9140f61443 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -235,6 +235,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( respBody, _ := io.ReadAll(io.LimitReader(resp.Body, openAIWSHTTPBridgeErrorBodyLimitBytes)) if account.Platform == PlatformGrok { s.handleGrokAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + } else if shouldCooldownOpenAITransientUpstreamError(resp.StatusCode, respBody) { + canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel) + s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, canonicalModel) } upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) if upstreamMsg == "" { @@ -258,6 +261,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( replayCollector := &openAIWSToolCallReplayCollector{} firstEventType := "" lastEventType := "" + upstreamTerminalEvent := "" sawDone := false wroteDownstream := false clientDisconnected := false @@ -275,17 +279,18 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( resultWithUsage := func() *OpenAIForwardResult { imageCount := imageCounter.Count() result := &OpenAIForwardResult{ - RequestID: responseID, - Usage: usage, - Model: originalModel, - UpstreamModel: mappedModel, - ServiceTier: extractOpenAIServiceTierFromBody(body), - ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel), - Stream: reqStream, - OpenAIWSMode: true, - ResponseHeaders: cloneHeader(resp.Header), - Duration: time.Since(turnStart), - FirstTokenMs: firstTokenMs, + RequestID: responseID, + Usage: usage, + Model: originalModel, + UpstreamModel: mappedModel, + ServiceTier: extractOpenAIServiceTierFromBody(body), + ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel), + Stream: reqStream, + OpenAIWSMode: true, + UpstreamTerminalEvent: upstreamTerminalEvent, + ResponseHeaders: cloneHeader(resp.Header), + Duration: time.Since(turnStart), + FirstTokenMs: firstTokenMs, } if replayInput := replayCollector.Items(); len(replayInput) > 0 { result.wsReplayInput = replayInput @@ -384,6 +389,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } if eventType == "error" { + s.handleOpenAIWSErrorEventTransientFailure(ctx, account, canonicalOpenAIAccountSchedulingModel(account, originalModel), resp.Header, upstreamMessage) errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(upstreamMessage) s.persistOpenAIWSRateLimitSignal(ctx, account, resp.Header, upstreamMessage, errCodeRaw, errTypeRaw, errMsgRaw) errMessage := strings.TrimSpace(errMsgRaw) @@ -393,6 +399,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( return resultWithUsage(), errors.New(errMessage) } if isOpenAIWSTerminalEvent(eventType) { + upstreamTerminalEvent = s.handleOpenAIWSTerminalTransientFailure(ctx, account, canonicalOpenAIAccountSchedulingModel(account, originalModel), resp.Header, upstreamMessage) terminalEventCount++ firstTokenMsValue := -1 if firstTokenMs != nil { diff --git a/backend/internal/service/openai_ws_ratelimit_signal_test.go b/backend/internal/service/openai_ws_ratelimit_signal_test.go index a3673d7458..9d1fdaf765 100644 --- a/backend/internal/service/openai_ws_ratelimit_signal_test.go +++ b/backend/internal/service/openai_ws_ratelimit_signal_test.go @@ -239,6 +239,52 @@ func TestOpenAIGatewayService_Forward_WSv2Handshake429PersistsRateLimit(t *testi require.Contains(t, repo.updateExtra[0], "codex_usage_updated_at") } +func TestOpenAIGatewayService_Forward_WSv2Handshake502RecordsModelTransient(t *testing.T) { + gin.SetMode(gin.TestMode) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("x-request-id", "req-ws-502") + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`{"error":{"type":"server_error","message":"bad gateway"}}`)) + })) + defer server.Close() + + cfg := newOpenAIWSV2TestConfig() + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + account := Account{ + ID: 504, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test", "base_url": server.URL}, + Extra: map[string]any{"responses_websockets_v2_enabled": true}, + } + svc := &OpenAIGatewayService{ + cfg: cfg, + rateLimitService: NewRateLimitService(transientCooldownAccountRepo{}, nil, cfg, nil, nil), + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + } + body := []byte(`{"model":"gpt-5.5","stream":false,"input":"hello"}`) + + for range 2 { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + c.Request.Header.Set("User-Agent", "unit-test-agent/1.0") + result, err := svc.Forward(context.Background(), c, &account, body) + require.Error(t, err) + require.Nil(t, result) + } + + require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(&account, "gpt-5.5")) +} + func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ErrorEventUsageLimitPersistsRateLimit(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index d2cf26a8ce..4505ffb665 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -386,7 +386,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if errors.As(err, &handshakeErr) && handshakeErr != nil { responseBody = handshakeErr.Body } - dialErr := &openAIWSDialError{StatusCode: statusCode, ResponseBody: responseBody, Err: err} + dialErr := &openAIWSDialError{StatusCode: statusCode, ResponseHeaders: cloneHeader(handshakeHeaders), ResponseBody: responseBody, Err: err} if s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidWSDialError(dialErr) && !agentTaskRecoveryTried { agentTaskRecoveryTried = true if recoveryErr := s.recoverAgentIdentityTask(ctx, account, account.GetCredential("task_id")); recoveryErr != nil { @@ -400,6 +400,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( statusCode, truncateOpenAIWSLogValue(err.Error(), openAIWSLogValueMaxLen), ) + s.handleOpenAIWSDialTransientFailure(ctx, account, capturedSessionModel, dialErr) if statusCode == http.StatusTooManyRequests { s.persistOpenAIWSRateLimitSignal(ctx, account, handshakeHeaders, nil, "rate_limit_exceeded", "rate_limit_error", strings.TrimSpace(err.Error())) return &UpstreamFailoverError{ @@ -571,14 +572,15 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( CacheReadInputTokens: turn.Usage.CacheReadInputTokens, ImageOutputTokens: turn.Usage.ImageOutputTokens, }, - Model: turn.RequestModel, - ServiceTier: usageMeta.serviceTier.Load(), - ReasoningEffort: usageMeta.reasoningEffort.Load(), - Stream: true, - OpenAIWSMode: true, - ResponseHeaders: cloneHeader(handshakeHeaders), - Duration: turn.Duration, - FirstTokenMs: turn.FirstTokenMs, + Model: turn.RequestModel, + ServiceTier: usageMeta.serviceTier.Load(), + ReasoningEffort: usageMeta.reasoningEffort.Load(), + Stream: true, + OpenAIWSMode: true, + UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(turn.TerminalEventType), + ResponseHeaders: cloneHeader(handshakeHeaders), + Duration: turn.Duration, + FirstTokenMs: turn.FirstTokenMs, } logOpenAIWSV2Passthrough( "relay_turn_completed account_id=%d turn=%d request_id=%s terminal_event=%s duration_ms=%d first_token_ms=%d input_tokens=%d output_tokens=%d cache_read_tokens=%d", @@ -597,10 +599,17 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } }, BeforeWriteClient: func(msgType coderws.MessageType, payload []byte, wroteDownstream bool) error { - if msgType != coderws.MessageText || wroteDownstream { + if msgType != coderws.MessageText { return nil } - if eventType, _, _ := parseOpenAIWSEventEnvelope(payload); eventType != "error" { + eventType, _, _ := parseOpenAIWSEventEnvelope(payload) + if isOpenAIWSTerminalEvent(eventType) { + s.handleOpenAIWSTerminalTransientFailure(ctx, account, capturedSessionModel, handshakeHeaders, payload) + } + if eventType == "error" { + s.handleOpenAIWSErrorEventTransientFailure(ctx, account, capturedSessionModel, handshakeHeaders, payload) + } + if wroteDownstream || eventType != "error" { return nil } errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(payload) @@ -646,14 +655,15 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( CacheReadInputTokens: relayResult.Usage.CacheReadInputTokens, ImageOutputTokens: relayResult.Usage.ImageOutputTokens, }, - Model: relayResult.RequestModel, - ServiceTier: usageMeta.serviceTier.Load(), - ReasoningEffort: usageMeta.reasoningEffort.Load(), - Stream: true, - OpenAIWSMode: true, - ResponseHeaders: cloneHeader(handshakeHeaders), - Duration: relayResult.Duration, - FirstTokenMs: relayResult.FirstTokenMs, + Model: relayResult.RequestModel, + ServiceTier: usageMeta.serviceTier.Load(), + ReasoningEffort: usageMeta.reasoningEffort.Load(), + Stream: true, + OpenAIWSMode: true, + UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(relayResult.TerminalEventType), + ResponseHeaders: cloneHeader(handshakeHeaders), + Duration: relayResult.Duration, + FirstTokenMs: relayResult.FirstTokenMs, } turnCount := int(completedTurns.Load()) From a8a8a58f65ff76d004e0ee27aadfc35cfb92cd6d Mon Sep 17 00:00:00 2001 From: jjaw Date: Thu, 16 Jul 2026 00:11:17 +0800 Subject: [PATCH 11/26] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E4=BA=8B=E4=BB=B6=E7=9A=84=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E9=87=8D=E5=BB=BA=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler_snapshot_bulk_event_test.go | 211 ++++++++++++++++++ .../service/scheduler_snapshot_service.go | 74 +++++- 2 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 backend/internal/service/scheduler_snapshot_bulk_event_test.go diff --git a/backend/internal/service/scheduler_snapshot_bulk_event_test.go b/backend/internal/service/scheduler_snapshot_bulk_event_test.go new file mode 100644 index 0000000000..00454a498d --- /dev/null +++ b/backend/internal/service/scheduler_snapshot_bulk_event_test.go @@ -0,0 +1,211 @@ +//go:build unit + +package service + +import ( + "context" + "sync" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type bulkEventAccountRepo struct { + *batchAccountQueryRepo + accounts []*Account +} + +func newBulkEventAccountRepo(accounts ...*Account) *bulkEventAccountRepo { + return &bulkEventAccountRepo{ + batchAccountQueryRepo: newBatchAccountQueryRepo(), + accounts: accounts, + } +} + +func (r *bulkEventAccountRepo) GetByIDs(context.Context, []int64) ([]*Account, error) { + return append([]*Account(nil), r.accounts...), nil +} + +type bulkEventSnapshotCache struct { + *batchSnapshotCache + + accountMu sync.Mutex + setAccountIDs []int64 + deleteAccountIDs []int64 +} + +func newBulkEventSnapshotCache() *bulkEventSnapshotCache { + return &bulkEventSnapshotCache{batchSnapshotCache: newBatchSnapshotCache()} +} + +func (c *bulkEventSnapshotCache) SetAccount(_ context.Context, account *Account) error { + c.accountMu.Lock() + defer c.accountMu.Unlock() + c.setAccountIDs = append(c.setAccountIDs, account.ID) + return nil +} + +func (c *bulkEventSnapshotCache) DeleteAccount(_ context.Context, accountID int64) error { + c.accountMu.Lock() + defer c.accountMu.Unlock() + c.deleteAccountIDs = append(c.deleteAccountIDs, accountID) + return nil +} + +func (c *bulkEventSnapshotCache) accountWrites() (set []int64, deleted []int64) { + c.accountMu.Lock() + defer c.accountMu.Unlock() + return append([]int64(nil), c.setAccountIDs...), append([]int64(nil), c.deleteAccountIDs...) +} + +func (c *bulkEventSnapshotCache) capturedBuckets() []SchedulerBucket { + c.mu.Lock() + defer c.mu.Unlock() + return append([]SchedulerBucket(nil), c.captures...) +} + +func newBulkEventTestService(cache SchedulerCache, accounts AccountRepository) *SchedulerSnapshotService { + return NewSchedulerSnapshotService(cache, nil, accounts, nil, &config.Config{RunMode: config.RunModeStandard}) +} + +func bulkEventPayload(accountIDs []int64, groupIDs []int64) map[string]any { + accountValues := make([]any, 0, len(accountIDs)) + for _, id := range accountIDs { + accountValues = append(accountValues, id) + } + groupValues := make([]any, 0, len(groupIDs)) + for _, id := range groupIDs { + groupValues = append(groupValues, id) + } + return map[string]any{ + "account_ids": accountValues, + "group_ids": groupValues, + } +} + +func schedulerBucketsForTest(groupIDs []int64, platforms ...string) []SchedulerBucket { + buckets := make([]SchedulerBucket, 0, len(groupIDs)*len(platforms)*3) + for _, platform := range platforms { + for _, groupID := range groupIDs { + buckets = append(buckets, + SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeSingle}, + SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeForced}, + ) + if platform == PlatformAnthropic || platform == PlatformGemini { + buckets = append(buckets, SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeMixed}) + } + } + } + return buckets +} + +func TestSchedulerBulkAccountEventScopesOpenAIRebuildToFreshPlatform(t *testing.T) { + cache := newBulkEventSnapshotCache() + repo := newBulkEventAccountRepo(&Account{ID: 1, Platform: PlatformOpenAI, GroupIDs: []int64{12}}) + svc := newBulkEventTestService(cache, repo) + + err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{1}, []int64{11}), make(map[batchSeenKey]struct{})) + + require.NoError(t, err) + require.ElementsMatch(t, schedulerBucketsForTest([]int64{11, 12}, PlatformOpenAI), cache.capturedBuckets()) + set, deleted := cache.accountWrites() + require.Equal(t, []int64{1}, set) + require.Empty(t, deleted) +} + +func TestSchedulerBulkAccountEventRebuildsOpenAIUngroupedBucket(t *testing.T) { + cache := newBulkEventSnapshotCache() + repo := newBulkEventAccountRepo(&Account{ID: 6, Platform: PlatformOpenAI}) + svc := newBulkEventTestService(cache, repo) + + err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{6}, nil), make(map[batchSeenKey]struct{})) + + require.NoError(t, err) + require.ElementsMatch(t, schedulerBucketsForTest([]int64{0}, PlatformOpenAI), cache.capturedBuckets()) +} + +func TestSchedulerBulkAccountEventKeepsGroupedAndUngroupedBuckets(t *testing.T) { + cache := newBulkEventSnapshotCache() + repo := newBulkEventAccountRepo( + &Account{ID: 7, Platform: PlatformOpenAI, GroupIDs: []int64{51}}, + &Account{ID: 8, Platform: PlatformOpenAI}, + ) + svc := newBulkEventTestService(cache, repo) + + err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{7, 8}, nil), make(map[batchSeenKey]struct{})) + + require.NoError(t, err) + require.ElementsMatch(t, schedulerBucketsForTest([]int64{0, 51}, PlatformOpenAI), cache.capturedBuckets()) +} + +func TestSchedulerBulkAccountEventDoesNotCrossCurrentGroupsBetweenPlatforms(t *testing.T) { + cache := newBulkEventSnapshotCache() + repo := newBulkEventAccountRepo( + &Account{ID: 9, Platform: PlatformOpenAI, GroupIDs: []int64{61}}, + &Account{ID: 10, Platform: PlatformGrok, GroupIDs: []int64{62}}, + ) + svc := newBulkEventTestService(cache, repo) + + err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{9, 10}, []int64{63}), make(map[batchSeenKey]struct{})) + + require.NoError(t, err) + want := append( + schedulerBucketsForTest([]int64{61, 63}, PlatformOpenAI), + schedulerBucketsForTest([]int64{62, 63}, PlatformGrok)..., + ) + require.ElementsMatch(t, want, cache.capturedBuckets()) +} + +func TestSchedulerBulkAccountEventUsesGroupZeroInSimpleMode(t *testing.T) { + cache := newBulkEventSnapshotCache() + repo := newBulkEventAccountRepo(&Account{ID: 11, Platform: PlatformOpenAI, GroupIDs: []int64{71}}) + svc := NewSchedulerSnapshotService(cache, nil, repo, nil, &config.Config{RunMode: config.RunModeSimple}) + + err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{11}, []int64{72}), make(map[batchSeenKey]struct{})) + + require.NoError(t, err) + require.ElementsMatch(t, schedulerBucketsForTest([]int64{0}, PlatformOpenAI), cache.capturedBuckets()) +} + +func TestSchedulerBulkAccountEventConservativelyExpandsAntigravityPlatforms(t *testing.T) { + cache := newBulkEventSnapshotCache() + // fresh 值可能已经关闭 mixed_scheduling,兼容平台仍要重建以清理旧快照。 + repo := newBulkEventAccountRepo(&Account{ID: 2, Platform: PlatformAntigravity, GroupIDs: []int64{22}}) + svc := newBulkEventTestService(cache, repo) + + err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{2}, []int64{21}), make(map[batchSeenKey]struct{})) + + require.NoError(t, err) + require.ElementsMatch(t, + schedulerBucketsForTest([]int64{21, 22}, PlatformAnthropic, PlatformGemini, PlatformAntigravity), + cache.capturedBuckets(), + ) +} + +func TestSchedulerBulkAccountEventMissingAccountFallsBackToAllPlatforms(t *testing.T) { + cache := newBulkEventSnapshotCache() + repo := newBulkEventAccountRepo(&Account{ID: 3, Platform: PlatformOpenAI, GroupIDs: []int64{32}}) + svc := newBulkEventTestService(cache, repo) + + err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{3, 4}, []int64{31}), make(map[batchSeenKey]struct{})) + + require.NoError(t, err) + platforms := schedulerSnapshotPlatforms() + require.ElementsMatch(t, schedulerBucketsForTest([]int64{31, 32}, platforms[:]...), cache.capturedBuckets()) + set, deleted := cache.accountWrites() + require.Equal(t, []int64{3}, set) + require.Equal(t, []int64{4}, deleted) +} + +func TestSchedulerBulkAccountEventUnknownPlatformFallsBackToAllPlatforms(t *testing.T) { + cache := newBulkEventSnapshotCache() + repo := newBulkEventAccountRepo(&Account{ID: 5, GroupIDs: []int64{42}}) + svc := newBulkEventTestService(cache, repo) + + err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{5}, []int64{41}), make(map[batchSeenKey]struct{})) + + require.NoError(t, err) + platforms := schedulerSnapshotPlatforms() + require.ElementsMatch(t, schedulerBucketsForTest([]int64{41, 42}, platforms[:]...), cache.capturedBuckets()) +} diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index a5bccdb265..5b186aaf6d 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -529,11 +529,13 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p } } - if s.cache != nil { - for _, id := range ids { - if _, ok := found[id]; ok { - continue - } + allAccountsFound := true + for _, id := range ids { + if _, ok := found[id]; ok { + continue + } + allAccountsFound = false + if s.cache != nil { if err := s.cache.DeleteAccount(ctx, id); err != nil { return err } @@ -544,7 +546,67 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p for gid := range rebuildGroupSet { rebuildGroupIDs = append(rebuildGroupIDs, gid) } - return s.rebuildByGroupIDs(ctx, rebuildGroupIDs, "account_bulk_change", seen) + + // 缺失账户无法确定原平台,保留五平台重建以避免遗留旧快照。 + if !allAccountsFound { + return s.rebuildByGroupIDs(ctx, rebuildGroupIDs, "account_bulk_change", seen) + } + + platformGroupSets := make(map[string]map[int64]struct{}, len(accounts)) + addPlatformGroups := func(platform string, groupIDs []int64) { + groupSet := platformGroupSets[platform] + if groupSet == nil { + groupSet = make(map[int64]struct{}, len(groupIDs)) + platformGroupSets[platform] = groupSet + } + for _, groupID := range groupIDs { + groupSet[groupID] = struct{}{} + } + } + for _, account := range accounts { + if account == nil || account.ID <= 0 { + continue + } + accountGroupIDs := s.normalizeGroupIDs(account.GroupIDs) + switch account.Platform { + case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok: + addPlatformGroups(account.Platform, accountGroupIDs) + case PlatformAntigravity: + // 批量更新可能刚关闭 mixed_scheduling,仍需清理两个兼容平台的旧快照。 + addPlatformGroups(PlatformAntigravity, accountGroupIDs) + addPlatformGroups(PlatformAnthropic, accountGroupIDs) + addPlatformGroups(PlatformGemini, accountGroupIDs) + default: + return s.rebuildByGroupIDs(ctx, rebuildGroupIDs, "account_bulk_change", seen) + } + } + + // payload 携带更新前的组;只扩散到本事件实际涉及的平台,避免平台间交叉重建。 + if len(preloadGroupIDs) > 0 { + preloadGroupIDs = s.normalizeGroupIDs(preloadGroupIDs) + for platform := range platformGroupSets { + addPlatformGroups(platform, preloadGroupIDs) + } + } + + bucketCapacity := 0 + for _, groupSet := range platformGroupSets { + bucketCapacity += len(groupSet) * 3 + } + buckets := make([]SchedulerBucket, 0, bucketCapacity) + for _, platform := range schedulerSnapshotPlatforms() { + groupSet, ok := platformGroupSets[platform] + if !ok { + continue + } + platformGroupIDs := make([]int64, 0, len(groupSet)) + for groupID := range groupSet { + platformGroupIDs = append(platformGroupIDs, groupID) + } + sort.Slice(platformGroupIDs, func(i, j int) bool { return platformGroupIDs[i] < platformGroupIDs[j] }) + buckets = append(buckets, s.bucketsForPlatform(platform, platformGroupIDs, seen)...) + } + return s.rebuildBuckets(ctx, buckets, "account_bulk_change") } func (s *SchedulerSnapshotService) handleAccountEvent(ctx context.Context, accountID *int64, payload map[string]any, seen map[batchSeenKey]struct{}) error { From 410ea8490cae48615894772915212593f30b0b0b Mon Sep 17 00:00:00 2001 From: superman2003 <2112076433zcr@gmail.com> Date: Thu, 16 Jul 2026 00:16:21 +0800 Subject: [PATCH 12/26] fix(grok): allow passive Codex image tool declarations --- .../handler/gateway_handler_responses.go | 2 +- .../handler/openai_gateway_handler.go | 4 +- .../openai_grok_image_intent_gate_test.go | 101 ++++++++++++++++ .../service/image_generation_intent.go | 106 ++++++++++++++++- .../image_generation_intent_grok_test.go | 108 ++++++++++++++++++ .../service/openai_ws_forwarder_ingress.go | 2 +- 6 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 backend/internal/handler/openai_grok_image_intent_gate_test.go create mode 100644 backend/internal/service/image_generation_intent_grok_test.go diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index a5060b7263..ce1ae40811 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -85,7 +85,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) { setOpsRequestContext(c, reqModel, reqStream) setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false))) requestCtx := c.Request.Context() - if service.IsImageGenerationIntent("/v1/responses", reqModel, body) { + if service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, body, openAICompatibleRequestPlatform(apiKey)) { requestCtx = service.WithOpenAIImageGenerationIntent(requestCtx) } diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 83c4b4ac23..6a634bb595 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -272,7 +272,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { return } - imageIntent := service.IsImageGenerationIntent("/v1/responses", reqModel, body) + imageIntent := service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, body, openAICompatibleRequestPlatform(apiKey)) if imageIntent && !service.GroupAllowsImageGeneration(apiKey.Group) { h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage()) return @@ -1461,7 +1461,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { return } - if service.IsImageGenerationIntent("/v1/responses", reqModel, firstMessage) && !service.GroupAllowsImageGeneration(apiKey.Group) { + if service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, firstMessage, openAICompatibleRequestPlatform(apiKey)) && !service.GroupAllowsImageGeneration(apiKey.Group) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, service.ImageGenerationPermissionMessage()) return } diff --git a/backend/internal/handler/openai_grok_image_intent_gate_test.go b/backend/internal/handler/openai_grok_image_intent_gate_test.go new file mode 100644 index 0000000000..3c20160c05 --- /dev/null +++ b/backend/internal/handler/openai_grok_image_intent_gate_test.go @@ -0,0 +1,101 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestOpenAIGatewayHandlerResponses_GrokPassiveImageToolDeclarationBypassesPermissionGate(t *testing.T) { + body := `{"model":"grok-4.5","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}],"tool_choice":"auto","input":"write code"}` + rec := runOpenAIResponsesImagePermissionGateTest(t, service.PlatformGrok, body) + + require.NotEqual(t, http.StatusForbidden, rec.Code) + require.NotContains(t, rec.Body.String(), service.ImageGenerationPermissionMessage()) +} + +func TestOpenAIGatewayHandlerResponses_GrokResponsesLiteImageToolDeclarationBypassesPermissionGate(t *testing.T) { + body := `{"model":"grok-4.5","tool_choice":"auto","input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}]},{"type":"message","role":"user","content":"write code"}]}` + rec := runOpenAIResponsesImagePermissionGateTest(t, service.PlatformGrok, body) + + require.NotEqual(t, http.StatusForbidden, rec.Code) + require.NotContains(t, rec.Body.String(), service.ImageGenerationPermissionMessage()) +} + +func TestOpenAIGatewayHandlerResponses_ImagePermissionHardSignalsStillRejected(t *testing.T) { + passiveNamespace := `{"model":"gpt-5.5","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}],"tool_choice":"auto","input":"write code"}` + tests := []struct { + name string + platform string + body string + }{ + { + name: "OpenAI keeps declaration semantics", + platform: service.PlatformOpenAI, + body: passiveNamespace, + }, + { + name: "Grok native image_generation declaration", + platform: service.PlatformGrok, + body: `{"model":"grok-4.5","tools":[{"type":"image_generation"}],"input":"draw"}`, + }, + { + name: "Grok explicit image_gen tool choice", + platform: service.PlatformGrok, + body: `{"model":"grok-4.5","tools":[{"type":"namespace","name":"image_gen"}],"tool_choice":{"type":"namespace","name":"image_gen"},"input":"draw"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := runOpenAIResponsesImagePermissionGateTest(t, tt.platform, tt.body) + + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), service.ImageGenerationPermissionMessage()) + }) + } +} + +func runOpenAIResponsesImagePermissionGateTest(t *testing.T, platform string, body string) *httptest.ResponseRecorder { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + groupID := int64(6301) + userID := int64(6302) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + ID: 6303, + GroupID: &groupID, + Group: &service.Group{ + ID: groupID, + Platform: platform, + AllowImageGeneration: false, + }, + User: &service.User{ID: userID, Status: service.StatusActive}, + }) + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: userID, Concurrency: 1}) + + h := &OpenAIGatewayHandler{ + gatewayService: &service.OpenAIGatewayService{}, + billingCacheService: service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil), + apiKeyService: &service.APIKeyService{}, + concurrencyHelper: &ConcurrencyHelper{concurrencyService: service.NewConcurrencyService( + &helperConcurrencyCacheStub{userSeq: []bool{true}}, + )}, + cfg: &config.Config{}, + imageLimiter: &imageConcurrencyLimiter{}, + } + + h.Responses(c) + return rec +} diff --git a/backend/internal/service/image_generation_intent.go b/backend/internal/service/image_generation_intent.go index 72f3be7db6..9351fcd682 100644 --- a/backend/internal/service/image_generation_intent.go +++ b/backend/internal/service/image_generation_intent.go @@ -79,6 +79,56 @@ func IsImageGenerationIntent(endpoint string, requestedModel string, body []byte return imageIntent } +// IsImageGenerationIntentForPlatform applies platform-specific intent rules. +// +// Codex advertises the image_gen namespace on ordinary Responses requests so +// that it is available if the model needs it. Grok strips namespace and +// Responses Lite additional_tools declarations before forwarding, so those +// declarations alone must not turn every Codex request into an image request. +// Native image_generation tools, explicit image selection and image models +// remain image intent. Other platforms retain the original declaration rule. +func IsImageGenerationIntentForPlatform(endpoint string, requestedModel string, body []byte, platform string) bool { + if !strings.EqualFold(strings.TrimSpace(platform), PlatformGrok) { + return IsImageGenerationIntent(endpoint, requestedModel, body) + } + return isExplicitGrokImageGenerationIntent(endpoint, requestedModel, body) +} + +func isExplicitGrokImageGenerationIntent(endpoint string, requestedModel string, body []byte) bool { + if IsImageGenerationEndpoint(endpoint) || isOpenAIImageGenerationModel(requestedModel) { + return true + } + if len(body) == 0 || !gjson.ValidBytes(body) { + return false + } + + var modelSeen, toolsSeen, toolChoiceSeen bool + imageIntent := false + parseRawJSONView(body).ForEach(func(key, value gjson.Result) bool { + switch key.Str { + case "model": + if !modelSeen { + modelSeen = true + imageIntent = isOpenAIImageGenerationModel(strings.TrimSpace(value.String())) + } + case "tools": + if !toolsSeen { + toolsSeen = true + // Grok removes namespace catalogs before forwarding. Native + // image_generation remains an explicit capability request. + imageIntent = openAIJSONToolsContainNativeImageGeneration(value) + } + case "tool_choice": + if !toolChoiceSeen { + toolChoiceSeen = true + imageIntent = openAIJSONToolChoiceSelectsExplicitImageGeneration(value) + } + } + return !imageIntent && (!modelSeen || !toolsSeen || !toolChoiceSeen) + }) + return imageIntent +} + // IsImageGenerationIntentMap is the map-backed variant used after service-side request mutation. func IsImageGenerationIntentMap(endpoint string, requestedModel string, reqBody map[string]any) bool { if IsImageGenerationEndpoint(endpoint) { @@ -140,6 +190,18 @@ func openAIJSONToolsContainImageGeneration(tools gjson.Result) bool { return found } +func openAIJSONToolsContainNativeImageGeneration(tools gjson.Result) bool { + if !tools.IsArray() { + return false + } + found := false + tools.ForEach(func(_, item gjson.Result) bool { + found = isOpenAIImageGenerationType(openAIJSONString(item.Get("type"))) + return !found + }) + return found +} + func isOpenAIImageGenerationType(value string) bool { return strings.TrimSpace(value) == "image_generation" } @@ -148,9 +210,8 @@ func isOpenAIImageGenNamespaceName(value string) bool { return strings.TrimSpace(value) == "image_gen" } -// isImageGenNamespaceTool detects the Codex namespace-style image generation -// tool declaration: { "type": "namespace", "name": "image_gen", ... }. -// Codex /image uses this instead of the flat { "type": "image_generation" }. +// isImageGenNamespaceTool detects the namespace advertised by Codex's built-in +// image-generation extension instead of a hosted image_generation tool. func isImageGenNamespaceTool(tool gjson.Result) bool { return openAIJSONString(tool.Get("type")) == "namespace" && isOpenAIImageGenNamespaceName(openAIJSONString(tool.Get("name"))) @@ -235,6 +296,45 @@ func openAIJSONToolChoiceSelectsImageGeneration(choice gjson.Result) bool { return false } +func openAIJSONToolChoiceSelectsExplicitImageGeneration(choice gjson.Result) bool { + if openAIJSONToolChoiceSelectsImageGeneration(choice) { + return true + } + if !choice.IsObject() { + return false + } + if tool := choice.Get("tool"); tool.IsObject() && openAIJSONToolChoiceSelectsExplicitImageGeneration(tool) { + return true + } + if isOpenAIImageGenFunctionReference( + openAIJSONString(choice.Get("namespace")), + openAIJSONString(choice.Get("name")), + ) { + return true + } + if fn := choice.Get("function"); fn.IsObject() { + return isOpenAIImageGenFunctionReference( + openAIJSONString(fn.Get("namespace")), + openAIJSONString(fn.Get("name")), + ) + } + return false +} + +func isOpenAIImageGenFunctionReference(namespace string, name string) bool { + namespace = strings.TrimSpace(namespace) + name = strings.TrimSpace(name) + if namespace == "image_gen" && name == "imagegen" { + return true + } + switch name { + case "image_gen.imagegen", "image_gen__imagegen": + return true + default: + return false + } +} + func openAIAnyToolChoiceSelectsImageGeneration(choice any) bool { switch v := choice.(type) { case string: diff --git a/backend/internal/service/image_generation_intent_grok_test.go b/backend/internal/service/image_generation_intent_grok_test.go new file mode 100644 index 0000000000..cfef208d45 --- /dev/null +++ b/backend/internal/service/image_generation_intent_grok_test.go @@ -0,0 +1,108 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsImageGenerationIntentForPlatform_GrokCodexDeclarations(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + { + name: "top-level image_gen namespace is passive", + body: `{"model":"grok-4.5","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}],"tool_choice":"auto","input":"write code"}`, + }, + { + name: "responses lite additional_tools image_gen is passive", + body: `{"model":"grok-4.5","tool_choice":"auto","input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}]},{"type":"message","role":"user","content":"write code"}]}`, + }, + { + name: "legacy adapter flattened image_gen function declaration is passive", + body: `{"model":"grok-4.5","tools":[{"type":"function","name":"image_gen.imagegen"}],"input":"write code"}`, + }, + { + name: "native image generation declaration remains explicit", + body: `{"model":"grok-4.5","tools":[{"type":"image_generation"}],"input":"draw a cat"}`, + want: true, + }, + { + name: "native image generation tool choice is explicit", + body: `{"model":"grok-4.5","tools":[{"type":"image_generation"}],"tool_choice":{"type":"image_generation"},"input":"draw a cat"}`, + want: true, + }, + { + name: "image_gen namespace tool choice is explicit", + body: `{"model":"grok-4.5","tools":[{"type":"namespace","name":"image_gen"}],"tool_choice":{"type":"namespace","name":"image_gen"},"input":"draw a cat"}`, + want: true, + }, + { + name: "flattened image_gen function tool choice is explicit", + body: `{"model":"grok-4.5","tools":[{"type":"function","name":"image_gen.imagegen"}],"tool_choice":{"type":"function","name":"image_gen.imagegen"},"input":"draw a cat"}`, + want: true, + }, + { + name: "wrapped image_gen function tool choice is explicit", + body: `{"model":"grok-4.5","tools":[{"type":"function","name":"image_gen.imagegen"}],"tool_choice":{"tool":{"type":"function","name":"image_gen.imagegen"}},"input":"draw a cat"}`, + want: true, + }, + { + name: "vision input is not image generation", + body: `{"model":"grok-4.5","input":[{"type":"message","role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}]}`, + }, + { + name: "ordinary function declaration is not image generation", + body: `{"model":"grok-4.5","tools":[{"type":"function","name":"lookup"}],"tool_choice":"auto","input":"write code"}`, + }, + { + name: "image_gen function call history is not current intent", + body: `{"model":"grok-4.5","input":[{"type":"function_call","namespace":"image_gen","name":"imagegen","arguments":"{}"}]}`, + }, + { + name: "image generation call history is not current intent", + body: `{"model":"grok-4.5","input":[{"type":"image_generation_call","id":"ig_1"}]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, IsImageGenerationIntentForPlatform( + openAIResponsesEndpoint, + "grok-4.5", + []byte(tt.body), + PlatformGrok, + )) + }) + } +} + +func TestIsImageGenerationIntentForPlatform_GrokPreservesHardSignals(t *testing.T) { + require.True(t, IsImageGenerationIntentForPlatform( + "/v1/images/generations", + "grok-4.5", + []byte(`{"input":"draw"}`), + PlatformGrok, + )) + require.True(t, IsImageGenerationIntentForPlatform( + openAIResponsesEndpoint, + "gpt-image-2", + []byte(`{"input":"draw"}`), + PlatformGrok, + )) + require.True(t, IsImageGenerationIntentForPlatform( + openAIResponsesEndpoint, + "grok-4.5", + []byte(`{"model":"gpt-image-2","input":"draw"}`), + PlatformGrok, + )) +} + +func TestIsImageGenerationIntentForPlatform_OtherPlatformsKeepDeclarationSemantics(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}],"input":"write code"}`) + + require.True(t, IsImageGenerationIntentForPlatform(openAIResponsesEndpoint, "gpt-5.5", body, PlatformOpenAI)) + require.True(t, IsImageGenerationIntentForPlatform(openAIResponsesEndpoint, "gpt-5.5", body, PlatformAnthropic)) +} diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 51aace6419..89846e1541 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -305,7 +305,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( normalized = stripped logOpenAIWSModeInfo("ingress_ws_codex_spark_image_tool_stripped account_id=%d", account.ID) } - imageIntent := IsImageGenerationIntent(openAIResponsesEndpoint, originalModel, normalized) + imageIntent := IsImageGenerationIntentForPlatform(openAIResponsesEndpoint, originalModel, normalized, account.Platform) if imageIntent && !imageGenerationAllowed { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, ImageGenerationPermissionMessage(), nil) } From e8cc1fe26b07152ee9a5a3cb584d2499e2d21f3d Mon Sep 17 00:00:00 2001 From: superman2003 <2112076433zcr@gmail.com> Date: Thu, 16 Jul 2026 00:15:38 +0800 Subject: [PATCH 13/26] feat(frontend): add Grok key setup templates --- frontend/src/components/keys/UseKeyModal.vue | 136 +++++++++++++++- .../keys/__tests__/UseKeyModal.spec.ts | 153 +++++++++++++++++- frontend/src/i18n/locales/en/dashboard.ts | 9 +- frontend/src/i18n/locales/zh/dashboard.ts | 11 +- 4 files changed, 297 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 1dda0c51eb..4663b2b7d8 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -29,11 +29,12 @@

-
-