@aria-framework/ai 0.11.0 → 0.12.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/index.js CHANGED
@@ -204,6 +204,9 @@ module.exports = {
204
204
  // install a database package to require this one.
205
205
  get createUsageStore() { return require('./usageStore').createUsageStore; },
206
206
  get createProviderStore() { return require('./providerStore').createProviderStore; },
207
+ // Speed history. Lazy for the same reason as the others: it needs the db-worker driver contract,
208
+ // which is an optional peer.
209
+ get createSpeedStore() { return require('./speedStore').createSpeedStore; },
207
210
  // No database behind health, so it loads eagerly like the rest of the seam.
208
211
  ...require('./health'),
209
212
  /**
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.11.0",
4
+ "version": "0.12.0",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
7
7
  "publishConfig": {
@@ -16,7 +16,7 @@
16
16
  "generate.js",
17
17
  "providers/openai-compatible.js",
18
18
  "providers/anthropic.js",
19
- "browser/ai-polish.js", "usageStore.js", "providerStore.js", "health.js",
19
+ "browser/ai-polish.js", "usageStore.js", "providerStore.js","speedStore.js", "health.js",
20
20
  "benchmark.js", "views/"
21
21
  ],
22
22
  "peerDependencies": {
@@ -26,6 +26,6 @@
26
26
  "@aria-framework/db-worker": { "optional": true }
27
27
  },
28
28
  "scripts": {
29
- "test": "node test/smoke.js && node test/usageStore.js && node test/providerStore.js && node test/health.js && node test/listModels.js && node test/benchmark.js && node test/packaging.js && node test/views.js"
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"
30
30
  }
31
31
  }
package/speedStore.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * A speed check, kept.
3
+ *
4
+ * WHY IT IS NOT THE AUDIT LOG. A benchmark already leaves an audit row, and it should: someone
5
+ * spent tokens, and that is an audit concern. But the audit log answers "who did what", and it is
6
+ * pruned on a policy about how long security records are kept. Coupling "how fast is this GPU over
7
+ * time" to that policy means either keeping audit rows longer than intended or losing months of
8
+ * performance history to a decision that had nothing to do with performance. Two questions, two
9
+ * retentions, two shapes.
10
+ *
11
+ * WHY ROWS AND NOT AN AVERAGE. An endpoint's speed is not one number, it is a line: the interesting
12
+ * moments are the changes. A local model that quietly halved in speed the day a second server
13
+ * started sharing its GPU is invisible in a running average and obvious in a sequence — and that
14
+ * exact case was observed while this was being built, LM Studio dropping from 51 tok/s to 8 while a
15
+ * 22 GB model sat resident beside it.
16
+ *
17
+ * WHY THE COLD TIME IS STORED SEPARATELY. It measures a different thing from the rate — whether the
18
+ * model was resident — and mixing them hides both.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ /** Newest first everywhere: a trend is read backwards from now, and a card wants the last one. */
24
+ const DEFAULT_LIMIT = 30;
25
+
26
+ function createSpeedStore(opts = {}) {
27
+ const driver = opts.driver;
28
+ if (!driver || typeof driver.run !== 'function') {
29
+ throw new Error('createSpeedStore({ driver }): the db-worker driver contract is required');
30
+ }
31
+ const table = opts.table || 'ai_speed_checks';
32
+ const now = opts.now || (() => new Date());
33
+
34
+ const num = (v) => (v == null || v === '' || Number.isNaN(Number(v)) ? null : Number(v));
35
+
36
+ return {
37
+ table,
38
+
39
+ /**
40
+ * Record one measurement.
41
+ *
42
+ * A FAILED CHECK IS RECORDED TOO, with a null rate. "It could not be reached at 14:00" is part
43
+ * of the trend, and dropping it would draw a line straight through an outage as though nothing
44
+ * had happened.
45
+ */
46
+ async record(entry = {}) {
47
+ await driver.run(
48
+ `INSERT INTO ${table}
49
+ (endpoint, checked_at, tokens_per_sec, cold_ms, run_tokens, verdict, reasoning, tokens_spent, error)
50
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
51
+ [
52
+ String(entry.endpoint || ''),
53
+ (entry.at || now()).toISOString(),
54
+ num(entry.tokensPerSec),
55
+ num(entry.coldMs),
56
+ num(entry.runTokens),
57
+ entry.verdict ? String(entry.verdict) : null,
58
+ entry.reasoning ? 1 : 0,
59
+ Number(entry.tokensSpent) || 0,
60
+ entry.error ? String(entry.error).slice(0, 500) : null
61
+ ]
62
+ );
63
+ },
64
+
65
+ /** The most recent checks for one endpoint, newest first. */
66
+ async recent(endpoint, limit = DEFAULT_LIMIT) {
67
+ return driver.all(
68
+ `SELECT * FROM ${table} WHERE endpoint = ? ORDER BY id DESC LIMIT ?`,
69
+ [String(endpoint), Math.max(1, Math.min(500, Number(limit) || DEFAULT_LIMIT))]
70
+ );
71
+ },
72
+
73
+ /**
74
+ * A series per endpoint, OLDEST FIRST, ready to draw.
75
+ *
76
+ * One query for every endpoint rather than one per card: a page with eight endpoints should not
77
+ * make eight round trips to draw eight small lines.
78
+ */
79
+ async seriesFor(endpoints, limit = DEFAULT_LIMIT) {
80
+ const ids = (endpoints || []).map((e) => String(e)).filter(Boolean);
81
+ if (!ids.length) return {};
82
+ 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
+ );
87
+ const out = {};
88
+ for (const row of rows) {
89
+ 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);
92
+ }
93
+ // Reversed at the end so a caller draws left-to-right in time without thinking about it.
94
+ for (const id of Object.keys(out)) out[id].points.reverse();
95
+ return out;
96
+ },
97
+
98
+ /**
99
+ * Keep the most recent N per endpoint.
100
+ *
101
+ * Bounded by ENDPOINT rather than by age: an endpoint checked twice a year deserves to keep its
102
+ * history, and one checked hourly should not be able to bury it. Age-based pruning gets that
103
+ * backwards for exactly the endpoints whose trend matters most.
104
+ */
105
+ async prune({ keepPerEndpoint = 200 } = {}) {
106
+ const keep = Math.max(10, Math.min(5000, Number(keepPerEndpoint) || 200));
107
+ const ids = await driver.all(`SELECT DISTINCT endpoint FROM ${table}`, []);
108
+ let removed = 0;
109
+ for (const { endpoint } of ids) {
110
+ const cutoff = await driver.get(
111
+ `SELECT id FROM ${table} WHERE endpoint = ? ORDER BY id DESC LIMIT 1 OFFSET ?`,
112
+ [endpoint, keep]
113
+ );
114
+ if (!cutoff) continue;
115
+ const r = await driver.run(
116
+ `DELETE FROM ${table} WHERE endpoint = ? AND id <= ?`, [endpoint, cutoff.id]
117
+ );
118
+ removed += (r && r.changes) || 0;
119
+ }
120
+ return { removed };
121
+ }
122
+ };
123
+ }
124
+
125
+ /** The DDL, in the caller's dialect. */
126
+ function schemaFor(dialect) {
127
+ const t = dialect || {
128
+ autoIncrementPk: () => 'INTEGER PRIMARY KEY AUTOINCREMENT',
129
+ now: () => "datetime('now')"
130
+ };
131
+ return `
132
+ id ${t.autoIncrementPk()},
133
+ -- The endpoint NAME, not a foreign key: a measurement is still true after the endpoint it
134
+ -- describes has been deleted, and "this machine used to manage 80 tok/s" is worth keeping when
135
+ -- deciding whether to configure it again.
136
+ endpoint TEXT NOT NULL,
137
+ checked_at TEXT NOT NULL,
138
+ -- NULL when the check failed. A failed check is part of the trend; a zero would be a lie about
139
+ -- a server that never answered at all.
140
+ tokens_per_sec REAL,
141
+ cold_ms INTEGER,
142
+ run_tokens INTEGER,
143
+ verdict TEXT,
144
+ reasoning INTEGER NOT NULL DEFAULT 0,
145
+ tokens_spent INTEGER NOT NULL DEFAULT 0,
146
+ error TEXT
147
+ `;
148
+ }
149
+
150
+ module.exports = { createSpeedStore, schemaFor };
@@ -8,6 +8,8 @@
8
8
  canEdit boolean — hides controls rather than disabling them, because a greyed-out
9
9
  button still advertises a feature and invites a support ticket
10
10
  routes [{ id, position }] this provider appears in, for the shared-fallback warning
11
+ speed { latest, points } from createSpeedStore.seriesFor() — may be undefined. `points` is
12
+ oldest-first, so the line reads left to right in time.
11
13
 
12
14
  WHY "reachable" AND "model loaded" ARE SHOWN SEPARATELY: a local server answers /models
13
15
  perfectly while the model itself has been evicted, and the next call then pays a
@@ -97,6 +99,63 @@
97
99
  <span class="font-monospace">never</span>
98
100
  <% } %>
99
101
  </div>
102
+
103
+ <%
104
+ // SPEED OVER TIME, because an endpoint's speed is not one number — the interesting moments
105
+ // are the changes. A local model that quietly halved the day a second server started
106
+ // sharing its GPU is invisible in the latest figure and obvious in a line. That exact case
107
+ // turned up while this was being built: 51 tok/s alone, 8 with a 22 GB model beside it.
108
+ const sp = (typeof speed !== 'undefined' && speed) ? speed : null;
109
+ // FAILED CHECKS ARE HOLES, NOT ZEROES. A server that never answered has no rate; drawing
110
+ // it at the bottom of the chart would read as "very slow" when it means "not there".
111
+ const pts = sp ? (sp.points || []).filter((r) => r.tokens_per_sec != null) : [];
112
+ const rates = pts.map((r) => Number(r.tokens_per_sec));
113
+ -%>
114
+ <% if (rates.length >= 2) { %>
115
+ <%
116
+ const lo = Math.min(...rates);
117
+ const hi = Math.max(...rates);
118
+ // A FLAT LINE MUST LOOK FLAT. With lo === hi the scale collapses and every point lands
119
+ // on the same row — which is correct, but only if the divisor does not become zero.
120
+ const span = (hi - lo) || 1;
121
+ const W = 88;
122
+ const H = 22;
123
+ const step = rates.length > 1 ? W / (rates.length - 1) : W;
124
+ const xy = rates.map((v, i) => [
125
+ Math.round(i * step * 10) / 10,
126
+ Math.round((H - 3 - ((v - lo) / span) * (H - 6)) * 10) / 10
127
+ ]);
128
+ const last = xy[xy.length - 1];
129
+ const latestRate = rates[rates.length - 1];
130
+ const verdict = sp.latest && sp.latest.verdict;
131
+ -%>
132
+ <div>
133
+ <div class="text-body-secondary text-uppercase" style="font-size:.68rem;letter-spacing:.06em">Speed</div>
134
+ <div class="d-flex align-items-center gap-2">
135
+ <%# currentColor throughout: this partial renders in two apps and both themes, and a
136
+ hard-coded stroke would be invisible in one of them. %>
137
+ <svg width="<%= W %>" height="<%= H %>" viewBox="0 0 <%= W %> <%= H %>"
138
+ class="<%= verdict === 'slow' ? 'text-warning' : (verdict === 'pass' ? 'text-success' : 'text-body-secondary') %>"
139
+ role="img"
140
+ aria-label="<%= rates.length %> speed checks, oldest <%= lo %> to newest <%= latestRate %> tokens per second">
141
+ <polyline fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"
142
+ stroke-linecap="round" opacity="0.65"
143
+ points="<%= xy.map(function (q) { return q[0] + ',' + q[1]; }).join(' ') %>"></polyline>
144
+ <%# The newest point marked, because "where is it now" is the first question the
145
+ line raises and counting to the right-hand end is a poor way to answer it. %>
146
+ <circle cx="<%= last[0] %>" cy="<%= last[1] %>" r="2" fill="currentColor"></circle>
147
+ </svg>
148
+ <span class="font-monospace" style="font-variant-numeric: tabular-nums"><%= latestRate %> tok/s</span>
149
+ </div>
150
+ </div>
151
+ <% } else if (sp && sp.latest && sp.latest.tokens_per_sec != null) { %>
152
+ <div>
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. %>
156
+ <span class="font-monospace" style="font-variant-numeric: tabular-nums"><%= sp.latest.tokens_per_sec %> tok/s</span>
157
+ </div>
158
+ <% } %>
100
159
  </div>
101
160
 
102
161
  <% if (p.daily_token_cap) { %>