From 874871a25a1441399d1cc416ded79c564897671f Mon Sep 17 00:00:00 2001 From: SavitarC <71271200+SavitarC@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:52:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(platform):=20=E6=8E=A5=E5=85=A5=20Cline=20?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/internal/domain/constants.go | 5 +- backend/internal/domain/platforms.go | 1 + backend/internal/domain/platforms_test.go | 7 +- .../model/error_passthrough_rule_test.go | 1 + backend/internal/server/api_contract_test.go | 4 +- backend/internal/service/account.go | 8 +- .../internal/service/account_test_service.go | 7 +- .../account_test_service_cn_adaptive.go | 4 +- .../internal/service/channel_service_test.go | 2 +- backend/internal/service/cline.go | 85 +++ backend/internal/service/cline_test.go | 647 ++++++++++++++++++ backend/internal/service/cline_usage.go | 368 ++++++++++ .../cn_provider_balance_check_service.go | 81 ++- .../service/cn_provider_balance_service.go | 10 + .../service/cn_provider_quota_service.go | 13 +- .../service/command_code_usage_test.go | 11 +- .../service/composite_platform_test.go | 4 +- backend/internal/service/domain_constants.go | 3 + backend/internal/service/model_rate_limit.go | 15 + .../internal/service/platform_list_test.go | 19 +- backend/internal/service/provider_profile.go | 27 + backend/internal/service/ratelimit_cline.go | 233 +++++++ .../service/ratelimit_cn_providers.go | 13 +- backend/internal/service/ratelimit_service.go | 13 + .../service/upstream_billing_probe.go | 1 + .../settings.authSourceDefaults.spec.ts | 2 +- .../components/account/CreateAccountModal.vue | 17 +- .../components/account/EditAccountModal.vue | 3 +- .../__tests__/AccountUsageCell.spec.ts | 23 + .../__tests__/CreateAccountModal.spec.ts | 50 +- ...credentialsBuilder.platformCatalog.spec.ts | 36 +- .../components/account/credentialsBuilder.ts | 26 +- .../__tests__/UserPlatformQuotaModal.spec.ts | 20 +- .../src/components/common/PlatformIcon.vue | 6 + frontend/src/components/keys/UseKeyModal.vue | 1 + .../src/constants/__tests__/platforms.spec.ts | 7 +- .../constants/platformCatalog.builtin.json | 21 +- frontend/src/types/index.ts | 2 +- frontend/src/utils/keyGroupProviders.ts | 3 +- frontend/src/utils/platformColors.ts | 15 + 40 files changed, 1704 insertions(+), 110 deletions(-) create mode 100644 backend/internal/service/cline.go create mode 100644 backend/internal/service/cline_test.go create mode 100644 backend/internal/service/cline_usage.go create mode 100644 backend/internal/service/ratelimit_cline.go diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 3ec7bacf07..f889281e87 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -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」两种接入方式。 diff --git a/backend/internal/domain/platforms.go b/backend/internal/domain/platforms.go index ec7497c600..1bb41813a0 100644 --- a/backend/internal/domain/platforms.go +++ b/backend/internal/domain/platforms.go @@ -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 { diff --git a/backend/internal/domain/platforms_test.go b/backend/internal/domain/platforms_test.go index 0d075b1331..248218a322 100644 --- a/backend/internal/domain/platforms_test.go +++ b/backend/internal/domain/platforms_test.go @@ -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: "", } ) diff --git a/backend/internal/model/error_passthrough_rule_test.go b/backend/internal/model/error_passthrough_rule_test.go index 10f9679854..9b66d71243 100644 --- a/backend/internal/model/error_passthrough_rule_test.go +++ b/backend/internal/model/error_passthrough_rule_test.go @@ -20,5 +20,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) { "opencode_go", "typesafe", "command_code", + "cline", }, AllPlatforms()) } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 60b817983f..5f9033d776 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -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, diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index cee477b22f..f5ce7c8de0 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -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 "" } diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index e6d1f77fbe..4133f01d04 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -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 } diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go index 83d03371d2..1ac31befcc 100644 --- a/backend/internal/service/account_test_service_cn_adaptive.go +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -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 diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index 0ecf1e8564..cb854fe00a 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -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 { diff --git a/backend/internal/service/cline.go b/backend/internal/service/cline.go new file mode 100644 index 0000000000..75d54c0ad4 --- /dev/null +++ b/backend/internal/service/cline.go @@ -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 +} diff --git a/backend/internal/service/cline_test.go b/backend/internal/service/cline_test.go new file mode 100644 index 0000000000..04cae1e3db --- /dev/null +++ b/backend/internal/service/cline_test.go @@ -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")) +} diff --git a/backend/internal/service/cline_usage.go b/backend/internal/service/cline_usage.go new file mode 100644 index 0000000000..dd796994bd --- /dev/null +++ b/backend/internal/service/cline_usage.go @@ -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 ;实测带 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)) +} diff --git a/backend/internal/service/cn_provider_balance_check_service.go b/backend/internal/service/cn_provider_balance_check_service.go index 975de23345..30ef0bd64c 100644 --- a/backend/internal/service/cn_provider_balance_check_service.go +++ b/backend/internal/service/cn_provider_balance_check_service.go @@ -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) } diff --git a/backend/internal/service/cn_provider_balance_service.go b/backend/internal/service/cn_provider_balance_service.go index 1bac2ef3d9..87e8a24cea 100644 --- a/backend/internal/service/cn_provider_balance_service.go +++ b/backend/internal/service/cn_provider_balance_service.go @@ -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() { diff --git a/backend/internal/service/cn_provider_quota_service.go b/backend/internal/service/cn_provider_quota_service.go index 38c290db16..def7586fa4 100644 --- a/backend/internal/service/cn_provider_quota_service.go +++ b/backend/internal/service/cn_provider_quota_service.go @@ -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") } diff --git a/backend/internal/service/command_code_usage_test.go b/backend/internal/service/command_code_usage_test.go index dc76c27a99..fc720111a1 100644 --- a/backend/internal/service/command_code_usage_test.go +++ b/backend/internal/service/command_code_usage_test.go @@ -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 { diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index aac637b50d..5a1db4da58 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -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)) } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 9b751aac5b..ad9e63d3c7 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -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)。 diff --git a/backend/internal/service/model_rate_limit.go b/backend/internal/service/model_rate_limit.go index d76bc423fe..6d1b770430 100644 --- a/backend/internal/service/model_rate_limit.go +++ b/backend/internal/service/model_rate_limit.go @@ -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 diff --git a/backend/internal/service/platform_list_test.go b/backend/internal/service/platform_list_test.go index 4612150ab6..e6a0d83af8 100644 --- a/backend/internal/service/platform_list_test.go +++ b/backend/internal/service/platform_list_test.go @@ -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()) diff --git a/backend/internal/service/provider_profile.go b/backend/internal/service/provider_profile.go index 04e9c74f83..37841403a7 100644 --- a/backend/internal/service/provider_profile.go +++ b/backend/internal/service/provider_profile.go @@ -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。 diff --git a/backend/internal/service/ratelimit_cline.go b/backend/internal/service/ratelimit_cline.go new file mode 100644 index 0000000000..fb9d4fa5a1 --- /dev/null +++ b/backend/internal/service/ratelimit_cline.go @@ -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 +} diff --git a/backend/internal/service/ratelimit_cn_providers.go b/backend/internal/service/ratelimit_cn_providers.go index b25e2c39a7..226f7bdb0f 100644 --- a/backend/internal/service/ratelimit_cn_providers.go +++ b/backend/internal/service/ratelimit_cn_providers.go @@ -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 } } diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 488c8042d5..29c121b3cb 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -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: diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index 0529322c90..d9a769e3c4 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -1046,6 +1046,7 @@ var upstreamBillingProbeOfficialAPIDomains = []string{ "opencode.ai", "typesafe.ai", "commandcode.ai", + "cline.bot", } func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool { diff --git a/frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts b/frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts index 6ec179bb11..fe84bdcfb6 100644 --- a/frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts +++ b/frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts @@ -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,用于断言归一化默认值 */ diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 759571b867..2ea44ee11c 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -591,7 +591,7 @@ > - {{ providerModeLabel(mode) }} + {{ providerModeLabel(mode, t) }} @@ -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 } diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 55ac086b96..949ac2386b 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -144,7 +144,7 @@ ]" @click="editAccountMode = mode" > - {{ mode }} + {{ providerModeLabel(mode, t) }} @@ -3176,6 +3176,7 @@ import { isMultiProtocolApiKeyPlatform, parseOpenCodeGoProtocolRules, providerAccountModes, + providerModeLabel, providerNativeProtocols, providerRoutesByModel, resolveProviderAccountMode, diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts index da4b857e73..71c5f5d7a5 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts @@ -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: { diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 3d66eda2ea..63aa5d1631 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -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') diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.platformCatalog.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.platformCatalog.spec.ts index c93794e966..4feec2d4f0 100644 --- a/frontend/src/components/account/__tests__/credentialsBuilder.platformCatalog.spec.ts +++ b/frontend/src/components/account/__tests__/credentialsBuilder.platformCatalog.spec.ts @@ -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: [ diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index 1aa83066e1..71fa7d98ab 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -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 } -// 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' } diff --git a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts index 2e1ae541d5..3b9788f5c5 100644 --- a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts +++ b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts @@ -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') diff --git a/frontend/src/components/common/PlatformIcon.vue b/frontend/src/components/common/PlatformIcon.vue index 488aad6312..a60d2aea53 100644 --- a/frontend/src/components/common/PlatformIcon.vue +++ b/frontend/src/components/common/PlatformIcon.vue @@ -65,6 +65,12 @@ + + + + + + diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index dc5dc45b6f..e0dece4ea8 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -1336,6 +1336,7 @@ function generateRoutedCodexFiles( opencode_go: 'OpenCode', typesafe: 'TypeSafe / Jev', command_code: 'Command Code', + cline: 'Cline', composite: 'Composite' } const label = labels[platform] diff --git a/frontend/src/constants/__tests__/platforms.spec.ts b/frontend/src/constants/__tests__/platforms.spec.ts index 6a169fccd4..d4d0ea97cc 100644 --- a/frontend/src/constants/__tests__/platforms.spec.ts +++ b/frontend/src/constants/__tests__/platforms.spec.ts @@ -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') diff --git a/frontend/src/constants/platformCatalog.builtin.json b/frontend/src/constants/platformCatalog.builtin.json index 0fddf0d570..c17a6ff78a 100644 --- a/frontend/src/constants/platformCatalog.builtin.json +++ b/frontend/src/constants/platformCatalog.builtin.json @@ -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" ] } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ca802a716b..cfd3f2f167 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 & {}` 保留内置平台的字面量补全。 diff --git a/frontend/src/utils/keyGroupProviders.ts b/frontend/src/utils/keyGroupProviders.ts index 41233820c5..fbdc8d0fd1 100644 --- a/frontend/src/utils/keyGroupProviders.ts +++ b/frontend/src/utils/keyGroupProviders.ts @@ -18,7 +18,8 @@ const PROVIDER_BY_PLATFORM: Record = { composite: 'other', opencode_go: 'other', typesafe: 'other', - command_code: 'other' + command_code: 'other', + cline: 'other' } export function getKeyGroupProvider(platform: GroupPlatform): KeyGroupProvider { diff --git a/frontend/src/utils/platformColors.ts b/frontend/src/utils/platformColors.ts index 21f7c26d44..673cb36723 100644 --- a/frontend/src/utils/platformColors.ts +++ b/frontend/src/utils/platformColors.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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' ) }