Unlimited was the default. Creating a project without limits is the easy path and the common one — the "default" project on this cluster is one, and the person using it is metered by nothing at all. The first sign of that is the bill or the outage. auth.default_rpm and default_tpm now apply to a key no scope gives a limit to. Zero keeps the old behaviour, which is still a reasonable choice; the point is that it becomes a choice rather than an omission. Applied after the chain rather than folded in as its widest scope, because a default is a fallback and not a ceiling: a project deliberately given 600 rpm must keep it, and tightening would silently cap it at the default. An explicit limit wins in both directions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
638 lines
18 KiB
Go
638 lines
18 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite" // the cgo-free driver, so the binary stays static
|
|
)
|
|
|
|
// SQLite is the single-instance store.
|
|
type SQLite struct {
|
|
db *sql.DB
|
|
|
|
// mu guards the default limits, which are set once at wiring but read on
|
|
// every authentication.
|
|
mu sync.RWMutex
|
|
defaultRPM *int
|
|
defaultTPM *int
|
|
}
|
|
|
|
// OpenSQLite opens (creating if needed) a database and brings it up to date.
|
|
//
|
|
// The pragmas are the ones that matter. WAL gives concurrent readers alongside
|
|
// the single writer, which is the access pattern exactly: many reads for
|
|
// authentication, one batched writer for usage. foreign_keys is off by default
|
|
// in SQLite, which would silently let the tenancy tree grow orphans. busy_timeout
|
|
// turns "database is locked" from an error into a wait, which is what a caller
|
|
// wants when the batch writer holds the write lock for a few milliseconds.
|
|
func OpenSQLite(ctx context.Context, path string) (*SQLite, error) {
|
|
dsn := path + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)"
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// One writer is what SQLite supports; more connections would only produce
|
|
// lock contention that looks like a deadlock under load.
|
|
db.SetMaxOpenConns(1)
|
|
|
|
if err := db.PingContext(ctx); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("opening %s: %w", path, err)
|
|
}
|
|
if err := migrate(ctx, db, "sqlite"); err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
// SQLite creates the file with the process umask, which on a stock system
|
|
// means world-readable. What is in here is not a working credential — keys
|
|
// are stored hashed — but it is the whole tenancy tree and every request
|
|
// anyone made, and that should not be readable by every account on the
|
|
// machine because nobody remembered to chmod it.
|
|
for _, f := range []string{path, path + "-wal", path + "-shm"} {
|
|
if err := os.Chmod(f, 0o600); err != nil && !os.IsNotExist(err) {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("securing %s: %w", f, err)
|
|
}
|
|
}
|
|
return &SQLite{db: db}, nil
|
|
}
|
|
|
|
// Close releases the database.
|
|
func (s *SQLite) Close() error { return s.db.Close() }
|
|
|
|
// --- tenancy ---
|
|
|
|
// PutOrg upserts an organization.
|
|
func (s *SQLite) PutOrg(ctx context.Context, o Org) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO organizations (id, name) VALUES (?, ?)
|
|
ON CONFLICT (id) DO UPDATE SET name = excluded.name`, o.ID, o.Name)
|
|
return err
|
|
}
|
|
|
|
// PutTeam upserts a team.
|
|
func (s *SQLite) PutTeam(ctx context.Context, t Team) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO teams (id, org_id, name) VALUES (?, ?, ?)
|
|
ON CONFLICT (id) DO UPDATE SET org_id = excluded.org_id, name = excluded.name`,
|
|
t.ID, t.OrgID, t.Name)
|
|
return err
|
|
}
|
|
|
|
// PutProject upserts a project.
|
|
func (s *SQLite) PutProject(ctx context.Context, p Project) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO projects (id, team_id, name) VALUES (?, ?, ?)
|
|
ON CONFLICT (id) DO UPDATE SET team_id = excluded.team_id, name = excluded.name`,
|
|
p.ID, p.TeamID, p.Name)
|
|
return err
|
|
}
|
|
|
|
// Orgs lists every organization.
|
|
func (s *SQLite) Orgs(ctx context.Context) ([]Org, error) {
|
|
return queryAll(ctx, s.db, `SELECT id, name FROM organizations ORDER BY name`,
|
|
nil, func(rows *sql.Rows) (Org, error) {
|
|
var o Org
|
|
return o, rows.Scan(&o.ID, &o.Name)
|
|
})
|
|
}
|
|
|
|
// Teams lists an organization's teams.
|
|
func (s *SQLite) Teams(ctx context.Context, orgID string) ([]Team, error) {
|
|
return queryAll(ctx, s.db, `SELECT id, org_id, name FROM teams WHERE org_id = ? ORDER BY name`,
|
|
[]any{orgID}, func(rows *sql.Rows) (Team, error) {
|
|
var t Team
|
|
return t, rows.Scan(&t.ID, &t.OrgID, &t.Name)
|
|
})
|
|
}
|
|
|
|
// Projects lists a team's projects.
|
|
func (s *SQLite) Projects(ctx context.Context, teamID string) ([]Project, error) {
|
|
return queryAll(ctx, s.db, `SELECT id, team_id, name FROM projects WHERE team_id = ? ORDER BY name`,
|
|
[]any{teamID}, func(rows *sql.Rows) (Project, error) {
|
|
var p Project
|
|
return p, rows.Scan(&p.ID, &p.TeamID, &p.Name)
|
|
})
|
|
}
|
|
|
|
// --- keys ---
|
|
|
|
const keyColumns = `id, key_hash, key_prefix, name, project_id, band, routing_profile,
|
|
created_at, expires_at, revoked_at, last_used_at`
|
|
|
|
// CreateKey stores a key. Only its hash is written; the key itself is the
|
|
// caller's to show once and then forget.
|
|
func (s *SQLite) CreateKey(ctx context.Context, k Key) error {
|
|
if len(k.Hash) == 0 {
|
|
return errors.New("store: a key needs a hash")
|
|
}
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO api_keys (`+keyColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
k.ID, k.Hash, k.Prefix, k.Name, k.ProjectID,
|
|
nullString(k.Band), nullString(k.RoutingProfile),
|
|
millis(k.CreatedAt), nullMillis(k.ExpiresAt), nullMillis(k.RevokedAt), nullMillis(k.LastUsedAt))
|
|
return err
|
|
}
|
|
|
|
// KeyByHash finds a key by the hash of the presented secret.
|
|
func (s *SQLite) KeyByHash(ctx context.Context, hash []byte) (Key, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT `+keyColumns+` FROM api_keys WHERE key_hash = ?`, hash)
|
|
k, err := scanKey(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Key{}, ErrNotFound
|
|
}
|
|
return k, err
|
|
}
|
|
|
|
// Keys lists a project's keys, revoked ones included.
|
|
func (s *SQLite) Keys(ctx context.Context, projectID string) ([]Key, error) {
|
|
return queryAll(ctx, s.db,
|
|
`SELECT `+keyColumns+` FROM api_keys WHERE project_id = ? ORDER BY created_at`,
|
|
[]any{projectID}, func(rows *sql.Rows) (Key, error) { return scanKey(rows) })
|
|
}
|
|
|
|
// RevokeKey marks a key unusable without deleting it.
|
|
func (s *SQLite) RevokeKey(ctx context.Context, id string, at time.Time) error {
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE api_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`,
|
|
millis(at), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
// Either it does not exist or it was already revoked. Both mean the key
|
|
// cannot authenticate, so only the first is worth an error.
|
|
var exists int
|
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM api_keys WHERE id = ?`, id).
|
|
Scan(&exists); err != nil {
|
|
return err
|
|
}
|
|
if exists == 0 {
|
|
return ErrNotFound
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TouchKeys records when keys were last used.
|
|
//
|
|
// Batched, and at most once a minute per key, because nothing reads this field
|
|
// in real time and writing it per request would put a database write back on
|
|
// the request path.
|
|
func (s *SQLite) TouchKeys(ctx context.Context, seen map[string]time.Time) error {
|
|
if len(seen) == 0 {
|
|
return nil
|
|
}
|
|
return s.inTx(ctx, func(tx *sql.Tx) error {
|
|
stmt, err := tx.PrepareContext(ctx,
|
|
`UPDATE api_keys SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stmt.Close()
|
|
for id, at := range seen {
|
|
if _, err := stmt.ExecContext(ctx, millis(at), id, millis(at)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// --- limits ---
|
|
|
|
// PutLimits upserts the limits at one scope.
|
|
func (s *SQLite) PutLimits(ctx context.Context, l Limits) error {
|
|
models, err := encodeModels(l.AllowedModels)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.db.ExecContext(ctx,
|
|
`INSERT INTO limits (scope_type, scope_id, rpm, tpm, budget_micros, budget_period, allowed_models)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT (scope_type, scope_id) DO UPDATE SET
|
|
rpm = excluded.rpm, tpm = excluded.tpm,
|
|
budget_micros = excluded.budget_micros, budget_period = excluded.budget_period,
|
|
allowed_models = excluded.allowed_models`,
|
|
string(l.Scope), l.ScopeID, nullInt(l.RPM), nullInt(l.TPM),
|
|
nullInt64(l.Budget), nullString(string(l.BudgetPeriod)), models)
|
|
return err
|
|
}
|
|
|
|
// LimitsFor reads the limits set at one scope, without inheritance.
|
|
func (s *SQLite) LimitsFor(ctx context.Context, scope Scope, id string) (Limits, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT rpm, tpm, budget_micros, budget_period, allowed_models
|
|
FROM limits WHERE scope_type = ? AND scope_id = ?`, string(scope), id)
|
|
|
|
l := Limits{Scope: scope, ScopeID: id}
|
|
var rpm, tpm sql.NullInt64
|
|
var budget sql.NullInt64
|
|
var period, models sql.NullString
|
|
if err := row.Scan(&rpm, &tpm, &budget, &period, &models); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Limits{}, ErrNotFound
|
|
}
|
|
return Limits{}, err
|
|
}
|
|
if rpm.Valid {
|
|
v := int(rpm.Int64)
|
|
l.RPM = &v
|
|
}
|
|
if tpm.Valid {
|
|
v := int(tpm.Int64)
|
|
l.TPM = &v
|
|
}
|
|
if budget.Valid {
|
|
v := budget.Int64
|
|
l.Budget = &v
|
|
}
|
|
l.BudgetPeriod = Period(period.String)
|
|
var err error
|
|
if l.AllowedModels, err = decodeModels(models); err != nil {
|
|
return Limits{}, err
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
// Resolve walks a key's chain and returns the most restrictive of each limit.
|
|
//
|
|
// One query rather than four lookups: the chain is a fixed three-level join, and
|
|
// this is called on a cache miss during authentication.
|
|
func (s *SQLite) Resolve(ctx context.Context, keyID string) (Effective, error) {
|
|
var e Effective
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT k.id, k.band, k.routing_profile, p.id, t.id, o.id
|
|
FROM api_keys k
|
|
JOIN projects p ON p.id = k.project_id
|
|
JOIN teams t ON t.id = p.team_id
|
|
JOIN organizations o ON o.id = t.org_id
|
|
WHERE k.id = ?`, keyID)
|
|
|
|
var band, profile sql.NullString
|
|
if err := row.Scan(&e.KeyID, &band, &profile, &e.ProjectID, &e.TeamID, &e.OrgID); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Effective{}, ErrNotFound
|
|
}
|
|
return Effective{}, err
|
|
}
|
|
e.Band, e.RoutingProfile = band.String, profile.String
|
|
|
|
// Widest first, so a narrower scope can only tighten what it inherits.
|
|
chain := []struct {
|
|
scope Scope
|
|
id string
|
|
}{
|
|
{ScopeOrg, e.OrgID},
|
|
{ScopeTeam, e.TeamID},
|
|
{ScopeProject, e.ProjectID},
|
|
{ScopeKey, e.KeyID},
|
|
}
|
|
for _, step := range chain {
|
|
l, err := s.LimitsFor(ctx, step.scope, step.id)
|
|
if errors.Is(err, ErrNotFound) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return Effective{}, err
|
|
}
|
|
e.tighten(l)
|
|
}
|
|
s.applyDefaults(&e)
|
|
return e, nil
|
|
}
|
|
|
|
// SetDefaultLimits sets what applies to a key no scope gives a limit to.
|
|
//
|
|
// A default is a fallback, not a ceiling, so it is applied after the chain and
|
|
// only where the chain left a limit unset — an explicit project limit above the
|
|
// default must win, which it would not if this were folded in as the widest
|
|
// scope and tightened.
|
|
//
|
|
// It exists because unlimited was the default. A project created without
|
|
// limits — which is the easy path, and what the "default" project on this
|
|
// cluster still is — metered nothing at all, and the first sign would have
|
|
// been the bill or the outage.
|
|
func (s *SQLite) SetDefaultLimits(rpm, tpm *int) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.defaultRPM, s.defaultTPM = rpm, tpm
|
|
}
|
|
|
|
func (s *SQLite) applyDefaults(e *Effective) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if e.RPM == nil && s.defaultRPM != nil {
|
|
v := *s.defaultRPM
|
|
e.RPM = &v
|
|
}
|
|
if e.TPM == nil && s.defaultTPM != nil {
|
|
v := *s.defaultTPM
|
|
e.TPM = &v
|
|
}
|
|
}
|
|
|
|
// tighten applies one scope's limits, keeping whichever is stricter.
|
|
func (e *Effective) tighten(l Limits) {
|
|
e.RPM = minPtr(e.RPM, l.RPM)
|
|
e.TPM = minPtr(e.TPM, l.TPM)
|
|
if l.Budget != nil && (e.Budget == nil || *l.Budget < *e.Budget) {
|
|
e.Budget = l.Budget
|
|
e.BudgetPeriod = l.BudgetPeriod
|
|
}
|
|
if l.AllowedModels != nil {
|
|
if e.AllowedModels == nil {
|
|
e.AllowedModels = slices.Clone(l.AllowedModels)
|
|
} else {
|
|
// Intersection: a narrower scope cannot add a model its parent
|
|
// withheld, or an allowlist would be advisory.
|
|
var keep []string
|
|
for _, m := range e.AllowedModels {
|
|
if slices.Contains(l.AllowedModels, m) {
|
|
keep = append(keep, m)
|
|
}
|
|
}
|
|
// An empty intersection is still an allowlist, and an empty one.
|
|
if keep == nil {
|
|
keep = []string{}
|
|
}
|
|
e.AllowedModels = keep
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- accounting ---
|
|
|
|
// WriteUsage appends a batch of records in one transaction.
|
|
func (s *SQLite) WriteUsage(ctx context.Context, records []Usage) error {
|
|
if len(records) == 0 {
|
|
return nil
|
|
}
|
|
return s.inTx(ctx, func(tx *sql.Tx) error {
|
|
stmt, err := tx.PrepareContext(ctx,
|
|
`INSERT INTO usage (ts, key_id, project_id, model, served_model,
|
|
prompt_tokens, completion_tokens, cost_micros, status, latency_ms)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stmt.Close()
|
|
for _, r := range records {
|
|
if _, err := stmt.ExecContext(ctx, millis(r.At), r.KeyID, r.ProjectID,
|
|
r.Model, r.ServedModel, r.PromptTokens, r.CompletionTokens,
|
|
r.CostMicros, r.Status, r.LatencyMs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// SpendSince totals cost per key since a moment.
|
|
func (s *SQLite) SpendSince(ctx context.Context, since time.Time) (map[string]int64, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT key_id, SUM(cost_micros) FROM usage WHERE ts >= ? GROUP BY key_id`, millis(since))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := map[string]int64{}
|
|
for rows.Next() {
|
|
var id string
|
|
var total sql.NullInt64
|
|
if err := rows.Scan(&id, &total); err != nil {
|
|
return nil, err
|
|
}
|
|
out[id] = total.Int64
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func (s *SQLite) inTx(ctx context.Context, fn func(*sql.Tx) error) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if err := fn(tx); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// scanner is what both *sql.Row and *sql.Rows satisfy.
|
|
type scanner interface{ Scan(...any) error }
|
|
|
|
func scanKey(sc scanner) (Key, error) {
|
|
var k Key
|
|
var band, profile sql.NullString
|
|
var expires, revoked, lastUsed sql.NullInt64
|
|
var created int64
|
|
if err := sc.Scan(&k.ID, &k.Hash, &k.Prefix, &k.Name, &k.ProjectID,
|
|
&band, &profile, &created, &expires, &revoked, &lastUsed); err != nil {
|
|
return Key{}, err
|
|
}
|
|
k.Band, k.RoutingProfile = band.String, profile.String
|
|
k.CreatedAt = fromMillis(created)
|
|
k.ExpiresAt = fromNullMillis(expires)
|
|
k.RevokedAt = fromNullMillis(revoked)
|
|
k.LastUsedAt = fromNullMillis(lastUsed)
|
|
return k, nil
|
|
}
|
|
|
|
func queryAll[T any](ctx context.Context, db *sql.DB, query string, args []any,
|
|
scan func(*sql.Rows) (T, error),
|
|
) ([]T, error) {
|
|
rows, err := db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []T
|
|
for rows.Next() {
|
|
v, err := scan(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func encodeModels(models []string) (any, error) {
|
|
if models == nil {
|
|
return nil, nil
|
|
}
|
|
b, err := json.Marshal(models)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
func decodeModels(v sql.NullString) ([]string, error) {
|
|
if !v.Valid || strings.TrimSpace(v.String) == "" {
|
|
return nil, nil
|
|
}
|
|
var out []string
|
|
if err := json.Unmarshal([]byte(v.String), &out); err != nil {
|
|
return nil, fmt.Errorf("allowed_models is not a JSON array: %w", err)
|
|
}
|
|
if out == nil {
|
|
out = []string{}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func minPtr(a, b *int) *int {
|
|
switch {
|
|
case b == nil:
|
|
return a
|
|
case a == nil:
|
|
return b
|
|
case *b < *a:
|
|
return b
|
|
default:
|
|
return a
|
|
}
|
|
}
|
|
|
|
func nullString(s string) any {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
func nullInt(v *int) any {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
return *v
|
|
}
|
|
|
|
func nullInt64(v *int64) any {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
return *v
|
|
}
|
|
|
|
func millis(t time.Time) int64 { return t.UnixMilli() }
|
|
|
|
func nullMillis(t time.Time) any {
|
|
if t.IsZero() {
|
|
return nil
|
|
}
|
|
return t.UnixMilli()
|
|
}
|
|
|
|
func fromMillis(v int64) time.Time { return time.UnixMilli(v).UTC() }
|
|
|
|
func fromNullMillis(v sql.NullInt64) time.Time {
|
|
if !v.Valid {
|
|
return time.Time{}
|
|
}
|
|
return time.UnixMilli(v.Int64).UTC()
|
|
}
|
|
|
|
func nowMillis() int64 { return time.Now().UnixMilli() }
|
|
|
|
// SQLite satisfies Store.
|
|
var _ Store = (*SQLite)(nil)
|
|
|
|
// UsageSince summarizes activity per key.
|
|
//
|
|
// The join brings in the key's name and prefix, which is what a dashboard needs
|
|
// to show: a row identified only by an opaque id tells an operator nothing.
|
|
// Revoked keys still appear, because their usage still happened.
|
|
func (s *SQLite) UsageSince(ctx context.Context, since time.Time) ([]KeyUsage, error) {
|
|
return queryAll(ctx, s.db, `
|
|
SELECT u.key_id,
|
|
COALESCE(k.name, ''), COALESCE(k.key_prefix, ''), u.project_id,
|
|
COUNT(*), SUM(u.prompt_tokens), SUM(u.completion_tokens),
|
|
SUM(u.cost_micros),
|
|
SUM(CASE WHEN u.status >= 400 THEN 1 ELSE 0 END),
|
|
MAX(u.ts)
|
|
FROM usage u
|
|
LEFT JOIN api_keys k ON k.id = u.key_id
|
|
WHERE u.ts >= ?
|
|
GROUP BY u.key_id, u.project_id
|
|
ORDER BY SUM(u.cost_micros) DESC, COUNT(*) DESC`,
|
|
[]any{millis(since)}, func(rows *sql.Rows) (KeyUsage, error) {
|
|
var k KeyUsage
|
|
var last int64
|
|
err := rows.Scan(&k.KeyID, &k.KeyName, &k.KeyPrefix, &k.ProjectID,
|
|
&k.Requests, &k.Prompt, &k.Completion, &k.CostMicros, &k.Errors, &last)
|
|
k.LastAt = fromMillis(last)
|
|
return k, err
|
|
})
|
|
}
|
|
|
|
// UsageBuckets groups usage into fixed time slices.
|
|
//
|
|
// Empty slices are filled in, which a GROUP BY cannot do: a chart with hours
|
|
// missing where nothing happened reads as a chart with hours missing from the
|
|
// data, and the two mean very different things.
|
|
func (s *SQLite) UsageBuckets(ctx context.Context, since time.Time, width time.Duration) ([]Bucket, error) {
|
|
if width <= 0 {
|
|
width = time.Hour
|
|
}
|
|
ms := width.Milliseconds()
|
|
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT (ts / ?) * ?, COUNT(*),
|
|
SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END),
|
|
SUM(prompt_tokens), SUM(completion_tokens), SUM(cost_micros),
|
|
AVG(latency_ms)
|
|
FROM usage WHERE ts >= ?
|
|
GROUP BY ts / ? ORDER BY 1`, ms, ms, millis(since), ms)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
found := map[int64]Bucket{}
|
|
for rows.Next() {
|
|
var start int64
|
|
var b Bucket
|
|
var latency float64
|
|
if err := rows.Scan(&start, &b.Requests, &b.Errors,
|
|
&b.Prompt, &b.Completion, &b.CostMicros, &latency); err != nil {
|
|
return nil, err
|
|
}
|
|
b.Start = fromMillis(start)
|
|
b.LatencyMs = int64(latency)
|
|
found[start] = b
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var out []Bucket
|
|
first := (millis(since) / ms) * ms
|
|
last := (millis(time.Now()) / ms) * ms
|
|
for t := first; t <= last; t += ms {
|
|
if b, ok := found[t]; ok {
|
|
out = append(out, b)
|
|
continue
|
|
}
|
|
out = append(out, Bucket{Start: fromMillis(t)})
|
|
}
|
|
return out, nil
|
|
}
|