@linxiraos/pi-catalog 1.1.26 → 1.2.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.
Files changed (128) hide show
  1. package/CHANGELOG.md +4 -6
  2. package/dist/types/compat/auth-ids.d.ts +2 -2
  3. package/dist/types/compat/collapse.d.ts +7 -0
  4. package/dist/types/compat/image-tokenization.d.ts +52 -0
  5. package/dist/types/compat/judgment-questions.d.ts +7 -0
  6. package/dist/types/compat/prompt-cache-lookback.d.ts +7 -0
  7. package/dist/types/compat/provider-ids.d.ts +1 -1
  8. package/dist/types/compat/reasoning-carry.d.ts +21 -0
  9. package/dist/types/compat/request-size.d.ts +7 -0
  10. package/dist/types/compat/taxonomy.d.ts +0 -10
  11. package/dist/types/compat/types.d.ts +4 -0
  12. package/dist/types/discovery/antigravity.d.ts +13 -3
  13. package/dist/types/hosts.d.ts +4 -0
  14. package/dist/types/model-cache.d.ts +6 -0
  15. package/dist/types/model-manager.d.ts +16 -0
  16. package/dist/types/provider-models/account-access.d.ts +11 -0
  17. package/dist/types/provider-models/google.d.ts +23 -1
  18. package/dist/types/provider-models/openai-compat.d.ts +19 -1
  19. package/dist/types/provider-models/special.d.ts +8 -0
  20. package/dist/types/types.d.ts +134 -23
  21. package/dist/types/wire/cloudflare-ai-gateway.d.ts +0 -10
  22. package/dist/types/wire/cloudflare.d.ts +10 -0
  23. package/dist/types/wire/factory-droid.d.ts +1 -1
  24. package/dist/types/wire/gemini-headers.d.ts +6 -3
  25. package/package.json +4 -4
  26. package/src/build.ts +39 -35
  27. package/src/compat/auth-ids.ts +6 -0
  28. package/src/compat/axes.ts +43 -5
  29. package/src/compat/cascade.ts +6 -1
  30. package/src/compat/collapse.d.ts +7 -0
  31. package/src/compat/collapse.ts +11 -0
  32. package/src/compat/image-tokenization.ts +166 -0
  33. package/src/compat/judgment-questions.ts +12 -0
  34. package/src/compat/prompt-cache-lookback.ts +23 -0
  35. package/src/compat/provider-ids.d.ts +1 -1
  36. package/src/compat/provider-ids.ts +3 -0
  37. package/src/compat/reasoning-carry.ts +64 -0
  38. package/src/compat/request-size.ts +25 -0
  39. package/src/compat/resolve.ts +36 -25
  40. package/src/compat/rules/README.md +13 -3
  41. package/src/compat/rules/auth/_order.kdl +2 -2
  42. package/src/compat/rules/auth/cloudflare-workers-ai.kdl +9 -0
  43. package/src/compat/rules/auth/coralbricks.kdl +13 -0
  44. package/src/compat/rules/auth/minimax-code-cn.kdl +1 -1
  45. package/src/compat/rules/auth/minimax-code.kdl +1 -1
  46. package/src/compat/rules/auth/openai-codex.kdl +1 -1
  47. package/src/compat/rules/auth/snowflake.kdl +11 -0
  48. package/src/compat/rules/auth/stencil.kdl +1 -1
  49. package/src/compat/rules/classes/anthropic.kdl +188 -7
  50. package/src/compat/rules/classes/deepseek.kdl +11 -3
  51. package/src/compat/rules/classes/gemini.kdl +12 -0
  52. package/src/compat/rules/classes/glm.kdl +17 -8
  53. package/src/compat/rules/classes/kimi.kdl +15 -0
  54. package/src/compat/rules/classes/meta.kdl +4 -4
  55. package/src/compat/rules/classes/minimax.kdl +31 -1
  56. package/src/compat/rules/classes/openai.kdl +114 -4
  57. package/src/compat/rules/classes/qwen.kdl +2 -1
  58. package/src/compat/rules/providers/abliteration.kdl +7 -0
  59. package/src/compat/rules/providers/amazon-bedrock.kdl +0 -7
  60. package/src/compat/rules/providers/anthropic.kdl +13 -0
  61. package/src/compat/rules/providers/apple.kdl +1 -0
  62. package/src/compat/rules/providers/cloudflare-workers-ai.kdl +34 -0
  63. package/src/compat/rules/providers/coralbricks.kdl +62 -0
  64. package/src/compat/rules/providers/cursor.kdl +28 -8
  65. package/src/compat/rules/providers/devin.kdl +2 -2
  66. package/src/compat/rules/providers/factory-droid.kdl +77 -13
  67. package/src/compat/rules/providers/github-copilot.kdl +29 -0
  68. package/src/compat/rules/providers/google-antigravity.kdl +8 -1
  69. package/src/compat/rules/providers/groq.kdl +21 -0
  70. package/src/compat/rules/providers/image-tokenization.kdl +42 -0
  71. package/src/compat/rules/providers/judgment-questions.kdl +53 -0
  72. package/src/compat/rules/providers/litellm.kdl +4 -1
  73. package/src/compat/rules/providers/meta.kdl +3 -3
  74. package/src/compat/rules/providers/minimax-code-cn.kdl +10 -5
  75. package/src/compat/rules/providers/minimax-code.kdl +10 -5
  76. package/src/compat/rules/providers/mistral.kdl +25 -1
  77. package/src/compat/rules/providers/moonshot.kdl +11 -0
  78. package/src/compat/rules/providers/muse-code.kdl +7 -0
  79. package/src/compat/rules/providers/ollama.kdl +3 -0
  80. package/src/compat/rules/providers/openai-codex.kdl +18 -13
  81. package/src/compat/rules/providers/openai.kdl +22 -0
  82. package/src/compat/rules/providers/opencode-go.kdl +11 -0
  83. package/src/compat/rules/providers/opencode-zen.kdl +20 -0
  84. package/src/compat/rules/providers/openrouter.kdl +6 -0
  85. package/src/compat/rules/providers/snowflake.kdl +135 -0
  86. package/src/compat/rules/providers/tool-result-images.kdl +16 -0
  87. package/src/compat/rules/providers/zhipu-coding-plan.kdl +10 -0
  88. package/src/compat/rules/runtime/behavior.kdl +8 -5
  89. package/src/compat/rules/taxonomy/_collapse.kdl +52 -2
  90. package/src/compat/rules/taxonomy/deepseek.kdl +4 -0
  91. package/src/compat/rules/taxonomy/kimi.kdl +5 -0
  92. package/src/compat/rules.json +1 -1
  93. package/src/compat/taxonomy.d.ts +0 -10
  94. package/src/compat/taxonomy.ts +3 -3
  95. package/src/compat/types.d.ts +4 -0
  96. package/src/compat/types.ts +4 -0
  97. package/src/discovery/antigravity.d.ts +13 -3
  98. package/src/discovery/antigravity.ts +33 -17
  99. package/src/discovery/cursor.ts +28 -4
  100. package/src/discovery/devin.ts +20 -2
  101. package/src/hosts.d.ts +4 -0
  102. package/src/hosts.ts +2 -0
  103. package/src/model-cache.d.ts +6 -0
  104. package/src/model-cache.ts +123 -47
  105. package/src/model-manager.d.ts +16 -0
  106. package/src/model-manager.ts +107 -41
  107. package/src/model-tokenizer.ts +3 -0
  108. package/src/models.json +1 -1
  109. package/src/provider-models/account-access.d.ts +11 -0
  110. package/src/provider-models/account-access.ts +30 -0
  111. package/src/provider-models/cache-provider-id.ts +13 -6
  112. package/src/provider-models/cline-pass.ts +33 -0
  113. package/src/provider-models/descriptors.ts +4 -0
  114. package/src/provider-models/google.d.ts +23 -1
  115. package/src/provider-models/google.ts +72 -14
  116. package/src/provider-models/openai-compat.d.ts +19 -1
  117. package/src/provider-models/openai-compat.ts +281 -42
  118. package/src/provider-models/special.d.ts +8 -0
  119. package/src/provider-models/special.ts +26 -22
  120. package/src/types.d.ts +134 -23
  121. package/src/types.ts +183 -20
  122. package/src/wire/cloudflare-ai-gateway.d.ts +0 -10
  123. package/src/wire/cloudflare-ai-gateway.ts +0 -36
  124. package/src/wire/cloudflare.ts +40 -0
  125. package/src/wire/factory-droid.d.ts +1 -1
  126. package/src/wire/factory-droid.ts +1 -1
  127. package/src/wire/gemini-headers.d.ts +6 -3
  128. package/src/wire/gemini-headers.ts +39 -21
@@ -3,6 +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 * as path from "node:path";
6
7
  import { getModelDbPath, isSqliteCorruptionError, logger, openSqliteDatabaseSync, VERSION } from "@linxiraos/pi-utils";
7
8
  import RULES from "./compat/rules.json" with { type: "json" };
8
9
  import type { Api, Model } from "./types";
@@ -46,14 +47,19 @@ function materializationPolicy(): string {
46
47
  return cachedMaterializationPolicy;
47
48
  }
48
49
 
49
- interface CacheRow {
50
+ interface CacheRowMeta {
50
51
  provider_id: string;
51
52
  version: number;
52
53
  materialization_policy: string;
53
54
  updated_at: number;
54
55
  authoritative: number;
55
56
  static_fingerprint: string;
56
- models: string;
57
+ /**
58
+ * `Bun.hash` of `models`; empty for rows written before the column existed
59
+ * (never equal). An in-place `UPDATE` of `models` that leaves the hash
60
+ * untouched resets it to empty (`model_cache_models_hash_reset` trigger).
61
+ */
62
+ models_hash: string;
57
63
  header_omitted_model_ids: string;
58
64
  unrestorable_header_model_ids: string;
59
65
  header_restore_version: number;
@@ -63,6 +69,10 @@ interface CacheRow {
63
69
  refresh_authoritative: number | null;
64
70
  }
65
71
 
72
+ interface CacheRow extends CacheRowMeta {
73
+ models: string;
74
+ }
75
+
66
76
  /**
67
77
  * Payload rows are multi-MB and SQLite rewrites a whole record (including its
68
78
  * overflow pages) on any column update. Freshness that advances without a
@@ -71,15 +81,23 @@ interface CacheRow {
71
81
  * the payload row's `updated_at`, so a payload rewritten by another binary
72
82
  * silently invalidates it.
73
83
  */
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
84
+ const CACHE_ROW_FROM = `
79
85
  FROM model_cache m
80
86
  LEFT JOIN model_cache_refresh r ON r.provider_id = m.provider_id
81
87
  WHERE m.provider_id = ?
82
88
  `;
89
+ const CACHE_ROW_REFRESH_COLUMNS = `
90
+ r.payload_updated_at AS refresh_payload_updated_at,
91
+ r.updated_at AS refresh_updated_at,
92
+ r.authoritative AS refresh_authoritative`;
93
+ const SELECT_CACHE_ROW = `SELECT m.*, ${CACHE_ROW_REFRESH_COLUMNS} ${CACHE_ROW_FROM}`;
94
+ /** Scalar columns plus `models_hash`: validates a memoized parse without reading the multi-MB payload. */
95
+ const SELECT_CACHE_ROW_META = `
96
+ SELECT m.provider_id, m.version, m.materialization_policy, m.updated_at, m.authoritative,
97
+ m.static_fingerprint, m.models_hash, m.header_omitted_model_ids,
98
+ m.unrestorable_header_model_ids, m.header_restore_version, ${CACHE_ROW_REFRESH_COLUMNS}
99
+ ${CACHE_ROW_FROM}
100
+ `;
83
101
 
84
102
  /** Cumulative model-cache write counters for this process. */
85
103
  export interface ModelCacheWriteStats {
@@ -149,8 +167,11 @@ let sharedDbPath: string | null = null;
149
167
  interface ReadRowCacheEntry {
150
168
  /** `PRAGMA data_version` of the shared connection when validated; null for per-call connections. */
151
169
  dataVersion: number | null;
152
- /** Raw row the entry was parsed from; null when the row was absent or rejected. */
153
- row: CacheRow | null;
170
+ /**
171
+ * Scalar columns (plus `models_hash`) of the row the entry was parsed from;
172
+ * null when the row was absent or rejected. Never the multi-MB payload itself.
173
+ */
174
+ meta: CacheRowMeta | null;
154
175
  entry: CacheEntry<Api> | null;
155
176
  }
156
177
 
@@ -182,7 +203,7 @@ function withFreshness<TApi extends Api>(
182
203
  }
183
204
  function invalidateReadRow(providerId: string, dbPath?: string): void {
184
205
  try {
185
- readRowCache.delete(readCacheKey(dbPath ?? getModelDbPath(), providerId));
206
+ readRowCache.delete(readCacheKey(resolveCacheDb(dbPath).resolvedPath, providerId));
186
207
  } catch {
187
208
  // Best-effort only; a missed invalidation just costs one extra parse.
188
209
  }
@@ -213,6 +234,7 @@ function initializeDb(db: Database): void {
213
234
  header_omitted_model_ids TEXT NOT NULL DEFAULT '[]',
214
235
  unrestorable_header_model_ids TEXT NOT NULL DEFAULT '[]',
215
236
  header_restore_version INTEGER NOT NULL DEFAULT 0,
237
+ models_hash TEXT NOT NULL DEFAULT '',
216
238
  models TEXT NOT NULL
217
239
  )
218
240
  `);
@@ -225,6 +247,17 @@ function initializeDb(db: Database): void {
225
247
  )
226
248
  `);
227
249
  migrateCacheSchema(db);
250
+ // `models_hash` is the only payload-change detector for the read memo and the
251
+ // unchanged-write skip. Writers here replace whole rows; this keeps an
252
+ // in-place edit of `models` by any other tool or binary from reusing a stale hash.
253
+ db.run(`
254
+ CREATE TRIGGER IF NOT EXISTS model_cache_models_hash_reset
255
+ AFTER UPDATE OF models ON model_cache
256
+ WHEN NEW.models_hash = OLD.models_hash
257
+ BEGIN
258
+ UPDATE model_cache SET models_hash = '' WHERE provider_id = NEW.provider_id;
259
+ END
260
+ `);
228
261
  }
229
262
 
230
263
  function closeSharedDb(): void {
@@ -234,6 +267,16 @@ function closeSharedDb(): void {
234
267
  sharedDbPath = null;
235
268
  }
236
269
 
270
+ /**
271
+ * Closes the shared handle to the default `<agent-dir>/models.db`; the next
272
+ * default-path access reopens it. Call before deleting an agent directory the
273
+ * cache was opened under — Windows cannot remove a database that is still open.
274
+ */
275
+ export function closeModelCache(): void {
276
+ if (sharedDbPath) invalidateReadPath(sharedDbPath);
277
+ closeSharedDb();
278
+ }
279
+
237
280
  function runModelCacheDb<T>(resolvedPath: string, shared: boolean, useDb: (db: Database) => T): T {
238
281
  if (shared && sharedDb && sharedDbPath !== resolvedPath) closeSharedDb();
239
282
  if (shared && sharedDb) {
@@ -270,8 +313,22 @@ function runModelCacheDb<T>(resolvedPath: string, shared: boolean, useDb: (db: D
270
313
  }
271
314
 
272
315
  function withModelCacheDb<T>(dbPath: string | undefined, useDb: (db: Database) => T): T {
273
- const resolvedPath = dbPath ?? getModelDbPath();
274
- return runModelCacheDb(resolvedPath, dbPath === undefined, useDb);
316
+ const { resolvedPath, shared } = resolveCacheDb(dbPath);
317
+ return runModelCacheDb(resolvedPath, shared, useDb);
318
+ }
319
+
320
+ /**
321
+ * An explicit path naming the default `models.db` (e.g. the SDK passing
322
+ * `getModelDbPath(agentDir)` for the default agent dir) shares the long-lived
323
+ * handle, so it gets the `data_version` fast path instead of opening,
324
+ * initializing and re-reading the row on every call.
325
+ */
326
+ function resolveCacheDb(dbPath: string | undefined): { resolvedPath: string; shared: boolean } {
327
+ const defaultPath = getModelDbPath();
328
+ if (dbPath === undefined || dbPath === defaultPath || path.resolve(dbPath) === path.resolve(defaultPath)) {
329
+ return { resolvedPath: defaultPath, shared: true };
330
+ }
331
+ return { resolvedPath: dbPath, shared: false };
275
332
  }
276
333
 
277
334
  function migrateCacheSchema(db: Database): void {
@@ -296,6 +353,12 @@ function migrateCacheSchema(db: Database): void {
296
353
  // header matching was introduced.
297
354
  db.run("ALTER TABLE model_cache ADD COLUMN header_restore_version INTEGER NOT NULL DEFAULT 0");
298
355
  }
356
+ if (!columns.some(column => column.name === "models_hash")) {
357
+ // Rows from older binaries keep '' (never equal), so they are re-read in
358
+ // full until the next write records their hash; no version bump needed.
359
+ // Older binaries rewriting a row via INSERT OR REPLACE reset it to ''.
360
+ db.run("ALTER TABLE model_cache ADD COLUMN models_hash TEXT NOT NULL DEFAULT ''");
361
+ }
299
362
  } finally {
300
363
  stmt.finalize();
301
364
  }
@@ -409,15 +472,14 @@ export function readModelCache<TApi extends Api>(
409
472
  dbPath?: string,
410
473
  ): CacheEntry<TApi> | null {
411
474
  try {
412
- const resolvedPath = dbPath ?? getModelDbPath();
413
- const key = readCacheKey(resolvedPath, providerId);
414
475
  // Monotonic change signal: same-shaped WAL overwrites after checkpoint
415
476
  // can leave every size:mtime pair identical, so file metadata alone
416
477
  // cannot invalidate. PRAGMA data_version increments on each committed
417
478
  // write transaction visible to a new reader. It is only comparable
418
479
  // across calls on the same connection, so it gates the no-query fast
419
480
  // path for the shared handle only.
420
- const shared = dbPath === undefined;
481
+ const { resolvedPath, shared } = resolveCacheDb(dbPath);
482
+ const key = readCacheKey(resolvedPath, providerId);
421
483
  return runModelCacheDb(resolvedPath, shared, db => {
422
484
  const dataVersion = shared ? dbDataVersion(db) : null;
423
485
  const cached = readRowCache.get(key);
@@ -426,34 +488,32 @@ export function readModelCache<TApi extends Api>(
426
488
  // cached row's updatedAt so a long-lived process goes stale.
427
489
  return withFreshness(cached.entry as CacheEntry<TApi> | null, ttlMs, now);
428
490
  }
429
- // Any commit (to any provider's row) bumps data_version. Re-read this
430
- // row, but reuse the parsed entry when its bytes are unchanged.
431
- const stmt = db.query<CacheRow, [string]>(SELECT_CACHE_ROW);
432
- let row: CacheRow | null;
433
- try {
434
- row = stmt.get(providerId);
435
- } finally {
436
- stmt.finalize();
491
+ // Any commit (to any provider's row) bumps data_version. Validate the
492
+ // memoized parse against the row's scalar columns and payload hash
493
+ // first; only a changed payload pays for the multi-MB `models` read.
494
+ let entry: CacheEntry<TApi> | null = null;
495
+ let meta: CacheRowMeta | null = null;
496
+ if (cached?.entry && cached.meta) {
497
+ meta = queryRow<CacheRowMeta>(db, SELECT_CACHE_ROW_META, providerId);
498
+ if (meta && meta.materialization_policy === materializationPolicy() && cacheRowsEqual(cached.meta, meta)) {
499
+ // Payload unchanged; only side-table freshness may have moved.
500
+ entry = withFreshness(withRowState(cached.entry as CacheEntry<TApi>, meta), ttlMs, now);
501
+ } else {
502
+ meta = null;
503
+ }
437
504
  }
438
- let entry: CacheEntry<TApi> | null;
439
- if (
440
- cached?.entry &&
441
- cached.row &&
442
- row &&
443
- row.materialization_policy === materializationPolicy() &&
444
- cacheRowsEqual(cached.row, row)
445
- ) {
446
- // Payload bytes unchanged; only side-table freshness may have moved.
447
- entry = withFreshness(withRowState(cached.entry as CacheEntry<TApi>, row), ttlMs, now);
448
- } else {
505
+ if (entry === null) {
506
+ // One statement reads payload and scalars from the same snapshot.
507
+ const row = queryRow<CacheRow>(db, SELECT_CACHE_ROW, providerId);
449
508
  entry = parseCacheRow<TApi>(db, providerId, row, ttlMs, now);
509
+ if (entry !== null && row !== null) {
510
+ // Drop the payload so the memo never pins the multi-MB `models` string.
511
+ const { models: _models, ...rowScalars } = row;
512
+ meta = rowScalars;
513
+ }
450
514
  }
451
515
  if (readRowCache.size >= READ_ROW_CACHE_MAX) readRowCache.clear();
452
- readRowCache.set(key, {
453
- dataVersion,
454
- row: entry === null ? null : row,
455
- entry: entry as CacheEntry<Api> | null,
456
- });
516
+ readRowCache.set(key, { dataVersion, meta, entry: entry as CacheEntry<Api> | null });
457
517
  return entry;
458
518
  });
459
519
  } catch {
@@ -461,8 +521,20 @@ export function readModelCache<TApi extends Api>(
461
521
  }
462
522
  }
463
523
 
464
- /** Whether two rows carry the same payload record (refresh side-row columns excluded). */
465
- function cacheRowsEqual(left: CacheRow, right: CacheRow): boolean {
524
+ function queryRow<T>(db: Database, sql: string, providerId: string): T | null {
525
+ const stmt = db.query<T, [string]>(sql);
526
+ try {
527
+ return stmt.get(providerId);
528
+ } finally {
529
+ stmt.finalize();
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Whether two rows carry the same payload record (refresh side-row columns
535
+ * excluded). Payloads compare by `models_hash`; rows without one never match.
536
+ */
537
+ function cacheRowsEqual(left: CacheRowMeta, right: CacheRowMeta): boolean {
466
538
  return (
467
539
  left.version === right.version &&
468
540
  left.materialization_policy === right.materialization_policy &&
@@ -472,7 +544,8 @@ function cacheRowsEqual(left: CacheRow, right: CacheRow): boolean {
472
544
  left.header_omitted_model_ids === right.header_omitted_model_ids &&
473
545
  left.unrestorable_header_model_ids === right.unrestorable_header_model_ids &&
474
546
  left.header_restore_version === right.header_restore_version &&
475
- left.models === right.models
547
+ left.models_hash !== "" &&
548
+ left.models_hash === right.models_hash
476
549
  );
477
550
  }
478
551
 
@@ -488,7 +561,7 @@ function rowState(row: StoredRowState): { updatedAt: number; authoritative: bool
488
561
  return { updatedAt: row.updated_at, authoritative: row.authoritative === 1 };
489
562
  }
490
563
 
491
- function withRowState<TApi extends Api>(entry: CacheEntry<TApi>, row: CacheRow): CacheEntry<TApi> {
564
+ function withRowState<TApi extends Api>(entry: CacheEntry<TApi>, row: StoredRowState): CacheEntry<TApi> {
492
565
  const { updatedAt, authoritative } = rowState(row);
493
566
  return entry.updatedAt === updatedAt && entry.authoritative === authoritative
494
567
  ? entry
@@ -638,6 +711,8 @@ export function writeModelCache<TApi extends Api>(
638
711
  const serializedHeaderOmitted = JSON.stringify(headerOmittedModelIds);
639
712
  const serializedUnrestorable = JSON.stringify(unrestorableHeaderModelIds);
640
713
  const serializedModels = JSON.stringify(cachedModels);
714
+ // Length prefix: a collision would also need an equal payload size.
715
+ const modelsHash = `${serializedModels.length.toString(36)}:${Bun.hash(serializedModels).toString(36)}`;
641
716
  const outcome = db
642
717
  .transaction((): "skipped" | "refreshed" | "written" => {
643
718
  const matchStmt = db.prepare<
@@ -652,7 +727,7 @@ export function writeModelCache<TApi extends Api>(
652
727
  LEFT JOIN model_cache_refresh r ON r.provider_id = m.provider_id
653
728
  WHERE m.provider_id = ? AND m.version = ? AND m.materialization_policy = ?
654
729
  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 = ?`,
730
+ AND m.unrestorable_header_model_ids = ? AND m.header_restore_version = ? AND m.models_hash = ?`,
656
731
  );
657
732
  let stored: StoredRowState | null;
658
733
  try {
@@ -664,7 +739,7 @@ export function writeModelCache<TApi extends Api>(
664
739
  serializedHeaderOmitted,
665
740
  serializedUnrestorable,
666
741
  HEADER_RESTORE_VERSION,
667
- serializedModels,
742
+ modelsHash,
668
743
  );
669
744
  } finally {
670
745
  matchStmt.finalize();
@@ -687,8 +762,8 @@ export function writeModelCache<TApi extends Api>(
687
762
  `INSERT OR REPLACE INTO model_cache (
688
763
  provider_id, version, materialization_policy, updated_at, authoritative, static_fingerprint,
689
764
  header_omitted_model_ids, unrestorable_header_model_ids,
690
- header_restore_version, models
691
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
765
+ header_restore_version, models_hash, models
766
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
692
767
  [
693
768
  providerId,
694
769
  CACHE_SCHEMA_VERSION,
@@ -699,6 +774,7 @@ export function writeModelCache<TApi extends Api>(
699
774
  serializedHeaderOmitted,
700
775
  serializedUnrestorable,
701
776
  HEADER_RESTORE_VERSION,
777
+ modelsHash,
702
778
  serializedModels,
703
779
  ],
704
780
  );
@@ -35,6 +35,22 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
35
35
  cacheTtlMs?: number;
36
36
  /** When true, a successful dynamic fetch is the complete provider catalog and prunes static-only models. */
37
37
  dynamicModelsAuthoritative?: boolean;
38
+ /**
39
+ * When true, a provider's dynamic rows are the deployment's whole truth
40
+ * for input modality: a live text-only row strips the bundled reference's
41
+ * image support instead of OR-merging it back. Declared by providers whose
42
+ * capability flags are authoritative and whose endpoints reject
43
+ * unsupported content (`github-copilot`, `deepinfra`, `coralbricks`).
44
+ */
45
+ dynamicInputAuthoritative?: boolean;
46
+ /** When true, live reasoning capability replaces the fallback, including an explicit false. */
47
+ dynamicReasoningAuthoritative?: boolean;
48
+ /**
49
+ * When true, a live per-million price replaces the fallback even when it is
50
+ * an explicit `0` (a free model or a dropped cache-write charge). Without it,
51
+ * a zero discovery price is treated as unknown and the bundled rate is kept.
52
+ */
53
+ dynamicCostAuthoritative?: boolean;
38
54
  /**
39
55
  * When true, a fresh cache never satisfies an online-eligible refresh: the
40
56
  * dynamic fetch always runs (an explicit `"offline"` strategy is still
@@ -1,4 +1,4 @@
1
- import { VERSION } from "@linxiraos/pi-utils";
1
+ import { logger, VERSION } from "@linxiraos/pi-utils";
2
2
  import { buildModel } from "./build";
3
3
  import { collapseBuiltVariants } from "./compat/collapse";
4
4
  import { applyCatalogMetrics, CatalogMetricsIndex } from "./identity/metrics";
@@ -57,6 +57,22 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
57
57
  cacheTtlMs?: number;
58
58
  /** When true, a successful dynamic fetch is the complete provider catalog and prunes static-only models. */
59
59
  dynamicModelsAuthoritative?: boolean;
60
+ /**
61
+ * When true, a provider's dynamic rows are the deployment's whole truth
62
+ * for input modality: a live text-only row strips the bundled reference's
63
+ * image support instead of OR-merging it back. Declared by providers whose
64
+ * capability flags are authoritative and whose endpoints reject
65
+ * unsupported content (`github-copilot`, `deepinfra`, `coralbricks`).
66
+ */
67
+ dynamicInputAuthoritative?: boolean;
68
+ /** When true, live reasoning capability replaces the fallback, including an explicit false. */
69
+ dynamicReasoningAuthoritative?: boolean;
70
+ /**
71
+ * When true, a live per-million price replaces the fallback even when it is
72
+ * an explicit `0` (a free model or a dropped cache-write charge). Without it,
73
+ * a zero discovery price is treated as unknown and the bundled rate is kept.
74
+ */
75
+ dynamicCostAuthoritative?: boolean;
60
76
  /**
61
77
  * When true, a fresh cache never satisfies an online-eligible refresh: the
62
78
  * dynamic fetch always runs (an explicit `"offline"` strategy is still
@@ -255,6 +271,11 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
255
271
  const usableCachedModels = restoredCache.models.filter(model => !restoredCache.unresolvedModelIds.has(model.id));
256
272
  const cacheHasUnresolvedHeaders = restoredCache.unresolvedModelIds.size > 0;
257
273
  const dynamicModelsAuthoritative = options.dynamicModelsAuthoritative ?? false;
274
+ const mergeAuthority: DynamicMergeAuthority = {
275
+ input: options.dynamicInputAuthoritative ?? false,
276
+ reasoning: options.dynamicReasoningAuthoritative ?? false,
277
+ cost: options.dynamicCostAuthoritative ?? false,
278
+ };
258
279
  const cacheDropIds = options.dropCachedModelIdsOnStaticMismatch;
259
280
  const staticCatalogFingerprint = fingerprintStaticModels(staticModels, dynamicModelsAuthoritative);
260
281
  // Endpoint-migration policy is cache identity: adding an id must invalidate
@@ -298,7 +319,10 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
298
319
  ? restoredCache.models.filter(model => !additiveStaticModelIds.has(model.id))
299
320
  : restoredCache.models;
300
321
  const cachedModels = additiveStaticModelIds
301
- ? mergeCatalogMetrics(mergeDynamicModels(staticModels, cacheContribution), restoredCache.models)
322
+ ? mergeCatalogMetrics(
323
+ mergeDynamicModels(staticModels, cacheContribution, mergeAuthority),
324
+ restoredCache.models,
325
+ )
302
326
  : restoredCache.models;
303
327
  const source: ModelResolutionSource = cacheContribution.length > 0 ? "cache" : "bundled";
304
328
  return {
@@ -351,10 +375,11 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
351
375
  dynamicModels.length > 0 &&
352
376
  (dynamicModelsAuthoritative || !hasModelsDevFetcher || modelsDevFetchSucceeded)
353
377
  : modelsDevFetchSucceeded;
354
- const mergedWithCache = mergeDynamicModels(staticModels, cacheModels);
378
+ const mergedWithCache = mergeDynamicModels(staticModels, cacheModels, mergeAuthority);
355
379
  const mergedWithModelsDev = mergeDynamicModels(
356
380
  mergedWithCache,
357
381
  modelsDevModels,
382
+ mergeAuthority,
358
383
  fetchedModelsDevModels?.explicitKindModels,
359
384
  );
360
385
  const catalogMetricsSource = modelsDevFetchSucceeded ? normalizedModelsDevModels : preparedCacheModels;
@@ -364,6 +389,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
364
389
  const mergedModels = mergeDynamicModels(
365
390
  mergedWithCatalogMetrics,
366
391
  dynamicModels,
392
+ mergeAuthority,
367
393
  fetchedDynamicModels?.explicitKindModels,
368
394
  );
369
395
  const models = collapseBuiltVariants(
@@ -416,7 +442,11 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
416
442
  ? preparedLatestCacheModels.filter(model => !additiveStaticModelIds.has(model.id))
417
443
  : preparedLatestCacheModels;
418
444
  const fallbackSnapshotModels = collapseBuiltVariants(
419
- mergeDynamicModels(mergeDynamicModels(staticModels, latestCacheModels), modelsDevModels),
445
+ mergeDynamicModels(
446
+ mergeDynamicModels(staticModels, latestCacheModels, mergeAuthority),
447
+ modelsDevModels,
448
+ mergeAuthority,
449
+ ),
420
450
  );
421
451
  if (fallbackSnapshotModels.length > 0 || latestCache !== null || cache !== null) {
422
452
  writeModelCache(
@@ -544,9 +574,20 @@ function mergeCatalogMetrics<TApi extends Api>(
544
574
  return applyCatalogMetrics(models, new CatalogMetricsIndex(catalogModels));
545
575
  }
546
576
 
577
+ /**
578
+ * Which dynamic-row fields replace the existing row outright instead of
579
+ * merging additively. Set per provider by `ModelManagerOptions`.
580
+ */
581
+ interface DynamicMergeAuthority {
582
+ readonly input: boolean;
583
+ readonly reasoning: boolean;
584
+ readonly cost: boolean;
585
+ }
586
+
547
587
  function mergeDynamicModels<TApi extends Api>(
548
588
  baseModels: readonly Model<TApi>[],
549
589
  dynamicModels: readonly Model<TApi>[],
590
+ authority: DynamicMergeAuthority,
550
591
  explicitKindModels?: ReadonlySet<Model<TApi>>,
551
592
  ): Model<TApi>[] {
552
593
  // Empty-side fast paths: `mergeDynamicModels(base, [])` is the common shape
@@ -567,7 +608,7 @@ function mergeDynamicModels<TApi extends Api>(
567
608
  // A policy-derived kind on a chat row is not permission to replace an
568
609
  // authored runner. Only a kind present before materialization can do so.
569
610
  if (modelKind(existingModel) !== "chat" && !explicitKindModels?.has(dynamicModel)) continue;
570
- merged.set(dynamicModel.id, mergeDynamicModel(existingModel, dynamicModel));
611
+ merged.set(dynamicModel.id, mergeDynamicModel(existingModel, dynamicModel, authority));
571
612
  }
572
613
  return Array.from(merged.values());
573
614
  }
@@ -598,39 +639,30 @@ export function fingerprintStaticModels<TApi extends Api>(
598
639
  return `${MODEL_CACHE_FINGERPRINT_VERSION}:${Bun.hash(JSON.stringify(models)).toString(36)}`;
599
640
  }
600
641
 
601
- function mergeDynamicModel<TApi extends Api>(existingModel: Model<TApi>, dynamicModel: Model<TApi>): Model<TApi> {
642
+ function mergeDynamicModel<TApi extends Api>(
643
+ existingModel: Model<TApi>,
644
+ dynamicModel: Model<TApi>,
645
+ authority: DynamicMergeAuthority,
646
+ ): Model<TApi> {
602
647
  // When discovery resolves the same model id to a different endpoint (e.g.
603
648
  // a GitHub Copilot business/enterprise host), the bundled reference's
604
- // capabilities are pinned to another endpoint and no longer apply. Copilot
605
- // dynamic discovery also pre-applies the correct image fallback for omitted
606
- // `supports.vision`, so its explicit `false` must not be OR-upgraded by the
607
- // canonical bundled model.
608
- // DeepInfra's discovery is authoritative (`dynamicModelsAuthoritative`) and
609
- // its `vision`/`vlm` tags are the catalog's whole truth for modality. Every
610
- // row shares the single DeepInfra endpoint, so `endpointChanged` never fires
611
- // there: without this carve-out a model that dropped those tags would keep
612
- // the bundled reference's image support and the agent would go on sending
649
+ // capabilities are pinned to another endpoint and no longer apply.
650
+ // `authority.input` carries the descriptor's declaration that a
651
+ // provider's discovery rows are the deployment's whole truth for input
652
+ // modality (Copilot's pre-applied `supports.vision` fallback, DeepInfra's
653
+ // `vision`/`vlm` tags, CoralBricks' authoritative `supports_image_input`
654
+ // flag): without it, a live row that dropped image support would keep the
655
+ // bundled reference's image support and the agent would go on sending
613
656
  // images to a now text-only route.
614
657
  const endpointChanged = existingModel.baseUrl !== dynamicModel.baseUrl;
615
- const dynamicInputAuthoritative =
616
- endpointChanged ||
617
- (existingModel.provider === "github-copilot" && dynamicModel.provider === "github-copilot") ||
618
- (existingModel.provider === "deepinfra" && dynamicModel.provider === "deepinfra");
658
+ const dynamicInputAuthoritative = endpointChanged || authority.input;
619
659
  const supportsImage = dynamicInputAuthoritative
620
660
  ? dynamicModel.input.includes("image")
621
661
  : existingModel.input.includes("image") || dynamicModel.input.includes("image");
622
- // Synthetic's discovery is authoritative (`dynamicModelsAuthoritative`) and
623
- // its per-model `reasoning_parameters.efforts` vocabulary is the route's
624
- // whole truth: when the wire advertises only the `none` off-state the
625
- // mapper emits `reasoning: false`, and OR-ing the bundled reference's
626
- // stale `reasoning: true` back would re-arm an effort dial the route
627
- // doesn't expose. Other providers keep the OR so a bundled reasoning flag
628
- // survives a discovery row that simply omits the capability.
629
- const dynamicReasoningAuthoritative =
630
- existingModel.provider === "synthetic" && dynamicModel.provider === "synthetic";
631
- const reasoning = dynamicReasoningAuthoritative
632
- ? dynamicModel.reasoning
633
- : existingModel.reasoning || dynamicModel.reasoning;
662
+ // Providers with authoritative reasoning metadata must not regain a
663
+ // stale bundled dial after explicitly reporting no reasoning. Others
664
+ // retain the additive fallback for discovery that omits capabilities.
665
+ const reasoning = authority.reasoning ? dynamicModel.reasoning : existingModel.reasoning || dynamicModel.reasoning;
634
666
  const longContextCost = dynamicModel.cost.longContext ?? existingModel.cost.longContext;
635
667
  const timeBasedCost = dynamicModel.cost.timeBased ?? existingModel.cost.timeBased;
636
668
  const existingHeaders = existingModel.resolveHeaders ?? existingModel.headers;
@@ -651,17 +683,26 @@ function mergeDynamicModel<TApi extends Api>(existingModel: Model<TApi>, dynamic
651
683
  // the effort dial once the id is pinned to Responses (#12901).
652
684
  const compat =
653
685
  dynamicModel.compatConfig ?? (dynamicModel.api === existingModel.api ? existingModel.compatConfig : undefined);
686
+ // A live row that trusts only its own thinking (e.g. CoralBricks' advertised
687
+ // ladder) must not inherit the fallback's dial. Cached rows lose an
688
+ // explicit `thinking: undefined` to JSON, so the spread alone would.
689
+ const liveCompat = dynamicModel.compatConfig;
690
+ const trustsOwnThinking =
691
+ liveCompat !== undefined &&
692
+ "trustExplicitThinkingOnly" in liveCompat &&
693
+ liveCompat.trustExplicitThinkingOnly === true;
654
694
  return buildModel({
655
695
  ...existingModel,
656
696
  ...dynamicModel,
657
697
  name: preferDiscoveryName(dynamicModel.name, existingModel.name, dynamicModel.id),
658
698
  reasoning,
699
+ ...(trustsOwnThinking ? { thinking: dynamicModel.thinking } : {}),
659
700
  input: supportsImage ? ["text", "image"] : ["text"],
660
701
  cost: {
661
- input: preferDiscoveryCost(dynamicModel.cost.input, existingModel.cost.input),
662
- output: preferDiscoveryCost(dynamicModel.cost.output, existingModel.cost.output),
663
- cacheRead: preferDiscoveryCost(dynamicModel.cost.cacheRead, existingModel.cost.cacheRead),
664
- cacheWrite: preferDiscoveryCost(dynamicModel.cost.cacheWrite, existingModel.cost.cacheWrite),
702
+ input: preferDiscoveryCost(dynamicModel.cost.input, existingModel.cost.input, authority.cost),
703
+ output: preferDiscoveryCost(dynamicModel.cost.output, existingModel.cost.output, authority.cost),
704
+ cacheRead: preferDiscoveryCost(dynamicModel.cost.cacheRead, existingModel.cost.cacheRead, authority.cost),
705
+ cacheWrite: preferDiscoveryCost(dynamicModel.cost.cacheWrite, existingModel.cost.cacheWrite, authority.cost),
665
706
  ...(longContextCost ? { longContext: longContextCost } : {}),
666
707
  ...(timeBasedCost ? { timeBased: timeBasedCost } : {}),
667
708
  },
@@ -678,8 +719,12 @@ function mergeDynamicModel<TApi extends Api>(existingModel: Model<TApi>, dynamic
678
719
  } as ModelSpec<TApi>);
679
720
  }
680
721
 
681
- function preferDiscoveryCost(discoveryCost: number, fallbackCost: number): number {
682
- if (Number.isFinite(discoveryCost) && discoveryCost > 0) {
722
+ /**
723
+ * A zero discovery price usually means "not reported", so the fallback wins
724
+ * unless the provider declared its live prices authoritative (`zeroIsPrice`).
725
+ */
726
+ function preferDiscoveryCost(discoveryCost: number, fallbackCost: number, zeroIsPrice: boolean): number {
727
+ if (Number.isFinite(discoveryCost) && (discoveryCost > 0 || (zeroIsPrice && discoveryCost === 0))) {
683
728
  return discoveryCost;
684
729
  }
685
730
  return fallbackCost;
@@ -708,19 +753,40 @@ function preferDiscoveryLimit(discoveryLimit: number | null, fallbackLimit: numb
708
753
  return discoveryLimit;
709
754
  }
710
755
 
756
+ /**
757
+ * Materializes a remote source's rows. A row `buildModel` rejects (an id newer
758
+ * than this build's rule tree that lands on an equal-rank overlap) is skipped
759
+ * with a warning instead of discarding the source's other rows.
760
+ *
761
+ * @throws the first build error when rows were present but none built, so the
762
+ * caller treats the source as failed (cache/static fallback) rather than as an
763
+ * intentionally empty catalog that an authoritative provider would prune to.
764
+ */
711
765
  function buildDiscoveredModelSpecs<TApi extends Api>(value: unknown): DiscoveredModelSet<TApi> {
712
766
  const models: Model<TApi>[] = [];
713
767
  const explicitKindModels = new Set<Model<TApi>>();
714
768
  if (!Array.isArray(value)) {
715
769
  return { models, explicitKindModels };
716
770
  }
771
+ const failures: unknown[] = [];
717
772
  for (const item of value) {
718
- if (isModelLike(item)) {
719
- const model = buildModel(item as ModelSpec<TApi>);
720
- models.push(model);
721
- if (item.kind !== undefined) explicitKindModels.add(model);
773
+ if (!isModelLike(item)) continue;
774
+ let model: Model<TApi>;
775
+ try {
776
+ model = buildModel(item as ModelSpec<TApi>);
777
+ } catch (error) {
778
+ failures.push(error);
779
+ logger.warn("model discovery skipped a model that failed to build", {
780
+ provider: item.provider,
781
+ model: item.id,
782
+ error: error instanceof Error ? error.message : String(error),
783
+ });
784
+ continue;
722
785
  }
786
+ models.push(model);
787
+ if (item.kind !== undefined) explicitKindModels.add(model);
723
788
  }
789
+ if (models.length === 0 && failures.length > 0) throw failures[0];
724
790
  return { models, explicitKindModels };
725
791
  }
726
792
 
@@ -27,6 +27,9 @@ function claudeTokenizer(identity: ModelIdentity): ModelTokenizer | undefined {
27
27
  if (identity.family === "sonnet" || identity.family === "fable" || identity.family === "mythos") {
28
28
  return revisionAtLeast(identity.revision, "5") ? "claude-v5-sonnet" : "claude-v3";
29
29
  }
30
+ // Haiku 5.5 moved to the post-4.7 tokenizer (~30% more tokens than Haiku
31
+ // 4.5). No Haiku-measured vocabulary is embedded; Sonnet 5's is the nearest.
32
+ if (identity.family === "haiku" && revisionAtLeast(identity.revision, "5.5")) return "claude-v5-sonnet";
30
33
  return "claude-v3";
31
34
  }
32
35