Files
llmgw/internal/store/selfusage.go
Ilker ManapandClaude Opus 5.5 4454e1322c Read usage from sums kept as it is written, not from every request
Charts, totals and budgets summed the request table on every view. Usage is
now also summed in the writing transaction into five-minute, hourly and daily
tables per key, person, project and model, with latency and first-token
histograms. Charts read the coarsest tier their slices allow; totals and
budgets compose rows, slices, hours and days, and stay exact. Request records
can be given a retention without moving any total.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:43:10 +02:00

149 lines
4.7 KiB
Go

package store
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
// PlaceUsage is one person's use in one place: a project, or on their own.
type PlaceUsage struct {
// ProjectID is empty for personal use.
ProjectID string
// Path names the project; empty for personal use.
Path string
Requests int64
Tokens int64
CostMicros int64
}
// UsageOfUser is one person's own use since a time: where it went, and how it
// ran over time in slices of width. What a person sees of themselves — every
// place they spent, and nothing anybody else did.
func (s *DB) UsageOfUser(
ctx context.Context, userID string, since time.Time, width time.Duration,
) ([]PlaceUsage, []Bucket, error) {
if userID == "" {
return nil, nil, fmt.Errorf("whose usage?")
}
src, args := usageSince(since)
places, err := queryAll(ctx, s.db, `
SELECT COALESCE(NULLIF(u.project_id, ''), ''),
COALESCE(o.name || ' / ' || t.name || ' / ' || p.name, ''),
SUM(u.requests), SUM(u.prompt_tokens + u.completion_tokens), SUM(u.cost_micros)
FROM `+src+` u
LEFT JOIN projects p ON p.id = NULLIF(u.project_id, '')
LEFT JOIN teams t ON t.id = p.team_id
LEFT JOIN organizations o ON o.id = t.org_id
WHERE u.user_id = ?
GROUP BY 1, 2
ORDER BY 4 DESC`, append(args, userID),
func(rows *sql.Rows) (PlaceUsage, error) {
var pu PlaceUsage
return pu, rows.Scan(&pu.ProjectID, &pu.Path, &pu.Requests, &pu.Tokens, &pu.CostMicros)
})
if err != nil {
return nil, nil, err
}
if width <= 0 {
width = 24 * time.Hour
}
ms := width.Milliseconds()
q, from := `
SELECT (ts / ?) * ?, COUNT(*),
SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END),
SUM(prompt_tokens), SUM(completion_tokens), SUM(cost_micros)
FROM usage WHERE user_id = ? AND ts >= ?
GROUP BY 1 ORDER BY 1`, millis(since)
if t, ok := tierForSlices(width); ok {
q, from = `
SELECT (bucket / ?) * ?, SUM(requests), SUM(errors),
SUM(prompt_tokens), SUM(completion_tokens), SUM(cost_micros)
FROM `+t.table+` WHERE user_id = ? AND bucket >= ?
GROUP BY 1 ORDER BY 1`, t.floor(since)
}
got, err := queryAll(ctx, s.db, q, []any{ms, ms, userID, from},
func(rows *sql.Rows) (Bucket, error) {
var b Bucket
var start int64
err := rows.Scan(&start, &b.Requests, &b.Errors, &b.Prompt, &b.Completion, &b.CostMicros)
b.Start = fromMillis(start)
return b, err
})
if err != nil {
return nil, nil, err
}
// Gap-filled, for the reason UsageBuckets is: a day with nothing in it is
// a day, not a hole.
at := map[int64]Bucket{}
for _, b := range got {
at[millis(b.Start)] = b
}
var buckets []Bucket
for t := (millis(since) / ms) * ms; t <= millis(time.Now()); t += ms {
b, ok := at[t]
if !ok {
b = Bucket{Start: fromMillis(t)}
}
buckets = append(buckets, b)
}
return places, buckets, nil
}
// SpendOfPool is what has been spent since a time from the pool a limit
// counts against — the figure its budget is compared with.
//
// The pool follows the level: a project, team or organization's budget is
// spent by everyone beneath it; a person's by every key they own and the chat
// page; a per-member limit by one person within one scope, whose ScopeID is
// "scopeID/userID". A key's own limit is not answered here, and gives zero.
func (s *DB) SpendOfPool(ctx context.Context, l Limits, since time.Time) (int64, error) {
under := func(scope Scope) (string, error) {
switch scope {
case ScopeProject:
return `NULLIF(u.project_id, '') = ?`, nil
case ScopeTeam:
return `NULLIF(u.project_id, '') IN (SELECT id FROM projects WHERE team_id = ?)`, nil
case ScopeOrg:
return `NULLIF(u.project_id, '') IN (SELECT p.id FROM projects p
JOIN teams t ON t.id = p.team_id WHERE t.org_id = ?)`, nil
}
return "", fmt.Errorf("no pool for %q", scope)
}
var where string
var args []any
switch l.Scope {
case ScopeUser:
where, args = `u.user_id = ?`, []any{l.ScopeID}
case ScopeOrg, ScopeTeam, ScopeProject:
w, err := under(l.Scope)
if err != nil {
return 0, err
}
where, args = w, []any{l.ScopeID}
case ScopeOrgMember, ScopeTeamMember, ScopeProjectMember:
scopeID, userID, ok := strings.Cut(l.ScopeID, "/")
if !ok {
return 0, fmt.Errorf("a per-member pool is scope/user, not %q", l.ScopeID)
}
level := map[Scope]Scope{ScopeOrgMember: ScopeOrg, ScopeTeamMember: ScopeTeam,
ScopeProjectMember: ScopeProject}[l.Scope]
w, err := under(level)
if err != nil {
return 0, err
}
where, args = `u.user_id = ? AND `+w, []any{userID, scopeID}
default:
return 0, nil
}
var total sql.NullInt64
src, srcArgs := usageSince(since)
err := s.db.QueryRowContext(ctx,
`SELECT SUM(u.cost_micros) FROM `+src+` u WHERE `+where,
append(srcArgs, args...)...).Scan(&total)
return total.Int64, err
}