Files
sub2api/backend/internal/service/command_code_usage.go
T
SavitarCandClaude Opus 5.5 e2939cd8fa feat(platform): 接入 Command Code 平台
转发:
- 平台清单与 provider profile 新增 command_code:按模型分流,Chat
  Completions / Responses 基址 https://api.commandcode.ai/provider/v1,
  Anthropic 基址 https://api.commandcode.ai/provider;内置规则 claude-* →
  Anthropic、gpt-* → Responses(也支持 Chat Completions)。
- 原生 Anthropic 端点的版本感知拼接改按分流方式判断,转发与连接测试共用
  (Anthropic 基址带 /v1 时连接测试不再误报为配置错误)。

按模型的协议集合(模型支持入站协议时直通,免去协议转换):
- 分流规则可带协议集合 protocols(首项为首选协议):入站协议在集合中时同
  协议直通,否则走首选协议;只有一个协议时与原规则相同,OpenCode 行为不变。
- profile 新增 model_catalog:Command Code 的 /models 为每个模型给出
  supported_endpoints。官方默认地址的目录按地址全进程共享,自定义上游按账号
  隔离;目录请求的 UA 收敛与账号请求头覆写与转发一致。目录缓存 30 分钟、后台
  刷新,失败退避 5 分钟;从未加载成功时首批请求最多等 2 秒,冷启动也按目录
  分流。账号规则未命中的模型按目录取协议集合;目录不可用时回落内置规则(账号
  未配置规则时)或 Chat Completions。
- 三个入站入口共用的 resolveUpstreamProtocol 增加目录参数。
- 前端分流规则编辑器可为每条规则勾选"也支持"的协议;提供模型目录的平台,
  兜底行改为说明按上游模型列表选协议。

连接测试:
- 按模型分流的平台共用一条测试路径(协议与网关同一判定,含模型目录;默认
  测试模型取自 profile),不再落入 Claude 测试路径。
- 首个分支由"国产厂商"改为"按入站协议分流";adaptive 测试跳过 profile 未
  提供的 Anthropic 端点。现有国产厂商行为不变。
- 连接测试是 OpenCode 专属分流入口的最后一个调用方,改走统一判定后删除
  ResolveOpenCodeGoUpstreamProtocol、openCodeGoNativeProtocol,以及此前就只剩
  测试使用的 OpenCodeGoModelProtocol;路由矩阵的旧行为基准改为测试内独立复述。

额度与余额(/alpha/whoami、billing/credits、billing/subscriptions、
usage/summary):
- 额度探测写入 5 小时 / 每周窗口(限额套餐)与还原出的月度窗口,同时写入
  积分余额;余额探测只取积分。仅对官方主机上的 API Key 账号生效。
- 订阅周期或本周期用量取数失败时保留上一次的月度快照,不当作没有月度窗口
  清掉;窗口尚未开始(没有重置时间)时清掉上一个窗口的重置时间。
- whoami 取不到时探测整体失败,不按个人账户继续(组织账号会查到成员的个人
  钱包,并据此按余额停调)。
- 阈值停调与定时检测纳入 Command Code;有充值积分时上游跳过窗口检查,窗口
  快照不参与停调与冷却;积分低于余额阈值时临时停调。
- 上游计费探测的官方域名加入 commandcode.ai,不把 Key 发往官方主机上并不
  存在的中转计费接口。

上游错误(依据 commandcode.ai/docs/troubleshooting/errors,按文案分类,
402 / 403 / 429 同样处理):
- 5 小时 / 每周窗口用满:带原因的临时停调,到文案中的 "Resets in …",解析
  不出时回落额度快照;充值积分不受窗口限制,额度 / 余额探测发现充值积分后
  只解除这一类停调。
- 积分耗尽(含 usage_exceeded):可恢复的临时停调,积分恢复后由周期检测
  解除,不再永久置为 error。
- 组织花费上限:文案指明模型("set for <模型>")且请求模型已知时只写模型级
  限流;成员总额度或作用域不明时整个账号冷却,最长一小时。
- 普通频率限制走默认 429 逻辑,不再冷却到窗口重置点。

前端:
- 账号列表显示额度窗口与积分余额,阈值设置增加 command_code。单元格可见性
  改为按账号判定,与后端一致:只对官方主机上的 API Key 账号显示,自定义中转
  显示占位符,不再每次打开列表都探测出一行报错。
- 使用官网的官方标志(圆角方块内镂空 ⌘);配色为中性灰(品牌黑色已由
  Grok 使用)。

测试基准中原先用作"仅服务端登记"示例的 command_code 改为虚构的 acme_router;
新增的前端测试加入 CI 前端关键测试列表。

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

512 lines
18 KiB
Go

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
}