From 42df7b37be8379aa28e4dee535492ac301ffa725 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 4 Oct 2026 15:38:56 +0800 Subject: [PATCH 1/2] refactor(openai): drop two placeholder-context functions left by Cindy resolveOpenAIForwardModelContext only returned resolveOpenAIForwardModel with a nil error, so its 14 callers each carried an error branch that could never run. isOpenAIAccountRuntimeBlockedContext held the body of isOpenAIAccountRuntimeBlocked behind an unused context. Both came from Cindy features that have since been removed. Callers now use the upstream line where upstream has the same call, and the fork's line from before the Cindy commits elsewhere. No behaviour change. Co-Authored-By: Claude Opus 5.5 --- .../openai_account_runtime_block_fastpath.go | 6 +----- backend/internal/service/openai_embeddings.go | 5 +---- .../openai_gateway_chat_completions.go | 10 ++-------- .../openai_gateway_chat_completions_raw.go | 10 ++-------- .../service/openai_gateway_forward.go | 6 +----- .../service/openai_gateway_messages.go | 5 +---- .../openai_gateway_messages_chat_fallback.go | 5 +---- .../openai_gateway_responses_chat_fallback.go | 5 +---- backend/internal/service/openai_images.go | 5 +---- .../internal/service/openai_model_mapping.go | 11 +---------- .../service/openai_ws_forwarder_ingress.go | 19 ++++++------------- .../openai_ws_v2_passthrough_adapter.go | 12 ++---------- 12 files changed, 20 insertions(+), 79 deletions(-) diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 3c3f3bb3aa39..7f12f4827b06 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -963,10 +963,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 +1167,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_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_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_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_images.go b/backend/internal/service/openai_images.go index 1d7d83ee0dbe..150a6e7cca5d 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -652,10 +652,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 } 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_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_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) From b6f09a482fb6e30d3f4bac186e9ef151512eda81 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 4 Oct 2026 18:15:30 +0800 Subject: [PATCH 2/2] refactor(backend): remove placeholders left by removed features Follow-up to the two placeholder-context functions: the same Cindy, plugin-host and overdraft commits left more of the same kind. Billing (openai_gateway_usage.go) is back to upstream's shape: no unused account/tokens parameters, no error return that was nil on every path, no duplicated channel-pricing branch. Result structs lose fields that were only written or never assigned. Runtime-block code loses a condition that was always false and a fallback no caller could reach. Functions with no production caller go with their tests. Blank parameters whose reason is gone are dropped from their signatures; wire_gen.go is regenerated. Kept on purpose: blank parameters required by an interface, a callback or wire ordering, and signatures kept identical to upstream. No request changes what it sends, bills or fails on. Co-Authored-By: Claude Opus 5.5 --- backend/cmd/server/wire_gen.go | 16 ++-- .../accounttools/policy/import_test.go | 2 +- .../internal/accounttools/policy/module.go | 20 ----- .../accounttools/policy/module_test.go | 4 +- .../account_handler_traffic_telemetry_test.go | 4 +- backend/internal/handler/grok_media.go | 2 +- .../internal/handler/openai_alpha_search.go | 2 +- .../handler/openai_chat_completions.go | 2 +- .../handler/openai_codex_models_handler.go | 2 +- ...i_count_tokens_model_not_supported_test.go | 2 +- backend/internal/handler/openai_embeddings.go | 2 +- .../internal/handler/openai_failover_retry.go | 1 - .../handler/openai_gateway_count_tokens.go | 24 +++-- ...penai_gateway_first_output_timeout_test.go | 2 +- .../handler/openai_gateway_handler.go | 4 +- .../handler/openai_gateway_handler_test.go | 4 +- backend/internal/handler/openai_images.go | 2 +- .../openai_refusal_recovery_handler_test.go | 10 +-- backend/internal/handler/wire.go | 11 +-- backend/internal/nativeapi/api.go | 13 +-- backend/internal/repository/account_repo.go | 8 +- .../repository/concurrency_cache_live_test.go | 2 +- .../service/account_business_errors.go | 7 -- .../service/account_business_errors_test.go | 2 +- backend/internal/service/account_job.go | 6 -- .../internal/service/account_test_prompt.go | 4 +- .../service/account_test_reasoning.go | 6 +- .../service/account_test_reasoning_test.go | 15 ++-- .../internal/service/account_test_service.go | 31 ++----- .../account_test_service_cn_adaptive.go | 2 +- .../account_test_service_openai_test.go | 2 +- .../account_test_service_opencode_go.go | 4 +- backend/internal/service/account_tools.go | 13 +-- .../service/account_traffic_observe.go | 15 +--- backend/internal/service/admin_account.go | 7 +- .../service/admin_account_taxonomy.go | 4 - .../service/admin_account_taxonomy_test.go | 6 +- .../service/admin_observability_test.go | 7 +- .../gateway_image_reasoning_pricing_test.go | 2 +- .../internal/service/image_studio_artifact.go | 1 - .../service/image_studio_artifact_test.go | 6 +- backend/internal/service/image_studio_job.go | 7 +- .../service/image_studio_job_service.go | 26 +++--- .../service/image_studio_job_service_test.go | 25 +----- .../internal/service/image_studio_job_test.go | 8 +- .../internal/service/image_studio_runtime.go | 2 +- backend/internal/service/image_tools.go | 27 +++--- .../service/image_tools_policy_test.go | 11 +-- .../service/model_context_capacity.go | 14 +-- .../service/model_context_capacity_test.go | 9 +- .../service/model_context_identity.go | 2 +- .../openai_account_runtime_block_fastpath.go | 58 +++++------- ...nai_account_runtime_block_fastpath_test.go | 24 ++--- .../openai_account_runtime_transient_test.go | 2 +- .../openai_alpha_search_billing_test.go | 6 +- .../internal/service/openai_apikey_compat.go | 4 +- .../service/openai_apikey_compat_test.go | 8 +- .../service/openai_gateway_cn_fixes_test.go | 2 +- .../internal/service/openai_gateway_grok.go | 4 +- .../openai_gateway_search_surcharge_test.go | 3 - .../service/openai_gateway_service.go | 9 +- .../internal/service/openai_gateway_usage.go | 65 +++++--------- backend/internal/service/openai_images.go | 10 --- .../service/openai_reasoning_recovery.go | 6 +- .../service/openai_reasoning_recovery_test.go | 4 +- .../service/openai_refusal_recovery.go | 2 +- .../service/openai_refusal_recovery_test.go | 2 +- .../service/openai_refusal_recovery_ws.go | 8 -- .../openai_refusal_recovery_ws_test.go | 16 ---- .../internal/service/openai_sticky_compat.go | 26 ++---- .../service/openai_sticky_compat_test.go | 88 ++----------------- .../service/openai_tool_continuation.go | 37 ++++---- .../service/openai_tool_continuation_test.go | 29 ------ .../openai_upstream_transport_error.go | 2 +- .../openai_ws_forwarder_client_cancel_test.go | 2 +- .../service/openai_ws_forwarder_v2.go | 2 +- backend/internal/service/ratelimit_service.go | 4 +- .../scheduler_snapshot_bulk_event_test.go | 24 ----- .../service/scheduler_snapshot_service.go | 16 ---- .../internal/service/setting_image_tools.go | 6 -- backend/internal/testextensions/fixtures.go | 2 +- 81 files changed, 246 insertions(+), 635 deletions(-) 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 7f12f4827b06..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 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_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_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_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 150a6e7cca5d..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 "" @@ -768,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, @@ -791,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, @@ -816,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_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_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/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