From 5a25507dcbb33fc40e3ef0659af210f206229a31 Mon Sep 17 00:00:00 2001 From: feeeei Date: Sat, 3 Oct 2026 12:08:05 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(account):=20=E4=B8=B4=E6=97=B6?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E8=B0=83=E5=BA=A6=E8=A7=84=E5=88=99=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E4=BD=9C=E7=94=A8=E8=8C=83=E5=9B=B4=20scope=20?= =?UTF-8?q?=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 规则新增 scope 字段:account 命中后暂停整个账号,model 只冷却本次请求的模型; 字段缺省时保持现有语义(模型已知且非 401 走模型级,否则账号级),显式取值由运营人员决定,401 也不例外 - 模型未知的路径按账号级兜底;状态 JSON 记录 scope/model 便于排查 - 创建/编辑账号弹窗的规则编辑器新增作用范围单选卡片,新规则默认模型级; 未设置 scope 的存量规则按后端缺省语义回显(401 账号级、其余模型级),打开再保存不改变行为 --- backend/internal/service/account.go | 22 +++++ backend/internal/service/ratelimit_service.go | 36 ++++++-- backend/internal/service/temp_unsched.go | 2 + .../components/account/CreateAccountModal.vue | 72 +++++++++++++-- .../components/account/EditAccountModal.vue | 88 +++++++++++++++++-- .../src/i18n/locales/en/admin/accounts.ts | 8 +- .../src/i18n/locales/zh/admin/accounts.ts | 8 +- frontend/src/types/index.ts | 6 ++ 8 files changed, 219 insertions(+), 23 deletions(-) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 5262134a2b..70591f7241 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -129,11 +129,20 @@ func isOpenAIPersonalAccessTokenAuthMode(value string) bool { } } +// TempUnschedulableRule.Scope 的取值:命中后隔离的粒度。 +const ( + TempUnschedScopeAccount = "account" + TempUnschedScopeModel = "model" +) + type TempUnschedulableRule struct { ErrorCode int `json:"error_code"` Keywords []string `json:"keywords"` DurationMinutes int `json:"duration_minutes"` Description string `json:"description"` + // Scope 决定命中后的隔离粒度:account 暂停整个账号,model 只冷却本次请求的模型。 + // 为空时模型已知且非 401 走模型级,否则账号级。 + Scope string `json:"scope,omitempty"` } func (a *Account) IsActive() bool { @@ -460,6 +469,7 @@ func (a *Account) GetTempUnschedulableRules() []TempUnschedulableRule { Keywords: parseTempUnschedStrings(entry["keywords"]), DurationMinutes: parseTempUnschedInt(entry["duration_minutes"]), Description: parseTempUnschedString(entry["description"]), + Scope: normalizeTempUnschedScope(entry["scope"]), } if rule.ErrorCode <= 0 || rule.DurationMinutes <= 0 || len(rule.Keywords) == 0 { @@ -472,6 +482,18 @@ func (a *Account) GetTempUnschedulableRules() []TempUnschedulableRule { return rules } +// normalizeTempUnschedScope 只认 account / model 两个值,其余一律视为未设置。 +func normalizeTempUnschedScope(raw any) string { + switch strings.ToLower(strings.TrimSpace(parseTempUnschedString(raw))) { + case TempUnschedScopeAccount: + return TempUnschedScopeAccount + case TempUnschedScopeModel: + return TempUnschedScopeModel + default: + return "" + } +} + func parseTempUnschedString(value any) string { s, ok := value.(string) if !ok { diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 6438efc3e2..421de77a5c 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -2564,6 +2564,23 @@ func firstRequestedModel(requestedModel []string) string { return strings.TrimSpace(requestedModel[0]) } +// tempUnschedRuleUsesModelScope 决定一次命中按模型级冷却还是账号级暂停。 +// 显式 scope 完全由规则决定;scope 为空时模型已知且非 401 走模型级。 +// 模型未知时无法隔离,一律账号级。 +func tempUnschedRuleUsesModelScope(rule TempUnschedulableRule, modelKey string, statusCode int) bool { + if strings.TrimSpace(modelKey) == "" { + return false + } + switch rule.Scope { + case TempUnschedScopeAccount: + return false + case TempUnschedScopeModel: + return true + default: + return statusCode != http.StatusUnauthorized + } +} + type tempUnschedulableModelContextKey struct{} func withTempUnschedulableModel(ctx context.Context, requestedModel []string) context.Context { @@ -2696,6 +2713,8 @@ func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account now := time.Now() until := now.Add(time.Duration(rule.DurationMinutes) * time.Minute) + modelKey := firstRequestedModel(requestedModel) + modelScoped := tempUnschedRuleUsesModelScope(rule, modelKey, statusCode) state := &TempUnschedState{ UntilUnix: until.Unix(), @@ -2704,6 +2723,11 @@ func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account MatchedKeyword: matchedKeyword, RuleIndex: ruleIndex, ErrorMessage: truncateTempUnschedMessage(responseBody, tempUnschedMessageMaxBytes), + Scope: TempUnschedScopeAccount, + } + if modelScoped { + state.Scope = TempUnschedScopeModel + state.Model = modelKey } reason := "" @@ -2714,18 +2738,16 @@ func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account reason = strings.TrimSpace(state.ErrorMessage) } - // Persist known-model failures under the model key so the scheduler excludes - // only this (account, model) pair. Authentication and model-unknown failures - // retain the legacy account-wide temporary-unschedulable behavior below. - modelKey := firstRequestedModel(requestedModel) - if modelKey != "" && statusCode != http.StatusUnauthorized { + // Persist model-scoped hits under the model key so the scheduler excludes + // only this (account, model) pair; every other hit pauses the whole account below. + if modelScoped { if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelKey, until, reason); err != nil { slog.Warn("temp_unsched_model_rate_limit_set_failed", "account_id", account.ID, "model", modelKey, "error", err) // The rule matched, so fail over the current request even if persistence // failed; never widen a model-scoped failure into an account-wide block. return true } - slog.Info("account_model_temp_unschedulable", "account_id", account.ID, "model", modelKey, "until", until, "rule_index", ruleIndex, "status_code", statusCode) + slog.Info("account_model_temp_unschedulable", "account_id", account.ID, "model", modelKey, "until", until, "rule_index", ruleIndex, "rule_scope", rule.Scope, "status_code", statusCode) return true } @@ -2741,7 +2763,7 @@ func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account } } - slog.Info("account_temp_unschedulable", "account_id", account.ID, "until", until, "rule_index", ruleIndex, "status_code", statusCode) + slog.Info("account_temp_unschedulable", "account_id", account.ID, "until", until, "rule_index", ruleIndex, "rule_scope", rule.Scope, "status_code", statusCode) return true } diff --git a/backend/internal/service/temp_unsched.go b/backend/internal/service/temp_unsched.go index 92b7da3f72..67ecb8e70e 100644 --- a/backend/internal/service/temp_unsched.go +++ b/backend/internal/service/temp_unsched.go @@ -16,6 +16,8 @@ type TempUnschedState struct { TriggerCount int64 `json:"trigger_count,omitempty"` // 本次触发累计命中次数 TriggerThreshold int `json:"trigger_threshold,omitempty"` // 触发阈值 TriggerWindowMinutes int `json:"trigger_window_minutes,omitempty"` // 计数窗口(分钟) + Scope string `json:"scope,omitempty"` // 隔离粒度:account / model + Model string `json:"model,omitempty"` // 模型级时被冷却的模型键 } // TempUnschedCache 临时不可调度缓存接口 diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 64cea06900..e3482a11b7 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2561,6 +2561,44 @@ :placeholder="t('admin.accounts.tempUnschedulable.durationPlaceholder')" /> +
+ +
+ +
+

{{ t('admin.accounts.tempUnschedulable.scopeHint') }}

+
getPresetMappingsByPlatform(form.platform)) +const tempUnschedScopeOptions = computed>(() => [ + { + value: 'model', + label: t('admin.accounts.tempUnschedulable.scopeModel'), + description: t('admin.accounts.tempUnschedulable.scopeModelDesc') + }, + { + value: 'account', + label: t('admin.accounts.tempUnschedulable.scopeAccount'), + description: t('admin.accounts.tempUnschedulable.scopeAccountDesc') + } +]) + const tempUnschedPresets = computed(() => [ { label: t('admin.accounts.tempUnschedulable.presets.overloadLabel'), @@ -4720,7 +4774,8 @@ const tempUnschedPresets = computed(() => [ error_code: 529, keywords: 'overloaded, too many', duration_minutes: 60, - description: t('admin.accounts.tempUnschedulable.presets.overloadDesc') + description: t('admin.accounts.tempUnschedulable.presets.overloadDesc'), + scope: 'model' as const } }, { @@ -4729,7 +4784,8 @@ const tempUnschedPresets = computed(() => [ error_code: 429, keywords: 'rate limit, too many requests', duration_minutes: 10, - description: t('admin.accounts.tempUnschedulable.presets.rateLimitDesc') + description: t('admin.accounts.tempUnschedulable.presets.rateLimitDesc'), + scope: 'model' as const } }, { @@ -4738,7 +4794,8 @@ const tempUnschedPresets = computed(() => [ error_code: 503, keywords: 'unavailable, maintenance', duration_minutes: 30, - description: t('admin.accounts.tempUnschedulable.presets.unavailableDesc') + description: t('admin.accounts.tempUnschedulable.presets.unavailableDesc'), + scope: 'model' as const } } ]) @@ -5123,7 +5180,8 @@ const addTempUnschedRule = (preset?: TempUnschedRuleForm) => { error_code: null, keywords: '', duration_minutes: 30, - description: '' + description: '', + scope: 'model' }) } @@ -5146,6 +5204,7 @@ const buildTempUnschedRules = (rules: TempUnschedRuleForm[]) => { keywords: string[] duration_minutes: number description: string + scope: TempUnschedulableScope }> = [] for (const rule of rules) { @@ -5165,7 +5224,8 @@ const buildTempUnschedRules = (rules: TempUnschedRuleForm[]) => { error_code: Math.trunc(errorCode), keywords, duration_minutes: Math.trunc(duration), - description: rule.description.trim() + description: rule.description.trim(), + scope: rule.scope }) } diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index f7af882c7b..9bddf388d2 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1536,6 +1536,44 @@ :placeholder="t('admin.accounts.tempUnschedulable.durationPlaceholder')" />
+
+ +
+ +
+

{{ t('admin.accounts.tempUnschedulable.scopeHint') }}

+
{ // Computed: current preset mappings based on platform const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic')) +const tempUnschedScopeOptions = computed>(() => [ + { + value: 'model', + label: t('admin.accounts.tempUnschedulable.scopeModel'), + description: t('admin.accounts.tempUnschedulable.scopeModelDesc') + }, + { + value: 'account', + label: t('admin.accounts.tempUnschedulable.scopeAccount'), + description: t('admin.accounts.tempUnschedulable.scopeAccountDesc') + } +]) + const tempUnschedPresets = computed(() => [ { label: t('admin.accounts.tempUnschedulable.presets.overloadLabel'), @@ -3963,7 +4017,8 @@ const tempUnschedPresets = computed(() => [ error_code: 529, keywords: 'overloaded, too many', duration_minutes: 60, - description: t('admin.accounts.tempUnschedulable.presets.overloadDesc') + description: t('admin.accounts.tempUnschedulable.presets.overloadDesc'), + scope: 'model' as const } }, { @@ -3972,7 +4027,8 @@ const tempUnschedPresets = computed(() => [ error_code: 429, keywords: 'rate limit, too many requests', duration_minutes: 10, - description: t('admin.accounts.tempUnschedulable.presets.rateLimitDesc') + description: t('admin.accounts.tempUnschedulable.presets.rateLimitDesc'), + scope: 'model' as const } }, { @@ -3981,7 +4037,8 @@ const tempUnschedPresets = computed(() => [ error_code: 503, keywords: 'unavailable, maintenance', duration_minutes: 30, - description: t('admin.accounts.tempUnschedulable.presets.unavailableDesc') + description: t('admin.accounts.tempUnschedulable.presets.unavailableDesc'), + scope: 'model' as const } } ]) @@ -4718,7 +4775,8 @@ const addTempUnschedRule = (preset?: TempUnschedRuleForm) => { error_code: null, keywords: '', duration_minutes: 30, - description: '' + description: '', + scope: 'model' }) } @@ -4741,6 +4799,7 @@ const buildTempUnschedRules = (rules: TempUnschedRuleForm[]) => { keywords: string[] duration_minutes: number description: string + scope: TempUnschedulableScope }> = [] for (const rule of rules) { @@ -4760,7 +4819,8 @@ const buildTempUnschedRules = (rules: TempUnschedRuleForm[]) => { error_code: Math.trunc(errorCode), keywords, duration_minutes: Math.trunc(duration), - description: rule.description.trim() + description: rule.description.trim(), + scope: rule.scope }) } @@ -4858,11 +4918,13 @@ function loadTempUnschedRules(credentials?: Record) { tempUnschedRules.value = rawRules.map((rule) => { const entry = rule as Record + const errorCode = toPositiveNumber(entry.error_code) return { - error_code: toPositiveNumber(entry.error_code), + error_code: errorCode, keywords: formatTempUnschedKeywords(entry.keywords), duration_minutes: toPositiveNumber(entry.duration_minutes), - description: typeof entry.description === 'string' ? entry.description : '' + description: typeof entry.description === 'string' ? entry.description : '', + scope: resolveTempUnschedScope(entry.scope, errorCode) } }) } @@ -4961,6 +5023,16 @@ function formatTempUnschedKeywords(value: unknown) { return '' } +// 未设置 scope 的规则由后端按「401 账号级、其余模型级」处理,这里按同一规则回显, +// 保证打开再保存不改变规则行为。 +function resolveTempUnschedScope(value: unknown, errorCode: number | null): TempUnschedulableScope { + const scope = typeof value === 'string' ? value.trim().toLowerCase() : '' + if (scope === 'account' || scope === 'model') { + return scope + } + return errorCode === 401 ? 'account' : 'model' +} + const splitTempUnschedKeywords = (value: string) => { return value .split(/[,;]/) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index d11d7a9e7f..c605d27e66 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -409,7 +409,7 @@ export default { tempUnschedulable: { title: 'Temp Unschedulable', statusTitle: 'Temp Unschedulable Status', - hint: 'Disable accounts temporarily when error code and keyword both match.', + hint: 'When error code and keyword both match, pause the whole account or cool down only the model for the configured duration, per the rule scope.', notice: 'Rules are evaluated in order and require both error code and keyword match.', addRule: 'Add Rule', ruleOrder: 'Rule Order', @@ -425,6 +425,12 @@ export default { keywords: 'Keywords', keywordsPlaceholder: 'e.g. overloaded, too many requests', keywordsHint: 'Separate keywords with commas; any keyword match will trigger.', + scope: 'Scope', + scopeModel: 'Model', + scopeModelDesc: 'Cool down only the requested model', + scopeAccount: 'Account', + scopeAccountDesc: 'Pause scheduling for the whole account', + scopeHint: 'Model-scoped rules fall back to account scope when the request model cannot be identified.', description: 'Description', descriptionPlaceholder: 'Optional note for this rule', rulesInvalid: 'Add at least one rule with error code, keywords, and duration.', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index b002e5d54b..a9c4546fc1 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -457,7 +457,7 @@ export default { tempUnschedulable: { title: '临时不可调度', statusTitle: '临时不可调度状态', - hint: '当错误码与关键词同时匹配时,账号会在指定时间内被临时禁用。', + hint: '当错误码与关键词同时匹配时,按规则的作用范围在指定时间内暂停整个账号,或只冷却对应模型。', notice: '规则按顺序匹配,需同时满足错误码与关键词。', addRule: '添加规则', ruleOrder: '规则序号', @@ -473,6 +473,12 @@ export default { keywords: '关键词', keywordsPlaceholder: '例如 overloaded, too many requests', keywordsHint: '多个关键词用逗号分隔,匹配时必须命中其中之一。', + scope: '作用范围', + scopeModel: '模型级', + scopeModelDesc: '只冷却本次请求的模型', + scopeAccount: '账户级', + scopeAccountDesc: '暂停整个账号的调度', + scopeHint: '无法识别请求模型时,模型级规则按账户级处理。', description: '描述', descriptionPlaceholder: '可选,便于记忆规则用途', rulesInvalid: '请至少填写一条包含错误码、关键词和时长的规则。', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7764284dfd..4d1c658651 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1024,11 +1024,15 @@ export interface GeminiCredentials { model_mapping?: Record } +export type TempUnschedulableScope = 'account' | 'model' + export interface TempUnschedulableRule { error_code: number keywords: string[] duration_minutes: number description: string + // 作用范围:account 暂停整个账号,model 只冷却本次请求的模型;缺省时 401 按 account、其余按 model 处理。 + scope?: TempUnschedulableScope } export interface TempUnschedulableState { @@ -1036,6 +1040,8 @@ export interface TempUnschedulableState { triggered_at_unix: number status_code: number matched_keyword: string + scope?: TempUnschedulableScope + model?: string rule_index: number error_message: string trigger_count?: number From a46249aac5611fca71fb0d955ad19be7602aca71 Mon Sep 17 00:00:00 2001 From: feeeei Date: Sat, 3 Oct 2026 12:08:05 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test(account):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E4=B8=8D=E5=8F=AF=E8=B0=83=E5=BA=A6=E4=BD=9C?= =?UTF-8?q?=E7=94=A8=E8=8C=83=E5=9B=B4=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:scope 解析归一化、缺省/显式账号级/显式模型级/模型未知兜底的分流判定, 以及显式账号级、显式模型级 401、模型未知三种命中的落库位置与状态 JSON - 前端:未设置 scope 的存量规则按 401 账号级、其余模型级回显并原样保存, 显式 scope 回显与切换后提交,新增规则默认模型级 --- .../service/temp_unsched_scope_test.go | 156 ++++++++++++++++++ .../__tests__/EditAccountModal.spec.ts | 76 +++++++++ 2 files changed, 232 insertions(+) create mode 100644 backend/internal/service/temp_unsched_scope_test.go diff --git a/backend/internal/service/temp_unsched_scope_test.go b/backend/internal/service/temp_unsched_scope_test.go new file mode 100644 index 0000000000..34619dad53 --- /dev/null +++ b/backend/internal/service/temp_unsched_scope_test.go @@ -0,0 +1,156 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type tempUnschedScopeRepoStub struct { + modelNotFoundAccountRepoStub + tempReasons []string +} + +func (r *tempUnschedScopeRepoStub) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error { + r.tempCalls++ + r.tempReasons = append(r.tempReasons, reason) + return nil +} + +func newTempUnschedScopeAccount(scope string, extra map[string]any) *Account { + rule := map[string]any{ + "error_code": float64(http.StatusServiceUnavailable), + "keywords": []any{"unavailable"}, + "duration_minutes": float64(10), + } + if scope != "" { + rule["scope"] = scope + } + for k, v := range extra { + rule[k] = v + } + return &Account{ + ID: 202, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Credentials: map[string]any{ + "temp_unschedulable_enabled": true, + "temp_unschedulable_rules": []any{rule}, + }, + } +} + +var tempUnschedScopeBody = []byte(`{"error":{"message":"upstream unavailable"}}`) + +func TestGetTempUnschedulableRules_ParsesScope(t *testing.T) { + mk := func(keyword string, scope any) map[string]any { + rule := map[string]any{ + "error_code": float64(http.StatusServiceUnavailable), + "keywords": []any{keyword}, + "duration_minutes": float64(1), + } + if scope != nil { + rule["scope"] = scope + } + return rule + } + account := &Account{Credentials: map[string]any{ + "temp_unschedulable_rules": []any{ + mk("a", "account"), + mk("b", " Model "), + mk("c", "whatever"), + mk("d", nil), + }, + }} + + rules := account.GetTempUnschedulableRules() + + require.Len(t, rules, 4) + require.Equal(t, TempUnschedScopeAccount, rules[0].Scope) + require.Equal(t, TempUnschedScopeModel, rules[1].Scope, "大小写与首尾空白应被归一化") + require.Empty(t, rules[2].Scope, "未知取值视为未设置") + require.Empty(t, rules[3].Scope) +} + +func TestTempUnschedRuleUsesModelScope(t *testing.T) { + cases := []struct { + name string + scope string + model string + status int + want bool + }{ + {"缺省 模型已知 非401 走模型级", "", "gpt-5.4", http.StatusServiceUnavailable, true}, + {"缺省 401 走账号级", "", "gpt-5.4", http.StatusUnauthorized, false}, + {"缺省 模型未知 走账号级", "", "", http.StatusServiceUnavailable, false}, + {"显式账号级 模型已知 仍账号级", TempUnschedScopeAccount, "gpt-5.4", http.StatusServiceUnavailable, false}, + {"显式模型级 401 仍模型级", TempUnschedScopeModel, "gpt-5.4", http.StatusUnauthorized, true}, + {"显式模型级 模型未知 兜底账号级", TempUnschedScopeModel, " ", http.StatusServiceUnavailable, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tempUnschedRuleUsesModelScope(TempUnschedulableRule{Scope: tc.scope}, tc.model, tc.status) + require.Equal(t, tc.want, got) + }) + } +} + +func TestHandleTempUnschedulable_ExplicitAccountScopePausesWholeAccountWithKnownModel(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := newTempUnschedScopeAccount(TempUnschedScopeAccount, nil) + + handled := svc.HandleTempUnschedulable(context.Background(), account, http.StatusServiceUnavailable, tempUnschedScopeBody, "gpt-5.4") + + require.True(t, handled) + require.Equal(t, 1, repo.tempCalls) + require.Empty(t, repo.modelRateLimitCalls) + + var state TempUnschedState + require.NoError(t, json.Unmarshal([]byte(repo.tempReasons[0]), &state)) + require.Equal(t, TempUnschedScopeAccount, state.Scope) + require.Empty(t, state.Model) +} + +func TestCheckErrorPolicy_ExplicitModelScopeKeeps401ModelScoped(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := newTempUnschedScopeAccount(TempUnschedScopeModel, map[string]any{ + "error_code": float64(http.StatusUnauthorized), + "keywords": []any{"unauthorized"}, + }) + + result := svc.CheckErrorPolicy(context.Background(), account, http.StatusUnauthorized, + []byte(`{"error":{"message":"unauthorized"}}`), "gpt-5.4") + + require.Equal(t, ErrorPolicyTempUnscheduled, result) + require.Zero(t, repo.tempCalls, "运营人员显式选了模型级,401 也不应连累整个账号") + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "gpt-5.4", repo.modelRateLimitCalls[0].scope) + + var state TempUnschedState + require.NoError(t, json.Unmarshal([]byte(repo.modelRateLimitCalls[0].reason), &state)) + require.Equal(t, TempUnschedScopeModel, state.Scope) + require.Equal(t, "gpt-5.4", state.Model) + require.Equal(t, http.StatusUnauthorized, state.StatusCode) +} + +func TestHandleTempUnschedulable_ExplicitModelScopeFallsBackToAccountWhenModelUnknown(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := newTempUnschedScopeAccount(TempUnschedScopeModel, nil) + + handled := svc.HandleTempUnschedulable(context.Background(), account, http.StatusServiceUnavailable, tempUnschedScopeBody) + + require.True(t, handled) + require.Equal(t, 1, repo.tempCalls) + require.Empty(t, repo.modelRateLimitCalls) +} diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 92274571a6..756ab78e3f 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -1731,3 +1731,79 @@ describe('EditAccountModal OpenAI 自动使用重置卡', () => { wrapper.unmount() }) }) + +describe('EditAccountModal temp-unschedulable rule scope', () => { + function buildAccountWithTempUnschedRules(rules: Array>) { + const account = buildAccount() + return { + ...account, + credentials: { + ...account.credentials, + temp_unschedulable_enabled: true, + temp_unschedulable_rules: rules + } + } as any + } + + const checkedScopes = (wrapper: ReturnType) => + wrapper + .findAll('[data-testid^="temp-unsched-scope-"][aria-checked="true"]') + .map((radio) => radio.attributes('data-testid')) + + const savedRules = () => + updateAccountMock.mock.calls[0]?.[1]?.credentials?.temp_unschedulable_rules + + beforeEach(() => { + authIsSimpleMode.value = true + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + }) + + it('shows rules without scope the way the backend treats them and keeps that on save', async () => { + const account = buildAccountWithTempUnschedRules([ + { error_code: 401, keywords: ['unauthorized'], duration_minutes: 10 }, + { error_code: 503, keywords: ['unavailable'], duration_minutes: 30 } + ]) + updateAccountMock.mockResolvedValue(account) + const wrapper = mountModal(account) + + expect(checkedScopes(wrapper)).toEqual(['temp-unsched-scope-account', 'temp-unsched-scope-model']) + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(savedRules().map((rule: { scope: string }) => rule.scope)).toEqual(['account', 'model']) + wrapper.unmount() + }) + + it('respects an explicit scope and saves the card the operator picks', async () => { + const account = buildAccountWithTempUnschedRules([ + { error_code: 401, keywords: ['unauthorized'], duration_minutes: 10, scope: 'model' }, + { error_code: 503, keywords: ['unavailable'], duration_minutes: 30, scope: ' Account ' } + ]) + updateAccountMock.mockResolvedValue(account) + const wrapper = mountModal(account) + + expect(checkedScopes(wrapper)).toEqual(['temp-unsched-scope-model', 'temp-unsched-scope-account']) + + await wrapper.findAll('[data-testid="temp-unsched-scope-model"]')[1].trigger('click') + expect(checkedScopes(wrapper)).toEqual(['temp-unsched-scope-model', 'temp-unsched-scope-model']) + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(savedRules().map((rule: { scope: string }) => rule.scope)).toEqual(['model', 'model']) + wrapper.unmount() + }) + + it('defaults a newly added rule to model scope', async () => { + const wrapper = mountModal(buildAccountWithTempUnschedRules([])) + + const addRule = wrapper + .findAll('button') + .find((button) => button.text().includes('admin.accounts.tempUnschedulable.addRule')) + await addRule!.trigger('click') + + expect(checkedScopes(wrapper)).toEqual(['temp-unsched-scope-model']) + wrapper.unmount() + }) +}) From fc82e831026a15eaf609cc1f7cd4d4a3690e5cad Mon Sep 17 00:00:00 2001 From: feeeei Date: Mon, 12 Oct 2026 17:39:26 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(account):=20=E6=98=BE=E5=BC=8F=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=BA=A7=20401=20=E8=A7=84=E5=88=99=E5=9C=A8=E7=BD=91?= =?UTF-8?q?=E5=85=B3=E6=95=85=E9=9A=9C=E8=BD=AC=E7=A7=BB=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI 转发 fastpath、HandleUpstreamError 及两处池模式分支对 401 一律跳过 临时不可调度规则,显式 scope=model 的 401 规则在 API Key 账号上仍落到 handleAuthError 整号置 error。 - 新增 tempUnschedRulesApply:401 仅在首条命中规则按模型级生效时交给规则, 其余保持认证错误处理 - 凭据级 401(OAuth 账号与 Spark 影子、OpenAI token_invalidated/token_revoked、 {"detail":"Unauthorized"})不受规则作用范围影响,各入口沿用原有处理 - 模型级命中不参与重复 401 升级;fastpath 不再因模型级命中封整号 --- .../openai_account_runtime_block_fastpath.go | 6 +- backend/internal/service/ratelimit_service.go | 68 +++++-- .../service/temp_unsched_scope_test.go | 167 ++++++++++++++++++ 3 files changed, 229 insertions(+), 12 deletions(-) diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index cc8b51cfd4..942ea07bcb 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -158,7 +158,8 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont // Isolate a custom temporary-unschedulable match to the known upstream // model before entering the generic account error path. This keeps the // account available to other models and avoids the account runtime blocker. - if s.rateLimitService != nil && statusCode != http.StatusUnauthorized && len(canonicalModel) > 0 && strings.TrimSpace(canonicalModel[0]) != "" && + if s.rateLimitService != nil && len(canonicalModel) > 0 && strings.TrimSpace(canonicalModel[0]) != "" && + tempUnschedRulesApply(account, statusCode, responseBody, canonicalModel[0]) && s.rateLimitService.HandleTempUnschedulable(stateCtx, account, statusCode, responseBody, canonicalModel[0]) { return true } @@ -173,7 +174,8 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont return false } shouldDisable := s.rateLimitService.HandleUpstreamError(stateCtx, account, statusCode, headers, responseBody) - modelTempMatched := statusCode != http.StatusUnauthorized && tempUnschedulableModel(stateCtx, nil) != "" && + tempModel := tempUnschedulableModel(stateCtx, nil) + modelTempMatched := tempModel != "" && tempUnschedRulesApply(account, statusCode, responseBody, tempModel) && len(matchTempUnschedulableRules(account, statusCode, responseBody)) > 0 if shouldDisable && !modelTempMatched { s.BlockAccountScheduling(account, time.Time{}, "upstream_disable") diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 421de77a5c..e085a58a96 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -310,8 +310,9 @@ func (s *RateLimitService) CheckErrorPolicy(ctx context.Context, account *Accoun } if account.IsPoolMode() { // 池模式只跳过默认账号状态处理;管理员显式配置的临时不可调度规则仍应生效。 - // 401 保留现有认证错误语义,避免改变重复 401 的升级行为。 - if statusCode != http.StatusUnauthorized && s.tryTempUnschedulable(ctx, account, statusCode, responseBody) { + // 401 只在非凭据级且命中显式模型级规则时由规则处理,其余保留现有认证错误语义。 + if tempUnschedRulesApply(account, statusCode, responseBody, tempUnschedulableModel(ctx, nil)) && + s.tryTempUnschedulable(ctx, account, statusCode, responseBody) { return ErrorPolicyTempUnscheduled } return ErrorPolicySkipped @@ -337,9 +338,10 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc customErrorCodesEnabled := account.IsCustomErrorCodesEnabled() // 池模式默认不标记本地账号状态;但管理员显式配置的临时不可调度规则优先。 - // 401 保留现有认证错误语义,不在这里改变池模式的认证处理。 + // 401 只在非凭据级且命中显式模型级规则时由规则处理,其余保留池模式现有的认证处理。 if account.IsPoolMode() && !customErrorCodesEnabled { - if statusCode != http.StatusUnauthorized && s.tryTempUnschedulable(ctx, account, statusCode, responseBody) { + if tempUnschedRulesApply(account, statusCode, responseBody, tempUnschedulableModel(ctx, nil)) && + s.tryTempUnschedulable(ctx, account, statusCode, responseBody) { return true } slog.Info("pool_mode_error_skipped", "account_id", account.ID, "status_code", statusCode) @@ -385,9 +387,9 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc } } - // 先尝试临时不可调度规则(401除外) + // 先尝试临时不可调度规则(401 仅限非凭据级且命中显式模型级规则) // 如果匹配成功,直接返回,不执行后续禁用逻辑 - if statusCode != 401 { + if tempUnschedRulesApply(account, statusCode, responseBody, tempUnschedulableModel(ctx, requestedModel)) { if s.tryTempUnschedulable(ctx, account, statusCode, responseBody, firstRequestedModel(requestedModel)) { return true } @@ -2564,6 +2566,45 @@ func firstRequestedModel(requestedModel []string) string { return strings.TrimSpace(requestedModel[0]) } +// tempUnschedRulesApply 决定本次错误是否交给临时不可调度规则处理。 +// 401 默认留给认证错误处理;只有首条命中规则按模型级生效时,才由规则只冷却该模型。 +func tempUnschedRulesApply(account *Account, statusCode int, responseBody []byte, modelKey string) bool { + if statusCode != http.StatusUnauthorized { + return true + } + matches := matchTempUnschedulableRules(account, statusCode, responseBody) + return len(matches) > 0 && tempUnschedHitUsesModelScope(account, matches[0].rule, modelKey, statusCode, responseBody) +} + +// tempUnschedHitUsesModelScope 决定一次命中是否按模型级冷却:凭据级 401 始终按账号处理, +// 其余由规则作用范围决定。 +func tempUnschedHitUsesModelScope(account *Account, rule TempUnschedulableRule, modelKey string, statusCode int, responseBody []byte) bool { + if statusCode == http.StatusUnauthorized && unauthorizedRequiresCredentialHandling(account, responseBody) { + return false + } + return tempUnschedRuleUsesModelScope(rule, modelKey, statusCode) +} + +// unauthorizedRequiresCredentialHandling 识别凭据级 401:OAuth 账号与 Spark 影子 +// (失效缓存、刷新窗口、缺 refresh_token 停用),以及 OpenAI 明确的凭据失效 +// (token_invalidated / token_revoked、{"detail":"Unauthorized"})。 +func unauthorizedRequiresCredentialHandling(account *Account, responseBody []byte) bool { + if account == nil { + return false + } + if account.Type == AccountTypeOAuth || account.IsShadow() { + return true + } + if account.Platform != PlatformOpenAI { + return false + } + switch extractUpstreamErrorCode(responseBody) { + case "token_invalidated", "token_revoked": + return true + } + return gjson.GetBytes(responseBody, "detail").String() == "Unauthorized" +} + // tempUnschedRuleUsesModelScope 决定一次命中按模型级冷却还是账号级暂停。 // 显式 scope 完全由规则决定;scope 为空时模型已知且非 401 走模型级。 // 模型未知时无法隔离,一律账号级。 @@ -2645,10 +2686,17 @@ func (s *RateLimitService) tryTempUnschedulable(ctx context.Context, account *Ac if !account.IsTempUnschedulableEnabled() { return false } + matches := matchTempUnschedulableRules(account, statusCode, responseBody) + if len(matches) == 0 { + return false + } + modelKey := tempUnschedulableModel(ctx, requestedModel) // 401 首次命中可临时不可调度(给 token 刷新窗口); // 若历史上已因 401 进入过临时不可调度,则本次应升级为 error(返回 false 交由默认错误逻辑处理)。 // Antigravity 跳过:其 401 由 applyErrorPolicy 的 temp_unschedulable_rules 自行控制,无需升级逻辑。 - if statusCode == http.StatusUnauthorized && account.Platform != PlatformAntigravity { + // 模型级命中只冷却模型、不写账号级状态,不参与升级。 + if statusCode == http.StatusUnauthorized && account.Platform != PlatformAntigravity && + !tempUnschedHitUsesModelScope(account, matches[0].rule, modelKey, statusCode, responseBody) { reason := account.TempUnschedulableReason // 缓存可能没有 reason,从 DB 回退读取 if reason == "" { @@ -2662,8 +2710,8 @@ func (s *RateLimitService) tryTempUnschedulable(ctx context.Context, account *Ac return false } } - for _, match := range matchTempUnschedulableRules(account, statusCode, responseBody) { - if s.triggerTempUnschedulable(ctx, account, match.rule, match.ruleIndex, statusCode, match.matchedKeyword, responseBody, tempUnschedulableModel(ctx, requestedModel)) { + for _, match := range matches { + if s.triggerTempUnschedulable(ctx, account, match.rule, match.ruleIndex, statusCode, match.matchedKeyword, responseBody, modelKey) { return true } } @@ -2714,7 +2762,7 @@ func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account now := time.Now() until := now.Add(time.Duration(rule.DurationMinutes) * time.Minute) modelKey := firstRequestedModel(requestedModel) - modelScoped := tempUnschedRuleUsesModelScope(rule, modelKey, statusCode) + modelScoped := tempUnschedHitUsesModelScope(account, rule, modelKey, statusCode, responseBody) state := &TempUnschedState{ UntilUnix: until.Unix(), diff --git a/backend/internal/service/temp_unsched_scope_test.go b/backend/internal/service/temp_unsched_scope_test.go index 34619dad53..d956078418 100644 --- a/backend/internal/service/temp_unsched_scope_test.go +++ b/backend/internal/service/temp_unsched_scope_test.go @@ -9,12 +9,19 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" ) type tempUnschedScopeRepoStub struct { modelNotFoundAccountRepoStub tempReasons []string + errorCalls int +} + +func (r *tempUnschedScopeRepoStub) SetError(context.Context, int64, string) error { + r.errorCalls++ + return nil } func (r *tempUnschedScopeRepoStub) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error { @@ -50,6 +57,36 @@ func newTempUnschedScopeAccount(scope string, extra map[string]any) *Account { var tempUnschedScopeBody = []byte(`{"error":{"message":"upstream unavailable"}}`) +var ( + tempUnschedScope401Body = []byte(`{"error":{"message":"unauthorized"}}`) + tempUnschedScopeRevoked401Body = []byte(`{"error":{"code":"token_revoked","message":"unauthorized"}}`) + tempUnschedScopeDetail401Body = []byte(`{"detail":"Unauthorized"}`) + tempUnschedScopePrevious401Mark = `{"status_code":401,"until_unix":1735689600}` +) + +func tempUnschedScope401Rule(scope string) map[string]any { + rule := map[string]any{ + "error_code": float64(http.StatusUnauthorized), + "keywords": []any{"unauthorized"}, + "duration_minutes": float64(10), + } + if scope != "" { + rule["scope"] = scope + } + return rule +} + +func newTempUnschedScope401Account(platform string, scopes ...string) *Account { + account := newTempUnschedScopeAccount("", nil) + account.Platform = platform + rules := make([]any, 0, len(scopes)) + for _, scope := range scopes { + rules = append(rules, tempUnschedScope401Rule(scope)) + } + account.Credentials["temp_unschedulable_rules"] = rules + return account +} + func TestGetTempUnschedulableRules_ParsesScope(t *testing.T) { mk := func(keyword string, scope any) map[string]any { rule := map[string]any{ @@ -154,3 +191,133 @@ func TestHandleTempUnschedulable_ExplicitModelScopeFallsBackToAccountWhenModelUn require.Equal(t, 1, repo.tempCalls) require.Empty(t, repo.modelRateLimitCalls) } + +func TestOpenAIFailoverSideEffects_401FollowsTempUnschedScope(t *testing.T) { + cases := []struct { + name string + scopes []string + body []byte + poolMode bool + oauth bool + wantModel bool + wantTemp bool + wantSetError bool + wantBlocked bool + }{ + {name: "显式模型级 只冷却模型", scopes: []string{TempUnschedScopeModel}, wantModel: true}, + {name: "显式模型级 池模式 只冷却模型", scopes: []string{TempUnschedScopeModel}, poolMode: true, wantModel: true}, + {name: "缺省 走认证错误处理", scopes: []string{""}, wantSetError: true, wantBlocked: true}, + {name: "显式账号级 走认证错误处理", scopes: []string{TempUnschedScopeAccount}, wantSetError: true, wantBlocked: true}, + {name: "首条命中为账号级 走认证错误处理", scopes: []string{TempUnschedScopeAccount, TempUnschedScopeModel}, wantSetError: true, wantBlocked: true}, + {name: "缺省 池模式 保持跳过", scopes: []string{""}, poolMode: true}, + {name: "显式模型级 token_revoked 走认证错误处理", scopes: []string{TempUnschedScopeModel}, body: tempUnschedScopeRevoked401Body, wantSetError: true, wantBlocked: true}, + {name: "显式模型级 detail Unauthorized 走认证错误处理", scopes: []string{TempUnschedScopeModel}, body: tempUnschedScopeDetail401Body, wantSetError: true, wantBlocked: true}, + {name: "显式模型级 token_revoked 池模式 保持跳过", scopes: []string{TempUnschedScopeModel}, body: tempUnschedScopeRevoked401Body, poolMode: true}, + {name: "显式模型级 OAuth 走刷新窗口", scopes: []string{TempUnschedScopeModel}, oauth: true, wantTemp: true, wantBlocked: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + gateway := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo, cfg: &config.Config{}}} + account := newTempUnschedScope401Account(PlatformOpenAI, tc.scopes...) + if tc.poolMode { + account.Credentials["pool_mode"] = true + } + if tc.oauth { + account.Type = AccountTypeOAuth + account.Credentials["refresh_token"] = "rt" + } + body := tc.body + if body == nil { + body = tempUnschedScope401Body + } + + require.True(t, gateway.shouldFailoverOpenAIUpstreamResponse(account, http.StatusUnauthorized, "unauthorized", body)) + gateway.handleFailoverSideEffects(context.Background(), &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{}, + }, account, body, "gpt-5.4") + + require.Equal(t, tc.wantTemp, repo.tempCalls == 1, "SetTempUnschedulable=%d", repo.tempCalls) + require.Equal(t, tc.wantSetError, repo.errorCalls == 1, "SetError=%d", repo.errorCalls) + require.Equal(t, tc.wantBlocked, gateway.isOpenAIAccountRuntimeBlocked(account)) + if tc.wantModel { + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "gpt-5.4", repo.modelRateLimitCalls[0].scope) + } else { + require.Empty(t, repo.modelRateLimitCalls) + } + }) + } +} + +func TestHandleUpstreamError_ExplicitModelScope401(t *testing.T) { + t.Run("模型已知 只冷却模型", func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := newTempUnschedScope401Account(PlatformAnthropic, TempUnschedScopeModel) + + shouldDisable := svc.HandleUpstreamError(context.Background(), account, http.StatusUnauthorized, http.Header{}, tempUnschedScope401Body, "claude-sonnet-4-5") + + require.True(t, shouldDisable) + require.Zero(t, repo.errorCalls) + require.Zero(t, repo.tempCalls) + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].scope) + }) + + t.Run("模型未知 按账号级规则走认证错误处理", func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := newTempUnschedScope401Account(PlatformAnthropic, TempUnschedScopeModel) + + shouldDisable := svc.HandleUpstreamError(context.Background(), account, http.StatusUnauthorized, http.Header{}, tempUnschedScope401Body) + + require.True(t, shouldDisable) + require.Equal(t, 1, repo.errorCalls) + require.Empty(t, repo.modelRateLimitCalls) + }) +} + +func TestCheckErrorPolicy_401FollowsTempUnschedScope(t *testing.T) { + cases := []struct { + name string + scope string + body []byte + oauth bool + previous401 bool + want ErrorPolicyResult + wantModel bool + wantTemp bool + }{ + {name: "显式模型级 不参与重复 401 升级", scope: TempUnschedScopeModel, previous401: true, want: ErrorPolicyTempUnscheduled, wantModel: true}, + {name: "缺省 第二次 401 升级", previous401: true, want: ErrorPolicyNone}, + {name: "显式模型级 token_revoked 按账号暂停", scope: TempUnschedScopeModel, body: tempUnschedScopeRevoked401Body, want: ErrorPolicyTempUnscheduled, wantTemp: true}, + {name: "显式模型级 detail Unauthorized 按账号暂停", scope: TempUnschedScopeModel, body: tempUnschedScopeDetail401Body, want: ErrorPolicyTempUnscheduled, wantTemp: true}, + {name: "显式模型级 OAuth 按账号暂停", scope: TempUnschedScopeModel, oauth: true, want: ErrorPolicyTempUnscheduled, wantTemp: true}, + {name: "显式模型级 凭据级第二次 401 升级", scope: TempUnschedScopeModel, body: tempUnschedScopeDetail401Body, previous401: true, want: ErrorPolicyNone}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := newTempUnschedScope401Account(PlatformOpenAI, tc.scope) + if tc.oauth { + account.Type = AccountTypeOAuth + } + if tc.previous401 { + account.TempUnschedulableReason = tempUnschedScopePrevious401Mark + } + body := tc.body + if body == nil { + body = tempUnschedScope401Body + } + + result := svc.CheckErrorPolicy(context.Background(), account, http.StatusUnauthorized, body, "gpt-5.4") + + require.Equal(t, tc.want, result) + require.Equal(t, tc.wantTemp, repo.tempCalls == 1, "SetTempUnschedulable=%d", repo.tempCalls) + require.Equal(t, tc.wantModel, len(repo.modelRateLimitCalls) == 1) + }) + } +} From 2023c290e1bbfa63e3f05bdddeeae1f264b8e637 Mon Sep 17 00:00:00 2001 From: feeeei Date: Tue, 13 Oct 2026 00:53:39 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(account):=20=E8=AE=A1=E6=95=B0=E3=80=81?= =?UTF-8?q?Bedrock=20=E4=B8=8E=E9=87=8D=E8=AF=95=E8=80=97=E5=B0=BD?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E6=8A=8A=E4=B8=8A=E6=B8=B8=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=BA=A4=E7=BB=99=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI 与 Anthropic 的 count_tokens / input_tokens 入口、Bedrock 故障转移入口 以及 OAuth 403 重试耗尽入口调 HandleUpstreamError 时没有传入实际发往上游的模型, 显式 scope=model 的 401 规则在这些入口看到空模型,仍落到 handleAuthError 整号置 error。 - 四处计数入口传入映射后的上游模型;Anthropic 透传计数函数新增 upstreamModel 参数 - handleRetryExhaustedSideEffects / handleRetryExhaustedError 增加模型变参, Forward、Anthropic 透传与 Bedrock 三处调用方传入实际模型 - Bedrock 入口传调度器的模型键(account.GetMappedModel),不是发往上游的 Bedrock 模型 ID,模型级冷却才会被调度器读取 - 这些入口的 404 模型未找到与缺省 scope 的非 401 规则随之按主转发路径语义处理 (模型已知走模型级) --- .../service/gateway_anthropic_passthrough.go | 4 +- backend/internal/service/gateway_bedrock.go | 16 +- .../internal/service/gateway_count_tokens.go | 14 +- backend/internal/service/gateway_forward.go | 4 +- .../service/gateway_upstream_response.go | 14 +- .../service/openai_gateway_count_tokens.go | 4 +- .../service/temp_unsched_scope_test.go | 170 ++++++++++++++++++ 7 files changed, 205 insertions(+), 21 deletions(-) diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index 31df1cb77c..b998f69eeb 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -184,7 +184,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( logger.LegacyPrintf("service.gateway", "[Anthropic Passthrough] Upstream error (retry exhausted, failover): Account=%d(%s) Status=%d RequestID=%s Body=%s", account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000)) - s.handleRetryExhaustedSideEffects(ctx, resp, account) + s.handleRetryExhaustedSideEffects(ctx, resp, account, input.RequestModel) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), @@ -209,7 +209,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), } } - return s.handleRetryExhaustedError(ctx, resp, c, account) + return s.handleRetryExhaustedError(ctx, resp, c, account, input.RequestModel) } if resp.StatusCode >= 400 && s.shouldFailoverUpstreamError(resp.StatusCode) { diff --git a/backend/internal/service/gateway_bedrock.go b/backend/internal/service/gateway_bedrock.go index 97ee44341d..60d6937fbb 100644 --- a/backend/internal/service/gateway_bedrock.go +++ b/backend/internal/service/gateway_bedrock.go @@ -130,7 +130,9 @@ func (s *GatewayService) forwardBedrock( // 错误/failover 处理 if resp.StatusCode >= 400 { - return s.handleBedrockUpstreamErrors(ctx, resp, c, account) + // 账号状态按模型隔离时用调度器的模型键(账号映射后的模型名),不是发往上游的 Bedrock 模型 ID; + // 两者不一致会让模型级冷却写到调度器不会读取的键上。 + return s.handleBedrockUpstreamErrors(ctx, resp, c, account, account.GetMappedModel(reqModel)) } // Bedrock 分支绕过通用 Forward 成功路径,这里保持上游接受回调语义一致。 @@ -263,12 +265,14 @@ func (s *GatewayService) executeBedrockUpstream( return resp, nil } -// handleBedrockUpstreamErrors 处理 Bedrock 上游 4xx/5xx 错误(failover + 错误响应) +// handleBedrockUpstreamErrors 处理 Bedrock 上游 4xx/5xx 错误(failover + 错误响应); +// modelKey 是调度器按模型隔离账号状态时使用的模型键(account.GetMappedModel 的结果)。 func (s *GatewayService) handleBedrockUpstreamErrors( ctx context.Context, resp *http.Response, c *gin.Context, account *Account, + modelKey string, ) (*ForwardResult, error) { // retry exhausted + failover if s.shouldRetryUpstreamError(account, resp.StatusCode) { @@ -280,7 +284,7 @@ func (s *GatewayService) handleBedrockUpstreamErrors( logger.LegacyPrintf("service.gateway", "[Bedrock] Upstream error (retry exhausted, failover): Account=%d(%s) Status=%d Body=%s", account.ID, account.Name, resp.StatusCode, truncateString(string(respBody), 1000)) - s.handleRetryExhaustedSideEffects(ctx, resp, account) + s.handleRetryExhaustedSideEffects(ctx, resp, account, modelKey) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), @@ -297,7 +301,7 @@ func (s *GatewayService) handleBedrockUpstreamErrors( RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), } } - return s.handleRetryExhaustedError(ctx, resp, c, account) + return s.handleRetryExhaustedError(ctx, resp, c, account, modelKey) } // non-retryable failover @@ -306,7 +310,7 @@ func (s *GatewayService) handleBedrockUpstreamErrors( _ = resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(respBody)) - s.handleFailoverSideEffects(ctx, resp, account) + s.handleFailoverSideEffects(ctx, resp, account, modelKey) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), @@ -325,7 +329,7 @@ func (s *GatewayService) handleBedrockUpstreamErrors( } // other errors - return s.handleErrorResponse(ctx, resp, c, account) + return s.handleErrorResponse(ctx, resp, c, account, modelKey) } // buildUpstreamRequestBedrock 构建 Bedrock 上游请求 diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 2a5f4a0401..ec3c2bad0e 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -42,13 +42,15 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() { passthroughBody := parsed.Body.Bytes() + passthroughModel := parsed.Model if reqModel := parsed.Model; reqModel != "" { if mappedModel := account.GetMappedModel(reqModel); mappedModel != reqModel { passthroughBody = s.replaceModelInBody(passthroughBody, mappedModel) + passthroughModel = mappedModel logger.LegacyPrintf("service.gateway", "CountTokens passthrough model mapping: %s -> %s (account: %s)", reqModel, mappedModel, account.Name) } } - return s.forwardCountTokensAnthropicAPIKeyPassthrough(ctx, c, account, passthroughBody) + return s.forwardCountTokensAnthropicAPIKeyPassthrough(ctx, c, account, passthroughBody, passthroughModel) } // Bedrock 不支持 count_tokens 端点 @@ -220,8 +222,8 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, // 处理错误响应 if resp.StatusCode >= 400 { - // 标记账号状态(429/529等) - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + // 标记账号状态(429/529等);传入实际发往上游的模型,临时不可调度规则才能按模型隔离 + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, reqModel) upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) @@ -267,7 +269,9 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, return nil } -func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx context.Context, c *gin.Context, account *Account, body []byte) error { +// forwardCountTokensAnthropicAPIKeyPassthrough 透传 count_tokens 请求;upstreamModel 是 body 中实际发往上游的模型, +// 仅用于上游错误处理按模型隔离。 +func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx context.Context, c *gin.Context, account *Account, body []byte, upstreamModel string) error { token, tokenType, err := s.GetAccessToken(ctx, account) if err != nil { s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to get access token") @@ -322,7 +326,7 @@ func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx contex if resp.StatusCode >= 400 { if s.rateLimitService != nil { - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, upstreamModel) } upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index c47e90967e..388f2495a9 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -689,7 +689,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (retry exhausted, failover): Account=%d(%s) Status=%d RequestID=%s Body=%s", account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000)) - s.handleRetryExhaustedSideEffects(ctx, resp, account) + s.handleRetryExhaustedSideEffects(ctx, resp, account, reqModel) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), @@ -713,7 +713,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), } } - return s.handleRetryExhaustedError(ctx, resp, c, account) + return s.handleRetryExhaustedError(ctx, resp, c, account, reqModel) } // 处理可切换账号的错误 diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 6aaaaf4b16..fb9368b6c0 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -527,13 +527,19 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg) } -func (s *GatewayService) handleRetryExhaustedSideEffects(ctx context.Context, resp *http.Response, account *Account) { +// handleRetryExhaustedSideEffects 在重试耗尽后标记账号状态;requestedModel 是实际发往上游的模型, +// 临时不可调度规则据此按模型隔离。 +func (s *GatewayService) handleRetryExhaustedSideEffects(ctx context.Context, resp *http.Response, account *Account, requestedModel ...string) { body, _ := s.readUpstreamErrorBody(resp) statusCode := resp.StatusCode // OAuth/Setup Token 账号的 403:标记账号异常 if account.IsOAuth() && statusCode == 403 { - s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body) + if len(requestedModel) > 0 { + s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body, requestedModel[0]) + } else { + s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body) + } logger.LegacyPrintf("service.gateway", "Account %d: marked as error after %d retries for status %d", account.ID, maxRetryAttempts, statusCode) } else { // API Key 未配置错误码:不标记账号状态 @@ -553,14 +559,14 @@ func (s *GatewayService) handleFailoverSideEffects(ctx context.Context, resp *ht // handleRetryExhaustedError 处理重试耗尽后的错误 // OAuth 403:标记账号异常 // API Key 未配置错误码:仅返回错误,不标记账号 -func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *http.Response, c *gin.Context, account *Account) (*ForwardResult, error) { +func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, requestedModel ...string) (*ForwardResult, error) { MarkResponseCommitted(c) // Capture upstream error body before side-effects consume the stream. respBody, _ := s.readUpstreamErrorBody(resp) _ = resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(respBody)) - s.handleRetryExhaustedSideEffects(ctx, resp, account) + s.handleRetryExhaustedSideEffects(ctx, resp, account, requestedModel...) upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) diff --git a/backend/internal/service/openai_gateway_count_tokens.go b/backend/internal/service/openai_gateway_count_tokens.go index 9ece2430c8..85ba8e00dc 100644 --- a/backend/internal/service/openai_gateway_count_tokens.go +++ b/backend/internal/service/openai_gateway_count_tokens.go @@ -104,7 +104,7 @@ func (s *OpenAIGatewayService) ForwardResponsesInputTokens( return nil } if s.rateLimitService != nil { - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, prepared.UpstreamModel) } upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, "") @@ -346,7 +346,7 @@ func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic( } if s.rateLimitService != nil { - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, prepared.UpstreamModel) } if isOpenAIInputTokensUnsupported(resp.StatusCode, respBody) { diff --git a/backend/internal/service/temp_unsched_scope_test.go b/backend/internal/service/temp_unsched_scope_test.go index d956078418..71b3ccc5b4 100644 --- a/backend/internal/service/temp_unsched_scope_test.go +++ b/backend/internal/service/temp_unsched_scope_test.go @@ -3,14 +3,20 @@ package service import ( + "bytes" "context" "encoding/json" + "io" "net/http" + "net/http/httptest" + "strings" "testing" "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) type tempUnschedScopeRepoStub struct { @@ -321,3 +327,167 @@ func TestCheckErrorPolicy_401FollowsTempUnschedScope(t *testing.T) { }) } } + +// 计数入口把实际发往上游的模型交给错误处理:显式模型级 401 规则只冷却该模型,不把整号置为 error。 +func TestCountTokensEntries_ExplicitModelScope401OnlyCoolsModel(t *testing.T) { + gin.SetMode(gin.TestMode) + unauthorized := func() *http.Response { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"unauthorized"}}`)), + } + } + cfg := &config.Config{ + Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, + Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false, AllowInsecureHTTP: true}}, + } + newContext := func(path string, body []byte) (*gin.Context, *httptest.ResponseRecorder) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) + return c, rec + } + + t.Run("openai count_tokens as anthropic", func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + upstream := &httpUpstreamRecorder{resp: unauthorized()} + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream, rateLimitService: &RateLimitService{accountRepo: repo, cfg: cfg}} + account := newTempUnschedScope401Account(PlatformOpenAI, TempUnschedScopeModel) + account.Credentials["api_key"] = "sk-test" + account.Credentials["base_url"] = "http://upstream.example" + account.Concurrency = 1 + body := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello"}]}`) + c, _ := newContext("/v1/messages/count_tokens", body) + + require.Error(t, svc.ForwardCountTokensAsAnthropic(context.Background(), c, account, body, "gpt-5.4")) + + require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Zero(t, repo.errorCalls) + require.Zero(t, repo.tempCalls) + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "gpt-5.4", repo.modelRateLimitCalls[0].scope) + }) + + t.Run("openai responses input_tokens", func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + upstream := &httpUpstreamRecorder{resp: unauthorized()} + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream, rateLimitService: &RateLimitService{accountRepo: repo, cfg: cfg}} + account := newTempUnschedScope401Account(PlatformOpenAI, TempUnschedScopeModel) + account.Credentials["api_key"] = "sk-test" + account.Credentials["model_mapping"] = map[string]any{"gpt-5.4": "gpt-5.4-upstream"} + account.Concurrency = 1 + body := []byte(`{"model":"gpt-5.4","input":"hello"}`) + c, _ := newContext("/v1/responses/input_tokens", body) + + require.Error(t, svc.ForwardResponsesInputTokens(context.Background(), c, account, body)) + + require.NotNil(t, upstream.lastReq) + require.Equal(t, "gpt-5.4-upstream", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Zero(t, repo.errorCalls) + require.Zero(t, repo.tempCalls) + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "gpt-5.4-upstream", repo.modelRateLimitCalls[0].scope) + }) + + for _, passthrough := range []bool{false, true} { + name := "anthropic count_tokens" + if passthrough { + name = "anthropic count_tokens api key passthrough" + } + t.Run(name, func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + upstream := &anthropicHTTPUpstreamRecorder{resp: unauthorized()} + svc := &GatewayService{ + cfg: cfg, + responseHeaderFilter: compileResponseHeaderFilter(cfg), + httpUpstream: upstream, + rateLimitService: &RateLimitService{accountRepo: repo, cfg: cfg}, + } + account := newTempUnschedScope401Account(PlatformAnthropic, TempUnschedScopeModel) + account.Credentials["api_key"] = "upstream-anthropic-key" + account.Credentials["base_url"] = "https://api.anthropic.com" + account.Credentials["model_mapping"] = map[string]any{"claude-sonnet-4-5": "claude-sonnet-4-5-upstream"} + account.Concurrency = 1 + if passthrough { + account.Extra = map[string]any{"anthropic_passthrough": true} + } + body := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello"}]}`) + c, _ := newContext("/v1/messages/count_tokens", body) + parsed := &ParsedRequest{Body: NewRequestBodyRef(body), Model: "claude-sonnet-4-5"} + + require.Error(t, svc.ForwardCountTokens(context.Background(), c, account, parsed)) + + require.NotNil(t, upstream.lastReq) + require.Equal(t, "claude-sonnet-4-5-upstream", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Zero(t, repo.errorCalls) + require.Zero(t, repo.tempCalls) + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "claude-sonnet-4-5-upstream", repo.modelRateLimitCalls[0].scope) + }) + } +} + +// Bedrock 故障转移入口与重试耗尽入口把实际发往上游的模型交给错误处理:显式模型级规则只冷却该模型。 +func TestAnthropicFailoverEntries_ExplicitModelScopeOnlyCoolsModel(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}} + newResponse := func(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } + } + + t.Run("bedrock failover 401", func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + upstream := &anthropicHTTPUpstreamRecorder{resp: newResponse(http.StatusUnauthorized, `{"message":"unauthorized"}`)} + svc := &GatewayService{cfg: cfg, httpUpstream: upstream, rateLimitService: &RateLimitService{accountRepo: repo, cfg: cfg}} + account := newTempUnschedScope401Account(PlatformAnthropic, TempUnschedScopeModel) + account.Type = AccountTypeBedrock + account.Status = StatusActive + account.Schedulable = true + account.Credentials["auth_mode"] = "apikey" + account.Credentials["api_key"] = "bedrock-key" + body := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello"}]}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + parsed := &ParsedRequest{Body: NewRequestBodyRef(body), Model: "claude-sonnet-4-5"} + + _, err := svc.forwardBedrock(context.Background(), c, account, parsed, time.Now()) + + var failover *UpstreamFailoverError + require.ErrorAs(t, err, &failover) + require.NotNil(t, upstream.lastReq) + require.Contains(t, upstream.lastReq.URL.Path, "anthropic.claude-sonnet-4-5-", "上游收到 Bedrock 模型 ID") + require.Zero(t, repo.errorCalls) + require.Zero(t, repo.tempCalls) + require.Len(t, repo.modelRateLimitCalls, 1) + // 冷却键必须是调度器查找的键(账号映射后的模型名),而不是 Bedrock 模型 ID。 + scope := repo.modelRateLimitCalls[0].scope + require.Equal(t, "claude-sonnet-4-5", scope) + require.True(t, account.IsSchedulableForModelWithContext(context.Background(), "claude-sonnet-4-5")) + setAccountModelRateLimitSnapshot(account, scope, time.Now().Add(10*time.Minute), "temp_unschedulable", time.Now()) + require.False(t, account.IsSchedulableForModelWithContext(context.Background(), "claude-sonnet-4-5"), "调度器按同一个键排除该模型") + }) + + t.Run("oauth retry exhausted 403", func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &GatewayService{cfg: cfg, rateLimitService: &RateLimitService{accountRepo: repo, cfg: cfg}} + account := newTempUnschedScopeAccount(TempUnschedScopeModel, map[string]any{ + "error_code": float64(http.StatusForbidden), + "keywords": []any{"forbidden"}, + }) + account.Platform = PlatformAnthropic + account.Type = AccountTypeOAuth + + svc.handleRetryExhaustedSideEffects(context.Background(), newResponse(http.StatusForbidden, `{"error":{"message":"forbidden"}}`), account, "claude-sonnet-4-5") + + require.Zero(t, repo.errorCalls) + require.Zero(t, repo.tempCalls) + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].scope) + }) +}