@mmmbuto/nexuscrew 0.9.30 → 0.9.32

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.
@@ -433,8 +433,16 @@ const CREDENTIAL_SOURCES = Object.freeze(['environment', 'nexuscrew-store', 'aut
433
433
  // altro test, qui si garantisce la COPERTURA dei valori che il backend produce.
434
434
  // Chi aggiunge un valore in credential() aggiunge una riga qui e la stringa nei
435
435
  // tre dizionari, oppure il gate lo ferma.
436
+ // Le origini sono DISTINTE perche' l'operatore deve poterle distinguere: prima
437
+ // `providers.zsh`, i due file di chiavi e il file legacy si presentavano tutti
438
+ // come `compatibility`, e chi guardava non poteva sapere chi avesse fornito la
439
+ // chiave — ne' se erano due a fornirla, con valori diversi. `compatibility`
440
+ // resta nell'elenco come alias di lettura: non e' piu' prodotto, ma un client
441
+ // vecchio puo' ancora riceverlo e deve avere la sua stringa.
436
442
  const CREDENTIAL_SOURCE_VALUES = Object.freeze([
437
- 'login', 'none', 'environment', 'nexuscrew-store', 'local', 'compatibility', 'missing', 'unreadable',
443
+ 'login', 'none', 'environment', 'nexuscrew-store', 'local',
444
+ 'providers-shell', 'keys-file', 'secure-file', 'legacy',
445
+ 'compatibility', 'missing', 'unreadable',
438
446
  ]);
439
447
  const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', grok: 'Grok', vl: 'VL', pi: 'Pi', agy: 'Agy', kimi: 'Kimi Code CLI', shell: 'Shell' });
440
448
  const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
@@ -858,7 +866,21 @@ function parseEnvFile(file, opts = {}, out) {
858
866
  const st = fs.lstatSync(target);
859
867
  if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077) || st.size > 256 * 1024) return {};
860
868
  if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
861
- return parseAssignments(fs.readFileSync(target, 'utf8'));
869
+ const values = parseAssignments(fs.readFileSync(target, 'utf8'));
870
+ // Provenienza richiesta dal chiamante (vedi parseProviderKeyFiles). Si
871
+ // registra per CHIAVE, con l'impronta e non il valore, e solo per i file che
872
+ // il chiamante ha dichiarato di voler tracciare: gli altri restano com'e-
873
+ // rano.
874
+ if (out && out.provenance instanceof Map) {
875
+ const mtime = Math.floor(st.mtimeMs);
876
+ const source = opts.provenanceSource || 'file';
877
+ for (const [key, value] of Object.entries(values)) {
878
+ const list = out.provenance.get(key) || [];
879
+ list.push({ source, path: file, mtime, hash8: hash8Of(value) });
880
+ out.provenance.set(key, list);
881
+ }
882
+ }
883
+ return values;
862
884
  } catch (e) {
863
885
  if (blocked && e.code !== 'ENOENT') blocked.push({ path: file, code: e.code || e.constructor.name });
864
886
  return {};
@@ -871,7 +893,20 @@ function parseProviderShellFile(file, out) {
871
893
  const st = fs.lstatSync(file);
872
894
  if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o022) || st.size > 256 * 1024) return {};
873
895
  if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
874
- return parseAssignments(fs.readFileSync(file, 'utf8'));
896
+ const values = parseAssignments(fs.readFileSync(file, 'utf8'));
897
+ // Stessa registrazione della provenienza dei file di chiavi: la shell sta
898
+ // PIU' IN ALTO nella precedenza, quindi una providers.zsh stantia produce
899
+ // lo stesso guasto dei file — a parti invertite — e va vista allo stesso
900
+ // modo. Il valore non entra qui: solo la sua impronta.
901
+ if (out && out.provenance instanceof Map) {
902
+ const mtime = Math.floor(st.mtimeMs);
903
+ for (const [key, value] of Object.entries(values)) {
904
+ const list = out.provenance.get(key) || [];
905
+ list.push({ source: 'providers-shell', path: file, mtime, hash8: hash8Of(value) });
906
+ out.provenance.set(key, list);
907
+ }
908
+ }
909
+ return values;
875
910
  } catch (e) {
876
911
  if (blocked && e.code !== 'ENOENT') blocked.push({ path: file, code: e.code || e.constructor.name });
877
912
  return {};
@@ -1001,6 +1036,32 @@ function shellProvidersPath(cfg, home) {
1001
1036
  || path.join(home, '.config', 'ai-shell', 'providers.zsh');
1002
1037
  }
1003
1038
 
1039
+ // L'impronta di un valore, MAI il valore. Serve a dire «due sorgenti hanno
1040
+ // questa variabile e il contenuto e' DIVERSO» senza dire cosa contiene: e' la
1041
+ // sola forma che rende visibile un conflitto restando sicura da mostrare, da
1042
+ // scrivere in un log e da mettere in una risposta API. Il valore entra in
1043
+ // questa funzione e non ne esce: qui si calcola, si prende l'impronta e si
1044
+ // scarta. Chi aggiungera' campi a questa struttura non aggiunga il valore.
1045
+ function hash8Of(text) {
1046
+ return crypto.createHash('sha256').update(String(text)).digest('hex').slice(0, 8);
1047
+ }
1048
+
1049
+ // I conflitti del SOLO gruppo dei file di chiavi: per ogni variabile definita in
1050
+ // piu' di un file, l'ultimo vince (e' la precedenza dichiarata) ma se il
1051
+ // contenuto differisce l'operatore deve saperlo. Due file con la STESSA impronta
1052
+ // non sono un conflitto: e' la stessa chiave in due posti.
1053
+ function findCredentialConflicts(provenance) {
1054
+ const conflicts = [];
1055
+ if (!(provenance instanceof Map)) return conflicts;
1056
+ for (const [envKey, list] of provenance) {
1057
+ if (!Array.isArray(list) || list.length < 2) continue;
1058
+ const winner = list[list.length - 1];
1059
+ const others = list.slice(0, -1).filter((e) => e && e.hash8 !== winner.hash8);
1060
+ if (others.length) conflicts.push({ envKey, winner, others });
1061
+ }
1062
+ return conflicts;
1063
+ }
1064
+
1004
1065
  function providerKeyPaths(cfg, home) {
1005
1066
  const paths = [
1006
1067
  cfg.providerKeysPath || process.env.NEXUSCREW_PROVIDER_KEYS
@@ -1018,7 +1079,14 @@ function parseProviderKeyFiles(cfg, home, out) {
1018
1079
  // private regular files owned by the NexusCrew user.
1019
1080
  const files = providerKeyPaths(cfg, home);
1020
1081
  const roots = [...new Set(files.map((file) => path.dirname(path.resolve(file))))];
1021
- for (const file of files) Object.assign(values, parseEnvFile(file, { allowSymlinkRoots: roots }, out));
1082
+ // La provenienza si traccia QUI, e solo per questi file: e' l'unico punto che
1083
+ // li vede in ordine e che sa quale ha fornito ogni chiave. L'etichetta segue
1084
+ // l'ORDINE: l'ultimo file e' il file di override, e con un solo path
1085
+ // configurato non c'e' nessun override da nominare.
1086
+ for (let i = 0; i < files.length; i += 1) {
1087
+ const source = files.length > 1 && i === files.length - 1 ? 'secure-file' : 'keys-file';
1088
+ Object.assign(values, parseEnvFile(files[i], { allowSymlinkRoots: roots, provenanceSource: source }, out));
1089
+ }
1022
1090
  return values;
1023
1091
  }
1024
1092
 
@@ -1031,12 +1099,28 @@ function parseProviderKeyFiles(cfg, home, out) {
1031
1099
  function credentialSources(cfg, home, out, { trackLegacy = true } = {}) {
1032
1100
  let local = {};
1033
1101
  try { local = readCredentialStore(cfg, home); } catch (_) { /* unsafe/corrupt store is ignored, never trusted */ }
1102
+ // UNA sola mappa di provenienza, riempita NELL'ORDINE DI PRECEDENZA: prima i
1103
+ // file di chiavi (il piu' debole, poi l'override), poi la shell, che li
1104
+ // scavalca. L'ultimo che ha scritto una variabile e' chi vince davvero, ed e'
1105
+ // quello che il conflitto deve nominare. Il `blocked` del chiamante resta
1106
+ // condiviso: l'illeggibilita' continua a essere tracciata come prima.
1107
+ const provenance = new Map();
1108
+ const bag = { blocked: out && Array.isArray(out.blocked) ? out.blocked : null, provenance };
1109
+ const keys = parseProviderKeyFiles(cfg, home, bag);
1110
+ const shell = parseProviderShellFile(shellProvidersPath(cfg, home), bag);
1111
+ const conflicts = findCredentialConflicts(provenance);
1112
+ if (out) { out.provenance = provenance; out.conflicts = conflicts; }
1034
1113
  return {
1035
1114
  runtime: cfg.env || process.env,
1036
1115
  local,
1037
- shell: parseProviderShellFile(shellProvidersPath(cfg, home), out),
1038
- keys: parseProviderKeyFiles(cfg, home, out),
1116
+ shell,
1117
+ keys,
1039
1118
  legacy: parseEnvFile(secretsPath(cfg, home), {}, trackLegacy ? out : undefined),
1119
+ // Ordine REALE dei file di chiavi: l'ultimo vince. Serve a dire DA QUALE
1120
+ // file viene la chiave vincente, non solo da quale gruppo.
1121
+ keyPaths: providerKeyPaths(cfg, home),
1122
+ provenance,
1123
+ conflicts,
1040
1124
  };
1041
1125
  }
1042
1126
 
@@ -1059,10 +1143,33 @@ function credential(profile, spec, cfg, home, out) {
1059
1143
  // auto: legacy resolution order (runtime -> store -> shell -> keys -> legacy).
1060
1144
  if (sources.runtime[envKey]) return { envKey, value: sources.runtime[envKey], source: 'environment' };
1061
1145
  if (sources.local[envKey]) return { envKey, value: sources.local[envKey], source: 'local' };
1062
- if (sources.shell[envKey]) return { envKey, value: sources.shell[envKey], source: 'compatibility' };
1063
- if (sources.keys[envKey]) return { envKey, value: sources.keys[envKey], source: 'compatibility' };
1146
+ if (sources.shell[envKey]) {
1147
+ // Il file della shell: prima condivideva l'etichetta con i file di chiavi e
1148
+ // col legacy, quindi non si poteva sapere da dove venisse la chiave. E'
1149
+ // un FILE anche lui, quindi porta la sua provenienza come gli altri.
1150
+ const trailShell = sources.provenance && sources.provenance.get(envKey);
1151
+ const winShell = Array.isArray(trailShell) ? trailShell.find((e) => e.source === 'providers-shell') : null;
1152
+ return {
1153
+ envKey, value: sources.shell[envKey], source: 'providers-shell',
1154
+ ...(winShell ? { path: winShell.path, mtime: winShell.mtime, hash8: winShell.hash8 } : {}),
1155
+ };
1156
+ }
1157
+ if (sources.keys[envKey]) {
1158
+ // Quale DEI DUE file ha fornito la chiave: l'ultimo dell'ordine vince, e se
1159
+ // e' il file di override la provenienza lo dice. Con l'impronta e l'mtime,
1160
+ // mai col valore.
1161
+ const trail = sources.provenance && sources.provenance.get(envKey);
1162
+ const win = Array.isArray(trail) && trail.length ? trail[trail.length - 1] : null;
1163
+ const paths = Array.isArray(sources.keyPaths) ? sources.keyPaths : [];
1164
+ const secure = !!(win && paths.length > 1 && win.path === paths[paths.length - 1]);
1165
+ return {
1166
+ envKey, value: sources.keys[envKey],
1167
+ source: secure ? 'secure-file' : 'keys-file',
1168
+ ...(win ? { path: win.path, mtime: win.mtime, hash8: win.hash8 } : {}),
1169
+ };
1170
+ }
1064
1171
  if (profile.legacySecrets && sources.legacy[envKey]) {
1065
- return { envKey, value: sources.legacy[envKey], source: 'compatibility' };
1172
+ return { envKey, value: sources.legacy[envKey], source: 'legacy' };
1066
1173
  }
1067
1174
  // auto: nessuna fonte ha la chiave. Se un file credenziale esiste ma non si
1068
1175
  // e' potuto leggere (EACCES/ELOOP...), non possiamo dichiarare la chiave
@@ -1221,7 +1328,10 @@ function describeManaged(spec, cfg = {}) {
1221
1328
  const binary = normalized.client === 'shell'
1222
1329
  ? resolveInteractiveShell({ ...cfg, home }, { blocked: binaryBlocked })
1223
1330
  : findBinary(normalized.client, home, { blocked: binaryBlocked });
1224
- const cred = credential(profile, normalized, cfg, home, { blocked: credBlocked });
1331
+ // La scatola e' tenuta: dopo la chiamata porta anche i CONFLITTI fra file di
1332
+ // chiavi, calcolati dove i file si leggono in ordine.
1333
+ const credOut = { blocked: credBlocked };
1334
+ const cred = credential(profile, normalized, cfg, home, credOut);
1225
1335
  // Pi can resolve credentials from its own documented /login auth store. Do
1226
1336
  // not inspect or copy that store; delegate native-provider auth to Pi.
1227
1337
  const delegatedPiAuth = profile.client === 'pi' && profile.provider !== 'custom'
@@ -1256,6 +1366,38 @@ function describeManaged(spec, cfg = {}) {
1256
1366
  reason = `${normalized.client} non supportato su questa piattaforma (usa shell.local con command ${normalized.client})`;
1257
1367
  }
1258
1368
  }
1369
+ // Endpoint dichiarato A MANO: l'esistenza del binario e della credenziale non
1370
+ // dice se l'indirizzo risponde. Un router spento risultava `ready` e lo si
1371
+ // scopriva facendo partire la cella. Il verdetto arriva dal chiamante quando
1372
+ // ha potuto attendere la sonda (`cfg.endpointVerdict`), oppure dalla cache
1373
+ // (lettura sincrona, mai bloccante). Gli engine di catalogo non si sondano:
1374
+ // li' l'indirizzo non e' una scelta di questo nodo.
1375
+ if (configured && normalized.baseUrl) {
1376
+ let verdict = cfg.endpointVerdict && typeof cfg.endpointVerdict === 'object' ? cfg.endpointVerdict : null;
1377
+ if (!verdict && cfg.endpointProbe && typeof cfg.endpointProbe.ensure === 'function') {
1378
+ try { verdict = cfg.endpointProbe.ensure(normalized.baseUrl); } catch (_) { verdict = null; }
1379
+ }
1380
+ if (verdict && verdict.state === 'unreachable') {
1381
+ configured = false;
1382
+ reason = verdict.reason || `endpoint unreachable: ${normalized.baseUrl}`;
1383
+ } else if (verdict && verdict.state === 'ready') {
1384
+ reason = 'ready';
1385
+ }
1386
+ }
1387
+ // Conflitto fra i file di chiavi: l'engine RESTA configurato — una chiave
1388
+ // valida c'e' e il client puo' partire — ma chi guarda deve sapere che la
1389
+ // stessa variabile e' definita altrove con un contenuto diverso. E' il caso
1390
+ // che sul campo si e' presentato come «la cella usa una chiave revocata»:
1391
+ // senza questo, l'unico modo di accorgersene era confrontare gli hash a mano.
1392
+ // Le impronte, mai i valori.
1393
+ if (configured) {
1394
+ const conflitti = Array.isArray(credOut.conflicts) ? credOut.conflicts : [];
1395
+ const mio = conflitti.find((c) => c && c.envKey === cred.envKey);
1396
+ if (mio && mio.others && mio.others.length) {
1397
+ const altri = mio.others.map((o) => o.path).join(', ');
1398
+ reason = `${reason === 'ready' ? 'ready ' : reason + ' '}(credential ${cred.envKey} also defined in ${altri} with a different value)`;
1399
+ }
1400
+ }
1259
1401
  return {
1260
1402
  client: profile.client, clientLabel: CLIENT_LABELS[profile.client], provider: profile.provider,
1261
1403
  credentialProfile: normalized.credentialProfile || '', model: normalized.model,
@@ -1267,7 +1409,29 @@ function describeManaged(spec, cfg = {}) {
1267
1409
  // (source 'unreadable', authConfigured false) non viene collassata in
1268
1410
  // 'missing' — il discriminante e' CHI ha fallito, non il esito grezzo.
1269
1411
  credentialSource: cred.source,
1270
- configured, models: [...(profile.models || [])], defaultModel: profile.model || '',
1412
+ // Provenienza della credenziale risolta: path, mtime e impronta del valore
1413
+ // (MAI il valore). Presenti solo quando la sorgente e' un file.
1414
+ credentialPath: cred.path || '',
1415
+ credentialMtime: cred.mtime || 0,
1416
+ credentialHash8: cred.hash8 || '',
1417
+ // Conflitto fra i file di chiavi: la stessa variabile definita in piu' file
1418
+ // con contenuto diverso. `null` quando non c'e' — o quando i file dicono la
1419
+ // stessa cosa, che non e' un conflitto.
1420
+ credentialConflict: (() => {
1421
+ const list = Array.isArray(credOut.conflicts) ? credOut.conflicts : [];
1422
+ return list.find((c) => c && c.envKey === cred.envKey) || null;
1423
+ })(),
1424
+ configured,
1425
+ // Catalogo builtin del profilo PIU' i modelli dichiarati in
1426
+ // `definitions.models[]` per questo engine (o, legacy, per il profilo).
1427
+ // Senza l'unione un profilo custom — che catalogo non ha — elencava `[]`
1428
+ // anche con due modelli dichiarati: il boot li accettava (declaredFor) ma
1429
+ // la finestra, che legge da qui, non li mostrava.
1430
+ models: [...new Set([
1431
+ ...(profile.models || []),
1432
+ ...declaredModelsFor(extraModels, profile.id, cfg.engineId || null).map((m) => m.id),
1433
+ ])],
1434
+ defaultModel: profile.model || '',
1271
1435
  binary: binary || '', displayName: normalized.displayName || profile.label,
1272
1436
  reason,
1273
1437
  };
@@ -6,11 +6,20 @@
6
6
  // dice se il problema e' il nome del modello, la chiave o la rete. Qui la
7
7
  // domanda si fa prima, e la risposta e' un enum chiuso.
8
8
  //
9
- // COSTO: si interroga l'elenco dei modelli (`GET .../models`) e nient'altro.
10
- // Non si genera testo, quindi non si consumano token. Dove il catalogo non
11
- // esiste l'esito e' `unverified`: NON si ricade su una richiesta di
12
- // completamento, per quanto minima. Una prova che costa e' una prova che chi
13
- // la guarda impara a non fare — e allora tanto vale non averla.
9
+ // COSTO, per gli engine DI CATALOGO: si interroga l'elenco dei modelli
10
+ // (`GET .../models`) e nient'altro. Non si genera testo, quindi non si
11
+ // consumano token. Dove il catalogo non esiste l'esito e' `unverified`: NON si
12
+ // ricade su una richiesta di completamento, per quanto minima. Una prova che
13
+ // costa e' una prova che chi la guarda impara a non fare — e allora tanto vale
14
+ // non averla.
15
+ //
16
+ // COSTO, per un endpoint DICHIARATO A MANO (`probeCustomEndpoint`): la regola
17
+ // sopra vale finche' il catalogo c'e'. Un router locale spesso non espone
18
+ // `GET /models`, e su quel ramo l'alternativa a una richiesta minima non e' una
19
+ // prova gratuita — e' nessuna prova, cioe' un `unverified` su un modello che
20
+ // quasi certamente esiste. Li' si ricade su UN completamento da un token
21
+ // (`max_tokens: 1`) e il testo generato non viene letto ne' registrato: la
22
+ // risposta serve solo a distinguere «c'e'» da «non c'e'».
14
23
  //
15
24
  // COSA NON ESCE MAI DA QUI:
16
25
  // - il testo che il modello eventualmente genera: non viene letto, non viene
@@ -44,6 +53,98 @@ function modelsUrl(profile) {
44
53
  return joinUrl(endpoint, 'models');
45
54
  }
46
55
 
56
+ // Un endpoint dichiarato A MANO non sta nel catalogo pubblico: la prova si fa
57
+ // sul suo indirizzo. Stessa forma di URL della sonda di prontezza (`/v1` non si
58
+ // ripete), stesso verdetto enum della prova di catalogo — chi legge l'esito non
59
+ // deve sapere da quale dei due rami e' arrivato.
60
+ function chatCompletionsUrl(baseUrl) {
61
+ const b = String(baseUrl || '').trim().replace(/\/+$/, '');
62
+ if (!/^https?:\/\//i.test(b)) return null;
63
+ return /\/v1$/.test(b) ? `${b}/chat/completions` : `${b}/v1/chat/completions`;
64
+ }
65
+
66
+ // Un endpoint locale spesso non espone `GET /models` (o lo espone vuoto). La
67
+ // seconda via e' la richiesta minima: `max_tokens: 1`, nessun testo letto, e il
68
+ // corpo della risposta NON entra nell'esito. Serve a distinguere «il modello
69
+ // c'e'» da «il modello non c'e'», non a misurare la qualita' della risposta.
70
+ const CUSTOM_FALLBACK_TIMEOUT_MS = 10000;
71
+
72
+ async function probeCustomEndpoint({
73
+ endpoint, credential = '', model, fetchImpl = fetch,
74
+ timeoutMs = DEFAULT_TIMEOUT_MS, fallbackTimeoutMs = CUSTOM_FALLBACK_TIMEOUT_MS,
75
+ } = {}) {
76
+ const listUrl = modelsUrl({ endpoint });
77
+ if (!listUrl) return { outcome: 'unverified', latencyMs: 0, detail: 'endpoint non interrogabile' };
78
+
79
+ const started = Date.now();
80
+ const headers = credential ? { authorization: `Bearer ${credential}` } : {};
81
+ const list = await timedFetch({
82
+ fetchImpl, url: listUrl, method: 'GET', headers, timeoutMs,
83
+ });
84
+ if (list.transport === 'timeout') return { outcome: 'unreachable', latencyMs: list.latencyMs, detail: `timeout (${timeoutMs}ms)` };
85
+ if (list.transport === 'error') return { outcome: 'unreachable', latencyMs: list.latencyMs, detail: 'endpoint non raggiungibile' };
86
+ if (list.status === 401 || list.status === 403) return { outcome: 'auth', latencyMs: list.latencyMs };
87
+ if (list.status >= 200 && list.status < 300) {
88
+ // Un elenco VUOTO non e' «il modello non c'e'»: e' un endpoint che non
89
+ // espone un catalogo. Trattarlo come assenza darebbe un falso negativo su
90
+ // un modello che risponde benissimo — il caso tipico dei router locali.
91
+ const rows = Array.isArray(list.payload && list.payload.data) ? list.payload.data
92
+ : Array.isArray(list.payload && list.payload.models) ? list.payload.models : null;
93
+ if (rows && rows.length) {
94
+ const found = findInCatalog(list.payload, model);
95
+ if (found === true) return { outcome: 'ok', latencyMs: list.latencyMs };
96
+ if (found === false) return { outcome: 'unknown-model', latencyMs: list.latencyMs };
97
+ }
98
+ // Elenco assente, vuoto o illeggibile: si prova la via minima invece di
99
+ // dichiarare `unverified` un modello che probabilmente c'e'.
100
+ } else if (list.status >= 500) {
101
+ return { outcome: 'unreachable', latencyMs: list.latencyMs, detail: `http ${list.status}` };
102
+ }
103
+
104
+ const chatUrl = chatCompletionsUrl(endpoint);
105
+ if (!chatUrl) return { outcome: 'unverified', latencyMs: Date.now() - started, detail: 'endpoint non interrogabile' };
106
+ const chat = await timedFetch({
107
+ fetchImpl,
108
+ url: chatUrl,
109
+ method: 'POST',
110
+ headers: { 'content-type': 'application/json', ...headers },
111
+ body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }),
112
+ timeoutMs: fallbackTimeoutMs,
113
+ });
114
+ const latencyMs = Date.now() - started;
115
+ if (chat.transport === 'timeout') return { outcome: 'unreachable', latencyMs, detail: `timeout (${fallbackTimeoutMs}ms)` };
116
+ if (chat.transport === 'error') return { outcome: 'unreachable', latencyMs, detail: 'endpoint non raggiungibile' };
117
+ if (chat.status === 401 || chat.status === 403) return { outcome: 'auth', latencyMs };
118
+ if (chat.status >= 200 && chat.status < 300) return { outcome: 'ok', latencyMs };
119
+ // 400/404 su un modello chiesto per nome: l'endpoint risponde e non lo
120
+ // conosce. E' l'esito piu' utile che si possa dare senza leggere il corpo.
121
+ if (chat.status === 400 || chat.status === 404 || chat.status === 422) return { outcome: 'unknown-model', latencyMs };
122
+ return { outcome: 'unverified', latencyMs, detail: `http ${chat.status}` };
123
+ }
124
+
125
+ // Una fetch sola, con budget proprio, che non solleva mai: l'esito e' un valore.
126
+ async function timedFetch({ fetchImpl, url, method, headers, body, timeoutMs }) {
127
+ const started = Date.now();
128
+ const controller = new AbortController();
129
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
130
+ try {
131
+ const res = await fetchImpl(url, {
132
+ method, signal: controller.signal, headers, ...(body ? { body } : {}),
133
+ });
134
+ const status = res && typeof res.status === 'number' ? res.status : 0;
135
+ let payload = null;
136
+ if (status >= 200 && status < 300) {
137
+ try { payload = typeof res.json === 'function' ? await res.json() : null; } catch (_) { payload = null; }
138
+ }
139
+ return { status, payload, latencyMs: Date.now() - started };
140
+ } catch (error) {
141
+ const aborted = error && (error.name === 'AbortError' || error.code === 'ABORT_ERR');
142
+ return { status: 0, payload: null, latencyMs: Date.now() - started, transport: aborted ? 'timeout' : 'error' };
143
+ } finally {
144
+ clearTimeout(timer);
145
+ }
146
+ }
147
+
47
148
  function authHeaders(profile, credential) {
48
149
  const protocol = profile && profile.protocol;
49
150
  if (protocol === 'anthropic_messages') {
@@ -126,4 +227,7 @@ async function probeModel({
126
227
  }
127
228
  }
128
229
 
129
- module.exports = { probeModel, OUTCOMES, findInCatalog, modelsUrl };
230
+ module.exports = {
231
+ probeModel, OUTCOMES, findInCatalog, modelsUrl,
232
+ probeCustomEndpoint, chatCompletionsUrl, CUSTOM_FALLBACK_TIMEOUT_MS,
233
+ };
@@ -20,6 +20,7 @@ const {
20
20
  const {
21
21
  describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels, extraModelsFrom,
22
22
  } = require('./managed.js');
23
+ const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
23
24
  const {
24
25
  httpError, minimalEnv, tmuxExec,
25
26
  composeClientInvocation, alternateScreenArgs,
@@ -170,12 +171,29 @@ function createBuiltinRuntime(ctx) {
170
171
  // Le discovery esterne hanno budget propri. Avviarle in parallelo mantiene
171
172
  // il budget dello status sotto quello del bridge invece di sommare i timeout
172
173
  // di Ollama e Pi in sequenza.
173
- const [ollamaModels, piModels] = await Promise.all([
174
+ // Stessa logica per gli endpoint dichiarati a mano: la sonda ha un budget
175
+ // suo (≤ 1,5 s) e va in parallelo alle discovery, non in coda. Si attende
176
+ // SOLO cio' che non e' in cache: entro il TTL il verdetto e' gia' noto, ed
177
+ // e' questo che tiene `nc_status` e la UI lontani dal tempestare i router.
178
+ const endpointProbe = cfg.endpointProbe || defaultEndpointProbe;
179
+ const customUrls = [...new Set(cache.defs.engines
180
+ .map((e) => (e.managed && e.managed.baseUrl ? e.managed.baseUrl : null))
181
+ .filter(Boolean))];
182
+ const endpointVerdicts = new Map();
183
+ const probeEndpoints = customUrls.length
184
+ ? Promise.all(customUrls.map((u) => endpointProbe.status(u).then((v) => [u, v]).catch(() => [u, null])))
185
+ : Promise.resolve([]);
186
+ const [ollamaModels, piModels, probedEndpoints] = await Promise.all([
174
187
  needsOllama ? discoverOllamaModels({ ...cfg, home }) : [],
175
188
  needsPi ? discoverPiModels({ ...cfg, home }) : {},
189
+ probeEndpoints,
176
190
  ]);
191
+ for (const [u, v] of probedEndpoints) endpointVerdicts.set(u, v);
177
192
  const engines = cache.defs.engines.map((e) => {
178
- const managed = e.managed ? describeManaged(e.managed, { ...cfg, home, extraModels: extraModelsFrom(cache.defs) }) : null;
193
+ const managed = e.managed ? describeManaged(e.managed, {
194
+ ...cfg, home, extraModels: extraModelsFrom(cache.defs), engineId: e.id,
195
+ endpointVerdict: e.managed && e.managed.baseUrl ? endpointVerdicts.get(e.managed.baseUrl) || null : null,
196
+ }) : null;
179
197
  return {
180
198
  id: e.id, label: e.label, rc: !!e.rc,
181
199
  ...(managed ? {
@@ -184,12 +202,21 @@ function createBuiltinRuntime(ctx) {
184
202
  models: managed.provider === 'ollama-cloud' ? ollamaModels
185
203
  : (managed.client === 'pi'
186
204
  ? (e.managed.provider === 'custom'
187
- ? [e.managed.model]
205
+ ? [...new Set([e.managed.model, ...managed.models].filter(Boolean))]
188
206
  : (e.managed.provider === 'native'
189
207
  ? [...new Set(Object.values(piModels).flat())]
190
208
  : (piModels[e.managed.provider] || [])))
191
209
  : managed.models),
192
210
  configured: managed.configured, reason: managed.reason,
211
+ // Provenienza della credenziale risolta: prima non arrivava affatto
212
+ // qui, quindi `nc_status` e la vista non potevano distinguere le
213
+ // origini nemmeno in teoria. Path, mtime e impronta del valore — il
214
+ // valore non esce mai da `managed.js`.
215
+ credentialSource: managed.credentialSource || 'missing',
216
+ credentialPath: managed.credentialPath || '',
217
+ credentialMtime: managed.credentialMtime || 0,
218
+ credentialHash8: managed.credentialHash8 || '',
219
+ credentialConflict: managed.credentialConflict || null,
193
220
  } : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
194
221
  };
195
222
  });
@@ -61,8 +61,12 @@ function createAskAnswerService({ asks, paste, receipts, onClosure, labelPrefix
61
61
  const committed = asks.commit(askId, text);
62
62
  if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'committed', null);
63
63
  const finalAsk = asks.get(askId);
64
- emitClosure('ask-answered', { askId, revision: (finalAsk && finalAsk.revision) || 1, cellSession: finalAsk && finalAsk.session });
65
- return { ok: true, committed };
64
+ const closure = emitClosure('ask-answered', { askId, revision: (finalAsk && finalAsk.revision) || 1, cellSession: finalAsk && finalAsk.session });
65
+ // `closure` e' il lavoro di recapito della chiusura verso i peer. Nasce QUI,
66
+ // nel punto in cui la transizione e' autorevole, e non su una rotta: legarlo
67
+ // alle route locali lasciava fuori la via federata, che e' il caso normale
68
+ // quando a rispondere e' un altro nodo. Chi puo' attendere lo attende.
69
+ return { ok: true, committed, closure };
66
70
  }
67
71
 
68
72
  // LOCAL answer: validation and binding already happened in the route.
@@ -115,8 +119,10 @@ function createAskAnswerService({ asks, paste, receipts, onClosure, labelPrefix
115
119
  }
116
120
  const out = asks.dismiss(askId);
117
121
  if (!out.ok) return out;
118
- emitClosure('ask-dismissed', { askId, revision: (out.ask && out.ask.revision) || 1, cellSession: out.ask && out.ask.session });
119
- return out;
122
+ const closure = emitClosure('ask-dismissed', { askId, revision: (out.ask && out.ask.revision) || 1, cellSession: out.ask && out.ask.session });
123
+ // Stesso punto comune dell'answer. `dismiss` resta SINCRONO — i chiamanti
124
+ // esistenti leggono `ok` subito — e il recapito viaggia come promise a parte.
125
+ return { ...out, closure };
120
126
  }
121
127
 
122
128
  // Operator reconciliation of a delivery-unknown attempt. The revision is a
@@ -13,6 +13,10 @@ const ASKS_FILE = 'asks.json';
13
13
  // RIFIUTATO (reason 'cap'), mai droppato uno aperto. MAX_KEEP pota solo gli
14
14
  // answered piu' vecchi dal file.
15
15
  const MAX_OPEN = 100;
16
+ // Cap degli ask IMPORTATI, separato da quello locale (stessa cifra): le domande
17
+ // che arrivano da un peer e quelle poste da questo nodo sono due classi
18
+ // distinte, e le prime non devono poter esaurire il budget delle seconde.
19
+ const MAX_OPEN_IMPORTED = 100;
16
20
  const MAX_KEEP = 100; // ask totali persistiti (i piu' vecchi answered si potano)
17
21
  const MAX_QUESTION = 2000;
18
22
  const MAX_OPTIONS = 8;
@@ -65,19 +69,58 @@ function createAsksStore(opts = {}) {
65
69
  return { ok: true, value: { question: question.trim(), options: opts2 } };
66
70
  }
67
71
 
68
- function openCount() {
69
- return load().filter((a) => !a.answered && !a.dismissed).length;
72
+ // Un ask IMPORTATO appartiene a un altro nodo: lo marca `originNode`, che e'
73
+ // anche il marcatore che ne impedisce la ri-esportazione.
74
+ function isImported(a) { return !!(a && a.originNode); }
75
+
76
+ function openCount(kind = 'local') {
77
+ return load().filter((a) => !a.answered && !a.dismissed
78
+ && (kind === 'imported' ? isImported(a) : !isImported(a))).length;
79
+ }
80
+
81
+ // Identita' CANONICA di un ask importato: la coppia (ownerId, ownerAskId).
82
+ // L'`id` locale e' nostro e all'owner non dice nulla; e' la coppia che nomina
83
+ // lo STESSO oggetto sui due nodi, quindi e' la chiave con cui si riconcilia.
84
+ function findImported(ownerId, ownerAskId) {
85
+ return load().find((a) => isImported(a) && a.ownerId === String(ownerId)
86
+ && a.ownerAskId === String(ownerAskId)) || null;
87
+ }
88
+
89
+ // Chiusura dell'alias locale quando l'OWNER chiude la domanda. Durevole: la
90
+ // riga resta nello storico marcata, quindi non ricompare a un reload e non
91
+ // torna risponibile.
92
+ function closeImported({ ownerId, ownerAskId, outcome }) {
93
+ const ask = findImported(ownerId, ownerAskId);
94
+ if (!ask) return { ok: true, changed: false, ask: null };
95
+ if (ask.answered || ask.dismissed) return { ok: true, changed: false, ask };
96
+ if (outcome === 'answered') { ask.answered = true; ask.answeredReconciled = true; }
97
+ else ask.dismissed = true;
98
+ ask.revision = (ask.revision || 0) + 1;
99
+ save();
100
+ return { ok: true, changed: true, ask };
70
101
  }
71
102
 
72
- function create({ question, options, session }) {
103
+ function create({ question, options, session, ownerId, ownerAskId, originNode, originCell }) {
73
104
  const v = validate({ question, options });
74
105
  if (!v.ok) return { ok: false, reason: 'invalid', error: v.error };
75
- // da revisione: cap duro sugli aperti — rifiuto esplicito, MAI drop di ask aperti.
76
- if (openCount() >= MAX_OPEN) {
106
+ // DEDUP: la stessa domanda puo' arrivare due volte allo stesso nodo (import
107
+ // diretto del fan-out e feed dell'owner). L'identita' canonica e' la coppia
108
+ // (ownerId, ownerAskId): se l'alias esiste gia', si restituisce quello, non
109
+ // se ne crea un secondo.
110
+ if (originNode && ownerId && ownerAskId) {
111
+ const existing = findImported(ownerId, ownerAskId);
112
+ if (existing) return { ok: true, ask: existing, deduped: true };
113
+ }
114
+ // Cap duro sugli aperti, SEPARATO PER CLASSE: gli importati non consumano il
115
+ // budget dei locali. Con un cap unico, cento domande ricevute da un peer
116
+ // lasciavano questo nodo senza poter porre la prima domanda propria.
117
+ const imported = !!originNode;
118
+ const cap = imported ? MAX_OPEN_IMPORTED : MAX_OPEN;
119
+ if (openCount(imported ? 'imported' : 'local') >= cap) {
77
120
  return {
78
121
  ok: false,
79
122
  reason: 'cap',
80
- error: `cap ask aperti raggiunto (${MAX_OPEN}): rispondi o attendi prima di crearne altri`,
123
+ error: `cap ask aperti raggiunto (${cap}): rispondi o attendi prima di crearne altri`,
81
124
  };
82
125
  }
83
126
  const ask = {
@@ -85,6 +128,23 @@ function createAsksStore(opts = {}) {
85
128
  question: v.value.question,
86
129
  ...(v.value.options ? { options: v.value.options } : {}),
87
130
  session: String(session),
131
+ // Identita' QUALIFICATA dell'ask. `ownerId` e' il nodo che possiede la
132
+ // domanda: la UI identifica una card con la coppia (ownerId, id) — due
133
+ // proprietari possono usare lo stesso id locale — e instrada la risposta
134
+ // al proprietario via ask-relay invece di incollarla qui. Assente = ask
135
+ // di questo nodo (il caso locale storico).
136
+ ...(ownerId ? { ownerId: String(ownerId) } : {}),
137
+ // L'id con cui l'OWNER conosce questa domanda. Su un ask importato l'`id`
138
+ // locale e' nostro e serve solo a noi; la risposta deve invece citare
139
+ // l'id dell'owner, perche' e' lui che risolve l'ask nel proprio store.
140
+ // Senza questo campo una risposta a un ask importato colpirebbe un id
141
+ // inesistente (o, peggio, un ask locale nostro con lo stesso id).
142
+ ...(ownerAskId ? { ownerAskId: String(ownerAskId) } : {}),
143
+ // Provenienza di un ask ARRIVATO dalla federazione. E' il marcatore che
144
+ // rende esplicito l'invariante: un ask con `originNode` non viene mai
145
+ // ri-esportato (nessun loop A->B->A). Un ask locale non ha questo campo.
146
+ ...(originNode ? { originNode: String(originNode) } : {}),
147
+ ...(originCell ? { originCell: String(originCell) } : {}),
88
148
  ts: now(),
89
149
  revision: 0,
90
150
  answered: false,
@@ -190,7 +250,7 @@ function createAsksStore(opts = {}) {
190
250
  return commit(id, text);
191
251
  }
192
252
 
193
- return { create, get, list, openCount, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, filePath, MAX_OPEN };
253
+ return { create, get, list, openCount, isImported, findImported, closeImported, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, filePath, MAX_OPEN, MAX_OPEN_IMPORTED };
194
254
  }
195
255
 
196
256
  module.exports = { createAsksStore };