| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554 |
- 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:
- 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) {
- 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
- }
- 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 账号的 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
- }
|