Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
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
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
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user