diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 44b619784e8..0ecbd89d27b 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -308,7 +308,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { zap.String("platform", platform), zap.Error(err), ) - h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted) + status, errType, message := h.openAIAccountSelectionErrorResponse(c, platform, apiKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "No available accounts: "+err.Error()) + h.handleStreamingAwareError(c, status, errType, message, streamStarted) return } action := fs.HandleSelectionExhausted(c.Request.Context()) @@ -357,7 +358,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { zap.String("model", reqModel), zap.String("platform", platform), ) - h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted) + status, errType, message := h.openAIAccountSelectionErrorResponse(c, platform, apiKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "No available accounts") + h.handleStreamingAwareError(c, status, errType, message, streamStarted) return } accountWaitCounted := false @@ -586,7 +588,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { zap.Bool("fallback_used", fallbackUsed), zap.Error(err), ) - h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted) + status, errType, message := h.openAIAccountSelectionErrorResponse(c, platform, currentAPIKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "No available accounts: "+err.Error()) + h.handleStreamingAwareError(c, status, errType, message, streamStarted) return } action := fs.HandleSelectionExhausted(c.Request.Context()) @@ -645,7 +648,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { zap.String("model", reqModel), zap.String("platform", platform), ) - h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted) + status, errType, message := h.openAIAccountSelectionErrorResponse(c, platform, currentAPIKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "No available accounts") + h.handleStreamingAwareError(c, status, errType, message, streamStarted) return } accountWaitCounted := false @@ -1786,7 +1790,8 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) { if err != nil { reqLog.Warn("gateway.count_tokens_select_account_failed", zap.Error(err)) markOpsRoutingCapacityLimitedIfNoAvailable(c, err) - h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable") + status, errType, message := h.openAIAccountSelectionErrorResponse(c, apiKeyGroupPlatform(apiKey), apiKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "Service temporarily unavailable") + h.errorResponse(c, status, errType, message) return } setOpsSelectedAccount(c, account.ID, account.Platform) diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index 712c2b9fb4a..9ce915725f8 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -163,7 +163,8 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { if err != nil { if len(fs.FailedAccountIDs) == 0 { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) - h.chatCompletionsErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error()) + status, errType, message := h.openAIAccountSelectionErrorResponse(c, groupPlatform, apiKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "No available accounts: "+err.Error()) + h.chatCompletionsErrorResponse(c, status, errType, message) return } action := fs.HandleSelectionExhausted(c.Request.Context()) @@ -189,7 +190,8 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { if !selection.Acquired { if selection.WaitPlan == nil { markOpsRoutingCapacityLimited(c) - h.chatCompletionsErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts") + status, errType, message := h.openAIAccountSelectionErrorResponse(c, groupPlatform, apiKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "No available accounts") + h.chatCompletionsErrorResponse(c, status, errType, message) return } accountReleaseFunc, err = h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout( diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index a813f5f7670..c9a6bb2aad4 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -161,7 +161,8 @@ func (h *GatewayHandler) Responses(c *gin.Context) { if err != nil { if len(fs.FailedAccountIDs) == 0 { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) - h.responsesErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error()) + status, errType, message := h.openAIAccountSelectionErrorResponse(c, apiKeyGroupPlatform(apiKey), apiKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "No available accounts: "+err.Error()) + h.responsesErrorResponse(c, status, errType, message) return } action := fs.HandleSelectionExhausted(requestCtx) @@ -187,7 +188,8 @@ func (h *GatewayHandler) Responses(c *gin.Context) { if !selection.Acquired { if selection.WaitPlan == nil { markOpsRoutingCapacityLimited(c) - h.responsesErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts") + status, errType, message := h.openAIAccountSelectionErrorResponse(c, apiKeyGroupPlatform(apiKey), apiKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "No available accounts") + h.responsesErrorResponse(c, status, errType, message) return } accountReleaseFunc, err = h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout( diff --git a/backend/internal/handler/openai_account_selection_error.go b/backend/internal/handler/openai_account_selection_error.go new file mode 100644 index 00000000000..ec5f451d57a --- /dev/null +++ b/backend/internal/handler/openai_account_selection_error.go @@ -0,0 +1,92 @@ +package handler + +import ( + "context" + "net/http" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +type accountSelectionErrorTemplateDataFunc func(context.Context, string, *int64) service.AccountSelectionErrorTemplateData + +func openAIAccountSelectionErrorResponse( + c *gin.Context, + errorPassthroughService *service.ErrorPassthroughService, + templateData accountSelectionErrorTemplateDataFunc, + platform string, + groupID *int64, + matchMessage string, + defaultMessage string, +) (int, string, string) { + status := http.StatusServiceUnavailable + errType := "api_error" + message := defaultMessage + if errorPassthroughService == nil { + return status, errType, message + } + + body := []byte(matchMessage) + if platform == "" { + platform = service.PlatformOpenAI + } + rule := errorPassthroughService.MatchRule(platform, service.OpenAIAccountSelectionNoAvailableStatus, body) + if rule == nil && platform != service.PlatformOpenAI { + rule = errorPassthroughService.MatchRule(service.PlatformOpenAI, service.OpenAIAccountSelectionNoAvailableStatus, body) + } + if rule == nil { + return status, errType, message + } + + if !rule.PassthroughCode && rule.ResponseCode != nil { + status = *rule.ResponseCode + } + if !rule.PassthroughBody && rule.CustomMessage != nil { + data := service.AccountSelectionErrorTemplateData{} + if templateData != nil { + ctx := context.Background() + if c != nil && c.Request != nil { + ctx = c.Request.Context() + } + data = templateData(ctx, platform, groupID) + } + message = service.RenderAccountSelectionErrorMessage(*rule.CustomMessage, data) + } else { + message = matchMessage + } + if rule.SkipMonitoring && c != nil { + c.Set(service.OpsSkipPassthroughKey, true) + } + return status, "upstream_error", message +} + +func (h *OpenAIGatewayHandler) openAIAccountSelectionErrorResponse(c *gin.Context, groupID *int64, matchMessage string, defaultMessage string) (int, string, string) { + var templateData accountSelectionErrorTemplateDataFunc + if h != nil && h.gatewayService != nil { + templateData = h.gatewayService.AccountSelectionErrorTemplateData + } + var errorPassthroughService *service.ErrorPassthroughService + if h != nil { + errorPassthroughService = h.errorPassthroughService + } + return openAIAccountSelectionErrorResponse(c, errorPassthroughService, templateData, service.PlatformOpenAI, groupID, matchMessage, defaultMessage) +} + +func (h *GatewayHandler) openAIAccountSelectionErrorResponse(c *gin.Context, platform string, groupID *int64, matchMessage string, defaultMessage string) (int, string, string) { + var templateData accountSelectionErrorTemplateDataFunc + if h != nil && h.gatewayService != nil { + templateData = h.gatewayService.AccountSelectionErrorTemplateData + } + var errorPassthroughService *service.ErrorPassthroughService + if h != nil { + errorPassthroughService = h.errorPassthroughService + } + return openAIAccountSelectionErrorResponse(c, errorPassthroughService, templateData, platform, groupID, matchMessage, defaultMessage) +} + +func apiKeyGroupPlatform(apiKey *service.APIKey) string { + if apiKey == nil || apiKey.Group == nil { + return "" + } + return apiKey.Group.Platform +} diff --git a/backend/internal/handler/openai_account_selection_error_test.go b/backend/internal/handler/openai_account_selection_error_test.go new file mode 100644 index 00000000000..d5799d0d3f1 --- /dev/null +++ b/backend/internal/handler/openai_account_selection_error_test.go @@ -0,0 +1,92 @@ +//go:build unit + +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/model" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +func TestOpenAIAccountSelectionErrorResponse_DefaultWithoutRule(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/responses", nil) + h := &OpenAIGatewayHandler{} + + status, errType, message := h.openAIAccountSelectionErrorResponse(c, nil, service.OpenAIAccountSelectionNoAvailableMessage, "Service temporarily unavailable") + + if status != http.StatusServiceUnavailable { + t.Fatalf("status=%d want %d", status, http.StatusServiceUnavailable) + } + if errType != "api_error" { + t.Fatalf("errType=%s want api_error", errType) + } + if message != "Service temporarily unavailable" { + t.Fatalf("message=%q", message) + } +} + +func TestGatewayAccountSelectionErrorResponse_CustomRule(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/responses", nil) + responseCode := http.StatusTooManyRequests + customMessage := "账户额度已用完" + svc := service.NewErrorPassthroughService(&accountSelectionErrorRuleRepo{rules: []*model.ErrorPassthroughRule{ + { + ID: 1, + Name: "OpenAI no available accounts", + Enabled: true, + Priority: 0, + ErrorCodes: []int{service.OpenAIAccountSelectionNoAvailableStatus}, + Keywords: []string{service.OpenAIAccountSelectionNoAvailableMessage}, + MatchMode: model.MatchModeAll, + Platforms: []string{model.PlatformOpenAI}, + PassthroughCode: false, + ResponseCode: &responseCode, + PassthroughBody: false, + CustomMessage: &customMessage, + }, + }}, nil) + h := &GatewayHandler{errorPassthroughService: svc} + + status, errType, message := h.openAIAccountSelectionErrorResponse(c, service.PlatformGemini, nil, service.OpenAIAccountSelectionNoAvailableMessage, "No available accounts: no available accounts") + + if status != http.StatusTooManyRequests { + t.Fatalf("status=%d want %d", status, http.StatusTooManyRequests) + } + if errType != "upstream_error" { + t.Fatalf("errType=%s want upstream_error", errType) + } + if message != customMessage { + t.Fatalf("message=%q want %q", message, customMessage) + } +} + +type accountSelectionErrorRuleRepo struct { + rules []*model.ErrorPassthroughRule +} + +func (r *accountSelectionErrorRuleRepo) List(context.Context) ([]*model.ErrorPassthroughRule, error) { + return r.rules, nil +} + +func (r *accountSelectionErrorRuleRepo) GetByID(context.Context, int64) (*model.ErrorPassthroughRule, error) { + panic("unexpected GetByID") +} + +func (r *accountSelectionErrorRuleRepo) Create(context.Context, *model.ErrorPassthroughRule) (*model.ErrorPassthroughRule, error) { + panic("unexpected Create") +} + +func (r *accountSelectionErrorRuleRepo) Update(context.Context, *model.ErrorPassthroughRule) (*model.ErrorPassthroughRule, error) { + panic("unexpected Update") +} + +func (r *accountSelectionErrorRuleRepo) Delete(context.Context, int64) error { + panic("unexpected Delete") +} diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index a59450f1306..0440a12983b 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -152,7 +152,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { ) if len(failedAccountIDs) == 0 { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) - h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted) + status, errType, message := h.openAIAccountSelectionErrorResponse(c, apiKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "Service temporarily unavailable") + h.handleStreamingAwareError(c, status, errType, message, streamStarted) return } else { if lastFailoverErr != nil { @@ -165,7 +166,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { } if selection == nil || selection.Account == nil { markOpsRoutingCapacityLimited(c) - h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted) + status, errType, message := h.openAIAccountSelectionErrorResponse(c, apiKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "No available accounts") + h.handleStreamingAwareError(c, status, errType, message, streamStarted) return } account := selection.Account diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go index 20f9073510e..72331bd66c4 100644 --- a/backend/internal/handler/openai_embeddings.go +++ b/backend/internal/handler/openai_embeddings.go @@ -125,7 +125,8 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { ) if len(failedAccountIDs) == 0 { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) - h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable") + status, errType, message := h.openAIAccountSelectionErrorResponse(c, apiKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "Service temporarily unavailable") + h.errorResponse(c, status, errType, message) return } if lastFailoverErr != nil { @@ -137,7 +138,8 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { } if selection == nil || selection.Account == nil { markOpsRoutingCapacityLimited(c) - h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts") + status, errType, message := h.openAIAccountSelectionErrorResponse(c, apiKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "No available accounts") + h.errorResponse(c, status, errType, message) return } account := selection.Account diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 4c13a2e7aa3..585fcd18227 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -344,7 +344,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "compact_not_supported", "No available OpenAI accounts support /responses/compact", streamStarted) return } - h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted) + status, errType, message := h.openAIAccountSelectionErrorResponse(c, apiKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "Service temporarily unavailable") + h.handleStreamingAwareError(c, status, errType, message, streamStarted) return } if lastFailoverErr != nil { @@ -356,7 +357,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } if selection == nil || selection.Account == nil { markOpsRoutingCapacityLimited(c) - h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted) + status, errType, message := h.openAIAccountSelectionErrorResponse(c, apiKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "No available accounts") + h.handleStreamingAwareError(c, status, errType, message, streamStarted) return } if previousResponseID != "" && selection != nil && selection.Account != nil { @@ -762,7 +764,8 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { if len(failedAccountIDs) == 0 { if err != nil { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) - h.anthropicStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted) + status, errType, message := h.openAIAccountSelectionErrorResponse(c, apiKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "Service temporarily unavailable") + h.anthropicStreamingAwareError(c, status, errType, message, streamStarted) return } } else { @@ -776,7 +779,8 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { } if selection == nil || selection.Account == nil { markOpsRoutingCapacityLimited(c) - h.anthropicStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted) + status, errType, message := h.openAIAccountSelectionErrorResponse(c, apiKey.GroupID, service.OpenAIAccountSelectionNoAvailableMessage, "No available accounts") + h.anthropicStreamingAwareError(c, status, errType, message, streamStarted) return } account := selection.Account @@ -1061,7 +1065,8 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot( ) (func(), bool) { if selection == nil || selection.Account == nil { markOpsRoutingCapacityLimited(c) - h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", *streamStarted) + status, errType, message := h.openAIAccountSelectionErrorResponse(c, groupID, service.OpenAIAccountSelectionNoAvailableMessage, "No available accounts") + h.handleStreamingAwareError(c, status, errType, message, *streamStarted) return nil, false } @@ -1072,7 +1077,8 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot( } if selection.WaitPlan == nil { markOpsRoutingCapacityLimited(c) - h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", *streamStarted) + status, errType, message := h.openAIAccountSelectionErrorResponse(c, groupID, service.OpenAIAccountSelectionNoAvailableMessage, "No available accounts") + h.handleStreamingAwareError(c, status, errType, message, *streamStarted) return nil, false } diff --git a/backend/internal/service/openai_account_selection_error.go b/backend/internal/service/openai_account_selection_error.go new file mode 100644 index 00000000000..33835e47835 --- /dev/null +++ b/backend/internal/service/openai_account_selection_error.go @@ -0,0 +1,309 @@ +package service + +import ( + "context" + "fmt" + "sort" + "strings" + "time" +) + +const ( + OpenAIAccountSelectionNoAvailableStatus = 503 + OpenAIAccountSelectionNoAvailableMessage = "no available accounts" +) + +// AccountSelectionErrorTemplateData contains best-effort, non-secret account +// details for local account-selection failures across supported platforms. +type AccountSelectionErrorTemplateData struct { + NextResetAt string + NextResetAccount string + AccountSummary string +} + +// OpenAIAccountSelectionErrorTemplateData is kept as a compatibility alias for +// existing templates/tests. The data itself is no longer OpenAI-only. +type OpenAIAccountSelectionErrorTemplateData = AccountSelectionErrorTemplateData + +// AccountSelectionErrorTemplateData returns best-effort template values for +// local account-selection failures. It intentionally reads only non-secret +// account metadata, scheduler state, quota config, and public usage snapshots. +func (s *OpenAIGatewayService) AccountSelectionErrorTemplateData(ctx context.Context, platform string, groupID *int64) AccountSelectionErrorTemplateData { + if s == nil { + return AccountSelectionErrorTemplateData{} + } + return accountSelectionErrorTemplateData(ctx, s.accountRepo, platform, groupID) +} + +// OpenAIAccountSelectionErrorTemplateData returns compatibility data for older +// OpenAI-specific call sites. +func (s *OpenAIGatewayService) OpenAIAccountSelectionErrorTemplateData(ctx context.Context, groupID *int64) OpenAIAccountSelectionErrorTemplateData { + return s.AccountSelectionErrorTemplateData(ctx, PlatformOpenAI, groupID) +} + +// AccountSelectionErrorTemplateData returns best-effort template values for +// local account-selection failures in the generic gateway. +func (s *GatewayService) AccountSelectionErrorTemplateData(ctx context.Context, platform string, groupID *int64) AccountSelectionErrorTemplateData { + if s == nil { + return AccountSelectionErrorTemplateData{} + } + return accountSelectionErrorTemplateData(ctx, s.accountRepo, platform, groupID) +} + +// OpenAIAccountSelectionErrorTemplateData returns compatibility data for older +// OpenAI-specific call sites. +func (s *GatewayService) OpenAIAccountSelectionErrorTemplateData(ctx context.Context, groupID *int64) OpenAIAccountSelectionErrorTemplateData { + return s.AccountSelectionErrorTemplateData(ctx, PlatformOpenAI, groupID) +} + +func accountSelectionErrorTemplateData(ctx context.Context, accountRepo AccountRepository, platform string, groupID *int64) AccountSelectionErrorTemplateData { + if accountRepo == nil || groupID == nil || *groupID <= 0 { + return AccountSelectionErrorTemplateData{} + } + + accounts, err := accountRepo.ListByGroup(ctx, *groupID) + if err != nil { + return AccountSelectionErrorTemplateData{} + } + + platform = strings.TrimSpace(strings.ToLower(platform)) + selected := filterAccountsForSelectionSummary(accounts, platform) + if len(selected) == 0 { + if platform == "" { + return AccountSelectionErrorTemplateData{AccountSummary: "no accounts in group"} + } + return AccountSelectionErrorTemplateData{AccountSummary: "no " + platform + " accounts in group"} + } + + now := time.Now() + items := make([]accountSelectionQuotaSummary, 0, len(selected)) + for i := range selected { + items = append(items, buildAccountSelectionQuotaSummary(&selected[i], now)) + } + + sort.SliceStable(items, func(i, j int) bool { + if items[i].NextResetAt == nil && items[j].NextResetAt != nil { + return false + } + if items[i].NextResetAt != nil && items[j].NextResetAt == nil { + return true + } + if items[i].NextResetAt != nil && items[j].NextResetAt != nil && !items[i].NextResetAt.Equal(*items[j].NextResetAt) { + return items[i].NextResetAt.Before(*items[j].NextResetAt) + } + return items[i].ID < items[j].ID + }) + + data := AccountSelectionErrorTemplateData{ + AccountSummary: joinAccountSelectionQuotaSummaries(items), + } + for _, item := range items { + if item.NextResetAt != nil { + data.NextResetAt = item.NextResetAt.Format(time.RFC3339) + data.NextResetAccount = item.Name + break + } + } + return data +} + +func filterAccountsForSelectionSummary(accounts []Account, platform string) []Account { + if len(accounts) == 0 { + return nil + } + if platform == "" { + return accounts + } + matched := make([]Account, 0, len(accounts)) + for _, account := range accounts { + if strings.EqualFold(account.Platform, platform) { + matched = append(matched, account) + } + } + if len(matched) > 0 { + return matched + } + // If a group uses mixed scheduling or a platform alias, exact platform matches + // may be absent. Fall back to all group accounts so templates still show useful + // non-secret diagnostics instead of blank account_summary. + return accounts +} + +// RenderAccountSelectionErrorMessage replaces template variables in custom +// local account-selection error messages. +func RenderAccountSelectionErrorMessage(message string, data AccountSelectionErrorTemplateData) string { + replacer := strings.NewReplacer( + "{{next_reset_at}}", data.NextResetAt, + "{{next_reset_account}}", data.NextResetAccount, + "{{account_summary}}", data.AccountSummary, + ) + return replacer.Replace(message) +} + +// RenderOpenAIAccountSelectionErrorMessage is kept for compatibility. +func RenderOpenAIAccountSelectionErrorMessage(message string, data OpenAIAccountSelectionErrorTemplateData) string { + return RenderAccountSelectionErrorMessage(message, data) +} + +type accountSelectionQuotaSummary struct { + ID int64 + Name string + Platform string + Segments []string + NextResetAt *time.Time +} + +func buildAccountSelectionQuotaSummary(account *Account, now time.Time) accountSelectionQuotaSummary { + item := accountSelectionQuotaSummary{ + ID: account.ID, + Name: strings.TrimSpace(account.Name), + Platform: strings.TrimSpace(account.Platform), + } + if item.Name == "" { + item.Name = fmt.Sprintf("account-%d", account.ID) + } + if item.Platform == "" { + item.Platform = "unknown" + } + item.Segments = append(item.Segments, "platform "+item.Platform) + if account.Status != "" { + item.Segments = append(item.Segments, "status "+account.Status) + } + if !account.Schedulable { + item.Segments = append(item.Segments, "unschedulable") + } + if account.ErrorMessage != "" { + item.Segments = append(item.Segments, "error "+compactSummaryText(account.ErrorMessage)) + } + if account.RateLimitResetAt != nil && now.Before(*account.RateLimitResetAt) { + item.Segments = append(item.Segments, "rate_limit_reset "+account.RateLimitResetAt.Format(time.RFC3339)) + item.NextResetAt = earliestFutureTime(now, item.NextResetAt, account.RateLimitResetAt) + } + if account.OverloadUntil != nil && now.Before(*account.OverloadUntil) { + item.Segments = append(item.Segments, "overload_until "+account.OverloadUntil.Format(time.RFC3339)) + item.NextResetAt = earliestFutureTime(now, item.NextResetAt, account.OverloadUntil) + } + if account.TempUnschedulableUntil != nil && now.Before(*account.TempUnschedulableUntil) { + seg := "temp_unschedulable_until " + account.TempUnschedulableUntil.Format(time.RFC3339) + if account.TempUnschedulableReason != "" { + seg += " reason " + compactSummaryText(account.TempUnschedulableReason) + } + item.Segments = append(item.Segments, seg) + item.NextResetAt = earliestFutureTime(now, item.NextResetAt, account.TempUnschedulableUntil) + } + if account.ExpiresAt != nil { + item.Segments = append(item.Segments, "expires_at "+account.ExpiresAt.Format(time.RFC3339)) + } + appendQuotaSummary(account, now, &item) + appendCodexUsageSummary(account, now, &item) + return item +} + +func appendQuotaSummary(account *Account, now time.Time, item *accountSelectionQuotaSummary) { + if limit := account.GetQuotaLimit(); limit > 0 { + item.Segments = append(item.Segments, fmt.Sprintf("quota %.4g/%.4g", account.GetQuotaUsed(), limit)) + } + if limit := account.GetQuotaDailyLimit(); limit > 0 { + item.Segments = append(item.Segments, fmt.Sprintf("daily_quota %.4g/%.4g", account.GetQuotaDailyUsed(), limit)) + if resetAt := quotaResetAt(account.Extra, "quota_daily_reset_at", now); resetAt != nil { + item.Segments = append(item.Segments, "daily_reset "+resetAt.Format(time.RFC3339)) + item.NextResetAt = earliestFutureTime(now, item.NextResetAt, resetAt) + } + } + if limit := account.GetQuotaWeeklyLimit(); limit > 0 { + item.Segments = append(item.Segments, fmt.Sprintf("weekly_quota %.4g/%.4g", account.GetQuotaWeeklyUsed(), limit)) + if resetAt := quotaResetAt(account.Extra, "quota_weekly_reset_at", now); resetAt != nil { + item.Segments = append(item.Segments, "weekly_reset "+resetAt.Format(time.RFC3339)) + item.NextResetAt = earliestFutureTime(now, item.NextResetAt, resetAt) + } + } +} + +func appendCodexUsageSummary(account *Account, now time.Time, item *accountSelectionQuotaSummary) { + if value, ok := resolveAccountExtraNumber(account.Extra, "codex_5h_used_percent"); ok { + item.Segments = append(item.Segments, fmt.Sprintf("5h %.0f%%", value)) + } + if resetAt := parseOpenAIAccountQuotaResetAt(account.Extra, "5h", now); resetAt != nil { + item.Segments = append(item.Segments, "5h reset "+resetAt.Format(time.RFC3339)) + item.NextResetAt = earliestFutureTime(now, item.NextResetAt, resetAt) + } + if value, ok := resolveAccountExtraNumber(account.Extra, "codex_7d_used_percent"); ok { + item.Segments = append(item.Segments, fmt.Sprintf("7d %.0f%%", value)) + } + if resetAt := parseOpenAIAccountQuotaResetAt(account.Extra, "7d", now); resetAt != nil { + item.Segments = append(item.Segments, "7d reset "+resetAt.Format(time.RFC3339)) + item.NextResetAt = earliestFutureTime(now, item.NextResetAt, resetAt) + } +} + +func quotaResetAt(extra map[string]any, key string, now time.Time) *time.Time { + if len(extra) == 0 { + return nil + } + if raw, ok := extra[key]; ok { + if t, err := parseTime(fmt.Sprint(raw)); err == nil && now.Before(t) { + tt := t + return &tt + } + } + return nil +} + +func parseOpenAIAccountQuotaResetAt(extra map[string]any, window string, now time.Time) *time.Time { + if len(extra) == 0 { + return nil + } + if raw, ok := extra["codex_"+window+"_reset_at"]; ok { + if t, err := parseTime(fmt.Sprint(raw)); err == nil && now.Before(t) { + tt := t + return &tt + } + } + resetAfter := parseExtraInt(extra["codex_"+window+"_reset_after_seconds"]) + if resetAfter <= 0 { + return nil + } + base := now + if raw, ok := extra["codex_usage_updated_at"]; ok { + if updatedAt, err := parseTime(fmt.Sprint(raw)); err == nil { + base = updatedAt + } + } + resetAt := base.Add(time.Duration(resetAfter) * time.Second) + if !now.Before(resetAt) { + return nil + } + return &resetAt +} + +func earliestFutureTime(now time.Time, values ...*time.Time) *time.Time { + var earliest *time.Time + for _, value := range values { + if value == nil || !now.Before(*value) { + continue + } + if earliest == nil || value.Before(*earliest) { + v := *value + earliest = &v + } + } + return earliest +} + +func joinAccountSelectionQuotaSummaries(items []accountSelectionQuotaSummary) string { + parts := make([]string, 0, len(items)) + for _, item := range items { + segments := []string{item.Name} + segments = append(segments, item.Segments...) + parts = append(parts, strings.Join(segments, " ")) + } + return strings.Join(parts, "; ") +} + +func compactSummaryText(value string) string { + value = strings.Join(strings.Fields(value), " ") + if len(value) > 120 { + return value[:117] + "..." + } + return value +} diff --git a/backend/internal/service/openai_account_selection_error_test.go b/backend/internal/service/openai_account_selection_error_test.go new file mode 100644 index 00000000000..862789396d1 --- /dev/null +++ b/backend/internal/service/openai_account_selection_error_test.go @@ -0,0 +1,185 @@ +//go:build unit + +package service + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/model" +) + +func TestRenderOpenAIAccountSelectionErrorMessage(t *testing.T) { + msg := RenderOpenAIAccountSelectionErrorMessage( + "账户额度已用完;最快重置:{{next_reset_at}};账号:{{next_reset_account}};详情:{{account_summary}}", + OpenAIAccountSelectionErrorTemplateData{ + NextResetAt: "2026-06-12T21:00:00+08:00", + NextResetAccount: "team", + AccountSummary: "team 5h 100% 5h reset 2026-06-12T21:00:00+08:00", + }, + ) + + for _, want := range []string{"账户额度已用完", "2026-06-12T21:00:00+08:00", "team 5h 100%"} { + if !strings.Contains(msg, want) { + t.Fatalf("message %q does not contain %q", msg, want) + } + } +} + +func TestBuildOpenAIAccountQuotaSummary(t *testing.T) { + now := time.Date(2026, 6, 12, 16, 0, 0, 0, time.UTC) + reset5h := now.Add(2 * time.Hour).Format(time.RFC3339) + reset7d := now.Add(24 * time.Hour).Format(time.RFC3339) + account := &Account{ + ID: 42, + Name: "team", + Platform: PlatformOpenAI, + Extra: map[string]any{ + "codex_5h_used_percent": "100", + "codex_5h_reset_at": reset5h, + "codex_7d_used_percent": 88.0, + "codex_7d_reset_at": reset7d, + }, + } + + item := buildAccountSelectionQuotaSummary(account, now) + if item.NextResetAt == nil || !item.NextResetAt.Equal(now.Add(2*time.Hour)) { + t.Fatalf("NextResetAt = %v, want %v", item.NextResetAt, now.Add(2*time.Hour)) + } + summary := joinAccountSelectionQuotaSummaries([]accountSelectionQuotaSummary{item}) + if !strings.Contains(summary, "team") || !strings.Contains(summary, "5h 100%") || !strings.Contains(summary, "7d 88%") { + t.Fatalf("unexpected summary: %s", summary) + } +} + +func TestAccountSelectionErrorTemplateDataIncludesNonOpenAIAccounts(t *testing.T) { + now := time.Now() + repo := &openAISelectionErrorAccountRepo{ + accounts: []Account{ + { + ID: 10, + Name: "gemini-api", + Platform: PlatformGemini, + Status: StatusActive, + Schedulable: false, + Extra: map[string]any{ + "quota_daily_limit": 10.0, + "quota_daily_used": 10.0, + "quota_daily_reset_at": now.Add(time.Hour).Format(time.RFC3339), + }, + }, + { + ID: 11, + Name: "anthropic-api", + Platform: PlatformAnthropic, + Status: StatusActive, + Schedulable: false, + }, + }, + } + groupID := int64(18) + svc := &GatewayService{accountRepo: repo} + + data := svc.AccountSelectionErrorTemplateData(context.Background(), PlatformGemini, &groupID) + if data.NextResetAccount != "gemini-api" { + t.Fatalf("NextResetAccount = %q, want gemini-api", data.NextResetAccount) + } + for _, want := range []string{"gemini-api", "platform gemini", "daily_quota 10/10", "daily_reset"} { + if !strings.Contains(data.AccountSummary, want) { + t.Fatalf("AccountSummary %q does not contain %q", data.AccountSummary, want) + } + } + if strings.Contains(data.AccountSummary, "anthropic-api") { + t.Fatalf("AccountSummary should prefer requested platform accounts, got %q", data.AccountSummary) + } +} + +type openAISelectionErrorAccountRepo struct { + accountRepoStub + accounts []Account +} + +func (r *openAISelectionErrorAccountRepo) ListByGroup(context.Context, int64) ([]Account, error) { + return r.accounts, nil +} + +func TestOpenAIAccountSelectionErrorTemplateData(t *testing.T) { + now := time.Now() + repo := &openAISelectionErrorAccountRepo{ + accounts: []Account{ + { + ID: 2, + Name: "later", + Platform: PlatformOpenAI, + Extra: map[string]any{ + "codex_5h_used_percent": 100.0, + "codex_5h_reset_at": now.Add(2 * time.Hour).Format(time.RFC3339), + }, + }, + { + ID: 1, + Name: "soon", + Platform: PlatformOpenAI, + Extra: map[string]any{ + "codex_7d_used_percent": 99.0, + "codex_7d_reset_at": now.Add(30 * time.Minute).Format(time.RFC3339), + }, + }, + { + ID: 3, + Name: "anthropic", + Platform: PlatformAnthropic, + }, + }, + } + groupID := int64(18) + svc := &OpenAIGatewayService{accountRepo: repo} + + data := svc.OpenAIAccountSelectionErrorTemplateData(context.Background(), &groupID) + if data.NextResetAccount != "soon" { + t.Fatalf("NextResetAccount = %q, want soon", data.NextResetAccount) + } + if data.NextResetAt == "" { + t.Fatal("NextResetAt is empty") + } + if !strings.Contains(data.AccountSummary, "soon") || !strings.Contains(data.AccountSummary, "later") { + t.Fatalf("unexpected AccountSummary: %s", data.AccountSummary) + } +} + +func TestOpenAIAccountSelectionNoAvailableMatchesErrorPassthroughRule(t *testing.T) { + custom := "账户额度已用完,最快重置:{{next_reset_at}}" + respCode := 429 + svc := &ErrorPassthroughService{} + svc.setLocalCache([]*model.ErrorPassthroughRule{ + { + ID: 1, + Name: "local OpenAI no available accounts", + Enabled: true, + Priority: 0, + ErrorCodes: []int{OpenAIAccountSelectionNoAvailableStatus}, + Keywords: []string{OpenAIAccountSelectionNoAvailableMessage}, + MatchMode: model.MatchModeAll, + Platforms: []string{PlatformOpenAI}, + PassthroughCode: false, + ResponseCode: &respCode, + PassthroughBody: false, + CustomMessage: &custom, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + }) + + matched := svc.MatchRule(PlatformOpenAI, OpenAIAccountSelectionNoAvailableStatus, []byte(OpenAIAccountSelectionNoAvailableMessage)) + if matched == nil { + t.Fatal("expected local no available accounts rule to match") + } + if matched.CustomMessage == nil || *matched.CustomMessage != custom { + t.Fatalf("CustomMessage = %v, want %q", matched.CustomMessage, custom) + } + if matched.ResponseCode == nil || *matched.ResponseCode != respCode { + t.Fatalf("ResponseCode = %v, want %d", matched.ResponseCode, respCode) + } +} diff --git a/frontend/src/components/admin/ErrorPassthroughRulesModal.vue b/frontend/src/components/admin/ErrorPassthroughRulesModal.vue index 2ed6ded3dcd..88b33359f49 100644 --- a/frontend/src/components/admin/ErrorPassthroughRulesModal.vue +++ b/frontend/src/components/admin/ErrorPassthroughRulesModal.vue @@ -371,6 +371,9 @@ class="input text-sm" :placeholder="t('admin.errorPassthrough.form.customMessagePlaceholder')" /> +

+ {{ t('admin.errorPassthrough.form.customMessageHint') }} +

diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index c26d4d063c8..033d0d334a9 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -6546,6 +6546,7 @@ export default { passthroughBody: 'Passthrough upstream error message', customMessage: 'Custom error message', customMessagePlaceholder: 'Error message to return to client...', + customMessageHint: 'For local no-available-account errors, match status 503 + keyword "no available accounts". Custom messages support template variables next_reset_at, next_reset_account, and account_summary wrapped in double braces.', skipMonitoring: 'Skip monitoring', skipMonitoringHint: 'When enabled, errors matching this rule will not be recorded in ops monitoring', enabled: 'Enable this rule' diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 0ce766f9f7e..d7b0274dd0a 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -6701,6 +6701,7 @@ export default { passthroughBody: '透传上游错误信息', customMessage: '自定义错误信息', customMessagePlaceholder: '返回给客户端的错误信息...', + customMessageHint: '本地无可用账号可用错误码 503 + 关键词 no available accounts 匹配;自定义消息支持模板变量:next_reset_at、next_reset_account、account_summary(使用双大括号包裹)。', skipMonitoring: '跳过运维监控记录', skipMonitoringHint: '开启后,匹配此规则的错误不会被记录到运维监控中', enabled: '启用此规则'