pi-jev-guard 0.1.6 → 0.2.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 +41 -15
- package/config/jev-config.example.json +27 -4
- package/extensions/index.ts +277 -173
- package/package.json +1 -1
- package/src/automatic/overlay.ts +46 -5
- package/src/commands.ts +139 -25
- package/src/config.ts +69 -3
package/README.md
CHANGED
|
@@ -26,7 +26,15 @@ export TYPESAFE_API_KEY="..." # backend typesafe diretto
|
|
|
26
26
|
|
|
27
27
|
## Uso normale (invariato)
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Le nuove sessioni partono in `on-demand`: i modelli normali restano selezionabili e nessuna verifica parte da sola.
|
|
30
|
+
`/jev mode automatic|on-demand` salva la scelta **nella sessione**, non nella config globale.
|
|
31
|
+
La scelta sopravvive a `/reload`, riavvio e `/resume`; fork e `/tree` seguono lo stato del ramo.
|
|
32
|
+
Credenziali, policy e catalogo dei twin restano condivisi.
|
|
33
|
+
|
|
34
|
+
Migrazione: il vecchio `mode` in `jev-config.json` non controlla più l'estensione
|
|
35
|
+
(resta compatibile con i consumer standalone). Nelle sessioni senza uno stato salvato,
|
|
36
|
+
riattiva `automatic` una volta se desiderato. `JEV_MODE=automatic` può impostare il valore
|
|
37
|
+
iniziale in headless; una scelta già salvata nella sessione ha precedenza.
|
|
30
38
|
|
|
31
39
|
```text
|
|
32
40
|
/jev check ./src/example.ts # verifica singola di un file
|
|
@@ -39,28 +47,46 @@ In chat, l'LLM può chiamare `jev_validate` per controlli mirati.
|
|
|
39
47
|
## Uso protetto
|
|
40
48
|
|
|
41
49
|
```text
|
|
42
|
-
/jev upstream deepseek deepseek-flash
|
|
43
|
-
/jev
|
|
50
|
+
/jev upstream deepseek deepseek-flash # crea deepseek/deepseek-flash__jev
|
|
51
|
+
/jev upstream openai-codex gpt-5.6-terra # aggiunge openai-codex/gpt-5.6-terra__jev
|
|
52
|
+
/jev twins # elenca attivi e salvati
|
|
53
|
+
/jev untwin openai-codex gpt-5.6-terra # rimuove uno solo
|
|
54
|
+
/jev mode automatic # seleziona il primario + attiva enforcement
|
|
44
55
|
```
|
|
45
56
|
|
|
46
|
-
|
|
57
|
+
**Più twin insieme**: ogni `/jev upstream` *aggiunge* un modello guarded, senza
|
|
58
|
+
rimuovere i precedenti. I twin vengono registrati anche all'avvio (i modelli
|
|
59
|
+
dinamici come Codex sono letti dal models-store), quando gli originali sono disponibili.
|
|
60
|
+
Il **primario** salvato è il target di auto-selezione e revert; un twin già selezionato
|
|
61
|
+
nella sessione viene mantenuto al reload, anche se non è il primario.
|
|
62
|
+
|
|
63
|
+
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.
|
|
47
64
|
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
I twin salvati vengono registrati prima del ripristino del modello anche in
|
|
66
|
+
`on-demand`: registrazione e attivazione del gate sono indipendenti. Il suffisso
|
|
67
|
+
`__jev` è locale e non viene inviato al provider; anche la cronologia viene
|
|
68
|
+
normalizzata sull'identità originale senza perdere firme o metadati di continuazione.
|
|
50
69
|
|
|
51
|
-
Torna normale con `/
|
|
70
|
+
Torna normale con `/jev mode on-demand`, che seleziona anche il modello originale.
|
|
52
71
|
In `automatic`, selezionare un modello non protetto lo fa ritornare
|
|
53
72
|
subito al twin (enforcement a livello selezione).
|
|
54
|
-
`/jev upstream` senza argomenti ripristina
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
73
|
+
`/jev upstream` senza argomenti ripristina tutti i twin salvati, `/jev untwin P M`
|
|
74
|
+
ne rimuove uno solo, `/jev off` li rimuove tutti
|
|
75
|
+
(e salva on-demand nella sessione, selezionando l'originale prima di rimuovere il routing).
|
|
76
|
+
`/jev off` conserva il catalogo salvato: al reload gli alias tornano disponibili,
|
|
77
|
+
ma il gate rimane disattivato.
|
|
78
|
+
|
|
79
|
+
Se filtri i modelli con `enabledModels`, tieni le voci `__jev` dei twin che usi
|
|
80
|
+
o restano nascosti.
|
|
60
81
|
L'installazione punta alla cartella: dopo un aggiornamento del codice basta `/reload`.
|
|
61
82
|
|
|
62
|
-
Headless/CI: `JEV_AUTO_UPSTREAM=provider/model` crea
|
|
63
|
-
|
|
83
|
+
Headless/CI: `JEV_MODE=automatic JEV_AUTO_UPSTREAM=provider/model` crea e attiva
|
|
84
|
+
il guarded nelle sessioni senza uno stato Jev salvato.
|
|
85
|
+
|
|
86
|
+
Un hold `[JEV — OUTPUT NON PUBBLICATO]` interrompe intenzionalmente il turno:
|
|
87
|
+
controlla `/jev last`. Non viene aggirato con un proseguimento automatico.
|
|
88
|
+
Le conferme per comandi di rete sospetti attendono invece una decisione esplicita;
|
|
89
|
+
la normale sequenza tool → risposta successiva continua senza un nuovo prompt.
|
|
64
90
|
|
|
65
91
|
## Come decide
|
|
66
92
|
|
|
@@ -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,7 +1,8 @@
|
|
|
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";
|
|
4
|
+
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
5
|
+
import { registerJevCommand, type UpstreamResult } from "../src/commands.ts";
|
|
5
6
|
import { registerJevAskTool } from "../src/ask.ts";
|
|
6
7
|
import {
|
|
7
8
|
configSnapshot,
|
|
@@ -9,7 +10,10 @@ import {
|
|
|
9
10
|
parseUpstreamRef,
|
|
10
11
|
resolveBackend,
|
|
11
12
|
saveConfig,
|
|
13
|
+
twinRefs,
|
|
14
|
+
withTwinRefs,
|
|
12
15
|
type JevConfig,
|
|
16
|
+
type TwinRef,
|
|
13
17
|
} from "../src/config.ts";
|
|
14
18
|
import { createReviewer, createTypedAsk } from "../src/factory.ts";
|
|
15
19
|
import { createMetrics } from "../src/metrics.ts";
|
|
@@ -18,7 +22,9 @@ import {
|
|
|
18
22
|
STATIC_DEEPSEEK_FLASH,
|
|
19
23
|
buildOverlaidProvider,
|
|
20
24
|
isTwinId,
|
|
25
|
+
mergeModelsById,
|
|
21
26
|
twinIdFor,
|
|
27
|
+
untwinId,
|
|
22
28
|
} from "../src/automatic/overlay.ts";
|
|
23
29
|
import type { GateVerdictReport } from "../src/automatic/guardian.ts";
|
|
24
30
|
import { readStoreModels } from "../src/store-models.ts";
|
|
@@ -57,6 +63,20 @@ export interface GateStatus {
|
|
|
57
63
|
|
|
58
64
|
export default function (pi: ExtensionAPI) {
|
|
59
65
|
let config: JevConfig = loadConfig();
|
|
66
|
+
// Shared config holds credentials/policy/twins, not the session toggle.
|
|
67
|
+
const initialMode = process.env.JEV_MODE === "automatic" ? "automatic" : "on-demand";
|
|
68
|
+
let sessionMode: JevConfig["mode"] = initialMode;
|
|
69
|
+
function getConfig(): JevConfig {
|
|
70
|
+
return { ...config, mode: sessionMode };
|
|
71
|
+
}
|
|
72
|
+
function restoreMode(ctx: ExtensionContext): void {
|
|
73
|
+
sessionMode = initialMode;
|
|
74
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
75
|
+
if (entry.type !== "custom" || entry.customType !== "jev-mode") continue;
|
|
76
|
+
const mode = (entry.data as { mode?: unknown } | undefined)?.mode;
|
|
77
|
+
if (mode === "automatic" || mode === "on-demand") sessionMode = mode;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
60
80
|
// Full (3 regole) per tool/comandi/diagnostica; gate (difetti concreti) per provider.
|
|
61
81
|
let handle = createReviewer(configSnapshot(config));
|
|
62
82
|
let gateHandle = createReviewer(configSnapshot(config), { rules: DEFAULT_GATE_RULES });
|
|
@@ -73,24 +93,55 @@ export default function (pi: ExtensionAPI) {
|
|
|
73
93
|
let lastExfilErrorAt = 0;
|
|
74
94
|
const metrics = createMetrics();
|
|
75
95
|
|
|
76
|
-
//
|
|
77
|
-
// (o
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
96
|
+
// Twin attivi, uno o più per provider. Nessun overlay finché la config non
|
|
97
|
+
// li attiva (automatic) o l'utente non usa /jev upstream (opt-in).
|
|
98
|
+
interface OverlayState {
|
|
99
|
+
upstream: Provider;
|
|
100
|
+
twinIds: string[];
|
|
101
|
+
}
|
|
102
|
+
const overlays = new Map<string, OverlayState>();
|
|
81
103
|
|
|
82
|
-
const getConfig = () => config;
|
|
83
104
|
const getReview = () => handle.review;
|
|
84
105
|
const getGateReview = () => gateHandle.review;
|
|
85
106
|
const getInfo = () => ({ backend: handle.backend, model: handle.model });
|
|
86
107
|
|
|
108
|
+
/** Twin primario (auto-selezione e revert): ultimo aggiunto che è attivo. */
|
|
109
|
+
function primaryTwin(): TwinRef | undefined {
|
|
110
|
+
const saved = twinRefs(config);
|
|
111
|
+
const wanted =
|
|
112
|
+
saved.find(
|
|
113
|
+
(r) =>
|
|
114
|
+
r.provider === config.automatic.upstreamProvider &&
|
|
115
|
+
r.model === config.automatic.upstreamModel,
|
|
116
|
+
) ?? saved[saved.length - 1];
|
|
117
|
+
if (wanted && overlays.get(wanted.provider)?.twinIds.includes(wanted.model)) {
|
|
118
|
+
return wanted;
|
|
119
|
+
}
|
|
120
|
+
// Ripiego: primo twin attivo.
|
|
121
|
+
for (const [providerId, state] of overlays) {
|
|
122
|
+
const model = state.twinIds[0];
|
|
123
|
+
if (model) return { provider: providerId, model };
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function activeTwinRefs(): TwinRef[] {
|
|
129
|
+
const refs: TwinRef[] = [];
|
|
130
|
+
for (const [providerId, state] of overlays) {
|
|
131
|
+
for (const model of state.twinIds) refs.push({ provider: providerId, model });
|
|
132
|
+
}
|
|
133
|
+
return refs;
|
|
134
|
+
}
|
|
135
|
+
|
|
87
136
|
function getGateStatus(): GateStatus {
|
|
88
|
-
|
|
137
|
+
const primary = primaryTwin();
|
|
138
|
+
if (primary) {
|
|
139
|
+
const count = activeTwinRefs().length;
|
|
89
140
|
return {
|
|
90
141
|
active: true,
|
|
91
|
-
twin: `${
|
|
92
|
-
upstream: `${
|
|
93
|
-
reason:
|
|
142
|
+
twin: `${primary.provider}/${twinIdFor(primary.model)}`,
|
|
143
|
+
upstream: `${primary.provider}/${primary.model}`,
|
|
144
|
+
reason: `${count} twin attiv${count === 1 ? "o" : "i"} su ${overlays.size} provider (stessa auth)`,
|
|
94
145
|
};
|
|
95
146
|
}
|
|
96
147
|
return {
|
|
@@ -99,13 +150,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
99
150
|
};
|
|
100
151
|
}
|
|
101
152
|
|
|
153
|
+
/** True se la coppia provider/modello è una twin da noi registrata. */
|
|
102
154
|
function isGuardedModel(provider: string | undefined, modelId: string | undefined): boolean {
|
|
103
|
-
if (!
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
isTwinId(modelId)
|
|
108
|
-
);
|
|
155
|
+
if (!provider || !modelId || !isTwinId(modelId)) return false;
|
|
156
|
+
const state = overlays.get(provider);
|
|
157
|
+
if (!state) return false;
|
|
158
|
+
return state.twinIds.some((model) => twinIdFor(model) === modelId);
|
|
109
159
|
}
|
|
110
160
|
|
|
111
161
|
function refresh() {
|
|
@@ -115,18 +165,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
115
165
|
typedHandle = createTypedAsk(configSnapshot(config));
|
|
116
166
|
outputCache = createVerdictCache<OutputVerdict>({ ttlSeconds: config.outputJudge.cacheSeconds });
|
|
117
167
|
exfilCache = createVerdictCache<ExfilVerdict>({ ttlSeconds: config.exfilCheck.cacheSeconds });
|
|
118
|
-
return
|
|
168
|
+
return getConfig();
|
|
119
169
|
}
|
|
120
170
|
|
|
121
|
-
/** Provider upstream vero
|
|
171
|
+
/** Provider upstream vero: il nostro upstream catturato, o il live dal registry. */
|
|
122
172
|
function liveUpstreamProvider(
|
|
123
173
|
registry: ModelRegistry,
|
|
124
174
|
providerId: string,
|
|
125
175
|
): Provider | undefined {
|
|
126
|
-
|
|
127
|
-
return upstreamRefs.get(providerId);
|
|
128
|
-
}
|
|
129
|
-
return registry.getProvider(providerId);
|
|
176
|
+
return overlays.get(providerId)?.upstream ?? registry.getProvider(providerId);
|
|
130
177
|
}
|
|
131
178
|
|
|
132
179
|
function snapshotUpstream(
|
|
@@ -147,56 +194,78 @@ export default function (pi: ExtensionAPI) {
|
|
|
147
194
|
return { provider, model: model as Model<Api> };
|
|
148
195
|
}
|
|
149
196
|
|
|
150
|
-
|
|
151
|
-
|
|
197
|
+
/** Registra (o aggiorna) l'overlay di un provider con i suoi twin. */
|
|
198
|
+
function registerOverlay(
|
|
199
|
+
providerId: string,
|
|
200
|
+
upstream: Provider,
|
|
201
|
+
twinIds: string[],
|
|
202
|
+
): void {
|
|
203
|
+
const overlay = buildOverlaidProvider({
|
|
204
|
+
upstream,
|
|
205
|
+
twinModelIds: twinIds,
|
|
206
|
+
getConfig,
|
|
207
|
+
getReview: getGateReview,
|
|
208
|
+
onVerdict: recordGateVerdict,
|
|
209
|
+
});
|
|
210
|
+
pi.registerProvider(overlay);
|
|
211
|
+
overlays.set(providerId, { upstream, twinIds: [...twinIds] });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Allinea il registry ai twin richiesti: registra i provider coinvolti e
|
|
216
|
+
* ripristina (unregister) quelli che non hanno più twin.
|
|
217
|
+
*/
|
|
218
|
+
function applyTwins(
|
|
219
|
+
refs: TwinRef[],
|
|
220
|
+
registry: ModelRegistry,
|
|
152
221
|
persist: boolean,
|
|
153
|
-
):
|
|
154
|
-
const
|
|
222
|
+
): { ok: boolean; error?: string; twins?: string[] } {
|
|
223
|
+
const byProvider = new Map<string, string[]>();
|
|
224
|
+
for (const ref of refs) {
|
|
225
|
+
const list = byProvider.get(ref.provider) ?? [];
|
|
226
|
+
if (!list.includes(ref.model)) list.push(ref.model);
|
|
227
|
+
byProvider.set(ref.provider, list);
|
|
228
|
+
}
|
|
229
|
+
|
|
155
230
|
try {
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
231
|
+
// Provider non più gemellati: unregister → builtin ripristinato.
|
|
232
|
+
for (const providerId of [...overlays.keys()]) {
|
|
233
|
+
if (!byProvider.has(providerId)) {
|
|
234
|
+
try {
|
|
235
|
+
pi.unregisterProvider(providerId);
|
|
236
|
+
} catch {
|
|
237
|
+
// ignora
|
|
238
|
+
}
|
|
239
|
+
overlays.delete(providerId);
|
|
162
240
|
}
|
|
163
|
-
upstreamRefs.delete(overlaidProviderId);
|
|
164
241
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
242
|
+
|
|
243
|
+
for (const [providerId, twinIds] of byProvider) {
|
|
244
|
+
// Cattura il live PRIMA dell'override (dopo, getProvider dà l'overlay).
|
|
245
|
+
const existing = overlays.get(providerId)?.upstream;
|
|
246
|
+
const live = existing ?? registry.getProvider(providerId);
|
|
247
|
+
if (!live) return { ok: false, error: `Provider non trovato: ${providerId}` };
|
|
248
|
+
for (const model of twinIds) {
|
|
249
|
+
const has = live.getModels().some((m) => m.id === model && !isTwinId(m.id));
|
|
250
|
+
if (!has) return { ok: false, error: `Modello non trovato: ${providerId}/${model}` };
|
|
251
|
+
}
|
|
252
|
+
registerOverlay(providerId, live, twinIds);
|
|
168
253
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
twinModelIds: [snapshot.model.id],
|
|
172
|
-
getConfig,
|
|
173
|
-
getReview: getGateReview,
|
|
174
|
-
onVerdict: recordGateVerdict,
|
|
175
|
-
});
|
|
176
|
-
pi.registerProvider(overlay);
|
|
177
|
-
overlaidProviderId = providerId;
|
|
178
|
-
twinTarget = { provider: providerId, model: snapshot.model.id };
|
|
179
|
-
config = {
|
|
180
|
-
...config,
|
|
181
|
-
automatic: {
|
|
182
|
-
...config.automatic,
|
|
183
|
-
upstreamProvider: providerId,
|
|
184
|
-
upstreamModel: snapshot.model.id,
|
|
185
|
-
},
|
|
186
|
-
};
|
|
254
|
+
|
|
255
|
+
config = withTwinRefs(config, refs);
|
|
187
256
|
if (persist) {
|
|
188
257
|
try {
|
|
189
258
|
saveConfig(config);
|
|
190
259
|
} catch (error) {
|
|
191
260
|
return {
|
|
192
261
|
ok: false,
|
|
193
|
-
error: `
|
|
262
|
+
error: `Twin aggiornati in memoria ma salvataggio fallito: ${
|
|
194
263
|
error instanceof Error ? error.message : String(error)
|
|
195
264
|
}`,
|
|
196
265
|
};
|
|
197
266
|
}
|
|
198
267
|
}
|
|
199
|
-
return { ok: true,
|
|
268
|
+
return { ok: true, twins: activeTwinRefs().map((r) => `${r.provider}/${twinIdFor(r.model)}`) };
|
|
200
269
|
} catch (error) {
|
|
201
270
|
return {
|
|
202
271
|
ok: false,
|
|
@@ -205,7 +274,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
205
274
|
}
|
|
206
275
|
}
|
|
207
276
|
|
|
208
|
-
/**
|
|
277
|
+
/** Aggiunge un twin (persistente). Gli altri restano attivi. */
|
|
209
278
|
function setUpstream(
|
|
210
279
|
registry: ModelRegistry,
|
|
211
280
|
providerId: string,
|
|
@@ -213,55 +282,79 @@ export default function (pi: ExtensionAPI) {
|
|
|
213
282
|
): UpstreamResult {
|
|
214
283
|
const found = snapshotUpstream(registry, providerId, modelId);
|
|
215
284
|
if ("error" in found) return { ok: false, error: found.error };
|
|
216
|
-
|
|
285
|
+
const refs = twinRefs(config).filter(
|
|
286
|
+
(r) => !(r.provider === providerId && r.model === modelId),
|
|
287
|
+
);
|
|
288
|
+
refs.push({ provider: providerId, model: modelId });
|
|
289
|
+
const res = applyTwins(refs, registry, true);
|
|
290
|
+
if (!res.ok) return { ok: false, error: res.error };
|
|
291
|
+
return { ok: true, twin: `${providerId}/${twinIdFor(modelId)}` };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Rimuove un twin; se il provider resta senza twin, ripristina il builtin. */
|
|
295
|
+
function removeTwin(
|
|
296
|
+
registry: ModelRegistry,
|
|
297
|
+
providerId: string,
|
|
298
|
+
modelId: string,
|
|
299
|
+
): { ok: boolean; error?: string } {
|
|
300
|
+
const refs = twinRefs(config);
|
|
301
|
+
const next = refs.filter((r) => !(r.provider === providerId && r.model === modelId));
|
|
302
|
+
if (next.length === refs.length) {
|
|
303
|
+
return { ok: false, error: `twin non registrato: ${providerId}/${modelId}` };
|
|
304
|
+
}
|
|
305
|
+
const res = applyTwins(next, registry, true);
|
|
306
|
+
return res.ok ? { ok: true } : { ok: false, error: res.error };
|
|
217
307
|
}
|
|
218
308
|
|
|
219
|
-
/** Riapplica
|
|
309
|
+
/** Riapplica tutti i twin salvati in config (es. dopo il riavvio). */
|
|
220
310
|
function refreshUpstream(registry: ModelRegistry): UpstreamResult {
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
311
|
+
const res = applyTwins(twinRefs(config), registry, false);
|
|
312
|
+
if (!res.ok) return { ok: false, error: res.error };
|
|
313
|
+
const primary = primaryTwin();
|
|
314
|
+
return {
|
|
315
|
+
ok: true,
|
|
316
|
+
twin: primary ? `${primary.provider}/${twinIdFor(primary.model)}` : undefined,
|
|
317
|
+
};
|
|
228
318
|
}
|
|
229
319
|
|
|
230
|
-
/** Rimuove
|
|
231
|
-
function removeGuarded(): { ok: boolean; error?: string } {
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
320
|
+
/** Rimuove tutti gli overlay (i builtin tornano da soli). */
|
|
321
|
+
function removeGuarded(): { ok: boolean; error?: string; removed?: number } {
|
|
322
|
+
if (overlays.size === 0) return { ok: false, error: "nessun guarded attivo" };
|
|
323
|
+
const removed = overlays.size;
|
|
324
|
+
for (const providerId of [...overlays.keys()]) {
|
|
325
|
+
try {
|
|
326
|
+
pi.unregisterProvider(providerId);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
return {
|
|
329
|
+
ok: false,
|
|
330
|
+
error: error instanceof Error ? error.message : String(error),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
overlays.delete(providerId);
|
|
240
334
|
}
|
|
241
|
-
|
|
242
|
-
overlaidProviderId = undefined;
|
|
243
|
-
twinTarget = undefined;
|
|
244
|
-
return { ok: true };
|
|
335
|
+
return { ok: true, removed };
|
|
245
336
|
}
|
|
246
337
|
|
|
247
|
-
/** Seleziona il twin
|
|
338
|
+
/** Seleziona il twin primario come modello di sessione. */
|
|
248
339
|
async function switchToGuarded(
|
|
249
340
|
registry: ModelRegistry,
|
|
250
341
|
): Promise<{ ok: boolean; error?: string; model?: string }> {
|
|
251
|
-
if (
|
|
342
|
+
if (overlays.size === 0) refreshUpstream(registry);
|
|
343
|
+
const primary = primaryTwin();
|
|
344
|
+
if (!primary) {
|
|
252
345
|
return { ok: false, error: "nessun guarded attivo — prima /jev upstream <provider> <model>" };
|
|
253
346
|
}
|
|
254
|
-
const twinId = twinIdFor(
|
|
255
|
-
const model = registry.find(
|
|
347
|
+
const twinId = twinIdFor(primary.model);
|
|
348
|
+
const model = registry.find(primary.provider, twinId);
|
|
256
349
|
if (!model) {
|
|
257
|
-
return { ok: false, error: `twin non in registry: ${
|
|
350
|
+
return { ok: false, error: `twin non in registry: ${primary.provider}/${twinId}` };
|
|
258
351
|
}
|
|
259
352
|
try {
|
|
260
353
|
const ok = await pi.setModel(model);
|
|
261
354
|
if (!ok) {
|
|
262
|
-
return { ok: false, error: `auth mancante per ${
|
|
355
|
+
return { ok: false, error: `auth mancante per ${primary.provider} — /login ${primary.provider}` };
|
|
263
356
|
}
|
|
264
|
-
return { ok: true, model: `${
|
|
357
|
+
return { ok: true, model: `${primary.provider}/${model.id}` };
|
|
265
358
|
} catch (error) {
|
|
266
359
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
267
360
|
}
|
|
@@ -282,25 +375,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
282
375
|
registerJevCommand(pi, {
|
|
283
376
|
config: getConfig,
|
|
284
377
|
setMode: (mode) => {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
typedHandle = createTypedAsk(configSnapshot(config));
|
|
289
|
-
try {
|
|
290
|
-
saveConfig(config);
|
|
291
|
-
} catch {
|
|
292
|
-
// Resta in memoria anche se il salvataggio fallisce.
|
|
293
|
-
}
|
|
294
|
-
return config;
|
|
378
|
+
pi.appendEntry("jev-mode", { mode });
|
|
379
|
+
sessionMode = mode;
|
|
380
|
+
return getConfig();
|
|
295
381
|
},
|
|
382
|
+
sharedConfig: () => config,
|
|
296
383
|
reload: refresh,
|
|
297
384
|
review: getReview,
|
|
298
385
|
reviewerInfo: getInfo,
|
|
299
386
|
metrics,
|
|
300
387
|
gateStatus: getGateStatus,
|
|
388
|
+
twinStatus: () => ({
|
|
389
|
+
active: activeTwinRefs(),
|
|
390
|
+
saved: twinRefs(config),
|
|
391
|
+
primary: primaryTwin(),
|
|
392
|
+
}),
|
|
301
393
|
setUpstream,
|
|
302
394
|
refreshUpstream,
|
|
303
395
|
removeGuarded,
|
|
396
|
+
removeTwin,
|
|
304
397
|
switchToGuarded,
|
|
305
398
|
getLastOutput: () => lastOutput,
|
|
306
399
|
getLastGate: () => lastGate,
|
|
@@ -309,68 +402,54 @@ export default function (pi: ExtensionAPI) {
|
|
|
309
402
|
// Seed all'avvio: pi ripristina il modello di sessione PRIMA di
|
|
310
403
|
// session_start, quindi un twin persistito non esisterebbe ancora e
|
|
311
404
|
// cadrebbe su "Could not restore model ...__jev". Pre-registriamo un
|
|
312
|
-
// overlay
|
|
313
|
-
//
|
|
314
|
-
// sostituisce col clone live dal registry.
|
|
405
|
+
// overlay per tutti i twin salvati, anche in on-demand: il mode della
|
|
406
|
+
// sessione non è disponibile prima di session_start. Un alias non attiva il gate.
|
|
315
407
|
const bootAuto = process.env.JEV_AUTO_UPSTREAM?.trim();
|
|
316
408
|
const autoOff = bootAuto === "0" || bootAuto?.toLowerCase() === "off";
|
|
317
|
-
let
|
|
409
|
+
let seedRefs: TwinRef[] = [];
|
|
318
410
|
if (bootAuto && !autoOff) {
|
|
319
411
|
const parsed = parseUpstreamRef([bootAuto]);
|
|
320
|
-
if (!("error" in parsed))
|
|
321
|
-
} else if (!bootAuto
|
|
322
|
-
|
|
323
|
-
provider: config.automatic.upstreamProvider,
|
|
324
|
-
model: config.automatic.upstreamModel,
|
|
325
|
-
};
|
|
412
|
+
if (!("error" in parsed)) seedRefs = [parsed];
|
|
413
|
+
} else if (!bootAuto) {
|
|
414
|
+
seedRefs = twinRefs(config);
|
|
326
415
|
}
|
|
327
|
-
if (
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
if (!
|
|
333
|
-
|
|
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
|
-
twinModelIds: [seedTarget.model],
|
|
360
|
-
getConfig,
|
|
361
|
-
getReview: getGateReview,
|
|
362
|
-
onVerdict: recordGateVerdict,
|
|
363
|
-
});
|
|
364
|
-
pi.registerProvider(overlay);
|
|
365
|
-
upstreamRefs.set(seedTarget.provider, seedUpstream);
|
|
366
|
-
overlaidProviderId = seedTarget.provider;
|
|
367
|
-
twinTarget = { provider: seedTarget.provider, model: seedTarget.model };
|
|
368
|
-
} catch {
|
|
369
|
-
// Nessun seed: il twin arriverà da session_start se possibile.
|
|
416
|
+
if (seedRefs.length > 0) {
|
|
417
|
+
// Raggruppa per provider: un overlay per provider con tutti i suoi twin.
|
|
418
|
+
const byProvider = new Map<string, string[]>();
|
|
419
|
+
for (const ref of seedRefs) {
|
|
420
|
+
const list = byProvider.get(ref.provider) ?? [];
|
|
421
|
+
if (!list.includes(ref.model)) list.push(ref.model);
|
|
422
|
+
byProvider.set(ref.provider, list);
|
|
423
|
+
}
|
|
424
|
+
for (const [providerId, twinIds] of byProvider) {
|
|
425
|
+
try {
|
|
426
|
+
// providers/all è mappato dagli alias di pi; il subpath del singolo
|
|
427
|
+
// provider non lo è e fallirebbe a load (solo installazioni npm).
|
|
428
|
+
const base = builtinProviders().find((p) => p.id === providerId);
|
|
429
|
+
if (!base) throw new Error(`provider ${providerId} not in builtins`);
|
|
430
|
+
// Il catalogo builtin può non avere i modelli salvati: provider
|
|
431
|
+
// dinamici (es. openai-codex) li tengono nel models-store, altri
|
|
432
|
+
// (deepseek-flash su versioni vecchie) in una definizione statica.
|
|
433
|
+
const extra = [
|
|
434
|
+
...(providerId === "deepseek" ? [STATIC_DEEPSEEK_FLASH] : []),
|
|
435
|
+
...readStoreModels(providerId),
|
|
436
|
+
];
|
|
437
|
+
const seedUpstream: Provider = {
|
|
438
|
+
...base,
|
|
439
|
+
getModels: () => mergeModelsById(base.getModels(), extra),
|
|
440
|
+
};
|
|
441
|
+
const known = new Set(seedUpstream.getModels().map((m) => m.id));
|
|
442
|
+
const usable = twinIds.filter((model) => known.has(model));
|
|
443
|
+
if (usable.length === 0) continue;
|
|
444
|
+
registerOverlay(providerId, seedUpstream, usable);
|
|
445
|
+
} catch {
|
|
446
|
+
// Provider non gemellabile all'avvio: session_start riproverà dal registry.
|
|
447
|
+
}
|
|
370
448
|
}
|
|
371
449
|
}
|
|
372
450
|
|
|
373
|
-
|
|
451
|
+
async function startSession(ctx: ExtensionContext): Promise<void> {
|
|
452
|
+
restoreMode(ctx);
|
|
374
453
|
const auto = process.env.JEV_AUTO_UPSTREAM?.trim();
|
|
375
454
|
if (auto && auto !== "0" && auto.toLowerCase() !== "off") {
|
|
376
455
|
try {
|
|
@@ -382,17 +461,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
382
461
|
if ("error" in found) {
|
|
383
462
|
if (ctx.hasUI) ctx.ui.notify(`JEV_AUTO_UPSTREAM fallito: ${found.error}`, "warning");
|
|
384
463
|
} else {
|
|
385
|
-
|
|
464
|
+
applyTwins([parsed], ctx.modelRegistry, false);
|
|
386
465
|
}
|
|
387
466
|
}
|
|
388
467
|
} catch {
|
|
389
468
|
// Resta opt-in manuale.
|
|
390
469
|
}
|
|
391
470
|
}
|
|
392
|
-
//
|
|
471
|
+
// Automatic richiede il routing guarded della sessione.
|
|
393
472
|
try {
|
|
394
|
-
if (
|
|
395
|
-
|
|
473
|
+
if (sessionMode !== "automatic") {
|
|
474
|
+
// Reload can retain a stale alias after its registration was removed.
|
|
475
|
+
if (ctx.model && isTwinId(ctx.model.id) && !isGuardedModel(ctx.model.provider, ctx.model.id)) {
|
|
476
|
+
const original = ctx.modelRegistry.find(ctx.model.provider, untwinId(ctx.model.id));
|
|
477
|
+
if (original) await pi.setModel(original);
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (!config.automatic.requireGuardedModel) return;
|
|
482
|
+
if (overlays.size === 0) {
|
|
396
483
|
const restored = refreshUpstream(ctx.modelRegistry);
|
|
397
484
|
if (!restored.ok) {
|
|
398
485
|
if (ctx.hasUI) {
|
|
@@ -401,37 +488,51 @@ export default function (pi: ExtensionAPI) {
|
|
|
401
488
|
return;
|
|
402
489
|
}
|
|
403
490
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
491
|
+
const primary = primaryTwin();
|
|
492
|
+
if (!primary) return;
|
|
493
|
+
const twinId = twinIdFor(primary.model);
|
|
494
|
+
if (isGuardedModel(ctx.model?.provider, ctx.model?.id)) return;
|
|
495
|
+
const twin = ctx.modelRegistry.find(primary.provider, twinId);
|
|
408
496
|
if (!twin) return;
|
|
409
497
|
const switched = await pi.setModel(twin);
|
|
410
498
|
if (ctx.hasUI) {
|
|
411
499
|
ctx.ui.notify(
|
|
412
500
|
switched
|
|
413
|
-
? `Jev automatic: selezionato ${
|
|
414
|
-
: `Jev automatic: auth mancante per ${
|
|
501
|
+
? `Jev automatic: selezionato ${primary.provider}/${twinId}.`
|
|
502
|
+
: `Jev automatic: auth mancante per ${primary.provider} — /login ${primary.provider}.`,
|
|
415
503
|
switched ? "info" : "warning",
|
|
416
504
|
);
|
|
417
505
|
}
|
|
418
506
|
} catch {
|
|
419
507
|
// Non bloccare l'avvio sessione.
|
|
420
508
|
}
|
|
509
|
+
}
|
|
510
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
511
|
+
await startSession(ctx);
|
|
512
|
+
pi.appendEntry("jev-mode", { mode: sessionMode });
|
|
421
513
|
});
|
|
514
|
+
pi.on("session_tree", async (_event, ctx) => { await startSession(ctx); });
|
|
515
|
+
|
|
516
|
+
// Normalize persisted aliases even after /jev off removed their overlays.
|
|
517
|
+
// The event is a context copy: session history remains unchanged.
|
|
518
|
+
pi.on("context", async (event) => ({
|
|
519
|
+
messages: event.messages.map((message) =>
|
|
520
|
+
message.role === "assistant" && isTwinId(message.model)
|
|
521
|
+
? { ...message, model: untwinId(message.model) }
|
|
522
|
+
: message),
|
|
523
|
+
}));
|
|
422
524
|
|
|
423
525
|
// Enforcement a livello selezione: in automatic non si può restare
|
|
424
526
|
// su un modello non guarded (revert immediato al twin).
|
|
425
527
|
pi.on("model_select", async (event, ctx) => {
|
|
426
528
|
try {
|
|
427
|
-
if (
|
|
428
|
-
if (
|
|
529
|
+
if (sessionMode !== "automatic" || !config.automatic.requireGuardedModel) return;
|
|
530
|
+
if (overlays.size === 0) return;
|
|
429
531
|
if (isGuardedModel(event.model.provider, event.model.id)) return;
|
|
430
532
|
|
|
431
|
-
const
|
|
432
|
-
const twin = ctx.modelRegistry.find(overlaidProviderId, twinId);
|
|
533
|
+
const primary = primaryTwin();
|
|
433
534
|
const fallback =
|
|
434
|
-
|
|
535
|
+
(primary ? ctx.modelRegistry.find(primary.provider, twinIdFor(primary.model)) : undefined) ??
|
|
435
536
|
(event.previousModel && isGuardedModel(event.previousModel.provider, event.previousModel.id)
|
|
436
537
|
? event.previousModel
|
|
437
538
|
: undefined);
|
|
@@ -458,7 +559,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
458
559
|
// Jev non approva; mostra stato invece di silenzio ambiguo.
|
|
459
560
|
pi.on("message_start", async (event, ctx) => {
|
|
460
561
|
try {
|
|
461
|
-
if (
|
|
562
|
+
if (sessionMode !== "automatic" || !ctx.hasUI) return;
|
|
462
563
|
const msg: any = event.message;
|
|
463
564
|
if (msg?.role !== "assistant") return;
|
|
464
565
|
const provider: string | undefined =
|
|
@@ -476,7 +577,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
476
577
|
// Policy pre-esecuzione tool (solo automatic).
|
|
477
578
|
pi.on("tool_call", async (event, ctx) => {
|
|
478
579
|
try {
|
|
479
|
-
if (
|
|
580
|
+
if (sessionMode !== "automatic") return;
|
|
480
581
|
if (!config.toolsPolicy.enabled) return;
|
|
481
582
|
|
|
482
583
|
if (event.toolName === "bash" || event.toolName === "powershell") {
|
|
@@ -591,6 +692,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
591
692
|
const allow = await ctx.ui.confirm(
|
|
592
693
|
"Jev: comando di rete sospetto",
|
|
593
694
|
`${toolName}\n${summary}\n\nEseguire comunque?`,
|
|
695
|
+
{ signal: ctx.signal },
|
|
594
696
|
);
|
|
595
697
|
return allow ? undefined : { block: true, reason: `Bloccato da jev exfil check (${summary}) (declined).` };
|
|
596
698
|
}
|
|
@@ -600,7 +702,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
600
702
|
// una riga al risultato che il modello legge.
|
|
601
703
|
pi.on("tool_result", async (event, ctx) => {
|
|
602
704
|
try {
|
|
603
|
-
if (
|
|
705
|
+
if (sessionMode !== "automatic") return;
|
|
604
706
|
if (!config.outputJudge.enabled) return;
|
|
605
707
|
if (!config.outputJudge.tools.includes(event.toolName)) return;
|
|
606
708
|
if (!resolveBackend(config).apiKeyPresent) return;
|
|
@@ -666,14 +768,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
666
768
|
pi.on("message_end", async (event, ctx) => {
|
|
667
769
|
try {
|
|
668
770
|
if (ctx.hasUI) ctx.ui.setStatus("jev", undefined);
|
|
669
|
-
if (
|
|
771
|
+
if (sessionMode !== "automatic") return;
|
|
670
772
|
if (event.message.role !== "assistant") return;
|
|
671
773
|
|
|
672
774
|
const msg = event.message as { provider?: string; model?: string };
|
|
673
775
|
if (isGuardedModel(msg.provider, msg.model)) {
|
|
776
|
+
const primary = primaryTwin();
|
|
674
777
|
pi.appendEntry("jev-guarded", {
|
|
675
|
-
twin:
|
|
676
|
-
upstream:
|
|
778
|
+
twin: msg.model ? `${msg.provider}/${msg.model}` : undefined,
|
|
779
|
+
upstream: primary ? `${primary.provider}/${primary.model}` : undefined,
|
|
780
|
+
active: activeTwinRefs().map((r) => `${r.provider}/${r.model}`),
|
|
677
781
|
});
|
|
678
782
|
return;
|
|
679
783
|
}
|
package/package.json
CHANGED
package/src/automatic/overlay.ts
CHANGED
|
@@ -20,6 +20,8 @@ import type {
|
|
|
20
20
|
SimpleStreamOptions,
|
|
21
21
|
} from "@earendil-works/pi-ai";
|
|
22
22
|
import type { Api } from "@earendil-works/pi-ai";
|
|
23
|
+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
24
|
+
import { emptyUsage } from "./serialize.ts";
|
|
23
25
|
import type { JevConfig } from "../config.ts";
|
|
24
26
|
import { configSnapshot } from "../config.ts";
|
|
25
27
|
import type { ReviewFn } from "../reviewer.ts";
|
|
@@ -100,6 +102,21 @@ export function twinModelFor(
|
|
|
100
102
|
};
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
/** Unione di liste modelli con dedup per id (il primo vince). */
|
|
106
|
+
export function mergeModelsById(
|
|
107
|
+
primary: readonly Model<Api>[],
|
|
108
|
+
extra: readonly Model<Api>[],
|
|
109
|
+
): Model<Api>[] {
|
|
110
|
+
const out: Model<Api>[] = [];
|
|
111
|
+
const seen = new Set<string>();
|
|
112
|
+
for (const model of [...primary, ...extra]) {
|
|
113
|
+
if (!model || typeof model.id !== "string" || seen.has(model.id)) continue;
|
|
114
|
+
seen.add(model.id);
|
|
115
|
+
out.push(model);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
103
120
|
function stripTwins(models: readonly Model<Api>[]): Model<Api>[] {
|
|
104
121
|
return models.filter((m) => !isTwinId(m.id)).map((m) => ({ ...m }));
|
|
105
122
|
}
|
|
@@ -114,13 +131,17 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
|
|
|
114
131
|
let twinToOriginal = new Map<string, Model<Api>>();
|
|
115
132
|
let currentModels: Model<Api>[] = [];
|
|
116
133
|
|
|
117
|
-
function rebuildTwins(
|
|
134
|
+
function rebuildTwins(rawOriginals: readonly Model<Api>[]): Model<Api>[] {
|
|
118
135
|
twinToOriginal = new Map();
|
|
119
136
|
const chosen = new Set(deps.twinModelIds);
|
|
137
|
+
// Dedup per id: sorgenti miste (builtin + store + statica) possono
|
|
138
|
+
// ripetere lo stesso modello e produrrebbero twin duplicati.
|
|
139
|
+
const originals = mergeModelsById(stripTwins(rawOriginals), []);
|
|
120
140
|
const twins: Model<Api>[] = [];
|
|
121
141
|
for (const original of originals) {
|
|
122
142
|
if (chosen.has(original.id)) {
|
|
123
143
|
const twin = twinModelFor(original, upstream.id);
|
|
144
|
+
if (twinToOriginal.has(twin.id)) continue;
|
|
124
145
|
twinToOriginal.set(twin.id, original);
|
|
125
146
|
twins.push(twin);
|
|
126
147
|
}
|
|
@@ -136,10 +157,19 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
|
|
|
136
157
|
context: Context,
|
|
137
158
|
options?: SimpleStreamOptions,
|
|
138
159
|
): AssistantMessageEventStream {
|
|
160
|
+
// Public aliases are not model changes. Preserve reasoning signatures,
|
|
161
|
+
// response phases and tool ids when replaying the transcript upstream.
|
|
162
|
+
const originalContext: Context = {
|
|
163
|
+
...context,
|
|
164
|
+
messages: context.messages.map((message) =>
|
|
165
|
+
message.role === "assistant" && message.provider === upstream.id && isTwinId(message.model)
|
|
166
|
+
? { ...message, model: untwinId(message.model) }
|
|
167
|
+
: message),
|
|
168
|
+
};
|
|
139
169
|
if (deps.upstreamStreams) {
|
|
140
|
-
return deps.upstreamStreams.streamSimple(model,
|
|
170
|
+
return deps.upstreamStreams.streamSimple(model, originalContext, options);
|
|
141
171
|
}
|
|
142
|
-
return upstream.streamSimple(model,
|
|
172
|
+
return upstream.streamSimple(model, originalContext, options);
|
|
143
173
|
}
|
|
144
174
|
|
|
145
175
|
function guardedStreamSimple(
|
|
@@ -148,9 +178,20 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
|
|
|
148
178
|
options?: SimpleStreamOptions,
|
|
149
179
|
): AssistantMessageEventStream {
|
|
150
180
|
const twinOf = twinToOriginal.get(model.id);
|
|
151
|
-
//
|
|
181
|
+
// A stale/unknown alias must never leak to the upstream API, even if a
|
|
182
|
+
// dynamic refresh removed its original from the catalogue.
|
|
152
183
|
if (!twinOf) {
|
|
153
|
-
return upstreamCall(model, context, options);
|
|
184
|
+
if (!isTwinId(model.id)) return upstreamCall(model, context, options);
|
|
185
|
+
const stream = createAssistantMessageEventStream();
|
|
186
|
+
const error = {
|
|
187
|
+
role: "assistant" as const, content: [], api: model.api,
|
|
188
|
+
provider: model.provider, model: model.id, usage: emptyUsage(),
|
|
189
|
+
stopReason: "error" as const, timestamp: Date.now(),
|
|
190
|
+
errorMessage: `Jev: twin non disponibile ${model.provider}/${model.id}. Ripristina con /jev upstream o seleziona il modello originale.`,
|
|
191
|
+
};
|
|
192
|
+
stream.push({ type: "error", reason: "error", error });
|
|
193
|
+
stream.end(error);
|
|
194
|
+
return stream;
|
|
154
195
|
}
|
|
155
196
|
const snapshot = configSnapshot(deps.getConfig());
|
|
156
197
|
// Gemello in on-demand: passthrough (nessuna verifica implicita).
|
package/src/commands.ts
CHANGED
|
@@ -2,8 +2,16 @@ 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
|
|
6
|
-
import {
|
|
5
|
+
import { isTwinId, untwinId } from "./automatic/overlay.ts";
|
|
6
|
+
import type { JevConfig, TwinRef } from "./config.ts";
|
|
7
|
+
import {
|
|
8
|
+
defaultConfigPath,
|
|
9
|
+
expandHome,
|
|
10
|
+
parseUpstreamRef,
|
|
11
|
+
resolveBackend,
|
|
12
|
+
saveConfig,
|
|
13
|
+
twinRefs,
|
|
14
|
+
} from "./config.ts";
|
|
7
15
|
import type { ReviewFn } from "./reviewer.ts";
|
|
8
16
|
import { reviewWithCoverage } from "./reviewer.ts";
|
|
9
17
|
import type { Metrics } from "./metrics.ts";
|
|
@@ -17,6 +25,15 @@ export interface GateStatusView {
|
|
|
17
25
|
reason: string;
|
|
18
26
|
}
|
|
19
27
|
|
|
28
|
+
export interface TwinStatusView {
|
|
29
|
+
/** Twin attivi adesso (overlay registrati). */
|
|
30
|
+
active: TwinRef[];
|
|
31
|
+
/** Twin salvati in config (ripristinati all'avvio/refresh). */
|
|
32
|
+
saved: TwinRef[];
|
|
33
|
+
/** Twin primario: auto-selezione e revert. */
|
|
34
|
+
primary?: TwinRef;
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
export interface UpstreamResult {
|
|
21
38
|
ok: boolean;
|
|
22
39
|
error?: string;
|
|
@@ -26,6 +43,8 @@ export interface UpstreamResult {
|
|
|
26
43
|
export interface JevCommandDeps {
|
|
27
44
|
config: () => JevConfig;
|
|
28
45
|
setMode: (mode: "on-demand" | "automatic") => JevConfig;
|
|
46
|
+
/** Shared settings without the session-only mode override. */
|
|
47
|
+
sharedConfig?: () => JevConfig;
|
|
29
48
|
reload: () => JevConfig;
|
|
30
49
|
review: () => ReviewFn;
|
|
31
50
|
reviewerInfo: () => { backend: string; model: string };
|
|
@@ -37,7 +56,13 @@ export interface JevCommandDeps {
|
|
|
37
56
|
modelId: string,
|
|
38
57
|
) => UpstreamResult;
|
|
39
58
|
refreshUpstream: (registry: ModelRegistry) => UpstreamResult;
|
|
40
|
-
removeGuarded: () => { ok: boolean; error?: string };
|
|
59
|
+
removeGuarded: () => { ok: boolean; error?: string; removed?: number };
|
|
60
|
+
removeTwin: (
|
|
61
|
+
registry: ModelRegistry,
|
|
62
|
+
providerId: string,
|
|
63
|
+
modelId: string,
|
|
64
|
+
) => { ok: boolean; error?: string };
|
|
65
|
+
twinStatus: () => TwinStatusView;
|
|
41
66
|
switchToGuarded: (registry: ModelRegistry) => Promise<{
|
|
42
67
|
ok: boolean;
|
|
43
68
|
error?: string;
|
|
@@ -52,6 +77,8 @@ export const JEV_SUBCOMMANDS = [
|
|
|
52
77
|
"mode",
|
|
53
78
|
"models",
|
|
54
79
|
"upstream",
|
|
80
|
+
"twins",
|
|
81
|
+
"untwin",
|
|
55
82
|
"off",
|
|
56
83
|
"refresh-upstream",
|
|
57
84
|
"check",
|
|
@@ -67,13 +94,15 @@ export const JEV_HELP_TEXT = [
|
|
|
67
94
|
"Jev guard — verifica risposte con TypeSafe Jev (via OpenRouter o diretto).",
|
|
68
95
|
"",
|
|
69
96
|
" /jev status Stato: modalità, backend, policy, gate, auth",
|
|
70
|
-
" /jev mode on-demand
|
|
71
|
-
" /jev mode automatic
|
|
97
|
+
" /jev mode on-demand Disattiva il gate nella sessione (scelta persistente)",
|
|
98
|
+
" /jev mode automatic Attiva il gate nella sessione (scelta persistente)",
|
|
72
99
|
" /jev models [filtro] Modelli pi (● twin guarded attivo, ★ ultimo salvato)",
|
|
73
|
-
" /jev upstream P M
|
|
100
|
+
" /jev upstream P M Aggiunge il twin M__jev nel provider P (salvato)",
|
|
74
101
|
" /jev upstream P/M Stessa cosa in forma compatta",
|
|
75
|
-
" /jev upstream
|
|
76
|
-
" /jev
|
|
102
|
+
" /jev upstream Ripristina tutti i twin salvati",
|
|
103
|
+
" /jev twins Elenca twin attivi e salvati (● attivo, ★ primario)",
|
|
104
|
+
" /jev untwin P M Rimuove un twin (anche P/M)",
|
|
105
|
+
" /jev off Rimuove tutti i twin (torna on-demand)",
|
|
77
106
|
" /jev refresh-upstream Ricrea il twin dal registry (dopo refresh cataloghi)",
|
|
78
107
|
" /jev check <file> [req] Verifica un file subito, senza cambiare modalità",
|
|
79
108
|
" /jev stats Conteggi pass/block/review/unavailable + latenze",
|
|
@@ -85,7 +114,7 @@ export const JEV_HELP_TEXT = [
|
|
|
85
114
|
"",
|
|
86
115
|
"Flusso tipico: /jev upstream deepseek deepseek-flash → /model (scegli __jev)",
|
|
87
116
|
"→ /jev mode automatic. Il twin usa la STESSA auth del provider: nessun login extra.",
|
|
88
|
-
"Torna normale con: /
|
|
117
|
+
"Torna normale con: /jev mode on-demand. La scelta sopravvive a /reload e /resume.",
|
|
89
118
|
"Env JEV_AUTO_UPSTREAM=P/M: crea il twin all'avvio (default: nessuno;",
|
|
90
119
|
"con pi --print solo deepseek/deepseek-flash).",
|
|
91
120
|
].join("\n");
|
|
@@ -104,6 +133,18 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
104
133
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
105
134
|
const sub = parts[0] ?? "status";
|
|
106
135
|
|
|
136
|
+
// Never unregister the route while a synthetic model is still selected.
|
|
137
|
+
// The caller must disable automatic enforcement before switching away.
|
|
138
|
+
async function leaveSelectedTwin(): Promise<boolean> {
|
|
139
|
+
if (!ctx.model || !isTwinId(ctx.model.id)) return true;
|
|
140
|
+
const original = ctx.modelRegistry.find(ctx.model.provider, untwinId(ctx.model.id));
|
|
141
|
+
try {
|
|
142
|
+
if (original && await pi.setModel(original)) return true;
|
|
143
|
+
} catch { /* Keep the overlay on failure. */ }
|
|
144
|
+
ctx.ui.notify("Impossibile selezionare il modello originale; twin mantenuto per evitare richieste __jev al provider.", "error");
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
107
148
|
if (sub === "help" || sub === "--help" || sub === "-h") {
|
|
108
149
|
ctx.ui.notify(JEV_HELP_TEXT, "info");
|
|
109
150
|
return;
|
|
@@ -114,7 +155,8 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
114
155
|
const resolved = resolveBackend(cfg);
|
|
115
156
|
const info = deps.reviewerInfo();
|
|
116
157
|
const gate = deps.gateStatus();
|
|
117
|
-
const
|
|
158
|
+
const twinView = deps.twinStatus();
|
|
159
|
+
const authProvider = twinView.primary?.provider ?? cfg.automatic.upstreamProvider;
|
|
118
160
|
let guardedAuth = "—";
|
|
119
161
|
if (gate.active) {
|
|
120
162
|
try {
|
|
@@ -152,20 +194,22 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
152
194
|
if (m?.provider && m?.id) {
|
|
153
195
|
const ref = `${m.provider}/${m.id}`;
|
|
154
196
|
currentModel =
|
|
155
|
-
|
|
197
|
+
cfg.mode === "automatic" && twinView.active.some((r) => r.provider === m.provider && `${r.model}__jev` === m.id)
|
|
198
|
+
? `${ref} (twin: enforcement attivo)` : ref;
|
|
156
199
|
}
|
|
157
200
|
} catch {
|
|
158
201
|
currentModel = "sconosciuto";
|
|
159
202
|
}
|
|
160
203
|
ctx.ui.notify(
|
|
161
204
|
[
|
|
162
|
-
`Modalità: ${cfg.mode}`,
|
|
205
|
+
`Modalità: ${cfg.mode} (salvata nella sessione)`,
|
|
163
206
|
`Backend: ${info.backend} (setting=${cfg.jev.backend}, ${resolved.reason})`,
|
|
164
207
|
`Modello: ${info.model}`,
|
|
165
208
|
`Verificatore timeout: ${resolved.timeoutMs}ms`,
|
|
166
209
|
`Policy: ${cfg.policy.revision} pass<=${cfg.policy.passMaxFlawProbability} block>=${cfg.policy.blockMinFlawProbability}`,
|
|
167
210
|
`Chiave presente: ${resolved.apiKeyPresent ? "sì" : "no"} (${resolved.keySource})`,
|
|
168
|
-
`Gate: ${gate.active ? `
|
|
211
|
+
`Gate: ${cfg.mode === "automatic" && gate.active ? `disponibile (twin primario ${gate.twin})` : `non attivo (${cfg.mode === "on-demand" ? "on-demand" : gate.reason})`}`,
|
|
212
|
+
`Twin attivi: ${twinView.active.length > 0 ? twinView.active.map((r) => `${r.provider}/${r.model}__jev`).join(", ") : "nessuno"}`,
|
|
169
213
|
`Twin nel picker: ${twinScope}`,
|
|
170
214
|
`Modello corrente: ${currentModel}`,
|
|
171
215
|
`Ultimo upstream salvato: ${cfg.automatic.upstreamProvider}/${cfg.automatic.upstreamModel}`,
|
|
@@ -192,14 +236,15 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
192
236
|
if (value === "automatic") {
|
|
193
237
|
const sw = await deps.switchToGuarded(ctx.modelRegistry);
|
|
194
238
|
ctx.ui.notify(
|
|
195
|
-
`Modalità automatic attivata. Testo/codice saranno inviati a ${deps.reviewerInfo().backend}. ` +
|
|
239
|
+
`Modalità automatic attivata per questa sessione. Testo/codice saranno inviati a ${deps.reviewerInfo().backend}. ` +
|
|
196
240
|
(sw.ok
|
|
197
241
|
? `Modello selezionato: ${sw.model} (enforcement attivo).`
|
|
198
242
|
: `ATTENZIONE: ${sw.error} (solo diagnostica, nessun enforcement).`),
|
|
199
243
|
sw.ok ? "info" : "warning",
|
|
200
244
|
);
|
|
201
245
|
} else {
|
|
202
|
-
|
|
246
|
+
await leaveSelectedTwin();
|
|
247
|
+
ctx.ui.notify("Modalità on-demand salvata per questa sessione. Nessuna verifica automatica.", "info");
|
|
203
248
|
}
|
|
204
249
|
return;
|
|
205
250
|
}
|
|
@@ -242,12 +287,13 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
242
287
|
}
|
|
243
288
|
|
|
244
289
|
if (sub === "upstream") {
|
|
245
|
-
// Senza argomenti:
|
|
290
|
+
// Senza argomenti: ripristina tutti i twin salvati.
|
|
246
291
|
if (parts.length === 1) {
|
|
247
292
|
const res = deps.refreshUpstream(ctx.modelRegistry);
|
|
293
|
+
const count = deps.twinStatus().active.length;
|
|
248
294
|
ctx.ui.notify(
|
|
249
295
|
res.ok
|
|
250
|
-
? `Twin
|
|
296
|
+
? `Twin ripristinati: ${count} (primario ${res.twin ?? "—"}). Seleziona con /model.`
|
|
251
297
|
: `Ripristino fallito: ${res.error}`,
|
|
252
298
|
res.ok ? "info" : "error",
|
|
253
299
|
);
|
|
@@ -260,26 +306,94 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
260
306
|
}
|
|
261
307
|
const res = deps.setUpstream(ctx.modelRegistry, parsed.provider, parsed.model);
|
|
262
308
|
if (!res.ok) {
|
|
263
|
-
ctx.ui.notify(`
|
|
309
|
+
ctx.ui.notify(`Twin non aggiunto: ${res.error}`, "error");
|
|
264
310
|
return;
|
|
265
311
|
}
|
|
312
|
+
const total = deps.twinStatus().active.length;
|
|
266
313
|
ctx.ui.notify(
|
|
267
|
-
`Twin
|
|
314
|
+
`Twin aggiunto: ${res.twin} (totale attivi: ${total}, stessa auth del provider). ` +
|
|
268
315
|
`Selezionalo con /model per enforcement in automatic.`,
|
|
269
316
|
"info",
|
|
270
317
|
);
|
|
271
318
|
return;
|
|
272
319
|
}
|
|
273
320
|
|
|
274
|
-
if (sub === "
|
|
275
|
-
const
|
|
276
|
-
if (
|
|
277
|
-
|
|
321
|
+
if (sub === "twins") {
|
|
322
|
+
const view = deps.twinStatus();
|
|
323
|
+
if (view.active.length === 0 && view.saved.length === 0) {
|
|
324
|
+
ctx.ui.notify(
|
|
325
|
+
"Jev: nessun twin — aggiungine con /jev upstream <provider> <model>.",
|
|
326
|
+
"info",
|
|
327
|
+
);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const primaryKey = view.primary
|
|
331
|
+
? `${view.primary.provider}/${view.primary.model}`
|
|
332
|
+
: undefined;
|
|
333
|
+
const activeKeys = new Set(view.active.map((r) => `${r.provider}/${r.model}`));
|
|
334
|
+
const lines: string[] = [];
|
|
335
|
+
for (const ref of view.saved) {
|
|
336
|
+
const key = `${ref.provider}/${ref.model}`;
|
|
337
|
+
const mark = activeKeys.has(key) ? "● " : "○ ";
|
|
338
|
+
const star = key === primaryKey ? "★" : " ";
|
|
339
|
+
lines.push(`${mark}${star} ${key}${activeKeys.has(key) ? "" : " (non attivo)"}`);
|
|
340
|
+
}
|
|
341
|
+
ctx.ui.notify(
|
|
342
|
+
[
|
|
343
|
+
`Twin attivi: ${view.active.length} · salvati: ${view.saved.length}`,
|
|
344
|
+
"● registrato ★ primario (auto-selezione/revert)",
|
|
345
|
+
"",
|
|
346
|
+
...lines,
|
|
347
|
+
].join("\n"),
|
|
348
|
+
"info",
|
|
349
|
+
);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (sub === "untwin") {
|
|
354
|
+
const parsed = parseUpstreamRef(parts.slice(1));
|
|
355
|
+
if ("error" in parsed) {
|
|
356
|
+
ctx.ui.notify(
|
|
357
|
+
`${parsed.error.replace("/jev upstream", "/jev untwin")}`,
|
|
358
|
+
"warning",
|
|
359
|
+
);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const previousMode = deps.config().mode;
|
|
363
|
+
const selected = ctx.model?.provider === parsed.provider && ctx.model.id === `${parsed.model}__jev`;
|
|
364
|
+
if (selected) {
|
|
278
365
|
deps.setMode("on-demand");
|
|
366
|
+
if (!await leaveSelectedTwin()) {
|
|
367
|
+
deps.setMode(previousMode);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const res = deps.removeTwin(ctx.modelRegistry, parsed.provider, parsed.model);
|
|
372
|
+
const total = deps.twinStatus().active.length;
|
|
373
|
+
if (selected && previousMode === "automatic" && total > 0) {
|
|
374
|
+
deps.setMode(previousMode);
|
|
375
|
+
await deps.switchToGuarded(ctx.modelRegistry);
|
|
376
|
+
}
|
|
377
|
+
ctx.ui.notify(
|
|
378
|
+
res.ok
|
|
379
|
+
? `Twin rimosso: ${parsed.provider}/${parsed.model} (attivi: ${total}).`
|
|
380
|
+
: `Niente da rimuovere: ${res.error}`,
|
|
381
|
+
res.ok ? "info" : "warning",
|
|
382
|
+
);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (sub === "off") {
|
|
387
|
+
const previousMode = deps.config().mode;
|
|
388
|
+
deps.setMode("on-demand");
|
|
389
|
+
if (!await leaveSelectedTwin()) {
|
|
390
|
+
deps.setMode(previousMode);
|
|
391
|
+
return;
|
|
279
392
|
}
|
|
393
|
+
const res = deps.removeGuarded();
|
|
280
394
|
ctx.ui.notify(
|
|
281
395
|
res.ok
|
|
282
|
-
?
|
|
396
|
+
? `Twin rimossi: ${res.removed ?? 0} (provider originali ripristinati), modalità on-demand.`
|
|
283
397
|
: `Niente da rimuovere: ${res.error}`,
|
|
284
398
|
res.ok ? "info" : "warning",
|
|
285
399
|
);
|
|
@@ -383,7 +497,7 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
383
497
|
// Punta la config al file e ricarica gli handle col nuovo stato.
|
|
384
498
|
cfg.jev.apiKeyFile = target;
|
|
385
499
|
try {
|
|
386
|
-
saveConfig(cfg);
|
|
500
|
+
saveConfig({ ...cfg, mode: deps.sharedConfig?.().mode ?? cfg.mode });
|
|
387
501
|
} catch {
|
|
388
502
|
// Resta in memoria anche se il salvataggio fallisce.
|
|
389
503
|
}
|
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,57 @@ 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
|
+
upstreamProvider: primary?.provider ?? "",
|
|
412
|
+
upstreamModel: primary?.model ?? "",
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function twinKey(ref: TwinRef): string {
|
|
418
|
+
return `${ref.provider}/${ref.model}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
355
421
|
export function saveConfig(config: JevConfig, configPath?: string): string {
|
|
356
422
|
const path = configPath ?? defaultConfigPath();
|
|
357
423
|
mkdirSync(dirname(path), { recursive: true });
|