@aria-framework/ai 0.12.1 → 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 +57 -1
- package/package.json +1 -1
- package/usageStore.js +72 -3
- package/views/ai/provider-card.ejs +3 -2
package/health.js
CHANGED
|
@@ -320,13 +320,69 @@ function createHealthChecker(opts = {}) {
|
|
|
320
320
|
};
|
|
321
321
|
},
|
|
322
322
|
|
|
323
|
-
/** All known state
|
|
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/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.13.0",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
7
7
|
"publishConfig": {
|
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']
|
|
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
|
|
155
|
-
|
|
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
|
<% } %>
|