@aria-framework/ai 0.10.0 → 0.11.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/benchmark.js ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * A fixed-workload speed test for one endpoint.
3
+ *
4
+ * WHY NOT JUST TIME A CALL. Timing an ordinary request measures the model's verbosity as much as
5
+ * its speed: ask two models the same question and the wordier one looks slower. The workload here
6
+ * cannot be finished early — counting to a thousand always exceeds the reply budget — so every run
7
+ * ends at `finish_reason: length` and what is measured is generation rate, not how talkative the
8
+ * model felt.
9
+ *
10
+ * A FIRST ATTEMPT ASKED IT TO "continue the sequence" AND THE MODEL STOPPED AFTER 21 TOKENS. An
11
+ * open-ended instruction is an invitation to stop; a destination it cannot reach is not.
12
+ *
13
+ * WHY A DURATION BUDGET RATHER THAN A TOKEN COUNT. A fixed 256 tokens took 33 seconds on a local 9B
14
+ * — four of those would block a button press for over two minutes and flirt with the per-call
15
+ * timeout. Fixing the token count was meant to make runs comparable, but a RATE is already
16
+ * normalised: measuring 39 tokens over five seconds and 512 tokens over five seconds both yield
17
+ * tokens per second. So each run is given roughly TARGET_MS of work, bounded at both ends, and the
18
+ * budget actually used is reported. Measured on the same endpoint, the two approaches agreed to
19
+ * within 1% (7.6 tok/s over 21 tokens, 7.7 over 256).
20
+ *
21
+ * WHY THE WARM-UP IS REPORTED RATHER THAN DISCARDED. LM Studio unloads the previous model when a new
22
+ * one loads and Ollama unloads on idle, so the first call can pay a multi-second load — 28 seconds
23
+ * was observed on a 22 GB model. That number decides whether an endpoint can serve anything
24
+ * interactive, and it is invisible in a warm average. It is the point of the first call, not a
25
+ * sample to throw away. It doubles as the rate estimate that sizes the runs that follow.
26
+ *
27
+ * WHY THREE RUNS. One sample on a GPU shared with anything else is noise. The median of three
28
+ * survives a neighbour's job starting mid-test.
29
+ *
30
+ * REASONING IS COUNTED, NOT EXCLUDED. A thinking model spends completion tokens before it writes
31
+ * anything, and those are real work at a real rate — but they are not answer, so a tok/s that
32
+ * silently includes them is not comparable with a model that does not think. Both facts are
33
+ * reported and the caller is told which is which.
34
+ */
35
+
36
+ 'use strict';
37
+
38
+ /**
39
+ * A workload with a destination the model cannot reach inside the budget.
40
+ *
41
+ * Counting is the least ambiguous instruction across model families — no judgement, no refusal
42
+ * risk, nothing to be clever about — so what is measured is generation rate rather than how a
43
+ * particular model feels about the prompt.
44
+ */
45
+ const WORKLOAD = {
46
+ system: 'Count upward. Output one number per line and nothing else.',
47
+ user: 'Count from 1 to 1000.'
48
+ };
49
+
50
+ /** Roughly how long each measured run should generate for. Long enough to average out jitter,
51
+ * short enough that pressing a button does not feel like it has hung. */
52
+ const TARGET_MS = 4000;
53
+
54
+ /** The warm-up's budget: enough to pay the model load and estimate a rate, cheap if it fails. */
55
+ const PROBE_TOKENS = 64;
56
+
57
+ /** Bounds on the per-run budget, so a very fast endpoint cannot run up a bill and a very slow one
58
+ * still produces a usable sample. */
59
+ const MIN_TOKENS = 48;
60
+ const MAX_TOKENS = 320;
61
+
62
+ /** Middle value, or the mean of the two middle ones. Not the average: one stall would drag it. */
63
+ function median(values) {
64
+ if (!values.length) return null;
65
+ const v = values.slice().sort((a, b) => a - b);
66
+ const mid = Math.floor(v.length / 2);
67
+ return v.length % 2 ? v[mid] : Math.round(((v[mid - 1] + v[mid]) / 2) * 10) / 10;
68
+ }
69
+
70
+ const rate = (tokens, ms) => (ms > 0 ? Math.round((tokens / (ms / 1000)) * 10) / 10 : null);
71
+
72
+ /**
73
+ * Run one measured call.
74
+ *
75
+ * NEVER THROWS. A model that spends its whole reply budget on internal reasoning makes complete()
76
+ * throw with no answer to return — but it still generated tokens, at a rate worth knowing. When the
77
+ * error carries usage that is a measurement; when it does not, it is a failed run and says so.
78
+ */
79
+ async function once(complete, cfg, maxTokens) {
80
+ const started = Date.now();
81
+ try {
82
+ const r = await complete({
83
+ system: WORKLOAD.system,
84
+ messages: [{ role: 'user', content: WORKLOAD.user }],
85
+ maxTokens,
86
+ temperature: 0
87
+ }, cfg);
88
+ const ms = r.ms || (Date.now() - started);
89
+ const tokens = (r.usage && r.usage.completion) || 0;
90
+ return {
91
+ ms, tokens, maxTokens, tokensPerSec: rate(tokens, ms),
92
+ reasonedChars: r.reasonedFor || 0, reasoningOnly: false, error: null
93
+ };
94
+ } catch (err) {
95
+ const ms = Date.now() - started;
96
+ const tokens = (err.usage && (err.usage.completion_tokens || err.usage.completion)) || 0;
97
+ if (!tokens) {
98
+ return { ms, tokens: 0, maxTokens, tokensPerSec: null, reasonedChars: 0, reasoningOnly: false, error: err.message };
99
+ }
100
+ // Tokens were produced; they simply were not an answer. That is still throughput.
101
+ return {
102
+ ms, tokens, maxTokens, tokensPerSec: rate(tokens, ms),
103
+ reasonedChars: 0, reasoningOnly: true, error: null
104
+ };
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Measure one endpoint.
110
+ *
111
+ * @param {Function} complete the client's complete(opts, cfg)
112
+ * @param {object} cfg a resolved endpoint config
113
+ * @param {{runs?: number, targetMs?: number}} [opts]
114
+ */
115
+ async function benchmark(complete, cfg, opts = {}) {
116
+ const runs = Math.max(1, Math.min(10, Number(opts.runs) || 3));
117
+ const targetMs = Math.max(500, Math.min(30000, Number(opts.targetMs) || TARGET_MS));
118
+
119
+ if (!cfg || !cfg.enabled) {
120
+ return { ok: false, error: 'This endpoint is not enabled, so it was not asked to do anything.' };
121
+ }
122
+
123
+ // THE FIRST CALL IS THE COLD ONE. Its duration answers "can this serve a person who is waiting",
124
+ // which no later run can give once the model is resident.
125
+ const cold = await once(complete, cfg, PROBE_TOKENS);
126
+ if (cold.error) {
127
+ return {
128
+ ok: false, model: cfg.model || null, coldMs: cold.ms, runs: [], medianTokensPerSec: null,
129
+ reasoning: false, reasoningOnly: false, totalTokens: cold.tokens, runTokens: null,
130
+ error: cold.error
131
+ };
132
+ }
133
+
134
+ // A SECOND SMALL CALL, NOW WARM, PURELY TO SIZE THE RUNS.
135
+ //
136
+ // Sizing from the COLD call starves the measurement on exactly the endpoints that most need a
137
+ // decent sample: a 35B model that spent 26 seconds loading and then produced 64 tokens looks like
138
+ // 2.4 tok/s, so its runs were budgeted at the floor — when warm it actually managed 66.8, and
139
+ // four seconds of its work is closer to 270 tokens. The load is not the rate, and using it as one
140
+ // makes the fast endpoints look like the slow ones.
141
+ const pace = await once(complete, cfg, PROBE_TOKENS);
142
+ const estimated = pace.tokensPerSec || cold.tokensPerSec || 20;
143
+ const runTokens = Math.max(MIN_TOKENS, Math.min(MAX_TOKENS, Math.round((estimated * targetMs) / 1000)));
144
+
145
+ const measured = [];
146
+ for (let i = 0; i < runs; i += 1) {
147
+ measured.push(await once(complete, cfg, runTokens));
148
+ }
149
+
150
+ const good = measured.filter((r) => r.tokensPerSec != null);
151
+ const failed = measured.filter((r) => r.error);
152
+
153
+ return {
154
+ ok: good.length > 0,
155
+ model: cfg.model || null,
156
+ // Reported separately and never folded into the median: mixing a cold call into a warm average
157
+ // is how an endpoint that takes half a minute to wake looks merely mediocre.
158
+ coldMs: cold.ms,
159
+ runs: measured,
160
+ runTokens,
161
+ medianTokensPerSec: median(good.map((r) => r.tokensPerSec)),
162
+ // TRUE when any run thought before answering. The rate above still counts those tokens — they
163
+ // were generated — but it is not comparable with a model that does not think, and the caller
164
+ // needs to be able to say so rather than presenting one number as though it were the same.
165
+ reasoning: measured.some((r) => r.reasonedChars > 0 || r.reasoningOnly),
166
+ reasoningOnly: good.length > 0 && good.every((r) => r.reasoningOnly),
167
+ totalTokens: cold.tokens + pace.tokens + measured.reduce((n, r) => n + r.tokens, 0),
168
+ error: good.length ? null : (failed[0] && failed[0].error) || 'No run produced any tokens.'
169
+ };
170
+ }
171
+
172
+ module.exports = { benchmark, WORKLOAD, TARGET_MS, MIN_TOKENS, MAX_TOKENS };
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.10.0",
4
+ "version": "0.11.0",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
7
7
  "publishConfig": {
@@ -16,7 +16,8 @@
16
16
  "generate.js",
17
17
  "providers/openai-compatible.js",
18
18
  "providers/anthropic.js",
19
- "browser/ai-polish.js", "usageStore.js", "providerStore.js", "health.js", "views/"
19
+ "browser/ai-polish.js", "usageStore.js", "providerStore.js", "health.js",
20
+ "benchmark.js", "views/"
20
21
  ],
21
22
  "peerDependencies": {
22
23
  "@aria-framework/db-worker": ">=0.7.0"
@@ -25,6 +26,6 @@
25
26
  "@aria-framework/db-worker": { "optional": true }
26
27
  },
27
28
  "scripts": {
28
- "test": "node test/smoke.js && node test/usageStore.js && node test/providerStore.js && node test/health.js && node test/listModels.js && node test/benchmark.js && node test/views.js"
29
+ "test": "node test/smoke.js && node test/usageStore.js && node test/providerStore.js && node test/health.js && node test/listModels.js && node test/benchmark.js && node test/packaging.js && node test/views.js"
29
30
  }
30
31
  }
package/providerStore.js CHANGED
@@ -34,10 +34,18 @@
34
34
  /** Columns the caller may set. `id` is separate — it is the key and is never updated in place. */
35
35
  const FIELDS = [
36
36
  'label', 'kind', 'base_url', 'model', 'embedding_model',
37
- 'context_tokens', 'max_tokens', 'timeout_ms', 'daily_token_cap', 'enabled', 'sort_order'
37
+ 'context_tokens', 'max_tokens', 'timeout_ms', 'daily_token_cap', 'enabled', 'sort_order',
38
+ // WHAT THIS ENDPOINT IS FOR, expressed as a floor. A registry describes tiers — a triage endpoint
39
+ // has to be fast and a deep-dive one does not — and without a number recorded per endpoint, every
40
+ // speed test means recalling what the figure used to be. 0 means "no expectation".
41
+ //
42
+ // An app that has not added the column is unaffected: clean() skips anything undefined, so the
43
+ // field is only ever written by a caller that knows about it.
44
+ 'min_tokens_per_sec'
38
45
  ];
39
46
 
40
- const NUMERIC = new Set(['context_tokens', 'max_tokens', 'timeout_ms', 'daily_token_cap', 'enabled', 'sort_order']);
47
+ const NUMERIC = new Set(['context_tokens', 'max_tokens', 'timeout_ms', 'daily_token_cap', 'enabled',
48
+ 'sort_order', 'min_tokens_per_sec']);
41
49
 
42
50
  /** An id an operator typed, constrained so it can appear in a URL and a log line unescaped. */
43
51
  function assertId(id) {
@@ -163,7 +171,10 @@ function createProviderStore(opts = {}) {
163
171
  timeoutMs: Number(row.timeout_ms) || 60000,
164
172
  maxTokens: Number(row.max_tokens) || 1024,
165
173
  contextTokens: Number(row.context_tokens) || 8192,
166
- dailyTokenCap: Number(row.daily_token_cap) || 0
174
+ dailyTokenCap: Number(row.daily_token_cap) || 0,
175
+ // Carried on the resolved config so a speed test can judge a result without a second read.
176
+ // 0 means no expectation was recorded, which is different from "expected to be slow".
177
+ minTokensPerSec: Number(row.min_tokens_per_sec) || 0
167
178
  };
168
179
  }
169
180
  };
@@ -193,6 +204,10 @@ function schemaFor(dialect) {
193
204
  daily_token_cap INTEGER NOT NULL DEFAULT 0,
194
205
  enabled INTEGER NOT NULL DEFAULT 1,
195
206
  sort_order INTEGER NOT NULL DEFAULT 0,
207
+ -- The floor this endpoint is expected to clear, in generated tokens per second, so a speed test
208
+ -- can report a verdict rather than a number to be remembered. 0 means no expectation was set,
209
+ -- which is not the same as expecting it to be slow.
210
+ min_tokens_per_sec INTEGER NOT NULL DEFAULT 0,
196
211
  created_at TEXT NOT NULL DEFAULT (${t.now()})
197
212
  `;
198
213
  }