mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
转发: - 平台清单与 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>
175 lines
7.7 KiB
Go
175 lines
7.7 KiB
Go
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
|
||
}
|