mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
classifySelectionFailureError upgrades a selection failure to 429 rate_limit_error whenever the scheduler's compact reason string matches model_rate_limited=(\d+) with a positive count. It does so unconditionally, including when the fallback it was handed is the authoritative 404 model_not_found classification. Selection failure reasons are additive, so both can appear at once, e.g. "pool=9, filtered: model_not_supported=8 model_rate_limited=1": eight accounts cannot serve the model at all and the ninth happens to sit in a per-model cooldown. classifyNoAccountError only produces ModelNotFound through DiagnoseModelAvailabilityForPlatform, a dedicated database query over persistent eligibility (active + schedulable + model_mapping), so it has already established that no account in the group can serve the model. One transient cooldown does not make "All available accounts are currently rate-limited" true. The mislabel is not cosmetic: - Retrying can never succeed, yet 429 explicitly invites a retry. - Clients that treat 429 as a rate limit retry hard and swallow the body; Codex surfaces only "exceeded retry limit, last status: 429", discarding the one message that names the real problem. Operators then look for capacity and rate limits instead of a model whitelist. - Ops attribution becomes self-contradictory: classifyNoAccountErrorFromGin has already marked the request business-limited for local model configuration, while call sites gate markOpsRoutingCapacityLimitedIfNoAvailable on ModelNotFound, which the override clears, so the same request is also counted as routing capacity limited. Return the fallback untouched when it is ModelNotFound. The 429 upgrade is preserved for every other fallback, which is the case it was written for: accounts that can serve the model exist but are all cooling down, where the fallback is 503 and retrying is meaningful. Claude-Session: https://claude.ai/code/session_01LwvyJVV2sdqiu41Z9JDNUq
306 lines
13 KiB
Go
306 lines
13 KiB
Go
//go:build unit
|
||
|
||
package handler
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
)
|
||
|
||
type fakeDiagnoser struct {
|
||
calls []fakeDiagnoseCall
|
||
resp service.ModelAvailabilityDiagnosis
|
||
}
|
||
|
||
type fakeDiagnoseCall struct {
|
||
GroupID *int64
|
||
Model string
|
||
Platform string
|
||
}
|
||
|
||
func (f *fakeDiagnoser) DiagnoseModelAvailabilityForPlatform(
|
||
_ context.Context,
|
||
groupID *int64,
|
||
model, platform string,
|
||
) service.ModelAvailabilityDiagnosis {
|
||
f.calls = append(f.calls, fakeDiagnoseCall{
|
||
GroupID: groupID,
|
||
Model: model,
|
||
Platform: platform,
|
||
})
|
||
return f.resp
|
||
}
|
||
|
||
func ptrInt64(v int64) *int64 { return &v }
|
||
|
||
// newTestGinContextWithRequest wraps the bare newTestGinContext helper
|
||
// (defined in openai_gateway_cyber_test.go) by additionally attaching a stub
|
||
// *http.Request so the classifier can extract c.Request.Context().
|
||
func newTestGinContextWithRequest() *gin.Context {
|
||
c := newTestGinContext()
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/test", nil)
|
||
return c
|
||
}
|
||
|
||
func TestClassifyNoAccountError_NilDiagnoser_Falls503(t *testing.T) {
|
||
c := newTestGinContextWithRequest()
|
||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
||
cls := classifyNoAccountErrorFromGin(c, nil, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||
|
||
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||
require.Equal(t, "api_error", cls.ErrType)
|
||
require.False(t, cls.ModelNotFound)
|
||
}
|
||
|
||
func TestClassifySelectionFailureError_RateLimitedPool(t *testing.T) {
|
||
fallback := noAccountErrorClassification{Status: http.StatusServiceUnavailable, ErrType: "api_error", Message: "Service temporarily unavailable"}
|
||
|
||
got := classifySelectionFailureError(
|
||
fmt.Errorf("no available accounts supporting model: gpt-5.6-sol (total=3 eligible=0 model_rate_limited=3)"),
|
||
fallback,
|
||
)
|
||
|
||
require.Equal(t, http.StatusTooManyRequests, got.Status)
|
||
require.Equal(t, "rate_limit_error", got.ErrType)
|
||
require.Contains(t, got.Message, "rate-limited")
|
||
require.Equal(t, fallback, classifySelectionFailureError(fmt.Errorf("model_rate_limited=0"), fallback))
|
||
require.Equal(t, fallback, classifySelectionFailureError(fmt.Errorf("no available accounts"), fallback))
|
||
}
|
||
|
||
func TestClassifyNoAccountError_NilAPIKey_Falls503(t *testing.T) {
|
||
c := newTestGinContextWithRequest()
|
||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
|
||
cls := classifyNoAccountErrorFromGin(c, fd, nil, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||
|
||
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||
require.False(t, cls.ModelNotFound)
|
||
require.Empty(t, fd.calls, "diagnoser must not be consulted when apiKey missing")
|
||
}
|
||
|
||
func TestClassifyNoAccountError_NilGroupID_Falls503(t *testing.T) {
|
||
c := newTestGinContextWithRequest()
|
||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
apiKey := &service.APIKey{GroupID: nil}
|
||
|
||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||
|
||
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||
require.False(t, cls.ModelNotFound)
|
||
require.Empty(t, fd.calls, "diagnoser must not be consulted when group not bound")
|
||
}
|
||
|
||
func TestClassifyNoAccountError_EmptyModel_Falls503(t *testing.T) {
|
||
c := newTestGinContextWithRequest()
|
||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, " ", "", service.PlatformOpenAI)
|
||
|
||
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||
require.False(t, cls.ModelNotFound)
|
||
require.Empty(t, fd.calls)
|
||
}
|
||
|
||
func TestClassifyNoAccountError_ModelNotSupported_Returns404(t *testing.T) {
|
||
c := newTestGinContextWithRequest()
|
||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
apiKey := &service.APIKey{GroupID: ptrInt64(42)}
|
||
|
||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5.1-codex-mini", "gpt-5.1-codex-mini", service.PlatformOpenAI)
|
||
|
||
require.Equal(t, http.StatusNotFound, cls.Status)
|
||
require.Equal(t, "model_not_found", cls.ErrType)
|
||
require.True(t, cls.ModelNotFound)
|
||
require.Contains(t, cls.Message, "gpt-5.1-codex-mini", "message must surface the requested model")
|
||
|
||
require.Len(t, fd.calls, 1)
|
||
require.Equal(t, "gpt-5.1-codex-mini", fd.calls[0].Model)
|
||
require.Equal(t, service.PlatformOpenAI, fd.calls[0].Platform)
|
||
require.NotNil(t, fd.calls[0].GroupID)
|
||
require.Equal(t, int64(42), *fd.calls[0].GroupID)
|
||
require.True(t, service.HasOpsClientBusinessLimited(c))
|
||
require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
|
||
}
|
||
|
||
func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing.T) {
|
||
c := newTestGinContextWithRequest()
|
||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
groupID := int64(43)
|
||
apiKey := &service.APIKey{
|
||
GroupID: &groupID,
|
||
Group: &service.Group{
|
||
ID: groupID,
|
||
Platform: service.PlatformGrok,
|
||
},
|
||
}
|
||
|
||
cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, fd, apiKey, "grok-4.5", "grok-4.5")
|
||
|
||
require.Equal(t, http.StatusNotFound, cls.Status)
|
||
require.Equal(t, "model_not_found", cls.ErrType)
|
||
require.True(t, cls.ModelNotFound)
|
||
require.Len(t, fd.calls, 1)
|
||
require.Equal(t, service.PlatformGrok, fd.calls[0].Platform)
|
||
require.True(t, service.HasOpsClientBusinessLimited(c))
|
||
require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
|
||
|
||
logErr := openAICompatibleSelectionErrorForLog(
|
||
fmt.Errorf("no available OpenAI accounts supporting model: grok-4.5"),
|
||
service.PlatformGrok,
|
||
)
|
||
require.EqualError(t, logErr, "no available Grok accounts supporting model: grok-4.5")
|
||
}
|
||
|
||
func TestClassifyNoAccountError_PureClassifierDoesNotMarkGinContext(t *testing.T) {
|
||
c := newTestGinContextWithRequest()
|
||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
||
cls := classifyNoAccountError(c.Request.Context(), fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||
|
||
require.True(t, cls.ModelNotFound)
|
||
require.False(t, service.HasOpsClientBusinessLimited(c))
|
||
require.Empty(t, service.OpsClientBusinessLimitedReason(c))
|
||
}
|
||
|
||
func TestClassifyNoAccountError_HasModelSupport_KeepsRoutingMessageGenerationToCaller(t *testing.T) {
|
||
c := newTestGinContextWithRequest()
|
||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}}
|
||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||
|
||
require.Equal(t, http.StatusServiceUnavailable, cls.Status, "model exists somewhere — caller stays on 503")
|
||
require.Equal(t, "api_error", cls.ErrType)
|
||
require.False(t, cls.ModelNotFound)
|
||
}
|
||
|
||
func TestClassifyNoAccountError_ModelSupportedOnlyByRateLimitedAccount_Returns503(t *testing.T) {
|
||
c := newTestGinContextWithRequest()
|
||
// The diagnoser's configured-state lookup still sees the model-supporting
|
||
// account even though normal scheduling has excluded it during cooldown.
|
||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}}
|
||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "claude-opus-4-8", "claude-opus-4-8", service.PlatformAnthropic)
|
||
|
||
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||
require.Equal(t, "api_error", cls.ErrType)
|
||
require.False(t, cls.ModelNotFound, "temporary account cooldown must remain retryable")
|
||
}
|
||
|
||
func TestClassifyNoAccountError_NoAccountsInPool_Stays503(t *testing.T) {
|
||
c := newTestGinContextWithRequest()
|
||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: false, HasModelSupport: false}}
|
||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||
|
||
require.Equal(t, http.StatusServiceUnavailable, cls.Status, "empty pool is a service-availability issue, not a model issue")
|
||
require.False(t, cls.ModelNotFound)
|
||
}
|
||
|
||
func TestClassifyNoAccountError_DisplayModelOverridesRoutingForMessage(t *testing.T) {
|
||
c := newTestGinContextWithRequest()
|
||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "claude-3-fancy", service.PlatformOpenAI)
|
||
|
||
require.True(t, cls.ModelNotFound)
|
||
require.Contains(t, cls.Message, "claude-3-fancy", "user-facing message must reference the model the user asked for, not the post-mapping routing model")
|
||
require.Len(t, fd.calls, 1)
|
||
require.Equal(t, "gpt-5", fd.calls[0].Model, "diagnosis must run against the routing model (post group dispatch mapping)")
|
||
}
|
||
|
||
func TestClassifyNoAccountError_FromGin_NilContextStillSafe(t *testing.T) {
|
||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
||
cls := classifyNoAccountErrorFromGin(nil, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||
|
||
require.Equal(t, http.StatusNotFound, cls.Status, "even with a nil gin context the classifier must still run and yield a coherent response")
|
||
require.True(t, cls.ModelNotFound)
|
||
require.False(t, service.HasOpsClientBusinessLimited(nil))
|
||
require.Empty(t, service.OpsClientBusinessLimitedReason(nil))
|
||
}
|
||
|
||
// 权威的 404 model_not_found 不能被"账号被限流"的 429 盖掉。
|
||
//
|
||
// 选号失败的错误串同时携带多种过滤原因,例如
|
||
// "pool=9, filtered: model_not_supported=8 model_rate_limited=1":8 个账号根本不支持该模型,
|
||
// 剩下 1 个恰好处于模型级冷却。此时 classifyNoAccountError 已通过持久化判据确认整个分组
|
||
// 没有账号能服务该模型(ModelNotFound=true),改判成 429 "All available accounts are
|
||
// currently rate-limited" 是错误诊断——重试永远不会成功,而把 429 当限流的客户端会反复
|
||
// 重试并吞掉 body(Codex 只显示 "exceeded retry limit"),恰好丢掉唯一说明真实原因的信息。
|
||
func TestClassifySelectionFailureError_ModelNotFoundIsNotOverriddenByRateLimited(t *testing.T) {
|
||
modelNotFound := noAccountErrorClassification{
|
||
Status: http.StatusNotFound,
|
||
ErrType: "model_not_found",
|
||
Message: `Model "gpt-5.3-codex" is not supported by any configured account in this group`,
|
||
ModelNotFound: true,
|
||
}
|
||
|
||
got := classifySelectionFailureError(
|
||
fmt.Errorf("no available OpenAI accounts supporting model: gpt-5.3-codex "+
|
||
"(pool=9, filtered: model_not_supported=8 model_rate_limited=1)"),
|
||
modelNotFound,
|
||
)
|
||
|
||
require.Equal(t, modelNotFound, got,
|
||
"分组里没有任何账号能服务该模型时,模型级冷却不该把 404 改判成 429")
|
||
}
|
||
|
||
// 真实调用点的顺序:先 classifyNoAccountErrorFromGin,再 classifySelectionFailureError。
|
||
// 覆盖这条链路是为了同时锁住 ops 归因——调用点用 ModelNotFound 决定是否标记
|
||
// routing capacity limited,一旦 404 被改判成 429,同一个请求会既被标成
|
||
// local model configuration 又被标成容量问题,自相矛盾。
|
||
func TestClassifySelectionFailureError_CallSiteChainKeepsModelNotFoundAttribution(t *testing.T) {
|
||
c := newTestGinContextWithRequest()
|
||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
apiKey := &service.APIKey{GroupID: ptrInt64(43)}
|
||
|
||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5.3-codex", "gpt-5.3-codex", service.PlatformOpenAI)
|
||
cls = classifySelectionFailureError(
|
||
fmt.Errorf("no available OpenAI accounts supporting model: gpt-5.3-codex "+
|
||
"(pool=9, filtered: model_not_supported=8 model_rate_limited=1)"),
|
||
cls,
|
||
)
|
||
|
||
require.Equal(t, http.StatusNotFound, cls.Status)
|
||
require.Equal(t, "model_not_found", cls.ErrType)
|
||
require.True(t, cls.ModelNotFound)
|
||
require.Contains(t, cls.Message, "gpt-5.3-codex")
|
||
require.True(t, service.HasOpsClientBusinessLimited(c))
|
||
require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
|
||
}
|
||
|
||
// 池子里确实存在能服务该模型、只是全部在冷却的账号时,429 改判仍需保留:
|
||
// 这种情况 fallback 是 503(HasModelSupport=true),重试是有意义的。
|
||
func TestClassifySelectionFailureError_StillUpgradesNonModelNotFoundFallback(t *testing.T) {
|
||
fallback := noAccountErrorClassification{
|
||
Status: http.StatusServiceUnavailable,
|
||
ErrType: "api_error",
|
||
Message: "Service temporarily unavailable",
|
||
}
|
||
|
||
got := classifySelectionFailureError(
|
||
fmt.Errorf("no available accounts supporting model: gpt-5.6-sol (total=3 eligible=0 model_rate_limited=3)"),
|
||
fallback,
|
||
)
|
||
|
||
require.Equal(t, http.StatusTooManyRequests, got.Status)
|
||
require.Equal(t, "rate_limit_error", got.ErrType)
|
||
require.False(t, got.ModelNotFound)
|
||
}
|