@aria-framework/ai 0.11.0 → 0.12.1
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 +8 -0
- package/package.json +3 -3
- package/speedStore.js +186 -0
- package/views/ai/provider-card.ejs +59 -0
package/index.js
CHANGED
|
@@ -204,6 +204,14 @@ 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; },
|
|
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; },
|
|
207
215
|
// No database behind health, so it loads eagerly like the rest of the seam.
|
|
208
216
|
...require('./health'),
|
|
209
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.
|
|
4
|
+
"version": "0.12.1",
|
|
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,186 @@
|
|
|
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
|
+
/**
|
|
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
|
+
|
|
52
|
+
return {
|
|
53
|
+
table,
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Record one measurement.
|
|
57
|
+
*
|
|
58
|
+
* A FAILED CHECK IS RECORDED TOO, with a null rate. "It could not be reached at 14:00" is part
|
|
59
|
+
* of the trend, and dropping it would draw a line straight through an outage as though nothing
|
|
60
|
+
* had happened.
|
|
61
|
+
*/
|
|
62
|
+
async record(entry = {}) {
|
|
63
|
+
await driver.run(
|
|
64
|
+
`INSERT INTO ${table}
|
|
65
|
+
(endpoint, checked_at, tokens_per_sec, cold_ms, run_tokens, verdict, reasoning, tokens_spent, error)
|
|
66
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
67
|
+
[
|
|
68
|
+
String(entry.endpoint || ''),
|
|
69
|
+
stamp(entry.at || now()),
|
|
70
|
+
num(entry.tokensPerSec),
|
|
71
|
+
num(entry.coldMs),
|
|
72
|
+
num(entry.runTokens),
|
|
73
|
+
entry.verdict ? String(entry.verdict) : null,
|
|
74
|
+
entry.reasoning ? 1 : 0,
|
|
75
|
+
Number(entry.tokensSpent) || 0,
|
|
76
|
+
entry.error ? String(entry.error).slice(0, 500) : null
|
|
77
|
+
]
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
/** The most recent checks for one endpoint, newest first. */
|
|
82
|
+
async recent(endpoint, limit = DEFAULT_LIMIT) {
|
|
83
|
+
return driver.all(
|
|
84
|
+
`SELECT * FROM ${table} WHERE endpoint = ? ORDER BY id DESC LIMIT ?`,
|
|
85
|
+
[String(endpoint), Math.max(1, Math.min(500, Number(limit) || DEFAULT_LIMIT))]
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A series per endpoint, OLDEST FIRST, ready to draw.
|
|
91
|
+
*
|
|
92
|
+
* One query for every endpoint rather than one per card: a page with eight endpoints should not
|
|
93
|
+
* make eight round trips to draw eight small lines.
|
|
94
|
+
*/
|
|
95
|
+
async seriesFor(endpoints, limit = DEFAULT_LIMIT) {
|
|
96
|
+
const ids = (endpoints || []).map((e) => String(e)).filter(Boolean);
|
|
97
|
+
if (!ids.length) return {};
|
|
98
|
+
const capped = Math.max(1, Math.min(500, Number(limit) || DEFAULT_LIMIT));
|
|
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);
|
|
115
|
+
const out = {};
|
|
116
|
+
for (const row of rows) {
|
|
117
|
+
const bucket = out[row.endpoint] || (out[row.endpoint] = { latest: null, points: [] });
|
|
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);
|
|
122
|
+
}
|
|
123
|
+
// Reversed at the end so a caller draws left-to-right in time without thinking about it.
|
|
124
|
+
for (const id of Object.keys(out)) out[id].points.reverse();
|
|
125
|
+
return out;
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Keep the most recent N per endpoint.
|
|
130
|
+
*
|
|
131
|
+
* Bounded by ENDPOINT rather than by age: an endpoint checked twice a year deserves to keep its
|
|
132
|
+
* history, and one checked hourly should not be able to bury it. Age-based pruning gets that
|
|
133
|
+
* backwards for exactly the endpoints whose trend matters most.
|
|
134
|
+
*/
|
|
135
|
+
async prune({ keepPerEndpoint = 200, endpoint = null } = {}) {
|
|
136
|
+
const keep = Math.max(10, Math.min(5000, Number(keepPerEndpoint) || 200));
|
|
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}`, []);
|
|
144
|
+
let removed = 0;
|
|
145
|
+
for (const { endpoint } of ids) {
|
|
146
|
+
const cutoff = await driver.get(
|
|
147
|
+
`SELECT id FROM ${table} WHERE endpoint = ? ORDER BY id DESC LIMIT 1 OFFSET ?`,
|
|
148
|
+
[endpoint, keep]
|
|
149
|
+
);
|
|
150
|
+
if (!cutoff) continue;
|
|
151
|
+
const r = await driver.run(
|
|
152
|
+
`DELETE FROM ${table} WHERE endpoint = ? AND id <= ?`, [endpoint, cutoff.id]
|
|
153
|
+
);
|
|
154
|
+
removed += (r && r.changes) || 0;
|
|
155
|
+
}
|
|
156
|
+
return { removed };
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The DDL, in the caller's dialect. */
|
|
162
|
+
function schemaFor(dialect) {
|
|
163
|
+
const t = dialect || {
|
|
164
|
+
autoIncrementPk: () => 'INTEGER PRIMARY KEY AUTOINCREMENT',
|
|
165
|
+
now: () => "datetime('now')"
|
|
166
|
+
};
|
|
167
|
+
return `
|
|
168
|
+
id ${t.autoIncrementPk()},
|
|
169
|
+
-- The endpoint NAME, not a foreign key: a measurement is still true after the endpoint it
|
|
170
|
+
-- describes has been deleted, and "this machine used to manage 80 tok/s" is worth keeping when
|
|
171
|
+
-- deciding whether to configure it again.
|
|
172
|
+
endpoint TEXT NOT NULL,
|
|
173
|
+
checked_at TEXT NOT NULL,
|
|
174
|
+
-- NULL when the check failed. A failed check is part of the trend; a zero would be a lie about
|
|
175
|
+
-- a server that never answered at all.
|
|
176
|
+
tokens_per_sec REAL,
|
|
177
|
+
cold_ms INTEGER,
|
|
178
|
+
run_tokens INTEGER,
|
|
179
|
+
verdict TEXT,
|
|
180
|
+
reasoning INTEGER NOT NULL DEFAULT 0,
|
|
181
|
+
tokens_spent INTEGER NOT NULL DEFAULT 0,
|
|
182
|
+
error TEXT
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
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) { %>
|