@aria-framework/ai 0.25.0 → 0.26.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/README.md CHANGED
@@ -32,7 +32,11 @@ const ai = createAiClient({
32
32
  logger: console // optional
33
33
  });
34
34
 
35
- const r = await ai.complete({ system, messages, maxTokens: 400, schema /* optional */ });
35
+ const r = await ai.complete({
36
+ system, messages, maxTokens: 400,
37
+ schema, // optional: constrained JSON
38
+ // reasoning: 'full', // optional, lmx only: let the model think fully (see Reasoning below)
39
+ });
36
40
  // r.text, r.json (when schema), r.model, r.usage.total, r.ms
37
41
  ```
38
42
 
@@ -40,6 +44,12 @@ Every failure is thrown as an `AiError` with a `kind`: `disabled`, `unconfigured
40
44
  `timeout`, `auth`, `rate_limit`, `bad_response` or `refused`. The `message` is written for
41
45
  an admin screen and never includes a URL or key.
42
46
 
47
+ **A failed call that spent tokens is still metered** (0.26.1). When the model answered but the answer
48
+ was unusable (it thought until the limit, or a structured reply was cut off), the error carries
49
+ `usage` (`{ prompt, completion, total }`) and `budget.record(cfg, { usage, model, ms, failed: true }, meta)`
50
+ is called before the error is thrown. A failure that spent nothing (refused, unreachable, timeout)
51
+ records nothing. If `record` throws, the original error still reaches the caller.
52
+
43
53
  `providerStore`, `usageStore`, `speedStore` and `lmxStore` are optional db-worker-backed
44
54
  stores. Each exports a `schemaFor(dialect)`, so the app's migration can be checked against it.
45
55
 
@@ -59,7 +69,7 @@ to write yourself:
59
69
  | Engine URLs come from the document | Resolved on every call, and never stored |
60
70
  | A status outage is not an inference outage | Keeps routing on the last good document for 3 minutes (`staleMs`), logging loudly |
61
71
  | Pin the certificate; never disable verification | Uses an undici `Agent({ connect: { ca } })` per stack. Never `NODE_EXTRA_CA_CERTS`, never `rejectUnauthorized:false` |
62
- | Choose the reasoning flag from the model | `qwen` → `chat_template_kwargs.enable_thinking=false`, `gpt-oss` → `reasoning_effort:'low'`, anything else gets neither (with a warning) |
72
+ | Choose the reasoning flag from the model | By default thinking is kept to a minimum: `qwen` → `chat_template_kwargs.enable_thinking=false`, `gpt-oss` → `reasoning_effort:'low'`, anything else gets neither (with a warning). `reasoning: 'full'` on the call (since 0.26.0) sends `enable_thinking=true` / `reasoning_effort:'high'` instead. The flag always overrides a raw `extra`; only the named option changes it. See [Reasoning](#reasoning-reasoning-full) |
63
73
  | Size requests against `maxInputTokens` (per slot) | `contextTokens` is taken from the engine, not from config |
64
74
  | Identity is `(instance, id)`, falling back to `name` | `lmx.engineId` is matched first; `rekeyPlan()` migrates rows when ids are minted or engines renamed |
65
75
  | A `429` is about the key, not the engine | Fails fast (since 0.25.0): throws `rate_limit` with `lmxSkip: 'lmx_throttled'` and `retryAfterMs`. The client never waits; whether to wait or move on is the caller's decision |
@@ -166,6 +176,54 @@ instead:
166
176
  Failover across several engines for one job is the app's job; lmx provides none. List
167
177
  endpoints in preference order and take the first one that serves.
168
178
 
179
+ ### Reasoning (`reasoning: 'full'`)
180
+
181
+ By default the adapter makes the model think as little as its family allows, so a quick answer
182
+ stays quick. Pass `reasoning: 'full'` on a `complete()` call to let the model think fully:
183
+
184
+ ```js
185
+ const r = await ai.complete({
186
+ system, messages, schema,
187
+ reasoning: 'full',
188
+ maxTokens: 4096, // at least 4096; see the trap below
189
+ }); // and give the endpoint timeoutMs: 120000 or more
190
+ ```
191
+
192
+ **When to use it.** Use it for judgement tasks where the answer depends on weighing evidence,
193
+ such as alert triage. Do not use it for quick summaries, rewrites or extraction. They do not get
194
+ better, only slower.
195
+
196
+ **What it costs.** It is much slower, roughly 30–45 s per answer on lab hardware instead of a few
197
+ seconds, and it spends many more tokens, because the thinking is generated and billed like any
198
+ other output. Budget for both.
199
+
200
+ **The trap.** Thinking comes out of `maxTokens`. If the limit is too small, the model spends all
201
+ of it thinking and returns **an empty answer, with no error from the server** (`finish_reason:
202
+ "length"`). The adapter turns that, and a reply cut off inside an unclosed `<think>` block, into
203
+ a `bad_response` AiError saying it ran out of room (and, when the server says so, that the
204
+ tokens went on internal reasoning), but the work is lost either way. Use **`maxTokens` ≥ 4096** and a
205
+ **`timeoutMs` ≥ 120 s** (120000) on the endpoint, or the deadline cuts the answer off first.
206
+
207
+ **What is sent, per model family** (read from the model the engine is running on each call):
208
+
209
+ | Family | Default (no `reasoning`) | `reasoning: 'full'` |
210
+ |---|---|---|
211
+ | qwen | `{"chat_template_kwargs":{"enable_thinking":false}}` | `{"chat_template_kwargs":{"enable_thinking":true}}` |
212
+ | gpt-oss | `{"reasoning_effort":"low"}` | `{"reasoning_effort":"high"}` |
213
+ | anything else | nothing (warning logged) | nothing (warning logged that `'full'` could not be honoured) |
214
+
215
+ Rules:
216
+
217
+ - `'full'` is the only value. Leaving the option out (or `null`) means the default. Any other value,
218
+ for example `'high'`, `true` or `''`, is refused with `AiError` kind `refused` before any
219
+ request is made, whichever provider the endpoint uses.
220
+ - The flag is merged **after** `extra`, so a raw `extra: { chat_template_kwargs: … }` or
221
+ `extra: { reasoning_effort: … }` cannot change it. Only `reasoning` can.
222
+ - Only `lmx` honours it. On `openai-compatible`, `lmstudio` or `anthropic` the client logs a
223
+ warning, removes the option, and nothing reaches that provider's request body.
224
+ - `REASONING_MODES` (package root) lists the accepted values for an app that validates its own
225
+ settings.
226
+
169
227
  ### Embeddings
170
228
 
171
229
  Call `client.embed(cfg, texts, { signal })` and pass your own resolved embedding config; it never
@@ -1,138 +1,138 @@
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
- // ── EDITING A STACK HAPPENS IN THE PANEL ────────────────────────────────────────────────────
86
- //
87
- // It used to happen in a form at the foot of the page, and Edit scrolled you down to it — away
88
- // from the stack you were reading, into a form that also served as "Add a supervisor" and said
89
- // nothing about which stack it had been filled with. Nothing needs prefilling now: every panel
90
- // renders its own form, from the server, with its own values already in it.
91
- var open = ev.target.closest('[data-stack-edit-open]');
92
- if (open) { stackMode(open.closest('[data-stack]'), true); return; }
93
-
94
- var cancel = ev.target.closest('[data-stack-cancel]');
95
- if (cancel) {
96
- var panel2 = cancel.closest('[data-stack]');
97
- // A NEW stack has no reading half to go back to, so cancelling it puts the blank panel away.
98
- if (panel2 && panel2.hasAttribute('data-stack-new')) { panel2.hidden = true; return; }
99
- // Otherwise reload rather than restore: a Cancel that left half-typed values behind, ready to
100
- // be posted by the next Save, would be worse than the extra request.
101
- window.location.reload();
102
- return;
103
- }
104
-
105
- // "Add LMX" reveals the blank panel that is already on the page, at the top of the list.
106
- var add = ev.target.closest('[data-stack-add]');
107
- if (add) {
108
- var blank = document.querySelector('[data-stack-new]');
109
- if (!blank) return;
110
- blank.hidden = false;
111
- blank.scrollIntoView({ block: 'nearest' });
112
- var id = blank.querySelector('[name="id"]');
113
- if (id) id.focus();
114
- }
115
- });
116
-
117
- /** Swap one stack panel between reading and editing. */
118
- function stackMode(panel, editing) {
119
- if (!panel) return;
120
- var view = panel.querySelector('[data-stack-view]');
121
- var form = panel.querySelector('[data-stack-edit]');
122
- if (!view || !form) return;
123
- view.hidden = editing;
124
- form.hidden = !editing;
125
- if (editing) {
126
- // The id is readonly on an existing stack, so focus the first field somebody can actually
127
- // change rather than one that will not accept a keystroke.
128
- var first = form.querySelector('[name="label"]') || form.querySelector('[name="status_url"]');
129
- if (first) first.focus();
130
- }
131
- }
132
-
133
- if (document.readyState === 'loading') {
134
- document.addEventListener('DOMContentLoaded', restore);
135
- } else {
136
- restore();
137
- }
138
- })();
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
+ // ── EDITING A STACK HAPPENS IN THE PANEL ────────────────────────────────────────────────────
86
+ //
87
+ // It used to happen in a form at the foot of the page, and Edit scrolled you down to it — away
88
+ // from the stack you were reading, into a form that also served as "Add a supervisor" and said
89
+ // nothing about which stack it had been filled with. Nothing needs prefilling now: every panel
90
+ // renders its own form, from the server, with its own values already in it.
91
+ var open = ev.target.closest('[data-stack-edit-open]');
92
+ if (open) { stackMode(open.closest('[data-stack]'), true); return; }
93
+
94
+ var cancel = ev.target.closest('[data-stack-cancel]');
95
+ if (cancel) {
96
+ var panel2 = cancel.closest('[data-stack]');
97
+ // A NEW stack has no reading half to go back to, so cancelling it puts the blank panel away.
98
+ if (panel2 && panel2.hasAttribute('data-stack-new')) { panel2.hidden = true; return; }
99
+ // Otherwise reload rather than restore: a Cancel that left half-typed values behind, ready to
100
+ // be posted by the next Save, would be worse than the extra request.
101
+ window.location.reload();
102
+ return;
103
+ }
104
+
105
+ // "Add LMX" reveals the blank panel that is already on the page, at the top of the list.
106
+ var add = ev.target.closest('[data-stack-add]');
107
+ if (add) {
108
+ var blank = document.querySelector('[data-stack-new]');
109
+ if (!blank) return;
110
+ blank.hidden = false;
111
+ blank.scrollIntoView({ block: 'nearest' });
112
+ var id = blank.querySelector('[name="id"]');
113
+ if (id) id.focus();
114
+ }
115
+ });
116
+
117
+ /** Swap one stack panel between reading and editing. */
118
+ function stackMode(panel, editing) {
119
+ if (!panel) return;
120
+ var view = panel.querySelector('[data-stack-view]');
121
+ var form = panel.querySelector('[data-stack-edit]');
122
+ if (!view || !form) return;
123
+ view.hidden = editing;
124
+ form.hidden = !editing;
125
+ if (editing) {
126
+ // The id is readonly on an existing stack, so focus the first field somebody can actually
127
+ // change rather than one that will not accept a keystroke.
128
+ var first = form.querySelector('[name="label"]') || form.querySelector('[name="status_url"]');
129
+ if (first) first.focus();
130
+ }
131
+ }
132
+
133
+ if (document.readyState === 'loading') {
134
+ document.addEventListener('DOMContentLoaded', restore);
135
+ } else {
136
+ restore();
137
+ }
138
+ })();