Files
sub2api/backend/internal/handler/no_account_error.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

147 lines
5.1 KiB
Go

package handler
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// noAccountErrorClassification describes the HTTP response to emit when
// account selection failed with ErrNoAvailableAccounts. Handlers obtain it
// via classifyNoAccountError and choose between:
//
// - 404 model_not_found — the group has accounts, but none of them are
// configured to serve the requested model (config / typo / unsupported
// model). Returning 503 here misleads operators and trips reverse-proxy
// health checks; 404 lets the client surface the real problem.
//
// - 503 api_error — accounts that could serve the model exist but are
// temporarily exhausted (rate limit, quota auto-pause, runtime block) OR
// the group has no accounts at all. Both stay on 503 because retrying
// after a backoff can plausibly succeed (or, in the empty-pool case, the
// operator may be in the middle of adding accounts).
type noAccountErrorClassification struct {
Status int
ErrType string
Message string
ModelNotFound bool // true when this is a 404 model_not_found classification
}
// classifyNoAccountError decides between 404 model_not_found and 503
// api_error for "no available accounts" failures.
//
// The classifier intentionally does not consume the original error: the
// selection layer never tells us *why* the pool came up empty (rate-limited
// vs. unsupported model are both wrapped as ErrNoAvailableAccounts). Instead
// we re-check pool composition through DiagnoseModelAvailabilityForPlatform.
// Its dedicated database query considers only persistent eligibility
// (active status + schedulable setting) and model_mapping, bypassing scheduler
// snapshots and transient filters. That guarantees a 404 is only returned
// when persistent account/group/model configuration must change before the
// request can succeed.
//
// routingModel is the model name that account selection actually compared
// against (i.e. after group-level dispatch mapping). displayModel is the
// raw model the caller asked for; it is used only in the user-facing error
// message so that internal mapping details don't leak. Most callers pass
// the same value for both.
//
// platform is the platform the request was routed to (use
// service.PlatformOpenAI / PlatformAnthropic / PlatformGemini). It is
// required because Anthropic/Gemini routes additionally surface
// mixed-scheduled Antigravity accounts; passing the wrong platform would
// flip a legitimate 503 to a misleading 404 (or vice versa).
func classifyNoAccountError(
ctx context.Context,
diag service.ModelAvailabilityDiagnoser,
apiKey *service.APIKey,
routingModel string,
displayModel string,
platform string,
) noAccountErrorClassification {
fallback := noAccountErrorClassification{
Status: http.StatusServiceUnavailable,
ErrType: "api_error",
Message: "Service temporarily unavailable",
}
routingModel = strings.TrimSpace(routingModel)
displayModel = strings.TrimSpace(displayModel)
if displayModel == "" {
displayModel = routingModel
}
if diag == nil || apiKey == nil || apiKey.GroupID == nil || routingModel == "" {
return fallback
}
result := diag.DiagnoseModelAvailabilityForPlatform(ctx, apiKey.GroupID, routingModel, platform)
if result.HasAccountsInPool && !result.HasModelSupport {
return noAccountErrorClassification{
Status: http.StatusNotFound,
ErrType: "model_not_found",
Message: fmt.Sprintf("Model %q is not supported by any configured account in this group", displayModel),
ModelNotFound: true,
}
}
return fallback
}
// classifyNoAccountErrorFromGin is a thin wrapper that forwards the gin
// context's underlying request context. Most call sites already have a
// *gin.Context handy, so this keeps the call sites uncluttered.
func classifyNoAccountErrorFromGin(
c *gin.Context,
diag service.ModelAvailabilityDiagnoser,
apiKey *service.APIKey,
routingModel string,
displayModel string,
platform string,
) noAccountErrorClassification {
ctx := context.Background()
if c != nil && c.Request != nil {
ctx = c.Request.Context()
}
classification := classifyNoAccountError(ctx, diag, apiKey, routingModel, displayModel, platform)
if classification.ModelNotFound {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
}
return classification
}
func classifyOpenAICompatibleNoAccountErrorFromGin(
c *gin.Context,
diag service.ModelAvailabilityDiagnoser,
apiKey *service.APIKey,
routingModel string,
displayModel string,
) noAccountErrorClassification {
ctx := context.Background()
if c != nil && c.Request != nil {
ctx = c.Request.Context()
}
return classifyNoAccountErrorFromGin(
c,
diag,
apiKey,
routingModel,
displayModel,
openAICompatibleRequestPlatform(ctx, apiKey),
)
}
func openAICompatibleSelectionErrorForLog(err error, platform string) error {
if err == nil || platform != service.PlatformGrok {
return err
}
message := strings.ReplaceAll(err.Error(), "OpenAI accounts", "Grok accounts")
if message == err.Error() {
return err
}
return fmt.Errorf("%s", message)
}