@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 +3 -0
- package/package.json +3 -3
- package/speedStore.js +150 -0
- package/views/ai/provider-card.ejs +59 -0
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.
|
|
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) { %>
|