mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-03 06:52:13 +08:00
fix(usage): use real reset header for session window instead of prediction
The 5h window reset time displayed for Setup Token accounts was inaccurate because UpdateSessionWindow predicted the window end as "current hour + 5h" instead of reading the actual `anthropic-ratelimit-unified-5h-reset` response header. This caused the countdown to differ from the official Claude page. Backend: parse the reset header (Unix timestamp) and use it as the real window end, falling back to the hour-truncated prediction only when the header is absent. Also correct stale predictions when a subsequent request provides the real reset time. Frontend: add a reactive 60s timer so the reset countdown in UsageProgressBar ticks down in real-time instead of freezing at the initial value. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1051,16 +1051,32 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc
|
|||||||
var windowStart, windowEnd *time.Time
|
var windowStart, windowEnd *time.Time
|
||||||
needInitWindow := account.SessionWindowEnd == nil || time.Now().After(*account.SessionWindowEnd)
|
needInitWindow := account.SessionWindowEnd == nil || time.Now().After(*account.SessionWindowEnd)
|
||||||
|
|
||||||
if needInitWindow && (status == "allowed" || status == "allowed_warning") {
|
// 优先使用响应头中的真实重置时间(比预测更准确)
|
||||||
// 预测时间窗口:从当前时间的整点开始,+5小时为结束
|
if resetStr := headers.Get("anthropic-ratelimit-unified-5h-reset"); resetStr != "" {
|
||||||
// 例如:现在是 14:30,窗口为 14:00 ~ 19:00
|
if ts, err := strconv.ParseInt(resetStr, 10, 64); err == nil {
|
||||||
|
end := time.Unix(ts, 0)
|
||||||
|
// 窗口需要初始化,或者真实重置时间与已存储的不同,则更新
|
||||||
|
if needInitWindow || account.SessionWindowEnd == nil || !end.Equal(*account.SessionWindowEnd) {
|
||||||
|
start := end.Add(-5 * time.Hour)
|
||||||
|
windowStart = &start
|
||||||
|
windowEnd = &end
|
||||||
|
slog.Info("account_session_window_from_header", "account_id", account.ID, "window_start", start, "window_end", end, "status", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:如果没有真实重置时间且需要初始化窗口,使用预测
|
||||||
|
if windowEnd == nil && needInitWindow && (status == "allowed" || status == "allowed_warning") {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
start := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
|
start := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
|
||||||
end := start.Add(5 * time.Hour)
|
end := start.Add(5 * time.Hour)
|
||||||
windowStart = &start
|
windowStart = &start
|
||||||
windowEnd = &end
|
windowEnd = &end
|
||||||
slog.Info("account_session_window_initialized", "account_id", account.ID, "window_start", start, "window_end", end, "status", status)
|
slog.Info("account_session_window_initialized", "account_id", account.ID, "window_start", start, "window_end", end, "status", status)
|
||||||
// 窗口重置时清除旧的 utilization,避免残留上个窗口的数据
|
}
|
||||||
|
|
||||||
|
// 窗口重置时清除旧的 utilization,避免残留上个窗口的数据
|
||||||
|
if windowEnd != nil && needInitWindow {
|
||||||
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
|
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
|
||||||
"session_window_utilization": nil,
|
"session_window_utilization": nil,
|
||||||
})
|
})
|
||||||
|
|||||||
366
backend/internal/service/ratelimit_session_window_test.go
Normal file
366
backend/internal/service/ratelimit_session_window_test.go
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionWindowMockRepo is a minimal AccountRepository mock that records calls
|
||||||
|
// made by UpdateSessionWindow. Unrelated methods panic if invoked.
|
||||||
|
type sessionWindowMockRepo struct {
|
||||||
|
// captured calls
|
||||||
|
sessionWindowCalls []swCall
|
||||||
|
updateExtraCalls []ueCall
|
||||||
|
clearRateLimitIDs []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type swCall struct {
|
||||||
|
ID int64
|
||||||
|
Start *time.Time
|
||||||
|
End *time.Time
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ueCall struct {
|
||||||
|
ID int64
|
||||||
|
Updates map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *sessionWindowMockRepo) UpdateSessionWindow(_ context.Context, id int64, start, end *time.Time, status string) error {
|
||||||
|
m.sessionWindowCalls = append(m.sessionWindowCalls, swCall{ID: id, Start: start, End: end, Status: status})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
|
||||||
|
m.updateExtraCalls = append(m.updateExtraCalls, ueCall{ID: id, Updates: updates})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ClearRateLimit(_ context.Context, id int64) error {
|
||||||
|
m.clearRateLimitIDs = append(m.clearRateLimitIDs, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ClearAntigravityQuotaScopes(_ context.Context, _ int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ClearModelRateLimits(_ context.Context, _ int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ClearTempUnschedulable(_ context.Context, _ int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Unused interface methods (panic on unexpected call) ---
|
||||||
|
|
||||||
|
func (m *sessionWindowMockRepo) Create(context.Context, *Account) error { panic("unexpected") }
|
||||||
|
func (m *sessionWindowMockRepo) GetByID(context.Context, int64) (*Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) GetByIDs(context.Context, []int64) ([]*Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ExistsByID(context.Context, int64) (bool, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) GetByCRSAccountID(context.Context, string) (*Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) FindByExtraField(context.Context, string, any) ([]Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ListCRSAccountIDs(context.Context) (map[string]int64, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) Update(context.Context, *Account) error { panic("unexpected") }
|
||||||
|
func (m *sessionWindowMockRepo) Delete(context.Context, int64) error { panic("unexpected") }
|
||||||
|
func (m *sessionWindowMockRepo) List(context.Context, pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string, string, int64) ([]Account, *pagination.PaginationResult, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ListByGroup(context.Context, int64) ([]Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ListActive(context.Context) ([]Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ListByPlatform(context.Context, string) ([]Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) UpdateLastUsed(context.Context, int64) error { panic("unexpected") }
|
||||||
|
func (m *sessionWindowMockRepo) BatchUpdateLastUsed(context.Context, map[int64]time.Time) error {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) SetError(context.Context, int64, string) error {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ClearError(context.Context, int64) error { panic("unexpected") }
|
||||||
|
func (m *sessionWindowMockRepo) SetSchedulable(context.Context, int64, bool) error {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) AutoPauseExpiredAccounts(context.Context, time.Time) (int64, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) BindGroups(context.Context, int64, []int64) error {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ListSchedulable(context.Context) ([]Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ListSchedulableByGroupID(context.Context, int64) ([]Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ListSchedulableByPlatform(context.Context, string) ([]Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ListSchedulableByGroupIDAndPlatform(context.Context, int64, string) ([]Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ListSchedulableByPlatforms(context.Context, []string) ([]Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ListSchedulableByGroupIDAndPlatforms(context.Context, int64, []string) ([]Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ListSchedulableUngroupedByPlatform(context.Context, string) ([]Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ListSchedulableUngroupedByPlatforms(context.Context, []string) ([]Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) SetRateLimited(context.Context, int64, time.Time) error {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) SetModelRateLimit(context.Context, int64, string, time.Time) error {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) SetOverloaded(context.Context, int64, time.Time) error {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) SetTempUnschedulable(context.Context, int64, time.Time, string) error {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) BulkUpdate(context.Context, []int64, AccountBulkUpdate) (int64, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) IncrementQuotaUsed(context.Context, int64, float64) error {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
|
func (m *sessionWindowMockRepo) ResetQuotaUsed(context.Context, int64) error { panic("unexpected") }
|
||||||
|
|
||||||
|
// newRateLimitServiceForTest creates a RateLimitService with the given mock repo.
|
||||||
|
func newRateLimitServiceForTest(repo AccountRepository) *RateLimitService {
|
||||||
|
return &RateLimitService{accountRepo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSessionWindow_UsesResetHeader(t *testing.T) {
|
||||||
|
// The reset header provides the real window end as a Unix timestamp.
|
||||||
|
// UpdateSessionWindow should use it instead of the hour-truncated prediction.
|
||||||
|
resetUnix := int64(1771020000) // 2026-02-14T10:00:00Z
|
||||||
|
wantEnd := time.Unix(resetUnix, 0)
|
||||||
|
wantStart := wantEnd.Add(-5 * time.Hour)
|
||||||
|
|
||||||
|
repo := &sessionWindowMockRepo{}
|
||||||
|
svc := newRateLimitServiceForTest(repo)
|
||||||
|
|
||||||
|
account := &Account{ID: 42} // no existing window → needInitWindow=true
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", resetUnix))
|
||||||
|
|
||||||
|
svc.UpdateSessionWindow(context.Background(), account, headers)
|
||||||
|
|
||||||
|
if len(repo.sessionWindowCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
call := repo.sessionWindowCalls[0]
|
||||||
|
if call.ID != 42 {
|
||||||
|
t.Errorf("expected account ID 42, got %d", call.ID)
|
||||||
|
}
|
||||||
|
if call.End == nil || !call.End.Equal(wantEnd) {
|
||||||
|
t.Errorf("expected window end %v, got %v", wantEnd, call.End)
|
||||||
|
}
|
||||||
|
if call.Start == nil || !call.Start.Equal(wantStart) {
|
||||||
|
t.Errorf("expected window start %v, got %v", wantStart, call.Start)
|
||||||
|
}
|
||||||
|
if call.Status != "allowed" {
|
||||||
|
t.Errorf("expected status 'allowed', got %q", call.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSessionWindow_FallbackPredictionWhenNoResetHeader(t *testing.T) {
|
||||||
|
// When the reset header is absent, should fall back to hour-truncated prediction.
|
||||||
|
repo := &sessionWindowMockRepo{}
|
||||||
|
svc := newRateLimitServiceForTest(repo)
|
||||||
|
|
||||||
|
account := &Account{ID: 10} // no existing window
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-status", "allowed_warning")
|
||||||
|
// No anthropic-ratelimit-unified-5h-reset header
|
||||||
|
|
||||||
|
svc.UpdateSessionWindow(context.Background(), account, headers)
|
||||||
|
|
||||||
|
if len(repo.sessionWindowCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
call := repo.sessionWindowCalls[0]
|
||||||
|
if call.End == nil {
|
||||||
|
t.Fatal("expected window end to be set (fallback prediction)")
|
||||||
|
}
|
||||||
|
// Fallback: start = current hour truncated, end = start + 5h
|
||||||
|
now := time.Now()
|
||||||
|
expectedStart := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
|
||||||
|
expectedEnd := expectedStart.Add(5 * time.Hour)
|
||||||
|
|
||||||
|
if !call.End.Equal(expectedEnd) {
|
||||||
|
t.Errorf("expected fallback end %v, got %v", expectedEnd, *call.End)
|
||||||
|
}
|
||||||
|
if call.Start == nil || !call.Start.Equal(expectedStart) {
|
||||||
|
t.Errorf("expected fallback start %v, got %v", expectedStart, call.Start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSessionWindow_CorrectsStalePrediction(t *testing.T) {
|
||||||
|
// When the stored SessionWindowEnd is wrong (from a previous prediction),
|
||||||
|
// and the reset header provides the real time, it should update the window.
|
||||||
|
staleEnd := time.Now().Add(2 * time.Hour) // existing prediction: 2h from now
|
||||||
|
realResetUnix := time.Now().Add(4 * time.Hour).Unix() // real reset: 4h from now
|
||||||
|
wantEnd := time.Unix(realResetUnix, 0)
|
||||||
|
|
||||||
|
repo := &sessionWindowMockRepo{}
|
||||||
|
svc := newRateLimitServiceForTest(repo)
|
||||||
|
|
||||||
|
account := &Account{
|
||||||
|
ID: 55,
|
||||||
|
SessionWindowEnd: &staleEnd,
|
||||||
|
}
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", realResetUnix))
|
||||||
|
|
||||||
|
svc.UpdateSessionWindow(context.Background(), account, headers)
|
||||||
|
|
||||||
|
if len(repo.sessionWindowCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
call := repo.sessionWindowCalls[0]
|
||||||
|
if call.End == nil || !call.End.Equal(wantEnd) {
|
||||||
|
t.Errorf("expected corrected end %v, got %v", wantEnd, call.End)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSessionWindow_NoUpdateWhenHeaderMatchesStored(t *testing.T) {
|
||||||
|
// If the reset header matches the stored SessionWindowEnd, no window update needed.
|
||||||
|
futureUnix := time.Now().Add(3 * time.Hour).Unix()
|
||||||
|
existingEnd := time.Unix(futureUnix, 0)
|
||||||
|
|
||||||
|
repo := &sessionWindowMockRepo{}
|
||||||
|
svc := newRateLimitServiceForTest(repo)
|
||||||
|
|
||||||
|
account := &Account{
|
||||||
|
ID: 77,
|
||||||
|
SessionWindowEnd: &existingEnd,
|
||||||
|
}
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", futureUnix)) // same as stored
|
||||||
|
|
||||||
|
svc.UpdateSessionWindow(context.Background(), account, headers)
|
||||||
|
|
||||||
|
if len(repo.sessionWindowCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
call := repo.sessionWindowCalls[0]
|
||||||
|
// windowStart and windowEnd should be nil (no update needed)
|
||||||
|
if call.Start != nil || call.End != nil {
|
||||||
|
t.Errorf("expected nil start/end (no window change needed), got start=%v end=%v", call.Start, call.End)
|
||||||
|
}
|
||||||
|
// Status is still updated
|
||||||
|
if call.Status != "allowed" {
|
||||||
|
t.Errorf("expected status 'allowed', got %q", call.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSessionWindow_ClearsUtilizationOnWindowReset(t *testing.T) {
|
||||||
|
// When needInitWindow=true and window is set, utilization should be cleared.
|
||||||
|
resetUnix := int64(1771020000)
|
||||||
|
|
||||||
|
repo := &sessionWindowMockRepo{}
|
||||||
|
svc := newRateLimitServiceForTest(repo)
|
||||||
|
|
||||||
|
account := &Account{ID: 33} // no existing window → needInitWindow=true
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", resetUnix))
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.15")
|
||||||
|
|
||||||
|
svc.UpdateSessionWindow(context.Background(), account, headers)
|
||||||
|
|
||||||
|
// Should have 2 UpdateExtra calls: one to clear utilization, one to store new utilization
|
||||||
|
if len(repo.updateExtraCalls) != 2 {
|
||||||
|
t.Fatalf("expected 2 UpdateExtra calls, got %d", len(repo.updateExtraCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
// First call: clear utilization (nil value)
|
||||||
|
clearCall := repo.updateExtraCalls[0]
|
||||||
|
if clearCall.Updates["session_window_utilization"] != nil {
|
||||||
|
t.Errorf("expected utilization cleared to nil, got %v", clearCall.Updates["session_window_utilization"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second call: store new utilization
|
||||||
|
storeCall := repo.updateExtraCalls[1]
|
||||||
|
if val, ok := storeCall.Updates["session_window_utilization"].(float64); !ok || val != 0.15 {
|
||||||
|
t.Errorf("expected utilization stored as 0.15, got %v", storeCall.Updates["session_window_utilization"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSessionWindow_NoClearUtilizationOnCorrection(t *testing.T) {
|
||||||
|
// When correcting a stale prediction (needInitWindow=false), utilization should NOT be cleared.
|
||||||
|
staleEnd := time.Now().Add(2 * time.Hour)
|
||||||
|
realResetUnix := time.Now().Add(4 * time.Hour).Unix()
|
||||||
|
|
||||||
|
repo := &sessionWindowMockRepo{}
|
||||||
|
svc := newRateLimitServiceForTest(repo)
|
||||||
|
|
||||||
|
account := &Account{
|
||||||
|
ID: 66,
|
||||||
|
SessionWindowEnd: &staleEnd,
|
||||||
|
}
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", realResetUnix))
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.30")
|
||||||
|
|
||||||
|
svc.UpdateSessionWindow(context.Background(), account, headers)
|
||||||
|
|
||||||
|
// Only 1 UpdateExtra call (store utilization), no clear call
|
||||||
|
if len(repo.updateExtraCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 UpdateExtra call (no clear), got %d", len(repo.updateExtraCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := repo.updateExtraCalls[0].Updates["session_window_utilization"].(float64); !ok || val != 0.30 {
|
||||||
|
t.Errorf("expected utilization 0.30, got %v", repo.updateExtraCalls[0].Updates["session_window_utilization"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSessionWindow_NoStatusHeader(t *testing.T) {
|
||||||
|
// Should return immediately if no status header.
|
||||||
|
repo := &sessionWindowMockRepo{}
|
||||||
|
svc := newRateLimitServiceForTest(repo)
|
||||||
|
|
||||||
|
account := &Account{ID: 1}
|
||||||
|
|
||||||
|
svc.UpdateSessionWindow(context.Background(), account, http.Header{})
|
||||||
|
|
||||||
|
if len(repo.sessionWindowCalls) != 0 {
|
||||||
|
t.Errorf("expected no calls when status header absent, got %d", len(repo.sessionWindowCalls))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { useIntervalFn } from '@vueuse/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { WindowStats } from '@/types'
|
import type { WindowStats } from '@/types'
|
||||||
import { formatCompactNumber } from '@/utils/format'
|
import { formatCompactNumber } from '@/utils/format'
|
||||||
@@ -71,6 +72,12 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Reactive clock for countdown (updates every 60s)
|
||||||
|
const now = ref(new Date())
|
||||||
|
useIntervalFn(() => {
|
||||||
|
now.value = new Date()
|
||||||
|
}, 60_000)
|
||||||
|
|
||||||
// Label background colors
|
// Label background colors
|
||||||
const labelClass = computed(() => {
|
const labelClass = computed(() => {
|
||||||
const colors = {
|
const colors = {
|
||||||
@@ -119,8 +126,7 @@ const displayPercent = computed(() => {
|
|||||||
const formatResetTime = computed(() => {
|
const formatResetTime = computed(() => {
|
||||||
if (!props.resetsAt) return '-'
|
if (!props.resetsAt) return '-'
|
||||||
const date = new Date(props.resetsAt)
|
const date = new Date(props.resetsAt)
|
||||||
const now = new Date()
|
const diffMs = date.getTime() - now.value.getTime()
|
||||||
const diffMs = date.getTime() - now.getTime()
|
|
||||||
|
|
||||||
if (diffMs <= 0) return '现在'
|
if (diffMs <= 0) return '现在'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user