From 6a8f921650bbdccc7e0cc58415d9b2fe97aa1197 Mon Sep 17 00:00:00 2001 From: korkin25 Date: Fri, 25 Sep 2026 06:02:27 +0800 Subject: [PATCH] feat(claude): expose sanitized native reset credit status --- backend/cmd/server/wire_gen.go | 3 +- backend/go.sum | 2 + .../internal/handler/admin/account_handler.go | 1 + .../handler/admin/claude_reset_handler.go | 34 +++ backend/internal/handler/wire.go | 2 + backend/internal/server/routes/admin.go | 1 + .../internal/service/claude_reset_credits.go | 212 ++++++++++++++++++ .../service/claude_reset_credits_test.go | 101 +++++++++ backend/internal/service/wire.go | 1 + 9 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 backend/internal/handler/admin/claude_reset_handler.go create mode 100644 backend/internal/service/claude_reset_credits.go create mode 100644 backend/internal/service/claude_reset_credits_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index d77f515e1a..a74ae81290 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -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) diff --git a/backend/go.sum b/backend/go.sum index caf01c3b6b..a8fffe4904 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -253,6 +253,8 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 8780fcded3..3d0d2ec510 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -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 diff --git a/backend/internal/handler/admin/claude_reset_handler.go b/backend/internal/handler/admin/claude_reset_handler.go new file mode 100644 index 0000000000..8ec456444a --- /dev/null +++ b/backend/internal/handler/admin/claude_reset_handler.go @@ -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) +} diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index bac8e0a3a0..f3f08ad366 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -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, diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index d0737f0397..ab28619b62 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -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) diff --git a/backend/internal/service/claude_reset_credits.go b/backend/internal/service/claude_reset_credits.go new file mode 100644 index 0000000000..fe674face0 --- /dev/null +++ b/backend/internal/service/claude_reset_credits.go @@ -0,0 +1,212 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "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 { + SelectionToken string `json:"selection_token"` + 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, *claudeResetBlock, error) { + _, token, proxy, err := s.account(ctx, id) + if err != nil { + return nil, nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeResetUsageURL, nil) + if err != nil { + return nil, nil, err + } + s.headers(ctx, req, token) + resp, err := s.do(req, proxy) + if err != nil { + return nil, nil, infraerrors.ServiceUnavailable("CLAUDE_RESET_QUERY_FAILED", "reset status request failed") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, 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, nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset status") + } + if _, ok := envelope["error"]; ok { + return nil, 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, nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset grants") + } + } + result := projectClaudeResetCredits(block, s.now()) + return result, block, nil +} + +func (s *ClaudeResetCreditService) Query(ctx context.Context, id int64) (*ClaudeResetCredits, error) { + r, _, e := s.query(ctx, id) + return r, e +} + +func claudeGrantSelection(g claudeResetGrant) string { + h := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", g.ID, g.ResetsLeft))) + return hex.EncodeToString(h[:]) +} + +func projectClaudeResetCredits(b *claudeResetBlock, now time.Time) *ClaudeResetCredits { + r := &ClaudeResetCredits{Credits: []ClaudeResetCredit{}, FetchedAt: now.UTC()} + if b == nil { + return r + } + r.Eligible = b.Eligible + 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{SelectionToken: claudeGrantSelection(g), 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 +} diff --git a/backend/internal/service/claude_reset_credits_test.go b/backend/internal/service/claude_reset_credits_test.go new file mode 100644 index 0000000000..e2f4b99a4a --- /dev/null +++ b/backend/internal/service/claude_reset_credits_test.go @@ -0,0 +1,101 @@ +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") +} +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") + } + }) + } +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 31362723b5..6721fd8691 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -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)),