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

175 lines
7.7 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"
"regexp"
"strings"
"time"
)
// Command Code 用量超限的响应式处理,依据官方错误说明
// (https://commandcode.ai/docs/troubleshooting/errors):
//
// - Rate limit:短时间请求过多,很快恢复,走默认 429 逻辑;
// - Usage exceeded,三种来源:
// 1. 5 小时 / 每周滚动窗口用满,文案如
// "You've reached your 5-hour usage limit. Resets in 2h 41m (3:00 PM)";
// 2. 月度积分耗尽;
// 3. 组织为成员设的花费上限,分成员总额度与成员某个模型的额度两种,后者文案如
// "You've reached the $10.00/mo spending limit your organization set for GLM-5. It resets in 3 days."
//
// 文档没有给出状态码与响应体结构,因此按文案分类,402 / 403 / 429 同样处理;
// 页面路径中的 usage_exceeded 可能是错误码,一并识别。未识别的错误交回默认逻辑。
//
// 窗口用满写带原因的临时停调(而非 SetRateLimited):充值积分不受滚动窗口限制,
// 额度探测发现充值积分后只清除这一类停调(见 queryCommandCodeUsage)。
type commandCodeUsageErrorKind int
const (
commandCodeUsageErrorNone commandCodeUsageErrorKind = iota
// commandCodeUsageWindowLimit 滚动窗口用满:冷却到窗口重置。
commandCodeUsageWindowLimit
// commandCodeUsageCreditsExhausted 积分耗尽:可恢复停调,积分恢复后由周期检测解除。
commandCodeUsageCreditsExhausted
// commandCodeUsageSpendLimit 组织花费上限:模型级只限当前模型,成员总额度按账号冷却。
commandCodeUsageSpendLimit
)
const (
// commandCodeUsageLimitReason 是窗口用满临时停调的原因前缀,充值后据此只清除这一类停调。
commandCodeUsageLimitReason = "command_code_usage_limit"
commandCodeSpendLimitReason = "command_code_spend_limit"
// 从文案解析出的重置时长上限:窗口最长为每周,花费上限最长按月。
commandCodeMaxWindowCooldown = 8 * 24 * time.Hour
commandCodeMaxSpendLimitCooldown = 32 * 24 * time.Hour
// commandCodeAccountSpendLimitRecheck 是账号级花费上限(或作用域不明)的最长冷却:
// 管理员可能随时调高上限,到期后由下一次请求重新确认。
commandCodeAccountSpendLimitRecheck = time.Hour
)
// commandCodeModelSpendLimitPattern 匹配组织为成员某个模型设的花费上限
// ("... spending limit your organization set for GLM-5.");成员总额度没有 "for <模型>"。
var commandCodeModelSpendLimitPattern = regexp.MustCompile(`(?i)spending limit your organization set for\s+([^.\s][^.]*)`)
// commandCodeSpendLimitIsModelScoped 报告花费上限是否只针对单个模型。
func commandCodeSpendLimitIsModelScoped(body []byte) bool {
match := commandCodeModelSpendLimitPattern.FindSubmatch(body)
if match == nil {
return false
}
target := strings.ToLower(strings.TrimSpace(string(match[1])))
return target != "" && !strings.HasPrefix(target, "you")
}
func classifyCommandCodeUsageError(body []byte) commandCodeUsageErrorKind {
text := strings.ToLower(string(body))
switch {
case strings.Contains(text, "spending limit"):
return commandCodeUsageSpendLimit
case strings.Contains(text, "usage limit"):
return commandCodeUsageWindowLimit
case cnProviderResponseIndicatesInsufficientBalance(body),
strings.Contains(text, "insufficient credit"),
strings.Contains(text, "out of credits"),
strings.Contains(text, "usage_exceeded"),
strings.Contains(text, "usage exceeded"):
return commandCodeUsageCreditsExhausted
default:
return commandCodeUsageErrorNone
}
}
// commandCodeResetAt 解析文案中的 "Resets in 2h 41m" / "resets in 3 days";无法解析
// 或超过 maxWait 时返回 nil。
func commandCodeResetAt(body []byte, now time.Time, maxWait time.Duration) *time.Time {
after := parseOpenCodeGoUsageLimitResetDuration(extractUpstreamErrorMessage(body))
if after <= 0 {
after = parseOpenCodeGoUsageLimitResetDuration(string(body))
}
if after <= 0 || after > maxWait {
return nil
}
until := now.Add(after)
return &until
}
// handleCommandCodeUsageError 处理已识别的 Command Code 用量超限;handled=false 时由
// 调用方继续走默认逻辑。shouldDisable 与同类状态码的既有口径一致:429 不置位;
// 只限单个模型时也不置位,避免调用方把整个账号拉黑。
func (s *RateLimitService) handleCommandCodeUsageError(
ctx context.Context,
account *Account,
statusCode int,
responseBody []byte,
upstreamMsg string,
) (handled, shouldDisable bool) {
accountWide := statusCode != http.StatusTooManyRequests
now := time.Now()
switch classifyCommandCodeUsageError(responseBody) {
case commandCodeUsageWindowLimit:
until := commandCodeResetAt(responseBody, now, commandCodeMaxWindowCooldown)
if until == nil {
until = cnProviderQuotaSnapshotReset(account, now)
}
if until == nil {
return false, false
}
reason := commandCodeUsageLimitReason
if msg := strings.TrimSpace(upstreamMsg); msg != "" {
reason += ": " + msg
}
s.notifyAccountSchedulingBlocked(account, *until, commandCodeUsageLimitReason)
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, *until, reason); err != nil {
slog.Warn("command_code_usage_limit_set_failed", "account_id", account.ID, "error", err)
return false, false
}
slog.Info("command_code_usage_limit", "account_id", account.ID, "status_code", statusCode, "reset_at", until.UTC())
return true, accountWide
case commandCodeUsageCreditsExhausted:
s.handleCNProviderInsufficientBalance(ctx, account, upstreamMsg)
return true, accountWide
case commandCodeUsageSpendLimit:
return true, s.handleCommandCodeSpendLimit(ctx, account, statusCode, responseBody, now)
default:
return false, false
}
}
// handleCommandCodeSpendLimit 处理组织花费上限:文案指明模型且请求模型已知时只限该
// 模型,冷却到文案中的重置时间;成员总额度或作用域不明时整个账号冷却,但最长
// commandCodeAccountSpendLimitRecheck,避免管理员调高上限后账号仍停用数天,也不会
// 因累积 403 被永久禁用。
func (s *RateLimitService) handleCommandCodeSpendLimit(ctx context.Context, account *Account, statusCode int, responseBody []byte, now time.Time) (shouldDisable bool) {
reset := commandCodeResetAt(responseBody, now, commandCodeMaxSpendLimitCooldown)
modelKey := modelRateLimitKeyForUpstreamModelNotFound(ctx, account, tempUnschedulableModel(ctx, nil))
if modelKey != "" && commandCodeSpendLimitIsModelScoped(responseBody) {
until := reset
if until == nil {
fallback, ok := s.get429FallbackCooldown(ctx, account)
if !ok || fallback <= 0 {
fallback = time.Duration(defaultRateLimit429CooldownSeconds) * time.Second
}
next := now.Add(fallback)
until = &next
}
if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelKey, *until, commandCodeSpendLimitReason); err != nil {
slog.Warn("command_code_spend_limit_set_failed", "account_id", account.ID, "model", modelKey, "error", err)
}
slog.Info("command_code_spend_limit", "account_id", account.ID, "model", modelKey, "status_code", statusCode, "reset_at", until.UTC())
return false
}
until := now.Add(commandCodeAccountSpendLimitRecheck)
if reset != nil && reset.Before(until) {
until = *reset
}
s.notifyAccountSchedulingBlocked(account, until, commandCodeSpendLimitReason)
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, commandCodeSpendLimitReason); err != nil {
slog.Warn("command_code_spend_limit_set_failed", "account_id", account.ID, "error", err)
}
slog.Info("command_code_spend_limit", "account_id", account.ID, "status_code", statusCode, "until", until.UTC())
return statusCode != http.StatusTooManyRequests
}