pi-jev-guard 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,6 +54,29 @@ In chat, l'LLM può chiamare `jev_validate` per controlli mirati.
54
54
  /jev mode automatic # seleziona il primario + attiva enforcement
55
55
  ```
56
56
 
57
+ ### Backend e chiavi
58
+
59
+ Due backend, stessa policy e stesse regole:
60
+
61
+ | Backend | Endpoint | Variabile | Modello | Timeout |
62
+ |---|---|---|---|---|
63
+ | `openrouter` (default) | OpenRouter | `OPENROUTER_API_KEY` | `typesafe/jev-1.13` | 4000 ms |
64
+ | `typesafe` | SDK ufficiale `@typesafe-ai/sdk` → `api.typesafe.ai` | `TYPESAFE_API_KEY` | `jev-1.13.0` | 1500 ms |
65
+ | `auto` | sceglie il backend che ha una chiave (entrambe → OpenRouter) | — | — | — |
66
+
67
+ ```text
68
+ /jev backend # stato: backend, modello, chiavi rilevate
69
+ /jev backend typesafe # cambia backend (salvato in jev-config.json)
70
+ /jev save-key typesafe # salva la chiave del backend (file 600)
71
+ /jev save-key openrouter # l'altra chiave resta dov'è
72
+ /jev key-file # dove viene cercata la chiave
73
+ ```
74
+
75
+ Le chiavi stanno in file separati (`jev-api-key.<backend>.txt` accanto alla
76
+ config), quindi i due backend possono coesistere. Precedenza: variabile
77
+ d'ambiente → file del backend → file generico `jev.apiKeyFile` → percorso
78
+ canonico. Vedi `config/jev-config.example.json`.
79
+
57
80
  **Più twin insieme**: ogni `/jev upstream` *aggiunge* un modello guarded, senza
58
81
  rimuovere i precedenti. I twin vengono registrati anche all'avvio (i modelli
59
82
  dinamici come Codex sono letti dal models-store), quando gli originali sono disponibili.
@@ -76,8 +99,15 @@ ne rimuove uno solo, `/jev off` li rimuove tutti
76
99
  `/jev off` conserva il catalogo salvato: al reload gli alias tornano disponibili,
77
100
  ma il gate rimane disattivato.
78
101
 
79
- Se filtri i modelli con `enabledModels`, tieni le voci `__jev` dei twin che usi
80
- o restano nascosti.
102
+ Se filtri i modelli con `enabledModels`, usa i glob invece delle voci esplicite,
103
+ così i twin futuri non restano nascosti:
104
+
105
+ ```json
106
+ "enabledModels": ["openai-codex/*__jev", "deepseek/*__jev", "...altri pattern..."]
107
+ ```
108
+
109
+ Dopo aver aggiunto un twin serve `/reload` (lo scope è risolto all'avvio della sessione).
110
+ `/jev upstream` avvisa subito quando il twin appena creato è fuori dal picker.
81
111
  L'installazione punta alla cartella: dopo un aggiornamento del codice basta `/reload`.
82
112
 
83
113
  Headless/CI: `JEV_MODE=automatic JEV_AUTO_UPSTREAM=provider/model` crea e attiva
@@ -85,6 +115,11 @@ il guarded nelle sessioni senza uno stato Jev salvato.
85
115
 
86
116
  Un hold `[JEV — OUTPUT NON PUBBLICATO]` interrompe intenzionalmente il turno:
87
117
  controlla `/jev last`. Non viene aggirato con un proseguimento automatico.
118
+
119
+ Verdetto **incerto** (`review`): il gate tenta prima una riga riparazione privata
120
+ (`automatic.repairOnReview`, default `true`), poi applica la policy configurata:
121
+ `policy.onUncertain` e `policy.onUnavailable` (`"hold"` default, oppure `"pass"`).
122
+ Con `"pass"` il testo viene pubblicato con una nota di trasparenza sul verdetto.
88
123
  Le conferme per comandi di rete sospetti attendono invece una decisione esplicita;
89
124
  la normale sequenza tool → risposta successiva continua senza un nuovo prompt.
90
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jev-guard",
3
- "version": "0.2.1",
3
+ "version": "0.3.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": [
@@ -23,3 +23,7 @@ Run the appropriate compiler, static checks, or targeted tests separately.
23
23
  For ad-hoc typed judgements (classification, relevance, rubric scores) use
24
24
  `jev_ask` with model-defined questions instead; read p >= 0.90 as yes,
25
25
  p <= 0.20 as no, and choice/score below 0.60 confidence as uncertain.
26
+
27
+ Questa skill riguarda i tool di verifica di pi-jev-guard. Per progettare
28
+ integrazioni con TypeSafe (System One, SDK, cookbook) usa la skill ufficiale
29
+ `typesafe-ai` se installata.
@@ -130,6 +130,26 @@ export async function consumePrivate(
130
130
  return final;
131
131
  }
132
132
 
133
+ /** Aggiunge una riga di trasparenza sul verdetto non conclusivo. */
134
+ function uncertainNotice(verdict: ReviewResult): string {
135
+ const top = [...verdict.checks].sort((a, b) => b.pFlaw - a.pFlaw)[0];
136
+ const detail = top ? ` (${top.ruleId} p=${top.pFlaw.toFixed(3)})` : "";
137
+ const reason = verdict.errorCode ? ` errore=${verdict.errorCode}` : "";
138
+ return `\n\n> [jev] verdetto non conclusivo: ${verdict.status}${detail}${reason} — pubblicato per policy onUncertain/onUnavailable=pass.`;
139
+ }
140
+
141
+ /** Copia del candidato con la nota accodata all'ultimo blocco di testo. */
142
+ function withNotice(message: AssistantMessage, notice: string): AssistantMessage {
143
+ const content = message.content.map((block) => ({ ...block }));
144
+ const last = [...content].reverse().find((block) => block.type === "text");
145
+ if (last && last.type === "text") {
146
+ last.text = `${last.text}${notice}`;
147
+ } else {
148
+ content.push({ type: "text", text: notice.trimStart() });
149
+ }
150
+ return { ...message, content };
151
+ }
152
+
133
153
  function assertCompleteCandidate(message: AssistantMessage): void {
134
154
  // Rifiuta terminali non supportati per pubblicazione.
135
155
  if (
@@ -177,6 +197,10 @@ export function runGuardedStream(
177
197
  const usages: Usage[] = [];
178
198
  let attemptContext = originalContext;
179
199
  const maxRegenerations = deps.config.automatic.maxRegenerations;
200
+ // Un verdetto incerto non è un difetto accertato: un solo tentativo di
201
+ // riparazione mirata evita l'hold immediato su prosa borderline.
202
+ const maxReviewRepairs = deps.config.automatic.repairOnReview === false ? 0 : 1;
203
+ let reviewRepairs = 0;
180
204
 
181
205
  try {
182
206
  for (let attempt = 0; attempt <= maxRegenerations; attempt++) {
@@ -224,10 +248,11 @@ export function runGuardedStream(
224
248
  }
225
249
  signal?.throwIfAborted();
226
250
 
227
- if (verdict.status === "pass") {
251
+ const publishCandidate = async (notice?: string): Promise<void> => {
228
252
  deps.onVerdict?.({ verdict, attempts: attempt + 1, outcome: "published" });
253
+ const approved = notice ? withNotice(candidate, notice) : candidate;
229
254
  const aggregated = aggregateUsage(usages);
230
- const replayed = replayApproved(candidate, {
255
+ const replayed = replayApproved(approved, {
231
256
  provider: deps.publishedProvider,
232
257
  model: deps.publishedModel,
233
258
  usage: aggregated,
@@ -239,10 +264,19 @@ export function runGuardedStream(
239
264
  }
240
265
  const final = await replayed.result();
241
266
  out.end(final);
267
+ };
268
+
269
+ if (verdict.status === "pass") {
270
+ await publishCandidate();
242
271
  return;
243
272
  }
244
273
 
245
- if (verdict.status === "block" && attempt < maxRegenerations) {
274
+ const canRepair =
275
+ verdict.status === "block"
276
+ ? attempt < maxRegenerations
277
+ : verdict.status === "review" && reviewRepairs < maxReviewRepairs;
278
+ if (canRepair) {
279
+ if (verdict.status === "review") reviewRepairs++;
246
280
  attemptContext = buildRepairContext({
247
281
  originalContext,
248
282
  rejectedText: rawCandidate,
@@ -252,6 +286,15 @@ export function runGuardedStream(
252
286
  continue;
253
287
  }
254
288
 
289
+ // Policy esplicita per i verdetti non conclusivi (config, non silenziosa).
290
+ const uncertainPass = verdict.status === "review" && deps.config.policy.onUncertain === "pass";
291
+ const unavailablePass =
292
+ verdict.status === "unavailable" && deps.config.policy.onUnavailable === "pass";
293
+ if (uncertainPass || unavailablePass) {
294
+ await publishCandidate(uncertainNotice(verdict));
295
+ return;
296
+ }
297
+
255
298
  // Hold: pubblica failure sicura, mai il candidato bocciato.
256
299
  const attempts = attempt + 1;
257
300
  deps.onVerdict?.({ verdict, attempts, outcome: "held" });
package/src/commands.ts CHANGED
@@ -6,7 +6,9 @@ import { isTwinId, untwinId } from "./automatic/overlay.ts";
6
6
  import type { JevConfig, TwinRef } from "./config.ts";
7
7
  import {
8
8
  defaultConfigPath,
9
+ defaultKeyFileFor,
9
10
  expandHome,
11
+ keyFileCandidates,
10
12
  parseUpstreamRef,
11
13
  resolveBackend,
12
14
  saveConfig,
@@ -86,6 +88,7 @@ export const JEV_SUBCOMMANDS = [
86
88
  "reload",
87
89
  "output",
88
90
  "last",
91
+ "backend",
89
92
  "save-key",
90
93
  "help",
91
94
  ] as const;
@@ -108,7 +111,12 @@ export const JEV_HELP_TEXT = [
108
111
  " /jev stats Conteggi pass/block/review/unavailable + latenze",
109
112
  " /jev output Ultimo output giudicato: leak + classe errore",
110
113
  " /jev last Ultimo verdetto del gate (twin): esito e controlli",
111
- " /jev save-key Salva la chiave Jev su file (permessi 600)",
114
+ " /jev backend Mostra backend attivo e chiavi rilevate",
115
+ " /jev backend auto|openrouter|typesafe",
116
+ " Cambia backend (salvato in jev-config.json)",
117
+ " /jev save-key [backend] Salva la chiave del backend (default: attivo), 600",
118
+ " auto e typesafe tengono file separati",
119
+ " /jev key-file [backend] Mostra dove viene cercata la chiave",
112
120
  " /jev reload Ricarica jev-config.json da disco",
113
121
  " /jev help Questo aiuto",
114
122
  "",
@@ -133,6 +141,20 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
133
141
  const parts = args.trim().split(/\s+/).filter(Boolean);
134
142
  const sub = parts[0] ?? "status";
135
143
 
144
+ // Terza domanda del picker: registrato, visibile, selezionato. Il twin
145
+ // appena creato non è mai nello scope risolto a inizio sessione.
146
+ function twinInScope(provider: string, modelId: string): boolean {
147
+ try {
148
+ const scoped = ctx.scopedModels ?? [];
149
+ if (scoped.length === 0) return true;
150
+ return scoped.some(
151
+ (s) => s.model.provider === provider && s.model.id === `${modelId}__jev`,
152
+ );
153
+ } catch {
154
+ return true;
155
+ }
156
+ }
157
+
136
158
  // Never unregister the route while a synthetic model is still selected.
137
159
  // The caller must disable automatic enforcement before switching away.
138
160
  async function leaveSelectedTwin(): Promise<boolean> {
@@ -310,10 +332,15 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
310
332
  return;
311
333
  }
312
334
  const total = deps.twinStatus().active.length;
335
+ const visible = twinInScope(parsed.provider, parsed.model);
313
336
  ctx.ui.notify(
314
337
  `Twin aggiunto: ${res.twin} (totale attivi: ${total}, stessa auth del provider). ` +
315
- `Selezionalo con /model per enforcement in automatic.`,
316
- "info",
338
+ (visible
339
+ ? "Selezionalo con /model per enforcement in automatic."
340
+ : `ATTENZIONE: non compare in /model — enabledModels non lo copre. ` +
341
+ `Aggiungi "${parsed.provider}/*__jev" a enabledModels e fai /reload, ` +
342
+ `oppure attivalo direttamente con /jev mode automatic.`),
343
+ visible ? "info" : "warning",
317
344
  );
318
345
  return;
319
346
  }
@@ -336,7 +363,8 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
336
363
  const key = `${ref.provider}/${ref.model}`;
337
364
  const mark = activeKeys.has(key) ? "● " : "○ ";
338
365
  const star = key === primaryKey ? "★" : " ";
339
- lines.push(`${mark}${star} ${key}${activeKeys.has(key) ? "" : " (non attivo)"}`);
366
+ const hidden = twinInScope(ref.provider, ref.model) ? "" : " (fuori dal picker)";
367
+ lines.push(`${mark}${star} ${key}${activeKeys.has(key) ? "" : " (non attivo)"}${hidden}`);
340
368
  }
341
369
  ctx.ui.notify(
342
370
  [
@@ -458,11 +486,102 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
458
486
  return;
459
487
  }
460
488
 
489
+ if (sub === "backend") {
490
+ const cfg = deps.config();
491
+ const resolved = resolveBackend(cfg);
492
+ const requested = parts[1];
493
+ if (requested === undefined) {
494
+ const backendStatus = (["openrouter", "typesafe"] as const)
495
+ .map((b) => {
496
+ const key = resolveBackend({ ...cfg, jev: { ...cfg.jev, backend: b } });
497
+ const files = keyFileCandidates(cfg, b).map((f) => expandHome(f));
498
+ return ` ${b}: ${key.apiKeyPresent ? "chiave OK" : "chiave assente"} (${key.keySource})` +
499
+ (files.length > 0 ? `\n file: ${files.join(", ")}` : "");
500
+ })
501
+ .join("\n");
502
+ ctx.ui.notify(
503
+ [
504
+ `Backend: ${cfg.jev.backend} → effettivo ${resolved.backend} (${resolved.reason})`,
505
+ `Modello: ${resolved.model}`,
506
+ backendStatus,
507
+ "",
508
+ `Cambia con: /jev backend auto|openrouter|typesafe. Salva la chiave con /jev save-key ${resolved.backend}.`,
509
+ ].join("\n"),
510
+ "info",
511
+ );
512
+ return;
513
+ }
514
+ if (requested !== "auto" && requested !== "openrouter" && requested !== "typesafe") {
515
+ ctx.ui.notify("Uso: /jev backend auto|openrouter|typesafe", "warning");
516
+ return;
517
+ }
518
+ const shared = deps.sharedConfig?.() ?? cfg;
519
+ const next: JevConfig = {
520
+ ...shared,
521
+ jev: { ...shared.jev, backend: requested },
522
+ };
523
+ let savedPath: string | undefined;
524
+ try {
525
+ savedPath = saveConfig(next);
526
+ } catch (error) {
527
+ ctx.ui.notify(
528
+ `Jev: backend non salvato: ${error instanceof Error ? error.message : String(error)}`,
529
+ "error",
530
+ );
531
+ return;
532
+ }
533
+ const reloaded = deps.reload();
534
+ const after = resolveBackend(reloaded);
535
+ ctx.ui.notify(
536
+ [
537
+ `Backend impostato: ${requested} → effettivo ${after.backend} (${after.reason}).`,
538
+ `Modello: ${after.model}`,
539
+ after.apiKeyPresent
540
+ ? `Chiave: ${after.keySource}`
541
+ : `ATTENZIONE: nessuna chiave per ${requested}. Salvala con /jev save-key${requested === "auto" ? "" : ` ${requested}`}.`,
542
+ savedPath ? `Config: ${savedPath}` : "",
543
+ ]
544
+ .filter((l) => l !== "")
545
+ .join("\n"),
546
+ after.apiKeyPresent ? "info" : "warning",
547
+ );
548
+ return;
549
+ }
550
+
551
+ if (sub === "key-file") {
552
+ const cfg = deps.config();
553
+ const which = parts[1];
554
+ const backends = (["openrouter", "typesafe"] as const).filter(
555
+ (b) => which === undefined || which === b,
556
+ );
557
+ if (backends.length === 0) {
558
+ ctx.ui.notify("Uso: /jev key-file [openrouter|typesafe]", "warning");
559
+ return;
560
+ }
561
+ const lines = backends.map((b) => {
562
+ const files = keyFileCandidates(cfg, b);
563
+ return `${b}: ` + (files.length > 0
564
+ ? files.map((f) => expandHome(f)).join(" → ")
565
+ : `(nessun file; default /jev save-key ${b} → ${defaultKeyFileFor(b)})`);
566
+ });
567
+ ctx.ui.notify(
568
+ [...lines, "", "Precedenza: variabile d'ambiente, poi file specifico del backend, poi il file generico."].join("\n"),
569
+ "info",
570
+ );
571
+ return;
572
+ }
573
+
461
574
  if (sub === "save-key") {
462
575
  const cfg = deps.config();
463
- let target = cfg.jev.apiKeyFile?.trim()
464
- ? expandHome(cfg.jev.apiKeyFile.trim())
465
- : resolve(dirname(defaultConfigPath()), "jev-api-key.txt");
576
+ const which = parts[1];
577
+ const backend: "openrouter" | "typesafe" =
578
+ which === "openrouter" || which === "typesafe"
579
+ ? which
580
+ : resolveBackend(cfg).backend;
581
+ // Le chiavi sono per-backend: auto e typesafe possono coesistere.
582
+ let target = cfg.jev.apiKeyFiles?.[backend]?.trim()
583
+ ? expandHome(cfg.jev.apiKeyFiles[backend]!.trim())
584
+ : defaultKeyFileFor(backend);
466
585
  // Mai chiavi in tmp: dir effimera (pulizia automatica = chiave persa).
467
586
  // Può capitare con config stantie caricate in memoria prima di un fix.
468
587
  if (target === tmpdir() || target.startsWith(tmpdir() + "/")) {
@@ -470,12 +589,12 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
470
589
  `Jev: percorso chiave sotto tmp ignorato (${target}); uso quello canonico.`,
471
590
  "warning",
472
591
  );
473
- target = resolve(dirname(defaultConfigPath()), "jev-api-key.txt");
592
+ target = defaultKeyFileFor(backend);
474
593
  }
475
594
  let key: string | undefined;
476
595
  try {
477
596
  key = await ctx.ui.input(
478
- "Jev API key",
597
+ `Jev API key (${backend})`,
479
598
  "Incolla la chiave (potrebbe fare echo nel terminale)",
480
599
  );
481
600
  } catch {
@@ -494,20 +613,31 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
494
613
  ctx.ui.notify(`Jev: salvataggio fallito: ${error instanceof Error ? error.message : String(error)}`, "error");
495
614
  return;
496
615
  }
497
- // Punta la config al file e ricarica gli handle col nuovo stato.
498
- cfg.jev.apiKeyFile = target;
616
+ // Punta la config al file del backend e ricarica gli handle.
617
+ const shared = deps.sharedConfig?.() ?? cfg;
618
+ const next: JevConfig = {
619
+ ...shared,
620
+ jev: {
621
+ ...shared.jev,
622
+ apiKeyFiles: { ...shared.jev.apiKeyFiles, [backend]: target },
623
+ },
624
+ };
499
625
  try {
500
- saveConfig({ ...cfg, mode: deps.sharedConfig?.().mode ?? cfg.mode });
626
+ saveConfig(next);
501
627
  } catch {
502
628
  // Resta in memoria anche se il salvataggio fallisce.
503
629
  }
504
630
  const reloaded = deps.reload();
505
631
  const resolved = resolveBackend(reloaded);
632
+ const legacyNote =
633
+ shared.jev.apiKeyFile && shared.jev.apiKeyFile !== target
634
+ ? ` Nota: resta anche il file generico ${expandHome(shared.jev.apiKeyFile)} (usato come ripiego).`
635
+ : "";
506
636
  ctx.ui.notify(
507
- resolved.apiKeyPresent
508
- ? `Jev: chiave salvata in ${target} (600). Sorgente: ${resolved.keySource}.`
509
- : `Jev: file scritto ma chiave non rilevata (${resolved.keySource}).`,
510
- resolved.apiKeyPresent ? "info" : "warning",
637
+ resolved.backend === backend && resolved.apiKeyPresent
638
+ ? `Jev: chiave ${backend} salvata in ${target} (600). Sorgente: ${resolved.keySource}.${legacyNote}`
639
+ : `Jev: file scritto, ma il backend effettivo è ${resolved.backend} (${resolved.keySource}).${legacyNote}`,
640
+ resolved.backend === backend && resolved.apiKeyPresent ? "info" : "warning",
511
641
  );
512
642
  return;
513
643
  }
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
4
4
  import type { JevBackend } from "./reviewer.ts";
5
5
 
6
6
  export type JevMode = "on-demand" | "automatic";
@@ -20,6 +20,8 @@ export interface JevConfig {
20
20
  retryTransients: { enabled: boolean; maxRetries: number };
21
21
  /** Percorso file con la chiave per il backend attivo (alternativa all'env, `~/` espanso). */
22
22
  apiKeyFile?: string;
23
+ /** File chiave dedicati per backend: permettono di tenerle entrambe. */
24
+ apiKeyFiles?: { openrouter?: string; typesafe?: string };
23
25
  };
24
26
  policy: {
25
27
  revision: string;
@@ -31,6 +33,8 @@ export interface JevConfig {
31
33
  automatic: {
32
34
  transport: "provider-gate";
33
35
  maxRegenerations: number;
36
+ /** Tentativo privato extra quando il verdetto è incerto (review). */
37
+ repairOnReview: boolean;
34
38
  requireGuardedModel: boolean;
35
39
  /** Twin salvati: più modelli guarded insieme (uno o più per provider). */
36
40
  twins: TwinRef[];
@@ -121,6 +125,7 @@ export const DEFAULT_CONFIG: JevConfig = {
121
125
  automatic: {
122
126
  transport: "provider-gate",
123
127
  maxRegenerations: 2,
128
+ repairOnReview: true,
124
129
  requireGuardedModel: true,
125
130
  twins: [],
126
131
  // Vuoti di default: nessun twin "salvato" finché l'utente non ne aggiunge
@@ -274,28 +279,69 @@ interface BackendKey {
274
279
  source: string;
275
280
  }
276
281
 
282
+ /**
283
+ * File chiave da provare, in ordine: specifico del backend, generico,
284
+ * poi il percorso canonico per-backend (chiave lasciata lì senza config).
285
+ */
286
+ /** Solo i file dichiarati in config (errori di lettura da segnalare). */
287
+ export function configuredKeyFiles(config: JevConfig, backend: JevBackend): string[] {
288
+ const paths: string[] = [];
289
+ const push = (path: string | undefined) => {
290
+ const trimmed = path?.trim();
291
+ if (trimmed && !paths.includes(trimmed)) paths.push(trimmed);
292
+ };
293
+ push(config.jev.apiKeyFiles?.[backend]);
294
+ push(config.jev.apiKeyFile);
295
+ return paths;
296
+ }
297
+
298
+ export function keyFileCandidates(config: JevConfig, backend: JevBackend): string[] {
299
+ const paths = configuredKeyFiles(config, backend);
300
+ const canonical = defaultKeyFileFor(backend);
301
+ if (!paths.includes(canonical)) paths.push(canonical);
302
+ return paths;
303
+ }
304
+
277
305
  function resolveKeyFor(
278
306
  backend: JevBackend,
279
- apiKeyFile: string | undefined,
307
+ files: { candidates: string[]; configured: string[] },
280
308
  ): BackendKey {
281
309
  const envVar = backend === "typesafe" ? "TYPESAFE_API_KEY" : "OPENROUTER_API_KEY";
282
310
  const fromEnv = process.env[envVar]?.trim();
283
311
  if (fromEnv) return { key: fromEnv, source: `env:${envVar}` };
284
- const file = readKeyFile(apiKeyFile);
285
- if (file.key) return { key: file.key, source: `file:${file.tried}` };
286
- if (file.tried) {
287
- return { key: undefined, source: `none (file ${file.tried}: ${file.error})` };
312
+
313
+ const problems: string[] = [];
314
+ for (const path of files.candidates) {
315
+ const file = readKeyFile(path);
316
+ if (file.key) return { key: file.key, source: `file:${file.tried}` };
317
+ // Il percorso canonico mancante è atteso: non è un problema da mostrare.
318
+ if (file.error && files.configured.includes(path)) {
319
+ problems.push(`file ${file.tried}: ${file.error}`);
320
+ }
288
321
  }
289
- return { key: undefined, source: `none (set ${envVar} or jev.apiKeyFile)` };
322
+ if (problems.length > 0) {
323
+ return { key: undefined, source: `none (${problems.join("; ")})` };
324
+ }
325
+ return {
326
+ key: undefined,
327
+ source: `none (set ${envVar} or /jev save-key ${backend})`,
328
+ };
329
+ }
330
+
331
+ /** Percorso canonico del file chiave per un backend (accanto alla config). */
332
+ export function defaultKeyFileFor(backend: JevBackend): string {
333
+ return resolve(dirname(defaultConfigPath()), `jev-api-key.${backend}.txt`);
290
334
  }
291
335
 
292
336
  export function resolveBackend(config: JevConfig): ResolvedBackend {
293
337
  const setting = config.jev.backend;
294
- const keyFile = config.jev.apiKeyFile;
295
338
 
296
339
  if (setting === "typesafe" || setting === "openrouter") {
297
340
  const backend = setting;
298
- const resolved = resolveKeyFor(backend, keyFile);
341
+ const resolved = resolveKeyFor(backend, {
342
+ candidates: keyFileCandidates(config, backend),
343
+ configured: configuredKeyFiles(config, backend),
344
+ });
299
345
  return {
300
346
  backend,
301
347
  model:
@@ -314,8 +360,14 @@ export function resolveBackend(config: JevConfig): ResolvedBackend {
314
360
  }
315
361
 
316
362
  // auto: preferisci backend con chiave disponibile, default openrouter.
317
- const openRouterKey = resolveKeyFor("openrouter", keyFile);
318
- const typesafeKey = resolveKeyFor("typesafe", keyFile);
363
+ const openRouterKey = resolveKeyFor("openrouter", {
364
+ candidates: keyFileCandidates(config, "openrouter"),
365
+ configured: configuredKeyFiles(config, "openrouter"),
366
+ });
367
+ const typesafeKey = resolveKeyFor("typesafe", {
368
+ candidates: keyFileCandidates(config, "typesafe"),
369
+ configured: configuredKeyFiles(config, "typesafe"),
370
+ });
319
371
  const hasOpenRouter = Boolean(openRouterKey.key);
320
372
  const hasTypesafe = Boolean(typesafeKey.key);
321
373