@aria-framework/ai 0.14.2 → 0.15.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/browser/ai-panels.js +113 -0
- package/index.js +261 -240
- package/lmxStatus.js +120 -0
- package/lmxStore.js +188 -0
- package/lmxVerify.js +395 -0
- package/package.json +53 -48
- package/providerStore.js +10 -0
- package/views/ai/lmx-stack.ejs +264 -0
package/lmxStatus.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a stack's rows and its last verification into the handful of facts a screen shows.
|
|
3
|
+
*
|
|
4
|
+
* ── WHY THIS IS A FUNCTION AND NOT A FEW LINES IN A VIEW ────────────────────────────────────────
|
|
5
|
+
* Because the definitions are not obvious, and two apps that each invent them will disagree about
|
|
6
|
+
* whether the same stack is working. "How many engines has this stack got" has four defensible
|
|
7
|
+
* answers, and only one of them is the question an operator is actually asking.
|
|
8
|
+
*
|
|
9
|
+
* reported — what the stack publishes. Interesting only as a denominator.
|
|
10
|
+
* added — how many this app has made endpoints of. This is "configured", and it is the number
|
|
11
|
+
* that made a broken stack look exactly like a working one.
|
|
12
|
+
* ACTIVE — added, enabled here, AND healthy in the stack's own document right now. The only
|
|
13
|
+
* one that answers "if this goes away, what stops working?", which is the question
|
|
14
|
+
* somebody has before they touch anything.
|
|
15
|
+
* missing — added, and the stack no longer reports it at all.
|
|
16
|
+
*
|
|
17
|
+
* ── MISSING IS NOT THE SAME AS UNVERIFIED ───────────────────────────────────────────────────────
|
|
18
|
+
* An engine can only be called missing if a document actually arrived. Without one, every engine is
|
|
19
|
+
* unverified — and telling somebody their engine has vanished because the stack was asleep sends
|
|
20
|
+
* them to fix entirely the wrong thing. So `missing` is false, not unknown, until there is
|
|
21
|
+
* something to be absent from.
|
|
22
|
+
*
|
|
23
|
+
* ── TWO FACTS THE DOCUMENT DOES NOT CARRY ───────────────────────────────────────────────────────
|
|
24
|
+
* The model NAME (the document reports a full path, which is mostly directory) and the REASONING
|
|
25
|
+
* FLAG. The flag is a property of the model, not of the job, and the supervisor does not report it;
|
|
26
|
+
* reading a field that is never there makes a screen say "no known reasoning flag" about a model
|
|
27
|
+
* that has one. Getting it wrong is silent in exactly one direction — the model spends its whole
|
|
28
|
+
* budget thinking and returns nothing, with no error at all — so the screen must agree with what a
|
|
29
|
+
* call actually does, which means asking the same function the adapter asks.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
const { reasoningFor } = require('./providers/lmx');
|
|
35
|
+
|
|
36
|
+
/** Inside this window a certificate is worth mentioning: an expired pin fails at the handshake. */
|
|
37
|
+
const EXPIRY_WARN_DAYS = 30;
|
|
38
|
+
|
|
39
|
+
/** A gguf path is mostly directory. The file is the part that identifies the model. */
|
|
40
|
+
function modelName(p) {
|
|
41
|
+
// The class carries BOTH separators. It lost its backslash once, to a tool that ate it, leaving a
|
|
42
|
+
// perfectly valid regex that splits on the one separator these stacks never use — and nothing
|
|
43
|
+
// failed, the name simply stayed long.
|
|
44
|
+
return p ? (String(p).split(/[\\/]/).pop() || null) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** How the flag would be sent, in the words an operator can compare to a config. */
|
|
48
|
+
function reasoningLabel(model) {
|
|
49
|
+
const flag = reasoningFor(model);
|
|
50
|
+
if (!flag) return null;
|
|
51
|
+
if (flag.reasoning_effort) return `reasoning_effort: ${flag.reasoning_effort}`;
|
|
52
|
+
return 'enable_thinking: false';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One engine from the status document, with the two computed facts attached.
|
|
57
|
+
* The document's own fields are preserved untouched — this only ever adds.
|
|
58
|
+
*/
|
|
59
|
+
function describeEngine(e) {
|
|
60
|
+
if (!e) return e;
|
|
61
|
+
return Object.assign({}, e, {
|
|
62
|
+
modelName: modelName(e.model),
|
|
63
|
+
reasoning: e.reasoning || reasoningLabel(e.model) || null
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Everything a stack panel needs, from what the app already has.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} o
|
|
71
|
+
* engines the endpoint ROWS this app has adopted (from lmxStore.engines)
|
|
72
|
+
* report the last verification (from lmxVerify), or null if never checked
|
|
73
|
+
* health { [endpointId]: healthStatus } — optional, the app's own health snapshot
|
|
74
|
+
* routes { [endpointId]: [{ id, position }] } — optional, which jobs each endpoint serves
|
|
75
|
+
* pin the certificate summary from lmxVerify.readPin(), optional
|
|
76
|
+
*/
|
|
77
|
+
function describeStack(o = {}) {
|
|
78
|
+
const rows = o.engines || [];
|
|
79
|
+
const health = o.health || {};
|
|
80
|
+
const routes = o.routes || {};
|
|
81
|
+
const pin = o.pin || { present: false };
|
|
82
|
+
|
|
83
|
+
// NULL, NOT [] — "we never got a document" and "this stack has no engines" send an operator to
|
|
84
|
+
// completely different places, and only the first one means "press Check".
|
|
85
|
+
const reported = (o.report && o.report.engines) ? o.report.engines.map(describeEngine) : null;
|
|
86
|
+
const byName = new Map((reported || []).map((e) => [e.name, e]));
|
|
87
|
+
|
|
88
|
+
const added = rows.map((row) => ({
|
|
89
|
+
row,
|
|
90
|
+
engine: byName.get(row.lmx_engine) || null,
|
|
91
|
+
missing: !!reported && !byName.has(row.lmx_engine),
|
|
92
|
+
health: health[row.id] || null,
|
|
93
|
+
routes: routes[row.id] || []
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
const available = (reported || []).filter((e) => !rows.some((r) => r.lmx_engine === e.name));
|
|
97
|
+
|
|
98
|
+
const activeCount = added
|
|
99
|
+
.filter((a) => Number(a.row.enabled) && a.engine && a.engine.state === 'healthy').length;
|
|
100
|
+
const drainingCount = added.filter((a) => a.engine && a.engine.state !== 'healthy').length;
|
|
101
|
+
const missingCount = added.filter((a) => a.missing).length;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
added,
|
|
105
|
+
available,
|
|
106
|
+
reported,
|
|
107
|
+
reportedCount: reported ? reported.length : null,
|
|
108
|
+
activeCount,
|
|
109
|
+
drainingCount,
|
|
110
|
+
missingCount,
|
|
111
|
+
// WHAT KEEPS A PANEL OPEN. Attention beats tidiness: a stack with any of these does not get to
|
|
112
|
+
// fold itself away, whatever a viewer chose to remember about it. A fold that honours a
|
|
113
|
+
// preference from last week and hides the thing that broke yesterday is worse than no fold.
|
|
114
|
+
attention: !!(missingCount
|
|
115
|
+
|| pin.expired || pin.expiringSoon
|
|
116
|
+
|| (o.report && !o.report.ok))
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { describeStack, describeEngine, modelName, reasoningLabel, EXPIRY_WARN_DAYS };
|
package/lmxStore.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The supervisors — one row per supervised inference stack this app can reach.
|
|
3
|
+
*
|
|
4
|
+
* ── A SUPERVISOR IS NOT A THING YOU SEND WORK TO ────────────────────────────────────────────────
|
|
5
|
+
* It is the thing that says WHERE the engines are. It holds one address, the status listener, and
|
|
6
|
+
* everything else about a stack — which engines exist, what they are running, which are healthy —
|
|
7
|
+
* comes from the live document rather than from here. That is the whole point: ports move, engines
|
|
8
|
+
* come and go, and a row that tried to remember any of it would go stale in silence.
|
|
9
|
+
*
|
|
10
|
+
* So this table deliberately has none of an endpoint's columns. No model, no token ceiling, no
|
|
11
|
+
* speed floor: a supervisor can never use them, and a column that can never be used is a question
|
|
12
|
+
* somebody will eventually answer.
|
|
13
|
+
*
|
|
14
|
+
* ── WHY IT IS A SEPARATE TABLE FROM THE ENDPOINTS ───────────────────────────────────────────────
|
|
15
|
+
* Because the two have different lifetimes and different identities. An engine row is an endpoint —
|
|
16
|
+
* something a route can name — and it points at a supervisor by id. Several engines share one
|
|
17
|
+
* supervisor, so one poller serves all of them; folding the supervisor's address into each engine
|
|
18
|
+
* row would mean N pollers for one stack, and N places for the certificate to disagree with itself.
|
|
19
|
+
*
|
|
20
|
+
* ── THE ID IS THE STACK'S OWN NAME ──────────────────────────────────────────────────────────────
|
|
21
|
+
* Not one the operator invents. It is compared against the `instance` the status document reports,
|
|
22
|
+
* and that comparison is the only thing standing between a mistyped URL and work being sent to
|
|
23
|
+
* another deployment — engine names collide across stacks, so `analysis` on the wrong machine
|
|
24
|
+
* answers perfectly plausibly. Which is why `update()` cannot change it.
|
|
25
|
+
*
|
|
26
|
+
* ── WHAT IS NOT HERE ────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
* No token and no key. Those are credentials and belong in whatever secret store the app already
|
|
28
|
+
* has, exactly as with providerStore — this package never sees a credential at rest.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
/** Columns a caller may set. `id` is the key and is never updated in place. */
|
|
34
|
+
const FIELDS = ['label', 'status_url', 'poll_ms', 'stale_ms', 'enabled', 'sort_order'];
|
|
35
|
+
|
|
36
|
+
const NUMERIC = new Set(['poll_ms', 'stale_ms', 'enabled', 'sort_order']);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The same shape as an endpoint id, so a supervisor can appear in a URL and a log line unescaped.
|
|
40
|
+
*
|
|
41
|
+
* It must ALSO be what the stack calls itself, which is why the message says so: an id that is
|
|
42
|
+
* merely valid but does not match the document turns the identity check into a permanent failure
|
|
43
|
+
* that looks like a connection problem.
|
|
44
|
+
*/
|
|
45
|
+
function assertId(id) {
|
|
46
|
+
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/.test(id)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`lmx instance id ${JSON.stringify(id)} is not usable: 3-40 characters, lowercase letters, `
|
|
49
|
+
+ 'digits and hyphens, not starting or ending with a hyphen. It must match the `instance` '
|
|
50
|
+
+ 'the status document reports.'
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} opts
|
|
58
|
+
* driver @aria-framework/db-worker driver contract (required)
|
|
59
|
+
* table default 'ai_lmx_instances'
|
|
60
|
+
* providersTable default 'ai_providers' — read by engines(), which is the join that makes a
|
|
61
|
+
* stack and its endpoints one object on screen
|
|
62
|
+
*/
|
|
63
|
+
function createLmxStore(opts = {}) {
|
|
64
|
+
const driver = opts.driver;
|
|
65
|
+
if (!driver || typeof driver.run !== 'function') {
|
|
66
|
+
throw new Error('createLmxStore({ driver }): the db-worker driver contract is required');
|
|
67
|
+
}
|
|
68
|
+
const table = opts.table || 'ai_lmx_instances';
|
|
69
|
+
const providers = opts.providersTable || 'ai_providers';
|
|
70
|
+
|
|
71
|
+
const clean = (p) => {
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const f of FIELDS) {
|
|
74
|
+
if (p[f] === undefined) continue;
|
|
75
|
+
// null is MEANINGFUL for the two intervals: it means "use the package default", which is not
|
|
76
|
+
// the same as zero. Coercing it to 0 would set a poll interval of zero milliseconds.
|
|
77
|
+
if (p[f] === null && (f === 'poll_ms' || f === 'stale_ms')) { out[f] = null; continue; }
|
|
78
|
+
out[f] = NUMERIC.has(f) ? (Number(p[f]) || 0) : String(p[f]);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
table,
|
|
85
|
+
|
|
86
|
+
async all() {
|
|
87
|
+
return driver.all(`SELECT * FROM ${table} ORDER BY sort_order, id`);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/** Only the ones that may be used. A disabled supervisor stays configured but is never polled. */
|
|
91
|
+
async enabled() {
|
|
92
|
+
return driver.all(`SELECT * FROM ${table} WHERE enabled = 1 ORDER BY sort_order, id`);
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async byId(id) {
|
|
96
|
+
if (typeof id !== 'string' || !id) return null;
|
|
97
|
+
return (await driver.get(`SELECT * FROM ${table} WHERE id = ?`, [id])) || null;
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
async create(p = {}) {
|
|
101
|
+
const id = assertId(p.id);
|
|
102
|
+
if (await this.byId(id)) throw new Error(`an lmx supervisor called ${JSON.stringify(id)} already exists`);
|
|
103
|
+
if (!p.status_url) throw new Error('an lmx supervisor needs the URL of its status listener');
|
|
104
|
+
const fields = clean(Object.assign({ label: p.id, enabled: 1, sort_order: 0 }, p));
|
|
105
|
+
const cols = ['id'].concat(Object.keys(fields));
|
|
106
|
+
const vals = [id].concat(Object.values(fields));
|
|
107
|
+
await driver.run(
|
|
108
|
+
`INSERT INTO ${table} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`, vals);
|
|
109
|
+
return { id };
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Update in place. THE ID IS NOT UPDATABLE, and that is load-bearing twice over: every engine
|
|
114
|
+
* row points at it, so a rename would orphan them all; and it is the value checked against the
|
|
115
|
+
* document, so a rename would silently disable the one guard that catches a status URL aimed at
|
|
116
|
+
* the wrong deployment.
|
|
117
|
+
*/
|
|
118
|
+
async update(id, p = {}) {
|
|
119
|
+
if (!await this.byId(id)) throw new Error(`no lmx supervisor called ${JSON.stringify(id)}`);
|
|
120
|
+
const fields = clean(p);
|
|
121
|
+
const keys = Object.keys(fields);
|
|
122
|
+
if (!keys.length) return { changes: 0 };
|
|
123
|
+
const r = await driver.run(
|
|
124
|
+
`UPDATE ${table} SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`,
|
|
125
|
+
Object.values(fields).concat([String(id)]));
|
|
126
|
+
return { changes: r.changes };
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* REFUSES WHILE ENGINES STILL POINT AT IT.
|
|
131
|
+
*
|
|
132
|
+
* Those rows would become endpoints whose address can never be resolved — still enabled, still
|
|
133
|
+
* listed in routes, failing every call. Cascading the delete instead would be worse: a job
|
|
134
|
+
* would quietly lose a step, and nobody chose that.
|
|
135
|
+
*/
|
|
136
|
+
async remove(id) {
|
|
137
|
+
const engines = await this.engines(id);
|
|
138
|
+
if (engines.length) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`${engines.length} endpoint(s) still use the ${JSON.stringify(id)} supervisor `
|
|
141
|
+
+ `(${engines.map((e) => e.id).join(', ')}). Remove or reassign them first — without it `
|
|
142
|
+
+ 'their addresses cannot be resolved at all.'
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const r = await driver.run(`DELETE FROM ${table} WHERE id = ?`, [String(id)]);
|
|
146
|
+
return { changes: r.changes };
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/** The endpoint rows served by this supervisor — the join that groups a stack on screen. */
|
|
150
|
+
async engines(id) {
|
|
151
|
+
return driver.all(
|
|
152
|
+
`SELECT id, label, lmx_engine, enabled, sort_order FROM ${providers} `
|
|
153
|
+
+ 'WHERE lmx_instance = ? ORDER BY sort_order, id', [String(id)]);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The DDL, in the caller's dialect.
|
|
160
|
+
*
|
|
161
|
+
* EXPORTED SO A CONSUMER CAN ASSERT ITS TABLE MATCHES — the same reason providerSchemaFor,
|
|
162
|
+
* usageSchemaFor and speedSchemaFor are exported. An app writes its own migration, that migration
|
|
163
|
+
* is a hand copy of this, and a copy with nothing comparing it to the original is a failure mode
|
|
164
|
+
* this repo has now documented three times. The first app to carry this table wrote it by hand with
|
|
165
|
+
* nothing to check it against; this is what stops the second one drifting from the first.
|
|
166
|
+
*/
|
|
167
|
+
function schemaFor(dialect) {
|
|
168
|
+
const t = dialect || { now: () => "datetime('now')" };
|
|
169
|
+
return `
|
|
170
|
+
-- The stack's OWN name, checked against the \`instance\` its status document reports. Not a label
|
|
171
|
+
-- somebody chose: engine names collide across deployments, so this comparison is the only thing
|
|
172
|
+
-- that catches a status URL pointed at the wrong machine.
|
|
173
|
+
id TEXT PRIMARY KEY,
|
|
174
|
+
label TEXT,
|
|
175
|
+
-- The ONLY address stored anywhere for this stack. Engine addresses are discovered per call,
|
|
176
|
+
-- because ports move and a stored URL is the one thing the contract says not to keep.
|
|
177
|
+
status_url TEXT NOT NULL,
|
|
178
|
+
-- NULL means "use the package default" for both, which is not the same as 0 — a zero poll
|
|
179
|
+
-- interval is not a slower poll, it is a busy loop.
|
|
180
|
+
poll_ms INTEGER,
|
|
181
|
+
stale_ms INTEGER,
|
|
182
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
183
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
184
|
+
created_at TEXT NOT NULL DEFAULT (${t.now()})
|
|
185
|
+
`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { createLmxStore, schemaFor, _assertId: assertId, _fields: FIELDS };
|