Files
zhanlu_proxy/internal/store/store.go
T
m1saka fa9640d919
build / build (push) Successful in 2m34s
Add token usage stats and consolidate credentials in SQLite
- 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.
2026-08-19 15:52:00 +08:00

90 lines
3.0 KiB
Go

// 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
}