Files
sub2api/backend/internal/service/cline_usage.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

369 lines
14 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"
"errors"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"net/url"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/tidwall/gjson"
)
// Cline 积分余额与 ClinePass 用量来自账号接口(Cline 控制台与客户端同源),账号 API Key
// 可直接访问(Bearer <key>;实测带 workos: 前缀反而 401):
//
// GET /api/v1/users/me -> {id, email, ...}
// GET /api/v1/users/{id}/balance -> {balance, userId}
// GET /api/v1/users/me/plan/usage-limits -> {limits:[{type, percentUsed, resetsAt?}]}
//
// 响应包在 {success, error, data} 信封里。balance 单位为百万分之一积分(1 积分 =
// 1 美元;Cline CLI 的 normalizeCreditBalance 除以 1e6,扩展先除 100 再除 10000),
// 按 Cline 的展示精度保留 4 位小数。usage-limits 的 type 为 five_hour / weekly /
// monthly(套餐里对应最近 5 小时 / 7 天 / 30 天的滚动用量上限),percentUsed 为 0-100,
// 窗口未开始时没有 resetsAt;未订阅时返回 404(no plan history found for user)或空列表。
const clineAPIBase = "https://" + clineAPIHost
var errClineInvalidResponse = errors.New("invalid cline account response")
// clineHTTPError 是账号接口的非 2xx 响应。
type clineHTTPError struct {
status int
body string
}
func (e *clineHTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.status, e.body)
}
type clineAccountClient struct {
cfg *config.Config
httpUpstream HTTPUpstream
account *Account
apiKey string
proxyURL string
// errPrefix 是应用错误码前缀(CN_BALANCE / CN_QUOTA),与调用方服务的错误码一致。
errPrefix string
}
func newClineAccountClient(cfg *config.Config, httpUpstream HTTPUpstream, account *Account, proxyURL, errPrefix string) *clineAccountClient {
return &clineAccountClient{
cfg: cfg,
httpUpstream: httpUpstream,
account: account,
apiKey: strings.TrimSpace(account.GetCNAPIKey()),
proxyURL: proxyURL,
errPrefix: errPrefix,
}
}
// get 请求一个账号接口,返回拆掉 {success, error, data} 信封后的 JSON。
func (c *clineAccountClient) get(ctx context.Context, path string) (gjson.Result, error) {
validatedURL, err := cnValidateProbeURL(c.cfg, clineAPIBase+path)
if err != nil {
return gjson.Result{}, infraerrors.New(http.StatusForbidden, c.errPrefix+"_URL_REJECTED", err.Error())
}
callCtx, cancel := context.WithTimeout(ctx, cnQuotaUpstreamTimeout)
defer cancel()
req, err := http.NewRequestWithContext(callCtx, http.MethodGet, validatedURL, nil)
if err != nil {
return gjson.Result{}, infraerrors.Newf(http.StatusInternalServerError, c.errPrefix+"_REQUEST_BUILD_FAILED", "build request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Accept", "application/json")
c.account.ApplyHeaderOverrides(req.Header)
resp, err := c.httpUpstream.Do(req, c.proxyURL, c.account.ID, maxInt(c.account.Concurrency, 1))
if err != nil {
return gjson.Result{}, fmt.Errorf("upstream request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, cnQuotaMaxBodyBytes))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return gjson.Result{}, &clineHTTPError{status: resp.StatusCode, body: truncate(strings.TrimSpace(string(body)), 240)}
}
if !gjson.ValidBytes(body) {
return gjson.Result{}, fmt.Errorf("%w: %s", errClineInvalidResponse, path)
}
parsed := gjson.ParseBytes(body)
if success := parsed.Get("success"); success.Exists() {
if !success.Bool() {
return gjson.Result{}, &clineHTTPError{status: resp.StatusCode, body: truncate(parsed.Get("error").String(), 240)}
}
return parsed.Get("data"), nil
}
return parsed, nil
}
// balance 查询积分余额(美元)。
func (c *clineAccountClient) balance(ctx context.Context) (float64, error) {
me, err := c.get(ctx, "/api/v1/users/me")
if err != nil {
return 0, err
}
userID := strings.TrimSpace(me.Get("id").String())
if userID == "" {
return 0, fmt.Errorf("%w: missing user id", errClineInvalidResponse)
}
balance, err := c.get(ctx, "/api/v1/users/"+url.PathEscape(userID)+"/balance")
if err != nil {
return 0, err
}
micro := balance.Get("balance")
if !micro.Exists() || (micro.Type != gjson.Number && micro.Type != gjson.String) {
return 0, fmt.Errorf("%w: missing balance", errClineInvalidResponse)
}
return math.Round(micro.Float()/100) / 10000, nil
}
// passLimits 查询 ClinePass 三档窗口;subscribed=false 表示没有订阅。
func (c *clineAccountClient) passLimits(ctx context.Context) (tiers []CNQuotaTier, subscribed bool, err error) {
data, err := c.get(ctx, "/api/v1/users/me/plan/usage-limits")
if err != nil {
var httpErr *clineHTTPError
if errors.As(err, &httpErr) && httpErr.status == http.StatusNotFound {
return nil, false, nil
}
return nil, false, err
}
limits := data.Get("limits")
if !limits.IsArray() {
return nil, false, fmt.Errorf("%w: missing limits", errClineInvalidResponse)
}
windows := map[string]string{"five_hour": "5h", "weekly": "weekly", "monthly": "monthly"}
for _, item := range limits.Array() {
window, ok := windows[strings.ToLower(strings.TrimSpace(item.Get("type").String()))]
used := item.Get("percentUsed")
if !ok || used.Type != gjson.Number {
continue
}
tier := CNQuotaTier{Window: window, UsedPercent: used.Float()}
if resetAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(item.Get("resetsAt").String())); err == nil {
tier.ResetAt = resetAt.UTC().Format(time.RFC3339)
}
tiers = append(tiers, tier)
}
return tiers, len(tiers) > 0, nil
}
// clineFetchFailure 把取数错误转换为探测失败的状态码与文案;ok=false 表示应作为错误返回。
func clineFetchFailure(err error) (status int, message string, ok bool) {
var httpErr *clineHTTPError
switch {
case errors.As(err, &httpErr):
return httpErr.status, fmt.Sprintf("API error (HTTP %d): %s", httpErr.status, httpErr.body), true
case errors.Is(err, errClineInvalidResponse):
return http.StatusOK, "Invalid response", true
default:
return 0, "", false
}
}
func clineApplicationError(err error, errPrefix string) error {
if isApplicationError(err) {
return err
}
return infraerrors.Newf(http.StatusBadGateway, errPrefix+"_REQUEST_FAILED", "%v", err)
}
func clineBalanceResult(usd float64, now time.Time) *CNProviderBalanceResult {
return &CNProviderBalanceResult{
Provider: PlatformCline,
Success: true,
Balance: usd,
Currency: "USD",
Balances: []CNProviderBalanceEntry{{Currency: "USD", Balance: usd}},
Available: true,
StatusCode: http.StatusOK,
FetchedAt: now.Unix(),
}
}
// queryClineBalance 只查积分余额(美元)并落余额快照。
func (s *CNProviderBalanceService) queryClineBalance(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) {
client := newClineAccountClient(s.cfg, s.httpUpstream, account, s.resolveProxyURL(ctx, account), "CN_BALANCE")
if client.apiKey == "" {
return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_APIKEY", "account api_key is empty")
}
now := time.Now().UTC()
usd, err := client.balance(ctx)
if err != nil {
status, message, ok := clineFetchFailure(err)
if !ok {
return nil, clineApplicationError(err, "CN_BALANCE")
}
return &CNProviderBalanceResult{Provider: PlatformCline, StatusCode: status, FetchedAt: now.Unix(), Available: true,
Error: "Balance query failed: " + message}, nil
}
result := clineBalanceResult(usd, now)
if err := s.accountRepo.UpdateExtra(ctx, account.ID, cnBalanceExtraUpdates(PlatformCline, result, now)); err != nil {
slog.Warn("cn_balance_persist_failed", "account_id", account.ID, "provider", PlatformCline, "error", err)
} else {
result.Persisted = true
}
return result, nil
}
// queryClineUsage 查询 ClinePass 窗口与积分余额,落快照,并按结果设置或解除两个钱包的
// 冷却(见 applyClineWallets)。余额查询失败不影响窗口结果。
func (s *CNProviderQuotaService) queryClineUsage(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) {
client := newClineAccountClient(s.cfg, s.httpUpstream, account, s.resolveProxyURL(ctx, account), "CN_QUOTA")
if client.apiKey == "" {
return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NO_APIKEY", "account api_key is empty")
}
now := time.Now().UTC()
result := &CNProviderQuotaProbeResult{
Provider: PlatformCline,
Source: "cline_usage_limits",
FetchedAt: now.Unix(),
}
tiers, subscribed, err := client.passLimits(ctx)
if err != nil {
status, message, ok := clineFetchFailure(err)
if !ok {
return nil, clineApplicationError(err, "CN_QUOTA")
}
result.StatusCode = status
result.Error = message
result.CredentialValid = status != http.StatusUnauthorized && status != http.StatusForbidden
return result, nil
}
result.StatusCode = http.StatusOK
result.Success = true
result.CredentialValid = true
result.Tiers = tiers
if subscribed {
result.PlanLevel = "ClinePass"
}
updates := cnQuotaExtraUpdates(PlatformCline, tiers, now)
present := make(map[string]CNQuotaTier, len(tiers))
for _, tier := range tiers {
present[tier.Window] = tier
}
// 缺席的窗口写 nil:取消订阅后不沿用旧快照(clinePassSubscribed 据此判断);窗口尚未
// 开始时没有重置时间,同样清掉上一次的值。
for _, window := range []struct{ name, used, reset string }{
{"5h", cnExtraSuffix5hUsed, cnExtraSuffix5hReset},
{"weekly", cnExtraSuffixWeeklyUsed, cnExtraSuffixWeeklyReset},
{"monthly", cnExtraSuffixMonthlyUsed, cnExtraSuffixMonthlyReset},
} {
tier, ok := present[window.name]
if !ok {
updates[cnExtraKey(PlatformCline, window.used)] = nil
}
if tier.ResetAt == "" {
updates[cnExtraKey(PlatformCline, window.reset)] = nil
}
}
if usd, err := client.balance(ctx); err != nil {
slog.Warn("cline_balance_probe_failed", "account_id", account.ID, "error", err)
} else {
result.Balance = clineBalanceResult(usd, now)
for key, value := range cnBalanceExtraUpdates(PlatformCline, result.Balance, now) {
updates[key] = value
}
}
if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil {
slog.Warn("cn_quota_persist_failed", "account_id", account.ID, "provider", PlatformCline, "error", err)
} else {
result.Persisted = true
if result.Balance != nil {
result.Balance.Persisted = true
}
}
s.applyClineWallets(ctx, account, subscribed, tiers, result.Balance, now)
return result, nil
}
const (
clineCreditsLowReason = "cline_credits_low"
// clinePassNoSubscriptionReason 是探测发现未订阅时的原因;与请求侧的未订阅 / 组织账户
// 共用 clinePassUnavailableReason 前缀,单独区分出探测能证明已恢复的这一种。
clinePassNoSubscriptionReason = clinePassUnavailableReason + ": no ClinePass subscription"
)
// applyClineWallets 按探测结果设置或解除两个钱包的冷却:
// - ClinePass:未订阅,或有窗口用满(≥100%)时冷却到该窗口的重置时间;可用时只解除
// 窗口用满与未订阅的冷却(组织账户、所需订阅档位与推理额度上限反映不到用量接口里,
// 仍等到期后由请求重新确认);
// - 积分:余额低于阈值时冷却,恢复后只解除积分类原因的冷却(组织花费上限无从探测,
// 仍等到期后由请求重新确认)。
//
// 没有重置时间时冷却 2 个检测周期,由下一次探测续期或解除。
func (s *CNProviderQuotaService) applyClineWallets(ctx context.Context, account *Account, subscribed bool, tiers []CNQuotaTier, balance *CNProviderBalanceResult, now time.Time) {
fallback := now.Add(cnBalanceCheckCooldown(s.cfg))
switch until, reason := clinePassWalletState(subscribed, tiers, now, fallback); {
case until != nil:
s.setClineWalletLimit(ctx, account, clinePassRateLimitKey, *until, reason, now)
case account.isRateLimitActiveForKey(clinePassRateLimitKey):
reason := account.modelRateLimitReason(clinePassRateLimitKey)
if strings.HasPrefix(reason, clinePassLimitReason) || strings.HasPrefix(reason, clinePassNoSubscriptionReason) {
s.setClineWalletLimit(ctx, account, clinePassRateLimitKey, now, "", now)
}
}
if balance == nil {
return
}
threshold := 0.0
if s.cfg != nil {
threshold = s.cfg.Gateway.CNProviders.BalanceThreshold
}
switch low := balance.Balance <= 0 || balance.Balance < threshold; {
case low:
s.setClineWalletLimit(ctx, account, clineCreditsRateLimitKey, fallback,
fmt.Sprintf("%s: balance %.4g USD below threshold %.2f", clineCreditsLowReason, balance.Balance, threshold), now)
case account.isRateLimitActiveForKey(clineCreditsRateLimitKey):
reason := account.modelRateLimitReason(clineCreditsRateLimitKey)
if strings.HasPrefix(reason, clineCreditsReason) || strings.HasPrefix(reason, clineCreditsLowReason) {
s.setClineWalletLimit(ctx, account, clineCreditsRateLimitKey, now, "", now)
}
}
}
// clinePassWalletState 返回 ClinePass 钱包应冷却到的时间;nil 表示可用。
func clinePassWalletState(subscribed bool, tiers []CNQuotaTier, now, fallback time.Time) (*time.Time, string) {
if !subscribed {
return &fallback, clinePassNoSubscriptionReason
}
var until *time.Time
var exhausted []string
for _, tier := range tiers {
if tier.UsedPercent < 100 {
continue
}
exhausted = append(exhausted, tier.Window)
resetAt := fallback
if parsed := parseSchedulingResetAt(tier.ResetAt); parsed != nil && parsed.After(now) && parsed.Sub(now) <= clinePassMaxCooldown {
resetAt = *parsed
}
if until == nil || resetAt.After(*until) {
until = &resetAt
}
}
if until == nil {
return nil, ""
}
return until, clinePassLimitReason + ": " + strings.Join(exhausted, ", ") + " window used up"
}
// setClineWalletLimit 写钱包冷却;until 不晚于 now 时即解除。
func (s *CNProviderQuotaService) setClineWalletLimit(ctx context.Context, account *Account, scope string, until time.Time, reason string, now time.Time) {
setAccountModelRateLimitSnapshot(account, scope, until, reason, now)
if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, scope, until, reason); err != nil {
slog.Warn("cline_wallet_limit_set_failed", "account_id", account.ID, "scope", scope, "error", err)
return
}
slog.Info("cline_wallet_limit", "account_id", account.ID, "scope", scope, "until", until.UTC(), "cleared", !until.After(now))
}