aiosqlite; a fleet collector can point the same code at Postgres.
Where the layer lives
There is noSQLiteStorage class and no hand-rolled executescript() schema. The real pieces:
StorageService is a thin facade: every public method calls _ensure_initialized() (which runs migrations once, lazily) and then delegates to a per-domain service or repository function opened on its own AsyncSession.
Database location
resolve_database_url() in core/database.py resolves the SQLAlchemy URL in this priority order:
VOICEGW_DB_URLenvironment variable: a full SQLAlchemy URL (e.g. a Postgres collector). Wins outright over everything below.VOICEGW_DB_PATHenvironment variable: a SQLite file path.cost_tracking.db_pathinvoicegw.yaml.- Default:
~/.config/voicegateway/voicegw.db.
PRAGMA journal_mode=WAL, PRAGMA busy_timeout=5000) so the agent’s writes and the dashboard’s reads don’t collide with “database is locked”. A Postgres URL skips the filesystem and WAL setup entirely.
Connection management
Database builds one async engine and an async_sessionmaker at construction. SQLite uses the default connection pool with pool_pre_ping=True; a non-SQLite URL (Postgres) uses NullPool (a fresh connection per checkout) because Gateway.__init__ runs its async startup through several short-lived asyncio.run() loops and a pooled asyncpg connection bound to the wrong loop raises. Callers open a session via Database.session(), an async context manager that rolls back on exception and always closes.
Migrations
Schema changes ship as Alembic revisions inalembic/versions/, not autogenerated: alembic/env.py registers every SQLModel table so --autogenerate can diff against it, but nothing in this repo actually runs autogenerate, so each migration’s DDL is written by hand and the SQLModel column definitions have to be kept in sync manually.
Database.run_migrations() runs alembic upgrade head the first time any StorageService method is called (via _ensure_initialized()), not on every write: a lock guards against a race on first use, and a failure is cached and re-raised rather than retried on every subsequent write. The upgrade itself runs on a dedicated single-worker thread pool, never on the event loop, because env.py opens its own asyncio.run(). If the database was already migrated by a newer build (a stamped revision this build ships no script for), run_migrations raises DatabaseAheadOfCode; StorageService catches that, logs once, and keeps serving reads and writes against the superset schema instead of failing every request.
Tests build a fresh schema with Database.create_all() (SQLModel.metadata.create_all) instead of running the migration chain.
Re-encrypting managed provider API keys under a new Fernet key is a separate, operator-invoked action (
voicegw rotate-secret, backed by ManagedConfigService.rotate_credentials), not an automatic migration step. It rotates every managed_providers row on demand; it does not run when an old database is opened. See Security for the encryption details.Tables
requests
The primary table for every completed (or failed) inference request. ORM class Request in models/request_model.py; producers build a RequestRecord dataclass (kept separate because the column metadata collides with SQLAlchemy’s reserved metadata attribute) which the request-log repository converts to a row at write time.
managed_providers, managed_models, managed_projects
Providers, models, and projects added via the dashboard or MCP server rather than YAML. API keys are encrypted with Fernet (see Security).
All three carry
created_at/updated_at (REAL, Unix epoch).
managed_rate_rules
DB-side rate-card overrides layered after the YAML rate_card: seed. See Rating for how a rule is resolved.
config_audit_log
Append-only record of managed-config mutations.
calls and call_legs
Written by paths that never touch inference (the LiveKit webhook receiver, a load generator reporting its own attempts), so a call that answered and said nothing, or never answered at all, still gets a row. Neither table has a real foreign key: SQLite doesn’t enforce them by default and an out-of-order or redelivered webhook must never fail a write. Both are forward-only; nothing backfills traffic that predates the writers.
calls, one row per call attempt, keyed on the LiveKit room SID:
call_legs, one row per participant, upserted on (call_id, participant_sid):
Deliberately absent from
call_legs: per-leg RTP loss/jitter/MOS. None of it is observable server-side.
node_samples
One row per scrape of one node’s Prometheus exposition: layer-7 fleet telemetry, keyed on (node, source, at_ms), filled by the background scrape worker described in Node metrics. No foreign key and no call_id: these are node-wide counters, and attributing one to a specific call would invent a per-call measurement that doesn’t exist server-side. Every value column is nullable, and NULL means “not measured”, never 0.
Roughly 80 further nullable value columns group into six families: livekit-server room/packet counters, livekit-sip counters (fleet-aggregate, never per-call), node-exporter host metrics (file descriptors, load, memory, network, port headroom), Go runtime (heap, goroutines), Redis health, and the same-loop HTTP health-probe result. Counters are stored raw and diffed at read time so a process restart never bakes in a fake negative rate. See
src/voicegateway/models/node_sample_model.py for the full column list; Node metrics covers what the scrape configures and what the dashboard renders from it.
diagnostics_runs
One row per LiveKit diagnostics run started from the dashboard, replacing what used to be a process-local dict trimmed to 20 entries (erased on every restart).
load_runs and load_run_tests
load_runs is one execution of an external load plan (a ramp, a soak); load_run_tests is one row per test step within it. Nothing derived is stored: counts are kept, not rates, so a bad import can’t silently disagree with the numbers beside it.
load_runs:
load_run_tests (unique on (run_id, name)):
Replay tables
Session replay (attach(snapshots=True)) writes into four tables: replay_stt_events, replay_llm_tokens, replay_tts_frames, replay_state_snapshots, each keyed on (session_id, t_ms) with a JSON payload column. See Replay storage costs for the byte-budget breakdown and voicegw replay for reading a session back.
Views
Two SQL views ship with the initial migration (dialect-branched: Postgres usesto_char(to_timestamp(...)), SQLite uses date(timestamp, 'unixepoch')). No application code queries either one today: the cost-aggregation reads in repository/cost_repository.py (and everything built on it, the dashboard and voicegw costs) run their own hand-written GROUP BY SQL directly against requests instead.
daily_costs
project_daily_costs
Indexes on requests
Every other table listed above carries its own indexes, matched to how it’s read (newest-first history, per-project retention scans, correlation windows); see each model file in
src/voicegateway/models/ for specifics.
Fleet and ClickHouse sinks
Cost writes go through oneSink at a time, chosen by how the process is configured, never more than one per process:
- Embedded (default
voicegw serve).CostTrackerwrites throughLocalSqliteSink, straight into the localStorageService(SQLite, or Postgres ifVOICEGW_DB_URLis set). - Fleet agent (
VOICEGW_COLLECTOR_URL+VOICEGW_API_KEYset).CostTrackerwrites throughRemoteCollectorSink, which batches records and POSTs them to the collector’sPOST /v1/ingest. An optionalspool_pathadds a durable local outbox, a small SQLite file used only as a retry buffer, not a query surface, so a restart resumes delivery instead of losing rows. - Collector process, on
POST /v1/ingest: writes toClickHouseSinkwhen a ClickHouse host is configured, otherwise to its ownStorageService. Never both. Either way the collector re-rates every incoming record against its own rate card before persisting; see Rating.
Related pages
Configuration layers
How SQL managed tables fit into the config merge order.
Security
Fernet encryption for API keys stored in managed_providers.
Cost tracking
How costs are computed before writing to the requests table.
Rating
How rated_price_usd and rate_rule are stamped at write time.
Replay storage costs
On-disk footprint of the conversation replay tables.