//go:build unit package service import ( "context" "encoding/json" "errors" "net/http" "testing" "time" "github.com/stretchr/testify/require" ) type modelNotFoundRateLimitCall struct { accountID int64 scope string resetAt time.Time reason string } type modelNotFoundAccountRepoStub struct { mockAccountRepoForGemini tempCalls int modelRateLimitCalls []modelNotFoundRateLimitCall modelRateLimitErr error } func (r *modelNotFoundAccountRepoStub) SetTempUnschedulable(ctx context.Context, id int64, until time.Time, reason string) error { r.tempCalls++ return nil } func (r *modelNotFoundAccountRepoStub) SetModelRateLimit(ctx context.Context, id int64, scope string, resetAt time.Time, reason ...string) error { call := modelNotFoundRateLimitCall{ accountID: id, scope: scope, resetAt: resetAt, } if len(reason) > 0 { call.reason = reason[0] } r.modelRateLimitCalls = append(r.modelRateLimitCalls, call) return r.modelRateLimitErr } func TestRateLimitService_HandleUpstreamError_ModelNotFoundUsesModelRateLimit(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAIModelNotFoundTempAccount() handled := svc.HandleUpstreamError( context.Background(), account, http.StatusNotFound, http.Header{}, []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`), "gpt-5.4", ) require.True(t, handled) require.Zero(t, repo.tempCalls) require.Len(t, repo.modelRateLimitCalls, 1) call := repo.modelRateLimitCalls[0] require.Equal(t, account.ID, call.accountID) require.Equal(t, "gpt-5.4", call.scope) require.Equal(t, upstreamModelNotFoundReason, call.reason) require.WithinDuration(t, time.Now().Add(upstreamModelNotFoundCooldown), call.resetAt, 5*time.Second) } func TestRateLimitService_HandleUpstreamError_ModelNotFoundWriteFailureDoesNotTempUnschedule(t *testing.T) { repo := &modelNotFoundAccountRepoStub{modelRateLimitErr: errors.New("write failed")} svc := &RateLimitService{accountRepo: repo} account := openAIModelNotFoundTempAccount() handled := svc.HandleUpstreamError( context.Background(), account, http.StatusNotFound, http.Header{}, []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`), "gpt-5.4", ) require.True(t, handled) require.Zero(t, repo.tempCalls) require.Len(t, repo.modelRateLimitCalls, 1) } func TestRateLimitService_HandleUpstreamError_Bare404UsesModelScopedTempUnschedulableWhenModelKnown(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAIModelNotFoundTempAccount() handled := svc.HandleUpstreamError( context.Background(), account, http.StatusNotFound, http.Header{}, []byte(`{"error":{"message":"endpoint not found"}}`), "gpt-5.4", ) require.True(t, handled) require.Zero(t, repo.tempCalls) require.Len(t, repo.modelRateLimitCalls, 1) call := repo.modelRateLimitCalls[0] require.Equal(t, account.ID, call.accountID) require.Equal(t, "gpt-5.4", call.scope) require.WithinDuration(t, time.Now().Add(10*time.Minute), call.resetAt, 5*time.Second) var state TempUnschedState require.NoError(t, json.Unmarshal([]byte(call.reason), &state)) require.Equal(t, http.StatusNotFound, state.StatusCode) require.Equal(t, "not found", state.MatchedKeyword) } func TestRateLimitService_HandleUpstreamError_Bare404WithoutModelKeepsAccountTempUnschedulable(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAIModelNotFoundTempAccount() handled := svc.HandleUpstreamError( context.Background(), account, http.StatusNotFound, http.Header{}, []byte(`{"error":{"message":"endpoint not found"}}`), ) require.True(t, handled) require.Equal(t, 1, repo.tempCalls) require.Empty(t, repo.modelRateLimitCalls) } func TestRateLimitService_HandleUpstreamError_ModelTempWriteFailureNeverWidensToAccount(t *testing.T) { repo := &modelNotFoundAccountRepoStub{modelRateLimitErr: errors.New("write failed")} svc := &RateLimitService{accountRepo: repo} account := openAIModelNotFoundTempAccount() handled := svc.HandleUpstreamError( context.Background(), account, http.StatusNotFound, http.Header{}, []byte(`{"error":{"message":"endpoint not found"}}`), "gpt-5.4", ) require.True(t, handled) require.Zero(t, repo.tempCalls) require.Len(t, repo.modelRateLimitCalls, 1) } func TestRateLimitService_HandleTempUnschedulable_PoolModeWithoutCustomPolicySkipsState(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAIModelNotFoundTempAccount() account.Credentials["pool_mode"] = true handled := svc.HandleTempUnschedulable( context.Background(), account, http.StatusNotFound, []byte(`{"error":{"message":"endpoint not found"}}`), "gpt-5.4", ) require.False(t, handled) require.Zero(t, repo.tempCalls) require.Empty(t, repo.modelRateLimitCalls) } func TestRateLimitService_HandleTempUnschedulable_PoolModeCustomPolicyUsesModelScope(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAIModelNotFoundTempAccount() account.Credentials["pool_mode"] = true account.Credentials["custom_error_codes_enabled"] = true account.Credentials["custom_error_codes"] = []any{float64(http.StatusNotFound)} handled := svc.HandleTempUnschedulable( context.Background(), account, http.StatusNotFound, []byte(`{"error":{"message":"endpoint not found"}}`), "gpt-5.4", ) require.True(t, handled) require.Zero(t, repo.tempCalls) require.Len(t, repo.modelRateLimitCalls, 1) require.Equal(t, "gpt-5.4", repo.modelRateLimitCalls[0].scope) } func TestRateLimitService_TempUnschedulableContextPreservesModelForPoolDependency(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAIModelNotFoundTempAccount() account.Credentials["pool_mode"] = true ctx := withTempUnschedulableModel(context.Background(), []string{"gpt-5.4"}) // #4496 calls tryTempUnschedulable from the pool-mode branch without an // explicit model argument. The request context must preserve the canonical // model so that combined behavior remains model-scoped after it lands. handled := svc.tryTempUnschedulable( ctx, account, http.StatusNotFound, []byte(`{"error":{"message":"endpoint not found"}}`), ) require.True(t, handled) require.Zero(t, repo.tempCalls) require.Len(t, repo.modelRateLimitCalls, 1) require.Equal(t, "gpt-5.4", repo.modelRateLimitCalls[0].scope) } func TestRateLimitService_HandleUpstreamError_CustomPolicyExclusionSkipsAllState(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAIModelNotFoundTempAccount() account.Credentials["custom_error_codes_enabled"] = true account.Credentials["custom_error_codes"] = []any{float64(http.StatusServiceUnavailable)} handled := svc.HandleUpstreamError( context.Background(), account, http.StatusNotFound, http.Header{}, []byte(`{"error":{"message":"endpoint not found"}}`), "gpt-5.4", ) require.False(t, handled) require.Zero(t, repo.tempCalls) require.Empty(t, repo.modelRateLimitCalls) } func TestRateLimitService_HandleTempUnschedulable_AuthenticationFailureStaysAccountScoped(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAIModelNotFoundTempAccount() account.TempUnschedulableReason = "legacy non-JSON reason" account.Credentials["temp_unschedulable_rules"] = []any{ map[string]any{ "error_code": float64(http.StatusUnauthorized), "keywords": []any{"unauthorized"}, "duration_minutes": float64(10), }, } handled := svc.HandleTempUnschedulable( context.Background(), account, http.StatusUnauthorized, []byte(`{"error":{"message":"unauthorized"}}`), "gpt-5.4", ) require.True(t, handled) require.Equal(t, 1, repo.tempCalls) require.Empty(t, repo.modelRateLimitCalls) } func TestRateLimitService_ModelTempUnschedulableIsolatesSchedulerByModel(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAIModelNotFoundTempAccount() account.Credentials["model_mapping"] = map[string]any{ "public-a": "upstream-a", "upstream-a": "upstream-b", } handled := svc.HandleUpstreamError( context.Background(), account, http.StatusNotFound, http.Header{}, []byte(`{"error":{"message":"endpoint not found"}}`), "upstream-a", ) require.True(t, handled) require.Len(t, repo.modelRateLimitCalls, 1) call := repo.modelRateLimitCalls[0] require.Equal(t, "upstream-a", call.scope, "canonical upstream model must not be mapped a second time") account.Extra = map[string]any{ modelRateLimitsKey: map[string]any{ call.scope: map[string]any{ "rate_limit_reset_at": call.resetAt.UTC().Format(time.RFC3339), }, }, } require.False(t, account.IsSchedulableForModelWithContext(context.Background(), "public-a")) require.True(t, account.IsSchedulableForModelWithContext(context.Background(), "gpt-5.6-sol")) } func openAIModelNotFoundTempAccount() *Account { return &Account{ ID: 101, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Credentials: map[string]any{ "temp_unschedulable_enabled": true, "temp_unschedulable_rules": []any{ map[string]any{ "error_code": float64(http.StatusNotFound), "keywords": []any{"not found"}, "duration_minutes": float64(10), }, }, }, } } func TestRateLimitService_HandleUpstreamError_CodexPlanGatedModelUsesModelRateLimit(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAICodexPlanGatedOAuthAccount() handled := svc.HandleUpstreamError( context.Background(), account, http.StatusBadRequest, http.Header{}, []byte(`{"detail":"The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account."}`), "gpt-5.6-sol", ) require.True(t, handled) require.Zero(t, repo.tempCalls) require.Len(t, repo.modelRateLimitCalls, 1) call := repo.modelRateLimitCalls[0] require.Equal(t, account.ID, call.accountID) require.Equal(t, "gpt-5.6-sol", call.scope) require.Equal(t, upstreamCodexPlanGatedModelReason, call.reason) require.WithinDuration(t, time.Now().Add(upstreamCodexPlanGatedModelCooldown), call.resetAt, 5*time.Second) } func TestRateLimitService_HandleUpstreamError_CodexPlanGatedModelRespectsModelMapping(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAICodexPlanGatedOAuthAccount() account.Credentials["model_mapping"] = map[string]any{"gpt-5.6-sol": "gpt-5.6-sol-upstream"} handled := svc.HandleUpstreamError( context.Background(), account, http.StatusBadRequest, http.Header{}, []byte(`{"detail":"The 'gpt-5.6-sol-upstream' model is not supported when using Codex with a ChatGPT account."}`), "gpt-5.6-sol", ) require.True(t, handled) require.Len(t, repo.modelRateLimitCalls, 1) require.Equal(t, "gpt-5.6-sol-upstream", repo.modelRateLimitCalls[0].scope) } func TestRateLimitService_HandleUpstreamError_CodexPlanGatedModelIgnoresAPIKeyAccount(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAICodexPlanGatedOAuthAccount() account.Type = AccountTypeAPIKey handled := svc.HandleUpstreamError( context.Background(), account, http.StatusBadRequest, http.Header{}, []byte(`{"detail":"The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account."}`), "gpt-5.6-sol", ) require.False(t, handled) require.Empty(t, repo.modelRateLimitCalls) } func TestRateLimitService_HandleUpstreamError_CodexPlanGatedImageModelSkipsCooldown(t *testing.T) { for _, model := range []string{"gpt-image-1", "gpt-image-1.5", "gpt-image-2"} { t.Run(model, func(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAICodexPlanGatedOAuthAccount() handled := svc.HandleUpstreamError( context.Background(), account, http.StatusBadRequest, http.Header{}, []byte(`{"detail":"The '`+model+`' model is not supported when using Codex with a ChatGPT account."}`), model, ) require.True(t, handled, "attempt should still fail over") require.Empty(t, repo.modelRateLimitCalls, "image models must not be cooled down: the account still serves them over /v1/images/*") require.Zero(t, repo.tempCalls) }) } } func TestRateLimitService_HandleUpstreamError_CodexPlanGatedTextModelStillCoolsDown(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAICodexPlanGatedOAuthAccount() handled := svc.HandleUpstreamError( context.Background(), account, http.StatusBadRequest, http.Header{}, []byte(`{"detail":"The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account."}`), "gpt-5.6-sol", ) require.True(t, handled) require.Len(t, repo.modelRateLimitCalls, 1, "non-image plan-gated models keep the existing cooldown") require.Equal(t, upstreamCodexPlanGatedModelReason, repo.modelRateLimitCalls[0].reason) } func openAICodexPlanGatedOAuthAccount() *Account { return &Account{ ID: 202, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Credentials: map[string]any{}, } } // 请求本身就走 /v1/images/* 时必须保留冷却。 // // OAuth 账号的 /v1/images/* 上游同样是 Codex Responses(openai_images_responses.go // → handleOpenAIImagesErrorResponse → handleOpenAIAccountUpstreamError → // HandleUpstreamModelNotFound),所以这条路径也会命中 plan-gated 分支。账号确实 // 不具备生图能力时,冷却是唯一的刹车:调度层靠 model_rate_limits 跳过该账号后 // 快速 503;一旦跳过冷却,每个请求都会完整走一遍号池,对上游形成无上界的 400 放大。 func TestRateLimitService_HandleUpstreamError_CodexPlanGatedImageModelKeepsCooldownOnImagesEndpoint(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAICodexPlanGatedOAuthAccount() handled := svc.HandleUpstreamError( WithOpenAIImagesEndpoint(context.Background()), account, http.StatusBadRequest, http.Header{}, []byte(`{"detail":"The 'gpt-image-2' model is not supported when using Codex with a ChatGPT account."}`), "gpt-image-2", ) require.True(t, handled) require.Len(t, repo.modelRateLimitCalls, 1, "/v1/images/* 上的 plan-gated 拒绝是真实的能力缺失,必须保留冷却刹车") require.Equal(t, "gpt-image-2", repo.modelRateLimitCalls[0].scope) require.Equal(t, upstreamCodexPlanGatedModelReason, repo.modelRateLimitCalls[0].reason) } // 仅 WithOpenAIImageGenerationIntent(/v1/responses 因模型名自动置位)不算专用生图 // 端点,仍按"用错端点"处理。 func TestRateLimitService_HandleUpstreamError_CodexPlanGatedImageModelSkipsCooldownOnIntentOnly(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAICodexPlanGatedOAuthAccount() handled := svc.HandleUpstreamError( WithOpenAIImageGenerationIntent(context.Background()), account, http.StatusBadRequest, http.Header{}, []byte(`{"detail":"The 'gpt-image-2' model is not supported when using Codex with a ChatGPT account."}`), "gpt-image-2", ) require.True(t, handled) require.Empty(t, repo.modelRateLimitCalls) } // 守卫口径必须与冷却键一致:冷却键走 account.GetMappedModel,账号可以把文本别名 // 映射到 gpt-image-*,只判请求模型会漏掉这种形态,原 bug 原样复现。 func TestRateLimitService_HandleUpstreamError_CodexPlanGatedImageModelSkipsCooldownViaModelMapping(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAICodexPlanGatedOAuthAccount() account.Credentials["model_mapping"] = map[string]any{"my-draw-alias": "gpt-image-2"} handled := svc.HandleUpstreamError( context.Background(), account, http.StatusBadRequest, http.Header{}, []byte(`{"detail":"The 'gpt-image-2' model is not supported when using Codex with a ChatGPT account."}`), "my-draw-alias", ) require.True(t, handled) require.Empty(t, repo.modelRateLimitCalls, "映射后的上游模型是图片模型,冷却键会写到 gpt-image-2 上,守卫必须一并识别") } // 404 model-not-found 分支不受守卫影响:即使是图片模型也照常冷却。 func TestRateLimitService_HandleUpstreamError_ModelNotFoundImageModelStillCoolsDown(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &RateLimitService{accountRepo: repo} account := openAICodexPlanGatedOAuthAccount() handled := svc.HandleUpstreamError( context.Background(), account, http.StatusNotFound, http.Header{}, []byte(`{"error":{"message":"The model 'gpt-image-2' does not exist","code":"model_not_found"}}`), "gpt-image-2", ) require.True(t, handled) require.Len(t, repo.modelRateLimitCalls, 1, "守卫只作用于 codex plan-gated 分支") require.Equal(t, upstreamModelNotFoundReason, repo.modelRateLimitCalls[0].reason) }