@aria-framework/ai 0.14.3 → 0.15.2
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 +131 -0
- package/index.js +261 -245
- package/lmxStatus.js +125 -0
- package/lmxStore.js +188 -0
- package/lmxVerify.js +395 -0
- package/package.json +53 -49
- package/views/ai/lmx-stack.ejs +324 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fold on the Inference list, and prefilling the supervisor form from a panel.
|
|
3
|
+
*
|
|
4
|
+
* WHY ANYTHING FOLDS. A stable stack is four engines, three credentials, a certificate fingerprint
|
|
5
|
+
* and a verification report — worth having, not worth reading every time you open this page to do
|
|
6
|
+
* something else. Folded, a panel is one line that still carries the whole verdict: status, the
|
|
7
|
+
* count of what depends on it, all three credentials and when it was last checked. Folding hides
|
|
8
|
+
* detail; it must never hide the reason you would have opened it.
|
|
9
|
+
*
|
|
10
|
+
* A PANEL WITH A PROBLEM IGNORES WHAT YOU REMEMBERED. `data-panel-attention` is stamped by the
|
|
11
|
+
* server on anything with a missing engine, an expiring certificate, a failed check or a nearly
|
|
12
|
+
* spent cap. Those open and stay open. Attention beats tidiness — the alternative is a page that
|
|
13
|
+
* quietly honours a fold you chose last week and hides the thing that broke yesterday.
|
|
14
|
+
*
|
|
15
|
+
* THE PREFERENCE IS PER BROWSER AND DISPOSABLE. It is a convenience about how a page looks to one
|
|
16
|
+
* person, so localStorage is the right home and losing it costs nothing. Every access is guarded:
|
|
17
|
+
* a browser set to block site data throws on read, and a settings page must not break because
|
|
18
|
+
* somebody tightened their privacy settings.
|
|
19
|
+
*/
|
|
20
|
+
(function () {
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
var KEY = 's101.ai.panels';
|
|
24
|
+
|
|
25
|
+
function readPrefs() {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(window.localStorage.getItem(KEY) || '{}') || {};
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writePref(id, open) {
|
|
34
|
+
try {
|
|
35
|
+
var prefs = readPrefs();
|
|
36
|
+
prefs[id] = !!open;
|
|
37
|
+
window.localStorage.setItem(KEY, JSON.stringify(prefs));
|
|
38
|
+
} catch (e) { /* private window, or site data blocked — the fold still works for this visit */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function bodyOf(panel) {
|
|
42
|
+
var btn = panel.querySelector('[data-panel-toggle]');
|
|
43
|
+
if (!btn) return null;
|
|
44
|
+
return document.getElementById(btn.getAttribute('aria-controls'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function setOpen(panel, open, remember) {
|
|
48
|
+
var btn = panel.querySelector('[data-panel-toggle]');
|
|
49
|
+
var body = bodyOf(panel);
|
|
50
|
+
if (!btn || !body) return;
|
|
51
|
+
// `hidden`, not a style: the server renders the closed state the same way, so a panel does not
|
|
52
|
+
// flicker open on load before this script runs.
|
|
53
|
+
body.hidden = !open;
|
|
54
|
+
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
55
|
+
panel.classList.toggle('panel-open', open);
|
|
56
|
+
if (remember) writePref(panel.getAttribute('data-panel'), open);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function restore() {
|
|
60
|
+
var prefs = readPrefs();
|
|
61
|
+
var panels = document.querySelectorAll('[data-panel]');
|
|
62
|
+
for (var i = 0; i < panels.length; i += 1) {
|
|
63
|
+
var panel = panels[i];
|
|
64
|
+
var id = panel.getAttribute('data-panel');
|
|
65
|
+
// The server already opened this one and means it. Do not consult the preference at all —
|
|
66
|
+
// reading it and then ignoring it is the same thing, but invites somebody to "fix" it later.
|
|
67
|
+
if (panel.hasAttribute('data-panel-attention')) {
|
|
68
|
+
setOpen(panel, true, false);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (Object.prototype.hasOwnProperty.call(prefs, id)) setOpen(panel, !!prefs[id], false);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
document.addEventListener('click', function (ev) {
|
|
76
|
+
var toggle = ev.target.closest('[data-panel-toggle]');
|
|
77
|
+
if (toggle) {
|
|
78
|
+
var panel = toggle.closest('[data-panel]');
|
|
79
|
+
if (!panel) return;
|
|
80
|
+
var body = bodyOf(panel);
|
|
81
|
+
setOpen(panel, !!(body && body.hidden), true);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// EDIT PREFILLS FROM THE PANEL, so changing a stack is not retyping it.
|
|
86
|
+
//
|
|
87
|
+
// The two secrets stay BLANK on purpose. Blank means keep, which is the rule the handler
|
|
88
|
+
// already follows, and it is why correcting an address cannot cost you the credentials. There
|
|
89
|
+
// is no read-back to prefill them with in any case: they are write-only by design. The
|
|
90
|
+
// certificate is NOT one of them — it is public, and is prefilled below.
|
|
91
|
+
var edit = ev.target.closest('[data-lmx-edit]');
|
|
92
|
+
if (!edit) return;
|
|
93
|
+
var src = document.querySelector('[data-lmx-id="' + edit.getAttribute('data-lmx-edit') + '"]');
|
|
94
|
+
var form = document.querySelector('#lmx-new form');
|
|
95
|
+
if (!src || !form) return;
|
|
96
|
+
var set = function (name, value) {
|
|
97
|
+
var field = form.querySelector('[name="' + name + '"]');
|
|
98
|
+
if (field) field.value = value == null ? '' : value;
|
|
99
|
+
};
|
|
100
|
+
set('id', src.getAttribute('data-lmx-id'));
|
|
101
|
+
set('label', src.getAttribute('data-lmx-label'));
|
|
102
|
+
set('status_url', src.getAttribute('data-lmx-url'));
|
|
103
|
+
// THE CERTIFICATE COMES BACK. It is public — not encrypted at rest — and Edit opening onto an
|
|
104
|
+
// empty certificate box is indistinguishable from having none stored, which reads as "my save
|
|
105
|
+
// did not work". Reported as exactly that, and it was: the save handler had always intended to
|
|
106
|
+
// render it back, and never did.
|
|
107
|
+
set('ca_cert', src.getAttribute('data-lmx-cert'));
|
|
108
|
+
set('status_token', '');
|
|
109
|
+
set('engines_key', '');
|
|
110
|
+
// Blank means keep, so blank must not read as absent. The placeholder says what is actually
|
|
111
|
+
// held — and two fields both offering to keep "50 characters" is the cheapest possible way to
|
|
112
|
+
// notice that one value was pasted into both.
|
|
113
|
+
var held = function (name, len) {
|
|
114
|
+
var field = form.querySelector('[name="' + name + '"]');
|
|
115
|
+
if (!field) return;
|
|
116
|
+
field.placeholder = len
|
|
117
|
+
? 'unchanged — ' + len + ' characters stored. Enter a value to replace it.'
|
|
118
|
+
: 'nothing stored — enter a value';
|
|
119
|
+
};
|
|
120
|
+
held('status_token', src.getAttribute('data-lmx-token-len'));
|
|
121
|
+
held('engines_key', src.getAttribute('data-lmx-key-len'));
|
|
122
|
+
var heading = document.querySelector('#lmx-new .card-title');
|
|
123
|
+
if (heading) heading.textContent = 'Change ' + (src.getAttribute('data-lmx-label') || src.getAttribute('data-lmx-id'));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (document.readyState === 'loading') {
|
|
127
|
+
document.addEventListener('DOMContentLoaded', restore);
|
|
128
|
+
} else {
|
|
129
|
+
restore();
|
|
130
|
+
}
|
|
131
|
+
})();
|
package/index.js
CHANGED
|
@@ -1,245 +1,261 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @aria-framework/ai — the AI seam. One `complete()`, several providers behind it, plus the
|
|
3
|
-
* writing-assist engines (polish/generate) and the fact-preservation guard.
|
|
4
|
-
*
|
|
5
|
-
* DEPENDENCY-INJECTED, DATABASE-FREE. The package knows how to talk to a model; it does NOT know
|
|
6
|
-
* where an app keeps its settings, its credentials or its token ledger. The consumer builds a
|
|
7
|
-
* client with two functions of its own:
|
|
8
|
-
*
|
|
9
|
-
* const ai = createAiClient({
|
|
10
|
-
* resolveConfig, // async () => resolved config (provider, baseUrl, model, apiKey, caps…)
|
|
11
|
-
* budget, // { assertWithinBudget(cfg, ctx), record(cfg, result, ctx) } — optional
|
|
12
|
-
* logger // { info, warn, error } — optional
|
|
13
|
-
* });
|
|
14
|
-
*
|
|
15
|
-
* The provider adapters already take an explicit config and never read a database, which is what
|
|
16
|
-
* makes the seam testable: a stub adapter and a real adapter are called identically.
|
|
17
|
-
*
|
|
18
|
-
* PROMPTS ARE CONTENT AND LIVE IN THE APP. This package carries the mechanism (how to call a model,
|
|
19
|
-
* how to enforce a token ceiling, how to check a rewrite kept its facts) and generic writing
|
|
20
|
-
* operations; the words that say "you are editing a reply to a customer" belong to the app.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
'use strict';
|
|
24
|
-
|
|
25
|
-
const facts = require('./facts');
|
|
26
|
-
const { AiError, fromFetchFailure, redact } = require('./error');
|
|
27
|
-
const { polish } = require('./polish');
|
|
28
|
-
const { generate } = require('./generate');
|
|
29
|
-
|
|
30
|
-
const PROVIDERS = {
|
|
31
|
-
// 'lmstudio' and 'openai-compatible' are the SAME adapter with different defaults — a kindness to
|
|
32
|
-
// whoever configures it: an operator running LM Studio should not have to know it speaks a shape
|
|
33
|
-
// named after somebody else.
|
|
34
|
-
lmstudio: require('./providers/openai-compatible'),
|
|
35
|
-
'openai-compatible': require('./providers/openai-compatible'),
|
|
36
|
-
anthropic: require('./providers/anthropic'),
|
|
37
|
-
// 0.14.0 — a supervised fleet rather than an address. The engine URL is discovered from the
|
|
38
|
-
// supervisor's status document per call, the reasoning flag is read from the model the engine
|
|
39
|
-
// is actually running, and the whole conversation is pinned to a self-signed certificate.
|
|
40
|
-
lmx: require('./providers/lmx')
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const DEFAULTS = {
|
|
44
|
-
lmstudio: { baseUrl: 'http://localhost:1234/v1', model: 'qwen3.5-9b', label: 'LM Studio' },
|
|
45
|
-
'openai-compatible': { baseUrl: 'http://localhost:11434/v1', model: '', label: 'The model server' },
|
|
46
|
-
anthropic: { baseUrl: 'https://api.anthropic.com/v1', model: 'claude-sonnet-4-5', label: 'Claude' },
|
|
47
|
-
// No baseUrl: an lmx engine's address is never configured, only discovered.
|
|
48
|
-
lmx: { baseUrl: '', model: '', label: 'lmx engine' }
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const RETRY_AFTER_MS = 400;
|
|
52
|
-
const RETRY_ONLY_IF_FAILED_WITHIN_MS = 5000;
|
|
53
|
-
|
|
54
|
-
const NOOP_LOGGER = { info() {}, warn() {}, error() {} };
|
|
55
|
-
const NOOP_BUDGET = { async assertWithinBudget() {}, async record() {} };
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Build an AI client bound to one app's config resolution and token budget.
|
|
59
|
-
* @param {{resolveConfig: () => Promise<object>, budget?: object, logger?: object}} deps
|
|
60
|
-
*/
|
|
61
|
-
function createAiClient(deps = {}) {
|
|
62
|
-
const resolveConfig = deps.resolveConfig;
|
|
63
|
-
if (typeof resolveConfig !== 'function') {
|
|
64
|
-
throw new Error('createAiClient: resolveConfig must be an async function returning the resolved config');
|
|
65
|
-
}
|
|
66
|
-
const log = deps.logger || NOOP_LOGGER;
|
|
67
|
-
const meter = deps.budget || NOOP_BUDGET;
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Try once more, but only for the failures where trying again could help — a local provider that
|
|
71
|
-
* dropped the connection while loading a model, and nothing else. A cancelled call, a timeout, a
|
|
72
|
-
* rate limit or a slow failure is never retried (see the guards below).
|
|
73
|
-
*/
|
|
74
|
-
async function withOneRetry(run, opts = {}) {
|
|
75
|
-
const startedAt = Date.now();
|
|
76
|
-
try {
|
|
77
|
-
return await run();
|
|
78
|
-
} catch (err) {
|
|
79
|
-
if (opts.signal && opts.signal.aborted) throw err;
|
|
80
|
-
if (!err || !err.retryable) throw err;
|
|
81
|
-
const elapsed = Date.now() - startedAt;
|
|
82
|
-
// A TIMEOUT is the deadline itself being reached — retrying waits the whole deadline again. A
|
|
83
|
-
// RATE LIMIT is the provider asking for less pressure. A slow `unreachable` is not the
|
|
84
|
-
// sub-second dropped-connection transient this retry exists for. None of those retry.
|
|
85
|
-
if (err.kind === 'timeout' || err.kind === 'rate_limit' || elapsed > RETRY_ONLY_IF_FAILED_WITHIN_MS) {
|
|
86
|
-
log.warn(`AI: ${err.kind} after ${elapsed}ms — not retrying (${err.message})`);
|
|
87
|
-
throw err;
|
|
88
|
-
}
|
|
89
|
-
log.warn(`AI: ${err.kind} — trying once more in ${RETRY_AFTER_MS}ms (${err.message})`);
|
|
90
|
-
await new Promise((r) => setTimeout(r, RETRY_AFTER_MS));
|
|
91
|
-
return run();
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Ask the configured model for something.
|
|
97
|
-
* @param {{system?:string, messages:Array, maxTokens?:number, temperature?:number,
|
|
98
|
-
* schema?:object, signal?:AbortSignal, ticketId?:*, skipBudget?:boolean}} opts
|
|
99
|
-
* @param {object} [cfgOverride] the resolved config, when the caller already has it
|
|
100
|
-
*/
|
|
101
|
-
async function complete(opts, cfgOverride) {
|
|
102
|
-
const cfg = cfgOverride || await resolveConfig();
|
|
103
|
-
if (!cfg.enabled) {
|
|
104
|
-
throw new AiError('disabled', 'AI assistance is switched off. An administrator can enable it in Settings.');
|
|
105
|
-
}
|
|
106
|
-
// A MODEL NAME IS REQUIRED OF EVERY PROVIDER THAT HAS ONE TO CONFIGURE — which is all of them
|
|
107
|
-
// except a supervised stack. There the model is a FACT the supervisor reports about an engine,
|
|
108
|
-
// not a setting: the operator picks an engine and whatever it is running answers. Insisting on
|
|
109
|
-
// a model name would make an lmx endpoint unusable by demanding the one field the design says
|
|
110
|
-
// not to store, and would say so with a message naming nothing an operator could go and fill in.
|
|
111
|
-
if (!cfg.model && cfg.provider !== 'lmx') {
|
|
112
|
-
throw new AiError('unconfigured', 'No model name is configured.');
|
|
113
|
-
}
|
|
114
|
-
const adapter = PROVIDERS[cfg.provider];
|
|
115
|
-
if (!adapter) {
|
|
116
|
-
throw new AiError('unconfigured', `No adapter is registered for provider "${cfg.provider}".`);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// The ceiling, before the call — the only place a limit can be enforced without being bypassable
|
|
120
|
-
// by whichever caller forgets. `skipBudget` is for the admin's Test connection; it still records.
|
|
121
|
-
if (!opts.skipBudget) await meter.assertWithinBudget(cfg, { ticketId: opts.ticketId });
|
|
122
|
-
|
|
123
|
-
const result = await withOneRetry(() => adapter.complete(cfg, opts), opts);
|
|
124
|
-
|
|
125
|
-
// ...and the counter after it, AWAITED: two calls in quick succession must both be counted
|
|
126
|
-
// before the second's ceiling check reads the total, or the limit is enforced against a stale one.
|
|
127
|
-
await meter.record(cfg, result, { ticketId: opts.ticketId });
|
|
128
|
-
|
|
129
|
-
log.info(`AI: ${cfg.provider}/${result.model} ${result.usage.total} tokens in ${result.ms}ms`);
|
|
130
|
-
return result;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Is there a provider configured at all? Callers use this to decide whether to render a control. */
|
|
134
|
-
async function isEnabled() {
|
|
135
|
-
return (await resolveConfig()).enabled;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** A short round trip for an admin "Test connection". NEVER THROWS — it reports what is wrong. */
|
|
139
|
-
async function test(cfgOverride) {
|
|
140
|
-
const cfg = cfgOverride || await resolveConfig();
|
|
141
|
-
if (!cfg.enabled) return { ok: false, kind: 'disabled', error: 'No provider is selected.' };
|
|
142
|
-
try {
|
|
143
|
-
const r = await complete({
|
|
144
|
-
system: 'Reply with the single word: ready. Do not explain.',
|
|
145
|
-
messages: [{ role: 'user', content: 'ready?' }],
|
|
146
|
-
maxTokens: 512,
|
|
147
|
-
temperature: 0,
|
|
148
|
-
skipBudget: true // an admin diagnosing a provider must not be blocked by a full budget
|
|
149
|
-
}, cfg);
|
|
150
|
-
return {
|
|
151
|
-
ok: true, model: r.model, ms: r.ms, reply: (r.text || '').trim().slice(0, 60), usage: r.usage,
|
|
152
|
-
finishReason: r.finishReason || null, reasoned: !!r.reasonedFor
|
|
153
|
-
};
|
|
154
|
-
} catch (err) {
|
|
155
|
-
if (err instanceof AiError) return { ok: false, kind: err.kind, error: err.message, retryable: err.retryable };
|
|
156
|
-
return { ok: false, kind: 'bad_response', error: err.message };
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** What the server has loaded, for an admin model picker. Empty when it cannot say. */
|
|
161
|
-
async function listModels(cfgOverride) {
|
|
162
|
-
return (await listModelsResult(cfgOverride)).models;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* The model list and WHY it is the length it is.
|
|
167
|
-
*
|
|
168
|
-
* Callers that only want names should use listModels(). An admin screen wants this one: an empty
|
|
169
|
-
* array on its own cannot tell "the server has nothing loaded" from "that address is not an
|
|
170
|
-
* OpenAI-compatible API root", and those need different fixes.
|
|
171
|
-
*/
|
|
172
|
-
async function listModelsResult(cfgOverride) {
|
|
173
|
-
const cfg = cfgOverride || await resolveConfig();
|
|
174
|
-
if (!cfg.enabled) {
|
|
175
|
-
return { ok: false, models: [], url: null, status: 0, error: 'No provider is selected.' };
|
|
176
|
-
}
|
|
177
|
-
const adapter = PROVIDERS[cfg.provider];
|
|
178
|
-
if (!adapter) {
|
|
179
|
-
return { ok: false, models: [], url: null, status: 0, error: `Unknown provider “${cfg.provider}”.` };
|
|
180
|
-
}
|
|
181
|
-
try {
|
|
182
|
-
if (adapter.listModelsResult) return await adapter.listModelsResult(cfg);
|
|
183
|
-
// An adapter that predates this contract still works; it simply cannot explain itself.
|
|
184
|
-
return { ok: true, models: await adapter.listModels(cfg), url: null, status: 0, error: null };
|
|
185
|
-
} catch (err) {
|
|
186
|
-
return { ok: false, models: [], url: null, status: 0, error: err.message };
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* A fixed-workload speed test for one endpoint. See benchmark.js for why the workload is fixed
|
|
192
|
-
* rather than the prompt, and why the warm-up is reported rather than discarded.
|
|
193
|
-
*/
|
|
194
|
-
async function benchmarkEndpoint(cfgOverride, benchOpts) {
|
|
195
|
-
const cfg = cfgOverride || await resolveConfig();
|
|
196
|
-
return require('./benchmark').benchmark(complete, cfg, benchOpts || {});
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// The writing-assist engines are bound to this client's complete() so a caller gets config +
|
|
200
|
-
// budget + retry for free. Prompt framing is supplied per call by the app (content).
|
|
201
|
-
const boundPolish = (opts) => polish(complete, opts);
|
|
202
|
-
const boundGenerate = (opts) => generate(complete, opts);
|
|
203
|
-
|
|
204
|
-
return {
|
|
205
|
-
complete, isEnabled, test, listModels, listModelsResult, withOneRetry,
|
|
206
|
-
benchmark: benchmarkEndpoint,
|
|
207
|
-
polish: boundPolish, generate: boundGenerate,
|
|
208
|
-
facts, AiError, PROVIDERS, DEFAULTS
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
module.exports = {
|
|
213
|
-
// The usage counter behind every ceiling. LAZY: it needs the db-worker driver contract, which
|
|
214
|
-
// is an OPTIONAL peer — a consumer using only createAiClient/polish/facts must not be made to
|
|
215
|
-
// install a database package to require this one.
|
|
216
|
-
get createUsageStore() { return require('./usageStore').createUsageStore; },
|
|
217
|
-
get createProviderStore() { return require('./providerStore').createProviderStore; },
|
|
218
|
-
// Speed history. Lazy for the same reason as the others: it needs the db-worker driver contract,
|
|
219
|
-
// which is an optional peer.
|
|
220
|
-
get createSpeedStore() { return require('./speedStore').createSpeedStore; },
|
|
221
|
-
// EXPORTED SO A CONSUMER CAN ASSERT ITS TABLE MATCHES. An app writes its own migration, which is
|
|
222
|
-
// a hand copy of this DDL — and a copy with nothing comparing it to the original is the failure
|
|
223
|
-
// mode this repo has already documented twice. providerSchemaFor and usageSchemaFor exist for the
|
|
224
|
-
// same reason; leaving this one out meant a drift would surface as an INSERT throwing at runtime.
|
|
225
|
-
get speedSchemaFor() { return require('./speedStore').schemaFor; },
|
|
226
|
-
// No database behind health, so it loads eagerly like the rest of the seam.
|
|
227
|
-
...require('./health'),
|
|
228
|
-
/**
|
|
229
|
-
* Where this package's EJS partials live, for the consumer's view-roots list.
|
|
230
|
-
*
|
|
231
|
-
* Same contract as backup/server/notify/uploads: the package knows its own layout, the app
|
|
232
|
-
* puts its own views FIRST so a local file of the same name wins.
|
|
233
|
-
*/
|
|
234
|
-
viewsDir: require('path').join(__dirname, 'views'),
|
|
235
|
-
get providerSchemaFor() { return require('./providerStore').schemaFor; },
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @aria-framework/ai — the AI seam. One `complete()`, several providers behind it, plus the
|
|
3
|
+
* writing-assist engines (polish/generate) and the fact-preservation guard.
|
|
4
|
+
*
|
|
5
|
+
* DEPENDENCY-INJECTED, DATABASE-FREE. The package knows how to talk to a model; it does NOT know
|
|
6
|
+
* where an app keeps its settings, its credentials or its token ledger. The consumer builds a
|
|
7
|
+
* client with two functions of its own:
|
|
8
|
+
*
|
|
9
|
+
* const ai = createAiClient({
|
|
10
|
+
* resolveConfig, // async () => resolved config (provider, baseUrl, model, apiKey, caps…)
|
|
11
|
+
* budget, // { assertWithinBudget(cfg, ctx), record(cfg, result, ctx) } — optional
|
|
12
|
+
* logger // { info, warn, error } — optional
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* The provider adapters already take an explicit config and never read a database, which is what
|
|
16
|
+
* makes the seam testable: a stub adapter and a real adapter are called identically.
|
|
17
|
+
*
|
|
18
|
+
* PROMPTS ARE CONTENT AND LIVE IN THE APP. This package carries the mechanism (how to call a model,
|
|
19
|
+
* how to enforce a token ceiling, how to check a rewrite kept its facts) and generic writing
|
|
20
|
+
* operations; the words that say "you are editing a reply to a customer" belong to the app.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const facts = require('./facts');
|
|
26
|
+
const { AiError, fromFetchFailure, redact } = require('./error');
|
|
27
|
+
const { polish } = require('./polish');
|
|
28
|
+
const { generate } = require('./generate');
|
|
29
|
+
|
|
30
|
+
const PROVIDERS = {
|
|
31
|
+
// 'lmstudio' and 'openai-compatible' are the SAME adapter with different defaults — a kindness to
|
|
32
|
+
// whoever configures it: an operator running LM Studio should not have to know it speaks a shape
|
|
33
|
+
// named after somebody else.
|
|
34
|
+
lmstudio: require('./providers/openai-compatible'),
|
|
35
|
+
'openai-compatible': require('./providers/openai-compatible'),
|
|
36
|
+
anthropic: require('./providers/anthropic'),
|
|
37
|
+
// 0.14.0 — a supervised fleet rather than an address. The engine URL is discovered from the
|
|
38
|
+
// supervisor's status document per call, the reasoning flag is read from the model the engine
|
|
39
|
+
// is actually running, and the whole conversation is pinned to a self-signed certificate.
|
|
40
|
+
lmx: require('./providers/lmx')
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const DEFAULTS = {
|
|
44
|
+
lmstudio: { baseUrl: 'http://localhost:1234/v1', model: 'qwen3.5-9b', label: 'LM Studio' },
|
|
45
|
+
'openai-compatible': { baseUrl: 'http://localhost:11434/v1', model: '', label: 'The model server' },
|
|
46
|
+
anthropic: { baseUrl: 'https://api.anthropic.com/v1', model: 'claude-sonnet-4-5', label: 'Claude' },
|
|
47
|
+
// No baseUrl: an lmx engine's address is never configured, only discovered.
|
|
48
|
+
lmx: { baseUrl: '', model: '', label: 'lmx engine' }
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const RETRY_AFTER_MS = 400;
|
|
52
|
+
const RETRY_ONLY_IF_FAILED_WITHIN_MS = 5000;
|
|
53
|
+
|
|
54
|
+
const NOOP_LOGGER = { info() {}, warn() {}, error() {} };
|
|
55
|
+
const NOOP_BUDGET = { async assertWithinBudget() {}, async record() {} };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build an AI client bound to one app's config resolution and token budget.
|
|
59
|
+
* @param {{resolveConfig: () => Promise<object>, budget?: object, logger?: object}} deps
|
|
60
|
+
*/
|
|
61
|
+
function createAiClient(deps = {}) {
|
|
62
|
+
const resolveConfig = deps.resolveConfig;
|
|
63
|
+
if (typeof resolveConfig !== 'function') {
|
|
64
|
+
throw new Error('createAiClient: resolveConfig must be an async function returning the resolved config');
|
|
65
|
+
}
|
|
66
|
+
const log = deps.logger || NOOP_LOGGER;
|
|
67
|
+
const meter = deps.budget || NOOP_BUDGET;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Try once more, but only for the failures where trying again could help — a local provider that
|
|
71
|
+
* dropped the connection while loading a model, and nothing else. A cancelled call, a timeout, a
|
|
72
|
+
* rate limit or a slow failure is never retried (see the guards below).
|
|
73
|
+
*/
|
|
74
|
+
async function withOneRetry(run, opts = {}) {
|
|
75
|
+
const startedAt = Date.now();
|
|
76
|
+
try {
|
|
77
|
+
return await run();
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (opts.signal && opts.signal.aborted) throw err;
|
|
80
|
+
if (!err || !err.retryable) throw err;
|
|
81
|
+
const elapsed = Date.now() - startedAt;
|
|
82
|
+
// A TIMEOUT is the deadline itself being reached — retrying waits the whole deadline again. A
|
|
83
|
+
// RATE LIMIT is the provider asking for less pressure. A slow `unreachable` is not the
|
|
84
|
+
// sub-second dropped-connection transient this retry exists for. None of those retry.
|
|
85
|
+
if (err.kind === 'timeout' || err.kind === 'rate_limit' || elapsed > RETRY_ONLY_IF_FAILED_WITHIN_MS) {
|
|
86
|
+
log.warn(`AI: ${err.kind} after ${elapsed}ms — not retrying (${err.message})`);
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
log.warn(`AI: ${err.kind} — trying once more in ${RETRY_AFTER_MS}ms (${err.message})`);
|
|
90
|
+
await new Promise((r) => setTimeout(r, RETRY_AFTER_MS));
|
|
91
|
+
return run();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Ask the configured model for something.
|
|
97
|
+
* @param {{system?:string, messages:Array, maxTokens?:number, temperature?:number,
|
|
98
|
+
* schema?:object, signal?:AbortSignal, ticketId?:*, skipBudget?:boolean}} opts
|
|
99
|
+
* @param {object} [cfgOverride] the resolved config, when the caller already has it
|
|
100
|
+
*/
|
|
101
|
+
async function complete(opts, cfgOverride) {
|
|
102
|
+
const cfg = cfgOverride || await resolveConfig();
|
|
103
|
+
if (!cfg.enabled) {
|
|
104
|
+
throw new AiError('disabled', 'AI assistance is switched off. An administrator can enable it in Settings.');
|
|
105
|
+
}
|
|
106
|
+
// A MODEL NAME IS REQUIRED OF EVERY PROVIDER THAT HAS ONE TO CONFIGURE — which is all of them
|
|
107
|
+
// except a supervised stack. There the model is a FACT the supervisor reports about an engine,
|
|
108
|
+
// not a setting: the operator picks an engine and whatever it is running answers. Insisting on
|
|
109
|
+
// a model name would make an lmx endpoint unusable by demanding the one field the design says
|
|
110
|
+
// not to store, and would say so with a message naming nothing an operator could go and fill in.
|
|
111
|
+
if (!cfg.model && cfg.provider !== 'lmx') {
|
|
112
|
+
throw new AiError('unconfigured', 'No model name is configured.');
|
|
113
|
+
}
|
|
114
|
+
const adapter = PROVIDERS[cfg.provider];
|
|
115
|
+
if (!adapter) {
|
|
116
|
+
throw new AiError('unconfigured', `No adapter is registered for provider "${cfg.provider}".`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The ceiling, before the call — the only place a limit can be enforced without being bypassable
|
|
120
|
+
// by whichever caller forgets. `skipBudget` is for the admin's Test connection; it still records.
|
|
121
|
+
if (!opts.skipBudget) await meter.assertWithinBudget(cfg, { ticketId: opts.ticketId });
|
|
122
|
+
|
|
123
|
+
const result = await withOneRetry(() => adapter.complete(cfg, opts), opts);
|
|
124
|
+
|
|
125
|
+
// ...and the counter after it, AWAITED: two calls in quick succession must both be counted
|
|
126
|
+
// before the second's ceiling check reads the total, or the limit is enforced against a stale one.
|
|
127
|
+
await meter.record(cfg, result, { ticketId: opts.ticketId });
|
|
128
|
+
|
|
129
|
+
log.info(`AI: ${cfg.provider}/${result.model} ${result.usage.total} tokens in ${result.ms}ms`);
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Is there a provider configured at all? Callers use this to decide whether to render a control. */
|
|
134
|
+
async function isEnabled() {
|
|
135
|
+
return (await resolveConfig()).enabled;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A short round trip for an admin "Test connection". NEVER THROWS — it reports what is wrong. */
|
|
139
|
+
async function test(cfgOverride) {
|
|
140
|
+
const cfg = cfgOverride || await resolveConfig();
|
|
141
|
+
if (!cfg.enabled) return { ok: false, kind: 'disabled', error: 'No provider is selected.' };
|
|
142
|
+
try {
|
|
143
|
+
const r = await complete({
|
|
144
|
+
system: 'Reply with the single word: ready. Do not explain.',
|
|
145
|
+
messages: [{ role: 'user', content: 'ready?' }],
|
|
146
|
+
maxTokens: 512,
|
|
147
|
+
temperature: 0,
|
|
148
|
+
skipBudget: true // an admin diagnosing a provider must not be blocked by a full budget
|
|
149
|
+
}, cfg);
|
|
150
|
+
return {
|
|
151
|
+
ok: true, model: r.model, ms: r.ms, reply: (r.text || '').trim().slice(0, 60), usage: r.usage,
|
|
152
|
+
finishReason: r.finishReason || null, reasoned: !!r.reasonedFor
|
|
153
|
+
};
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (err instanceof AiError) return { ok: false, kind: err.kind, error: err.message, retryable: err.retryable };
|
|
156
|
+
return { ok: false, kind: 'bad_response', error: err.message };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** What the server has loaded, for an admin model picker. Empty when it cannot say. */
|
|
161
|
+
async function listModels(cfgOverride) {
|
|
162
|
+
return (await listModelsResult(cfgOverride)).models;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The model list and WHY it is the length it is.
|
|
167
|
+
*
|
|
168
|
+
* Callers that only want names should use listModels(). An admin screen wants this one: an empty
|
|
169
|
+
* array on its own cannot tell "the server has nothing loaded" from "that address is not an
|
|
170
|
+
* OpenAI-compatible API root", and those need different fixes.
|
|
171
|
+
*/
|
|
172
|
+
async function listModelsResult(cfgOverride) {
|
|
173
|
+
const cfg = cfgOverride || await resolveConfig();
|
|
174
|
+
if (!cfg.enabled) {
|
|
175
|
+
return { ok: false, models: [], url: null, status: 0, error: 'No provider is selected.' };
|
|
176
|
+
}
|
|
177
|
+
const adapter = PROVIDERS[cfg.provider];
|
|
178
|
+
if (!adapter) {
|
|
179
|
+
return { ok: false, models: [], url: null, status: 0, error: `Unknown provider “${cfg.provider}”.` };
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
if (adapter.listModelsResult) return await adapter.listModelsResult(cfg);
|
|
183
|
+
// An adapter that predates this contract still works; it simply cannot explain itself.
|
|
184
|
+
return { ok: true, models: await adapter.listModels(cfg), url: null, status: 0, error: null };
|
|
185
|
+
} catch (err) {
|
|
186
|
+
return { ok: false, models: [], url: null, status: 0, error: err.message };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A fixed-workload speed test for one endpoint. See benchmark.js for why the workload is fixed
|
|
192
|
+
* rather than the prompt, and why the warm-up is reported rather than discarded.
|
|
193
|
+
*/
|
|
194
|
+
async function benchmarkEndpoint(cfgOverride, benchOpts) {
|
|
195
|
+
const cfg = cfgOverride || await resolveConfig();
|
|
196
|
+
return require('./benchmark').benchmark(complete, cfg, benchOpts || {});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The writing-assist engines are bound to this client's complete() so a caller gets config +
|
|
200
|
+
// budget + retry for free. Prompt framing is supplied per call by the app (content).
|
|
201
|
+
const boundPolish = (opts) => polish(complete, opts);
|
|
202
|
+
const boundGenerate = (opts) => generate(complete, opts);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
complete, isEnabled, test, listModels, listModelsResult, withOneRetry,
|
|
206
|
+
benchmark: benchmarkEndpoint,
|
|
207
|
+
polish: boundPolish, generate: boundGenerate,
|
|
208
|
+
facts, AiError, PROVIDERS, DEFAULTS
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = {
|
|
213
|
+
// The usage counter behind every ceiling. LAZY: it needs the db-worker driver contract, which
|
|
214
|
+
// is an OPTIONAL peer — a consumer using only createAiClient/polish/facts must not be made to
|
|
215
|
+
// install a database package to require this one.
|
|
216
|
+
get createUsageStore() { return require('./usageStore').createUsageStore; },
|
|
217
|
+
get createProviderStore() { return require('./providerStore').createProviderStore; },
|
|
218
|
+
// Speed history. Lazy for the same reason as the others: it needs the db-worker driver contract,
|
|
219
|
+
// which is an optional peer.
|
|
220
|
+
get createSpeedStore() { return require('./speedStore').createSpeedStore; },
|
|
221
|
+
// EXPORTED SO A CONSUMER CAN ASSERT ITS TABLE MATCHES. An app writes its own migration, which is
|
|
222
|
+
// a hand copy of this DDL — and a copy with nothing comparing it to the original is the failure
|
|
223
|
+
// mode this repo has already documented twice. providerSchemaFor and usageSchemaFor exist for the
|
|
224
|
+
// same reason; leaving this one out meant a drift would surface as an INSERT throwing at runtime.
|
|
225
|
+
get speedSchemaFor() { return require('./speedStore').schemaFor; },
|
|
226
|
+
// No database behind health, so it loads eagerly like the rest of the seam.
|
|
227
|
+
...require('./health'),
|
|
228
|
+
/**
|
|
229
|
+
* Where this package's EJS partials live, for the consumer's view-roots list.
|
|
230
|
+
*
|
|
231
|
+
* Same contract as backup/server/notify/uploads: the package knows its own layout, the app
|
|
232
|
+
* puts its own views FIRST so a local file of the same name wins.
|
|
233
|
+
*/
|
|
234
|
+
viewsDir: require('path').join(__dirname, 'views'),
|
|
235
|
+
get providerSchemaFor() { return require('./providerStore').schemaFor; },
|
|
236
|
+
// ── SUPERVISED STACKS (lmx) ─────────────────────────────────────────────────────────────────
|
|
237
|
+
// The store is LAZY for the same reason as the others: it needs the db-worker driver contract,
|
|
238
|
+
// which is an optional peer. A consumer using only createAiClient must not be made to install a
|
|
239
|
+
// database package to require this one.
|
|
240
|
+
get createLmxStore() { return require('./lmxStore').createLmxStore; },
|
|
241
|
+
// EXPORTED SO A CONSUMER CAN ASSERT ITS TABLE MATCHES — the migration is a hand copy of this, and
|
|
242
|
+
// a copy with nothing comparing it to the original is the failure this repo has documented three
|
|
243
|
+
// times now. The first app to carry the table wrote it with nothing to check against.
|
|
244
|
+
get lmxSchemaFor() { return require('./lmxStore').schemaFor; },
|
|
245
|
+
// Does this stack actually work? Four checks in the only order they can run. No database and no
|
|
246
|
+
// keystore — every credential arrives as an argument — so it loads eagerly.
|
|
247
|
+
lmxVerify: require('./lmxVerify'),
|
|
248
|
+
// The counting rules a screen needs. Separate from the verifier because "what did the stack say"
|
|
249
|
+
// and "what does that mean for what I am relying on" are different questions, and only the second
|
|
250
|
+
// one needs to know which engines this app has adopted.
|
|
251
|
+
...require('./lmxStatus'),
|
|
252
|
+
get usageSchemaFor() { return require('./usageStore').schemaFor; },
|
|
253
|
+
createAiClient,
|
|
254
|
+
PROVIDERS, DEFAULTS,
|
|
255
|
+
AiError, fromFetchFailure, redact,
|
|
256
|
+
facts,
|
|
257
|
+
// Default writing-op catalogues, so an app can build its menus without re-declaring them.
|
|
258
|
+
POLISH_MODES: require('./polish').MODES,
|
|
259
|
+
POLISH_TONES: require('./polish').TONES,
|
|
260
|
+
RETRY_AFTER_MS
|
|
261
|
+
};
|