@aria-framework/ai 0.7.0 → 0.8.1

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 CHANGED
@@ -172,23 +172,42 @@ function createHealthChecker(opts = {}) {
172
172
  const cooldownMs = Number(opts.cooldownMs) || 60000;
173
173
  const threshold = Math.max(1, Number(opts.failureThreshold) || 3);
174
174
 
175
- /** id -> { failures, downUntil, lastGoodAt, lastError, lastMs, lastCheckedAt, modelPresent } */
176
175
  const state = new Map();
177
176
  const entry = (id) => {
178
177
  if (!state.has(id)) {
179
178
  state.set(id, {
180
179
  failures: 0, downUntil: 0, lastGoodAt: null,
181
- 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: []
182
184
  });
183
185
  }
184
186
  return state.get(id);
185
187
  };
186
188
 
189
+ /** How many completions to average throughput over. Small enough to still track a real change. */
190
+ const WINDOW = 10;
191
+
187
192
  /** Fold one outcome into the breaker. Exposed because a real CALL is better evidence than a probe. */
188
193
  function report(id, ok, info = {}) {
189
194
  const e = entry(id);
190
195
  e.lastCheckedAt = now();
191
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
+ // Guarded on TOKENS, not on duration. Requiring ms > 0 here silently dropped any call that
204
+ // completed inside a millisecond — which a fast local model or a stub genuinely does, so the
205
+ // sample count became timing-dependent. The division is where zero actually matters, so that
206
+ // is where it is handled.
207
+ if (ok && info.tokens > 0) {
208
+ e.samples.push({ tokens: info.tokens, ms: Math.max(0, Number(info.ms) || 0) });
209
+ if (e.samples.length > WINDOW) e.samples.shift();
210
+ }
192
211
  if (ok) {
193
212
  e.failures = 0;
194
213
  e.downUntil = 0;
@@ -267,7 +286,15 @@ function createHealthChecker(opts = {}) {
267
286
  /** Everything the UI needs for one provider, without probing. */
268
287
  status(id) {
269
288
  const e = state.get(id);
270
- if (!e) return { id, status: 'unknown', failures: 0 };
289
+ // The same SHAPE for an unseen provider, so a caller never has to tell `undefined` (this key
290
+ // does not exist here) from `null` (we do not know yet) — they mean the same thing to a view.
291
+ if (!e) {
292
+ return {
293
+ id, status: 'unknown', failures: 0, lastGoodAt: null, lastError: null, lastMs: null,
294
+ lastCheckedAt: null, modelPresent: null, tokensPerSec: null, samples: 0,
295
+ cooldownRemainingMs: 0
296
+ };
297
+ }
271
298
  const down = !!(e.downUntil && now() < e.downUntil);
272
299
  return {
273
300
  id,
@@ -278,6 +305,17 @@ function createHealthChecker(opts = {}) {
278
305
  lastMs: e.lastMs,
279
306
  lastCheckedAt: e.lastCheckedAt,
280
307
  modelPresent: e.modelPresent,
308
+ // NULL until something has actually generated tokens. Reporting 0 tok/s for a provider
309
+ // nobody has used yet reads as "it is slow" rather than "we do not know".
310
+ // Null rather than Infinity when every sample was too fast to measure: "we cannot tell"
311
+ // is the honest answer, and a card showing Infinity tok/s is worse than showing nothing.
312
+ tokensPerSec: (() => {
313
+ if (!e.samples.length) return null;
314
+ const ms = e.samples.reduce((n, x) => n + x.ms, 0);
315
+ if (ms <= 0) return null;
316
+ return Math.round(e.samples.reduce((n, x) => n + x.tokens, 0) / (ms / 1000));
317
+ })(),
318
+ samples: e.samples.length,
281
319
  cooldownRemainingMs: down ? e.downUntil - now() : 0
282
320
  };
283
321
  },
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.7.0",
4
+ "version": "0.8.1",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
7
7
  "publishConfig": {
@@ -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>