@houtini/lm 2.13.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +732 -307
- package/dist/index.js.map +1 -1
- package/dist/inference-lock.d.ts +39 -0
- package/dist/inference-lock.js +155 -0
- package/dist/inference-lock.js.map +1 -0
- package/dist/model-cache.d.ts +27 -10
- package/dist/model-cache.js +239 -241
- package/dist/model-cache.js.map +1 -1
- package/dist/suppress-experimental-warnings.d.ts +0 -0
- package/dist/suppress-experimental-warnings.js +17 -0
- package/dist/suppress-experimental-warnings.js.map +1 -0
- package/package.json +4 -6
- package/server.json +2 -2
package/dist/model-cache.js
CHANGED
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
* looks up each one on HuggingFace's free API. The results are cached in a
|
|
6
6
|
* local SQLite database so subsequent startups are instant (no network).
|
|
7
7
|
*
|
|
8
|
-
* Uses
|
|
8
|
+
* Uses node:sqlite (Node's built-in SQLite, Node >=22.5) in WAL mode, so
|
|
9
|
+
* multiple houtini-lm processes sharing this file get real cross-process
|
|
10
|
+
* concurrency — per-row writes and proper locking instead of whole-file
|
|
11
|
+
* snapshots. Built into Node, so no third-party native dependency and no build
|
|
12
|
+
* step. Existing sql.js databases are standard SQLite and open unchanged.
|
|
9
13
|
*/
|
|
10
|
-
import
|
|
11
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
14
|
+
import { mkdirSync, renameSync } from 'node:fs';
|
|
12
15
|
import { join } from 'node:path';
|
|
13
16
|
import { homedir } from 'node:os';
|
|
14
17
|
const PROMPT_HINTS = [
|
|
@@ -108,28 +111,86 @@ const DB_DIR = join(homedir(), '.houtini-lm');
|
|
|
108
111
|
const DB_PATH = join(DB_DIR, 'model-cache.db');
|
|
109
112
|
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
110
113
|
const HF_TIMEOUT_MS = 8000;
|
|
111
|
-
// ── Database ─────────────────────────────────────────────────────────
|
|
112
114
|
let db = null;
|
|
115
|
+
let DatabaseSyncCtor = null;
|
|
116
|
+
let cacheDisabled = false;
|
|
117
|
+
/**
|
|
118
|
+
* Open (or create) the on-disk SQLite database. node:sqlite is loaded lazily via
|
|
119
|
+
* a guarded dynamic import: on a Node build where it's unavailable (e.g. Node
|
|
120
|
+
* 22.5–22.12 without --experimental-sqlite) the cache is disabled and the server
|
|
121
|
+
* runs without persistence rather than hard-crashing at startup. Construction is
|
|
122
|
+
* synchronous and writes persist directly (no snapshot, no init race). WAL +
|
|
123
|
+
* busy_timeout give multiple processes safe concurrent access to one file.
|
|
124
|
+
* Kept async so existing `await initDb()` callers don't change; returns null when
|
|
125
|
+
* the cache is unavailable — every caller guards on that.
|
|
126
|
+
*/
|
|
113
127
|
export async function initDb() {
|
|
114
128
|
if (db)
|
|
115
129
|
return db;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
db = new SQL.Database(buf);
|
|
130
|
+
if (cacheDisabled)
|
|
131
|
+
return null;
|
|
132
|
+
try {
|
|
133
|
+
if (!DatabaseSyncCtor) {
|
|
134
|
+
({ DatabaseSync: DatabaseSyncCtor } = (await import('node:sqlite')));
|
|
122
135
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
136
|
+
db = doInitDb(DatabaseSyncCtor);
|
|
137
|
+
return db;
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
cacheDisabled = true;
|
|
141
|
+
process.stderr.write(`[houtini-lm] Model cache disabled — node:sqlite unavailable (${err}). ` +
|
|
142
|
+
`Upgrade to Node >=22.13, or run with --experimental-sqlite on Node 22.5–22.12. ` +
|
|
143
|
+
`The server still works; model profiling and cross-session stats are off.\n`);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function openConnection(Ctor) {
|
|
148
|
+
const database = new Ctor(DB_PATH);
|
|
149
|
+
// busy_timeout MUST come first: it makes every subsequent locked operation —
|
|
150
|
+
// including the WAL switch and all writes — wait for the lock instead of
|
|
151
|
+
// throwing SQLITE_BUSY. Without it, concurrent processes opening the same file
|
|
152
|
+
// collide on the journal-mode switch. Then enable WAL (persistent once set) so
|
|
153
|
+
// multiple processes get concurrent readers + a serialised writer.
|
|
154
|
+
database.exec('PRAGMA busy_timeout = 5000');
|
|
155
|
+
database.exec('PRAGMA journal_mode = WAL');
|
|
156
|
+
database.exec('PRAGMA synchronous = NORMAL'); // durable enough for a regenerable cache, much faster
|
|
157
|
+
return database;
|
|
158
|
+
}
|
|
159
|
+
/** True only for errors that mean the file is genuinely unusable — NOT a
|
|
160
|
+
* transient lock/busy, which must never trigger the destructive reset. */
|
|
161
|
+
function isCorruptionError(err) {
|
|
162
|
+
return /malformed|not a database|file is encrypted|disk image|out of memory/i.test(String(err));
|
|
163
|
+
}
|
|
164
|
+
/** Move the corrupt DB aside — INCLUDING its -wal/-shm sidecars. SQLite
|
|
165
|
+
* associates a WAL with a database by filename, so leaving the sidecars would
|
|
166
|
+
* make it replay the corrupt frames into the fresh DB. */
|
|
167
|
+
function quarantineDbFiles() {
|
|
168
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
169
|
+
try {
|
|
170
|
+
renameSync(`${DB_PATH}${suffix}`, `${DB_PATH}${suffix}.corrupt-${process.pid}`);
|
|
126
171
|
}
|
|
172
|
+
catch { /* absent — ignore */ }
|
|
127
173
|
}
|
|
128
|
-
|
|
129
|
-
|
|
174
|
+
}
|
|
175
|
+
function doInitDb(Ctor) {
|
|
176
|
+
mkdirSync(DB_DIR, { recursive: true });
|
|
177
|
+
try {
|
|
178
|
+
return openAndInit(Ctor);
|
|
130
179
|
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
// Genuine corruption anywhere in open OR schema setup → quarantine and retry
|
|
182
|
+
// once. A transient lock is not corruption and rethrows untouched.
|
|
183
|
+
if (!isCorruptionError(err))
|
|
184
|
+
throw err;
|
|
185
|
+
process.stderr.write(`[houtini-lm] Cache DB corrupt (${err}); starting fresh.\n`);
|
|
186
|
+
quarantineDbFiles();
|
|
187
|
+
return openAndInit(Ctor);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function openAndInit(Ctor) {
|
|
191
|
+
const db = openConnection(Ctor);
|
|
131
192
|
// Create table if not exists
|
|
132
|
-
db.
|
|
193
|
+
db.exec(`
|
|
133
194
|
CREATE TABLE IF NOT EXISTS model_profiles (
|
|
134
195
|
model_id TEXT PRIMARY KEY,
|
|
135
196
|
hf_id TEXT,
|
|
@@ -152,15 +213,15 @@ export async function initDb() {
|
|
|
152
213
|
`);
|
|
153
214
|
// Migrate: add thinking columns if upgrading from older schema
|
|
154
215
|
try {
|
|
155
|
-
db.
|
|
216
|
+
db.exec('ALTER TABLE model_profiles ADD COLUMN emits_think_blocks INTEGER NOT NULL DEFAULT 0');
|
|
156
217
|
}
|
|
157
218
|
catch { /* column already exists */ }
|
|
158
219
|
try {
|
|
159
|
-
db.
|
|
220
|
+
db.exec('ALTER TABLE model_profiles ADD COLUMN supports_thinking_toggle INTEGER NOT NULL DEFAULT 0');
|
|
160
221
|
}
|
|
161
222
|
catch { /* column already exists */ }
|
|
162
223
|
// Per-model performance history — accumulated across sessions.
|
|
163
|
-
db.
|
|
224
|
+
db.exec(`
|
|
164
225
|
CREATE TABLE IF NOT EXISTS model_performance (
|
|
165
226
|
model_id TEXT PRIMARY KEY,
|
|
166
227
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
|
@@ -179,7 +240,7 @@ export async function initDb() {
|
|
|
179
240
|
// Stores (prompt_tokens, TTFT_ms) pairs so we can fit TTFT ≈ α + β·tokens
|
|
180
241
|
// and separate fixed per-request overhead from real per-token prefill cost.
|
|
181
242
|
// Capped at PREFILL_SAMPLES_PER_MODEL rows per model; oldest pruned on insert.
|
|
182
|
-
db.
|
|
243
|
+
db.exec(`
|
|
183
244
|
CREATE TABLE IF NOT EXISTS model_prefill_samples (
|
|
184
245
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
185
246
|
model_id TEXT NOT NULL,
|
|
@@ -188,89 +249,64 @@ export async function initDb() {
|
|
|
188
249
|
recorded_at INTEGER NOT NULL
|
|
189
250
|
)
|
|
190
251
|
`);
|
|
191
|
-
db.
|
|
252
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_prefill_samples_model ON model_prefill_samples(model_id, recorded_at DESC)`);
|
|
192
253
|
return db;
|
|
193
254
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
255
|
+
/** Coerce a JS value to something node:sqlite accepts as a bound parameter. */
|
|
256
|
+
function p(v) {
|
|
257
|
+
if (v === undefined || v === null)
|
|
258
|
+
return null;
|
|
259
|
+
if (typeof v === 'boolean')
|
|
260
|
+
return v ? 1 : 0;
|
|
261
|
+
return v;
|
|
262
|
+
}
|
|
263
|
+
/** Map a model_profiles row to the CachedModelProfile shape. */
|
|
264
|
+
function rowToProfile(row) {
|
|
265
|
+
return {
|
|
266
|
+
modelId: row.model_id,
|
|
267
|
+
hfId: row.hf_id,
|
|
268
|
+
pipelineTag: row.pipeline_tag,
|
|
269
|
+
architectures: row.architectures,
|
|
270
|
+
license: row.license,
|
|
271
|
+
downloads: row.downloads,
|
|
272
|
+
likes: row.likes,
|
|
273
|
+
libraryName: row.library_name,
|
|
274
|
+
family: row.family,
|
|
275
|
+
description: row.description,
|
|
276
|
+
strengths: row.strengths,
|
|
277
|
+
weaknesses: row.weaknesses,
|
|
278
|
+
bestFor: row.best_for,
|
|
279
|
+
emitsThinkBlocks: !!row.emits_think_blocks,
|
|
280
|
+
supportsThinkingToggle: !!row.supports_thinking_toggle,
|
|
281
|
+
fetchedAt: row.fetched_at,
|
|
282
|
+
source: row.source,
|
|
283
|
+
};
|
|
205
284
|
}
|
|
206
285
|
// ── Cache operations ─────────────────────────────────────────────────
|
|
207
286
|
export async function getCachedProfile(modelId) {
|
|
208
287
|
const database = await initDb();
|
|
209
|
-
|
|
210
|
-
try {
|
|
211
|
-
stmt.bind([modelId]);
|
|
212
|
-
if (stmt.step()) {
|
|
213
|
-
const row = stmt.getAsObject();
|
|
214
|
-
return {
|
|
215
|
-
modelId: row.model_id,
|
|
216
|
-
hfId: row.hf_id,
|
|
217
|
-
pipelineTag: row.pipeline_tag,
|
|
218
|
-
architectures: row.architectures,
|
|
219
|
-
license: row.license,
|
|
220
|
-
downloads: row.downloads,
|
|
221
|
-
likes: row.likes,
|
|
222
|
-
libraryName: row.library_name,
|
|
223
|
-
family: row.family,
|
|
224
|
-
description: row.description,
|
|
225
|
-
strengths: row.strengths,
|
|
226
|
-
weaknesses: row.weaknesses,
|
|
227
|
-
bestFor: row.best_for,
|
|
228
|
-
emitsThinkBlocks: !!row.emits_think_blocks,
|
|
229
|
-
supportsThinkingToggle: !!row.supports_thinking_toggle,
|
|
230
|
-
fetchedAt: row.fetched_at,
|
|
231
|
-
source: row.source,
|
|
232
|
-
};
|
|
233
|
-
}
|
|
288
|
+
if (!database)
|
|
234
289
|
return null;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
stmt.free();
|
|
238
|
-
}
|
|
290
|
+
const row = database.prepare('SELECT * FROM model_profiles WHERE model_id = ?').get(modelId);
|
|
291
|
+
return row ? rowToProfile(row) : null;
|
|
239
292
|
}
|
|
240
293
|
/**
|
|
241
|
-
* Insert or update a profile in the DB.
|
|
242
|
-
*
|
|
294
|
+
* Insert or update a profile in the DB. node:sqlite persists the write directly,
|
|
295
|
+
* so there's no separate save step. `skipSave` is retained for call-site
|
|
296
|
+
* compatibility but is now a no-op (as is flushDb).
|
|
243
297
|
*/
|
|
244
|
-
export async function upsertProfile(profile,
|
|
298
|
+
export async function upsertProfile(profile, _skipSave = false) {
|
|
245
299
|
const database = await initDb();
|
|
246
|
-
database
|
|
300
|
+
if (!database)
|
|
301
|
+
return;
|
|
302
|
+
database.prepare(`INSERT OR REPLACE INTO model_profiles
|
|
247
303
|
(model_id, hf_id, pipeline_tag, architectures, license, downloads, likes, library_name,
|
|
248
304
|
family, description, strengths, weaknesses, best_for, emits_think_blocks, supports_thinking_toggle, fetched_at, source)
|
|
249
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
profile.pipelineTag,
|
|
253
|
-
profile.architectures,
|
|
254
|
-
profile.license,
|
|
255
|
-
profile.downloads,
|
|
256
|
-
profile.likes,
|
|
257
|
-
profile.libraryName,
|
|
258
|
-
profile.family,
|
|
259
|
-
profile.description,
|
|
260
|
-
profile.strengths,
|
|
261
|
-
profile.weaknesses,
|
|
262
|
-
profile.bestFor,
|
|
263
|
-
profile.emitsThinkBlocks ? 1 : 0,
|
|
264
|
-
profile.supportsThinkingToggle ? 1 : 0,
|
|
265
|
-
profile.fetchedAt,
|
|
266
|
-
profile.source,
|
|
267
|
-
]);
|
|
268
|
-
if (!skipSave)
|
|
269
|
-
saveDb();
|
|
270
|
-
}
|
|
271
|
-
/** Flush DB to disk. Call after batch upsertProfile(…, true) operations. */
|
|
305
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(p(profile.modelId), p(profile.hfId), p(profile.pipelineTag), p(profile.architectures), p(profile.license), p(profile.downloads), p(profile.likes), p(profile.libraryName), p(profile.family), p(profile.description), p(profile.strengths), p(profile.weaknesses), p(profile.bestFor), profile.emitsThinkBlocks ? 1 : 0, profile.supportsThinkingToggle ? 1 : 0, p(profile.fetchedAt), p(profile.source));
|
|
306
|
+
}
|
|
307
|
+
/** No-op — node:sqlite persists writes directly. Retained for compatibility. */
|
|
272
308
|
export function flushDb() {
|
|
273
|
-
|
|
309
|
+
/* writes are persisted immediately by node:sqlite */
|
|
274
310
|
}
|
|
275
311
|
export function isCacheStale(profile) {
|
|
276
312
|
return Date.now() - profile.fetchedAt > CACHE_TTL_MS;
|
|
@@ -394,6 +430,22 @@ async function lookupHF(modelId, publisher) {
|
|
|
394
430
|
// ── Auto-profile generation ──────────────────────────────────────────
|
|
395
431
|
// When HF gives us metadata but we don't have a hardcoded profile,
|
|
396
432
|
// generate a reasonable one from the available data.
|
|
433
|
+
/**
|
|
434
|
+
* Strip control chars / newlines and cap length on free-text HuggingFace card
|
|
435
|
+
* fields before they're stored and later rendered into tool output. A squatted
|
|
436
|
+
* model card (matching a local model id) could otherwise plant multi-line
|
|
437
|
+
* "SYSTEM: …" text that reads as trusted server metadata in discover/list_models.
|
|
438
|
+
*/
|
|
439
|
+
function sanitizeCardText(s, maxLen = 80) {
|
|
440
|
+
if (!s)
|
|
441
|
+
return null;
|
|
442
|
+
// Collapse control chars (incl. newlines) and whitespace runs to one space,
|
|
443
|
+
// then cap length, so a card can't inject multi-line instructions.
|
|
444
|
+
const oneLine = String(s).replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
445
|
+
if (!oneLine)
|
|
446
|
+
return null;
|
|
447
|
+
return oneLine.length > maxLen ? oneLine.slice(0, maxLen) + '\u2026' : oneLine;
|
|
448
|
+
}
|
|
397
449
|
function inferProfileFromHF(card, modelId) {
|
|
398
450
|
const tag = card.pipeline_tag || '';
|
|
399
451
|
const tags = card.tags || [];
|
|
@@ -412,8 +464,9 @@ function inferProfileFromHF(card, modelId) {
|
|
|
412
464
|
description += ' Vision-language model — handles text and image inputs.';
|
|
413
465
|
else if (tag === 'feature-extraction' || tag === 'sentence-similarity')
|
|
414
466
|
description += ' Embedding model for semantic search.';
|
|
415
|
-
|
|
416
|
-
|
|
467
|
+
const safeLicense = sanitizeCardText(card.cardData?.license, 40);
|
|
468
|
+
if (safeLicense)
|
|
469
|
+
description += ` License: ${safeLicense}.`;
|
|
417
470
|
// Infer strengths from tags
|
|
418
471
|
const strengths = [];
|
|
419
472
|
const weaknesses = [];
|
|
@@ -501,6 +554,8 @@ function inferFamily(modelName, org) {
|
|
|
501
554
|
*/
|
|
502
555
|
export async function profileModelsAtStartup(models) {
|
|
503
556
|
const database = await initDb();
|
|
557
|
+
if (!database)
|
|
558
|
+
return;
|
|
504
559
|
let profiledCount = 0;
|
|
505
560
|
let cachedCount = 0;
|
|
506
561
|
for (const model of models) {
|
|
@@ -520,7 +575,7 @@ export async function profileModelsAtStartup(models) {
|
|
|
520
575
|
hfId: card.id,
|
|
521
576
|
pipelineTag: card.pipeline_tag || null,
|
|
522
577
|
architectures: card.config?.architectures ? JSON.stringify(card.config.architectures) : null,
|
|
523
|
-
license: card.cardData?.license
|
|
578
|
+
license: sanitizeCardText(card.cardData?.license, 40),
|
|
524
579
|
downloads: card.downloads || null,
|
|
525
580
|
likes: card.likes || null,
|
|
526
581
|
libraryName: card.library_name || null,
|
|
@@ -607,32 +662,10 @@ export async function getHFEnrichmentLine(modelId) {
|
|
|
607
662
|
*/
|
|
608
663
|
export async function getAllCachedProfiles() {
|
|
609
664
|
const database = await initDb();
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
results.push({
|
|
615
|
-
modelId: row.model_id,
|
|
616
|
-
hfId: row.hf_id,
|
|
617
|
-
pipelineTag: row.pipeline_tag,
|
|
618
|
-
architectures: row.architectures,
|
|
619
|
-
license: row.license,
|
|
620
|
-
downloads: row.downloads,
|
|
621
|
-
likes: row.likes,
|
|
622
|
-
libraryName: row.library_name,
|
|
623
|
-
family: row.family,
|
|
624
|
-
description: row.description,
|
|
625
|
-
strengths: row.strengths,
|
|
626
|
-
weaknesses: row.weaknesses,
|
|
627
|
-
bestFor: row.best_for,
|
|
628
|
-
emitsThinkBlocks: !!row.emits_think_blocks,
|
|
629
|
-
supportsThinkingToggle: !!row.supports_thinking_toggle,
|
|
630
|
-
fetchedAt: row.fetched_at,
|
|
631
|
-
source: row.source,
|
|
632
|
-
});
|
|
633
|
-
}
|
|
634
|
-
stmt.free();
|
|
635
|
-
return results;
|
|
665
|
+
if (!database)
|
|
666
|
+
return [];
|
|
667
|
+
const rows = database.prepare('SELECT * FROM model_profiles ORDER BY model_id').all();
|
|
668
|
+
return rows.map(rowToProfile);
|
|
636
669
|
}
|
|
637
670
|
/**
|
|
638
671
|
* Query thinking support for a model from the cache.
|
|
@@ -688,17 +721,10 @@ export async function getPerformance(modelId) {
|
|
|
688
721
|
if (!modelId)
|
|
689
722
|
return null;
|
|
690
723
|
const database = await initDb();
|
|
691
|
-
|
|
692
|
-
try {
|
|
693
|
-
stmt.bind([modelId]);
|
|
694
|
-
if (stmt.step()) {
|
|
695
|
-
return rowToPerformance(stmt.getAsObject());
|
|
696
|
-
}
|
|
724
|
+
if (!database)
|
|
697
725
|
return null;
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
stmt.free();
|
|
701
|
-
}
|
|
726
|
+
const row = database.prepare('SELECT * FROM model_performance WHERE model_id = ?').get(modelId);
|
|
727
|
+
return row ? rowToPerformance(row) : null;
|
|
702
728
|
}
|
|
703
729
|
/**
|
|
704
730
|
* Fetch all per-model performance records (used for routing and for seeding
|
|
@@ -706,17 +732,10 @@ export async function getPerformance(modelId) {
|
|
|
706
732
|
*/
|
|
707
733
|
export async function getAllPerformance() {
|
|
708
734
|
const database = await initDb();
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
results.push(rowToPerformance(stmt.getAsObject()));
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
finally {
|
|
717
|
-
stmt.free();
|
|
718
|
-
}
|
|
719
|
-
return results;
|
|
735
|
+
if (!database)
|
|
736
|
+
return [];
|
|
737
|
+
const rows = database.prepare('SELECT * FROM model_performance ORDER BY last_used_at DESC').all();
|
|
738
|
+
return rows.map(rowToPerformance);
|
|
720
739
|
}
|
|
721
740
|
/**
|
|
722
741
|
* Fetch workstation-wide lifetime totals: every call, every token ever
|
|
@@ -725,29 +744,24 @@ export async function getAllPerformance() {
|
|
|
725
744
|
*/
|
|
726
745
|
export async function getLifetimeTotals() {
|
|
727
746
|
const database = await initDb();
|
|
728
|
-
|
|
747
|
+
if (!database)
|
|
748
|
+
return { totalTokens: 0, totalCalls: 0, modelsUsed: 0, firstSeenAt: null };
|
|
749
|
+
const row = database.prepare(`
|
|
729
750
|
SELECT
|
|
730
751
|
COALESCE(SUM(total_prompt_tokens + total_completion_tokens), 0) AS total_tokens,
|
|
731
752
|
COALESCE(SUM(total_calls), 0) AS total_calls,
|
|
732
753
|
COUNT(*) AS models_used,
|
|
733
754
|
MIN(first_seen_at) AS first_seen_at
|
|
734
755
|
FROM model_performance
|
|
735
|
-
`);
|
|
736
|
-
|
|
737
|
-
if (stmt.step()) {
|
|
738
|
-
const row = stmt.getAsObject();
|
|
739
|
-
return {
|
|
740
|
-
totalTokens: row.total_tokens || 0,
|
|
741
|
-
totalCalls: row.total_calls || 0,
|
|
742
|
-
modelsUsed: row.models_used || 0,
|
|
743
|
-
firstSeenAt: row.first_seen_at ?? null,
|
|
744
|
-
};
|
|
745
|
-
}
|
|
756
|
+
`).get();
|
|
757
|
+
if (!row)
|
|
746
758
|
return { totalTokens: 0, totalCalls: 0, modelsUsed: 0, firstSeenAt: null };
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
759
|
+
return {
|
|
760
|
+
totalTokens: row.total_tokens || 0,
|
|
761
|
+
totalCalls: row.total_calls || 0,
|
|
762
|
+
modelsUsed: row.models_used || 0,
|
|
763
|
+
firstSeenAt: row.first_seen_at ?? null,
|
|
764
|
+
};
|
|
751
765
|
}
|
|
752
766
|
/**
|
|
753
767
|
* Append a single call's usage + timing to the per-model performance record.
|
|
@@ -758,56 +772,35 @@ export async function recordPerformance(modelId, opts) {
|
|
|
758
772
|
if (!modelId)
|
|
759
773
|
return;
|
|
760
774
|
const database = await initDb();
|
|
775
|
+
if (!database)
|
|
776
|
+
return;
|
|
761
777
|
const now = Date.now();
|
|
762
778
|
const ttftDelta = opts.ttftMs && opts.ttftMs > 0 ? opts.ttftMs : 0;
|
|
763
779
|
const ttftCallDelta = ttftDelta > 0 ? 1 : 0;
|
|
764
780
|
const perfDelta = opts.tokPerSec && opts.tokPerSec > 0 ? opts.tokPerSec : 0;
|
|
765
781
|
const perfCallDelta = perfDelta > 0 ? 1 : 0;
|
|
766
782
|
const reasoningDelta = opts.reasoningTokens ?? 0;
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
now,
|
|
789
|
-
modelId,
|
|
790
|
-
]);
|
|
791
|
-
}
|
|
792
|
-
else {
|
|
793
|
-
database.run(`INSERT INTO model_performance (
|
|
794
|
-
model_id, total_calls, ttft_calls, total_ttft_ms, perf_calls, total_tok_per_sec,
|
|
795
|
-
total_prompt_tokens, total_completion_tokens, total_reasoning_tokens,
|
|
796
|
-
first_seen_at, last_used_at
|
|
797
|
-
) VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
798
|
-
modelId,
|
|
799
|
-
ttftCallDelta,
|
|
800
|
-
ttftDelta,
|
|
801
|
-
perfCallDelta,
|
|
802
|
-
perfDelta,
|
|
803
|
-
opts.promptTokens,
|
|
804
|
-
opts.completionTokens,
|
|
805
|
-
reasoningDelta,
|
|
806
|
-
now,
|
|
807
|
-
now,
|
|
808
|
-
]);
|
|
809
|
-
}
|
|
810
|
-
saveDb();
|
|
783
|
+
// Single atomic upsert with relative arithmetic done in SQL. The previous
|
|
784
|
+
// read-modify-write (SELECT then UPDATE with absolute values) lost updates
|
|
785
|
+
// when two fire-and-forget calls for the same model raced across the await,
|
|
786
|
+
// and two concurrent first-calls both took the INSERT branch and one threw a
|
|
787
|
+
// swallowed UNIQUE violation. `ON CONFLICT DO UPDATE SET col = col + excluded.col`
|
|
788
|
+
// has no read gap and no duplicate-insert failure.
|
|
789
|
+
database.prepare(`INSERT INTO model_performance (
|
|
790
|
+
model_id, total_calls, ttft_calls, total_ttft_ms, perf_calls, total_tok_per_sec,
|
|
791
|
+
total_prompt_tokens, total_completion_tokens, total_reasoning_tokens,
|
|
792
|
+
first_seen_at, last_used_at
|
|
793
|
+
) VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
794
|
+
ON CONFLICT(model_id) DO UPDATE SET
|
|
795
|
+
total_calls = total_calls + 1,
|
|
796
|
+
ttft_calls = ttft_calls + excluded.ttft_calls,
|
|
797
|
+
total_ttft_ms = total_ttft_ms + excluded.total_ttft_ms,
|
|
798
|
+
perf_calls = perf_calls + excluded.perf_calls,
|
|
799
|
+
total_tok_per_sec = total_tok_per_sec + excluded.total_tok_per_sec,
|
|
800
|
+
total_prompt_tokens = total_prompt_tokens + excluded.total_prompt_tokens,
|
|
801
|
+
total_completion_tokens = total_completion_tokens + excluded.total_completion_tokens,
|
|
802
|
+
total_reasoning_tokens = total_reasoning_tokens + excluded.total_reasoning_tokens,
|
|
803
|
+
last_used_at = excluded.last_used_at`).run(p(modelId), ttftCallDelta, ttftDelta, perfCallDelta, perfDelta, p(opts.promptTokens), p(opts.completionTokens), reasoningDelta, now, now);
|
|
811
804
|
}
|
|
812
805
|
// ── Prefill sample collection (linear-fit estimator) ─────────────────
|
|
813
806
|
//
|
|
@@ -829,19 +822,20 @@ export async function recordPrefillSample(modelId, promptTokens, ttftMs) {
|
|
|
829
822
|
if (!modelId || promptTokens <= 0 || ttftMs <= 0)
|
|
830
823
|
return;
|
|
831
824
|
const database = await initDb();
|
|
825
|
+
if (!database)
|
|
826
|
+
return;
|
|
832
827
|
const now = Date.now();
|
|
833
|
-
database.
|
|
834
|
-
VALUES (?, ?, ?, ?)
|
|
828
|
+
database.prepare(`INSERT INTO model_prefill_samples (model_id, prompt_tokens, ttft_ms, recorded_at)
|
|
829
|
+
VALUES (?, ?, ?, ?)`).run(p(modelId), promptTokens, ttftMs, now);
|
|
835
830
|
// Prune oldest samples beyond the cap for this model.
|
|
836
|
-
database.
|
|
831
|
+
database.prepare(`DELETE FROM model_prefill_samples
|
|
837
832
|
WHERE model_id = ?
|
|
838
833
|
AND id NOT IN (
|
|
839
834
|
SELECT id FROM model_prefill_samples
|
|
840
835
|
WHERE model_id = ?
|
|
841
|
-
ORDER BY recorded_at DESC
|
|
836
|
+
ORDER BY recorded_at DESC, id DESC
|
|
842
837
|
LIMIT ?
|
|
843
|
-
)
|
|
844
|
-
saveDb();
|
|
838
|
+
)`).run(p(modelId), p(modelId), PREFILL_SAMPLES_PER_MODEL);
|
|
845
839
|
}
|
|
846
840
|
/**
|
|
847
841
|
* Fetch the most recent N prefill samples for a model (oldest-first so the
|
|
@@ -851,52 +845,56 @@ export async function getPrefillSamples(modelId, limit = PREFILL_SAMPLES_PER_MOD
|
|
|
851
845
|
if (!modelId)
|
|
852
846
|
return [];
|
|
853
847
|
const database = await initDb();
|
|
854
|
-
|
|
848
|
+
if (!database)
|
|
849
|
+
return [];
|
|
850
|
+
const rows = database.prepare(`SELECT prompt_tokens, ttft_ms, recorded_at
|
|
855
851
|
FROM model_prefill_samples
|
|
856
852
|
WHERE model_id = ?
|
|
857
|
-
ORDER BY recorded_at DESC
|
|
858
|
-
LIMIT ?`);
|
|
859
|
-
const results = [];
|
|
860
|
-
try {
|
|
861
|
-
stmt.bind([modelId, limit]);
|
|
862
|
-
while (stmt.step()) {
|
|
863
|
-
const row = stmt.getAsObject();
|
|
864
|
-
results.push({
|
|
865
|
-
promptTokens: row.prompt_tokens,
|
|
866
|
-
ttftMs: row.ttft_ms,
|
|
867
|
-
recordedAt: row.recorded_at,
|
|
868
|
-
});
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
finally {
|
|
872
|
-
stmt.free();
|
|
873
|
-
}
|
|
853
|
+
ORDER BY recorded_at DESC, id DESC
|
|
854
|
+
LIMIT ?`).all(p(modelId), limit);
|
|
874
855
|
// Reverse so caller gets oldest-first (monotonic recordedAt).
|
|
875
|
-
return
|
|
856
|
+
return rows.map((row) => ({
|
|
857
|
+
promptTokens: row.prompt_tokens,
|
|
858
|
+
ttftMs: row.ttft_ms,
|
|
859
|
+
recordedAt: row.recorded_at,
|
|
860
|
+
})).reverse();
|
|
876
861
|
}
|
|
877
862
|
/**
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
863
|
+
* Half-life (in samples) for the recency weighting below. A backend restart
|
|
864
|
+
* with different settings (vLLM batching flags, quant, GPU split) changes the
|
|
865
|
+
* prefill regime entirely; without decay, stale samples from the old regime
|
|
866
|
+
* poison the fit for up to PREFILL_SAMPLES_PER_MODEL calls.
|
|
867
|
+
*/
|
|
868
|
+
const PREFILL_FIT_HALF_LIFE_SAMPLES = 6;
|
|
869
|
+
/**
|
|
870
|
+
* Recency-weighted least-squares linear regression:
|
|
871
|
+
* ttft_ms ≈ α + β·prompt_tokens, with sample weights decaying by half every
|
|
872
|
+
* PREFILL_FIT_HALF_LIFE_SAMPLES samples (newest weighted highest — `samples`
|
|
873
|
+
* arrives oldest-first). Returns null when there are too few samples or zero
|
|
874
|
+
* variance in the inputs (e.g. every sample had the same prompt size).
|
|
881
875
|
*/
|
|
882
876
|
export function fitPrefillLinear(samples) {
|
|
883
877
|
const n = samples.length;
|
|
884
878
|
if (n < PREFILL_FIT_MIN_SAMPLES)
|
|
885
879
|
return null;
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
880
|
+
const weight = (i) => 0.5 ** ((n - 1 - i) / PREFILL_FIT_HALF_LIFE_SAMPLES);
|
|
881
|
+
let sumW = 0, sumX = 0, sumY = 0;
|
|
882
|
+
for (let i = 0; i < n; i++) {
|
|
883
|
+
const w = weight(i);
|
|
884
|
+
sumW += w;
|
|
885
|
+
sumX += w * samples[i].promptTokens;
|
|
886
|
+
sumY += w * samples[i].ttftMs;
|
|
887
|
+
}
|
|
888
|
+
const meanX = sumX / sumW;
|
|
889
|
+
const meanY = sumY / sumW;
|
|
893
890
|
let num = 0, denX = 0, denY = 0;
|
|
894
|
-
for (
|
|
895
|
-
const
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
891
|
+
for (let i = 0; i < n; i++) {
|
|
892
|
+
const w = weight(i);
|
|
893
|
+
const dx = samples[i].promptTokens - meanX;
|
|
894
|
+
const dy = samples[i].ttftMs - meanY;
|
|
895
|
+
num += w * dx * dy;
|
|
896
|
+
denX += w * dx * dx;
|
|
897
|
+
denY += w * dy * dy;
|
|
900
898
|
}
|
|
901
899
|
// Zero variance in X — every sample was the same prompt size. Can't fit
|
|
902
900
|
// a meaningful slope; caller should fall back to the simpler estimator.
|
|
@@ -904,7 +902,7 @@ export function fitPrefillLinear(samples) {
|
|
|
904
902
|
return null;
|
|
905
903
|
const beta = num / denX;
|
|
906
904
|
const alpha = meanY - beta * meanX;
|
|
907
|
-
// R
|
|
905
|
+
// Weighted R². Falls back to 0 when denY=0 (all-same TTFTs, rare).
|
|
908
906
|
const r2 = denY > 0 ? (num * num) / (denX * denY) : 0;
|
|
909
907
|
return { alphaMs: alpha, betaMsPerToken: beta, r2, n };
|
|
910
908
|
}
|