@aria-framework/ai 0.12.0 → 0.13.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
@@ -207,6 +207,11 @@ module.exports = {
207
207
  // Speed history. Lazy for the same reason as the others: it needs the db-worker driver contract,
208
208
  // which is an optional peer.
209
209
  get createSpeedStore() { return require('./speedStore').createSpeedStore; },
210
+ // EXPORTED SO A CONSUMER CAN ASSERT ITS TABLE MATCHES. An app writes its own migration, which is
211
+ // a hand copy of this DDL — and a copy with nothing comparing it to the original is the failure
212
+ // mode this repo has already documented twice. providerSchemaFor and usageSchemaFor exist for the
213
+ // same reason; leaving this one out meant a drift would surface as an INSERT throwing at runtime.
214
+ get speedSchemaFor() { return require('./speedStore').schemaFor; },
210
215
  // No database behind health, so it loads eagerly like the rest of the seam.
211
216
  ...require('./health'),
212
217
  /**
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.0",
4
+ "version": "0.13.0",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
7
7
  "publishConfig": {
package/speedStore.js CHANGED
@@ -33,6 +33,22 @@ function createSpeedStore(opts = {}) {
33
33
 
34
34
  const num = (v) => (v == null || v === '' || Number.isNaN(Number(v)) ? null : Number(v));
35
35
 
36
+ /**
37
+ * `YYYY-MM-DD HH:MM:SS` in UTC — what `datetime('now')` produces, and what every other timestamp
38
+ * column in a consuming app already holds.
39
+ *
40
+ * toISOString() was the obvious thing to write and the wrong thing to store. Apps parse these
41
+ * columns with `new Date(String(ts).replace(' ', 'T') + 'Z')`; given an ISO string there is no
42
+ * space to replace, so the 'Z' is appended to one already there and the result is Invalid Date —
43
+ * silently, because the helper checks isNaN and returns ''. Sorting is worse: 'T' (0x54) sorts
44
+ * after ' ' (0x20), so an ISO row and a datetime('now') row from the same second compare wrong.
45
+ */
46
+ const stamp = (d) => {
47
+ const p2 = (n) => String(n).padStart(2, '0');
48
+ return `${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())} `
49
+ + `${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}:${p2(d.getUTCSeconds())}`;
50
+ };
51
+
36
52
  return {
37
53
  table,
38
54
 
@@ -50,7 +66,7 @@ function createSpeedStore(opts = {}) {
50
66
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
51
67
  [
52
68
  String(entry.endpoint || ''),
53
- (entry.at || now()).toISOString(),
69
+ stamp(entry.at || now()),
54
70
  num(entry.tokensPerSec),
55
71
  num(entry.coldMs),
56
72
  num(entry.runTokens),
@@ -80,15 +96,29 @@ function createSpeedStore(opts = {}) {
80
96
  const ids = (endpoints || []).map((e) => String(e)).filter(Boolean);
81
97
  if (!ids.length) return {};
82
98
  const capped = Math.max(1, Math.min(500, Number(limit) || DEFAULT_LIMIT));
83
- const rows = await driver.all(
84
- `SELECT * FROM ${table} WHERE endpoint IN (${ids.map(() => '?').join(', ')}) ORDER BY id DESC`,
85
- ids
86
- );
99
+ // LIMITED PER ENDPOINT, IN SQL. A single `WHERE endpoint IN (...) ORDER BY id DESC` reads
100
+ // every retained row for every endpoint and throws most of them away in JS — at the 200-row
101
+ // bound with eight endpoints that is 1600 rows, `error` text included, marshalled across the
102
+ // worker boundary to draw eight 88-pixel lines. It also cannot use the (endpoint, id DESC)
103
+ // index for a global ordering, so it sorts in a temp b-tree as well.
104
+ //
105
+ // UNION ALL of per-endpoint selects rather than a window function: it reads the same on both
106
+ // engines and needs no version floor.
107
+ // EACH BRANCH IN A SUBQUERY, and aliased. A bare `... ORDER BY id DESC LIMIT n UNION ALL ...`
108
+ // is a syntax error — the ORDER BY binds to the compound, not the branch — and Postgres
109
+ // additionally requires a name for a subquery in FROM. Both engines accept this form.
110
+ const sql = ids
111
+ .map((_, i) => `SELECT * FROM (SELECT * FROM ${table} WHERE endpoint = ? `
112
+ + `ORDER BY id DESC LIMIT ${capped}) AS s${i}`)
113
+ .join(' UNION ALL ');
114
+ const rows = await driver.all(sql, ids);
87
115
  const out = {};
88
116
  for (const row of rows) {
89
117
  const bucket = out[row.endpoint] || (out[row.endpoint] = { latest: null, points: [] });
90
- if (!bucket.latest) bucket.latest = row; // rows arrive newest first
91
- if (bucket.points.length < capped) bucket.points.push(row);
118
+ // Newest first WITHIN each endpoint's block, which is all this needs — the blocks
119
+ // themselves may arrive in any order.
120
+ if (!bucket.latest) bucket.latest = row;
121
+ bucket.points.push(row);
92
122
  }
93
123
  // Reversed at the end so a caller draws left-to-right in time without thinking about it.
94
124
  for (const id of Object.keys(out)) out[id].points.reverse();
@@ -102,9 +132,15 @@ function createSpeedStore(opts = {}) {
102
132
  * history, and one checked hourly should not be able to bury it. Age-based pruning gets that
103
133
  * backwards for exactly the endpoints whose trend matters most.
104
134
  */
105
- async prune({ keepPerEndpoint = 200 } = {}) {
135
+ async prune({ keepPerEndpoint = 200, endpoint = null } = {}) {
106
136
  const keep = Math.max(10, Math.min(5000, Number(keepPerEndpoint) || 200));
107
- const ids = await driver.all(`SELECT DISTINCT endpoint FROM ${table}`, []);
137
+ // ONE ENDPOINT WHEN THE CALLER KNOWS WHICH. Pruning on write swept every endpoint in the
138
+ // table every time — 1 + 2N queries to delete at most one row belonging to one of them, since
139
+ // none of the others can have grown since their own last write. With eight endpoints that was
140
+ // seventeen worker round trips inside a request, for one deletion.
141
+ const ids = endpoint
142
+ ? [{ endpoint: String(endpoint) }]
143
+ : await driver.all(`SELECT DISTINCT endpoint FROM ${table}`, []);
108
144
  let removed = 0;
109
145
  for (const { endpoint } of ids) {
110
146
  const cutoff = await driver.get(
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
  <% } %>