@mzwing/pi-model-info 0.1.0 → 0.1.1

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 CHANGED
@@ -23,8 +23,9 @@ This extension looks each model up in two catalogs and fills the gaps at runtime
23
23
  ## What it does not do
24
24
 
25
25
  It never creates providers, never discovers models, never changes `id` / `provider` / `baseUrl` /
26
- `api` / auth / transport, and never writes to any of your files. Remove it and Pi goes back to
27
- exactly what it did before.
26
+ `api` / auth / transport, and never writes to any of your files. Where it wraps a provider it
27
+ delegates that provider's own auth, transport and refresh behaviour untouched. Remove it and Pi
28
+ goes back to exactly what it did before.
28
29
 
29
30
  ## Install
30
31
 
@@ -39,6 +40,26 @@ Then opt a provider in — nothing happens until you do:
39
40
  { "providers": { "my-relay": {} } }
40
41
  ```
41
42
 
43
+ ## Staying current
44
+
45
+ Model lists move. Pi refreshes every built-in provider's catalog from pi.dev on a four-hour clock
46
+ and again the moment you open `/model`, and a discovery extension may refetch its own list. A
47
+ completion that was snapshotted once does not survive any of that, so it is applied in whichever of
48
+ three ways claims the least:
49
+
50
+ | What Pi already holds for the provider | How it is completed | How current the list stays |
51
+ | ------------------------------------------------------ | --------------------------------------------------------------- | --------------------------------------------------------------------- |
52
+ | Nothing else registered, and it refreshes its own list | the provider object is wrapped; `getModels()` completes on read | live — a model that appears mid-session is completed on first read |
53
+ | A sibling extension registered `refreshModels` | that hook is wrapped, so each refresh returns a completed list | live — `/model` renders the completion in the refresh that fetched it |
54
+ | Anything else | the list is snapshotted and re-registered | fixed for the session, re-checked on each agent turn |
55
+
56
+ `/model-info` names which one is in force, per provider.
57
+
58
+ Only the third row can freeze a dynamic list, and it is the only one that warns. It is reached when
59
+ something else owns the provider's registration slot — Pi merges every extension registration for a
60
+ provider into one entry, and taking that entry over would take the sibling's `apiKey` with it — or
61
+ when models.json defines the provider's `models[]`, which Pi rebuilds above anything underneath.
62
+
42
63
  ## Pairing with a discovery extension
43
64
 
44
65
  If your provider's models come from something like
@@ -49,9 +70,10 @@ completes _what they are_.**
49
70
 
50
71
  Ordering is guaranteed, not lucky. Discovery extensions register from inside their factory, which
51
72
  Pi flushes before any session event; this extension registers from `session_start`, strictly
52
- after. It only ever sends the `models` key, so Pi's merge preserves the sibling's `baseUrl`,
53
- `api`, and `apiKey` — and it never calls `unregisterProvider` on a registration carrying keys it
54
- did not write, because that call would take those credentials with it.
73
+ after. It only ever sends `models` — plus, for a sibling that refreshes, a wrapper around that
74
+ sibling's own `refreshModels` — so Pi's merge preserves its `baseUrl`, `api`, and `apiKey`, and it
75
+ never calls `unregisterProvider` on a registration carrying keys it did not write, because that
76
+ call would take those credentials with it.
55
77
 
56
78
  Where the sibling already supplied a real value and the catalogs cannot resolve the model, its
57
79
  value is kept byte-for-byte. Where you prefer its numbers even when a catalog does resolve, set
@@ -101,17 +123,17 @@ Global at `~/.pi/agent/extensions/pi-model-info/config.json`, per-project at
101
123
 
102
124
  ### Per provider
103
125
 
104
- | Key | Default | Meaning |
105
- | --------------------- | --------- | ---------------------------------------------------------------------------------- |
106
- | `catalogProvider` | — | Scope lookups to one catalog provider, e.g. `openrouter`. |
107
- | `costMultiplier` | `1` | Relay markup, applied to catalog pricing only — never to a rule's explicit `0`. |
108
- | `costPolicy` | `catalog` | `zero` to force free, `keep` to leave pricing alone. |
109
- | `contextWindowPolicy` | `catalog` | `min` never raises the limit past what Pi already had; `keep` leaves limits alone. |
110
- | `capabilityPolicy` | `catalog` | `widen` only ever adds a capability; `keep` leaves them alone. |
111
- | `useCatalogName` | `false` | Rename models to their catalog names. |
112
- | `mapThinkingLevels` | `false` | Derive a thinking-level map from models.dev. pi.dev's real map is always used. |
113
- | `allowDynamic` | `false` | Suppress the warning for a provider that refreshes its own model list. |
114
- | `models` | — | Per-model gates: `alias`, `override`, `skip`, `prefixes`, `suffixes`. |
126
+ | Key | Default | Meaning |
127
+ | --------------------- | --------- | ------------------------------------------------------------------------------------- |
128
+ | `catalogProvider` | — | Scope lookups to one catalog provider, e.g. `openrouter`. |
129
+ | `costMultiplier` | `1` | Relay markup, applied to catalog pricing only — never to a rule's explicit `0`. |
130
+ | `costPolicy` | `catalog` | `zero` to force free, `keep` to leave pricing alone. |
131
+ | `contextWindowPolicy` | `catalog` | `min` never raises the limit past what Pi already had; `keep` leaves limits alone. |
132
+ | `capabilityPolicy` | `catalog` | `widen` only ever adds a capability; `keep` leaves them alone. |
133
+ | `useCatalogName` | `false` | Rename models to their catalog names. |
134
+ | `mapThinkingLevels` | `false` | Derive a thinking-level map from models.dev. pi.dev's real map is always used. |
135
+ | `allowDynamic` | `false` | Suppress the warning when a refreshing provider can only be completed by replacement. |
136
+ | `models` | — | Per-model gates: `alias`, `override`, `skip`, `prefixes`, `suffixes`. |
115
137
 
116
138
  `contextWindowPolicy: "min"` and `capabilityPolicy: "keep"` exist because inflating a limit or
117
139
  promoting a capability the relay does not actually support turns into a failed request rather
@@ -176,9 +198,10 @@ cost: $0/$0 per Mtok from rule 'free-dash'
176
198
 
177
199
  ## Limits
178
200
 
179
- - Completing a provider replaces its model list, so a provider that discovers models
180
- _dynamically_ has that list frozen for the session. This extension warns when it sees one and
181
- re-reads the list every session; a mid-session change is picked up on the next agent turn.
201
+ - A provider whose registration slot is already taken, or whose `models[]` models.json spells out,
202
+ can only be completed by replacement, which fixes its list for the session. It warns when it sees
203
+ one; a mid-session change is picked up on the next agent turn. See
204
+ [Staying current](#staying-current).
182
205
  - If two extensions complete the same provider, the last one to register wins.
183
206
  - With no cache and no network, nothing is applied at all rather than partially.
184
207
 
package/dist/index.js CHANGED
@@ -863,6 +863,12 @@ var CatalogStore = class {
863
863
  //#endregion
864
864
  //#region src/command.ts
865
865
  const COMPLETION_LIMIT = 50;
866
+ /** How current each provider's list stays — the one thing you cannot tell from the counts. */
867
+ const STRATEGY_NOTE = {
868
+ native: "list re-read live",
869
+ decorate: "list re-read on refresh",
870
+ replace: "list fixed for this session"
871
+ };
866
872
  function splitReference(reference) {
867
873
  const separator = reference.indexOf("/");
868
874
  return separator > 0 ? {
@@ -896,7 +902,8 @@ function formatSummary(reports, catalog, issues, now) {
896
902
  continue;
897
903
  }
898
904
  const counts = countByKind(report.models);
899
- lines.push(`${report.provider}: ${counts.resolved} completed, ${counts.ambiguous} ambiguous, ${counts.unresolved} unresolved (${report.models.length} models)`);
905
+ const note = report.strategy === void 0 ? "" : ` · ${STRATEGY_NOTE[report.strategy]}`;
906
+ lines.push(`${report.provider}: ${counts.resolved} completed, ${counts.ambiguous} ambiguous, ${counts.unresolved} unresolved (${report.models.length} models)${note}`);
900
907
  }
901
908
  if (catalog === void 0) lines.push("", "catalogs: not loaded yet");
902
909
  else {
@@ -1055,8 +1062,7 @@ function readUserAuthoredFields(agentDir, readFile = defaultRead) {
1055
1062
  const models = /* @__PURE__ */ new Map();
1056
1063
  for (const definition of provider["models"]) {
1057
1064
  if (!isRecord(definition) || typeof definition["id"] !== "string") continue;
1058
- const fields = new Set(Object.keys(definition).filter((key) => TRACKED_FIELDS.has(key)));
1059
- if (fields.size > 0) models.set(definition["id"], fields);
1065
+ models.set(definition["id"], new Set(Object.keys(definition).filter((key) => TRACKED_FIELDS.has(key))));
1060
1066
  }
1061
1067
  if (models.size > 0) authored.set(providerId, models);
1062
1068
  }
@@ -1448,137 +1454,220 @@ function resolveModel(input) {
1448
1454
  };
1449
1455
  }
1450
1456
 
1457
+ //#endregion
1458
+ //#region src/complete.ts
1459
+ /** The single completion pass, shared by every strategy so they cannot drift apart. */
1460
+ function completeModel(snapshot, context) {
1461
+ const resolution = resolveModel({
1462
+ index: context.catalog.index,
1463
+ provider: context.provider,
1464
+ prefixRules: context.config.prefixRules,
1465
+ suffixRules: context.config.suffixRules,
1466
+ modelId: snapshot.id
1467
+ });
1468
+ const merged = mergeMetadata({
1469
+ snapshot,
1470
+ provider: context.provider,
1471
+ resolution,
1472
+ gate: context.provider.models.get(snapshot.id),
1473
+ userAuthored: context.authored?.get(snapshot.id)
1474
+ });
1475
+ return {
1476
+ model: merged.model,
1477
+ report: {
1478
+ id: snapshot.id,
1479
+ resolution,
1480
+ provenance: merged.provenance,
1481
+ issues: merged.issues,
1482
+ model: merged.model
1483
+ }
1484
+ };
1485
+ }
1486
+
1487
+ //#endregion
1488
+ //#region src/native-wrap.ts
1489
+ /**
1490
+ * Registered globally so a `/reload` — which hands us a fresh module instance — still recognises
1491
+ * our own wrapper and unwraps to the provider underneath instead of completing a completion.
1492
+ */
1493
+ const WRAPPED = Symbol.for("@mzwing/pi-model-info.wrapped-provider");
1494
+ function isOurWrapper(provider) {
1495
+ return provider !== void 0 && WRAPPED in provider;
1496
+ }
1497
+ function unwrapProvider(provider) {
1498
+ return provider[WRAPPED] ?? provider;
1499
+ }
1500
+ /**
1501
+ * A provider whose `getModels()` completes the list underneath on every read.
1502
+ *
1503
+ * This is what keeps a built-in provider reactive: Pi's own dynamic providers keep their list in a
1504
+ * closure that `refreshModels()` rewrites, so reading it late — rather than snapshotting it once and
1505
+ * registering a replacement list — is what lets `/model`, the four-hourly pi.dev catalog refresh and
1506
+ * anything else that refreshes show newly discovered models already completed.
1507
+ *
1508
+ * The wrapper is a spread of the provider it wraps, which is safe only because we never wrap a
1509
+ * third-party native registration: Pi's own providers are closures over their state, never `this`.
1510
+ */
1511
+ var NativeWrap = class {
1512
+ pristine;
1513
+ provider;
1514
+ deps;
1515
+ cache = /* @__PURE__ */ new WeakMap();
1516
+ constructor(pristine, deps) {
1517
+ this.pristine = pristine;
1518
+ this.deps = deps;
1519
+ const wrapper = {
1520
+ ...pristine,
1521
+ [WRAPPED]: pristine,
1522
+ getModels: () => this.completeAll()
1523
+ };
1524
+ this.provider = wrapper;
1525
+ }
1526
+ /** Drops the per-model cache; call whenever the catalog or the resolved config changes. */
1527
+ invalidate() {
1528
+ this.cache = /* @__PURE__ */ new WeakMap();
1529
+ }
1530
+ completeAll() {
1531
+ const base = this.pristine.getModels();
1532
+ const context = this.deps.context();
1533
+ if (context === void 0) return base;
1534
+ try {
1535
+ const reports = [];
1536
+ const models = base.map((model) => {
1537
+ let completed = this.cache.get(model);
1538
+ if (completed === void 0) {
1539
+ const completion = completeModel(model, context);
1540
+ completed = {
1541
+ model: {
1542
+ ...model,
1543
+ ...completion.model
1544
+ },
1545
+ report: completion.report
1546
+ };
1547
+ this.cache.set(model, completed);
1548
+ }
1549
+ reports.push(completed.report);
1550
+ return completed.model;
1551
+ });
1552
+ this.deps.onReports(reports);
1553
+ return models;
1554
+ } catch (error) {
1555
+ this.deps.warn(`completing '${this.pristine.id}' failed: ${error instanceof Error ? error.message : String(error)}`);
1556
+ return base;
1557
+ }
1558
+ }
1559
+ };
1560
+
1451
1561
  //#endregion
1452
1562
  //#region src/provider-apply.ts
1563
+ /** Global, like the provider marker: a `/reload` must not let us decorate our own decorator. */
1564
+ const DECORATED = Symbol.for("@mzwing/pi-model-info.refresh-decorator");
1453
1565
  var ProviderApplier = class {
1454
- snapshots = /* @__PURE__ */ new Map();
1566
+ tracked = /* @__PURE__ */ new Map();
1455
1567
  skipped = /* @__PURE__ */ new Map();
1456
1568
  reports = /* @__PURE__ */ new Map();
1457
1569
  lastRegistered = /* @__PURE__ */ new Map();
1570
+ /** Outlives `capture`, so a `/reload` reuses one wrapper per provider instead of nesting them. */
1571
+ wraps = /* @__PURE__ */ new Map();
1572
+ /** Provider to the catalog fingerprint it was last registered with, to skip a no-op re-register. */
1573
+ appliedCatalog = /* @__PURE__ */ new Map();
1458
1574
  warn;
1575
+ applied;
1459
1576
  constructor(deps = {}) {
1460
1577
  this.warn = deps.warn ?? (() => {});
1461
1578
  }
1462
1579
  /**
1463
- * Must run before the first registration of the session: afterwards `getProvider(id).getModels()`
1464
- * returns our own list, and re-deriving from it would fold every previous pass into the next one.
1580
+ * Chooses a strategy per provider, and for the two registration strategies takes the list as Pi
1581
+ * has it now: afterwards `getProvider(id).getModels()` returns our own, and re-deriving from it
1582
+ * would fold every previous pass into the next one.
1465
1583
  */
1466
- capture(registry, config) {
1467
- this.snapshots.clear();
1584
+ capture(registry, config, authored) {
1585
+ this.tracked.clear();
1468
1586
  this.skipped.clear();
1469
1587
  this.reports.clear();
1588
+ this.appliedCatalog.clear();
1470
1589
  for (const provider of config.providers.values()) {
1471
1590
  const live = registry.getProvider(provider.id);
1472
1591
  if (live === void 0) {
1473
1592
  this.skip(provider.id, "not present in Pi; check the provider id");
1474
1593
  continue;
1475
1594
  }
1476
- if (registry.getRegisteredNativeProvider(provider.id) !== void 0) {
1595
+ const native = registry.getRegisteredNativeProvider(provider.id);
1596
+ if (native !== void 0 && !isOurWrapper(native)) {
1477
1597
  this.skip(provider.id, "another extension registered a native provider for this id");
1478
1598
  continue;
1479
1599
  }
1480
- const snapshot = [...live.getModels()];
1600
+ const pristine = native === void 0 ? live : unwrapProvider(native);
1601
+ const registered = registry.getRegisteredProviderConfig(provider.id);
1602
+ if (registered === void 0 && pristine.refreshModels !== void 0 && !authored.has(provider.id)) {
1603
+ this.trackNative(provider, pristine);
1604
+ continue;
1605
+ }
1606
+ const snapshot = [...pristine.getModels()];
1481
1607
  if (snapshot.length === 0) {
1482
1608
  this.skip(provider.id, "no models to complete");
1483
1609
  continue;
1484
1610
  }
1485
- if (live.refreshModels !== void 0 && !provider.allowDynamic) this.warn(`provider '${provider.id}' refreshes its model list dynamically; completing it freezes newly discovered models until the next session`);
1486
- this.snapshots.set(provider.id, snapshot);
1487
- this.reports.set(provider.id, {
1488
- provider: provider.id,
1489
- status: "pending",
1490
- reason: void 0,
1491
- models: []
1492
- });
1611
+ const sibling = undecorate(registered?.refreshModels);
1612
+ if (sibling !== void 0) {
1613
+ this.track(provider, "decorate", snapshot, {
1614
+ refresh: this.decorate(provider.id, sibling),
1615
+ api: registered?.api,
1616
+ baseUrl: registered?.baseUrl
1617
+ });
1618
+ continue;
1619
+ }
1620
+ if (pristine.refreshModels !== void 0 && !provider.allowDynamic) this.warn(`provider '${provider.id}' refreshes its model list dynamically, but ${registered === void 0 ? "models.json defines models for it" : "another extension owns its registration"}, so completing it freezes newly discovered models until the next session`);
1621
+ this.track(provider, "replace", snapshot, {});
1493
1622
  }
1494
1623
  }
1495
1624
  apply(pi, config, catalog, userAuthored) {
1496
1625
  if (catalog.status === "unavailable") return;
1497
- for (const [providerId, snapshot] of this.snapshots) {
1498
- const provider = config.providers.get(providerId);
1499
- if (provider !== void 0) this.applyProvider(pi, provider, snapshot, config, catalog, userAuthored);
1500
- }
1501
- }
1502
- applyProvider(pi, provider, snapshots, config, catalog, userAuthored) {
1503
- const authored = userAuthored.get(provider.id);
1504
- const models = [];
1505
- const reports = [];
1506
- for (const snapshot of snapshots) {
1507
- const resolution = resolveModel({
1508
- index: catalog.index,
1509
- provider,
1510
- prefixRules: config.prefixRules,
1511
- suffixRules: config.suffixRules,
1512
- modelId: snapshot.id
1513
- });
1514
- const merged = mergeMetadata({
1515
- snapshot,
1516
- provider,
1517
- resolution,
1518
- gate: provider.models.get(snapshot.id),
1519
- userAuthored: authored?.get(snapshot.id)
1520
- });
1521
- models.push(merged.model);
1522
- reports.push({
1523
- id: snapshot.id,
1524
- resolution,
1525
- provenance: merged.provenance,
1526
- issues: merged.issues,
1527
- model: merged.model
1528
- });
1529
- }
1530
- try {
1531
- pi.registerProvider(provider.id, { models });
1532
- this.lastRegistered.set(provider.id, models);
1533
- this.reports.set(provider.id, {
1534
- provider: provider.id,
1535
- status: "applied",
1536
- reason: void 0,
1537
- models: reports
1538
- });
1539
- } catch (error) {
1540
- const message = error instanceof Error ? error.message : String(error);
1541
- this.warn(`failed to complete provider '${provider.id}': ${message}`);
1542
- this.reports.set(provider.id, {
1543
- provider: provider.id,
1544
- status: "failed",
1545
- reason: message,
1546
- models: reports
1547
- });
1548
- }
1626
+ this.applied = {
1627
+ config,
1628
+ catalog,
1629
+ authored: userAuthored
1630
+ };
1631
+ for (const [providerId, tracked] of this.tracked) if (tracked.strategy === "native") this.applyNative(pi, providerId);
1632
+ else this.applyRegistration(pi, providerId, tracked);
1549
1633
  }
1550
1634
  /**
1551
- * Cheap id-set comparison against the live list. Ordering already guarantees a discovery extension
1552
- * registers before our first pass, so this only catches a third party changing the list mid-session.
1635
+ * Cheap id-set comparison against the live list, for the strategies that snapshot one. `native`
1636
+ * re-reads on every access and `decorate` completes inside the refresh, so neither can drift.
1553
1637
  */
1554
1638
  reconcile(registry) {
1555
1639
  let drifted = false;
1556
- for (const providerId of this.snapshots.keys()) {
1640
+ for (const [providerId, tracked] of this.tracked) {
1641
+ if (tracked.strategy === "native") continue;
1557
1642
  const live = registry.getProvider(providerId);
1558
1643
  if (live === void 0) continue;
1559
1644
  const liveModels = [...live.getModels()];
1560
1645
  const registered = this.lastRegistered.get(providerId);
1561
1646
  if (liveModels.length === 0 || registered !== void 0 && sameIds(liveModels, registered)) continue;
1562
- this.snapshots.set(providerId, liveModels);
1647
+ tracked.snapshot = liveModels;
1563
1648
  drifted = true;
1564
1649
  }
1565
1650
  return drifted;
1566
1651
  }
1567
1652
  /**
1568
- * `extensionProviders` outlives a `/reload` while our in-memory state does not, so a registration
1569
- * for a provider that is no longer opted in would linger and could make a later recompose delete
1570
- * the provider outright.
1653
+ * Registrations outlive a `/reload` while our in-memory state does not, so one left behind for a
1654
+ * provider that is no longer opted in would linger and could make a later recompose delete the
1655
+ * provider outright.
1571
1656
  */
1572
1657
  releaseStale(pi, registry, config) {
1658
+ for (const providerId of [...this.wraps.keys()]) {
1659
+ if (config.providers.has(providerId)) continue;
1660
+ if (!isOurWrapper(registry.getRegisteredNativeProvider(providerId))) {
1661
+ this.wraps.delete(providerId);
1662
+ continue;
1663
+ }
1664
+ if (registry.getRegisteredProviderConfig(providerId) !== void 0) continue;
1665
+ this.release(pi, providerId);
1666
+ }
1573
1667
  for (const [providerId, models] of this.lastRegistered) {
1574
1668
  if (config.providers.has(providerId)) continue;
1575
1669
  if (!isSolelyOurRegistration(registry.getRegisteredProviderConfig(providerId), models)) continue;
1576
- try {
1577
- pi.unregisterProvider(providerId);
1578
- this.lastRegistered.delete(providerId);
1579
- } catch (error) {
1580
- this.warn(`failed to release provider '${providerId}': ${error instanceof Error ? error.message : String(error)}`);
1581
- }
1670
+ this.release(pi, providerId);
1582
1671
  }
1583
1672
  }
1584
1673
  getReports() {
@@ -1591,11 +1680,190 @@ var ProviderApplier = class {
1591
1680
  });
1592
1681
  return reports.sort((a, b) => a.provider.localeCompare(b.provider));
1593
1682
  }
1683
+ track(provider, strategy, snapshot, extra) {
1684
+ this.tracked.set(provider.id, {
1685
+ provider,
1686
+ strategy,
1687
+ snapshot,
1688
+ refresh: extra.refresh,
1689
+ api: extra.api,
1690
+ baseUrl: extra.baseUrl
1691
+ });
1692
+ this.reports.set(provider.id, {
1693
+ provider: provider.id,
1694
+ status: "pending",
1695
+ strategy,
1696
+ reason: void 0,
1697
+ models: []
1698
+ });
1699
+ }
1700
+ trackNative(provider, pristine) {
1701
+ this.track(provider, "native", [], {});
1702
+ if (this.wraps.get(provider.id)?.pristine === pristine) return;
1703
+ this.wraps.set(provider.id, new NativeWrap(pristine, {
1704
+ context: () => this.nativeContext(provider.id),
1705
+ onReports: (reports) => {
1706
+ this.recordNative(provider.id, reports);
1707
+ },
1708
+ warn: this.warn
1709
+ }));
1710
+ }
1711
+ /** `undefined` keeps a wrapper inert: it hands back the list underneath, uncompleted. */
1712
+ nativeContext(providerId) {
1713
+ const applied = this.applied;
1714
+ const tracked = this.tracked.get(providerId);
1715
+ if (applied === void 0 || tracked?.strategy !== "native") return;
1716
+ return {
1717
+ config: applied.config,
1718
+ provider: tracked.provider,
1719
+ catalog: applied.catalog,
1720
+ authored: applied.authored.get(providerId)
1721
+ };
1722
+ }
1723
+ recordNative(providerId, models) {
1724
+ if (this.tracked.get(providerId)?.strategy !== "native") return;
1725
+ this.reports.set(providerId, {
1726
+ provider: providerId,
1727
+ status: "applied",
1728
+ strategy: "native",
1729
+ reason: void 0,
1730
+ models
1731
+ });
1732
+ }
1733
+ applyNative(pi, providerId) {
1734
+ const wrap = this.wraps.get(providerId);
1735
+ const applied = this.applied;
1736
+ if (wrap === void 0 || applied === void 0) return;
1737
+ const fingerprint = catalogFingerprint(applied.catalog);
1738
+ if (this.appliedCatalog.get(providerId) === fingerprint) return;
1739
+ wrap.invalidate();
1740
+ try {
1741
+ pi.registerProvider(wrap.provider);
1742
+ this.appliedCatalog.set(providerId, fingerprint);
1743
+ } catch (error) {
1744
+ const message = error instanceof Error ? error.message : String(error);
1745
+ this.warn(`failed to complete provider '${providerId}': ${message}`);
1746
+ this.reports.set(providerId, {
1747
+ provider: providerId,
1748
+ status: "failed",
1749
+ strategy: "native",
1750
+ reason: message,
1751
+ models: []
1752
+ });
1753
+ }
1754
+ }
1755
+ applyRegistration(pi, providerId, tracked) {
1756
+ const completed = this.complete(tracked, tracked.snapshot);
1757
+ if (completed === void 0) return;
1758
+ try {
1759
+ const config = tracked.refresh === void 0 ? { models: completed.models } : {
1760
+ models: completed.models,
1761
+ refreshModels: tracked.refresh
1762
+ };
1763
+ pi.registerProvider(providerId, config);
1764
+ this.lastRegistered.set(providerId, completed.models);
1765
+ this.reports.set(providerId, {
1766
+ provider: providerId,
1767
+ status: "applied",
1768
+ strategy: tracked.strategy,
1769
+ reason: void 0,
1770
+ models: completed.reports
1771
+ });
1772
+ } catch (error) {
1773
+ const message = error instanceof Error ? error.message : String(error);
1774
+ this.warn(`failed to complete provider '${providerId}': ${message}`);
1775
+ this.reports.set(providerId, {
1776
+ provider: providerId,
1777
+ status: "failed",
1778
+ strategy: tracked.strategy,
1779
+ reason: message,
1780
+ models: completed.reports
1781
+ });
1782
+ }
1783
+ }
1784
+ /**
1785
+ * Wraps a sibling's own hook so the completion runs inside Pi's refresh: what the sibling returns
1786
+ * is the clean upstream list, and what we return is what `/model` renders once the refresh lands.
1787
+ */
1788
+ decorate(providerId, original) {
1789
+ const decorator = async (context) => {
1790
+ const fresh = await original(context);
1791
+ const tracked = this.tracked.get(providerId);
1792
+ if (tracked === void 0 || !Array.isArray(fresh) || fresh.length === 0) return fresh;
1793
+ const snapshot = fresh.map((definition) => toSnapshot(definition, tracked.api, tracked.baseUrl)).filter((model) => model !== void 0);
1794
+ const completed = this.complete(tracked, snapshot);
1795
+ if (completed === void 0) return fresh;
1796
+ tracked.snapshot = snapshot;
1797
+ const byId = new Map(completed.models.map((model) => [model.id, model]));
1798
+ const models = fresh.map((definition) => byId.get(definition.id) ?? definition);
1799
+ this.lastRegistered.set(providerId, models);
1800
+ this.reports.set(providerId, {
1801
+ provider: providerId,
1802
+ status: "applied",
1803
+ strategy: "decorate",
1804
+ reason: void 0,
1805
+ models: completed.reports
1806
+ });
1807
+ return models;
1808
+ };
1809
+ const marked = decorator;
1810
+ marked[DECORATED] = original;
1811
+ return marked;
1812
+ }
1813
+ /** `undefined` until a catalog is loaded. */
1814
+ complete(tracked, snapshots) {
1815
+ const applied = this.applied;
1816
+ if (applied === void 0) return;
1817
+ const context = {
1818
+ config: applied.config,
1819
+ provider: tracked.provider,
1820
+ catalog: applied.catalog,
1821
+ authored: applied.authored.get(tracked.provider.id)
1822
+ };
1823
+ const models = [];
1824
+ const reports = [];
1825
+ for (const snapshot of snapshots) {
1826
+ const completion = completeModel(snapshot, context);
1827
+ models.push(completion.model);
1828
+ reports.push(completion.report);
1829
+ }
1830
+ return {
1831
+ models,
1832
+ reports
1833
+ };
1834
+ }
1835
+ release(pi, providerId) {
1836
+ try {
1837
+ pi.unregisterProvider(providerId);
1838
+ this.lastRegistered.delete(providerId);
1839
+ this.wraps.delete(providerId);
1840
+ this.appliedCatalog.delete(providerId);
1841
+ } catch (error) {
1842
+ this.warn(`failed to release provider '${providerId}': ${error instanceof Error ? error.message : String(error)}`);
1843
+ }
1844
+ }
1594
1845
  skip(providerId, reason) {
1595
1846
  this.skipped.set(providerId, reason);
1596
1847
  this.warn(`skipping provider '${providerId}': ${reason}`);
1597
1848
  }
1598
1849
  };
1850
+ function catalogFingerprint(catalog) {
1851
+ return catalog.sources.map((source) => `${source.source}@${source.fetchedAt ?? 0}#${source.entryCount}`).join("|");
1852
+ }
1853
+ function undecorate(refresh) {
1854
+ return refresh === void 0 ? void 0 : refresh[DECORATED] ?? refresh;
1855
+ }
1856
+ /** A registration definition may lean on the provider for `api` and `baseUrl`; a snapshot may not. */
1857
+ function toSnapshot(definition, api, baseUrl) {
1858
+ const resolvedApi = definition.api ?? api;
1859
+ const resolvedBaseUrl = definition.baseUrl ?? baseUrl;
1860
+ if (resolvedApi === void 0 || resolvedBaseUrl === void 0) return;
1861
+ return {
1862
+ ...definition,
1863
+ api: resolvedApi,
1864
+ baseUrl: resolvedBaseUrl
1865
+ };
1866
+ }
1599
1867
  function sameIds(live, registered) {
1600
1868
  if (live.length !== registered.length) return false;
1601
1869
  const ids = new Set(registered.map((model) => model.id));
@@ -1634,13 +1902,13 @@ function createModelInfoExtension(pi, dependencies = {}) {
1634
1902
  let session;
1635
1903
  let isIdle = () => true;
1636
1904
  function applyCatalog(snapshot) {
1637
- catalog = snapshot;
1638
1905
  if (config === void 0) return;
1639
1906
  if (config.applyOnIdleOnly && !isIdle()) {
1640
1907
  pending = snapshot;
1641
1908
  return;
1642
1909
  }
1643
1910
  pending = void 0;
1911
+ catalog = snapshot;
1644
1912
  applier.apply(pi, config, snapshot, userAuthored);
1645
1913
  }
1646
1914
  async function run(signal) {
@@ -1674,8 +1942,8 @@ function createModelInfoExtension(pi, dependencies = {}) {
1674
1942
  if (config === void 0) return;
1675
1943
  applier.releaseStale(pi, context.modelRegistry, config);
1676
1944
  if (config.providers.size === 0) return;
1677
- applier.capture(context.modelRegistry, config);
1678
1945
  userAuthored = readUserAuthored(agentDir);
1946
+ applier.capture(context.modelRegistry, config, userAuthored);
1679
1947
  start();
1680
1948
  });
1681
1949
  pi.on("before_agent_start", (_event, context) => {
@@ -1684,7 +1952,10 @@ function createModelInfoExtension(pi, dependencies = {}) {
1684
1952
  if (applier.reconcile(registry)) applier.apply(pi, config, catalog, userAuthored);
1685
1953
  });
1686
1954
  pi.on("turn_end", () => {
1687
- if (pending !== void 0 && config !== void 0) applier.apply(pi, config, pending, userAuthored);
1955
+ if (pending !== void 0 && config !== void 0) {
1956
+ catalog = pending;
1957
+ applier.apply(pi, config, pending, userAuthored);
1958
+ }
1688
1959
  pending = void 0;
1689
1960
  });
1690
1961
  pi.on("session_shutdown", () => {