pi-jev-guard 0.1.5 → 0.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.
- package/README.md +21 -8
- package/config/jev-config.example.json +27 -4
- package/extensions/index.ts +222 -148
- package/package.json +7 -1
- package/src/automatic/overlay.ts +20 -1
- package/src/commands.ts +95 -13
- package/src/config.ts +68 -3
- package/src/store-models.ts +47 -0
package/README.md
CHANGED
|
@@ -39,21 +39,34 @@ In chat, l'LLM può chiamare `jev_validate` per controlli mirati.
|
|
|
39
39
|
## Uso protetto
|
|
40
40
|
|
|
41
41
|
```text
|
|
42
|
-
/jev upstream deepseek deepseek-flash
|
|
43
|
-
/jev
|
|
42
|
+
/jev upstream deepseek deepseek-flash # crea deepseek/deepseek-flash__jev
|
|
43
|
+
/jev upstream openai-codex gpt-5.6-terra # aggiunge openai-codex/gpt-5.6-terra__jev
|
|
44
|
+
/jev twins # elenca attivi e salvati
|
|
45
|
+
/jev untwin openai-codex gpt-5.6-terra # rimuove uno solo
|
|
46
|
+
/jev mode automatic # seleziona il primario + attiva enforcement
|
|
44
47
|
```
|
|
45
48
|
|
|
46
|
-
|
|
49
|
+
**Più twin insieme**: ogni `/jev upstream` *aggiunge* un modello guarded, senza
|
|
50
|
+
rimuovere i precedenti. I twin vengono registrati anche all'avvio (i modelli
|
|
51
|
+
dinamici come Codex sono letti dal models-store), quindi la sessione riapre
|
|
52
|
+
direttamente sul `__jev` senza warning. Il **primario** (ultimo aggiunto, o
|
|
53
|
+
quello salvato come tale) è il target di auto-selezione e revert.
|
|
54
|
+
|
|
55
|
+
Nessun login extra: i twin vivono nello stesso provider e riusano la credenziale già configurata (env, stored, OAuth/SSO come Codex business plan). I modelli originali restano intatti e selezionabili.
|
|
56
|
+
|
|
57
|
+
In `automatic` il twin viene registrato già all'avvio (prima del ripristino del
|
|
58
|
+
modello di sessione), così la sessione può riaprire direttamente su `__jev`.
|
|
47
59
|
|
|
48
60
|
Torna normale con `/model` (modello normale) + `/jev mode on-demand`.
|
|
49
61
|
In `automatic`, selezionare un modello non protetto lo fa ritornare
|
|
50
62
|
subito al twin (enforcement a livello selezione).
|
|
51
|
-
`/jev upstream` senza argomenti ripristina
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
`/jev upstream` senza argomenti ripristina tutti i twin salvati, `/jev untwin P M`
|
|
64
|
+
ne rimuove uno solo, `/jev off` li rimuove tutti
|
|
65
|
+
(e torna a on-demand, provider originali ripristinati). In `automatic` i twin
|
|
66
|
+
si ripristinano da soli al riavvio.
|
|
54
67
|
|
|
55
|
-
Se filtri i modelli con `enabledModels`, tieni
|
|
56
|
-
|
|
68
|
+
Se filtri i modelli con `enabledModels`, tieni le voci `__jev` dei twin che usi
|
|
69
|
+
o restano nascosti.
|
|
57
70
|
L'installazione punta alla cartella: dopo un aggiornamento del codice basta `/reload`.
|
|
58
71
|
|
|
59
72
|
Headless/CI: `JEV_AUTO_UPSTREAM=provider/model` crea il guarded all'avvio
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
"timeoutMs": 4000,
|
|
9
9
|
"timeoutMsTypesafe": 1500,
|
|
10
10
|
"timeoutMsOpenRouter": 4000,
|
|
11
|
-
"retryTransients": {
|
|
11
|
+
"retryTransients": {
|
|
12
|
+
"enabled": true,
|
|
13
|
+
"maxRetries": 1
|
|
14
|
+
},
|
|
12
15
|
"apiKeyFile": "~/.pi/agent/jev-api-key.txt"
|
|
13
16
|
},
|
|
14
17
|
"policy": {
|
|
@@ -22,6 +25,16 @@
|
|
|
22
25
|
"transport": "provider-gate",
|
|
23
26
|
"maxRegenerations": 2,
|
|
24
27
|
"requireGuardedModel": true,
|
|
28
|
+
"twins": [
|
|
29
|
+
{
|
|
30
|
+
"provider": "deepseek",
|
|
31
|
+
"model": "deepseek-flash"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"provider": "openai-codex",
|
|
35
|
+
"model": "gpt-5.6-terra"
|
|
36
|
+
}
|
|
37
|
+
],
|
|
25
38
|
"upstreamProvider": "deepseek",
|
|
26
39
|
"upstreamModel": "deepseek-flash"
|
|
27
40
|
},
|
|
@@ -44,7 +57,9 @@
|
|
|
44
57
|
},
|
|
45
58
|
"outputJudge": {
|
|
46
59
|
"enabled": true,
|
|
47
|
-
"tools": [
|
|
60
|
+
"tools": [
|
|
61
|
+
"bash"
|
|
62
|
+
],
|
|
48
63
|
"outputChars": 2000,
|
|
49
64
|
"leakThreshold": 0.9,
|
|
50
65
|
"minConfidence": 0.6,
|
|
@@ -52,8 +67,16 @@
|
|
|
52
67
|
},
|
|
53
68
|
"exfilCheck": {
|
|
54
69
|
"enabled": true,
|
|
55
|
-
"tools": [
|
|
56
|
-
|
|
70
|
+
"tools": [
|
|
71
|
+
"bash",
|
|
72
|
+
"powershell"
|
|
73
|
+
],
|
|
74
|
+
"blockOn": {
|
|
75
|
+
"destructive": 0.9,
|
|
76
|
+
"exfiltration": 0.7,
|
|
77
|
+
"beyondScope": 0.85,
|
|
78
|
+
"impact": 2.5
|
|
79
|
+
},
|
|
57
80
|
"minConfidence": 0.5,
|
|
58
81
|
"cacheSeconds": 120,
|
|
59
82
|
"blockWithoutUI": false
|
package/extensions/index.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
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
|
-
import { registerJevCommand, type UpstreamResult } from "../src/commands.ts";
|
|
4
|
+
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";import { registerJevCommand, type UpstreamResult } from "../src/commands.ts";
|
|
6
5
|
import { registerJevAskTool } from "../src/ask.ts";
|
|
7
6
|
import {
|
|
8
7
|
configSnapshot,
|
|
@@ -10,7 +9,10 @@ import {
|
|
|
10
9
|
parseUpstreamRef,
|
|
11
10
|
resolveBackend,
|
|
12
11
|
saveConfig,
|
|
12
|
+
twinRefs,
|
|
13
|
+
withTwinRefs,
|
|
13
14
|
type JevConfig,
|
|
15
|
+
type TwinRef,
|
|
14
16
|
} from "../src/config.ts";
|
|
15
17
|
import { createReviewer, createTypedAsk } from "../src/factory.ts";
|
|
16
18
|
import { createMetrics } from "../src/metrics.ts";
|
|
@@ -19,11 +21,12 @@ import {
|
|
|
19
21
|
STATIC_DEEPSEEK_FLASH,
|
|
20
22
|
buildOverlaidProvider,
|
|
21
23
|
isTwinId,
|
|
24
|
+
mergeModelsById,
|
|
22
25
|
twinIdFor,
|
|
23
26
|
} from "../src/automatic/overlay.ts";
|
|
24
27
|
import type { GateVerdictReport } from "../src/automatic/guardian.ts";
|
|
25
|
-
import {
|
|
26
|
-
import { elideWithMarker } from "../src/reviewer.ts";
|
|
28
|
+
import { readStoreModels } from "../src/store-models.ts";
|
|
29
|
+
import { DEFAULT_GATE_RULES, elideWithMarker } from "../src/reviewer.ts";
|
|
27
30
|
import {
|
|
28
31
|
TOOL_POLICY_REVISION,
|
|
29
32
|
checkFilePath,
|
|
@@ -74,24 +77,56 @@ export default function (pi: ExtensionAPI) {
|
|
|
74
77
|
let lastExfilErrorAt = 0;
|
|
75
78
|
const metrics = createMetrics();
|
|
76
79
|
|
|
77
|
-
//
|
|
78
|
-
// (o
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// Twin attivi, uno o più per provider. Nessun overlay finché la config non
|
|
81
|
+
// li attiva (automatic) o l'utente non usa /jev upstream (opt-in).
|
|
82
|
+
interface OverlayState {
|
|
83
|
+
upstream: Provider;
|
|
84
|
+
twinIds: string[];
|
|
85
|
+
}
|
|
86
|
+
const overlays = new Map<string, OverlayState>();
|
|
82
87
|
|
|
83
88
|
const getConfig = () => config;
|
|
84
89
|
const getReview = () => handle.review;
|
|
85
90
|
const getGateReview = () => gateHandle.review;
|
|
86
91
|
const getInfo = () => ({ backend: handle.backend, model: handle.model });
|
|
87
92
|
|
|
93
|
+
/** Twin primario (auto-selezione e revert): ultimo aggiunto che è attivo. */
|
|
94
|
+
function primaryTwin(): TwinRef | undefined {
|
|
95
|
+
const saved = twinRefs(config);
|
|
96
|
+
const wanted =
|
|
97
|
+
saved.find(
|
|
98
|
+
(r) =>
|
|
99
|
+
r.provider === config.automatic.upstreamProvider &&
|
|
100
|
+
r.model === config.automatic.upstreamModel,
|
|
101
|
+
) ?? saved[saved.length - 1];
|
|
102
|
+
if (wanted && overlays.get(wanted.provider)?.twinIds.includes(wanted.model)) {
|
|
103
|
+
return wanted;
|
|
104
|
+
}
|
|
105
|
+
// Ripiego: primo twin attivo.
|
|
106
|
+
for (const [providerId, state] of overlays) {
|
|
107
|
+
const model = state.twinIds[0];
|
|
108
|
+
if (model) return { provider: providerId, model };
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function activeTwinRefs(): TwinRef[] {
|
|
114
|
+
const refs: TwinRef[] = [];
|
|
115
|
+
for (const [providerId, state] of overlays) {
|
|
116
|
+
for (const model of state.twinIds) refs.push({ provider: providerId, model });
|
|
117
|
+
}
|
|
118
|
+
return refs;
|
|
119
|
+
}
|
|
120
|
+
|
|
88
121
|
function getGateStatus(): GateStatus {
|
|
89
|
-
|
|
122
|
+
const primary = primaryTwin();
|
|
123
|
+
if (primary) {
|
|
124
|
+
const count = activeTwinRefs().length;
|
|
90
125
|
return {
|
|
91
126
|
active: true,
|
|
92
|
-
twin: `${
|
|
93
|
-
upstream: `${
|
|
94
|
-
reason:
|
|
127
|
+
twin: `${primary.provider}/${twinIdFor(primary.model)}`,
|
|
128
|
+
upstream: `${primary.provider}/${primary.model}`,
|
|
129
|
+
reason: `${count} twin attiv${count === 1 ? "o" : "i"} su ${overlays.size} provider (stessa auth)`,
|
|
95
130
|
};
|
|
96
131
|
}
|
|
97
132
|
return {
|
|
@@ -100,13 +135,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
100
135
|
};
|
|
101
136
|
}
|
|
102
137
|
|
|
138
|
+
/** True se la coppia provider/modello è una twin da noi registrata. */
|
|
103
139
|
function isGuardedModel(provider: string | undefined, modelId: string | undefined): boolean {
|
|
104
|
-
if (!
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
isTwinId(modelId)
|
|
109
|
-
);
|
|
140
|
+
if (!provider || !modelId || !isTwinId(modelId)) return false;
|
|
141
|
+
const state = overlays.get(provider);
|
|
142
|
+
if (!state) return false;
|
|
143
|
+
return state.twinIds.some((model) => twinIdFor(model) === modelId);
|
|
110
144
|
}
|
|
111
145
|
|
|
112
146
|
function refresh() {
|
|
@@ -119,15 +153,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
119
153
|
return config;
|
|
120
154
|
}
|
|
121
155
|
|
|
122
|
-
/** Provider upstream vero
|
|
156
|
+
/** Provider upstream vero: il nostro upstream catturato, o il live dal registry. */
|
|
123
157
|
function liveUpstreamProvider(
|
|
124
158
|
registry: ModelRegistry,
|
|
125
159
|
providerId: string,
|
|
126
160
|
): Provider | undefined {
|
|
127
|
-
|
|
128
|
-
return upstreamRefs.get(providerId);
|
|
129
|
-
}
|
|
130
|
-
return registry.getProvider(providerId);
|
|
161
|
+
return overlays.get(providerId)?.upstream ?? registry.getProvider(providerId);
|
|
131
162
|
}
|
|
132
163
|
|
|
133
164
|
function snapshotUpstream(
|
|
@@ -148,56 +179,78 @@ export default function (pi: ExtensionAPI) {
|
|
|
148
179
|
return { provider, model: model as Model<Api> };
|
|
149
180
|
}
|
|
150
181
|
|
|
151
|
-
|
|
152
|
-
|
|
182
|
+
/** Registra (o aggiorna) l'overlay di un provider con i suoi twin. */
|
|
183
|
+
function registerOverlay(
|
|
184
|
+
providerId: string,
|
|
185
|
+
upstream: Provider,
|
|
186
|
+
twinIds: string[],
|
|
187
|
+
): void {
|
|
188
|
+
const overlay = buildOverlaidProvider({
|
|
189
|
+
upstream,
|
|
190
|
+
twinModelIds: twinIds,
|
|
191
|
+
getConfig,
|
|
192
|
+
getReview: getGateReview,
|
|
193
|
+
onVerdict: recordGateVerdict,
|
|
194
|
+
});
|
|
195
|
+
pi.registerProvider(overlay);
|
|
196
|
+
overlays.set(providerId, { upstream, twinIds: [...twinIds] });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Allinea il registry ai twin richiesti: registra i provider coinvolti e
|
|
201
|
+
* ripristina (unregister) quelli che non hanno più twin.
|
|
202
|
+
*/
|
|
203
|
+
function applyTwins(
|
|
204
|
+
refs: TwinRef[],
|
|
205
|
+
registry: ModelRegistry,
|
|
153
206
|
persist: boolean,
|
|
154
|
-
):
|
|
155
|
-
const
|
|
207
|
+
): { ok: boolean; error?: string; twins?: string[] } {
|
|
208
|
+
const byProvider = new Map<string, string[]>();
|
|
209
|
+
for (const ref of refs) {
|
|
210
|
+
const list = byProvider.get(ref.provider) ?? [];
|
|
211
|
+
if (!list.includes(ref.model)) list.push(ref.model);
|
|
212
|
+
byProvider.set(ref.provider, list);
|
|
213
|
+
}
|
|
214
|
+
|
|
156
215
|
try {
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
216
|
+
// Provider non più gemellati: unregister → builtin ripristinato.
|
|
217
|
+
for (const providerId of [...overlays.keys()]) {
|
|
218
|
+
if (!byProvider.has(providerId)) {
|
|
219
|
+
try {
|
|
220
|
+
pi.unregisterProvider(providerId);
|
|
221
|
+
} catch {
|
|
222
|
+
// ignora
|
|
223
|
+
}
|
|
224
|
+
overlays.delete(providerId);
|
|
163
225
|
}
|
|
164
|
-
upstreamRefs.delete(overlaidProviderId);
|
|
165
226
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
227
|
+
|
|
228
|
+
for (const [providerId, twinIds] of byProvider) {
|
|
229
|
+
// Cattura il live PRIMA dell'override (dopo, getProvider dà l'overlay).
|
|
230
|
+
const existing = overlays.get(providerId)?.upstream;
|
|
231
|
+
const live = existing ?? registry.getProvider(providerId);
|
|
232
|
+
if (!live) return { ok: false, error: `Provider non trovato: ${providerId}` };
|
|
233
|
+
for (const model of twinIds) {
|
|
234
|
+
const has = live.getModels().some((m) => m.id === model && !isTwinId(m.id));
|
|
235
|
+
if (!has) return { ok: false, error: `Modello non trovato: ${providerId}/${model}` };
|
|
236
|
+
}
|
|
237
|
+
registerOverlay(providerId, live, twinIds);
|
|
169
238
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
twinModelIds: [snapshot.model.id],
|
|
173
|
-
getConfig,
|
|
174
|
-
getReview: getGateReview,
|
|
175
|
-
onVerdict: recordGateVerdict,
|
|
176
|
-
});
|
|
177
|
-
pi.registerProvider(overlay);
|
|
178
|
-
overlaidProviderId = providerId;
|
|
179
|
-
twinTarget = { provider: providerId, model: snapshot.model.id };
|
|
180
|
-
config = {
|
|
181
|
-
...config,
|
|
182
|
-
automatic: {
|
|
183
|
-
...config.automatic,
|
|
184
|
-
upstreamProvider: providerId,
|
|
185
|
-
upstreamModel: snapshot.model.id,
|
|
186
|
-
},
|
|
187
|
-
};
|
|
239
|
+
|
|
240
|
+
config = withTwinRefs(config, refs);
|
|
188
241
|
if (persist) {
|
|
189
242
|
try {
|
|
190
243
|
saveConfig(config);
|
|
191
244
|
} catch (error) {
|
|
192
245
|
return {
|
|
193
246
|
ok: false,
|
|
194
|
-
error: `
|
|
247
|
+
error: `Twin aggiornati in memoria ma salvataggio fallito: ${
|
|
195
248
|
error instanceof Error ? error.message : String(error)
|
|
196
249
|
}`,
|
|
197
250
|
};
|
|
198
251
|
}
|
|
199
252
|
}
|
|
200
|
-
return { ok: true,
|
|
253
|
+
return { ok: true, twins: activeTwinRefs().map((r) => `${r.provider}/${twinIdFor(r.model)}`) };
|
|
201
254
|
} catch (error) {
|
|
202
255
|
return {
|
|
203
256
|
ok: false,
|
|
@@ -206,7 +259,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
206
259
|
}
|
|
207
260
|
}
|
|
208
261
|
|
|
209
|
-
/**
|
|
262
|
+
/** Aggiunge un twin (persistente). Gli altri restano attivi. */
|
|
210
263
|
function setUpstream(
|
|
211
264
|
registry: ModelRegistry,
|
|
212
265
|
providerId: string,
|
|
@@ -214,55 +267,78 @@ export default function (pi: ExtensionAPI) {
|
|
|
214
267
|
): UpstreamResult {
|
|
215
268
|
const found = snapshotUpstream(registry, providerId, modelId);
|
|
216
269
|
if ("error" in found) return { ok: false, error: found.error };
|
|
217
|
-
|
|
270
|
+
const refs = twinRefs(config).filter(
|
|
271
|
+
(r) => !(r.provider === providerId && r.model === modelId),
|
|
272
|
+
);
|
|
273
|
+
refs.push({ provider: providerId, model: modelId });
|
|
274
|
+
const res = applyTwins(refs, registry, true);
|
|
275
|
+
if (!res.ok) return { ok: false, error: res.error };
|
|
276
|
+
return { ok: true, twin: `${providerId}/${twinIdFor(modelId)}` };
|
|
218
277
|
}
|
|
219
278
|
|
|
220
|
-
/**
|
|
279
|
+
/** Rimuove un twin; se il provider resta senza twin, ripristina il builtin. */
|
|
280
|
+
function removeTwin(
|
|
281
|
+
registry: ModelRegistry,
|
|
282
|
+
providerId: string,
|
|
283
|
+
modelId: string,
|
|
284
|
+
): { ok: boolean; error?: string } {
|
|
285
|
+
const refs = twinRefs(config);
|
|
286
|
+
const next = refs.filter((r) => !(r.provider === providerId && r.model === modelId));
|
|
287
|
+
if (next.length === refs.length) {
|
|
288
|
+
return { ok: false, error: `twin non registrato: ${providerId}/${modelId}` };
|
|
289
|
+
}
|
|
290
|
+
const res = applyTwins(next, registry, true);
|
|
291
|
+
return res.ok ? { ok: true } : { ok: false, error: res.error };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Riapplica tutti i twin salvati in config (es. dopo il riavvio). */
|
|
221
295
|
function refreshUpstream(registry: ModelRegistry): UpstreamResult {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
296
|
+
const res = applyTwins(twinRefs(config), registry, false);
|
|
297
|
+
if (!res.ok) return { ok: false, error: res.error };
|
|
298
|
+
const primary = primaryTwin();
|
|
299
|
+
return {
|
|
300
|
+
ok: true,
|
|
301
|
+
twin: primary ? `${primary.provider}/${twinIdFor(primary.model)}` : undefined,
|
|
302
|
+
};
|
|
229
303
|
}
|
|
230
304
|
|
|
231
|
-
/** Rimuove
|
|
232
|
-
function removeGuarded(): { ok: boolean; error?: string } {
|
|
233
|
-
if (
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
305
|
+
/** Rimuove tutti gli overlay (i builtin tornano da soli). */
|
|
306
|
+
function removeGuarded(): { ok: boolean; error?: string; removed?: number } {
|
|
307
|
+
if (overlays.size === 0) return { ok: false, error: "nessun guarded attivo" };
|
|
308
|
+
const removed = overlays.size;
|
|
309
|
+
for (const providerId of [...overlays.keys()]) {
|
|
310
|
+
try {
|
|
311
|
+
pi.unregisterProvider(providerId);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
return {
|
|
314
|
+
ok: false,
|
|
315
|
+
error: error instanceof Error ? error.message : String(error),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
overlays.delete(providerId);
|
|
241
319
|
}
|
|
242
|
-
|
|
243
|
-
overlaidProviderId = undefined;
|
|
244
|
-
twinTarget = undefined;
|
|
245
|
-
return { ok: true };
|
|
320
|
+
return { ok: true, removed };
|
|
246
321
|
}
|
|
247
322
|
|
|
248
|
-
/** Seleziona il twin
|
|
323
|
+
/** Seleziona il twin primario come modello di sessione. */
|
|
249
324
|
async function switchToGuarded(
|
|
250
325
|
registry: ModelRegistry,
|
|
251
326
|
): Promise<{ ok: boolean; error?: string; model?: string }> {
|
|
252
|
-
|
|
327
|
+
const primary = primaryTwin();
|
|
328
|
+
if (!primary) {
|
|
253
329
|
return { ok: false, error: "nessun guarded attivo — prima /jev upstream <provider> <model>" };
|
|
254
330
|
}
|
|
255
|
-
const twinId = twinIdFor(
|
|
256
|
-
const model = registry.find(
|
|
331
|
+
const twinId = twinIdFor(primary.model);
|
|
332
|
+
const model = registry.find(primary.provider, twinId);
|
|
257
333
|
if (!model) {
|
|
258
|
-
return { ok: false, error: `twin non in registry: ${
|
|
334
|
+
return { ok: false, error: `twin non in registry: ${primary.provider}/${twinId}` };
|
|
259
335
|
}
|
|
260
336
|
try {
|
|
261
337
|
const ok = await pi.setModel(model);
|
|
262
338
|
if (!ok) {
|
|
263
|
-
return { ok: false, error: `auth mancante per ${
|
|
339
|
+
return { ok: false, error: `auth mancante per ${primary.provider} — /login ${primary.provider}` };
|
|
264
340
|
}
|
|
265
|
-
return { ok: true, model: `${
|
|
341
|
+
return { ok: true, model: `${primary.provider}/${model.id}` };
|
|
266
342
|
} catch (error) {
|
|
267
343
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
268
344
|
}
|
|
@@ -299,9 +375,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
299
375
|
reviewerInfo: getInfo,
|
|
300
376
|
metrics,
|
|
301
377
|
gateStatus: getGateStatus,
|
|
378
|
+
twinStatus: () => ({
|
|
379
|
+
active: activeTwinRefs(),
|
|
380
|
+
saved: twinRefs(config),
|
|
381
|
+
primary: primaryTwin(),
|
|
382
|
+
}),
|
|
302
383
|
setUpstream,
|
|
303
384
|
refreshUpstream,
|
|
304
385
|
removeGuarded,
|
|
386
|
+
removeTwin,
|
|
305
387
|
switchToGuarded,
|
|
306
388
|
getLastOutput: () => lastOutput,
|
|
307
389
|
getLastGate: () => lastGate,
|
|
@@ -315,55 +397,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
315
397
|
// sostituisce col clone live dal registry.
|
|
316
398
|
const bootAuto = process.env.JEV_AUTO_UPSTREAM?.trim();
|
|
317
399
|
const autoOff = bootAuto === "0" || bootAuto?.toLowerCase() === "off";
|
|
318
|
-
let
|
|
400
|
+
let seedRefs: TwinRef[] = [];
|
|
319
401
|
if (bootAuto && !autoOff) {
|
|
320
402
|
const parsed = parseUpstreamRef([bootAuto]);
|
|
321
|
-
if (!("error" in parsed))
|
|
403
|
+
if (!("error" in parsed)) seedRefs = [parsed];
|
|
322
404
|
} else if (!bootAuto && config.mode === "automatic") {
|
|
323
|
-
|
|
324
|
-
provider: config.automatic.upstreamProvider,
|
|
325
|
-
model: config.automatic.upstreamModel,
|
|
326
|
-
};
|
|
405
|
+
seedRefs = twinRefs(config);
|
|
327
406
|
}
|
|
328
|
-
if (
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
if (!
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
});
|
|
361
|
-
pi.registerProvider(overlay);
|
|
362
|
-
upstreamRefs.set(seedTarget.provider, seedUpstream);
|
|
363
|
-
overlaidProviderId = seedTarget.provider;
|
|
364
|
-
twinTarget = { provider: seedTarget.provider, model: seedTarget.model };
|
|
365
|
-
} catch {
|
|
366
|
-
// Nessun seed: il twin arriverà da session_start se possibile.
|
|
407
|
+
if (seedRefs.length > 0) {
|
|
408
|
+
// Raggruppa per provider: un overlay per provider con tutti i suoi twin.
|
|
409
|
+
const byProvider = new Map<string, string[]>();
|
|
410
|
+
for (const ref of seedRefs) {
|
|
411
|
+
const list = byProvider.get(ref.provider) ?? [];
|
|
412
|
+
if (!list.includes(ref.model)) list.push(ref.model);
|
|
413
|
+
byProvider.set(ref.provider, list);
|
|
414
|
+
}
|
|
415
|
+
for (const [providerId, twinIds] of byProvider) {
|
|
416
|
+
try {
|
|
417
|
+
// providers/all è mappato dagli alias di pi; il subpath del singolo
|
|
418
|
+
// provider non lo è e fallirebbe a load (solo installazioni npm).
|
|
419
|
+
const base = builtinProviders().find((p) => p.id === providerId);
|
|
420
|
+
if (!base) throw new Error(`provider ${providerId} not in builtins`);
|
|
421
|
+
// Il catalogo builtin può non avere i modelli salvati: provider
|
|
422
|
+
// dinamici (es. openai-codex) li tengono nel models-store, altri
|
|
423
|
+
// (deepseek-flash su versioni vecchie) in una definizione statica.
|
|
424
|
+
const extra = [
|
|
425
|
+
...(providerId === "deepseek" ? [STATIC_DEEPSEEK_FLASH] : []),
|
|
426
|
+
...readStoreModels(providerId),
|
|
427
|
+
];
|
|
428
|
+
const seedUpstream: Provider = {
|
|
429
|
+
...base,
|
|
430
|
+
getModels: () => mergeModelsById(base.getModels(), extra),
|
|
431
|
+
};
|
|
432
|
+
const known = new Set(seedUpstream.getModels().map((m) => m.id));
|
|
433
|
+
const usable = twinIds.filter((model) => known.has(model));
|
|
434
|
+
if (usable.length === 0) continue;
|
|
435
|
+
registerOverlay(providerId, seedUpstream, usable);
|
|
436
|
+
} catch {
|
|
437
|
+
// Provider non gemellabile all'avvio: session_start riproverà dal registry.
|
|
438
|
+
}
|
|
367
439
|
}
|
|
368
440
|
}
|
|
369
441
|
|
|
@@ -379,7 +451,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
379
451
|
if ("error" in found) {
|
|
380
452
|
if (ctx.hasUI) ctx.ui.notify(`JEV_AUTO_UPSTREAM fallito: ${found.error}`, "warning");
|
|
381
453
|
} else {
|
|
382
|
-
|
|
454
|
+
applyTwins([parsed], ctx.modelRegistry, false);
|
|
383
455
|
}
|
|
384
456
|
}
|
|
385
457
|
} catch {
|
|
@@ -389,7 +461,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
389
461
|
// Config automatic senza guarded = contraddittorio: ripristina + seleziona.
|
|
390
462
|
try {
|
|
391
463
|
if (config.mode !== "automatic" || !config.automatic.requireGuardedModel) return;
|
|
392
|
-
if (
|
|
464
|
+
if (overlays.size === 0) {
|
|
393
465
|
const restored = refreshUpstream(ctx.modelRegistry);
|
|
394
466
|
if (!restored.ok) {
|
|
395
467
|
if (ctx.hasUI) {
|
|
@@ -398,17 +470,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
398
470
|
return;
|
|
399
471
|
}
|
|
400
472
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
473
|
+
const primary = primaryTwin();
|
|
474
|
+
if (!primary) return;
|
|
475
|
+
const twinId = twinIdFor(primary.model);
|
|
476
|
+
if (ctx.model?.provider === primary.provider && ctx.model?.id === twinId) return;
|
|
477
|
+
const twin = ctx.modelRegistry.find(primary.provider, twinId);
|
|
405
478
|
if (!twin) return;
|
|
406
479
|
const switched = await pi.setModel(twin);
|
|
407
480
|
if (ctx.hasUI) {
|
|
408
481
|
ctx.ui.notify(
|
|
409
482
|
switched
|
|
410
|
-
? `Jev automatic: selezionato ${
|
|
411
|
-
: `Jev automatic: auth mancante per ${
|
|
483
|
+
? `Jev automatic: selezionato ${primary.provider}/${twinId}.`
|
|
484
|
+
: `Jev automatic: auth mancante per ${primary.provider} — /login ${primary.provider}.`,
|
|
412
485
|
switched ? "info" : "warning",
|
|
413
486
|
);
|
|
414
487
|
}
|
|
@@ -422,13 +495,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
422
495
|
pi.on("model_select", async (event, ctx) => {
|
|
423
496
|
try {
|
|
424
497
|
if (config.mode !== "automatic" || !config.automatic.requireGuardedModel) return;
|
|
425
|
-
if (
|
|
498
|
+
if (overlays.size === 0) return;
|
|
426
499
|
if (isGuardedModel(event.model.provider, event.model.id)) return;
|
|
427
500
|
|
|
428
|
-
const
|
|
429
|
-
const twin = ctx.modelRegistry.find(overlaidProviderId, twinId);
|
|
501
|
+
const primary = primaryTwin();
|
|
430
502
|
const fallback =
|
|
431
|
-
|
|
503
|
+
(primary ? ctx.modelRegistry.find(primary.provider, twinIdFor(primary.model)) : undefined) ??
|
|
432
504
|
(event.previousModel && isGuardedModel(event.previousModel.provider, event.previousModel.id)
|
|
433
505
|
? event.previousModel
|
|
434
506
|
: undefined);
|
|
@@ -668,9 +740,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
668
740
|
|
|
669
741
|
const msg = event.message as { provider?: string; model?: string };
|
|
670
742
|
if (isGuardedModel(msg.provider, msg.model)) {
|
|
743
|
+
const primary = primaryTwin();
|
|
671
744
|
pi.appendEntry("jev-guarded", {
|
|
672
|
-
twin:
|
|
673
|
-
upstream:
|
|
745
|
+
twin: msg.model ? `${msg.provider}/${msg.model}` : undefined,
|
|
746
|
+
upstream: primary ? `${primary.provider}/${primary.model}` : undefined,
|
|
747
|
+
active: activeTwinRefs().map((r) => `${r.provider}/${r.model}`),
|
|
674
748
|
});
|
|
675
749
|
return;
|
|
676
750
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-jev-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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": [
|
|
@@ -46,5 +46,11 @@
|
|
|
46
46
|
"typebox": "1.3.34",
|
|
47
47
|
"typescript": "5.9.3",
|
|
48
48
|
"vitest": "5.0.1"
|
|
49
|
+
},
|
|
50
|
+
"allowScripts": {
|
|
51
|
+
"@google/genai@1.52.0": true,
|
|
52
|
+
"@openrouter/sdk@1.3.1": true,
|
|
53
|
+
"protobufjs@7.6.6": true,
|
|
54
|
+
"esbuild@0.28.2": true
|
|
49
55
|
}
|
|
50
56
|
}
|
package/src/automatic/overlay.ts
CHANGED
|
@@ -100,6 +100,21 @@ export function twinModelFor(
|
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/** Unione di liste modelli con dedup per id (il primo vince). */
|
|
104
|
+
export function mergeModelsById(
|
|
105
|
+
primary: readonly Model<Api>[],
|
|
106
|
+
extra: readonly Model<Api>[],
|
|
107
|
+
): Model<Api>[] {
|
|
108
|
+
const out: Model<Api>[] = [];
|
|
109
|
+
const seen = new Set<string>();
|
|
110
|
+
for (const model of [...primary, ...extra]) {
|
|
111
|
+
if (!model || typeof model.id !== "string" || seen.has(model.id)) continue;
|
|
112
|
+
seen.add(model.id);
|
|
113
|
+
out.push(model);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
103
118
|
function stripTwins(models: readonly Model<Api>[]): Model<Api>[] {
|
|
104
119
|
return models.filter((m) => !isTwinId(m.id)).map((m) => ({ ...m }));
|
|
105
120
|
}
|
|
@@ -114,13 +129,17 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
|
|
|
114
129
|
let twinToOriginal = new Map<string, Model<Api>>();
|
|
115
130
|
let currentModels: Model<Api>[] = [];
|
|
116
131
|
|
|
117
|
-
function rebuildTwins(
|
|
132
|
+
function rebuildTwins(rawOriginals: readonly Model<Api>[]): Model<Api>[] {
|
|
118
133
|
twinToOriginal = new Map();
|
|
119
134
|
const chosen = new Set(deps.twinModelIds);
|
|
135
|
+
// Dedup per id: sorgenti miste (builtin + store + statica) possono
|
|
136
|
+
// ripetere lo stesso modello e produrrebbero twin duplicati.
|
|
137
|
+
const originals = mergeModelsById(rawOriginals, []);
|
|
120
138
|
const twins: Model<Api>[] = [];
|
|
121
139
|
for (const original of originals) {
|
|
122
140
|
if (chosen.has(original.id)) {
|
|
123
141
|
const twin = twinModelFor(original, upstream.id);
|
|
142
|
+
if (twinToOriginal.has(twin.id)) continue;
|
|
124
143
|
twinToOriginal.set(twin.id, original);
|
|
125
144
|
twins.push(twin);
|
|
126
145
|
}
|
package/src/commands.ts
CHANGED
|
@@ -2,8 +2,15 @@ import { readFileSync, existsSync, statSync, realpathSync, writeFileSync, mkdirS
|
|
|
2
2
|
import { resolve, dirname } from "node:path";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import type { ExtensionAPI, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import type { JevConfig } from "./config.ts";
|
|
6
|
-
import {
|
|
5
|
+
import type { JevConfig, TwinRef } from "./config.ts";
|
|
6
|
+
import {
|
|
7
|
+
defaultConfigPath,
|
|
8
|
+
expandHome,
|
|
9
|
+
parseUpstreamRef,
|
|
10
|
+
resolveBackend,
|
|
11
|
+
saveConfig,
|
|
12
|
+
twinRefs,
|
|
13
|
+
} from "./config.ts";
|
|
7
14
|
import type { ReviewFn } from "./reviewer.ts";
|
|
8
15
|
import { reviewWithCoverage } from "./reviewer.ts";
|
|
9
16
|
import type { Metrics } from "./metrics.ts";
|
|
@@ -17,6 +24,15 @@ export interface GateStatusView {
|
|
|
17
24
|
reason: string;
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
export interface TwinStatusView {
|
|
28
|
+
/** Twin attivi adesso (overlay registrati). */
|
|
29
|
+
active: TwinRef[];
|
|
30
|
+
/** Twin salvati in config (ripristinati all'avvio/refresh). */
|
|
31
|
+
saved: TwinRef[];
|
|
32
|
+
/** Twin primario: auto-selezione e revert. */
|
|
33
|
+
primary?: TwinRef;
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
export interface UpstreamResult {
|
|
21
37
|
ok: boolean;
|
|
22
38
|
error?: string;
|
|
@@ -37,7 +53,13 @@ export interface JevCommandDeps {
|
|
|
37
53
|
modelId: string,
|
|
38
54
|
) => UpstreamResult;
|
|
39
55
|
refreshUpstream: (registry: ModelRegistry) => UpstreamResult;
|
|
40
|
-
removeGuarded: () => { ok: boolean; error?: string };
|
|
56
|
+
removeGuarded: () => { ok: boolean; error?: string; removed?: number };
|
|
57
|
+
removeTwin: (
|
|
58
|
+
registry: ModelRegistry,
|
|
59
|
+
providerId: string,
|
|
60
|
+
modelId: string,
|
|
61
|
+
) => { ok: boolean; error?: string };
|
|
62
|
+
twinStatus: () => TwinStatusView;
|
|
41
63
|
switchToGuarded: (registry: ModelRegistry) => Promise<{
|
|
42
64
|
ok: boolean;
|
|
43
65
|
error?: string;
|
|
@@ -52,6 +74,8 @@ export const JEV_SUBCOMMANDS = [
|
|
|
52
74
|
"mode",
|
|
53
75
|
"models",
|
|
54
76
|
"upstream",
|
|
77
|
+
"twins",
|
|
78
|
+
"untwin",
|
|
55
79
|
"off",
|
|
56
80
|
"refresh-upstream",
|
|
57
81
|
"check",
|
|
@@ -70,10 +94,12 @@ export const JEV_HELP_TEXT = [
|
|
|
70
94
|
" /jev mode on-demand Solo verifiche esplicite (default, zero costi impliciti)",
|
|
71
95
|
" /jev mode automatic Enforcement: seleziona il twin guarded + verifica",
|
|
72
96
|
" /jev models [filtro] Modelli pi (● twin guarded attivo, ★ ultimo salvato)",
|
|
73
|
-
" /jev upstream P M
|
|
97
|
+
" /jev upstream P M Aggiunge il twin M__jev nel provider P (salvato)",
|
|
74
98
|
" /jev upstream P/M Stessa cosa in forma compatta",
|
|
75
|
-
" /jev upstream
|
|
76
|
-
" /jev
|
|
99
|
+
" /jev upstream Ripristina tutti i twin salvati",
|
|
100
|
+
" /jev twins Elenca twin attivi e salvati (● attivo, ★ primario)",
|
|
101
|
+
" /jev untwin P M Rimuove un twin (anche P/M)",
|
|
102
|
+
" /jev off Rimuove tutti i twin (torna on-demand)",
|
|
77
103
|
" /jev refresh-upstream Ricrea il twin dal registry (dopo refresh cataloghi)",
|
|
78
104
|
" /jev check <file> [req] Verifica un file subito, senza cambiare modalità",
|
|
79
105
|
" /jev stats Conteggi pass/block/review/unavailable + latenze",
|
|
@@ -114,7 +140,8 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
114
140
|
const resolved = resolveBackend(cfg);
|
|
115
141
|
const info = deps.reviewerInfo();
|
|
116
142
|
const gate = deps.gateStatus();
|
|
117
|
-
const
|
|
143
|
+
const twinView = deps.twinStatus();
|
|
144
|
+
const authProvider = twinView.primary?.provider ?? cfg.automatic.upstreamProvider;
|
|
118
145
|
let guardedAuth = "—";
|
|
119
146
|
if (gate.active) {
|
|
120
147
|
try {
|
|
@@ -165,7 +192,8 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
165
192
|
`Verificatore timeout: ${resolved.timeoutMs}ms`,
|
|
166
193
|
`Policy: ${cfg.policy.revision} pass<=${cfg.policy.passMaxFlawProbability} block>=${cfg.policy.blockMinFlawProbability}`,
|
|
167
194
|
`Chiave presente: ${resolved.apiKeyPresent ? "sì" : "no"} (${resolved.keySource})`,
|
|
168
|
-
`Gate: ${gate.active ? `attivo (twin ${gate.twin}
|
|
195
|
+
`Gate: ${gate.active ? `attivo (twin ${gate.twin})` : `non attivo (${gate.reason})`}`,
|
|
196
|
+
`Twin attivi: ${twinView.active.length > 0 ? twinView.active.map((r) => `${r.provider}/${r.model}__jev`).join(", ") : "nessuno"}`,
|
|
169
197
|
`Twin nel picker: ${twinScope}`,
|
|
170
198
|
`Modello corrente: ${currentModel}`,
|
|
171
199
|
`Ultimo upstream salvato: ${cfg.automatic.upstreamProvider}/${cfg.automatic.upstreamModel}`,
|
|
@@ -242,12 +270,13 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
242
270
|
}
|
|
243
271
|
|
|
244
272
|
if (sub === "upstream") {
|
|
245
|
-
// Senza argomenti:
|
|
273
|
+
// Senza argomenti: ripristina tutti i twin salvati.
|
|
246
274
|
if (parts.length === 1) {
|
|
247
275
|
const res = deps.refreshUpstream(ctx.modelRegistry);
|
|
276
|
+
const count = deps.twinStatus().active.length;
|
|
248
277
|
ctx.ui.notify(
|
|
249
278
|
res.ok
|
|
250
|
-
? `Twin
|
|
279
|
+
? `Twin ripristinati: ${count} (primario ${res.twin ?? "—"}). Seleziona con /model.`
|
|
251
280
|
: `Ripristino fallito: ${res.error}`,
|
|
252
281
|
res.ok ? "info" : "error",
|
|
253
282
|
);
|
|
@@ -260,17 +289,70 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
260
289
|
}
|
|
261
290
|
const res = deps.setUpstream(ctx.modelRegistry, parsed.provider, parsed.model);
|
|
262
291
|
if (!res.ok) {
|
|
263
|
-
ctx.ui.notify(`
|
|
292
|
+
ctx.ui.notify(`Twin non aggiunto: ${res.error}`, "error");
|
|
264
293
|
return;
|
|
265
294
|
}
|
|
295
|
+
const total = deps.twinStatus().active.length;
|
|
266
296
|
ctx.ui.notify(
|
|
267
|
-
`Twin
|
|
297
|
+
`Twin aggiunto: ${res.twin} (totale attivi: ${total}, stessa auth del provider). ` +
|
|
268
298
|
`Selezionalo con /model per enforcement in automatic.`,
|
|
269
299
|
"info",
|
|
270
300
|
);
|
|
271
301
|
return;
|
|
272
302
|
}
|
|
273
303
|
|
|
304
|
+
if (sub === "twins") {
|
|
305
|
+
const view = deps.twinStatus();
|
|
306
|
+
if (view.active.length === 0 && view.saved.length === 0) {
|
|
307
|
+
ctx.ui.notify(
|
|
308
|
+
"Jev: nessun twin — aggiungine con /jev upstream <provider> <model>.",
|
|
309
|
+
"info",
|
|
310
|
+
);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const primaryKey = view.primary
|
|
314
|
+
? `${view.primary.provider}/${view.primary.model}`
|
|
315
|
+
: undefined;
|
|
316
|
+
const activeKeys = new Set(view.active.map((r) => `${r.provider}/${r.model}`));
|
|
317
|
+
const lines: string[] = [];
|
|
318
|
+
for (const ref of view.saved) {
|
|
319
|
+
const key = `${ref.provider}/${ref.model}`;
|
|
320
|
+
const mark = activeKeys.has(key) ? "● " : "○ ";
|
|
321
|
+
const star = key === primaryKey ? "★" : " ";
|
|
322
|
+
lines.push(`${mark}${star} ${key}${activeKeys.has(key) ? "" : " (non attivo)"}`);
|
|
323
|
+
}
|
|
324
|
+
ctx.ui.notify(
|
|
325
|
+
[
|
|
326
|
+
`Twin attivi: ${view.active.length} · salvati: ${view.saved.length}`,
|
|
327
|
+
"● registrato ★ primario (auto-selezione/revert)",
|
|
328
|
+
"",
|
|
329
|
+
...lines,
|
|
330
|
+
].join("\n"),
|
|
331
|
+
"info",
|
|
332
|
+
);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (sub === "untwin") {
|
|
337
|
+
const parsed = parseUpstreamRef(parts.slice(1));
|
|
338
|
+
if ("error" in parsed) {
|
|
339
|
+
ctx.ui.notify(
|
|
340
|
+
`${parsed.error.replace("/jev upstream", "/jev untwin")}`,
|
|
341
|
+
"warning",
|
|
342
|
+
);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const res = deps.removeTwin(ctx.modelRegistry, parsed.provider, parsed.model);
|
|
346
|
+
const total = deps.twinStatus().active.length;
|
|
347
|
+
ctx.ui.notify(
|
|
348
|
+
res.ok
|
|
349
|
+
? `Twin rimosso: ${parsed.provider}/${parsed.model} (attivi: ${total}).`
|
|
350
|
+
: `Niente da rimuovere: ${res.error}`,
|
|
351
|
+
res.ok ? "info" : "warning",
|
|
352
|
+
);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
274
356
|
if (sub === "off") {
|
|
275
357
|
const res = deps.removeGuarded();
|
|
276
358
|
if (res.ok) {
|
|
@@ -279,7 +361,7 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
279
361
|
}
|
|
280
362
|
ctx.ui.notify(
|
|
281
363
|
res.ok
|
|
282
|
-
?
|
|
364
|
+
? `Twin rimossi: ${res.removed ?? 0} (provider originali ripristinati), modalità on-demand.`
|
|
283
365
|
: `Niente da rimuovere: ${res.error}`,
|
|
284
366
|
res.ok ? "info" : "warning",
|
|
285
367
|
);
|
package/src/config.ts
CHANGED
|
@@ -32,6 +32,9 @@ export interface JevConfig {
|
|
|
32
32
|
transport: "provider-gate";
|
|
33
33
|
maxRegenerations: number;
|
|
34
34
|
requireGuardedModel: boolean;
|
|
35
|
+
/** Twin salvati: più modelli guarded insieme (uno o più per provider). */
|
|
36
|
+
twins: TwinRef[];
|
|
37
|
+
/** Ultimo twin aggiunto: primario per auto-selezione e revert. */
|
|
35
38
|
upstreamProvider: string;
|
|
36
39
|
upstreamModel: string;
|
|
37
40
|
};
|
|
@@ -89,6 +92,11 @@ export interface JevConfig {
|
|
|
89
92
|
};
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
export interface TwinRef {
|
|
96
|
+
provider: string;
|
|
97
|
+
model: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
92
100
|
export const DEFAULT_CONFIG: JevConfig = {
|
|
93
101
|
version: 1,
|
|
94
102
|
mode: "on-demand",
|
|
@@ -114,8 +122,11 @@ export const DEFAULT_CONFIG: JevConfig = {
|
|
|
114
122
|
transport: "provider-gate",
|
|
115
123
|
maxRegenerations: 2,
|
|
116
124
|
requireGuardedModel: true,
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
twins: [],
|
|
126
|
+
// Vuoti di default: nessun twin "salvato" finché l'utente non ne aggiunge
|
|
127
|
+
// uno con /jev upstream. Il seed usa questa lista (o l'env).
|
|
128
|
+
upstreamProvider: "",
|
|
129
|
+
upstreamModel: "",
|
|
119
130
|
},
|
|
120
131
|
limits: {
|
|
121
132
|
maxPayloadBytes: 120000,
|
|
@@ -184,11 +195,15 @@ export function loadConfig(configPath?: string): JevConfig {
|
|
|
184
195
|
}
|
|
185
196
|
}
|
|
186
197
|
|
|
187
|
-
const
|
|
198
|
+
const loaded = mergeDeep(
|
|
188
199
|
JSON.parse(JSON.stringify(DEFAULT_CONFIG)),
|
|
189
200
|
fileConfig,
|
|
190
201
|
) as JevConfig;
|
|
191
202
|
|
|
203
|
+
// Normalizza la lista twin (config scritta a mano o da versioni vecchie):
|
|
204
|
+
// dedup, scarta voci malformate, garantisce che il primario sia incluso.
|
|
205
|
+
let merged = withTwinRefs(loaded, twinRefs(loaded));
|
|
206
|
+
|
|
192
207
|
const envMode = process.env.JEV_MODE;
|
|
193
208
|
if (envMode === "on-demand" || envMode === "automatic") {
|
|
194
209
|
merged.mode = envMode;
|
|
@@ -352,6 +367,56 @@ export function configSnapshot(config: JevConfig): JevConfig {
|
|
|
352
367
|
return JSON.parse(JSON.stringify(config)) as JevConfig;
|
|
353
368
|
}
|
|
354
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Twin salvati: la lista `automatic.twins` se popolata, altrimenti il singolo
|
|
372
|
+
* upstream primario (migrazione dalle versioni precedenti). Semplifica e
|
|
373
|
+
* scarta voci malformate: la config è modificabile a mano.
|
|
374
|
+
*/
|
|
375
|
+
export function twinRefs(config: JevConfig): TwinRef[] {
|
|
376
|
+
const raw = Array.isArray(config.automatic.twins) ? config.automatic.twins : [];
|
|
377
|
+
const cleaned: TwinRef[] = [];
|
|
378
|
+
const seen = new Set<string>();
|
|
379
|
+
for (const entry of raw) {
|
|
380
|
+
if (!entry || typeof entry.provider !== "string" || typeof entry.model !== "string") continue;
|
|
381
|
+
const provider = entry.provider.trim();
|
|
382
|
+
const model = entry.model.trim();
|
|
383
|
+
if (!provider || !model) continue;
|
|
384
|
+
const key = `${provider}\0${model}`;
|
|
385
|
+
if (seen.has(key)) continue;
|
|
386
|
+
seen.add(key);
|
|
387
|
+
cleaned.push({ provider, model });
|
|
388
|
+
}
|
|
389
|
+
if (cleaned.length > 0) return cleaned;
|
|
390
|
+
const provider = config.automatic.upstreamProvider?.trim();
|
|
391
|
+
const model = config.automatic.upstreamModel?.trim();
|
|
392
|
+
return provider && model ? [{ provider, model }] : [];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Applica una lista di twin alla config: la lista è la fonte di verità.
|
|
397
|
+
* Il campo primario (auto-selezione/revert) resta se già presente nella
|
|
398
|
+
* lista, altrimenti diventa l'ultimo aggiunto.
|
|
399
|
+
*/
|
|
400
|
+
export function withTwinRefs(config: JevConfig, refs: TwinRef[]): JevConfig {
|
|
401
|
+
const primary = refs.find(
|
|
402
|
+
(r) =>
|
|
403
|
+
r.provider === config.automatic.upstreamProvider &&
|
|
404
|
+
r.model === config.automatic.upstreamModel,
|
|
405
|
+
) ?? refs[refs.length - 1];
|
|
406
|
+
return {
|
|
407
|
+
...config,
|
|
408
|
+
automatic: {
|
|
409
|
+
...config.automatic,
|
|
410
|
+
twins: refs.map((r) => ({ ...r })),
|
|
411
|
+
...(primary ? { upstreamProvider: primary.provider, upstreamModel: primary.model } : {}),
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function twinKey(ref: TwinRef): string {
|
|
417
|
+
return `${ref.provider}/${ref.model}`;
|
|
418
|
+
}
|
|
419
|
+
|
|
355
420
|
export function saveConfig(config: JevConfig, configPath?: string): string {
|
|
356
421
|
const path = configPath ?? defaultConfigPath();
|
|
357
422
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lettura read-only dei modelli dinamici dal models-store di pi.
|
|
3
|
+
*
|
|
4
|
+
* Alcuni provider (es. openai-codex) non hanno modelli nel catalogo statico:
|
|
5
|
+
* la lista arriva da `<agentDir>/models-store.json` e viene popolata al
|
|
6
|
+
* refresh con le credenziali. Senza questo, il seed all'avvio non troverebbe
|
|
7
|
+
* il modello e il twin non esisterebbe al momento del ripristino sessione.
|
|
8
|
+
*
|
|
9
|
+
* Solo lettura, best-effort: file assente/corrotto → nessun modello.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
15
|
+
|
|
16
|
+
export function modelsStorePath(agentDir?: string): string {
|
|
17
|
+
return join(agentDir ?? getAgentDir(), "models-store.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isUsableModel(value: unknown, providerId: string): value is Model<Api> {
|
|
21
|
+
if (typeof value !== "object" || value === null) return false;
|
|
22
|
+
const m = value as Record<string, unknown>;
|
|
23
|
+
return (
|
|
24
|
+
typeof m.id === "string" &&
|
|
25
|
+
m.id.length > 0 &&
|
|
26
|
+
typeof m.api === "string" &&
|
|
27
|
+
m.api.length > 0 &&
|
|
28
|
+
(m.provider === undefined || m.provider === providerId)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Modelli salvati nello store per un provider, normalizzati sull'id richiesto. */
|
|
33
|
+
export function readStoreModels(providerId: string, agentDir?: string): Model<Api>[] {
|
|
34
|
+
try {
|
|
35
|
+
const raw = JSON.parse(readFileSync(modelsStorePath(agentDir), "utf8")) as Record<
|
|
36
|
+
string,
|
|
37
|
+
{ models?: unknown }
|
|
38
|
+
>;
|
|
39
|
+
const entry = raw?.[providerId];
|
|
40
|
+
const models = Array.isArray(entry?.models) ? entry.models : [];
|
|
41
|
+
return models
|
|
42
|
+
.filter((m) => isUsableModel(m, providerId))
|
|
43
|
+
.map((m) => ({ ...(m as Model<Api>), provider: providerId }));
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|