Add token usage stats and consolidate credentials in SQLite
build / build (push) Successful in 2m34s
build / build (push) Successful in 2m34s
- New internal/stats (types) and internal/store (SQLite owner: requests + credentials tables, WAL); store implements stats.Recorder. - Stream (SSE tee) and non-stream chat paths parse upstream usage incl. cached_tokens and record per-request; add /api/stats, /api/stats/reset, /admin/stats HTML with cache hit rate. - Drop credentials.json: remove auth file I/O and ZHANLU_CREDENTIALS_FILE; credential precedence is env vars > db row.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
||||
)
|
||||
|
||||
// LoadCredentials reads the persisted credentials. The credentials row always
|
||||
// exists after Open; an empty row (nothing saved yet) yields a zero-value
|
||||
// Credentials with a nil error — callers check Validate()/HasAPIKey().
|
||||
func (s *Store) LoadCredentials() (auth.Credentials, error) {
|
||||
var c auth.Credentials
|
||||
var savedAt int64
|
||||
err := s.db.QueryRow(`SELECT access_key, secret_key, token, api_key, model_base_url, email, organization, team, base_url, saved_at FROM credentials WHERE id = 1`).
|
||||
Scan(&c.AccessKey, &c.SecretKey, &c.Token, &c.APIKey, &c.ModelBaseURL, &c.Email, &c.Organization, &c.Team, &c.BaseURL, &savedAt)
|
||||
if err != nil {
|
||||
return auth.Credentials{}, fmt.Errorf("load credentials: %w", err)
|
||||
}
|
||||
c.SavedAt = time.Unix(savedAt, 0).Local()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SaveCredentials upserts the credentials into the single row. It only writes
|
||||
// when the credentials validate or already carry an API key, so partial /
|
||||
// env-only creds are not persisted.
|
||||
func (s *Store) SaveCredentials(c auth.Credentials) error {
|
||||
if c.Validate() != nil && !c.HasAPIKey() {
|
||||
return fmt.Errorf("save credentials: %w", c.Validate())
|
||||
}
|
||||
c.SavedAt = time.Now()
|
||||
_, err := s.db.Exec(`INSERT INTO credentials (id, access_key, secret_key, token, api_key, model_base_url, email, organization, team, base_url, saved_at)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
access_key=excluded.access_key, secret_key=excluded.secret_key, token=excluded.token,
|
||||
api_key=excluded.api_key, model_base_url=excluded.model_base_url, email=excluded.email,
|
||||
organization=excluded.organization, team=excluded.team, base_url=excluded.base_url,
|
||||
saved_at=excluded.saved_at`,
|
||||
c.AccessKey, c.SecretKey, c.Token, c.APIKey, c.ModelBaseURL, c.Email, c.Organization, c.Team, c.BaseURL, c.SavedAt.Unix())
|
||||
if err != nil {
|
||||
return fmt.Errorf("save credentials: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
|
||||
)
|
||||
|
||||
// Compile-time guard: *Store satisfies stats.Recorder.
|
||||
var _ stats.Recorder = (*Store)(nil)
|
||||
|
||||
const defaultRecentLimit = 200
|
||||
|
||||
// Record appends a single usage observation.
|
||||
func (s *Store) Record(rec stats.Record) error {
|
||||
if rec.Status == "" {
|
||||
rec.Status = "success"
|
||||
}
|
||||
stream := 0
|
||||
if rec.Stream {
|
||||
stream = 1
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO requests (ts, model, stream, prompt_tokens, completion_tokens, total_tokens, reasoning_tokens, cached_tokens, status, latency_ms) VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
rec.Ts.Unix(), rec.Model, stream, rec.PromptTokens, rec.CompletionTokens, rec.TotalTokens, rec.ReasoningTokens, rec.CachedTokens, rec.Status, rec.LatencyMs)
|
||||
return err
|
||||
}
|
||||
|
||||
// Stats computes the aggregate summary for the given query.
|
||||
func (s *Store) Stats(q stats.Query) (*stats.Summary, error) {
|
||||
q = normalizeQuery(q)
|
||||
where, args := whereClause(q)
|
||||
|
||||
sum := &stats.Summary{}
|
||||
if err := s.scanTotals(sum, where, args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sum.Totals.PromptTokens > 0 {
|
||||
sum.Totals.CacheRate = float64(sum.Totals.CachedTokens) / float64(sum.Totals.PromptTokens)
|
||||
}
|
||||
if err := s.scanPerModel(sum, where, args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.scanDaily(sum, where, args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.scanRecent(sum, q, where, args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
// Reset deletes all recorded usage statistics (the credentials row is kept).
|
||||
func (s *Store) Reset() error {
|
||||
_, err := s.db.Exec(`DELETE FROM requests`)
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeQuery(q stats.Query) stats.Query {
|
||||
if q.Limit <= 0 {
|
||||
q.Limit = defaultRecentLimit
|
||||
}
|
||||
if q.Limit > 5000 {
|
||||
q.Limit = 5000
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func whereClause(q stats.Query) (string, []any) {
|
||||
var conds []string
|
||||
var args []any
|
||||
if !q.Since.IsZero() {
|
||||
conds = append(conds, "ts >= ?")
|
||||
args = append(args, q.Since.Unix())
|
||||
}
|
||||
if !q.Until.IsZero() {
|
||||
conds = append(conds, "ts <= ?")
|
||||
args = append(args, q.Until.Unix())
|
||||
}
|
||||
if q.Model != "" {
|
||||
conds = append(conds, "model = ?")
|
||||
args = append(args, q.Model)
|
||||
}
|
||||
if q.Stream != nil {
|
||||
conds = append(conds, "stream = ?")
|
||||
args = append(args, boolToInt(*q.Stream))
|
||||
}
|
||||
if len(conds) == 0 {
|
||||
return "", args
|
||||
}
|
||||
out := ""
|
||||
for i, p := range conds {
|
||||
if i > 0 {
|
||||
out += " AND "
|
||||
}
|
||||
out += p
|
||||
}
|
||||
return " WHERE " + out, args
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Store) scanTotals(sum *stats.Summary, where string, args []any) error {
|
||||
q := `SELECT COUNT(*),
|
||||
COALESCE(SUM(CASE WHEN status='success' THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN status!='success' THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(prompt_tokens),0),
|
||||
COALESCE(SUM(completion_tokens),0),
|
||||
COALESCE(SUM(total_tokens),0),
|
||||
COALESCE(SUM(reasoning_tokens),0),
|
||||
COALESCE(SUM(cached_tokens),0) FROM requests` + where
|
||||
row := s.db.QueryRow(q, args...)
|
||||
var success, failures int64
|
||||
err := row.Scan(&sum.Totals.Requests, &success, &failures,
|
||||
&sum.Totals.PromptTokens, &sum.Totals.CompletionTokens,
|
||||
&sum.Totals.TotalTokens, &sum.Totals.ReasoningTokens, &sum.Totals.CachedTokens)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan totals: %w", err)
|
||||
}
|
||||
sum.Totals.SuccessRequests = int(success)
|
||||
sum.Totals.ErrorRequests = int(failures)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) scanPerModel(sum *stats.Summary, where string, args []any) error {
|
||||
q := `SELECT model, COUNT(*), COALESCE(SUM(prompt_tokens),0), COALESCE(SUM(completion_tokens),0), COALESCE(SUM(total_tokens),0), COALESCE(SUM(cached_tokens),0) FROM requests` + where + " GROUP BY model ORDER BY SUM(total_tokens) DESC"
|
||||
rows, err := s.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan per-model: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var m stats.ModelStat
|
||||
if err := rows.Scan(&m.Model, &m.Requests, &m.PromptTokens, &m.CompletionTokens, &m.TotalTokens, &m.CachedTokens); err != nil {
|
||||
return err
|
||||
}
|
||||
sum.PerModel = append(sum.PerModel, m)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) scanDaily(sum *stats.Summary, where string, args []any) error {
|
||||
q := `SELECT date(ts,'unixepoch','localtime') AS day, COUNT(*), COALESCE(SUM(prompt_tokens),0), COALESCE(SUM(completion_tokens),0), COALESCE(SUM(total_tokens),0), COALESCE(SUM(cached_tokens),0) FROM requests` + where + " GROUP BY day ORDER BY day ASC"
|
||||
rows, err := s.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan daily: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var d stats.DayStat
|
||||
if err := rows.Scan(&d.Day, &d.Requests, &d.PromptTokens, &d.CompletionTokens, &d.TotalTokens, &d.CachedTokens); err != nil {
|
||||
return err
|
||||
}
|
||||
sum.Daily = append(sum.Daily, d)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) scanRecent(sum *stats.Summary, q stats.Query, where string, args []any) error {
|
||||
limit := q.Limit
|
||||
if limit <= 0 {
|
||||
limit = defaultRecentLimit
|
||||
}
|
||||
query := `SELECT ts, model, stream, prompt_tokens, completion_tokens, total_tokens, reasoning_tokens, cached_tokens, status, latency_ms FROM requests` + where + " ORDER BY id DESC LIMIT ?"
|
||||
rows, err := s.db.Query(query, append(args, limit)...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan recent: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var rec stats.Record
|
||||
var ts int64
|
||||
var stream int
|
||||
if err := rows.Scan(&ts, &rec.Model, &stream, &rec.PromptTokens, &rec.CompletionTokens, &rec.TotalTokens, &rec.ReasoningTokens, &rec.CachedTokens, &rec.Status, &rec.LatencyMs); err != nil {
|
||||
return err
|
||||
}
|
||||
rec.Ts = time.Unix(ts, 0).Local()
|
||||
rec.Stream = stream == 1
|
||||
sum.Recent = append(sum.Recent, rec)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Package store owns the single embedded SQLite database backing the zhanlu
|
||||
// proxy: token-usage records (the requests table) and the persisted login
|
||||
// credentials (the credentials table, single-tenant single row). It implements
|
||||
// stats.Recorder for usage tracking and exposes Load/Save for credentials.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Store is the single owner of the proxy's SQLite database handle.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Open opens (or creates) the database at path and ensures both tables exist.
|
||||
// SQLite is opened with WAL journaling and a busy timeout so concurrent reads
|
||||
// (stats queries) and writes (request records, credential saves) do not
|
||||
// collide.
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open db %q: %w", path, err)
|
||||
}
|
||||
if err := ensureSchema(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// Close releases the database handle.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func ensureSchema(db *sql.DB) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
stream INTEGER NOT NULL DEFAULT 0,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cached_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
latency_ms INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS credentials (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
access_key TEXT NOT NULL DEFAULT '',
|
||||
secret_key TEXT NOT NULL DEFAULT '',
|
||||
token TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
model_base_url TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
organization TEXT NOT NULL DEFAULT '',
|
||||
team TEXT NOT NULL DEFAULT '',
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
saved_at INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_requests_ts ON requests(ts)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_requests_model ON requests(model)`,
|
||||
// Ensure the single credentials row exists so UPSERTs and SELECTs always
|
||||
// have a target.
|
||||
`INSERT INTO credentials (id) VALUES (1) ON CONFLICT(id) DO NOTHING`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := db.Exec(q); err != nil {
|
||||
return fmt.Errorf("schema: %w", err)
|
||||
}
|
||||
}
|
||||
// Add cached_tokens to databases created before this column existed. SQLite
|
||||
// returns "duplicate column name" when it already exists; that is expected
|
||||
// and ignored.
|
||||
if _, err := db.Exec(`ALTER TABLE requests ADD COLUMN cached_tokens INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
if !strings.Contains(err.Error(), "duplicate column") {
|
||||
return fmt.Errorf("migrate cached_tokens: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
st, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func TestCredentialsRoundTrip(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
in := auth.Credentials{
|
||||
AccessKey: "AK", SecretKey: "SK", Token: "TOK",
|
||||
APIKey: "sk-1", ModelBaseURL: "https://up.example", Email: "[email protected]",
|
||||
Organization: "org", Team: "team",
|
||||
}
|
||||
if err := st.SaveCredentials(in); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
got, err := st.LoadCredentials()
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got.APIKey != "sk-1" || got.Email != "[email protected]" || got.Organization != "org" {
|
||||
t.Fatalf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
if got.SavedAt.IsZero() {
|
||||
t.Fatalf("saved_at not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyLoadReturnsZero(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
got, err := st.LoadCredentials()
|
||||
if err != nil {
|
||||
t.Fatalf("load on empty store: %v", err)
|
||||
}
|
||||
if got.HasAPIKey() {
|
||||
t.Fatalf("expected no api key on empty store, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordStatsReset(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
recs := []stats.Record{
|
||||
{Ts: now, Model: "glm-4.7", Stream: false, PromptTokens: 10, CompletionTokens: 20, TotalTokens: 30, CachedTokens: 4, Status: "success", LatencyMs: 5},
|
||||
{Ts: now, Model: "glm-4.7", Stream: true, PromptTokens: 5, CompletionTokens: 5, TotalTokens: 10, Status: "success", LatencyMs: 8},
|
||||
{Ts: now, Model: "minimax", Stream: false, PromptTokens: 1, CompletionTokens: 1, TotalTokens: 2, Status: "upstream_error", LatencyMs: 3},
|
||||
}
|
||||
for _, r := range recs {
|
||||
if err := st.Record(r); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
sum, err := st.Stats(stats.Query{})
|
||||
if err != nil {
|
||||
t.Fatalf("stats: %v", err)
|
||||
}
|
||||
if sum.Totals.Requests != 3 {
|
||||
t.Fatalf("requests = %d, want 3", sum.Totals.Requests)
|
||||
}
|
||||
if sum.Totals.SuccessRequests != 2 || sum.Totals.ErrorRequests != 1 {
|
||||
t.Fatalf("success/error = %d/%d, want 2/1", sum.Totals.SuccessRequests, sum.Totals.ErrorRequests)
|
||||
}
|
||||
if sum.Totals.TotalTokens != 42 {
|
||||
t.Fatalf("total tokens = %d, want 42", sum.Totals.TotalTokens)
|
||||
}
|
||||
if sum.Totals.CachedTokens != 4 {
|
||||
t.Fatalf("cached tokens = %d, want 4", sum.Totals.CachedTokens)
|
||||
}
|
||||
// prompt total = 10+5+1 = 16, cached = 4 => 0.25
|
||||
if sum.Totals.CacheRate < 0.24 || sum.Totals.CacheRate > 0.26 {
|
||||
t.Fatalf("cache rate = %v, want ~0.25", sum.Totals.CacheRate)
|
||||
}
|
||||
if len(sum.PerModel) != 2 {
|
||||
t.Fatalf("per-model entries = %d, want 2", len(sum.PerModel))
|
||||
}
|
||||
// glm-4.7 should lead on total tokens (40 vs 2)
|
||||
if sum.PerModel[0].Model != "glm-4.7" || sum.PerModel[0].TotalTokens != 40 || sum.PerModel[0].CachedTokens != 4 {
|
||||
t.Fatalf("top model = %+v, want glm-4.7/40/4 cached", sum.PerModel[0])
|
||||
}
|
||||
if len(sum.Recent) != 3 {
|
||||
t.Fatalf("recent entries = %d, want 3", len(sum.Recent))
|
||||
}
|
||||
// most recent first (id desc) => minimax record
|
||||
if sum.Recent[0].Model != "minimax" {
|
||||
t.Fatalf("most recent = %+v, want minimax", sum.Recent[0])
|
||||
}
|
||||
|
||||
// model filter
|
||||
sumF, _ := st.Stats(stats.Query{Model: "minimax"})
|
||||
if sumF.Totals.Requests != 1 || sumF.Totals.TotalTokens != 2 {
|
||||
t.Fatalf("filtered stats = %+v, want 1/2", sumF.Totals)
|
||||
}
|
||||
|
||||
if err := st.Reset(); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
sum2, _ := st.Stats(stats.Query{})
|
||||
if sum2.Totals.Requests != 0 {
|
||||
t.Fatalf("after reset requests = %d, want 0", sum2.Totals.Requests)
|
||||
}
|
||||
// credentials must survive a stats reset
|
||||
creds, _ := st.LoadCredentials()
|
||||
_ = creds
|
||||
}
|
||||
Reference in New Issue
Block a user