pi-jev-guard 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -121,11 +121,19 @@ Verdetto **incerto** (`review`): il gate tenta prima una riparazione privata
121
121
  `policy.onUncertain` e `policy.onUnavailable` (`"hold"` default, oppure `"pass"`).
122
122
  Con `"pass"` il testo viene pubblicato con una nota di trasparenza sul verdetto.
123
123
 
124
- Se un'altra estensione ri-registra lo stesso provider (caso reale: `pi-accounts`
125
- al cambio account), l'overlay viene ricostruito sopra il provider live prima di
126
- ogni turno; in più il gate toglie il suffisso `__jev` da qualunque payload,
127
- registrando `jev-gate-bypass` e avvisando in TUI, perché quel turno non è
128
- passato dalla verifica.
124
+ Se un'altra estensione gestisce lo stesso provider (caso reale: `pi-accounts`
125
+ con overlay auth su `openai-codex`), il gate NON ri-registra nulla: applica
126
+ una patch in-place sull'oggetto provider live (stessa identità, stessa config
127
+ del proprietario). Così i controlli di ownership altrui non vedono mai una
128
+ "configuration changed" e non armano abort dei turni (`This operation was
129
+ aborted` istantaneo). Quando il proprietario sostituisce l'oggetto, il nuovo
130
+ viene patchato ex-novo agli hook (`input`, `before_agent_start`, `turn_start`,
131
+ `model_select`, `message_end`); `/jev off` ripristina i metodi originali senza
132
+ `unregister`. I twin restano visibili nel registry per le letture altrui
133
+ (`jev-overlay-reassert` solo quando un oggetto nuovo viene patchato).
134
+ In più, rete di sicurezza: se un id `__jev` sfugge comunque nel payload, il
135
+ gate toglie il suffisso registrando `jev-gate-bypass` e avvisando in TUI,
136
+ perché quel turno non è passato dalla verifica.
129
137
  Le conferme per comandi di rete sospetti attendono invece una decisione esplicita;
130
138
  la normale sequenza tool → risposta successiva continua senza un nuovo prompt.
131
139
 
@@ -1,7 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
2
2
  import type { Model, Provider } from "@earendil-works/pi-ai";
3
3
  import type { Api } from "@earendil-works/pi-ai";
4
- import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
5
4
  import { registerJevCommand, type UpstreamResult } from "../src/commands.ts";
6
5
  import { registerJevAskTool } from "../src/ask.ts";
7
6
  import {
@@ -19,15 +18,14 @@ import { createReviewer, createTypedAsk } from "../src/factory.ts";
19
18
  import { createMetrics } from "../src/metrics.ts";
20
19
  import { registerJevTool } from "../src/tools.ts";
21
20
  import {
22
- STATIC_DEEPSEEK_FLASH,
23
- buildOverlaidProvider,
21
+ isPatched,
24
22
  isTwinId,
25
- mergeModelsById,
23
+ patchProvider,
26
24
  twinIdFor,
25
+ unpatchProvider,
27
26
  untwinId,
28
27
  } from "../src/automatic/overlay.ts";
29
28
  import type { GateVerdictReport } from "../src/automatic/guardian.ts";
30
- import { readStoreModels } from "../src/store-models.ts";
31
29
  import { DEFAULT_GATE_RULES, elideWithMarker } from "../src/reviewer.ts";
32
30
  import {
33
31
  TOOL_POLICY_REVISION,
@@ -113,16 +111,13 @@ export default function (pi: ExtensionAPI) {
113
111
  let lastGroundingErrorAt = 0;
114
112
  const metrics = createMetrics();
115
113
 
116
- // Twin attivi, uno o più per provider. Nessun overlay finché la config non
117
- // li attiva (automatic) o l'utente non usa /jev upstream (opt-in).
114
+ // Twin attivi, uno o più per provider. Patch in-place sull'oggetto live:
115
+ // nessuna (ri-)registrazione, quindi nessun conflitto di ownership con
116
+ // altre estensioni (es. pi-accounts) e nessuna entry spuria nel registry.
118
117
  interface OverlayState {
119
- /** Provider live catturato prima dell'override (auth inclusa). */
120
- upstream: Provider;
121
118
  twinIds: string[];
122
- /** Il nostro overlay registrato, per accorgersi se viene scalzato. */
123
- provider: Provider;
124
- /** Ripiego dopo un tentativo di ri-registrazione non riuscito. */
125
- reassertFailed?: boolean;
119
+ /** Oggetto live patchato (per unpatch mirato senza registry). */
120
+ live?: Provider;
126
121
  }
127
122
  const overlays = new Map<string, OverlayState>();
128
123
 
@@ -194,12 +189,16 @@ export default function (pi: ExtensionAPI) {
194
189
  return getConfig();
195
190
  }
196
191
 
197
- /** Provider upstream vero: il nostro upstream catturato, o il live dal registry. */
192
+ /** Provider live corrente dal registry (sempre fresco, mai snapshot). */
198
193
  function liveUpstreamProvider(
199
194
  registry: ModelRegistry,
200
195
  providerId: string,
201
196
  ): Provider | undefined {
202
- return overlays.get(providerId)?.upstream ?? registry.getProvider(providerId);
197
+ try {
198
+ return registry.getProvider(providerId);
199
+ } catch {
200
+ return undefined;
201
+ }
203
202
  }
204
203
 
205
204
  function snapshotUpstream(
@@ -220,61 +219,50 @@ export default function (pi: ExtensionAPI) {
220
219
  return { provider, model: model as Model<Api> };
221
220
  }
222
221
 
223
- /** Registra (o aggiorna) l'overlay di un provider con i suoi twin. */
224
- function registerOverlay(
225
- providerId: string,
226
- upstream: Provider,
227
- twinIds: string[],
228
- ): void {
229
- const overlay = buildOverlaidProvider({
230
- upstream,
222
+ /** Applica (o aggiorna) la patch gate sull'oggetto provider live. */
223
+ function patchLiveProvider(live: Provider, twinIds: string[]): string[] {
224
+ return patchProvider(live, {
231
225
  twinModelIds: twinIds,
232
226
  getConfig,
233
227
  getReview: getGateReview,
234
228
  onVerdict: recordGateVerdict,
235
229
  createPostCheck: createGroundingCheck,
236
230
  });
237
- pi.registerProvider(overlay);
238
- overlays.set(providerId, {
239
- upstream,
240
- twinIds: [...twinIds],
241
- provider: overlay,
242
- });
243
231
  }
244
232
 
245
233
  /**
246
- * Un'altra estensione (es. pi-accounts) può ri-registrare lo stesso provider
247
- * id, sostituendo il nostro overlay con un provider che conserva i modelli
248
- * (twin compresi) ma non il gate: l'id `__jev` finirebbe all'API.
249
- * Qui riapplichiamo l'overlay sopra il provider live, preservandone auth e
250
- * config. Se la ri-registrazione non regge, si smette di ritentare.
234
+ * Assicura la patch su tutti i twin attivi contro il provider live
235
+ * corrente. Se un'altra estensione (es. pi-accounts) ha sostituito
236
+ * l'oggetto, il nuovo viene patchato ex-novo; se l'oggetto è lo stesso
237
+ * ma non patchato, viene patchato. Non chiama mai register/unregister:
238
+ * la config del proprietario resta intatta e i suoi controlli di
239
+ * ownership non vedono alcuna "configuration changed".
240
+ * Ritorna i provider (ri-)patchati in questa chiamata.
251
241
  */
252
- function reassertOverlays(registry: ModelRegistry): string[] {
253
- const fixed: string[] = [];
242
+ function ensurePatches(registry: ModelRegistry): string[] {
243
+ // Re-patch sempre (idempotente ed economico): dopo un /reload la factory
244
+ // è nuova ma gli oggetti restano patchati con le closure vecchie, e la
245
+ // lista twin potrebbe essere cambiata. Si segnalano solo i provider che
246
+ // non erano patchati, per non spammare entry diagnostiche a ogni hook.
247
+ const patched: string[] = [];
254
248
  for (const [providerId, state] of [...overlays]) {
255
- if (state.reassertFailed) continue;
256
249
  let live: Provider | undefined;
257
250
  try {
258
251
  live = registry.getProvider(providerId);
259
252
  } catch {
260
253
  continue;
261
254
  }
262
- if (!live || live === state.provider) continue;
255
+ if (!live) continue;
256
+ const was = isPatched(live);
263
257
  try {
264
- registerOverlay(providerId, live, state.twinIds);
265
- const applied = overlays.get(providerId);
266
- if (applied && registry.getProvider(providerId) === applied.provider) {
267
- fixed.push(providerId);
268
- } else if (applied) {
269
- // Il registry restituisce un altro oggetto: non insistere.
270
- applied.reassertFailed = true;
271
- }
258
+ patchLiveProvider(live, state.twinIds);
259
+ state.live = live;
260
+ if (!was) patched.push(providerId);
272
261
  } catch {
273
- const current = overlays.get(providerId);
274
- if (current) current.reassertFailed = true;
262
+ // Riprova al prossimo hook; mai bloccare il flusso.
275
263
  }
276
264
  }
277
- return fixed;
265
+ return patched;
278
266
  }
279
267
 
280
268
  /**
@@ -294,11 +282,13 @@ export default function (pi: ExtensionAPI) {
294
282
  }
295
283
 
296
284
  try {
297
- // Provider non più gemellati: unregister → builtin ripristinato.
285
+ // Provider non più gemellati: ripristina i metodi originali (l'oggetto
286
+ // resta del proprietario: nessuna unregister che spiazzi gli altri).
298
287
  for (const providerId of [...overlays.keys()]) {
299
288
  if (!byProvider.has(providerId)) {
300
289
  try {
301
- pi.unregisterProvider(providerId);
290
+ const live = registry.getProvider(providerId);
291
+ if (live) unpatchProvider(live);
302
292
  } catch {
303
293
  // ignora
304
294
  }
@@ -307,15 +297,23 @@ export default function (pi: ExtensionAPI) {
307
297
  }
308
298
 
309
299
  for (const [providerId, twinIds] of byProvider) {
310
- // Cattura il live PRIMA dell'override (dopo, getProvider dà l'overlay).
311
- const existing = overlays.get(providerId)?.upstream;
312
- const live = existing ?? registry.getProvider(providerId);
300
+ // Sempre il live corrente: con la patch non esistono chain di
301
+ // upstream stale da preservare.
302
+ const live = liveUpstreamProvider(registry, providerId);
313
303
  if (!live) return { ok: false, error: `Provider non trovato: ${providerId}` };
314
304
  for (const model of twinIds) {
315
305
  const has = live.getModels().some((m) => m.id === model && !isTwinId(m.id));
316
306
  if (!has) return { ok: false, error: `Modello non trovato: ${providerId}/${model}` };
317
307
  }
318
- registerOverlay(providerId, live, twinIds);
308
+ try {
309
+ patchLiveProvider(live, twinIds);
310
+ } catch (error) {
311
+ return {
312
+ ok: false,
313
+ error: error instanceof Error ? error.message : String(error),
314
+ };
315
+ }
316
+ overlays.set(providerId, { twinIds: [...twinIds], live });
319
317
  }
320
318
 
321
319
  config = withTwinRefs(config, refs);
@@ -383,21 +381,20 @@ export default function (pi: ExtensionAPI) {
383
381
  };
384
382
  }
385
383
 
386
- /** Rimuove tutti gli overlay (i builtin tornano da soli). */
384
+ /** Rimuove tutte le patch (i metodi originali tornano da soli). */
387
385
  function removeGuarded(): { ok: boolean; error?: string; removed?: number } {
388
386
  if (overlays.size === 0) return { ok: false, error: "nessun guarded attivo" };
389
387
  const removed = overlays.size;
390
- for (const providerId of [...overlays.keys()]) {
388
+ // Nessuna unregister: gli oggetti restano del proprietario, si
389
+ // ripristinano solo i metodi sugli oggetti che abbiamo patchato.
390
+ for (const state of [...overlays.values()]) {
391
391
  try {
392
- pi.unregisterProvider(providerId);
393
- } catch (error) {
394
- return {
395
- ok: false,
396
- error: error instanceof Error ? error.message : String(error),
397
- };
392
+ if (state.live) unpatchProvider(state.live);
393
+ } catch {
394
+ // ignora
398
395
  }
399
- overlays.delete(providerId);
400
396
  }
397
+ overlays.clear();
401
398
  return { ok: true, removed };
402
399
  }
403
400
 
@@ -482,60 +479,29 @@ export default function (pi: ExtensionAPI) {
482
479
  getLastGate: () => lastGate,
483
480
  });
484
481
 
485
- // Seed all'avvio: pi ripristina il modello di sessione PRIMA di
486
- // session_start, quindi un twin persistito non esisterebbe ancora e
487
- // cadrebbe su "Could not restore model ...__jev". Pre-registriamo un
488
- // overlay per tutti i twin salvati, anche in on-demand: il mode della
489
- // sessione non è disponibile prima di session_start. Un alias non attiva il gate.
490
- const bootAuto = process.env.JEV_AUTO_UPSTREAM?.trim();
491
- const autoOff = bootAuto === "0" || bootAuto?.toLowerCase() === "off";
492
- let seedRefs: TwinRef[] = [];
493
- if (bootAuto && !autoOff) {
494
- const parsed = parseUpstreamRef([bootAuto]);
495
- if (!("error" in parsed)) seedRefs = [parsed];
496
- } else if (!bootAuto) {
497
- seedRefs = twinRefs(config);
498
- }
499
- if (seedRefs.length > 0) {
500
- // Raggruppa per provider: un overlay per provider con tutti i suoi twin.
501
- const byProvider = new Map<string, string[]>();
502
- for (const ref of seedRefs) {
503
- const list = byProvider.get(ref.provider) ?? [];
504
- if (!list.includes(ref.model)) list.push(ref.model);
505
- byProvider.set(ref.provider, list);
506
- }
507
- for (const [providerId, twinIds] of byProvider) {
508
- try {
509
- // providers/all è mappato dagli alias di pi; il subpath del singolo
510
- // provider non lo è e fallirebbe a load (solo installazioni npm).
511
- const base = builtinProviders().find((p) => p.id === providerId);
512
- if (!base) throw new Error(`provider ${providerId} not in builtins`);
513
- // Il catalogo builtin può non avere i modelli salvati: provider
514
- // dinamici (es. openai-codex) li tengono nel models-store, altri
515
- // (deepseek-flash su versioni vecchie) in una definizione statica.
516
- const extra = [
517
- ...(providerId === "deepseek" ? [STATIC_DEEPSEEK_FLASH] : []),
518
- ...readStoreModels(providerId),
519
- ];
520
- const seedUpstream: Provider = {
521
- ...base,
522
- getModels: () => mergeModelsById(base.getModels(), extra),
523
- };
524
- const known = new Set(seedUpstream.getModels().map((m) => m.id));
525
- const usable = twinIds.filter((model) => known.has(model));
526
- if (usable.length === 0) continue;
527
- registerOverlay(providerId, seedUpstream, usable);
528
- } catch {
529
- // Provider non gemellabile all'avvio: session_start riproverà dal registry.
530
- }
531
- }
532
- }
482
+ // NOTA: nessun seed di boot con oggetti fake. La patch lavora SOLO
483
+ // sull'oggetto live del registry: registrare un oggetto nostro
484
+ // cancellerebbe la config del proprietario (es. pi-accounts vedrebbe
485
+ // "configuration changed" e abortirebbe i turni). I twin salvati vengono
486
+ // patchati a session_start (ogni modalità) e il twin automatic viene
487
+ // riselezionato lì; un twin persistito non ripristinabile cade sul modello
488
+ // di default e startSession lo riseleziona.
533
489
 
534
490
  async function startSession(ctx: ExtensionContext): Promise<void> {
535
491
  restoreMode(ctx);
536
492
  // Nuova sessione = nuova evidenza: il ledger non attraversa le sessioni.
537
493
  evidenceLedger = [];
538
494
  evidenceSeq = 0;
495
+ // Patch dei twin salvati in OGNI modalità (nessun setModel qui): senza,
496
+ // un twin persistito non esisterebbe nel catalogo. Silenzioso: la
497
+ // diagnostica resta agli hook di turno.
498
+ try {
499
+ if (twinRefs(config).length > 0) {
500
+ applyTwins(twinRefs(config), ctx.modelRegistry, false);
501
+ }
502
+ } catch {
503
+ // session_start non deve mai fallire per il seed.
504
+ }
539
505
  const auto = process.env.JEV_AUTO_UPSTREAM?.trim();
540
506
  if (auto && auto !== "0" && auto.toLowerCase() !== "off") {
541
507
  try {
@@ -599,6 +565,63 @@ export default function (pi: ExtensionAPI) {
599
565
  });
600
566
  pi.on("session_tree", async (_event, ctx) => { await startSession(ctx); });
601
567
 
568
+ /**
569
+ * Assicura gli overlay PRIMA di qualsiasi before_agent_start (evento input).
570
+ * Un'altra estensione (pi-accounts) legge il registry nel suo
571
+ * before_agent_start per decidere i modelli disponibili dell'account e,
572
+ * se il twin non c'è, arma l'abort del turno ("This operation was
573
+ * aborted" istantaneo su turn_start). Il seed precoce rende i twin
574
+ * visibili a quella lettura, indipendentemente dall'ordine di load.
575
+ * Silenzioso: nessuna entry diagnostica qui (l'input arriva a ogni
576
+ * prompt); la diagnostica resta nel reassert di before_agent_start.
577
+ * Vale in ogni modalità: anche in on-demand il twin passthrough ha
578
+ * bisogno dell'overlay registrato (altrimenti l'id __jev finisce all'API).
579
+ */
580
+ function ensureOverlays(registry: ModelRegistry): void {
581
+ try {
582
+ const refs = twinRefs(config);
583
+ if (refs.length === 0) return;
584
+ // Ricuci le patch su oggetti sostituiti dal proprietario.
585
+ ensurePatches(registry);
586
+ // Patch ex-novo i provider gemellati senza stato (seed precoce).
587
+ const byProvider = new Map<string, string[]>();
588
+ for (const ref of refs) {
589
+ if (overlays.get(ref.provider)?.twinIds.includes(ref.model)) continue;
590
+ const list = byProvider.get(ref.provider) ?? [];
591
+ if (!list.includes(ref.model)) list.push(ref.model);
592
+ byProvider.set(ref.provider, list);
593
+ }
594
+ for (const [providerId, twinIds] of byProvider) {
595
+ let live: Provider | undefined;
596
+ try {
597
+ live = registry.getProvider(providerId);
598
+ } catch {
599
+ continue;
600
+ }
601
+ if (!live || isPatched(live)) {
602
+ if (live) overlays.set(providerId, { twinIds: [...twinIds], live });
603
+ continue;
604
+ }
605
+ const models = live.getModels();
606
+ if (!twinIds.every((id) => models.some((m) => m.id === id && !isTwinId(m.id)))) {
607
+ continue;
608
+ }
609
+ try {
610
+ patchLiveProvider(live, twinIds);
611
+ overlays.set(providerId, { twinIds: [...twinIds], live });
612
+ } catch {
613
+ // Riprova al prossimo hook; mai bloccare l'input.
614
+ }
615
+ }
616
+ } catch {
617
+ // Mai bloccare l'input per il seed.
618
+ }
619
+ }
620
+
621
+ pi.on("input", async (_event, ctx) => {
622
+ ensureOverlays(ctx.modelRegistry);
623
+ });
624
+
602
625
  // Prima di ogni turno: se un'altra estensione ci ha scalzati dal provider,
603
626
  // rimettiamo l'overlay (altrimenti l'id __jev arriverebbe all'API).
604
627
  function noteReassert(fixed: string[]): void {
@@ -610,9 +633,19 @@ export default function (pi: ExtensionAPI) {
610
633
  }
611
634
  }
612
635
 
636
+ // Ultima rete prima della generation: dopo eventuali replace altrui nel
637
+ // before_agent_start, ripatcha in silenzio (mai register/unregister).
638
+ pi.on("turn_start", async (_event, ctx) => {
639
+ try {
640
+ ensurePatches(ctx.modelRegistry);
641
+ } catch {
642
+ // Mai bloccare il turno.
643
+ }
644
+ });
645
+
613
646
  pi.on("before_agent_start", async (_event, ctx) => {
614
647
  try {
615
- noteReassert(reassertOverlays(ctx.modelRegistry));
648
+ noteReassert(ensurePatches(ctx.modelRegistry));
616
649
  } catch {
617
650
  // mai bloccare il turno
618
651
  }
@@ -666,8 +699,8 @@ export default function (pi: ExtensionAPI) {
666
699
  // su un modello non guarded (revert immediato al twin).
667
700
  pi.on("model_select", async (event, ctx) => {
668
701
  try {
669
- // Una selezione account/provider può aver rimpiazzato il nostro overlay.
670
- noteReassert(reassertOverlays(ctx.modelRegistry));
702
+ // Una selezione account/provider può aver sostituito l'oggetto: ripatcha.
703
+ noteReassert(ensurePatches(ctx.modelRegistry));
671
704
  if (sessionMode !== "automatic" || !config.automatic.requireGuardedModel) return;
672
705
  if (overlays.size === 0) return;
673
706
  if (isGuardedModel(event.model.provider, event.model.id)) return;
@@ -1060,9 +1093,9 @@ export default function (pi: ExtensionAPI) {
1060
1093
  pi.on("message_end", async (event, ctx) => {
1061
1094
  try {
1062
1095
  if (ctx.hasUI) ctx.ui.setStatus("jev", undefined);
1063
- // Dopo la richiesta: se qualcuno ci ha scalzati, rimetti l'overlay
1096
+ // Dopo la richiesta: se qualcuno ha sostituito l'oggetto, ripatcha
1064
1097
  // per il turno successivo (il payload resta già normalizzato).
1065
- noteReassert(reassertOverlays(ctx.modelRegistry));
1098
+ noteReassert(ensurePatches(ctx.modelRegistry));
1066
1099
  if (sessionMode !== "automatic") return;
1067
1100
  if (event.message.role !== "assistant") return;
1068
1101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jev-guard",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Jev validation guard for pi: on-demand tool + automatic provider gate. Dual backend: TypeSafe direct + OpenRouter.",
6
6
  "keywords": [
@@ -128,54 +128,129 @@ function stripTwins(models: readonly Model<Api>[]): Model<Api>[] {
128
128
  }
129
129
 
130
130
  /**
131
- * Costruisce l'overlay. Non registra nulla: la registrazione spetta al
132
- * chiamante (pi.registerProvider), così può fallire senza toccare nulla.
131
+ * Patch in-place del gate gemellato sullo STESSO oggetto provider.
132
+ *
133
+ * Nessuna (ri-)registrazione nel registry: l'identità dell'oggetto e la sua
134
+ * config restano quelle pubblicate dal proprietario (builtin o altre
135
+ * estensioni come pi-accounts), quindi i loro controlli di ownership e i
136
+ * loro snapshot non vedono mai una "configuration changed" e non armano
137
+ * abort. Rieseguibile: se l'oggetto è già patchato riparte dai metodi
138
+ * originali con la nuova lista twin. Ritorna gli id gemello pubblicati.
133
139
  */
134
- export function buildOverlaidProvider(deps: OverlayDeps): Provider {
135
- const upstream = deps.upstream;
136
- // Mappa twin-id -> modello originale (ricostruita a ogni refresh).
137
- let twinToOriginal = new Map<string, Model<Api>>();
138
- let currentModels: Model<Api>[] = [];
140
+ export interface PatchDeps {
141
+ /** Id modelli (originali) da gemellare. */
142
+ twinModelIds: string[];
143
+ getConfig: () => JevConfig;
144
+ getReview: () => ReviewFn;
145
+ /** Riceve l'esito finale di ogni turno sul twin (per `/jev last`). */
146
+ onVerdict?: (report: GateVerdictReport) => void;
147
+ /**
148
+ * Factory del controllo post-safety, una per richiesta: il budget repair
149
+ * vive nella closure restituita, non nel loop del guardian.
150
+ */
151
+ createPostCheck?: () => PostPublishCheckFn | undefined;
152
+ }
153
+
154
+ /** Metodi originali prima della patch (per ripristino su unpatch). */
155
+ interface PatchedOriginals {
156
+ getModels: Provider["getModels"];
157
+ stream: Provider["stream"] | undefined;
158
+ streamSimple: Provider["streamSimple"];
159
+ refreshModels?: Provider["refreshModels"];
160
+ filterModels?: Provider["filterModels"];
161
+ }
162
+
163
+ interface PatchRecord {
164
+ /** Originali veri (mai i nostri wrapper): evolve con le adozioni. */
165
+ orig: PatchedOriginals;
166
+ /** I nostri wrapper, per distinguerli dalle riassegnazioni altrui. */
167
+ wrapGetModels: Provider["getModels"];
168
+ }
169
+
170
+ const patchedOriginals = new WeakMap<object, PatchRecord>();
171
+
172
+ /** True se l'oggetto provider è attualmente patchato da noi. */
173
+ export function isPatched(provider: Provider): boolean {
174
+ return patchedOriginals.has(provider);
175
+ }
139
176
 
140
- function rebuildTwins(rawOriginals: readonly Model<Api>[]): Model<Api>[] {
141
- twinToOriginal = new Map();
142
- const chosen = new Set(deps.twinModelIds);
143
- // Dedup per id: sorgenti miste (builtin + store + statica) possono
144
- // ripetere lo stesso modello e produrrebbero twin duplicati.
145
- const originals = mergeModelsById(stripTwins(rawOriginals), []);
177
+ export function patchProvider(provider: Provider, deps: PatchDeps): string[] {
178
+ const previous = patchedOriginals.get(provider);
179
+ const rawStream: unknown = previous
180
+ ? previous.orig.stream
181
+ : (provider as { stream?: unknown }).stream;
182
+ const base: PatchedOriginals = previous?.orig ?? {
183
+ // Raw, mai bind: invocati con this=provider al momento della chiamata.
184
+ getModels: provider.getModels,
185
+ stream: typeof rawStream === "function"
186
+ ? (rawStream as NonNullable<Provider["stream"]>)
187
+ : undefined,
188
+ streamSimple: provider.streamSimple,
189
+ ...(provider.refreshModels
190
+ ? { refreshModels: provider.refreshModels }
191
+ : {}),
192
+ ...(provider.filterModels
193
+ ? { filterModels: provider.filterModels }
194
+ : {}),
195
+ };
196
+ const twinIds = [...deps.twinModelIds];
197
+ const providerId = provider.id;
198
+
199
+ // Risoluzione pigra: legge SEMPRE i modelli correnti (nessuna snapshot
200
+ // che diventa stale dopo refresh altrui o cataloghi dinamici). Se il
201
+ // proprietario riassegna getModels dopo la patch, la nuova fn viene
202
+ // adottata come base (i nostri wrapper si riconoscono e non si adottano).
203
+ function currentBase(): PatchedOriginals {
204
+ const rec = patchedOriginals.get(provider);
205
+ const orig = rec?.orig ?? base;
206
+ const cur = provider.getModels as Provider["getModels"];
207
+ if (rec && cur !== rec.wrapGetModels && cur !== orig.getModels) {
208
+ orig.getModels = cur;
209
+ }
210
+ return orig;
211
+ }
212
+
213
+ function currentOriginals(): Model<Api>[] {
214
+ return mergeModelsById(stripTwins(currentBase().getModels.call(provider)), []);
215
+ }
216
+
217
+ function resolveTwin(modelId: string): Model<Api> | undefined {
218
+ if (!isTwinId(modelId)) return undefined;
219
+ const wanted = untwinId(modelId);
220
+ if (!twinIds.includes(wanted)) return undefined;
221
+ return currentOriginals().find((m) => m.id === wanted);
222
+ }
223
+
224
+ function twinEntries(): Model<Api>[] {
225
+ const originals = currentOriginals();
226
+ const chosen = new Set(twinIds);
146
227
  const twins: Model<Api>[] = [];
228
+ const seen = new Set<string>();
147
229
  for (const original of originals) {
148
- if (chosen.has(original.id)) {
149
- const twin = twinModelFor(original, upstream.id);
150
- if (twinToOriginal.has(twin.id)) continue;
151
- twinToOriginal.set(twin.id, original);
152
- twins.push(twin);
153
- }
230
+ if (!chosen.has(original.id)) continue;
231
+ const twin = twinModelFor(original, providerId);
232
+ if (seen.has(twin.id)) continue;
233
+ seen.add(twin.id);
234
+ twins.push(twin);
154
235
  }
155
- currentModels = [...originals.map((m) => ({ ...m })), ...twins];
156
- return currentModels;
236
+ return twins;
157
237
  }
158
238
 
159
- rebuildTwins(upstream.getModels());
160
-
161
239
  function upstreamCall(
162
240
  model: Model<Api>,
163
241
  context: Context,
164
242
  options?: SimpleStreamOptions,
165
243
  ): AssistantMessageEventStream {
166
- // Public aliases are not model changes. Preserve reasoning signatures,
167
- // response phases and tool ids when replaying the transcript upstream.
244
+ // Gli alias pubblici non sono cambi di modello: normalizza la cronologia
245
+ // preservando firme reasoning e tool id nel transcript upstream.
168
246
  const originalContext: Context = {
169
247
  ...context,
170
248
  messages: context.messages.map((message) =>
171
- message.role === "assistant" && message.provider === upstream.id && isTwinId(message.model)
249
+ message.role === "assistant" && message.provider === providerId && isTwinId(message.model)
172
250
  ? { ...message, model: untwinId(message.model) }
173
251
  : message),
174
252
  };
175
- if (deps.upstreamStreams) {
176
- return deps.upstreamStreams.streamSimple(model, originalContext, options);
177
- }
178
- return upstream.streamSimple(model, originalContext, options);
253
+ return base.streamSimple.call(provider, model, originalContext, options);
179
254
  }
180
255
 
181
256
  function guardedStreamSimple(
@@ -183,9 +258,9 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
183
258
  context: Context,
184
259
  options?: SimpleStreamOptions,
185
260
  ): AssistantMessageEventStream {
186
- const twinOf = twinToOriginal.get(model.id);
187
- // A stale/unknown alias must never leak to the upstream API, even if a
188
- // dynamic refresh removed its original from the catalogue.
261
+ const twinOf = resolveTwin(model.id);
262
+ // Un alias stale/sconosciuto non deve mai arrivare all'API upstream,
263
+ // anche se un refresh ha rimosso l'originale dal catalogo.
189
264
  if (!twinOf) {
190
265
  if (!isTwinId(model.id)) return upstreamCall(model, context, options);
191
266
  const stream = createAssistantMessageEventStream();
@@ -221,31 +296,29 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
221
296
  });
222
297
  }
223
298
 
224
- const overlay: Provider = {
225
- id: upstream.id,
226
- name: upstream.name,
227
- baseUrl: upstream.baseUrl,
228
- headers: upstream.headers,
229
- // STESSO oggetto auth: stesso slot credenziale, stesso login/OAuth.
230
- auth: upstream.auth,
231
- getModels: () => currentModels,
232
- stream: (model, context, options) =>
299
+ const wrapGetModels: Provider["getModels"] = () => [
300
+ ...currentOriginals().map((m) => ({ ...m })),
301
+ ...twinEntries(),
302
+ ];
303
+ provider.getModels = wrapGetModels;
304
+ if (base.stream) {
305
+ provider.stream = ((model: any, context: any, options: any) =>
233
306
  guardedStreamSimple(
234
307
  model as Model<Api>,
235
308
  context,
236
309
  options as SimpleStreamOptions | undefined,
237
- ),
238
- streamSimple: (model, context, options) =>
239
- guardedStreamSimple(model as Model<Api>, context, options),
240
- };
310
+ )) as Provider["stream"];
311
+ }
312
+ provider.streamSimple = ((model: any, context: any, options: any) =>
313
+ guardedStreamSimple(model as Model<Api>, context, options)) as Provider["streamSimple"];
241
314
 
242
- // Forward refresh con re-gemellamento. Il persist va depurato dai twin:
243
- // lo store è letto anche dal builtin a overlay assente, che li
244
- // scambierebbe per modelli normali (senza gate!).
245
- if (upstream.refreshModels) {
246
- const upstreamRefresh = upstream.refreshModels.bind(upstream);
247
- overlay.refreshModels = async (ctx: RefreshModelsContext): Promise<void> => {
248
- await upstreamRefresh({
315
+ // Forward refresh con persist depurato dai twin: lo store è letto anche
316
+ // dal builtin a overlay assente, che li scambierebbe per modelli normali
317
+ // (senza gate!). La risoluzione pigra non ha snapshot da ricostruire.
318
+ if (base.refreshModels) {
319
+ const upstreamRefresh = base.refreshModels;
320
+ provider.refreshModels = (async (ctx: RefreshModelsContext): Promise<void> => {
321
+ await upstreamRefresh.call(provider, {
249
322
  ...ctx,
250
323
  publish: (publication) =>
251
324
  ctx.publish({
@@ -260,41 +333,60 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
260
333
  },
261
334
  update: publication.update
262
335
  ? () => {
263
- // Applica prima l'update upstream (popola il suo oggetto),
264
- // poi ricostruisci originali + twin da quelli freschi.
265
336
  publication.update?.();
266
- rebuildTwins(upstream.getModels());
267
337
  }
268
338
  : undefined,
269
339
  }),
270
340
  });
271
- };
341
+ }) as Provider["refreshModels"];
272
342
  }
273
343
 
274
344
  // Forward filtro credenziali: si applica agli originali, i twin seguono
275
345
  // il destino dell'originale corrispondente.
276
- if (upstream.filterModels) {
277
- const upstreamFilter = upstream.filterModels.bind(upstream);
278
- overlay.filterModels = (
346
+ if (base.filterModels) {
347
+ const upstreamFilter = base.filterModels;
348
+ provider.filterModels = ((
279
349
  models: readonly Model<Api>[],
280
350
  credential: Credential | undefined,
281
351
  ): readonly Model<Api>[] => {
282
352
  const originals = models.filter((m) => !isTwinId(m.id));
283
353
  const kept = new Set(
284
- upstreamFilter(originals as ModelT<Api>[], credential).map((m) => m.id),
354
+ upstreamFilter.call(provider, originals as ModelT<Api>[], credential).map((m) => m.id),
285
355
  );
286
356
  return models.filter((m) =>
287
357
  isTwinId(m.id) ? kept.has(untwinId(m.id)) : kept.has(m.id),
288
358
  );
289
- };
359
+ }) as Provider["filterModels"];
290
360
  }
291
361
 
292
- if (upstream.fetchDeferred) {
293
- overlay.fetchDeferred = upstream.fetchDeferred.bind(upstream);
294
- }
295
- if (upstream.cancelDeferred) {
296
- overlay.cancelDeferred = upstream.cancelDeferred.bind(upstream);
362
+ if (previous) {
363
+ // Re-patch: i wrapper si ricreano, gli originali veri restano.
364
+ // Aggiorna il riferimento al wrapper corrente o l'adozione
365
+ // scambierebbe il nuovo wrapper per una fn altrui.
366
+ previous.wrapGetModels = wrapGetModels;
367
+ patchedOriginals.set(provider, previous);
368
+ } else {
369
+ patchedOriginals.set(provider, { orig: base, wrapGetModels });
297
370
  }
371
+ return twinEntries().map((t) => t.id);
372
+ }
298
373
 
299
- return overlay;
374
+ /**
375
+ * Ripristina i metodi originali. Ritorna false se l'oggetto non era patchato
376
+ * (es. già sostituito dal proprietario: niente da ripristinare).
377
+ */
378
+ export function unpatchProvider(provider: Provider): boolean {
379
+ const rec = patchedOriginals.get(provider);
380
+ const originals = rec?.orig;
381
+ if (!originals) return false;
382
+ provider.getModels = originals.getModels;
383
+ if (originals.stream) provider.stream = originals.stream;
384
+ else delete (provider as { stream?: unknown }).stream;
385
+ provider.streamSimple = originals.streamSimple;
386
+ if (originals.refreshModels) provider.refreshModels = originals.refreshModels;
387
+ else delete (provider as { refreshModels?: unknown }).refreshModels;
388
+ if (originals.filterModels) provider.filterModels = originals.filterModels;
389
+ else delete (provider as { filterModels?: unknown }).filterModels;
390
+ patchedOriginals.delete(provider);
391
+ return true;
300
392
  }