diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 994b3a5ee6b8..f23ab8e9d160 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -309,17 +309,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService) systemPromptHandler := admin.NewSystemPromptHandler(systemPromptService) reasoningRecoveryHandler := admin.NewReasoningRecoveryHandler(reasoningRecoveryService) - accountJobRuntime, err := handler.ProvideAccountJobRuntime(accountJobService, accountHandler) - if err != nil { - return nil, err - } upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db) accountTrafficObserveCache := repository.ProvideAccountTrafficObserveCache(redisClient, configConfig) - accountTrafficObserver := service.NewAccountTrafficObserver(accountTrafficObserveCache, configConfig) + accountTrafficObserver := service.NewAccountTrafficObserver(accountTrafficObserveCache) openCodeGoUsageService := service.ProvideOpenCodeGoUsageService(accountRepository, httpUpstream, settingService, leaderLockCache, db) idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) claudeResetCreditService := service.ProvideClaudeResetCreditService(accountRepository, claudeTokenProvider, proxyRepository, settingService, idempotencyCoordinator, leaderLockCache) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, accountJobHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, systemPromptHandler, reasoningRecoveryHandler, accountJobRuntime, upstreamBillingProbeService, ollamaCloudUsageService, accountTrafficObserver, openCodeGoUsageService, claudeResetCreditService) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, accountJobHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, systemPromptHandler, reasoningRecoveryHandler, upstreamBillingProbeService, ollamaCloudUsageService, accountTrafficObserver, openCodeGoUsageService, claudeResetCreditService) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) @@ -354,7 +350,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler) imageStudioRepository := repository.NewImageStudioRepository(db) imageStudioArtifactStore := service.ProvideImageStudioArtifactStore(configConfig, imageStudioRepository) - imageStudioService := service.NewImageStudioService(imageStudioRepository, apiKeyRepository, accountRepository, imageStudioArtifactStore) + imageStudioService := service.NewImageStudioService(imageStudioRepository, apiKeyRepository, imageStudioArtifactStore) imageStudioJobHandler := handler.NewImageStudioJobHandler(imageStudioService) idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig) handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, imageStudioJobHandler, idempotencyCoordinator, idempotencyCleanupService) @@ -366,11 +362,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { stepUpAuthMiddleware := middleware.NewStepUpAuthMiddleware(totpService, userService, settingService) engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, optionalJWTAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, compositeRouteResolver, redisClient) httpServer := server.ProvideHTTPServer(configConfig, engine) + accountJobRuntime := handler.ProvideAccountJobRuntime(accountJobService, accountHandler) imageStudioGatewayExecutor := handler.NewImageStudioGatewayExecutor(openAIGatewayHandler, subscriptionService, opsService) - imageStudioRuntime, err := handler.ProvideImageStudioRuntime(imageStudioRepository, imageStudioService, imageStudioArtifactStore, imageStudioGatewayExecutor) - if err != nil { - return nil, err - } + imageStudioRuntime := handler.ProvideImageStudioRuntime(imageStudioRepository, imageStudioService, imageStudioArtifactStore, imageStudioGatewayExecutor) codexClientIdentityBackfillService := service.ProvideCodexClientIdentityBackfillService(accountRepository) opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig) opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig) diff --git a/backend/internal/accounttools/policy/import_test.go b/backend/internal/accounttools/policy/import_test.go index 384f00561730..25956f82727a 100644 --- a/backend/internal/accounttools/policy/import_test.go +++ b/backend/internal/accounttools/policy/import_test.go @@ -30,7 +30,7 @@ func TestImportPolicyPlansEachItemByIdentityMatches(t *testing.T) { } request.Phase = "finalize" raw, _ := json.Marshal(request) - if _, err := New().Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityAdmin, Operation: "import.plan", Payload: raw}); err == nil { + if _, err := New().Invoke(context.Background(), extensionv1.Invocation{Operation: "import.plan", Payload: raw}); err == nil { t.Fatal("incomplete prepared phase was accepted") } } diff --git a/backend/internal/accounttools/policy/module.go b/backend/internal/accounttools/policy/module.go index d2869a83aef2..97ab5c219ebc 100644 --- a/backend/internal/accounttools/policy/module.go +++ b/backend/internal/accounttools/policy/module.go @@ -14,20 +14,6 @@ import ( type Module struct{} func New() *Module { return &Module{} } -func (m *Module) ValidateConfig(_ context.Context, raw json.RawMessage) (json.RawMessage, error) { - var fields map[string]json.RawMessage - if json.Unmarshal(raw, &fields) != nil || fields == nil || len(fields) != 0 { - return nil, errors.New("account tools has no additional configuration") - } - return json.RawMessage(`{}`), nil -} -func (m *Module) ApplyConfig(ctx context.Context, raw json.RawMessage) error { - _, err := m.ValidateConfig(ctx, raw) - return err -} -func (m *Module) Status(context.Context) (json.RawMessage, error) { - return json.Marshal(map[string]any{"taxonomy": true, "reasoning_selection": true, "text_prompt": true}) -} func failure(code, message string, status int) extensionv1.Result { return extensionv1.Result{Code: code, Message: message, HTTPStatus: status} @@ -46,9 +32,6 @@ func (m *Module) Invoke(ctx context.Context, in extensionv1.Invocation) (extensi if err := ctx.Err(); err != nil { return extensionv1.Result{}, err } - if in.Capability != extensionv1.CapabilityAdmin { - return extensionv1.Result{}, errors.New("unsupported account tools capability") - } var output any = map[string]bool{"valid": true} switch in.Operation { case "import.plan": @@ -68,9 +51,6 @@ func (m *Module) Invoke(ctx context.Context, in extensionv1.Invocation) (extensi if !request.ValidUTF8 || request.Characters < 0 || request.Characters > 8192 { return failure("ACCOUNT_TEST_PROMPT_INVALID", "test prompt must be valid UTF-8 and at most 8192 characters", 400), nil } - case "tools.describe": - raw, err := m.Status(ctx) - return extensionv1.Result{Payload: raw}, err case "taxonomy.name": var request extensionv1.TaxonomyName if json.Unmarshal(in.Payload, &request) != nil { diff --git a/backend/internal/accounttools/policy/module_test.go b/backend/internal/accounttools/policy/module_test.go index 101e14fd845c..2594249ebb4a 100644 --- a/backend/internal/accounttools/policy/module_test.go +++ b/backend/internal/accounttools/policy/module_test.go @@ -21,7 +21,7 @@ func TestTaxonomyPoliciesRejectAmbiguityAndInvalidAssignments(t *testing.T) { {"taxonomy.assignment", `{"tag_ids":[-1]}`, "ACCOUNT_TAG_ID_INVALID"}, {"taxonomy.assignment", `{"tag_ids":[1,1,2]}`, ""}, } { - out, err := New().Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityAdmin, Operation: tc.operation, Payload: json.RawMessage(tc.payload)}) + out, err := New().Invoke(context.Background(), extensionv1.Invocation{Operation: tc.operation, Payload: json.RawMessage(tc.payload)}) if err != nil || out.Code != tc.code { t.Fatalf("%s: %v %+v", tc.operation, err, out) } @@ -34,7 +34,7 @@ func TestReasoningSelectionValidatesTheSuppliedWireCapabilities(t *testing.T) { valid bool }{{"default", "ultra", true}, {"text", "ultra", true}, {"compact", "ultra", false}, {"default", " high ", false}, {"default", "high", false}, {"default", "", true}} { raw, _ := json.Marshal(extensionv1.ReasoningSelection{Mode: tc.mode, Effort: tc.effort, Levels: []string{"ultra"}}) - out, err := New().Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityAdmin, Operation: "test.reasoning", Payload: raw}) + out, err := New().Invoke(context.Background(), extensionv1.Invocation{Operation: "test.reasoning", Payload: raw}) if err != nil || (out.Code == "") != tc.valid { t.Fatalf("%s/%s: %v %+v", tc.mode, tc.effort, err, out) } diff --git a/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go b/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go index e4a3d07e2f01..1efdb27f2eef 100644 --- a/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go +++ b/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go @@ -53,7 +53,7 @@ func TestAccountHandlerTrafficTelemetryFollowsTelemetrySwitch(t *testing.T) { stub.getAccountResult = &service.Account{ID: 18, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Concurrency: 3, Credentials: map[string]any{"api_key": "never-return-secret"}} cache := &stubAccountTrafficObserveCache{snapshot: map[service.AccountTrafficProtocol]service.AccountTrafficObserveState{service.AccountTrafficProtocolHTTP: {Started: 3, Completed2xx: 2}}} handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - handler.SetAccountTrafficObserver(service.NewAccountTrafficObserver(cache, nil)) + handler.SetAccountTrafficObserver(service.NewAccountTrafficObserver(cache)) router := gin.New() router.GET("/accounts/:id/traffic-telemetry", handler.GetTrafficTelemetry) recorder := httptest.NewRecorder() @@ -91,7 +91,7 @@ func TestAccountHandlerGetTrafficTelemetryReturnsSnapshot(t *testing.T) { service.AccountTrafficProtocolHTTP: {Started: 3, Completed2xx: 2, Upstream429: 1, PeakInFlight: 2, RequestsLast60s: 1}, service.AccountTrafficProtocolWS: {}, }, - }, nil)) + })) router := gin.New() router.GET("/accounts/:id/traffic-telemetry", handler.GetTrafficTelemetry) diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index 5c30cfdcb782..3c89e9157630 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -431,7 +431,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. if slotResult != openAISlotAcquireOK { if retryingSameAccount && lastFailoverErr != nil && h.failoverAfterSameAccountSlotFailure( c, account, grokMediaScheduleModel(account, routingModel, nil), lastFailoverErr, failedAccountIDs, - &switchCount, maxAccountSwitches, &oauth429FailoverState, streamStarted, + &switchCount, maxAccountSwitches, &oauth429FailoverState, "grok_media", reqLog, !endpoint.IsVideoLookupRequest(), func() { h.handleFailoverExhausted(c, lastFailoverErr, streamStarted) }, ) { diff --git a/backend/internal/handler/openai_alpha_search.go b/backend/internal/handler/openai_alpha_search.go index c84067dd15fb..0df7da6ef2f2 100644 --- a/backend/internal/handler/openai_alpha_search.go +++ b/backend/internal/handler/openai_alpha_search.go @@ -201,7 +201,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { if slotResult != openAISlotAcquireOK { if retryingSameAccount && lastFailoverErr != nil && h.failoverAfterSameAccountSlotFailure( c, account, account.GetMappedModel(requestedModel), lastFailoverErr, failedAccountIDs, - &switchCount, h.maxAccountSwitches, &oauth429FailoverState, streamStarted, + &switchCount, h.maxAccountSwitches, &oauth429FailoverState, "alpha_search", reqLog, true, func() { h.handleFailoverExhausted(c, lastFailoverErr, streamStarted) }, ) { diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index dd88a65aba33..c7d0e53154d9 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -269,7 +269,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { if slotResult != openAISlotAcquireOK { if retryingSameAccount && lastFailoverErr != nil && h.failoverAfterSameAccountSlotFailure( c, account, account.GetMappedModel(routingModel), lastFailoverErr, failedAccountIDs, - &switchCount, maxAccountSwitches, &oauth429FailoverState, streamStarted, + &switchCount, maxAccountSwitches, &oauth429FailoverState, "chat_completions", reqLog, true, func() { h.handleFailoverExhausted(c, lastFailoverErr, streamStarted) }, ) { diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go index d7c85f1a229d..b0ef2ca7e797 100644 --- a/backend/internal/handler/openai_codex_models_handler.go +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -160,7 +160,7 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { if slotResult != openAISlotAcquireOK { if retryingSameAccount && lastFailoverErr != nil && h.failoverAfterSameAccountSlotFailure( c, account, "", lastFailoverErr, failedAccountIDs, - &switchCount, maxAccountSwitches, &oauth429FailoverState, false, + &switchCount, maxAccountSwitches, &oauth429FailoverState, "codex_models", nil, true, func() { h.errorResponse(c, infraerrors.Code(lastUpstreamErr), "upstream_error", infraerrors.Message(lastUpstreamErr)) diff --git a/backend/internal/handler/openai_count_tokens_model_not_supported_test.go b/backend/internal/handler/openai_count_tokens_model_not_supported_test.go index a1715be18910..8faf8fb706e0 100644 --- a/backend/internal/handler/openai_count_tokens_model_not_supported_test.go +++ b/backend/internal/handler/openai_count_tokens_model_not_supported_test.go @@ -18,7 +18,7 @@ func TestWriteCountTokensFailoverErrorPreservesModelNotSupported(t *testing.T) { writeCountTokensFailoverError(ctx, &service.UpstreamFailoverError{ StatusCode: http.StatusBadRequest, Reason: service.GatewayFailureReason("upstream_400_model_not_supported"), - }, nil) + }) var envelope map[string]any require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope)) diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go index 1ccbdadc0462..27ccb2172005 100644 --- a/backend/internal/handler/openai_embeddings.go +++ b/backend/internal/handler/openai_embeddings.go @@ -211,7 +211,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { if slotResult != openAISlotAcquireOK { if retryingSameAccount && lastFailoverErr != nil && h.failoverAfterSameAccountSlotFailure( c, account, account.GetMappedModel(reqModel), lastFailoverErr, failedAccountIDs, - &switchCount, maxAccountSwitches, &oauth429FailoverState, streamStarted, + &switchCount, maxAccountSwitches, &oauth429FailoverState, "embeddings", reqLog, true, func() { h.handleFailoverExhausted(c, lastFailoverErr, streamStarted) }, ) { diff --git a/backend/internal/handler/openai_failover_retry.go b/backend/internal/handler/openai_failover_retry.go index a75c2d1dc8a0..c444f8e43bdb 100644 --- a/backend/internal/handler/openai_failover_retry.go +++ b/backend/internal/handler/openai_failover_retry.go @@ -28,7 +28,6 @@ func (h *OpenAIGatewayHandler) failoverAfterSameAccountSlotFailure( switchCount *int, maxAccountSwitches int, oauth429State *service.OpenAIOAuth429FailoverState, - streamStarted bool, logScope string, reqLog *zap.Logger, allowAccountSwitch bool, diff --git a/backend/internal/handler/openai_gateway_count_tokens.go b/backend/internal/handler/openai_gateway_count_tokens.go index 1ed15e704269..18eca28b7e61 100644 --- a/backend/internal/handler/openai_gateway_count_tokens.go +++ b/backend/internal/handler/openai_gateway_count_tokens.go @@ -288,7 +288,6 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { switchCount := 0 failedAccountIDs := make(map[int64]struct{}) retryState := newOpenAIFailoverRetryState() - var lastUpstreamErr error var lastFailoverErr *service.UpstreamFailoverError var sameAccountRetrySelection *service.AccountSelectionResult var oauth429FailoverState service.OpenAIOAuth429FailoverState @@ -316,12 +315,12 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { if c.Request.Context().Err() != nil { return } - if retryingSameAccount && lastUpstreamErr != nil { - writeCountTokensFailoverError(c, lastFailoverErr, lastUpstreamErr) + if retryingSameAccount && lastFailoverErr != nil { + writeCountTokensFailoverError(c, lastFailoverErr) return } - if len(failedAccountIDs) > 0 && lastUpstreamErr != nil { - writeCountTokensFailoverError(c, lastFailoverErr, lastUpstreamErr) + if len(failedAccountIDs) > 0 && lastFailoverErr != nil { + writeCountTokensFailoverError(c, lastFailoverErr) return } requestPlatform := openAICompatibleRequestPlatform(c.Request.Context(), apiKey) @@ -349,8 +348,8 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { if h.failoverAfterSameAccountSlotFailure( c, account, account.GetMappedModel(currentRoutingModel), lastFailoverErr, failedAccountIDs, &switchCount, maxAccountSwitches, &oauth429FailoverState, - false, "count_tokens", reqLog, true, - func() { writeCountTokensFailoverError(c, lastFailoverErr, lastUpstreamErr) }, + "count_tokens", reqLog, true, + func() { writeCountTokensFailoverError(c, lastFailoverErr) }, ) { continue } @@ -377,10 +376,9 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { } if !failoverErr.ShouldRetryNextAccount() { finalizeOpenAIFailoverSelection(h.gatewayService, selection, account, account.GetMappedModel(currentRoutingModel), failoverErr, openAIFailoverRetryStop) - writeCountTokensFailoverError(c, failoverErr, attemptErr) + writeCountTokensFailoverError(c, failoverErr) return } - lastUpstreamErr = attemptErr lastFailoverErr = failoverErr retryAction := retryState.Handle(c.Request.Context(), h.gatewayService, account, account.GetMappedModel(currentRoutingModel), failoverErr, true, sameAccountRetryDelay, "count_tokens") finalizeOpenAIFailoverSelection(h.gatewayService, selection, account, account.GetMappedModel(currentRoutingModel), failoverErr, retryAction) @@ -391,24 +389,24 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { case openAIFailoverRetryCanceled: return case openAIFailoverRetryStop: - writeCountTokensFailoverError(c, failoverErr, attemptErr) + writeCountTokensFailoverError(c, failoverErr) return } failedAccountIDs[account.ID] = struct{}{} if switchCount >= maxAccountSwitches { - writeCountTokensFailoverError(c, failoverErr, attemptErr) + writeCountTokensFailoverError(c, failoverErr) return } switchCount++ if h.gatewayService.ShouldStopOpenAIOAuth429Failover(account, failoverErr.StatusCode, switchCount, &oauth429FailoverState) { - writeCountTokensFailoverError(c, failoverErr, attemptErr) + writeCountTokensFailoverError(c, failoverErr) return } h.gatewayService.RecordOpenAIAccountSwitch() } } -func writeCountTokensFailoverError(c *gin.Context, failoverErr *service.UpstreamFailoverError, _ error) { +func writeCountTokensFailoverError(c *gin.Context, failoverErr *service.UpstreamFailoverError) { status := http.StatusBadGateway errorType := "upstream_error" errorCode := "" diff --git a/backend/internal/handler/openai_gateway_first_output_timeout_test.go b/backend/internal/handler/openai_gateway_first_output_timeout_test.go index 76654ebded83..e5615dca4e18 100644 --- a/backend/internal/handler/openai_gateway_first_output_timeout_test.go +++ b/backend/internal/handler/openai_gateway_first_output_timeout_test.go @@ -38,7 +38,7 @@ func TestOpenAIFirstOutputFailoverStopsAfterOneAccountSwitch(t *testing.T) { } func TestOpenAIFirstOutputFailoverDoesNotCapRefusalRecoveryAccountSwitches(t *testing.T) { - failoverErr := service.NewOpenAICyberFailoverError(nil, nil) + failoverErr := service.NewOpenAICyberFailoverError(nil) count := 0 for range 3 { diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index dcbba023b3cd..4a87de6d2432 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -851,7 +851,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { if slotResult != openAISlotAcquireOK { if retryingSameAccount && lastFailoverErr != nil && h.failoverAfterSameAccountSlotFailure( c, account, account.GetMappedModel(routingModel), lastFailoverErr, failedAccountIDs, - &switchCount, maxAccountSwitches, &oauth429FailoverState, streamStarted, + &switchCount, maxAccountSwitches, &oauth429FailoverState, "responses", reqLog, true, func() { h.handleFailoverExhausted(c, lastFailoverErr, streamStarted) }, ) { @@ -1496,7 +1496,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { if slotResult != openAISlotAcquireOK { if retryingSameAccount && lastFailoverErr != nil && h.failoverAfterSameAccountSlotFailure( c, account, account.GetMappedModel(currentRoutingModel), lastFailoverErr, failedAccountIDs, - &switchCount, maxAccountSwitches, &oauth429FailoverState, streamStarted, + &switchCount, maxAccountSwitches, &oauth429FailoverState, "messages", reqLog, true, func() { h.handleAnthropicFailoverExhausted(c, lastFailoverErr, streamStarted) }, ) { diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 082a2440198e..29a667e3147e 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -1837,7 +1837,7 @@ func TestOpenAIWSPostOutputCyberClose(t *testing.T) { service.NewOpenAIWSClientCloseError( coderws.StatusTryAgainLater, reason, - service.NewOpenAICyberFailoverError(nil, nil), + service.NewOpenAICyberFailoverError(nil), ), ) @@ -1848,7 +1848,7 @@ func TestOpenAIWSPostOutputCyberClose(t *testing.T) { require.Equal(t, reason, closeErr.Reason()) require.False(t, shouldReportOpenAIWSProxyAccountFailure(wrappedCyberClose)) - _, ok = openAIWSPostOutputCyberClose(service.NewOpenAICyberFailoverError(nil, nil)) + _, ok = openAIWSPostOutputCyberClose(service.NewOpenAICyberFailoverError(nil)) require.False(t, ok, "a replay-safe Cyber failover must remain eligible for account switching") nonCyberClose := service.NewOpenAIWSClientCloseError( diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go index c7e9ebb31080..ea0f4fc51f1b 100644 --- a/backend/internal/handler/openai_images.go +++ b/backend/internal/handler/openai_images.go @@ -274,7 +274,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { if slotResult != openAISlotAcquireOK { if retryingSameAccount && lastFailoverErr != nil && h.failoverAfterSameAccountSlotFailure( c, account, account.GetMappedModel(requestModel), lastFailoverErr, failedAccountIDs, - &switchCount, maxAccountSwitches, &oauth429FailoverState, streamStarted, + &switchCount, maxAccountSwitches, &oauth429FailoverState, "images", reqLog, true, func() { h.handleFailoverExhausted(c, lastFailoverErr, streamStarted) }, ) { diff --git a/backend/internal/handler/openai_refusal_recovery_handler_test.go b/backend/internal/handler/openai_refusal_recovery_handler_test.go index f377ca54c61d..5d4282e51508 100644 --- a/backend/internal/handler/openai_refusal_recovery_handler_test.go +++ b/backend/internal/handler/openai_refusal_recovery_handler_test.go @@ -18,7 +18,7 @@ func TestOpenAIRefusalRecoveryFailoverExhaustionReturnsRetryable503(t *testing.T c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) - (&OpenAIGatewayHandler{}).handleFailoverExhausted(c, service.NewOpenAICyberFailoverError(nil, nil), false) + (&OpenAIGatewayHandler{}).handleFailoverExhausted(c, service.NewOpenAICyberFailoverError(nil), false) require.Equal(t, http.StatusServiceUnavailable, recorder.Code) require.Equal(t, "1", recorder.Header().Get("Retry-After")) @@ -32,7 +32,7 @@ func TestOpenAIRefusalRecoveryFailoverExhaustionWritesServerErrorSSE(t *testing. c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) - (&OpenAIGatewayHandler{}).handleFailoverExhausted(c, service.NewOpenAICyberFailoverError(nil, nil), true) + (&OpenAIGatewayHandler{}).handleFailoverExhausted(c, service.NewOpenAICyberFailoverError(nil), true) require.Equal(t, http.StatusOK, recorder.Code) require.Contains(t, recorder.Body.String(), "event: response.failed") @@ -47,7 +47,7 @@ func TestOpenAIRefusalRecoveryFailoverExhaustionWritesAnthropicRetryableError(t c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) - (&OpenAIGatewayHandler{}).handleAnthropicFailoverExhausted(c, service.NewOpenAICyberFailoverError(nil, nil), false) + (&OpenAIGatewayHandler{}).handleAnthropicFailoverExhausted(c, service.NewOpenAICyberFailoverError(nil), false) require.Equal(t, http.StatusServiceUnavailable, recorder.Code) require.Equal(t, "1", recorder.Header().Get("Retry-After")) @@ -59,7 +59,7 @@ func TestOpenAIRefusalRecoveryCyberAttemptClearsPerAttemptState(t *testing.T) { c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) h := &OpenAIGatewayHandler{} - failoverErr := service.NewOpenAICyberFailoverError(nil, nil) + failoverErr := service.NewOpenAICyberFailoverError(nil) for _, inputTokens := range []int{7, 11} { service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Code: "cyber_policy", UpstreamInTok: inputTokens}) @@ -109,7 +109,7 @@ func TestPrepareOpenAIRefusalPromptRetryNeverMutatesCyberFailover(t *testing.T) c, _ := gin.CreateTestContext(httptest.NewRecorder()) body := []byte(`{"model":"gpt-5.6-sol","input":"task"}`) - repaired, changed, err := prepareOpenAIRefusalPromptRetry(c, body, service.NewOpenAICyberFailoverError(nil, nil)) + repaired, changed, err := prepareOpenAIRefusalPromptRetry(c, body, service.NewOpenAICyberFailoverError(nil)) require.NoError(t, err) require.False(t, changed) diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index c650b0bb5da1..33fae0f9cd0a 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -50,7 +50,6 @@ func ProvideAdminHandlers( auditLogHandler *admin.AuditLogHandler, systemPromptHandler *admin.SystemPromptHandler, reasoningRecoveryHandler *admin.ReasoningRecoveryHandler, - _ *service.AccountJobRuntime, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, accountTrafficObserver *service.AccountTrafficObserver, @@ -317,10 +316,9 @@ var ProviderSet = wire.NewSet( ProvideHandlers, ) -func ProvideAccountJobRuntime(jobs *service.AccountJobService, accountHandler *admin.AccountHandler) (*service.AccountJobRuntime, error) { +func ProvideAccountJobRuntime(jobs *service.AccountJobService, accountHandler *admin.AccountHandler) *service.AccountJobRuntime { accountHandler.SetAccountJobService(jobs) - runtime := service.NewAccountJobRuntime(jobs, accountHandler) - return runtime, nil + return service.NewAccountJobRuntime(jobs, accountHandler) } func ProvidePluginHandler(manager *service.PluginManager, retired *service.NativeFeatureBootstrap) *admin.PluginHandler { @@ -334,7 +332,6 @@ func ProvideImageStudioRuntime( studio *service.ImageStudioService, store service.ImageStudioFileStorage, executor *ImageStudioGatewayExecutor, -) (*service.ImageStudioRuntime, error) { - runtime := service.NewImageStudioRuntime(repo, studio, store, executor, service.ImageStudioRuntimeOptions{}) - return runtime, nil +) *service.ImageStudioRuntime { + return service.NewImageStudioRuntime(repo, studio, store, executor, service.ImageStudioRuntimeOptions{}) } diff --git a/backend/internal/nativeapi/api.go b/backend/internal/nativeapi/api.go index bb1ae7bb1807..561ad93aedb5 100644 --- a/backend/internal/nativeapi/api.go +++ b/backend/internal/nativeapi/api.go @@ -10,22 +10,15 @@ import ( const ( // Preserve the existing bound on internal policy plans and catalog payloads. MaxPayloadBytes = 4 * 1024 * 1024 - CapabilityAdmin = "extensions.admin.v1" ) -// Invocation carries one declared capability and a domain operation. The host -// validates the capability binding before crossing the process boundary. +// Invocation carries one domain operation and its payload. type Invocation struct { - Capability string `json:"capability"` - Operation string `json:"operation"` - Payload json.RawMessage `json:"payload"` - // AccountID is set from the host's authorized execution target, never from - // a plugin UI payload. It also partitions cached policy results. - AccountID int64 `json:"account_id,omitempty"` + Operation string `json:"operation"` + Payload json.RawMessage `json:"payload"` } type Result struct { - PluginID int64 `json:"plugin_id,omitempty"` HTTPStatus int `json:"http_status,omitempty"` Payload json.RawMessage `json:"payload,omitempty"` Code string `json:"code,omitempty"` diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 4ced339dda1f..ad975b17ae5d 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -853,7 +853,7 @@ func mergeAccountModelContextExtra( extra map[string]any, currentOverrides, currentMetadata []byte, ) (map[string]any, error) { - if err := service.ValidateModelContextOverrides(account, account.ModelContextOverridesPatch); err != nil { + if err := service.ValidateModelContextOverrides(account.ModelContextOverridesPatch); err != nil { return nil, err } merged := copyJSONMap(normalizeJSONMap(extra)) @@ -2051,7 +2051,7 @@ func (r *accountRepository) BindGroups(ctx context.Context, accountID int64, gro } func (r *accountRepository) ListSchedulable(ctx context.Context) ([]service.Account, error) { - accounts, err := r.schedulableAccountsQuery(ctx, time.Now()).All(ctx) + accounts, err := r.schedulableAccountsQuery(time.Now()).All(ctx) if err != nil { return nil, err } @@ -2059,7 +2059,7 @@ func (r *accountRepository) ListSchedulable(ctx context.Context) ([]service.Acco } func (r *accountRepository) ListSchedulableAccountLoads(ctx context.Context) ([]service.AccountWithConcurrency, error) { - accounts, err := r.schedulableAccountsQuery(ctx, time.Now()). + accounts, err := r.schedulableAccountsQuery(time.Now()). Select( dbaccount.FieldID, dbaccount.FieldConcurrency, @@ -2079,7 +2079,7 @@ func (r *accountRepository) ListSchedulableAccountLoads(ctx context.Context) ([] return loads, nil } -func (r *accountRepository) schedulableAccountsQuery(ctx context.Context, now time.Time) *dbent.AccountQuery { +func (r *accountRepository) schedulableAccountsQuery(now time.Time) *dbent.AccountQuery { return r.client.Account.Query(). Where( dbaccount.StatusEQ(service.StatusActive), diff --git a/backend/internal/repository/concurrency_cache_live_test.go b/backend/internal/repository/concurrency_cache_live_test.go index d11a5def4e72..e9a3b744f8ce 100644 --- a/backend/internal/repository/concurrency_cache_live_test.go +++ b/backend/internal/repository/concurrency_cache_live_test.go @@ -104,7 +104,7 @@ func TestAccountTrafficObserveBeginReadsSlotsAndRollingMinute(t *testing.T) { require.NoError(t, err) require.Equal(t, 2, count) - turn := service.NewAccountTrafficObserver(observe, nil).Begin(ctx, &service.Account{ID: 10, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, service.AccountTrafficProtocolHTTP) + turn := service.NewAccountTrafficObserver(observe).Begin(ctx, &service.Account{ID: 10, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, service.AccountTrafficProtocolHTTP) require.NotNil(t, turn) turn.Finish(nil, &service.UpstreamFailoverError{StatusCode: 429}, false) turn.Finish(nil, &service.UpstreamFailoverError{StatusCode: 429}, false) diff --git a/backend/internal/service/account_business_errors.go b/backend/internal/service/account_business_errors.go index a74d41b52a9e..98c2412d063e 100644 --- a/backend/internal/service/account_business_errors.go +++ b/backend/internal/service/account_business_errors.go @@ -83,10 +83,3 @@ func AccountJobFailure(code, message string) (string, string) { } return code, accountBusinessMessageCatalog[AccountJobCodeExecutionFailed].message } - -// NormalizeAccountBusinessFailure returns the code and sentence of a failure -// without underlying error text. Every reported code is kept, including codes -// without a catalog sentence; only an empty code becomes execution_failed. -func NormalizeAccountBusinessFailure(code string) (string, string) { - return AccountJobFailure(code, "") -} diff --git a/backend/internal/service/account_business_errors_test.go b/backend/internal/service/account_business_errors_test.go index b227ee45deab..116d05c42a57 100644 --- a/backend/internal/service/account_business_errors_test.go +++ b/backend/internal/service/account_business_errors_test.go @@ -27,7 +27,7 @@ func TestAccountBusinessMessageCatalogSeparatesPreviewAndFailureCodes(t *testing require.Equal(t, "pq: account 7 is referenced by usage_logs", message) // A code without a catalog sentence is kept as reported and gets the // generic item sentence. - code, message = NormalizeAccountBusinessFailure("code_not_in_catalog") + code, message = AccountJobFailure("code_not_in_catalog", "") require.Equal(t, "code_not_in_catalog", code) require.Equal(t, "account job item failed", message) diff --git a/backend/internal/service/account_job.go b/backend/internal/service/account_job.go index e309c05e28bb..98526dfad8b6 100644 --- a/backend/internal/service/account_job.go +++ b/backend/internal/service/account_job.go @@ -794,9 +794,3 @@ func normalizeAccountJobMetadata(raw json.RawMessage) json.RawMessage { } return append(json.RawMessage(nil), raw...) } - -// NormalizeAccountJobFailure keeps code and supplies the catalog sentence for -// a failure without underlying error text; see AccountJobFailure. -func NormalizeAccountJobFailure(code string) (string, string) { - return NormalizeAccountBusinessFailure(code) -} diff --git a/backend/internal/service/account_test_prompt.go b/backend/internal/service/account_test_prompt.go index f1b5c305ddb0..39b895f4c6bb 100644 --- a/backend/internal/service/account_test_prompt.go +++ b/backend/internal/service/account_test_prompt.go @@ -47,11 +47,11 @@ func accountTestUsesTextPrompt(model, mode string) bool { return !strings.Contains(strings.ToLower(model), "image") } -func validateAccountPromptExtension(ctx context.Context, account *Account, prompt, model, mode string) error { +func validateAccountPromptExtension(ctx context.Context, prompt, model, mode string) error { if strings.TrimSpace(prompt) == "" || !accountTestUsesTextPrompt(model, mode) || mode == "search" { return nil } - return accountToolsOperationForAccount(ctx, account, "test.prompt", extensionv1.TextPromptSelection{Characters: utf8.RuneCountInString(prompt), ValidUTF8: utf8.ValidString(prompt)}, nil) + return accountToolsOperation(ctx, "test.prompt", extensionv1.TextPromptSelection{Characters: utf8.RuneCountInString(prompt), ValidUTF8: utf8.ValidString(prompt)}, nil) } func ValidateAccountTestPrompt(prompt string) error { diff --git a/backend/internal/service/account_test_reasoning.go b/backend/internal/service/account_test_reasoning.go index 74daef7dadfb..393090800ade 100644 --- a/backend/internal/service/account_test_reasoning.go +++ b/backend/internal/service/account_test_reasoning.go @@ -104,10 +104,6 @@ func accountTestSupportsReasoningWire(account *Account, model string) bool { } } -func ValidateAccountTestReasoning(account *Account, model, mode, effort string) error { - return ValidateAccountTestReasoningContext(context.Background(), account, model, mode, effort) -} - func ValidateAccountTestReasoningContext(ctx context.Context, account *Account, model, mode, effort string) error { if effort == "" { return nil @@ -116,7 +112,7 @@ func ValidateAccountTestReasoningContext(ctx context.Context, account *Account, return errors.New("account is unavailable") } levels, _ := AccountTestReasoningOptions(account, model) - return accountToolsOperationForAccount(ctx, account, "test.reasoning", extensionv1.ReasoningSelection{Mode: mode, Effort: effort, Levels: levels}, nil) + return accountToolsOperation(ctx, "test.reasoning", extensionv1.ReasoningSelection{Mode: mode, Effort: effort, Levels: levels}, nil) } func applyAccountTestReasoning(c *gin.Context, payload map[string]any, chat bool) { diff --git a/backend/internal/service/account_test_reasoning_test.go b/backend/internal/service/account_test_reasoning_test.go index c02f42ba4a65..639d419f982d 100644 --- a/backend/internal/service/account_test_reasoning_test.go +++ b/backend/internal/service/account_test_reasoning_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "encoding/json" "net/http/httptest" "testing" @@ -14,11 +15,11 @@ func TestAccountTestReasoningValidatedAgainstMappedModel(t *testing.T) { a := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"model_mapping": map[string]any{"friendly": "gpt-6-astra"}}} levels, _ := AccountTestReasoningOptions(a, "friendly") require.Contains(t, levels, "ultra") - require.NoError(t, ValidateAccountTestReasoning(a, "friendly", "default", "ultra")) - require.Error(t, ValidateAccountTestReasoning(a, "friendly", "compact", "ultra")) - require.Error(t, ValidateAccountTestReasoning(a, "friendly", "default", "invented")) - require.Error(t, ValidateAccountTestReasoning(a, "gpt-image-2", "default", "high")) - require.NoError(t, ValidateAccountTestReasoning(a, "unknown-model", "default", "")) + require.NoError(t, ValidateAccountTestReasoningContext(context.Background(), a, "friendly", "default", "ultra")) + require.Error(t, ValidateAccountTestReasoningContext(context.Background(), a, "friendly", "compact", "ultra")) + require.Error(t, ValidateAccountTestReasoningContext(context.Background(), a, "friendly", "default", "invented")) + require.Error(t, ValidateAccountTestReasoningContext(context.Background(), a, "gpt-image-2", "default", "high")) + require.NoError(t, ValidateAccountTestReasoningContext(context.Background(), a, "unknown-model", "default", "")) } func TestAccountTestReasoningFinalPayload(t *testing.T) { @@ -46,8 +47,8 @@ func TestAccountTestReasoningDoesNotAdvertiseUnsupportedProtocol(t *testing.T) { account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{"test-model": {Reasoning: &supported, SupportedReasoningLevels: []string{"high"}}}}) levels, _ := AccountTestReasoningOptions(account, "test-model") require.Empty(t, levels) - require.Error(t, ValidateAccountTestReasoning(account, "test-model", "default", "high")) - require.NoError(t, ValidateAccountTestReasoning(account, "test-model", "default", "")) + require.Error(t, ValidateAccountTestReasoningContext(context.Background(), account, "test-model", "default", "high")) + require.NoError(t, ValidateAccountTestReasoningContext(context.Background(), account, "test-model", "default", "")) } } diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index e0606f09b3c8..ad205045261d 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -163,7 +163,6 @@ type AccountTestService struct { cfg *config.Config settingService *SettingService tlsFPProfileService *TLSFingerprintProfileService - openAIGatewayService *OpenAIGatewayService openaiGatewayService *OpenAIGatewayService modelMetadataRegistryMu sync.Mutex modelMetadataRegistry map[string]modelsDevProvider @@ -194,7 +193,6 @@ func (s *AccountTestService) SetPluginManager(pluginManager *PluginManager) { func (s *AccountTestService) SetOpenAIGatewayService(gateway *OpenAIGatewayService) { if s != nil { - s.openAIGatewayService = gateway s.openaiGatewayService = gateway } } @@ -214,14 +212,10 @@ func (s *AccountTestService) FetchOpenAIAccountCatalogModels(ctx context.Context } func (s *AccountTestService) fetchOpenAIAccountModels(ctx context.Context, account *Account, projectMapping bool) ([]openai.Model, error) { - if s == nil || (s.openAIGatewayService == nil && s.openaiGatewayService == nil) { + if s == nil || s.openaiGatewayService == nil { return nil, errors.New("OpenAI model discovery service is unavailable") } - gateway := s.openAIGatewayService - if gateway == nil { - gateway = s.openaiGatewayService - } - response, err := gateway.FetchOpenAIModelsList(ctx, account) + response, err := s.openaiGatewayService.FetchOpenAIModelsList(ctx, account) if err != nil { return nil, err } @@ -416,7 +410,7 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int return s.sendErrorAndEnd(c, "Account not found") } c.Set("account_test_allow_media", !accountTestUsesTextPrompt(modelID, mode)) - if err := validateAccountPromptExtension(ctx, account, prompt, modelID, mode); err != nil { + if err := validateAccountPromptExtension(ctx, prompt, modelID, mode); err != nil { return s.sendErrorAndEnd(c, err.Error()) } if err := ValidateAccountTestReasoningContext(ctx, account, modelID, mode, testOpts.ReasoningEffort); err != nil { @@ -1038,10 +1032,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account } // Process SSE stream - if err := s.processOpenAIStream(c, ctx, account, resp.Body); err != nil { - return err - } - return nil + return s.processOpenAIStream(c, account, resp.Body) } // testGrokAccountConnection routes Grok admin connectivity tests by explicit mode first, @@ -1353,7 +1344,7 @@ func (s *AccountTestService) testGrokResponsesConnection(c *gin.Context, ctx con return s.sendErrorAndEnd(c, fmt.Sprintf("Grok Responses API returned %d: %s", resp.StatusCode, string(body))) } - return s.processOpenAIStream(c, ctx, account, resp.Body) + return s.processOpenAIStream(c, account, resp.Body) } func (s *AccountTestService) testGrokImageGeneration(c *gin.Context, ctx context.Context, account *Account, authToken, modelID, prompt, imageDataURL string) error { @@ -2233,7 +2224,7 @@ func (s *AccountTestService) testOpenAIChatCompletionsConnection( return s.sendErrorAndEnd(c, fmt.Sprintf("Chat Completions API (/v1/chat/completions) returned %d: %s", resp.StatusCode, string(body))) } - return s.processOpenAIChatCompletionsStream(c, ctx, account, resp.Body) + return s.processOpenAIChatCompletionsStream(c, account, resp.Body) } // testOpenAICompactConnection probes native remote compaction v2 (streaming @@ -2483,11 +2474,7 @@ func (s *AccountTestService) markOpenAIBudgetExceededFromTest(ctx context.Contex return false } if s != nil { - gateway := s.openAIGatewayService - if gateway == nil { - gateway = s.openaiGatewayService - } - gateway.handleOpenAIBudgetExceeded(ctx, account, body) + s.openaiGatewayService.handleOpenAIBudgetExceeded(ctx, account, body) } return true } @@ -2847,12 +2834,12 @@ func (s *AccountTestService) processClaudeStream(c *gin.Context, body io.Reader) // processOpenAIChatCompletionsStream processes SSE chunks from the // OpenAI-compatible Chat Completions API. -func (s *AccountTestService) processOpenAIChatCompletionsStream(c *gin.Context, ctx context.Context, account *Account, body io.Reader) error { +func (s *AccountTestService) processOpenAIChatCompletionsStream(c *gin.Context, account *Account, body io.Reader) error { return s.processConnectionStream(c, body, "chat", account) } // processOpenAIStream processes the SSE stream from OpenAI Responses API -func (s *AccountTestService) processOpenAIStream(c *gin.Context, ctx context.Context, account *Account, body io.Reader) error { +func (s *AccountTestService) processOpenAIStream(c *gin.Context, account *Account, body io.Reader) error { return s.processConnectionStream(c, body, "responses", account) } diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go index b13b1d4e7701..d73665bdc0d6 100644 --- a/backend/internal/service/account_test_service_cn_adaptive.go +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -155,7 +155,7 @@ func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Co return s.sendErrorAndEnd(c, errMsg) } - if err := s.processOpenAIStream(c, ctx, account, resp.Body); err != nil { + if err := s.processOpenAIStream(c, account, resp.Body); err != nil { return err } s.sendEvent(c, TestEvent{Type: "status", Text: "已通过原生 /responses 验证"}) diff --git a/backend/internal/service/account_test_service_openai_test.go b/backend/internal/service/account_test_service_openai_test.go index 295416c22440..6790a4f47136 100644 --- a/backend/internal/service/account_test_service_openai_test.go +++ b/backend/internal/service/account_test_service_openai_test.go @@ -154,7 +154,7 @@ func TestAccountTestService_Budget429PutsAccountIntoError(t *testing.T) { } rateLimitService.SetAccountRuntimeBlocker(gateway) svc := &AccountTestService{ - accountRepo: repo, httpUpstream: upstream, openAIGatewayService: gateway, cfg: cfg, + accountRepo: repo, httpUpstream: upstream, openaiGatewayService: gateway, cfg: cfg, } account := newBudgetRelayAccount(8660, false) account.Concurrency = 1 diff --git a/backend/internal/service/account_test_service_opencode_go.go b/backend/internal/service/account_test_service_opencode_go.go index bc4f08709538..159db6d11908 100644 --- a/backend/internal/service/account_test_service_opencode_go.go +++ b/backend/internal/service/account_test_service_opencode_go.go @@ -101,7 +101,7 @@ func (s *AccountTestService) testOpenCodeGoConnection(c *gin.Context, account *A return s.processClaudeStream(c, resp.Body) } if protocol == APIProtocolChatCompletions { - return s.processOpenAIChatCompletionsStream(c, ctx, account, resp.Body) + return s.processOpenAIChatCompletionsStream(c, account, resp.Body) } - return s.processOpenAIStream(c, ctx, account, resp.Body) + return s.processOpenAIStream(c, account, resp.Body) } diff --git a/backend/internal/service/account_tools.go b/backend/internal/service/account_tools.go index 56bb18c1fdba..f59ee5d51ddc 100644 --- a/backend/internal/service/account_tools.go +++ b/backend/internal/service/account_tools.go @@ -10,24 +10,13 @@ import ( ) func accountToolsOperation(ctx context.Context, operation string, input, output any) error { - return accountToolsOperationScoped(ctx, 0, operation, input, output) -} - -func accountToolsOperationForAccount(ctx context.Context, account *Account, operation string, input, output any) error { - if account == nil { - return ErrExtensionOperationUnavailable - } - return accountToolsOperationScoped(ctx, account.ID, operation, input, output) -} - -func accountToolsOperationScoped(ctx context.Context, accountID int64, operation string, input, output any) error { raw, err := json.Marshal(input) if err != nil { return err } call, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() - result, err := invokeAccountTools(call, extensionv1.Invocation{Capability: extensionv1.CapabilityAdmin, Operation: operation, AccountID: accountID, Payload: raw}) + result, err := invokeAccountTools(call, extensionv1.Invocation{Operation: operation, Payload: raw}) if err != nil { // Administrators see why the operation failed (for example a timeout). return infraerrors.New(503, "ACCOUNT_TOOLS_UNAVAILABLE", "account tools are unavailable: "+err.Error()).WithCause(err) diff --git a/backend/internal/service/account_traffic_observe.go b/backend/internal/service/account_traffic_observe.go index 76e04710af8a..87afa355aac8 100644 --- a/backend/internal/service/account_traffic_observe.go +++ b/backend/internal/service/account_traffic_observe.go @@ -7,7 +7,6 @@ import ( "sync" "time" - "github.com/Wei-Shaw/sub2api/internal/config" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "go.uber.org/zap" @@ -121,18 +120,12 @@ type AccountTrafficObserver struct { // Whether turns are recorded follows the admin_observability_config setting, // whose default is the legacy gateway flag. -func NewAccountTrafficObserver(cache AccountTrafficObserveCache, _ *config.Config) *AccountTrafficObserver { +func NewAccountTrafficObserver(cache AccountTrafficObserveCache) *AccountTrafficObserver { return &AccountTrafficObserver{ cache: cache, } } -// Enabled reports whether telemetry is switched on, for management displays. -// Begin and Snapshot additionally require a concrete account identity. -func (o *AccountTrafficObserver) Enabled() bool { - return o != nil && o.cache != nil && currentAdminObservabilityConfig().TelemetryEnabled -} - // Begin records one started turn for account/protocol and samples in-flight // slots. It never returns an error: on failure it debug-logs and returns nil, // whose Finish is a no-op, so started and outcomes stay reconciled. @@ -213,12 +206,6 @@ func (t *AccountTrafficTurn) Finish(result *OpenAIForwardResult, err error, clie }) } -// Compatibility facade for callers without a begun turn. Started observations -// instead evaluate the rule table captured before incrementing. -func classifyAccountTrafficOutcome(result *OpenAIForwardResult, err error, clientCancelled bool) AccountTrafficOutcome { - return evaluateAccountTrafficOutcome(accountTrafficOutcomeRules, result, err, clientCancelled) -} - func evaluateAccountTrafficOutcome(table extensionv1.DecisionTable, result *OpenAIForwardResult, err error, clientCancelled bool) AccountTrafficOutcome { facts := map[string]string{"client_cancelled": strconv.FormatBool(clientCancelled || (result != nil && result.ClientDisconnect)), "has_result": strconv.FormatBool(result != nil), "has_error": strconv.FormatBool(err != nil), "ws": "false", "terminal": "", "terminal_status": "0", "error_status": "0"} if result != nil { diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index c5deb1c40953..c15b19e7aac9 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -458,7 +458,7 @@ func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]an if !AccountTakesSystemPrompt(account) { delete(account.Extra, AccountExtraSystemPromptKey) } - if err := ValidateModelContextOverrides(account, input.ModelContextOverrides); err != nil { + if err := ValidateModelContextOverrides(input.ModelContextOverrides); err != nil { return nil, err } if input.ModelContextOverrides != nil { @@ -622,7 +622,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U owned = openAIAPIKeyOwnedExtraForUpdate(account, input.Extra) } NormalizeAccountCredentialBaseURLs(input.Credentials) - if err := ValidateModelContextOverrides(account, input.ModelContextOverrides); err != nil { + if err := ValidateModelContextOverrides(input.ModelContextOverrides); err != nil { return nil, err } var normalizedExtra map[string]any @@ -912,9 +912,6 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U } } } - if err := ValidateModelContextOverrides(account, input.ModelContextOverrides); err != nil { - return nil, err - } account.ModelContextOverridesPatch = input.ModelContextOverrides if input.ModelContextOverrides != nil { overrides, err := ApplyAccountModelContextOverrides(account, account.Extra[ModelContextOverridesExtraKey], input.ModelContextOverrides) diff --git a/backend/internal/service/admin_account_taxonomy.go b/backend/internal/service/admin_account_taxonomy.go index 522dd253832f..8aed3dac8fc7 100644 --- a/backend/internal/service/admin_account_taxonomy.go +++ b/backend/internal/service/admin_account_taxonomy.go @@ -89,10 +89,6 @@ type AccountConsoleFacets struct { Tags []AccountManagementTag `json:"tags"` } -func normalizeAccountTaxonomyName(value string) (string, string, error) { - return normalizeAccountTaxonomyNameContext(context.Background(), value) -} - func (s *adminServiceImpl) ListAccountFolders(ctx context.Context) ([]AccountManagementFolder, error) { return s.listAccountFolders(ctx, true) } diff --git a/backend/internal/service/admin_account_taxonomy_test.go b/backend/internal/service/admin_account_taxonomy_test.go index 884c49f5a44a..94c11cb20355 100644 --- a/backend/internal/service/admin_account_taxonomy_test.go +++ b/backend/internal/service/admin_account_taxonomy_test.go @@ -74,14 +74,14 @@ func TestTaxonomyAssignmentPreservesPolicyNormalizationAndFailsClosed(t *testing } func TestNormalizeAccountTaxonomyNameTrimsAndBuildsCaseInsensitiveKey(t *testing.T) { - display, normalized, err := normalizeAccountTaxonomyName(" Production ") + display, normalized, err := normalizeAccountTaxonomyNameContext(context.Background(), " Production ") require.NoError(t, err) require.Equal(t, "Production", display) require.Equal(t, "production", normalized) - _, _, err = normalizeAccountTaxonomyName(" ") + _, _, err = normalizeAccountTaxonomyNameContext(context.Background(), " ") require.Error(t, err) - _, _, err = normalizeAccountTaxonomyName(string(make([]rune, 101))) + _, _, err = normalizeAccountTaxonomyNameContext(context.Background(), string(make([]rune, 101))) require.Error(t, err) } diff --git a/backend/internal/service/admin_observability_test.go b/backend/internal/service/admin_observability_test.go index 4039767a5525..eca865d56eff 100644 --- a/backend/internal/service/admin_observability_test.go +++ b/backend/internal/service/admin_observability_test.go @@ -41,8 +41,7 @@ func TestTrafficObservationFollowsTelemetrySwitch(t *testing.T) { t.Run(strconv.FormatBool(enabled), func(t *testing.T) { useAdminObservability(t, extensionv1.AdminObservabilityConfig{TelemetryEnabled: enabled}) cache := &trafficScopeCache{} - observer := NewAccountTrafficObserver(cache, nil) - require.Equal(t, enabled, observer.Enabled()) + observer := NewAccountTrafficObserver(cache) turn := observer.Begin(context.Background(), account, AccountTrafficProtocolWS) snapshot, err := observer.Snapshot(context.Background(), account) if !enabled { @@ -64,7 +63,7 @@ func TestTrafficObservationFollowsTelemetrySwitch(t *testing.T) { func TestTrafficObservationRejectsMissingAccountIdentity(t *testing.T) { useAdminObservability(t, extensionv1.AdminObservabilityConfig{TelemetryEnabled: true}) cache := &trafficScopeCache{} - observer := NewAccountTrafficObserver(cache, nil) + observer := NewAccountTrafficObserver(cache) for _, account := range []*Account{ nil, {Platform: PlatformOpenAI, Type: AccountTypeOAuth}, @@ -85,7 +84,7 @@ func TestTrafficObservationRejectsMissingAccountIdentity(t *testing.T) { func TestTrafficObservationFinishesStartedTurnAfterTelemetryIsSwitchedOff(t *testing.T) { useAdminObservability(t, extensionv1.AdminObservabilityConfig{TelemetryEnabled: true}) cache := &trafficScopeCache{} - observer := NewAccountTrafficObserver(cache, nil) + observer := NewAccountTrafficObserver(cache) account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth} turn := observer.Begin(context.Background(), account, AccountTrafficProtocolHTTP) require.NotNil(t, turn) diff --git a/backend/internal/service/gateway_image_reasoning_pricing_test.go b/backend/internal/service/gateway_image_reasoning_pricing_test.go index 84d86f8d4dac..39309153ba8a 100644 --- a/backend/internal/service/gateway_image_reasoning_pricing_test.go +++ b/backend/internal/service/gateway_image_reasoning_pricing_test.go @@ -130,7 +130,7 @@ func TestCalculateRecordUsageCost_MediaReasoningPricing(t *testing.T) { result.VideoCount, result.VideoDurationSeconds = 2, 5 } var err error - cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, nil, + cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{model}, 0.5, 0.5, 0.5, 0.5, UsageTokens{}, "", nil, time.Time{}) require.NoError(t, err) } diff --git a/backend/internal/service/image_studio_artifact.go b/backend/internal/service/image_studio_artifact.go index 2798ebe8b961..5805ac274bf4 100644 --- a/backend/internal/service/image_studio_artifact.go +++ b/backend/internal/service/image_studio_artifact.go @@ -37,7 +37,6 @@ func (s *ImageStudioArtifactStore) Save( kind ImageStudioArtifactKind, data []byte, contentType string, - expiresAt time.Time, ) (ImageStudioInputArtifact, error) { contentType, extension, err := validateImageStudioImage(data, contentType) if err != nil { diff --git a/backend/internal/service/image_studio_artifact_test.go b/backend/internal/service/image_studio_artifact_test.go index 885d3fc350b4..4e6a8c4d8f2c 100644 --- a/backend/internal/service/image_studio_artifact_test.go +++ b/backend/internal/service/image_studio_artifact_test.go @@ -24,7 +24,7 @@ func TestImageStudioArtifactStoreWritesOpaqueValidatedFile(t *testing.T) { store := NewImageStudioArtifactStore(t.TempDir(), &imageStudioUsageRepoStub{}) png := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3} - artifact, err := store.Save(context.Background(), 7, ImageStudioArtifactReference, png, "image/png", time.Now().Add(time.Hour)) + artifact, err := store.Save(context.Background(), 7, ImageStudioArtifactReference, png, "image/png") require.NoError(t, err) require.NotContains(t, artifact.StorageKey, "/") require.NotContains(t, artifact.StorageKey, `\`) @@ -45,7 +45,7 @@ func TestImageStudioArtifactStoreRejectsQuotaBeforeWriting(t *testing.T) { }}) png := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3} - _, err := store.Save(context.Background(), 7, ImageStudioArtifactOutput, png, "image/png", time.Now().Add(time.Hour)) + _, err := store.Save(context.Background(), 7, ImageStudioArtifactOutput, png, "image/png") var studioErr *ImageStudioError require.ErrorAs(t, err, &studioErr) require.Equal(t, "storage_quota_exceeded", studioErr.Code) @@ -55,7 +55,7 @@ func TestImageStudioArtifactStoreRejectsMismatchedContentType(t *testing.T) { store := NewImageStudioArtifactStore(t.TempDir(), &imageStudioUsageRepoStub{}) jpeg := []byte{0xff, 0xd8, 0xff, 0xdb, 1, 2, 3} - _, err := store.Save(context.Background(), 7, ImageStudioArtifactOutput, jpeg, "image/png", time.Now().Add(time.Hour)) + _, err := store.Save(context.Background(), 7, ImageStudioArtifactOutput, jpeg, "image/png") var studioErr *ImageStudioError require.ErrorAs(t, err, &studioErr) require.Equal(t, "invalid_image", studioErr.Code) diff --git a/backend/internal/service/image_studio_job.go b/backend/internal/service/image_studio_job.go index dc18f5c45536..efdcc07ea0df 100644 --- a/backend/internal/service/image_studio_job.go +++ b/backend/internal/service/image_studio_job.go @@ -206,7 +206,7 @@ type ImageStudioExecutor interface { } type ImageStudioFileStorage interface { - Save(ctx context.Context, userID int64, kind ImageStudioArtifactKind, data []byte, contentType string, expiresAt time.Time) (ImageStudioInputArtifact, error) + Save(ctx context.Context, userID int64, kind ImageStudioArtifactKind, data []byte, contentType string) (ImageStudioInputArtifact, error) Open(storageKey string) (io.ReadCloser, error) Read(storageKey string) ([]byte, error) Remove(storageKey string) error @@ -238,11 +238,6 @@ type ImageStudioRepository interface { DeleteExpiredJobs(ctx context.Context, now time.Time) error } -func ValidateImageStudioCreateInput(input ImageStudioCreateInput, hasReference, hasMask bool) error { - _, err := planImageStudio(context.Background(), input, hasReference, hasMask, false) - return err -} - func ResolveImageStudioTerminalStatus(cancelRequested bool, counts ImageStudioCounts) ImageStudioJobStatus { if cancelRequested { if counts.Succeeded > 0 { diff --git a/backend/internal/service/image_studio_job_service.go b/backend/internal/service/image_studio_job_service.go index 2002194442fa..4f31ba4172dd 100644 --- a/backend/internal/service/image_studio_job_service.go +++ b/backend/internal/service/image_studio_job_service.go @@ -36,23 +36,21 @@ type ImageStudioArtifactDownload struct { } type ImageStudioService struct { - runtime *ImageStudioRuntime - repo ImageStudioRepository - apiKeys APIKeyRepository - accounts AccountRepository - store ImageStudioFileStorage - now func() time.Time - origins imageStudioOrigins + runtime *ImageStudioRuntime + repo ImageStudioRepository + apiKeys APIKeyRepository + store ImageStudioFileStorage + now func() time.Time + origins imageStudioOrigins } func NewImageStudioService( repo ImageStudioRepository, apiKeys APIKeyRepository, - accounts AccountRepository, store ImageStudioFileStorage, ) *ImageStudioService { return &ImageStudioService{ - repo: repo, apiKeys: apiKeys, accounts: accounts, store: store, now: time.Now, + repo: repo, apiKeys: apiKeys, store: store, now: time.Now, } } @@ -69,7 +67,7 @@ func imageStudioGroupEligible(group *Group) bool { // EligibleKeys lists the caller's active API keys whose group allows image // generation while Image Studio has a model source. func (s *ImageStudioService) EligibleKeys(ctx context.Context, userID int64) ([]ImageStudioEligibleKey, error) { - if s == nil || s.apiKeys == nil || s.accounts == nil || userID <= 0 { + if s == nil || s.apiKeys == nil || userID <= 0 { return nil, newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") } lister, ok := s.apiKeys.(apiKeyAllByUserIDLister) @@ -95,7 +93,7 @@ func (s *ImageStudioService) EligibleKeys(ctx context.Context, userID int64) ([] } func (s *ImageStudioService) eligibleAPIKey(ctx context.Context, userID, apiKeyID int64) (*APIKey, error) { - if s == nil || s.apiKeys == nil || s.accounts == nil { + if s == nil || s.apiKeys == nil { return nil, newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") } key, err := s.apiKeys.GetByID(ctx, apiKeyID) @@ -112,7 +110,7 @@ func (s *ImageStudioService) Create( reference, mask *ImageStudioUpload, ) (*ImageStudioJob, error) { input.Prompt = strings.TrimSpace(input.Prompt) - plan, err := planImageStudio(ctx, input, reference != nil, mask != nil, true) + plan, err := planImageStudio(ctx, input, reference != nil, mask != nil) if err != nil { return nil, err } @@ -137,14 +135,14 @@ func (s *ImageStudioService) Create( } } if reference != nil { - artifact, err := s.store.Save(ctx, userID, ImageStudioArtifactReference, reference.Data, reference.ContentType, expiresAt) + artifact, err := s.store.Save(ctx, userID, ImageStudioArtifactReference, reference.Data, reference.ContentType) if err != nil { return nil, err } saved = append(saved, artifact) } if mask != nil { - artifact, err := s.store.Save(ctx, userID, ImageStudioArtifactMask, mask.Data, mask.ContentType, expiresAt) + artifact, err := s.store.Save(ctx, userID, ImageStudioArtifactMask, mask.Data, mask.ContentType) if err != nil { removeSaved() return nil, err diff --git a/backend/internal/service/image_studio_job_service_test.go b/backend/internal/service/image_studio_job_service_test.go index 0b14340299d4..98f23e1f8656 100644 --- a/backend/internal/service/image_studio_job_service_test.go +++ b/backend/internal/service/image_studio_job_service_test.go @@ -8,7 +8,6 @@ import ( "io" "strings" "testing" - "time" "github.com/stretchr/testify/require" ) @@ -32,15 +31,6 @@ func (f *imageStudioAPIKeyRepoFake) GetByID(_ context.Context, id int64) (*APIKe return nil, ErrAPIKeyNotFound } -type imageStudioAccountReaderFake struct { - AccountRepository - accounts map[int64][]Account -} - -func (f *imageStudioAccountReaderFake) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) { - return append([]Account(nil), f.accounts[groupID]...), nil -} - type imageStudioRepoFake struct { ImageStudioRepository created ImageStudioCreateParams @@ -57,7 +47,7 @@ type imageStudioStoreFake struct { removed []string } -func (f *imageStudioStoreFake) Save(_ context.Context, _ int64, kind ImageStudioArtifactKind, data []byte, contentType string, _ time.Time) (ImageStudioInputArtifact, error) { +func (f *imageStudioStoreFake) Save(_ context.Context, _ int64, kind ImageStudioArtifactKind, data []byte, contentType string) (ImageStudioInputArtifact, error) { artifact := ImageStudioInputArtifact{Kind: kind, StorageKey: string(kind) + ".png", ContentType: contentType, ByteSize: int64(len(data))} f.saved = append(f.saved, artifact) return artifact, nil @@ -73,24 +63,18 @@ func (f *imageStudioStoreFake) Remove(key string) error { return nil } -func canonicalImageStudioFixture() (*Group, APIKey, Account) { +func canonicalImageStudioFixture() APIKey { group := &Group{ID: 31, Name: "Images", Platform: PlatformOpenAI, Status: StatusActive, AllowImageGeneration: true} - key := APIKey{ID: 41, UserID: 7, Key: "must-never-leave-server", Name: "Studio", Status: StatusActive, GroupID: &group.ID, Group: group} - account := Account{ - ID: 51, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, - Credentials: map[string]any{"api_key": "upstream-secret", "base_url": "https://relay.example.test"}, - } - return group, key, account + return APIKey{ID: 41, UserID: 7, Key: "must-never-leave-server", Name: "Studio", Status: StatusActive, GroupID: &group.ID, Group: group} } func TestImageStudioEligibleKeysHidesUnavailableImageCandidatesAndCredentials(t *testing.T) { - group, key, account := canonicalImageStudioFixture() + key := canonicalImageStudioFixture() legacyGroup := &Group{ID: 32, Name: "legacy", Platform: PlatformOpenAI, Status: StatusActive, AllowImageGeneration: true} legacyKey := APIKey{ID: 42, UserID: 7, Key: "legacy-secret", Name: "legacy", Status: StatusActive, GroupID: &legacyGroup.ID, Group: legacyGroup} studio := NewImageStudioService( &imageStudioRepoFake{}, &imageStudioAPIKeyRepoFake{keys: []APIKey{key, legacyKey}}, - &imageStudioAccountReaderFake{accounts: map[int64][]Account{group.ID: {account}}}, &imageStudioStoreFake{}, ) @@ -101,6 +85,5 @@ func TestImageStudioEligibleKeysHidesUnavailableImageCandidatesAndCredentials(t raw, err := json.Marshal(items) require.NoError(t, err) require.NotContains(t, string(raw), "must-never-leave-server") - require.NotContains(t, string(raw), "upstream-secret") require.NotContains(t, string(raw), `"key"`) } diff --git a/backend/internal/service/image_studio_job_test.go b/backend/internal/service/image_studio_job_test.go index e7f2307effd8..2aa08c8f4847 100644 --- a/backend/internal/service/image_studio_job_test.go +++ b/backend/internal/service/image_studio_job_test.go @@ -7,10 +7,14 @@ import ( "fmt" "testing" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) -func TestValidateImageStudioCreateInputEnforcesFixedModelsAndNativeOneFanout(t *testing.T) { +func TestPlanImageStudioEnforcesFixedModelsAndNativeOneFanout(t *testing.T) { + previous := imageToolsConfigOverride.Load() + t.Cleanup(func() { imageToolsConfigOverride.Store(previous) }) + ConfigureImageTools(&extensionv1.ImageToolsConfig{StudioEnabled: true}) tests := []struct { name string input ImageStudioCreateInput @@ -80,7 +84,7 @@ func TestValidateImageStudioCreateInputEnforcesFixedModelsAndNativeOneFanout(t * for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := ValidateImageStudioCreateInput(tt.input, tt.hasReference, tt.hasMask) + _, err := planImageStudio(context.Background(), tt.input, tt.hasReference, tt.hasMask) if tt.wantCode == "" { require.NoError(t, err) return diff --git a/backend/internal/service/image_studio_runtime.go b/backend/internal/service/image_studio_runtime.go index 49c3061595fc..ee6fd9a47c28 100644 --- a/backend/internal/service/image_studio_runtime.go +++ b/backend/internal/service/image_studio_runtime.go @@ -313,7 +313,7 @@ func (r *ImageStudioRuntime) processClaim(ctx context.Context, claim *ImageStudi return } expiresAt := time.Now().Add(ImageStudioFileRetention) - artifact, err := r.store.Save(ctx, job.UserID, ImageStudioArtifactOutput, result.Data, result.ContentType, expiresAt) + artifact, err := r.store.Save(ctx, job.UserID, ImageStudioArtifactOutput, result.Data, result.ContentType) if err != nil { r.failClaim(ctx, job.ID, claim.Item.ID, err) return diff --git a/backend/internal/service/image_tools.go b/backend/internal/service/image_tools.go index a25e69499e37..bfcb3bb2a63d 100644 --- a/backend/internal/service/image_tools.go +++ b/backend/internal/service/image_tools.go @@ -12,14 +12,14 @@ import ( var imageToolsConfigOverride atomic.Pointer[extensionv1.ImageToolsConfig] -// currentImageToolsConfig returns the effective Image Studio switch: the -// stored administrator setting once loaded, otherwise the deploy-time flag -// (the value the former image-tools plugin was seeded with). -func currentImageToolsConfig() (extensionv1.ImageToolsConfig, bool) { +// EffectiveImageToolsConfig returns the Image Studio switch this process +// applies: the stored administrator setting once loaded, otherwise the +// deploy-time flag (the value the former image-tools plugin was seeded with). +func EffectiveImageToolsConfig() extensionv1.ImageToolsConfig { if config := imageToolsConfigOverride.Load(); config != nil { - return *config, true + return *config } - return LegacyImageToolsConfig(), true + return LegacyImageToolsConfig() } // imageStudioEnabledFromEnvironment is the deploy-time @@ -37,8 +37,7 @@ func LegacyImageToolsConfig() extensionv1.ImageToolsConfig { // ImageStudioFeatureEnabled reports whether Image Studio is switched on. func ImageStudioFeatureEnabled() bool { - value, _ := currentImageToolsConfig() - return value.StudioEnabled + return EffectiveImageToolsConfig().StudioEnabled } // imageStudioStop is closed whenever Image Studio is switched off so running @@ -62,8 +61,7 @@ func SetImageStudioStarter(start func()) { // update, tests). A nil value falls back to the deploy-time rollout flags. func ConfigureImageTools(config *extensionv1.ImageToolsConfig) { imageToolsConfigOverride.Store(config) - current, _ := currentImageToolsConfig() - if current.StudioEnabled { + if ImageStudioFeatureEnabled() { if start := imageStudioStarter.Load(); start != nil { (*start)() } @@ -106,14 +104,13 @@ func EnsureImageStudioAvailable(ctx context.Context) error { if err := ctx.Err(); err != nil { return newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") } - config, _ := currentImageToolsConfig() - if !config.StudioEnabled { + if !ImageStudioFeatureEnabled() { return newImageStudioError(404, "studio_disabled", "Image Studio is not enabled") } return nil } -func planImageStudio(ctx context.Context, input ImageStudioCreateInput, hasReference, hasMask, execute bool) (extensionv1.ImageStudioPlan, error) { +func planImageStudio(ctx context.Context, input ImageStudioCreateInput, hasReference, hasMask bool) (extensionv1.ImageStudioPlan, error) { request := extensionv1.ImageStudioPlanRequest{APIKeySelected: input.APIKeyID > 0, Mode: string(input.Mode), Model: input.Model, PromptBytes: len(strings.TrimSpace(input.Prompt)), Count: input.Count, Size: input.Size, Quality: input.Quality, HasReference: hasReference, HasMask: hasMask} if err := ctx.Err(); err != nil { return extensionv1.ImageStudioPlan{}, newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") @@ -122,7 +119,7 @@ func planImageStudio(ctx context.Context, input ImageStudioCreateInput, hasRefer if code != "" { return plan, newImageStudioError(400, code, message) } - if config, _ := currentImageToolsConfig(); execute && !config.StudioEnabled { + if !ImageStudioFeatureEnabled() { return plan, newImageStudioError(404, "studio_disabled", "Image Studio is not enabled") } return plan, nil @@ -133,5 +130,5 @@ func PlanImageStudioExecution(ctx context.Context, request ImageStudioExecutionR if request.APIKey != nil { keyID = request.APIKey.ID } - return planImageStudio(ctx, ImageStudioCreateInput{APIKeyID: keyID, Mode: request.Job.Mode, Model: request.Job.Model, Prompt: request.Job.Prompt, Count: 1, Size: request.Job.Size, Quality: request.Job.Quality}, len(request.Reference) > 0, len(request.Mask) > 0, true) + return planImageStudio(ctx, ImageStudioCreateInput{APIKeyID: keyID, Mode: request.Job.Mode, Model: request.Job.Model, Prompt: request.Job.Prompt, Count: 1, Size: request.Job.Size, Quality: request.Job.Quality}, len(request.Reference) > 0, len(request.Mask) > 0) } diff --git a/backend/internal/service/image_tools_policy_test.go b/backend/internal/service/image_tools_policy_test.go index 4a9ba189e658..23eb442d0a4f 100644 --- a/backend/internal/service/image_tools_policy_test.go +++ b/backend/internal/service/image_tools_policy_test.go @@ -12,23 +12,20 @@ func TestImageToolsStudioPlanRequiresExplicitEnablementAndKeepsNativeOne(t *test t.Cleanup(func() { ConfigureImageTools(nil) }) input := ImageStudioCreateInput{APIKeyID: 1, Mode: "generate", Model: "gpt-image-2", Prompt: "fixture text", Count: 4} ConfigureImageTools(&extensionv1.ImageToolsConfig{}) - if _, err := planImageStudio(context.Background(), input, false, false, true); err == nil { + if _, err := planImageStudio(context.Background(), input, false, false); err == nil { t.Fatal("default must remain disabled") } - if _, err := planImageStudio(context.Background(), input, false, false, false); err != nil { - t.Fatalf("validation must not require enablement: %v", err) - } ConfigureImageTools(&extensionv1.ImageToolsConfig{StudioEnabled: true}) - plan, err := planImageStudio(context.Background(), input, false, false, true) + plan, err := planImageStudio(context.Background(), input, false, false) if err != nil || plan.OutputPerRequest != 1 || plan.Size != "1024x1024" || plan.Endpoint != "/v1/images/generations" { t.Fatalf("unexpected plan: %+v %v", plan, err) } input.Mode = "edit" - if _, err := planImageStudio(context.Background(), input, true, false, true); err == nil { + if _, err := planImageStudio(context.Background(), input, true, false); err == nil { t.Fatal("unsupported editing must be rejected") } input.Model = "gemini-3-pro-image" - plan, err = planImageStudio(context.Background(), input, true, false, true) + plan, err = planImageStudio(context.Background(), input, true, false) if err != nil || plan.Endpoint != "/v1/images/edits" { t.Fatalf("supported editing plan unavailable: %+v %v", plan, err) } diff --git a/backend/internal/service/model_context_capacity.go b/backend/internal/service/model_context_capacity.go index 04dade75fcf1..a07eb6e3cb59 100644 --- a/backend/internal/service/model_context_capacity.go +++ b/backend/internal/service/model_context_capacity.go @@ -239,16 +239,8 @@ func isMediaModelForCapacity(modelID string) bool { } // ValidateModelContextOverrides distinguishes omitted (nil map), no-op (empty -// map), and deletion (nil value at one model key). It never mutates the account; -// every account may carry overrides. -func ValidateModelContextOverrides(_ *Account, patch map[string]*int64) error { - if patch == nil { - return nil - } - return validateModelContextOverridesPatch(patch) -} - -func validateModelContextOverridesPatch(patch map[string]*int64) error { +// map), and deletion (nil value at one model key). +func ValidateModelContextOverrides(patch map[string]*int64) error { for modelID, value := range patch { if !validModelContextID(modelID) { return infraerrors.BadRequest("INVALID_MODEL_CONTEXT_OVERRIDE", "model context override requires a concrete upstream model ID") @@ -263,7 +255,7 @@ func validateModelContextOverridesPatch(patch map[string]*int64) error { // ApplyModelContextOverrides is also used under the repository row lock. The // returned map is fresh, and a patch cannot erase unrelated model overrides. func ApplyModelContextOverrides(existing any, patch map[string]*int64) (map[string]int64, error) { - if err := validateModelContextOverridesPatch(patch); err != nil { + if err := ValidateModelContextOverrides(patch); err != nil { return nil, err } values := modelContextOverridesFromExtra(existing) diff --git a/backend/internal/service/model_context_capacity_test.go b/backend/internal/service/model_context_capacity_test.go index 322ae5992e70..612fa12eb291 100644 --- a/backend/internal/service/model_context_capacity_test.go +++ b/backend/internal/service/model_context_capacity_test.go @@ -223,9 +223,8 @@ func TestModelContextCapacityCatalogParserKeepsIndependentModels(t *testing.T) { } func TestModelContextCapacityOverridesPatchValidationAndIsolation(t *testing.T) { - account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} value := int64(1050000) - if err := ValidateModelContextOverrides(account, map[string]*int64{"real-model": &value}); err != nil { + if err := ValidateModelContextOverrides(map[string]*int64{"real-model": &value}); err != nil { t.Fatal(err) } for _, test := range []struct { @@ -233,7 +232,7 @@ func TestModelContextCapacityOverridesPatchValidationAndIsolation(t *testing.T) value int64 }{{"*", 1}, {" bad", 1}, {"", 1}, {"bad\n", 1}, {"good", 0}, {"good", -1}, {"good", MaxSafeModelContextTokens + 1}} { value := test.value - if ValidateModelContextOverrides(account, map[string]*int64{test.id: &value}) == nil { + if ValidateModelContextOverrides(map[string]*int64{test.id: &value}) == nil { t.Errorf("accepted invalid override %q=%d", test.id, value) } } @@ -246,8 +245,8 @@ func TestModelContextCapacityOverridesPatchValidationAndIsolation(t *testing.T) if existing["delete"] != 200000 || existing["change"] != 400000 { t.Fatal("patch mutated the old snapshot") } - account.Type = AccountTypeOAuth - if ValidateModelContextOverrides(account, map[string]*int64{"gpt-6-sol": &value}) != nil { + oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + if _, err := ApplyAccountModelContextOverrides(oauth, nil, map[string]*int64{"gpt-6-sol": &value}); err != nil { t.Fatal("an OAuth account must accept overrides like any other account") } } diff --git a/backend/internal/service/model_context_identity.go b/backend/internal/service/model_context_identity.go index 422c6c8fa5fd..49c48c45d961 100644 --- a/backend/internal/service/model_context_identity.go +++ b/backend/internal/service/model_context_identity.go @@ -231,7 +231,7 @@ func upstreamMetadataCapacity(snapshotSource string, entry UpstreamModelMetadata // Explicit edits address one logical upstream model and remove its obsolete // spelling keys. Unedited values are preserved, including unresolved conflicts. func ApplyAccountModelContextOverrides(account *Account, existing any, patch map[string]*int64) (map[string]int64, error) { - if err := ValidateModelContextOverrides(account, patch); err != nil { + if err := ValidateModelContextOverrides(patch); err != nil { return nil, err } resolve := capacityOverrideTargetResolver(account) diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 3c3f3bb3aa39..91791621419c 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -721,33 +721,13 @@ func openAIOAuth429SameAccountRetryDelay(headers http.Header, deadline time.Time } func (s *OpenAIGatewayService) BlockAccountScheduling(account *Account, until time.Time, reason string) { - s.blockAccountScheduling(account, until, reason, false) -} - -// BlockAccountSchedulingFromPersistedCooldown mirrors only the ordinary DB -// cooldown fields. Request-owned cooldowns and credential mutation guards -// continue to use BlockAccountScheduling instead. -func (s *OpenAIGatewayService) BlockAccountSchedulingFromPersistedCooldown(account *Account, until time.Time, reason string) { - if until.IsZero() { - s.BlockAccountScheduling(account, until, reason) - return - } - s.blockAccountScheduling(account, until, reason, true) -} - -func (s *OpenAIGatewayService) blockAccountScheduling(account *Account, until time.Time, reason string, fromPersistedCooldown bool) { if s == nil || !isOpenAIAccount(account) { return } mu := s.openAIAccountRuntimeBlockLock(account.ID) mu.Lock() defer mu.Unlock() - _, _ = s.blockAccountSchedulingLockedWithSource(account, until, reason, fromPersistedCooldown) - if fromPersistedCooldown { - // A DB mirror is not an independent Redis breaker. Persisting its - // deadline there would keep vetoing the account after DB recovery. - return - } + _, _ = s.blockAccountSchedulingLocked(account, until, reason) if account.Type != AccountTypeAPIKey { sources := s.openAIAccountRuntimeBlockSourcesLocked(account.ID) if sources.hasIndependent { @@ -756,6 +736,21 @@ func (s *OpenAIGatewayService) blockAccountScheduling(account *Account, until ti } } +// BlockAccountSchedulingFromPersistedCooldown mirrors only the ordinary DB +// cooldown fields. Request-owned cooldowns and credential mutation guards +// continue to use BlockAccountScheduling instead. +func (s *OpenAIGatewayService) BlockAccountSchedulingFromPersistedCooldown(account *Account, until time.Time) { + if s == nil || !isOpenAIAccount(account) { + return + } + mu := s.openAIAccountRuntimeBlockLock(account.ID) + mu.Lock() + defer mu.Unlock() + // A DB mirror is not an independent Redis breaker. Persisting its + // deadline there would keep vetoing the account after DB recovery. + _, _ = s.blockAccountSchedulingLockedWithSource(account, until, true) +} + func (s *OpenAIGatewayService) openAIRuntimeBreakerStore() (OpenAIRuntimeBreakerStore, bool) { if s == nil || s.cache == nil { return nil, false @@ -851,7 +846,7 @@ type openAIAccountRuntimeBlockSources struct { func (sources openAIAccountRuntimeBlockSources) effectiveUntil() (time.Time, bool) { if sources.hasIndependent { - if sources.independentUntil.IsZero() || !sources.hasPersisted || !sources.persistedUntil.After(sources.independentUntil) { + if !sources.hasPersisted || !sources.persistedUntil.After(sources.independentUntil) { return sources.independentUntil, true } } @@ -859,7 +854,7 @@ func (sources openAIAccountRuntimeBlockSources) effectiveUntil() (time.Time, boo } func (sources *openAIAccountRuntimeBlockSources) expire(now time.Time) { - if sources.hasIndependent && !sources.independentUntil.IsZero() && !now.Before(sources.independentUntil) { + if sources.hasIndependent && !now.Before(sources.independentUntil) { sources.hasIndependent = false sources.independentUntil = time.Time{} sources.independentOwner = 0 @@ -870,8 +865,6 @@ func (sources *openAIAccountRuntimeBlockSources) expire(now time.Time) { } } -// Unknown entries retain the legacy independent meaning. Only an explicit -// persisted-cooldown writer may make a block eligible for DB-authority cleanup. // Callers hold the per-account runtime block lock. func (s *OpenAIGatewayService) openAIAccountRuntimeBlockSourcesLocked(accountID int64) openAIAccountRuntimeBlockSources { if raw, ok := s.openaiAccountRuntimeBlockSources.Load(accountID); ok { @@ -879,21 +872,14 @@ func (s *OpenAIGatewayService) openAIAccountRuntimeBlockSourcesLocked(accountID return sources } } - if raw, ok := s.openaiAccountRuntimeBlockUntil.Load(accountID); ok { - if until, valid := raw.(time.Time); valid { - generation, _ := s.openaiAccountRuntimeBlockGeneration.Load(accountID) - owner, _ := generation.(uint64) - return openAIAccountRuntimeBlockSources{hasIndependent: true, independentUntil: until, independentOwner: owner} - } - } return openAIAccountRuntimeBlockSources{} } -func (s *OpenAIGatewayService) blockAccountSchedulingLocked(account *Account, until time.Time, reason string) (uint64, bool) { - return s.blockAccountSchedulingLockedWithSource(account, until, reason, false) +func (s *OpenAIGatewayService) blockAccountSchedulingLocked(account *Account, until time.Time, _ string) (uint64, bool) { + return s.blockAccountSchedulingLockedWithSource(account, until, false) } -func (s *OpenAIGatewayService) blockAccountSchedulingLockedWithSource(account *Account, until time.Time, reason string, fromPersistedCooldown bool) (uint64, bool) { +func (s *OpenAIGatewayService) blockAccountSchedulingLockedWithSource(account *Account, until time.Time, fromPersistedCooldown bool) (uint64, bool) { now := time.Now() blockUntil := until if blockUntil.IsZero() || !blockUntil.After(now) { @@ -911,7 +897,7 @@ func (s *OpenAIGatewayService) blockAccountSchedulingLockedWithSource(account *A } sources.hasPersisted = true } else { - if !sources.hasIndependent || blockUntil.IsZero() || (!sources.independentUntil.IsZero() && blockUntil.After(sources.independentUntil)) { + if !sources.hasIndependent || blockUntil.After(sources.independentUntil) { sources.independentUntil = blockUntil } sources.hasIndependent = true @@ -963,10 +949,6 @@ func (s *OpenAIGatewayService) clearOpenAIAccountSchedulingBlockScope(accountID } func (s *OpenAIGatewayService) isOpenAIAccountRuntimeBlocked(account *Account) bool { - return s.isOpenAIAccountRuntimeBlockedContext(context.Background(), account) -} - -func (s *OpenAIGatewayService) isOpenAIAccountRuntimeBlockedContext(_ context.Context, account *Account) bool { if s == nil || account == nil || account.ID <= 0 { return false } @@ -1171,7 +1153,7 @@ func (s *OpenAIGatewayService) isOpenAIAccountRequestRuntimeBlockedContext(ctx c } // Re-read after the conditional clear so a concurrent replacement remains // blocked. - if s.isOpenAIAccountRuntimeBlockedContext(ctx, account) || s.isOpenAIAccountModelRuntimeBlocked(account, requestedModel) { + if s.isOpenAIAccountRuntimeBlocked(account) || s.isOpenAIAccountModelRuntimeBlocked(account, requestedModel) { return true } if account.Type == AccountTypeAPIKey { diff --git a/backend/internal/service/openai_account_runtime_block_fastpath_test.go b/backend/internal/service/openai_account_runtime_block_fastpath_test.go index 2805d21ac454..b0ff0d5d2f90 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath_test.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath_test.go @@ -931,7 +931,7 @@ func TestOpenAIRuntimeBreaker_LateSuccessDoesNotClearActiveModelCooldown(t *test svc.CooldownOpenAIRetryExhausted(context.Background(), account, "gpt-5.4", &UpstreamFailoverError{ StatusCode: http.StatusServiceUnavailable, }) - svc.BlockAccountSchedulingFromPersistedCooldown(account, time.Now().Add(time.Minute), "stale_account_cooldown") + svc.BlockAccountSchedulingFromPersistedCooldown(account, time.Now().Add(time.Minute)) svc.ReportOpenAIAccountScheduleResult(account.ID, "gpt-5.4", true, nil) require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4")) @@ -1485,7 +1485,7 @@ func TestOpenAIRuntimeBlock_ClearAccountSchedulingBlock(t *testing.T) { func TestRuntimeBlockHonorsClearedPersistedCooldown(t *testing.T) { svc := &OpenAIGatewayService{} account := &Account{ID: 92, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true} - svc.BlockAccountSchedulingFromPersistedCooldown(account, time.Now().Add(30*time.Minute), "grok payment required") + svc.BlockAccountSchedulingFromPersistedCooldown(account, time.Now().Add(30*time.Minute)) require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "grok-3")) require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) } @@ -1502,10 +1502,10 @@ func TestRuntimeBlockConditionalClearSkipsNewerGeneration(t *testing.T) { svc := &OpenAIGatewayService{} account := &Account{ID: 94, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true} firstUntil := time.Now().Add(10 * time.Minute) - svc.BlockAccountSchedulingFromPersistedCooldown(account, firstUntil, "stale") + svc.BlockAccountSchedulingFromPersistedCooldown(account, firstUntil) snapshot := svc.peekOpenAIAccountRuntimeBlock(account) require.True(t, snapshot.blocked) - svc.BlockAccountSchedulingFromPersistedCooldown(account, firstUntil.Add(testCase.extension), "fresh") + svc.BlockAccountSchedulingFromPersistedCooldown(account, firstUntil.Add(testCase.extension)) svc.clearOpenAIAccountRuntimeBlockIfUnchanged(account.ID, snapshot) require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "grok-3")) @@ -1518,7 +1518,7 @@ func TestRuntimeBlockConditionalClearKeepsOAuthProbeOwnership(t *testing.T) { cache := &runtimeBreakerTestCache{entries: make(map[string]runtimeBreakerTestEntry)} svc := &OpenAIGatewayService{cache: cache} account := &Account{ID: 95, Platform: PlatformOpenAI, Type: AccountTypeOAuth} - svc.BlockAccountSchedulingFromPersistedCooldown(account, time.Now().Add(time.Minute), "stale_local_cooldown") + svc.BlockAccountSchedulingFromPersistedCooldown(account, time.Now().Add(time.Minute)) cache.mu.Lock() cache.entries[runtimeBreakerTestKey(account.ID, "")] = runtimeBreakerTestEntry{ blockUntil: time.Now().Add(-time.Second), owner: "current-owner", @@ -1550,7 +1550,7 @@ func TestRuntimeBlockKeepsActivePersistedCooldown(t *testing.T) { Schedulable: true, TempUnschedulableUntil: &until, } - svc.BlockAccountSchedulingFromPersistedCooldown(account, until, "grok payment required") + svc.BlockAccountSchedulingFromPersistedCooldown(account, until) require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "grok-3")) require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) } @@ -1582,11 +1582,11 @@ func TestRuntimeBlockSourcesKeepIndependentDeadlineAndClearWithoutResurrection(t independentUntil := time.Now().Add(time.Minute) persistedUntil := independentUntil.Add(time.Hour) if persistedFirst { - svc.BlockAccountSchedulingFromPersistedCooldown(account, persistedUntil, "429") + svc.BlockAccountSchedulingFromPersistedCooldown(account, persistedUntil) } svc.BlockAccountScheduling(account, independentUntil, "429") if !persistedFirst { - svc.BlockAccountSchedulingFromPersistedCooldown(account, persistedUntil, "429") + svc.BlockAccountSchedulingFromPersistedCooldown(account, persistedUntil) } require.Equal(t, persistedUntil, svc.peekOpenAIAccountRuntimeBlock(account).until) require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4")) @@ -1597,7 +1597,7 @@ func TestRuntimeBlockSourcesKeepIndependentDeadlineAndClearWithoutResurrection(t svc.ClearAccountSchedulingBlock(account.ID) _, sourcesRemain := svc.openaiAccountRuntimeBlockSources.Load(account.ID) require.False(t, sourcesRemain) - svc.BlockAccountSchedulingFromPersistedCooldown(account, persistedUntil, "429") + svc.BlockAccountSchedulingFromPersistedCooldown(account, persistedUntil) require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4"), "a cleared independent contribution must not reappear behind a new DB mirror") }) @@ -1609,7 +1609,7 @@ func TestRuntimeBlockSourcesDoNotExtendIndependentRedisDeadline(t *testing.T) { svc := &OpenAIGatewayService{cache: cache} account := &Account{ID: 97, Platform: PlatformOpenAI, Type: AccountTypeOAuth} independentUntil := time.Now().Add(time.Minute) - svc.BlockAccountSchedulingFromPersistedCooldown(account, independentUntil.Add(time.Hour), "429") + svc.BlockAccountSchedulingFromPersistedCooldown(account, independentUntil.Add(time.Hour)) require.Empty(t, cache.entries, "DB mirrors do not create independent Redis breakers") svc.BlockAccountScheduling(account, independentUntil, "429") cache.mu.Lock() @@ -1637,11 +1637,11 @@ func TestRuntimeBlockSourcesRollbackOnlyOwnedIndependentContribution(t *testing. account := &Account{ID: 98, Platform: PlatformGrok, Type: AccountTypeOAuth} now := time.Now() if testCase.beforeDB > 0 { - svc.BlockAccountSchedulingFromPersistedCooldown(account, now.Add(testCase.beforeDB), "429") + svc.BlockAccountSchedulingFromPersistedCooldown(account, now.Add(testCase.beforeDB)) } rollback := svc.blockGrokCredentialRuntime(account, now.Add(testCase.tentative), "credential_tentative") if testCase.afterDB > 0 { - svc.BlockAccountSchedulingFromPersistedCooldown(account, now.Add(testCase.afterDB), "429") + svc.BlockAccountSchedulingFromPersistedCooldown(account, now.Add(testCase.afterDB)) } rollback() snapshot := svc.peekOpenAIAccountRuntimeBlock(account) diff --git a/backend/internal/service/openai_account_runtime_transient_test.go b/backend/internal/service/openai_account_runtime_transient_test.go index 960d217a6b70..c181cd040a84 100644 --- a/backend/internal/service/openai_account_runtime_transient_test.go +++ b/backend/internal/service/openai_account_runtime_transient_test.go @@ -120,7 +120,7 @@ func TestHandleOpenAITransientError_PersistedCooldownClearsWithDatabaseState(t * svc := &OpenAIGatewayService{} account := &Account{ID: 5106, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} - svc.BlockAccountSchedulingFromPersistedCooldown(account, time.Now().Add(time.Minute), "upstream_disable") + svc.BlockAccountSchedulingFromPersistedCooldown(account, time.Now().Add(time.Minute)) require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.5")) diff --git a/backend/internal/service/openai_alpha_search_billing_test.go b/backend/internal/service/openai_alpha_search_billing_test.go index 2a8d3d0fc2b5..b8c6236d9dba 100644 --- a/backend/internal/service/openai_alpha_search_billing_test.go +++ b/backend/internal/service/openai_alpha_search_billing_test.go @@ -51,7 +51,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // 即使 token 倍率(含高峰,3.0)更高也不采用。 apiKey := &APIKey{ID: 1, GroupID: &groupID, Group: &Group{ID: groupID, Platform: PlatformOpenAI}} result := &OpenAIForwardResult{Model: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol", WebSearchCalls: 1} - cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, nil, []string{"gpt-5.6-sol"}, 3.0, 1.0, 1.0, 2.0, UsageTokens{}, "", boolPtr(false), time.Time{}) + cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 3.0, 1.0, 1.0, 2.0, UsageTokens{}, "", boolPtr(false), time.Time{}) require.NoError(t, err) require.Equal(t, string(BillingModePerRequest), cost.BillingMode) require.InDelta(t, 0.01, cost.TotalCost, 1e-12) @@ -59,7 +59,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // 分组配置单价 0.005 apiKey.Group.WebSearchPricePerCall = float64Ptr(0.005) - cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, nil, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{}, "", boolPtr(false), time.Time{}) + cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{}, "", boolPtr(false), time.Time{}) require.NoError(t, err) require.InDelta(t, 0.005, cost.TotalCost, 1e-12) require.InDelta(t, 0.005, cost.ActualCost, 1e-12) @@ -67,7 +67,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // WebSearchCalls = 0 时不得走按次分支(无定价数据会返回 pricing 错误, // 证明回落到了 token 路径而不是被按次分支吞掉)。 result.WebSearchCalls = 0 - _, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, nil, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "", boolPtr(false), time.Time{}) + _, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "", boolPtr(false), time.Time{}) require.Error(t, err) } diff --git a/backend/internal/service/openai_apikey_compat.go b/backend/internal/service/openai_apikey_compat.go index 122823e3a7f5..419dc80aeef4 100644 --- a/backend/internal/service/openai_apikey_compat.go +++ b/backend/internal/service/openai_apikey_compat.go @@ -68,7 +68,7 @@ func observeOpenAIPromptCacheKeyNormalization(c *gin.Context, changed bool) { // Responses wire body. finalizeResponsesForSend calls it for every Responses // send after the system prompt, so the upstream never sees an over-long key. func applyOpenAIAPIKeyPromptCacheKeyMode(c *gin.Context, account *Account, body []byte) ([]byte, error) { - normalized, changed, err := normalizeOpenAIAPIKeyPromptCacheKey(body, c, account) + normalized, changed, err := normalizeOpenAIAPIKeyPromptCacheKey(body, account) if err != nil { return nil, err } @@ -78,7 +78,7 @@ func applyOpenAIAPIKeyPromptCacheKeyMode(c *gin.Context, account *Account, body // normalizeOpenAIAPIKeyPromptCacheKey is the single final-wire normalizer for // the account option openai_prompt_cache_key_mode=sha256_64. -func normalizeOpenAIAPIKeyPromptCacheKey(body []byte, _ *gin.Context, account *Account) ([]byte, bool, error) { +func normalizeOpenAIAPIKeyPromptCacheKey(body []byte, account *Account) ([]byte, bool, error) { if account.OpenAIPromptCacheKeyMode() != OpenAIPromptCacheKeyModeSHA25664 { return body, false, nil } diff --git a/backend/internal/service/openai_apikey_compat_test.go b/backend/internal/service/openai_apikey_compat_test.go index 4d1538683b1a..63f6b1c6f513 100644 --- a/backend/internal/service/openai_apikey_compat_test.go +++ b/backend/internal/service/openai_apikey_compat_test.go @@ -32,13 +32,13 @@ func TestOpenAIPromptCacheKeyModeAppliesOnlyToOptedInAPIKeys(t *testing.T) { promptCacheKeyModeAccount("SHA256_64"), {Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{OpenAIPromptCacheKeyModeExtraKey: OpenAIPromptCacheKeyModeSHA25664}}, } { - unchanged, changed, err := normalizeOpenAIAPIKeyPromptCacheKey(body, nil, account) + unchanged, changed, err := normalizeOpenAIAPIKeyPromptCacheKey(body, account) require.NoError(t, err) require.False(t, changed) require.Equal(t, body, unchanged) } - hashed, changed, err := normalizeOpenAIAPIKeyPromptCacheKey(body, nil, promptCacheKeyModeAccount(OpenAIPromptCacheKeyModeSHA25664)) + hashed, changed, err := normalizeOpenAIAPIKeyPromptCacheKey(body, promptCacheKeyModeAccount(OpenAIPromptCacheKeyModeSHA25664)) require.NoError(t, err) require.True(t, changed) digest := sha256.Sum256([]byte(longKey)) @@ -62,7 +62,7 @@ func TestOpenAIPromptCacheKeyModeNormalizesFinalUnicodeWireValue(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { body := []byte(`{"prompt_cache_key":"` + test.value + `","input":[]}`) - normalized, changed, err := normalizeOpenAIAPIKeyPromptCacheKey(body, nil, account) + normalized, changed, err := normalizeOpenAIAPIKeyPromptCacheKey(body, account) require.NoError(t, err) require.Equal(t, test.wantHash, changed) if !test.wantHash { @@ -76,7 +76,7 @@ func TestOpenAIPromptCacheKeyModeNormalizesFinalUnicodeWireValue(t *testing.T) { } nonString := []byte(`{"prompt_cache_key":123,"input":[]}`) - result, changed, err := normalizeOpenAIAPIKeyPromptCacheKey(nonString, nil, account) + result, changed, err := normalizeOpenAIAPIKeyPromptCacheKey(nonString, account) require.NoError(t, err) require.False(t, changed) require.Equal(t, nonString, result) diff --git a/backend/internal/service/openai_embeddings.go b/backend/internal/service/openai_embeddings.go index 17c75694482c..7318ca15b7f8 100644 --- a/backend/internal/service/openai_embeddings.go +++ b/backend/internal/service/openai_embeddings.go @@ -32,10 +32,7 @@ func (s *OpenAIGatewayService) ForwardEmbeddings( return nil, fmt.Errorf("missing model in request") } - billingModel, modelPolicyErr := resolveOpenAIForwardModelContext(ctx, account, originalModel, defaultMappedModel) - if modelPolicyErr != nil { - return nil, modelPolicyErr - } + billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) SetOpsUpstreamModel(c, upstreamModel) upstreamBody := body diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 461a97acd139..834eb151bbf9 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -81,10 +81,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( beginUpstreamResponseModelObservation(c) if account != nil && account.IsOpenAI() { requestedModel := gjson.GetBytes(body, "model").String() - mappedModel, modelPolicyErr := resolveOpenAIForwardModelContext(ctx, account, requestedModel, defaultMappedModel) - if modelPolicyErr != nil { - return nil, modelPolicyErr - } + mappedModel := resolveOpenAIForwardModel(account, requestedModel, defaultMappedModel) withEffort, _, err := materializeOpenAIForwardReasoningEffort(ctx, body, mappedModel) if err != nil { return nil, err @@ -221,10 +218,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( // 2. Resolve model mapping early so compat prompt_cache_key injection can // derive a stable seed from the final upstream model family. - billingModel, modelPolicyErr := resolveOpenAIForwardModelContext(ctx, account, originalModel, defaultMappedModel) - if modelPolicyErr != nil { - return nil, modelPolicyErr - } + billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil { writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index a099da85e0b4..c56885754299 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -64,10 +64,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( startTime := time.Now() if account != nil && account.IsOpenAI() { requestedModel := gjson.GetBytes(body, "model").String() - mappedModel, modelPolicyErr := resolveOpenAIForwardModelContext(ctx, account, requestedModel, defaultMappedModel) - if modelPolicyErr != nil { - return nil, modelPolicyErr - } + mappedModel := resolveOpenAIForwardModel(account, requestedModel, defaultMappedModel) withEffort, _, err := materializeOpenAIForwardReasoningEffort(ctx, body, mappedModel) if err != nil { return nil, err @@ -87,10 +84,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( serviceTier := extractOpenAIServiceTierFromBody(body) // 2. Resolve model mapping (same as ForwardAsChatCompletions) - billingModel, modelPolicyErr := resolveOpenAIForwardModelContext(ctx, account, originalModel, defaultMappedModel) - if modelPolicyErr != nil { - return nil, modelPolicyErr - } + billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil { writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) diff --git a/backend/internal/service/openai_gateway_cn_fixes_test.go b/backend/internal/service/openai_gateway_cn_fixes_test.go index fb231b71e478..da532536b179 100644 --- a/backend/internal/service/openai_gateway_cn_fixes_test.go +++ b/backend/internal/service/openai_gateway_cn_fixes_test.go @@ -73,7 +73,7 @@ func TestCalculateOpenAIRecordUsageCost_EmptyCandidatesIsPricingUnavailable(t *t apiKey := &APIKey{Group: &Group{ID: 1, Platform: PlatformKimi}} _, err := svc.calculateOpenAIRecordUsageCost( - context.Background(), nil, apiKey, nil, nil, + context.Background(), nil, apiKey, nil, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 100}, "", nil, time.Time{}, ) require.Error(t, err) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 4e7d38020f1e..9fdbd4d15943 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -31,11 +31,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco var integrityEffortPolicy func([]byte) ([]byte, error) if account != nil && account.IsOpenAI() { requestedModel := gjson.GetBytes(body, "model").String() - mappedCandidate, mapErr := resolveOpenAIForwardModelContext(ctx, account, requestedModel, "") - if mapErr != nil { - return nil, mapErr - } - candidates := []string{mappedCandidate} + candidates := []string{account.GetMappedModel(requestedModel)} if isOpenAIResponsesCompactPath(c) { if compactModel, matched := account.ResolveCompactMappedModel(requestedModel); matched { candidates = append([]string{compactModel}, candidates...) diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 932131767565..9d57a201db7e 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -1890,7 +1890,7 @@ func (s *OpenAIGatewayService) rateLimitGrok(ctx context.Context, account *Accou if account.TempUnschedulableUntil != nil && account.TempUnschedulableUntil.After(runtimeUntil) { runtimeUntil = *account.TempUnschedulableUntil } - s.BlockAccountSchedulingFromPersistedCooldown(account, runtimeUntil, "429") + s.BlockAccountSchedulingFromPersistedCooldown(account, runtimeUntil) persistGrokRateLimit(ctx, s.accountRepo, account, resetAt) // Propagate a short team+model cool so sibling OAuth accounts on the same @@ -2120,7 +2120,7 @@ func (s *OpenAIGatewayService) tempUnscheduleGrok(ctx context.Context, account * if account.TempUnschedulableUntil != nil && account.TempUnschedulableUntil.After(until) { until = *account.TempUnschedulableUntil } - s.BlockAccountSchedulingFromPersistedCooldown(account, until, reason) + s.BlockAccountSchedulingFromPersistedCooldown(account, until) if s.accountRepo != nil { stateCtx, cancel := openAIAccountStateContext(ctx) defer cancel() diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index b1e378729b0c..9ef3ac34a444 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -94,10 +94,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( clientStream := anthropicReq.Stream // client's original stream preference // 2. Model mapping - billingModel, modelPolicyErr := resolveOpenAIForwardModelContext(ctx, account, normalizedModel, defaultMappedModel) - if modelPolicyErr != nil { - return nil, modelPolicyErr - } + billingModel := resolveOpenAIForwardModel(account, normalizedModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil { writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 3d93d936b72d..92023f559fb7 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -58,10 +58,7 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( return nil, fmt.Errorf("convert anthropic to chat completions: %w", err) } - billingModel, modelPolicyErr := resolveOpenAIForwardModelContext(ctx, account, anthropicReq.Model, defaultMappedModel) - if modelPolicyErr != nil { - return nil, modelPolicyErr - } + billingModel := resolveOpenAIForwardModel(account, anthropicReq.Model, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil { writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index 2173e845dd6e..20fa07509670 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -76,10 +76,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( return nil, fmt.Errorf("convert responses to chat completions: %w", err) } - billingModel, modelPolicyErr := resolveOpenAIForwardModelContext(ctx, account, originalModel, "") - if modelPolicyErr != nil { - return nil, modelPolicyErr - } + billingModel := resolveOpenAIForwardModel(account, originalModel, "") upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil { writeOpenAIResponsesFallbackError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) diff --git a/backend/internal/service/openai_gateway_search_surcharge_test.go b/backend/internal/service/openai_gateway_search_surcharge_test.go index 1922d5158139..d2d069ff5c26 100644 --- a/backend/internal/service/openai_gateway_search_surcharge_test.go +++ b/backend/internal/service/openai_gateway_search_surcharge_test.go @@ -30,7 +30,6 @@ func TestCalculateOpenAIRecordUsageCost_SearchIsAdditiveToTokens(t *testing.T) { context.Background(), &OpenAIForwardResult{SearchCount: 100}, apiKey, - nil, []string{"claude-sonnet-4"}, 1.0, 1.0, @@ -63,7 +62,6 @@ func TestCalculateOpenAIRecordUsageCost_SearchOnlyWhenNoTokenPricing(t *testing. &OpenAIForwardResult{SearchCount: 100}, apiKey, nil, - nil, 1.0, 1.0, 1.0, @@ -109,7 +107,6 @@ func TestCalculateOpenAIRecordUsageCost_TokenPricingErrorNotSwallowedBySearch(t context.Background(), &OpenAIForwardResult{SearchCount: 100}, apiKey, - nil, []string{"totally-unknown-model-xyz-no-pricing"}, 1.0, 1.0, diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 198991238290..93ca13d31fdc 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -234,8 +234,6 @@ type OpenAIUsage struct { OutputTokens int `json:"output_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` - CacheCreation5mTokens int `json:"-"` - CacheCreation1hTokens int `json:"-"` ImageOutputTokens int `json:"image_output_tokens,omitempty"` } @@ -246,11 +244,7 @@ type OpenAIForwardResult struct { // Direct upstream headers are used for request-ID attribution. UpstreamHeaders http.Header Usage OpenAIUsage - // UsageInputTokensExcludeCache marks Anthropic-style usage where - // input_tokens is already disjoint from cache read/write tokens. OpenAI - // Responses reports a total input count and therefore leaves this false. - UsageInputTokensExcludeCache bool - Model string // 原始模型(用于响应和日志显示) + Model string // 原始模型(用于响应和日志显示) // BillingModel is the model used for cost calculation. // When non-empty, CalculateCost uses this instead of Model. // This is set by the Anthropic Messages conversion path where @@ -294,7 +288,6 @@ type OpenAIForwardResult struct { FirstTokenMs *int ClientDisconnect bool ImageCount int - ImageInputCount int ImageSize string ImageInputSize string ImageOutputSize string diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 31d1d5a34888..f9df82abe82f 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -176,28 +176,22 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec } logServiceTierBillingDowngrade("service.openai_gateway", account, result.RequestID, ApplyOpenAIServiceTierBillingResolution(billingAccount, result)) - // OpenAI input_tokens 是总输入,包含缓存读取和缓存写入明细;原生 - // Anthropic Messages 的 input_tokens 已与两类缓存 token 互斥。按结果上 - // 的显式语义拆桶,避免任一协议重复计费或重复扣减。 - actualInputTokens := result.Usage.InputTokens - if !result.UsageInputTokensExcludeCache { - actualInputTokens -= result.Usage.CacheReadInputTokens + result.Usage.CacheCreationInputTokens - } + // OpenAI input_tokens 是总输入,包含缓存读取和缓存写入明细。 + // 将三类 token 拆成互斥桶,避免缓存写入同时按普通输入和 cache_write 重复计费。 + actualInputTokens := result.Usage.InputTokens - result.Usage.CacheReadInputTokens - result.Usage.CacheCreationInputTokens if actualInputTokens < 0 { actualInputTokens = 0 } // Calculate cost tokens := UsageTokens{ - InputTokens: actualInputTokens, - ImageInputTokens: max(result.Usage.ImageInputTokens-result.Usage.ImageCacheReadTokens, 0), - ImageCacheReadTokens: result.Usage.ImageCacheReadTokens, - OutputTokens: result.Usage.OutputTokens, - CacheCreationTokens: result.Usage.CacheCreationInputTokens, - CacheReadTokens: result.Usage.CacheReadInputTokens, - CacheCreation5mTokens: result.Usage.CacheCreation5mTokens, - CacheCreation1hTokens: result.Usage.CacheCreation1hTokens, - ImageOutputTokens: result.Usage.ImageOutputTokens, + InputTokens: actualInputTokens, + ImageInputTokens: max(result.Usage.ImageInputTokens-result.Usage.ImageCacheReadTokens, 0), + ImageCacheReadTokens: result.Usage.ImageCacheReadTokens, + OutputTokens: result.Usage.OutputTokens, + CacheCreationTokens: result.Usage.CacheCreationInputTokens, + CacheReadTokens: result.Usage.CacheReadInputTokens, + ImageOutputTokens: result.Usage.ImageOutputTokens, } // Get rate multiplier @@ -246,7 +240,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec ctx, result, apiKey, - account, billingModels, multiplier, imageMultiplier, @@ -284,10 +277,10 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec result.ImageCount > 0 || result.VideoCount > 0 || result.WebSearchCalls > 0 || result.AudioUsage != nil || result.SearchCount > 0, ); responseModel != "" && !strings.EqualFold(responseModel, baselineBillingModel) { - if identified, responseChannelPriced := s.hasIdentifiedOpenAIResponsePricing(ctx, responseModel, apiKey, account); identified { + if identified, responseChannelPriced := s.hasIdentifiedOpenAIResponsePricing(ctx, responseModel, apiKey); identified { responseModels := s.filterCNProviderBillingModelCandidates(ctx, account, apiKey, usageBillingModelCandidates(responseModel)) responseCost, responseErr := s.calculateOpenAIRecordUsageCost( - ctx, result, apiKey, account, responseModels, multiplier, imageMultiplier, + ctx, result, apiKey, responseModels, multiplier, imageMultiplier, videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingGate, pricingAt, ) // 基线定价源以 baselineBillingModel 为准:它正是 calculateOpenAIRecordUsageCost @@ -311,7 +304,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec ctx, result, apiKey, - billingAccount, billingModels, multiplier, imageMultiplier, @@ -405,8 +397,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec OutputTokens: result.Usage.OutputTokens, CacheCreationTokens: result.Usage.CacheCreationInputTokens, CacheReadTokens: result.Usage.CacheReadInputTokens, - CacheCreation5mTokens: result.Usage.CacheCreation5mTokens, - CacheCreation1hTokens: result.Usage.CacheCreation1hTokens, ImageInputTokens: result.Usage.ImageInputTokens, ImageOutputTokens: result.Usage.ImageOutputTokens, ImageCount: result.ImageCount, @@ -556,7 +546,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec // 只接受管理员为该模型显式配置的渠道定价,或价格表中能被确定性识别的条目; // 刻意不接受按子串猜出来的系列兜底价,否则上游随便编一个含 "haiku" 的名字就能把 // 计费拉到最便宜的系列价上。详见 responseModelBillingDeclaration。 -func (s *OpenAIGatewayService) hasIdentifiedOpenAIResponsePricing(ctx context.Context, model string, apiKey *APIKey, account *Account) (identified bool, channelPriced bool) { +func (s *OpenAIGatewayService) hasIdentifiedOpenAIResponsePricing(ctx context.Context, model string, apiKey *APIKey) (identified bool, channelPriced bool) { model = strings.TrimSpace(model) if model == "" { return false, false @@ -584,7 +574,6 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( ctx context.Context, result *OpenAIForwardResult, apiKey *APIKey, - account *Account, billingModels []string, multiplier float64, imageMultiplier float64, @@ -626,7 +615,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( if result != nil && result.ImageCount > 0 { // 渠道定价为 token 计费时走 token 路径,否则走图片计费 if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved == nil || resolved.Mode != BillingModeToken { - return s.calculateOpenAIImageCost(ctx, billingModel, apiKey, account, result, tokens, imageMultiplier) + return s.calculateOpenAIImageCost(ctx, billingModel, apiKey, result, imageMultiplier), nil } } @@ -642,7 +631,6 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( cost, err := s.calculateOpenAIRecordUsageTokenCost( ctx, apiKey, - account, candidate, multiplier, pricingAt, @@ -737,7 +725,6 @@ func isUsagePricingUnavailableError(err error) bool { func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( ctx context.Context, apiKey *APIKey, - account *Account, billingModel string, multiplier float64, pricingAt time.Time, @@ -746,16 +733,6 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( reasoningEffort string, longContextBillingGate *bool, ) (*CostBreakdown, error) { - if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil { - gid := apiKey.Group.ID - return s.billingService.CalculateCostUnified(CostInput{ - Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, - Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, - PricingAt: pricingAt, - ServiceTier: serviceTier, ReasoningEffort: reasoningEffort, Resolver: s.resolver, Resolved: resolved, - LongContextBillingEnabled: longContextBillingGate, - }) - } if s.resolver != nil && apiKey.Group != nil { gid := apiKey.Group.ID return s.billingService.CalculateCostUnified(CostInput{ @@ -775,11 +752,9 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost( ctx context.Context, billingModel string, apiKey *APIKey, - account *Account, result *OpenAIForwardResult, - tokens UsageTokens, multiplier float64, -) (*CostBreakdown, error) { +) *CostBreakdown { sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize) resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey) if resolved != nil && resolved.Source == PricingSourceGroup && @@ -792,18 +767,18 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost( ReasoningEffort: optionalStringValue(result.ReasoningEffort), }) if err == nil { - return cost, nil + return cost } } groupConfig := imagePriceConfigFromAPIKey(apiKey) if apiKeyHasConfiguredImagePrice(apiKey, sizeTier) { - return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier), nil + return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) } if refreshed := s.apiKeyWithFreshGroupMediaPricing(ctx, apiKey); refreshed != apiKey { apiKey = refreshed groupConfig = imagePriceConfigFromAPIKey(apiKey) if apiKeyHasConfiguredImagePrice(apiKey, sizeTier) { - return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier), nil + return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) } } if resolved != nil && resolved.Source == PricingSourceChannel && @@ -822,12 +797,12 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost( Resolved: resolved, }) if err == nil { - return cost, nil + return cost } logger.LegacyPrintf("service.openai_gateway", "Calculate image channel cost failed: %v", err) } - return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier), nil + return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) } func (s *OpenAIGatewayService) calculateOpenAIVideoCost( diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index 1d7d83ee0dbe..cb6b7e9dbe55 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -171,13 +171,6 @@ func (r *OpenAIImagesRequest) IsEdits() bool { return r != nil && r.Endpoint == openAIImagesEditsEndpoint } -func (r *OpenAIImagesRequest) InputImageCount() int { - if r == nil || !r.IsEdits() { - return 0 - } - return len(r.InputImageURLs) + len(r.Uploads) -} - func (r *OpenAIImagesRequest) StickySessionSeed() string { if r == nil { return "" @@ -652,10 +645,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( if err := validateCompatibleImagesModel(requestModel); err != nil { return nil, err } - upstreamModel, err := resolveOpenAIForwardModelContext(ctx, account, requestModel, "") - if err != nil { - return nil, err - } + upstreamModel := account.GetMappedModel(requestModel) if err := validateCompatibleImagesModel(upstreamModel); err != nil { return nil, err } @@ -771,7 +761,6 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( Duration: time.Since(startTime), FirstTokenMs: ttft, ImageCount: streamCount, - ImageInputCount: parsed.InputImageCount(), ImageSize: parsed.SizeTier, ImageInputSize: parsed.Size, ImageOutputSizes: streamSizes, @@ -794,7 +783,6 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, ImageCount: imageCount, - ImageInputCount: parsed.InputImageCount(), ImageSize: parsed.SizeTier, ImageInputSize: parsed.Size, ImageOutputSizes: imageOutputSizes, @@ -819,7 +807,6 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, ImageCount: imageCount, - ImageInputCount: parsed.InputImageCount(), ImageSize: parsed.SizeTier, ImageInputSize: parsed.Size, ImageOutputSizes: nonStreamSizes, diff --git a/backend/internal/service/openai_model_mapping.go b/backend/internal/service/openai_model_mapping.go index 4c733c434645..c735c0ba86e8 100644 --- a/backend/internal/service/openai_model_mapping.go +++ b/backend/internal/service/openai_model_mapping.go @@ -1,9 +1,6 @@ package service -import ( - "context" - "strings" -) +import "strings" // resolveOpenAIForwardModel 解析 OpenAI 兼容转发使用的模型。 // messagesDispatchMappedModel 是调用方已为 /v1/messages 解析的显式调度结果; @@ -24,12 +21,6 @@ func resolveOpenAIForwardModel(account *Account, requestedModel, messagesDispatc return mappedModel } -// resolveOpenAIForwardModelContext is the request-path spelling of -// resolveOpenAIForwardModel; account model_mapping is the only model policy. -func resolveOpenAIForwardModelContext(_ context.Context, account *Account, requestedModel, messagesDispatchMappedModel string) (string, error) { - return resolveOpenAIForwardModel(account, requestedModel, messagesDispatchMappedModel), nil -} - // openAIOAuthForeignModelPrefixes 列出明确属于其他厂商家族的模型名前缀。 // Codex 上游不可能服务这些模型:转发阶段 normalizeOpenAIModelForUpstream // 对未知模型原样透传,上游必然返回不可重试的 400。 diff --git a/backend/internal/service/openai_reasoning_recovery.go b/backend/internal/service/openai_reasoning_recovery.go index 901b10d88a86..11f9a82cc268 100644 --- a/backend/internal/service/openai_reasoning_recovery.go +++ b/backend/internal/service/openai_reasoning_recovery.go @@ -198,7 +198,7 @@ func buildOpenAIReasoningScope(c *gin.Context, account *Account, req *http.Reque if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } - identity := openAIReasoningRequestIdentity(account, req, wireBody, proxyURL, "") + identity := openAIReasoningRequestIdentity(account, req, proxyURL, "") return BuildOpenAIReasoningCacheScope(OpenAIReasoningScopeInput{ UserID: key.UserID, APIKeyID: key.ID, GroupID: groupID, AccountID: account.ID, SourceIdentityHash: identity, Endpoint: req.URL.String(), @@ -207,7 +207,7 @@ func buildOpenAIReasoningScope(c *gin.Context, account *Account, req *http.Reque }) } -func openAIReasoningRequestIdentity(account *Account, req *http.Request, _ []byte, proxyURL, token string) string { +func openAIReasoningRequestIdentity(account *Account, req *http.Request, proxyURL, token string) string { identity := struct { AccountID int64 AccountType, Endpoint, Host, Method, Proxy, Token string @@ -262,7 +262,7 @@ func (r *openAIReasoningRecoveryState) PrepareRequest(req *http.Request, body [] return nil, nil, errors.New("reasoning recovery request snapshot unavailable") } } - identity := openAIReasoningRequestIdentity(r.account, req, body, proxyURL, r.token) + identity := openAIReasoningRequestIdentity(r.account, req, proxyURL, r.token) if identity == "" { return nil, nil, errors.New("reasoning recovery source identity unavailable") } diff --git a/backend/internal/service/openai_reasoning_recovery_test.go b/backend/internal/service/openai_reasoning_recovery_test.go index e7233134fe5d..f421a42c80ba 100644 --- a/backend/internal/service/openai_reasoning_recovery_test.go +++ b/backend/internal/service/openai_reasoning_recovery_test.go @@ -263,8 +263,8 @@ func TestOpenAIReasoningRecoveryIdentityWithoutReasoningStillIsolatesSources(t * other, err := buildOpenAIReasoningScope(state.c, state.account, changed, []byte(`{"model":"gpt-5.6-sol"}`)) require.NoError(t, err) require.NotEqual(t, initial.ScopeHash, other.ScopeHash) - a := openAIReasoningRequestIdentity(state.account, req, nil, "", "") - b := openAIReasoningRequestIdentity(state.account, req, nil, "http://other-proxy.example", "") + a := openAIReasoningRequestIdentity(state.account, req, "", "") + b := openAIReasoningRequestIdentity(state.account, req, "http://other-proxy.example", "") require.NotEqual(t, a, b) require.NotEqual(t, openAIReasoningDigest(nil), a) one, err := buildOpenAIReasoningScope(state.c, state.account, req, []byte(`{"model":"gpt-5.6-sol","reasoning":{"effort":"xhigh","context":"keep"}}`)) diff --git a/backend/internal/service/openai_refusal_recovery.go b/backend/internal/service/openai_refusal_recovery.go index 99e233db9485..4878590eafb7 100644 --- a/backend/internal/service/openai_refusal_recovery.go +++ b/backend/internal/service/openai_refusal_recovery.go @@ -297,7 +297,7 @@ func NewOpenAIRefusalRecoveryFailoverError(upstreamHeaders http.Header) *Upstrea } } -func NewOpenAICyberFailoverError(_ []byte, upstreamHeaders http.Header) *UpstreamFailoverError { +func NewOpenAICyberFailoverError(upstreamHeaders http.Header) *UpstreamFailoverError { err := NewOpenAIRefusalRecoveryFailoverError(upstreamHeaders) err.Reason = OpenAICyberFailoverReason err.ResponseBody = []byte(`{"error":{"message":"Temporary upstream failure","type":"server_error","code":"upstream_retry_exhausted","retryable":true}}`) diff --git a/backend/internal/service/openai_refusal_recovery_test.go b/backend/internal/service/openai_refusal_recovery_test.go index 8b80940775eb..c008cfb5c589 100644 --- a/backend/internal/service/openai_refusal_recovery_test.go +++ b/backend/internal/service/openai_refusal_recovery_test.go @@ -185,7 +185,7 @@ func TestRewriteOpenAIResponsesJSONLeavesIncompleteResponsesUntouched(t *testing } func TestOpenAICyberFailoverErrorRetriesAnotherAccountWithoutHealthPenalty(t *testing.T) { - err := NewOpenAICyberFailoverError([]byte(`{"response":{"error":{"code":"cyber_policy"}}}`), http.Header{"X-Request-Id": []string{"req_1"}}) + err := NewOpenAICyberFailoverError(http.Header{"X-Request-Id": []string{"req_1"}}) require.Equal(t, http.StatusServiceUnavailable, err.StatusCode) require.Equal(t, http.StatusServiceUnavailable, err.ClientStatusCode) diff --git a/backend/internal/service/openai_refusal_recovery_ws.go b/backend/internal/service/openai_refusal_recovery_ws.go index 04e5c372ef25..7a5fee707f7e 100644 --- a/backend/internal/service/openai_refusal_recovery_ws.go +++ b/backend/internal/service/openai_refusal_recovery_ws.go @@ -2,7 +2,6 @@ package service import ( "context" - "net/http" "strings" openaiwsv2 "github.com/Wei-Shaw/sub2api/internal/service/openai_ws_v2" @@ -262,13 +261,6 @@ func openAIRefusalRecoveryWSSemanticEvent(eventType string, payload []byte) bool } } -func newOpenAIWSCyberRecoveryError(payload []byte, headers http.Header, replaySafe bool) error { - if replaySafe { - return NewOpenAICyberFailoverError(payload, headers) - } - return NewOpenAIWSClientCloseError(coderws.StatusTryAgainLater, "Temporary upstream failure; please retry", NewOpenAICyberFailoverError(payload, headers)) -} - func OpenAIWSRetryableFailureEvent() []byte { return []byte(`{"type":"response.failed","response":{"id":"resp_retryable_failure","object":"response","status":"failed","output":[],"error":{"type":"server_error","code":"upstream_retry_exhausted","message":"Temporary upstream failure","retryable":true}}}`) } diff --git a/backend/internal/service/openai_refusal_recovery_ws_test.go b/backend/internal/service/openai_refusal_recovery_ws_test.go index 469c59e84783..c6371b2d4899 100644 --- a/backend/internal/service/openai_refusal_recovery_ws_test.go +++ b/backend/internal/service/openai_refusal_recovery_ws_test.go @@ -2,9 +2,7 @@ package service import ( "context" - "errors" "fmt" - "net/http" "testing" coderws "github.com/coder/websocket" @@ -213,20 +211,6 @@ func TestOpenAIRefusalRecoveryWSOutputFailsOpenAboveBufferLimit(t *testing.T) { require.Len(t, written, 1) } -func TestOpenAIWSCyberRecoveryErrorUsesFailoverOnlyWhenReplaySafe(t *testing.T) { - payload := []byte(`{"type":"response.failed","response":{"error":{"code":"cyber_policy"}}}`) - - safeErr := newOpenAIWSCyberRecoveryError(payload, http.Header{}, true) - var failoverErr *UpstreamFailoverError - require.True(t, errors.As(safeErr, &failoverErr)) - require.True(t, failoverErr.IsOpenAIRefusalRecovery()) - - unsafeErr := newOpenAIWSCyberRecoveryError(payload, http.Header{}, false) - var closeErr *OpenAIWSClientCloseError - require.True(t, errors.As(unsafeErr, &closeErr)) - require.Equal(t, coderws.StatusTryAgainLater, closeErr.StatusCode()) -} - func TestOpenAIRefusalRecoveryWSOutputWritesRetryableFailureWithoutBufferedFrames(t *testing.T) { var written [][]byte output := newOpenAIRefusalRecoveryWSOutput(nil, true, func(_ context.Context, _ coderws.MessageType, payload []byte) error { diff --git a/backend/internal/service/openai_sticky_compat.go b/backend/internal/service/openai_sticky_compat.go index 5549a6088e20..48d8e0dc2135 100644 --- a/backend/internal/service/openai_sticky_compat.go +++ b/backend/internal/service/openai_sticky_compat.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "errors" "fmt" "strings" "sync/atomic" @@ -220,27 +219,14 @@ func (s *OpenAIGatewayService) deleteStickySessionAccountIDIfMatches(ctx context return err } - primaryErr := deleteIfMatches(primaryKey) + err := deleteIfMatches(primaryKey) if !s.openAISessionHashReadOldFallbackEnabled() && !s.openAISessionHashDualWriteOldEnabled() { - return primaryErr + return err } legacyKey := s.openAILegacySessionCacheKey(ctx, sessionHash) - legacyErr := deleteIfMatches(legacyKey) - // Both generations are independent Redis keys, so always attempt both and - // preserve both failures. A legacy-only residue can re-pin the next fallback - // read even when the primary compare-and-delete succeeded. - return errors.Join(primaryErr, legacyErr) -} - -// ClearOpenAIStickySessionAccountIDIfMatches removes only the binding still -// owned by expectedAccountID. A concurrent request that already rebound the -// session to another account is preserved by the cache compare-and-delete. -func (s *OpenAIGatewayService) ClearOpenAIStickySessionAccountIDIfMatches( - ctx context.Context, - groupID *int64, - sessionHash string, - expectedAccountID int64, -) error { - return s.deleteStickySessionAccountIDIfMatches(ctx, groupID, sessionHash, expectedAccountID) + if legacyKey != "" { + _ = deleteIfMatches(legacyKey) + } + return err } diff --git a/backend/internal/service/openai_sticky_compat_test.go b/backend/internal/service/openai_sticky_compat_test.go index cc22bb33eac5..d32115c38cf8 100644 --- a/backend/internal/service/openai_sticky_compat_test.go +++ b/backend/internal/service/openai_sticky_compat_test.go @@ -2,7 +2,6 @@ package service import ( "context" - "errors" "testing" "github.com/Wei-Shaw/sub2api/internal/config" @@ -10,25 +9,6 @@ import ( "github.com/stretchr/testify/require" ) -type stickyDeleteFaultCache struct { - *stubGatewayCache - deleteErrors map[string]error - deleteCalls []string -} - -func (c *stickyDeleteFaultCache) DeleteSessionAccountIDIfMatches( - ctx context.Context, - groupID int64, - sessionHash string, - expectedAccountID int64, -) (bool, error) { - c.deleteCalls = append(c.deleteCalls, sessionHash) - if err := c.deleteErrors[sessionHash]; err != nil { - return false, err - } - return c.stubGatewayCache.DeleteSessionAccountIDIfMatches(ctx, groupID, sessionHash, expectedAccountID) -} - func TestGetStickySessionAccountID_FallbackToLegacyKey(t *testing.T) { beforeFallbackTotal, beforeFallbackHit, _ := openAIStickyCompatStats() @@ -105,78 +85,24 @@ func TestSetStickySessionAccountID_DualWriteOldDisabled(t *testing.T) { } func TestDeleteStickySessionAccountIDIfMatches_PreservesConcurrentRebind(t *testing.T) { - cache := &stickyDeleteFaultCache{stubGatewayCache: &stubGatewayCache{sessionBindings: map[string]int64{ - "openai:new-hash": 22, - "openai:legacy-hash": 22, - }}} + cache := &stubGatewayCache{sessionBindings: map[string]int64{"openai:new-hash": 22}} svc := &OpenAIGatewayService{cache: cache} - ctx := withOpenAILegacySessionHash(context.Background(), "legacy-hash") - err := svc.ClearOpenAIStickySessionAccountIDIfMatches(ctx, nil, "new-hash", 11) + err := svc.deleteStickySessionAccountIDIfMatches(context.Background(), nil, "new-hash", 11) require.NoError(t, err) require.Equal(t, int64(22), cache.sessionBindings["openai:new-hash"]) - require.Equal(t, int64(22), cache.sessionBindings["openai:legacy-hash"]) - require.Equal(t, []string{"openai:new-hash", "openai:legacy-hash"}, cache.deleteCalls) } -func TestDeleteStickySessionAccountIDIfMatches_DeletesExpectedDualBindings(t *testing.T) { - cache := &stickyDeleteFaultCache{stubGatewayCache: &stubGatewayCache{sessionBindings: map[string]int64{ - "openai:new-hash": 11, - "openai:legacy-hash": 11, - }}} +func TestDeleteStickySessionAccountIDIfMatches_DeletesExpectedBinding(t *testing.T) { + cache := &stubGatewayCache{sessionBindings: map[string]int64{"openai:new-hash": 11}} svc := &OpenAIGatewayService{cache: cache} - ctx := withOpenAILegacySessionHash(context.Background(), "legacy-hash") - err := svc.ClearOpenAIStickySessionAccountIDIfMatches(ctx, nil, "new-hash", 11) + err := svc.deleteStickySessionAccountIDIfMatches(context.Background(), nil, "new-hash", 11) require.NoError(t, err) - require.NotContains(t, cache.sessionBindings, "openai:new-hash") - require.NotContains(t, cache.sessionBindings, "openai:legacy-hash") - require.Equal(t, []string{"openai:new-hash", "openai:legacy-hash"}, cache.deleteCalls) -} - -func TestDeleteStickySessionAccountIDIfMatches_LegacyFailureIsObservable(t *testing.T) { - legacyErr := errors.New("legacy delete failed") - cache := &stickyDeleteFaultCache{ - stubGatewayCache: &stubGatewayCache{sessionBindings: map[string]int64{ - "openai:new-hash": 11, - "openai:legacy-hash": 11, - }}, - deleteErrors: map[string]error{"openai:legacy-hash": legacyErr}, - } - svc := &OpenAIGatewayService{cache: cache} - ctx := withOpenAILegacySessionHash(context.Background(), "legacy-hash") - - err := svc.ClearOpenAIStickySessionAccountIDIfMatches(ctx, nil, "new-hash", 11) - - require.ErrorIs(t, err, legacyErr) - require.NotContains(t, cache.sessionBindings, "openai:new-hash") - require.Equal(t, int64(11), cache.sessionBindings["openai:legacy-hash"]) - require.Equal(t, []string{"openai:new-hash", "openai:legacy-hash"}, cache.deleteCalls) -} - -func TestDeleteStickySessionAccountIDIfMatches_JoinsPrimaryAndLegacyErrors(t *testing.T) { - primaryErr := errors.New("primary delete failed") - legacyErr := errors.New("legacy delete failed") - cache := &stickyDeleteFaultCache{ - stubGatewayCache: &stubGatewayCache{sessionBindings: map[string]int64{ - "openai:new-hash": 11, - "openai:legacy-hash": 11, - }}, - deleteErrors: map[string]error{ - "openai:new-hash": primaryErr, - "openai:legacy-hash": legacyErr, - }, - } - svc := &OpenAIGatewayService{cache: cache} - ctx := withOpenAILegacySessionHash(context.Background(), "legacy-hash") - - err := svc.ClearOpenAIStickySessionAccountIDIfMatches(ctx, nil, "new-hash", 11) - - require.ErrorIs(t, err, primaryErr) - require.ErrorIs(t, err, legacyErr) - require.Equal(t, []string{"openai:new-hash", "openai:legacy-hash"}, cache.deleteCalls) + _, exists := cache.sessionBindings["openai:new-hash"] + require.False(t, exists) } func TestSnapshotOpenAICompatibilityFallbackMetrics(t *testing.T) { diff --git a/backend/internal/service/openai_tool_continuation.go b/backend/internal/service/openai_tool_continuation.go index 191c2de76087..3b2356c9715f 100644 --- a/backend/internal/service/openai_tool_continuation.go +++ b/backend/internal/service/openai_tool_continuation.go @@ -230,36 +230,21 @@ type ToolCallOutputContextCoverage struct { // 与可重建上下文。不能复用 ValidateFunctionCallOutputContextBytes 的 HasToolCallContext: // 该标志只代表"存在某一个上下文项",部分覆盖的续链仍会被上游拒绝。 func AnalyzeToolCallOutputContextCoverageBytes(body []byte) ToolCallOutputContextCoverage { - return analyzeToolCallOutputContextCoverageBytes(body, true) -} - -// AnalyzeConcreteToolCallOutputContextCoverageBytes is the migration-safe -// variant of AnalyzeToolCallOutputContextCoverageBytes. It requires each output -// to have its concrete tool-call item in the same payload; item_reference alone -// still depends on the old provider's stored response and is not portable. -func AnalyzeConcreteToolCallOutputContextCoverageBytes(body []byte) ToolCallOutputContextCoverage { - return analyzeToolCallOutputContextCoverageBytes(body, false) -} - -func analyzeToolCallOutputContextCoverageBytes(body []byte, allowItemReferences bool) ToolCallOutputContextCoverage { coverage := ToolCallOutputContextCoverage{} if len(body) == 0 { return coverage } input := parseRawJSONView(body).Get("input") - items := input.Array() - if input.IsObject() { - items = []gjson.Result{input} - } else if !input.IsArray() { + if !input.IsArray() && !input.IsObject() { return coverage } missingCallID := false var outputCallIDs map[string]struct{} var contextIDs map[string]struct{} - for _, item := range items { + analyzeItem := func(item gjson.Result) { if !item.IsObject() { - continue + return } itemType := item.Get("type").String() switch { @@ -268,7 +253,7 @@ func analyzeToolCallOutputContextCoverageBytes(body []byte, allowItemReferences callID := strings.TrimSpace(item.Get("call_id").String()) if callID == "" { missingCallID = true - continue + return } if outputCallIDs == nil { outputCallIDs = make(map[string]struct{}) @@ -277,16 +262,16 @@ func analyzeToolCallOutputContextCoverageBytes(body []byte, allowItemReferences case isCodexToolCallContextItemType(itemType): callID := strings.TrimSpace(item.Get("call_id").String()) if callID == "" { - continue + return } if contextIDs == nil { contextIDs = make(map[string]struct{}) } contextIDs[callID] = struct{}{} - case allowItemReferences && itemType == "item_reference": + case itemType == "item_reference": idValue := strings.TrimSpace(item.Get("id").String()) if idValue == "" { - continue + return } if contextIDs == nil { contextIDs = make(map[string]struct{}) @@ -294,6 +279,14 @@ func analyzeToolCallOutputContextCoverageBytes(body []byte, allowItemReferences contextIDs[idValue] = struct{}{} } } + if input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + analyzeItem(item) + return true + }) + } else { + analyzeItem(input) + } if !coverage.HasFunctionCallOutput || missingCallID { return coverage diff --git a/backend/internal/service/openai_tool_continuation_test.go b/backend/internal/service/openai_tool_continuation_test.go index edb2aa15b270..460a659c2eea 100644 --- a/backend/internal/service/openai_tool_continuation_test.go +++ b/backend/internal/service/openai_tool_continuation_test.go @@ -263,12 +263,6 @@ func TestAnalyzeToolCallOutputContextCoverageBytes(t *testing.T) { hasOutput: true, coversAllIDs: false, }, - { - name: "single_object_tool_output_not_movable", - body: map[string]any{"input": map[string]any{"type": "custom_tool_call_output", "call_id": "call_a"}}, - hasOutput: true, - coversAllIDs: false, - }, { name: "mixed_context_and_reference_cover_all", body: map[string]any{"input": []any{ @@ -304,26 +298,3 @@ func TestAnalyzeToolCallOutputContextCoverageBytes(t *testing.T) { }) } } - -func TestAnalyzeConcreteToolCallOutputContextCoverageBytes_RejectsStoredReferences(t *testing.T) { - itemReferenceOnly := []byte(`{"input":[` + - `{"type":"custom_tool_call_output","call_id":"call_1","output":"ok"},` + - `{"type":"item_reference","id":"call_1"}` + - `]}`) - concrete := []byte(`{"input":[` + - `{"type":"custom_tool_call","call_id":"call_1","name":"shell","input":"{}"},` + - `{"type":"custom_tool_call_output","call_id":"call_1","output":"ok"}` + - `]}`) - - legacyCoverage := AnalyzeToolCallOutputContextCoverageBytes(itemReferenceOnly) - require.True(t, legacyCoverage.HasFunctionCallOutput) - require.True(t, legacyCoverage.ContextCoversAllCallIDs) - - migrationCoverage := AnalyzeConcreteToolCallOutputContextCoverageBytes(itemReferenceOnly) - require.True(t, migrationCoverage.HasFunctionCallOutput) - require.False(t, migrationCoverage.ContextCoversAllCallIDs) - - concreteCoverage := AnalyzeConcreteToolCallOutputContextCoverageBytes(concrete) - require.True(t, concreteCoverage.HasFunctionCallOutput) - require.True(t, concreteCoverage.ContextCoversAllCallIDs) -} diff --git a/backend/internal/service/openai_upstream_transport_error.go b/backend/internal/service/openai_upstream_transport_error.go index 3f93709f07cc..43e116ee8d50 100644 --- a/backend/internal/service/openai_upstream_transport_error.go +++ b/backend/internal/service/openai_upstream_transport_error.go @@ -183,7 +183,7 @@ func (s *OpenAIGatewayService) tempUnscheduleOpenAITransportError(ctx context.Co reason := "upstream transport error (proxy/network): " + safeErr // Mark this as a DB-cooldown mirror, so later authoritative recovery can // clear it without leaving an independent downstream breaker behind. - s.BlockAccountSchedulingFromPersistedCooldown(account, until, "transport_error") + s.BlockAccountSchedulingFromPersistedCooldown(account, until) if s.accountRepo == nil { logger.L().With(zap.String("component", "service.openai_gateway")).Warn( "openai.account_temp_unscheduled_transport_memory_only", diff --git a/backend/internal/service/openai_ws_forwarder_client_cancel_test.go b/backend/internal/service/openai_ws_forwarder_client_cancel_test.go index cf7d47fd0fd6..2905223479cd 100644 --- a/backend/internal/service/openai_ws_forwarder_client_cancel_test.go +++ b/backend/internal/service/openai_ws_forwarder_client_cancel_test.go @@ -323,7 +323,7 @@ func TestClassifyAccountTrafficOutcome(t *testing.T) { {name: "safety_net_nil_nil", want: AccountTrafficOutcomeFailedOther}, } { t.Run(tc.name, func(t *testing.T) { - require.Equal(t, tc.want, classifyAccountTrafficOutcome(tc.result, tc.err, tc.clientCancelled)) + require.Equal(t, tc.want, evaluateAccountTrafficOutcome(accountTrafficOutcomeRules, tc.result, tc.err, tc.clientCancelled)) }) } } diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 1cad0c492a1a..9c2986b01f63 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -303,11 +303,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( requestModel = mappedModel } } - mappedRequestModel, err := resolveOpenAIForwardModelContext(ctx, account, requestModel, "") - if err != nil { - return openAIWSClientPayload{}, err - } - upstreamModel := normalizeOpenAIModelForUpstream(account, mappedRequestModel) + upstreamModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(requestModel)) requestedReasoningEffort := CanonicalRequestedReasoningEffort(normalized, strings.TrimSpace(values[1].String())) if next, policyErr := applyOpenAIWSReasoningEffortPolicy(normalized, hooks); policyErr != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, policyErr.Error(), policyErr) @@ -1056,14 +1052,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( var rejectedFieldRetryState *openAIResponsesRejectedFieldRetryState sendAndRelay := func(turn int, lease *openAIWSConnLease, cleanPayload, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string, requestedReasoningEffort *string) (*OpenAIForwardResult, error) { - mappedModel := strings.TrimSpace(gjson.GetBytes(payload, "model").String()) - if originalModel != "" && mappedModel == "" { - mapped, err := resolveOpenAIForwardModelContext(ctx, account, originalModel, "") - if err != nil { - return nil, err - } - mappedModel = normalizeOpenAIModelForUpstream(account, mapped) - } responseModelObserver := &upstreamResponseModelObserver{} if lease == nil { return nil, errors.New("upstream websocket lease is nil") @@ -1141,8 +1129,13 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( lastEventType := "" needModelReplace := false clientDisconnected := false + mappedModel := "" var mappedModelBytes []byte if originalModel != "" { + mappedModel = strings.TrimSpace(gjson.GetBytes(payload, "model").String()) + if mappedModel == "" { + mappedModel = normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) + } needModelReplace = mappedModel != "" && mappedModel != originalModel if needModelReplace { mappedModelBytes = []byte(mappedModel) diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 5d6f220a1728..52755ff0f7af 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -817,7 +817,7 @@ readLoop: if refusalOutput != nil { refusalOutput.DropTurn() } - return nil, NewOpenAICyberFailoverError(message, lease.HandshakeHeaders()) + return nil, NewOpenAICyberFailoverError(lease.HandshakeHeaders()) } if sanitized, ok := sanitizeOpenAICyberPolicyFailedEvent(message); ok { message = sanitized diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 19aed3a79bc5..c5ebeac453fb 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -771,11 +771,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2PassthroughAttempt( return mapErr } if mappedModel = strings.TrimSpace(mappedModel); mappedModel != "" { - accountMapped, err := resolveOpenAIForwardModelContext(ctx, account, mappedModel, "") - if err != nil { - return err - } - if accountMapped = normalizeOpenAIModelForUpstream(account, accountMapped); accountMapped != "" { + if accountMapped := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(mappedModel)); accountMapped != "" { mappedModel = accountMapped } firstClientMessage = s.ReplaceModelInBody(firstClientMessage, mappedModel) @@ -1134,11 +1130,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2PassthroughAttempt( return payload, nil, err } if upstreamModel = strings.TrimSpace(upstreamModel); upstreamModel != "" { - accountMapped, err := resolveOpenAIForwardModelContext(ctx, account, upstreamModel, "") - if err != nil { - return payload, nil, err - } - if accountMapped = normalizeOpenAIModelForUpstream(account, accountMapped); accountMapped != "" { + if accountMapped := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(upstreamModel)); accountMapped != "" { upstreamModel = accountMapped } payload = s.ReplaceModelInBody(payload, upstreamModel) diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 827e0421e35e..c80311e60a85 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -50,7 +50,7 @@ type AccountRuntimeBlocker interface { } type accountPersistedSchedulingCooldownBlocker interface { - BlockAccountSchedulingFromPersistedCooldown(account *Account, until time.Time, reason string) + BlockAccountSchedulingFromPersistedCooldown(account *Account, until time.Time) } // notifyPersistedAccountSchedulingCooldown distinguishes DB cooldown mirrors @@ -60,7 +60,7 @@ func notifyPersistedAccountSchedulingCooldown(blocker AccountRuntimeBlocker, acc return } if persisted, ok := blocker.(accountPersistedSchedulingCooldownBlocker); ok { - persisted.BlockAccountSchedulingFromPersistedCooldown(account, until, reason) + persisted.BlockAccountSchedulingFromPersistedCooldown(account, until) return } blocker.BlockAccountScheduling(account, until, reason) diff --git a/backend/internal/service/scheduler_snapshot_bulk_event_test.go b/backend/internal/service/scheduler_snapshot_bulk_event_test.go index 7975d04f271e..17bfec7c9f4c 100644 --- a/backend/internal/service/scheduler_snapshot_bulk_event_test.go +++ b/backend/internal/service/scheduler_snapshot_bulk_event_test.go @@ -27,16 +27,6 @@ func (r *bulkEventAccountRepo) GetByIDs(context.Context, []int64) ([]*Account, e return append([]*Account(nil), r.accounts...), nil } -func (r *bulkEventAccountRepo) GetByID(_ context.Context, accountID int64) (*Account, error) { - for _, account := range r.accounts { - if account != nil && account.ID == accountID { - clone := *account - return &clone, nil - } - } - return nil, ErrAccountNotFound -} - type bulkEventSnapshotCache struct { *batchSnapshotCache @@ -124,20 +114,6 @@ func TestSchedulerBulkAccountEventScopesOpenAIRebuildToFreshPlatform(t *testing. require.Empty(t, deleted) } -func TestSchedulerRefreshAccountAndGroupsRebuildsOldAndNewBuckets(t *testing.T) { - cache := newBulkEventSnapshotCache() - repo := newBulkEventAccountRepo(&Account{ID: 12, Platform: PlatformOpenAI, GroupIDs: []int64{82}}) - svc := newBulkEventTestService(cache, repo) - - err := svc.RefreshAccountAndGroups(context.Background(), 12, []int64{81, 82}) - - require.NoError(t, err) - require.ElementsMatch(t, schedulerBucketsForTest([]int64{81, 82}, PlatformOpenAI), cache.capturedBuckets()) - set, deleted := cache.accountWrites() - require.Equal(t, []int64{12}, set) - require.Empty(t, deleted) -} - func TestSchedulerBulkAccountEventScopesCNRebuildToFreshPlatform(t *testing.T) { for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} { t.Run(platform, func(t *testing.T) { diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index fa1b566de796..3c911e3e4e89 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -325,22 +325,6 @@ func (s *SchedulerSnapshotService) UpdateAccountInCache(ctx context.Context, acc return s.cache.SetAccount(ctx, account) } -// RefreshAccountAndGroups synchronously applies the same account event path -// used by the durable scheduler outbox. groupIDs must contain both the old and -// new memberships so stale buckets are removed before an admin mutation -// returns. -func (s *SchedulerSnapshotService) RefreshAccountAndGroups(ctx context.Context, accountID int64, groupIDs []int64) error { - if s == nil || accountID <= 0 { - return nil - } - groupValues := make([]any, 0, len(groupIDs)) - for _, groupID := range groupIDs { - groupValues = append(groupValues, groupID) - } - payload := map[string]any{"group_ids": groupValues} - return s.handleAccountEvent(ctx, &accountID, payload, make(map[batchSeenKey]struct{})) -} - func (s *SchedulerSnapshotService) runInitialRebuild() { if s.cache == nil { return diff --git a/backend/internal/service/setting_image_tools.go b/backend/internal/service/setting_image_tools.go index 9781957070c4..f184632f1d8c 100644 --- a/backend/internal/service/setting_image_tools.go +++ b/backend/internal/service/setting_image_tools.go @@ -10,12 +10,6 @@ import ( // the image-tools plugin configuration. A missing switch is off. const SettingKeyImageToolsConfig = "image_tools_config" -// EffectiveImageToolsConfig returns the switch this process applies. -func EffectiveImageToolsConfig() extensionv1.ImageToolsConfig { - config, _ := currentImageToolsConfig() - return config -} - // LoadImageToolsConfig installs the stored switch for this process at startup. // Without a stored value the deploy-time flag stays in force. func (s *SettingService) LoadImageToolsConfig(ctx context.Context) error { diff --git a/backend/internal/testextensions/fixtures.go b/backend/internal/testextensions/fixtures.go index 8235fbda7397..2ddff6a126bc 100644 --- a/backend/internal/testextensions/fixtures.go +++ b/backend/internal/testextensions/fixtures.go @@ -15,7 +15,7 @@ import ( type operations struct{} func (operations) InvokeOperation(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { - if strings.HasPrefix(in.Operation, "taxonomy.") || strings.HasPrefix(in.Operation, "test.") || strings.HasPrefix(in.Operation, "import.") || in.Operation == "tools.describe" { + if strings.HasPrefix(in.Operation, "taxonomy.") || strings.HasPrefix(in.Operation, "test.") || strings.HasPrefix(in.Operation, "import.") { return accounttools.New().Invoke(ctx, in) } return extensionv1.Result{}, service.ErrExtensionOperationDisabled