@aria-framework/ai 0.14.3 → 0.15.2

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/lmxVerify.js ADDED
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Does this supervisor actually work? — the four questions, asked in the only order they can be.
3
+ *
4
+ * WHY THIS EXISTS. A supervisor was saved correctly twice and looked identical both times, while the
5
+ * engines key sat in the status token's field. The listener answered 401, and the only place that
6
+ * surfaced was an HTTP code behind a button — a number that names neither credential. Saving
7
+ * something and finding out later whether it works is the shape of that whole failure.
8
+ *
9
+ * THE ORDER IS FORCED, and each step's failure means something the next cannot tell you:
10
+ *
11
+ * 1. reachable — nothing below it means anything if the machine does not answer
12
+ * 2. certificate — fails BEFORE any credential leaves this process, which is why its message
13
+ * says so: a pin mismatch is the whole story, and the tokens are not suspect
14
+ * 3. status token — FREE. Reading the document is exactly what this credential authorises, so
15
+ * whether it works is learned as a side effect of asking
16
+ * 4. engines key — authorises INFERENCE, and no amount of status reading exercises it
17
+ *
18
+ * CHECK 4 SPENDS NOTHING. The obvious way to test an inference credential is to run an inference;
19
+ * asking the engine's own `/models` instead authenticates against the same route with the same
20
+ * bearer and costs no tokens at all. An operator should never have to weigh "is my key right?"
21
+ * against what the answer costs.
22
+ *
23
+ * NOTHING HERE READS A DATABASE OR A KEYSTORE. Every credential arrives as an argument, which is
24
+ * what lets the same function serve an admin screen, a boot check and a test with a stub fetch.
25
+ * Where the values come from is the consuming app's business.
26
+ *
27
+ * WHAT IS NEVER DONE: a check is never reported as passed because it probably would have. When the
28
+ * engine list does not arrive, the engines key reads `not checked` rather than a tick — it is the
29
+ * credential whose failure stays invisible until a job runs, so a tick meaning "probably" is worse
30
+ * there than no tick at all.
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ const crypto = require('crypto');
36
+
37
+ const DEFAULT_TIMEOUT_MS = 6000;
38
+
39
+ /** How long a verification stays on the page before it is stale enough to be misleading. */
40
+ const REPORT_TTL_MS = 10 * 60 * 1000;
41
+
42
+ /** Certificates inside this window are called out — an expired pin takes the stack out silently. */
43
+ const EXPIRY_WARN_DAYS = 30;
44
+
45
+ /**
46
+ * The last verification per instance, so the report survives the redirect that follows a save.
47
+ *
48
+ * NOT A FLASH. The flash partial escapes to a single line, and a per-check report is exactly the
49
+ * kind of result the partial's own notes warn about losing — "the only place that output appears,
50
+ * and a message that erases itself after three seconds would lose it with no way back". Keeping it
51
+ * here means it stays readable until something supersedes it, which is also what makes it useful
52
+ * outside the moment of saving.
53
+ */
54
+ const _reports = new Map();
55
+
56
+ function remember(instance, report) {
57
+ _reports.set(String(instance), report);
58
+ return report;
59
+ }
60
+
61
+ /** The last report for an instance, or null once it is old enough to mislead. */
62
+ function lastFor(instance) {
63
+ const r = _reports.get(String(instance));
64
+ if (!r) return null;
65
+ if (Date.now() - new Date(r.at).getTime() > REPORT_TTL_MS) {
66
+ _reports.delete(String(instance));
67
+ return null;
68
+ }
69
+ return r;
70
+ }
71
+
72
+ function forget(instance) { _reports.delete(String(instance)); }
73
+
74
+ /** Only for tests — the cache is process-wide and would otherwise leak between them. */
75
+ function _reset() { _reports.clear(); }
76
+
77
+ // ── classifying what went wrong ────────────────────────────────────────────────────────────────
78
+ //
79
+ // fetch() reports every transport failure as the same "fetch failed", with the real reason on
80
+ // `cause`. Collapsing those into one message would put a certificate mismatch and an unplugged
81
+ // network cable behind identical words, which is the confusion this whole module exists to end.
82
+
83
+ const TLS_CODES = new Set([
84
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'SELF_SIGNED_CERT_IN_CHAIN', 'DEPTH_ZERO_SELF_SIGNED_CERT',
85
+ 'CERT_HAS_EXPIRED', 'CERT_NOT_YET_VALID', 'ERR_TLS_CERT_ALTNAME_INVALID',
86
+ 'CERT_SIGNATURE_FAILURE', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'ERR_SSL_WRONG_VERSION_NUMBER'
87
+ ]);
88
+
89
+ function causeOf(err) {
90
+ let e = err;
91
+ for (let i = 0; i < 5 && e && e.cause; i += 1) e = e.cause;
92
+ return e || err;
93
+ }
94
+
95
+ function classify(err) {
96
+ const c = causeOf(err);
97
+ const code = (c && c.code) || '';
98
+ if (err && err.name === 'TimeoutError') return { kind: 'unreachable', code: 'ETIMEDOUT' };
99
+ if (TLS_CODES.has(code)) return { kind: 'tls', code };
100
+ if (/certificate|self.signed|SSL/i.test(String(c && c.message))) return { kind: 'tls', code: code || 'TLS' };
101
+ return { kind: 'unreachable', code: code || 'unknown' };
102
+ }
103
+
104
+ // ── the pinned certificate, read locally ───────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * What we are pinning, without asking anybody. The fingerprint and expiry are properties of the PEM
108
+ * in front of us, so they are known even when the stack is off — which is when an operator most
109
+ * needs to be told the pin expires in a fortnight.
110
+ */
111
+ function readPin(pem) {
112
+ if (!pem || !String(pem).trim()) return { present: false };
113
+ try {
114
+ const x = new crypto.X509Certificate(String(pem));
115
+ const expires = new Date(x.validTo);
116
+ const days = Math.floor((expires.getTime() - Date.now()) / 86400000);
117
+ return {
118
+ present: true,
119
+ valid: true,
120
+ // Short form, because the whole digest is unreadable and nobody compares 32 bytes by eye.
121
+ fingerprint: shortPrint(x.fingerprint256),
122
+ expires,
123
+ expiresDays: days,
124
+ expired: days < 0,
125
+ expiringSoon: days >= 0 && days <= EXPIRY_WARN_DAYS
126
+ };
127
+ } catch (e) {
128
+ return { present: true, valid: false, error: e.message };
129
+ }
130
+ }
131
+
132
+ function shortPrint(fp) {
133
+ const parts = String(fp || '').split(':');
134
+ if (parts.length < 4) return String(fp || '');
135
+ return `${parts.slice(0, 2).join(':')}…${parts[parts.length - 1]}`;
136
+ }
137
+
138
+ function fmtDate(d) {
139
+ return d instanceof Date && !isNaN(d) ? d.toISOString().slice(0, 10) : 'unknown';
140
+ }
141
+
142
+ // ── the checks ─────────────────────────────────────────────────────────────────────────────────
143
+
144
+ const pass = (key, text) => ({ key, status: 'pass', text });
145
+ const fail = (key, text) => ({ key, status: 'fail', text });
146
+ const skip = (key, text) => ({ key, status: 'skip', text });
147
+
148
+ /**
149
+ * Run the four (occasionally five) checks against a supervisor.
150
+ *
151
+ * @param {object} o
152
+ * instance the id this stack must report as itself
153
+ * statusUrl the status listener
154
+ * statusToken bearer for the status document
155
+ * caCert the certificate to pin, PEM
156
+ * enginesKey bearer for inference — checked only when there is an engine to check it against
157
+ * engineName an adopted engine to authenticate against; omitted, the first healthy one is used
158
+ * fetchImpl test seam; the pinned transport is used when absent
159
+ */
160
+ async function verify(o = {}) {
161
+ const instance = String(o.instance || '');
162
+ const statusUrl = String(o.statusUrl || '');
163
+ const timeoutMs = Number(o.timeoutMs) || DEFAULT_TIMEOUT_MS;
164
+ const checks = [];
165
+ const at = new Date().toISOString();
166
+
167
+ const pin = readPin(o.caCert);
168
+ const host = hostOf(statusUrl);
169
+
170
+ // ── 1 + 2 + 3: one request answers all three, because they fail at different layers of it ──
171
+ let doc = null;
172
+ let found = null; // the engine list, when the document arrived
173
+ try {
174
+ const started = Date.now();
175
+ const res = await statusFetch(o, statusUrl, timeoutMs);
176
+ const ms = Date.now() - started;
177
+
178
+ checks.push(pass('reachable', `reached ${host} in ${ms} ms`));
179
+ checks.push(certCheck(pin, true));
180
+
181
+ if (res.status === 401 || res.status === 403) {
182
+ checks.push(fail('status_token',
183
+ `status token rejected — the listener answered ${res.status}`));
184
+ checks.push(skip('engines_key', 'engines key not checked — needs the engine list'));
185
+ return finish(instance, at, checks, null, found);
186
+ }
187
+ if (!res.ok) {
188
+ checks.push(fail('status_token', `the listener answered ${res.status}`));
189
+ checks.push(skip('engines_key', 'engines key not checked — needs the engine list'));
190
+ return finish(instance, at, checks, null, found);
191
+ }
192
+
193
+ try {
194
+ doc = await res.json();
195
+ } catch (e) {
196
+ checks.push(fail('status_token', 'the listener answered, but not with a status document'));
197
+ checks.push(skip('engines_key', 'engines key not checked — needs the engine list'));
198
+ return finish(instance, at, checks, null, found);
199
+ }
200
+
201
+ const engines = Array.isArray(doc && doc.engines) ? doc.engines : [];
202
+ checks.push(pass('status_token',
203
+ `status token accepted · ${engines.length} engine${engines.length === 1 ? '' : 's'} reported`));
204
+
205
+ // THE DOCUMENT COMES BACK WITH THE VERDICT. Verifying already costs a status read, and that read
206
+ // is the same one the engine picker used to make on demand — so carrying the list here lets the
207
+ // panel render its engines from what we just fetched instead of asking again from the browser.
208
+ // It is also what removes the "Discover engines" button: a list you have to press for is a list
209
+ // that is stale by definition, and pressing it was where the 401 used to hide.
210
+ found = engines;
211
+
212
+ // IDENTITY, not connectivity. Engine names collide across deployments — `analysis` exists on
213
+ // every stack — so a status URL pointed at the wrong machine answers plausibly and sends work
214
+ // somewhere else. Only raised when it is actually wrong; a matching id needs no line.
215
+ if (doc && doc.instance && String(doc.instance) !== instance) {
216
+ checks.push(fail('instance',
217
+ `this stack reports itself as “${doc.instance}”, not “${instance}” — the address points at a different deployment`));
218
+ }
219
+
220
+ checks.push(await enginesKeyCheck(o, engines, timeoutMs));
221
+ return finish(instance, at, checks, null, found);
222
+ } catch (err) {
223
+ const { kind, code } = classify(err);
224
+ if (kind === 'tls') {
225
+ // NOTHING WAS SENT. The handshake failed, so neither credential left this process — worth
226
+ // saying, because it means the tokens are not what to go and look at.
227
+ checks.push(pass('reachable', `reached ${host}`));
228
+ checks.push(fail('certificate', certFailText(pin, code)));
229
+ checks.push(skip('status_token', 'status token not sent — the connection was refused first'));
230
+ checks.push(skip('engines_key', 'engines key not sent'));
231
+ return finish(instance, at, checks, null, found);
232
+ }
233
+ checks.push(fail('reachable', `no answer from ${host} (${code})`));
234
+ checks.push(skip('certificate', 'certificate not checked'));
235
+ checks.push(skip('status_token', 'status token not checked'));
236
+ checks.push(skip('engines_key', 'engines key not checked'));
237
+ return finish(instance, at, checks, pin, found);
238
+ }
239
+ }
240
+
241
+ function hostOf(url) {
242
+ try { const u = new URL(url); return u.host; } catch (e) { return url || 'the listener'; }
243
+ }
244
+
245
+ /** The status read, over the pinned transport unless a test supplies its own fetch. */
246
+ async function statusFetch(o, statusUrl, timeoutMs) {
247
+ const headers = { Accept: 'application/json' };
248
+ if (o.statusToken) headers.Authorization = `Bearer ${o.statusToken}`;
249
+ return send(o, statusUrl, headers, timeoutMs);
250
+ }
251
+
252
+ /**
253
+ * One request, over the pinned transport.
254
+ *
255
+ * THE DISPATCHER IS HALF THE TRANSPORT. `lmxTransport(ca)` returns `{ fetch, dispatcher }` and the
256
+ * certificate lives on the DISPATCHER — taking only `.fetch` and calling it leaves the pin behind,
257
+ * and the request goes out against the system trust store instead. Against a self-signed stack that
258
+ * surfaces as DEPTH_ZERO_SELF_SIGNED_CERT, which reads exactly like a genuine certificate mismatch:
259
+ * a verifier that reported "the certificate did not match" for a perfectly good certificate, which
260
+ * is a worse failure than the one it was written to catch.
261
+ */
262
+ async function send(o, url, headers, timeoutMs) {
263
+ const opts = { headers, signal: AbortSignal.timeout(timeoutMs) };
264
+ if (o.fetchImpl) return o.fetchImpl(url, opts);
265
+ const { lmxTransport } = require('./providers/lmxTransport');
266
+ const t = lmxTransport(o.caCert || null);
267
+ if (t.dispatcher) opts.dispatcher = t.dispatcher;
268
+ return t.fetch(url, opts);
269
+ }
270
+
271
+ function certCheck(pin, handshakeOk) {
272
+ if (!pin.present) {
273
+ // Not a failure: a stack on loopback, or one behind a certificate the system already trusts,
274
+ // needs no pin. Saying "none pinned" is the true statement; a tick would claim a check ran.
275
+ return skip('certificate', 'no certificate pinned — the system trust store was used');
276
+ }
277
+ if (!pin.valid) return fail('certificate', `the pinned certificate could not be read (${pin.error})`);
278
+ if (pin.expired) {
279
+ return fail('certificate',
280
+ `the pinned certificate EXPIRED on ${fmtDate(pin.expires)} — every call fails at the handshake`);
281
+ }
282
+ const base = `certificate matched the pin · ${pin.fingerprint} · expires ${fmtDate(pin.expires)}`;
283
+ if (pin.expiringSoon) {
284
+ return { key: 'certificate', status: 'warn', text: `${base} — ${pin.expiresDays} days left` };
285
+ }
286
+ return handshakeOk ? pass('certificate', base) : skip('certificate', base);
287
+ }
288
+
289
+ function certFailText(pin, code) {
290
+ if (pin.present && pin.expired) {
291
+ return `the pinned certificate expired on ${fmtDate(pin.expires)} (${code})`;
292
+ }
293
+ if (code === 'CERT_HAS_EXPIRED') return 'the stack is presenting an expired certificate';
294
+ if (code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
295
+ return 'the certificate is not valid for this address — check the hostname';
296
+ }
297
+ if (!pin.present) {
298
+ return 'the stack presented a certificate this machine does not trust, and none is pinned here';
299
+ }
300
+ return `the stack presented a different certificate from the one pinned here (${code})`;
301
+ }
302
+
303
+ /**
304
+ * Check 4 — WITHOUT SPENDING ANYTHING.
305
+ *
306
+ * The engines key is a bearer on the engine's own OpenAI-compatible API, so asking that API to list
307
+ * its models authenticates against exactly the route inference uses. A completion would prove the
308
+ * same thing and bill for the privilege.
309
+ */
310
+ async function enginesKeyCheck(o, engines, timeoutMs) {
311
+ if (!o.enginesKey) {
312
+ return skip('engines_key', 'no engines key set — engines are being called unauthenticated');
313
+ }
314
+ const wanted = o.engineName
315
+ ? engines.find((e) => e && e.name === o.engineName)
316
+ : engines.find((e) => e && e.state === 'healthy' && e.url);
317
+ if (!wanted || !wanted.url) {
318
+ return skip('engines_key',
319
+ o.engineName
320
+ ? `engines key not checked — “${o.engineName}” is not currently healthy`
321
+ : 'engines key not checked — no healthy engine to authenticate against');
322
+ }
323
+ try {
324
+ const url = `${String(wanted.url).replace(/\/+$/, '')}/models`;
325
+ const res = await send(o, url,
326
+ { Accept: 'application/json', Authorization: `Bearer ${o.enginesKey}` }, timeoutMs);
327
+ if (res.status === 401 || res.status === 403) {
328
+ return fail('engines_key',
329
+ `engines key rejected by ${wanted.name} — inference calls will fail with ${res.status}`);
330
+ }
331
+ if (!res.ok) {
332
+ return skip('engines_key', `engines key not confirmed — ${wanted.name} answered ${res.status}`);
333
+ }
334
+ return pass('engines_key', `engines key accepted · checked against ${wanted.name}`);
335
+ } catch (err) {
336
+ const { code } = classify(err);
337
+ return skip('engines_key', `engines key not checked — ${wanted.name} did not answer (${code})`);
338
+ }
339
+ }
340
+
341
+ // ── the verdict ────────────────────────────────────────────────────────────────────────────────
342
+
343
+ function finish(instance, at, checks, pinForOffline, engines) {
344
+ // A certificate that expires soon is worth saying even when nothing could be reached, because it
345
+ // is knowable from the PEM alone and is exactly the failure nobody sees coming.
346
+ if (pinForOffline && pinForOffline.present && pinForOffline.valid
347
+ && (pinForOffline.expired || pinForOffline.expiringSoon)) {
348
+ const i = checks.findIndex((c) => c.key === 'certificate');
349
+ if (i > -1) checks[i] = certCheck(pinForOffline, false);
350
+ }
351
+
352
+ const failed = checks.filter((c) => c.status === 'fail');
353
+ const warned = checks.filter((c) => c.status === 'warn');
354
+ const ok = failed.length === 0;
355
+
356
+ return {
357
+ ok,
358
+ level: failed.length ? (failed.some((c) => c.key === 'certificate') ? 'bad' : 'warn')
359
+ : (warned.length ? 'warn' : 'ok'),
360
+ headline: headlineFor(failed, warned),
361
+ at,
362
+ instance,
363
+ checks,
364
+ // Null means "we never got a document", which is NOT the same as a stack with no engines — the
365
+ // panel must be able to say "not checked" rather than "no engines", because those send an
366
+ // operator to entirely different places.
367
+ engines: engines || null
368
+ };
369
+ }
370
+
371
+ /**
372
+ * ONE SENTENCE NAMING THE CAUSE, for the flash. "Saved, but the status token is being rejected"
373
+ * is a different instruction from "saved, but the stack did not answer" — the first sends somebody
374
+ * to a credential, the second to a machine, and "401" sent them to neither.
375
+ */
376
+ function headlineFor(failed, warned) {
377
+ if (!failed.length) {
378
+ return warned.length ? warned[0].text : 'connected, certificate pinned, both credentials accepted';
379
+ }
380
+ const first = failed[0];
381
+ switch (first.key) {
382
+ case 'reachable': return 'the stack did not answer — its settings are stored and will be used when it is back';
383
+ case 'certificate': return 'the certificate did not match — nothing was sent';
384
+ case 'status_token': return 'the status token is being rejected — engines cannot be listed until it is corrected';
385
+ case 'instance': return 'this address points at a different deployment';
386
+ case 'engines_key': return 'the engines key is being rejected — inference calls will fail';
387
+ default: return first.text;
388
+ }
389
+ }
390
+
391
+ module.exports = {
392
+ verify, remember, lastFor, forget, readPin,
393
+ DEFAULT_TIMEOUT_MS, EXPIRY_WARN_DAYS, REPORT_TTL_MS,
394
+ _reset, _classify: classify, _shortPrint: shortPrint
395
+ };
package/package.json CHANGED
@@ -1,49 +1,53 @@
1
- {
2
- "name": "@aria-framework/ai",
3
- "description": "Aria App Framework AI module. A dependency-injected model seam (createAiClient) over several providers (LM Studio / OpenAI-compatible / Anthropic), with a fact-preservation guard, generic Polish and Generate writing engines, and a browser polish widget. Prompts and config stay in the consuming app.",
4
- "version": "0.14.3",
5
- "license": "UNLICENSED",
6
- "private": false,
7
- "publishConfig": {
8
- "access": "public"
9
- },
10
- "main": "index.js",
11
- "files": [
12
- "index.js",
13
- "error.js",
14
- "facts.js",
15
- "polish.js",
16
- "generate.js",
17
- "providers/openai-compatible.js",
18
- "providers/anthropic.js",
19
- "browser/ai-polish.js",
20
- "usageStore.js",
21
- "providerStore.js",
22
- "speedStore.js",
23
- "health.js",
24
- "benchmark.js",
25
- "views/",
26
- "providers/lmx.js",
27
- "providers/lmxDiscovery.js",
28
- "providers/lmxTransport.js"
29
- ],
30
- "peerDependencies": {
31
- "@aria-framework/db-worker": ">=0.7.0",
32
- "undici": ">=6"
33
- },
34
- "peerDependenciesMeta": {
35
- "@aria-framework/db-worker": {
36
- "optional": true
37
- },
38
- "undici": {
39
- "optional": true
40
- }
41
- },
42
- "scripts": {
43
- "test": "node test/smoke.js && node test/usageStore.js && node test/providerStore.js && node test/speedStore.js && node test/health.js && node test/listModels.js && node test/benchmark.js && node test/lmxDiscovery.js && node test/lmx.js && node test/packaging.js && node test/views.js"
44
- },
45
- "devDependencies": {
46
- "undici": "^8.10.0",
47
- "ejs": "^3.1.10"
48
- }
49
- }
1
+ {
2
+ "name": "@aria-framework/ai",
3
+ "description": "Aria App Framework \u2014 AI module. A dependency-injected model seam (createAiClient) over several providers (LM Studio / OpenAI-compatible / Anthropic), with a fact-preservation guard, generic Polish and Generate writing engines, and a browser polish widget. Prompts and config stay in the consuming app.",
4
+ "version": "0.15.2",
5
+ "license": "UNLICENSED",
6
+ "private": false,
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "main": "index.js",
11
+ "files": [
12
+ "index.js",
13
+ "error.js",
14
+ "facts.js",
15
+ "polish.js",
16
+ "generate.js",
17
+ "providers/openai-compatible.js",
18
+ "providers/anthropic.js",
19
+ "browser/ai-polish.js",
20
+ "usageStore.js",
21
+ "providerStore.js",
22
+ "speedStore.js",
23
+ "health.js",
24
+ "benchmark.js",
25
+ "views/",
26
+ "providers/lmx.js",
27
+ "providers/lmxDiscovery.js",
28
+ "providers/lmxTransport.js",
29
+ "lmxVerify.js",
30
+ "lmxStore.js",
31
+ "lmxStatus.js",
32
+ "browser/ai-panels.js"
33
+ ],
34
+ "peerDependencies": {
35
+ "@aria-framework/db-worker": ">=0.7.0",
36
+ "undici": ">=6"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "@aria-framework/db-worker": {
40
+ "optional": true
41
+ },
42
+ "undici": {
43
+ "optional": true
44
+ }
45
+ },
46
+ "scripts": {
47
+ "test": "node test/smoke.js && node test/usageStore.js && node test/providerStore.js && node test/speedStore.js && node test/health.js && node test/listModels.js && node test/benchmark.js && node test/lmxDiscovery.js && node test/lmx.js && node test/lmxVerify.js && node test/lmxStore.js && node test/lmxStatus.js && node test/packaging.js && node test/views.js"
48
+ },
49
+ "devDependencies": {
50
+ "undici": "^8.10.0",
51
+ "ejs": "^3.1.10"
52
+ }
53
+ }