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

576 lines
16 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 (
"encoding/json"
"math"
"strconv"
"strings"
"time"
)
// AccountSchedulingThresholdDecision captures the pure pause decision for one account.
type AccountSchedulingThresholdDecision struct {
ShouldPause bool
Platform string
Window string
Scope string
ThresholdPercent int
UsedPercent float64
Until *time.Time
}
type accountSchedulingThresholdCandidate struct {
window string
scope string
usedPercent float64
until *time.Time
}
const accountSchedulingThresholdCredentialKey = "account_scheduling_threshold"
// EvaluateAccountSchedulingThreshold evaluates whether an account should be paused
// based on the current per-platform scheduling threshold snapshot.
func EvaluateAccountSchedulingThreshold(account *Account, thresholds map[string]int, now time.Time) AccountSchedulingThresholdDecision {
decision := AccountSchedulingThresholdDecision{}
if account == nil {
return decision
}
decision.Platform = strings.ToLower(strings.TrimSpace(account.Platform))
if decision.Platform == "" {
return decision
}
if !isAllowedSchedulingThresholdPlatform(decision.Platform) {
return decision
}
threshold, ok := resolveEffectiveAccountSchedulingThreshold(account, thresholds, decision.Platform)
decision.ThresholdPercent = threshold
if !ok || threshold >= 100 {
return decision
}
var winner *accountSchedulingThresholdCandidate
switch decision.Platform {
case PlatformOpenAI:
winner = pickLatestResetSchedulingCandidate(openAIThresholdCandidates(account, now), threshold, now)
case PlatformAnthropic:
winner = pickLatestResetSchedulingCandidate(anthropicThresholdCandidates(account), threshold, now)
case PlatformGrok:
winner = pickLatestResetSchedulingCandidate(grokThresholdCandidates(account), threshold, now)
case PlatformKimi, PlatformZhipu, PlatformMiniMax, PlatformOpenCodeGo, PlatformCommandCode:
winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, decision.Platform), threshold, now)
default:
return decision
}
if winner == nil {
return decision
}
decision.ShouldPause = true
decision.Window = winner.window
decision.Scope = winner.scope
decision.UsedPercent = winner.usedPercent
decision.Until = winner.until
return decision
}
func evaluateAnthropicFableSchedulingThreshold(account *Account, thresholds map[string]int, now time.Time) AccountSchedulingThresholdDecision {
decision := AccountSchedulingThresholdDecision{}
if account == nil || !strings.EqualFold(strings.TrimSpace(account.Platform), PlatformAnthropic) {
return decision
}
decision.Platform = PlatformAnthropic
threshold, ok := resolveEffectiveAccountSchedulingThreshold(account, thresholds, PlatformAnthropic)
decision.ThresholdPercent = threshold
if !ok || threshold >= 100 {
return decision
}
candidate := anthropicFableThresholdCandidate(account)
if !candidateMatchesThreshold(candidate, threshold, now) {
return decision
}
decision.ShouldPause = true
decision.Window = candidate.window
decision.Scope = candidate.scope
decision.UsedPercent = candidate.usedPercent
decision.Until = candidate.until
return decision
}
func isAllowedSchedulingThresholdPlatform(platform string) bool {
for _, allowed := range AllowedSchedulingThresholdPlatforms {
if platform == allowed {
return true
}
}
return false
}
func resolveEffectiveAccountSchedulingThreshold(account *Account, thresholds map[string]int, platform string) (int, bool) {
if account != nil {
if threshold, ok := accountSchedulingThresholdOverride(account); ok {
return threshold, true
}
}
return lookupAccountSchedulingThreshold(thresholds, platform)
}
func accountSchedulingThresholdOverride(account *Account) (int, bool) {
if account == nil || len(account.Credentials) == 0 {
return 0, false
}
raw, ok := account.Credentials[accountSchedulingThresholdCredentialKey]
if !ok {
return 0, false
}
return parseAccountSchedulingThresholdValue(raw)
}
func parseAccountSchedulingThresholdValue(raw any) (int, bool) {
var value int
switch v := raw.(type) {
case int:
value = v
case int64:
value = int(v)
case float64:
value = int(math.Round(v))
case float32:
value = int(math.Round(float64(v)))
case json.Number:
parsed, err := v.Float64()
if err != nil {
return 0, false
}
value = int(math.Round(parsed))
case string:
raw := strings.TrimSpace(v)
parsed, err := strconv.Atoi(raw)
if err == nil {
value = parsed
break
}
parsedFloat, floatErr := strconv.ParseFloat(raw, 64)
if floatErr != nil {
return 0, false
}
value = int(math.Round(parsedFloat))
default:
return 0, false
}
if value < 1 || value > 100 {
return 0, false
}
return value, true
}
func lookupAccountSchedulingThreshold(thresholds map[string]int, platform string) (int, bool) {
if len(thresholds) == 0 {
return 0, false
}
value, ok := thresholds[platform]
return value, ok
}
func openAIThresholdCandidates(account *Account, now time.Time) []*accountSchedulingThresholdCandidate {
if account == nil {
return nil
}
if !openAICodexSnapshotIdentityTrusted(account) {
return nil
}
return []*accountSchedulingThresholdCandidate{
openAIThresholdCandidate(account.Extra, "5h", now),
openAIThresholdCandidate(account.Extra, "7d", now),
}
}
func openAICodexSnapshotIdentityTrusted(account *Account) bool {
if account == nil || !account.IsOpenAIOAuth() || len(account.Extra) == 0 {
return true
}
if identityValuesConflict(
firstStringValue(account.Credentials, "email"),
firstStringValue(account.Extra, "email", "email_address"),
) {
return false
}
if identityValuesConflict(
firstStringValue(account.Credentials, "chatgpt_account_id"),
firstStringValue(account.Extra, "chatgpt_account_id", "account_id"),
) {
return false
}
if identityValuesConflict(
firstStringValue(account.Credentials, "workspace_id", "chatgpt_workspace_id", "organization_id", "org_id"),
firstStringValue(account.Extra, "workspace_id", "chatgpt_workspace_id", "organization_id", "org_id"),
) {
return false
}
return true
}
func identityValuesConflict(left, right string) bool {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
return left != "" && right != "" && !strings.EqualFold(left, right)
}
// firstStringValue returns the first non-empty string among the given map keys.
// Used by OpenAI codex snapshot identity matching for scheduling thresholds.
func firstStringValue(values map[string]any, keys ...string) string {
if len(values) == 0 {
return ""
}
for _, key := range keys {
raw, ok := values[key]
if !ok || raw == nil {
continue
}
switch typed := raw.(type) {
case string:
if v := strings.TrimSpace(typed); v != "" {
return v
}
default:
if v := strings.TrimSpace(stringValue(raw)); v != "" {
return v
}
}
}
return ""
}
func openAIThresholdCandidate(extra map[string]any, window string, now time.Time) *accountSchedulingThresholdCandidate {
if len(extra) == 0 {
return nil
}
var (
usedPercentKey string
resetAtKey string
)
switch window {
case "5h":
usedPercentKey = "codex_5h_used_percent"
resetAtKey = "codex_5h_reset_at"
case "7d":
usedPercentKey = "codex_7d_used_percent"
resetAtKey = "codex_7d_reset_at"
default:
return nil
}
usedPercent, ok := extra[usedPercentKey]
if !ok {
return nil
}
if openAIQuotaWindowReset(extra, window, now) || (openAICodexSnapshotStaleForPause(extra, now) && !openAIQuotaWindowResetPending(extra, window, now)) {
return nil
}
until := parseSchedulingResetAt(extra[resetAtKey])
if until == nil {
if resetAt, ok := openAICodexWindowResetAt(extra, window); ok {
until = &resetAt
}
}
return &accountSchedulingThresholdCandidate{
window: window,
usedPercent: schedulingPercentValue(usedPercent),
until: until,
}
}
func anthropicThresholdCandidates(account *Account) []*accountSchedulingThresholdCandidate {
if account == nil {
return nil
}
var candidates []*accountSchedulingThresholdCandidate
if usedPercent := utilizationAsPercent(account.Extra["session_window_utilization"]); usedPercent > 0 {
candidates = append(candidates, &accountSchedulingThresholdCandidate{
window: "5h",
usedPercent: usedPercent,
until: cloneTimePtr(account.SessionWindowEnd),
})
}
if usedPercent := utilizationAsPercent(account.Extra["passive_usage_7d_utilization"]); usedPercent > 0 {
candidates = append(candidates, &accountSchedulingThresholdCandidate{
window: "7d",
usedPercent: usedPercent,
until: parseSchedulingResetAt(account.Extra["passive_usage_7d_reset"]),
})
}
return candidates
}
func anthropicFableThresholdCandidate(account *Account) *accountSchedulingThresholdCandidate {
if account == nil {
return nil
}
usedPercent := utilizationAsPercent(account.Extra["passive_usage_7d_oi_utilization"])
if usedPercent <= 0 {
return nil
}
return &accountSchedulingThresholdCandidate{
window: "7d_oi",
scope: anthropicFableRateLimitKey,
usedPercent: usedPercent,
until: parseSchedulingResetAt(account.Extra["passive_usage_7d_oi_reset"]),
}
}
// NOTE: Gemini / Kiro / Antigravity are intentionally NOT threshold-pausing
// platforms (see AllowedSchedulingThresholdPlatforms and the evaluator switch,
// asserted by TestEvaluateAccountSchedulingThreshold_UnsupportedPlatformsDoNotPause).
// Their former per-platform candidate readers were dead code — never reachable
// from EvaluateAccountSchedulingThreshold — and have been removed to avoid the
// false impression that configuring a threshold for them has any effect. The
// kiro_sched_* / antigravity_sched_* extras are still written purely as
// observability snapshots.
// grokThresholdCandidates uses only header-projected
// grok_sched_utilization / grok_sched_reset_at (rolling quota window, reset
// capped at ~25h when written). Official billing 7d/30d windows are not used
// for auto-pause here.
func grokThresholdCandidates(account *Account) []*accountSchedulingThresholdCandidate {
if account == nil {
return nil
}
return []*accountSchedulingThresholdCandidate{
{
window: "quota",
scope: "grok",
usedPercent: schedulingPercentValue(account.Extra["grok_sched_utilization"]),
until: parseSchedulingResetAt(account.Extra["grok_sched_reset_at"]),
},
}
}
// cnProviderThresholdCandidates 读取国产供应商 Coding Plan、OpenCode Go 与 Command Code
// 账号的 5h / weekly(及月度)窗口用量快照(由 CNProviderQuotaService 写入 account.Extra,
// 键形如 <provider>_5h_used_percent / <provider>_weekly_reset_at)。payg 账号无此快照,
// 候选为空 → 不触发阈值停调(余额型走余额检测)。与 openai 的快照驱动停调一致:
// 仅当用量超阈值且窗口尚未重置时才停调。
func cnProviderThresholdCandidates(account *Account, provider string) []*accountSchedulingThresholdCandidate {
if account == nil || len(account.Extra) == 0 {
return nil
}
if provider == PlatformCommandCode && !commandCodeWindowsBinding(account.Extra) {
return nil
}
candidates := []*accountSchedulingThresholdCandidate{
cnThresholdCandidate(account.Extra, provider, "5h"),
cnThresholdCandidate(account.Extra, provider, "weekly"),
}
if cnQuotaHasMonthlyWindow(provider) {
candidates = append(candidates, cnThresholdCandidate(account.Extra, provider, "monthly"))
}
return candidates
}
// cnQuotaHasMonthlyWindow 报告供应商的额度快照是否含月度窗口(OpenCode Go、Command Code)。
func cnQuotaHasMonthlyWindow(provider string) bool {
return provider == PlatformOpenCodeGo || provider == PlatformCommandCode
}
func cnThresholdCandidate(extra map[string]any, provider, window string) *accountSchedulingThresholdCandidate {
var usedKey, resetKey string
switch window {
case "5h":
usedKey = cnExtraKey(provider, cnExtraSuffix5hUsed)
resetKey = cnExtraKey(provider, cnExtraSuffix5hReset)
case "weekly":
usedKey = cnExtraKey(provider, cnExtraSuffixWeeklyUsed)
resetKey = cnExtraKey(provider, cnExtraSuffixWeeklyReset)
case "monthly":
usedKey = cnExtraKey(provider, cnExtraSuffixMonthlyUsed)
resetKey = cnExtraKey(provider, cnExtraSuffixMonthlyReset)
default:
return nil
}
usedPercent, ok := extra[usedKey]
if !ok {
return nil
}
return &accountSchedulingThresholdCandidate{
window: window,
scope: provider,
usedPercent: schedulingPercentValue(usedPercent),
until: parseSchedulingResetAt(extra[resetKey]),
}
}
func pickLatestResetSchedulingCandidate(candidates []*accountSchedulingThresholdCandidate, threshold int, now time.Time) *accountSchedulingThresholdCandidate {
var winner *accountSchedulingThresholdCandidate
for _, candidate := range candidates {
if !candidateMatchesThreshold(candidate, threshold, now) {
continue
}
if winner == nil || candidate.until.After(*winner.until) {
winner = candidate
continue
}
if winner.until.Equal(*candidate.until) && candidate.usedPercent > winner.usedPercent {
winner = candidate
}
}
return winner
}
func candidateMatchesThreshold(candidate *accountSchedulingThresholdCandidate, threshold int, now time.Time) bool {
if candidate == nil || candidate.until == nil || !candidate.until.After(now) {
return false
}
return candidate.usedPercent >= float64(threshold)
}
func utilizationAsPercent(raw any) float64 {
switch v := raw.(type) {
case float64:
if v >= 0 && v <= 1 {
return v * 100
}
return v
case float32:
value := float64(v)
if value >= 0 && value <= 1 {
return value * 100
}
return value
case int:
return float64(v)
case int64:
return float64(v)
case json.Number:
value, err := v.Float64()
if err != nil {
return 0
}
if strings.Contains(v.String(), ".") && value >= 0 && value <= 1 {
return value * 100
}
return value
case string:
trimmed := strings.TrimSpace(v)
value, err := strconv.ParseFloat(trimmed, 64)
if err != nil {
return 0
}
if strings.Contains(trimmed, ".") && value >= 0 && value <= 1 {
return value * 100
}
return value
default:
return 0
}
}
func schedulingPercentValue(raw any) float64 {
switch v := raw.(type) {
case float64:
return v
case float32:
return float64(v)
case int:
return float64(v)
case int64:
return float64(v)
case json.Number:
value, err := v.Float64()
if err != nil {
return 0
}
return value
case string:
value, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err != nil {
return 0
}
return value
default:
return 0
}
}
func parseSchedulingResetAt(raw any) *time.Time {
switch v := raw.(type) {
case nil:
return nil
case time.Time:
ts := v
return &ts
case *time.Time:
return cloneTimePtr(v)
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" {
return nil
}
ts, err := parseSchedulingTime(trimmed)
if err != nil {
return nil
}
return &ts
case json.Number:
if value, err := v.Int64(); err == nil && value > 0 {
ts := time.Unix(value, 0)
return &ts
}
if value, err := v.Float64(); err == nil && value > 0 {
ts := time.Unix(int64(value), 0)
return &ts
}
case float64:
if v > 0 {
ts := time.Unix(int64(v), 0)
return &ts
}
case float32:
if v > 0 {
ts := time.Unix(int64(v), 0)
return &ts
}
case int:
if v > 0 {
ts := time.Unix(int64(v), 0)
return &ts
}
case int64:
if v > 0 {
ts := time.Unix(v, 0)
return &ts
}
}
return nil
}
func parseSchedulingTime(raw string) (time.Time, error) {
formats := []string{
time.RFC3339,
time.RFC3339Nano,
"2006-01-02T15:04:05Z",
"2006-01-02T15:04:05.000Z",
}
for _, format := range formats {
if ts, err := time.Parse(format, raw); err == nil {
return ts, nil
}
}
return time.Time{}, strconv.ErrSyntax
}
func cloneTimePtr(src *time.Time) *time.Time {
if src == nil {
return nil
}
value := *src
return &value
}