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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:11:06 +08:00

299 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"log/slog"
"net/http"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/tidwall/gjson"
)
// 国产供应商(kimi/zhipu/deepseek)的响应式冷却辅助。
//
// 与 openai/anthropic 不同:
// - 余额不足是「可恢复」状态(充值/检测恢复后自动重新调度),不能走 handleAuthError
// 永久置 status=error。这里改为 SetTempUnschedulable,由 CN 余额检测周期任务
// (cn_provider_balance_check_service.go)在余额恢复后 ClearTempUnschedulable。
// - Coding Plan 滚动窗口耗尽(429)的冷却终点应是真实的窗口重置时间(已由
// CNProviderQuotaService 落入 account.Extra 快照),而非默认的秒级兜底。
// cnBalanceExtraSuffixLow 标记账号响应过「余额不足」,供余额检测任务区分
// 「确属余额不足」与「尚未探测」。
const cnBalanceExtraSuffixLow = "balance_low"
// cnBalanceLowReasonPrefix 是余额不足临时停调 reason 的稳定前缀。
// 周期余额检测任务据此识别「是我们停调的」并在余额恢复后安全清除——不会误清
// 其他子系统(阈值/限流/401)写入的临时停调。
const cnBalanceLowReasonPrefix = "cn_balance_low"
const kimiConcurrentRequestLimitMessage = "You've reached your concurrent request limit. Please wait for your ongoing requests to finish and try again."
const cnConcurrencyLimitReasonPrefix = "cn_concurrency_limit"
func isCNProviderConcurrencyLimit403(account *Account, upstreamMsg string) bool {
return account != nil && account.Platform == PlatformKimi &&
strings.TrimSpace(upstreamMsg) == kimiConcurrentRequestLimitMessage
}
// cnQuotaExhausted403ErrorType 是 Kimi Coding Plan 配额窗口耗尽的 403 错误类型。
// 与并发限制 403(见 isCNProviderConcurrencyLimit403)不同,这是 5h/weekly
// 窗口耗尽的限流信号:窗口到期后自动恢复,绝不能落入通用 403 升级计数
// (连续 3 次永久 SetError),必须按 429 口径冷却到真实窗口重置点。
const cnQuotaExhausted403ErrorType = "access_terminated_error"
// cnQuotaExhaustedReasonPrefix 是配额耗尽 403 临时停调 reason 的稳定前缀。
const cnQuotaExhaustedReasonPrefix = "cn_quota_exhausted"
// isCNProviderQuotaExhausted403 识别 CN 供应商配额窗口耗尽的 403。
// 先做廉价的文案子串匹配(usage limit / quota will reset),未命中再解析
// 响应体匹配结构化错误类型(Kimi 的 access_terminated_error),以兼容同族
// 供应商的文案变体。与并发限制文案互斥,两类信号不会误判。
// 仅限 Coding Plan 账号:滚动窗口快照(含重置时间)只有 Coding Plan 账号才有,
// 非 Coding Plan 账号的同类 403 语义不明(可能是需要升级套餐的硬上限),
// 应回退到通用 403 升级逻辑而非无限临时冷却。
func isCNProviderQuotaExhausted403(account *Account, responseBody []byte, upstreamMsg string) bool {
if account == nil || !account.IsCNProvider() || !account.IsCodingPlan() {
return false
}
msg := strings.ToLower(strings.TrimSpace(upstreamMsg))
if strings.Contains(msg, "usage limit") || strings.Contains(msg, "quota will reset") {
return true
}
return strings.EqualFold(strings.TrimSpace(gjson.GetBytes(responseBody, "error.type").String()), cnQuotaExhausted403ErrorType)
}
// handleCNProviderQuotaExhausted403 把 CN 供应商配额窗口耗尽的 403 按 429
// 同口径处理:有配额快照时冷却到最早的未来窗口重置点(SetRateLimited,
// 窗口过期后调度自动恢复);快照缺失(额度探测尚未刷新)时兜底默认 403
// 冷却时长的临时停调,避免过度停调到数天后的窗口。无论哪条路径都不写
// 账号 error 状态。
func (s *RateLimitService) handleCNProviderQuotaExhausted403(
ctx context.Context,
account *Account,
upstreamMsg string,
) {
if s.cooldownCNProviderToQuotaSnapshotReset(ctx, account, cnQuotaExhaustedReasonPrefix, "cn_quota_exhausted_rate_limited") != nil {
return
}
// upstreamMsg 由调用方(HandleUpstreamError)从同一响应体提取,此处直接复用;
// 为空时仅存前缀,保证 reason 稳定可检索。
reason := cnQuotaExhaustedReasonPrefix
if msg := strings.TrimSpace(upstreamMsg); msg != "" {
reason += ": " + msg
}
until := time.Now().Add(time.Duration(openAI403CooldownMinutesDefault) * time.Minute)
s.setCNProviderTempUnschedulable(ctx, account, until, cnQuotaExhaustedReasonPrefix, reason, "cn_quota_exhausted_temp_unschedulable")
}
func (s *RateLimitService) handleCNProviderConcurrencyLimit403(
ctx context.Context,
account *Account,
) {
until := time.Now().Add(time.Duration(openAI403CooldownMinutesDefault) * time.Minute)
s.setCNProviderTempUnschedulable(ctx, account, until,
cnConcurrencyLimitReasonPrefix, cnConcurrencyLimitReasonPrefix+": "+kimiConcurrentRequestLimitMessage,
"cn_provider_concurrency_limited")
}
// cnBalanceLowReason 构造余额不足临时停调的 reason(带稳定前缀)。
func cnBalanceLowReason(upstreamMsg string) string {
if upstreamMsg = strings.TrimSpace(upstreamMsg); upstreamMsg != "" {
return cnBalanceLowReasonPrefix + ": " + upstreamMsg
}
return cnBalanceLowReasonPrefix + ": 余额不足,账号临时停调"
}
// cnProviderResponseIndicatesInsufficientBalance 通过响应体文案识别余额不足
// (智谱 payg 无独立余额端点,仅能靠响应文案识别)。
func cnProviderResponseIndicatesInsufficientBalance(body []byte) bool {
if len(body) == 0 {
return false
}
s := strings.ToLower(string(body))
return strings.Contains(s, "余额不足") ||
strings.Contains(s, "insufficient balance") ||
strings.Contains(s, "insufficient_credit") ||
strings.Contains(s, "balance is not enough") ||
strings.Contains(s, "no enough balance")
}
// handleCNProviderInsufficientBalance 把余额不足标记为可恢复的临时停调:
// 写入 balance_low 快照 + SetTempUnschedulable 一个余额检测周期,
// 由周期任务在余额恢复后清除。返回前已通知调度阻塞。
func (s *RateLimitService) handleCNProviderInsufficientBalance(
ctx context.Context,
account *Account,
upstreamMsg string,
) {
msg := cnBalanceLowReason(upstreamMsg)
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
cnExtraKey(account.Platform, cnBalanceExtraSuffixLow): true,
}); err != nil {
slog.Warn("cn_balance_low_mark_failed", "account_id", account.ID, "error", err)
}
until := time.Now().Add(s.cnBalanceCooldownDuration())
s.notifyAccountSchedulingBlocked(account, until, "cn_insufficient_balance")
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, msg); err != nil {
slog.Warn("cn_balance_set_temp_unschedulable_failed", "account_id", account.ID, "error", err)
return
}
slog.Info("cn_provider_insufficient_balance",
"account_id", account.ID,
"platform", account.Platform,
"until", until.UTC(),
)
}
// cnBalanceCooldownDuration 返回余额不足临时停调的持续时长(= 2× 余额检测周期,
// 默认 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 cfg != nil {
if cfgMin := 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
}
// cnProviderQuotaSnapshotReset 读取 Coding Plan 账号快照中最早一个仍在未来的窗口
// 重置时间(5h / weekly)。429 多数由 5h 滚动窗口触发,取较早的重置点可避免
// 把账号冷却到 weekly 重置(可达数天)的过度停调;如果确是 weekly 窗口耗尽,
// 周期额度探测刷新快照后阈值评估会再次停调到正确的时间点。
// 无快照或均已过期返回 nil。
func cnProviderQuotaSnapshotReset(account *Account, now time.Time) *time.Time {
if account == nil || len(account.Extra) == 0 {
return nil
}
switch {
case account.IsOpenCodeGo():
case account.IsCommandCode():
// 有充值积分时上游跳过窗口检查,429 与窗口无关。
if !commandCodeWindowsBinding(account.Extra) {
return nil
}
case account.IsCNProvider() && account.IsCodingPlan():
default:
return nil
}
provider := account.Platform
suffixes := []string{cnExtraSuffix5hReset, cnExtraSuffixWeeklyReset}
if cnQuotaHasMonthlyWindow(provider) {
suffixes = append(suffixes, cnExtraSuffixMonthlyReset)
}
var earliest *time.Time
for _, suffix := range suffixes {
t := parseSchedulingResetAt(account.Extra[cnExtraKey(provider, suffix)])
if t == nil || !t.After(now) {
continue
}
if earliest == nil || t.Before(*earliest) {
earliest = t
}
}
return earliest
}
// cooldownCNProviderToQuotaSnapshotReset 把账号冷却到配额快照中最早的未来窗口
// 重置点(SetRateLimited,窗口过期后调度自动恢复),供 429(OpenCodeGo /
// Coding Plan)与配额耗尽 403 共用。仅在冷却成功持久化后返回非 nil;
// 无快照或写库失败时返回 nil,由调用方落入各自的兜底逻辑(避免写库失败
// 时账号完全失去持久冷却)。
func (s *RateLimitService) cooldownCNProviderToQuotaSnapshotReset(ctx context.Context, account *Account, reason, logEvent string) *time.Time {
until := cnProviderQuotaSnapshotReset(account, time.Now())
if until == nil {
return nil
}
s.notifyAccountSchedulingBlocked(account, *until, reason)
if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil {
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
return nil
}
slog.Info(logEvent,
"account_id", account.ID,
"platform", account.Platform,
"reset_at", until.UTC(),
)
return until
}
// setCNProviderTempUnschedulable 写临时停调(含调度层通知与日志),供 CN 403
// 各分支(并发限制、配额耗尽兜底)共用。告警 key 由 notifyReason 派生,保持
// 各分支既有日志 key 稳定(<prefix>_set_temp_unschedulable_failed)。
func (s *RateLimitService) setCNProviderTempUnschedulable(ctx context.Context, account *Account, until time.Time, notifyReason, storeReason, logEvent string) {
s.notifyAccountSchedulingBlocked(account, until, notifyReason)
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, storeReason); err != nil {
slog.Warn(notifyReason+"_set_temp_unschedulable_failed", "account_id", account.ID, "error", err)
return
}
slog.Info(logEvent,
"account_id", account.ID,
"platform", account.Platform,
"until", until.UTC(),
)
}
// applyCNProviderReactive429 处理国产供应商的 429 响应。
// 返回 true 表示已处理(调用方应 return),false 表示未命中、继续走默认 429 逻辑。
func (s *RateLimitService) applyCNProviderReactive429(
ctx context.Context,
account *Account,
headers http.Header,
responseBody []byte,
) bool {
if account.IsOpenCodeGo() {
if s.cooldownCNProviderToQuotaSnapshotReset(ctx, account, "429", "opencode_go_rate_limited") != nil {
return true
}
if resetAt := parseOpenAIRateLimitResetTime(responseBody); resetAt != nil {
resetTime := time.Unix(*resetAt, 0)
s.notifyAccountSchedulingBlocked(account, resetTime, "429")
if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetTime); err != nil {
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
return true
}
slog.Info("opencode_go_rate_limited",
"account_id", account.ID,
"platform", account.Platform,
"reset_at", resetTime,
)
return true
}
return false
}
if !account.IsCNProvider() {
return false
}
// 1) 余额不足文案:可恢复临时停调(含智谱 payg 这类无余额端点的场景)。
if cnProviderResponseIndicatesInsufficientBalance(responseBody) {
s.handleCNProviderInsufficientBalance(ctx, account, extractUpstreamErrorMessage(responseBody))
return true
}
// 2) Coding Plan 窗口耗尽:冷却到快照中最早的窗口重置点(见
// cnProviderQuotaSnapshotReset:429 多由 5h 窗口触发,取较早点避免过度停调)。
if account.IsCodingPlan() &&
s.cooldownCNProviderToQuotaSnapshotReset(ctx, account, "429", "cn_coding_plan_rate_limited") != nil {
return true
}
return false
}