llmgw keys cache --project P --ttl 1h [--any] | --off, and Cache answers… on the console's project menu. The same request — project, endpoint, canonical model and the body without stream, stream_options, user, metadata, store and service_tier, keys sorted — is answered from the gateway's memory for the TTL. Only reproducible answers (temperature 0 or a seed; embeddings, rerank, score) unless --any; only a whole 200 from the model asked for, not a fallback's, a canary's, or one a guardrail changed. Looked up after the limits and the input guardrails; the output policy judges a reused answer; no_store and stored conversations bypass it. Streamed answers are kept put back together and streamed back as chunks. x-llmgw-cache: hit|miss|bypass, Age on a hit, Cache-Control: no-cache or x-llmgw-cache: no to bypass. A hit is recorded with no tokens and no cost, marked cache_hit (migration 0027) and shown by ctl request. An LRU bounded by router.cache.max_bytes and max_entry_bytes; llmgw_cache_requests_total, llmgw_cache_bytes, llmgw_cache_entries. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
884 lines
29 KiB
Go
884 lines
29 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
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/secrets"
|
|
)
|
|
|
|
// DB is the store, on either dialect.
|
|
//
|
|
// It was called SQLite, and kept the name after PostgreSQL was added, so
|
|
// OpenPostgres returned a *SQLite and every one of the seventy-odd methods on
|
|
// the production store was spelled after the database it was not running on.
|
|
// Nothing broke; people did. A method written for a table that only ever lives
|
|
// in PostgreSQL still read as SQLite code, and the deployment that matters most
|
|
// looked like the one nobody uses.
|
|
//
|
|
// The dialect is a field, which is all it ever was.
|
|
type DB struct {
|
|
// raw is for the things that are not queries: transactions, pings, close.
|
|
raw *sql.DB
|
|
dialect Dialect
|
|
// db is the same handle with placeholders rewritten for the dialect. Every
|
|
// query goes through it, which is the point — a call site that reached the
|
|
// driver directly would work on SQLite and fail on PostgreSQL, and only on
|
|
// the path nobody exercised before deploying.
|
|
db bound
|
|
|
|
// sealer seals the store's own secrets; nil keeps them plain.
|
|
sealer secrets.Sealer
|
|
|
|
// mu guards the default limits, which are set once at wiring but read on
|
|
// every authentication.
|
|
mu sync.RWMutex
|
|
defaultRPM *int
|
|
defaultTPM *int
|
|
// personal is what a person's own use is held to when nobody wrote a
|
|
// limit for them; see SetPersonalDefaults.
|
|
personal *Limits
|
|
}
|
|
|
|
// 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) (*DB, 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 &DB{raw: db, dialect: SQLiteDialect, db: bound{c: SQLiteDialect, to: db}}, nil
|
|
}
|
|
|
|
// Close releases the database.
|
|
func (s *DB) Close() error { return s.raw.Close() }
|
|
|
|
// --- tenancy ---
|
|
|
|
// PutOrg upserts an organization.
|
|
func (s *DB) 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.
|
|
//
|
|
// It will rename a team but not move it: an upsert that rewrote the parent
|
|
// let `keys init --org B --team platform` take organization A's team without
|
|
// a word. Moving is MoveTeam, asked for by name.
|
|
func (s *DB) PutTeam(ctx context.Context, t Team) error {
|
|
var org string
|
|
err := s.db.QueryRowContext(ctx, `SELECT org_id FROM teams WHERE id = ?`, t.ID).Scan(&org)
|
|
if err == nil && org != t.OrgID {
|
|
return fmt.Errorf("team %s belongs to organization %s; move it rather than redeclare it", t.ID, org)
|
|
}
|
|
_, 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. Like PutTeam, it renames but never moves.
|
|
func (s *DB) PutProject(ctx context.Context, p Project) error {
|
|
var team string
|
|
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM projects WHERE id = ?`, p.ID).Scan(&team)
|
|
if err == nil && team != p.TeamID {
|
|
return fmt.Errorf("project %s belongs to team %s; move it rather than redeclare it", p.ID, team)
|
|
}
|
|
_, 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 *DB) 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 *DB) 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 *DB) 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, user_id`
|
|
|
|
// CreateKey stores a key. Only its hash is written; the key itself is the
|
|
// caller's to show once and then forget.
|
|
func (s *DB) CreateKey(ctx context.Context, k Key) error {
|
|
if len(k.Hash) == 0 {
|
|
return errors.New("store: a key needs a hash")
|
|
}
|
|
k, err := s.keyTTL(ctx, k)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.db.ExecContext(ctx,
|
|
`INSERT INTO api_keys (`+keyColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
k.ID, k.Hash, k.Prefix, k.Name, nullString(k.ProjectID),
|
|
nullString(k.Band), nullString(k.RoutingProfile),
|
|
millis(k.CreatedAt), nullMillis(k.ExpiresAt), nullMillis(k.RevokedAt), nullMillis(k.LastUsedAt),
|
|
nullString(k.UserID))
|
|
return err
|
|
}
|
|
|
|
// KeyByHash finds a key by the hash of the presented secret.
|
|
func (s *DB) 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 *DB) 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 *DB) 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 *DB) TouchKeys(ctx context.Context, seen map[string]time.Time) error {
|
|
if len(seen) == 0 {
|
|
return nil
|
|
}
|
|
return s.inTx(ctx, func(tx conn) 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 *DB) 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 *DB) 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
|
|
}
|
|
|
|
// 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 *DB) SetDefaultLimits(rpm, tpm *int) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.defaultRPM, s.defaultTPM = rpm, tpm
|
|
}
|
|
|
|
// applyDefaults fills in what the chain left unset.
|
|
//
|
|
// Owned by the key, not by any scope. A cluster-wide default is a fallback for
|
|
// each caller that was given no limit of its own — read as a shared pool it
|
|
// would become one cluster-wide rate limit that every key in every
|
|
// organization competes for, which is not a default, it is an outage.
|
|
func (s *DB) applyDefaults(e *Effective) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
own := LimitOwner{Scope: ScopeKey, ID: e.KeyID}
|
|
fallback := Limits{Scope: ScopeKey, ScopeID: e.KeyID}
|
|
if e.RPM == nil && s.defaultRPM != nil {
|
|
v := *s.defaultRPM
|
|
e.RPM, e.RPMOwner = &v, own
|
|
fallback.RPM = &v
|
|
}
|
|
if e.TPM == nil && s.defaultTPM != nil {
|
|
v := *s.defaultTPM
|
|
e.TPM, e.TPMOwner = &v, own
|
|
fallback.TPM = &v
|
|
}
|
|
// Appended so enforcement sees it like any other level. Left out, a cluster
|
|
// default would be reported as the ceiling and then never checked.
|
|
if fallback.RPM != nil || fallback.TPM != nil {
|
|
e.Chain = append(e.Chain, fallback)
|
|
}
|
|
}
|
|
|
|
// SpendSinceByScope totals cost per key and person, with the key's place in
|
|
// the tree. Personal keys and the chat page's requests have no project and are
|
|
// kept: dropping them is what would reset a person's budget on every restart.
|
|
func (s *DB) SpendSinceByScope(ctx context.Context, since time.Time) ([]ScopeSpend, error) {
|
|
src, args := usageSince(since)
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT u.key_id, u.user_id, COALESCE(p.id, ''), COALESCE(t.id, ''), COALESCE(t.org_id, ''),
|
|
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
|
|
GROUP BY u.key_id, u.user_id, p.id, t.id, t.org_id`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []ScopeSpend
|
|
for rows.Next() {
|
|
var sp ScopeSpend
|
|
var total sql.NullInt64
|
|
if err := rows.Scan(&sp.KeyID, &sp.UserID, &sp.ProjectID, &sp.TeamID, &sp.OrgID, &total); err != nil {
|
|
return nil, err
|
|
}
|
|
sp.Micros = total.Int64
|
|
out = append(out, sp)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// tighten applies one scope's limits, keeping whichever is stricter.
|
|
//
|
|
// Each value that wins records the scope it came from. That is what lets
|
|
// consumption be counted against the scope that set the limit: a budget written
|
|
// on a team is one pool the team shares, not a copy of the number handed to
|
|
// every key beneath it.
|
|
func (e *Effective) tighten(l Limits) {
|
|
owner := LimitOwner{Scope: l.Scope, ID: l.ScopeID}
|
|
if next := minPtr(e.RPM, l.RPM); next != e.RPM {
|
|
e.RPM, e.RPMOwner = next, owner
|
|
}
|
|
if next := minPtr(e.TPM, l.TPM); next != e.TPM {
|
|
e.TPM, e.TPMOwner = next, owner
|
|
}
|
|
if l.Budget != nil && (e.Budget == nil || *l.Budget < *e.Budget) {
|
|
e.Budget = l.Budget
|
|
e.BudgetPeriod = l.BudgetPeriod
|
|
e.BudgetOwner = owner
|
|
}
|
|
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 *DB) WriteUsage(ctx context.Context, records []Usage) error {
|
|
if len(records) == 0 {
|
|
return nil
|
|
}
|
|
return s.inTx(ctx, func(tx conn) 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, user_id,
|
|
request_id, replica, queue_ms, ttft_ms,
|
|
guard_action, guard_policy, guard_category, guard_ms, end_user, tags, cache_hit)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stmt.Close()
|
|
for _, r := range records {
|
|
hit := 0
|
|
if r.CacheHit {
|
|
hit = 1
|
|
}
|
|
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, r.UserID,
|
|
r.RequestID, r.Replica, r.QueueMs, r.TTFTMs,
|
|
r.GuardAction, r.GuardPolicy, r.GuardCategory, r.GuardMs, r.EndUser, tagsJSON(r.Tags), hit); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return writeRollup(ctx, tx, records)
|
|
})
|
|
}
|
|
|
|
// SpendSince totals cost per key since a moment.
|
|
func (s *DB) SpendSince(ctx context.Context, since time.Time) (map[string]int64, error) {
|
|
src, args := usageSince(since)
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT u.key_id, SUM(u.cost_micros) FROM `+src+` u GROUP BY u.key_id`, args...)
|
|
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 ---
|
|
|
|
// begin starts a transaction and returns it twice: bound for queries, raw for
|
|
// commit and rollback. The bound half is what callers must use, so a statement
|
|
// inside a transaction is rewritten exactly as one outside it is.
|
|
func (s *DB) begin(ctx context.Context) (conn, *sql.Tx, error) {
|
|
tx, err := s.raw.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return bound{c: s.dialect, to: tx}, tx, nil
|
|
}
|
|
|
|
// inTx runs fn in a transaction, with the same placeholder rewriting the
|
|
// outside-transaction path gets.
|
|
func (s *DB) inTx(ctx context.Context, fn func(conn) error) error {
|
|
tx, err := s.raw.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if err := fn(bound{c: s.dialect, to: 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, project, user sql.NullString
|
|
var expires, revoked, lastUsed sql.NullInt64
|
|
var created int64
|
|
if err := sc.Scan(&k.ID, &k.Hash, &k.Prefix, &k.Name, &project,
|
|
&band, &profile, &created, &expires, &revoked, &lastUsed, &user); err != nil {
|
|
return Key{}, err
|
|
}
|
|
k.ProjectID, k.UserID = project.String, user.String
|
|
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 conn, 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 = (*DB)(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 *DB) UsageSince(ctx context.Context, since time.Time) ([]KeyUsage, error) {
|
|
src, args := usageSince(since)
|
|
return queryAll(ctx, s.db, `
|
|
SELECT u.key_id,
|
|
COALESCE(k.name, ''), COALESCE(k.key_prefix, ''), u.project_id,
|
|
COALESCE(us.email, ''),
|
|
COALESCE(o.name || ' / ' || t.name || ' / ' || p.name, ''),
|
|
SUM(u.requests), SUM(u.prompt_tokens), SUM(u.completion_tokens),
|
|
SUM(u.cost_micros), SUM(u.errors), MAX(u.last_ts)
|
|
FROM `+src+` u
|
|
LEFT JOIN api_keys k ON k.id = u.key_id
|
|
-- Who, as recorded with the spend; the owner of the key for rows
|
|
-- written before the person was.
|
|
LEFT JOIN users us ON us.id = COALESCE(NULLIF(u.user_id, ''), k.user_id)
|
|
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
|
|
-- Everything selected beside the sums is grouped, which PostgreSQL
|
|
-- wants; each is determined by the key and project already there.
|
|
GROUP BY u.key_id, u.project_id, k.name, k.key_prefix, us.email, o.name, t.name, p.name
|
|
ORDER BY SUM(u.cost_micros) DESC, SUM(u.requests) DESC`,
|
|
args, func(rows *sql.Rows) (KeyUsage, error) {
|
|
var k KeyUsage
|
|
var last int64
|
|
err := rows.Scan(&k.KeyID, &k.KeyName, &k.KeyPrefix, &k.ProjectID,
|
|
&k.Email, &k.ProjectPath,
|
|
&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 *DB) UsageBuckets(ctx context.Context, since time.Time, width time.Duration) ([]Bucket, error) {
|
|
if width <= 0 {
|
|
width = time.Hour
|
|
}
|
|
ms := width.Milliseconds()
|
|
|
|
// From the rollup when the slices are whole rollup slices, which every
|
|
// page asks for; from the rows otherwise.
|
|
q, from := `
|
|
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 >= ?
|
|
-- By ordinal, because PostgreSQL matches a grouped expression to a
|
|
-- selected one syntactically: "ts / ?" and "(ts / ?) * ?" are not the
|
|
-- same text, so it saw a bare ts and refused. The ordinal is the same
|
|
-- expression by construction, and both dialects accept it.
|
|
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),
|
|
1.0 * SUM(latency_ms_sum) / SUM(requests)
|
|
FROM `+t.table+` WHERE bucket >= ?
|
|
GROUP BY 1 ORDER BY 1`, t.floor(since)
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, q, ms, ms, from)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
found := map[int64]Bucket{}
|
|
for rows.Next() {
|
|
var start int64
|
|
var b Bucket
|
|
var latency sql.NullFloat64
|
|
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.Float64)
|
|
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
|
|
}
|
|
|
|
// UserUsage is one person's consumption, across every key they hold.
|
|
type UserUsage struct {
|
|
UserID string
|
|
Email string
|
|
Name string
|
|
Keys int64
|
|
Requests int64
|
|
Prompt int64
|
|
Completion int64
|
|
CostMicros int64
|
|
Errors int64
|
|
LastSeen time.Time
|
|
}
|
|
|
|
// Tokens is what the person actually consumed, which is the figure they
|
|
// recognise — prompt and completion together.
|
|
func (u UserUsage) Tokens() int64 { return u.Prompt + u.Completion }
|
|
|
|
// UsageByUser aggregates usage per person rather than per key.
|
|
//
|
|
// Per key is the right unit for revoking something and the wrong one for
|
|
// answering "who is using this cluster": one person holds a laptop key, a CI
|
|
// key and a notebook key, and reading three rows to add them up is a chore that
|
|
// gets skipped. Keys with no owner — issued by an operator before accounts
|
|
// existed — are grouped under an empty user, which is honest rather than tidy.
|
|
func (s *DB) UsageByUser(ctx context.Context, since time.Time) ([]UserUsage, error) {
|
|
src, args := usageSince(since)
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT u.user_id, COALESCE(us.email, ''), COALESCE(us.name, ''),
|
|
COUNT(DISTINCT u.key_id),
|
|
SUM(u.requests), SUM(u.prompt_tokens), SUM(u.completion_tokens),
|
|
SUM(u.cost_micros), SUM(u.errors), MAX(u.last_ts)
|
|
FROM `+src+` u
|
|
LEFT JOIN users us ON us.id = NULLIF(u.user_id, '')
|
|
-- Every selected column that is not an aggregate has to be grouped.
|
|
-- SQLite picks an arbitrary row for the rest and carries on; PostgreSQL
|
|
-- refuses, so this query worked in tests and returned nothing but an
|
|
-- error on the cluster. The email and name are functionally dependent
|
|
-- on the user id, so grouping by them changes no result.
|
|
GROUP BY u.user_id, COALESCE(us.email, ''), COALESCE(us.name, '')
|
|
ORDER BY SUM(u.cost_micros) DESC, SUM(u.requests) DESC`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []UserUsage
|
|
for rows.Next() {
|
|
var u UserUsage
|
|
var last int64
|
|
if err := rows.Scan(&u.UserID, &u.Email, &u.Name, &u.Keys,
|
|
&u.Requests, &u.Prompt, &u.Completion, &u.CostMicros, &u.Errors, &last); err != nil {
|
|
return nil, err
|
|
}
|
|
u.LastSeen = time.UnixMilli(last).UTC()
|
|
out = append(out, u)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ModelUsage is one model's share of the traffic.
|
|
type ModelUsage struct {
|
|
Model string
|
|
Requests int64
|
|
Prompt int64
|
|
Completion int64
|
|
CostMicros int64
|
|
Errors int64
|
|
AvgLatency float64
|
|
}
|
|
|
|
// Tokens is prompt and completion together.
|
|
func (m ModelUsage) Tokens() int64 { return m.Prompt + m.Completion }
|
|
|
|
// UsageByModel aggregates usage per model.
|
|
//
|
|
// By served_model rather than the requested name, because those differ whenever
|
|
// a fallback answers — and "what actually ran" is the question being asked of a
|
|
// cluster. The requested name is kept in the same table for anyone who needs to
|
|
// ask the other question.
|
|
func (s *DB) UsageByModel(ctx context.Context, since time.Time) ([]ModelUsage, error) {
|
|
src, args := usageSince(since)
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT CASE WHEN served_model != '' THEN served_model ELSE model END,
|
|
SUM(requests), SUM(prompt_tokens), SUM(completion_tokens),
|
|
SUM(cost_micros), SUM(errors),
|
|
1.0 * SUM(latency_ms_sum) / SUM(requests)
|
|
FROM `+src+` u
|
|
GROUP BY 1 ORDER BY SUM(requests) DESC`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []ModelUsage
|
|
for rows.Next() {
|
|
var m ModelUsage
|
|
var avg sql.NullFloat64
|
|
if err := rows.Scan(&m.Model, &m.Requests, &m.Prompt, &m.Completion,
|
|
&m.CostMicros, &m.Errors, &avg); err != nil {
|
|
return nil, err
|
|
}
|
|
m.AvgLatency = avg.Float64
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// SnapshotTo writes a consistent copy of a SQLite store to path, while it is
|
|
// in use. VACUUM INTO takes a read transaction, so the copy is of one moment
|
|
// even with writes going on, which copying the file is not.
|
|
func (s *DB) SnapshotTo(ctx context.Context, path string) error {
|
|
if s.dialect != SQLiteDialect {
|
|
return fmt.Errorf("a snapshot is for SQLite; back up PostgreSQL with pg_dump")
|
|
}
|
|
_, err := s.raw.ExecContext(ctx, `VACUUM INTO ?`, path)
|
|
return err
|
|
}
|
|
|
|
// UsageByRequest finds the record of one request by the id it was given, or
|
|
// ok=false when none has that id.
|
|
func (s *DB) UsageByRequest(ctx context.Context, requestID string) (Usage, bool, error) {
|
|
var u Usage
|
|
var ts int64
|
|
var tags string
|
|
var hit int
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT ts, key_id, project_id, COALESCE(user_id, ''), model, served_model, prompt_tokens,
|
|
completion_tokens, cost_micros, status, latency_ms, request_id, replica, queue_ms, ttft_ms,
|
|
guard_action, guard_policy, guard_category, guard_ms, end_user, tags, cache_hit
|
|
FROM usage WHERE request_id = ? ORDER BY ts DESC LIMIT 1`, requestID).
|
|
Scan(&ts, &u.KeyID, &u.ProjectID, &u.UserID, &u.Model, &u.ServedModel, &u.PromptTokens,
|
|
&u.CompletionTokens, &u.CostMicros, &u.Status, &u.LatencyMs, &u.RequestID, &u.Replica,
|
|
&u.QueueMs, &u.TTFTMs, &u.GuardAction, &u.GuardPolicy, &u.GuardCategory, &u.GuardMs,
|
|
&u.EndUser, &tags, &hit)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Usage{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return Usage{}, false, err
|
|
}
|
|
u.At = time.UnixMilli(ts)
|
|
u.Tags = parseTags(tags)
|
|
u.CacheHit = hit == 1
|
|
return u, true, nil
|
|
}
|