From 72a56f862c3fec1340da727222d07ce18be96b4b Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Fri, 7 Aug 2026 18:52:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(grok):=20free=20500k=20+=20=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20personal-dev=20tier/=E6=97=B6=E9=97=B4=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - soft-gate 默认额度 500k tokens / 滚动 24h / 95%(门禁 475k) - soft-gate 仅显式 free OAuth(subscription_tier/plan_type == free) - isKnownGrokFreeAccount 按 personal-dev(usage% 为 paid 证据、仅 credentials tier) - 调度阈值仅 grok_sched header quota 窗,去掉 billing 7d/30d 候选 --- backend/internal/config/config.go | 4 +- backend/internal/config/config_test.go | 2 +- backend/internal/pkg/xai/quota.go | 6 +- backend/internal/pkg/xai/quota_test.go | 2 + .../account_scheduling_threshold_eval.go | 62 ++--------------- .../account_scheduling_threshold_eval_test.go | 66 ++++--------------- .../internal/service/grok_free_quota_gate.go | 34 +++++++--- .../service/grok_free_quota_gate_test.go | 28 +++++--- .../service/openai_gateway_grok_cache.go | 56 +++++----------- ...openai_gateway_grok_search_billing_test.go | 8 +-- 10 files changed, 95 insertions(+), 173 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 186e59d5b5..2531b67a1c 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2343,8 +2343,8 @@ func setDefaults() { // Enabled by default because free detection requires an explicit free tier marker. viper.SetDefault("gateway.grok.free_quota_soft_gate_enabled", true) viper.SetDefault("gateway.grok.password_auth_enabled", false) - // Align soft-gate nominal limit with current Free rolling quota (1M tokens / 24h). -viper.SetDefault("gateway.grok.free_quota_token_limit", int64(1_000_000)) + // Free soft-gate nominal limit: 500k tokens / rolling 24h (operator policy). + viper.SetDefault("gateway.grok.free_quota_token_limit", int64(500_000)) viper.SetDefault("gateway.grok.free_quota_soft_gate_percent", 95) viper.SetDefault("gateway.grok.free_quota_window_hours", 24) viper.SetDefault("gateway.grok.free_quota_stats_cache_seconds", 5) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 037c5cd135..a307ac693f 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -545,7 +545,7 @@ func TestLoadDefaultGrokFreeQuotaSoftGate(t *testing.T) { require.NoError(t, err) require.False(t, cfg.Gateway.Grok.PasswordAuthEnabled) require.True(t, cfg.Gateway.Grok.FreeQuotaSoftGateEnabled) - require.Equal(t, int64(1_000_000), cfg.Gateway.Grok.FreeQuotaTokenLimit) + require.Equal(t, int64(500_000), cfg.Gateway.Grok.FreeQuotaTokenLimit) require.Equal(t, 95, cfg.Gateway.Grok.FreeQuotaSoftGatePercent) require.Equal(t, 24, cfg.Gateway.Grok.FreeQuotaWindowHours) require.Equal(t, 5, cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds) diff --git a/backend/internal/pkg/xai/quota.go b/backend/internal/pkg/xai/quota.go index 3802b4fa4f..11133a05b3 100644 --- a/backend/internal/pkg/xai/quota.go +++ b/backend/internal/pkg/xai/quota.go @@ -7,10 +7,14 @@ import ( "time" ) -const GrokFreeRolling24hTokenLimit int64 = 1_000_000 +// GrokFreeRolling24hTokenLimit is the operator soft-gate nominal Free allowance +// (rolling 24h). Soft-gate default matches this; upstream header limits may +// still report historical 1M/2M Free snapshots. +const GrokFreeRolling24hTokenLimit int64 = 500_000 var grokFreeRolling24hTokenLimits = map[int64]struct{}{ GrokFreeRolling24hTokenLimit: {}, + 1_000_000: {}, // Observed Free limit variants. 2_000_000: {}, // Legacy Free limit observed before July 2026. } diff --git a/backend/internal/pkg/xai/quota_test.go b/backend/internal/pkg/xai/quota_test.go index aafa7486b9..40dfde4c09 100644 --- a/backend/internal/pkg/xai/quota_test.go +++ b/backend/internal/pkg/xai/quota_test.go @@ -165,6 +165,8 @@ func TestIsGrokFreeRolling24hTokenLimit(t *testing.T) { t.Parallel() require.True(t, IsGrokFreeRolling24hTokenLimit(GrokFreeRolling24hTokenLimit)) + require.True(t, IsGrokFreeRolling24hTokenLimit(500_000)) + require.True(t, IsGrokFreeRolling24hTokenLimit(1_000_000), "observed Free limit variants remain classifiable") require.True(t, IsGrokFreeRolling24hTokenLimit(2_000_000), "legacy snapshots remain classifiable") require.False(t, IsGrokFreeRolling24hTokenLimit(3_000_000)) } diff --git a/backend/internal/service/account_scheduling_threshold_eval.go b/backend/internal/service/account_scheduling_threshold_eval.go index aed703e9ca..20afe25889 100644 --- a/backend/internal/service/account_scheduling_threshold_eval.go +++ b/backend/internal/service/account_scheduling_threshold_eval.go @@ -282,11 +282,15 @@ func anthropicThresholdCandidates(account *Account) []*accountSchedulingThreshol // kiro_sched_* / antigravity_sched_* extras are still written purely as // observability snapshots. +// grokThresholdCandidates mirrors personal-dev: 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 } - candidates := []*accountSchedulingThresholdCandidate{ + return []*accountSchedulingThresholdCandidate{ { window: "quota", scope: "grok", @@ -294,62 +298,6 @@ func grokThresholdCandidates(account *Account) []*accountSchedulingThresholdCand until: parseSchedulingResetAt(account.Extra["grok_sched_reset_at"]), }, } - // Official billing windows (weekly credits / monthly used%) from probe snapshot. - // Complements header-based grok_sched_* so paid SuperGrok accounts can auto-pause - // when request/token headers are sparse but billing % is high. - // Do not cross-fallback period ends (weekly until ≠ monthly end) to avoid multi-week parks. - const grokBillingThresholdStaleAfter = 48 * time.Hour - if billing, err := grokBillingSnapshotFromExtra(account.Extra); err == nil && billing != nil { - fetchedAt := firstNonEmpty(billing.UpdatedAt, billing.FetchedAt, billing.WeeklyUpdatedAt, billing.MonthlyUpdatedAt) - if !grokBillingSnapshotStale(fetchedAt, grokBillingThresholdStaleAfter) { - if billing.UsagePercent != nil { - if until := parseSchedulingResetAt(billing.PeriodEnd); until != nil { - candidates = append(candidates, &accountSchedulingThresholdCandidate{ - window: "seven_day", - scope: "grok_billing", - usedPercent: *billing.UsagePercent, - until: until, - }) - } - } - monthlyUtil := 0.0 - hasMonthly := false - if billing.UsedPercent != nil { - monthlyUtil = *billing.UsedPercent - hasMonthly = true - } else if billing.MonthlyLimitCents != nil && *billing.MonthlyLimitCents > 0 && billing.UsedCents != nil { - monthlyUtil = (*billing.UsedCents / *billing.MonthlyLimitCents) * 100 - hasMonthly = true - } - if hasMonthly { - if until := parseSchedulingResetAt(billing.BillingPeriodEnd); until != nil { - candidates = append(candidates, &accountSchedulingThresholdCandidate{ - window: "thirty_day", - scope: "grok_billing", - usedPercent: monthlyUtil, - until: until, - }) - } - } - } - } - return candidates -} - -func grokBillingSnapshotStale(fetchedAtRFC3339 string, maxAge time.Duration) bool { - if maxAge <= 0 { - return false - } - raw := strings.TrimSpace(fetchedAtRFC3339) - if raw == "" { - // Unknown freshness: still allow (probe may omit timestamps on older snapshots). - return false - } - ts, err := time.Parse(time.RFC3339, raw) - if err != nil { - return false - } - return time.Since(ts) > maxAge } func pickLatestResetSchedulingCandidate(candidates []*accountSchedulingThresholdCandidate, threshold int, now time.Time) *accountSchedulingThresholdCandidate { diff --git a/backend/internal/service/account_scheduling_threshold_eval_test.go b/backend/internal/service/account_scheduling_threshold_eval_test.go index 95c8c41aac..07a3a4b0b2 100644 --- a/backend/internal/service/account_scheduling_threshold_eval_test.go +++ b/backend/internal/service/account_scheduling_threshold_eval_test.go @@ -308,70 +308,32 @@ func TestEvaluateAccountSchedulingThreshold_GrokUsesConfiguredThresholds(t *test require.True(t, wantUntil.Equal(*decision.Until)) } -func TestEvaluateAccountSchedulingThreshold_GrokBillingWindows(t *testing.T) { +func TestEvaluateAccountSchedulingThreshold_GrokUsesOnlyHeaderQuotaWindow(t *testing.T) { t.Parallel() - - now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) - weeklyEnd := now.Add(3 * time.Hour) - weeklyPct := 95.0 - monthlyPct := 40.0 - account := &Account{ - Platform: PlatformGrok, - Extra: map[string]any{ - // Header-based quota below threshold. - "grok_sched_utilization": 50.0, - "grok_sched_reset_at": now.Add(time.Hour).Format(time.RFC3339), - // Official weekly billing above threshold (extra key used by probe persist). - grokBillingExtraKey: &xai.BillingSummary{ - UsagePercent: &weeklyPct, - PeriodEnd: weeklyEnd.Format(time.RFC3339), - UsedPercent: &monthlyPct, - BillingPeriodEnd: now.Add(10 * 24 * time.Hour).Format(time.RFC3339), - }, - }, - } - - decision := EvaluateAccountSchedulingThreshold(account, map[string]int{ - PlatformGrok: 90, - }, now) - - require.True(t, decision.ShouldPause) - require.Equal(t, "grok_billing", decision.Scope) - require.Equal(t, "seven_day", decision.Window) - require.InDelta(t, 95.0, decision.UsedPercent, 1e-9) - require.NotNil(t, decision.Until) - require.True(t, weeklyEnd.Equal(*decision.Until)) -} - -func TestEvaluateAccountSchedulingThreshold_GrokBillingStaleIgnored(t *testing.T) { - t.Parallel() - + // personal-dev: billing seven_day/thirty_day must not drive pause; only grok_sched_*. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) weeklyEnd := now.Add(3 * time.Hour) weeklyPct := 99.0 - // Snapshot older than 48h → billing windows skipped; header util below threshold. + headerUntil := now.Add(2 * time.Hour) account := &Account{ Platform: PlatformGrok, Extra: map[string]any{ - "grok_sched_utilization": 50.0, - "grok_sched_reset_at": now.Add(time.Hour).Format(time.RFC3339), + "grok_sched_utilization": 50.0, // below threshold + "grok_sched_reset_at": headerUntil.Format(time.RFC3339), grokBillingExtraKey: &xai.BillingSummary{ UsagePercent: &weeklyPct, PeriodEnd: weeklyEnd.Format(time.RFC3339), - UpdatedAt: now.Add(-49 * time.Hour).Format(time.RFC3339), }, }, } + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformGrok: 90}, now) + require.False(t, decision.ShouldPause, "high billing % alone must not pause under personal-dev windows") - decision := EvaluateAccountSchedulingThreshold(account, map[string]int{ - PlatformGrok: 90, - }, now) - require.False(t, decision.ShouldPause) -} - -func TestGrokBillingSnapshotStale(t *testing.T) { - t.Parallel() - require.False(t, grokBillingSnapshotStale("", 48*time.Hour)) - require.False(t, grokBillingSnapshotStale(time.Now().UTC().Format(time.RFC3339), 48*time.Hour)) - require.True(t, grokBillingSnapshotStale(time.Now().UTC().Add(-49*time.Hour).Format(time.RFC3339), 48*time.Hour)) + account.Extra["grok_sched_utilization"] = 95.0 + decision = EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformGrok: 90}, now) + require.True(t, decision.ShouldPause) + require.Equal(t, "grok", decision.Scope) + require.Equal(t, "quota", decision.Window) + require.NotNil(t, decision.Until) + require.True(t, headerUntil.Equal(*decision.Until)) } diff --git a/backend/internal/service/grok_free_quota_gate.go b/backend/internal/service/grok_free_quota_gate.go index 4d48de8995..d6a1700c3a 100644 --- a/backend/internal/service/grok_free_quota_gate.go +++ b/backend/internal/service/grok_free_quota_gate.go @@ -3,6 +3,7 @@ package service import ( "context" "log/slog" + "strings" "sync" "sync/atomic" "time" @@ -14,18 +15,18 @@ import ( // Local free-tier soft gate for Grok OAuth scheduling. // // Config keys (gateway.grok.*): -// - free_quota_soft_gate_enabled (bool, default true) — only applied when free-tier detection is strict -// - free_quota_token_limit (int64, default = xai free rolling limit 1_000_000) +// - free_quota_soft_gate_enabled (bool, default true) +// - free_quota_token_limit (int64, default 500_000) // - free_quota_soft_gate_percent (int, default 95) — stop scheduling before the nominal limit // - free_quota_window_hours (int, default 24) — local usage rolling window // - free_quota_stats_cache_seconds (int, default 5) — bound hot-path aggregate query frequency // -// Admin paths (QueryQuota / import probe / AccountUsageService.GetUsage) never call -// filterGrokFreeQuotaAccounts; only the OpenAI-compatible account scheduler filter does. +// Soft-gate applies only to *explicit* free OAuth (subscription_tier/plan_type == +// "free"), matching personal-dev. Media/cache free detection uses isKnownGrokFreeAccount. +// Admin paths (QueryQuota / import probe) never call this filter. const ( - // Match current Free rolling limit (pkg/xai). A 2M default never pre-empts real exhaustion. - defaultGrokFreeQuotaTokenLimit int64 = 1_000_000 + defaultGrokFreeQuotaTokenLimit int64 = 500_000 defaultGrokFreeQuotaSoftGatePercent = 95 defaultGrokFreeQuotaWindowHours = 24 ) @@ -85,11 +86,24 @@ func calculateGrokFreeQuotaSoftGateTokens(limit int64, percent int) int64 { } // isExplicitGrokFreeOAuthAccount decides whether the free soft-gate applies. -// Uses the same free/paid evidence as media eligibility (isKnownGrokFreeAccount): -// free/basic tiers, billing-inferred free, while paid plan/monthly evidence wins. -// Unknown/empty tier without free evidence and API-key accounts are not gated. +// personal-dev contract: only OAuth accounts with credentials/extra +// subscription_tier or plan_type exactly "free" (case-insensitive). Inferred +// free / basic / blank plan do not soft-gate. func isExplicitGrokFreeOAuthAccount(account *Account) bool { - return isKnownGrokFreeAccount(account) + if account == nil || !account.IsGrokOAuth() { + return false + } + for _, tier := range []string{ + account.GetCredential("subscription_tier"), + account.GetCredential("plan_type"), + account.GetExtraString("subscription_tier"), + account.GetExtraString("plan_type"), + } { + if strings.EqualFold(strings.TrimSpace(tier), "free") { + return true + } + } + return false } // filterGrokFreeQuotaAccounts applies a local, rolling soft gate only to diff --git a/backend/internal/service/grok_free_quota_gate_test.go b/backend/internal/service/grok_free_quota_gate_test.go index 7612f24699..267533b803 100644 --- a/backend/internal/service/grok_free_quota_gate_test.go +++ b/backend/internal/service/grok_free_quota_gate_test.go @@ -56,7 +56,7 @@ func (r *grokFreeQuotaUsageRepoStub) GetAccountWindowStatsBatch(_ context.Contex func grokFreeQuotaTestConfig() *config.Config { cfg := &config.Config{} cfg.Gateway.Grok.FreeQuotaSoftGateEnabled = true - cfg.Gateway.Grok.FreeQuotaTokenLimit = 2_000_000 + cfg.Gateway.Grok.FreeQuotaTokenLimit = 500_000 cfg.Gateway.Grok.FreeQuotaSoftGatePercent = 95 cfg.Gateway.Grok.FreeQuotaWindowHours = 24 cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds = 5 @@ -65,7 +65,7 @@ func grokFreeQuotaTestConfig() *config.Config { func TestFilterGrokFreeQuotaAccountsOnlyBlocksExplicitFreeOAuth(t *testing.T) { repo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{ - 1: {Tokens: 1_900_000}, + 1: {Tokens: 475_000}, // 95% of 500k }} scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: grokFreeQuotaTestConfig(), usageLogRepo: repo}} accounts := []Account{ @@ -116,7 +116,7 @@ func TestFilterGrokFreeQuotaAccountsUnknownTierFailOpen(t *testing.T) { func TestFilterGrokFreeQuotaAccountsRecoversAfterRollingUsageFalls(t *testing.T) { repo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{ - 1: {Tokens: 1_950_000}, + 1: {Tokens: 490_000}, }} scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: grokFreeQuotaTestConfig(), usageLogRepo: repo}} accounts := []Account{{ @@ -126,12 +126,12 @@ func TestFilterGrokFreeQuotaAccountsRecoversAfterRollingUsageFalls(t *testing.T) require.Empty(t, scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts)) repo.mu.Lock() - repo.stats[1] = &usagestats.AccountStats{Tokens: 1_200_000} + repo.stats[1] = &usagestats.AccountStats{Tokens: 100_000} repo.mu.Unlock() require.Empty(t, scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts), "fresh cache keeps the short soft-gate hold") scheduler.grokFreeQuotaGateCache.Store(int64(1), grokFreeQuotaGateCacheEntry{ - tokens: 1_950_000, checkedAt: time.Now().Add(-time.Minute), known: true, + tokens: 490_000, checkedAt: time.Now().Add(-time.Minute), known: true, }) require.Equal(t, []int64{1}, accountIDs(scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts))) require.Equal(t, 2, repo.calls) @@ -140,10 +140,22 @@ func TestFilterGrokFreeQuotaAccountsRecoversAfterRollingUsageFalls(t *testing.T) func TestResolveGrokFreeQuotaGateSettingsDefaultsToNinetyFivePercent(t *testing.T) { settings, ok := resolveGrokFreeQuotaGateSettings(grokFreeQuotaTestConfig()) require.True(t, ok) - require.Equal(t, int64(1_900_000), settings.gateTokens) + require.Equal(t, int64(500_000), settings.limitTokens) + require.Equal(t, int64(475_000), settings.gateTokens) // 95% of 500k require.Equal(t, 24*time.Hour, settings.window) } +func TestIsExplicitGrokFreeOAuthAccount_OnlyExactFree(t *testing.T) { + t.Parallel() + require.False(t, isExplicitGrokFreeOAuthAccount(nil)) + require.False(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeAPIKey, Credentials: map[string]any{"subscription_tier": "free"}})) + require.True(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "FREE"}})) + require.True(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"plan_type": "free"}})) + // basic / inferred free are NOT soft-gated (personal-dev contract). + require.False(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "basic"}})) + require.False(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth})) +} + func TestOpenAIAccountSchedulerLoadBalanceAppliesGrokFreeQuotaGate(t *testing.T) { cfg := grokFreeQuotaTestConfig() cfg.RunMode = config.RunModeSimple @@ -155,7 +167,7 @@ func TestOpenAIAccountSchedulerLoadBalanceAppliesGrokFreeQuotaGate(t *testing.T) cfg: cfg, accountRepo: &grokFreeQuotaAccountRepoStub{accounts: accounts}, usageLogRepo: &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{ - 1: {Tokens: 1_900_000}, + 1: {Tokens: 480_000}, // over 95% of 500k }}, } scheduler := &defaultOpenAIAccountScheduler{service: svc, stats: newOpenAIAccountRuntimeStats()} @@ -177,7 +189,7 @@ func TestGrokFreeQuotaGateIsSchedulerOnlyAdminPathUnfiltered(t *testing.T) { require.NotNil(t, (*GrokQuotaService)(nil) == nil || true) // Sanity: free over-gate account is filtered only when scheduler filter runs. repo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{ - 9: {Tokens: 2_000_000}, + 9: {Tokens: 500_000}, }} scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: grokFreeQuotaTestConfig(), usageLogRepo: repo}} overGate := Account{ID: 9, Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "FREE"}} diff --git a/backend/internal/service/openai_gateway_grok_cache.go b/backend/internal/service/openai_gateway_grok_cache.go index 208a9f0edd..83482bd320 100644 --- a/backend/internal/service/openai_gateway_grok_cache.go +++ b/backend/internal/service/openai_gateway_grok_cache.go @@ -304,6 +304,9 @@ func applyGrokFreeToolCacheRoute(body, intentSourceBody []byte, account *Account return appendGrokFreeCacheNativeToolsWithPolicy(body, allowPureClientTools, allowFunctionSearch) } +// isKnownGrokFreeAccount matches personal-dev free-tier recognition used for +// Free cache routing / media free_tier blocks (broader than soft-gate). +// Soft-gate uses isExplicitGrokFreeOAuthAccount (exact "free" only). func isKnownGrokFreeAccount(account *Account) bool { if account == nil || !account.IsGrokOAuth() { return false @@ -319,20 +322,15 @@ func isKnownGrokFreeAccount(account *Account) bool { paidSignal = true } } - // Paid evidence: explicit plan name or monthly dollar cap — NOT mere presence of - // weekly/monthly usage percent (Free accounts also report creditUsagePercent). - if billing.MonthlyLimitCents != nil && *billing.MonthlyLimitCents > 0 { + // personal-dev: usage % or monthly dollar cap is paid evidence. + if billing.UsagePercent != nil || billing.UsedPercent != nil || + (billing.MonthlyLimitCents != nil && *billing.MonthlyLimitCents > 0) { paidSignal = true } - // Infer free only from a successful *monthly* billing window with no paid - // plan/limit. Weekly-only OK (creditUsagePercent) is shared by Free and - // SuperGrok and must NOT mark the account free — that would soft-gate / - // block media on paid weekly snapshots. - monthlyOK := strings.TrimSpace(billing.MonthlyUpdatedAt) != "" || - (billing.MonthlyStatusCode >= http.StatusOK && billing.MonthlyStatusCode < http.StatusMultipleChoices) - if monthlyOK && !billing.Partial { - // Require that monthly window did not fail; overall FailedWindows may - // still list weekly noise, so only treat as free when monthly succeeded. + // Empty plan + successful monthly observation → inferred free (no paid plan/limit). + if strings.TrimSpace(billing.MonthlyUpdatedAt) != "" || + (billing.StatusCode >= http.StatusOK && billing.StatusCode < http.StatusMultipleChoices && + !billing.Partial && len(billing.FailedWindows) == 0) { inferredFreeSignal = true } } @@ -349,34 +347,16 @@ func isKnownGrokFreeAccount(account *Account) bool { inferredFreeSignal = true } } - for _, key := range []string{"subscription_tier", "plan_type"} { - if tier := strings.TrimSpace(account.GetCredential(key)); tier != "" { - if isGrokFreeSubscriptionTier(tier) { - freeSignal = true - } else if !isGrokUnknownSubscriptionTier(tier) { - paidSignal = true - } - } - if tier := strings.TrimSpace(account.GetExtraString(key)); tier != "" { - if isGrokFreeSubscriptionTier(tier) { - freeSignal = true - } else if !isGrokUnknownSubscriptionTier(tier) { - paidSignal = true - } + // personal-dev: only credentials subscription_tier (not plan_type / extra keys). + if tier := strings.TrimSpace(account.GetCredential("subscription_tier")); tier != "" { + if isGrokFreeSubscriptionTier(tier) { + freeSignal = true + } else if !isGrokUnknownSubscriptionTier(tier) { + paidSignal = true } } - // Strong paid evidence always wins — including over stale free credentials - // left after SuperGrok upgrade (subscription_tier still "free" until re-import). - if paidSignal { - return false - } - // Explicit free/basic tier (credentials or free plan string). - if freeSignal { - return true - } - // Inferred free (empty plan + successful monthly probe, or free token limit) - // only when there is no paid evidence. - return inferredFreeSignal + // Explicit paid evidence always wins over an inferred Free signal. + return !paidSignal && (freeSignal || inferredFreeSignal) } func isGrokFreeSubscriptionTier(tier string) bool { diff --git a/backend/internal/service/openai_gateway_grok_search_billing_test.go b/backend/internal/service/openai_gateway_grok_search_billing_test.go index ed8c1fedb9..5d0525b121 100644 --- a/backend/internal/service/openai_gateway_grok_search_billing_test.go +++ b/backend/internal/service/openai_gateway_grok_search_billing_test.go @@ -90,7 +90,7 @@ func TestGetSchedulableAccount_AppliesGrokFreeSoftGate(t *testing.T) { // Sticky/non-list path must not return over-gate free OAuth accounts. cfg := &config.Config{} cfg.Gateway.Grok.FreeQuotaSoftGateEnabled = true - cfg.Gateway.Grok.FreeQuotaTokenLimit = 1_000_000 + cfg.Gateway.Grok.FreeQuotaTokenLimit = 500_000 cfg.Gateway.Grok.FreeQuotaSoftGatePercent = 95 cfg.Gateway.Grok.FreeQuotaWindowHours = 24 cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds = 0 @@ -104,7 +104,7 @@ func TestGetSchedulableAccount_AppliesGrokFreeSoftGate(t *testing.T) { accountsByID: map[int64]*Account{account.ID: account}, } usageRepo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{ - account.ID: {Tokens: 999_000}, + account.ID: {Tokens: 480_000}, // above 95% of 500k }} // Clear shared gateway free-gate cache so this test is deterministic. gatewayGrokFreeQuotaGateCache.Range(func(key, _ any) bool { @@ -126,7 +126,7 @@ func TestOpenAIGetSchedulableAccount_AppliesGrokFreeSoftGate(t *testing.T) { // Legacy OpenAI-compatible sticky (advanced scheduler off) must free-gate Grok. cfg := &config.Config{} cfg.Gateway.Grok.FreeQuotaSoftGateEnabled = true - cfg.Gateway.Grok.FreeQuotaTokenLimit = 1_000_000 + cfg.Gateway.Grok.FreeQuotaTokenLimit = 500_000 cfg.Gateway.Grok.FreeQuotaSoftGatePercent = 95 cfg.Gateway.Grok.FreeQuotaWindowHours = 24 cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds = 0 @@ -140,7 +140,7 @@ func TestOpenAIGetSchedulableAccount_AppliesGrokFreeSoftGate(t *testing.T) { accountsByID: map[int64]*Account{account.ID: account}, } usageRepo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{ - account.ID: {Tokens: 999_000}, + account.ID: {Tokens: 480_000}, }} openaiGrokFreeQuotaGateCache.Range(func(key, _ any) bool { openaiGrokFreeQuotaGateCache.Delete(key)