feat(platform): 接入 Cline 平台

Cline API(https://api.cline.bot/api/v1)是 OpenAI 兼容的多模型聚合端点,
只提供 Chat Completions(Cline 自己的客户端同样只走该端点),模型名带厂商
前缀。同一个 API Key 下有两个互不影响的额度来源,由模型名决定扣哪一边:
cline-pass/* 走 ClinePass 订阅(5 小时 / 7 天 / 30 天滚动上限),其余付费
模型扣账户积分;cline-free/* 免费模型只有逐模型的每日额度。

转发:
- 平台清单与 provider profile 新增 cline:按入站协议分流,只登记 Chat
  Completions 端点,Responses / Anthropic 入站由现有转换链转为 Chat
  Completions,无需新增转发代码。
- 只有一个接入模式,不需要选择账号类型。
- 连接测试未指定模型时使用 profile 默认模型(固定 Chat Completions 的账号
  此前落到不带厂商前缀的通用默认模型);订阅了 ClinePass 的账号改用订阅
  模型,不消耗积分,ClinePass 冷却中时仍用按量模型。

用量与余额:API Key 调 /api/v1/users/me/plan/usage-limits(三档窗口的已用
百分比与重置时间,未订阅时 404)与 /api/v1/users/me、/users/{id}/balance
(余额单位为百万分之一积分,1 积分 = 1 美元),兼容 {success, data} 信封,
只查官方主机。复用额度探测与周期检测:账号列表显示 ClinePass 窗口与积分
余额;窗口用满时 ClinePass 模型冷却到重置时间,未订阅时不调度 ClinePass
模型,积分低于阈值时冷却付费模型;数据显示恢复后自动解除,但只解除探测能
证明已恢复的原因(窗口用满、未订阅、积分不足),组织账户、所需订阅档位、推理
额度与花费上限的冷却到期后由请求重新确认。周期检测中走账号接口探测的平台
改为登记表(平台、支持判定、检测函数一一对应),按账号所属平台分派。

上游错误(依据 docs.cline.bot/api/errors 与 Cline 客户端的错误分类,按文案
识别):积分不足(含其他 402)、组织花费上限、推理额度上限、ClinePass 超限、
未订阅 ClinePass / 组织账户、免费模型每日额度。两边的超限只冷却请求模型所属
的一边(模型级冷却键 cline:pass / cline:credits,调度按请求模型检查),不停
整个账号;免费模型只限该模型,冷却到文案中的 "try again in …"。ClinePass
超限冷却到快照中对应窗口的重置时间,没有快照时 5 小时窗口 1 小时、7 天
6 小时、30 天 24 小时。普通频率限制走默认 429 逻辑。402 的冷却写入失败时
也不落入默认分支永久置 error(探测只覆盖激活账号,置 error 后无法自动恢复)。
余额类冷却时长抽为 cnBalanceCheckCooldown 共用。

上游计费探测的官方域名加入 cline.bot。

前端:官方标志(机器人头像)、violet 配色(品牌紫)、复用额度与余额单元格
(同样只对官方主机上的 API Key 账号显示);接入模式展示名抽为共用函数,编辑
弹窗不再显示原始模式值。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SavitarC
2026-10-09 10:11:06 +08:00
co-authored by Claude Opus 5.5
parent e2939cd8fa
commit 874871a25a
40 changed files with 1704 additions and 110 deletions
+4 -1
View File
@@ -35,7 +35,10 @@ const (
// PlatformCommandCode 是 Command Code Provider API(多模型聚合,积分计费,
// 订阅套餐另有 5 小时 / 每周滚动窗口)。
PlatformCommandCode = "command_code"
PlatformComposite = "composite"
// PlatformCline 是 Cline API(多模型聚合,OpenAI 兼容 Chat Completions;按量积分计费,
// 另有 ClinePass 订阅)。
PlatformCline = "cline"
PlatformComposite = "composite"
)
// Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。
+1
View File
@@ -47,6 +47,7 @@ var platformList = []PlatformSpec{
{ID: PlatformOpenCodeGo, DisplayName: "OpenCode", Gateway: PlatformGatewayOpenAI, LiteLLMProvider: "opencode-go"},
{ID: PlatformTypeSafe, DisplayName: "TypeSafe / Jev", Gateway: PlatformGatewayAnthropic, LiteLLMProvider: "typesafe"},
{ID: PlatformCommandCode, DisplayName: "Command Code", Gateway: PlatformGatewayOpenAI},
{ID: PlatformCline, DisplayName: "Cline", Gateway: PlatformGatewayOpenAI},
}
var platformIndex = func() map[string]int {
+5 -2
View File
@@ -7,28 +7,31 @@ import (
)
// 重构前各处手写的平台列表,作为平台清单派生结果的等价基准;重构后新登记的平台
// (Command Code)按同类平台(OpenCode)的位置补入。
// (Command Code、Cline)按同类平台(OpenCode)的位置补入。
var (
legacyDisplayOrder = []string{
PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok,
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe,
PlatformCommandCode,
PlatformCline,
}
legacyCompositePrecedence = []string{
PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok,
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe,
PlatformCommandCode,
PlatformCline,
}
legacyOpenAIGateway = []string{
PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo,
PlatformCommandCode,
PlatformCline,
}
legacyCNProviders = []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax}
legacyLiteLLMByPlatf = map[string]string{
PlatformAnthropic: "anthropic", PlatformOpenAI: "openai", PlatformGemini: "gemini",
PlatformAntigravity: "anthropic", PlatformGrok: "xai", PlatformKimi: "moonshot",
PlatformZhipu: "zhipu", PlatformDeepseek: "deepseek", PlatformMiniMax: "minimax",
PlatformOpenCodeGo: "opencode-go", PlatformTypeSafe: "typesafe", PlatformCommandCode: "",
PlatformOpenCodeGo: "opencode-go", PlatformTypeSafe: "typesafe", PlatformCommandCode: "", PlatformCline: "",
}
)
@@ -20,5 +20,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) {
"opencode_go",
"typesafe",
"command_code",
"cline",
}, AllPlatforms())
}
+2 -2
View File
@@ -863,7 +863,7 @@ func TestAPIContracts(t *testing.T) {
"force_email_on_third_party_signup": false,
"default_concurrency": 5,
"default_balance": 1.25,
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"command_code":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"cline":{"daily":null,"weekly":null,"monthly":null},"command_code":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"auth_source_default_email_platform_quotas": null,
"auth_source_default_github_platform_quotas": null,
"auth_source_default_google_platform_quotas": null,
@@ -1189,7 +1189,7 @@ func TestAPIContracts(t *testing.T) {
"purchase_subscription_url": "",
"table_default_page_size": 20,
"table_page_size_options": [10, 20, 50],
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"command_code":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"cline":{"daily":null,"weekly":null,"monthly":null},"command_code":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"auth_source_default_email_platform_quotas": null,
"auth_source_default_github_platform_quotas": null,
"auth_source_default_google_platform_quotas": null,
+7 -1
View File
@@ -1548,7 +1548,7 @@ func (a *Account) GetCNAPIKey() string {
}
// GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu / minimax;
// OpenCode Go 订阅与官方主机上的 Command Code 账号按平台识别),
// OpenCode Go 订阅与官方主机上的 Command Code、Cline 账号按平台识别),
// 用于路由到对应的额度查询端点。非 coding 模式或无法识别时返回空串。
// 只认官方域名:自定义中转不得把第三方 Key 发往厂商官方额度端点。
func (a *Account) GetCodingPlanProvider() string {
@@ -1564,6 +1564,12 @@ func (a *Account) GetCodingPlanProvider() string {
}
return ""
}
if a.IsCline() {
if a.clineAccountAPISupported() {
return PlatformCline
}
return ""
}
if a.GetAccountMode() != AccountModeCoding {
return ""
}
@@ -438,9 +438,7 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
func (s *AccountTestService) testModelRoutedAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
testModelID := strings.TrimSpace(modelID)
if testModelID == "" {
if profile := account.providerProfile(); profile != nil {
testModelID = profile.DefaultTestModel
}
testModelID = account.providerDefaultTestModel()
}
if testModelID == "" {
testModelID = openai.DefaultTestModel
@@ -480,6 +478,9 @@ func (s *AccountTestService) testModelRoutedResponsesConnection(c *gin.Context,
func (s *AccountTestService) testCNProviderChatCompletionsConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
testModelID := strings.TrimSpace(modelID)
if testModelID == "" {
testModelID = account.providerDefaultTestModel()
}
if testModelID == "" {
testModelID = openai.DefaultTestModel
}
@@ -25,9 +25,7 @@ const accountTestSuppressCompletionContextKey = "account_test_suppress_completio
func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
requestedModelID := strings.TrimSpace(modelID)
if requestedModelID == "" {
if profile := account.providerProfile(); profile != nil {
requestedModelID = profile.DefaultTestModel
}
requestedModelID = account.providerDefaultTestModel()
}
if requestedModelID == "" {
requestedModelID = openai.DefaultTestModel
@@ -2127,7 +2127,7 @@ func TestMatchingPlatforms(t *testing.T) {
{"anthropic returns itself", PlatformAnthropic, []string{PlatformAnthropic}},
{"gemini returns itself", PlatformGemini, []string{PlatformGemini}},
{"openai returns itself", PlatformOpenAI, []string{PlatformOpenAI}},
{"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, PlatformCommandCode}},
{"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, PlatformCommandCode, PlatformCline}},
}
for _, tt := range tests {
+85
View File
@@ -0,0 +1,85 @@
package service
import (
"net/url"
"strings"
)
// Cline API(https://docs.cline.bot/api/overview):OpenAI 兼容的多模型聚合端点,
// 基址 https://api.cline.bot/api/v1,只提供 Chat Completions(Cline 自己的客户端同样
// 只走该端点),模型名带厂商前缀(如 anthropic/claude-sonnet-4-6)。Responses 与
// Anthropic 入站由网关转换为 Chat Completions。
//
// 同一个 API Key 下有两个互不影响的额度来源(下称"钱包"),由模型名决定扣哪一个:
// - 积分:cline-pass/* 与 cline-free/* 以外的模型按量扣账户积分,余额可查;
// - ClinePass 订阅:cline-pass/* 模型,受 5 小时 / 7 天 / 30 天三档滚动用量上限限制,
// 各档已用百分比与重置时间可查(/api/v1/users/me/plan/usage-limits);
// - cline-free/* 免费模型不扣任何一边,只有逐模型的每日额度。
//
// 一边用完只影响该边的模型,所以超限冷却按钱包落在模型级冷却键上(见
// clineWalletRateLimitKey),不停整个账号,也不需要管理员声明账号类型。
const (
// DefaultClineTestModel 是连接测试的默认模型(按量积分,价格低、长期在售)。
DefaultClineTestModel = "deepseek/deepseek-v4-flash"
// DefaultClinePassTestModel 是订阅了 ClinePass 的账号连接测试的默认模型。
DefaultClinePassTestModel = "cline-pass/glm-5.3-flash"
clineAPIHost = "api.cline.bot"
clinePassModelPrefix = "cline-pass/"
clineFreeModelPrefix = "cline-free/"
// clinePassRateLimitKey / clineCreditsRateLimitKey 是两个钱包的模型级冷却键:
// 命中后该钱包的全部模型都不再调度到该账号,另一边不受影响。
clinePassRateLimitKey = "cline:pass"
clineCreditsRateLimitKey = "cline:credits"
)
// IsCline 报告账号是否为 Cline 平台账号。
func (a *Account) IsCline() bool {
return a != nil && a.Platform == PlatformCline
}
// clineWalletRateLimitKey 返回模型所属钱包的冷却键;免费模型不属于任何钱包,返回空串。
func clineWalletRateLimitKey(model string) string {
model = strings.ToLower(strings.TrimSpace(model))
switch {
case model == "":
return ""
case strings.HasPrefix(model, clinePassModelPrefix):
return clinePassRateLimitKey
case strings.HasPrefix(model, clineFreeModelPrefix):
return ""
default:
return clineCreditsRateLimitKey
}
}
// isOfficialClineHost 报告 URL 是否指向官方 Cline API 主机。
func isOfficialClineHost(target string) bool {
parsed, err := url.Parse(strings.TrimSpace(target))
if err != nil {
return false
}
return strings.EqualFold(parsed.Scheme, "https") && strings.EqualFold(parsed.Hostname(), clineAPIHost)
}
// clineAccountAPISupported 报告账号能否查询积分余额与 ClinePass 用量:API Key 账号且
// 推理端点指向官方主机。自定义中转的 Key 不发往官方账号接口。
func (a *Account) clineAccountAPISupported() bool {
return a.IsCline() && a.Type == AccountTypeAPIKey && isOfficialClineHost(a.GetOpenAIBaseURL())
}
// clinePassSubscribed 报告最近一次用量探测是否显示账号订阅了 ClinePass(有窗口快照)。
func (a *Account) clinePassSubscribed() bool {
if a == nil || a.Extra == nil {
return false
}
for _, suffix := range []string{cnExtraSuffix5hUsed, cnExtraSuffixWeeklyUsed, cnExtraSuffixMonthlyUsed} {
if value, ok := a.Extra[cnExtraKey(PlatformCline, suffix)]; ok && value != nil {
return true
}
}
return false
}
+647
View File
@@ -0,0 +1,647 @@
//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 {
require.Equal(t, want, classifyClineError([]byte(body)), 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"))
}
+368
View File
@@ -0,0 +1,368 @@
package service
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"net/url"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/tidwall/gjson"
)
// Cline 积分余额与 ClinePass 用量来自账号接口(Cline 控制台与客户端同源),账号 API Key
// 可直接访问(Bearer <key>;实测带 workos: 前缀反而 401):
//
// GET /api/v1/users/me -> {id, email, ...}
// GET /api/v1/users/{id}/balance -> {balance, userId}
// GET /api/v1/users/me/plan/usage-limits -> {limits:[{type, percentUsed, resetsAt?}]}
//
// 响应包在 {success, error, data} 信封里。balance 单位为百万分之一积分(1 积分 =
// 1 美元;Cline CLI 的 normalizeCreditBalance 除以 1e6,扩展先除 100 再除 10000),
// 按 Cline 的展示精度保留 4 位小数。usage-limits 的 type 为 five_hour / weekly /
// monthly(套餐里对应最近 5 小时 / 7 天 / 30 天的滚动用量上限),percentUsed 为 0-100,
// 窗口未开始时没有 resetsAt;未订阅时返回 404(no plan history found for user)或空列表。
const clineAPIBase = "https://" + clineAPIHost
var errClineInvalidResponse = errors.New("invalid cline account response")
// clineHTTPError 是账号接口的非 2xx 响应。
type clineHTTPError struct {
status int
body string
}
func (e *clineHTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.status, e.body)
}
type clineAccountClient struct {
cfg *config.Config
httpUpstream HTTPUpstream
account *Account
apiKey string
proxyURL string
// errPrefix 是应用错误码前缀(CN_BALANCE / CN_QUOTA),与调用方服务的错误码一致。
errPrefix string
}
func newClineAccountClient(cfg *config.Config, httpUpstream HTTPUpstream, account *Account, proxyURL, errPrefix string) *clineAccountClient {
return &clineAccountClient{
cfg: cfg,
httpUpstream: httpUpstream,
account: account,
apiKey: strings.TrimSpace(account.GetCNAPIKey()),
proxyURL: proxyURL,
errPrefix: errPrefix,
}
}
// get 请求一个账号接口,返回拆掉 {success, error, data} 信封后的 JSON。
func (c *clineAccountClient) get(ctx context.Context, path string) (gjson.Result, error) {
validatedURL, err := cnValidateProbeURL(c.cfg, clineAPIBase+path)
if err != nil {
return gjson.Result{}, infraerrors.New(http.StatusForbidden, c.errPrefix+"_URL_REJECTED", err.Error())
}
callCtx, cancel := context.WithTimeout(ctx, cnQuotaUpstreamTimeout)
defer cancel()
req, err := http.NewRequestWithContext(callCtx, http.MethodGet, validatedURL, nil)
if err != nil {
return gjson.Result{}, infraerrors.Newf(http.StatusInternalServerError, c.errPrefix+"_REQUEST_BUILD_FAILED", "build request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Accept", "application/json")
c.account.ApplyHeaderOverrides(req.Header)
resp, err := c.httpUpstream.Do(req, c.proxyURL, c.account.ID, maxInt(c.account.Concurrency, 1))
if err != nil {
return gjson.Result{}, fmt.Errorf("upstream request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, cnQuotaMaxBodyBytes))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return gjson.Result{}, &clineHTTPError{status: resp.StatusCode, body: truncate(strings.TrimSpace(string(body)), 240)}
}
if !gjson.ValidBytes(body) {
return gjson.Result{}, fmt.Errorf("%w: %s", errClineInvalidResponse, path)
}
parsed := gjson.ParseBytes(body)
if success := parsed.Get("success"); success.Exists() {
if !success.Bool() {
return gjson.Result{}, &clineHTTPError{status: resp.StatusCode, body: truncate(parsed.Get("error").String(), 240)}
}
return parsed.Get("data"), nil
}
return parsed, nil
}
// balance 查询积分余额(美元)。
func (c *clineAccountClient) balance(ctx context.Context) (float64, error) {
me, err := c.get(ctx, "/api/v1/users/me")
if err != nil {
return 0, err
}
userID := strings.TrimSpace(me.Get("id").String())
if userID == "" {
return 0, fmt.Errorf("%w: missing user id", errClineInvalidResponse)
}
balance, err := c.get(ctx, "/api/v1/users/"+url.PathEscape(userID)+"/balance")
if err != nil {
return 0, err
}
micro := balance.Get("balance")
if !micro.Exists() || (micro.Type != gjson.Number && micro.Type != gjson.String) {
return 0, fmt.Errorf("%w: missing balance", errClineInvalidResponse)
}
return math.Round(micro.Float()/100) / 10000, nil
}
// passLimits 查询 ClinePass 三档窗口;subscribed=false 表示没有订阅。
func (c *clineAccountClient) passLimits(ctx context.Context) (tiers []CNQuotaTier, subscribed bool, err error) {
data, err := c.get(ctx, "/api/v1/users/me/plan/usage-limits")
if err != nil {
var httpErr *clineHTTPError
if errors.As(err, &httpErr) && httpErr.status == http.StatusNotFound {
return nil, false, nil
}
return nil, false, err
}
limits := data.Get("limits")
if !limits.IsArray() {
return nil, false, fmt.Errorf("%w: missing limits", errClineInvalidResponse)
}
windows := map[string]string{"five_hour": "5h", "weekly": "weekly", "monthly": "monthly"}
for _, item := range limits.Array() {
window, ok := windows[strings.ToLower(strings.TrimSpace(item.Get("type").String()))]
used := item.Get("percentUsed")
if !ok || used.Type != gjson.Number {
continue
}
tier := CNQuotaTier{Window: window, UsedPercent: used.Float()}
if resetAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(item.Get("resetsAt").String())); err == nil {
tier.ResetAt = resetAt.UTC().Format(time.RFC3339)
}
tiers = append(tiers, tier)
}
return tiers, len(tiers) > 0, nil
}
// clineFetchFailure 把取数错误转换为探测失败的状态码与文案;ok=false 表示应作为错误返回。
func clineFetchFailure(err error) (status int, message string, ok bool) {
var httpErr *clineHTTPError
switch {
case errors.As(err, &httpErr):
return httpErr.status, fmt.Sprintf("API error (HTTP %d): %s", httpErr.status, httpErr.body), true
case errors.Is(err, errClineInvalidResponse):
return http.StatusOK, "Invalid response", true
default:
return 0, "", false
}
}
func clineApplicationError(err error, errPrefix string) error {
if isApplicationError(err) {
return err
}
return infraerrors.Newf(http.StatusBadGateway, errPrefix+"_REQUEST_FAILED", "%v", err)
}
func clineBalanceResult(usd float64, now time.Time) *CNProviderBalanceResult {
return &CNProviderBalanceResult{
Provider: PlatformCline,
Success: true,
Balance: usd,
Currency: "USD",
Balances: []CNProviderBalanceEntry{{Currency: "USD", Balance: usd}},
Available: true,
StatusCode: http.StatusOK,
FetchedAt: now.Unix(),
}
}
// queryClineBalance 只查积分余额(美元)并落余额快照。
func (s *CNProviderBalanceService) queryClineBalance(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) {
client := newClineAccountClient(s.cfg, s.httpUpstream, account, s.resolveProxyURL(ctx, account), "CN_BALANCE")
if client.apiKey == "" {
return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_APIKEY", "account api_key is empty")
}
now := time.Now().UTC()
usd, err := client.balance(ctx)
if err != nil {
status, message, ok := clineFetchFailure(err)
if !ok {
return nil, clineApplicationError(err, "CN_BALANCE")
}
return &CNProviderBalanceResult{Provider: PlatformCline, StatusCode: status, FetchedAt: now.Unix(), Available: true,
Error: "Balance query failed: " + message}, nil
}
result := clineBalanceResult(usd, now)
if err := s.accountRepo.UpdateExtra(ctx, account.ID, cnBalanceExtraUpdates(PlatformCline, result, now)); err != nil {
slog.Warn("cn_balance_persist_failed", "account_id", account.ID, "provider", PlatformCline, "error", err)
} else {
result.Persisted = true
}
return result, nil
}
// queryClineUsage 查询 ClinePass 窗口与积分余额,落快照,并按结果设置或解除两个钱包的
// 冷却(见 applyClineWallets)。余额查询失败不影响窗口结果。
func (s *CNProviderQuotaService) queryClineUsage(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) {
client := newClineAccountClient(s.cfg, s.httpUpstream, account, s.resolveProxyURL(ctx, account), "CN_QUOTA")
if client.apiKey == "" {
return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NO_APIKEY", "account api_key is empty")
}
now := time.Now().UTC()
result := &CNProviderQuotaProbeResult{
Provider: PlatformCline,
Source: "cline_usage_limits",
FetchedAt: now.Unix(),
}
tiers, subscribed, err := client.passLimits(ctx)
if err != nil {
status, message, ok := clineFetchFailure(err)
if !ok {
return nil, clineApplicationError(err, "CN_QUOTA")
}
result.StatusCode = status
result.Error = message
result.CredentialValid = status != http.StatusUnauthorized && status != http.StatusForbidden
return result, nil
}
result.StatusCode = http.StatusOK
result.Success = true
result.CredentialValid = true
result.Tiers = tiers
if subscribed {
result.PlanLevel = "ClinePass"
}
updates := cnQuotaExtraUpdates(PlatformCline, tiers, now)
present := make(map[string]CNQuotaTier, len(tiers))
for _, tier := range tiers {
present[tier.Window] = tier
}
// 缺席的窗口写 nil:取消订阅后不沿用旧快照(clinePassSubscribed 据此判断);窗口尚未
// 开始时没有重置时间,同样清掉上一次的值。
for _, window := range []struct{ name, used, reset string }{
{"5h", cnExtraSuffix5hUsed, cnExtraSuffix5hReset},
{"weekly", cnExtraSuffixWeeklyUsed, cnExtraSuffixWeeklyReset},
{"monthly", cnExtraSuffixMonthlyUsed, cnExtraSuffixMonthlyReset},
} {
tier, ok := present[window.name]
if !ok {
updates[cnExtraKey(PlatformCline, window.used)] = nil
}
if tier.ResetAt == "" {
updates[cnExtraKey(PlatformCline, window.reset)] = nil
}
}
if usd, err := client.balance(ctx); err != nil {
slog.Warn("cline_balance_probe_failed", "account_id", account.ID, "error", err)
} else {
result.Balance = clineBalanceResult(usd, now)
for key, value := range cnBalanceExtraUpdates(PlatformCline, result.Balance, now) {
updates[key] = value
}
}
if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil {
slog.Warn("cn_quota_persist_failed", "account_id", account.ID, "provider", PlatformCline, "error", err)
} else {
result.Persisted = true
if result.Balance != nil {
result.Balance.Persisted = true
}
}
s.applyClineWallets(ctx, account, subscribed, tiers, result.Balance, now)
return result, nil
}
const (
clineCreditsLowReason = "cline_credits_low"
// clinePassNoSubscriptionReason 是探测发现未订阅时的原因;与请求侧的未订阅 / 组织账户
// 共用 clinePassUnavailableReason 前缀,单独区分出探测能证明已恢复的这一种。
clinePassNoSubscriptionReason = clinePassUnavailableReason + ": no ClinePass subscription"
)
// applyClineWallets 按探测结果设置或解除两个钱包的冷却:
// - ClinePass:未订阅,或有窗口用满(≥100%)时冷却到该窗口的重置时间;可用时只解除
// 窗口用满与未订阅的冷却(组织账户、所需订阅档位与推理额度上限反映不到用量接口里,
// 仍等到期后由请求重新确认);
// - 积分:余额低于阈值时冷却,恢复后只解除积分类原因的冷却(组织花费上限无从探测,
// 仍等到期后由请求重新确认)。
//
// 没有重置时间时冷却 2 个检测周期,由下一次探测续期或解除。
func (s *CNProviderQuotaService) applyClineWallets(ctx context.Context, account *Account, subscribed bool, tiers []CNQuotaTier, balance *CNProviderBalanceResult, now time.Time) {
fallback := now.Add(cnBalanceCheckCooldown(s.cfg))
switch until, reason := clinePassWalletState(subscribed, tiers, now, fallback); {
case until != nil:
s.setClineWalletLimit(ctx, account, clinePassRateLimitKey, *until, reason, now)
case account.isRateLimitActiveForKey(clinePassRateLimitKey):
reason := account.modelRateLimitReason(clinePassRateLimitKey)
if strings.HasPrefix(reason, clinePassLimitReason) || strings.HasPrefix(reason, clinePassNoSubscriptionReason) {
s.setClineWalletLimit(ctx, account, clinePassRateLimitKey, now, "", now)
}
}
if balance == nil {
return
}
threshold := 0.0
if s.cfg != nil {
threshold = s.cfg.Gateway.CNProviders.BalanceThreshold
}
switch low := balance.Balance <= 0 || balance.Balance < threshold; {
case low:
s.setClineWalletLimit(ctx, account, clineCreditsRateLimitKey, fallback,
fmt.Sprintf("%s: balance %.4g USD below threshold %.2f", clineCreditsLowReason, balance.Balance, threshold), now)
case account.isRateLimitActiveForKey(clineCreditsRateLimitKey):
reason := account.modelRateLimitReason(clineCreditsRateLimitKey)
if strings.HasPrefix(reason, clineCreditsReason) || strings.HasPrefix(reason, clineCreditsLowReason) {
s.setClineWalletLimit(ctx, account, clineCreditsRateLimitKey, now, "", now)
}
}
}
// clinePassWalletState 返回 ClinePass 钱包应冷却到的时间;nil 表示可用。
func clinePassWalletState(subscribed bool, tiers []CNQuotaTier, now, fallback time.Time) (*time.Time, string) {
if !subscribed {
return &fallback, clinePassNoSubscriptionReason
}
var until *time.Time
var exhausted []string
for _, tier := range tiers {
if tier.UsedPercent < 100 {
continue
}
exhausted = append(exhausted, tier.Window)
resetAt := fallback
if parsed := parseSchedulingResetAt(tier.ResetAt); parsed != nil && parsed.After(now) && parsed.Sub(now) <= clinePassMaxCooldown {
resetAt = *parsed
}
if until == nil || resetAt.After(*until) {
until = &resetAt
}
}
if until == nil {
return nil, ""
}
return until, clinePassLimitReason + ": " + strings.Join(exhausted, ", ") + " window used up"
}
// setClineWalletLimit 写钱包冷却;until 不晚于 now 时即解除。
func (s *CNProviderQuotaService) setClineWalletLimit(ctx context.Context, account *Account, scope string, until time.Time, reason string, now time.Time) {
setAccountModelRateLimitSnapshot(account, scope, until, reason, now)
if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, scope, until, reason); err != nil {
slog.Warn("cline_wallet_limit_set_failed", "account_id", account.ID, "scope", scope, "error", err)
return
}
slog.Info("cline_wallet_limit", "account_id", account.ID, "scope", scope, "until", until.UTC(), "cleared", !until.After(now))
}
@@ -28,6 +28,7 @@ const cnQuotaProbeConcurrency = 4
// 余额探测仅覆盖有公开余额端点的 kimi / deepseek;智谱无余额端点,仅靠响应式 429/402。
// 额度探测覆盖 kimi / zhipu 的 coding plan 账号(deepseek 无 coding 套餐)。
// Command Code 账号一次探测同时刷新窗口快照与积分余额,积分低于阈值时同样临时停调。
// Cline 账号同样一次探测刷新 ClinePass 窗口与积分,只冷却用完的一边(见 cline_usage.go)。
type CNProviderBalanceCheckService struct {
accountRepo AccountRepository
balanceService *CNProviderBalanceService
@@ -155,8 +156,8 @@ func (s *CNProviderBalanceCheckService) runOnce() {
}
// 预算按工作量放大:4 并发 × 15s/批 + payg 每账号 5s,下限 30s 上限 300s。
commandCodeTargets := s.collectCommandCodeTargets()
batches := (len(quotaTargets) + len(commandCodeTargets) + cnQuotaProbeConcurrency - 1) / cnQuotaProbeConcurrency
accountTargets := s.collectAccountProbeTargets()
batches := (len(quotaTargets) + len(accountTargets) + cnQuotaProbeConcurrency - 1) / cnQuotaProbeConcurrency
timeout := 30*time.Second + time.Duration(batches)*15*time.Second + time.Duration(len(paygTargets))*5*time.Second
if timeout > 300*time.Second {
timeout = 300 * time.Second
@@ -190,17 +191,17 @@ func (s *CNProviderBalanceCheckService) runOnce() {
wg.Wait()
}
if len(commandCodeTargets) > 0 {
if len(accountTargets) > 0 {
sem := make(chan struct{}, cnQuotaProbeConcurrency)
var wg sync.WaitGroup
var mu sync.Mutex
for _, target := range commandCodeTargets {
for _, target := range accountTargets {
wg.Add(1)
go func(account *Account) {
go func(t accountProbeTarget) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
switch s.checkCommandCode(ctx, account, threshold) {
switch t.check(ctx, t.account, threshold) {
case cnBalancePaused:
mu.Lock()
paused++
@@ -220,22 +221,41 @@ func (s *CNProviderBalanceCheckService) runOnce() {
}
}
// collectCommandCodeTargets 选出官方主机上激活的 Command Code 账号:一次探测同时刷新
// 窗口快照(阈值停调读取)与积分余额。
func (s *CNProviderBalanceCheckService) collectCommandCodeTargets() []*Account {
// accountProbeCheck 是走账号接口探测的平台的周期检测函数。
type accountProbeCheck func(ctx context.Context, account *Account, threshold float64) cnBalanceCheckOutcome
// accountProbeTarget 是一个待检测账号与其平台的检测函数。
type accountProbeTarget struct {
account *Account
check accountProbeCheck
}
// collectAccountProbeTargets 选出走账号接口探测的平台上、官方主机上激活的账号,并配上
// 该平台的检测函数。一次探测同时刷新窗口快照与积分余额:
// - Command Code:手动可调度的账号再按积分余额停调 / 恢复(窗口快照供阈值停调读取);
// - Cline:探测中按结果设置或解除两个钱包的冷却,不停整个账号。
func (s *CNProviderBalanceCheckService) collectAccountProbeTargets() []accountProbeTarget {
if s.quotaService == nil {
return nil
}
accounts, err := s.accountRepo.ListByPlatform(context.Background(), PlatformCommandCode)
if err != nil {
log.Printf("[CNBalance] list %s accounts failed: %v", PlatformCommandCode, err)
return nil
}
targets := make([]*Account, 0, len(accounts))
for i := range accounts {
account := &accounts[i]
if account.IsActive() && account.commandCodeUsageSupported() {
targets = append(targets, account)
var targets []accountProbeTarget
for _, p := range []struct {
platform string
supported func(*Account) bool
check accountProbeCheck
}{
{PlatformCommandCode, (*Account).commandCodeUsageSupported, s.checkCommandCode},
{PlatformCline, (*Account).clineAccountAPISupported, s.checkCline},
} {
accounts, err := s.accountRepo.ListByPlatform(context.Background(), p.platform)
if err != nil {
log.Printf("[CNBalance] list %s accounts failed: %v", p.platform, err)
continue
}
for i := range accounts {
if account := &accounts[i]; account.IsActive() && p.supported(account) {
targets = append(targets, accountProbeTarget{account: account, check: p.check})
}
}
}
return targets
@@ -260,6 +280,17 @@ func (s *CNProviderBalanceCheckService) checkCommandCode(ctx context.Context, ac
return s.applyBalanceResult(ctx, account, result.Balance, threshold)
}
// checkCline 探测 Cline 窗口与积分;钱包冷却在探测中按结果设置或解除,不停整个账号。
func (s *CNProviderBalanceCheckService) checkCline(ctx context.Context, account *Account, _ float64) cnBalanceCheckOutcome {
result, err := s.quotaService.QueryUsage(ctx, account.ID)
if err != nil {
log.Printf("[CNBalance] cline usage account %d failed: %v", account.ID, err)
} else if result != nil && !result.Success && result.Error != "" {
log.Printf("[CNBalance] cline usage account %d error: %s", account.ID, result.Error)
}
return cnBalanceNoChange
}
// probeQuota 探测单个 coding plan 账号的滚动窗口用量并落 extra 快照。
// 不在此处做停调/恢复决策:调度阈值评估读取快照统一判定(含暂停账号的续停)。
func (s *CNProviderBalanceCheckService) probeQuota(ctx context.Context, accountID int64, platform string) {
@@ -345,15 +376,5 @@ func allCNBalancesBelowThreshold(result *CNProviderBalanceResult, threshold floa
// cooldown 返回临时停调持续时长(= 2× 检测周期),与响应式 402/429 路径一致。
func (s *CNProviderBalanceCheckService) cooldown() time.Duration {
minutes := 10
if s.cfg != nil {
if cfgMin := s.cfg.Gateway.CNProviders.BalanceCheckIntervalMinutes; cfgMin > 0 {
minutes = cfgMin
}
}
cooldown := time.Duration(minutes) * time.Minute * 2
if cooldown < time.Minute {
cooldown = 10 * time.Minute
}
return cooldown
return cnBalanceCheckCooldown(s.cfg)
}
@@ -126,6 +126,9 @@ func (s *CNProviderBalanceService) queryBalanceForAccount(ctx context.Context, a
if account.IsCommandCode() {
return s.queryCommandCodeBalance(ctx, account)
}
if account.IsCline() {
return s.queryClineBalance(ctx, account)
}
provider := account.Platform
if provider != PlatformKimi && provider != PlatformDeepseek {
return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_ENDPOINT", "account provider has no balance endpoint")
@@ -269,6 +272,13 @@ func validatePayGAccount(account *Account) error {
if account == nil {
return infraerrors.New(http.StatusNotFound, "CN_BALANCE_ACCOUNT_NOT_FOUND", "account not found")
}
// Cline 积分余额只查官方主机。
if account.IsCline() {
if !account.clineAccountAPISupported() {
return infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_ENDPOINT", "cline balance is only available for api key accounts on the official host")
}
return nil
}
// Command Code 积分余额与套餐窗口同源,只查官方主机。
if account.IsCommandCode() {
if !account.commandCodeUsageSupported() {
@@ -67,8 +67,8 @@ type CNProviderQuotaProbeResult struct {
Balance *CNProviderBalanceResult `json:"balance,omitempty"`
}
// CNProviderQuotaService 探测 Kimi / Zhipu / MiniMax Coding Plan、OpenCode Go 与
// Command Code 的滚动窗口用量。
// CNProviderQuotaService 探测 Kimi / Zhipu / MiniMax Coding Plan、OpenCode Go、
// Command Code 与 Cline(ClinePass)的滚动窗口用量。
type CNProviderQuotaService struct {
accountRepo AccountRepository
proxyRepo ProxyRepository
@@ -138,6 +138,9 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou
if provider == PlatformCommandCode {
return s.queryCommandCodeUsage(ctx, account)
}
if provider == PlatformCline {
return s.queryClineUsage(ctx, account)
}
if provider != PlatformKimi && provider != PlatformZhipu && provider != PlatformMiniMax && provider != PlatformOpenCodeGo {
return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a kimi/zhipu/minimax coding plan or opencode go account")
}
@@ -303,6 +306,12 @@ func validateCodingPlanAccount(account *Account) error {
}
return nil
}
if account.IsCline() {
if !account.clineAccountAPISupported() {
return infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "cline usage is only available for api key accounts on the official host")
}
return nil
}
if account.IsOpenCodeGo() {
return infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "opencode zen accounts have no subscription quota window")
}
@@ -341,7 +341,7 @@ func TestCommandCodeWindowsIgnoredWithPurchasedCredits(t *testing.T) {
require.Nil(t, cnProviderQuotaSnapshotReset(account, time.Now()))
}
// commandCodeCheckRepo 记录周期检测对 Command Code 账号的停调。
// commandCodeCheckRepo 按平台列出账号,并记录周期检测对账号的停调。
type commandCodeCheckRepo struct {
AccountRepository
accounts []Account
@@ -350,10 +350,13 @@ type commandCodeCheckRepo struct {
}
func (r *commandCodeCheckRepo) ListByPlatform(_ context.Context, platform string) ([]Account, error) {
if platform == PlatformCommandCode {
return r.accounts, nil
var accounts []Account
for _, account := range r.accounts {
if account.Platform == platform {
accounts = append(accounts, account)
}
}
return nil, nil
return accounts, nil
}
func (r *commandCodeCheckRepo) SetTempUnschedulable(_ context.Context, id int64, _ time.Time, _ string) error {
@@ -218,13 +218,13 @@ func TestCompositeGroupSchedulerHasAllCanonicalPlatformBuckets(t *testing.T) {
platforms = append(platforms, platform)
}
require.ElementsMatch(t,
[]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, PlatformCommandCode},
[]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, PlatformCommandCode, PlatformCline},
platforms,
)
}
func TestCompositeConcretePlatformsIncludeCNProviders(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, PlatformCommandCode} {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, PlatformCommandCode, PlatformCline} {
require.True(t, isConcreteRequestPlatform(platform))
require.True(t, canCopyAccountsFromGroupPlatform(PlatformComposite, platform))
}
@@ -51,6 +51,7 @@ const (
PlatformOpenCodeGo = domain.PlatformOpenCodeGo
PlatformTypeSafe = domain.PlatformTypeSafe
PlatformCommandCode = domain.PlatformCommandCode
PlatformCline = domain.PlatformCline
PlatformComposite = domain.PlatformComposite
// PlatformKiro is retained for unsupported-platform threshold tests and legacy
// account rows. Scheduling-threshold evaluation never pauses kiro accounts.
@@ -89,6 +90,8 @@ const (
DefaultOpenCodeZenBaseURL = "https://opencode.ai/zen/v1"
// Command Code Provider API:Chat Completions / Responses / models 共用 /provider/v1 基址。
DefaultCommandCodeBaseURL = "https://api.commandcode.ai/provider/v1"
// Cline API:只提供 Chat Completions({base}/chat/completions)与模型列表。
DefaultClineBaseURL = "https://api.cline.bot/api/v1"
)
// 国产供应商 Anthropic 协议端点的默认 base_url(上游路径为 {base}/v1/messages)。
@@ -90,6 +90,10 @@ func (a *Account) modelRateLimitKeysForRequest(ctx context.Context, requestedMod
if isAnthropicFableModel(modelKey) && modelKey != anthropicFableRateLimitKey {
keys = append(keys, anthropicFableRateLimitKey)
}
case PlatformCline:
if walletKey := clineWalletRateLimitKey(modelKey); walletKey != "" {
keys = append(keys, walletKey)
}
}
return keys
}
@@ -189,6 +193,17 @@ func (a *Account) modelRateLimitResetAt(scope string) *time.Time {
return &resetAt
}
// modelRateLimitReason 返回指定 scope 冷却记录中的原因;没有记录时为空。
func (a *Account) modelRateLimitReason(scope string) string {
if a == nil || a.Extra == nil {
return ""
}
rawLimits, _ := a.Extra[modelRateLimitsKey].(map[string]any)
rawLimit, _ := rawLimits[scope].(map[string]any)
reason, _ := rawLimit["reason"].(string)
return reason
}
func setAccountModelRateLimitSnapshot(account *Account, scope string, resetAt time.Time, reason string, now time.Time) {
if account == nil || strings.TrimSpace(scope) == "" {
return
+11 -8
View File
@@ -10,20 +10,22 @@ import (
)
// 以下为重构前各处手写的平台列表 / switch,作为平台清单派生结果的等价基准;
// 重构后新登记的平台(Command Code)按同类平台(OpenCode)的位置补入。
// 重构后新登记的平台(Command Code、Cline)按同类平台(OpenCode)的位置补入。
var (
legacyAllPlatforms = []string{
PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok,
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe,
PlatformCommandCode,
PlatformCline,
}
legacySchedulerSnapshotPlatforms = []string{
PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok,
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe,
PlatformCommandCode,
PlatformCline,
}
legacyCompositeMatchingPlatforms = legacySchedulerSnapshotPlatforms
legacyMultiProtocolProviders = []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformCommandCode}
legacyMultiProtocolProviders = []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformCommandCode, PlatformCline}
platformProbeValues = append(append([]string{}, legacyAllPlatforms...), PlatformComposite, "", "moonshot", "Kimi", "openai ", "glm", "bogus")
platformProbeAccountTypes = []string{AccountTypeAPIKey, AccountTypeOAuth, AccountTypeSetupToken, AccountTypeUpstream, ""}
)
@@ -37,12 +39,12 @@ func legacyIsCNProvider(platform string) bool {
}
func legacyIsOpenAICompatible(platform string) bool {
return platform == PlatformOpenAI || platform == PlatformGrok || legacyIsCNProvider(platform) || platform == PlatformOpenCodeGo || platform == PlatformCommandCode
return platform == PlatformOpenAI || platform == PlatformGrok || legacyIsCNProvider(platform) || platform == PlatformOpenCodeGo || platform == PlatformCommandCode || platform == PlatformCline
}
func legacyNormalizeOpenAICompatiblePlatform(platform string) string {
switch platform {
case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformCommandCode:
case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformCommandCode, PlatformCline:
return platform
}
return PlatformOpenAI
@@ -55,7 +57,7 @@ func legacyIsUpstreamBillingProbeIdentity(platform, accountType string) bool {
switch platform {
case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok,
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo,
PlatformTypeSafe, PlatformCommandCode:
PlatformTypeSafe, PlatformCommandCode, PlatformCline:
return true
}
return false
@@ -63,7 +65,7 @@ func legacyIsUpstreamBillingProbeIdentity(platform, accountType string) bool {
func legacyIsHeaderOverrideEligible(platform, accountType string) bool {
switch platform {
case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformCommandCode:
case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformCommandCode, PlatformCline:
return accountType == AccountTypeAPIKey
case PlatformGrok:
return accountType == AccountTypeAPIKey || accountType == AccountTypeOAuth
@@ -125,8 +127,9 @@ func TestProviderProfilesUseOpenAIGateway(t *testing.T) {
func TestProviderProfileAccountPredicatesMatchLegacy(t *testing.T) {
for _, platform := range platformProbeValues {
account := &Account{Platform: platform, Type: AccountTypeAPIKey}
require.Equal(t, legacyIsCNProvider(platform), account.RoutesProtocolByInbound(), platform)
require.Equal(t, legacyIsCNProvider(platform) || platform == PlatformOpenCodeGo || platform == PlatformCommandCode, account.IsMultiProtocolAPIKey(), platform)
// Cline 只有 Chat Completions 一个端点,按入站协议分流(其余入站转换)。
require.Equal(t, legacyIsCNProvider(platform) || platform == PlatformCline, account.RoutesProtocolByInbound(), platform)
require.Equal(t, legacyIsCNProvider(platform) || platform == PlatformOpenCodeGo || platform == PlatformCommandCode || platform == PlatformCline, account.IsMultiProtocolAPIKey(), platform)
}
var nilAccount *Account
require.False(t, nilAccount.RoutesProtocolByInbound())
@@ -47,6 +47,21 @@ type ProviderProfile struct {
ModelCatalog bool
}
// providerDefaultTestModel 返回账号连接测试未指定模型时的默认模型;非多协议供应商或
// 未设置时为空。
func (a *Account) providerDefaultTestModel() string {
profile := a.providerProfile()
if profile == nil {
return ""
}
// Cline 订阅了 ClinePass 的账号用订阅模型测试,不消耗积分;ClinePass 钱包冷却中
// (窗口用满等)时改用按量模型,否则测试必然失败,而积分模型照常可用。
if a.IsCline() && a.clinePassSubscribed() && !a.isRateLimitActiveForKey(clinePassRateLimitKey) {
return DefaultClinePassTestModel
}
return profile.DefaultTestModel
}
// Endpoints 返回指定接入模式的端点;未知模式回落 DefaultMode。
func (p *ProviderProfile) Endpoints(mode string) ProviderEndpoints {
if p == nil {
@@ -194,6 +209,18 @@ var providerProfiles = map[string]*ProviderProfile{
},
},
},
PlatformCline: {
Platform: PlatformCline,
DefaultMode: AccountModePayG,
DefaultTestModel: DefaultClineTestModel,
// 按量积分与 ClinePass 订阅共用同一个 API Key 与端点,由模型名决定扣哪一边
// (cline-pass/* 走订阅),只有一个接入模式;两边的超限分别冷却(见 cline.go)。
Modes: map[string]ProviderEndpoints{
AccountModePayG: {
BaseURLs: map[string]string{APIProtocolChatCompletions: DefaultClineBaseURL},
},
},
},
}
// LookupProviderProfile 返回 platform 对应的多协议供应商 profile;非此类平台返回 nil。
+233
View File
@@ -0,0 +1,233 @@
package service
import (
"context"
"log/slog"
"net/http"
"regexp"
"strings"
"time"
)
// Cline 上游错误的响应式处理。依据官方错误说明(https://docs.cline.bot/api/errors)与
// Cline 客户端的错误分类(cline/cline:sdk/packages/llms/src/providers/errors.ts、
// apps/vscode/src/services/error/ClineError.ts):
//
// - 402 insufficient_credits:积分不足;
// - 429 SPEND_LIMIT_EXCEEDED:组织为成员设的花费上限;
// - INFERENCE_CAP_ERROR:推理额度上限(客户端归为 QuotaExceeded,文案由服务端给出);
// - "You have reached your … ClinePass limit … Please try again later.":ClinePass
// 5 小时 / 7 天 / 30 天滚动用量上限,文案不带重置时间;
// - "the user is not subscribed to required model plan" 与 "organization accounts
// cannot use individual model inference subscriptions":没有可用的 ClinePass 订阅;
// - "Free limit reached on model … try again in …":免费模型的每日额度,只限该模型。
//
// 错误码只出现在响应体里(状态码对应关系文档没有写全),按文案分类;未识别的错误交回
// 默认逻辑(普通频率限制走默认 429)。
//
// 作用范围:积分与 ClinePass 是同一个 Key 下互不影响的两个钱包,一边超限只冷却该边的
// 全部模型(模型级冷却键,见 clineWalletRateLimitKey),不停整个账号;钱包按请求模型
// 判定——cline-pass/* 请求只会被订阅额度拒绝,其余付费模型只会被积分拒绝。
type clineErrorKind int
const (
clineErrorNone clineErrorKind = iota
// clineCreditsExhausted 积分不足:冷却积分钱包,用量探测发现余额恢复后解除。
clineCreditsExhausted
// clineSpendLimit 组织花费上限:冷却后由下一次请求重新确认。
clineSpendLimit
// clineInferenceCap 推理额度上限:归属由请求模型所在的钱包决定。
clineInferenceCap
// clinePassLimit ClinePass 用量上限:冷却到对应窗口的重置时间。
clinePassLimit
// clinePassUnavailable 没有可用的 ClinePass 订阅(未订阅或组织账户)。
clinePassUnavailable
// clineFreeModelLimit 免费模型的每日额度:只限当前模型。
clineFreeModelLimit
)
const (
clinePassLimitReason = "cline_pass_limit"
clinePassUnavailableReason = "cline_pass_unavailable"
clineSpendLimitReason = "cline_spend_limit"
clineInferenceCapReason = "cline_inference_cap"
clineFreeModelLimitReason = "cline_free_model_limit"
clineCreditsReason = "cline_credits_exhausted"
// clineLimitRecheck 是无法得知重置时间时的冷却:到期后由下一次请求重新确认
// (管理员可能调高上限、订阅可能续费、滚动窗口逐步释放)。
clineLimitRecheck = time.Hour
// ClinePass 7 天 / 30 天窗口没有用量快照时的冷却:窗口滚动释放,时间点无从得知,
// 冷却较长但远短于窗口,由下一次请求或用量探测重新确认。
clinePassWeeklyRecheck = 6 * time.Hour
clinePassMonthlyRecheck = 24 * time.Hour
// clinePassMaxCooldown 限制按快照重置时间冷却的上限(最长的 30 天窗口)。
clinePassMaxCooldown = 31 * 24 * time.Hour
// clineFreeModelMaxCooldown 是从免费模型文案解析出的重置时长上限(每日额度)。
clineFreeModelMaxCooldown = 25 * time.Hour
)
var (
// clinePassLimitPattern 与 Cline 客户端的判定一致:"you have reached your" 开头、
// "please try again later." 结尾,中间含 "clinepass limit"。
clinePassLimitPattern = regexp.MustCompile(`(?is)you have reached your(.*?)clinepass limit.*?please try again later\.`)
clineTryAgainPattern = regexp.MustCompile(`(?i)\btry\s+again\s+in\s+`)
)
func classifyClineError(body []byte) clineErrorKind {
text := strings.ToLower(string(body))
switch {
case strings.Contains(text, "organization accounts cannot use individual model inference subscriptions"),
strings.Contains(text, "the user is not subscribed to required model plan"):
return clinePassUnavailable
case strings.Contains(text, "free limit reached on model"):
return clineFreeModelLimit
case clinePassLimitPattern.Match(body):
return clinePassLimit
case strings.Contains(text, "spend_limit_exceeded"):
return clineSpendLimit
case strings.Contains(text, "inference_cap_error"):
return clineInferenceCap
case strings.Contains(text, "insufficient_credits"),
strings.Contains(text, "insufficient credits"),
cnProviderResponseIndicatesInsufficientBalance(body):
return clineCreditsExhausted
default:
return clineErrorNone
}
}
// clineErrorWallet 返回错误本身指明的钱包冷却键,用于请求模型未知的情况。
func clineErrorWallet(kind clineErrorKind) string {
switch kind {
case clinePassLimit, clinePassUnavailable, clineInferenceCap:
return clinePassRateLimitKey
case clineCreditsExhausted, clineSpendLimit:
return clineCreditsRateLimitKey
default:
return ""
}
}
// clinePassWindow 返回 ClinePass 超限文案所指的窗口(5h / weekly / monthly)。
func clinePassWindow(body []byte) string {
match := clinePassLimitPattern.FindSubmatch(body)
if match == nil {
return "5h"
}
window := strings.ToLower(string(match[1]))
switch {
case strings.Contains(window, "month"):
return "monthly"
case strings.Contains(window, "week"):
return "weekly"
default:
return "5h"
}
}
// clinePassLimitCooldown 冷却到用量快照中该窗口的重置时间;没有可用快照时按窗口估计。
func clinePassLimitCooldown(account *Account, window string, now time.Time) time.Duration {
if account != nil && account.Extra != nil {
resetKey := map[string]string{
"5h": cnExtraSuffix5hReset,
"weekly": cnExtraSuffixWeeklyReset,
"monthly": cnExtraSuffixMonthlyReset,
}[window]
if resetAt := parseSchedulingResetAt(account.Extra[cnExtraKey(PlatformCline, resetKey)]); resetAt != nil {
if wait := resetAt.Sub(now); wait > 0 && wait <= clinePassMaxCooldown {
return wait
}
}
}
switch window {
case "monthly":
return clinePassMonthlyRecheck
case "weekly":
return clinePassWeeklyRecheck
default:
return clineLimitRecheck
}
}
// clineFreeModelResetAfter 解析 "try again in 3h 20m";无法解析或超出上限时返回 0。
func clineFreeModelResetAfter(body []byte) time.Duration {
text := string(body)
loc := clineTryAgainPattern.FindStringIndex(text)
if loc == nil {
return 0
}
after := parseOpenCodeGoUsageLimitResetDuration("resets in " + text[loc[1]:])
if after <= 0 || after > clineFreeModelMaxCooldown {
return 0
}
return after
}
// handleClineError 处理已识别的 Cline 上游错误:冷却对应钱包(或免费模型本身),不停
// 整个账号。handled=false 时由调用方继续走默认逻辑。
func (s *RateLimitService) handleClineError(
ctx context.Context,
account *Account,
statusCode int,
responseBody []byte,
upstreamMsg string,
) bool {
kind := classifyClineError(responseBody)
if kind == clineErrorNone && statusCode == http.StatusPaymentRequired {
// 402 只会来自积分不足(ClinePass 超限走 429 等其他状态码)。
kind = clineCreditsExhausted
}
if kind == clineErrorNone {
return false
}
now := time.Now()
modelKey := modelRateLimitKeyForUpstreamModelNotFound(ctx, account, tempUnschedulableModel(ctx, nil))
scope := modelKey
if kind != clineFreeModelLimit {
if scope = clineWalletRateLimitKey(modelKey); scope == "" {
scope = clineErrorWallet(kind)
}
}
if scope == "" {
return false
}
var cooldown time.Duration
var reason string
switch kind {
case clineCreditsExhausted:
cooldown, reason = s.cnBalanceCooldownDuration(), clineCreditsReason
case clineSpendLimit:
cooldown, reason = clineLimitRecheck, clineSpendLimitReason
case clineInferenceCap:
cooldown, reason = clineLimitRecheck, clineInferenceCapReason
if scope == clinePassRateLimitKey {
cooldown = clinePassLimitCooldown(account, "5h", now)
}
case clinePassLimit:
cooldown, reason = clinePassLimitCooldown(account, clinePassWindow(responseBody), now), clinePassLimitReason
case clinePassUnavailable:
cooldown, reason = clineLimitRecheck, clinePassUnavailableReason
case clineFreeModelLimit:
cooldown, reason = clineFreeModelResetAfter(responseBody), clineFreeModelLimitReason
if cooldown <= 0 {
cooldown = clineLimitRecheck
}
}
until := now.Add(cooldown)
if msg := strings.TrimSpace(upstreamMsg); msg != "" {
reason += ": " + msg
}
setAccountModelRateLimitSnapshot(account, scope, until, reason, now)
if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, scope, until, reason); err != nil {
slog.Warn("cline_limit_set_failed", "account_id", account.ID, "scope", scope, "error", err)
return false
}
slog.Info("cline_limit", "account_id", account.ID, "scope", scope, "model", modelKey,
"status_code", statusCode, "until", until.UTC())
return true
}
@@ -7,6 +7,7 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/tidwall/gjson"
)
@@ -153,9 +154,17 @@ func (s *RateLimitService) handleCNProviderInsufficientBalance(
// 默认 20 分钟)。周期任务会在余额恢复后提前清除,故此处只需保证冷却覆盖到下一次
// 周期检测即可。
func (s *RateLimitService) cnBalanceCooldownDuration() time.Duration {
if s == nil {
return cnBalanceCheckCooldown(nil)
}
return cnBalanceCheckCooldown(s.cfg)
}
// cnBalanceCheckCooldown 返回余额类冷却时长(= 2× 余额检测周期,默认周期 10 分钟)。
func cnBalanceCheckCooldown(cfg *config.Config) time.Duration {
minutes := 10
if s != nil && s.cfg != nil {
if cfgMin := s.cfg.Gateway.CNProviders.BalanceCheckIntervalMinutes; cfgMin > 0 {
if cfg != nil {
if cfgMin := cfg.Gateway.CNProviders.BalanceCheckIntervalMinutes; cfgMin > 0 {
minutes = cfgMin
}
}
@@ -407,6 +407,19 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
return disable
}
}
// Cline 积分、花费上限与 ClinePass 超限同样按文案区分,只冷却对应的钱包
// (见 ratelimit_cline.go)。
if account.IsCline() && (statusCode == http.StatusBadRequest || statusCode == http.StatusPaymentRequired ||
statusCode == http.StatusForbidden || statusCode == http.StatusTooManyRequests) {
if s.handleClineError(ctx, account, statusCode, responseBody, upstreamMsg) {
return false
}
// 402 只会是积分不足:冷却没写进去也不能落到下面的 402 默认分支——那里永久置
// status=error,而周期探测只覆盖激活账号,充值后无法自动恢复。
if statusCode == http.StatusPaymentRequired {
return false
}
}
switch statusCode {
case 400:
@@ -1046,6 +1046,7 @@ var upstreamBillingProbeOfficialAPIDomains = []string{
"opencode.ai",
"typesafe.ai",
"commandcode.ai",
"cline.bot",
}
func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool {
@@ -13,7 +13,7 @@ import { listPlatformIds } from "@/constants/platformCatalog";
/** 与后端 AllowedQuotaPlatforms 一致的全部具体平台(平台清单)。 */
const quotaPlatforms = [
"anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek", "minimax", "opencode_go", "typesafe", "command_code",
"kimi", "zhipu", "deepseek", "minimax", "opencode_go", "typesafe", "command_code", "cline",
];
/** 全部平台全 null 的 map,用于断言归一化默认值 */
@@ -591,7 +591,7 @@
>
<Icon name="creditCard" size="sm" />
</div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ providerModeLabel(mode) }}</span>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ providerModeLabel(mode, t) }}</span>
</button>
</div>
</div>
@@ -4023,6 +4023,7 @@ import {
isHeaderOverrideCapable,
isMultiProtocolApiKeyPlatform,
providerAccountModes,
providerModeLabel,
providerNativeProtocols,
providerRoutesByModel,
resolveProviderAccountMode,
@@ -4265,20 +4266,6 @@ const extraMultiProtocolPlatforms = computed(() =>
const genericAccountModes = computed(() => providerAccountModes(form.platform))
// 按模型分流的供应商(OpenCode 等):adaptive 账号携带 protocol_rules。
const routesByModel = computed(() => providerRoutesByModel(form.platform))
function providerModeLabel(mode: string): string {
switch (mode) {
case 'payg':
return t('admin.accounts.cnProviders.accountMode.payg')
case 'coding':
return t('admin.accounts.cnProviders.accountMode.coding')
case 'zen':
return t('admin.accounts.opencodeGo.accountMode.zen')
case 'go':
return t('admin.accounts.opencodeGo.accountMode.go')
default:
return mode
}
}
function currentOpenCodeOrCNMode(): string {
return isOpenCodeGoPlatform.value ? openCodeAccountMode.value : accountMode.value
}
@@ -144,7 +144,7 @@
]"
@click="editAccountMode = mode"
>
{{ mode }}
{{ providerModeLabel(mode, t) }}
</button>
</div>
</div>
@@ -3176,6 +3176,7 @@ import {
isMultiProtocolApiKeyPlatform,
parseOpenCodeGoProtocolRules,
providerAccountModes,
providerModeLabel,
providerNativeProtocols,
providerRoutesByModel,
resolveProviderAccountMode,
@@ -384,6 +384,29 @@ describe('AccountUsageCell', () => {
expect(wrapper.find('div[title="admin.accounts.cnProviders.noBalanceEndpoint"]').exists()).toBe(true)
})
it('Cline 账号渲染 ClinePass 窗口与积分余额单元格', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 9104,
platform: 'cline',
type: 'apikey',
credentials: { api_key: 'sk-cline' }
})
},
global: {
stubs: { ...cnUsageCellStubs, UsageProgressBar: true, AccountQuotaInfo: true }
}
})
await flushPromises()
expect(wrapper.find('[data-test="cn-quota-cell"]').exists()).toBe(true)
expect(wrapper.find('[data-test="cn-balance-cell"]').exists()).toBe(true)
// 子单元格可见时不显示 `-` 占位符(按量账号与订阅账号用同一套单元格)。
expect(wrapper.text()).not.toContain('-')
})
it('Antigravity 图片用量会聚合新旧 image 模型', async () => {
getUsage.mockResolvedValue({
antigravity_quota: {
@@ -542,18 +542,18 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
},
},
{
id: 'cline_pass',
display_name: 'Cline Pass',
id: 'acme_chat',
display_name: 'Acme Chat',
gateway: 'openai',
cn_provider: false,
multi_protocol: {
default_mode: 'pass',
routing: 'by_inbound',
modes: [{ mode: 'pass', base_urls: { chat_completions: 'https://api.cline.bot/api/v1' } }],
modes: [{ mode: 'pass', base_urls: { chat_completions: 'https://api.acme-chat.example/v1' } }],
},
},
],
composite_precedence: [...BUILTIN_PLATFORM_CATALOG.composite_precedence, 'acme_router', 'cline_pass'],
composite_precedence: [...BUILTIN_PLATFORM_CATALOG.composite_precedence, 'acme_router', 'acme_chat'],
}
beforeEach(() => {
@@ -617,11 +617,11 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
it('creates a by-inbound provider account without protocol rules', async () => {
const wrapper = mountModal()
await wrapper.get('[data-testid="platform-button-cline_pass"]').trigger('click')
await wrapper.get('[data-testid="platform-button-acme_chat"]').trigger('click')
// 单一接入模式时不显示模式选择。
expect(wrapper.find('[data-testid="generic-account-mode"]').exists()).toBe(false)
await wrapper.get('form#create-account-form input[type="text"]').setValue('cline')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-cline')
await wrapper.get('form#create-account-form input[type="text"]').setValue('acme-chat')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-acme-chat')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
@@ -630,8 +630,8 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
expect(credentials).toMatchObject({
account_mode: 'pass',
api_protocol: 'adaptive',
base_url: 'https://api.cline.bot/api/v1',
api_base_urls: { chat_completions: 'https://api.cline.bot/api/v1' },
base_url: 'https://api.acme-chat.example/v1',
api_base_urls: { chat_completions: 'https://api.acme-chat.example/v1' },
})
expect(credentials).not.toHaveProperty('protocol_rules')
})
@@ -654,6 +654,38 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
})
})
it('groups the aggregators on their own row below the CN providers', () => {
const wrapper = mountModal()
const labels = (testid: string) =>
wrapper.get(`[data-testid="${testid}"]`).findAll('button').map(button => button.text().trim())
expect(labels('platform-row-cn')).toEqual(['Kimi', 'Zhipu GLM', 'DeepSeek', 'MiniMax'])
expect(labels('platform-row-aggregators')).toEqual(['OpenCode', 'Command Code', 'Cline'])
})
it('creates a Cline account without an account type and with only the Chat Completions endpoint', async () => {
const wrapper = mountModal()
await wrapper.get('[data-testid="platform-button-cline"]').trigger('click')
// 积分与 ClinePass 共用同一个 Key,按模型计费,不需要选择账号类型。
expect(wrapper.find('[data-testid="generic-account-mode"]').exists()).toBe(false)
await wrapper.get('form#create-account-form input[type="text"]').setValue('cline')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-cline')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
const payload = createAccountMock.mock.calls[0]?.[0]
expect(payload?.platform).toBe('cline')
expect(payload?.credentials).toMatchObject({
account_mode: 'payg',
api_protocol: 'adaptive',
base_url: 'https://api.cline.bot/api/v1',
api_base_urls: { chat_completions: 'https://api.cline.bot/api/v1' },
})
expect(payload?.credentials).not.toHaveProperty('protocol_rules')
expect(payload?.credentials?.api_base_urls).not.toHaveProperty('responses')
expect(payload?.credentials?.api_base_urls).not.toHaveProperty('anthropic')
})
it('submits adaptive Kimi protocol endpoints', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'Kimi')
@@ -24,6 +24,7 @@ import {
isMultiProtocolApiKeyPlatform,
providerAccountModes,
providerHasModelCatalog,
providerModeLabel,
providerNativeProtocols,
providerRoutesByModel,
resolveProviderAccountMode,
@@ -56,7 +57,7 @@ const legacyZenRules = [
]
function legacyIsMultiProtocol(platform: string): boolean {
return ['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'command_code'].includes(platform)
return ['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'command_code', 'cline'].includes(platform)
}
function legacySupportsResponses(platform: string): boolean {
@@ -64,7 +65,7 @@ function legacySupportsResponses(platform: string): boolean {
}
function legacyHeaderOverride(platform: string, type: string): boolean {
if (['anthropic', 'openai', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'command_code'].includes(platform)) {
if (['anthropic', 'openai', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'command_code', 'cline'].includes(platform)) {
return type === 'apikey'
}
if (platform === 'grok') return type === 'apikey' || type === 'oauth'
@@ -225,6 +226,37 @@ describe('credentialsBuilder built-in Command Code provider', () => {
})
})
describe('credentialsBuilder built-in Cline provider', () => {
it('only offers Chat Completions and routes by inbound protocol', () => {
expect(isMultiProtocolApiKeyPlatform('cline')).toBe(true)
expect(providerRoutesByModel('cline')).toBe(false)
// 积分与 ClinePass 共用同一个 Key 与端点,只有一个接入模式(不显示账号类型)。
expect(providerAccountModes('cline')).toEqual(['payg'])
expect(defaultCNAdaptiveBaseUrls('cline', 'payg')).toEqual({
chat_completions: 'https://api.cline.bot/api/v1',
anthropic: '',
responses: ''
})
expect(providerNativeProtocols('cline', 'payg')).toEqual(['chat_completions'])
expect(defaultProviderProtocolRules('cline', 'payg')).toEqual([])
})
it('shows ClinePass windows and the credit balance for official-host API keys only', () => {
expect(cnQuotaCellVisible({ platform: 'cline', type: 'apikey', credentials: {} })).toBe(true)
expect(cnBalanceCellVisible({ platform: 'cline', type: 'apikey', credentials: {} })).toBe(true)
const relay = { platform: 'cline', type: 'apikey', credentials: { base_url: 'https://relay.example.com/v1' } }
expect(cnQuotaCellVisible(relay)).toBe(false)
expect(cnBalanceCellVisible(relay)).toBe(false)
})
it('labels provider modes', () => {
const t = (key: string) => key
expect(providerModeLabel('payg', t)).toBe('admin.accounts.cnProviders.accountMode.payg')
expect(providerModeLabel('go', t)).toBe('admin.accounts.opencodeGo.accountMode.go')
expect(providerModeLabel('standard', t)).toBe('standard')
})
})
describe('credentialsBuilder picks up newly registered providers from the catalog', () => {
const serverCatalog: PlatformCatalog = {
platforms: [
@@ -301,6 +301,22 @@ export function providerHasModelCatalog(platform: string): boolean {
return getProviderProfile(platform)?.model_catalog === true
}
/** 接入模式的展示名;未知模式原样显示。 */
export function providerModeLabel(mode: string, t: (key: string) => string): string {
switch (mode) {
case 'payg':
return t('admin.accounts.cnProviders.accountMode.payg')
case 'coding':
return t('admin.accounts.cnProviders.accountMode.coding')
case 'zen':
return t('admin.accounts.opencodeGo.accountMode.zen')
case 'go':
return t('admin.accounts.opencodeGo.accountMode.go')
default:
return mode
}
}
/** 供应商的接入模式,默认模式在前。 */
export function providerAccountModes(platform: string): string[] {
return getProviderProfile(platform)?.modes.map(item => item.mode) ?? []
@@ -527,10 +543,10 @@ export interface CNUsageCellAccount {
credentials?: Record<string, unknown>
}
// Command Code 的积分与用量接口只查官方主机上的 API Key 账号(与后端
// commandCodeUsageSupported 一致):自定义中转的 Key 不发往官方接口,后端直接拒绝
// 探测,单元格只会显示一行报错。
const OFFICIAL_ACCOUNT_API_PLATFORMS = ['command_code']
// Command Code、Cline 的积分与用量接口只查官方主机上的 API Key 账号(与后端
// commandCodeUsageSupported / clineAccountAPISupported 一致):自定义中转的 Key 不发往
// 官方接口,后端直接拒绝探测,单元格只会显示一行报错。
const OFFICIAL_ACCOUNT_API_PLATFORMS = ['command_code', 'cline']
function cnUsageCellAccountMode(account: CNUsageCellAccount): string {
const mode = account.credentials?.account_mode
@@ -577,7 +593,7 @@ export function cnQuotaCellVisible(account: CNUsageCellAccount): boolean {
const { platform } = account
const mode = cnUsageCellAccountMode(account)
if (platform === 'opencode_go') return mode !== 'zen'
// Command Code:订阅套餐窗口与积分同源,一次探测同时刷新两者。
// Command Code、Cline:订阅窗口与积分由一次探测同时刷新。
if (OFFICIAL_ACCOUNT_API_PLATFORMS.includes(platform)) return officialAccountAPISupported(account, mode)
return (platform === 'kimi' || platform === 'zhipu' || platform === 'minimax') && mode === 'coding'
}
@@ -101,12 +101,12 @@ describe('UserPlatformQuotaModal', () => {
expect(apiMocks.getPlatformQuotas).toHaveBeenCalledWith(99)
})
it('renders all twelve supported platforms with empty limits', async () => {
it('renders all thirteen supported platforms with empty limits', async () => {
const w = await mountAndOpen()
const rows = w.findAll('tbody tr')
expect(rows.map(row => row.find('td').text())).toEqual([
'anthropic', 'openai', 'gemini', 'antigravity', 'grok',
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe', 'command_code',
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe', 'command_code', 'cline',
])
for (const row of rows) {
const inputs = row.findAll('input[type=number]')
@@ -116,11 +116,11 @@ describe('UserPlatformQuotaModal', () => {
w.unmount()
})
it.each(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe', 'command_code'] as const)(
it.each(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe', 'command_code', 'cline'] as const)(
'saves edits to %s without erasing existing platform limits', async (platform) => {
const existing: PlatformQuotaUpdateItem[] = [
{ platform: 'openai', daily_limit_usd: 10, weekly_limit_usd: 20, monthly_limit_usd: 100 },
...(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe', 'command_code'] as const).map(p => ({
...(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe', 'command_code', 'cline'] as const).map(p => ({
platform: p, daily_limit_usd: 0, weekly_limit_usd: null, monthly_limit_usd: 50,
})),
]
@@ -137,7 +137,7 @@ describe('UserPlatformQuotaModal', () => {
: item)
expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1)
expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledWith(99, expect.arrayContaining(expected))
expect(apiMocks.updatePlatformQuotas.mock.calls[0][1]).toHaveLength(12)
expect(apiMocks.updatePlatformQuotas.mock.calls[0][1]).toHaveLength(13)
expect(w.emitted('success')).toHaveLength(1)
w.unmount()
},
@@ -152,13 +152,13 @@ describe('UserPlatformQuotaModal', () => {
})
const w = await mountAndOpen()
const inputs = w.findAll('input[type=number]')
// 12 platforms × 3 windows = 36 inputs
expect(inputs.length).toBe(36)
// 13 platforms × 3 windows = 39 inputs
expect(inputs.length).toBe(39)
// 第一个 input 是 anthropic.daily = 10
expect((inputs[0].element as HTMLInputElement).value).toBe('10')
})
it('保存提交完整 12 platform payload', async () => {
it('保存提交完整 13 platform payload', async () => {
apiMocks.getPlatformQuotas.mockResolvedValueOnce({
platform_quotas: [
{ platform: 'openai', daily_limit_usd: null, weekly_limit_usd: 20, monthly_limit_usd: null,
@@ -175,7 +175,7 @@ describe('UserPlatformQuotaModal', () => {
expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1)
const [uid, payload] = apiMocks.updatePlatformQuotas.mock.calls[0]
expect(uid).toBe(99)
expect(payload).toHaveLength(12) // 12 platforms always submitted
expect(payload).toHaveLength(13) // 13 platforms always submitted
const openai = payload.find((p: any) => p.platform === 'openai')
expect(openai.weekly_limit_usd).toBe(20)
})
@@ -240,7 +240,7 @@ describe('UserPlatformQuotaModal', () => {
it('未配置限额的平台重置按钮禁用并提示不可用', async () => {
const w = await mountAndOpen()
const resetBtns = w.findAll('button').filter((b) => b.text() === '↻')
expect(resetBtns.length).toBe(36) // 12 平台 × 3 窗口
expect(resetBtns.length).toBe(39) // 13 平台 × 3 窗口
for (const b of resetBtns) {
expect((b.element as HTMLButtonElement).disabled).toBe(true)
expect(b.attributes('title')).toBe('admin.users.platformQuota.reset.unavailable')
@@ -65,6 +65,12 @@
<path d="M4432 2348 l3 -224 33 -66 c38 -77 92 -130 171 -167 48 -22 70 -26 146 -26 82 0 97 3 157 33 77 38 130 92 167 171 22 47 26 70 26 146 0 76 -4 99 -26 146 -37 79 -90 133 -167 171 l-66 33 -224 3 -223 3 3 -223z" />
</g>
</svg>
<!-- Cline official mark (robot head), from cline.bot/assets/branding/logos/cline-icon-dark.svg -->
<svg v-else-if="platform === 'cline'" :class="sizeClass" viewBox="0 0 112 112" fill="currentColor">
<path d="M56 3.99805L56.0001 0.298047H56V3.99805ZM68 15.708L71.7 15.7081V15.708H68ZM67.4932 20L63.8665 19.2671L62.9706 23.7H67.4932V20ZM84 20L84 16.3H84V20ZM95.2002 44.7998H91.5002V46.2066L92.4349 47.258L95.2002 44.7998ZM107.527 58.667L110.293 56.2088L110.293 56.2088L107.527 58.667ZM107.527 59.7305L110.293 62.1887L110.293 62.1883L107.527 59.7305ZM95.2002 73.5977L92.4349 71.1394L91.5002 72.1909V73.5977H95.2002ZM95.2002 87.2002L98.9002 87.2002V87.2002H95.2002ZM84 98.4004V102.1H84L84 98.4004ZM16.7998 87.2002H13.0998V87.2002L16.7998 87.2002ZM16.7998 73.5986H20.4998V72.1918L19.5651 71.1404L16.7998 73.5986ZM4.47168 59.7305L1.70627 62.1886L1.70635 62.1887L4.47168 59.7305ZM4.47168 58.667L7.23709 61.1251L7.23709 61.1251L4.47168 58.667ZM16.7998 44.7979L19.5652 47.256L20.4998 46.2046V44.7979H16.7998ZM44.5068 20V23.7H49.0294L48.1335 19.267L44.5068 20ZM44 15.708H40.3V15.7081L44 15.708ZM56 3.99805L55.9999 7.69805C60.6814 7.69818 64.3 11.3247 64.3 15.708H68H71.7C71.7 7.04515 64.5731 0.298294 56.0001 0.298047L56 3.99805ZM68 15.708L64.3 15.7079C64.3 16.6154 64.1616 17.8066 63.8665 19.2671L67.4932 20L71.1198 20.7329C71.4693 19.004 71.6999 17.2864 71.7 15.7081L68 15.708ZM67.4932 20V23.7H84V20V16.3H67.4932V20ZM84 20L84 23.7C88.1421 23.7001 91.5002 27.0582 91.5002 31.2002H95.2002H98.9002C98.9002 22.9712 92.2289 16.3001 84 16.3L84 20ZM95.2002 31.2002H91.5002V44.7998H95.2002H98.9002V31.2002H95.2002ZM95.2002 44.7998L92.4349 47.258L104.762 61.1252L107.527 58.667L110.293 56.2088L97.9655 42.3416L95.2002 44.7998ZM107.527 58.667L104.762 61.1251C103.785 60.026 103.786 58.371 104.762 57.2727L107.527 59.7305L110.293 62.1883C111.808 60.4837 111.809 57.9141 110.293 56.2088L107.527 58.667ZM107.527 59.7305L104.762 57.2722L92.4349 71.1394L95.2002 73.5977L97.9655 76.0559L110.293 62.1887L107.527 59.7305ZM95.2002 73.5977H91.5002V87.2002H95.2002H98.9002V73.5977H95.2002ZM95.2002 87.2002L91.5002 87.2002C91.5002 91.3422 88.1421 94.7003 84 94.7004L84 98.4004L84 102.1C92.2289 102.1 98.9001 95.4292 98.9002 87.2002L95.2002 87.2002ZM84 98.4004V94.7004H28V98.4004V102.1H84V98.4004ZM28 98.4004V94.7004C23.8579 94.7004 20.4998 91.3423 20.4998 87.2002L16.7998 87.2002L13.0998 87.2002C13.0998 95.4292 19.771 102.1 28 102.1V98.4004ZM16.7998 87.2002H20.4998V73.5986H16.7998H13.0998V87.2002H16.7998ZM16.7998 73.5986L19.5651 71.1404L7.23701 57.2722L4.47168 59.7305L1.70635 62.1887L14.0345 76.0569L16.7998 73.5986ZM4.47168 59.7305L7.23709 57.2723C8.21378 58.3711 8.21378 60.0264 7.23709 61.1251L4.47168 58.667L1.70627 56.2088C0.190721 57.9138 0.190721 60.4836 1.70627 62.1886L4.47168 59.7305ZM4.47168 58.667L7.23709 61.1251L19.5652 47.256L16.7998 44.7979L14.0344 42.3397L1.70627 56.2088L4.47168 58.667ZM16.7998 44.7979H20.4998V31.2002H16.7998H13.0998V44.7979H16.7998ZM16.7998 31.2002H20.4998C20.4998 27.0581 23.8579 23.7 28 23.7V20V16.3C19.771 16.3 13.0998 22.9712 13.0998 31.2002H16.7998ZM28 20V23.7H44.5068V20V16.3H28V20ZM44.5068 20L48.1335 19.267C47.8384 17.8066 47.7 16.6154 47.7 15.7079L44 15.708L40.3 15.7081C40.3001 17.2864 40.5307 19.0039 40.8802 20.733L44.5068 20ZM44 15.708H47.7C47.7 11.3244 51.3185 7.69805 56 7.69805V3.99805V0.298047C47.4267 0.298047 40.3 7.04514 40.3 15.708H44Z" />
<rect x="65.5996" y="44.8008" width="9.6" height="28.8" rx="4.8" />
<rect x="36.8008" y="44.8008" width="9.6" height="28.8" rx="4.8" />
</svg>
<!-- Composite group icon -->
<svg v-else-if="platform === 'composite'" :class="sizeClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="6" cy="12" r="3" />
@@ -1336,6 +1336,7 @@ function generateRoutedCodexFiles(
opencode_go: 'OpenCode',
typesafe: 'TypeSafe / Jev',
command_code: 'Command Code',
cline: 'Cline',
composite: 'Composite'
}
const label = labels[platform]
@@ -26,7 +26,8 @@ const concretePlatforms = [
'minimax',
'opencode_go',
'typesafe',
'command_code'
'command_code',
'cline'
]
describe('platform option catalogs', () => {
@@ -59,11 +60,11 @@ describe('platform catalog with a newly registered platform', () => {
expect(listPlatformIds()).toEqual(concretePlatforms)
expect(compositePrecedencePlatformIds()).toEqual([
'anthropic', 'gemini', 'openai', 'antigravity', 'grok',
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe', 'command_code'
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe', 'command_code', 'cline'
])
expect(CONCRETE_PLATFORM_OPTIONS.map((option) => option.label)).toEqual([
'Anthropic', 'OpenAI', 'Gemini', 'Antigravity', 'Grok',
'Kimi', 'Zhipu GLM', 'DeepSeek', 'MiniMax', 'OpenCode', 'TypeSafe / Jev', 'Command Code'
'Kimi', 'Zhipu GLM', 'DeepSeek', 'MiniMax', 'OpenCode', 'TypeSafe / Jev', 'Command Code', 'Cline'
])
expect(platformLabel('zhipu')).toBe('Zhipu GLM')
expect(platformLabel('composite')).toBe('Composite')
@@ -256,6 +256,24 @@
}
]
}
},
{
"id": "cline",
"display_name": "Cline",
"gateway": "openai",
"cn_provider": false,
"multi_protocol": {
"default_mode": "payg",
"routing": "by_inbound",
"modes": [
{
"mode": "payg",
"base_urls": {
"chat_completions": "https://api.cline.bot/api/v1"
}
}
]
}
}
],
"composite_precedence": [
@@ -270,6 +288,7 @@
"minimax",
"opencode_go",
"typesafe",
"command_code"
"command_code",
"cline"
]
}
+1 -1
View File
@@ -923,7 +923,7 @@ export interface UpdateGroupRequest {
// ==================== Account & Proxy Types ====================
/** 前端内置专属界面(图标、配色、表单等)的平台。 */
export type KnownAccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'kimi' | 'zhipu' | 'deepseek' | 'minimax' | 'opencode_go' | 'typesafe' | 'command_code'
export type KnownAccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'kimi' | 'zhipu' | 'deepseek' | 'minimax' | 'opencode_go' | 'typesafe' | 'command_code' | 'cline'
/**
* 账号平台:内置平台,或后端平台清单中新登记的平台(任意字符串)。
* `string & {}` 保留内置平台的字面量补全。
+2 -1
View File
@@ -18,7 +18,8 @@ const PROVIDER_BY_PLATFORM: Record<GroupPlatform, KeyGroupProvider> = {
composite: 'other',
opencode_go: 'other',
typesafe: 'other',
command_code: 'other'
command_code: 'other',
cline: 'other'
}
export function getKeyGroupProvider(platform: GroupPlatform): KeyGroupProvider {
+15
View File
@@ -21,6 +21,7 @@ export type Platform =
| 'opencode_go'
| 'typesafe'
| 'command_code'
| 'cline'
| 'composite'
// ── Badge (bg + text + border, for inline badges with border) ───────
@@ -37,6 +38,7 @@ const BADGE: Record<Platform, string> = {
opencode_go: 'bg-amber-500/10 text-amber-700 border-amber-500/30 dark:text-amber-300',
typesafe: 'bg-sky-500/10 text-sky-700 border-sky-500/30 dark:text-sky-300',
command_code: 'bg-neutral-500/10 text-neutral-700 border-neutral-500/30 dark:text-neutral-300',
cline: 'bg-violet-500/10 text-violet-600 border-violet-500/30 dark:text-violet-400',
composite: 'bg-cyan-500/10 text-cyan-700 border-cyan-500/30 dark:text-cyan-300',
}
const BADGE_DEFAULT = 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-400'
@@ -55,6 +57,7 @@ const BADGE_LIGHT: Record<Platform, string> = {
opencode_go: 'bg-amber-500/10 text-amber-700 dark:bg-amber-500/10 dark:text-amber-300',
typesafe: 'bg-sky-500/10 text-sky-700 dark:bg-sky-500/10 dark:text-sky-300',
command_code: 'bg-neutral-500/10 text-neutral-700 dark:bg-neutral-500/10 dark:text-neutral-300',
cline: 'bg-violet-500/10 text-violet-600 dark:bg-violet-500/10 dark:text-violet-300',
composite: 'bg-cyan-500/10 text-cyan-700 dark:bg-cyan-500/10 dark:text-cyan-300',
}
@@ -72,6 +75,7 @@ const BORDER: Record<Platform, string> = {
opencode_go: 'border-amber-500/20 dark:border-amber-500/20',
typesafe: 'border-sky-500/20 dark:border-sky-500/20',
command_code: 'border-neutral-500/20 dark:border-neutral-500/20',
cline: 'border-violet-500/20 dark:border-violet-500/20',
composite: 'border-cyan-500/20 dark:border-cyan-500/20',
}
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
@@ -90,6 +94,7 @@ const BORDER_STRONG: Record<Platform, string> = {
opencode_go: 'border-amber-500/35 dark:border-amber-500/30',
typesafe: 'border-sky-500/35 dark:border-sky-500/30',
command_code: 'border-neutral-500/35 dark:border-neutral-500/30',
cline: 'border-violet-500/35 dark:border-violet-500/30',
composite: 'border-cyan-500/35 dark:border-cyan-500/30',
}
const BORDER_STRONG_DEFAULT = 'border-gray-300 dark:border-dark-600'
@@ -109,6 +114,7 @@ const ACCENT: Record<Platform, string> = {
opencode_go: '#f59e0b', // amber-500
typesafe: '#0ea5e9', // sky-500
command_code: '#737373', // neutral-500
cline: '#8b5cf6', // violet-500(Cline 品牌紫 #9F58FA)
composite: '#06b6d4', // cyan-500
}
const ACCENT_DEFAULT = '#14b8a6' // primary-500 (teal)
@@ -127,6 +133,7 @@ const ACCENT_BAR: Record<Platform, string> = {
opencode_go: 'bg-gradient-to-r from-amber-400 to-amber-500',
typesafe: 'bg-gradient-to-r from-sky-400 to-sky-500',
command_code: 'bg-gradient-to-r from-neutral-400 to-neutral-500',
cline: 'bg-gradient-to-r from-violet-400 to-violet-500',
composite: 'bg-gradient-to-r from-slate-500 to-cyan-500',
}
const ACCENT_BAR_DEFAULT = 'bg-gradient-to-r from-primary-400 to-primary-500'
@@ -145,6 +152,7 @@ const TEXT: Record<Platform, string> = {
opencode_go: 'text-amber-700 dark:text-amber-300',
typesafe: 'text-sky-700 dark:text-sky-300',
command_code: 'text-neutral-700 dark:text-neutral-300',
cline: 'text-violet-600 dark:text-violet-400',
composite: 'text-cyan-700 dark:text-cyan-300',
}
const TEXT_DEFAULT = 'text-primary-600 dark:text-primary-400'
@@ -163,6 +171,7 @@ const ICON: Record<Platform, string> = {
opencode_go: 'text-amber-500 dark:text-amber-300',
typesafe: 'text-sky-500 dark:text-sky-300',
command_code: 'text-neutral-500 dark:text-neutral-300',
cline: 'text-violet-500 dark:text-violet-400',
composite: 'text-cyan-600 dark:text-cyan-300',
}
const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400'
@@ -181,6 +190,7 @@ const BUTTON: Record<Platform, string> = {
opencode_go: 'bg-amber-500 text-white hover:bg-amber-600 active:bg-amber-700 dark:bg-amber-500/80 dark:hover:bg-amber-500',
typesafe: 'bg-sky-600 text-white hover:bg-sky-700 active:bg-sky-800 dark:bg-sky-600/80 dark:hover:bg-sky-600',
command_code: 'bg-neutral-500 text-white hover:bg-neutral-600 active:bg-neutral-700 dark:bg-neutral-500/80 dark:hover:bg-neutral-500',
cline: 'bg-violet-500 text-white hover:bg-violet-600 active:bg-violet-700 dark:bg-violet-500/80 dark:hover:bg-violet-500',
composite: 'bg-cyan-700 text-white hover:bg-cyan-800 active:bg-cyan-900 dark:bg-cyan-600 dark:hover:bg-cyan-500',
}
const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
@@ -199,6 +209,7 @@ const DISCOUNT: Record<Platform, string> = {
opencode_go: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
typesafe: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
command_code: 'bg-neutral-100 text-neutral-800 dark:bg-neutral-900/40 dark:text-neutral-300',
cline: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
composite: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
}
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
@@ -217,6 +228,7 @@ const GRADIENT: Record<Platform, string> = {
opencode_go: 'from-amber-500 to-amber-600',
typesafe: 'from-sky-500 to-sky-600',
command_code: 'from-neutral-500 to-neutral-600',
cline: 'from-violet-500 to-violet-600',
composite: 'from-slate-600 to-cyan-600',
}
const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
@@ -235,6 +247,7 @@ const GRADIENT_TEXT: Record<Platform, string> = {
opencode_go: 'text-amber-100',
typesafe: 'text-sky-100',
command_code: 'text-neutral-100',
cline: 'text-violet-100',
composite: 'text-cyan-100',
}
const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
@@ -252,6 +265,7 @@ const GRADIENT_SUBTEXT: Record<Platform, string> = {
opencode_go: 'text-amber-200',
typesafe: 'text-sky-200',
command_code: 'text-neutral-200',
cline: 'text-violet-200',
composite: 'text-cyan-200',
}
const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
@@ -272,6 +286,7 @@ function isPlatform(p: string): p is Platform {
p === 'opencode_go' ||
p === 'typesafe' ||
p === 'command_code' ||
p === 'cline' ||
p === 'composite'
)
}