@aria-framework/ai 0.12.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/health.js +57 -1
- package/index.js +8 -2
- package/package.json +23 -6
- package/providerStore.js +12 -1
- package/providers/lmx.js +0 -0
- package/providers/lmxDiscovery.js +209 -0
- package/providers/lmxTransport.js +60 -0
- package/providers/openai-compatible.js +23 -3
- package/usageStore.js +72 -3
- package/views/ai/provider-card.ejs +3 -2
package/health.js
CHANGED
|
@@ -320,13 +320,69 @@ function createHealthChecker(opts = {}) {
|
|
|
320
320
|
};
|
|
321
321
|
},
|
|
322
322
|
|
|
323
|
-
/** All known state
|
|
323
|
+
/** All known state in the shape a UI renders. For PERSISTENCE use exportState(). */
|
|
324
324
|
snapshot() {
|
|
325
325
|
const out = {};
|
|
326
326
|
for (const id of state.keys()) out[id] = this.status(id);
|
|
327
327
|
return out;
|
|
328
328
|
},
|
|
329
329
|
|
|
330
|
+
/**
|
|
331
|
+
* The facts worth surviving a restart.
|
|
332
|
+
*
|
|
333
|
+
* NOT EVERYTHING IS. Health lives in memory, so every deploy wipes it and each card reads "no
|
|
334
|
+
* record" until something touches the endpoint — which is why this exists. But restoring the
|
|
335
|
+
* whole state would be worse than restoring none of it:
|
|
336
|
+
*
|
|
337
|
+
* lastGoodAt, lastCheckedAt, lastMs, lastError, modelPresent — KEPT. Each is a record of
|
|
338
|
+
* something that already happened, and the UI dates them ("last good 3h ago"). An operator
|
|
339
|
+
* reading one is not misled about when it was true.
|
|
340
|
+
*
|
|
341
|
+
* failures and downUntil — DROPPED. A cooldown is a decision about NOW, and a restart is very
|
|
342
|
+
* often the fix: restoring a trip from before it would hold a repaired endpoint out of
|
|
343
|
+
* rotation for a reason that no longer exists. A fresh process gives every endpoint one
|
|
344
|
+
* honest attempt, which costs at most a single timeout to discover.
|
|
345
|
+
*
|
|
346
|
+
* throughput samples — DROPPED. A rate is shown bare — "52 tok/s" — with nothing saying when
|
|
347
|
+
* it was measured, so carrying one across a restart presents a stale figure as current.
|
|
348
|
+
* Unlike "3h ago" it cannot date itself. It refills on the next real call.
|
|
349
|
+
*/
|
|
350
|
+
exportState() {
|
|
351
|
+
const out = {};
|
|
352
|
+
for (const [id, e] of state) {
|
|
353
|
+
if (!e.lastCheckedAt && !e.lastGoodAt) continue; // nothing worth writing down
|
|
354
|
+
out[id] = {
|
|
355
|
+
lastGoodAt: e.lastGoodAt,
|
|
356
|
+
lastCheckedAt: e.lastCheckedAt,
|
|
357
|
+
lastMs: e.lastMs,
|
|
358
|
+
lastError: e.lastError,
|
|
359
|
+
modelPresent: e.modelPresent
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Seed from a previous process. Anything already known in THIS process wins.
|
|
367
|
+
*
|
|
368
|
+
* A live entry means something has happened since boot, and a fact measured a moment ago beats
|
|
369
|
+
* one restored from disk — importing over it would move "last good" backwards.
|
|
370
|
+
*/
|
|
371
|
+
importState(saved) {
|
|
372
|
+
let seeded = 0;
|
|
373
|
+
for (const [id, v] of Object.entries(saved || {})) {
|
|
374
|
+
if (state.has(id) || !v) continue;
|
|
375
|
+
const e = entry(id);
|
|
376
|
+
e.lastGoodAt = v.lastGoodAt == null ? null : Number(v.lastGoodAt);
|
|
377
|
+
e.lastCheckedAt = v.lastCheckedAt == null ? null : Number(v.lastCheckedAt);
|
|
378
|
+
e.lastMs = v.lastMs == null ? null : Number(v.lastMs);
|
|
379
|
+
e.lastError = v.lastError == null ? null : String(v.lastError);
|
|
380
|
+
e.modelPresent = v.modelPresent == null ? null : !!v.modelPresent;
|
|
381
|
+
seeded += 1;
|
|
382
|
+
}
|
|
383
|
+
return seeded;
|
|
384
|
+
},
|
|
385
|
+
|
|
330
386
|
/** Forget everything — used by tests and by "recheck now" in a UI. */
|
|
331
387
|
reset(id) {
|
|
332
388
|
if (id) state.delete(id); else state.clear();
|
package/index.js
CHANGED
|
@@ -33,13 +33,19 @@ const PROVIDERS = {
|
|
|
33
33
|
// named after somebody else.
|
|
34
34
|
lmstudio: require('./providers/openai-compatible'),
|
|
35
35
|
'openai-compatible': require('./providers/openai-compatible'),
|
|
36
|
-
anthropic: require('./providers/anthropic')
|
|
36
|
+
anthropic: require('./providers/anthropic'),
|
|
37
|
+
// 0.14.0 — a supervised fleet rather than an address. The engine URL is discovered from the
|
|
38
|
+
// supervisor's status document per call, the reasoning flag is read from the model the engine
|
|
39
|
+
// is actually running, and the whole conversation is pinned to a self-signed certificate.
|
|
40
|
+
lmx: require('./providers/lmx')
|
|
37
41
|
};
|
|
38
42
|
|
|
39
43
|
const DEFAULTS = {
|
|
40
44
|
lmstudio: { baseUrl: 'http://localhost:1234/v1', model: 'qwen3.5-9b', label: 'LM Studio' },
|
|
41
45
|
'openai-compatible': { baseUrl: 'http://localhost:11434/v1', model: '', label: 'The model server' },
|
|
42
|
-
anthropic: { baseUrl: 'https://api.anthropic.com/v1', model: 'claude-sonnet-4-5', label: 'Claude' }
|
|
46
|
+
anthropic: { baseUrl: 'https://api.anthropic.com/v1', model: 'claude-sonnet-4-5', label: 'Claude' },
|
|
47
|
+
// No baseUrl: an lmx engine's address is never configured, only discovered.
|
|
48
|
+
lmx: { baseUrl: '', model: '', label: 'lmx engine' }
|
|
43
49
|
};
|
|
44
50
|
|
|
45
51
|
const RETRY_AFTER_MS = 400;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aria-framework/ai",
|
|
3
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.
|
|
4
|
+
"version": "0.14.0",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
7
7
|
"publishConfig": {
|
|
@@ -16,16 +16,33 @@
|
|
|
16
16
|
"generate.js",
|
|
17
17
|
"providers/openai-compatible.js",
|
|
18
18
|
"providers/anthropic.js",
|
|
19
|
-
"browser/ai-polish.js",
|
|
20
|
-
"
|
|
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"
|
|
21
29
|
],
|
|
22
30
|
"peerDependencies": {
|
|
23
|
-
"@aria-framework/db-worker": ">=0.7.0"
|
|
31
|
+
"@aria-framework/db-worker": ">=0.7.0",
|
|
32
|
+
"undici": ">=6"
|
|
24
33
|
},
|
|
25
34
|
"peerDependenciesMeta": {
|
|
26
|
-
"@aria-framework/db-worker": {
|
|
35
|
+
"@aria-framework/db-worker": {
|
|
36
|
+
"optional": true
|
|
37
|
+
},
|
|
38
|
+
"undici": {
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
27
41
|
},
|
|
28
42
|
"scripts": {
|
|
29
|
-
"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/packaging.js && node test/views.js"
|
|
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"
|
|
30
47
|
}
|
|
31
48
|
}
|
package/providerStore.js
CHANGED
|
@@ -41,7 +41,18 @@ const FIELDS = [
|
|
|
41
41
|
//
|
|
42
42
|
// An app that has not added the column is unaffected: clean() skips anything undefined, so the
|
|
43
43
|
// field is only ever written by a caller that knows about it.
|
|
44
|
-
'min_tokens_per_sec'
|
|
44
|
+
'min_tokens_per_sec',
|
|
45
|
+
// WHICH ENGINE ON WHICH SUPERVISED STACK, for `kind: lmx`. Identity is the PAIR: engine names are
|
|
46
|
+
// stable but say nothing about purpose, and they collide across deployments — `analysis` exists
|
|
47
|
+
// on every stack — so a name alone would let a mis-pointed status URL send work to another
|
|
48
|
+
// machine and get plausible answers back.
|
|
49
|
+
//
|
|
50
|
+
// Deliberately NOT reusing `model`: the status document already carries `model` as a fact about
|
|
51
|
+
// the engine, and conflating the two would make a model swap look like a configuration change.
|
|
52
|
+
//
|
|
53
|
+
// An lmx row has no meaningful `base_url`. The address is discovered from the supervisor on every
|
|
54
|
+
// call, because ports move and a stored URL is the one thing the contract says not to keep.
|
|
55
|
+
'lmx_instance', 'lmx_engine'
|
|
45
56
|
];
|
|
46
57
|
|
|
47
58
|
const NUMERIC = new Set(['context_tokens', 'max_tokens', 'timeout_ms', 'daily_token_cap', 'enabled',
|
package/providers/lmx.js
ADDED
|
Binary file
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowing WHERE to send inference, when the answer changes underneath you.
|
|
3
|
+
*
|
|
4
|
+
* An lmx stack is a supervisor and several engines. The supervisor publishes a status document
|
|
5
|
+
* saying which engines exist and what state each is in; the engines take the work. The supervisor
|
|
6
|
+
* is deliberately NOT on the request path — a client calls engines directly, so a supervisor fault
|
|
7
|
+
* cannot break an in-flight request. This module is the only part that talks to the supervisor, and
|
|
8
|
+
* it never carries work.
|
|
9
|
+
*
|
|
10
|
+
* FOUR RULES FROM THE CONTRACT, each of which exists because ignoring it fails silently:
|
|
11
|
+
*
|
|
12
|
+
* SELECT *FOR* `healthy`, NEVER AGAINST A LIST OF BAD STATES. The supervisor may add a state
|
|
13
|
+
* later; a client testing `state !== 'failed'` would start routing to it the day it appears.
|
|
14
|
+
* Testing `state === 'healthy'` cannot.
|
|
15
|
+
*
|
|
16
|
+
* `draining` MEANS FINISH WHAT YOU SENT AND SEND NOTHING NEW. It is the trap in the whole
|
|
17
|
+
* contract: a draining engine still answers 200 on its own /health, because draining is the
|
|
18
|
+
* supervisor's concept and llama-server knows nothing about it. Probe it yourself and it looks
|
|
19
|
+
* fine. Ignore the state and every rolling restart kills whatever was in flight.
|
|
20
|
+
*
|
|
21
|
+
* A STATUS OUTAGE IS NOT AN INFERENCE OUTAGE. If the listener is unreachable, keep routing on the
|
|
22
|
+
* last good document. Halting would put the supervisor back on the request path it was designed
|
|
23
|
+
* to stay off — turning a supervisor blip into a total loss of inference. Bounded, because
|
|
24
|
+
* routing on a view from an hour ago is its own kind of wrong.
|
|
25
|
+
*
|
|
26
|
+
* THE URL COMES FROM THE DOCUMENT, NEVER FROM CONFIG. Ports move. An engine bound to 0.0.0.0 is
|
|
27
|
+
* advertised at the same host the client used to reach the listener, because that is an address
|
|
28
|
+
* known to work from where the client is standing.
|
|
29
|
+
*
|
|
30
|
+
* IDENTITY IS (instance, name). Engine names are stable but say nothing about purpose, and they
|
|
31
|
+
* collide across deployments — `analysis` exists on every stack. The document names its own
|
|
32
|
+
* instance, so pointing a status URL at a different stack is caught here rather than discovered as
|
|
33
|
+
* work quietly going to the wrong machine.
|
|
34
|
+
*
|
|
35
|
+
* NOTHING HERE THROWS INTO A REQUEST. A discovery failure is an ABSENCE OF A ROUTE, which the
|
|
36
|
+
* caller turns into "try the next endpoint", not an error that trips a breaker on an engine that
|
|
37
|
+
* is very probably fine.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
'use strict';
|
|
41
|
+
|
|
42
|
+
/** Two seconds is what the contract suggests; the document is served no-store. */
|
|
43
|
+
const POLL_MS = 2000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How long a document stays usable once the listener goes quiet.
|
|
47
|
+
*
|
|
48
|
+
* Long enough that a supervisor restart is invisible, short enough that nobody is routing on a view
|
|
49
|
+
* from another era. The contract says "a few minutes is reasonable".
|
|
50
|
+
*/
|
|
51
|
+
const STALE_MS = 3 * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
/** The only state that may receive new work. */
|
|
54
|
+
const HEALTHY = 'healthy';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} opts
|
|
58
|
+
* @param {string} opts.statusUrl e.g. https://host:9443/status
|
|
59
|
+
* @param {string} opts.instance the deployment this row believes it is talking to
|
|
60
|
+
* @param {string} opts.token bearer for the status listener
|
|
61
|
+
* @param {string} [opts.ca] PEM of the certificate to pin
|
|
62
|
+
* @param {number} [opts.staleMs]
|
|
63
|
+
* @param {number} [opts.pollMs]
|
|
64
|
+
* @param {object} [opts.logger]
|
|
65
|
+
* @param {function} [opts.fetchImpl] injectable for tests; defaults to global fetch
|
|
66
|
+
* @param {function} [opts.now] injectable clock
|
|
67
|
+
*/
|
|
68
|
+
function createLmxDiscovery(opts = {}) {
|
|
69
|
+
const {
|
|
70
|
+
statusUrl,
|
|
71
|
+
instance,
|
|
72
|
+
token,
|
|
73
|
+
ca = null,
|
|
74
|
+
staleMs = STALE_MS,
|
|
75
|
+
pollMs = POLL_MS,
|
|
76
|
+
logger = console,
|
|
77
|
+
fetchImpl,
|
|
78
|
+
now = () => Date.now()
|
|
79
|
+
} = opts;
|
|
80
|
+
|
|
81
|
+
if (!statusUrl) throw new Error('createLmxDiscovery: statusUrl is required');
|
|
82
|
+
if (!instance) throw new Error('createLmxDiscovery: instance is required — identity is (instance, name)');
|
|
83
|
+
|
|
84
|
+
let doc = null; // the last document that parsed and matched our instance
|
|
85
|
+
let docAt = 0;
|
|
86
|
+
let lastError = null;
|
|
87
|
+
let timer = null;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The pinned transport — see providers/lmxTransport.js for why this is not NODE_EXTRA_CA_CERTS
|
|
91
|
+
* and never `rejectUnauthorized: false`. Built lazily and cached there, so a consumer that never
|
|
92
|
+
* configures lmx does not load undici at all.
|
|
93
|
+
*/
|
|
94
|
+
const transport = () => (fetchImpl
|
|
95
|
+
? { fetch: fetchImpl, dispatcher: undefined }
|
|
96
|
+
: require('./lmxTransport').lmxTransport(ca));
|
|
97
|
+
|
|
98
|
+
async function fetchOnce() {
|
|
99
|
+
const t = transport();
|
|
100
|
+
const res = await t.fetch(statusUrl, {
|
|
101
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
102
|
+
dispatcher: t.dispatcher
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
const err = new Error(`status listener answered ${res.status}`);
|
|
106
|
+
err.status = res.status;
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
return res.json();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Fetch once and adopt the result if it is usable.
|
|
114
|
+
*
|
|
115
|
+
* A document for the WRONG INSTANCE is rejected rather than adopted. It is not a transport
|
|
116
|
+
* failure — the listener answered perfectly — so it is reported as a configuration error, which
|
|
117
|
+
* is what it is. Adopting it would route `analysis` to another deployment's `analysis`.
|
|
118
|
+
*/
|
|
119
|
+
async function refresh() {
|
|
120
|
+
try {
|
|
121
|
+
const next = await fetchOnce();
|
|
122
|
+
if (next && next.instance && next.instance !== instance) {
|
|
123
|
+
lastError = `status listener at ${statusUrl} reports instance "${next.instance}", not `
|
|
124
|
+
+ `"${instance}" — this endpoint is pointed at a different stack, and engine names `
|
|
125
|
+
+ 'collide across stacks';
|
|
126
|
+
logger.error(`lmx: ${lastError}`);
|
|
127
|
+
return { ok: false, error: lastError };
|
|
128
|
+
}
|
|
129
|
+
doc = next;
|
|
130
|
+
docAt = now();
|
|
131
|
+
lastError = null;
|
|
132
|
+
return { ok: true, doc: next };
|
|
133
|
+
} catch (e) {
|
|
134
|
+
lastError = e.message;
|
|
135
|
+
// LOUDLY, per the contract — and with the age, because "unreachable" matters differently at
|
|
136
|
+
// four seconds and at four minutes.
|
|
137
|
+
logger.warn(`lmx: status unreachable (${e.message}); routing on a document ${ageSec()}s old`);
|
|
138
|
+
return { ok: false, error: e.message };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const ageMs = () => (docAt ? now() - docAt : Infinity);
|
|
143
|
+
const ageSec = () => (docAt ? Math.round(ageMs() / 1000) : 0);
|
|
144
|
+
const isStale = () => ageMs() > staleMs;
|
|
145
|
+
|
|
146
|
+
/** Every engine in the last usable document. Empty when there is nothing fresh enough to say. */
|
|
147
|
+
function engines() {
|
|
148
|
+
if (!doc || isStale()) return [];
|
|
149
|
+
return Array.isArray(doc.engines) ? doc.engines : [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The engine record for `name`, whatever state it is in — for a health card, which needs to show
|
|
154
|
+
* "draining" rather than "gone".
|
|
155
|
+
*/
|
|
156
|
+
const engine = (name) => engines().find((e) => e && e.name === name) || null;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Where to send work for `name`, or null.
|
|
160
|
+
*
|
|
161
|
+
* Returns a REASON alongside, because the caller has to distinguish "this engine is fine and busy
|
|
162
|
+
* being replaced" from "we cannot see the stack" from "there is no such engine" — three different
|
|
163
|
+
* things that all mean "not right now" and only one of which is anybody's fault.
|
|
164
|
+
*/
|
|
165
|
+
function resolve(name) {
|
|
166
|
+
if (!doc) return { url: null, reason: 'no_document', detail: lastError };
|
|
167
|
+
if (isStale()) return { url: null, reason: 'stale', detail: `${ageSec()}s old` };
|
|
168
|
+
|
|
169
|
+
const e = engine(name);
|
|
170
|
+
if (!e) return { url: null, reason: 'unknown_engine' };
|
|
171
|
+
|
|
172
|
+
// Select FOR healthy. `draining`, `restarting`, and any state invented after this was written
|
|
173
|
+
// all fail this test, which is the property worth having.
|
|
174
|
+
if (e.state !== HEALTHY) return { url: null, reason: 'not_healthy', detail: e.state };
|
|
175
|
+
if (!e.url) return { url: null, reason: 'no_url' };
|
|
176
|
+
|
|
177
|
+
return { url: e.url, engine: e };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function start() {
|
|
181
|
+
if (timer) return;
|
|
182
|
+
refresh();
|
|
183
|
+
timer = setInterval(refresh, pollMs);
|
|
184
|
+
if (timer.unref) timer.unref(); // never hold a process open for a poller
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function stop() {
|
|
188
|
+
if (timer) clearInterval(timer);
|
|
189
|
+
timer = null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
start, stop, refresh, resolve, engines, engine,
|
|
194
|
+
/** The pinned transport, so ENGINE calls reach the same host over the same trust. */
|
|
195
|
+
transport,
|
|
196
|
+
/** For a diagnostics panel: what we know and how old it is. */
|
|
197
|
+
status: () => ({
|
|
198
|
+
instance,
|
|
199
|
+
statusUrl,
|
|
200
|
+
overall: doc ? doc.state : null,
|
|
201
|
+
engineCount: engines().length,
|
|
202
|
+
ageSec: doc ? ageSec() : null,
|
|
203
|
+
stale: doc ? isStale() : true,
|
|
204
|
+
lastError
|
|
205
|
+
})
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = { createLmxDiscovery, POLL_MS, STALE_MS, HEALTHY };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pinned HTTPS transport an lmx stack is reached over — status listener AND engines.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS NOT `NODE_EXTRA_CA_CERTS`. That is the first thing the contract suggests and the
|
|
5
|
+
* wrong answer here, for three reasons: it is read once before Node starts, so a certificate that
|
|
6
|
+
* lives in the database can never get into it; it is PROCESS-GLOBAL, so a second stack with its own
|
|
7
|
+
* certificate is impossible and every unrelated outbound request in the app silently gains a new
|
|
8
|
+
* trusted root; and it cannot be overridden per engine, which the configuration deliberately
|
|
9
|
+
* allows.
|
|
10
|
+
*
|
|
11
|
+
* WHY NOT `rejectUnauthorized: false`. Because that is not "ignore this one self-signed
|
|
12
|
+
* certificate", it is "accept any certificate from anyone able to answer on that address" — the
|
|
13
|
+
* whole attack pinning exists to stop. The option appears nowhere in this package and a test
|
|
14
|
+
* asserts its absence.
|
|
15
|
+
*
|
|
16
|
+
* `ca` REPLACES the trust store for this connection rather than adding to it. That is what makes it
|
|
17
|
+
* a pin: only this certificate is accepted, not this certificate plus every public CA.
|
|
18
|
+
*
|
|
19
|
+
* UNDICI'S OWN `fetch`, NOT THE GLOBAL ONE. Node's global fetch is built on a private copy of
|
|
20
|
+
* undici, and there is no supported way to hand it a dispatcher from the copy installed here — the
|
|
21
|
+
* two do not have to recognise each other's classes. Using the installed package for both halves
|
|
22
|
+
* removes the question entirely.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
/** Cache by certificate text: one agent per distinct cert, not one per request. */
|
|
28
|
+
const agents = new Map();
|
|
29
|
+
|
|
30
|
+
function requireUndici() {
|
|
31
|
+
try {
|
|
32
|
+
return require('undici');
|
|
33
|
+
} catch (e) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'lmx needs the `undici` package to pin a self-signed certificate — Node\'s built-in fetch '
|
|
36
|
+
+ 'cannot be given a certificate authority. Install it: npm install undici'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string|null} ca PEM text of the certificate to pin. Null means the system trust store,
|
|
43
|
+
* which is correct for a stack behind a normally-issued certificate.
|
|
44
|
+
* @returns {{fetch: function, dispatcher: object|undefined}}
|
|
45
|
+
*/
|
|
46
|
+
function lmxTransport(ca) {
|
|
47
|
+
if (!ca) return { fetch: globalThis.fetch, dispatcher: undefined };
|
|
48
|
+
|
|
49
|
+
const key = String(ca);
|
|
50
|
+
if (!agents.has(key)) {
|
|
51
|
+
const { Agent } = requireUndici();
|
|
52
|
+
agents.set(key, new Agent({ connect: { ca: key } }));
|
|
53
|
+
}
|
|
54
|
+
return { fetch: requireUndici().fetch, dispatcher: agents.get(key) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Test seam: forget the cached agents so a rotated certificate is picked up. */
|
|
58
|
+
lmxTransport._reset = () => agents.clear();
|
|
59
|
+
|
|
60
|
+
module.exports = { lmxTransport };
|
|
@@ -20,6 +20,16 @@ const { AiError, fromFetchFailure, redact } = require('../error');
|
|
|
20
20
|
* temperature?:number, schema?:object, signal?:AbortSignal}} opts
|
|
21
21
|
* @returns {Promise<{text:string, json:object|null, model:string, usage:object, ms:number}>}
|
|
22
22
|
*/
|
|
23
|
+
/**
|
|
24
|
+
* The HTTP transport for this config.
|
|
25
|
+
*
|
|
26
|
+
* Defaults to the global fetch, which is right for every provider reached over ordinary TLS. A
|
|
27
|
+
* caller that must PIN a certificate — lmx, whose stack is self-signed — passes its own
|
|
28
|
+
* { fetch, dispatcher } through cfg.transport, so engine calls travel over the same pinned trust
|
|
29
|
+
* as the status poll rather than falling back to the system trust store for the actual work.
|
|
30
|
+
*/
|
|
31
|
+
const tx = (cfg) => (cfg && cfg.transport) || { fetch: globalThis.fetch, dispatcher: undefined };
|
|
32
|
+
|
|
23
33
|
async function complete(cfg, opts) {
|
|
24
34
|
const label = cfg.label || 'The model server';
|
|
25
35
|
const url = apiRoot(cfg.baseUrl) + '/chat/completions';
|
|
@@ -31,6 +41,13 @@ async function complete(cfg, opts) {
|
|
|
31
41
|
for (const m of opts.messages || []) messages.push({ role: m.role, content: m.content });
|
|
32
42
|
|
|
33
43
|
const body = {
|
|
44
|
+
// SERVER-SPECIFIC ARGUMENTS THIS ADAPTER KNOWS NOTHING ABOUT, spread FIRST so nothing in a
|
|
45
|
+
// passthrough can override the fields below — a caller must not be able to change the model or
|
|
46
|
+
// turn on streaming this way. Its purpose is arguments that are real, documented and outside
|
|
47
|
+
// the OpenAI schema: llama.cpp's `chat_template_kwargs`, gpt-oss's `reasoning_effort`. Both are
|
|
48
|
+
// load-bearing — without the right one the model spends its whole budget reasoning and returns
|
|
49
|
+
// empty content with finish_reason "length", and no error at all.
|
|
50
|
+
...(opts.extra || {}),
|
|
34
51
|
model: cfg.model,
|
|
35
52
|
messages,
|
|
36
53
|
max_tokens: opts.maxTokens || 1024,
|
|
@@ -55,7 +72,8 @@ async function complete(cfg, opts) {
|
|
|
55
72
|
|
|
56
73
|
let res;
|
|
57
74
|
try {
|
|
58
|
-
res = await fetch(url, {
|
|
75
|
+
res = await tx(cfg).fetch(url, {
|
|
76
|
+
dispatcher: tx(cfg).dispatcher,
|
|
59
77
|
method: 'POST',
|
|
60
78
|
headers: Object.assign(
|
|
61
79
|
{ 'Content-Type': 'application/json' },
|
|
@@ -304,7 +322,8 @@ function normaliseUsage(u) {
|
|
|
304
322
|
async function listModelsResult(cfg) {
|
|
305
323
|
const url = apiRoot(cfg.baseUrl) + '/models';
|
|
306
324
|
try {
|
|
307
|
-
const res = await fetch(url, {
|
|
325
|
+
const res = await tx(cfg).fetch(url, {
|
|
326
|
+
dispatcher: tx(cfg).dispatcher,
|
|
308
327
|
headers: cfg.apiKey ? { Authorization: `Bearer ${cfg.apiKey}` } : {},
|
|
309
328
|
signal: AbortSignal.timeout(cfg.timeoutMs || 10000)
|
|
310
329
|
});
|
|
@@ -353,7 +372,8 @@ async function embed(cfg, texts) {
|
|
|
353
372
|
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs || 60000);
|
|
354
373
|
let res;
|
|
355
374
|
try {
|
|
356
|
-
res = await fetch(url, {
|
|
375
|
+
res = await tx(cfg).fetch(url, {
|
|
376
|
+
dispatcher: tx(cfg).dispatcher,
|
|
357
377
|
method: 'POST', headers, signal: controller.signal,
|
|
358
378
|
body: JSON.stringify({ model: cfg.embeddingModel, input: list })
|
|
359
379
|
});
|
package/usageStore.js
CHANGED
|
@@ -71,12 +71,22 @@ function createUsageStore(opts = {}) {
|
|
|
71
71
|
const rollupTable = opts.rollupTable || 'ai_usage_daily';
|
|
72
72
|
const scopeCol = opts.scopeColumn || null;
|
|
73
73
|
const routeCol = opts.routeColumn || null;
|
|
74
|
+
/**
|
|
75
|
+
* WHICH ENDPOINT, as opposed to which KIND.
|
|
76
|
+
*
|
|
77
|
+
* `provider` has always held the adapter kind — 'lmstudio', 'openai-compatible' — which was
|
|
78
|
+
* sufficient while one provider was configured and became wrong the moment a registry could hold
|
|
79
|
+
* several. Two endpoints of the same kind share a `provider` value, so per-endpoint totals were
|
|
80
|
+
* unobtainable and a consuming screen keying on the endpoint id found nothing at all.
|
|
81
|
+
*/
|
|
82
|
+
const endpointCol = opts.endpointColumn || null;
|
|
74
83
|
const now = typeof opts.now === 'function' ? opts.now : () => new Date();
|
|
75
84
|
|
|
76
85
|
/** Column list and placeholders, built once from what the app actually has. */
|
|
77
86
|
const cols = ['day', 'provider', 'model', 'calls', 'prompt_tokens', 'completion_tokens', 'total_tokens'];
|
|
78
87
|
if (scopeCol) cols.splice(3, 0, scopeCol);
|
|
79
88
|
if (routeCol) cols.splice(3, 0, routeCol);
|
|
89
|
+
if (endpointCol) cols.splice(3, 0, endpointCol);
|
|
80
90
|
|
|
81
91
|
return {
|
|
82
92
|
table,
|
|
@@ -95,6 +105,9 @@ function createUsageStore(opts = {}) {
|
|
|
95
105
|
const total = Number(entry.totalTokens) || (prompt + completion);
|
|
96
106
|
|
|
97
107
|
const values = [dayOf(now()), String(entry.provider || ''), String(entry.model || '')];
|
|
108
|
+
// ORDER MATCHES THE SPLICES ABOVE, last spliced first: each splice at index 3 pushes the
|
|
109
|
+
// previous one right, so the column list reads endpoint, route, scope.
|
|
110
|
+
if (endpointCol) values.push(entry.endpoint == null ? null : String(entry.endpoint));
|
|
98
111
|
if (routeCol) values.push(entry.route == null ? null : String(entry.route));
|
|
99
112
|
if (scopeCol) values.push(entry.scope == null ? null : entry.scope);
|
|
100
113
|
values.push(1, prompt, completion, total);
|
|
@@ -130,7 +143,9 @@ function createUsageStore(opts = {}) {
|
|
|
130
143
|
/** For the admin screen: recent days, newest first. */
|
|
131
144
|
async byDay(days = 14) {
|
|
132
145
|
const limit = Math.min(200, Math.max(1, Number(days) * 4));
|
|
133
|
-
const group = ['day', 'provider', 'model']
|
|
146
|
+
const group = ['day', 'provider', 'model']
|
|
147
|
+
.concat(endpointCol ? [endpointCol] : [])
|
|
148
|
+
.concat(routeCol ? [routeCol] : []);
|
|
134
149
|
return driver.all(
|
|
135
150
|
`SELECT ${group.join(', ')},
|
|
136
151
|
SUM(calls) calls, SUM(prompt_tokens) prompt_tokens,
|
|
@@ -141,6 +156,59 @@ function createUsageStore(opts = {}) {
|
|
|
141
156
|
LIMIT ?`, [limit]);
|
|
142
157
|
},
|
|
143
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Totals per ENDPOINT over a window.
|
|
161
|
+
*
|
|
162
|
+
* The question a registry screen asks — "what has this machine cost" — which `byProvider` could
|
|
163
|
+
* never answer, since it groups by adapter kind and two endpoints of one kind collapse together.
|
|
164
|
+
* Returns nothing useful unless an endpointColumn is configured, and says so rather than
|
|
165
|
+
* silently grouping by something else.
|
|
166
|
+
*/
|
|
167
|
+
async byEndpoint(sinceDay) {
|
|
168
|
+
if (!endpointCol) throw new Error('createUsageStore: no endpointColumn was configured');
|
|
169
|
+
return driver.all(
|
|
170
|
+
`SELECT ${endpointCol} AS endpoint, SUM(calls) calls, SUM(prompt_tokens) prompt_tokens,
|
|
171
|
+
SUM(completion_tokens) completion_tokens, SUM(total_tokens) total_tokens
|
|
172
|
+
FROM ${table}
|
|
173
|
+
WHERE day >= ?
|
|
174
|
+
GROUP BY ${endpointCol}
|
|
175
|
+
ORDER BY total_tokens DESC`, [String(sinceDay)]);
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Totals per ROUTE over a window.
|
|
180
|
+
*
|
|
181
|
+
* The actionable half of a spend report: a job can be pointed at a cheaper machine, run less
|
|
182
|
+
* often or switched off, while a machine is only ever the thing that happened to answer. Rows
|
|
183
|
+
* with no route come back under a NULL key — real spend that no job asked for, which must be
|
|
184
|
+
* shown rather than folded into a job that did not incur it.
|
|
185
|
+
*/
|
|
186
|
+
async byRoute(sinceDay) {
|
|
187
|
+
if (!routeCol) throw new Error('createUsageStore: no routeColumn was configured');
|
|
188
|
+
return driver.all(
|
|
189
|
+
`SELECT ${routeCol} AS route, SUM(calls) calls, SUM(prompt_tokens) prompt_tokens,
|
|
190
|
+
SUM(completion_tokens) completion_tokens, SUM(total_tokens) total_tokens
|
|
191
|
+
FROM ${table}
|
|
192
|
+
WHERE day >= ?
|
|
193
|
+
GROUP BY ${routeCol}
|
|
194
|
+
ORDER BY total_tokens DESC`, [String(sinceDay)]);
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Tokens spent today by ONE endpoint.
|
|
199
|
+
*
|
|
200
|
+
* WHAT A PER-ENDPOINT CEILING HAS TO COUNT. `tokensToday()` sums every endpoint, so comparing it
|
|
201
|
+
* against one endpoint's cap makes every cap a global one: two machines capped at 10k each stop
|
|
202
|
+
* at 10k between them rather than 10k apiece, which is not what a per-endpoint field says.
|
|
203
|
+
*/
|
|
204
|
+
async tokensTodayFor(endpoint) {
|
|
205
|
+
if (!endpointCol) return this.tokensToday();
|
|
206
|
+
const r = await driver.get(
|
|
207
|
+
`SELECT COALESCE(SUM(total_tokens), 0) n FROM ${table} WHERE day = ? AND ${endpointCol} = ?`,
|
|
208
|
+
[dayOf(now()), String(endpoint)]);
|
|
209
|
+
return Number(r.n) || 0;
|
|
210
|
+
},
|
|
211
|
+
|
|
144
212
|
/** Totals per provider over a window, for the usage screen's per-provider table. */
|
|
145
213
|
async byProvider(sinceDay) {
|
|
146
214
|
return driver.all(
|
|
@@ -243,12 +311,13 @@ function schemaFor(dialect, o = {}) {
|
|
|
243
311
|
now: () => "datetime('now')"
|
|
244
312
|
};
|
|
245
313
|
const route = o.routeColumn ? `\n ${o.routeColumn}${' '.repeat(Math.max(1, 14 - o.routeColumn.length))}TEXT,` : '';
|
|
314
|
+
const endpoint = o.endpointColumn ? `\n ${o.endpointColumn}${' '.repeat(Math.max(1, 14 - o.endpointColumn.length))}TEXT,` : '';
|
|
246
315
|
return {
|
|
247
316
|
detail: `
|
|
248
317
|
id ${t.autoIncrementPk()},
|
|
249
318
|
day TEXT NOT NULL,
|
|
250
319
|
provider TEXT NOT NULL,
|
|
251
|
-
model TEXT NOT NULL,${route}
|
|
320
|
+
model TEXT NOT NULL,${endpoint}${route}
|
|
252
321
|
calls INTEGER NOT NULL DEFAULT 1,
|
|
253
322
|
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
|
254
323
|
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
|
@@ -260,7 +329,7 @@ function schemaFor(dialect, o = {}) {
|
|
|
260
329
|
rollup: `
|
|
261
330
|
day TEXT NOT NULL,
|
|
262
331
|
provider TEXT NOT NULL,
|
|
263
|
-
model TEXT NOT NULL,${route}
|
|
332
|
+
model TEXT NOT NULL,${endpoint}${route}
|
|
264
333
|
calls INTEGER NOT NULL DEFAULT 0,
|
|
265
334
|
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
|
266
335
|
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
|
@@ -151,8 +151,9 @@
|
|
|
151
151
|
<% } else if (sp && sp.latest && sp.latest.tokens_per_sec != null) { %>
|
|
152
152
|
<div>
|
|
153
153
|
<div class="text-body-secondary text-uppercase" style="font-size:.68rem;letter-spacing:.06em">Speed</div>
|
|
154
|
-
<%# ONE MEASUREMENT IS NOT A TREND, so it is shown as a number and no line. Two
|
|
155
|
-
|
|
154
|
+
<%# ONE MEASUREMENT IS NOT A TREND, so it is shown as a number and no line. Two ARE a
|
|
155
|
+
trend — the smallest one there is, and "it moved from 51.5 to 51.9" is worth seeing —
|
|
156
|
+
so the line starts at two, which is what the guard above says. %>
|
|
156
157
|
<span class="font-monospace" style="font-variant-numeric: tabular-nums"><%= sp.latest.tokens_per_sec %> tok/s</span>
|
|
157
158
|
</div>
|
|
158
159
|
<% } %>
|