diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 92300337be..ec2db0e11c 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -206,7 +206,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig) backupHandler := admin.NewBackupHandler(backupService, userService, imageStorageSettingService) oAuthHandler := admin.NewOAuthHandler(oAuthService) - openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService) + openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService, rateLimitService) geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService) antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService) diff --git a/backend/internal/handler/admin/account_handler_long_context_billing_test.go b/backend/internal/handler/admin/account_handler_long_context_billing_test.go index b3d3db9667..4d40e93bf7 100644 --- a/backend/internal/handler/admin/account_handler_long_context_billing_test.go +++ b/backend/internal/handler/admin/account_handler_long_context_billing_test.go @@ -144,7 +144,7 @@ func TestApplyOAuthCredentialsRejectsMalformedOpenAILongContextBillingBeforeMuta func TestOpenAIOAuthCodexPATBoundaryRejectsMalformedOpenAILongContextBillingValueBeforeTokenValidation(t *testing.T) { gin.SetMode(gin.TestMode) - handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil) + handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil, nil) router := gin.New() router.Use(gin.Recovery()) router.POST("/openai/create-from-codex-pat", handler.CreateAccountFromCodexPAT) diff --git a/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go index 78d57299b6..d7cfc8d2a6 100644 --- a/backend/internal/handler/admin/openai_oauth_handler.go +++ b/backend/internal/handler/admin/openai_oauth_handler.go @@ -1,6 +1,9 @@ package admin import ( + "context" + "log/slog" + "net/http" "strconv" "strings" "time" @@ -17,7 +20,61 @@ import ( type OpenAIOAuthHandler struct { openaiOAuthService *service.OpenAIOAuthService adminService service.AdminService - quotaService *service.OpenAIQuotaService + quotaService openAIQuotaService + rateLimitService openAIAccountStateRecoverer +} + +type openAIQuotaService interface { + QueryUsage(ctx context.Context, accountID int64) (*service.OpenAIQuotaUsage, error) + CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *service.OpenAIRateLimitResetCredits) error + ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error) +} + +type openAIAccountStateRecoverer interface { + RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error) +} + +const ( + openAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed" + openAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed" + openAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed" +) + +// openAIQuotaResetPostProcessTimeout bounds the work performed AFTER the +// (non-refundable) reset credit has already been consumed upstream. The whole +// request must stay comfortably inside the panel HTTP client timeout, otherwise +// the browser aborts a mutation that already succeeded and the operator retries +// it — spending a second credit. +const openAIQuotaResetPostProcessTimeout = 8 * time.Second + +type openAIQuotaResetResponse struct { + service.OpenAIQuotaResetResult + Quota *service.OpenAIQuotaUsage `json:"quota,omitempty"` + Account *dto.Account `json:"account,omitempty"` + CacheRefreshed bool `json:"cache_refreshed"` + AccountStateRecovered bool `json:"account_state_recovered"` + WarningCode string `json:"warning_code,omitempty"` +} + +// openAIQuotaRefreshResponse is the reset-credit-persisting variant of the quota +// query. The usage payload is embedded so the shape stays identical to the plain +// query; cache_persisted reports whether the snapshot write succeeded, because a +// failed display-cache write must never discard a successful upstream read. +type openAIQuotaRefreshResponse struct { + service.OpenAIQuotaUsage + CachePersisted bool `json:"cache_persisted"` +} + +// openAIQuotaResetPostProcessContext detaches the post-reset bookkeeping from the +// client connection. The credit is already spent at that point, so account-state +// recovery must complete even if the operator closes the tab (mirrors +// systemUpdateContext, added for the same reason in #4504). +func openAIQuotaResetPostProcessContext(ctx context.Context) (context.Context, context.CancelFunc) { + base := context.Background() + if ctx != nil { + base = context.WithoutCancel(ctx) + } + return context.WithTimeout(base, openAIQuotaResetPostProcessTimeout) } func oauthPlatformFromPath(c *gin.Context) string { @@ -29,12 +86,22 @@ func NewOpenAIOAuthHandler( openaiOAuthService *service.OpenAIOAuthService, adminService service.AdminService, quotaService *service.OpenAIQuotaService, + rateLimitService *service.RateLimitService, ) *OpenAIOAuthHandler { - return &OpenAIOAuthHandler{ + h := &OpenAIOAuthHandler{ openaiOAuthService: openaiOAuthService, adminService: adminService, - quotaService: quotaService, } + // Assign through explicit nil checks: storing a nil *Service in an interface + // field yields a non-nil interface, which would silently defeat the + // `== nil` capability guards below and panic instead of returning 400. + if quotaService != nil { + h.quotaService = quotaService + } + if rateLimitService != nil { + h.rateLimitService = rateLimitService + } + return h } // OpenAIGenerateAuthURLRequest represents the request for generating OpenAI auth URL @@ -420,6 +487,7 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) { response.BadRequest(c, "openai quota service is not enabled") return } + usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID) if err != nil { response.ErrorFrom(c, err) @@ -428,6 +496,47 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) { response.Success(c, usage) } +// RefreshQuota queries the rate-limit / quota usage AND persists the reset-credit +// snapshot so the card can be rehydrated without an upstream round-trip. +// POST /api/v1/admin/openai/accounts/:id/quota/refresh +// +// It is a POST (not a GET with a side-effect flag) because it writes account +// state: the audit middleware only records mutating verbs, so a persisting GET +// would mutate the database without an audit trail. +func (h *OpenAIOAuthHandler) RefreshQuota(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + if h.quotaService == nil { + response.BadRequest(c, "openai quota service is not enabled") + return + } + + usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + if usage == nil { + response.Error(c, http.StatusInternalServerError, "openai quota query returned an empty result") + return + } + + refreshResponse := openAIQuotaRefreshResponse{OpenAIQuotaUsage: *usage} + // A failed snapshot write leaves the previous cache intact — report it as a + // partial success instead of discarding the usage payload we just fetched, + // which would leave the card without a credit count at all. + if err := h.quotaService.CacheResetCreditsSnapshot(c.Request.Context(), accountID, usage.RateLimitResetCredits); err != nil { + slog.Warn("openai_quota_reset_credit_cache_persist_failed", "account_id", accountID, "error", err) + response.Success(c, refreshResponse) + return + } + refreshResponse.CachePersisted = true + response.Success(c, refreshResponse) +} + // CreateShadowRequest is the request body for CreateShadow. type CreateShadowRequest struct { Name string `json:"name"` @@ -482,5 +591,63 @@ func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) { response.ErrorFrom(c, err) return } - response.Success(c, result) + if result == nil { + response.Error(c, http.StatusInternalServerError, "openai quota reset returned an empty result") + return + } + + resetResponse := openAIQuotaResetResponse{OpenAIQuotaResetResult: *result} + postCtx, cancelPost := openAIQuotaResetPostProcessContext(c.Request.Context()) + defer cancelPost() + + // Step 1 — unblocking the account is the whole point of consuming a credit + // (#3672 / #3740), so it runs FIRST and is never gated on the display cache. + // Recovery is DB-only and leaves the manual `schedulable` switch untouched. + if h.rateLimitService == nil { + resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed + response.Success(c, resetResponse) + return + } + if _, err := h.rateLimitService.RecoverAccountState(postCtx, accountID, service.AccountRecoveryOptions{ + InvalidateToken: true, + }); err != nil { + // Recovery failures are almost always storage-level; the remaining steps + // share that dependency, so stop here instead of compounding the failure. + slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error", err) + resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed + response.Success(c, resetResponse) + return + } + resetResponse.AccountStateRecovered = true + + // Step 2 — refresh the reset-credit display cache. A failure here is reported + // but must not hide the recovered account row produced by step 3. + usage, usageErr := h.quotaService.QueryUsage(postCtx, accountID) + switch { + case usageErr != nil || usage == nil: + slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", usageErr) + resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed + default: + if err := h.quotaService.CacheResetCreditsSnapshot(postCtx, accountID, usage.RateLimitResetCredits); err != nil { + slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", err) + resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed + } else { + resetResponse.Quota = usage + resetResponse.CacheRefreshed = true + } + } + + // Step 3 — hand back the post-recovery account row so the list drops the + // stale rate-limit badge without waiting for the next poll. + account, err := h.adminService.GetAccount(postCtx, accountID) + if err != nil { + slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error", err) + if resetResponse.WarningCode == "" { + resetResponse.WarningCode = openAIQuotaResetWarningAccountRefreshFailed + } + response.Success(c, resetResponse) + return + } + resetResponse.Account = dto.AccountFromService(account) + response.Success(c, resetResponse) } diff --git a/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go b/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go new file mode 100644 index 0000000000..c0e3ca161d --- /dev/null +++ b/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go @@ -0,0 +1,486 @@ +//go:build unit + +package admin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type openAIQuotaWorkflowStub struct { + resetResult *service.OpenAIQuotaResetResult + resetErr error + queryResult *service.OpenAIQuotaUsage + queryErr error + cacheErr error + + resetCalls int + queryCalls int + cacheCalls int + + queryCtxErr error + cacheCtxErr error +} + +func (s *openAIQuotaWorkflowStub) ResetCredit(context.Context, int64) (*service.OpenAIQuotaResetResult, error) { + s.resetCalls++ + return s.resetResult, s.resetErr +} + +func (s *openAIQuotaWorkflowStub) QueryUsage(ctx context.Context, _ int64) (*service.OpenAIQuotaUsage, error) { + s.queryCalls++ + s.queryCtxErr = ctx.Err() + return s.queryResult, s.queryErr +} + +func (s *openAIQuotaWorkflowStub) CacheResetCreditsSnapshot(ctx context.Context, _ int64, _ *service.OpenAIRateLimitResetCredits) error { + s.cacheCalls++ + s.cacheCtxErr = ctx.Err() + return s.cacheErr +} + +type openAIAccountStateRecovererStub struct { + err error + calls int + accountID int64 + lastOptions service.AccountRecoveryOptions + lastCtxErr error +} + +func (s *openAIAccountStateRecovererStub) RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error) { + s.calls++ + s.accountID = accountID + s.lastOptions = options + s.lastCtxErr = ctx.Err() + return &service.SuccessfulTestRecoveryResult{}, s.err +} + +type openAIResetAdminServiceStub struct { + service.AdminService + account *service.Account + err error + calls int +} + +func (s *openAIResetAdminServiceStub) GetAccount(context.Context, int64) (*service.Account, error) { + s.calls++ + return s.account, s.err +} + +type openAIQuotaResetEnvelope struct { + Code int `json:"code"` + Data openAIQuotaResetResponse `json:"data"` +} + +type openAIQuotaRefreshEnvelope struct { + Code int `json:"code"` + Data openAIQuotaRefreshResponse `json:"data"` +} + +func performOpenAIQuotaResetRequest(t *testing.T, handler *OpenAIOAuthHandler) (int, openAIQuotaResetEnvelope) { + t.Helper() + return performOpenAIQuotaResetRequestWithContext(t, handler, nil) +} + +// performOpenAIQuotaResetRequestWithContext drives the reset endpoint, optionally +// with an already-canceled request context (client disconnect simulation). +func performOpenAIQuotaResetRequestWithContext(t *testing.T, handler *OpenAIOAuthHandler, ctx context.Context) (int, openAIQuotaResetEnvelope) { + t.Helper() + gin.SetMode(gin.TestMode) + + router := gin.New() + router.POST("/api/v1/admin/openai/accounts/:id/reset-quota", handler.ResetQuota) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/openai/accounts/42/reset-quota", nil) + if ctx != nil { + request = request.WithContext(ctx) + } + router.ServeHTTP(recorder, request) + + var envelope openAIQuotaResetEnvelope + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope)) + return recorder.Code, envelope +} + +func performOpenAIQuotaRefreshRequest(t *testing.T, handler *OpenAIOAuthHandler) (int, openAIQuotaRefreshEnvelope) { + t.Helper() + gin.SetMode(gin.TestMode) + + router := gin.New() + router.POST("/api/v1/admin/openai/accounts/:id/quota/refresh", handler.RefreshQuota) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/openai/accounts/42/quota/refresh", nil) + router.ServeHTTP(recorder, request) + + var envelope openAIQuotaRefreshEnvelope + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope)) + return recorder.Code, envelope +} + +func successfulOpenAIQuotaWorkflowStub() *openAIQuotaWorkflowStub { + return &openAIQuotaWorkflowStub{ + resetResult: &service.OpenAIQuotaResetResult{ + Code: "success", + WindowsReset: 1, + }, + queryResult: &service.OpenAIQuotaUsage{ + FetchedAt: 123, + RateLimitResetCredits: &service.OpenAIRateLimitResetCredits{ + AvailableCount: 0, + Credits: []service.OpenAIRateLimitResetCreditDetail{}, + }, + }, + } +} + +func recoveredAccountStub() *openAIResetAdminServiceStub { + return &openAIResetAdminServiceStub{account: &service.Account{ + ID: 42, + Name: "recovered", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Schedulable: false, + }} +} + +func TestOpenAIResetQuota_ResetFailureStopsWorkflow(t *testing.T) { + quota := &openAIQuotaWorkflowStub{resetErr: errors.New("upstream reset failed")} + recoverer := &openAIAccountStateRecovererStub{} + handler := &OpenAIOAuthHandler{ + adminService: &openAIResetAdminServiceStub{}, + quotaService: quota, + rateLimitService: recoverer, + } + + status, _ := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusInternalServerError, status) + require.Equal(t, 1, quota.resetCalls) + require.Zero(t, quota.queryCalls) + require.Zero(t, quota.cacheCalls) + require.Zero(t, recoverer.calls) +} + +// Account-state recovery is the reason the credit was spent (#3672 / #3740), so it +// must run before — and independently of — the reset-credit display cache. +func TestOpenAIResetQuota_RecoversAccountStateBeforeRefreshingCache(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + recoverer := &openAIAccountStateRecovererStub{} + adminService := recoveredAccountStub() + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Empty(t, envelope.Data.WarningCode) + require.True(t, envelope.Data.AccountStateRecovered) + require.True(t, envelope.Data.CacheRefreshed) + require.NotNil(t, envelope.Data.Quota) + require.NotNil(t, envelope.Data.Account) + require.Equal(t, int64(42), envelope.Data.Account.ID) + require.False(t, envelope.Data.Account.Schedulable, "manual scheduling switch must not be flipped") + require.Equal(t, int64(42), recoverer.accountID) + require.True(t, recoverer.lastOptions.InvalidateToken) + require.Equal(t, 1, quota.resetCalls) + require.Equal(t, 1, quota.queryCalls) + require.Equal(t, 1, quota.cacheCalls) + require.Equal(t, 1, recoverer.calls) + require.Equal(t, 1, adminService.calls) +} + +func TestOpenAIResetQuota_RecoveryFailureStopsWorkflow(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + recoverer := &openAIAccountStateRecovererStub{err: errors.New("recovery failed")} + adminService := recoveredAccountStub() + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) + require.False(t, envelope.Data.AccountStateRecovered) + require.False(t, envelope.Data.CacheRefreshed) + require.Nil(t, envelope.Data.Quota) + require.Nil(t, envelope.Data.Account) + require.Equal(t, 1, recoverer.calls) + require.Zero(t, quota.queryCalls) + require.Zero(t, quota.cacheCalls) + require.Zero(t, adminService.calls) +} + +func TestOpenAIResetQuota_MissingRecovererReportsRecoveryFailure(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + adminService := recoveredAccountStub() + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) + require.False(t, envelope.Data.AccountStateRecovered) + require.Zero(t, quota.queryCalls) + require.Zero(t, adminService.calls) +} + +// A failed cache refresh must not hide the recovered account row: the operator +// still needs the list to drop the stale rate-limit badge. +func TestOpenAIResetQuota_QueryFailureStillRecoversAndReturnsAccount(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + quota.queryResult = nil + quota.queryErr = errors.New("upstream query failed") + recoverer := &openAIAccountStateRecovererStub{} + adminService := recoveredAccountStub() + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.True(t, envelope.Data.AccountStateRecovered) + require.False(t, envelope.Data.CacheRefreshed) + require.Nil(t, envelope.Data.Quota) + require.NotNil(t, envelope.Data.Account) + require.Equal(t, 1, quota.queryCalls) + require.Zero(t, quota.cacheCalls) + require.Equal(t, 1, recoverer.calls) + require.Equal(t, 1, adminService.calls) +} + +func TestOpenAIResetQuota_CacheFailureStillRecoversAndReturnsAccount(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + quota.cacheErr = errors.New("cache write failed") + recoverer := &openAIAccountStateRecovererStub{} + adminService := recoveredAccountStub() + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.True(t, envelope.Data.AccountStateRecovered) + require.False(t, envelope.Data.CacheRefreshed) + require.Nil(t, envelope.Data.Quota) + require.NotNil(t, envelope.Data.Account) + require.Equal(t, 1, quota.cacheCalls) + require.Equal(t, 1, adminService.calls) +} + +func TestOpenAIResetQuota_AccountRefreshFailureReportsRecoveredState(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + recoverer := &openAIAccountStateRecovererStub{} + adminService := &openAIResetAdminServiceStub{err: errors.New("account refresh failed")} + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode) + require.True(t, envelope.Data.CacheRefreshed) + require.True(t, envelope.Data.AccountStateRecovered) + require.NotNil(t, envelope.Data.Quota) + require.Nil(t, envelope.Data.Account) + require.Equal(t, 1, adminService.calls) +} + +// The first (most actionable) failure wins so the UI never downgrades a cache +// problem into a cosmetic "could not reload the row" message. +func TestOpenAIResetQuota_CacheAndAccountFailureKeepsFirstWarning(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + quota.cacheErr = errors.New("cache write failed") + recoverer := &openAIAccountStateRecovererStub{} + adminService := &openAIResetAdminServiceStub{err: errors.New("account refresh failed")} + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.True(t, envelope.Data.AccountStateRecovered) + require.Nil(t, envelope.Data.Account) +} + +// The credit is non-refundable once consumed, so post-reset bookkeeping must +// survive a client disconnect instead of leaving the account rate-limited. +func TestOpenAIResetQuota_PostProcessingSurvivesClientCancellation(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + recoverer := &openAIAccountStateRecovererStub{} + adminService := recoveredAccountStub() + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + status, _ := performOpenAIQuotaResetRequestWithContext(t, handler, ctx) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, 1, recoverer.calls) + require.NoError(t, recoverer.lastCtxErr, "recovery must not inherit the canceled client context") + require.NoError(t, quota.queryCtxErr) + require.NoError(t, quota.cacheCtxErr) + require.Equal(t, 1, adminService.calls) +} + +func TestOpenAIRefreshQuota_PersistsSnapshot(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + handler := &OpenAIOAuthHandler{ + adminService: &openAIResetAdminServiceStub{}, + quotaService: quota, + } + + status, envelope := performOpenAIQuotaRefreshRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.True(t, envelope.Data.CachePersisted) + require.Equal(t, int64(123), envelope.Data.FetchedAt) + require.Equal(t, 1, quota.queryCalls) + require.Equal(t, 1, quota.cacheCalls) + require.Zero(t, quota.resetCalls) +} + +// A rejected snapshot write must never discard the usage payload: otherwise the +// card loses its credit count and the reset button stays disabled forever. +func TestOpenAIRefreshQuota_PersistFailureStillReturnsUsage(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + quota.queryResult = &service.OpenAIQuotaUsage{ + FetchedAt: 456, + RateLimitResetCredits: &service.OpenAIRateLimitResetCredits{ + AvailableCount: 2, + }, + } + quota.cacheErr = errors.New("expiration details unavailable") + handler := &OpenAIOAuthHandler{ + adminService: &openAIResetAdminServiceStub{}, + quotaService: quota, + } + + status, envelope := performOpenAIQuotaRefreshRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.False(t, envelope.Data.CachePersisted) + require.Equal(t, int64(456), envelope.Data.FetchedAt) + require.NotNil(t, envelope.Data.RateLimitResetCredits) + require.Equal(t, 2, envelope.Data.RateLimitResetCredits.AvailableCount) + require.Equal(t, 1, quota.cacheCalls) +} + +// An empty-but-successful upstream read must not be dereferenced blindly. +func TestOpenAIQuotaEmptyUsageIsHandledWithoutPanic(t *testing.T) { + t.Run("refresh reports an internal error", func(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + quota.queryResult = nil + handler := &OpenAIOAuthHandler{ + adminService: &openAIResetAdminServiceStub{}, + quotaService: quota, + } + + status, envelope := performOpenAIQuotaRefreshRequest(t, handler) + + require.Equal(t, http.StatusInternalServerError, status) + require.False(t, envelope.Data.CachePersisted) + require.Zero(t, quota.cacheCalls) + }) + + t.Run("reset degrades to a cache warning", func(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + quota.queryResult = nil + recoverer := &openAIAccountStateRecovererStub{} + adminService := recoveredAccountStub() + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.True(t, envelope.Data.AccountStateRecovered) + require.NotNil(t, envelope.Data.Account) + require.Zero(t, quota.cacheCalls) + }) +} + +func TestOpenAIRefreshQuota_QueryFailureIsReported(t *testing.T) { + quota := &openAIQuotaWorkflowStub{queryErr: errors.New("upstream query failed")} + handler := &OpenAIOAuthHandler{ + adminService: &openAIResetAdminServiceStub{}, + quotaService: quota, + } + + status, _ := performOpenAIQuotaRefreshRequest(t, handler) + + require.Equal(t, http.StatusInternalServerError, status) + require.Equal(t, 1, quota.queryCalls) + require.Zero(t, quota.cacheCalls) +} + +// Storing a nil *Service in an interface field would make the capability guards +// non-nil and panic on the first call; the constructor must keep them nil. +func TestNewOpenAIOAuthHandlerKeepsNilQuotaCapabilitiesGuarded(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil, nil) + require.Nil(t, handler.quotaService) + require.Nil(t, handler.rateLimitService) + + router := gin.New() + router.Use(gin.Recovery()) + router.GET("/openai/accounts/:id/quota", handler.QueryQuota) + router.POST("/openai/accounts/:id/quota/refresh", handler.RefreshQuota) + router.POST("/openai/accounts/:id/reset-quota", handler.ResetQuota) + + for _, tc := range []struct { + method string + path string + }{ + {http.MethodGet, "/openai/accounts/42/quota"}, + {http.MethodPost, "/openai/accounts/42/quota/refresh"}, + {http.MethodPost, "/openai/accounts/42/reset-quota"}, + } { + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(tc.method, tc.path, nil)) + require.Equal(t, http.StatusBadRequest, recorder.Code, "%s %s", tc.method, tc.path) + } +} diff --git a/backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go b/backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go index af3b245c4b..da856b3926 100644 --- a/backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go +++ b/backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go @@ -20,7 +20,7 @@ func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) { gin.SetMode(gin.TestMode) stub := &stubAdminService{} - h := NewOpenAIOAuthHandler(nil, stub, nil) + h := NewOpenAIOAuthHandler(nil, stub, nil, nil) router := gin.New() router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow) @@ -54,7 +54,7 @@ func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) { func TestCreateShadow_InvalidID(t *testing.T) { gin.SetMode(gin.TestMode) - h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil) + h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil, nil) router := gin.New() router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow) @@ -72,7 +72,7 @@ func TestCreateShadow_ServiceError(t *testing.T) { gin.SetMode(gin.TestMode) stub := &stubAdminService{createSparkShadowErr: errors.New("database unavailable")} - h := NewOpenAIOAuthHandler(nil, stub, nil) + h := NewOpenAIOAuthHandler(nil, stub, nil, nil) router := gin.New() router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow) @@ -91,7 +91,7 @@ func TestCreateShadow_ServiceError(t *testing.T) { func TestCreateShadow_BadBody(t *testing.T) { gin.SetMode(gin.TestMode) - h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil) + h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil, nil) router := gin.New() router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow) diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 92f098768c..010442eb6b 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -56,6 +56,7 @@ var schedulerNeutralExtraKeyPrefixes = []string{ "codex_secondary_", "codex_5h_", "codex_7d_", + "codex_reset_credit_", "passive_usage_", "upstream_billing_probe", "upstream_billing_rate_sync", diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index a5635bec6e..161571cd53 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -437,6 +437,7 @@ func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { openai.POST("/create-from-oauth", h.Admin.OpenAIOAuth.CreateAccountFromOAuth) openai.POST("/create-from-codex-pat", h.Admin.OpenAIOAuth.CreateAccountFromCodexPAT) openai.GET("/accounts/:id/quota", h.Admin.OpenAIOAuth.QueryQuota) + openai.POST("/accounts/:id/quota/refresh", h.Admin.OpenAIOAuth.RefreshQuota) openai.POST("/accounts/:id/reset-quota", h.Admin.OpenAIOAuth.ResetQuota) } } diff --git a/backend/internal/service/openai_quota_service.go b/backend/internal/service/openai_quota_service.go index c4bad643e7..58bf4659f5 100644 --- a/backend/internal/service/openai_quota_service.go +++ b/backend/internal/service/openai_quota_service.go @@ -34,6 +34,7 @@ const ( openaiQuotaSecFetchSite = "none" openaiQuotaSecFetchMode = "no-cors" openaiQuotaSecFetchDest = "empty" + openaiQuotaResetCreditsKey = "codex_reset_credit_snapshot" ) // OpenAIRateLimitWindow describes a single rate-limit window returned by @@ -206,6 +207,37 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (* return &payload, nil } +// CacheResetCreditsSnapshot persists a complete reset-credit snapshot after an +// explicit UI refresh. The snapshot is written to the account that was queried +// (for a spark shadow that is the shadow row, even though the credits belong to +// its parent) because it is a per-row display cache: each row caches exactly +// what its own card renders, and shadows cannot consume credits anyway. +// +// Missing expiration details leave the old cache intact: +// a snapshot claiming N>0 available credits without their expiration timestamps +// cannot be aged out by readers, so it would keep showing (and offering to +// consume) credits that already expired. Callers must treat this rejection as a +// partial success — the upstream read itself is still valid. +func (s *OpenAIQuotaService) CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits) error { + if credits == nil || (credits.AvailableCount > 0 && len(credits.Credits) == 0) { + return infraerrors.New( + http.StatusBadGateway, + "OPENAI_QUOTA_RESET_CREDITS_REFRESH_FAILED", + "failed to refresh reset-credit expiration details; cached data was preserved", + ) + } + if err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{ + openaiQuotaResetCreditsKey: credits, + }); err != nil { + return infraerrors.New( + http.StatusInternalServerError, + "OPENAI_QUOTA_CACHE_WRITE_FAILED", + "failed to cache reset-credit details", + ).WithCause(err) + } + return nil +} + func (s *OpenAIQuotaService) queryResetCreditDetails(ctx context.Context, client *req.Client, accessToken, chatGPTAccountID string, fedRAMP bool, accountID int64) *openAIRateLimitResetCreditDetails { quotaHeaders, _, headerErr := s.buildCodexQuotaHeaders(ctx, accountID, accessToken, chatGPTAccountID, fedRAMP) if headerErr != nil { diff --git a/backend/internal/service/openai_quota_spark_window_test.go b/backend/internal/service/openai_quota_spark_window_test.go index ad871c3f2e..a351381cb4 100644 --- a/backend/internal/service/openai_quota_spark_window_test.go +++ b/backend/internal/service/openai_quota_spark_window_test.go @@ -27,7 +27,9 @@ import ( // stubQuotaAccountRepo 是多账号 AccountRepository stub,仅实现 GetByID。 type stubQuotaAccountRepo struct { AccountRepository - accounts map[int64]*Account + accounts map[int64]*Account + extraUpdates map[int64]map[string]any + extraUpdateErr error } func (r *stubQuotaAccountRepo) GetByID(_ context.Context, id int64) (*Account, error) { @@ -47,6 +49,17 @@ func (r *stubQuotaAccountRepo) UpdateCredentials(_ context.Context, id int64, cr return nil } +func (r *stubQuotaAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error { + if r.extraUpdateErr != nil { + return r.extraUpdateErr + } + if r.extraUpdates == nil { + r.extraUpdates = make(map[int64]map[string]any) + } + r.extraUpdates[id] = updates + return nil +} + // stubQuotaTokenCache 实现 OpenAITokenCache,返回预设静态 token。 type stubQuotaTokenCache struct { tokens map[string]string @@ -535,6 +548,14 @@ func TestQueryUsageIncludesResetCreditExpirations_EndToEnd(t *testing.T) { {ExpiresAt: "2026-07-03T04:05:06Z"}, {ExpiresAt: "2026-07-04T04:05:06Z"}, }, usage.RateLimitResetCredits.Credits) + require.NoError(t, svc.CacheResetCreditsSnapshot(ctx, 100, usage.RateLimitResetCredits)) + require.Equal(t, &OpenAIRateLimitResetCredits{ + AvailableCount: 2, + Credits: []OpenAIRateLimitResetCreditDetail{ + {ExpiresAt: "2026-07-03T04:05:06Z"}, + {ExpiresAt: "2026-07-04T04:05:06Z"}, + }, + }, repo.extraUpdates[100][openaiQuotaResetCreditsKey]) encoded, err := json.Marshal(usage) require.NoError(t, err) @@ -584,6 +605,67 @@ func TestQueryUsageResetCreditDetails401NonFatal(t *testing.T) { require.Equal(t, 1, usage.RateLimitResetCredits.AvailableCount) require.Equal(t, 1, detailCalls) require.Empty(t, usage.RateLimitResetCredits.Credits) + + // A count without expiration details must not be persisted (the reader could + // never age it out), and the previous snapshot must survive untouched. + require.Error(t, svc.CacheResetCreditsSnapshot(ctx, 100, usage.RateLimitResetCredits)) + require.Empty(t, repo.extraUpdates) +} + +func TestCacheResetCreditsSnapshot(t *testing.T) { + ctx := context.Background() + + t.Run("zero count allows an empty expiration list", func(t *testing.T) { + repo := &stubQuotaAccountRepo{} + svc := &OpenAIQuotaService{accountRepo: repo} + credits := &OpenAIRateLimitResetCredits{AvailableCount: 0} + + require.NoError(t, svc.CacheResetCreditsSnapshot(ctx, 100, credits)) + require.Equal(t, credits, repo.extraUpdates[100][openaiQuotaResetCreditsKey]) + }) + + t.Run("missing expiration list preserves the cache", func(t *testing.T) { + repo := &stubQuotaAccountRepo{} + svc := &OpenAIQuotaService{accountRepo: repo} + + err := svc.CacheResetCreditsSnapshot(ctx, 100, &OpenAIRateLimitResetCredits{AvailableCount: 1}) + + require.Error(t, err) + require.Empty(t, repo.extraUpdates) + }) + + t.Run("empty expiration list with a positive count preserves the cache", func(t *testing.T) { + repo := &stubQuotaAccountRepo{} + svc := &OpenAIQuotaService{accountRepo: repo} + + err := svc.CacheResetCreditsSnapshot(ctx, 100, &OpenAIRateLimitResetCredits{ + AvailableCount: 2, + Credits: []OpenAIRateLimitResetCreditDetail{}, + }) + + require.Error(t, err) + require.Empty(t, repo.extraUpdates) + }) + + t.Run("nil snapshot preserves the cache", func(t *testing.T) { + repo := &stubQuotaAccountRepo{} + svc := &OpenAIQuotaService{accountRepo: repo} + + require.Error(t, svc.CacheResetCreditsSnapshot(ctx, 100, nil)) + require.Empty(t, repo.extraUpdates) + }) + + t.Run("repository errors are returned", func(t *testing.T) { + repo := &stubQuotaAccountRepo{extraUpdateErr: errors.New("database unavailable")} + svc := &OpenAIQuotaService{accountRepo: repo} + + err := svc.CacheResetCreditsSnapshot(ctx, 100, &OpenAIRateLimitResetCredits{ + AvailableCount: 1, + Credits: []OpenAIRateLimitResetCreditDetail{{ExpiresAt: "2026-07-03T04:05:06Z"}}, + }) + + require.ErrorContains(t, err, "database unavailable") + }) } // TestResetCreditGetByIDError_FailsClosed 验证守卫「失败关闭」语义: diff --git a/backend/internal/service/scheduler_snapshot_cancellation_test.go b/backend/internal/service/scheduler_snapshot_cancellation_test.go new file mode 100644 index 0000000000..3f46cb14aa --- /dev/null +++ b/backend/internal/service/scheduler_snapshot_cancellation_test.go @@ -0,0 +1,79 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +type schedulerCancellationCache struct { + SchedulerCache + cancel context.CancelFunc + tokenCaptures int +} + +func (c *schedulerCancellationCache) GetSnapshot(ctx context.Context, _ SchedulerBucket) ([]*Account, bool, error) { + c.cancel() + return nil, false, ctx.Err() +} + +func (c *schedulerCancellationCache) CaptureBucketWriteToken(ctx context.Context, _ SchedulerBucket) (SchedulerBucketWriteToken, error) { + c.tokenCaptures++ + return SchedulerBucketWriteToken{}, ctx.Err() +} + +func (c *schedulerCancellationCache) GetAccount(ctx context.Context, _ int64) (*Account, error) { + c.cancel() + return nil, ctx.Err() +} + +type schedulerCancellationAccountRepo struct { + AccountRepository + listCalls int + getByIDCalls int +} + +func (r *schedulerCancellationAccountRepo) ListSchedulableUngroupedByPlatform(ctx context.Context, _ string) ([]Account, error) { + r.listCalls++ + return nil, ctx.Err() +} + +func (r *schedulerCancellationAccountRepo) GetByID(ctx context.Context, _ int64) (*Account, error) { + r.getByIDCalls++ + return nil, ctx.Err() +} + +func TestSchedulerSnapshotListStopsAfterRequestCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + cache := &schedulerCancellationCache{cancel: cancel} + repo := &schedulerCancellationAccountRepo{} + svc := NewSchedulerSnapshotService(cache, nil, repo, nil, nil) + + accounts, useMixed, err := svc.ListSchedulableAccounts(ctx, nil, PlatformOpenAI, false) + + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, accounts) + require.False(t, useMixed) + require.Zero(t, cache.tokenCaptures, "canceled requests must not capture a cache publish token") + require.Zero(t, repo.listCalls, "canceled requests must not fall back to the database") +} + +func TestSchedulerSnapshotGetAccountStopsAfterRequestCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + cache := &schedulerCancellationCache{cancel: cancel} + repo := &schedulerCancellationAccountRepo{} + svc := NewSchedulerSnapshotService(cache, nil, repo, nil, nil) + + account, err := svc.GetAccount(ctx, 42) + + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, account) + require.Zero(t, repo.getByIDCalls, "canceled requests must not fall back to the database") +} diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index 542fc18b97..9655f54f25 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -213,15 +213,24 @@ func (s *SchedulerSnapshotService) ListSchedulableAccounts(ctx context.Context, bucket := s.bucketFor(groupID, platform, mode) var writeToken SchedulerBucketWriteToken canPublish := false + if err := ctx.Err(); err != nil { + return nil, useMixed, err + } if s.cache != nil { cached, hit, err := s.cache.GetSnapshot(ctx, bucket) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, useMixed, ctxErr + } if err != nil { logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] cache read failed: bucket=%s err=%v", bucket.String(), err) } else if hit { return derefAccounts(cached), useMixed, nil } token, err := s.cache.CaptureBucketWriteToken(ctx, bucket) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, useMixed, ctxErr + } if err != nil { if errors.Is(err, ErrSchedulerBucketRetired) || errors.Is(err, ErrSchedulerBucketWriteFenced) { slog.Debug("[Scheduler] cache publish fenced", "bucket", bucket.String()) @@ -245,6 +254,9 @@ func (s *SchedulerSnapshotService) ListSchedulableAccounts(ctx context.Context, if err != nil { return nil, useMixed, err } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, useMixed, ctxErr + } if s.cache != nil && canPublish { if err := s.cache.SetSnapshot(fallbackCtx, bucket, writeToken, accounts); err != nil { @@ -263,8 +275,14 @@ func (s *SchedulerSnapshotService) GetAccount(ctx context.Context, accountID int if accountID <= 0 { return nil, nil } + if err := ctx.Err(); err != nil { + return nil, err + } if s.cache != nil { account, err := s.cache.GetAccount(ctx, accountID) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } if err != nil { logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] account cache read failed: id=%d err=%v", accountID, err) } else if account != nil { diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index ea9b59b20c..bd9c54f8e1 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -834,21 +834,51 @@ export interface OpenAIQuotaResetResult { code: string credit?: OpenAIQuotaResetCredit | null windows_reset: number + quota?: OpenAIQuotaUsage | null + account?: Account | null + cache_refreshed: boolean + account_state_recovered: boolean + warning_code?: + | 'reset_credit_cache_refresh_failed' + | 'account_state_recovery_failed' + | 'account_state_refresh_failed' +} + +/** Usage payload plus whether the reset-credit snapshot was persisted. */ +export interface OpenAIQuotaRefreshResult extends OpenAIQuotaUsage { + cache_persisted: boolean } /** - * Query OpenAI/Codex rate-limit usage for an OAuth account. + * Query the upstream quota AND persist the reset-credit snapshot on the account + * so the card can be rehydrated without an upstream round-trip. It is a POST + * because it writes account state (and must therefore be audited). + * + * The read-only `GET /admin/openai/accounts/:id/quota` endpoint still exists for + * API consumers; the panel always wants the snapshot persisted, so it has no + * client binding here. */ -export async function queryOpenAIQuota(id: number): Promise { - const { data } = await apiClient.get(`/admin/openai/accounts/${id}/quota`) +export async function refreshOpenAIQuota(id: number): Promise { + const { data } = await apiClient.post( + `/admin/openai/accounts/${id}/quota/refresh` + ) return data } /** * Consume one rate-limit-reset credit for an OpenAI/Codex OAuth account. + * + * The credit is non-refundable and the endpoint chains an upstream reset with an + * upstream re-query, so it needs a larger budget than the default client + * timeout: aborting locally would report a successful consumption as a failure + * and invite a retry that spends a second credit. */ export async function resetOpenAIQuota(id: number): Promise { - const { data } = await apiClient.post(`/admin/openai/accounts/${id}/reset-quota`) + const { data } = await apiClient.post( + `/admin/openai/accounts/${id}/reset-quota`, + undefined, + { timeout: 90_000 } + ) return data } @@ -985,7 +1015,7 @@ export const accountsAPI = { batchRefresh, setPrivacy, revertProxyFallback, - queryOpenAIQuota, + refreshOpenAIQuota, resetOpenAIQuota, createSparkShadow, getUpstreamBillingProbeSettings, diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index b83e0308cd..bce09b37c8 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -144,7 +144,7 @@ refresh button is rendered via the pre-actions slot so the user sees a single row of related buttons instead of two stacked rows. --> - + @@ -639,6 +643,8 @@ import OllamaCloudUsageCell from './OllamaCloudUsageCell.vue' // Module-level cache shared across all AccountUsageCell instances const _usageCache = new Map() const USAGE_CACHE_TTL = 5 * 60 * 1000 // 5 minutes +// How long a quota-reset response may suppress the row-patch usage refetch. +const SUPPRESS_USAGE_REFRESH_WINDOW_MS = 5 * 1000 const props = withDefaults( defineProps<{ @@ -654,6 +660,10 @@ const props = withDefaults( } ) +const emit = defineEmits<{ + 'account-updated': [account: Account] +}>() + const { t } = useI18n() const desktopViewportQuery = '(min-width: 768px)' @@ -664,6 +674,7 @@ const loading = ref(false) const activeQueryLoading = ref(false) const error = ref(null) const usageInfo = ref(null) +const suppressOpenAIUsageRefreshUntil = ref(0) const rootRef = ref(null) const isDesktopViewport = ref( typeof window === 'undefined' ? true : window.matchMedia(desktopViewportQuery).matches @@ -1473,6 +1484,15 @@ const quotaTotalBar = computed((): QuotaBarInfo | null => { return makeQuotaBar(props.account.quota_used ?? 0, limit) }) +const handleQuotaResetAccountUpdated = (account: Account) => { + // The reset response already carries authoritative quota and account data. + // Avoid turning the parent patch into a second automatic /usage request. + // The suppression is time-boxed so an unhandled emit (parent that ignores + // account-updated) cannot latch it and swallow a later, unrelated refresh. + suppressOpenAIUsageRefreshUntil.value = Date.now() + SUPPRESS_USAGE_REFRESH_WINDOW_MS + emit('account-updated', account) +} + // ===== Key account today stats formatters ===== const formatKeyRequests = computed(() => { @@ -1517,6 +1537,10 @@ onMounted(() => { watch(openAIUsageRefreshKey, (nextKey, prevKey) => { if (!prevKey || nextKey === prevKey) return if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return + if (Date.now() < suppressOpenAIUsageRefreshUntil.value) { + suppressOpenAIUsageRefreshUntil.value = 0 + return + } _usageCache.delete(props.account.id) requestAutoLoad() diff --git a/frontend/src/components/account/OpenAIQuotaResetCell.vue b/frontend/src/components/account/OpenAIQuotaResetCell.vue index c1b5e1eec3..cb81bcfd9d 100644 --- a/frontend/src/components/account/OpenAIQuotaResetCell.vue +++ b/frontend/src/components/account/OpenAIQuotaResetCell.vue @@ -19,7 +19,7 @@ class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:bg-blue-900/30" :disabled="loading || resetting" :title="countButtonTitle" - @click="handleQuery" + @click="handleQuery()" > {{ truncatedError }} +
+ {{ resetWarning }} +
() +const emit = defineEmits<{ + 'account-updated': [account: Account] +}>() + const { t } = useI18n() // Visible only for OpenAI OAuth accounts. @@ -156,17 +166,67 @@ const loading = ref(false) const resetting = ref(false) const error = ref(null) const data = ref(null) +const cachedData = ref(null) const resetMessage = ref(null) +const resetWarning = ref(null) const showResetConfirm = ref(false) const showResetCreditDetails = ref(false) +// Rehydrate the card from the persisted snapshot. Credits that already expired +// are dropped and the count is clamped to what remains: the snapshot has no +// freshness signal, so an unfiltered read would offer to consume credits that no +// longer exist. A snapshot claiming credits with no usable expiration left is +// treated as absent, which keeps the reset button gated on a live query. +const readCachedResetCredits = (account: Account): OpenAIQuotaUsage | null => { + const cached = account.extra?.codex_reset_credit_snapshot + if (!cached || typeof cached !== 'object' || Array.isArray(cached)) return null + + const { available_count: count, credits: rawCredits } = cached as { + available_count?: unknown + credits?: unknown + } + if (typeof count !== 'number' || !Number.isFinite(count)) return null + + const now = Date.now() + const credits: { expires_at?: string }[] = [] + if (Array.isArray(rawCredits)) { + for (const credit of rawCredits) { + if (!credit || typeof credit !== 'object') continue + const expiresAt = (credit as { expires_at?: unknown }).expires_at + if (typeof expiresAt !== 'string' || expiresAt.trim() === '') continue + const expiryTime = new Date(expiresAt).getTime() + // Unparsable timestamps are kept: they are already rendered verbatim and + // dropping them would silently understate the available count. + if (!Number.isNaN(expiryTime) && expiryTime <= now) continue + credits.push({ expires_at: expiresAt }) + } + } + const availableCount = Math.min(Math.max(count, 0), credits.length) + // A snapshot that claimed credits but has none left is no longer informative; + // report "unknown" so the operator re-queries instead of trusting it. + if (count > 0 && availableCount <= 0) return null + return { + fetched_at: 0, + rate_limit_reset_credits: { + available_count: availableCount, + credits + } + } +} + +cachedData.value = readCachedResetCredits(props.account) +data.value = cachedData.value + // 影子账号的额度查询会 resolve 到母账号,但影子本身不支持重置(后端返回 409); // 重置必须在母账号上进行。前端据此禁用影子的重置入口(外审 F6)。 const isShadow = computed(() => props.account.parent_account_id != null) const availableResetCount = computed(() => data.value?.rate_limit_reset_credits?.available_count ?? 0) +// Prefer the live payload and fall back to the persisted snapshot only when the +// live state is unknown, so the count and the expirations never come from two +// different generations of the same data. const resetCreditExpirations = computed(() => - (data.value?.rate_limit_reset_credits?.credits ?? []) + ((data.value ?? cachedData.value)?.rate_limit_reset_credits?.credits ?? []) .map((credit) => credit.expires_at?.trim() ?? '') .filter((expiresAt) => expiresAt.length > 0) .sort(compareResetCreditExpiry) @@ -265,9 +325,19 @@ const handleQuery = async () => { loading.value = true error.value = null resetMessage.value = null + resetWarning.value = null showResetCreditDetails.value = false try { - data.value = await queryOpenAIQuota(props.account.id) + const result = await refreshOpenAIQuota(props.account.id) + // The upstream read succeeded even when the snapshot write was rejected, so + // the live count is always adopted. Only the persisted view is left alone, + // which keeps the displayed expirations consistent with what is stored. + data.value = result + if (result.cache_persisted) { + cachedData.value = result + } else { + resetWarning.value = t('admin.accounts.openaiQuotaReset.refreshCachePersistFailed') + } } catch (e) { error.value = extractErrorMessage(e) } finally { @@ -294,15 +364,32 @@ const confirmReset = async () => { resetting.value = true error.value = null resetMessage.value = null + resetWarning.value = null try { const result: OpenAIQuotaResetResult = await resetOpenAIQuota(props.account.id) - // Refresh the reset-credit count so the badge reflects the consumed credit. - // handleQuery clears resetMessage on entry, so the success toast is set - // AFTER it resolves. - await handleQuery() - resetMessage.value = t('admin.accounts.openaiQuotaReset.resetSuccess', { - windows: result.windows_reset - }) + showResetCreditDetails.value = false + if (result.cache_refreshed && result.quota) { + data.value = result.quota + cachedData.value = result.quota + } else { + // A credit was consumed but the post-reset count could not be read back. + // Whatever we still hold is one generation stale, so report the count as + // unknown instead of letting a second consumption start from stale data. + data.value = null + } + if (result.account) emit('account-updated', result.account) + + if (result.warning_code === 'reset_credit_cache_refresh_failed') { + resetWarning.value = t('admin.accounts.openaiQuotaReset.resetCacheRefreshFailed') + } else if (result.warning_code === 'account_state_recovery_failed') { + resetWarning.value = t('admin.accounts.openaiQuotaReset.resetAccountRecoveryFailed') + } else if (result.warning_code === 'account_state_refresh_failed') { + resetWarning.value = t('admin.accounts.openaiQuotaReset.resetAccountRefreshFailed') + } else { + resetMessage.value = t('admin.accounts.openaiQuotaReset.resetSuccess', { + windows: result.windows_reset + }) + } } catch (e) { error.value = extractErrorMessage(e) } finally { @@ -314,9 +401,11 @@ watch( () => props.account.id, () => { // Account row may be reused across paginated lists; reset local state. - data.value = null + cachedData.value = readCachedResetCredits(props.account) + data.value = cachedData.value error.value = null resetMessage.value = null + resetWarning.value = null loading.value = false resetting.value = false showResetConfirm.value = false diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts index 70c32ee480..6fcc963145 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts @@ -512,6 +512,50 @@ describe('AccountUsageCell', () => { expect(wrapper.text()).toContain('5h|0|200') }) + it('OpenAI 重置响应更新账号行时不会额外拉取 usage', async () => { + getUsage.mockResolvedValue({ + five_hour: { + utilization: 0, + resets_at: null, + remaining_seconds: 0 + }, + seven_day: null + }) + const account = makeAccount({ + id: 2004, + platform: 'openai', + type: 'oauth', + updated_at: '2026-03-07T10:00:00Z', + extra: {} + }) + const wrapper = mount(AccountUsageCell, { + props: { account }, + global: { + stubs: { + UsageProgressBar: true, + AccountQuotaInfo: true, + OpenAIQuotaResetCell: { + props: ['account'], + emits: ['account-updated'], + template: '