@mmmbuto/nexuscrew 0.9.31 → 0.9.33
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/CHANGELOG.md +15 -0
- package/docs/CONFIGURATION.md +39 -0
- package/frontend/dist/assets/{index-DaAL7P-m.js → index-Uco-EbNb.js} +13 -13
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/doctor.js +74 -2
- package/lib/fleet/builtin.js +131 -10
- package/lib/fleet/endpoint-probe.js +146 -0
- package/lib/fleet/managed.js +164 -10
- package/lib/fleet/model-probe.js +110 -6
- package/lib/fleet/routes.js +5 -0
- package/lib/fleet/runtime.js +29 -2
- package/lib/notify/ask-answer-service.js +10 -4
- package/lib/notify/asks.js +67 -7
- package/lib/notify/closure-retry.js +201 -0
- package/lib/notify/event-feed-asks-routes.js +4 -0
- package/lib/notify/event-feed-routes.js +8 -0
- package/lib/notify/routes.js +274 -12
- package/lib/proxy/federation.js +18 -5
- package/lib/proxy/resource-acl.js +7 -1
- package/lib/server.js +132 -14
- package/package.json +1 -1
package/lib/fleet/managed.js
CHANGED
|
@@ -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',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1038
|
-
keys
|
|
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])
|
|
1063
|
-
|
|
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: '
|
|
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
|
-
|
|
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,6 +1409,18 @@ 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,
|
|
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
|
+
})(),
|
|
1270
1424
|
configured,
|
|
1271
1425
|
// Catalogo builtin del profilo PIU' i modelli dichiarati in
|
|
1272
1426
|
// `definitions.models[]` per questo engine (o, legacy, per il profilo).
|
package/lib/fleet/model-probe.js
CHANGED
|
@@ -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
|
|
10
|
-
// Non si genera testo, quindi non si
|
|
11
|
-
// esiste l'esito e' `unverified`: NON si
|
|
12
|
-
// completamento, per quanto minima. Una prova che
|
|
13
|
-
// la guarda impara a non fare — e allora tanto vale
|
|
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 = {
|
|
230
|
+
module.exports = {
|
|
231
|
+
probeModel, OUTCOMES, findInCatalog, modelsUrl,
|
|
232
|
+
probeCustomEndpoint, chatCompletionsUrl, CUSTOM_FALLBACK_TIMEOUT_MS,
|
|
233
|
+
};
|
package/lib/fleet/routes.js
CHANGED
|
@@ -188,6 +188,11 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
188
188
|
if (typeof f.removeModel !== 'function') { const e = new Error('not supported by this fleet provider'); e.status = 501; throw e; }
|
|
189
189
|
return f.removeModel(String(b.id || ''), String(b.engine || ''));
|
|
190
190
|
}, { mutate: true }));
|
|
191
|
+
r.post('/edit-model', guard((f, b) => {
|
|
192
|
+
requireCap(f, 'edit-model');
|
|
193
|
+
if (typeof f.editModel !== 'function') { const e = new Error('not supported by this fleet provider'); e.status = 501; throw e; }
|
|
194
|
+
return f.editModel(String(b.id || ''), String(b.engine || ''), b.patch);
|
|
195
|
+
}, { mutate: true }));
|
|
191
196
|
r.post('/define-engine', guard((f, b) => { requireCap(f, 'define'); return f.defineEngine(b.def); }, { mutate: true }));
|
|
192
197
|
r.post('/edit-engine', guard((f, b) => { requireCap(f, 'edit'); return f.editEngine(b.id, b.patch, b.envChanges); }, { mutate: true }));
|
|
193
198
|
r.post('/remove-engine', guard((f, b) => { requireCap(f, 'remove'); return f.removeEngine(b.id); }, { mutate: true }));
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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, {
|
|
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 ? {
|
|
@@ -190,6 +208,15 @@ function createBuiltinRuntime(ctx) {
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/lib/notify/asks.js
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
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
|
-
//
|
|
76
|
-
|
|
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 (${
|
|
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 };
|