diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index b68a06ac54..e2adb3a17a 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -117,10 +117,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { openAI403CounterCache := repository.NewOpenAI403CounterCache(redisClient) geminiTokenCache := repository.NewGeminiTokenCache(redisClient) compositeTokenCacheInvalidator := service.NewCompositeTokenCacheInvalidator(geminiTokenCache) - rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, openAI403CounterCache, settingService, compositeTokenCacheInvalidator) + httpUpstream := repository.NewHTTPUpstream(configConfig) + leaderLockCache := repository.NewLeaderLockCache(redisClient) + ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db) + rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, openAI403CounterCache, settingService, compositeTokenCacheInvalidator, ollamaCloudUsageService) identityCache := repository.NewIdentityCache(redisClient) identityService := service.NewIdentityService(identityCache) - httpUpstream := repository.NewHTTPUpstream(configConfig) timingWheelService, err := service.ProvideTimingWheelService() if err != nil { return nil, err @@ -183,7 +185,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db) dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig) dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig) - leaderLockCache := repository.NewLeaderLockCache(redisClient) dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, leaderLockCache, db, configConfig) dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService) adminGroupRepository := repository.NewAdminGroupRepository(client, db) @@ -284,7 +285,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService) auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService) upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db) - ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db) adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 2e3961423a..db43cc7422 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -2257,6 +2257,61 @@ func (r *accountRepository) ClearRateLimitIfObserved(ctx context.Context, id int return true, nil } +// SetRateLimitedIfUnchanged atomically applies a rate-limit reset only while the +// account still carries exactly the generation the caller observed: its +// UpdatedAt row version, its RateLimitedAt and its RateLimitResetAt (nil means +// that field is currently unset). It is the write-back CAS counterpart to +// ClearRateLimitIfObserved: an async rate-limit reset (e.g. an Ollama Cloud +// usage probe) must not overwrite a newer 429, an admin clear, a re-armed +// generation, or a key/state change observed by another writer between the +// caller's read and this write. The whole update is a single statement, so the +// write itself is race-free. updated reports whether the write happened, and the +// caller must ONLY send its scheduling notification when updated == true (this +// method already performed the DB update; no further SetRateLimited call is +// allowed, as a second unconditional write would reintroduce the race). No new +// migration is required. +func (r *accountRepository) SetRateLimitedIfUnchanged( + ctx context.Context, + id int64, + expectedUpdatedAt time.Time, + expectedLimitedAt, expectedResetAt *time.Time, + newResetAt time.Time, +) (bool, error) { + preds := []dbpredicate.Account{dbaccount.IDEQ(id), dbaccount.UpdatedAtEQ(expectedUpdatedAt)} + if expectedLimitedAt == nil { + preds = append(preds, dbaccount.RateLimitedAtIsNil()) + } else { + preds = append(preds, dbaccount.RateLimitedAtEQ(*expectedLimitedAt)) + } + if expectedResetAt == nil { + preds = append(preds, dbaccount.RateLimitResetAtIsNil()) + } else { + preds = append(preds, dbaccount.RateLimitResetAtEQ(*expectedResetAt)) + } + + now := time.Now() + updated, err := r.client.Account.Update(). + Where(preds...). + SetRateLimitedAt(now). + SetRateLimitResetAt(newResetAt). + Save(ctx) + if err != nil { + return false, err + } + if updated == 0 { + // The generation changed concurrently (cleared, re-armed, or the account + // was otherwise updated elsewhere): do not announce anything, just + // refresh the local scheduler snapshot. + r.syncSchedulerAccountSnapshot(ctx, id) + return false, nil + } + if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &id, nil, nil); err != nil { + logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue rate limit failed: account=%d err=%v", id, err) + } + r.syncSchedulerAccountSnapshot(ctx, id) + return true, nil +} + func (r *accountRepository) SetModelRateLimit(ctx context.Context, id int64, scope string, resetAt time.Time, reason ...string) error { if scope == "" { return nil diff --git a/backend/internal/repository/account_repo_ratelimit_cas_integration_test.go b/backend/internal/repository/account_repo_ratelimit_cas_integration_test.go new file mode 100644 index 0000000000..250a964034 --- /dev/null +++ b/backend/internal/repository/account_repo_ratelimit_cas_integration_test.go @@ -0,0 +1,96 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +// TestAccountRepositorySetRateLimitedIfUnchanged exercises the atomic generation +// CAS backing the Ollama Cloud usage probe write-back (SetRateLimitedIfUnchanged). +// It must only write when the row still carries the exact UpdatedAt / +// RateLimitedAt / RateLimitResetAt the caller observed, so a stale async reset can +// never overwrite a newer 429, an admin clear, or a re-armed generation. +func TestAccountRepositorySetRateLimitedIfUnchanged(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + + makeAcct := func(name string) *service.Account { + return mustCreateAccount(t, tx.Client(), &service.Account{ + Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://www.ollama.com", "api_key": name}, + }) + } + + // --- Matching generation applies the new reset. --- + acct := makeAcct("cas-match") + base := time.Now().Add(5 * time.Second) + require.NoError(t, repo.SetRateLimited(ctx, acct.ID, base)) + + cur, err := repo.GetByID(ctx, acct.ID) + require.NoError(t, err) + require.NotNil(t, cur) + require.NotNil(t, cur.RateLimitedAt) + require.NotNil(t, cur.RateLimitResetAt) + expUpdated, expLimited, expReset := cur.UpdatedAt, cur.RateLimitedAt, cur.RateLimitResetAt + + newReset := time.Now().Add(2 * time.Hour) + updated, err := repo.SetRateLimitedIfUnchanged(ctx, acct.ID, expUpdated, expLimited, expReset, newReset) + require.NoError(t, err) + require.True(t, updated, "a write matching the observed generation must apply") + + after, err := repo.GetByID(ctx, acct.ID) + require.NoError(t, err) + require.NotNil(t, after.RateLimitResetAt) + require.WithinDuration(t, newReset, *after.RateLimitResetAt, 2*time.Second) + + // --- Stale UpdatedAt (row version moved by the write above) must NOT apply. --- + stale := time.Now().Add(3 * time.Hour) + updated, err = repo.SetRateLimitedIfUnchanged(ctx, acct.ID, expUpdated, expLimited, expReset, stale) + require.NoError(t, err) + require.False(t, updated, "a write whose row version moved on must not apply") + + // --- Current UpdatedAt but OLD limited/reset must NOT apply: the rate-limit + // generation was re-armed by the successful write, so the CAS must reject + // purely on the limited/reset predicates (isolated from UpdatedAt). --- + updated, err = repo.SetRateLimitedIfUnchanged(ctx, acct.ID, after.UpdatedAt, expLimited, expReset, stale) + require.NoError(t, err) + require.False(t, updated, "re-armed limited/reset generation must not accept the old one even with the current row version") + + // --- Current UpdatedAt but nil limited/reset must NOT apply against a set + // generation (admin-clear expectation only matches an unset state). --- + clearedReset := time.Now().Add(30 * time.Minute) + updated, err = repo.SetRateLimitedIfUnchanged(ctx, acct.ID, after.UpdatedAt, nil, nil, clearedReset) + require.NoError(t, err) + require.False(t, updated, "nil-limited expectation must not match a set generation") + + // --- Stale UpdatedAt alone (with nil/nil) must also fail on the version. --- + updated, err = repo.SetRateLimitedIfUnchanged(ctx, acct.ID, expUpdated, nil, nil, clearedReset) + require.NoError(t, err) + require.False(t, updated, "stale row version must not apply even with nil limited/reset") + + // --- Never-limited account: nil generation CAS applies. --- + fresh := makeAcct("cas-fresh") + freshCur, err := repo.GetByID(ctx, fresh.ID) + require.NoError(t, err) + require.Nil(t, freshCur.RateLimitedAt) + require.Nil(t, freshCur.RateLimitResetAt) + firstReset := time.Now().Add(time.Hour) + updated, err = repo.SetRateLimitedIfUnchanged(ctx, fresh.ID, freshCur.UpdatedAt, nil, nil, firstReset) + require.NoError(t, err) + require.True(t, updated, "nil generation CAS must apply to a never-limited account") + + // Admin-clear style: clear, then the old (set) generation must no longer match. + require.NoError(t, repo.ClearRateLimit(ctx, fresh.ID)) + afterClear, err := repo.GetByID(ctx, fresh.ID) + require.NoError(t, err) + updated, err = repo.SetRateLimitedIfUnchanged(ctx, fresh.ID, afterClear.UpdatedAt, nil, nil, time.Now().Add(time.Hour)) + require.NoError(t, err) + require.True(t, updated, "cleared account with nil generation accepts a fresh write") +} diff --git a/backend/internal/service/ollama_cloud_usage.go b/backend/internal/service/ollama_cloud_usage.go index 08f1cbbcc6..a797246499 100644 --- a/backend/internal/service/ollama_cloud_usage.go +++ b/backend/internal/service/ollama_cloud_usage.go @@ -393,6 +393,16 @@ type OllamaCloudUsageService struct { lockCache LeaderLockCache db *sql.DB instanceID string + + // Event-triggered rate-limit probe coordination. A single background + // coordinator (started in Start, waited by Stop) fans out model-429 probe + // requests to one controlled refresh per api_key group, reusing the recent + // group snapshot for coalesced members so each account still gets its own + // result without a second upstream fetch. See ollama_cloud_usage_rate_limit_probe.go. + probeMu sync.Mutex + probeQueue []ollamaCloudUsageProbeRequest + probeWake chan struct{} + probeGroups map[string]ollamaCloudUsageProbeGroupEntry } func NewOllamaCloudUsageService( @@ -414,6 +424,8 @@ func NewOllamaCloudUsageService( refreshSlots: make(chan struct{}, ollamaCloudUsageConcurrency), now: time.Now, instanceID: uuid.NewString(), + probeWake: make(chan struct{}, 1), + probeGroups: make(map[string]ollamaCloudUsageProbeGroupEntry), } } @@ -444,9 +456,10 @@ func (s *OllamaCloudUsageService) Start() { return } s.started = true - s.wg.Add(1) + s.wg.Add(2) s.mu.Unlock() go s.runLoop() + go s.probeLoop() } func (s *OllamaCloudUsageService) Stop() { diff --git a/backend/internal/service/ollama_cloud_usage_rate_limit_probe.go b/backend/internal/service/ollama_cloud_usage_rate_limit_probe.go new file mode 100644 index 0000000000..9d20e344ed --- /dev/null +++ b/backend/internal/service/ollama_cloud_usage_rate_limit_probe.go @@ -0,0 +1,385 @@ +package service + +import ( + "context" + "time" +) + +// This file adds the event-triggered rate-limit probe to OllamaCloudUsageService: +// a model-429 handler calls ScheduleOllamaCloudUsageRateLimitProbe to prompt a +// controlled usage refresh now (instead of waiting for the regular cycle) and to +// learn the recovery horizon when the account's usage is actually exhausted. +// +// The probe reuses refreshAccount wholesale (singleflight, bounded concurrency +// slot, group resolution, settings-HTML fetch); no HTTP/parsing is duplicated. +// Work runs on a single background coordinator owned by Start/Stop with a +// detached, timeout-bounded context, so a model request never blocks on a probe +// and no unbounded goroutines are introduced. + +const ( + // ollamaCloudUsageProbeTimeout bounds one detached probe task. It must exceed + // the underlying settings-HTML request timeout. + ollamaCloudUsageProbeTimeout = 45 * time.Second + + // ollamaCloudUsageProbeMaxQueue bounds queued (not yet serviced) probe + // requests. A duplicate request for an already-queued account still merges + // into the latest callback even when the queue is full. + ollamaCloudUsageProbeMaxQueue = 256 + + // ollamaCloudUsageProbeGroupRetention bounds how long finished probe results + // are retained for coalescing before being pruned. + ollamaCloudUsageProbeGroupRetention = 2 * ollamaCloudUsageManualRefreshInterval +) + +// OllamaCloudUsageRateLimitProbeCallback is invoked (on the coordinator goroutine) +// when an account's Ollama Cloud usage is confirmed exhausted from a fresh +// successful snapshot; resetAt is the earliest wall-clock rollover of the +// exhausted window(s). It is never invoked for accounts that are not configured +// Ollama usage accounts, lack a web session, are in failure backoff, or whose most +// recent snapshot is not exhausted. +// +// The callback runs on the single coordinator, so it must not block indefinitely: +// the next serialized probe (and Stop) would be held back. It MAY do bounded +// persistence — e.g. a limited DB write-back under its own timeout context — and +// must not spawn unbounded goroutines. Stop waits the coordinator, so it may wait +// out an in-flight callback's own bounded write-back timeout (shorter than the +// probe task timeout) rather than a strict probe timeout; keep any persistence +// bounded so Stop returns promptly. The 429-wiring author is responsible for how +// the callback maps to a rate-limit write-back. +type OllamaCloudUsageRateLimitProbeCallback func(accountID int64, resetAt time.Time) + +// ollamaCloudUsageProbeRequest is one queued, per-account probe request. +type ollamaCloudUsageProbeRequest struct { + accountID int64 + onExhausted OllamaCloudUsageRateLimitProbeCallback +} + +// ollamaCloudUsageProbeGroupEntry is the most recent probe fetch outcome for an +// api_key group, kept briefly so coalesced members reuse the same upstream result +// instead of each issuing its own fetch. +type ollamaCloudUsageProbeGroupEntry struct { + attemptAt time.Time + snapshot *OllamaCloudUsageSnapshot +} + +// ScheduleOllamaCloudUsageRateLimitProbe schedules an asynchronous probe of the +// account's Ollama Cloud usage and reports whether the request was accepted. It +// never blocks on network or DB work: it only records the request for the +// background coordinator and returns, so it is safe to call from a model-429 path. +// The probe runs under a detached, timeout-bounded context derived from the +// service lifecycle. +// +// Accepted == true means the request was queued, not that a result will follow: +// an ineligible account quietly yields no callback. Accepted == false means the +// service is not running (not started / stopped) or the queue is full for a brand +// new account; a duplicate for an already-queued account is always merged. +func (s *OllamaCloudUsageService) ScheduleOllamaCloudUsageRateLimitProbe( + accountID int64, + onExhausted OllamaCloudUsageRateLimitProbeCallback, +) bool { + if s == nil || onExhausted == nil || accountID <= 0 { + return false + } + s.mu.Lock() + running := s.started && !s.stopped + s.mu.Unlock() + if !running { + return false + } + + s.probeMu.Lock() + // Coalesce a duplicate for the same account first (keep the latest callback) + // so a full queue still accepts a merge. + for i := range s.probeQueue { + if s.probeQueue[i].accountID == accountID { + s.probeQueue[i].onExhausted = onExhausted + s.probeMu.Unlock() + s.wakeProbeLoop() + return true + } + } + if len(s.probeQueue) >= ollamaCloudUsageProbeMaxQueue { + s.probeMu.Unlock() + return false + } + s.probeQueue = append(s.probeQueue, ollamaCloudUsageProbeRequest{ + accountID: accountID, + onExhausted: onExhausted, + }) + s.probeMu.Unlock() + + s.wakeProbeLoop() + return true +} + +// wakeProbeLoop nudges the coordinator; a dropped wake is safe because the +// coordinator re-checks the queue after every wake. +func (s *OllamaCloudUsageService) wakeProbeLoop() { + if s == nil { + return + } + select { + case s.probeWake <- struct{}{}: + default: + } +} + +// probeLoop is the single background worker. It is added to the service WaitGroup +// in Start and returns when parentCtx is cancelled in Stop, so Stop waits for any +// in-flight probe (including a bounded callback) to finish under its own timeout. +func (s *OllamaCloudUsageService) probeLoop() { + defer s.wg.Done() + for { + s.probeMu.Lock() + if len(s.probeQueue) == 0 { + s.probeMu.Unlock() + select { + case <-s.probeWake: + case <-s.parentCtx.Done(): + return + } + continue + } + batch := s.probeQueue + s.probeQueue = nil + s.probeMu.Unlock() + + for _, req := range batch { + if s.parentCtx.Err() != nil { + return + } + ctx, cancel := context.WithTimeout(s.parentCtx, ollamaCloudUsageProbeTimeout) + s.runOllamaCloudUsageProbe(ctx, req.accountID, req.onExhausted) + cancel() + } + } +} + +func (s *OllamaCloudUsageService) probeGroupResult(key string) (ollamaCloudUsageProbeGroupEntry, bool) { + if s == nil { + return ollamaCloudUsageProbeGroupEntry{}, false + } + s.probeMu.Lock() + defer s.probeMu.Unlock() + entry, ok := s.probeGroups[key] + return entry, ok +} + +func (s *OllamaCloudUsageService) storeProbeGroupResult(key string, entry ollamaCloudUsageProbeGroupEntry) { + if s == nil { + return + } + s.probeMu.Lock() + defer s.probeMu.Unlock() + if s.probeGroups == nil { + s.probeGroups = make(map[string]ollamaCloudUsageProbeGroupEntry) + } + pruneAt := entry.attemptAt.Add(-ollamaCloudUsageProbeGroupRetention) + for groupKey, existing := range s.probeGroups { + if existing.attemptAt.Before(pruneAt) { + delete(s.probeGroups, groupKey) + } + } + s.probeGroups[key] = entry +} + +// runOllamaCloudUsageProbe resolves the account and, when appropriate, performs a +// controlled group refresh and reports exhaustion. All decisions are best-effort; +// ineligible/missing-session accounts, transient failures, backoff and +// non-exhaustion are skipped silently. +// +// This is a 429-event recovery query: it deliberately runs regardless of the +// periodic auto_refresh switch (a model was just refused, so a one-off read is +// warranted even when auto-refresh is off). It still respects the presence of a +// configured session/cookie and any scrape-429 failure backoff. +func (s *OllamaCloudUsageService) runOllamaCloudUsageProbe( + ctx context.Context, + accountID int64, + onExhausted OllamaCloudUsageRateLimitProbeCallback, +) { + if s == nil || s.accountRepo == nil || onExhausted == nil || ctx.Err() != nil { + return + } + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil || account == nil { + return + } + if !IsOllamaCloudUsageAccount(account) { + return + } + // Resolve the api_key group so the account sees the group's managed session + // and most recent snapshot even when it is not the row that last refreshed. + if err := s.ResolveAccounts(ctx, []*Account{account}); err != nil { + return + } + if !ollamaCloudUsageConfigured(account) { + return + } + key, valid := ollamaCloudUsageGroupFingerprint(account) + if !valid { + return + } + + window := ollamaCloudUsageManualRefreshInterval + accountSnapshot := decodeOllamaCloudUsageSnapshot(account.Extra) + cached, hasCached := s.probeGroupResult(key) + + // The most recent observation wins, so an older success never masks a newer + // one (success or failure) and never drives a stale exhaustion report. + now := s.currentTime() + if newest := ollamaCloudUsageProbeNewestSuccess(accountSnapshot, cached, hasCached, now, window); newest != nil { + maybeOllamaCloudUsageProbeExhaustion(ctx, accountID, newest, s.currentTime(), window, onExhausted) + return + } + + // Failure backoff: a model 429 must not bypass a scrape 429's NextRefreshAt. + if horizon := ollamaCloudUsageProbeBackoffHorizon(accountSnapshot, cached, hasCached); !horizon.IsZero() && now.Before(horizon) { + return + } + // 30s group cooldown after the most recent group attempt. + if hasCached && now.Before(cached.attemptAt.Add(window)) { + return + } + + settings, settingsErr := s.GetSettings(ctx) + if settingsErr != nil { + return + } + fetched, refreshErr := s.refreshAccount(ctx, accountID, settings, false) + // A nil success means refreshAccount piggybacked on a singleflight run owned + // by another request (a timed-cycle refresh or a sibling probe). That owner + // already stored the group result, so this no-op must not be recorded as a + // fresh attempt (it would wrongly start a 30s group cooldown or mask state). + if refreshErr == nil && fetched == nil { + return + } + // Evaluate against when the fetch actually completed: a reset that rolled over + // during a slow fetch must not be reported as still-future, and the group + // cooldown must not start before the attempt finished. + doneNow := s.currentTime() + s.storeProbeGroupResult(key, ollamaCloudUsageProbeGroupEntry{attemptAt: doneNow, snapshot: fetched}) + if refreshErr != nil { + return + } + maybeOllamaCloudUsageProbeExhaustion(ctx, accountID, fetched, doneNow, window, onExhausted) +} + +// ollamaCloudUsageProbeObservedAt returns the wall-clock time at which a snapshot +// was actually observed: FetchedAt for a success (a failure carries a stale +// FetchedAt copied from a prior success), otherwise LastAttemptAt. +func ollamaCloudUsageProbeObservedAt(snapshot *OllamaCloudUsageSnapshot) (time.Time, bool) { + if snapshot == nil { + return time.Time{}, false + } + if snapshot.Status == OllamaCloudUsageStatusOK && snapshot.FetchedAt != nil && !snapshot.FetchedAt.IsZero() { + return snapshot.FetchedAt.UTC(), true + } + if !snapshot.LastAttemptAt.IsZero() { + return snapshot.LastAttemptAt.UTC(), true + } + return time.Time{}, false +} + +// ollamaCloudUsageProbeNewestSuccess returns the most recent observation when it +// is a fresh, successful snapshot that may feed the exhaustion decision, or nil +// otherwise. The candidate with the latest actual observation time is picked, so +// neither the account row nor the coordinator cache is unconditionally preferred, +// and a newer failure (or an unknown recent attempt) suppresses an older success +// rather than letting it drive a stale exhaustion report. +func ollamaCloudUsageProbeNewestSuccess( + accountSnapshot *OllamaCloudUsageSnapshot, + cached ollamaCloudUsageProbeGroupEntry, + hasCached bool, + now time.Time, + window time.Duration, +) *OllamaCloudUsageSnapshot { + var newest *OllamaCloudUsageSnapshot + var newestAt time.Time + consider := func(snapshot *OllamaCloudUsageSnapshot) { + if snapshot == nil { + return + } + at, ok := ollamaCloudUsageProbeObservedAt(snapshot) + if !ok { + return + } + if newest == nil || at.After(newestAt) { + newest = snapshot + newestAt = at + } + } + consider(accountSnapshot) + if hasCached { + if cached.snapshot != nil { + consider(cached.snapshot) + } else if !cached.attemptAt.IsZero() && (newest == nil || cached.attemptAt.After(newestAt)) { + // The most recent group attempt produced no snapshot (an errored + // fetch); it supersedes any older success. + newest = nil + newestAt = cached.attemptAt + } + } + if newest == nil || newest.Status != OllamaCloudUsageStatusOK { + return nil + } + if newest.FetchedAt == nil || newest.FetchedAt.IsZero() || newest.FetchedAt.Before(now.Add(-window)) { + return nil + } + return newest +} + +// ollamaCloudUsageProbeBackoffHorizon returns the furthest NextRefreshAt among +// failed/unauthorized snapshots (the account's persisted one and the coordinator's +// most recent group outcome), or the zero time when no failure backoff is in force. +func ollamaCloudUsageProbeBackoffHorizon( + accountSnapshot *OllamaCloudUsageSnapshot, + cached ollamaCloudUsageProbeGroupEntry, + hasCached bool, +) time.Time { + var horizon time.Time + consider := func(snapshot *OllamaCloudUsageSnapshot) { + if snapshot == nil { + return + } + if snapshot.Status != OllamaCloudUsageStatusFailed && snapshot.Status != OllamaCloudUsageStatusUnauthorized { + return + } + if snapshot.NextRefreshAt.IsZero() { + return + } + if horizon.IsZero() || snapshot.NextRefreshAt.After(horizon) { + horizon = snapshot.NextRefreshAt.UTC() + } + } + consider(accountSnapshot) + if hasCached { + consider(cached.snapshot) + } + return horizon +} + +// maybeOllamaCloudUsageProbeExhaustion invokes onExhausted only when the snapshot +// is a fresh, successful, genuinely exhausted one (per +// ollamaCloudUsageExhaustionResetAt). Non-exhausted or unusable snapshots yield +// no callback. +func maybeOllamaCloudUsageProbeExhaustion( + ctx context.Context, + accountID int64, + snapshot *OllamaCloudUsageSnapshot, + now time.Time, + window time.Duration, + onExhausted OllamaCloudUsageRateLimitProbeCallback, +) { + if snapshot == nil || onExhausted == nil { + return + } + resetAt, exhausted := ollamaCloudUsageExhaustionResetAt(snapshot, now, now.Add(-window)) + if !exhausted { + return + } + if ctx.Err() != nil { + // Probe was cancelled/stopped: do not deliver a stale callback. + return + } + onExhausted(accountID, resetAt) +} diff --git a/backend/internal/service/ollama_cloud_usage_rate_limit_probe_test.go b/backend/internal/service/ollama_cloud_usage_rate_limit_probe_test.go new file mode 100644 index 0000000000..d4fb57ba12 --- /dev/null +++ b/backend/internal/service/ollama_cloud_usage_rate_limit_probe_test.go @@ -0,0 +1,495 @@ +package service + +import ( + "context" + "fmt" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// ollamaCloudProbeUsageBody renders a minimal settings page exposing only the +// five-hour usage window at the given percent and an absolute reset time. +func ollamaCloudProbeUsageBody(percent int, resetRFC3339 string) []byte { + return []byte(fmt.Sprintf(` +
+
5 hour usage%d%% used
+ +
`, percent, resetRFC3339)) +} + +// TestOllamaCloudProbeUsageBodiesParse pins the crafted HTML fixtures used by the +// probe tests: 100% usage is exhausted and must carry the future reset. +func TestOllamaCloudProbeUsageBodiesParse(t *testing.T) { + reset := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + exhausted, err := parseOllamaCloudUsageHTML(ollamaCloudProbeUsageBody(100, reset.Format(time.RFC3339))) + require.NoError(t, err) + require.NotNil(t, exhausted.FiveHour) + require.Equal(t, float64(100), exhausted.FiveHour.UsedPercent) + require.NotNil(t, exhausted.FiveHour.ResetAt) + require.True(t, exhausted.FiveHour.ResetAt.Equal(reset), "reset = %v", exhausted.FiveHour.ResetAt) + + mild, err := parseOllamaCloudUsageHTML(ollamaCloudProbeUsageBody(5, reset.Format(time.RFC3339))) + require.NoError(t, err) + require.NotNil(t, mild.FiveHour) + require.Equal(t, float64(5), mild.FiveHour.UsedPercent) +} + +// ollamaCloudProbeFixture builds a started service with a fixed clock so exhaustion +// and reset decisions are deterministic. +func ollamaCloudProbeFixture(t *testing.T, repo AccountRepository, upstream HTTPUpstream, fixedNow time.Time) *OllamaCloudUsageService { + t.Helper() + svc := NewOllamaCloudUsageService(repo, upstream, NewSettingService(&upstreamBillingProbeSettingRepo{}, nil), ollamaUsageTestEncryptor{}, true) + svc.now = func() time.Time { return fixedNow } + svc.Start() + t.Cleanup(svc.Stop) + return svc +} + +func ollamaCloudProbeReset(t *testing.T, fixedNow time.Time) time.Time { + t.Helper() + return fixedNow.Add(2 * time.Hour).UTC() +} + +func TestOllamaCloudUsageScheduleRateLimitProbeReturnsWhileProbeRuns(t *testing.T) { + // A gated GetByID proves ScheduleRateLimitProbe does not synchronously block + // on the probe's database/network work. + fixedNow := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + account := ollamaUsageAccount(1) + account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret" + base := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{ + accounts: map[int64]*Account{account.ID: account}, + }} + repo := &ollamaCloudProbeGateRepo{ollamaUsageTestRepo: base, entered: make(chan struct{}), release: make(chan struct{})} + repo.gating.Store(true) + upstream := &ollamaUsageHTTPStub{body: ollamaCloudProbeUsageBody(100, ollamaCloudProbeReset(t, fixedNow).Format(time.RFC3339))} + svc := ollamaCloudProbeFixture(t, repo, upstream, fixedNow) + + called := make(chan time.Time, 1) + accepted := svc.ScheduleOllamaCloudUsageRateLimitProbe(account.ID, func(id int64, reset time.Time) { + called <- reset + }) + require.True(t, accepted, "running service must accept the probe") + + // The fetch is still blocked on the gate, yet Schedule already returned: + // this is only possible if the probe runs asynchronously on the coordinator. + require.Eventually(t, func() bool { + select { + case <-repo.entered: + return true + default: + return false + } + }, 5*time.Second, time.Millisecond, "coordinator must reach the gated GetByID") + require.Zero(t, upstream.calls.Load(), "no upstream fetch while the probe is gated") + select { + case <-called: + t.Fatal("callback must not fire before the probe completes") + default: + } + + repo.gating.Store(false) + close(repo.release) + select { + case reset := <-called: + require.True(t, reset.Equal(ollamaCloudProbeReset(t, fixedNow))) + case <-time.After(5 * time.Second): + t.Fatal("expected an exhaustion callback after the probe completed") + } + require.Equal(t, int64(1), upstream.calls.Load()) +} + +func TestOllamaCloudUsageRateLimitProbeExhaustedReportsReset(t *testing.T) { + fixedNow := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + account := ollamaUsageAccount(11) + account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret" + repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{ + accounts: map[int64]*Account{account.ID: account}, + }} + wantReset := ollamaCloudProbeReset(t, fixedNow) + upstream := &ollamaUsageHTTPStub{body: ollamaCloudProbeUsageBody(100, wantReset.Format(time.RFC3339))} + svc := ollamaCloudProbeFixture(t, repo, upstream, fixedNow) + + called := make(chan time.Time, 1) + require.True(t, svc.ScheduleOllamaCloudUsageRateLimitProbe(account.ID, func(id int64, reset time.Time) { + require.Equal(t, account.ID, id) + called <- reset + })) + select { + case reset := <-called: + require.True(t, reset.Equal(wantReset)) + case <-time.After(5 * time.Second): + t.Fatal("expected a callback for an exhausted account") + } + require.Equal(t, int64(1), upstream.calls.Load()) +} + +func TestOllamaCloudUsageRateLimitProbeNotExhaustedNoCallback(t *testing.T) { + fixedNow := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + account := ollamaUsageAccount(12) + account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret" + repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{ + accounts: map[int64]*Account{account.ID: account}, + }} + upstream := &ollamaUsageHTTPStub{body: ollamaCloudProbeUsageBody(5, ollamaCloudProbeReset(t, fixedNow).Format(time.RFC3339))} + svc := ollamaCloudProbeFixture(t, repo, upstream, fixedNow) + + called := make(chan struct{}, 1) + require.True(t, svc.ScheduleOllamaCloudUsageRateLimitProbe(account.ID, func(int64, time.Time) { + called <- struct{}{} + })) + select { + case <-called: + t.Fatal("not-exhausted usage must not invoke the callback") + case <-time.After(300 * time.Millisecond): + } + require.Equal(t, int64(1), upstream.calls.Load(), "a fresh probe must still refresh once") +} + +func TestOllamaCloudUsageRateLimitProbeSameGroupCoalescesIntoOneFetchPerAccount(t *testing.T) { + fixedNow := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + first := ollamaUsageAccount(21) + first.Credentials["api_key"] = "shared-key" + first.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=shared" + second := ollamaUsageAccount(22) + second.Platform = PlatformAnthropic + second.Credentials = map[string]any{"base_url": "https://www.ollama.com/v1", "api_key": "shared-key"} + second.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=shared" + repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{ + accounts: map[int64]*Account{first.ID: first, second.ID: second}, + }} + wantReset := ollamaCloudProbeReset(t, fixedNow) + upstream := &ollamaUsageHTTPStub{body: ollamaCloudProbeUsageBody(100, wantReset.Format(time.RFC3339))} + svc := ollamaCloudProbeFixture(t, repo, upstream, fixedNow) + + results := make(map[int64]time.Time) + var mu sync.Mutex + got := make(chan int64, 2) + cb := func(id int64, reset time.Time) { + mu.Lock() + results[id] = reset + mu.Unlock() + got <- id + } + require.True(t, svc.ScheduleOllamaCloudUsageRateLimitProbe(first.ID, cb)) + require.True(t, svc.ScheduleOllamaCloudUsageRateLimitProbe(second.ID, cb)) + + for range 2 { + select { + case <-got: + case <-time.After(5 * time.Second): + t.Fatal("both same-group accounts must each receive their own callback") + } + } + mu.Lock() + defer mu.Unlock() + require.Contains(t, results, first.ID) + require.Contains(t, results, second.ID) + for _, reset := range results { + require.True(t, reset.Equal(wantReset)) + } + require.Equal(t, int64(1), upstream.calls.Load(), "one shared group must fetch at most once for the coalesced probes") +} + +func TestOllamaCloudUsageRateLimitProbeSkipsMissingCookieAndNonOllamaAccount(t *testing.T) { + fixedNow := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + noCookie := ollamaUsageAccount(31) + noCookie.Extra = map[string]any{} // no session + nonOllama := ollamaUsageAccount(32) + nonOllama.Credentials["base_url"] = "https://api.openai.com" + repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{ + accounts: map[int64]*Account{noCookie.ID: noCookie, nonOllama.ID: nonOllama}, + }} + upstream := &ollamaUsageHTTPStub{body: ollamaCloudProbeUsageBody(100, ollamaCloudProbeReset(t, fixedNow).Format(time.RFC3339))} + svc := ollamaCloudProbeFixture(t, repo, upstream, fixedNow) + + called := make(chan struct{}, 1) + cb := func(int64, time.Time) { called <- struct{}{} } + require.True(t, svc.ScheduleOllamaCloudUsageRateLimitProbe(noCookie.ID, cb)) + require.True(t, svc.ScheduleOllamaCloudUsageRateLimitProbe(nonOllama.ID, cb)) + + select { + case <-called: + t.Fatal("ineligible accounts must be skipped quietly") + case <-time.After(300 * time.Millisecond): + } + require.Zero(t, upstream.calls.Load(), "no upstream fetch for ineligible accounts") +} + +func TestOllamaCloudUsageRateLimitProbeRespectsFailureBackoff(t *testing.T) { + fixedNow := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + account := ollamaUsageAccount(41) + account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret" + backoffUntil := fixedNow.Add(30 * time.Minute) + account.Extra[OllamaCloudUsageSnapshotExtraKey] = &OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusFailed, + LastAttemptAt: fixedNow.Add(-time.Minute), + NextRefreshAt: backoffUntil, + FailureCount: 2, + } + repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{ + accounts: map[int64]*Account{account.ID: account}, + }} + upstream := &ollamaUsageHTTPStub{body: ollamaCloudProbeUsageBody(100, ollamaCloudProbeReset(t, fixedNow).Format(time.RFC3339))} + svc := ollamaCloudProbeFixture(t, repo, upstream, fixedNow) + + called := make(chan struct{}, 1) + require.True(t, svc.ScheduleOllamaCloudUsageRateLimitProbe(account.ID, func(int64, time.Time) { + called <- struct{}{} + })) + select { + case <-called: + t.Fatal("a model 429 must not bypass scrape-429 backoff") + case <-time.After(300 * time.Millisecond): + } + require.Zero(t, upstream.calls.Load(), "no fetch while the snapshot is in failure backoff") +} + +func TestOllamaCloudUsageRateLimitProbeStopCancelsInFlightAndRejectsNew(t *testing.T) { + fixedNow := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + account := ollamaUsageAccount(51) + account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret" + base := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{ + accounts: map[int64]*Account{account.ID: account}, + }} + repo := &ollamaCloudProbeGateRepo{ollamaUsageTestRepo: base, entered: make(chan struct{}), release: make(chan struct{})} + repo.gating.Store(true) + svc := ollamaCloudProbeFixture(t, repo, &ollamaUsageHTTPStub{body: ollamaCloudProbeUsageBody(100, ollamaCloudProbeReset(t, fixedNow).Format(time.RFC3339))}, fixedNow) + + called := make(chan struct{}, 1) + require.True(t, svc.ScheduleOllamaCloudUsageRateLimitProbe(account.ID, func(int64, time.Time) { + called <- struct{}{} + })) + require.Eventually(t, func() bool { + select { + case <-repo.entered: + return true + default: + return false + } + }, 5*time.Second, time.Millisecond, "coordinator must be blocked in the probe") + + // Stop cancels parentCtx; the in-flight probe's detached context is derived + // from it, so the gated GetByID is aborted and Stop returns promptly. + done := make(chan struct{}) + go func() { svc.Stop(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Stop must not hang while a probe is in flight") + } + select { + case <-called: + t.Fatal("no callback may be delivered for a probe cancelled by Stop") + default: + } + + // A stopped service rejects further probes and performs no further fetch. + require.False(t, svc.ScheduleOllamaCloudUsageRateLimitProbe(account.ID, func(int64, time.Time) { + called <- struct{}{} + }), "a stopped service must reject new probes") +} + +// ollamaCloudProbeGateRepo blocks GetByID until released (or its context is +// cancelled), letting tests prove probes run asynchronously and that Stop aborts +// in-flight work. +type ollamaCloudProbeGateRepo struct { + *ollamaUsageTestRepo + entered chan struct{} + release chan struct{} + gating atomic.Bool + once sync.Once +} + +func (r *ollamaCloudProbeGateRepo) GetByID(ctx context.Context, id int64) (*Account, error) { + if r.gating.Load() { + r.once.Do(func() { close(r.entered) }) + select { + case <-r.release: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return r.ollamaUsageTestRepo.GetByID(ctx, id) +} + +// TestOllamaCloudUsageRateLimitProbeSlowFetchDoesNotReportExpiredReset checks that +// an exhaustion decision is evaluated at fetch completion time: when the reset +// rolls over during a slow fetch, the probe must not report a horizon that is +// already in the past (which would otherwise trigger a needless long-lived ban). +func TestOllamaCloudUsageRateLimitProbeSlowFetchDoesNotReportExpiredReset(t *testing.T) { + base := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + account := ollamaUsageAccount(61) + account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret" + repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{ + accounts: map[int64]*Account{account.ID: account}, + }} + + var mu sync.Mutex + cur := base + reset := base.Add(time.Second) // in the future at start, expired by completion + advanced := false + upstream := &ollamaUsageHTTPStub{body: ollamaCloudProbeUsageBody(100, reset.Format(time.RFC3339)), beforeResponse: func(*http.Request) { + mu.Lock() + defer mu.Unlock() + if !advanced { + cur = base.Add(2 * time.Second) // fetch completes after the reset + advanced = true + } + }} + svc := NewOllamaCloudUsageService(repo, upstream, NewSettingService(&upstreamBillingProbeSettingRepo{}, nil), ollamaUsageTestEncryptor{}, true) + svc.now = func() time.Time { mu.Lock(); defer mu.Unlock(); return cur } + svc.Start() + t.Cleanup(svc.Stop) + + called := make(chan struct{}, 1) + require.True(t, svc.ScheduleOllamaCloudUsageRateLimitProbe(account.ID, func(int64, time.Time) { + called <- struct{}{} + })) + select { + case <-called: + t.Fatal("a reset that already rolled over during the fetch must not be reported") + case <-time.After(400 * time.Millisecond): + } + require.Equal(t, int64(1), upstream.calls.Load(), "the slow probe must still fetch once") + + // The snapshot really was exhausted; only the expired reset suppressed the + // callback, which is what this test pins. Read it through the repo (which + // clones under its lock) to avoid racing the coordinator's snapshot write. + require.Eventually(t, func() bool { + loaded, err := repo.GetByID(context.Background(), account.ID) + if err != nil || loaded == nil { + return false + } + snapshot := decodeOllamaCloudUsageSnapshot(loaded.Extra) + return snapshot != nil && snapshot.Status == OllamaCloudUsageStatusOK && + snapshot.Data != nil && snapshot.Data.FiveHour != nil && snapshot.Data.FiveHour.UsedPercent == 100 + }, 5*time.Second, 5*time.Millisecond, "coordinator must persist the exhausted snapshot") +} + +// TestOllamaCloudUsageRateLimitProbeQueueFullStillMergesExistingAccount verifies +// that a full queue still accepts (and merges into the latest callback) a probe +// for an account that is already queued, while a brand-new account is rejected. +func TestOllamaCloudUsageRateLimitProbeQueueFullStillMergesExistingAccount(t *testing.T) { + base := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + repo := &ollamaCloudProbeGateRepo{ollamaUsageTestRepo: &ollamaUsageTestRepo{ + upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{}}, + }, entered: make(chan struct{}), release: make(chan struct{})} + repo.gating.Store(true) + svc := NewOllamaCloudUsageService(repo, &ollamaUsageHTTPStub{body: ollamaCloudProbeUsageBody(100, base.Add(time.Hour).Format(time.RFC3339))}, NewSettingService(&upstreamBillingProbeSettingRepo{}, nil), ollamaUsageTestEncryptor{}, true) + svc.now = func() time.Time { return base } + svc.Start() + t.Cleanup(svc.Stop) + + noop := func(int64, time.Time) {} + // The first probe blocks the single coordinator on its gated GetByID. + require.True(t, svc.ScheduleOllamaCloudUsageRateLimitProbe(9001, noop)) + require.Eventually(t, func() bool { + select { + case <-repo.entered: + return true + default: + return false + } + }, 5*time.Second, time.Millisecond, "coordinator must be blocked") + + // Fill the pending queue to capacity (item 9001 is in flight, not pending). + for id := int64(9002); id <= 9001+ollamaCloudUsageProbeMaxQueue; id++ { + require.True(t, svc.ScheduleOllamaCloudUsageRateLimitProbe(id, noop), "filling the queue must be accepted") + } + // Queue is now full: a brand-new account is rejected, but an already-queued + // account is still merged into its latest callback. + require.False(t, svc.ScheduleOllamaCloudUsageRateLimitProbe(9999, noop), "a new account must be rejected when the queue is full") + require.True(t, svc.ScheduleOllamaCloudUsageRateLimitProbe(9002, noop), "an already-queued account must still merge when the queue is full") +} + +func probeSnap(status string, fetchedAt, lastAttempt time.Time) *OllamaCloudUsageSnapshot { + s := &OllamaCloudUsageSnapshot{Status: status, LastAttemptAt: lastAttempt} + if !fetchedAt.IsZero() { + s.FetchedAt = &fetchedAt + } + return s +} + +// TestOllamaCloudUsageProbeNewestSuccess pins the "most recent observation wins" +// rule: neither the account row nor the coordinator cache is preferred outright, +// an older success never masks a newer success, and a newer failure suppresses an +// older success so it cannot drive a stale exhaustion report. +func TestOllamaCloudUsageProbeNewestSuccess(t *testing.T) { + now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + window := ollamaCloudUsageManualRefreshInterval + fresh := now.Add(-5 * time.Second) + newer := now.Add(-time.Second) + stale := now.Add(-time.Hour) + failed := now.Add(-2 * time.Second) // LastAttemptAt for a failure + entry := func(snapshot *OllamaCloudUsageSnapshot, at time.Time) ollamaCloudUsageProbeGroupEntry { + return ollamaCloudUsageProbeGroupEntry{attemptAt: at, snapshot: snapshot} + } + + tests := []struct { + name string + accountSnapshot *OllamaCloudUsageSnapshot + cached ollamaCloudUsageProbeGroupEntry + hasCached bool + wantFetchedAt time.Time // zero means nil result + }{ + { + name: "no snapshots", + wantFetchedAt: time.Time{}, + }, + { + name: "cached newer success wins over account success", + accountSnapshot: probeSnap(OllamaCloudUsageStatusOK, fresh, fresh), + cached: entry(probeSnap(OllamaCloudUsageStatusOK, newer, newer), newer), + hasCached: true, + wantFetchedAt: newer, + }, + { + name: "account newer success wins over cached success", + accountSnapshot: probeSnap(OllamaCloudUsageStatusOK, newer, newer), + cached: entry(probeSnap(OllamaCloudUsageStatusOK, fresh, fresh), fresh), + hasCached: true, + wantFetchedAt: newer, + }, + { + name: "newer account failure suppresses older cached success", + accountSnapshot: probeSnap(OllamaCloudUsageStatusFailed, time.Time{}, failed), + cached: entry(probeSnap(OllamaCloudUsageStatusOK, fresh, fresh), fresh), + hasCached: true, + wantFetchedAt: time.Time{}, + }, + { + name: "newer cached failure suppresses older account success", + accountSnapshot: probeSnap(OllamaCloudUsageStatusOK, fresh, fresh), + cached: entry(probeSnap(OllamaCloudUsageStatusFailed, time.Time{}, failed), failed), + hasCached: true, + wantFetchedAt: time.Time{}, + }, + { + name: "stale success alone is not usable", + accountSnapshot: probeSnap(OllamaCloudUsageStatusOK, stale, stale), + wantFetchedAt: time.Time{}, + }, + { + name: "only a fresh account success is usable", + accountSnapshot: probeSnap(OllamaCloudUsageStatusOK, fresh, fresh), + wantFetchedAt: fresh, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ollamaCloudUsageProbeNewestSuccess(tt.accountSnapshot, tt.cached, tt.hasCached, now, window) + if tt.wantFetchedAt.IsZero() { + require.Nil(t, got) + return + } + require.NotNil(t, got) + require.NotNil(t, got.FetchedAt) + require.True(t, got.FetchedAt.Equal(tt.wantFetchedAt), "FetchedAt = %v, want %v", got.FetchedAt, tt.wantFetchedAt) + }) + } +} diff --git a/backend/internal/service/ollama_cloud_usage_reset.go b/backend/internal/service/ollama_cloud_usage_reset.go new file mode 100644 index 0000000000..6de4880799 --- /dev/null +++ b/backend/internal/service/ollama_cloud_usage_reset.go @@ -0,0 +1,65 @@ +package service + +import "time" + +// ollamaCloudUsageExhaustionResetAt returns the wall-clock time at which an +// exhausted Ollama Cloud usage window is expected to roll over, i.e. the +// earliest moment a quota-driven upstream block (a 429 or equivalent) can be +// expected to clear on its own. It is a pure helper and performs no I/O and no +// routing; it does not itself trigger anything. +// +// Snapshot usability (conservative): the observation is trusted only when it is +// successful (Status == OK) and fresh. Freshness is expressed as a caller-chosen +// validity bound minFetchedAt: the snapshot must carry a FetchedAt that is +// present and not older than minFetchedAt. Passing now.Add(-freshness) keeps +// old snapshots out of the decision without reading any configuration here. +// +// Window handling: +// - Only windows with UsedPercent >= 100 count as exhausted. +// - A window that is not exhausted (or absent from Data) never affects the +// result and is ignored even when its ResetAt is missing or in the past. +// - The reset of every exhausted window must be known (non-nil, non-zero) and +// strictly in the future relative to now. If any exhausted window cannot +// produce such a reset, no complete recovery time can be claimed and ok is +// false — the caller must not assume a known recovery horizon. +// - When more than one window is exhausted, the later of their resets is +// returned: the account cannot recover until the last exhausted window rolls. +// +// ok is false (zero time) when there is nothing exhausted or the recovery time +// cannot be determined completely. +func ollamaCloudUsageExhaustionResetAt( + snapshot *OllamaCloudUsageSnapshot, + now time.Time, + minFetchedAt time.Time, +) (reset time.Time, ok bool) { + if snapshot == nil || snapshot.Status != OllamaCloudUsageStatusOK || snapshot.Data == nil { + return time.Time{}, false + } + if snapshot.FetchedAt == nil || snapshot.FetchedAt.IsZero() || snapshot.FetchedAt.Before(minFetchedAt) { + return time.Time{}, false + } + + var latest time.Time + for _, window := range []*OllamaCloudUsageWindow{ + snapshot.Data.FiveHour, + snapshot.Data.SevenDay, + } { + if window == nil || window.UsedPercent < 100 { + // Not exhausted: must not influence the recovery decision. + continue + } + if window.ResetAt == nil || window.ResetAt.IsZero() || !window.ResetAt.After(now) { + // Exhausted but without a known future reset: cannot claim a complete + // recovery time, so give up on the whole snapshot. + return time.Time{}, false + } + candidate := window.ResetAt.UTC() + if latest.IsZero() || candidate.After(latest) { + latest = candidate + } + } + if latest.IsZero() { + return time.Time{}, false + } + return latest, true +} diff --git a/backend/internal/service/ollama_cloud_usage_reset_test.go b/backend/internal/service/ollama_cloud_usage_reset_test.go new file mode 100644 index 0000000000..c717438cbd --- /dev/null +++ b/backend/internal/service/ollama_cloud_usage_reset_test.go @@ -0,0 +1,243 @@ +package service + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func mustT(t time.Time) *time.Time { return &t } + +func TestOllamaCloudUsageExhaustionResetAt(t *testing.T) { + now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + minFetchedAt := now.Add(-time.Hour) + + at := func(h, m int) *time.Time { + return mustT(now.Add(time.Duration(h)*time.Hour + time.Duration(m)*time.Minute)) + } + window := func(percent float64, reset *time.Time) *OllamaCloudUsageWindow { + return &OllamaCloudUsageWindow{UsedPercent: percent, ResetAt: reset} + } + okSnapshot := func(data *OllamaCloudUsageData) *OllamaCloudUsageSnapshot { + return &OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusOK, + Data: data, + FetchedAt: mustT(now.Add(-time.Minute)), + } + } + + tests := []struct { + name string + snapshot *OllamaCloudUsageSnapshot + now time.Time + minFresh time.Time + want time.Time + wantOK bool + }{ + { + name: "nil snapshot", + wantOK: false, + }, + { + name: "failed status unusable", + snapshot: &OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusFailed, + Data: &OllamaCloudUsageData{FiveHour: window(100, at(2, 0))}, + }, + wantOK: false, + }, + { + name: "nil data unusable", + snapshot: &OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusOK, + FetchedAt: mustT(now.Add(-time.Minute)), + }, + wantOK: false, + }, + { + name: "stale snapshot rejected", + snapshot: func() *OllamaCloudUsageSnapshot { + s := okSnapshot(&OllamaCloudUsageData{FiveHour: window(100, at(2, 0))}) + s.FetchedAt = mustT(now.Add(-3 * time.Hour)) + return s + }(), + minFresh: minFetchedAt, + wantOK: false, + }, + { + name: "no window exhausted yields no recovery", + snapshot: okSnapshot(&OllamaCloudUsageData{ + FiveHour: window(50, at(2, 0)), + SevenDay: window(30, at(48, 0)), + }), + minFresh: minFetchedAt, + wantOK: false, + }, + { + name: "five hour exhausted below boundary not counted", + snapshot: okSnapshot(&OllamaCloudUsageData{ + FiveHour: window(99.999, at(2, 0)), + }), + minFresh: minFetchedAt, + wantOK: false, + }, + { + name: "five hour exactly 100 future reset", + snapshot: okSnapshot(&OllamaCloudUsageData{ + FiveHour: window(100, at(2, 0)), + }), + minFresh: minFetchedAt, + want: now.Add(2 * time.Hour), + wantOK: true, + }, + { + name: "five hour exhausted past reset yields no recovery", + snapshot: okSnapshot(&OllamaCloudUsageData{ + FiveHour: window(100, at(-1, 0)), + }), + minFresh: minFetchedAt, + wantOK: false, + }, + { + name: "five hour exhausted missing reset yields no recovery", + snapshot: okSnapshot(&OllamaCloudUsageData{ + FiveHour: window(100, nil), + }), + minFresh: minFetchedAt, + wantOK: false, + }, + { + name: "seven day exhausted alone", + snapshot: okSnapshot(&OllamaCloudUsageData{ + SevenDay: window(120, at(30, 0)), + }), + minFresh: minFetchedAt, + want: now.Add(30 * time.Hour), + wantOK: true, + }, + { + name: "seven day exhausted missing reset blocks complete recovery", + snapshot: okSnapshot(&OllamaCloudUsageData{ + FiveHour: window(100, at(2, 0)), + SevenDay: window(100, nil), + }), + minFresh: minFetchedAt, + wantOK: false, + }, + { + name: "seven day exhausted past reset blocks complete recovery", + snapshot: okSnapshot(&OllamaCloudUsageData{ + FiveHour: window(100, at(2, 0)), + SevenDay: window(100, at(-1, 0)), + }), + minFresh: minFetchedAt, + wantOK: false, + }, + { + name: "both exhausted returns later reset", + snapshot: okSnapshot(&OllamaCloudUsageData{ + FiveHour: window(100, at(2, 0)), + SevenDay: window(100, at(48, 0)), + }), + minFresh: minFetchedAt, + want: now.Add(48 * time.Hour), + wantOK: true, + }, + { + name: "both exhausted returns later even when seven day is sooner", + snapshot: okSnapshot(&OllamaCloudUsageData{ + FiveHour: window(100, at(48, 0)), + SevenDay: window(100, at(2, 0)), + }), + minFresh: minFetchedAt, + want: now.Add(48 * time.Hour), + wantOK: true, + }, + { + name: "non exhausted window reset must not affect", + snapshot: okSnapshot(&OllamaCloudUsageData{ + FiveHour: window(100, at(2, 0)), + SevenDay: window(40, at(1, 0)), // past + not exhausted -> ignored + }), + minFresh: minFetchedAt, + want: now.Add(2 * time.Hour), + wantOK: true, + }, + { + name: "non exhausted window with missing reset must not affect", + snapshot: okSnapshot(&OllamaCloudUsageData{ + FiveHour: window(100, at(2, 0)), + SevenDay: window(40, nil), + }), + minFresh: minFetchedAt, + want: now.Add(2 * time.Hour), + wantOK: true, + }, + { + name: "absent seven day does not block", + snapshot: okSnapshot(&OllamaCloudUsageData{ + FiveHour: window(100, at(2, 0)), + }), + minFresh: minFetchedAt, + want: now.Add(2 * time.Hour), + wantOK: true, + }, + { + name: "future reset at now boundary treated as not future", + snapshot: okSnapshot(&OllamaCloudUsageData{ + FiveHour: window(100, at(0, 0)), + }), + minFresh: minFetchedAt, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testNow := tt.now + if testNow.IsZero() { + testNow = now + } + got, ok := ollamaCloudUsageExhaustionResetAt(tt.snapshot, testNow, tt.minFresh) + require.Equal(t, tt.wantOK, ok) + if tt.wantOK { + require.True(t, got.Equal(tt.want), "reset = %v, want %v", got, tt.want) + } else { + require.True(t, got.IsZero()) + } + }) + } +} + +// TestOllamaCloudUsageExhaustionResetAtMissingFetchedAt checks the fetched-at +// gate separately because the okSnapshot helper always sets FetchedAt. +func TestOllamaCloudUsageExhaustionResetAtMissingFetchedAt(t *testing.T) { + now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + snapshot := &OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusOK, + Data: &OllamaCloudUsageData{ + FiveHour: &OllamaCloudUsageWindow{UsedPercent: 100, ResetAt: mustT(now.Add(2 * time.Hour))}, + }, + } + got, ok := ollamaCloudUsageExhaustionResetAt(snapshot, now, now.Add(-time.Hour)) + require.False(t, ok) + require.True(t, got.IsZero()) +} + +// TestOllamaCloudUsageExhaustionResetAtFetchedBoundary pins the inclusive +// minFetchedAt boundary: a snapshot fetched exactly at the bound is usable. +func TestOllamaCloudUsageExhaustionResetAtFetchedBoundary(t *testing.T) { + now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + bound := now.Add(-time.Hour) + snapshot := &OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusOK, + FetchedAt: &bound, // exactly at the validity bound -> accepted + Data: &OllamaCloudUsageData{ + FiveHour: &OllamaCloudUsageWindow{UsedPercent: 100, ResetAt: mustT(now.Add(2 * time.Hour))}, + }, + } + got, ok := ollamaCloudUsageExhaustionResetAt(snapshot, now, bound) + require.True(t, ok) + require.True(t, got.Equal(now.Add(2*time.Hour))) +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 4158d0559c..370546af99 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -31,6 +31,11 @@ type RateLimitService struct { settingService *SettingService tokenCacheInvalidator TokenCacheInvalidator runtimeBlocker AccountRuntimeBlocker + // ollamaCloudUsageProbe is the optional Ollama Cloud usage probe scheduler + // injected via SetOllamaCloudUsageProbeScheduler. See + // ratelimit_service_ollama_429.go for how real-Ollama 429s schedule an async + // probe to learn the true usage-window reset. + ollamaCloudUsageProbe ollamaCloudUsageProbeScheduler usageCacheMu sync.RWMutex usageCache map[int64]*geminiUsageCacheEntry @@ -1152,6 +1157,14 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head } return } + // 真实 Ollama Cloud 用量账号(credentials base_url 指向 ollama.com)的 429 由 + // ollama.com 的用量窗口驱动。其响应头不得被当作 OpenAI codex / Anthropic / + // CN 限流来解析,故在国产供应商分支之前单独处理:先设置永不缩短的临时冷却, + // 再调度异步 probe 学习真实重置点(详见 ratelimit_service_ollama_429.go)。 + if account != nil && IsOllamaCloudUsageAccount(account) { + s.handleOllamaCloudUsage429(ctx, account, headers) + return + } // 国产供应商(kimi/zhipu/deepseek)的 429 走专用可恢复路径:余额不足 → 临时停调, // Coding Plan 窗口耗尽 → 冷却到快照重置点。未命中则继续默认 429 逻辑。 if account.IsCNProvider() || account.IsOpenCodeGo() { diff --git a/backend/internal/service/ratelimit_service_ollama_429.go b/backend/internal/service/ratelimit_service_ollama_429.go new file mode 100644 index 0000000000..39116737bf --- /dev/null +++ b/backend/internal/service/ratelimit_service_ollama_429.go @@ -0,0 +1,218 @@ +package service + +import ( + "context" + "log/slog" + "net/http" + "time" +) + +// Real Ollama Cloud usage accounts (API-key accounts whose base_url points at +// ollama.com) have quota-driven 429s; their headers must not be re-read as an +// OpenAI codex / Anthropic / CN limit. handleOllamaCloudUsage429 applies a +// never-shrinking immediate cooldown (valid Retry-After, else the seconds +// fallback), announces runtime scheduling from the authoritative row, then +// schedules an async usage probe (ollama_cloud_usage_rate_limit_probe.go) to +// learn the true window reset. The probe callback only writes back while the +// account still matches the trigger's fingerprint and rate-limit generation, +// enforced atomically in the repository CAS, so a stale async result cannot +// overwrite a newer 429, an admin clear/re-arm, a disabled account, or a key +// swap. No in-memory per-account guard is kept; each pending probe carries its +// own version in its callback closure. + +const ollamaCloudUsageProbeWritebackTimeout = 10 * time.Second + +// ollamaCloudUsageProbeScheduler is the single-method surface RateLimitService +// needs from the Ollama Cloud usage service. It is optional. +type ollamaCloudUsageProbeScheduler interface { + ScheduleOllamaCloudUsageRateLimitProbe(accountID int64, onExhausted OllamaCloudUsageRateLimitProbeCallback) bool +} + +// ollamaCloudUsageRateLimitExtender extends (never shrinks) an account-level +// rate limit via the concrete repository (SetRateLimitedIfLater). +type ollamaCloudUsageRateLimitExtender interface { + SetRateLimitedIfLater(ctx context.Context, id int64, resetAt time.Time) error +} + +// ollamaCloudUsageRateLimitSetterIfGeneration atomically writes a new reset only +// while the account still carries the exact generation the caller observed +// (UpdatedAt == expectedUpdatedAt AND RateLimitedAt == expectedLimitedAt AND +// RateLimitResetAt == expectedResetAt, where nil means unset), returning whether +// it updated. Provided by the concrete repository (SetRateLimitedIfUnchanged). +type ollamaCloudUsageRateLimitSetterIfGeneration interface { + SetRateLimitedIfUnchanged(ctx context.Context, id int64, expectedUpdatedAt time.Time, expectedLimitedAt, expectedResetAt *time.Time, newResetAt time.Time) (bool, error) +} + +// SetOllamaCloudUsageProbeScheduler injects the optional probe scheduler. When +// nil, handle429 still cools the account and announces runtime scheduling; only +// the async probe is skipped. Wire calls this once at startup. +func (s *RateLimitService) SetOllamaCloudUsageProbeScheduler(scheduler ollamaCloudUsageProbeScheduler) { + s.ollamaCloudUsageProbe = scheduler +} + +func (s *RateLimitService) handleOllamaCloudUsage429(ctx context.Context, account *Account, headers http.Header) { + if s == nil || account == nil || account.ID <= 0 || s.accountRepo == nil { + return + } + + var shortReset time.Time + now := time.Now() + if d := retryAfter(headers, now); d > 0 { + shortReset = now.Add(d) + } else if cooldown, enabled := s.get429FallbackCooldown(ctx, account); enabled { + shortReset = now.Add(cooldown) + } else { + slog.Info("rate_limit_ollama_429_fallback_ignored", "account_id", account.ID, "platform", account.Platform) + } + + if !shortReset.IsZero() { + s.applyOllamaCloudUsageImmediateCooldown(ctx, account, shortReset) + } + + // Read the authoritative row (IfLater may have kept a longer pre-existing + // reset) and announce runtime scheduling from it. This is independent of the + // optional probe scheduler. + authoritative, err := s.accountRepo.GetByID(ctx, account.ID) + if err != nil || authoritative == nil { + slog.Warn("ollama_cloud_usage_authoritative_load_failed", "account_id", account.ID, "error", err) + return + } + if authoritative.RateLimitResetAt != nil && authoritative.RateLimitResetAt.After(now) { + s.notifyAccountSchedulingBlocked(authoritative, *authoritative.RateLimitResetAt, "ollama_429") + } + + if s.ollamaCloudUsageProbe == nil { + return + } + s.scheduleOllamaCloudUsageProbe(authoritative) +} + +// applyOllamaCloudUsageImmediateCooldown persists the short cooldown with the +// atomic never-shrink IfLater write when available. Runtime announcement is done +// later from the authoritative row, never here, so an IfLater no-op never sends a +// shorter runtime-block notification. +func (s *RateLimitService) applyOllamaCloudUsageImmediateCooldown(ctx context.Context, account *Account, shortReset time.Time) { + if extender, ok := s.accountRepo.(ollamaCloudUsageRateLimitExtender); ok { + if err := extender.SetRateLimitedIfLater(ctx, account.ID, shortReset); err != nil { + slog.Warn("rate_limit_ollama_429_iflater_failed", "account_id", account.ID, "error", err) + } + return + } + reset := shortReset + if account.RateLimitResetAt != nil && account.RateLimitResetAt.After(reset) { + reset = *account.RateLimitResetAt + } + if err := s.accountRepo.SetRateLimited(ctx, account.ID, reset); err != nil { + slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err) + } +} + +// scheduleOllamaCloudUsageProbe captures the trigger fingerprint and rate-limit +// generation from the authoritative row and schedules the async probe bound to +// them. +func (s *RateLimitService) scheduleOllamaCloudUsageProbe(account *Account) { + if s == nil || account == nil || s.ollamaCloudUsageProbe == nil { + return + } + if !IsOllamaCloudUsageAccount(account) { + return + } + fingerprint, valid := ollamaCloudUsageGroupFingerprint(account) + if !valid { + return + } + expectedLimitedAt := cloneTimePtr(account.RateLimitedAt) + expectedResetAt := cloneTimePtr(account.RateLimitResetAt) + accepted := s.ollamaCloudUsageProbe.ScheduleOllamaCloudUsageRateLimitProbe( + account.ID, + func(accountID int64, resetAt time.Time) { + s.applyOllamaCloudUsageProbeReset(accountID, fingerprint, expectedLimitedAt, expectedResetAt, resetAt) + }, + ) + if !accepted { + // Service stopped or the probe queue is full; the account is already + // cooled by the immediate cooldown, so no retry or other action is needed. + slog.Debug("ollama_cloud_usage_probe_schedule_rejected", "account_id", account.ID) + } +} + +// applyOllamaCloudUsageProbeReset writes the reset learned by an async usage +// probe, but only while the account is still the same active Ollama Cloud usage +// account with the same rate-limit generation observed at scheduling time. It +// runs on the probe coordinator with a detached, timeout-bounded context and does +// at most one bounded re-read plus one bounded atomic CAS. +// +// The result is dropped when any of the following holds: +// - the reset is not in the future, or the account is gone; +// - the account is no longer an active/schedulable Ollama Cloud usage account +// (disabled/errored since the trigger) or its group fingerprint changed; +// - the reset does not extend the trigger's cooldown floor (an explicit +// Retry-After or an already-confirmed cooldown must never be shortened); +// - the CAS reports no update (the rate-limit generation moved on: a newer 429, +// an admin clear, a re-arm, or a concurrent update between the re-read and +// this write, guarded by the row's UpdatedAt). +// +// On success the CAS already updated the DB; only the runtime scheduling +// notification is sent here. +func (s *RateLimitService) applyOllamaCloudUsageProbeReset( + accountID int64, + expectedFingerprint string, + expectedLimitedAt, expectedResetAt *time.Time, + resetAt time.Time, +) { + now := time.Now() + if s == nil || accountID <= 0 || s.accountRepo == nil || !resetAt.After(now) { + return + } + // Never write an earlier reset than the trigger floor captured at schedule. + if expectedResetAt != nil && !resetAt.After(*expectedResetAt) { + return + } + + bgCtx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), ollamaCloudUsageProbeWritebackTimeout) + defer cancel() + + account, err := s.accountRepo.GetByID(bgCtx, accountID) + if err != nil || account == nil { + slog.Warn("ollama_cloud_usage_probe_reset_load_failed", "account_id", accountID, "error", err) + return + } + if !IsOllamaCloudUsageAccount(account) { + return + } + if !account.IsActive() || !account.Schedulable { + return + } + currentFingerprint, valid := ollamaCloudUsageGroupFingerprint(account) + if !valid || currentFingerprint != expectedFingerprint { + return + } + + setter, ok := s.accountRepo.(ollamaCloudUsageRateLimitSetterIfGeneration) + if !ok { + return + } + // Bind the CAS to the version just re-read (not the scheduling-time version, + // which the probe's own snapshot persist legitimately bumps via UpdatedAt). + // Any change between this re-read and the CAS therefore fails the write. + updated, err := setter.SetRateLimitedIfUnchanged(bgCtx, accountID, account.UpdatedAt, expectedLimitedAt, expectedResetAt, resetAt) + if err != nil { + slog.Warn("rate_limit_set_failed", "account_id", accountID, "error", err) + return + } + if !updated { + // The generation moved on (a newer 429, admin clear/re-arm, or an + // unrelated write bumped UpdatedAt): conservatively skip, never override. + // No credentials are logged. A later 429 for the same account schedules a + // fresh probe with a fresh generation, so no retry is needed here. + slog.Debug("ollama_cloud_usage_probe_reset_skipped_stale", "account_id", accountID) + return + } + + s.notifyAccountSchedulingBlocked(account, resetAt, "ollama_cloud_usage_429_probe") + slog.Info("ollama_cloud_account_rate_limited_probe", + "account_id", accountID, + "reset_at", resetAt.UTC(), + "reset_in", time.Until(resetAt).Truncate(time.Second), + ) +} diff --git a/backend/internal/service/ratelimit_service_ollama_429_test.go b/backend/internal/service/ratelimit_service_ollama_429_test.go new file mode 100644 index 0000000000..dca1c28608 --- /dev/null +++ b/backend/internal/service/ratelimit_service_ollama_429_test.go @@ -0,0 +1,721 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// This file unit-tests the Ollama Cloud usage 429 branch added to +// RateLimitService.handle429 (see ratelimit_service_ollama_429.go): early +// platform diversion, immediate never-shrink cooldown (Retry-After vs seconds +// fallback), scheduling rejection / scheduler-absent runtime notification, and, +// for the async probe callback, that a stale result can never overwrite a newer +// 429, admin clear, key swap, disabled account, or a shorter cooldown floor, +// guarded atomically via the repository CAS. + +func ollama429Account(id int64, platform string) *Account { + return &Account{ + ID: id, + Platform: platform, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Credentials: map[string]any{ + "base_url": "https://www.ollama.com", + "api_key": "ollama429-key", + }, + } +} + +// ollama429SchedulerStub records Schedule calls so tests can drive the recorded +// probe callback (as the background coordinator would) deterministically. +type ollama429SchedulerStub struct { + mu sync.Mutex + accept bool + scheduled []int64 + callbacks map[int64]OllamaCloudUsageRateLimitProbeCallback +} + +func newOllama429SchedulerStub(accept bool) *ollama429SchedulerStub { + return &ollama429SchedulerStub{accept: accept, callbacks: map[int64]OllamaCloudUsageRateLimitProbeCallback{}} +} + +func (p *ollama429SchedulerStub) ScheduleOllamaCloudUsageRateLimitProbe(accountID int64, cb OllamaCloudUsageRateLimitProbeCallback) bool { + p.mu.Lock() + defer p.mu.Unlock() + if !p.accept { + return false + } + p.scheduled = append(p.scheduled, accountID) + p.callbacks[accountID] = cb // latest wins, mirroring the coordinator coalescing + return true +} + +func (p *ollama429SchedulerStub) fire(accountID int64, resetAt time.Time) { + p.mu.Lock() + cb := p.callbacks[accountID] + p.mu.Unlock() + if cb != nil { + cb(accountID, resetAt) + } +} + +func (p *ollama429SchedulerStub) count() int { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.scheduled) +} + +// ollama429BlockRec records an in-memory runtime scheduling block. +type ollama429BlockRec struct { + accountID int64 + until time.Time + reason string +} + +type ollama429BlockerStub struct { + mu sync.Mutex + blocks []ollama429BlockRec +} + +func (b *ollama429BlockerStub) BlockAccountScheduling(account *Account, until time.Time, reason string) { + b.mu.Lock() + defer b.mu.Unlock() + if account != nil { + b.blocks = append(b.blocks, ollama429BlockRec{accountID: account.ID, until: until, reason: reason}) + } +} + +func (b *ollama429BlockerStub) ClearAccountSchedulingBlock(int64) {} + +func (b *ollama429BlockerStub) last() (ollama429BlockRec, bool) { + b.mu.Lock() + defer b.mu.Unlock() + if len(b.blocks) == 0 { + return ollama429BlockRec{}, false + } + return b.blocks[len(b.blocks)-1], true +} + +// ollama429Repo is a controllable AccountRepository fake implementing the narrow +// IfLater + generation-CAS contracts the production *accountRepository provides. +// updatedAt is simulated by bumping Account.UpdatedAt forward on every write. +type ollama429Repo struct { + mockAccountRepoForGemini + mu sync.Mutex + accounts map[int64]*Account + casUpdated int + casSkipped int + ifLaterWrites []time.Time + uncondWrites []time.Time + versionCounter int64 +} + +func newOllama429Repo(acct *Account) *ollama429Repo { + r := &ollama429Repo{accounts: map[int64]*Account{}} + r.accounts[acct.ID] = acct + return r +} + +func (r *ollama429Repo) bump(acct *Account) { + r.versionCounter++ + acct.UpdatedAt = time.Unix(0, r.versionCounter).UTC() +} + +func (r *ollama429Repo) GetByID(_ context.Context, id int64) (*Account, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.accounts[id], nil +} + +func (r *ollama429Repo) currentReset(id int64) *time.Time { + r.mu.Lock() + defer r.mu.Unlock() + a := r.accounts[id] + if a == nil || a.RateLimitResetAt == nil { + return nil + } + v := *a.RateLimitResetAt + return &v +} + +// mutate lets a test simulate an external change (admin clear / re-arm / key swap). +func (r *ollama429Repo) mutate(id int64, fn func(*Account)) { + r.mu.Lock() + defer r.mu.Unlock() + if a, ok := r.accounts[id]; ok { + fn(a) + r.bump(a) + } +} + +// bumpVersion simulates the real probe persisting its usage snapshot: it advances +// the row's UpdatedAt (TimeMixin bump) without touching rate-limit fields, which +// is exactly what updateSnapshot does between scheduling and callback delivery. +func (r *ollama429Repo) bumpVersion(id int64) { + r.mu.Lock() + defer r.mu.Unlock() + if a, ok := r.accounts[id]; ok { + r.bump(a) + } +} + +// SetRateLimitedIfLater implements the never-shrink narrow extender. +func (r *ollama429Repo) SetRateLimitedIfLater(_ context.Context, id int64, resetAt time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + a := r.accounts[id] + if a == nil { + return nil + } + cur := a.RateLimitResetAt + if cur == nil || resetAt.After(*cur) { + a.RateLimitResetAt = ollama429TimePtr(resetAt) + now := time.Now() + a.RateLimitedAt = &now + r.bump(a) + r.ifLaterWrites = append(r.ifLaterWrites, resetAt) + } + return nil +} + +func (r *ollama429Repo) SetRateLimited(_ context.Context, id int64, resetAt time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + a := r.accounts[id] + if a == nil { + return nil + } + a.RateLimitResetAt = ollama429TimePtr(resetAt) + now := time.Now() + a.RateLimitedAt = &now + r.bump(a) + r.uncondWrites = append(r.uncondWrites, resetAt) + return nil +} + +func (r *ollama429Repo) SetRateLimitedIfUnchanged( + _ context.Context, id int64, + expectedUpdatedAt time.Time, + expectedLimitedAt, expectedResetAt *time.Time, + newResetAt time.Time, +) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + a := r.accounts[id] + if a == nil { + r.casSkipped++ + return false, nil + } + limitedMatch := timePtrEqual(expectedLimitedAt, a.RateLimitedAt) + resetMatch := timePtrEqual(expectedResetAt, a.RateLimitResetAt) + if !a.UpdatedAt.Equal(expectedUpdatedAt) || !limitedMatch || !resetMatch { + r.casSkipped++ + return false, nil + } + a.RateLimitedAt = ollama429TimePtr(time.Now()) + a.RateLimitResetAt = ollama429TimePtr(newResetAt) + r.bump(a) + r.casUpdated++ + return true, nil +} + +func ollama429TimePtr(t time.Time) *time.Time { return &t } + +func timePtrEqual(a, b *time.Time) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return a.Equal(*b) +} + +// ollama429Fixture builds a started-ish RateLimitService over the given repo and +// scheduler. runtime blocker is injected so scheduling notifications are visible. +func ollama429Fixture(t *testing.T, repo *ollama429Repo, scheduler *ollama429SchedulerStub) (*RateLimitService, *ollama429BlockerStub) { + t.Helper() + svc := NewRateLimitService(repo, nil, nil, nil, nil) + blocker := &ollama429BlockerStub{} + svc.SetAccountRuntimeBlocker(blocker) + svc.SetOllamaCloudUsageProbeScheduler(scheduler) + return svc, blocker +} + +func TestHandle429_OllamaEarlyBranchAcrossPlatforms(t *testing.T) { + platforms := []string{PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek} + for _, platform := range platforms { + t.Run(platform, func(t *testing.T) { + acct := ollama429Account(101, platform) + repo := newOllama429Repo(acct) + scheduler := newOllama429SchedulerStub(true) + svc, _ := ollama429Fixture(t, repo, scheduler) + + svc.handle429(context.Background(), acct, http.Header{}, nil) + + require.Equal(t, 1, scheduler.count(), "real-Ollama %s account must schedule a probe", platform) + require.Greater(t, len(repo.ifLaterWrites), 0, "immediate never-shrink cooldown must be applied") + }) + } +} + +func TestHandle429_NonOllamaUnchanged(t *testing.T) { + acct := &Account{ID: 202, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://api.anthropic.com", "api_key": "k"}} + repo := newOllama429Repo(acct) + scheduler := newOllama429SchedulerStub(true) + svc, _ := ollama429Fixture(t, repo, scheduler) + + svc.handle429(context.Background(), acct, http.Header{}, nil) + + require.Zero(t, scheduler.count(), "non-Ollama account must not schedule an Ollama probe") + require.Zero(t, repo.casUpdated) +} + +func TestHandle429_OllamaUsesValidRetryAfter(t *testing.T) { + acct := ollama429Account(301, PlatformOpenAI) + repo := newOllama429Repo(acct) + scheduler := newOllama429SchedulerStub(true) + svc, _ := ollama429Fixture(t, repo, scheduler) + + before := time.Now() + svc.handle429(context.Background(), acct, http.Header{"Retry-After": []string{"120"}}, nil) + after := time.Now() + + require.Equal(t, 1, scheduler.count()) + reset := repo.currentReset(acct.ID) + require.NotNil(t, reset) + require.False(t, reset.Before(before.Add(120*time.Second)), "reset %v < now+120s", reset) + require.False(t, reset.After(after.Add(120*time.Second)), "reset %v > now+120s", reset) +} + +func TestHandle429_OllamaFallsBackToSecondsCooldown(t *testing.T) { + acct := ollama429Account(302, PlatformAnthropic) + repo := newOllama429Repo(acct) + scheduler := newOllama429SchedulerStub(true) + svc, _ := ollama429Fixture(t, repo, scheduler) + + before := time.Now() + svc.handle429(context.Background(), acct, http.Header{}, nil) // no Retry-After + after := time.Now() + + require.Equal(t, 1, scheduler.count()) + reset := repo.currentReset(acct.ID) + require.NotNil(t, reset) + def := defaultRateLimit429CooldownSeconds + require.False(t, reset.Before(before.Add(time.Duration(def)*time.Second))) + require.False(t, reset.After(after.Add(time.Duration(def)*time.Second))) +} + +func TestHandle429_OllamaScheduleRejectedStillCooled(t *testing.T) { + acct := ollama429Account(303, PlatformOpenAI) + repo := newOllama429Repo(acct) + scheduler := newOllama429SchedulerStub(false) // queue full / service stopped + svc, _ := ollama429Fixture(t, repo, scheduler) + + svc.handle429(context.Background(), acct, http.Header{}, nil) + + require.Zero(t, scheduler.count()) + require.Greater(t, len(repo.ifLaterWrites), 0, "immediate cooldown must still be applied when scheduling is rejected") +} + +func TestHandle429_OllamaDoesNotShrinkConfirmedLongCooldown(t *testing.T) { + acct := ollama429Account(304, PlatformOpenAI) + repo := newOllama429Repo(acct) + long := time.Now().Add(time.Hour) + repo.mutate(acct.ID, func(a *Account) { a.RateLimitResetAt = &long }) + scheduler := newOllama429SchedulerStub(true) + svc, _ := ollama429Fixture(t, repo, scheduler) + + // A 429 with no useful Retry-After arrives while a +1h reset is already set; + // the 5s fallback must not shrink it. + svc.handle429(context.Background(), acct, http.Header{}, nil) + + reset := repo.currentReset(acct.ID) + require.NotNil(t, reset) + require.GreaterOrEqual(t, reset.Unix(), long.Unix(), "already-confirmed long cooldown must not be shrunk to fallback seconds") + require.Equal(t, 1, scheduler.count()) +} + +func TestOllamaProbeCallback_ValidUpdatesDBAndMemory(t *testing.T) { + acct := ollama429Account(401, PlatformOpenAI) + repo := newOllama429Repo(acct) + scheduler := newOllama429SchedulerStub(true) + svc, blocker := ollama429Fixture(t, repo, scheduler) + + svc.handle429(context.Background(), acct, http.Header{}, nil) // schedules; immediate 5s + require.Equal(t, 1, scheduler.count()) + + probeReset := time.Now().Add(2 * time.Hour) + scheduler.fire(acct.ID, probeReset) + + require.Equal(t, 1, repo.casUpdated, "valid callback must update DB once") + require.Zero(t, repo.casSkipped) + reset := repo.currentReset(acct.ID) + require.NotNil(t, reset) + require.True(t, reset.Equal(probeReset), "DB reset = %v, want %v", reset, probeReset) + rec, ok := blocker.last() + require.True(t, ok) + require.Equal(t, "ollama_cloud_usage_429_probe", rec.reason) + require.True(t, rec.until.Equal(probeReset)) +} + +func TestOllamaProbeCallback_DuplicateNoDoubleUpdate(t *testing.T) { + acct := ollama429Account(402, PlatformOpenAI) + repo := newOllama429Repo(acct) + scheduler := newOllama429SchedulerStub(true) + svc, blocker := ollama429Fixture(t, repo, scheduler) + + svc.handle429(context.Background(), acct, http.Header{}, nil) + probeReset := time.Now().Add(2 * time.Hour) + scheduler.fire(acct.ID, probeReset) + require.Equal(t, 1, repo.casUpdated) + + // Same generation fired again (a duplicate/out-of-order delivery): the CAS + // generation has moved on, so no second update and no second notification. + scheduler.fire(acct.ID, probeReset.Add(time.Hour)) + require.Equal(t, 1, repo.casUpdated) + rec, _ := blocker.last() + require.Equal(t, "ollama_cloud_usage_429_probe", rec.reason) + require.True(t, rec.until.Equal(probeReset)) +} + +func TestOllamaProbeCallback_StaleLongDoesNotOverrideNewShort(t *testing.T) { + acct := ollama429Account(403, PlatformOpenAI) + repo := newOllama429Repo(acct) + scheduler := newOllama429SchedulerStub(true) + svc, blocker := ollama429Fixture(t, repo, scheduler) + + svc.handle429(context.Background(), acct, http.Header{}, nil) // schedules gen @5s + require.Equal(t, 1, scheduler.count()) + + // An admin / newer policy re-arms the account to a fresh SHORT cooldown. + newShort := time.Now().Add(5 * time.Second) + repo.mutate(acct.ID, func(a *Account) { a.RateLimitResetAt = ollama429TimePtr(newShort) }) + + // The old async result reports a long 7d reset; it must not override. + oldLong := time.Now().Add(7 * 24 * time.Hour) + scheduler.fire(acct.ID, oldLong) + + require.Zero(t, repo.casUpdated, "stale long callback must not pass the CAS") + require.Greater(t, repo.casSkipped, 0) + reset := repo.currentReset(acct.ID) + require.NotNil(t, reset) + require.True(t, reset.Equal(newShort), "current reset must remain the new short %v, got %v", newShort, reset) + rec, ok := blocker.last() + // The only scheduling notification should be the immediate cooldown one, not + // an ollama_cloud_usage_429_probe block for the stale 7d reset. + if ok && rec.reason == "ollama_cloud_usage_429_probe" { + t.Fatalf("stale long callback must not announce scheduling; got block to %v", rec.until) + } +} + +func TestOllamaProbeCallback_AdminClearSkips(t *testing.T) { + acct := ollama429Account(404, PlatformOpenAI) + repo := newOllama429Repo(acct) + scheduler := newOllama429SchedulerStub(true) + svc, blocker := ollama429Fixture(t, repo, scheduler) + + svc.handle429(context.Background(), acct, http.Header{}, nil) + // Admin clears the rate limit entirely. + repo.mutate(acct.ID, func(a *Account) { + a.RateLimitedAt = nil + a.RateLimitResetAt = nil + }) + + scheduler.fire(acct.ID, time.Now().Add(2*time.Hour)) + require.Zero(t, repo.casUpdated, "cleared account must not be re-limited by a stale callback") + require.Nil(t, repo.currentReset(acct.ID)) + rec, ok := blocker.last() + if ok && rec.reason == "ollama_cloud_usage_429_probe" { + t.Fatalf("cleared account must not be re-blocked by a stale callback") + } +} + +func TestOllamaProbeCallback_KeySwapSkips(t *testing.T) { + acct := ollama429Account(405, PlatformOpenAI) + repo := newOllama429Repo(acct) + scheduler := newOllama429SchedulerStub(true) + svc, _ := ollama429Fixture(t, repo, scheduler) + + svc.handle429(context.Background(), acct, http.Header{}, nil) // captures fingerprint of k1 + // The api_key is swapped to another key (still on ollama.com). + repo.mutate(acct.ID, func(a *Account) { a.Credentials["api_key"] = "ollama429-key-2" }) + + scheduler.fire(acct.ID, time.Now().Add(2*time.Hour)) + require.Zero(t, repo.casUpdated, "key-swapped account must not be limited by the old key's probe") + // The fingerprint guard returns before any CAS is attempted, so the current + // reset stays at the immediate cooldown, not the probe's far-future reset. + reset := repo.currentReset(acct.ID) + require.NotNil(t, reset) + require.False(t, reset.After(time.Now().Add(time.Hour)), "key-swapped account must keep only its immediate cooldown, got %v", reset) +} + +func TestOllamaProbeCallback_ResetAlreadyPastSkips(t *testing.T) { + acct := ollama429Account(406, PlatformOpenAI) + repo := newOllama429Repo(acct) + scheduler := newOllama429SchedulerStub(true) + svc, _ := ollama429Fixture(t, repo, scheduler) + + svc.handle429(context.Background(), acct, http.Header{}, nil) + repo.mutate(acct.ID, func(a *Account) { + // keep same reset/state, just ensure callback reset is in the past + }) + scheduler.fire(acct.ID, time.Now().Add(-time.Minute)) + + require.Zero(t, repo.casUpdated) + require.Zero(t, repo.casSkipped, "an already-passed reset is dropped before any CAS is attempted") +} + +func TestOllamaProbeCallback_SnapshotPersistBumpStillApplies(t *testing.T) { + // The real probe persists its usage snapshot between scheduling and callback + // delivery, which advances the row's UpdatedAt (TimeMixin) without touching + // the rate-limit generation. The write-back must still succeed: it binds the + // CAS to the version re-read in the callback, not the scheduling-time one. + acct := ollama429Account(501, PlatformOpenAI) + repo := newOllama429Repo(acct) + scheduler := newOllama429SchedulerStub(true) + svc, blocker := ollama429Fixture(t, repo, scheduler) + + svc.handle429(context.Background(), acct, http.Header{}, nil) + require.Equal(t, 1, scheduler.count()) + + repo.bumpVersion(acct.ID) // simulate updateSnapshot persisting the snapshot + + probeReset := time.Now().Add(2 * time.Hour) + scheduler.fire(acct.ID, probeReset) + + require.Equal(t, 1, repo.casUpdated, "snapshot persist bumping UpdatedAt must not fail the exhaustion CAS") + require.Zero(t, repo.casSkipped) + reset := repo.currentReset(acct.ID) + require.NotNil(t, reset) + require.True(t, reset.Equal(probeReset), "DB reset = %v, want %v", reset, probeReset) + rec, ok := blocker.last() + require.True(t, ok) + require.Equal(t, "ollama_cloud_usage_429_probe", rec.reason) +} + +func TestOllamaProbeCallback_NeverShortensCooldownFloor(t *testing.T) { + // An explicit Retry-After sets a 120s floor. A later async probe that reports + // a shorter (still-future) reset must not shrink that floor back down. + acct := ollama429Account(502, PlatformOpenAI) + repo := newOllama429Repo(acct) + scheduler := newOllama429SchedulerStub(true) + svc, _ := ollama429Fixture(t, repo, scheduler) + + before := time.Now() + svc.handle429(context.Background(), acct, http.Header{"Retry-After": []string{"120"}}, nil) + after := time.Now() + floor := repo.currentReset(acct.ID) + require.NotNil(t, floor) + require.False(t, floor.Before(before.Add(120*time.Second))) + require.False(t, floor.After(after.Add(120*time.Second))) + + // Probe reports a reset still in the future but sooner than the 120s floor. + scheduler.fire(acct.ID, time.Now().Add(30*time.Second)) + + require.Zero(t, repo.casUpdated, "a probe reset sooner than the explicit floor must be dropped") + still := repo.currentReset(acct.ID) + require.True(t, still.Equal(*floor), "the explicit Retry-After floor must be preserved") +} + +func TestOllamaProbeCallback_DisabledSkips(t *testing.T) { + acct := ollama429Account(503, PlatformOpenAI) + repo := newOllama429Repo(acct) + scheduler := newOllama429SchedulerStub(true) + svc, _ := ollama429Fixture(t, repo, scheduler) + + svc.handle429(context.Background(), acct, http.Header{}, nil) + repo.mutate(acct.ID, func(a *Account) { a.Status = StatusDisabled }) + + scheduler.fire(acct.ID, time.Now().Add(2*time.Hour)) + require.Zero(t, repo.casUpdated, "a disabled account must not be re-limited by a stale callback") +} + +func TestHandle429_OllamaSchedulerAbsentStillNotifiesRuntime(t *testing.T) { + acct := ollama429Account(504, PlatformOpenAI) + repo := newOllama429Repo(acct) + svc := NewRateLimitService(repo, nil, nil, nil, nil) + blocker := &ollama429BlockerStub{} + svc.SetAccountRuntimeBlocker(blocker) + // No probe scheduler injected. + + before := time.Now() + svc.handle429(context.Background(), acct, http.Header{}, nil) + after := time.Now() + + // Immediate cooldown applied and runtime scheduling announced even without a + // scheduler; only the async probe is skipped. + require.Greater(t, len(repo.ifLaterWrites), 0) + rec, ok := blocker.last() + require.True(t, ok) + require.Equal(t, "ollama_429", rec.reason) + require.False(t, rec.until.Before(before.Add(defaultRateLimit429CooldownSeconds*time.Second))) + require.False(t, rec.until.After(after.Add(defaultRateLimit429CooldownSeconds*time.Second))) +} + +// ollama429LinkRepo drives a real probe->callback linkage through the actual +// OllamaCloudUsageService. It wraps the shared ollama feature test repo, adds the +// narrow IfLater/CAS surface RateLimitService needs, and makes the real snapshot +// persist (UpdateOllamaCloudUsageSnapshot) advance the row's UpdatedAt exactly as +// the production TimeMixin-backed repository does. +type ollama429LinkRepo struct { + *ollamaUsageTestRepo + mu sync.Mutex + versionCounter int64 + casUpdated int + linkIfLater []time.Time +} + +func (r *ollama429LinkRepo) bump(a *Account) { + r.versionCounter++ + a.UpdatedAt = time.Unix(0, r.versionCounter).UTC() +} + +func (r *ollama429LinkRepo) UpdateOllamaCloudUsageSnapshot(ctx context.Context, expected *Account, snapshot *OllamaCloudUsageSnapshot) error { + if err := r.ollamaUsageTestRepo.UpdateOllamaCloudUsageSnapshot(ctx, expected, snapshot); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + if a, ok := r.upstreamBillingProbeAccountRepo.accounts[expected.ID]; ok { + r.bump(a) + } + return nil +} + +func (r *ollama429LinkRepo) SetRateLimitedIfLater(_ context.Context, id int64, resetAt time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + a := r.upstreamBillingProbeAccountRepo.accounts[id] + if a == nil { + return nil + } + cur := a.RateLimitResetAt + if cur == nil || resetAt.After(*cur) { + a.RateLimitResetAt = ollama429TimePtr(resetAt) + now := time.Now() + a.RateLimitedAt = &now + r.bump(a) + r.linkIfLater = append(r.linkIfLater, resetAt) + } + return nil +} + +func (r *ollama429LinkRepo) SetRateLimitedIfUnchanged( + _ context.Context, id int64, + expectedUpdatedAt time.Time, + expectedLimitedAt, expectedResetAt *time.Time, + newResetAt time.Time, +) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + a := r.upstreamBillingProbeAccountRepo.accounts[id] + if a == nil { + return false, nil + } + if !a.UpdatedAt.Equal(expectedUpdatedAt) || + !timePtrEqual(expectedLimitedAt, a.RateLimitedAt) || + !timePtrEqual(expectedResetAt, a.RateLimitResetAt) { + return false, nil + } + a.RateLimitedAt = ollama429TimePtr(time.Now()) + a.RateLimitResetAt = ollama429TimePtr(newResetAt) + r.bump(a) + r.casUpdated++ + return true, nil +} + +func (r *ollama429LinkRepo) currentReset(id int64) *time.Time { + r.mu.Lock() + defer r.mu.Unlock() + a := r.upstreamBillingProbeAccountRepo.accounts[id] + if a == nil || a.RateLimitResetAt == nil { + return nil + } + v := *a.RateLimitResetAt + return &v +} + +func (r *ollama429LinkRepo) casUpdatedCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.casUpdated +} + +// TestOllama429RealProbeLinkage_SnapshotPersistThenWriteBack drives the full real +// chain: handle429 schedules the async probe on a real started +// OllamaCloudUsageService; the probe fetches an exhausted settings page, persists +// its snapshot (which advances UpdatedAt through the link repo, mirroring the +// production TimeMixin bump), and the exhaustion callback then writes the reset +// back through the generation CAS. The CAS must still succeed even though the +// snapshot persist moved UpdatedAt, because it is bound to the version re-read at +// callback time, not the scheduling-time version. +func TestOllama429RealProbeLinkage_SnapshotPersistThenWriteBack(t *testing.T) { + account := ollamaUsageAccount(701) + account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret" + // The probe is a 429-event recovery query and must run even when the periodic + // auto_refresh switch is off (it still needs the configured cookie/session). + account.Extra[OllamaCloudUsageAutoRefreshExtraKey] = false + + reset := time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339) + body := []byte(` +
+
5 hour usage100% used
+ +
`) + + repo := &ollama429LinkRepo{ollamaUsageTestRepo: &ollamaUsageTestRepo{ + upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{ + accounts: map[int64]*Account{account.ID: account}, + }, + }} + upstream := &ollamaUsageHTTPStub{body: body} + + usageSvc := NewOllamaCloudUsageService(repo, upstream, NewSettingService(&upstreamBillingProbeSettingRepo{}, nil), ollamaUsageTestEncryptor{}, true) + + // With auto_refresh off, a requireEnabled refresh is a (nil,nil) no-op. When + // the 429-event probe piggybacks such a timed-cycle no-op through the shared + // singleflight, runOllamaCloudUsageProbe must not record it as a real attempt. + noop, noopErr := usageSvc.refreshAccount(context.Background(), account.ID, defaultOllamaCloudUsageSettings(), true) + require.NoError(t, noopErr) + require.Nil(t, noop, "auto_refresh-disabled refresh is a nil,nil no-op") + + usageSvc.Start() + t.Cleanup(usageSvc.Stop) + + blocker := &ollama429BlockerStub{} + rlSvc := NewRateLimitService(repo, nil, nil, nil, nil) + rlSvc.SetAccountRuntimeBlocker(blocker) + rlSvc.SetOllamaCloudUsageProbeScheduler(usageSvc) + + rlSvc.handle429(context.Background(), account, http.Header{}, nil) + require.Greater(t, len(repo.linkIfLater), 0, "immediate cooldown must be applied") + + // The real probe coordinator (async) fetches, persists (bumping UpdatedAt), + // reports exhaustion, and the write-back must then succeed. Reads go through + // lock-protected accessors because the coordinator mutates the shared repo. + require.Eventually(t, func() bool { + if repo.casUpdatedCount() != 1 { + return false + } + rec, ok := blocker.last() + return ok && rec.reason == "ollama_cloud_usage_429_probe" + }, 10*time.Second, 5*time.Millisecond, "exhaustion write-back through the real probe must update once and notify") + + // The 429-event probe actually performed a usage fetch despite auto_refresh=off. + require.Greater(t, upstream.calls.Load(), int64(0), "probe must query usage even with auto_refresh disabled") + + rec, ok := blocker.last() + require.True(t, ok) + require.Equal(t, "ollama_cloud_usage_429_probe", rec.reason) + require.True(t, rec.until.After(time.Now()), "probe reset must be in the future") + resetAfter := repo.currentReset(account.ID) + require.NotNil(t, resetAfter) + require.True(t, resetAfter.After(time.Now()), "DB reset must be extended by the real probe write-back") +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index e7ff2dc4d3..3944ca5f07 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -486,6 +486,7 @@ func ProvideRateLimitService( openAI403CounterCache OpenAI403CounterCache, settingService *SettingService, tokenCacheInvalidator TokenCacheInvalidator, + ollamaCloudUsage *OllamaCloudUsageService, ) *RateLimitService { svc := NewRateLimitService(accountRepo, usageRepo, cfg, geminiQuotaService, tempUnschedCache) if healthCache, ok := tempUnschedCache.(OpenAIAPIKeyHealthCache); ok { @@ -495,6 +496,7 @@ func ProvideRateLimitService( svc.SetOpenAI403CounterCache(openAI403CounterCache) svc.SetSettingService(settingService) svc.SetTokenCacheInvalidator(tokenCacheInvalidator) + svc.SetOllamaCloudUsageProbeScheduler(ollamaCloudUsage) return svc }