@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 CHANGED
@@ -320,13 +320,69 @@ function createHealthChecker(opts = {}) {
320
320
  };
321
321
  },
322
322
 
323
- /** All known state, for persisting across a restart if an app wants to. */
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.12.1",
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", "usageStore.js", "providerStore.js","speedStore.js", "health.js",
20
- "benchmark.js", "views/"
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": { "optional": true }
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',
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'].concat(routeCol ? [routeCol] : []);
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 points
155
- drawn as a chart invite reading a slope into a single pair of readings. %>
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
  <% } %>