mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
Merge pull request #7684 from william-drakemond/feat/claude-reset-credits-ui
feat(claude): 查询 Claude 重置次数(只读),UI 复用 Codex 次数按钮 / Read-only Claude reset credit count, reusing Codex count UI
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)
|
||||
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 := service.NewClaudeResetCreditService(accountRepository, claudeTokenProvider, proxyRepository, settingService)
|
||||
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)
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
|
||||
@@ -48,6 +48,7 @@ func NewOAuthHandler(oauthService *service.OAuthService) *OAuthHandler {
|
||||
|
||||
// AccountHandler handles admin account management
|
||||
type AccountHandler struct {
|
||||
claudeResetCredits claudeResetReader
|
||||
adminService service.AdminService
|
||||
oauthService *service.OAuthService
|
||||
openaiOAuthService *service.OpenAIOAuthService
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type claudeResetReader interface {
|
||||
Query(context.Context, int64) (*service.ClaudeResetCredits, error)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) SetClaudeResetCreditService(s *service.ClaudeResetCreditService) {
|
||||
h.claudeResetCredits = s
|
||||
}
|
||||
func (h *AccountHandler) ClaudeResetCredits(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
|
||||
}
|
||||
status, err := h.claudeResetCredits.Query(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, status)
|
||||
}
|
||||
@@ -50,10 +50,12 @@ func ProvideAdminHandlers(
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService,
|
||||
ollamaCloudUsage *service.OllamaCloudUsageService,
|
||||
opencodeGoUsage *service.OpenCodeGoUsageService,
|
||||
claudeResetCredits *service.ClaudeResetCreditService,
|
||||
) *AdminHandlers {
|
||||
accountHandler.SetUpstreamBillingProbeService(upstreamBillingProbe)
|
||||
accountHandler.SetOllamaCloudUsageService(ollamaCloudUsage)
|
||||
accountHandler.SetOpenCodeGoUsageService(opencodeGoUsage)
|
||||
accountHandler.SetClaudeResetCreditService(claudeResetCredits)
|
||||
return &AdminHandlers{
|
||||
Dashboard: dashboardHandler,
|
||||
User: userHandler,
|
||||
|
||||
@@ -367,6 +367,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu
|
||||
accounts.GET("/opencode-go-usage/settings", h.Admin.Account.GetOpenCodeGoUsageSettings)
|
||||
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)
|
||||
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,204 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||||
)
|
||||
|
||||
var claudeResetGrantIDPattern = regexp.MustCompile(`^[a-z0-9_-]{1,40}$`)
|
||||
|
||||
const claudeResetUsageURL = "https://api.anthropic.com/api/oauth/usage?cedar_ember=1&skip_spend=1"
|
||||
|
||||
// ClaudeResetCredit is deliberately free of upstream grant and organization IDs.
|
||||
type ClaudeResetCredit struct {
|
||||
Label string `json:"label"`
|
||||
ResetsLeft int `json:"resets_left"`
|
||||
StartsAt *time.Time `json:"starts_at,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Clears []string `json:"clears"`
|
||||
PercentUsed map[string]float64 `json:"percent_used"`
|
||||
Blocking []string `json:"blocking"`
|
||||
UseRequiresLimit bool `json:"use_requires_limit"`
|
||||
Redeemable bool `json:"redeemable"`
|
||||
}
|
||||
|
||||
type ClaudeResetCredits struct {
|
||||
Eligible bool `json:"eligible"`
|
||||
AvailableCount int `json:"available_count"`
|
||||
Credits []ClaudeResetCredit `json:"credits"`
|
||||
CooldownUntil *time.Time `json:"cooldown_until,omitempty"`
|
||||
WeeklyResetsAt *time.Time `json:"weekly_resets_at,omitempty"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
type claudeResetGrant struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
ResetsLeft int `json:"resets_left"`
|
||||
StartsAt *time.Time `json:"starts_at"`
|
||||
EndsAt *time.Time `json:"ends_at"`
|
||||
Clears []string `json:"clears"`
|
||||
Paused bool `json:"paused"`
|
||||
UsableNow bool `json:"usable_now"`
|
||||
UseRequiresLimit *bool `json:"use_requires_limit"`
|
||||
PercentUsed map[string]float64 `json:"percent_used"`
|
||||
Blocking []string `json:"blocking"`
|
||||
}
|
||||
type claudeResetBlock struct {
|
||||
Eligible bool `json:"eligible"`
|
||||
AtLimit bool `json:"at_limit"`
|
||||
Grants []claudeResetGrant `json:"grants"`
|
||||
NextGrantID string `json:"next_grant_id"`
|
||||
CooldownUntil *time.Time `json:"cooldown_until"`
|
||||
WeeklyResetsAt *time.Time `json:"weekly_resets_at"`
|
||||
}
|
||||
|
||||
type claudeResetAccounts interface {
|
||||
GetByID(context.Context, int64) (*Account, error)
|
||||
}
|
||||
type claudeResetTokens interface {
|
||||
GetAccessToken(context.Context, *Account) (string, error)
|
||||
}
|
||||
|
||||
type ClaudeResetCreditService struct {
|
||||
accounts claudeResetAccounts
|
||||
tokens claudeResetTokens
|
||||
proxies ProxyRepository
|
||||
settings *SettingService
|
||||
do func(*http.Request, string) (*http.Response, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewClaudeResetCreditService(accounts AccountRepository, tokens *ClaudeTokenProvider, proxies ProxyRepository, settings *SettingService) *ClaudeResetCreditService {
|
||||
s := &ClaudeResetCreditService{accounts: accounts, tokens: tokens, proxies: proxies, settings: settings, now: time.Now}
|
||||
s.do = func(req *http.Request, proxy string) (*http.Response, error) {
|
||||
client, err := httpclient.GetClient(httpclient.Options{ProxyURL: proxy, Timeout: 25 * time.Second, ValidateResolvedIP: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Never forward OAuth credentials across redirects, even to another public host.
|
||||
isolated := *client
|
||||
isolated.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return isolated.Do(req)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ClaudeResetCreditService) account(ctx context.Context, id int64) (*Account, string, string, error) {
|
||||
a, err := s.accounts.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeOAuth {
|
||||
return nil, "", "", infraerrors.BadRequest("CLAUDE_RESET_OAUTH_REQUIRED", "Claude OAuth account required")
|
||||
}
|
||||
profile := false
|
||||
for _, scope := range strings.Fields(a.GetCredential("scope")) {
|
||||
if scope == "user:profile" {
|
||||
profile = true
|
||||
}
|
||||
}
|
||||
if !profile {
|
||||
return nil, "", "", infraerrors.BadRequest("CLAUDE_RESET_PROFILE_SCOPE_REQUIRED", "user:profile scope required")
|
||||
}
|
||||
proxy := ""
|
||||
if a.ProxyID != nil {
|
||||
p, e := s.proxies.GetByID(ctx, *a.ProxyID)
|
||||
if e != nil || p == nil {
|
||||
return nil, "", "", infraerrors.ServiceUnavailable("CLAUDE_RESET_PROXY_UNAVAILABLE", "account proxy unavailable")
|
||||
}
|
||||
proxy = p.URL()
|
||||
}
|
||||
token, err := s.tokens.GetAccessToken(ctx, a)
|
||||
if err != nil || strings.TrimSpace(token) == "" {
|
||||
return nil, "", "", infraerrors.ServiceUnavailable("CLAUDE_RESET_TOKEN_UNAVAILABLE", "OAuth token unavailable")
|
||||
}
|
||||
return a, token, proxy, nil
|
||||
}
|
||||
|
||||
func (s *ClaudeResetCreditService) headers(ctx context.Context, req *http.Request, token string) {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("anthropic-beta", "oauth-2025-04-20")
|
||||
req.Header.Set("x-app", "cli")
|
||||
req.Header.Set("User-Agent", "claude-cli/"+s.settings.GetClaudeCodeClientVersion(ctx)+" (external, cli)")
|
||||
}
|
||||
|
||||
func (s *ClaudeResetCreditService) query(ctx context.Context, id int64) (*ClaudeResetCredits, error) {
|
||||
_, token, proxy, err := s.account(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeResetUsageURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.headers(ctx, req, token)
|
||||
resp, err := s.do(req, proxy)
|
||||
if err != nil {
|
||||
return nil, infraerrors.ServiceUnavailable("CLAUDE_RESET_QUERY_FAILED", "reset status request failed")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_QUERY_FAILED", fmt.Sprintf("reset status upstream HTTP %d", resp.StatusCode))
|
||||
}
|
||||
var envelope map[string]json.RawMessage
|
||||
if err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&envelope); err != nil || envelope == nil {
|
||||
return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset status")
|
||||
}
|
||||
if _, ok := envelope["error"]; ok {
|
||||
return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset status")
|
||||
}
|
||||
var block *claudeResetBlock
|
||||
raw, present := envelope["cedar_ember"]
|
||||
if present && string(raw) != "null" {
|
||||
if err = json.Unmarshal(raw, &block); err != nil || block == nil || block.Grants == nil {
|
||||
return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset grants")
|
||||
}
|
||||
}
|
||||
return projectClaudeResetCredits(block, s.now()), nil
|
||||
}
|
||||
|
||||
func (s *ClaudeResetCreditService) Query(ctx context.Context, id int64) (*ClaudeResetCredits, error) {
|
||||
return s.query(ctx, id)
|
||||
}
|
||||
|
||||
func projectClaudeResetCredits(b *claudeResetBlock, now time.Time) *ClaudeResetCredits {
|
||||
r := &ClaudeResetCredits{Credits: []ClaudeResetCredit{}, FetchedAt: now.UTC()}
|
||||
if b == nil {
|
||||
return r
|
||||
}
|
||||
r.Eligible = b.Eligible
|
||||
if b.CooldownUntil != nil && now.Before(*b.CooldownUntil) {
|
||||
r.CooldownUntil = b.CooldownUntil
|
||||
}
|
||||
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)) {
|
||||
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))
|
||||
used := map[string]float64{}
|
||||
for k, v := range g.PercentUsed {
|
||||
if v >= 0 && v <= 100 {
|
||||
used[k] = v
|
||||
}
|
||||
}
|
||||
r.Credits = append(r.Credits, ClaudeResetCredit{Label: g.Label, ResetsLeft: g.ResetsLeft, StartsAt: g.StartsAt, ExpiresAt: g.EndsAt, Clears: g.Clears, PercentUsed: used, Blocking: g.Blocking, UseRequiresLimit: requires, Redeemable: usable})
|
||||
if usable {
|
||||
r.AvailableCount += g.ResetsLeft
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type resetAccountStub struct{ account *Account }
|
||||
|
||||
func (s resetAccountStub) GetByID(context.Context, int64) (*Account, error) { return s.account, nil }
|
||||
|
||||
type resetTokenStub struct{}
|
||||
|
||||
func (resetTokenStub) GetAccessToken(context.Context, *Account) (string, error) {
|
||||
return "synthetic-token", nil
|
||||
}
|
||||
func TestClaudeResetStatusNativeContract(t *testing.T) {
|
||||
now := time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)
|
||||
s := &ClaudeResetCreditService{accounts: resetAccountStub{&Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth, Credentials: map[string]any{"scope": "user:profile user:inference"}}}, tokens: resetTokenStub{}, now: func() time.Time { return now }}
|
||||
s.do = func(r *http.Request, p string) (*http.Response, error) {
|
||||
require.Equal(t, claudeResetUsageURL, r.URL.String())
|
||||
require.Equal(t, "GET", r.Method)
|
||||
require.Equal(t, "Bearer synthetic-token", r.Header.Get("Authorization"))
|
||||
require.Contains(t, r.Header.Get("User-Agent"), "claude-cli/")
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"cedar_ember":{"eligible":true,"at_limit":false,"next_grant_id":"launch","grants":[{"id":"launch","resets_left":2,"usable_now":true,"use_requires_limit":false,"ends_at":"2026-10-22T00:00:00Z","clears":["five_hour"],"percent_used":{"five_hour":65},"blocking":[]},{"id":"later","clears":["five_hour"],"resets_left":1,"usable_now":true,"use_requires_limit":false}]}}`))}, nil
|
||||
}
|
||||
r, e := s.Query(context.Background(), 1)
|
||||
require.NoError(t, e)
|
||||
require.Equal(t, 2, r.AvailableCount)
|
||||
require.True(t, r.Credits[0].Redeemable)
|
||||
require.False(t, r.Credits[1].Redeemable)
|
||||
b, e := json.Marshal(r)
|
||||
require.NoError(t, e)
|
||||
require.NotContains(t, string(b), `"id"`)
|
||||
require.NotContains(t, string(b), "launch")
|
||||
require.NotContains(t, string(b), "synthetic-token")
|
||||
require.NotContains(t, string(b), "selection_token")
|
||||
}
|
||||
func TestClaudeResetPastCooldownIsCleared(t *testing.T) {
|
||||
now := time.Now()
|
||||
past := now.Add(-time.Minute)
|
||||
no := false
|
||||
g := claudeResetGrant{ID: "grant", ResetsLeft: 1, UsableNow: true, UseRequiresLimit: &no, Clears: []string{"five_hour"}}
|
||||
r := projectClaudeResetCredits(&claudeResetBlock{Eligible: true, NextGrantID: g.ID, CooldownUntil: &past, Grants: []claudeResetGrant{g}}, now)
|
||||
require.Nil(t, r.CooldownUntil)
|
||||
require.Equal(t, 1, r.AvailableCount)
|
||||
future := now.Add(time.Hour)
|
||||
r = projectClaudeResetCredits(&claudeResetBlock{Eligible: true, NextGrantID: g.ID, CooldownUntil: &future, Grants: []claudeResetGrant{g}}, now)
|
||||
require.Equal(t, &future, r.CooldownUntil)
|
||||
}
|
||||
func TestClaudeResetEligibilityFailClosed(t *testing.T) {
|
||||
now := time.Now()
|
||||
past := now.Add(-time.Minute)
|
||||
future := now.Add(time.Hour)
|
||||
no := false
|
||||
base := claudeResetGrant{ID: "grant", ResetsLeft: 1, UsableNow: true, UseRequiresLimit: &no, Clears: []string{"five_hour"}}
|
||||
for _, name := range []string{"paused", "expired", "future", "cooldown", "requires-limit", "blocking", "ineligible", "spent", "not-next"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
g := base
|
||||
b := &claudeResetBlock{Eligible: true, NextGrantID: g.ID}
|
||||
switch name {
|
||||
case "paused":
|
||||
g.Paused = true
|
||||
case "expired":
|
||||
g.EndsAt = &past
|
||||
case "future":
|
||||
g.StartsAt = &future
|
||||
case "cooldown":
|
||||
b.CooldownUntil = &future
|
||||
case "requires-limit":
|
||||
g.UseRequiresLimit = nil
|
||||
case "blocking":
|
||||
g.Blocking = []string{"seven_day"}
|
||||
case "ineligible":
|
||||
b.Eligible = false
|
||||
case "spent":
|
||||
g.ResetsLeft = 0
|
||||
case "not-next":
|
||||
b.NextGrantID = "other"
|
||||
}
|
||||
b.Grants = []claudeResetGrant{g}
|
||||
r := projectClaudeResetCredits(b, now)
|
||||
require.Zero(t, r.AvailableCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestClaudeResetStatusRejectsMissingScopeBeforeNetwork(t *testing.T) {
|
||||
s := &ClaudeResetCreditService{accounts: resetAccountStub{&Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}}, do: func(*http.Request, string) (*http.Response, error) { t.Fatal("network called"); return nil, nil }}
|
||||
_, e := s.Query(context.Background(), 1)
|
||||
require.Error(t, e)
|
||||
}
|
||||
func TestClaudeResetMalformedAndAbsent(t *testing.T) {
|
||||
for _, body := range []string{`{}`, `{"five_hour":{},"cedar_ember":null}`, `{"cedar_ember":{"eligible":true}}`, `{"error":{"message":"private upstream data"}}`, `not json`} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
s := &ClaudeResetCreditService{accounts: resetAccountStub{&Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth, Credentials: map[string]any{"scope": "user:profile"}}}, tokens: resetTokenStub{}, now: time.Now, do: func(*http.Request, string) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||
}}
|
||||
r, e := s.Query(context.Background(), 1)
|
||||
if body == `{}` || strings.Contains(body, `"cedar_ember":null`) {
|
||||
require.NoError(t, e)
|
||||
require.Empty(t, r.Credits)
|
||||
} else {
|
||||
require.Error(t, e)
|
||||
require.NotContains(t, e.Error(), "private upstream data")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -880,6 +880,7 @@ var ProviderSet = wire.NewSet(
|
||||
ProvideBatchImageWorkerRuntime,
|
||||
wire.Bind(new(AccountRuntimeBlocker), new(*OpenAIGatewayService)),
|
||||
NewOAuthService,
|
||||
NewClaudeResetCreditService,
|
||||
ProvideOpenAIOAuthService,
|
||||
ProvideGrokOAuthService,
|
||||
wire.Bind(new(GrokOAuthTokenService), new(*GrokOAuthService)),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export interface ClaudeResetCredit {
|
||||
label: string
|
||||
resets_left: number
|
||||
starts_at?: string
|
||||
expires_at?: string
|
||||
clears: string[]
|
||||
percent_used: Record<string, number>
|
||||
blocking: string[]
|
||||
use_requires_limit: boolean
|
||||
redeemable: boolean
|
||||
}
|
||||
|
||||
export interface ClaudeResetCredits {
|
||||
eligible: boolean
|
||||
available_count: number
|
||||
credits: ClaudeResetCredit[]
|
||||
cooldown_until?: string
|
||||
weekly_resets_at?: string
|
||||
fetched_at: string
|
||||
}
|
||||
|
||||
export async function getClaudeResetCredits(id: number): Promise<ClaudeResetCredits> {
|
||||
const { data } = await apiClient.get<ClaudeResetCredits>(`/admin/accounts/${id}/claude/reset-credits`)
|
||||
return data
|
||||
}
|
||||
@@ -77,8 +77,19 @@
|
||||
color="amber"
|
||||
/>
|
||||
|
||||
<!-- Passive sampling label + active query button -->
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
</div>
|
||||
|
||||
<!-- No data yet -->
|
||||
<div v-else class="space-y-1">
|
||||
<div class="text-xs text-gray-400">-</div>
|
||||
</div>
|
||||
<!--
|
||||
One stable instance for every usage state, so a reset-credit query started
|
||||
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">
|
||||
<template v-if="usageInfo" #pre-actions>
|
||||
<span
|
||||
v-if="usageInfo.source === 'passive'"
|
||||
class="text-[9px] text-gray-400 dark:text-gray-500 italic"
|
||||
@@ -87,7 +98,7 @@
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[9px] font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 transition-colors"
|
||||
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="activeQueryLoading"
|
||||
@click="loadActiveUsage"
|
||||
>
|
||||
@@ -107,13 +118,8 @@
|
||||
</svg>
|
||||
{{ t('admin.accounts.usageWindow.activeQuery') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No data yet -->
|
||||
<div v-else class="space-y-1">
|
||||
<div class="text-xs text-gray-400">-</div>
|
||||
</div>
|
||||
</template>
|
||||
</ClaudeResetCreditsCell>
|
||||
</template>
|
||||
|
||||
<!-- OpenAI OAuth accounts: single source from /usage API -->
|
||||
@@ -677,6 +683,7 @@ import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
|
||||
import { formatCompactNumber } from '@/utils/format'
|
||||
import UsageProgressBar from './UsageProgressBar.vue'
|
||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||
import ClaudeResetCreditsCell from './ClaudeResetCreditsCell.vue'
|
||||
import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue'
|
||||
import GrokQuotaProbeCell from './GrokQuotaProbeCell.vue'
|
||||
import CNProviderQuotaCell from './CNProviderQuotaCell.vue'
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<slot name="pre-actions" />
|
||||
|
||||
<button
|
||||
v-if="visible"
|
||||
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"
|
||||
:title="countButtonTitle"
|
||||
@click="refresh"
|
||||
>
|
||||
<svg
|
||||
class="h-2.5 w-2.5"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.claudeResetCredits.count') }}<span v-if="status" class="ml-0.5 tabular-nums">{{ totalResets }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="visible && status && (primaryCredit || !status.eligible || cooldownActive)" class="flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
v-if="primaryCredit?.expires_at"
|
||||
data-testid="claude-reset-expiry"
|
||||
class="inline-flex max-w-full items-center rounded bg-gray-100 px-1.5 py-0.5 text-[10px] leading-4 text-gray-600 tabular-nums dark:bg-dark-800 dark:text-gray-300"
|
||||
:title="creditTitle"
|
||||
>
|
||||
{{ t('admin.accounts.claudeResetCredits.expiresAt', { time: formatTime(primaryCredit.expires_at, 'short') }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="status.eligible && totalResets > 0 && status.available_count === 0"
|
||||
data-testid="claude-reset-not-usable"
|
||||
class="text-[10px] text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('admin.accounts.claudeResetCredits.notUsableNow') }}
|
||||
</span>
|
||||
<span v-if="!status.eligible" class="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.accounts.claudeResetCredits.ineligible') }}
|
||||
</span>
|
||||
<span v-if="cooldownActive" data-testid="claude-reset-cooldown" class="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.accounts.claudeResetCredits.cooldown', { time: formatTime(status.cooldown_until!, 'short') }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="visible && error" role="alert" class="text-[10px] text-red-600 dark:text-red-400">
|
||||
{{ t('admin.accounts.claudeResetCredits.error') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Account } from '@/types'
|
||||
import { getClaudeResetCredits, type ClaudeResetCredits } from '@/api/admin/claudeResetCredits'
|
||||
|
||||
const props = defineProps<{ account: Account }>()
|
||||
const { t } = useI18n()
|
||||
const status = ref<ClaudeResetCredits | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
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 })
|
||||
|
||||
// 与 OpenAIQuotaResetCell 的到期时间格式保持一致
|
||||
const formatTime = (value: string, style: 'short' | 'full'): string => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
const options: Intl.DateTimeFormatOptions = { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }
|
||||
if (style === 'full') options.year = 'numeric'
|
||||
return new Intl.DateTimeFormat(undefined, options).format(date)
|
||||
}
|
||||
|
||||
// 与 Codex 的「次数」一致:显示持有的剩余次数,而不是此刻可兑换的次数
|
||||
const totalResets = computed(() => (status.value?.credits ?? []).reduce((sum, c) => sum + c.resets_left, 0))
|
||||
|
||||
// 优先展示下一张会被使用的券(仅它可能 redeemable),否则取最早到期的一张
|
||||
const primaryCredit = computed(() => {
|
||||
const credits = status.value?.credits ?? []
|
||||
return credits.find(c => c.redeemable) ?? [...credits].sort((a, b) => expiryMs(a.expires_at) - expiryMs(b.expires_at))[0] ?? null
|
||||
})
|
||||
|
||||
// 缺失或无法解析的到期时间排在最后
|
||||
function expiryMs(value?: string): number {
|
||||
const ms = value ? new Date(value).getTime() : NaN
|
||||
return Number.isNaN(ms) ? Number.POSITIVE_INFINITY : ms
|
||||
}
|
||||
|
||||
// 仅在冷却时间仍在未来时提示(后端已清理过期值,这里防御性再判断一次)
|
||||
const cooldownActive = computed(() => {
|
||||
const until = status.value?.cooldown_until
|
||||
return !!until && new Date(until).getTime() > Date.now()
|
||||
})
|
||||
|
||||
const creditTitle = computed(() => {
|
||||
const credit = primaryCredit.value
|
||||
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.use_requires_limit) lines.push(t('admin.accounts.claudeResetCredits.requiresLimit'))
|
||||
return lines.join('\n')
|
||||
})
|
||||
|
||||
const countButtonTitle = computed(() => {
|
||||
if (!status.value) return t('admin.accounts.claudeResetCredits.countTooltipLoad')
|
||||
return [
|
||||
t('admin.accounts.claudeResetCredits.countTooltipRefresh'),
|
||||
t('admin.accounts.claudeResetCredits.fetched', { time: formatTime(status.value.fetched_at, 'full') })
|
||||
].join('\n')
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
if (loading.value) return
|
||||
const current = ++generation
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const result = await getClaudeResetCredits(props.account.id)
|
||||
if (current === generation) status.value = result
|
||||
} catch {
|
||||
if (current === generation) { error.value = true; status.value = null }
|
||||
} finally {
|
||||
if (current === generation) loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,110 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
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 account = { id: 1, platform: 'anthropic', type: 'oauth' } as Account
|
||||
const credit = {
|
||||
label: 'Launch reset', resets_left: 1,
|
||||
expires_at: '2026-10-22T16:00:00Z', clears: ['five_hour', 'seven_day'],
|
||||
percent_used: {}, blocking: [], use_requires_limit: false, redeemable: true
|
||||
}
|
||||
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"]')
|
||||
|
||||
describe('Claude reset credit status', () => {
|
||||
beforeEach(() => getCredits.mockReset())
|
||||
|
||||
it('queries only on explicit request and shows count and expiry', async () => {
|
||||
getCredits.mockResolvedValue(snapshot)
|
||||
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
|
||||
expect(getCredits).not.toHaveBeenCalled()
|
||||
expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count')
|
||||
await countButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(getCredits).toHaveBeenCalledWith(1)
|
||||
expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count1')
|
||||
const expiry = wrapper.get('[data-testid="claude-reset-expiry"]')
|
||||
expect(expiry.attributes('title')).toContain('Launch reset')
|
||||
})
|
||||
|
||||
it('counts held resets and prefers the next redeemable grant', async () => {
|
||||
const later = { ...credit, label: 'Later', expires_at: '2026-12-01T00:00:00Z', redeemable: false }
|
||||
const next = { ...credit, label: 'Next', expires_at: '2026-11-01T00:00:00Z', redeemable: true }
|
||||
getCredits.mockResolvedValue({ ...snapshot, available_count: 1, credits: [later, next] })
|
||||
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
|
||||
await countButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count2')
|
||||
expect(wrapper.get('[data-testid="claude-reset-expiry"]').attributes('title')).toContain('Next')
|
||||
expect(wrapper.find('[data-testid="claude-reset-not-usable"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('flags held resets that cannot be used yet', async () => {
|
||||
const waiting = { ...credit, use_requires_limit: true, redeemable: false }
|
||||
getCredits.mockResolvedValue({ ...snapshot, available_count: 0, credits: [waiting] })
|
||||
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
|
||||
await countButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count1')
|
||||
expect(wrapper.find('[data-testid="claude-reset-not-usable"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('offers no redeem action', () => {
|
||||
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
|
||||
expect(wrapper.findAll('button')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps slot content for setup tokens and discards responses after account changes', async () => {
|
||||
let resolve!: (value: typeof snapshot) => void
|
||||
getCredits.mockReturnValue(new Promise(r => { resolve = r }))
|
||||
const wrapper = mount(ClaudeResetCreditsCell, {
|
||||
props: { account },
|
||||
slots: { 'pre-actions': '<button data-testid="local-query">q</button>' }
|
||||
})
|
||||
await countButton(wrapper).trigger('click')
|
||||
await wrapper.setProps({ account: { ...account, id: 2, type: 'setup-token' } })
|
||||
resolve(snapshot)
|
||||
await flushPromises()
|
||||
expect(countButton(wrapper).exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="local-query"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="claude-reset-expiry"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the earliest parsed expiry across offsets, invalid last', async () => {
|
||||
const a = { ...credit, label: 'Tokyo', expires_at: '2026-11-01T08:00:00+09:00', redeemable: false }
|
||||
const b = { ...credit, label: 'Chicago', expires_at: '2026-10-31T20:00:00-05:00', redeemable: false }
|
||||
const bad = { ...credit, label: 'Bad', expires_at: 'not-a-date', redeemable: false }
|
||||
const none = { ...credit, label: 'None', expires_at: undefined, redeemable: false }
|
||||
getCredits.mockResolvedValue({ ...snapshot, available_count: 0, credits: [none, bad, b, a] })
|
||||
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
|
||||
await countButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-testid="claude-reset-expiry"]').attributes('title')).toContain('Tokyo')
|
||||
expect(wrapper.get('[data-testid="claude-reset-expiry"]').attributes('title')).not.toContain('Chicago')
|
||||
})
|
||||
|
||||
it('hides a cooldown hint that is already in the past', async () => {
|
||||
getCredits.mockResolvedValue({ ...snapshot, cooldown_until: '2000-01-01T00:00:00Z' })
|
||||
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
|
||||
await countButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="claude-reset-cooldown"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('discards in-flight responses when the same account changes type', async () => {
|
||||
let resolve!: (value: typeof snapshot) => void
|
||||
getCredits.mockReturnValue(new Promise(r => { resolve = r }))
|
||||
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
|
||||
await countButton(wrapper).trigger('click')
|
||||
await wrapper.setProps({ account: { ...account, type: 'setup-token' } })
|
||||
await wrapper.setProps({ account: { ...account } })
|
||||
resolve(snapshot)
|
||||
await flushPromises()
|
||||
expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count')
|
||||
expect(countButton(wrapper).attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.find('[data-testid="claude-reset-expiry"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -926,7 +926,21 @@ export default {
|
||||
autoPause5hDisabled: 'Disable 5h auto-pause',
|
||||
autoPause7dDisabled: 'Disable 7d auto-pause',
|
||||
autoPauseDisabledHint: 'When enabled, this account is never auto-paused (even if a global default threshold is configured).',
|
||||
autoResetCredit: {
|
||||
claudeResetCredits: {
|
||||
count: 'Resets',
|
||||
countTooltipLoad: 'Check remaining Claude resets (read-only, never consumes one)',
|
||||
countTooltipRefresh: 'Refresh remaining Claude resets (read-only, never consumes one)',
|
||||
fetched: 'Checked at {time}',
|
||||
error: 'Could not check reset credits',
|
||||
ineligible: 'This account cannot use resets right now',
|
||||
cooldown: 'Cooldown until {time}',
|
||||
expiresAt: 'Expires {time}',
|
||||
expiresAtFull: 'Reset credit expires at: {time}',
|
||||
clears: 'Clears windows: {windows}',
|
||||
notUsableNow: 'Not usable now',
|
||||
requiresLimit: 'Usable only after hitting a limit'
|
||||
},
|
||||
autoResetCredit: {
|
||||
title: 'Automatically use reset credits',
|
||||
hint: 'Uses the earliest-expiring available credit only when actual usage reaches a threshold. Off by default; the account remains paused if no credit is available or reset fails.',
|
||||
threshold5h: '5h auto-reset threshold (%)',
|
||||
|
||||
@@ -1027,7 +1027,21 @@ export default {
|
||||
autoPause5hDisabled: '禁用 5h 自动暂停',
|
||||
autoPause7dDisabled: '禁用 7d 自动暂停',
|
||||
autoPauseDisabledHint: '开启后该账号永不进入自动暂停(即使全局默认阈值已配置)。',
|
||||
autoResetCredit: {
|
||||
claudeResetCredits: {
|
||||
count: '次数',
|
||||
countTooltipLoad: '点击查询 Claude 剩余重置次数(只读,不会消耗)',
|
||||
countTooltipRefresh: '点击刷新 Claude 剩余重置次数(只读,不会消耗)',
|
||||
fetched: '查询时间:{time}',
|
||||
error: '无法查询重置次数',
|
||||
ineligible: '此账号当前不可使用重置',
|
||||
cooldown: '冷却至 {time}',
|
||||
expiresAt: '到期 {time}',
|
||||
expiresAtFull: '重置次数到期时间:{time}',
|
||||
clears: '可清除窗口:{windows}',
|
||||
notUsableNow: '暂不可用',
|
||||
requiresLimit: '需达到限额后才能使用'
|
||||
},
|
||||
autoResetCredit: {
|
||||
title: '自动使用重置卡',
|
||||
hint: '仅在实际用量达到阈值时使用最早到期的可用卡;默认关闭。无卡或失败时账号保持暂停。',
|
||||
threshold5h: '5h 自动用卡阈值(%)',
|
||||
|
||||
Reference in New Issue
Block a user