eda66ff7d4
Token counts per rate limit window are now derived in Grafana via increase(counter[5h/168h]) on the existing cumulative OTel counters. Removes TokensIn/Out from Window, RecordTokens, setResetTime, and the window_tokens observable gauges.
179 lines
4.3 KiB
Go
179 lines
4.3 KiB
Go
package ratelimit
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// Window holds per-window usage state.
|
|
type Window struct {
|
|
Utilization float64 // 0-100 from API
|
|
ResetsAt time.Time // when window resets
|
|
}
|
|
|
|
// Snapshot is a read-only copy of a Window's state.
|
|
type Snapshot struct {
|
|
Utilization float64
|
|
ResetsAt time.Time
|
|
}
|
|
|
|
// Tracker polls /api/oauth/usage and tracks per-window token usage.
|
|
type Tracker struct {
|
|
tokenFn func() string // returns current OAuth access token
|
|
mu sync.RWMutex
|
|
fiveHour Window
|
|
sevenDay Window
|
|
sonnet Window
|
|
extra ExtraUsage
|
|
}
|
|
|
|
// NewTracker creates a tracker. tokenFn should return the current access token.
|
|
func NewTracker(tokenFn func() string) *Tracker {
|
|
return &Tracker{tokenFn: tokenFn}
|
|
}
|
|
|
|
// Start begins the background poll loop.
|
|
func (t *Tracker) Start(ctx context.Context) {
|
|
go func() {
|
|
// Poll immediately on start
|
|
t.poll(ctx)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(5 * time.Minute):
|
|
t.poll(ctx)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// UpdateFromHeaders extracts rate limit data from /v1/messages response headers.
|
|
// This provides real-time utilization updates without polling the usage API.
|
|
func (t *Tracker) UpdateFromHeaders(h http.Header) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
if v := h.Get("Anthropic-Ratelimit-Unified-5h-Utilization"); v != "" {
|
|
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
|
t.fiveHour.Utilization = f * 100 // header is 0-1, we store 0-100
|
|
}
|
|
}
|
|
if v := h.Get("Anthropic-Ratelimit-Unified-5h-Reset"); v != "" {
|
|
if ts, err := strconv.ParseInt(v, 10, 64); err == nil {
|
|
t.fiveHour.ResetsAt = time.Unix(ts, 0).UTC().Truncate(time.Minute)
|
|
}
|
|
}
|
|
if v := h.Get("Anthropic-Ratelimit-Unified-7d-Utilization"); v != "" {
|
|
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
|
t.sevenDay.Utilization = f * 100
|
|
}
|
|
}
|
|
if v := h.Get("Anthropic-Ratelimit-Unified-7d-Reset"); v != "" {
|
|
if ts, err := strconv.ParseInt(v, 10, 64); err == nil {
|
|
t.sevenDay.ResetsAt = time.Unix(ts, 0).UTC().Truncate(time.Minute)
|
|
}
|
|
}
|
|
}
|
|
|
|
// FiveHour returns a snapshot of the 5-hour window.
|
|
func (t *Tracker) FiveHour() Snapshot {
|
|
t.mu.RLock()
|
|
defer t.mu.RUnlock()
|
|
return Snapshot{
|
|
Utilization: t.fiveHour.Utilization,
|
|
ResetsAt: t.fiveHour.ResetsAt,
|
|
}
|
|
}
|
|
|
|
// SevenDay returns a snapshot of the 7-day window.
|
|
func (t *Tracker) SevenDay() Snapshot {
|
|
t.mu.RLock()
|
|
defer t.mu.RUnlock()
|
|
return Snapshot{
|
|
Utilization: t.sevenDay.Utilization,
|
|
ResetsAt: t.sevenDay.ResetsAt,
|
|
}
|
|
}
|
|
|
|
// Sonnet returns a snapshot of the 7-day sonnet window.
|
|
func (t *Tracker) Sonnet() Snapshot {
|
|
t.mu.RLock()
|
|
defer t.mu.RUnlock()
|
|
return Snapshot{
|
|
Utilization: t.sonnet.Utilization,
|
|
ResetsAt: t.sonnet.ResetsAt,
|
|
}
|
|
}
|
|
|
|
// Extra returns the current extra usage state.
|
|
func (t *Tracker) Extra() ExtraUsage {
|
|
t.mu.RLock()
|
|
defer t.mu.RUnlock()
|
|
return t.extra
|
|
}
|
|
|
|
func (t *Tracker) poll(ctx context.Context) {
|
|
token := t.tokenFn()
|
|
if token == "" {
|
|
return
|
|
}
|
|
|
|
usage, err := fetchUsage(ctx, token)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("usage poll failed")
|
|
return
|
|
}
|
|
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
if usage.FiveHour != nil {
|
|
t.updateWindow(&t.fiveHour, usage.FiveHour)
|
|
}
|
|
if usage.SevenDay != nil {
|
|
t.updateWindow(&t.sevenDay, usage.SevenDay)
|
|
}
|
|
if usage.SevenDaySonnet != nil {
|
|
t.updateWindow(&t.sonnet, usage.SevenDaySonnet)
|
|
}
|
|
if usage.ExtraUsage != nil {
|
|
t.extra = *usage.ExtraUsage
|
|
}
|
|
|
|
log.Debug().
|
|
Float64("5h_util", t.fiveHour.Utilization).
|
|
Time("5h_resets", t.fiveHour.ResetsAt).
|
|
Float64("7d_util", t.sevenDay.Utilization).
|
|
Time("7d_resets", t.sevenDay.ResetsAt).
|
|
Msg("usage poll")
|
|
|
|
// Warn on high utilization
|
|
if t.fiveHour.Utilization > 80 {
|
|
log.Warn().Float64("utilization", t.fiveHour.Utilization).Time("resets_at", t.fiveHour.ResetsAt).Msg("5h window utilization high")
|
|
}
|
|
if t.sevenDay.Utilization > 80 {
|
|
log.Warn().Float64("utilization", t.sevenDay.Utilization).Time("resets_at", t.sevenDay.ResetsAt).Msg("7d window utilization high")
|
|
}
|
|
}
|
|
|
|
func (t *Tracker) updateWindow(w *Window, rl *RateLimit) {
|
|
if rl.Utilization != nil {
|
|
w.Utilization = *rl.Utilization
|
|
}
|
|
if rl.ResetsAt != nil {
|
|
parsed, err := time.Parse(time.RFC3339Nano, *rl.ResetsAt)
|
|
if err != nil {
|
|
parsed, err = time.Parse(time.RFC3339, *rl.ResetsAt)
|
|
}
|
|
if err == nil {
|
|
w.ResetsAt = parsed.UTC().Truncate(time.Minute)
|
|
}
|
|
}
|
|
}
|