@aria-framework/ai 0.5.0 → 0.8.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 +128 -17
- package/package.json +1 -1
- package/providers/openai-compatible.js +22 -8
- package/views/ai/provider-card.ejs +8 -0
package/health.js
CHANGED
|
@@ -12,9 +12,19 @@
|
|
|
12
12
|
* Reporting that as "healthy" hides the only fact worth knowing. So `reachable` and `modelPresent`
|
|
13
13
|
* are separate, and the UI shows them separately.
|
|
14
14
|
*
|
|
15
|
-
* `modelPresent` is NULL, not false,
|
|
16
|
-
* models on demand, and
|
|
17
|
-
*
|
|
15
|
+
* `modelPresent` is NULL, not false, whenever the answer is not KNOWN — a hosted API does not load
|
|
16
|
+
* models on demand, and a server that cannot report load state must not be guessed at. Null means
|
|
17
|
+
* "cannot tell", false means "we asked something that knows, and it said no".
|
|
18
|
+
*
|
|
19
|
+
* ── /v1/models CANNOT ANSWER THIS, WHICH TOOK REAL HARDWARE TO FIND ─────────────────────────────
|
|
20
|
+
* Measured against a live LM Studio: `/v1/models` returned FIVE models with keys `id, object,
|
|
21
|
+
* owned_by`, while only TWO were actually resident. It lists what is DOWNLOADED, not what is
|
|
22
|
+
* LOADED — so residency judged from it reports "loaded" for every model on the disk, and the
|
|
23
|
+
* evicted-model warning this module exists for could never fire at all.
|
|
24
|
+
*
|
|
25
|
+
* LM Studio's native `/api/v0/models` carries a `state` field (`loaded` / `not-loaded`), which is
|
|
26
|
+
* the real signal. So the probe asks that FIRST for kinds that load on demand, and falls back to
|
|
27
|
+
* `/v1/models` purely for reachability — reporting residency as null rather than inventing it.
|
|
18
28
|
*
|
|
19
29
|
* ── THE CIRCUIT BREAKER IS WHY THIS IS NOT JUST A PING ──────────────────────────────────────────
|
|
20
30
|
* Without one, every call to a dead provider pays the full timeout before failing over. Annoying at
|
|
@@ -35,8 +45,19 @@ const DEFAULT_TIMEOUT_MS = 8000;
|
|
|
35
45
|
/** Kinds that load models on demand, where residency is a real question. */
|
|
36
46
|
const RESIDENT_KINDS = new Set(['lmstudio', 'openai-compatible']);
|
|
37
47
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
48
|
+
/**
|
|
49
|
+
* THE ADAPTER'S root, not a second one.
|
|
50
|
+
*
|
|
51
|
+
* This file originally rolled its own — strip the trailing slash and append `/models` — which is
|
|
52
|
+
* wrong for the base URL people actually type. `http://host:1234` has no path, so the adapter adds
|
|
53
|
+
* `/v1`; the probe did not, and asked for `/models`. LM Studio answers that with
|
|
54
|
+
* "Unexpected endpoint or method. Returning 200 anyway" — a 200 with no model list — so the probe
|
|
55
|
+
* saw an empty listing and reported a perfectly loaded model as EVICTED, on a server that was
|
|
56
|
+
* answering completions the whole time.
|
|
57
|
+
*
|
|
58
|
+
* Two roots that disagree about where the API lives is a bug waiting to happen twice. There is one.
|
|
59
|
+
*/
|
|
60
|
+
const { apiRoot } = require('./providers/openai-compatible');
|
|
40
61
|
|
|
41
62
|
/**
|
|
42
63
|
* The default probe: ask the endpoint what models it has.
|
|
@@ -45,6 +66,39 @@ const apiRoot = (baseUrl) => String(baseUrl || '').replace(/\/+$/, '');
|
|
|
45
66
|
* for a dead host and [] for a live host with nothing loaded, which is exactly the difference this
|
|
46
67
|
* module exists to report.
|
|
47
68
|
*/
|
|
69
|
+
/**
|
|
70
|
+
* LM Studio's native model list, which reports LOAD STATE.
|
|
71
|
+
*
|
|
72
|
+
* Returns null when the endpoint is not there (any other server), so the caller falls back rather
|
|
73
|
+
* than treating its absence as a failure.
|
|
74
|
+
*/
|
|
75
|
+
async function nativeResidency(cfg, fetchImpl, signalMs) {
|
|
76
|
+
// The native API sits beside the OpenAI-compatible one, not under /v1.
|
|
77
|
+
let base;
|
|
78
|
+
try {
|
|
79
|
+
const u = new URL(String(cfg.baseUrl || ''));
|
|
80
|
+
base = `${u.protocol}//${u.host}`;
|
|
81
|
+
} catch (_) { return null; }
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetchImpl(`${base}/api/v0/models`, {
|
|
85
|
+
headers: cfg.apiKey ? { Authorization: `Bearer ${cfg.apiKey}` } : {},
|
|
86
|
+
signal: AbortSignal.timeout(signalMs)
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) return null;
|
|
89
|
+
const payload = await res.json();
|
|
90
|
+
if (!Array.isArray(payload && payload.data)) return null;
|
|
91
|
+
const loaded = payload.data
|
|
92
|
+
.filter((m) => m && m.state === 'loaded')
|
|
93
|
+
.map((m) => m.id || m.key || '')
|
|
94
|
+
.filter(Boolean);
|
|
95
|
+
// An empty `data` is a real answer (nothing loaded); a missing one was handled above.
|
|
96
|
+
return { loaded, all: payload.data.map((m) => (m && m.id) || '').filter(Boolean) };
|
|
97
|
+
} catch (_) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
48
102
|
async function defaultProbe(cfg, { fetchImpl = fetch, timeoutMs } = {}) {
|
|
49
103
|
const url = `${apiRoot(cfg.baseUrl)}/models`;
|
|
50
104
|
const started = Date.now();
|
|
@@ -62,25 +116,47 @@ async function defaultProbe(cfg, { fetchImpl = fetch, timeoutMs } = {}) {
|
|
|
62
116
|
// lives in `e.cause.code`, so lead with that and keep the message as context.
|
|
63
117
|
const cause = (e && e.cause) || {};
|
|
64
118
|
const detail = cause.code || (e && e.message) || 'unreachable';
|
|
119
|
+
// NAME THE URL. Nothing normalises a base URL any more, so a wrong one is a real possibility
|
|
120
|
+
// and the operator's first question is "what did it actually call". `ECONNREFUSED` alone sends
|
|
121
|
+
// them to check the server; `ECONNREFUSED at http://host:1234/models` shows them the typo.
|
|
122
|
+
const reason = timedOut
|
|
123
|
+
? `no response within ${timeoutMs || cfg.timeoutMs || DEFAULT_TIMEOUT_MS}ms`
|
|
124
|
+
: (cause.code ? `${cause.code}${cause.message ? ' — ' + cause.message : ''}` : detail);
|
|
65
125
|
return {
|
|
66
|
-
reachable: false, models: [], ms: Date.now() - started,
|
|
67
|
-
error:
|
|
68
|
-
? `no response within ${timeoutMs || cfg.timeoutMs || DEFAULT_TIMEOUT_MS}ms`
|
|
69
|
-
: (cause.code ? `${cause.code}${cause.message ? ' — ' + cause.message : ''}` : detail)
|
|
126
|
+
reachable: false, listed: false, models: [], loaded: null, ms: Date.now() - started,
|
|
127
|
+
url, error: `${reason} (${url})`
|
|
70
128
|
};
|
|
71
129
|
}
|
|
72
130
|
const ms = Date.now() - started;
|
|
73
131
|
if (!res.ok) {
|
|
74
132
|
// It ANSWERED, so the host is reachable — the failure is auth, quota or a bad path, and saying
|
|
75
133
|
// "unreachable" would send an operator to check the network instead of the key.
|
|
76
|
-
return { reachable: true, models: [], ms, error: `HTTP ${res.status}` };
|
|
134
|
+
return { reachable: true, listed: false, models: [], loaded: null, ms, url, error: `HTTP ${res.status} (${url})` };
|
|
77
135
|
}
|
|
78
136
|
let payload = null;
|
|
79
137
|
try { payload = await res.json(); } catch (_) { payload = null; }
|
|
80
|
-
|
|
138
|
+
|
|
139
|
+
// "A 200 THAT IS NOT A MODEL LISTING" IS NOT "NO MODELS LOADED".
|
|
140
|
+
//
|
|
141
|
+
// Servers answer 200 to things they do not implement — LM Studio logs
|
|
142
|
+
// "Unexpected endpoint or method. Returning 200 anyway" and returns a body with no `data`.
|
|
143
|
+
// Treating that as an empty list makes a loaded model look evicted, which is the exact false
|
|
144
|
+
// alarm this module exists to avoid. So `listed` says whether a real listing came back, and
|
|
145
|
+
// residency is only judged when it did.
|
|
146
|
+
const listed = Array.isArray(payload && payload.data);
|
|
147
|
+
const models = listed
|
|
81
148
|
? payload.data.map((m) => (m && (m.id || m.name)) || '').filter(Boolean)
|
|
82
149
|
: [];
|
|
83
|
-
|
|
150
|
+
|
|
151
|
+
// Now ask something that actually knows about residency. Only for kinds that load on demand,
|
|
152
|
+
// and only as an addition — a server without it still reports reachable, with residency null.
|
|
153
|
+
let loaded = null;
|
|
154
|
+
if (RESIDENT_KINDS.has(cfg.provider)) {
|
|
155
|
+
const native = await nativeResidency(cfg, fetchImpl, timeoutMs || cfg.timeoutMs || DEFAULT_TIMEOUT_MS);
|
|
156
|
+
if (native) loaded = native.loaded;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { reachable: true, listed, models, loaded, ms, url, error: null };
|
|
84
160
|
}
|
|
85
161
|
|
|
86
162
|
/**
|
|
@@ -96,23 +172,38 @@ function createHealthChecker(opts = {}) {
|
|
|
96
172
|
const cooldownMs = Number(opts.cooldownMs) || 60000;
|
|
97
173
|
const threshold = Math.max(1, Number(opts.failureThreshold) || 3);
|
|
98
174
|
|
|
99
|
-
/** id -> { failures, downUntil, lastGoodAt, lastError, lastMs, lastCheckedAt, modelPresent } */
|
|
100
175
|
const state = new Map();
|
|
101
176
|
const entry = (id) => {
|
|
102
177
|
if (!state.has(id)) {
|
|
103
178
|
state.set(id, {
|
|
104
179
|
failures: 0, downUntil: 0, lastGoodAt: null,
|
|
105
|
-
lastError: null, lastMs: null, lastCheckedAt: null, modelPresent: null
|
|
180
|
+
lastError: null, lastMs: null, lastCheckedAt: null, modelPresent: null,
|
|
181
|
+
// The last few completions, for throughput. A window rather than a single value because
|
|
182
|
+
// one reload spike would otherwise define the number an operator plans capacity from.
|
|
183
|
+
samples: []
|
|
106
184
|
});
|
|
107
185
|
}
|
|
108
186
|
return state.get(id);
|
|
109
187
|
};
|
|
110
188
|
|
|
189
|
+
/** How many completions to average throughput over. Small enough to still track a real change. */
|
|
190
|
+
const WINDOW = 10;
|
|
191
|
+
|
|
111
192
|
/** Fold one outcome into the breaker. Exposed because a real CALL is better evidence than a probe. */
|
|
112
193
|
function report(id, ok, info = {}) {
|
|
113
194
|
const e = entry(id);
|
|
114
195
|
e.lastCheckedAt = now();
|
|
115
196
|
if (info.ms != null) e.lastMs = info.ms;
|
|
197
|
+
|
|
198
|
+
// THROUGHPUT, and what it honestly measures: completion tokens divided by the WHOLE call, so
|
|
199
|
+
// it includes prompt processing, queueing and the network. That is not the model's raw
|
|
200
|
+
// generation speed — separating those would need time-to-first-token, which no adapter
|
|
201
|
+
// reports — but it is what the caller actually experienced, which is the number worth planning
|
|
202
|
+
// against. Only completions count; a health probe generates no tokens and would drag it to 0.
|
|
203
|
+
if (ok && info.tokens > 0 && info.ms > 0) {
|
|
204
|
+
e.samples.push({ tokens: info.tokens, ms: info.ms });
|
|
205
|
+
if (e.samples.length > WINDOW) e.samples.shift();
|
|
206
|
+
}
|
|
116
207
|
if (ok) {
|
|
117
208
|
e.failures = 0;
|
|
118
209
|
e.downUntil = 0;
|
|
@@ -166,9 +257,13 @@ function createHealthChecker(opts = {}) {
|
|
|
166
257
|
|
|
167
258
|
const r = await probe(cfg);
|
|
168
259
|
// Residency only means something where models are loaded on demand.
|
|
260
|
+
// RESIDENCY COMES FROM `loaded`, NEVER FROM THE MODEL LIST. `/v1/models` reports what is
|
|
261
|
+
// downloaded; judging residency from it says "loaded" for every model on the disk. Null
|
|
262
|
+
// whenever nothing authoritative answered — an unknown is honest, a guess is not.
|
|
169
263
|
let modelPresent = null;
|
|
170
|
-
if (RESIDENT_KINDS.has(cfg.provider) && r.reachable && !r.error
|
|
171
|
-
|
|
264
|
+
if (RESIDENT_KINDS.has(cfg.provider) && r.reachable && !r.error
|
|
265
|
+
&& Array.isArray(r.loaded) && cfg.model) {
|
|
266
|
+
modelPresent = r.loaded.includes(cfg.model);
|
|
172
267
|
}
|
|
173
268
|
|
|
174
269
|
// REACHABLE BUT NOT LOADED IS NOT A FAILURE. The call will still succeed; it will just be
|
|
@@ -187,7 +282,15 @@ function createHealthChecker(opts = {}) {
|
|
|
187
282
|
/** Everything the UI needs for one provider, without probing. */
|
|
188
283
|
status(id) {
|
|
189
284
|
const e = state.get(id);
|
|
190
|
-
|
|
285
|
+
// The same SHAPE for an unseen provider, so a caller never has to tell `undefined` (this key
|
|
286
|
+
// does not exist here) from `null` (we do not know yet) — they mean the same thing to a view.
|
|
287
|
+
if (!e) {
|
|
288
|
+
return {
|
|
289
|
+
id, status: 'unknown', failures: 0, lastGoodAt: null, lastError: null, lastMs: null,
|
|
290
|
+
lastCheckedAt: null, modelPresent: null, tokensPerSec: null, samples: 0,
|
|
291
|
+
cooldownRemainingMs: 0
|
|
292
|
+
};
|
|
293
|
+
}
|
|
191
294
|
const down = !!(e.downUntil && now() < e.downUntil);
|
|
192
295
|
return {
|
|
193
296
|
id,
|
|
@@ -198,6 +301,14 @@ function createHealthChecker(opts = {}) {
|
|
|
198
301
|
lastMs: e.lastMs,
|
|
199
302
|
lastCheckedAt: e.lastCheckedAt,
|
|
200
303
|
modelPresent: e.modelPresent,
|
|
304
|
+
// NULL until something has actually generated tokens. Reporting 0 tok/s for a provider
|
|
305
|
+
// nobody has used yet reads as "it is slow" rather than "we do not know".
|
|
306
|
+
tokensPerSec: e.samples.length
|
|
307
|
+
? Math.round(
|
|
308
|
+
e.samples.reduce((n, x) => n + x.tokens, 0)
|
|
309
|
+
/ (e.samples.reduce((n, x) => n + x.ms, 0) / 1000))
|
|
310
|
+
: null,
|
|
311
|
+
samples: e.samples.length,
|
|
201
312
|
cooldownRemainingMs: down ? e.downUntil - now() : 0
|
|
202
313
|
};
|
|
203
314
|
},
|
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.8.0",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
7
7
|
"publishConfig": {
|
|
@@ -251,15 +251,29 @@ async function httpError(res, label, apiKey) {
|
|
|
251
251
|
* left alone, because other servers in this family mount elsewhere (LiteLLM behind a prefix, for
|
|
252
252
|
* one) and second-guessing an explicit path would break them.
|
|
253
253
|
*/
|
|
254
|
+
/**
|
|
255
|
+
* The configured base URL, with a trailing slash trimmed. Nothing else.
|
|
256
|
+
*
|
|
257
|
+
* IT USED TO APPEND `/v1` WHEN THE URL HAD NO PATH, and that was wrong for reasons that only got
|
|
258
|
+
* clearer the more providers there were:
|
|
259
|
+
*
|
|
260
|
+
* - the stored value and the called value differed, so the form did not show what went on the
|
|
261
|
+
* wire;
|
|
262
|
+
* - it cannot generalise. One provider wants `/v1`, another `/api/v1`, another nothing at all.
|
|
263
|
+
* A blanket rule is wrong for every provider it was not written for, and there is no way to
|
|
264
|
+
* know which is which;
|
|
265
|
+
* - it turned a typo into a SILENT wrong answer that surfaced three layers away. A base URL
|
|
266
|
+
* missing `/v1` still completed chats — because this quietly fixed it — while the health probe
|
|
267
|
+
* asked a path LM Studio answers with "Unexpected endpoint, returning 200 anyway", and a
|
|
268
|
+
* perfectly loaded model was reported as evicted.
|
|
269
|
+
*
|
|
270
|
+
* So: call exactly what was configured. A wrong URL now fails immediately and says which URL it
|
|
271
|
+
* called, which is a configuration error an operator can act on rather than a mystery.
|
|
272
|
+
*
|
|
273
|
+
* Trailing-slash trimming stays because it changes no meaning — it only prevents `//models`.
|
|
274
|
+
*/
|
|
254
275
|
function apiRoot(baseUrl) {
|
|
255
|
-
|
|
256
|
-
let path;
|
|
257
|
-
try {
|
|
258
|
-
path = new URL(trimmed).pathname.replace(/\/+$/, '');
|
|
259
|
-
} catch (err) {
|
|
260
|
-
return trimmed; // not a URL we can parse; leave it exactly as typed
|
|
261
|
-
}
|
|
262
|
-
return path === '' ? trimmed + '/v1' : trimmed;
|
|
276
|
+
return String(baseUrl || '').replace(/\/+$/, '');
|
|
263
277
|
}
|
|
264
278
|
|
|
265
279
|
/** Both providers report tokens; they name the fields differently. One shape reaches the caller. */
|
|
@@ -64,6 +64,14 @@
|
|
|
64
64
|
<div><div class="text-body-secondary text-uppercase" style="font-size:.68rem;letter-spacing:.06em">Latency</div>
|
|
65
65
|
<span class="font-monospace"><%= h.lastMs %> ms</span></div>
|
|
66
66
|
<% } %>
|
|
67
|
+
<% if (h.tokensPerSec != null) { %>
|
|
68
|
+
<%# Completion tokens over the WHOLE call, so it includes prompt processing and the
|
|
69
|
+
network — what the caller experienced, not the model's raw generation speed. The
|
|
70
|
+
title says so, because an unqualified "tok/s" invites comparing it to a benchmark. %>
|
|
71
|
+
<div><div class="text-body-secondary text-uppercase" style="font-size:.68rem;letter-spacing:.06em">Throughput</div>
|
|
72
|
+
<span class="font-monospace"
|
|
73
|
+
title="Completion tokens per second of total call time, averaged over the last <%= h.samples %> call<%= h.samples === 1 ? '' : 's' %>. Includes prompt processing and network."><%= h.tokensPerSec %> tok/s</span></div>
|
|
74
|
+
<% } %>
|
|
67
75
|
<% if (typeof usage !== 'undefined' && usage) { %>
|
|
68
76
|
<div><div class="text-body-secondary text-uppercase" style="font-size:.68rem;letter-spacing:.06em">Calls</div>
|
|
69
77
|
<span class="font-monospace"><%= Number(usage.calls || 0).toLocaleString() %></span></div>
|