@houtini/lm 2.13.1 → 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.
@@ -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 sql.js (pure WASM) zero native deps, works everywhere.
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 initSqlJs from 'sql.js';
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
- const SQL = await initSqlJs();
117
- // Load existing DB from disk if it exists
118
- if (existsSync(DB_PATH)) {
119
- try {
120
- const buf = readFileSync(DB_PATH);
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
- catch {
124
- // Corrupt DB — start fresh
125
- db = new SQL.Database();
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
- else {
129
- db = new SQL.Database();
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.run(`
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.run('ALTER TABLE model_profiles ADD COLUMN emits_think_blocks INTEGER NOT NULL DEFAULT 0');
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.run('ALTER TABLE model_profiles ADD COLUMN supports_thinking_toggle INTEGER NOT NULL DEFAULT 0');
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.run(`
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.run(`
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.run(`CREATE INDEX IF NOT EXISTS idx_prefill_samples_model ON model_prefill_samples(model_id, recorded_at DESC)`);
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
- function saveDb() {
195
- if (!db)
196
- return;
197
- try {
198
- mkdirSync(DB_DIR, { recursive: true });
199
- const data = db.export();
200
- writeFileSync(DB_PATH, Buffer.from(data));
201
- }
202
- catch (err) {
203
- process.stderr.write(`[houtini-lm] Failed to save model cache: ${err}\n`);
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
- const stmt = database.prepare('SELECT * FROM model_profiles WHERE model_id = ?');
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
- finally {
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. Saves to disk immediately by default.
242
- * Pass skipSave=true during batch operations, then call flushDb() when done.
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, skipSave = false) {
298
+ export async function upsertProfile(profile, _skipSave = false) {
245
299
  const database = await initDb();
246
- database.run(`INSERT OR REPLACE INTO model_profiles
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
- profile.modelId,
251
- profile.hfId,
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
- saveDb();
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
- if (card.cardData?.license)
416
- description += ` License: ${card.cardData.license}.`;
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 || null,
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
- const results = [];
611
- const stmt = database.prepare('SELECT * FROM model_profiles ORDER BY model_id');
612
- while (stmt.step()) {
613
- const row = stmt.getAsObject();
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
- const stmt = database.prepare('SELECT * FROM model_performance WHERE model_id = ?');
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
- finally {
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
- const stmt = database.prepare('SELECT * FROM model_performance ORDER BY last_used_at DESC');
710
- const results = [];
711
- try {
712
- while (stmt.step()) {
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
- const stmt = database.prepare(`
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
- try {
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
- finally {
749
- stmt.free();
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
- const existing = await getPerformance(modelId);
768
- if (existing) {
769
- database.run(`UPDATE model_performance SET
770
- total_calls = ?,
771
- ttft_calls = ?,
772
- total_ttft_ms = ?,
773
- perf_calls = ?,
774
- total_tok_per_sec = ?,
775
- total_prompt_tokens = ?,
776
- total_completion_tokens = ?,
777
- total_reasoning_tokens = ?,
778
- last_used_at = ?
779
- WHERE model_id = ?`, [
780
- existing.totalCalls + 1,
781
- existing.ttftCalls + ttftCallDelta,
782
- existing.totalTtftMs + ttftDelta,
783
- existing.perfCalls + perfCallDelta,
784
- existing.totalTokPerSec + perfDelta,
785
- existing.totalPromptTokens + opts.promptTokens,
786
- existing.totalCompletionTokens + opts.completionTokens,
787
- existing.totalReasoningTokens + reasoningDelta,
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.run(`INSERT INTO model_prefill_samples (model_id, prompt_tokens, ttft_ms, recorded_at)
834
- VALUES (?, ?, ?, ?)`, [modelId, promptTokens, ttftMs, now]);
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.run(`DELETE FROM model_prefill_samples
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
- )`, [modelId, modelId, PREFILL_SAMPLES_PER_MODEL]);
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
- const stmt = database.prepare(`SELECT prompt_tokens, ttft_ms, recorded_at
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 results.reverse();
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
- * Ordinary-least-squares linear regression: ttft_ms α + β·prompt_tokens.
879
- * Returns null when there are too few samples or zero variance in the inputs
880
- * (e.g. every sample happened to have the same prompt size).
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
- let sumX = 0, sumY = 0;
887
- for (const s of samples) {
888
- sumX += s.promptTokens;
889
- sumY += s.ttftMs;
890
- }
891
- const meanX = sumX / n;
892
- const meanY = sumY / n;
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 (const s of samples) {
895
- const dx = s.promptTokens - meanX;
896
- const dy = s.ttftMs - meanY;
897
- num += dx * dy;
898
- denX += dx * dx;
899
- denY += dy * dy;
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² via sum-of-squares. Falls back to 0 when denY=0 (all-same TTFTs, rare).
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
  }