Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
515 lines
14 KiB
Go
515 lines
14 KiB
Go
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:
|
|
winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, PlatformKimi), threshold, now)
|
|
case PlatformZhipu:
|
|
winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, PlatformZhipu), 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 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) {
|
|
return nil
|
|
}
|
|
return &accountSchedulingThresholdCandidate{
|
|
window: window,
|
|
usedPercent: schedulingPercentValue(usedPercent),
|
|
until: parseSchedulingResetAt(extra[resetAtKey]),
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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 账号的 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
|
|
}
|
|
return []*accountSchedulingThresholdCandidate{
|
|
cnThresholdCandidate(account.Extra, provider, "5h"),
|
|
cnThresholdCandidate(account.Extra, provider, "weekly"),
|
|
}
|
|
}
|
|
|
|
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)
|
|
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
|
|
}
|