mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
Merge pull request #7726 from william-drakemond/feat/claude-reset-redeem
feat(claude): 手动兑换 Claude 重置次数 / Manually redeem Claude limit resets
This commit is contained in:
@@ -288,7 +288,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
|
||||
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
|
||||
openCodeGoUsageService := service.ProvideOpenCodeGoUsageService(accountRepository, httpUpstream, settingService, leaderLockCache, db)
|
||||
claudeResetCreditService := service.NewClaudeResetCreditService(accountRepository, claudeTokenProvider, proxyRepository, settingService)
|
||||
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
|
||||
claudeResetCreditService := service.ProvideClaudeResetCreditService(accountRepository, claudeTokenProvider, proxyRepository, settingService, idempotencyCoordinator, leaderLockCache)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, claudeResetCreditService)
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
@@ -322,7 +323,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
batchImageDownloadService := service.NewBatchImageDownloadService(batchImageRepository, accountRepository, batchImageDownloadLimiter, configConfig)
|
||||
batchImageCleanupService := service.ProvideBatchImageCleanupService(batchImageRepository, accountRepository, configConfig)
|
||||
batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler)
|
||||
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
|
||||
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
|
||||
openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService, openAIQuotaAutoResetService)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
type claudeResetReader interface {
|
||||
Query(context.Context, int64) (*service.ClaudeResetCredits, error)
|
||||
Redeem(context.Context, int64, string) (*service.ClaudeResetOutcome, error)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) SetClaudeResetCreditService(s *service.ClaudeResetCreditService) {
|
||||
@@ -32,3 +33,24 @@ func (h *AccountHandler) ClaudeResetCredits(c *gin.Context) {
|
||||
}
|
||||
response.Success(c, status)
|
||||
}
|
||||
|
||||
// RedeemClaudeResetCredit consumes the upstream next reset credit. The request has
|
||||
// no body: the server picks the grant; the Idempotency-Key header identifies one
|
||||
// operator confirmation and replays its outcome.
|
||||
func (h *AccountHandler) RedeemClaudeResetCredit(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
if h.claudeResetCredits == nil {
|
||||
response.Error(c, 503, "Claude reset service unavailable")
|
||||
return
|
||||
}
|
||||
result, err := h.claudeResetCredits.Redeem(c.Request.Context(), id, c.GetHeader("Idempotency-Key"))
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type claudeResetHandlerStub struct {
|
||||
key string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *claudeResetHandlerStub) Query(context.Context, int64) (*service.ClaudeResetCredits, error) {
|
||||
return &service.ClaudeResetCredits{Credits: []service.ClaudeResetCredit{}}, nil
|
||||
}
|
||||
|
||||
func (s *claudeResetHandlerStub) Redeem(_ context.Context, _ int64, key string) (*service.ClaudeResetOutcome, error) {
|
||||
s.calls++
|
||||
s.key = key
|
||||
if key == "" {
|
||||
return nil, service.ErrIdempotencyKeyRequired
|
||||
}
|
||||
return &service.ClaudeResetOutcome{Outcome: "reset", Cleared: []string{"five_hour"}}, nil
|
||||
}
|
||||
|
||||
func TestClaudeResetHandlerRedeemContract(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
stub := &claudeResetHandlerStub{}
|
||||
h := &AccountHandler{claudeResetCredits: stub}
|
||||
r := gin.New()
|
||||
r.POST("/accounts/:id/claude/reset-credits/redeem", h.RedeemClaudeResetCredit)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/accounts/1/claude/reset-credits/redeem", nil)
|
||||
req.Header.Set("Idempotency-Key", "same-operation")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, "same-operation", stub.key)
|
||||
require.Contains(t, w.Body.String(), `"outcome":"reset"`)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/accounts/1/claude/reset-credits/redeem", nil))
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
require.Contains(t, w.Body.String(), "IDEMPOTENCY_KEY_REQUIRED")
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/accounts/abc/claude/reset-credits/redeem", nil))
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
require.Equal(t, 2, stub.calls)
|
||||
}
|
||||
@@ -368,6 +368,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu
|
||||
accounts.PUT("/opencode-go-usage/settings", h.Admin.Account.UpdateOpenCodeGoUsageSettings)
|
||||
accounts.GET("/:id", h.Admin.Account.GetByID)
|
||||
accounts.GET("/:id/claude/reset-credits", h.Admin.Account.ClaudeResetCredits)
|
||||
// Same protection as the Codex reset-quota route (admin auth, audit, compliance guard).
|
||||
accounts.POST("/:id/claude/reset-credits/redeem", h.Admin.Account.RedeemClaudeResetCredit)
|
||||
accounts.POST("", h.Admin.Account.Create)
|
||||
accounts.POST("/:id/duplicate", h.Admin.Account.Duplicate)
|
||||
accounts.POST("/check-mixed-channel", h.Admin.Account.CheckMixedChannel)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
adminhandler "github.com/Wei-Shaw/sub2api/internal/handler/admin"
|
||||
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The Claude redeem route consumes an irreversible credit just like the Codex
|
||||
// reset-quota route, so it must sit behind exactly the same middleware chain.
|
||||
func TestClaudeResetRedeemRouteMatchesCodexResetProtection(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
handlers := &handler.Handlers{Admin: &handler.AdminHandlers{Account: &adminhandler.AccountHandler{}, OpenAIOAuth: &adminhandler.OpenAIOAuthHandler{}}}
|
||||
chains := map[string][]string{}
|
||||
adminAuth := servermiddleware.AdminAuthMiddleware(func(c *gin.Context) {
|
||||
names := c.HandlerNames()
|
||||
chains[c.FullPath()] = names[:len(names)-1]
|
||||
if c.GetHeader("Authorization") == "" {
|
||||
servermiddleware.AbortWithError(c, http.StatusUnauthorized, "UNAUTHORIZED", "Authorization required")
|
||||
return
|
||||
}
|
||||
servermiddleware.AbortWithError(c, http.StatusForbidden, "FORBIDDEN", "Admin access required")
|
||||
})
|
||||
auditLog := servermiddleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() })
|
||||
stepUp := servermiddleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() })
|
||||
RegisterAdminRoutes(router.Group("/api/v1"), handlers, adminAuth, auditLog, stepUp, nil, nil)
|
||||
|
||||
codex := "/api/v1/admin/openai/accounts/1/reset-quota"
|
||||
claude := "/api/v1/admin/accounts/1/claude/reset-credits/redeem"
|
||||
for _, path := range []string{codex, claude} {
|
||||
for _, auth := range []string{"", "Bearer user-token"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
if auth != "" {
|
||||
request.Header.Set("Authorization", auth)
|
||||
}
|
||||
router.ServeHTTP(recorder, request)
|
||||
if auth == "" {
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code, path)
|
||||
} else {
|
||||
require.Equal(t, http.StatusForbidden, recorder.Code, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
codexChain := chains["/api/v1/admin/openai/accounts/:id/reset-quota"]
|
||||
claudeChain := chains["/api/v1/admin/accounts/:id/claude/reset-credits/redeem"]
|
||||
require.NotEmpty(t, codexChain)
|
||||
require.Equal(t, strings.Join(codexChain, "\n"), strings.Join(claudeChain, "\n"))
|
||||
}
|
||||
@@ -76,6 +76,10 @@ type ClaudeResetCreditService struct {
|
||||
settings *SettingService
|
||||
do func(*http.Request, string) (*http.Response, error)
|
||||
now func() time.Time
|
||||
|
||||
// Redemption only; both are mandatory and never fail open.
|
||||
idempotency *IdempotencyCoordinator
|
||||
locks LeaderLockCache
|
||||
}
|
||||
|
||||
func NewClaudeResetCreditService(accounts AccountRepository, tokens *ClaudeTokenProvider, proxies ProxyRepository, settings *SettingService) *ClaudeResetCreditService {
|
||||
@@ -139,6 +143,16 @@ func (s *ClaudeResetCreditService) query(ctx context.Context, id int64) (*Claude
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := s.fetchBlock(ctx, token, proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return projectClaudeResetCredits(block, s.now()), nil
|
||||
}
|
||||
|
||||
// fetchBlock returns the raw cedar_ember block (nil when absent). It carries grant
|
||||
// IDs, so it must never leave the service.
|
||||
func (s *ClaudeResetCreditService) fetchBlock(ctx context.Context, token, proxy string) (*claudeResetBlock, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeResetUsageURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -166,7 +180,7 @@ func (s *ClaudeResetCreditService) query(ctx context.Context, id int64) (*Claude
|
||||
return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset grants")
|
||||
}
|
||||
}
|
||||
return projectClaudeResetCredits(block, s.now()), nil
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func (s *ClaudeResetCreditService) Query(ctx context.Context, id int64) (*ClaudeResetCredits, error) {
|
||||
@@ -184,11 +198,11 @@ func projectClaudeResetCredits(b *claudeResetBlock, now time.Time) *ClaudeResetC
|
||||
}
|
||||
r.WeeklyResetsAt = b.WeeklyResetsAt
|
||||
for _, g := range b.Grants {
|
||||
if !claudeResetGrantIDPattern.MatchString(g.ID) || len(g.Clears) == 0 || g.ResetsLeft <= 0 || g.Paused || (g.StartsAt != nil && now.Before(*g.StartsAt)) || (g.EndsAt != nil && !now.Before(*g.EndsAt)) {
|
||||
if !claudeResetGrantHeld(g, now) {
|
||||
continue
|
||||
}
|
||||
requires := g.UseRequiresLimit == nil || *g.UseRequiresLimit
|
||||
usable := b.Eligible && g.UsableNow && g.ID == b.NextGrantID && (!requires || b.AtLimit) && len(g.Blocking) == 0 && (b.CooldownUntil == nil || !now.Before(*b.CooldownUntil))
|
||||
usable := claudeResetGrantRedeemable(b, g, now)
|
||||
used := map[string]float64{}
|
||||
for k, v := range g.PercentUsed {
|
||||
if v >= 0 && v <= 100 {
|
||||
@@ -202,3 +216,21 @@ func projectClaudeResetCredits(b *claudeResetBlock, now time.Time) *ClaudeResetC
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// claudeResetGrantHeld reports whether a grant is a live, well-formed credit.
|
||||
func claudeResetGrantHeld(g claudeResetGrant, now time.Time) bool {
|
||||
return claudeResetGrantIDPattern.MatchString(g.ID) && len(g.Clears) > 0 && g.ResetsLeft > 0 && !g.Paused &&
|
||||
(g.StartsAt == nil || !now.Before(*g.StartsAt)) && (g.EndsAt == nil || now.Before(*g.EndsAt))
|
||||
}
|
||||
|
||||
// claudeResetGrantRedeemable is the single gate shared by the query projection and
|
||||
// redemption: only the upstream next grant, usable now, unblocked, outside cooldown,
|
||||
// and with its at-limit requirement satisfied.
|
||||
func claudeResetGrantRedeemable(b *claudeResetBlock, g claudeResetGrant, now time.Time) bool {
|
||||
if b == nil || !claudeResetGrantHeld(g, now) {
|
||||
return false
|
||||
}
|
||||
requires := g.UseRequiresLimit == nil || *g.UseRequiresLimit
|
||||
return b.Eligible && g.UsableNow && g.ID == b.NextGrantID && (!requires || b.AtLimit) && len(g.Blocking) == 0 &&
|
||||
(b.CooldownUntil == nil || !now.Before(*b.CooldownUntil))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
package service
|
||||
|
||||
// Manual redemption of Claude native limit resets. The claim protocol (idempotent
|
||||
// operation, account + organization leases, durable organization fence) is ported
|
||||
// from upstream PR #7591 by korkin25, adapted so the server alone selects the grant.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
claudeResetOperationScope = "claude_reset_redeem"
|
||||
claudeResetFenceScope = "claude_reset_org_fence"
|
||||
claudeResetProfileURL = "https://api.anthropic.com/api/oauth/profile"
|
||||
claudeResetRedeemURLFmt = "https://api.anthropic.com/api/organizations/%s/reset_rate_limits"
|
||||
claudeResetLeaseTTL = 90 * time.Second
|
||||
claudeResetRecordTTL = 365 * 24 * time.Hour
|
||||
// An unconfirmed claim blocks every further redemption of the organization until
|
||||
// the upstream outcome has certainly settled; after that a fresh query is
|
||||
// authoritative again (a consumed credit shows up as a lower count or cooldown).
|
||||
claudeResetUnknownFenceTTL = 24 * time.Hour
|
||||
// An explicit, well-formed "unavailable" answer means nothing was claimed, so it
|
||||
// only fences briefly instead of locking the organization out for a day.
|
||||
claudeResetUnavailableFenceTTL = 15 * time.Minute
|
||||
claudeResetReasonUnavailable = "upstream_unavailable"
|
||||
|
||||
ClaudeResetOutcomeReset = "reset"
|
||||
ClaudeResetOutcomeAlreadyUsed = "already_used"
|
||||
ClaudeResetOutcomeNotLimited = "not_limited"
|
||||
ClaudeResetOutcomeCooldown = "cooldown"
|
||||
ClaudeResetOutcomeIneligible = "ineligible"
|
||||
ClaudeResetOutcomeUnknown = "unknown"
|
||||
)
|
||||
|
||||
// Only known reason codes and window names reach clients, so an upstream value can
|
||||
// never echo a grant ID or other identifier.
|
||||
var (
|
||||
claudeResetKnownReasons = map[string]bool{
|
||||
"no_grant": true, "unknown_grant": true, "not_next_grant": true, "grant_id_required": true,
|
||||
"tenure": true, "other_experiment": true, "stamp_indeterminate": true, "reset_unconfirmed": true,
|
||||
"authorization_rejected": true, "claim_unconfirmed": true, claudeResetReasonUnavailable: true,
|
||||
"result_persistence_failed": true,
|
||||
}
|
||||
claudeResetKnownWindows = map[string]bool{"five_hour": true, "seven_day": true, "seven_day_overage_included": true}
|
||||
)
|
||||
|
||||
// ClaudeResetOutcome is the sanitized redemption result. It never carries grant,
|
||||
// organization, or upstream request IDs.
|
||||
type ClaudeResetOutcome struct {
|
||||
Outcome string `json:"outcome"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Cleared []string `json:"cleared,omitempty"`
|
||||
CooldownUntil *time.Time `json:"cooldown_until,omitempty"`
|
||||
Credits *ClaudeResetCredits `json:"credits,omitempty"`
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
// claudeResetFence is stored in the idempotency table, one row per provider
|
||||
// organization, so duplicate local accounts share it and account edits cannot erase it.
|
||||
type claudeResetFence struct {
|
||||
Operation string `json:"operation"`
|
||||
Outcome string `json:"outcome"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// ConfigureRedemption enables Redeem. Without both stores Redeem refuses to run.
|
||||
func (s *ClaudeResetCreditService) ConfigureRedemption(idem *IdempotencyCoordinator, locks LeaderLockCache) {
|
||||
s.idempotency = idem
|
||||
s.locks = locks
|
||||
}
|
||||
|
||||
// Redeem consumes the upstream next reset grant of the account, if and only if a
|
||||
// fresh query shows it redeemable. key identifies one operator confirmation: the
|
||||
// same key replays the stored outcome and never sends a second claim.
|
||||
func (s *ClaudeResetCreditService) Redeem(ctx context.Context, id int64, key string) (*ClaudeResetOutcome, error) {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return nil, ErrIdempotencyKeyRequired
|
||||
}
|
||||
normalized, err := NormalizeIdempotencyKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.idempotency == nil || s.idempotency.repo == nil || s.locks == nil {
|
||||
return nil, ErrIdempotencyStoreUnavail
|
||||
}
|
||||
// Validate before entering the idempotency scope so a deleted or converted
|
||||
// account never replays. No token is stored anywhere.
|
||||
if _, _, _, err = s.account(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operation := HashIdempotencyKey(fmt.Sprintf("claude-reset:%d:%s", id, normalized))
|
||||
result, err := s.idempotency.Execute(ctx, IdempotencyExecuteOptions{
|
||||
Scope: claudeResetOperationScope, ActorScope: fmt.Sprintf("account:%d", id), Method: http.MethodPost,
|
||||
Route: "/admin/accounts/:id/claude/reset-credits/redeem", IdempotencyKey: operation,
|
||||
Payload: map[string]any{"account_id": id}, TTL: claudeResetRecordTTL, RequireKey: true, ExecutionTimeout: 60 * time.Second,
|
||||
}, func(exec context.Context) (any, error) { return s.redeemOnce(exec, id, operation) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := json.Marshal(result.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var outcome ClaudeResetOutcome
|
||||
if err = json.Unmarshal(raw, &outcome); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outcome.Replayed = outcome.Replayed || result.Replayed
|
||||
return &outcome, nil
|
||||
}
|
||||
|
||||
func (s *ClaudeResetCreditService) lease(ctx context.Context, key, owner string) (func(), error) {
|
||||
acquired, err := s.locks.TryAcquireLeaderLock(ctx, key, owner, claudeResetLeaseTTL)
|
||||
if err != nil {
|
||||
return nil, infraerrors.ServiceUnavailable("CLAUDE_RESET_LOCK_UNAVAILABLE", "reset coordination unavailable")
|
||||
}
|
||||
if !acquired {
|
||||
return nil, infraerrors.Conflict("CLAUDE_RESET_BUSY", "another reset is in progress")
|
||||
}
|
||||
return func() {
|
||||
release, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = s.locks.ReleaseLeaderLock(release, key, owner)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ClaudeResetCreditService) redeemOnce(ctx context.Context, id int64, operation string) (*ClaudeResetOutcome, error) {
|
||||
owner := uuid.NewString()
|
||||
release, err := s.lease(ctx, fmt.Sprintf("claude:reset-credit:account:%d", id), owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
_, token, proxy, err := s.account(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
org, err := s.organization(ctx, token, proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orgHash := HashIdempotencyKey("claude-org:" + org)
|
||||
releaseOrg, err := s.lease(ctx, "claude:reset-credit:organization:"+orgHash, owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer releaseOrg()
|
||||
|
||||
fence, err := s.loadFence(ctx, orgHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var prior claudeResetFence
|
||||
if fence.ResponseBody != nil {
|
||||
if json.Unmarshal([]byte(*fence.ResponseBody), &prior) != nil || prior.Operation == "" {
|
||||
return nil, infraerrors.Conflict("CLAUDE_RESET_UNRESOLVED", "previous reset requires reconciliation")
|
||||
}
|
||||
if prior.Operation == operation {
|
||||
// A crashed or interrupted attempt of this same confirmation: never resend.
|
||||
return &ClaudeResetOutcome{Outcome: prior.Outcome, Reason: prior.Reason, Replayed: true}, nil
|
||||
}
|
||||
if prior.Outcome == ClaudeResetOutcomeUnknown && prior.Reason == claudeResetReasonUnavailable {
|
||||
if s.now().Before(prior.At.Add(claudeResetUnavailableFenceTTL)) {
|
||||
return nil, infraerrors.Conflict("CLAUDE_RESET_UPSTREAM_UNAVAILABLE", "reset service was unavailable; retry after a while")
|
||||
}
|
||||
} else if prior.Outcome == ClaudeResetOutcomeUnknown && s.now().Before(prior.At.Add(claudeResetUnknownFenceTTL)) {
|
||||
return nil, infraerrors.Conflict("CLAUDE_RESET_UNRESOLVED", "previous reset outcome is unconfirmed; redemption is blocked for now")
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh eligibility check right before the irreversible call; the server alone
|
||||
// picks the grant, and only the upstream next grant can qualify.
|
||||
block, err := s.fetchBlock(ctx, token, proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var grant *claudeResetGrant
|
||||
if block != nil {
|
||||
for i := range block.Grants {
|
||||
if block.Grants[i].ID == block.NextGrantID && claudeResetGrantRedeemable(block, block.Grants[i], s.now()) {
|
||||
grant = &block.Grants[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if grant == nil {
|
||||
return nil, infraerrors.Conflict("CLAUDE_RESET_NOT_AVAILABLE", "no reset is redeemable right now")
|
||||
}
|
||||
|
||||
// Persist the unknown marker before sending: a crash after this point blocks
|
||||
// both a resend and another credit until the fence settles.
|
||||
marker := claudeResetFence{Operation: operation, Outcome: ClaudeResetOutcomeUnknown, Reason: "claim_unconfirmed", At: s.now().UTC()}
|
||||
if err = s.persistFence(ctx, fence.ID, marker); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outcome := s.claim(ctx, token, proxy, org, grant.ID, operation)
|
||||
marker.Outcome, marker.Reason = outcome.Outcome, outcome.Reason
|
||||
persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second)
|
||||
persistErr := s.persistFence(persistCtx, fence.ID, marker)
|
||||
cancel()
|
||||
if persistErr != nil {
|
||||
return &ClaudeResetOutcome{Outcome: ClaudeResetOutcomeUnknown, Reason: "result_persistence_failed"}, nil
|
||||
}
|
||||
if outcome.Outcome != ClaudeResetOutcomeUnknown {
|
||||
if fresh, e := s.fetchBlock(ctx, token, proxy); e == nil {
|
||||
outcome.Credits = projectClaudeResetCredits(fresh, s.now())
|
||||
}
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
func (s *ClaudeResetCreditService) loadFence(ctx context.Context, orgHash string) (*IdempotencyRecord, error) {
|
||||
repo := s.idempotency.repo
|
||||
fence, err := repo.GetByScopeAndKeyHash(ctx, claudeResetFenceScope, orgHash)
|
||||
if err != nil {
|
||||
return nil, ErrIdempotencyStoreUnavail
|
||||
}
|
||||
if fence != nil {
|
||||
return fence, nil
|
||||
}
|
||||
row := &IdempotencyRecord{Scope: claudeResetFenceScope, IdempotencyKeyHash: orgHash, RequestFingerprint: orgHash, Status: IdempotencyStatusProcessing, ExpiresAt: s.now().Add(claudeResetRecordTTL)}
|
||||
if _, err = repo.CreateProcessing(ctx, row); err != nil {
|
||||
return nil, ErrIdempotencyStoreUnavail
|
||||
}
|
||||
fence, err = repo.GetByScopeAndKeyHash(ctx, claudeResetFenceScope, orgHash)
|
||||
if err != nil || fence == nil {
|
||||
return nil, ErrIdempotencyStoreUnavail
|
||||
}
|
||||
return fence, nil
|
||||
}
|
||||
|
||||
func (s *ClaudeResetCreditService) persistFence(ctx context.Context, id int64, marker claudeResetFence) error {
|
||||
body, err := json.Marshal(marker)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.idempotency.repo.MarkSucceeded(ctx, id, http.StatusOK, string(body), s.now().Add(claudeResetRecordTTL)); err != nil {
|
||||
return ErrIdempotencyStoreUnavail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ClaudeResetCreditService) organization(ctx context.Context, token, proxy string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeResetProfileURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.headers(ctx, req, token)
|
||||
resp, err := s.do(req, proxy)
|
||||
if err != nil {
|
||||
return "", infraerrors.ServiceUnavailable("CLAUDE_RESET_PROFILE_FAILED", "OAuth profile unavailable")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var body struct {
|
||||
Organization struct {
|
||||
UUID string `json:"uuid"`
|
||||
} `json:"organization"`
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body) != nil {
|
||||
return "", infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_PROFILE_FAILED", "OAuth profile unavailable")
|
||||
}
|
||||
parsed, err := uuid.Parse(body.Organization.UUID)
|
||||
if err != nil || parsed == uuid.Nil {
|
||||
return "", infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_ORGANIZATION_INVALID", "OAuth organization unavailable")
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// claim sends the single irreversible request. Anything but a well-formed, known
|
||||
// result is reported as unknown so the fence blocks a blind retry.
|
||||
func (s *ClaudeResetCreditService) claim(ctx context.Context, token, proxy, org, grantID, operation string) *ClaudeResetOutcome {
|
||||
unknown := &ClaudeResetOutcome{Outcome: ClaudeResetOutcomeUnknown, Reason: "claim_unconfirmed"}
|
||||
// Deterministic per confirmation (64 hex chars, matches ^[A-Za-z0-9_-]{1,64}$).
|
||||
body, err := json.Marshal(map[string]string{"program": "cedar_ember", "grant_id": grantID, "request_id": operation})
|
||||
if err != nil {
|
||||
return unknown
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf(claudeResetRedeemURLFmt, org), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return unknown
|
||||
}
|
||||
s.headers(ctx, req, token)
|
||||
resp, err := s.do(req, proxy)
|
||||
if err != nil {
|
||||
return unknown
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return &ClaudeResetOutcome{Outcome: ClaudeResetOutcomeIneligible, Reason: "authorization_rejected"}
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return unknown
|
||||
}
|
||||
var result struct {
|
||||
Result string `json:"result"`
|
||||
Reason string `json:"reason"`
|
||||
Cleared []string `json:"cleared"`
|
||||
CooldownUntil *time.Time `json:"cooldown_until"`
|
||||
}
|
||||
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil {
|
||||
return unknown
|
||||
}
|
||||
reason := ""
|
||||
if claudeResetKnownReasons[result.Reason] {
|
||||
reason = result.Reason
|
||||
}
|
||||
if reason == "stamp_indeterminate" || reason == "reset_unconfirmed" {
|
||||
return unknown
|
||||
}
|
||||
switch result.Result {
|
||||
case ClaudeResetOutcomeReset, ClaudeResetOutcomeAlreadyUsed, ClaudeResetOutcomeNotLimited, ClaudeResetOutcomeCooldown, ClaudeResetOutcomeIneligible:
|
||||
out := &ClaudeResetOutcome{Outcome: result.Result, Reason: reason, CooldownUntil: result.CooldownUntil}
|
||||
for _, w := range result.Cleared {
|
||||
if claudeResetKnownWindows[w] {
|
||||
out.Cleared = append(out.Cleared, w)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case "unavailable":
|
||||
return &ClaudeResetOutcome{Outcome: ClaudeResetOutcomeUnknown, Reason: claudeResetReasonUnavailable}
|
||||
default:
|
||||
return unknown
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const redeemTestOrg = "11111111-1111-4111-8111-111111111111"
|
||||
|
||||
type redeemAccountsStub struct{}
|
||||
|
||||
func (redeemAccountsStub) GetByID(_ context.Context, id int64) (*Account, error) {
|
||||
return &Account{ID: id, Platform: PlatformAnthropic, Type: AccountTypeOAuth, Credentials: map[string]any{"scope": "user:profile"}}, nil
|
||||
}
|
||||
|
||||
type redeemLeaseStub struct {
|
||||
mu sync.Mutex
|
||||
keys map[string]bool
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (s *redeemLeaseStub) TryAcquireLeaderLock(_ context.Context, key, _ string, _ time.Duration) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.fail {
|
||||
return false, errors.New("unavailable")
|
||||
}
|
||||
if s.keys == nil {
|
||||
s.keys = map[string]bool{}
|
||||
}
|
||||
if s.keys[key] {
|
||||
return false, nil
|
||||
}
|
||||
s.keys[key] = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *redeemLeaseStub) ReleaseLeaderLock(_ context.Context, key, _ string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.keys, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
type redeemFake struct {
|
||||
mu sync.Mutex
|
||||
status string // usage body
|
||||
claim string // POST body, or "network-error"
|
||||
claimHTTP int
|
||||
posts []map[string]string
|
||||
gate chan struct{} // when set, POST blocks until closed
|
||||
entered chan struct{}
|
||||
}
|
||||
|
||||
func (f *redeemFake) postCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.posts)
|
||||
}
|
||||
|
||||
const redeemableStatus = `{"cedar_ember":{"eligible":true,"at_limit":true,"next_grant_id":"grant_next","grants":[` +
|
||||
`{"id":"grant_other","resets_left":1,"usable_now":true,"use_requires_limit":false,"clears":["five_hour"]},` +
|
||||
`{"id":"grant_next","resets_left":2,"usable_now":true,"use_requires_limit":true,"clears":["five_hour","seven_day"]}]}}`
|
||||
|
||||
func newRedeemService(t *testing.T, f *redeemFake) (*ClaudeResetCreditService, *inMemoryIdempotencyRepo, *redeemLeaseStub) {
|
||||
t.Helper()
|
||||
repo := newInMemoryIdempotencyRepo()
|
||||
cfg := DefaultIdempotencyConfig()
|
||||
cfg.FailedRetryBackoff = 0
|
||||
locks := &redeemLeaseStub{}
|
||||
s := &ClaudeResetCreditService{accounts: redeemAccountsStub{}, tokens: resetTokenStub{}, now: time.Now}
|
||||
s.ConfigureRedemption(NewIdempotencyCoordinator(repo, cfg), locks)
|
||||
if f.status == "" {
|
||||
f.status = redeemableStatus
|
||||
}
|
||||
s.do = func(r *http.Request, _ string) (*http.Response, error) {
|
||||
require.Equal(t, "Bearer synthetic-token", r.Header.Get("Authorization"))
|
||||
code := http.StatusOK
|
||||
var body string
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.String() == claudeResetProfileURL:
|
||||
body = `{"organization":{"uuid":"` + redeemTestOrg + `"}}`
|
||||
case r.Method == http.MethodGet && r.URL.String() == claudeResetUsageURL:
|
||||
f.mu.Lock()
|
||||
body = f.status
|
||||
f.mu.Unlock()
|
||||
case r.Method == http.MethodPost:
|
||||
require.Equal(t, "https://api.anthropic.com/api/organizations/"+redeemTestOrg+"/reset_rate_limits", r.URL.String())
|
||||
var payload map[string]string
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
|
||||
require.Regexp(t, `^[A-Za-z0-9_-]{1,64}$`, payload["request_id"])
|
||||
f.mu.Lock()
|
||||
f.posts = append(f.posts, payload)
|
||||
f.mu.Unlock()
|
||||
if f.entered != nil {
|
||||
f.entered <- struct{}{}
|
||||
}
|
||||
if f.gate != nil {
|
||||
<-f.gate
|
||||
}
|
||||
if f.claim == "network-error" {
|
||||
return nil, errors.New("timeout private upstream details")
|
||||
}
|
||||
body = f.claim
|
||||
if f.claimHTTP != 0 {
|
||||
code = f.claimHTTP
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected upstream request %s %s", r.Method, r.URL)
|
||||
}
|
||||
return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||
}
|
||||
return s, repo, locks
|
||||
}
|
||||
|
||||
func TestClaudeResetRedeemHappyPathServerPicksNextGrant(t *testing.T) {
|
||||
f := &redeemFake{claim: `{"result":"reset","resets_left":1,"cleared":["five_hour","seven_day"]}`}
|
||||
s, _, _ := newRedeemService(t, f)
|
||||
out, err := s.Redeem(context.Background(), 1, "op-1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ClaudeResetOutcomeReset, out.Outcome)
|
||||
require.False(t, out.Replayed)
|
||||
require.Equal(t, []string{"five_hour", "seven_day"}, out.Cleared)
|
||||
require.NotNil(t, out.Credits)
|
||||
require.Equal(t, 1, f.postCount())
|
||||
require.Equal(t, "grant_next", f.posts[0]["grant_id"])
|
||||
require.Equal(t, "cedar_ember", f.posts[0]["program"])
|
||||
|
||||
raw, err := json.Marshal(out)
|
||||
require.NoError(t, err)
|
||||
for _, secret := range []string{"grant_next", "grant_other", redeemTestOrg, "synthetic-token", f.posts[0]["request_id"], `"id"`} {
|
||||
require.NotContains(t, string(raw), secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeResetRedeemNonRedeemableSendsNoPost(t *testing.T) {
|
||||
future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
|
||||
cases := map[string]string{
|
||||
"not at limit": strings.Replace(redeemableStatus, `"at_limit":true`, `"at_limit":false`, 1),
|
||||
"ineligible": strings.Replace(redeemableStatus, `"eligible":true`, `"eligible":false`, 1),
|
||||
"cooldown": strings.Replace(redeemableStatus, `"eligible":true,`, `"eligible":true,"cooldown_until":"`+future+`",`, 1),
|
||||
"no next grant": strings.Replace(redeemableStatus, `"next_grant_id":"grant_next"`, `"next_grant_id":"missing"`, 1),
|
||||
"blocked": strings.Replace(redeemableStatus, `"clears":["five_hour","seven_day"]`, `"clears":["five_hour","seven_day"],"blocking":["x"]`, 1),
|
||||
"paused": strings.Replace(redeemableStatus, `"id":"grant_next",`, `"id":"grant_next","paused":true,`, 1),
|
||||
"not usable now": strings.Replace(redeemableStatus, `"id":"grant_next","resets_left":2,"usable_now":true`, `"id":"grant_next","resets_left":2,"usable_now":false`, 1),
|
||||
"expired": strings.Replace(redeemableStatus, `"id":"grant_next",`, `"id":"grant_next","ends_at":"2000-01-01T00:00:00Z",`, 1),
|
||||
"not started": strings.Replace(redeemableStatus, `"id":"grant_next",`, `"id":"grant_next","starts_at":"`+future+`",`, 1),
|
||||
"no resets left": strings.Replace(redeemableStatus, `"id":"grant_next","resets_left":2`, `"id":"grant_next","resets_left":0`, 1),
|
||||
"no program": `{"cedar_ember":null}`,
|
||||
}
|
||||
for name, status := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
require.NotEqual(t, redeemableStatus, status)
|
||||
f := &redeemFake{status: status, claim: `{"result":"reset"}`}
|
||||
s, _, _ := newRedeemService(t, f)
|
||||
_, err := s.Redeem(context.Background(), 1, "op-1")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "CLAUDE_RESET_NOT_AVAILABLE", infraerrors.Reason(err))
|
||||
require.Zero(t, f.postCount())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeResetRedeemRequiresKeyAndStores(t *testing.T) {
|
||||
f := &redeemFake{claim: `{"result":"reset"}`}
|
||||
s, _, locks := newRedeemService(t, f)
|
||||
_, err := s.Redeem(context.Background(), 1, " ")
|
||||
require.ErrorIs(t, err, ErrIdempotencyKeyRequired)
|
||||
locks.fail = true
|
||||
_, err = s.Redeem(context.Background(), 1, "op-1")
|
||||
require.Error(t, err)
|
||||
unconfigured := &ClaudeResetCreditService{accounts: redeemAccountsStub{}, tokens: resetTokenStub{}, now: time.Now}
|
||||
_, err = unconfigured.Redeem(context.Background(), 1, "op-2")
|
||||
require.ErrorIs(t, err, ErrIdempotencyStoreUnavail)
|
||||
require.Zero(t, f.postCount())
|
||||
}
|
||||
|
||||
func TestClaudeResetRedeemSameKeyReplaysWithoutSecondPost(t *testing.T) {
|
||||
f := &redeemFake{claim: `{"result":"reset"}`}
|
||||
s, _, _ := newRedeemService(t, f)
|
||||
first, err := s.Redeem(context.Background(), 1, "op-1")
|
||||
require.NoError(t, err)
|
||||
again, err := s.Redeem(context.Background(), 1, "op-1")
|
||||
require.NoError(t, err)
|
||||
require.True(t, again.Replayed)
|
||||
require.Equal(t, first.Outcome, again.Outcome)
|
||||
require.Equal(t, 1, f.postCount())
|
||||
|
||||
// A new confirmation is a new operation with a new upstream request_id.
|
||||
_, err = s.Redeem(context.Background(), 1, "op-2")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, f.postCount())
|
||||
require.NotEqual(t, f.posts[0]["request_id"], f.posts[1]["request_id"])
|
||||
}
|
||||
|
||||
func TestClaudeResetRedeemDuplicateOrgAccountsConcurrentOnlyOnePost(t *testing.T) {
|
||||
f := &redeemFake{claim: `{"result":"reset"}`, gate: make(chan struct{}), entered: make(chan struct{}, 2)}
|
||||
s, _, _ := newRedeemService(t, f)
|
||||
type res struct {
|
||||
out *ClaudeResetOutcome
|
||||
err error
|
||||
}
|
||||
first := make(chan res, 1)
|
||||
go func() {
|
||||
out, err := s.Redeem(context.Background(), 1, "account-one")
|
||||
first <- res{out, err}
|
||||
}()
|
||||
<-f.entered // account 1 holds the organization lease and is mid-claim
|
||||
_, err := s.Redeem(context.Background(), 2, "account-two")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "CLAUDE_RESET_BUSY", infraerrors.Reason(err))
|
||||
close(f.gate)
|
||||
r := <-first
|
||||
require.NoError(t, r.err)
|
||||
require.Equal(t, ClaudeResetOutcomeReset, r.out.Outcome)
|
||||
require.Equal(t, 1, f.postCount())
|
||||
}
|
||||
|
||||
func TestClaudeResetRedeemUnknownOutcomeFencesOrganization(t *testing.T) {
|
||||
for _, claim := range []string{"network-error", `{broken`, `{"result":"weird"}`, `{"result":"unavailable","reason":"stamp_indeterminate"}`, `{"result":"reset","reason":"reset_unconfirmed"}`, "http-500"} {
|
||||
t.Run(claim, func(t *testing.T) {
|
||||
f := &redeemFake{claim: claim}
|
||||
if claim == "http-500" {
|
||||
f.claim, f.claimHTTP = `{"result":"reset"}`, http.StatusInternalServerError
|
||||
}
|
||||
s, _, _ := newRedeemService(t, f)
|
||||
out, err := s.Redeem(context.Background(), 1, "op-1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ClaudeResetOutcomeUnknown, out.Outcome)
|
||||
require.Nil(t, out.Credits)
|
||||
require.NotContains(t, out.Reason, "private")
|
||||
|
||||
// Same confirmation replays, no resend.
|
||||
again, err := s.Redeem(context.Background(), 1, "op-1")
|
||||
require.NoError(t, err)
|
||||
require.True(t, again.Replayed)
|
||||
require.Equal(t, ClaudeResetOutcomeUnknown, again.Outcome)
|
||||
|
||||
// Simulated restart (fresh leases); a new confirmation on this or a
|
||||
// duplicate account of the same organization is blocked by the fence.
|
||||
s.locks = &redeemLeaseStub{}
|
||||
for _, id := range []int64{1, 2} {
|
||||
_, err = s.Redeem(context.Background(), id, "op-new")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "CLAUDE_RESET_UNRESOLVED", infraerrors.Reason(err))
|
||||
}
|
||||
require.Equal(t, 1, f.postCount())
|
||||
|
||||
// Still blocked past the short unavailable fence.
|
||||
s.now = func() time.Time { return time.Now().Add(claudeResetUnavailableFenceTTL + time.Minute) }
|
||||
_, err = s.Redeem(context.Background(), 1, "op-new")
|
||||
require.Equal(t, "CLAUDE_RESET_UNRESOLVED", infraerrors.Reason(err))
|
||||
|
||||
// Once the fence has settled, a fresh query is authoritative again.
|
||||
s.now = func() time.Time { return time.Now().Add(claudeResetUnknownFenceTTL + time.Minute) }
|
||||
f.claim, f.claimHTTP = `{"result":"reset"}`, 0
|
||||
out, err = s.Redeem(context.Background(), 1, "op-later")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ClaudeResetOutcomeReset, out.Outcome)
|
||||
require.Equal(t, 2, f.postCount())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeResetRedeemCrashAfterMarkerNeverResends(t *testing.T) {
|
||||
f := &redeemFake{claim: `{"result":"reset"}`}
|
||||
s, _, _ := newRedeemService(t, f)
|
||||
// Simulate a process that persisted the marker for op-1 and died mid-claim.
|
||||
orgHash := HashIdempotencyKey("claude-org:" + redeemTestOrg)
|
||||
fence, err := s.loadFence(context.Background(), orgHash)
|
||||
require.NoError(t, err)
|
||||
op := HashIdempotencyKey("claude-reset:1:op-1")
|
||||
require.NoError(t, s.persistFence(context.Background(), fence.ID, claudeResetFence{Operation: op, Outcome: ClaudeResetOutcomeUnknown, Reason: "claim_unconfirmed", At: time.Now()}))
|
||||
out, err := s.Redeem(context.Background(), 1, "op-1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ClaudeResetOutcomeUnknown, out.Outcome)
|
||||
require.True(t, out.Replayed)
|
||||
require.Zero(t, f.postCount())
|
||||
}
|
||||
|
||||
func TestClaudeResetRedeemMapsUpstreamResults(t *testing.T) {
|
||||
cases := []struct {
|
||||
claim, outcome string
|
||||
http int
|
||||
}{
|
||||
{`{"result":"reset"}`, ClaudeResetOutcomeReset, 0},
|
||||
{`{"result":"already_used"}`, ClaudeResetOutcomeAlreadyUsed, 0},
|
||||
{`{"result":"not_limited"}`, ClaudeResetOutcomeNotLimited, 0},
|
||||
{`{"result":"cooldown","cooldown_until":"2099-01-01T00:00:00Z"}`, ClaudeResetOutcomeCooldown, 0},
|
||||
{`{"result":"ineligible","reason":"tenure"}`, ClaudeResetOutcomeIneligible, 0},
|
||||
{`{"result":"unavailable","reason":"stamp_indeterminate"}`, ClaudeResetOutcomeUnknown, 0},
|
||||
{`{}`, ClaudeResetOutcomeIneligible, http.StatusForbidden},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.claim, func(t *testing.T) {
|
||||
f := &redeemFake{claim: tc.claim, claimHTTP: tc.http}
|
||||
s, _, _ := newRedeemService(t, f)
|
||||
out, err := s.Redeem(context.Background(), 1, "op-1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.outcome, out.Outcome)
|
||||
if tc.outcome == ClaudeResetOutcomeCooldown {
|
||||
require.NotNil(t, out.CooldownUntil)
|
||||
}
|
||||
if tc.outcome == ClaudeResetOutcomeIneligible && tc.http == 0 {
|
||||
require.Equal(t, "tenure", out.Reason)
|
||||
}
|
||||
// Definite outcomes never block a later confirmation.
|
||||
if tc.outcome != ClaudeResetOutcomeUnknown {
|
||||
_, err = s.Redeem(context.Background(), 1, "op-2")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, f.postCount())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeResetRedeemSanitizesUpstreamReason(t *testing.T) {
|
||||
f := &redeemFake{claim: `{"result":"ineligible","reason":"Bearer synthetic-token leaked <script>","cleared":["five_hour","<bad>"]}`}
|
||||
s, _, _ := newRedeemService(t, f)
|
||||
out, err := s.Redeem(context.Background(), 1, "op-1")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, out.Reason)
|
||||
require.Equal(t, []string{"five_hour"}, out.Cleared)
|
||||
}
|
||||
|
||||
func TestClaudeResetRedeemExplicitUnavailableFencesBriefly(t *testing.T) {
|
||||
f := &redeemFake{claim: `{"result":"unavailable","reason":"grant_next"}`}
|
||||
s, _, _ := newRedeemService(t, f)
|
||||
out, err := s.Redeem(context.Background(), 1, "op-1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ClaudeResetOutcomeUnknown, out.Outcome)
|
||||
require.Equal(t, claudeResetReasonUnavailable, out.Reason)
|
||||
|
||||
s.locks = &redeemLeaseStub{}
|
||||
_, err = s.Redeem(context.Background(), 2, "op-new")
|
||||
require.Equal(t, "CLAUDE_RESET_UPSTREAM_UNAVAILABLE", infraerrors.Reason(err))
|
||||
require.Equal(t, 1, f.postCount())
|
||||
|
||||
s.now = func() time.Time { return time.Now().Add(claudeResetUnavailableFenceTTL + time.Minute) }
|
||||
f.claim = `{"result":"reset"}`
|
||||
out, err = s.Redeem(context.Background(), 1, "op-later")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ClaudeResetOutcomeReset, out.Outcome)
|
||||
require.Equal(t, 2, f.postCount())
|
||||
}
|
||||
|
||||
func TestClaudeResetRedeemNeverEchoesGrantIDs(t *testing.T) {
|
||||
f := &redeemFake{claim: `{"result":"reset","reason":"grant_next","cleared":["five_hour","grant_next","launch","seven_day_overage_included"]}`}
|
||||
s, _, _ := newRedeemService(t, f)
|
||||
out, err := s.Redeem(context.Background(), 1, "op-1")
|
||||
require.NoError(t, err)
|
||||
raw, err := json.Marshal(out)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(raw), "grant_next")
|
||||
require.NotContains(t, string(raw), "launch")
|
||||
require.Empty(t, out.Reason)
|
||||
require.Equal(t, []string{"five_hour", "seven_day_overage_included"}, out.Cleared)
|
||||
}
|
||||
@@ -880,7 +880,7 @@ var ProviderSet = wire.NewSet(
|
||||
ProvideBatchImageWorkerRuntime,
|
||||
wire.Bind(new(AccountRuntimeBlocker), new(*OpenAIGatewayService)),
|
||||
NewOAuthService,
|
||||
NewClaudeResetCreditService,
|
||||
ProvideClaudeResetCreditService,
|
||||
ProvideOpenAIOAuthService,
|
||||
ProvideGrokOAuthService,
|
||||
wire.Bind(new(GrokOAuthTokenService), new(*GrokOAuthService)),
|
||||
@@ -1071,3 +1071,11 @@ func ProvideChannelMonitorV2Aggregator(repo ChannelMonitorV2Repository, db *sql.
|
||||
aggregator.Start()
|
||||
return aggregator
|
||||
}
|
||||
|
||||
// ProvideClaudeResetCreditService wires the Claude reset query and, with the
|
||||
// idempotency store and Redis leases, manual redemption.
|
||||
func ProvideClaudeResetCreditService(accounts AccountRepository, tokens *ClaudeTokenProvider, proxies ProxyRepository, settings *SettingService, idem *IdempotencyCoordinator, locks LeaderLockCache) *ClaudeResetCreditService {
|
||||
s := NewClaudeResetCreditService(accounts, tokens, proxies, settings)
|
||||
s.ConfigureRedemption(idem, locks)
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -25,3 +25,23 @@ export async function getClaudeResetCredits(id: number): Promise<ClaudeResetCred
|
||||
const { data } = await apiClient.get<ClaudeResetCredits>(`/admin/accounts/${id}/claude/reset-credits`)
|
||||
return data
|
||||
}
|
||||
|
||||
export type ClaudeResetOutcomeKind = 'reset' | 'already_used' | 'not_limited' | 'cooldown' | 'ineligible' | 'unknown'
|
||||
|
||||
export interface ClaudeResetOutcome {
|
||||
outcome: ClaudeResetOutcomeKind
|
||||
reason?: string
|
||||
cleared?: string[]
|
||||
cooldown_until?: string
|
||||
credits?: ClaudeResetCredits
|
||||
replayed: boolean
|
||||
}
|
||||
|
||||
// Consumes one reset credit (irreversible). The server picks the grant; the key
|
||||
// identifies one operator confirmation and must be reused when retrying it.
|
||||
export async function redeemClaudeResetCredit(id: number, idempotencyKey: string): Promise<ClaudeResetOutcome> {
|
||||
const { data } = await apiClient.post<ClaudeResetOutcome>(`/admin/accounts/${id}/claude/reset-credits/redeem`, undefined, {
|
||||
headers: { 'Idempotency-Key': idempotencyKey }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
while usage is still loading survives the usage response. The local query
|
||||
button shares its row once usage data exists.
|
||||
-->
|
||||
<ClaudeResetCreditsCell :account="account" class="mt-1">
|
||||
<ClaudeResetCreditsCell :account="account" class="mt-1" @redeemed="loadActiveUsage">
|
||||
<template v-if="usageInfo" #pre-actions>
|
||||
<span
|
||||
v-if="usageInfo.source === 'passive'"
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
Same action row layout as OpenAIQuotaResetCell: the parent's local
|
||||
"查询" button is passed in via #pre-actions so related buttons share one
|
||||
row. The reset count only shows for Anthropic OAuth accounts; the slot always
|
||||
renders. This cell is read-only — it never redeems a reset.
|
||||
renders. The count button is read-only; only the orange reset button,
|
||||
after a query shows a redeemable credit and the operator confirms,
|
||||
consumes one reset.
|
||||
-->
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<slot name="pre-actions" />
|
||||
@@ -14,7 +16,7 @@
|
||||
type="button"
|
||||
data-testid="claude-reset-count"
|
||||
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"
|
||||
:disabled="loading || redeeming"
|
||||
:title="countButtonTitle"
|
||||
@click="refresh"
|
||||
>
|
||||
@@ -34,6 +36,32 @@
|
||||
</svg>
|
||||
{{ t('admin.accounts.claudeResetCredits.count') }}<span v-if="status" class="ml-0.5 tabular-nums">{{ totalResets }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="visible"
|
||||
type="button"
|
||||
data-testid="claude-reset-redeem"
|
||||
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-orange-600 transition-colors hover:bg-orange-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-orange-400 dark:hover:bg-orange-900/30"
|
||||
:disabled="redeeming || loading || !canRedeem"
|
||||
:title="redeemButtonTitle"
|
||||
@click="openRedeemConfirm"
|
||||
>
|
||||
<svg
|
||||
class="h-2.5 w-2.5"
|
||||
:class="{ 'animate-spin': redeeming }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 12a8 8 0 11-2.343-5.657L20 8m0 0V4m0 4h-4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.claudeResetCredits.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="visible && status && (primaryCredit || !status.eligible || cooldownActive)" class="flex flex-wrap items-center gap-1">
|
||||
@@ -63,6 +91,28 @@
|
||||
<div v-if="visible && error" role="alert" class="text-[10px] text-red-600 dark:text-red-400">
|
||||
{{ t('admin.accounts.claudeResetCredits.error') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="visible && redeemFeedback"
|
||||
data-testid="claude-reset-feedback"
|
||||
:role="redeemFeedback.kind === 'success' ? 'status' : 'alert'"
|
||||
class="text-[10px]"
|
||||
:class="feedbackClass"
|
||||
:title="redeemFeedback.text"
|
||||
>
|
||||
{{ redeemFeedback.text }}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-if="visible"
|
||||
:show="showRedeemConfirm"
|
||||
:title="t('admin.accounts.claudeResetCredits.confirmTitle')"
|
||||
:message="confirmMessage"
|
||||
:confirm-text="t('admin.accounts.claudeResetCredits.reset')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
danger
|
||||
@confirm="confirmRedeem"
|
||||
@cancel="showRedeemConfirm = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -70,18 +120,41 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Account } from '@/types'
|
||||
import { getClaudeResetCredits, type ClaudeResetCredits } from '@/api/admin/claudeResetCredits'
|
||||
import {
|
||||
getClaudeResetCredits,
|
||||
redeemClaudeResetCredit,
|
||||
type ClaudeResetCredits,
|
||||
type ClaudeResetOutcome
|
||||
} from '@/api/admin/claudeResetCredits'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
|
||||
const props = defineProps<{ account: Account }>()
|
||||
// Fired after a redemption attempt so the parent can refresh the usage row.
|
||||
const emit = defineEmits<{ redeemed: [outcome: ClaudeResetOutcome] }>()
|
||||
const { t } = useI18n()
|
||||
const status = ref<ClaudeResetCredits | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
const redeeming = ref(false)
|
||||
const showRedeemConfirm = ref(false)
|
||||
const redeemFeedback = ref<{ kind: 'success' | 'warning' | 'error'; text: string } | null>(null)
|
||||
// One key per operator confirmation. It survives a failed request so retrying the
|
||||
// same confirmation replays server-side instead of claiming a second reset.
|
||||
let pendingKey: string | null = null
|
||||
let generation = 0
|
||||
|
||||
const visible = computed(() => props.account.platform === 'anthropic' && props.account.type === 'oauth')
|
||||
|
||||
watch(() => [props.account.id, props.account.platform, props.account.type], () => { generation++; status.value = null; loading.value = false; error.value = false })
|
||||
watch(() => [props.account.id, props.account.platform, props.account.type], () => {
|
||||
generation++
|
||||
status.value = null
|
||||
loading.value = false
|
||||
error.value = false
|
||||
redeeming.value = false
|
||||
showRedeemConfirm.value = false
|
||||
redeemFeedback.value = null
|
||||
pendingKey = null
|
||||
})
|
||||
|
||||
// 与 OpenAIQuotaResetCell 的到期时间格式保持一致
|
||||
const formatTime = (value: string, style: 'short' | 'full'): string => {
|
||||
@@ -118,11 +191,21 @@ const creditTitle = computed(() => {
|
||||
if (!credit) return ''
|
||||
const lines = [credit.label]
|
||||
if (credit.expires_at) lines.push(t('admin.accounts.claudeResetCredits.expiresAtFull', { time: formatTime(credit.expires_at, 'full') }))
|
||||
if (credit.clears.length) lines.push(t('admin.accounts.claudeResetCredits.clears', { windows: credit.clears.join(', ') }))
|
||||
if (credit.clears.length) lines.push(t('admin.accounts.claudeResetCredits.clears', { windows: windowLabels(credit.clears) }))
|
||||
if (credit.use_requires_limit) lines.push(t('admin.accounts.claudeResetCredits.requiresLimit'))
|
||||
return lines.join('\n')
|
||||
})
|
||||
|
||||
// 已知窗口显示为可读名称,未知窗口原样显示
|
||||
const windowKeys: Record<string, string> = {
|
||||
five_hour: 'fiveHour',
|
||||
seven_day: 'sevenDay',
|
||||
seven_day_overage_included: 'sevenDayOverage'
|
||||
}
|
||||
function windowLabels(windows?: string[]): string {
|
||||
return (windows ?? []).map(w => windowKeys[w] ? t(`admin.accounts.claudeResetCredits.windows.${windowKeys[w]}`) : w).join(', ')
|
||||
}
|
||||
|
||||
const countButtonTitle = computed(() => {
|
||||
if (!status.value) return t('admin.accounts.claudeResetCredits.countTooltipLoad')
|
||||
return [
|
||||
@@ -131,8 +214,109 @@ const countButtonTitle = computed(() => {
|
||||
].join('\n')
|
||||
})
|
||||
|
||||
const canRedeem = computed(() => (status.value?.available_count ?? 0) > 0)
|
||||
|
||||
const redeemButtonTitle = computed(() => {
|
||||
if (!status.value) return t('admin.accounts.claudeResetCredits.resetTooltipNeedQuery')
|
||||
if (!canRedeem.value) return t('admin.accounts.claudeResetCredits.resetTooltipNone')
|
||||
return t('admin.accounts.claudeResetCredits.resetTooltipReady')
|
||||
})
|
||||
|
||||
const confirmMessage = computed(() => {
|
||||
const next = status.value?.credits.find(c => c.redeemable)
|
||||
return t('admin.accounts.claudeResetCredits.confirmMessage', {
|
||||
windows: windowLabels(next?.clears) || '—',
|
||||
count: Math.max(totalResets.value - 1, 0)
|
||||
})
|
||||
})
|
||||
|
||||
const feedbackClass = computed(() => {
|
||||
switch (redeemFeedback.value?.kind) {
|
||||
case 'success': return 'text-emerald-600 dark:text-emerald-400'
|
||||
case 'warning': return 'text-amber-600 dark:text-amber-400'
|
||||
default: return 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
})
|
||||
|
||||
function newOperationKey(accountID: number): string {
|
||||
const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
return `claude-reset-${accountID}-${id}`
|
||||
}
|
||||
|
||||
function openRedeemConfirm() {
|
||||
if (redeeming.value || loading.value || !canRedeem.value) return
|
||||
showRedeemConfirm.value = true
|
||||
}
|
||||
|
||||
function outcomeFeedback(result: ClaudeResetOutcome): { kind: 'success' | 'warning' | 'error'; text: string } {
|
||||
const key = 'admin.accounts.claudeResetCredits.outcome'
|
||||
switch (result.outcome) {
|
||||
case 'reset':
|
||||
return { kind: 'success', text: t(`${key}.reset`, { windows: windowLabels(result.cleared) || '—' }) }
|
||||
case 'already_used':
|
||||
return { kind: 'warning', text: t(`${key}.alreadyUsed`) }
|
||||
case 'cooldown':
|
||||
return {
|
||||
kind: 'warning',
|
||||
text: result.cooldown_until
|
||||
? t(`${key}.cooldownUntil`, { time: formatTime(result.cooldown_until, 'short') })
|
||||
: t(`${key}.cooldown`)
|
||||
}
|
||||
case 'not_limited':
|
||||
return { kind: 'warning', text: t(`${key}.notLimited`) }
|
||||
case 'ineligible':
|
||||
return { kind: 'error', text: t(`${key}.ineligible`) }
|
||||
default:
|
||||
if (result.reason === 'upstream_unavailable') return { kind: 'warning', text: t(`${key}.unavailable`) }
|
||||
return { kind: 'warning', text: t(`${key}.unknown`) }
|
||||
}
|
||||
}
|
||||
|
||||
// Refusals issued before any claim was sent: the next attempt is a new confirmation.
|
||||
const preClaimRefusals = new Set(['CLAUDE_RESET_BUSY', 'CLAUDE_RESET_NOT_AVAILABLE', 'CLAUDE_RESET_UNRESOLVED', 'CLAUDE_RESET_UPSTREAM_UNAVAILABLE'])
|
||||
|
||||
function errorText(e: unknown): string {
|
||||
const err = e as { reason?: string; message?: string }
|
||||
switch (err?.reason) {
|
||||
case 'CLAUDE_RESET_UNRESOLVED': return t('admin.accounts.claudeResetCredits.outcome.unknown')
|
||||
case 'CLAUDE_RESET_BUSY': return t('admin.accounts.claudeResetCredits.outcome.busy')
|
||||
case 'CLAUDE_RESET_NOT_AVAILABLE': return t('admin.accounts.claudeResetCredits.outcome.notAvailable')
|
||||
case 'CLAUDE_RESET_UPSTREAM_UNAVAILABLE': return t('admin.accounts.claudeResetCredits.outcome.unavailable')
|
||||
case 'IDEMPOTENCY_IN_PROGRESS': return t('admin.accounts.claudeResetCredits.outcome.inProgress')
|
||||
case 'IDEMPOTENCY_RETRY_BACKOFF': return t('admin.accounts.claudeResetCredits.outcome.retryBackoff')
|
||||
default: return err?.message || t('admin.accounts.claudeResetCredits.outcome.failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRedeem() {
|
||||
showRedeemConfirm.value = false
|
||||
if (redeeming.value || loading.value || !canRedeem.value) return
|
||||
const accountID = props.account.id
|
||||
const current = generation
|
||||
pendingKey ??= newOperationKey(accountID)
|
||||
redeeming.value = true
|
||||
redeemFeedback.value = null
|
||||
try {
|
||||
const result = await redeemClaudeResetCredit(accountID, pendingKey)
|
||||
if (current !== generation) return
|
||||
// A definite server answer ends this confirmation; the next one gets a new key.
|
||||
pendingKey = null
|
||||
redeemFeedback.value = outcomeFeedback(result)
|
||||
redeeming.value = false
|
||||
emit('redeemed', result)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
if (current !== generation) return
|
||||
// Transport or unknown errors keep the key so a retry replays server-side.
|
||||
if (preClaimRefusals.has((e as { reason?: string })?.reason ?? '')) pendingKey = null
|
||||
redeemFeedback.value = { kind: 'error', text: errorText(e) }
|
||||
} finally {
|
||||
if (current === generation) redeeming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (loading.value) return
|
||||
if (loading.value || redeeming.value) return
|
||||
const current = ++generation
|
||||
loading.value = true
|
||||
error.value = false
|
||||
|
||||
@@ -3,8 +3,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ClaudeResetCreditsCell from '../ClaudeResetCreditsCell.vue'
|
||||
import type { Account } from '@/types'
|
||||
const getCredits = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/api/admin/claudeResetCredits', () => ({ getClaudeResetCredits: getCredits }))
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
|
||||
const redeem = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/api/admin/claudeResetCredits', () => ({ getClaudeResetCredits: getCredits, redeemClaudeResetCredit: redeem }))
|
||||
const t = vi.hoisted(() => vi.fn((key: string, _params?: Record<string, unknown>) => key))
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t }) }))
|
||||
// Minimal stand-in exposing the dialog's show state and confirm/cancel events.
|
||||
vi.mock('@/components/common/ConfirmDialog.vue', () => ({
|
||||
default: {
|
||||
props: { show: Boolean, title: String, message: String, confirmText: String, cancelText: String, danger: Boolean },
|
||||
emits: ['confirm', 'cancel'],
|
||||
template: `<div v-if="show" data-testid="confirm-dialog" :data-danger="String(danger)">{{ message }}
|
||||
<button data-testid="confirm-ok" @click="$emit('confirm')">ok</button>
|
||||
<button data-testid="confirm-cancel" @click="$emit('cancel')">cancel</button></div>`
|
||||
}
|
||||
}))
|
||||
const account = { id: 1, platform: 'anthropic', type: 'oauth' } as Account
|
||||
const credit = {
|
||||
label: 'Launch reset', resets_left: 1,
|
||||
@@ -13,9 +25,22 @@ const credit = {
|
||||
}
|
||||
const snapshot = { eligible: true, available_count: 1, credits: [credit], fetched_at: '2026-09-25T00:00:00Z' }
|
||||
const countButton = (wrapper: ReturnType<typeof mount>) => wrapper.find('[data-testid="claude-reset-count"]')
|
||||
const redeemButton = (wrapper: ReturnType<typeof mount>) => wrapper.get('[data-testid="claude-reset-redeem"]')
|
||||
async function queried(result: unknown = snapshot) {
|
||||
getCredits.mockResolvedValue(result)
|
||||
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
|
||||
await countButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
async function confirmReset(wrapper: ReturnType<typeof mount>) {
|
||||
await redeemButton(wrapper).trigger('click')
|
||||
await wrapper.get('[data-testid="confirm-ok"]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('Claude reset credit status', () => {
|
||||
beforeEach(() => getCredits.mockReset())
|
||||
beforeEach(() => { getCredits.mockReset(); redeem.mockReset() })
|
||||
|
||||
it('queries only on explicit request and shows count and expiry', async () => {
|
||||
getCredits.mockResolvedValue(snapshot)
|
||||
@@ -52,9 +77,142 @@ describe('Claude reset credit status', () => {
|
||||
expect(wrapper.find('[data-testid="claude-reset-not-usable"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('offers no redeem action', () => {
|
||||
it('disables reset until a query shows a redeemable credit', async () => {
|
||||
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
|
||||
expect(wrapper.findAll('button')).toHaveLength(1)
|
||||
expect(redeemButton(wrapper).attributes('disabled')).toBeDefined()
|
||||
await redeemButton(wrapper).trigger('click')
|
||||
expect(wrapper.find('[data-testid="confirm-dialog"]').exists()).toBe(false)
|
||||
|
||||
const waiting = await queried({ ...snapshot, available_count: 0, credits: [{ ...credit, redeemable: false }] })
|
||||
expect(redeemButton(waiting).attributes('disabled')).toBeDefined()
|
||||
|
||||
let resolve!: (value: typeof snapshot) => void
|
||||
getCredits.mockReturnValue(new Promise(r => { resolve = r }))
|
||||
const loading = mount(ClaudeResetCreditsCell, { props: { account } })
|
||||
await countButton(loading).trigger('click')
|
||||
expect(redeemButton(loading).attributes('disabled')).toBeDefined()
|
||||
resolve(snapshot)
|
||||
await flushPromises()
|
||||
expect(redeemButton(loading).attributes('disabled')).toBeUndefined()
|
||||
expect(redeem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never redeems without confirmation, and the count button never redeems', async () => {
|
||||
const wrapper = await queried()
|
||||
await countButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
await redeemButton(wrapper).trigger('click')
|
||||
const dialog = wrapper.get('[data-testid="confirm-dialog"]')
|
||||
expect(dialog.attributes('data-danger')).toBe('true')
|
||||
expect(dialog.text()).toContain('admin.accounts.claudeResetCredits.confirmMessage')
|
||||
// Known windows render as readable labels; count is what remains after this use.
|
||||
expect(t).toHaveBeenCalledWith('admin.accounts.claudeResetCredits.confirmMessage', {
|
||||
windows: 'admin.accounts.claudeResetCredits.windows.fiveHour, admin.accounts.claudeResetCredits.windows.sevenDay',
|
||||
count: 0
|
||||
})
|
||||
await wrapper.get('[data-testid="confirm-cancel"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="confirm-dialog"]').exists()).toBe(false)
|
||||
expect(redeem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redeems after confirmation, reports success, refreshes and notifies the parent', async () => {
|
||||
const wrapper = await queried()
|
||||
redeem.mockResolvedValue({ outcome: 'reset', cleared: ['five_hour'], replayed: false })
|
||||
getCredits.mockResolvedValue({ ...snapshot, available_count: 0, credits: [] })
|
||||
await confirmReset(wrapper)
|
||||
expect(redeem).toHaveBeenCalledTimes(1)
|
||||
expect(redeem.mock.calls[0][0]).toBe(1)
|
||||
expect(redeem.mock.calls[0][1]).toMatch(/^claude-reset-1-/)
|
||||
expect(wrapper.get('[data-testid="claude-reset-feedback"]').text()).toBe('admin.accounts.claudeResetCredits.outcome.reset')
|
||||
expect(getCredits).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('redeemed')).toHaveLength(1)
|
||||
expect(redeemButton(wrapper).attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('reuses the key when retrying a failed confirmation and rotates it after an answer', async () => {
|
||||
const wrapper = await queried()
|
||||
redeem.mockRejectedValueOnce({ status: 0, message: 'Network Error' })
|
||||
await confirmReset(wrapper)
|
||||
expect(wrapper.get('[data-testid="claude-reset-feedback"]').text()).toBe('Network Error')
|
||||
redeem.mockResolvedValueOnce({ outcome: 'not_limited', replayed: false })
|
||||
await confirmReset(wrapper)
|
||||
expect(redeem.mock.calls[1][1]).toBe(redeem.mock.calls[0][1])
|
||||
redeem.mockResolvedValueOnce({ outcome: 'not_limited', replayed: false })
|
||||
await confirmReset(wrapper)
|
||||
expect(redeem.mock.calls[2][1]).not.toBe(redeem.mock.calls[0][1])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['already_used', 'alreadyUsed'],
|
||||
['cooldown', 'cooldown'],
|
||||
['not_limited', 'notLimited'],
|
||||
['ineligible', 'ineligible'],
|
||||
['unknown', 'unknown']
|
||||
])('maps outcome %s to a clear message', async (outcome, key) => {
|
||||
const wrapper = await queried()
|
||||
redeem.mockResolvedValue({ outcome, replayed: false })
|
||||
await confirmReset(wrapper)
|
||||
expect(wrapper.get('[data-testid="claude-reset-feedback"]').text()).toBe(`admin.accounts.claudeResetCredits.outcome.${key}`)
|
||||
expect(getCredits).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('maps server refusals to clear messages', async () => {
|
||||
const wrapper = await queried()
|
||||
redeem.mockRejectedValueOnce({ status: 409, reason: 'CLAUDE_RESET_UNRESOLVED', message: 'x' })
|
||||
await confirmReset(wrapper)
|
||||
expect(wrapper.get('[data-testid="claude-reset-feedback"]').text()).toBe('admin.accounts.claudeResetCredits.outcome.unknown')
|
||||
redeem.mockRejectedValueOnce({ status: 409, reason: 'CLAUDE_RESET_BUSY', message: 'x' })
|
||||
await confirmReset(wrapper)
|
||||
expect(wrapper.get('[data-testid="claude-reset-feedback"]').text()).toBe('admin.accounts.claudeResetCredits.outcome.busy')
|
||||
})
|
||||
|
||||
it.each(['CLAUDE_RESET_BUSY', 'CLAUDE_RESET_NOT_AVAILABLE', 'CLAUDE_RESET_UNRESOLVED', 'CLAUDE_RESET_UPSTREAM_UNAVAILABLE'])(
|
||||
'rotates the key after pre-claim refusal %s',
|
||||
async reason => {
|
||||
const wrapper = await queried()
|
||||
redeem.mockRejectedValueOnce({ status: 409, reason, message: 'x' })
|
||||
await confirmReset(wrapper)
|
||||
redeem.mockResolvedValueOnce({ outcome: 'not_limited', replayed: false })
|
||||
await confirmReset(wrapper)
|
||||
expect(redeem.mock.calls[1][1]).not.toBe(redeem.mock.calls[0][1])
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['IDEMPOTENCY_IN_PROGRESS', 'inProgress'],
|
||||
['IDEMPOTENCY_RETRY_BACKOFF', 'retryBackoff'],
|
||||
['CLAUDE_RESET_UPSTREAM_UNAVAILABLE', 'unavailable']
|
||||
])('maps %s to a clear message', async (reason, key) => {
|
||||
const wrapper = await queried()
|
||||
redeem.mockRejectedValueOnce({ status: 409, reason, message: 'x' })
|
||||
await confirmReset(wrapper)
|
||||
expect(wrapper.get('[data-testid="claude-reset-feedback"]').text()).toBe(`admin.accounts.claudeResetCredits.outcome.${key}`)
|
||||
})
|
||||
|
||||
it('reuses the key after an idempotency conflict', async () => {
|
||||
const wrapper = await queried()
|
||||
redeem.mockRejectedValueOnce({ status: 409, reason: 'IDEMPOTENCY_IN_PROGRESS', message: 'x' })
|
||||
await confirmReset(wrapper)
|
||||
redeem.mockResolvedValueOnce({ outcome: 'not_limited', replayed: false })
|
||||
await confirmReset(wrapper)
|
||||
expect(redeem.mock.calls[1][1]).toBe(redeem.mock.calls[0][1])
|
||||
})
|
||||
|
||||
it('labels cleared windows and falls back to the raw key for unknown ones', async () => {
|
||||
const wrapper = await queried()
|
||||
redeem.mockResolvedValue({ outcome: 'reset', cleared: ['seven_day_overage_included', 'future_window'], replayed: false })
|
||||
await confirmReset(wrapper)
|
||||
expect(t).toHaveBeenCalledWith('admin.accounts.claudeResetCredits.outcome.reset', {
|
||||
windows: 'admin.accounts.claudeResetCredits.windows.sevenDayOverage, future_window'
|
||||
})
|
||||
})
|
||||
|
||||
it('tells the operator to retry later after an explicit unavailable answer', async () => {
|
||||
const wrapper = await queried()
|
||||
redeem.mockResolvedValue({ outcome: 'unknown', reason: 'upstream_unavailable', replayed: false })
|
||||
await confirmReset(wrapper)
|
||||
expect(wrapper.get('[data-testid="claude-reset-feedback"]').text()).toBe('admin.accounts.claudeResetCredits.outcome.unavailable')
|
||||
})
|
||||
|
||||
it('keeps slot content for setup tokens and discards responses after account changes', async () => {
|
||||
|
||||
@@ -938,7 +938,33 @@ export default {
|
||||
expiresAtFull: 'Reset credit expires at: {time}',
|
||||
clears: 'Clears windows: {windows}',
|
||||
notUsableNow: 'Not usable now',
|
||||
requiresLimit: 'Usable only after hitting a limit'
|
||||
requiresLimit: 'Usable only after hitting a limit',
|
||||
reset: 'Reset',
|
||||
resetTooltipNeedQuery: 'Check the count first; reset is available once a usable credit is found',
|
||||
resetTooltipNone: 'No reset can be used right now',
|
||||
resetTooltipReady: 'Consume 1 reset to clear limit windows (asks for confirmation)',
|
||||
confirmTitle: 'Confirm Claude Reset',
|
||||
confirmMessage: 'This will consume 1 reset credit to immediately restore the {windows} window(s) ({count} remaining). This action cannot be undone. Continue?',
|
||||
windows: {
|
||||
fiveHour: '5h',
|
||||
sevenDay: '7d',
|
||||
sevenDayOverage: '7d overage'
|
||||
},
|
||||
outcome: {
|
||||
reset: 'Reset applied; cleared: {windows}',
|
||||
alreadyUsed: 'This reset was already used; refreshing to confirm',
|
||||
cooldown: 'Resets are cooling down; try again later',
|
||||
cooldownUntil: 'Resets are cooling down until {time}',
|
||||
notLimited: 'Not at a limit, so nothing was reset and no credit was used',
|
||||
ineligible: 'This account cannot use resets right now',
|
||||
unknown: 'Result unconfirmed; further redemption is blocked for now. Check again later',
|
||||
unavailable: 'Reset service is temporarily unavailable; retry after a while',
|
||||
inProgress: 'This reset request is still processing; check again shortly',
|
||||
retryBackoff: 'This reset request just failed; retry after a moment',
|
||||
busy: 'Another reset is in progress; try again later',
|
||||
notAvailable: 'No reset can be used right now; no credit was used',
|
||||
failed: 'Reset request failed'
|
||||
}
|
||||
},
|
||||
autoResetCredit: {
|
||||
title: 'Automatically use reset credits',
|
||||
|
||||
@@ -1039,7 +1039,33 @@ export default {
|
||||
expiresAtFull: '重置次数到期时间:{time}',
|
||||
clears: '可清除窗口:{windows}',
|
||||
notUsableNow: '暂不可用',
|
||||
requiresLimit: '需达到限额后才能使用'
|
||||
requiresLimit: '需达到限额后才能使用',
|
||||
reset: '重置',
|
||||
resetTooltipNeedQuery: '请先点「次数」查询;查询到可用的重置后才能使用',
|
||||
resetTooltipNone: '当前没有可立即使用的重置',
|
||||
resetTooltipReady: '消耗 1 次重置,清除限额窗口(需确认)',
|
||||
confirmTitle: '确认使用 Claude 重置',
|
||||
confirmMessage: '将消耗 1 次重置次数,立即恢复 {windows} 窗口,剩余 {count} 次。此操作不可撤销,确定继续吗?',
|
||||
windows: {
|
||||
fiveHour: '5h',
|
||||
sevenDay: '7d',
|
||||
sevenDayOverage: '7d 超额'
|
||||
},
|
||||
outcome: {
|
||||
reset: '重置成功,已清除:{windows}',
|
||||
alreadyUsed: '该重置已被使用,正在刷新确认',
|
||||
cooldown: '重置处于冷却中,请稍后再试',
|
||||
cooldownUntil: '重置处于冷却中,冷却至 {time}',
|
||||
notLimited: '当前未达到限额,无需重置,未消耗次数',
|
||||
ineligible: '此账号当前不可使用重置',
|
||||
unknown: '结果未确认,已阻止再次兑换,请稍后查询',
|
||||
unavailable: '重置服务暂时不可用,未确认消耗,请稍后再试',
|
||||
inProgress: '该重置请求仍在处理中,请稍后查询结果',
|
||||
retryBackoff: '该重置请求刚刚失败,请稍后再试',
|
||||
busy: '另一个重置正在进行中,请稍后再试',
|
||||
notAvailable: '当前没有可立即使用的重置,未消耗次数',
|
||||
failed: '重置请求失败'
|
||||
}
|
||||
},
|
||||
autoResetCredit: {
|
||||
title: '自动使用重置卡',
|
||||
|
||||
Reference in New Issue
Block a user