mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #7340 from ye4241/fix/kimi-cn-quota-403-rate-limit-pause
fix(ratelimit): CN coding-plan 账号配额耗尽的 403 按限流暂停调度,而非永久禁用
This commit is contained in:
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// 国产供应商(kimi/zhipu/deepseek)的响应式冷却辅助。
|
||||
@@ -35,22 +37,65 @@ func isCNProviderConcurrencyLimit403(account *Account, upstreamMsg string) bool
|
||||
strings.TrimSpace(upstreamMsg) == kimiConcurrentRequestLimitMessage
|
||||
}
|
||||
|
||||
// cnQuotaExhausted403ErrorType 是 Kimi Coding Plan 配额窗口耗尽的 403 错误类型。
|
||||
// 与并发限制 403(见 isCNProviderConcurrencyLimit403)不同,这是 5h/weekly
|
||||
// 窗口耗尽的限流信号:窗口到期后自动恢复,绝不能落入通用 403 升级计数
|
||||
// (连续 3 次永久 SetError),必须按 429 口径冷却到真实窗口重置点。
|
||||
const cnQuotaExhausted403ErrorType = "access_terminated_error"
|
||||
|
||||
// cnQuotaExhaustedReasonPrefix 是配额耗尽 403 临时停调 reason 的稳定前缀。
|
||||
const cnQuotaExhaustedReasonPrefix = "cn_quota_exhausted"
|
||||
|
||||
// isCNProviderQuotaExhausted403 识别 CN 供应商配额窗口耗尽的 403。
|
||||
// 先做廉价的文案子串匹配(usage limit / quota will reset),未命中再解析
|
||||
// 响应体匹配结构化错误类型(Kimi 的 access_terminated_error),以兼容同族
|
||||
// 供应商的文案变体。与并发限制文案互斥,两类信号不会误判。
|
||||
// 仅限 Coding Plan 账号:滚动窗口快照(含重置时间)只有 Coding Plan 账号才有,
|
||||
// 非 Coding Plan 账号的同类 403 语义不明(可能是需要升级套餐的硬上限),
|
||||
// 应回退到通用 403 升级逻辑而非无限临时冷却。
|
||||
func isCNProviderQuotaExhausted403(account *Account, responseBody []byte, upstreamMsg string) bool {
|
||||
if account == nil || !account.IsCNProvider() || !account.IsCodingPlan() {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(upstreamMsg))
|
||||
if strings.Contains(msg, "usage limit") || strings.Contains(msg, "quota will reset") {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(gjson.GetBytes(responseBody, "error.type").String()), cnQuotaExhausted403ErrorType)
|
||||
}
|
||||
|
||||
// handleCNProviderQuotaExhausted403 把 CN 供应商配额窗口耗尽的 403 按 429
|
||||
// 同口径处理:有配额快照时冷却到最早的未来窗口重置点(SetRateLimited,
|
||||
// 窗口过期后调度自动恢复);快照缺失(额度探测尚未刷新)时兜底默认 403
|
||||
// 冷却时长的临时停调,避免过度停调到数天后的窗口。无论哪条路径都不写
|
||||
// 账号 error 状态。
|
||||
func (s *RateLimitService) handleCNProviderQuotaExhausted403(
|
||||
ctx context.Context,
|
||||
account *Account,
|
||||
upstreamMsg string,
|
||||
) {
|
||||
if s.cooldownCNProviderToQuotaSnapshotReset(ctx, account, cnQuotaExhaustedReasonPrefix, "cn_quota_exhausted_rate_limited") != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// upstreamMsg 由调用方(HandleUpstreamError)从同一响应体提取,此处直接复用;
|
||||
// 为空时仅存前缀,保证 reason 稳定可检索。
|
||||
reason := cnQuotaExhaustedReasonPrefix
|
||||
if msg := strings.TrimSpace(upstreamMsg); msg != "" {
|
||||
reason += ": " + msg
|
||||
}
|
||||
until := time.Now().Add(time.Duration(openAI403CooldownMinutesDefault) * time.Minute)
|
||||
s.setCNProviderTempUnschedulable(ctx, account, until, cnQuotaExhaustedReasonPrefix, reason, "cn_quota_exhausted_temp_unschedulable")
|
||||
}
|
||||
|
||||
func (s *RateLimitService) handleCNProviderConcurrencyLimit403(
|
||||
ctx context.Context,
|
||||
account *Account,
|
||||
) {
|
||||
until := time.Now().Add(time.Duration(openAI403CooldownMinutesDefault) * time.Minute)
|
||||
reason := cnConcurrencyLimitReasonPrefix + ": " + kimiConcurrentRequestLimitMessage
|
||||
s.notifyAccountSchedulingBlocked(account, until, cnConcurrencyLimitReasonPrefix)
|
||||
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); err != nil {
|
||||
slog.Warn("cn_concurrency_limit_set_temp_unschedulable_failed", "account_id", account.ID, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("cn_provider_concurrency_limited",
|
||||
"account_id", account.ID,
|
||||
"platform", account.Platform,
|
||||
"until", until.UTC(),
|
||||
)
|
||||
s.setCNProviderTempUnschedulable(ctx, account, until,
|
||||
cnConcurrencyLimitReasonPrefix, cnConcurrencyLimitReasonPrefix+": "+kimiConcurrentRequestLimitMessage,
|
||||
"cn_provider_concurrency_limited")
|
||||
}
|
||||
|
||||
// cnBalanceLowReason 构造余额不足临时停调的 reason(带稳定前缀)。
|
||||
@@ -151,6 +196,45 @@ func cnProviderQuotaSnapshotReset(account *Account, now time.Time) *time.Time {
|
||||
return earliest
|
||||
}
|
||||
|
||||
// cooldownCNProviderToQuotaSnapshotReset 把账号冷却到配额快照中最早的未来窗口
|
||||
// 重置点(SetRateLimited,窗口过期后调度自动恢复),供 429(OpenCodeGo /
|
||||
// Coding Plan)与配额耗尽 403 共用。仅在冷却成功持久化后返回非 nil;
|
||||
// 无快照或写库失败时返回 nil,由调用方落入各自的兜底逻辑(避免写库失败
|
||||
// 时账号完全失去持久冷却)。
|
||||
func (s *RateLimitService) cooldownCNProviderToQuotaSnapshotReset(ctx context.Context, account *Account, reason, logEvent string) *time.Time {
|
||||
until := cnProviderQuotaSnapshotReset(account, time.Now())
|
||||
if until == nil {
|
||||
return nil
|
||||
}
|
||||
s.notifyAccountSchedulingBlocked(account, *until, reason)
|
||||
if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil {
|
||||
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
|
||||
return nil
|
||||
}
|
||||
slog.Info(logEvent,
|
||||
"account_id", account.ID,
|
||||
"platform", account.Platform,
|
||||
"reset_at", until.UTC(),
|
||||
)
|
||||
return until
|
||||
}
|
||||
|
||||
// setCNProviderTempUnschedulable 写临时停调(含调度层通知与日志),供 CN 403
|
||||
// 各分支(并发限制、配额耗尽兜底)共用。告警 key 由 notifyReason 派生,保持
|
||||
// 各分支既有日志 key 稳定(<prefix>_set_temp_unschedulable_failed)。
|
||||
func (s *RateLimitService) setCNProviderTempUnschedulable(ctx context.Context, account *Account, until time.Time, notifyReason, storeReason, logEvent string) {
|
||||
s.notifyAccountSchedulingBlocked(account, until, notifyReason)
|
||||
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, storeReason); err != nil {
|
||||
slog.Warn(notifyReason+"_set_temp_unschedulable_failed", "account_id", account.ID, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info(logEvent,
|
||||
"account_id", account.ID,
|
||||
"platform", account.Platform,
|
||||
"until", until.UTC(),
|
||||
)
|
||||
}
|
||||
|
||||
// applyCNProviderReactive429 处理国产供应商的 429 响应。
|
||||
// 返回 true 表示已处理(调用方应 return),false 表示未命中、继续走默认 429 逻辑。
|
||||
func (s *RateLimitService) applyCNProviderReactive429(
|
||||
@@ -160,17 +244,7 @@ func (s *RateLimitService) applyCNProviderReactive429(
|
||||
responseBody []byte,
|
||||
) bool {
|
||||
if account.IsOpenCodeGo() {
|
||||
if until := cnProviderQuotaSnapshotReset(account, time.Now()); until != nil {
|
||||
s.notifyAccountSchedulingBlocked(account, *until, "429")
|
||||
if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil {
|
||||
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
|
||||
return true
|
||||
}
|
||||
slog.Info("opencode_go_rate_limited",
|
||||
"account_id", account.ID,
|
||||
"platform", account.Platform,
|
||||
"reset_at", *until,
|
||||
)
|
||||
if s.cooldownCNProviderToQuotaSnapshotReset(ctx, account, "429", "opencode_go_rate_limited") != nil {
|
||||
return true
|
||||
}
|
||||
if resetAt := parseOpenAIRateLimitResetTime(responseBody); resetAt != nil {
|
||||
@@ -199,20 +273,9 @@ func (s *RateLimitService) applyCNProviderReactive429(
|
||||
}
|
||||
// 2) Coding Plan 窗口耗尽:冷却到快照中最早的窗口重置点(见
|
||||
// cnProviderQuotaSnapshotReset:429 多由 5h 窗口触发,取较早点避免过度停调)。
|
||||
if account.IsCodingPlan() {
|
||||
if until := cnProviderQuotaSnapshotReset(account, time.Now()); until != nil {
|
||||
s.notifyAccountSchedulingBlocked(account, *until, "429")
|
||||
if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil {
|
||||
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
|
||||
return true
|
||||
}
|
||||
slog.Info("cn_coding_plan_rate_limited",
|
||||
"account_id", account.ID,
|
||||
"platform", account.Platform,
|
||||
"reset_at", *until,
|
||||
)
|
||||
return true
|
||||
}
|
||||
if account.IsCodingPlan() &&
|
||||
s.cooldownCNProviderToQuotaSnapshotReset(ctx, account, "429", "cn_coding_plan_rate_limited") != nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -992,6 +992,14 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst
|
||||
s.handleCNProviderConcurrencyLimit403(ctx, account)
|
||||
return true
|
||||
}
|
||||
// Kimi 等 CN 供应商把 Coding Plan 配额窗口耗尽打成 403
|
||||
// (error.type=access_terminated_error),这是窗口到期后自动恢复的限流
|
||||
// 信号而非封禁:按 429 口径冷却到真实窗口重置点,避免落入下方通用 403
|
||||
// 升级计数后被永久 SetError。
|
||||
if isCNProviderQuotaExhausted403(account, responseBody, upstreamMsg) {
|
||||
s.handleCNProviderQuotaExhausted403(ctx, account, upstreamMsg)
|
||||
return true
|
||||
}
|
||||
// 国产供应商与 openai 同口径:HTML 403(CDN/代理拦截页)不构成账号失效证据,
|
||||
// 且 403 在 failover 状态集里会被逐账号重放——直接 SetError 会让一个坏请求/
|
||||
// 一层坏代理连环永久禁用整组账号。走 HTML 豁免 + N 次累计 + 临时冷却。
|
||||
|
||||
@@ -17,6 +17,7 @@ type rateLimitAccountRepoStub struct {
|
||||
mockAccountRepoForGemini
|
||||
setErrorCalls int
|
||||
tempCalls int
|
||||
rateLimitedCalls int
|
||||
updateCredentialsCalls int
|
||||
updateExtraCalls int
|
||||
lastCredentials map[string]any
|
||||
@@ -25,6 +26,8 @@ type rateLimitAccountRepoStub struct {
|
||||
lastTempReason string
|
||||
lastErrorID int64
|
||||
lastTempID int64
|
||||
lastRateLimitedID int64
|
||||
lastRateLimitedAt time.Time
|
||||
tempErr error
|
||||
}
|
||||
|
||||
@@ -42,6 +45,13 @@ func (r *rateLimitAccountRepoStub) SetTempUnschedulable(ctx context.Context, id
|
||||
return r.tempErr
|
||||
}
|
||||
|
||||
func (r *rateLimitAccountRepoStub) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error {
|
||||
r.rateLimitedCalls++
|
||||
r.lastRateLimitedID = id
|
||||
r.lastRateLimitedAt = resetAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rateLimitAccountRepoStub) UpdateCredentials(ctx context.Context, id int64, credentials map[string]any) error {
|
||||
r.updateCredentialsCalls++
|
||||
r.lastCredentials = shallowCopyMap(credentials)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newCNQuotaExhaustedTestAccount 构造一个 Kimi Coding Plan 账号:
|
||||
// account_mode=coding 使 IsCodingPlan() 为真,Extra 带 5h/weekly 窗口快照。
|
||||
func newCNQuotaExhaustedTestAccount(resetIn time.Duration) *Account {
|
||||
extra := map[string]any{}
|
||||
if resetIn > 0 {
|
||||
extra[cnExtraKey(PlatformKimi, cnExtraSuffix5hUsed)] = 100
|
||||
extra[cnExtraKey(PlatformKimi, cnExtraSuffix5hReset)] = time.Now().Add(resetIn).UTC().Format(time.RFC3339)
|
||||
extra[cnExtraKey(PlatformKimi, cnExtraSuffixWeeklyReset)] = time.Now().Add(7 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
}
|
||||
return &Account{
|
||||
ID: 401,
|
||||
Platform: PlatformKimi,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"account_mode": AccountModeCoding},
|
||||
Extra: extra,
|
||||
}
|
||||
}
|
||||
|
||||
const kimiWeeklyQuotaExhaustedBody = `{"error":{"message":"You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window ends. To continue now, purchase extra usage or upgrade your plan: https://www.kimi.com/membership/subscription?tab=quota","type":"access_terminated_error"}}`
|
||||
|
||||
// runKimi403 以 403 + 给定响应体驱动 HandleUpstreamError,返回 shouldDisable。
|
||||
func runKimi403(service *RateLimitService, account *Account, body string) bool {
|
||||
return service.HandleUpstreamError(
|
||||
context.Background(),
|
||||
account,
|
||||
http.StatusForbidden,
|
||||
http.Header{},
|
||||
[]byte(body),
|
||||
)
|
||||
}
|
||||
|
||||
func TestHandleUpstreamError_KimiQuotaExhausted403RateLimitedToWindowReset(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
blocker := &runtimeBlockRecorder{}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
service.SetAccountRuntimeBlocker(blocker)
|
||||
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
|
||||
|
||||
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 0, repo.setErrorCalls, "quota exhaustion must never SetError")
|
||||
require.Equal(t, 0, repo.tempCalls)
|
||||
require.Equal(t, 1, repo.rateLimitedCalls)
|
||||
require.Equal(t, account.ID, repo.lastRateLimitedID)
|
||||
require.True(t, repo.lastRateLimitedAt.After(time.Now()), "cooldown should target the future window reset")
|
||||
require.True(t, repo.lastRateLimitedAt.Before(time.Now().Add(3*time.Hour)), "should cool down to the nearer 5h reset, not weekly")
|
||||
require.Len(t, blocker.accounts, 1)
|
||||
require.Equal(t, cnQuotaExhaustedReasonPrefix, blocker.reasons[0])
|
||||
}
|
||||
|
||||
func TestHandleUpstreamError_KimiQuotaExhausted403WithoutSnapshotFallsBackToTemp(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
account := newCNQuotaExhaustedTestAccount(0)
|
||||
|
||||
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 0, repo.setErrorCalls)
|
||||
require.Equal(t, 0, repo.rateLimitedCalls)
|
||||
require.Equal(t, 1, repo.tempCalls)
|
||||
require.Contains(t, repo.lastTempReason, cnQuotaExhaustedReasonPrefix)
|
||||
require.Greater(t, len(repo.lastTempReason), len(cnQuotaExhaustedReasonPrefix))
|
||||
}
|
||||
|
||||
func TestHandleUpstreamError_KimiQuotaExhausted403MatchedByMessageOnly(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
|
||||
// 无 error.type 字段,仅靠文案兜底匹配。
|
||||
body := `{"error":{"message":"You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window ends."}}`
|
||||
|
||||
shouldDisable := runKimi403(service, account, body)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 0, repo.setErrorCalls)
|
||||
require.Equal(t, 1, repo.rateLimitedCalls)
|
||||
}
|
||||
|
||||
func TestHandleUpstreamError_KimiConcurrencyLimit403StillUsesConcurrencyPath(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
|
||||
body := fmt.Sprintf(`{"error":{"message":%q,"type":"access_terminated_error"}}`, kimiConcurrentRequestLimitMessage)
|
||||
|
||||
shouldDisable := runKimi403(service, account, body)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 0, repo.setErrorCalls)
|
||||
require.Equal(t, 0, repo.rateLimitedCalls, "concurrency-limit message must keep its dedicated path")
|
||||
require.Equal(t, 1, repo.tempCalls)
|
||||
require.Contains(t, repo.lastTempReason, cnConcurrencyLimitReasonPrefix)
|
||||
}
|
||||
|
||||
func TestHandleUpstreamError_KimiGeneric403StillEscalates(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
counter := &openAI403CounterCacheStub{counts: []int64{3}}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
service.SetOpenAI403CounterCache(counter)
|
||||
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
|
||||
|
||||
shouldDisable := runKimi403(service, account, `{"error":{"message":"policy rejected this request"}}`)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 1, repo.setErrorCalls, "non-quota 403 keeps the generic escalation path")
|
||||
require.Equal(t, 0, repo.rateLimitedCalls)
|
||||
}
|
||||
|
||||
func TestHandleUpstreamError_KimiNonCodingPlanQuotaMessageUsesGeneric403(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
counter := &openAI403CounterCacheStub{counts: []int64{3}}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
service.SetOpenAI403CounterCache(counter)
|
||||
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
|
||||
account.Credentials = map[string]any{"account_mode": AccountModePayG}
|
||||
|
||||
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 1, repo.setErrorCalls, "non-coding-plan account must keep the generic 403 escalation")
|
||||
require.Equal(t, 0, repo.rateLimitedCalls)
|
||||
require.Equal(t, 0, repo.tempCalls)
|
||||
}
|
||||
|
||||
func TestHandleUpstreamError_NonCNAccessTerminated403KeepsGenericPath(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
counter := &openAI403CounterCacheStub{counts: []int64{1}}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
service.SetOpenAI403CounterCache(counter)
|
||||
account := &Account{
|
||||
ID: 402,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
}
|
||||
|
||||
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 0, repo.rateLimitedCalls, "non-CN platform must not enter the CN quota path")
|
||||
require.Equal(t, 0, repo.setErrorCalls)
|
||||
require.Equal(t, 1, repo.tempCalls)
|
||||
require.Contains(t, repo.lastTempReason, "(1/3)")
|
||||
}
|
||||
|
||||
func TestIsCNProviderQuotaExhausted403_Classification(t *testing.T) {
|
||||
kimi := newCNQuotaExhaustedTestAccount(0)
|
||||
require.True(t, isCNProviderQuotaExhausted403(kimi, []byte(kimiWeeklyQuotaExhaustedBody), ""))
|
||||
require.True(t, isCNProviderQuotaExhausted403(kimi, []byte(`{"error":{"message":"You've reached your weekly (7-day) usage limit."}}`), "You've reached your weekly (7-day) usage limit."))
|
||||
require.False(t, isCNProviderQuotaExhausted403(kimi, []byte(`{"error":{"message":"policy rejected"}}`), "policy rejected"))
|
||||
require.False(t, isCNProviderQuotaExhausted403(kimi, nil, ""))
|
||||
|
||||
openai := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
require.False(t, isCNProviderQuotaExhausted403(openai, []byte(kimiWeeklyQuotaExhaustedBody), ""))
|
||||
require.False(t, isCNProviderQuotaExhausted403(nil, []byte(kimiWeeklyQuotaExhaustedBody), ""))
|
||||
}
|
||||
Reference in New Issue
Block a user