147 lines
5.1 KiB
Go
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)
|
||
|
|
}
|