Skip to content

Commit 8a61014

Browse files
StarryKiraclaude
andcommitted
fix(clandes): 管理后台 current_window_cost/current_rpm 与实际不符
Cap'n Proto 路径下 session_window_* 从不被写入,GetCurrentWindowStartTime 退回"当前整点"预测,导致 current_window_cost 只覆盖 ≤1h 而非 5h,整点还会 归零;current_rpm 因从未调 IncrementAccountRPM 而恒为 0。 为 ratelimit_service 抽出一个无需响应头的 EnsurePredictedSessionWindow, 在 clandes ReportUsage 成功后对 Anthropic OAuth/SetupToken 账号持久化 hour-truncated 5h 窗口,并 bump RPM 计数器,令管理后台数据落到真实 5h 窗口上。公式与 UpdateSessionWindow 的 header-missing fallback 保持一致, Phase 2(Cap'n Proto 透传真实 header)落地后会自然覆盖此预测。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 25c0982 commit 8a61014

5 files changed

Lines changed: 50 additions & 2 deletions

File tree

backend/cmd/server/wire_gen.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/internal/service/clandes_client.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ type ClandesClient struct {
4343
billingCacheService *BillingCacheService
4444
apiKeyService *APIKeyService
4545
subscriptionService *SubscriptionService
46+
rateLimitService *RateLimitService
4647

4748
closed chan struct{}
4849
closeOnce sync.Once
@@ -57,6 +58,7 @@ func NewClandesClient(
5758
billingCacheService *BillingCacheService,
5859
apiKeyService *APIKeyService,
5960
subscriptionService *SubscriptionService,
61+
rateLimitService *RateLimitService,
6062
) *ClandesClient {
6163
interval := time.Duration(reconnectInterval) * time.Second
6264
if interval <= 0 {
@@ -70,6 +72,7 @@ func NewClandesClient(
7072
billingCacheService: billingCacheService,
7173
apiKeyService: apiKeyService,
7274
subscriptionService: subscriptionService,
75+
rateLimitService: rateLimitService,
7376
reqCache: newClandesRequestCache(),
7477
closed: make(chan struct{}),
7578
}
@@ -407,7 +410,7 @@ func (c *ClandesClient) registerCallback(ctx context.Context) error {
407410
defer c.mu.Unlock()
408411

409412
// Build the Router server
410-
c.routerImpl = newClandesRouterImpl(c.reqCache, c.gatewayService, c.billingCacheService, c.apiKeyService, c.subscriptionService)
413+
c.routerImpl = newClandesRouterImpl(c.reqCache, c.gatewayService, c.billingCacheService, c.apiKeyService, c.subscriptionService, c.rateLimitService)
411414
routerClient := proto.Router_ServerToClient(c.routerImpl)
412415

413416
// Register with clandes

backend/internal/service/clandes_router.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type clandesRouterImpl struct {
2020
billingCacheService *BillingCacheService
2121
apiKeyService *APIKeyService
2222
subscriptionService *SubscriptionService
23+
rateLimitService *RateLimitService
2324
}
2425

2526
func newClandesRouterImpl(
@@ -28,13 +29,15 @@ func newClandesRouterImpl(
2829
billingSvc *BillingCacheService,
2930
apiKeySvc *APIKeyService,
3031
subSvc *SubscriptionService,
32+
rateLimitSvc *RateLimitService,
3133
) *clandesRouterImpl {
3234
return &clandesRouterImpl{
3335
reqCache: reqCache,
3436
gatewayService: gatewaySvc,
3537
billingCacheService: billingSvc,
3638
apiKeyService: apiKeySvc,
3739
subscriptionService: subSvc,
40+
rateLimitService: rateLimitSvc,
3841
}
3942
}
4043

@@ -271,6 +274,21 @@ func (r *clandesRouterImpl) ReportUsage(ctx context.Context, call proto.Router_r
271274
r.subscriptionService.InvalidateSubCache(rctx.User.ID, *rctx.APIKey.GroupID)
272275
_ = r.billingCacheService.InvalidateSubscription(bctx, rctx.User.ID, *rctx.APIKey.GroupID)
273276
}
277+
278+
// Cap'n Proto path has no anthropic-ratelimit-unified-5h-* headers,
279+
// so session_window_start/end never populate via UpdateSessionWindow.
280+
// Persist a predicted 5h window and bump the per-minute RPM so the
281+
// admin page's current_window_cost and current_rpm reflect reality.
282+
if rctx.Account != nil && rctx.Account.IsAnthropicOAuthOrSetupToken() {
283+
if r.rateLimitService != nil && rctx.Account.GetWindowCostLimit() > 0 {
284+
r.rateLimitService.EnsurePredictedSessionWindow(bctx, rctx.Account)
285+
}
286+
if rctx.Account.GetBaseRPM() > 0 {
287+
if err := r.gatewayService.IncrementAccountRPM(bctx, rctx.Account.ID); err != nil {
288+
log.Warn("reportUsage: rpm increment failed", zap.Error(err))
289+
}
290+
}
291+
}
274292
}()
275293

276294
return nil

backend/internal/service/ratelimit_service.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,6 +1101,31 @@ func (s *RateLimitService) handle529(ctx context.Context, account *Account) {
11011101
slog.Info("account_overloaded", "account_id", account.ID, "until", until)
11021102
}
11031103

1104+
// EnsurePredictedSessionWindow 在没有 Anthropic 响应头的情况下持久化一个预测的 5h 窗口。
1105+
// 当窗口已存在且未过期时为 no-op。用于 clandes Cap'n Proto 路径等无法拿到
1106+
// anthropic-ratelimit-unified-5h-* 响应头的调用方,否则 GetCurrentWindowStartTime
1107+
// 会一直退回到"当前整点"预测,导致 current_window_cost 只覆盖不足 1 小时。
1108+
// 预测公式与 UpdateSessionWindow 的 header-missing 回退分支保持一致。
1109+
func (s *RateLimitService) EnsurePredictedSessionWindow(ctx context.Context, account *Account) {
1110+
if s == nil || s.accountRepo == nil || account == nil {
1111+
return
1112+
}
1113+
if account.SessionWindowEnd != nil && time.Now().Before(*account.SessionWindowEnd) {
1114+
return
1115+
}
1116+
now := time.Now()
1117+
start := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
1118+
end := start.Add(5 * time.Hour)
1119+
if err := s.accountRepo.UpdateSessionWindow(ctx, account.ID, &start, &end, "allowed"); err != nil {
1120+
slog.Warn("predicted_session_window_update_failed", "account_id", account.ID, "error", err)
1121+
return
1122+
}
1123+
account.SessionWindowStart = &start
1124+
account.SessionWindowEnd = &end
1125+
account.SessionWindowStatus = "allowed"
1126+
slog.Info("predicted_session_window_initialized", "account_id", account.ID, "window_start", start, "window_end", end)
1127+
}
1128+
11041129
// UpdateSessionWindow 从成功响应更新5h窗口状态
11051130
func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Account, headers http.Header) {
11061131
status := headers.Get("anthropic-ratelimit-unified-5h-status")

backend/internal/service/wire.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ func ProvideClandesClient(
498498
billingCacheSvc *BillingCacheService,
499499
apiKeySvc *APIKeyService,
500500
subscriptionSvc *SubscriptionService,
501+
rateLimitSvc *RateLimitService,
501502
accountRepo AccountRepository,
502503
) *ClandesClient {
503504
if !cfg.Clandes.Enabled {
@@ -512,6 +513,7 @@ func ProvideClandesClient(
512513
billingCacheSvc,
513514
apiKeySvc,
514515
subscriptionSvc,
516+
rateLimitSvc,
515517
)
516518

517519
syncFn := func(ctx context.Context, c *ClandesClient) error {

0 commit comments

Comments
 (0)