mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
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>
369 lines
14 KiB
Go
369 lines
14 KiB
Go
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))
|
||
}
|