Merge pull request #5183 from rick147/codex/feat-openai-reset-credit-cache

feat(openai): refresh reset credit state after quota reset
This commit is contained in:
Wesley Liddick
2026-08-03 16:01:10 +08:00
committed by GitHub
20 changed files with 1311 additions and 38 deletions
+1 -1
View File
@@ -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)
@@ -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)
@@ -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)
}
@@ -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)
}
}
@@ -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)
@@ -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",
+1
View File
@@ -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)
}
}
@@ -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 {
@@ -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 验证守卫「失败关闭」语义:
@@ -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")
}
@@ -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 {
+35 -5
View File
@@ -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<OpenAIQuotaUsage> {
const { data } = await apiClient.get<OpenAIQuotaUsage>(`/admin/openai/accounts/${id}/quota`)
export async function refreshOpenAIQuota(id: number): Promise<OpenAIQuotaRefreshResult> {
const { data } = await apiClient.post<OpenAIQuotaRefreshResult>(
`/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<OpenAIQuotaResetResult> {
const { data } = await apiClient.post<OpenAIQuotaResetResult>(`/admin/openai/accounts/${id}/reset-quota`)
const { data } = await apiClient.post<OpenAIQuotaResetResult>(
`/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,
@@ -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.
-->
<OpenAIQuotaResetCell :account="account">
<OpenAIQuotaResetCell :account="account" @account-updated="handleQuotaResetAccountUpdated">
<template #pre-actions>
<button
type="button"
@@ -186,7 +186,11 @@
<div v-else>
<div class="text-xs text-gray-400">-</div>
<!-- Always allow on-demand upstream quota query, even before local data exists. -->
<OpenAIQuotaResetCell :account="account" class="mt-1" />
<OpenAIQuotaResetCell
:account="account"
class="mt-1"
@account-updated="handleQuotaResetAccountUpdated"
/>
</div>
</template>
@@ -639,6 +643,8 @@ import OllamaCloudUsageCell from './OllamaCloudUsageCell.vue'
// Module-level cache shared across all AccountUsageCell instances
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
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<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null)
const suppressOpenAIUsageRefreshUntil = ref(0)
const rootRef = ref<HTMLElement | null>(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()
@@ -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()"
>
<svg
class="h-2.5 w-2.5"
@@ -111,6 +111,12 @@
>
{{ truncatedError }}
</div>
<div
v-else-if="resetWarning"
class="text-[10px] text-amber-600 dark:text-amber-400"
>
{{ resetWarning }}
</div>
<div
v-else-if="resetMessage"
class="text-[10px] text-emerald-600 dark:text-emerald-400"
@@ -136,7 +142,7 @@ import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account } from '@/types'
import {
queryOpenAIQuota,
refreshOpenAIQuota,
resetOpenAIQuota,
type OpenAIQuotaUsage,
type OpenAIQuotaResetResult
@@ -147,6 +153,10 @@ const props = defineProps<{
account: Account
}>()
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<string | null>(null)
const data = ref<OpenAIQuotaUsage | null>(null)
const cachedData = ref<OpenAIQuotaUsage | null>(null)
const resetMessage = ref<string | null>(null)
const resetWarning = ref<string | null>(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
@@ -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: '<button data-test="quota-reset-result" @click="$emit(\'account-updated\', { ...account, updated_at: \'2026-03-07T10:01:00Z\' })" />'
}
}
}
})
await flushPromises()
expect(getUsage).toHaveBeenCalledTimes(1)
await wrapper.get('[data-test="quota-reset-result"]').trigger('click')
const updatedAccount = wrapper.emitted<Account[]>('account-updated')?.[0]?.[0]
expect(updatedAccount?.updated_at).toBe('2026-03-07T10:01:00Z')
await wrapper.setProps({ account: updatedAccount as Account })
await flushPromises()
expect(getUsage).toHaveBeenCalledTimes(1)
})
it('OpenAI OAuth 已限额时显示 /usage API 返回的限额数据', async () => {
getUsage.mockResolvedValue({
five_hour: {
@@ -1,11 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import OpenAIQuotaResetCell from '../OpenAIQuotaResetCell.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import type { Account } from '@/types'
import { queryOpenAIQuota } from '@/api/admin/accounts'
import { refreshOpenAIQuota, resetOpenAIQuota } from '@/api/admin/accounts'
vi.mock('@/api/admin/accounts', () => ({
queryOpenAIQuota: vi.fn(),
refreshOpenAIQuota: vi.fn(),
resetOpenAIQuota: vi.fn(),
}))
@@ -20,6 +21,11 @@ vi.mock('vue-i18n', async () => {
}
})
// 缓存水合会丢弃已过期的重置卡,因此缓存类用例必须使用未来时间。
const FUTURE_EXPIRY_EARLY = '2099-07-03T04:05:06Z'
const FUTURE_EXPIRY_LATE = '2099-07-05T04:05:06Z'
const PAST_EXPIRY = '2020-07-03T04:05:06Z'
function makeAccount(overrides: Partial<Account>): Account {
return {
id: 1,
@@ -54,7 +60,8 @@ const resetButton = (wrapper: ReturnType<typeof mount>) =>
wrapper.findAll('button')[1]
beforeEach(() => {
vi.mocked(queryOpenAIQuota).mockReset()
vi.mocked(refreshOpenAIQuota).mockReset()
vi.mocked(resetOpenAIQuota).mockReset()
})
describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
@@ -78,8 +85,71 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
wrapper.unmount()
})
it('从账号 extra 缓存恢复重置卡次数和到期时间', () => {
const account = makeAccount({
parent_account_id: null,
extra: {
codex_reset_credit_snapshot: {
available_count: 2,
credits: [
{ expires_at: FUTURE_EXPIRY_LATE },
{ expires_at: FUTURE_EXPIRY_EARLY },
],
},
},
})
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
expect(refreshOpenAIQuota).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.count')
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
expect(wrapper.text()).toContain('+1')
expect(resetButton(wrapper).attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
it('缓存中的重置卡全部过期时视为未知,不点亮重置入口', () => {
const account = makeAccount({
parent_account_id: null,
extra: {
codex_reset_credit_snapshot: {
available_count: 1,
credits: [{ expires_at: PAST_EXPIRY }],
},
},
})
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.expiresAt:')
const btn = resetButton(wrapper)
expect(btn.attributes('disabled')).toBeDefined()
expect(btn.attributes('title')).toBe('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery')
wrapper.unmount()
})
it('缓存次数向未过期的明细数量收敛', () => {
const account = makeAccount({
parent_account_id: null,
extra: {
codex_reset_credit_snapshot: {
available_count: 3,
credits: [
{ expires_at: PAST_EXPIRY },
{ expires_at: FUTURE_EXPIRY_EARLY },
],
},
},
})
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.count1')
expect(wrapper.text()).not.toContain('+1')
expect(resetButton(wrapper).attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
it('查询后默认折叠为最早到期时间,点击 +N 展开完整列表', async () => {
vi.mocked(queryOpenAIQuota).mockResolvedValue({
vi.mocked(refreshOpenAIQuota).mockResolvedValue({
rate_limit_reset_credits: {
available_count: 3,
credits: [
@@ -89,6 +159,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
],
},
fetched_at: 1770000000,
cache_persisted: true,
})
const account = makeAccount({ parent_account_id: null })
@@ -97,7 +168,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
await wrapper.findAll('button')[0].trigger('click')
await flushPromises()
expect(queryOpenAIQuota).toHaveBeenCalledWith(1)
expect(refreshOpenAIQuota).toHaveBeenCalledWith(1)
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
expect(wrapper.text()).toContain('+2')
expect(wrapper.text()).not.toContain('not-a-date')
@@ -115,7 +186,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
})
it('只有一张重置卡时不显示展开按钮', async () => {
vi.mocked(queryOpenAIQuota).mockResolvedValue({
vi.mocked(refreshOpenAIQuota).mockResolvedValue({
rate_limit_reset_credits: {
available_count: 1,
credits: [
@@ -123,6 +194,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
],
},
fetched_at: 1770000000,
cache_persisted: true,
})
const account = makeAccount({ parent_account_id: null })
@@ -136,4 +208,139 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
wrapper.unmount()
})
// 快照写库被拒绝(上游未返回到期明细)不得吞掉这次成功的上游读取,
// 否则次数永远显示不出来、重置入口被永久禁用。
it('快照持久化失败时仍显示实时次数并给出警告', async () => {
vi.mocked(refreshOpenAIQuota).mockResolvedValue({
rate_limit_reset_credits: { available_count: 2 },
fetched_at: 1770000000,
cache_persisted: false,
})
const account = makeAccount({ parent_account_id: null })
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
await wrapper.findAll('button')[0].trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.count2')
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.refreshCachePersistFailed')
expect(resetButton(wrapper).attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
it('重置成功后直接使用响应中的最新缓存并回传恢复后的账号', async () => {
const recoveredAccount = makeAccount({
parent_account_id: null,
status: 'active',
error_message: null,
})
vi.mocked(resetOpenAIQuota).mockResolvedValue({
code: 'success',
windows_reset: 1,
cache_refreshed: true,
account_state_recovered: true,
quota: {
rate_limit_reset_credits: {
available_count: 0,
credits: [],
},
fetched_at: 1770000000,
},
account: recoveredAccount,
})
const account = makeAccount({
parent_account_id: null,
extra: {
codex_reset_credit_snapshot: {
available_count: 1,
credits: [{ expires_at: FUTURE_EXPIRY_EARLY }],
},
},
})
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
await resetButton(wrapper).trigger('click')
wrapper.findComponent(ConfirmDialog).vm.$emit('confirm')
await flushPromises()
expect(resetOpenAIQuota).toHaveBeenCalledWith(1)
expect(refreshOpenAIQuota).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.expiresAt:')
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetSuccess')
expect(wrapper.emitted('account-updated')).toEqual([[recoveredAccount]])
wrapper.unmount()
})
// 缓存回读失败不影响「账号状态已恢复」这一主目标:恢复后的账号行必须照常回传,
// 否则列表会继续显示已经不存在的限流状态。
it('缓存刷新失败时仍回传恢复后的账号并把次数标为未知', async () => {
const recoveredAccount = makeAccount({
parent_account_id: null,
status: 'active',
rate_limit_reset_at: null,
})
vi.mocked(resetOpenAIQuota).mockResolvedValue({
code: 'success',
windows_reset: 1,
cache_refreshed: false,
account_state_recovered: true,
warning_code: 'reset_credit_cache_refresh_failed',
account: recoveredAccount,
})
const account = makeAccount({
parent_account_id: null,
extra: {
codex_reset_credit_snapshot: {
available_count: 1,
credits: [{ expires_at: FUTURE_EXPIRY_EARLY }],
},
},
})
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
await resetButton(wrapper).trigger('click')
wrapper.findComponent(ConfirmDialog).vm.$emit('confirm')
await flushPromises()
expect(refreshOpenAIQuota).not.toHaveBeenCalled()
// 次数未知(隐藏)但仍展示已持久化的到期明细,重置入口保持禁用直到重新查询。
expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.count1')
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetCacheRefreshFailed')
expect(resetButton(wrapper).attributes('disabled')).toBeDefined()
expect(wrapper.emitted('account-updated')).toEqual([[recoveredAccount]])
wrapper.unmount()
})
it('账号状态恢复失败时停止后续步骤并提示手动恢复', async () => {
vi.mocked(resetOpenAIQuota).mockResolvedValue({
code: 'success',
windows_reset: 1,
cache_refreshed: false,
account_state_recovered: false,
warning_code: 'account_state_recovery_failed',
})
const account = makeAccount({
parent_account_id: null,
extra: {
codex_reset_credit_snapshot: {
available_count: 1,
credits: [{ expires_at: FUTURE_EXPIRY_EARLY }],
},
},
})
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
await resetButton(wrapper).trigger('click')
wrapper.findComponent(ConfirmDialog).vm.$emit('confirm')
await flushPromises()
expect(refreshOpenAIQuota).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetAccountRecoveryFailed')
expect(resetButton(wrapper).attributes('disabled')).toBeDefined()
expect(wrapper.emitted('account-updated')).toBeUndefined()
wrapper.unmount()
})
})
@@ -1374,7 +1374,11 @@ export default {
collapseExpirations: 'Collapse reset credit expirations',
expirationDetails: 'Reset credit expiration details',
noCreditsAvailable: 'No reset credits available',
resetSuccess: 'Reset {windows} window(s)',
resetSuccess: 'Reset {windows} window(s); credits and account state updated',
resetCacheRefreshFailed: 'The window was reset and account state recovered, but the reset-credit count could not be read back. Query it again.',
resetAccountRecoveryFailed: 'The window was reset, but account state recovery failed. Recover the account state manually.',
resetAccountRefreshFailed: 'The window, account state, and reset-credit cache were updated, but the latest account display could not be loaded.',
refreshCachePersistFailed: 'Showing the live count, but its expiration details were unavailable, so the cached details were kept.',
confirmTitle: 'Confirm Weekly Limit Reset',
confirmMessage: 'This will consume 1 reset credit to immediately restore the current window ({count} remaining). This action cannot be undone. Continue?'
},
@@ -429,7 +429,11 @@ export default {
collapseExpirations: '收起重置次数到期时间',
expirationDetails: '重置次数到期明细',
noCreditsAvailable: '没有可用的重置次数',
resetSuccess: '已重置 {windows} 个窗口',
resetSuccess: '已重置 {windows} 个窗口,次数和账号状态已更新',
resetCacheRefreshFailed: '窗口已重置、账号状态已恢复,但重置次数未能回读,请重新查询次数。',
resetAccountRecoveryFailed: '窗口已重置,但账号状态恢复失败,请手动恢复账号状态。',
resetAccountRefreshFailed: '窗口、账号状态和重置次数缓存已更新,但无法加载最新账号显示。',
refreshCachePersistFailed: '已显示实时次数,但到期明细获取失败,仍保留原有缓存明细。',
confirmTitle: '确认重置周限',
confirmMessage: '将消耗 1 次重置次数立即恢复当前窗口,剩余 {count} 次。此操作不可撤销,确定继续吗?'
},
+4
View File
@@ -1070,6 +1070,10 @@ export interface Account {
upstream_billing_probe_enabled?: boolean
upstream_billing_rate_sync_enabled?: boolean
upstream_billing_probe?: UpstreamBillingProbeSnapshot
codex_reset_credit_snapshot?: {
available_count?: number
credits?: { expires_at?: string }[]
}
} & Record<string, unknown>)
proxy_id: number | null
proxy_fallback_origin_id?: number | null
@@ -318,6 +318,7 @@
:today-stats="todayStatsByAccountId[String(row.id)] ?? null"
:today-stats-loading="todayStatsLoading"
:manual-refresh-token="usageManualRefreshToken"
@account-updated="handleAccountUpdated"
/>
</template>
<template #cell-proxy="{ row }">