| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- package handler
- import (
- "context"
- "fmt"
- "net/http"
- "regexp"
- "strconv"
- "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
- }
- var selectionModelRateLimitedPattern = regexp.MustCompile(`(?:model_rate_limited|rate_limited)=(\d+)`)
- // classifySelectionFailureError preserves the scheduler's compact reason when
- // every model-capable account is temporarily rate limited.
- func classifySelectionFailureError(err error, fallback noAccountErrorClassification) noAccountErrorClassification {
- if err == nil {
- return fallback
- }
- // A 404 model_not_found fallback is authoritative and must not be downgraded
- // to a rate-limit verdict. classifyNoAccountError only reaches it through
- // DiagnoseModelAvailabilityForPlatform, a dedicated database query over
- // persistent eligibility (active + schedulable + model_mapping) that already
- // established no account in the group can serve this model at all. A transient
- // per-model cooldown on one of the remaining candidates does not make "all
- // available accounts are rate-limited" true.
- //
- // Reporting 429 here is actively harmful: retrying can never succeed, and
- // clients that treat 429 as a rate limit retry hard and swallow the body
- // (Codex surfaces only "exceeded retry limit, last status: 429"), losing the
- // one message that names the real problem. It also flips the ops attribution
- // from a local model-configuration issue to routing capacity, because call
- // sites gate markOpsRoutingCapacityLimitedIfNoAvailable on ModelNotFound.
- if fallback.ModelNotFound {
- return fallback
- }
- match := selectionModelRateLimitedPattern.FindStringSubmatch(strings.ToLower(err.Error()))
- if len(match) != 2 {
- return fallback
- }
- count, parseErr := strconv.Atoi(match[1])
- if parseErr != nil || count <= 0 {
- return fallback
- }
- return noAccountErrorClassification{
- Status: http.StatusTooManyRequests,
- ErrType: "rate_limit_error",
- Message: "All available accounts are currently rate-limited. Please retry later.",
- }
- }
- // 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)
- }
|