Files
sub2api/backend/internal/service/command_code_usage.go
T

512 lines
18 KiB
Go
Raw Normal View History

2026-10-03 12:51:00 +08:00
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
// Command Code 的积分与用量窗口来自 /alpha 下四个只读接口(官方 CLI /usage 同源,
// 社区插件实测复核),全部使用账号 API Key(Bearer):
//
// GET /alpha/whoami -> {user, org};个人账户 org 为 null
// GET /alpha/billing/credits[?orgId=] -> {credits:{monthlyCredits, purchasedCredits, freeCredits},
// windowLimits:{limited, fiveHour, weekly}}
// GET /alpha/billing/subscriptions[?orgId=] -> {data:{planId, currentPeriodStart, currentPeriodEnd}}
// GET /alpha/usage/summary[?orgId=][&since=] -> {totalCost, totalMonthlyCredits}
//
// 积分单位为美元。windowLimits 与 credits 平级;fiveHour / weekly 的 used、cap 为积分,
// resetAt 为 epoch 毫秒;limited=false(按量计费)时没有滚动窗口。月度窗口上游只给
// 剩余量(credits.monthlyCredits),上限按「本周期已用 + 剩余」还原。个人账户不能带
// orgId 参数(空值返回 400),只有组织账户才带。
//
// 订阅套餐的滚动窗口只限制套餐内积分:有充值积分时请求跳过窗口检查,此时窗口快照
// 不参与阈值停调与 429 冷却(见 commandCodeWindowsBinding),已有的窗口冷却在探测到
// 充值积分后解除(见 clearCommandCodeWindowCooldown)。
const (
commandCodeAPIBase = "https://api.commandcode.ai"
// commandCodeExtraSuffixPurchasedCredits 记录充值积分,供阈值停调与 429 冷却判断
// 滚动窗口是否生效。
commandCodeExtraSuffixPurchasedCredits = "purchased_credits"
)
var errCommandCodeInvalidResponse = errors.New("invalid command code usage response")
// commandCodePlanNames 与官方 CLI 的 planId 展示名一致;未收录的原样展示。
var commandCodePlanNames = map[string]string{
"individual-go": "Go",
"individual-goat": "GOAT",
"individual-pro": "Pro",
"individual-pro-v1": "Pro",
"individual-provider": "Provider",
"individual-max": "Max",
"individual-ultra": "Ultra",
"teams-pro": "Teams Pro",
}
// commandCodeUsageSupported 报告账号能否查询 Command Code 用量:API Key 账号且推理
// 端点指向官方主机。自定义中转的 Key 不发往官方用量接口。
func (a *Account) commandCodeUsageSupported() bool {
return a.IsCommandCode() && a.Type == AccountTypeAPIKey && isOfficialCommandCodeHost(a.GetOpenAIBaseURL())
}
// commandCodeNumber 兼容数字与数字字符串。
type commandCodeNumber float64
func (n *commandCodeNumber) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
*n = 0
return nil
}
if data[0] == '"' {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
v, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return err
}
*n = commandCodeNumber(v)
return nil
}
var v float64
if err := json.Unmarshal(data, &v); err != nil {
return err
}
*n = commandCodeNumber(v)
return nil
}
type commandCodeWindow struct {
Used commandCodeNumber `json:"used"`
Cap commandCodeNumber `json:"cap"`
ResetAt commandCodeNumber `json:"resetAt"`
}
type commandCodeCredits struct {
MonthlyCredits commandCodeNumber `json:"monthlyCredits"`
PurchasedCredits commandCodeNumber `json:"purchasedCredits"`
FreeCredits commandCodeNumber `json:"freeCredits"`
}
type commandCodeCreditsPayload struct {
Credits *commandCodeCredits `json:"credits"`
WindowLimits struct {
Limited bool `json:"limited"`
FiveHour *commandCodeWindow `json:"fiveHour"`
Weekly *commandCodeWindow `json:"weekly"`
} `json:"windowLimits"`
}
type commandCodeSubscriptionPayload struct {
Data struct {
PlanID string `json:"planId"`
CurrentPeriodStart string `json:"currentPeriodStart"`
CurrentPeriodEnd string `json:"currentPeriodEnd"`
} `json:"data"`
}
type commandCodeSummaryPayload struct {
TotalCost *commandCodeNumber `json:"totalCost"`
TotalMonthlyCredits *commandCodeNumber `json:"totalMonthlyCredits"`
}
// commandCodeUsage 是一次取数的汇总;subscription / summary 取数失败时为 nil,并置
// periodFetchFailed:此时无从判断月度窗口是否存在,不能当作没有月度窗口。
type commandCodeUsage struct {
credits commandCodeCreditsPayload
subscription *commandCodeSubscriptionPayload
summary *commandCodeSummaryPayload
periodFetchFailed bool
}
// commandCodeHTTPError 是 /alpha 接口的非 2xx 响应。
type commandCodeHTTPError struct {
status int
body string
}
func (e *commandCodeHTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.status, e.body)
}
func (e *commandCodeHTTPError) unauthorized() bool {
return e.status == http.StatusUnauthorized || e.status == http.StatusForbidden
}
func isCommandCodeAuthError(err error) bool {
var httpErr *commandCodeHTTPError
return errors.As(err, &httpErr) && httpErr.unauthorized()
}
type commandCodeUsageClient struct {
cfg *config.Config
httpUpstream HTTPUpstream
account *Account
apiKey string
proxyURL string
}
// get 请求一个 /alpha 接口并解码 JSON。
func (c *commandCodeUsageClient) get(ctx context.Context, path string, query url.Values, out any) error {
target := commandCodeAPIBase + path
if len(query) > 0 {
target += "?" + query.Encode()
}
validatedURL, err := cnValidateProbeURL(c.cfg, target)
if err != nil {
return infraerrors.New(http.StatusForbidden, "CN_QUOTA_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 infraerrors.Newf(http.StatusInternalServerError, "CN_QUOTA_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 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 &commandCodeHTTPError{status: resp.StatusCode, body: truncate(strings.TrimSpace(string(body)), 240)}
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("%w: %s: %v", errCommandCodeInvalidResponse, path, err)
}
return nil
}
// fetch 取积分与窗口;withPeriod 时再取订阅周期与本周期用量以还原月度窗口。
// whoami 用来取 orgId,任何失败都整体返回:无从判断是否组织账户时,按个人账户查会把
// 组织账号查成成员的个人钱包,并据此按余额停调。
func (c *commandCodeUsageClient) fetch(ctx context.Context, withPeriod bool) (*commandCodeUsage, error) {
var whoami struct {
Org *struct {
ID string `json:"id"`
} `json:"org"`
}
query := url.Values{}
if err := c.get(ctx, "/alpha/whoami", nil, &whoami); err != nil {
return nil, err
}
if whoami.Org != nil && strings.TrimSpace(whoami.Org.ID) != "" {
query.Set("orgId", strings.TrimSpace(whoami.Org.ID))
}
usage := &commandCodeUsage{}
if err := c.get(ctx, "/alpha/billing/credits", query, &usage.credits); err != nil {
return nil, err
}
if usage.credits.Credits == nil {
return nil, fmt.Errorf("%w: credits missing", errCommandCodeInvalidResponse)
}
if !withPeriod {
return usage, nil
}
var subscription commandCodeSubscriptionPayload
if err := c.get(ctx, "/alpha/billing/subscriptions", query, &subscription); err != nil {
if isCommandCodeAuthError(err) {
return nil, err
}
slog.Warn("command_code_subscription_failed", "account_id", c.account.ID, "error", err)
usage.periodFetchFailed = true
return usage, nil
}
usage.subscription = &subscription
periodStart := strings.TrimSpace(subscription.Data.CurrentPeriodStart)
if periodStart == "" {
return usage, nil
}
summaryQuery := url.Values{}
for key, values := range query {
summaryQuery[key] = values
}
summaryQuery.Set("since", periodStart)
var summary commandCodeSummaryPayload
if err := c.get(ctx, "/alpha/usage/summary", summaryQuery, &summary); err != nil {
if isCommandCodeAuthError(err) {
return nil, err
}
slog.Warn("command_code_usage_summary_failed", "account_id", c.account.ID, "error", err)
usage.periodFetchFailed = true
return usage, nil
}
usage.summary = &summary
return usage, nil
}
func isApplicationError(err error) bool {
var appErr *infraerrors.ApplicationError
return errors.As(err, &appErr)
}
func commandCodePercent(used, limit float64) float64 {
percent := used / limit * 100
if percent < 0 {
percent = 0
}
return math.Round(percent*100) / 100
}
// tiers 返回 5h / weekly(限额套餐)与月度窗口。
func (u *commandCodeUsage) tiers() []CNQuotaTier {
var tiers []CNQuotaTier
limits := u.credits.WindowLimits
if limits.Limited {
for _, item := range []struct {
window string
limit *commandCodeWindow
}{
{window: "5h", limit: limits.FiveHour},
{window: "weekly", limit: limits.Weekly},
} {
if item.limit == nil || item.limit.Cap <= 0 {
continue
}
tiers = append(tiers, CNQuotaTier{
Window: item.window,
UsedPercent: commandCodePercent(float64(item.limit.Used), float64(item.limit.Cap)),
ResetAt: cnMillisToRFC3339(int64(item.limit.ResetAt)),
})
}
}
if u.summary != nil && u.subscription != nil {
used := 0.0
switch {
case u.summary.TotalMonthlyCredits != nil:
used = float64(*u.summary.TotalMonthlyCredits)
case u.summary.TotalCost != nil:
used = float64(*u.summary.TotalCost)
}
if limit := used + float64(u.credits.Credits.MonthlyCredits); limit > 0 {
tiers = append(tiers, CNQuotaTier{
Window: "monthly",
UsedPercent: commandCodePercent(used, limit),
ResetAt: cnNormalizeResetTime(u.subscription.Data.CurrentPeriodEnd),
})
}
}
return tiers
}
func (u *commandCodeUsage) planName() string {
if u.subscription == nil {
return ""
}
planID := strings.TrimSpace(u.subscription.Data.PlanID)
if name, ok := commandCodePlanNames[planID]; ok {
return name
}
return planID
}
// balance 返回可用积分合计(套餐剩余 + 充值 + 赠送,美元)。
func (u *commandCodeUsage) balance(now time.Time) *CNProviderBalanceResult {
credits := u.credits.Credits
total := float64(credits.MonthlyCredits + credits.PurchasedCredits + credits.FreeCredits)
total = math.Round(total*10000) / 10000
return &CNProviderBalanceResult{
Provider: PlatformCommandCode,
Success: true,
Balance: total,
Currency: "USD",
Balances: []CNProviderBalanceEntry{{Currency: "USD", Balance: total}},
Available: true,
StatusCode: http.StatusOK,
FetchedAt: now.Unix(),
}
}
// clearCommandCodeWindowCooldown 在探测到充值积分时解除窗口用满的临时停调:充值积分
// 不受滚动窗口限制,上游已允许继续请求。只清除 commandCodeUsageLimitReason 前缀的停调,
// 积分不足、花费上限、管理员规则等其他原因的停调不受影响。
func clearCommandCodeWindowCooldown(ctx context.Context, repo AccountRepository, account *Account, usage *commandCodeUsage, now time.Time) bool {
if usage == nil || usage.credits.Credits == nil || usage.credits.Credits.PurchasedCredits <= 0 {
return false
}
if account.TempUnschedulableUntil == nil || !now.Before(*account.TempUnschedulableUntil) ||
!strings.HasPrefix(account.TempUnschedulableReason, commandCodeUsageLimitReason) {
return false
}
if err := repo.ClearTempUnschedulable(ctx, account.ID); err != nil {
slog.Warn("command_code_usage_limit_clear_failed", "account_id", account.ID, "error", err)
return false
}
slog.Info("command_code_usage_limit_cleared", "account_id", account.ID,
"purchased_credits", float64(usage.credits.Credits.PurchasedCredits))
return true
}
// commandCodeWindowsBinding 报告账号快照中的滚动 / 月度窗口是否限制调度:
// 有充值积分时上游跳过窗口检查,窗口用满也不会被拒绝。
func commandCodeWindowsBinding(extra map[string]any) bool {
purchased, ok := cnParseF64(extra[cnExtraKey(PlatformCommandCode, commandCodeExtraSuffixPurchasedCredits)])
return !ok || purchased <= 0
}
// commandCodeUsageExtraUpdates 构造窗口 + 余额快照。缺席的窗口写 nil,避免套餐
// 变化后沿用旧快照;窗口尚未开始时没有重置时间,同样清掉上一个窗口的值;月度窗口
// 取数失败时保留旧快照;传 nil tiers 时只写余额。
func commandCodeUsageExtraUpdates(usage *commandCodeUsage, tiers []CNQuotaTier, balance *CNProviderBalanceResult, now time.Time, withTiers bool) map[string]any {
updates := cnBalanceExtraUpdates(PlatformCommandCode, balance, now)
updates[cnExtraKey(PlatformCommandCode, commandCodeExtraSuffixPurchasedCredits)] = float64(usage.credits.Credits.PurchasedCredits)
if !withTiers {
return updates
}
for key, value := range cnQuotaExtraUpdates(PlatformCommandCode, tiers, now) {
updates[key] = value
}
present := make(map[string]CNQuotaTier, len(tiers))
for _, tier := range tiers {
present[tier.Window] = tier
}
for _, window := range []struct {
name, used, reset string
}{
{"5h", cnExtraSuffix5hUsed, cnExtraSuffix5hReset},
{"weekly", cnExtraSuffixWeeklyUsed, cnExtraSuffixWeeklyReset},
{"monthly", cnExtraSuffixMonthlyUsed, cnExtraSuffixMonthlyReset},
} {
tier, ok := present[window.name]
if !ok && window.name == "monthly" && usage.periodFetchFailed {
continue
}
if !ok {
updates[cnExtraKey(PlatformCommandCode, window.used)] = nil
}
if tier.ResetAt == "" {
updates[cnExtraKey(PlatformCommandCode, window.reset)] = nil
}
}
return updates
}
func newCommandCodeUsageClient(cfg *config.Config, httpUpstream HTTPUpstream, account *Account, proxyURL string) *commandCodeUsageClient {
return &commandCodeUsageClient{
cfg: cfg,
httpUpstream: httpUpstream,
account: account,
apiKey: strings.TrimSpace(account.GetCNAPIKey()),
proxyURL: proxyURL,
}
}
// commandCodeFetchFailure 把取数错误转换为探测失败文案;ok=false 表示应作为错误返回。
func commandCodeFetchFailure(err error) (status int, message string, ok bool) {
var httpErr *commandCodeHTTPError
switch {
case errors.As(err, &httpErr) && httpErr.unauthorized():
return httpErr.status, fmt.Sprintf("Authentication failed (HTTP %d)", httpErr.status), true
case errors.As(err, &httpErr):
return httpErr.status, fmt.Sprintf("API error (HTTP %d): %s", httpErr.status, httpErr.body), true
case errors.Is(err, errCommandCodeInvalidResponse):
return http.StatusOK, "Invalid usage response", true
default:
return 0, "", false
}
}
// queryCommandCodeUsage 查询 Command Code 积分与窗口,落窗口与余额快照。
func (s *CNProviderQuotaService) queryCommandCodeUsage(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) {
client := newCommandCodeUsageClient(s.cfg, s.httpUpstream, account, s.resolveProxyURL(ctx, account))
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: PlatformCommandCode,
Source: "command_code_usage",
FetchedAt: now.Unix(),
}
usage, err := client.fetch(ctx, true)
if err != nil {
status, message, ok := commandCodeFetchFailure(err)
if !ok {
if isApplicationError(err) {
return nil, err
}
return nil, infraerrors.Newf(http.StatusBadGateway, "CN_QUOTA_REQUEST_FAILED", "%v", err)
}
result.StatusCode = status
result.Error = message
return result, nil
}
tiers := usage.tiers()
balance := usage.balance(now)
result.StatusCode = http.StatusOK
result.Tiers = tiers
result.PlanLevel = usage.planName()
result.Balance = balance
result.Success = true
result.CredentialValid = true
updates := commandCodeUsageExtraUpdates(usage, tiers, balance, now, true)
if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil {
slog.Warn("cn_quota_persist_failed", "account_id", account.ID, "provider", PlatformCommandCode, "error", err)
} else {
result.Persisted = true
balance.Persisted = true
}
clearCommandCodeWindowCooldown(ctx, s.accountRepo, account, usage, now)
return result, nil
}
// queryCommandCodeBalance 只查积分余额(whoami + credits),落余额快照。
func (s *CNProviderBalanceService) queryCommandCodeBalance(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) {
client := newCommandCodeUsageClient(s.cfg, s.httpUpstream, account, s.resolveProxyURL(ctx, account))
if client.apiKey == "" {
return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_APIKEY", "account api_key is empty")
}
now := time.Now().UTC()
usage, err := client.fetch(ctx, false)
if err != nil {
status, message, ok := commandCodeFetchFailure(err)
if !ok {
if isApplicationError(err) {
return nil, err
}
return nil, infraerrors.Newf(http.StatusBadGateway, "CN_BALANCE_REQUEST_FAILED", "%v", err)
}
return &CNProviderBalanceResult{
Provider: PlatformCommandCode,
StatusCode: status,
FetchedAt: now.Unix(),
Available: true,
Error: message,
}, nil
}
result := usage.balance(now)
if err := s.accountRepo.UpdateExtra(ctx, account.ID, commandCodeUsageExtraUpdates(usage, nil, result, now, false)); err != nil {
slog.Warn("cn_balance_persist_failed", "account_id", account.ID, "provider", PlatformCommandCode, "error", err)
} else {
result.Persisted = true
}
clearCommandCodeWindowCooldown(ctx, s.accountRepo, account, usage, now)
return result, nil
}