mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 18:30:37 +00:00
193 lines
7.3 KiB
Go
193 lines
7.3 KiB
Go
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 {
|
|
message := fmt.Sprintf("Model %q is not supported by any configured account in this group", displayModel)
|
|
if result.HasRetiredSubscriptionModel {
|
|
message = fmt.Sprintf("Model %q targets GPT-5.5, which retired from Codex with ChatGPT sign-in on October 14, 2026. Select an available replacement such as GPT-6.1 Sol or GPT-6 Luna, or configure an API-key upstream that supports GPT-5.5.", displayModel)
|
|
}
|
|
return noAccountErrorClassification{
|
|
Status: http.StatusNotFound,
|
|
ErrType: "model_not_found",
|
|
Message: message,
|
|
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)
|
|
}
|