Files
sub2api/backend/internal/service/cline_test.go
T

649 lines
31 KiB
Go

//go:build unit
package service
import (
"context"
"errors"
"net/http"
"strings"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// clineTestAccount 只带 api_key:端点与测试模型全部来自 provider profile。
func clineTestAccount(id int64) *Account {
return &Account{
ID: id,
Name: "cline",
Platform: PlatformCline,
Type: AccountTypeAPIKey,
Status: StatusActive,
Schedulable: true,
Concurrency: 1,
Credentials: map[string]any{"api_key": "cline_test_key", "api_protocol": APIProtocolAdaptive},
}
}
// withClinePassSnapshot 模拟用量探测写入的 ClinePass 窗口快照。
func withClinePassSnapshot(account *Account, extra map[string]any) *Account {
account.Extra = map[string]any{"cline_5h_used_percent": 10.0, "cline_weekly_used_percent": 20.0, "cline_monthly_used_percent": 30.0}
for key, value := range extra {
account.Extra[key] = value
}
return account
}
func TestClineProfileSingleModeChatCompletionsOnly(t *testing.T) {
profile := LookupProviderProfile(PlatformCline)
require.NotNil(t, profile)
require.Len(t, profile.Modes, 1)
account := clineTestAccount(1)
require.True(t, account.IsMultiProtocolAPIKey())
require.True(t, account.RoutesProtocolByInbound())
require.False(t, account.routesByModel())
require.Equal(t, APIProtocolAdaptive, account.GetAPIProtocol())
require.Equal(t, DefaultClineBaseURL, account.GetOpenAIBaseURL())
require.False(t, account.SupportsNativeCNResponses())
require.False(t, account.providerSupportsProtocol(APIProtocolAnthropic))
for _, inbound := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} {
require.Equal(t, APIProtocolChatCompletions, resolveUpstreamProtocol(account, inbound, "anthropic/claude-sonnet-4-6", nil), inbound)
}
require.False(t, account.clinePassSubscribed())
require.Equal(t, DefaultClineTestModel, account.providerDefaultTestModel())
subscribed := withClinePassSnapshot(clineTestAccount(2), nil)
require.True(t, subscribed.clinePassSubscribed())
require.Equal(t, DefaultClinePassTestModel, subscribed.providerDefaultTestModel())
// 取消订阅后探测把窗口写成 nil。
cancelled := withClinePassSnapshot(clineTestAccount(3), map[string]any{
"cline_5h_used_percent": nil, "cline_weekly_used_percent": nil, "cline_monthly_used_percent": nil,
})
require.False(t, cancelled.clinePassSubscribed())
}
// 经真实入口:三种入站都转换 / 直通到 Chat Completions,模型名带厂商前缀原样发往上游。
func TestClineGatewayForwardsToChatCompletions(t *testing.T) {
for _, ingress := range routingMatrixIngresses() {
upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
body := routingMatrixCase{ingress: ingress, model: "anthropic/claude-sonnet-4-6"}.body()
_ = ingress.forward(svc, adaptiveProtocolTestContext(ingress.path, body), clineTestAccount(3), body)
require.NotEmpty(t, upstream.requests, ingress.name)
require.Equal(t, "https://api.cline.bot/api/v1/chat/completions", upstream.requests[len(upstream.requests)-1].URL.String(), ingress.name)
require.Equal(t, "Bearer cline_test_key", upstream.requests[len(upstream.requests)-1].Header.Get("Authorization"), ingress.name)
require.True(t, gjson.GetBytes(upstream.lastBody, "messages").Exists(), ingress.name)
require.Equal(t, "anthropic/claude-sonnet-4-6", gjson.GetBytes(upstream.lastBody, "model").String(), ingress.name)
}
}
// 连接测试未指定模型时:订阅了 ClinePass 用订阅模型(不消耗积分),否则用按量模型;
// ClinePass 钱包冷却中也用按量模型;自适应与固定 Chat Completions 一致。
func TestAccountTestService_ClineDefaultTestModel(t *testing.T) {
cases := []struct {
subscribed bool
passCooling bool
apiProtocol string
want string
}{
{false, false, "", DefaultClineTestModel},
{true, false, "", DefaultClinePassTestModel},
{false, false, APIProtocolChatCompletions, DefaultClineTestModel},
{true, false, APIProtocolChatCompletions, DefaultClinePassTestModel},
{true, true, "", DefaultClineTestModel},
}
for i, tc := range cases {
account := clineTestAccount(int64(600 + i))
if tc.subscribed {
withClinePassSnapshot(account, nil)
}
if tc.passCooling {
now := time.Now()
setAccountModelRateLimitSnapshot(account, clinePassRateLimitKey, now.Add(time.Hour), clinePassLimitReason, now)
}
if tc.apiProtocol != "" {
account.Credentials["api_protocol"] = tc.apiProtocol
}
svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNChatTestResponse())
c, recorder := newTestContext()
err := svc.TestAccountConnection(c, account.ID, "", "hi", AccountTestModeDefault)
require.NoError(t, err, tc)
require.Len(t, upstream.requests, 1, tc)
require.Equal(t, "https://api.cline.bot/api/v1/chat/completions", upstream.requests[0].URL.String(), tc)
require.Equal(t, tc.want, gjson.GetBytes(upstream.lastBody, "model").String(), tc)
require.Contains(t, recorder.Body.String(), `"type":"test_complete"`, tc)
}
}
// 响应体样例取自 Cline 客户端的判定文案与官方错误文档。
const (
clineInsufficientCreditsBody = `{"error":{"code":"insufficient_credits","message":"Insufficient credits","details":{"current_balance":0}}}`
clineSpendLimitBody = `{"error":{"code":"SPEND_LIMIT_EXCEEDED","message":"Organization spend limit exceeded"}}`
clineInferenceCapBody = `{"error":{"code":"INFERENCE_CAP_ERROR","message":"Inference cap reached"}}`
clinePass5hBody = `{"error":{"code":429,"message":"You have reached your 5-hour ClinePass limit. Please try again later."}}`
clinePassWeeklyBody = `{"error":{"code":429,"message":"You have reached your weekly ClinePass limit. Please try again later."}}`
clinePassMonthlyBody = `{"error":{"code":429,"message":"You have reached your monthly ClinePass limit. Please try again later."}}`
clineNotSubscribedBody = `{"error":{"code":403,"message":"The user is not subscribed to required model plan"}}`
clineOrgPassBody = `{"error":{"code":403,"message":"Organization accounts cannot use individual model inference subscriptions"}}`
clineFreeModelLimitBody = `{"error":{"code":429,"message":"Free limit reached on model cline-free/kimi-k3. Try again in 3h 20m"}}`
clineRateLimitBody = `{"error":{"code":429,"message":"Rate limit exceeded"}}`
)
func TestClassifyClineError(t *testing.T) {
cases := map[string]clineErrorKind{
clineInsufficientCreditsBody: clineCreditsExhausted,
clineSpendLimitBody: clineSpendLimit,
clineInferenceCapBody: clineInferenceCap,
clinePass5hBody: clinePassLimit,
clinePassWeeklyBody: clinePassLimit,
clineNotSubscribedBody: clinePassUnavailable,
clineOrgPassBody: clinePassUnavailable,
clineFreeModelLimitBody: clineFreeModelLimit,
clineRateLimitBody: clineErrorNone,
`{"error":{"message":"You have reached your limit. Please try again later."}}`: clineErrorNone,
}
for body, want := range cases {
kind, _ := parseClineError([]byte(body))
require.Equal(t, want, kind, body)
}
require.Equal(t, "5h", clinePassWindow([]byte(clinePass5hBody)))
require.Equal(t, "weekly", clinePassWindow([]byte(clinePassWeeklyBody)))
require.Equal(t, "monthly", clinePassWindow([]byte(clinePassMonthlyBody)))
require.Equal(t, 3*time.Hour+20*time.Minute, clineFreeModelResetAfter([]byte(clineFreeModelLimitBody)))
require.Zero(t, clineFreeModelResetAfter([]byte(`Free limit reached on model x. Try again in 3 weeks`)))
}
// ClinePass 超限冷却到快照中该窗口的重置时间;没有可用快照时按窗口估计。
func TestClinePassLimitCooldown(t *testing.T) {
now := time.Now()
require.Equal(t, clineLimitRecheck, clinePassLimitCooldown(clineTestAccount(1), "5h", now))
require.Equal(t, clinePassWeeklyRecheck, clinePassLimitCooldown(clineTestAccount(1), "weekly", now))
require.Equal(t, clinePassMonthlyRecheck, clinePassLimitCooldown(clineTestAccount(1), "monthly", now))
account := withClinePassSnapshot(clineTestAccount(2), map[string]any{
"cline_weekly_reset_at": now.Add(50 * time.Hour).UTC().Format(time.RFC3339),
"cline_5h_reset_at": now.Add(-time.Minute).UTC().Format(time.RFC3339),
})
require.InDelta(t, (50 * time.Hour).Seconds(), clinePassLimitCooldown(account, "weekly", now).Seconds(), 1)
require.Equal(t, clineLimitRecheck, clinePassLimitCooldown(account, "5h", now), "past reset falls back to the estimate")
}
// cline-pass/* 与其余付费模型各自落在钱包冷却键上,免费模型不属于任何钱包;调度按
// 请求模型检查所属钱包。
func TestClineWalletRateLimitKeys(t *testing.T) {
require.Equal(t, clinePassRateLimitKey, clineWalletRateLimitKey("cline-pass/glm-5.3"))
require.Equal(t, clinePassRateLimitKey, clineWalletRateLimitKey(" Cline-Pass/GLM-5.3 "))
require.Equal(t, clineCreditsRateLimitKey, clineWalletRateLimitKey("deepseek/deepseek-v4-flash"))
require.Empty(t, clineWalletRateLimitKey("cline-free/kimi-k3"))
require.Empty(t, clineWalletRateLimitKey(""))
account := clineTestAccount(1)
setAccountModelRateLimitSnapshot(account, clinePassRateLimitKey, time.Now().Add(time.Hour), clinePassLimitReason, time.Now())
ctx := context.Background()
require.True(t, account.isModelRateLimitedWithContext(ctx, "cline-pass/glm-5.3"))
require.False(t, account.isModelRateLimitedWithContext(ctx, "deepseek/deepseek-v4-flash"))
require.False(t, account.isModelRateLimitedWithContext(ctx, "cline-free/kimi-k3"))
setAccountModelRateLimitSnapshot(account, clineCreditsRateLimitKey, time.Now().Add(time.Hour), clineCreditsReason, time.Now())
require.True(t, account.isModelRateLimitedWithContext(ctx, "deepseek/deepseek-v4-flash"))
require.False(t, account.isModelRateLimitedWithContext(ctx, "cline-free/kimi-k3"))
require.Equal(t, clineCreditsReason, account.modelRateLimitReason(clineCreditsRateLimitKey))
// 其他平台不受 Cline 钱包键影响。
other := &Account{Platform: PlatformDeepseek, Extra: account.Extra}
require.False(t, other.isModelRateLimitedWithContext(ctx, "cline-pass/glm-5.3"))
}
// 已识别的错误只冷却请求模型所属的钱包(或免费模型本身),不停整个账号、不置 error。
func TestHandleUpstreamError_ClineCoolsDownWallet(t *testing.T) {
paid := "deepseek/deepseek-v4-flash"
pass := "cline-pass/glm-5.3"
now := time.Now()
snapshot := withClinePassSnapshot(clineTestAccount(1), map[string]any{
"cline_5h_reset_at": now.Add(2 * time.Hour).UTC().Format(time.RFC3339),
})
cases := []struct {
name string
account *Account
model string
status int
body string
scope string
wait time.Duration
reason string
}{
{"pass weekly", clineTestAccount(10), pass, http.StatusTooManyRequests, clinePassWeeklyBody, clinePassRateLimitKey, clinePassWeeklyRecheck, clinePassLimitReason},
{"pass 5h from snapshot", snapshot, pass, http.StatusTooManyRequests, clinePass5hBody, clinePassRateLimitKey, 2 * time.Hour, clinePassLimitReason},
{"not subscribed", clineTestAccount(11), pass, http.StatusForbidden, clineNotSubscribedBody, clinePassRateLimitKey, clineLimitRecheck, clinePassUnavailableReason},
{"credits", clineTestAccount(12), paid, http.StatusPaymentRequired, clineInsufficientCreditsBody, clineCreditsRateLimitKey, cnBalanceCheckCooldown(nil), clineCreditsReason},
{"unrecognized 402", clineTestAccount(13), paid, http.StatusPaymentRequired, `{"error":{"message":"Payment required"}}`, clineCreditsRateLimitKey, cnBalanceCheckCooldown(nil), clineCreditsReason},
{"spend limit", clineTestAccount(14), paid, http.StatusTooManyRequests, clineSpendLimitBody, clineCreditsRateLimitKey, clineLimitRecheck, clineSpendLimitReason},
{"inference cap on paid model", clineTestAccount(15), paid, http.StatusTooManyRequests, clineInferenceCapBody, clineCreditsRateLimitKey, clineLimitRecheck, clineInferenceCapReason},
{"inference cap on pass model", clineTestAccount(16), pass, http.StatusTooManyRequests, clineInferenceCapBody, clinePassRateLimitKey, clineLimitRecheck, clineInferenceCapReason},
{"unknown model uses the error's wallet", clineTestAccount(17), "", http.StatusForbidden, clineOrgPassBody, clinePassRateLimitKey, clineLimitRecheck, clinePassUnavailableReason},
{"free model", clineTestAccount(18), "cline-free/kimi-k3", http.StatusTooManyRequests, clineFreeModelLimitBody, "cline-free/kimi-k3", 3*time.Hour + 20*time.Minute, clineFreeModelLimitReason},
}
for _, tc := range cases {
ctx := context.Background()
if tc.model != "" {
ctx = withTempUnschedulableModel(ctx, []string{tc.model})
}
repo := &commandCodeRateLimitRepo{}
shouldDisable := NewRateLimitService(repo, nil, &config.Config{}, nil, nil).HandleUpstreamError(ctx, tc.account,
tc.status, http.Header{}, []byte(tc.body))
require.False(t, shouldDisable, tc.name)
require.Equal(t, 0, repo.tempCalls, tc.name)
require.Equal(t, 0, repo.setErrorCalls, tc.name)
require.Equal(t, 0, repo.rateLimitedCalls, tc.name)
require.Len(t, repo.modelLimits, 1, tc.name)
require.WithinDuration(t, time.Now().Add(tc.wait), repo.modelLimits[tc.scope], time.Minute, tc.name)
require.True(t, strings.HasPrefix(repo.reasons[tc.scope], tc.reason), tc.name+": "+repo.reasons[tc.scope])
require.True(t, tc.account.isRateLimitActiveForKey(tc.scope), tc.name)
}
// 普通频率限制交回默认 429 逻辑。
plain := &commandCodeRateLimitRepo{}
NewRateLimitService(plain, nil, &config.Config{}, nil, nil).HandleUpstreamError(withTempUnschedulableModel(context.Background(), []string{paid}),
clineTestAccount(30), http.StatusTooManyRequests, http.Header{}, []byte(clineRateLimitBody))
require.Empty(t, plain.modelLimits)
require.Equal(t, 0, plain.tempCalls)
}
// clineModelLimitFailRepo 让钱包冷却写入失败。
type clineModelLimitFailRepo struct {
commandCodeRateLimitRepo
}
func (r *clineModelLimitFailRepo) SetModelRateLimit(context.Context, int64, string, time.Time, ...string) error {
return errors.New("db unavailable")
}
// 积分冷却写入失败时 402 也不落入默认分支永久置 error:探测只覆盖激活账号,置 error 后
// 充值也无法自动恢复。
func TestHandleUpstreamError_Cline402KeepsAccountWhenCooldownWriteFails(t *testing.T) {
ctx := withTempUnschedulableModel(context.Background(), []string{"deepseek/deepseek-v4-flash"})
for _, body := range []string{clineInsufficientCreditsBody, `{"error":{"message":"Payment required"}}`} {
repo := &clineModelLimitFailRepo{}
shouldDisable := NewRateLimitService(repo, nil, &config.Config{}, nil, nil).HandleUpstreamError(ctx, clineTestAccount(19),
http.StatusPaymentRequired, http.Header{}, []byte(body))
require.False(t, shouldDisable, body)
require.Equal(t, 0, repo.setErrorCalls, body)
require.Equal(t, 0, repo.tempCalls, body)
}
}
// clineAccountRepo 为余额 / 用量探测与周期检测提供 Cline 账号,并记录写入。
type clineAccountRepo struct {
AccountRepository
accounts []Account
mu sync.Mutex
extraWrites map[int64][]map[string]any
modelLimits map[int64]map[string]time.Time
reasons map[int64]map[string]string
paused []int64
}
func (r *clineAccountRepo) ListByPlatform(_ context.Context, platform string) ([]Account, error) {
if platform == PlatformCline {
return r.accounts, nil
}
return nil, nil
}
func (r *clineAccountRepo) GetByID(_ context.Context, id int64) (*Account, error) {
for i := range r.accounts {
if r.accounts[i].ID == id {
return &r.accounts[i], nil
}
}
return nil, errors.New("not found")
}
func (r *clineAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.extraWrites == nil {
r.extraWrites = map[int64][]map[string]any{}
}
r.extraWrites[id] = append(r.extraWrites[id], updates)
return nil
}
func (r *clineAccountRepo) SetModelRateLimit(_ context.Context, id int64, scope string, resetAt time.Time, reason ...string) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.modelLimits == nil {
r.modelLimits = map[int64]map[string]time.Time{}
r.reasons = map[int64]map[string]string{}
}
if r.modelLimits[id] == nil {
r.modelLimits[id] = map[string]time.Time{}
r.reasons[id] = map[string]string{}
}
r.modelLimits[id][scope] = resetAt
if len(reason) > 0 {
r.reasons[id][scope] = reason[0]
}
return nil
}
func (r *clineAccountRepo) SetTempUnschedulable(_ context.Context, id int64, _ time.Time, _ string) error {
r.mu.Lock()
defer r.mu.Unlock()
r.paused = append(r.paused, id)
return nil
}
const (
clineUsageLimitsPath = "/api/v1/users/me/plan/usage-limits"
clineUsageLimitsBody = `{"data":{"limits":[{"type":"five_hour","percentUsed":12.5,"resetsAt":"2099-01-01T05:00:00.123456Z"},{"type":"weekly","percentUsed":40},{"type":"monthly","percentUsed":0}]},"success":true}`
)
func newClineAccountUpstream(usageLimits commandCodeAlphaResponse, balance string) *commandCodeAlphaUpstream {
return &commandCodeAlphaUpstream{responses: map[string]commandCodeAlphaResponse{
clineUsageLimitsPath: usageLimits,
"/api/v1/users/me": {status: http.StatusOK, body: `{"success":true,"data":{"id":"u-1","email":"a@example.com"}}`},
"/api/v1/users/u-1/balance": {status: http.StatusOK, body: balance},
}}
}
// balance 是百万分之一积分(1 积分 = 1 美元),按 Cline 的展示精度保留 4 位小数。
func TestClineBalanceProbeConvertsMicrocredits(t *testing.T) {
for _, tc := range []struct {
balance string
want float64
}{
{`{"balance":12340000,"userId":"u-1"}`, 12.34},
{`{"success":true,"data":{"balance":"500000","userId":"u-1"}}`, 0.5},
{`{"balance":1234567,"userId":"u-1"}`, 1.2346},
} {
account := clineTestAccount(40)
upstream := newClineAccountUpstream(commandCodeAlphaResponse{}, tc.balance)
repo := &cnBalanceProbeRepo{account: account}
svc := NewCNProviderBalanceService(repo, nil, upstream, &config.Config{})
result, err := svc.QueryBalance(context.Background(), account.ID)
require.NoError(t, err)
require.True(t, result.Success)
require.True(t, result.Persisted)
require.InDelta(t, tc.want, result.Balance, 1e-9)
require.Equal(t, "USD", result.Currency)
require.Len(t, upstream.requests, 2)
require.Equal(t, "Bearer cline_test_key", upstream.requests[0].Header.Get("Authorization"))
require.InDelta(t, tc.want, repo.extraWrites[0]["cline_balance"], 1e-9)
}
}
func TestClineBalanceProbeFailures(t *testing.T) {
account := clineTestAccount(41)
upstream := &commandCodeAlphaUpstream{responses: map[string]commandCodeAlphaResponse{
"/api/v1/users/me": {status: http.StatusUnauthorized, body: `{"error":"Unauthorized"}`},
}}
repo := &cnBalanceProbeRepo{account: account}
result, err := NewCNProviderBalanceService(repo, nil, upstream, &config.Config{}).QueryBalance(context.Background(), account.ID)
require.NoError(t, err)
require.False(t, result.Success)
require.Equal(t, http.StatusUnauthorized, result.StatusCode)
require.Contains(t, result.Error, "HTTP 401")
require.Empty(t, repo.extraWrites)
envelope := &commandCodeAlphaUpstream{responses: map[string]commandCodeAlphaResponse{
"/api/v1/users/me": {status: http.StatusOK, body: `{"success":false,"error":"token expired"}`},
}}
result, err = NewCNProviderBalanceService(&cnBalanceProbeRepo{account: account}, nil, envelope, &config.Config{}).QueryBalance(context.Background(), account.ID)
require.NoError(t, err)
require.False(t, result.Success)
require.Contains(t, result.Error, "token expired")
}
// 余额与用量只查官方主机上的 API Key 账号:自定义中转的 Key 不发往官方账号接口。
func TestClineAccountAPIRequiresOfficialHost(t *testing.T) {
require.NoError(t, validatePayGAccount(clineTestAccount(50)))
require.NoError(t, validateCodingPlanAccount(clineTestAccount(50)))
require.Equal(t, PlatformCline, clineTestAccount(50).GetCodingPlanProvider())
relay := clineTestAccount(52)
relay.Credentials["base_url"] = "https://relay.example.com/v1"
relay.Credentials["api_protocol"] = APIProtocolChatCompletions
require.Error(t, validatePayGAccount(relay))
require.Error(t, validateCodingPlanAccount(relay))
require.Empty(t, relay.GetCodingPlanProvider())
}
func clineUsageTestConfig() *config.Config {
cfg := &config.Config{}
cfg.Gateway.CNProviders.BalanceThreshold = 0.5
return cfg
}
// 一次探测刷新三档窗口与积分余额;两个钱包都可用时不写冷却。
func TestClineUsageProbeWritesWindowsAndBalance(t *testing.T) {
account := clineTestAccount(60)
repo := &clineAccountRepo{accounts: []Account{*account}}
upstream := newClineAccountUpstream(commandCodeAlphaResponse{status: http.StatusOK, body: clineUsageLimitsBody}, `{"success":true,"data":{"balance":12340000}}`)
result, err := NewCNProviderQuotaService(repo, nil, upstream, clineUsageTestConfig()).QueryUsage(context.Background(), account.ID)
require.NoError(t, err)
require.True(t, result.Success)
require.True(t, result.CredentialValid)
require.True(t, result.Persisted)
require.Equal(t, "ClinePass", result.PlanLevel)
require.Equal(t, []CNQuotaTier{
{Window: "5h", UsedPercent: 12.5, ResetAt: "2099-01-01T05:00:00Z"},
{Window: "weekly", UsedPercent: 40},
{Window: "monthly", UsedPercent: 0},
}, result.Tiers)
require.NotNil(t, result.Balance)
require.InDelta(t, 12.34, result.Balance.Balance, 1e-9)
require.Equal(t, "Bearer cline_test_key", upstream.requests[0].Header.Get("Authorization"))
written := repo.extraWrites[60][0]
require.Equal(t, 12.5, written["cline_5h_used_percent"])
require.Equal(t, "2099-01-01T05:00:00Z", written["cline_5h_reset_at"])
require.Equal(t, 40.0, written["cline_weekly_used_percent"])
require.Contains(t, written, "cline_weekly_reset_at")
require.Nil(t, written["cline_weekly_reset_at"], "a window that has not started has no reset time")
require.InDelta(t, 12.34, written["cline_balance"], 1e-9)
require.Empty(t, repo.modelLimits)
}
// 窗口用满冷却 ClinePass 到重置时间;积分低于阈值冷却积分钱包;都不停整个账号。
func TestClineUsageProbeCoolsDownExhaustedWallets(t *testing.T) {
resetAt := time.Now().Add(72 * time.Hour).UTC().Truncate(time.Second)
body := `{"data":{"limits":[{"type":"five_hour","percentUsed":30},{"type":"weekly","percentUsed":100,"resetsAt":"` +
resetAt.Format(time.RFC3339) + `"}]},"success":true}`
account := clineTestAccount(61)
repo := &clineAccountRepo{accounts: []Account{*account}}
upstream := newClineAccountUpstream(commandCodeAlphaResponse{status: http.StatusOK, body: body}, `{"success":true,"data":{"balance":200000}}`)
_, err := NewCNProviderQuotaService(repo, nil, upstream, clineUsageTestConfig()).QueryUsage(context.Background(), account.ID)
require.NoError(t, err)
require.True(t, repo.modelLimits[61][clinePassRateLimitKey].Equal(resetAt))
require.Equal(t, clinePassLimitReason+": weekly window used up", repo.reasons[61][clinePassRateLimitKey])
require.WithinDuration(t, time.Now().Add(cnBalanceCheckCooldown(nil)), repo.modelLimits[61][clineCreditsRateLimitKey], time.Minute)
require.True(t, strings.HasPrefix(repo.reasons[61][clineCreditsRateLimitKey], clineCreditsLowReason), repo.reasons[61][clineCreditsRateLimitKey])
require.Empty(t, repo.paused)
}
// 没有订阅(404 或空列表):清掉旧窗口快照,ClinePass 模型不调度到该账号。
func TestClineUsageProbeWithoutSubscription(t *testing.T) {
for _, usage := range []commandCodeAlphaResponse{
{status: http.StatusNotFound, body: `{"success":false,"error":"no plan history found for user"}`},
{status: http.StatusOK, body: `{"success":true,"data":{"limits":[]}}`},
} {
account := withClinePassSnapshot(clineTestAccount(62), nil)
repo := &clineAccountRepo{accounts: []Account{*account}}
upstream := newClineAccountUpstream(usage, `{"success":true,"data":{"balance":5000000}}`)
result, err := NewCNProviderQuotaService(repo, nil, upstream, clineUsageTestConfig()).QueryUsage(context.Background(), account.ID)
require.NoError(t, err)
require.True(t, result.Success)
require.Empty(t, result.Tiers)
require.Empty(t, result.PlanLevel)
written := repo.extraWrites[62][0]
for _, key := range []string{"cline_5h_used_percent", "cline_weekly_used_percent", "cline_monthly_used_percent"} {
require.Contains(t, written, key)
require.Nil(t, written[key])
}
require.True(t, strings.HasPrefix(repo.reasons[62][clinePassRateLimitKey], clinePassUnavailableReason))
require.NotContains(t, repo.modelLimits[62], clineCreditsRateLimitKey)
}
}
// 数据显示钱包恢复可用时解除冷却;组织花费上限无从探测,不提前解除。
func TestClineUsageProbeClearsRecoveredWallets(t *testing.T) {
for _, tc := range []struct {
creditsReason string
cleared bool
}{
{clineCreditsReason + ": Insufficient credits", true},
{clineCreditsLowReason + ": balance 0.1 USD below threshold 0.50", true},
{clineSpendLimitReason + ": Organization spend limit exceeded", false},
} {
account := clineTestAccount(63)
now := time.Now()
setAccountModelRateLimitSnapshot(account, clinePassRateLimitKey, now.Add(6*time.Hour), clinePassLimitReason, now)
setAccountModelRateLimitSnapshot(account, clineCreditsRateLimitKey, now.Add(time.Hour), tc.creditsReason, now)
repo := &clineAccountRepo{accounts: []Account{*account}}
upstream := newClineAccountUpstream(commandCodeAlphaResponse{status: http.StatusOK, body: clineUsageLimitsBody}, `{"success":true,"data":{"balance":5000000}}`)
_, err := NewCNProviderQuotaService(repo, nil, upstream, clineUsageTestConfig()).QueryUsage(context.Background(), account.ID)
require.NoError(t, err)
require.False(t, repo.modelLimits[63][clinePassRateLimitKey].After(time.Now()), tc.creditsReason)
_, touched := repo.modelLimits[63][clineCreditsRateLimitKey]
require.Equal(t, tc.cleared, touched, tc.creditsReason)
if tc.cleared {
require.False(t, repo.modelLimits[63][clineCreditsRateLimitKey].After(time.Now()), tc.creditsReason)
}
}
}
// ClinePass 同样只解除探测能证明已恢复的冷却(窗口用满、未订阅):组织账户、所需订阅
// 档位与推理额度上限反映不到用量接口里,提前解除会让每个检测周期都重放一次失败请求。
func TestClineUsageProbeClearsOnlyProvablePassCooldowns(t *testing.T) {
ctx := withTempUnschedulableModel(context.Background(), []string{"cline-pass/glm-5.3"})
balance := `{"success":true,"data":{"balance":5000000}}`
for _, tc := range []struct {
status int
body string
cleared bool
}{
{http.StatusTooManyRequests, clinePassWeeklyBody, true},
{http.StatusForbidden, clineNotSubscribedBody, false},
{http.StatusForbidden, clineOrgPassBody, false},
{http.StatusTooManyRequests, clineInferenceCapBody, false},
} {
account := clineTestAccount(65)
NewRateLimitService(&commandCodeRateLimitRepo{}, nil, &config.Config{}, nil, nil).HandleUpstreamError(ctx, account,
tc.status, http.Header{}, []byte(tc.body))
require.True(t, account.isRateLimitActiveForKey(clinePassRateLimitKey), tc.body)
repo := &clineAccountRepo{accounts: []Account{*account}}
upstream := newClineAccountUpstream(commandCodeAlphaResponse{status: http.StatusOK, body: clineUsageLimitsBody}, balance)
_, err := NewCNProviderQuotaService(repo, nil, upstream, clineUsageTestConfig()).QueryUsage(context.Background(), account.ID)
require.NoError(t, err)
_, touched := repo.modelLimits[65][clinePassRateLimitKey]
require.Equal(t, tc.cleared, touched, tc.body)
}
// 探测发现未订阅设的冷却,订阅后的下一次探测解除。
repo := &clineAccountRepo{accounts: []Account{*clineTestAccount(66)}}
unsubscribed := newClineAccountUpstream(commandCodeAlphaResponse{status: http.StatusNotFound,
body: `{"success":false,"error":"no plan history found for user"}`}, balance)
_, err := NewCNProviderQuotaService(repo, nil, unsubscribed, clineUsageTestConfig()).QueryUsage(context.Background(), 66)
require.NoError(t, err)
require.True(t, repo.modelLimits[66][clinePassRateLimitKey].After(time.Now()))
subscribed := newClineAccountUpstream(commandCodeAlphaResponse{status: http.StatusOK, body: clineUsageLimitsBody}, balance)
_, err = NewCNProviderQuotaService(repo, nil, subscribed, clineUsageTestConfig()).QueryUsage(context.Background(), 66)
require.NoError(t, err)
require.False(t, repo.modelLimits[66][clinePassRateLimitKey].After(time.Now()))
}
// 用量接口鉴权失败:探测失败但不写快照、不动冷却。
func TestClineUsageProbeAuthFailure(t *testing.T) {
account := clineTestAccount(64)
repo := &clineAccountRepo{accounts: []Account{*account}}
upstream := newClineAccountUpstream(commandCodeAlphaResponse{status: http.StatusUnauthorized, body: `{"error":"Unauthorized"}`}, `{}`)
result, err := NewCNProviderQuotaService(repo, nil, upstream, clineUsageTestConfig()).QueryUsage(context.Background(), account.ID)
require.NoError(t, err)
require.False(t, result.Success)
require.False(t, result.CredentialValid)
require.Equal(t, http.StatusUnauthorized, result.StatusCode)
require.Empty(t, repo.extraWrites)
require.Empty(t, repo.modelLimits)
}
// 周期检测探测官方主机上的全部激活账号(含手动停调的,钱包冷却与调度开关无关),
// 只冷却钱包,不停整个账号。
func TestCNProviderBalanceCheckRunOnceProbesClineUsage(t *testing.T) {
official := *clineTestAccount(1)
manualOff := *clineTestAccount(2)
manualOff.Schedulable = false
relay := *clineTestAccount(3)
relay.Credentials["base_url"] = "https://relay.example.com/v1"
relay.Credentials["api_protocol"] = APIProtocolChatCompletions
repo := &clineAccountRepo{accounts: []Account{official, manualOff, relay}}
upstream := newClineAccountUpstream(commandCodeAlphaResponse{status: http.StatusOK, body: clineUsageLimitsBody}, `{"success":true,"data":{"balance":200000}}`)
cfg := clineUsageTestConfig()
svc := &CNProviderBalanceCheckService{
accountRepo: repo,
balanceService: NewCNProviderBalanceService(repo, nil, upstream, cfg),
quotaService: NewCNProviderQuotaService(repo, nil, upstream, cfg),
cfg: cfg,
}
svc.runOnce()
require.Len(t, upstream.requests, 6, "usage-limits + me + balance for each official account")
require.Len(t, repo.extraWrites, 2)
require.Contains(t, repo.modelLimits[1], clineCreditsRateLimitKey)
require.Contains(t, repo.modelLimits[2], clineCreditsRateLimitKey)
require.Empty(t, repo.paused)
}
// 同一轮检测里每个账号走自己平台登记的检测函数:积分同样低于阈值,Command Code 停整个
// 账号,Cline 不停(钱包冷却在探测中处理)。
func TestCNProviderBalanceCheckRunOnceDispatchesAccountProbesByPlatform(t *testing.T) {
commandCode := *commandCodeUsageAccount()
commandCode.ID, commandCode.Schedulable = 1, true
cline := *clineTestAccount(2)
repo := &commandCodeCheckRepo{accounts: []Account{commandCode, cline}}
prober := &commandCodeBalanceProber{balance: 0.2}
svc := &CNProviderBalanceCheckService{accountRepo: repo, quotaService: prober, cfg: clineUsageTestConfig()}
svc.runOnce()
require.ElementsMatch(t, []int64{1, 2}, prober.probed)
require.Equal(t, []int64{1}, repo.paused)
}
// 官方主机不做上游计费探测:中转站的计费接口在官方 API 上不存在,只会把 Key 发往官方主机。
func TestClineOfficialHostSkipsUpstreamBillingProbe(t *testing.T) {
require.True(t, upstreamBillingProbeTargetIsOfficialAPI(DefaultClineBaseURL))
require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://relay.example.com/v1"))
}