@oh-my-pi/pi-catalog 18.4.6 → 18.4.9

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.4.9] - 2026-10-01
6
+
7
+ ### Fixed
8
+
9
+ - Fixed model catalog caching so unchanged catalogs refresh without unnecessary rewrites, and offline snapshots for endpoint-less models (such as Azure models) are now handled correctly across startups.
10
+
5
11
  ## [18.4.6] - 2026-10-01
6
12
 
7
13
  ### Added
@@ -1,4 +1,19 @@
1
1
  import type { Api, Model } from "./types.js";
2
+ /** Cumulative model-cache write counters for this process. */
3
+ export interface ModelCacheWriteStats {
4
+ /** Payload rows (re)written because their stored content changed. */
5
+ payloadWrites: number;
6
+ /** Serialized model bytes written by `payloadWrites`. */
7
+ payloadBytes: number;
8
+ /** Unchanged payloads whose freshness/authority advanced via the side table. */
9
+ refreshWrites: number;
10
+ /** Writes that matched the stored row exactly and touched nothing. */
11
+ skippedWrites: number;
12
+ /** Provider ids of the most recent payload writes (bounded). */
13
+ recentPayloadProviders: readonly string[];
14
+ }
15
+ /** Snapshot of this process's model-cache write counters. */
16
+ export declare function getModelCacheWriteStats(): ModelCacheWriteStats;
2
17
  /** Materialized provider snapshot with freshness and header-restoration provenance. */
3
18
  export interface CacheEntry<TApi extends Api = Api> {
4
19
  /** Trusted, fully materialized rows; request headers remain omitted. */
@@ -21,4 +36,14 @@ export interface CacheEntry<TApi extends Api = Api> {
21
36
  staticFingerprint: string;
22
37
  }
23
38
  export declare function readModelCache<TApi extends Api>(providerId: string, ttlMs: number, now: () => number, dbPath?: string): CacheEntry<TApi> | null;
39
+ /**
40
+ * Persist a provider snapshot.
41
+ *
42
+ * When the stored payload (models, header provenance, fingerprint, schema and
43
+ * materialization policy) is byte-identical, the multi-MB row is left alone and
44
+ * only `model_cache_refresh` records the new freshness and authority; a write
45
+ * that changes nothing at all is skipped. Every model persisted here is
46
+ * validated with the reader's own predicate, so a row this writes is never
47
+ * rejected (and deleted) by `readModelCache`.
48
+ */
24
49
  export declare function writeModelCache<TApi extends Api>(providerId: string, updatedAt: number, models: Model<TApi>[], authoritative: boolean, staticFingerprint: string, dbPath?: string, staticHeaderSources?: readonly Model<TApi>[], restorableHeaderFallback?: Record<string, string>): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-catalog",
3
- "version": "18.4.6",
3
+ "version": "18.4.9",
4
4
  "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence",
5
5
  "keywords": [
6
6
  "ai",
@@ -93,12 +93,12 @@
93
93
  "gen:proto": "bun scripts/generate-protocols.ts"
94
94
  },
95
95
  "dependencies": {
96
- "@oh-my-pi/omptype": "18.4.6",
97
- "@oh-my-pi/pi-utils": "18.4.6"
96
+ "@oh-my-pi/omptype": "18.4.9",
97
+ "@oh-my-pi/pi-utils": "18.4.9"
98
98
  },
99
99
  "devDependencies": {
100
100
  "@bgotink/kdl": "0.4.0",
101
- "@oh-my-pi/pi-ai": "18.4.6",
101
+ "@oh-my-pi/pi-ai": "18.4.9",
102
102
  "@types/bun": "^1.3.14"
103
103
  },
104
104
  "engines": {
@@ -3,7 +3,7 @@
3
3
  * Replaces per-provider JSON files with a single cache.db.
4
4
  */
5
5
  import type { Database } from "bun:sqlite";
6
- import { getModelDbPath, isSqliteCorruptionError, openSqliteDatabaseSync, VERSION } from "@oh-my-pi/pi-utils";
6
+ import { getModelDbPath, isSqliteCorruptionError, logger, openSqliteDatabaseSync, VERSION } from "@oh-my-pi/pi-utils";
7
7
  import RULES from "./compat/rules.json" with { type: "json" };
8
8
  import type { Api, Model } from "./types";
9
9
 
@@ -25,6 +25,8 @@ import type { Api, Model } from "./types";
25
25
  // effort-tier variant collapsing (raw `-low`/`-high`/`-thinking` member ids);
26
26
  // v4 dropped the pre-efforts ThinkingConfig shape.
27
27
  const CACHE_SCHEMA_VERSION = 13;
28
+ /** Oldest schema whose rows are guaranteed never to carry request headers. */
29
+ const FIRST_HEADERLESS_SCHEMA_VERSION = 11;
28
30
  const HEADER_RESTORE_VERSION = 1;
29
31
  /**
30
32
  * Explicit compatibility gate for materialized rows. Bump whenever buildModel
@@ -55,8 +57,58 @@ interface CacheRow {
55
57
  header_omitted_model_ids: string;
56
58
  unrestorable_header_model_ids: string;
57
59
  header_restore_version: number;
60
+ /** `model_cache_refresh` columns joined onto the payload row; null when absent. */
61
+ refresh_payload_updated_at: number | null;
62
+ refresh_updated_at: number | null;
63
+ refresh_authoritative: number | null;
58
64
  }
59
65
 
66
+ /**
67
+ * Payload rows are multi-MB and SQLite rewrites a whole record (including its
68
+ * overflow pages) on any column update. Freshness that advances without a
69
+ * payload change therefore lives in `model_cache_refresh`, a small side row
70
+ * keyed by provider. It applies only while `payload_updated_at` still matches
71
+ * the payload row's `updated_at`, so a payload rewritten by another binary
72
+ * silently invalidates it.
73
+ */
74
+ const SELECT_CACHE_ROW = `
75
+ SELECT m.*,
76
+ r.payload_updated_at AS refresh_payload_updated_at,
77
+ r.updated_at AS refresh_updated_at,
78
+ r.authoritative AS refresh_authoritative
79
+ FROM model_cache m
80
+ LEFT JOIN model_cache_refresh r ON r.provider_id = m.provider_id
81
+ WHERE m.provider_id = ?
82
+ `;
83
+
84
+ /** Cumulative model-cache write counters for this process. */
85
+ export interface ModelCacheWriteStats {
86
+ /** Payload rows (re)written because their stored content changed. */
87
+ payloadWrites: number;
88
+ /** Serialized model bytes written by `payloadWrites`. */
89
+ payloadBytes: number;
90
+ /** Unchanged payloads whose freshness/authority advanced via the side table. */
91
+ refreshWrites: number;
92
+ /** Writes that matched the stored row exactly and touched nothing. */
93
+ skippedWrites: number;
94
+ /** Provider ids of the most recent payload writes (bounded). */
95
+ recentPayloadProviders: readonly string[];
96
+ }
97
+
98
+ const writeStats = { payloadWrites: 0, payloadBytes: 0, refreshWrites: 0, skippedWrites: 0 };
99
+ const recentPayloadProviders: string[] = [];
100
+ const RECENT_PAYLOAD_PROVIDERS_MAX = 64;
101
+
102
+ /** Snapshot of this process's model-cache write counters. */
103
+ export function getModelCacheWriteStats(): ModelCacheWriteStats {
104
+ return { ...writeStats, recentPayloadProviders: [...recentPayloadProviders] };
105
+ }
106
+
107
+ type StoredRowState = Pick<
108
+ CacheRow,
109
+ "updated_at" | "authoritative" | "refresh_payload_updated_at" | "refresh_updated_at" | "refresh_authoritative"
110
+ >;
111
+
60
112
  interface TableInfoRow {
61
113
  name: string;
62
114
  }
@@ -144,9 +196,11 @@ function invalidateReadPath(resolvedPath: string): void {
144
196
 
145
197
  function initializeDb(db: Database): void {
146
198
  // The shared opener installs the busy handler before any lock-taking
147
- // statement. Schema invalidation can delete rows containing credentials
148
- // written by old versions, so scrub deleted cells (#5780).
149
- db.run("PRAGMA secure_delete = ON");
199
+ // statement. Current rows never persist request headers, so routine
200
+ // replacements do not need freed overflow pages zero-filled (that doubled
201
+ // the I/O of every multi-MB refresh). The legacy credential purge (#5780)
202
+ // re-enables ON just around its DELETE.
203
+ db.run("PRAGMA secure_delete = FAST");
150
204
  db.run("PRAGMA journal_mode = WAL");
151
205
  db.run(`
152
206
  CREATE TABLE IF NOT EXISTS model_cache (
@@ -162,6 +216,14 @@ function initializeDb(db: Database): void {
162
216
  models TEXT NOT NULL
163
217
  )
164
218
  `);
219
+ db.run(`
220
+ CREATE TABLE IF NOT EXISTS model_cache_refresh (
221
+ provider_id TEXT PRIMARY KEY,
222
+ payload_updated_at INTEGER NOT NULL,
223
+ updated_at INTEGER NOT NULL,
224
+ authoritative INTEGER NOT NULL
225
+ )
226
+ `);
165
227
  migrateCacheSchema(db);
166
228
  }
167
229
 
@@ -237,15 +299,29 @@ function migrateCacheSchema(db: Database): void {
237
299
  } finally {
238
300
  stmt.finalize();
239
301
  }
240
- // Delete rows written under any older schema so they cannot be reused. The
241
- // legacy `UPDATE ... WHERE version = 2` migration silently promoted the very
242
- // first cache version to whatever the current one is, defeating every
243
- // subsequent invalidation (see #4146: pre-V2 Codex rows kept the legacy
244
- // compaction path even after CACHE_SCHEMA_VERSION was bumped).
245
- db.run("DELETE FROM model_cache WHERE version <> ? OR materialization_policy <> ?", [
246
- CACHE_SCHEMA_VERSION,
247
- materializationPolicy(),
248
- ]);
302
+ // Rows predating v11 may carry credential-bearing request headers (v10 could
303
+ // still persist derived computer-use headers), so purge them and scrub the
304
+ // freed cells (#5780). Every other non-current row (v11/v12, a newer schema,
305
+ // or another materialization policy from a different app version sharing
306
+ // this file) never persisted headers: readers gate on the exact version and
307
+ // policy, treat it as absent, and the next write for that provider replaces
308
+ // it lazily, so switching versions does not wipe and re-download every
309
+ // provider. Never promote old rows in place: the legacy `UPDATE ... WHERE
310
+ // version = 2` migration did, defeating every later invalidation (#4146).
311
+ const legacyStmt = db.prepare("SELECT 1 AS found FROM model_cache WHERE version < ? LIMIT 1");
312
+ let hasLegacyRows: boolean;
313
+ try {
314
+ hasLegacyRows = legacyStmt.get(FIRST_HEADERLESS_SCHEMA_VERSION) !== null;
315
+ } finally {
316
+ legacyStmt.finalize();
317
+ }
318
+ if (!hasLegacyRows) return;
319
+ db.run("PRAGMA secure_delete = ON");
320
+ try {
321
+ db.run("DELETE FROM model_cache WHERE version < ?", [FIRST_HEADERLESS_SCHEMA_VERSION]);
322
+ } finally {
323
+ db.run("PRAGMA secure_delete = FAST");
324
+ }
249
325
  }
250
326
 
251
327
  function isMaterializedModel(value: unknown): value is PersistedModel<Api> {
@@ -260,8 +336,9 @@ function isMaterializedModel(value: unknown): value is PersistedModel<Api> {
260
336
  model.api.length === 0 ||
261
337
  typeof model.provider !== "string" ||
262
338
  model.provider.length === 0 ||
339
+ // Empty is legitimate: Azure-style providers resolve the endpoint from
340
+ // configuration at request time, and their bundled rows carry "".
263
341
  typeof model.baseUrl !== "string" ||
264
- model.baseUrl.length === 0 ||
265
342
  typeof model.reasoning !== "boolean" ||
266
343
  !Array.isArray(model.input) ||
267
344
  model.input.length === 0 ||
@@ -351,7 +428,7 @@ export function readModelCache<TApi extends Api>(
351
428
  }
352
429
  // Any commit (to any provider's row) bumps data_version. Re-read this
353
430
  // row, but reuse the parsed entry when its bytes are unchanged.
354
- const stmt = db.query<CacheRow, [string]>("SELECT * FROM model_cache WHERE provider_id = ?");
431
+ const stmt = db.query<CacheRow, [string]>(SELECT_CACHE_ROW);
355
432
  let row: CacheRow | null;
356
433
  try {
357
434
  row = stmt.get(providerId);
@@ -366,7 +443,8 @@ export function readModelCache<TApi extends Api>(
366
443
  row.materialization_policy === materializationPolicy() &&
367
444
  cacheRowsEqual(cached.row, row)
368
445
  ) {
369
- entry = withFreshness(cached.entry as CacheEntry<TApi>, ttlMs, now);
446
+ // Payload bytes unchanged; only side-table freshness may have moved.
447
+ entry = withFreshness(withRowState(cached.entry as CacheEntry<TApi>, row), ttlMs, now);
370
448
  } else {
371
449
  entry = parseCacheRow<TApi>(db, providerId, row, ttlMs, now);
372
450
  }
@@ -383,6 +461,7 @@ export function readModelCache<TApi extends Api>(
383
461
  }
384
462
  }
385
463
 
464
+ /** Whether two rows carry the same payload record (refresh side-row columns excluded). */
386
465
  function cacheRowsEqual(left: CacheRow, right: CacheRow): boolean {
387
466
  return (
388
467
  left.version === right.version &&
@@ -397,6 +476,25 @@ function cacheRowsEqual(left: CacheRow, right: CacheRow): boolean {
397
476
  );
398
477
  }
399
478
 
479
+ /** Effective freshness/authority: the side row wins while it refers to this payload. */
480
+ function rowState(row: StoredRowState): { updatedAt: number; authoritative: boolean } {
481
+ if (
482
+ row.refresh_updated_at !== null &&
483
+ row.refresh_payload_updated_at === row.updated_at &&
484
+ row.refresh_authoritative !== null
485
+ ) {
486
+ return { updatedAt: row.refresh_updated_at, authoritative: row.refresh_authoritative === 1 };
487
+ }
488
+ return { updatedAt: row.updated_at, authoritative: row.authoritative === 1 };
489
+ }
490
+
491
+ function withRowState<TApi extends Api>(entry: CacheEntry<TApi>, row: CacheRow): CacheEntry<TApi> {
492
+ const { updatedAt, authoritative } = rowState(row);
493
+ return entry.updatedAt === updatedAt && entry.authoritative === authoritative
494
+ ? entry
495
+ : { ...entry, updatedAt, authoritative };
496
+ }
497
+
400
498
  function parseCacheRow<TApi extends Api>(
401
499
  db: Database,
402
500
  providerId: string,
@@ -413,17 +511,19 @@ function parseCacheRow<TApi extends Api>(
413
511
  if (models === null || headerOmittedModelIds === null || unrestorableHeaderModelIds === null) {
414
512
  // Fail closed on corrupt header provenance: treating malformed
415
513
  // markers as empty could return a model with required credentials
416
- // silently absent. secure_delete scrubs the rejected payload.
514
+ // silently absent. Current-schema rows never persist headers, so the
515
+ // rejected payload needs no secure scrub.
417
516
  db.run("DELETE FROM model_cache WHERE provider_id = ?", [providerId]);
418
517
  return null;
419
518
  }
420
- const ageMs = now() - row.updated_at;
519
+ const { updatedAt, authoritative } = rowState(row);
520
+ const ageMs = now() - updatedAt;
421
521
  const fresh = Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= ttlMs;
422
522
  return {
423
523
  models,
424
524
  fresh,
425
- authoritative: row.authoritative === 1,
426
- updatedAt: row.updated_at,
525
+ authoritative,
526
+ updatedAt,
427
527
  headerOmittedModelIds,
428
528
  unrestorableHeaderModelIds,
429
529
  legacyHeaderRestoreMarkers: row.header_restore_version < HEADER_RESTORE_VERSION,
@@ -468,6 +568,16 @@ function headersEqual(left: Record<string, string> | undefined, right: Record<st
468
568
  return true;
469
569
  }
470
570
 
571
+ /**
572
+ * Persist a provider snapshot.
573
+ *
574
+ * When the stored payload (models, header provenance, fingerprint, schema and
575
+ * materialization policy) is byte-identical, the multi-MB row is left alone and
576
+ * only `model_cache_refresh` records the new freshness and authority; a write
577
+ * that changes nothing at all is skipped. Every model persisted here is
578
+ * validated with the reader's own predicate, so a row this writes is never
579
+ * rejected (and deleted) by `readModelCache`.
580
+ */
471
581
  export function writeModelCache<TApi extends Api>(
472
582
  providerId: string,
473
583
  updatedAt: number,
@@ -484,8 +594,16 @@ export function writeModelCache<TApi extends Api>(
484
594
  const headerOmittedModelIds: string[] = [];
485
595
  const unrestorableHeaderModelIds: string[] = [];
486
596
  const cachedModels: PersistedModel<TApi>[] = [];
597
+ const rejectedModelIds: string[] = [];
487
598
  const staticById = new Map(staticHeaderSources.map(model => [model.id, model]));
488
599
  for (const model of models) {
600
+ const cachedModel = toCachedModel(model);
601
+ if (!isMaterializedModel(cachedModel)) {
602
+ // One unreadable model must not make the whole row fail
603
+ // validation on read (which deletes it and refetches forever).
604
+ rejectedModelIds.push(model.id);
605
+ continue;
606
+ }
489
607
  if (hasModelHeaders(model)) {
490
608
  headerOmittedModelIds.push(model.id);
491
609
  // Synthesized variants (e.g. Copilot `-1m`) have no same-id static
@@ -507,27 +625,97 @@ export function writeModelCache<TApi extends Api>(
507
625
  unrestorableHeaderModelIds.push(model.id);
508
626
  }
509
627
  }
510
- cachedModels.push(toCachedModel(model));
628
+ cachedModels.push(cachedModel);
629
+ }
630
+ if (rejectedModelIds.length > 0) {
631
+ logger.debug("model cache skipped models that would fail cache validation", {
632
+ provider: providerId,
633
+ models: rejectedModelIds,
634
+ });
635
+ }
636
+ const policy = materializationPolicy();
637
+ const authoritativeFlag = authoritative ? 1 : 0;
638
+ const serializedHeaderOmitted = JSON.stringify(headerOmittedModelIds);
639
+ const serializedUnrestorable = JSON.stringify(unrestorableHeaderModelIds);
640
+ const serializedModels = JSON.stringify(cachedModels);
641
+ const outcome = db
642
+ .transaction((): "skipped" | "refreshed" | "written" => {
643
+ const matchStmt = db.prepare<
644
+ StoredRowState,
645
+ [string, number, string, string, string, string, number, string]
646
+ >(
647
+ `SELECT m.updated_at, m.authoritative,
648
+ r.payload_updated_at AS refresh_payload_updated_at,
649
+ r.updated_at AS refresh_updated_at,
650
+ r.authoritative AS refresh_authoritative
651
+ FROM model_cache m
652
+ LEFT JOIN model_cache_refresh r ON r.provider_id = m.provider_id
653
+ WHERE m.provider_id = ? AND m.version = ? AND m.materialization_policy = ?
654
+ AND m.static_fingerprint = ? AND m.header_omitted_model_ids = ?
655
+ AND m.unrestorable_header_model_ids = ? AND m.header_restore_version = ? AND m.models = ?`,
656
+ );
657
+ let stored: StoredRowState | null;
658
+ try {
659
+ stored = matchStmt.get(
660
+ providerId,
661
+ CACHE_SCHEMA_VERSION,
662
+ policy,
663
+ staticFingerprint,
664
+ serializedHeaderOmitted,
665
+ serializedUnrestorable,
666
+ HEADER_RESTORE_VERSION,
667
+ serializedModels,
668
+ );
669
+ } finally {
670
+ matchStmt.finalize();
671
+ }
672
+ if (stored) {
673
+ const state = rowState(stored);
674
+ if (state.updatedAt === updatedAt && state.authoritative === authoritative) return "skipped";
675
+ db.run(
676
+ `INSERT INTO model_cache_refresh (provider_id, payload_updated_at, updated_at, authoritative)
677
+ VALUES (?, ?, ?, ?)
678
+ ON CONFLICT(provider_id) DO UPDATE SET
679
+ payload_updated_at = excluded.payload_updated_at,
680
+ updated_at = excluded.updated_at,
681
+ authoritative = excluded.authoritative`,
682
+ [providerId, stored.updated_at, updatedAt, authoritativeFlag],
683
+ );
684
+ return "refreshed";
685
+ }
686
+ db.run(
687
+ `INSERT OR REPLACE INTO model_cache (
688
+ provider_id, version, materialization_policy, updated_at, authoritative, static_fingerprint,
689
+ header_omitted_model_ids, unrestorable_header_model_ids,
690
+ header_restore_version, models
691
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
692
+ [
693
+ providerId,
694
+ CACHE_SCHEMA_VERSION,
695
+ policy,
696
+ updatedAt,
697
+ authoritativeFlag,
698
+ staticFingerprint,
699
+ serializedHeaderOmitted,
700
+ serializedUnrestorable,
701
+ HEADER_RESTORE_VERSION,
702
+ serializedModels,
703
+ ],
704
+ );
705
+ db.run("DELETE FROM model_cache_refresh WHERE provider_id = ?", [providerId]);
706
+ return "written";
707
+ })
708
+ .immediate();
709
+ if (outcome === "skipped") {
710
+ writeStats.skippedWrites++;
711
+ } else if (outcome === "refreshed") {
712
+ writeStats.refreshWrites++;
713
+ } else {
714
+ writeStats.payloadWrites++;
715
+ writeStats.payloadBytes += serializedModels.length;
716
+ if (recentPayloadProviders.length >= RECENT_PAYLOAD_PROVIDERS_MAX) recentPayloadProviders.shift();
717
+ recentPayloadProviders.push(providerId);
511
718
  }
512
- db.run(
513
- `INSERT OR REPLACE INTO model_cache (
514
- provider_id, version, materialization_policy, updated_at, authoritative, static_fingerprint,
515
- header_omitted_model_ids, unrestorable_header_model_ids,
516
- header_restore_version, models
517
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
518
- [
519
- providerId,
520
- CACHE_SCHEMA_VERSION,
521
- materializationPolicy(),
522
- updatedAt,
523
- authoritative ? 1 : 0,
524
- staticFingerprint,
525
- JSON.stringify(headerOmittedModelIds),
526
- JSON.stringify(unrestorableHeaderModelIds),
527
- HEADER_RESTORE_VERSION,
528
- JSON.stringify(cachedModels),
529
- ],
530
- );
531
719
  });
532
720
  } catch {
533
721
  // Cache writes are best-effort; failures should not break model resolution.