diff --git a/backend/internal/service/account_scheduling_threshold_eval.go b/backend/internal/service/account_scheduling_threshold_eval.go index 4812fceca..b8a9dae80 100644 --- a/backend/internal/service/account_scheduling_threshold_eval.go +++ b/backend/internal/service/account_scheduling_threshold_eval.go @@ -53,7 +53,7 @@ func EvaluateAccountSchedulingThreshold(account *Account, thresholds map[string] var winner *accountSchedulingThresholdCandidate switch decision.Platform { case PlatformOpenAI: - winner = pickLatestResetSchedulingCandidate(openAIThresholdCandidates(account), threshold, now) + winner = pickLatestResetSchedulingCandidate(openAIThresholdCandidates(account, now), threshold, now) case PlatformAnthropic: winner = pickLatestResetSchedulingCandidate(anthropicThresholdCandidates(account), threshold, now) case PlatformGrok: @@ -149,7 +149,7 @@ func lookupAccountSchedulingThreshold(thresholds map[string]int, platform string return value, ok } -func openAIThresholdCandidates(account *Account) []*accountSchedulingThresholdCandidate { +func openAIThresholdCandidates(account *Account, now time.Time) []*accountSchedulingThresholdCandidate { if account == nil { return nil } @@ -157,8 +157,8 @@ func openAIThresholdCandidates(account *Account) []*accountSchedulingThresholdCa return nil } return []*accountSchedulingThresholdCandidate{ - openAIThresholdCandidate(account.Extra, "5h"), - openAIThresholdCandidate(account.Extra, "7d"), + openAIThresholdCandidate(account.Extra, "5h", now), + openAIThresholdCandidate(account.Extra, "7d", now), } } @@ -219,7 +219,7 @@ func firstStringValue(values map[string]any, keys ...string) string { return "" } -func openAIThresholdCandidate(extra map[string]any, window string) *accountSchedulingThresholdCandidate { +func openAIThresholdCandidate(extra map[string]any, window string, now time.Time) *accountSchedulingThresholdCandidate { if len(extra) == 0 { return nil } @@ -243,6 +243,9 @@ func openAIThresholdCandidate(extra map[string]any, window string) *accountSched if !ok { return nil } + if openAIQuotaWindowReset(extra, window, now) || openAICodexSnapshotStaleForPause(extra, now) { + return nil + } return &accountSchedulingThresholdCandidate{ window: window, usedPercent: schedulingPercentValue(usedPercent), diff --git a/backend/internal/service/account_scheduling_threshold_eval_test.go b/backend/internal/service/account_scheduling_threshold_eval_test.go index 74d3e9648..653543175 100644 --- a/backend/internal/service/account_scheduling_threshold_eval_test.go +++ b/backend/internal/service/account_scheduling_threshold_eval_test.go @@ -109,7 +109,7 @@ func TestEvaluateAccountSchedulingThreshold_OpenAIPreservesPercentageSemantics(t }, } - candidate := openAIThresholdCandidate(openAIAccount.Extra, "5h") + candidate := openAIThresholdCandidate(openAIAccount.Extra, "5h", now) require.NotNil(t, candidate) require.Equal(t, 1.0, candidate.usedPercent) @@ -126,6 +126,88 @@ func TestEvaluateAccountSchedulingThreshold_OpenAIPreservesPercentageSemantics(t require.Equal(t, 91.0, openAIDecision.UsedPercent) } +func TestEvaluateAccountSchedulingThreshold_OpenAISkipsStaleSnapshot(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + account := &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{ + "codex_usage_updated_at": now.Add(-2 * time.Hour).Format(time.RFC3339), + "codex_5h_used_percent": 100.0, + "codex_5h_reset_at": now.Add(3 * time.Hour).Format(time.RFC3339), + }, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformOpenAI: 90}, now) + + require.False(t, decision.ShouldPause) +} + +func TestEvaluateAccountSchedulingThreshold_OpenAISkipsResetWindow(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + account := &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{ + "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339), + "codex_5h_used_percent": 100.0, + "codex_5h_reset_at": now.Add(-time.Second).Format(time.RFC3339), + }, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformOpenAI: 90}, now) + + require.False(t, decision.ShouldPause) +} + +func TestEvaluateAccountSchedulingThreshold_OpenAIPausesFreshExhaustedSnapshot(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + resetAt := now.Add(3 * time.Hour) + account := &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{ + "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339), + "codex_5h_used_percent": 100.0, + "codex_5h_reset_at": resetAt.Format(time.RFC3339), + }, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformOpenAI: 90}, now) + + require.True(t, decision.ShouldPause) + require.Equal(t, "5h", decision.Window) + require.Equal(t, 100.0, decision.UsedPercent) + require.NotNil(t, decision.Until) + require.True(t, resetAt.Equal(*decision.Until)) +} + +func TestEvaluateAccountSchedulingThreshold_OpenAIPausesFreshExhaustedSevenDayWindow(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + resetAt := now.Add(5 * 24 * time.Hour) + account := &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{ + "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339), + "codex_7d_used_percent": 95.0, + "codex_7d_reset_at": resetAt.Format(time.RFC3339), + }, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformOpenAI: 90}, now) + + require.True(t, decision.ShouldPause) + require.Equal(t, "7d", decision.Window) + require.Equal(t, 95.0, decision.UsedPercent) + require.NotNil(t, decision.Until) + require.True(t, resetAt.Equal(*decision.Until)) +} + func TestEvaluateAccountSchedulingThreshold_AnthropicPreservesFractionalUtilizationSemantics(t *testing.T) { t.Parallel()