feat(claude): expose sanitized native reset credit status

This commit is contained in:
korkin25
2026-09-28 18:04:13 -04:00
committed by William Drake
parent 9a62841fd1
commit 6a8f921650
9 changed files with 356 additions and 1 deletions
+2 -1
View File
@@ -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)
+2
View File
@@ -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=
@@ -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)
}
+2
View File
@@ -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,
+1
View File
@@ -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,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
}
@@ -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")
}
})
}
}
+1
View File
@@ -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)),