@aria-framework/ai 0.26.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aria-framework/ai",
3
3
  "description": "Aria App Framework \u2014 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.26.0",
4
+ "version": "0.26.1",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
7
7
  "publishConfig": {
@@ -47,7 +47,7 @@
47
47
  }
48
48
  },
49
49
  "scripts": {
50
- "test": "node test/smoke.js && node test/usageStore.js && node test/providerStore.js && node test/speedStore.js && node test/health.js && node test/listModels.js && node test/benchmark.js && node test/lmxDiscovery.js && node test/lmx.js && node test/lmxVerify.js && node test/lmxStore.js && node test/lmxStatus.js && node test/jobCard.js && node test/untrusted.js && node test/packaging.js && node test/views.js && node test/fenced.js && node test/polish.js && node test/reasoning.js",
50
+ "test": "node test/smoke.js && node test/usageStore.js && node test/providerStore.js && node test/speedStore.js && node test/health.js && node test/listModels.js && node test/benchmark.js && node test/lmxDiscovery.js && node test/lmx.js && node test/lmxVerify.js && node test/lmxStore.js && node test/lmxStatus.js && node test/jobCard.js && node test/untrusted.js && node test/packaging.js && node test/views.js && node test/fenced.js && node test/polish.js && node test/reasoning.js && node test/usageOnFailure.js",
51
51
  "prepublishOnly": "node ../../test/packaging.js ai"
52
52
  },
53
53
  "devDependencies": {
@@ -97,6 +97,13 @@ async function complete(cfg, opts) {
97
97
  clearTimeout(timer);
98
98
  }
99
99
 
100
+ // A 200 WHOSE BODY IS `null` (0.26.1) is a typed failure, not a TypeError further down.
101
+ if (!payload || typeof payload !== 'object') {
102
+ throw new AiError('bad_response', `${label} answered with an empty body.`);
103
+ }
104
+ // Spent tokens ride on every later error so the client can meter a failed call (0.26.1).
105
+ const usage = normaliseUsage(payload.usage);
106
+
100
107
  // Difference 4: blocks, not a single string. Concatenated so a model that splits its answer over
101
108
  // two text blocks does not silently lose the second one.
102
109
  // Thinking arrives as its own block TYPE here rather than a field, so filtering to 'text' already
@@ -109,7 +116,7 @@ async function complete(cfg, opts) {
109
116
  const thought = (payload.content || []).some((b) => b && b.type === 'thinking');
110
117
  throw new AiError('bad_response', thought
111
118
  ? `${label} returned only its internal reasoning. Raise the reply limit and try again.`
112
- : `${label} returned no text (stop reason: ${payload.stop_reason || 'none given'}).`);
119
+ : `${label} returned no text (stop reason: ${payload.stop_reason || 'none given'}).`, { usage });
113
120
  }
114
121
 
115
122
  let json = null;
@@ -121,10 +128,10 @@ async function complete(cfg, opts) {
121
128
  if (payload.stop_reason === 'max_tokens') {
122
129
  throw new AiError('bad_response',
123
130
  `${label} ran out of room mid-answer — the structured reply was cut off before it was ` +
124
- 'finished. A Regenerate usually succeeds.', { cause: err });
131
+ 'finished. A Regenerate usually succeeds.', { cause: err, usage });
125
132
  }
126
133
  throw new AiError('bad_response',
127
- `${label} was asked for structured output and returned text that will not parse.`, { cause: err });
134
+ `${label} was asked for structured output and returned text that will not parse.`, { cause: err, usage });
128
135
  }
129
136
  }
130
137
 
@@ -132,7 +139,7 @@ async function complete(cfg, opts) {
132
139
  text,
133
140
  json,
134
141
  model: payload.model || cfg.model,
135
- usage: normaliseUsage(payload.usage),
142
+ usage,
136
143
  ms: Date.now() - started,
137
144
  finishReason: payload.stop_reason || null,
138
145
  truncated: payload.stop_reason === 'max_tokens',
@@ -133,7 +133,13 @@ async function complete(cfg, opts) {
133
133
  // completion" — which sent this investigation after a thinking-model theory that was not the
134
134
  // cause. Verified against the real server: the same request on /v1/chat/completions answers
135
135
  // normally.
136
- if (payload && payload.error && !payload.choices) {
136
+ // A 200 WHOSE BODY IS `null` (0.26.1) — valid JSON, no object. It used to reach `payload.usage`
137
+ // below as a TypeError, which a router reads as an unknown fault.
138
+ if (!payload || typeof payload !== 'object') {
139
+ throw new AiError('bad_response', `${label} answered with an empty body.`);
140
+ }
141
+
142
+ if (payload.error && !payload.choices) {
137
143
  const said = typeof payload.error === 'string'
138
144
  ? payload.error
139
145
  : (payload.error.message || JSON.stringify(payload.error));
@@ -170,6 +176,10 @@ async function complete(cfg, opts) {
170
176
  throw emptyCompletion({ label, finishReason, reasoning, maxTokens: body.max_tokens, usage: payload.usage });
171
177
  }
172
178
 
179
+ // TOKENS WERE SPENT EVEN WHEN THE ANSWER IS UNUSABLE (0.26.1). Carried on every error raised after
180
+ // this point, normalised, so the client can meter a failed call — see index.js complete().
181
+ const usage = normaliseUsage(payload.usage);
182
+
173
183
  // With a schema the content is still a STRING containing JSON — the server constrains the shape,
174
184
  // it does not parse for you. Parsing here rather than at each call site means a malformed body is
175
185
  // one error type instead of a surprise in a page render.
@@ -188,10 +198,10 @@ async function complete(cfg, opts) {
188
198
  throw new AiError('bad_response',
189
199
  `${label} ran out of room mid-answer (${body.max_tokens} tokens) — the structured reply ` +
190
200
  'was cut off before it was finished. If this repeats, the schema may be letting the ' +
191
- 'model ramble; a Regenerate usually succeeds.', { cause: err });
201
+ 'model ramble; a Regenerate usually succeeds.', { cause: err, usage });
192
202
  }
193
203
  throw new AiError('bad_response',
194
- `${label} was asked for structured output and returned text that will not parse.`, { cause: err });
204
+ `${label} was asked for structured output and returned text that will not parse.`, { cause: err, usage });
195
205
  }
196
206
  }
197
207
 
@@ -199,7 +209,7 @@ async function complete(cfg, opts) {
199
209
  text,
200
210
  json,
201
211
  model: payload.model || cfg.model,
202
- usage: normaliseUsage(payload.usage),
212
+ usage,
203
213
  ms: Date.now() - started,
204
214
  finishReason,
205
215
  // A caller that cares (the summary's coverage line) can tell a complete answer from a cut-off
@@ -236,8 +246,9 @@ function unclosedThinking(text) {
236
246
  * payload, so they are distinguished: the budget ran out, the budget went on thinking, or the server
237
247
  * genuinely said nothing.
238
248
  */
239
- function emptyCompletion({ label, finishReason, reasoning, maxTokens, usage }) {
240
- const spent = (usage && usage.completion_tokens) || 0;
249
+ function emptyCompletion({ label, finishReason, reasoning, maxTokens, usage: raw }) {
250
+ const spent = (raw && raw.completion_tokens) || 0;
251
+ const usage = normaliseUsage(raw);
241
252
  if (finishReason === 'length' || (reasoning && !spentLeftRoom(spent, maxTokens))) {
242
253
  return new AiError('bad_response',
243
254
  `${label} ran out of room before it answered — it used all ${maxTokens} reply tokens` +
@@ -253,7 +264,7 @@ function emptyCompletion({ label, finishReason, reasoning, maxTokens, usage }) {
253
264
  }
254
265
  return new AiError('bad_response',
255
266
  `${label} returned an empty completion (finish reason: ${finishReason || 'none given'}). ` +
256
- 'Check the model is fully loaded on the server.');
267
+ 'Check the model is fully loaded on the server.', { usage });
257
268
  }
258
269
 
259
270
  /** Did the completion stop well short of the ceiling? Then the ceiling was not the problem. */
@@ -1,182 +1,182 @@
1
- <%# One job, and the path of endpoints that serves it.
2
- ────────────────────────────────────────────────────────────────────────────────────────────
3
- TWO MODES, NOT A FOLD. A stack panel folds because its detail is worth reading occasionally; a
4
- job's detail is a thing you EDIT, and the editing controls are useless the other 99% of the
5
- time. So the card reads as a sentence — this job, this path, this is what it is for — until
6
- somebody says they want to change it.
7
-
8
- THE PATH IS THE HEADLINE, because it is the only part that varies and the only part anybody
9
- comes here to check. `a → b → c` is the whole configuration of a job in one line: the first
10
- that answers serves, the rest are its fallbacks in order.
11
-
12
- ORDER IS CARRIED IN TYPED POSITIONS, never in the order the browser serialises fields. A form
13
- posts in document order, so a list that relied on that would be silently un-reorderable — and
14
- the handler this posts to already reads `position_<id>` for exactly that reason. The list is
15
- renumbered from its own DOM order on submit, so what you see is what is sent.
16
-
17
- ASSIGNED IS NOT THE SAME AS RESOLVED. A job with no assignment is not unconfigured, it is
18
- following the default — every enabled endpoint in listed order — and saying so stops somebody
19
- "fixing" a job that is already doing the right thing. A job whose assigned endpoints are all
20
- disabled resolves to nothing at all, which is a different and much worse state.
21
-
22
- Locals:
23
- job { id, label, character, detail }
24
- assigned [endpointId] explicitly chosen for this job, in order (may be empty)
25
- resolved [endpointId] what would actually serve right now, in order
26
- endpoints [{ id, label, enabled }] every endpoint that exists
27
- spend { calls, total_tokens } | null
28
- spendDays how many days `spend` covers, for the label beside it (default 14)
29
- canEdit render the edit affordance at all
30
- csrfToken () => token
31
- basePath where this app mounts its AI admin, e.g. '/admin/ai'
32
- %>
33
- <%
34
- var _edit = (typeof canEdit !== 'undefined') && canEdit;
35
- var _base = (typeof basePath !== 'undefined' && basePath) ? basePath : '/admin/ai';
36
- var _csrf = (typeof csrfToken === 'function') ? csrfToken : function () { return ''; };
37
- var _assigned = assigned || [];
38
- var _resolved = resolved || [];
39
- var _eps = endpoints || [];
40
- var _spend = (typeof spend !== 'undefined') ? spend : null;
41
- // THE WINDOW THAT PRODUCED `spend`. Defaulted to what the label used to say as a literal, so a
42
- // consumer that passes nothing renders exactly as it did before.
43
- var _spendDays = (typeof spendDays !== 'undefined' && Number(spendDays) > 0) ? Number(spendDays) : 14;
44
- var _isDefault = !_assigned.length;
45
- var _byId = {};
46
- _eps.forEach(function (e) { _byId[e.id] = e; });
47
- // The path being EDITED is the assignment, not the resolution: editing what is currently serving
48
- // would silently drop a disabled endpoint from the assignment the moment anybody pressed Save.
49
- var _path = _assigned.slice();
50
- var _rest = _eps.filter(function (e) { return _path.indexOf(e.id) === -1; });
51
- %>
52
- <div class="border rounded mb-2" data-job="<%= job.id %>">
53
-
54
- <%# ── READING MODE ────────────────────────────────────────────────────────────────────────── %>
55
- <div class="p-3" data-job-view>
56
- <%# THE ACTIONS STAY ON THE RIGHT, whatever the description does.
57
- `flex-wrap` plus a growing child with no `min-width: 0` is the classic way to lose a
58
- button: a flex item will not shrink below its content's intrinsic width, so a long
59
- description keeps the text column at full width, the actions wrap onto a line of their own,
60
- and `justify-content-between` then puts that lone item at the START — bottom LEFT, which is
61
- precisely where the Edit button went.
62
- `min-width: 0` lets the text column shrink so its own text wraps instead; `flex-shrink-0`
63
- keeps the buttons their natural size; and with no wrapping there is no second line to fall
64
- onto. %>
65
- <div class="d-flex justify-content-between align-items-start gap-3">
66
- <div class="flex-grow-1" style="min-width:0">
67
- <div class="d-flex align-items-center gap-2 flex-wrap">
68
- <strong><%= job.label %></strong>
69
- <span class="badge border bg-body-tertiary text-body-secondary text-uppercase fw-semibold"><%= job.character %></span>
70
- <% if (_isDefault) { %>
71
- <span class="badge rounded-pill border bg-body-secondary text-body-secondary fw-semibold">default</span>
72
- <% } %>
73
- </div>
74
- <%# THE PATH, directly under the name — the one line that says how this job is configured. %>
75
- <div class="font-monospace small mt-1" style="word-break:break-all">
76
- <% if (_resolved.length) { %>
77
- <% _resolved.forEach(function (id, i) { %><% if (i) { %><span class="text-body-tertiary"> → </span><% } %><span class="<%= i ? 'text-body-secondary' : '' %>"><%= id %></span><% }) %>
78
- <% } else { %>
79
- <span class="text-danger">nothing available — every endpoint for this job is disabled or missing</span>
80
- <% } %>
81
- </div>
82
- <div class="form-text mt-1 mb-0"><%= job.detail %></div>
83
- </div>
84
-
85
- <div class="text-end d-flex flex-column align-items-end gap-2 flex-shrink-0">
86
- <%# WHAT THIS JOB COSTS, which is the number worth acting on: a job can be pointed at a
87
- cheaper machine, run less often, or switched off. An endpoint total only says where the
88
- money went, never what asked for it.
89
-
90
- THE WINDOW IS PRINTED FROM THE SAME PLACE THE FIGURE COMES FROM. It used to be the
91
- literal "14 days" here, while the figure beside it was aggregated by the consuming app
92
- over a window only that app knew — two halves of one sentence in two repositories. A
93
- consumer changing its window relabelled nothing, and every card then stated a period
94
- its own number had never covered. Support101 hit exactly that on the other side of the
95
- same screen, where a total labelled "14 days" came from an unwindowed query and ran
96
- 139,792 tokens adrift of the tab beside it. %>
97
- <% if (_spend && _spend.total_tokens) { %>
98
- <div class="small text-body-secondary" style="font-variant-numeric: tabular-nums">
99
- <%= Number(_spend.total_tokens).toLocaleString() %> tokens in <%= _spend.calls %> call<%= _spend.calls === 1 ? '' : 's' %>
100
- <span class="text-body-tertiary">· <%= _spendDays %> days</span>
101
- </div>
102
- <% } %>
103
- <% if (_edit && _eps.length) { %>
104
- <button type="button" class="btn btn-sm btn-outline-secondary" data-job-edit-open="<%= job.id %>">Edit</button>
105
- <% } %>
106
- </div>
107
- </div>
108
- </div>
109
-
110
- <%# ── EDITING MODE ───────────────────────────────────────────────────────────────────────────
111
- Rendered by the server and hidden, rather than built by script when Edit is pressed: the
112
- whole list is here in the markup, so it works from a keyboard, survives a script that fails
113
- to load badly enough to matter, and posts the same fields the handler has always read. %>
114
- <% if (_edit && _eps.length) { %>
115
- <form method="POST" action="<%= _base %>/routes/<%= encodeURIComponent(job.id) %>"
116
- class="p-3 border-top" data-job-edit hidden>
117
- <input type="hidden" name="_csrf" value="<%= _csrf() %>">
118
-
119
- <div class="d-flex justify-content-between align-items-baseline mb-2">
120
- <span class="small text-uppercase fw-semibold text-body-secondary" style="letter-spacing:.08em">
121
- <%= job.label %> — the path, in order
122
- </span>
123
- <span class="small text-body-tertiary">first to answer serves</span>
124
- </div>
125
-
126
- <ol class="list-unstyled mb-2" data-job-list>
127
- <% _path.forEach(function (id, i) { %>
128
- <% var ep = _byId[id]; %>
129
- <li class="d-flex align-items-center gap-2 border rounded px-2 py-2 mb-1 bg-body-tertiary"
130
- draggable="true" data-job-item="<%= id %>">
131
- <span class="text-body-tertiary" aria-hidden="true" style="cursor:grab">⠿</span>
132
- <span class="badge rounded-pill border bg-body-secondary text-body-secondary font-monospace fw-normal"
133
- data-job-rank><%= i + 1 %></span>
134
- <span class="flex-grow-1 font-monospace small">
135
- <%= id %>
136
- <% if (ep && ep.label && ep.label !== id) { %><span class="text-body-tertiary"> · <%= ep.label %></span><% } %>
137
- <%# AN ENDPOINT IN THE PATH BUT DISABLED IS KEPT, and said out loud. Dropping it on
138
- save would quietly rewrite somebody's configuration because a machine happened to
139
- be switched off this afternoon. %>
140
- <% if (ep && !ep.enabled) { %>
141
- <span class="badge rounded-pill border bg-warning-subtle text-warning-emphasis border-warning-subtle ms-1">disabled — skipped while off</span>
142
- <% } %>
143
- <% if (!ep) { %>
144
- <span class="badge rounded-pill border bg-danger-subtle text-danger-emphasis border-danger-subtle ms-1">no such endpoint</span>
145
- <% } %>
146
- </span>
147
- <span class="btn-group btn-group-sm" role="group">
148
- <button type="button" class="btn btn-outline-secondary" data-job-up
149
- aria-label="Move <%= id %> earlier">↑</button>
150
- <button type="button" class="btn btn-outline-secondary" data-job-down
151
- aria-label="Move <%= id %> later">↓</button>
152
- <button type="button" class="btn btn-outline-secondary" data-job-remove
153
- aria-label="Remove <%= id %> from this job">✕</button>
154
- </span>
155
- </li>
156
- <% }) %>
157
- </ol>
158
-
159
- <p class="form-text mt-0" data-job-empty<% if (_path.length) { %> hidden<% } %>>
160
- Nothing chosen, so this job uses <strong>every enabled endpoint</strong> in the order they
161
- are listed above. That is a working configuration, not a missing one.
162
- </p>
163
-
164
- <% if (_rest.length) { %>
165
- <div class="small text-uppercase fw-semibold text-body-secondary mt-3 mb-2" style="letter-spacing:.08em">Not in this job</div>
166
- <div class="d-flex flex-wrap gap-2" data-job-pool>
167
- <% _rest.forEach(function (e) { %>
168
- <button type="button" class="btn btn-sm btn-outline-secondary font-monospace"
169
- data-job-add="<%= e.id %>"<% if (!e.enabled) { %> disabled title="This endpoint is switched off"<% } %>>
170
- + <%= e.id %><% if (!e.enabled) { %> (disabled)<% } %>
171
- </button>
172
- <% }) %>
173
- </div>
174
- <% } %>
175
-
176
- <div class="d-flex gap-2 mt-3">
177
- <button type="submit" class="btn btn-sm btn-primary">Save</button>
178
- <button type="button" class="btn btn-sm btn-outline-secondary" data-job-cancel>Cancel</button>
179
- </div>
180
- </form>
181
- <% } %>
182
- </div>
1
+ <%# One job, and the path of endpoints that serves it.
2
+ ────────────────────────────────────────────────────────────────────────────────────────────
3
+ TWO MODES, NOT A FOLD. A stack panel folds because its detail is worth reading occasionally; a
4
+ job's detail is a thing you EDIT, and the editing controls are useless the other 99% of the
5
+ time. So the card reads as a sentence — this job, this path, this is what it is for — until
6
+ somebody says they want to change it.
7
+
8
+ THE PATH IS THE HEADLINE, because it is the only part that varies and the only part anybody
9
+ comes here to check. `a → b → c` is the whole configuration of a job in one line: the first
10
+ that answers serves, the rest are its fallbacks in order.
11
+
12
+ ORDER IS CARRIED IN TYPED POSITIONS, never in the order the browser serialises fields. A form
13
+ posts in document order, so a list that relied on that would be silently un-reorderable — and
14
+ the handler this posts to already reads `position_<id>` for exactly that reason. The list is
15
+ renumbered from its own DOM order on submit, so what you see is what is sent.
16
+
17
+ ASSIGNED IS NOT THE SAME AS RESOLVED. A job with no assignment is not unconfigured, it is
18
+ following the default — every enabled endpoint in listed order — and saying so stops somebody
19
+ "fixing" a job that is already doing the right thing. A job whose assigned endpoints are all
20
+ disabled resolves to nothing at all, which is a different and much worse state.
21
+
22
+ Locals:
23
+ job { id, label, character, detail }
24
+ assigned [endpointId] explicitly chosen for this job, in order (may be empty)
25
+ resolved [endpointId] what would actually serve right now, in order
26
+ endpoints [{ id, label, enabled }] every endpoint that exists
27
+ spend { calls, total_tokens } | null
28
+ spendDays how many days `spend` covers, for the label beside it (default 14)
29
+ canEdit render the edit affordance at all
30
+ csrfToken () => token
31
+ basePath where this app mounts its AI admin, e.g. '/admin/ai'
32
+ %>
33
+ <%
34
+ var _edit = (typeof canEdit !== 'undefined') && canEdit;
35
+ var _base = (typeof basePath !== 'undefined' && basePath) ? basePath : '/admin/ai';
36
+ var _csrf = (typeof csrfToken === 'function') ? csrfToken : function () { return ''; };
37
+ var _assigned = assigned || [];
38
+ var _resolved = resolved || [];
39
+ var _eps = endpoints || [];
40
+ var _spend = (typeof spend !== 'undefined') ? spend : null;
41
+ // THE WINDOW THAT PRODUCED `spend`. Defaulted to what the label used to say as a literal, so a
42
+ // consumer that passes nothing renders exactly as it did before.
43
+ var _spendDays = (typeof spendDays !== 'undefined' && Number(spendDays) > 0) ? Number(spendDays) : 14;
44
+ var _isDefault = !_assigned.length;
45
+ var _byId = {};
46
+ _eps.forEach(function (e) { _byId[e.id] = e; });
47
+ // The path being EDITED is the assignment, not the resolution: editing what is currently serving
48
+ // would silently drop a disabled endpoint from the assignment the moment anybody pressed Save.
49
+ var _path = _assigned.slice();
50
+ var _rest = _eps.filter(function (e) { return _path.indexOf(e.id) === -1; });
51
+ %>
52
+ <div class="border rounded mb-2" data-job="<%= job.id %>">
53
+
54
+ <%# ── READING MODE ────────────────────────────────────────────────────────────────────────── %>
55
+ <div class="p-3" data-job-view>
56
+ <%# THE ACTIONS STAY ON THE RIGHT, whatever the description does.
57
+ `flex-wrap` plus a growing child with no `min-width: 0` is the classic way to lose a
58
+ button: a flex item will not shrink below its content's intrinsic width, so a long
59
+ description keeps the text column at full width, the actions wrap onto a line of their own,
60
+ and `justify-content-between` then puts that lone item at the START — bottom LEFT, which is
61
+ precisely where the Edit button went.
62
+ `min-width: 0` lets the text column shrink so its own text wraps instead; `flex-shrink-0`
63
+ keeps the buttons their natural size; and with no wrapping there is no second line to fall
64
+ onto. %>
65
+ <div class="d-flex justify-content-between align-items-start gap-3">
66
+ <div class="flex-grow-1" style="min-width:0">
67
+ <div class="d-flex align-items-center gap-2 flex-wrap">
68
+ <strong><%= job.label %></strong>
69
+ <span class="badge border bg-body-tertiary text-body-secondary text-uppercase fw-semibold"><%= job.character %></span>
70
+ <% if (_isDefault) { %>
71
+ <span class="badge rounded-pill border bg-body-secondary text-body-secondary fw-semibold">default</span>
72
+ <% } %>
73
+ </div>
74
+ <%# THE PATH, directly under the name — the one line that says how this job is configured. %>
75
+ <div class="font-monospace small mt-1" style="word-break:break-all">
76
+ <% if (_resolved.length) { %>
77
+ <% _resolved.forEach(function (id, i) { %><% if (i) { %><span class="text-body-tertiary"> → </span><% } %><span class="<%= i ? 'text-body-secondary' : '' %>"><%= id %></span><% }) %>
78
+ <% } else { %>
79
+ <span class="text-danger">nothing available — every endpoint for this job is disabled or missing</span>
80
+ <% } %>
81
+ </div>
82
+ <div class="form-text mt-1 mb-0"><%= job.detail %></div>
83
+ </div>
84
+
85
+ <div class="text-end d-flex flex-column align-items-end gap-2 flex-shrink-0">
86
+ <%# WHAT THIS JOB COSTS, which is the number worth acting on: a job can be pointed at a
87
+ cheaper machine, run less often, or switched off. An endpoint total only says where the
88
+ money went, never what asked for it.
89
+
90
+ THE WINDOW IS PRINTED FROM THE SAME PLACE THE FIGURE COMES FROM. It used to be the
91
+ literal "14 days" here, while the figure beside it was aggregated by the consuming app
92
+ over a window only that app knew — two halves of one sentence in two repositories. A
93
+ consumer changing its window relabelled nothing, and every card then stated a period
94
+ its own number had never covered. Support101 hit exactly that on the other side of the
95
+ same screen, where a total labelled "14 days" came from an unwindowed query and ran
96
+ 139,792 tokens adrift of the tab beside it. %>
97
+ <% if (_spend && _spend.total_tokens) { %>
98
+ <div class="small text-body-secondary" style="font-variant-numeric: tabular-nums">
99
+ <%= Number(_spend.total_tokens).toLocaleString() %> tokens in <%= _spend.calls %> call<%= _spend.calls === 1 ? '' : 's' %>
100
+ <span class="text-body-tertiary">· <%= _spendDays %> days</span>
101
+ </div>
102
+ <% } %>
103
+ <% if (_edit && _eps.length) { %>
104
+ <button type="button" class="btn btn-sm btn-outline-secondary" data-job-edit-open="<%= job.id %>">Edit</button>
105
+ <% } %>
106
+ </div>
107
+ </div>
108
+ </div>
109
+
110
+ <%# ── EDITING MODE ───────────────────────────────────────────────────────────────────────────
111
+ Rendered by the server and hidden, rather than built by script when Edit is pressed: the
112
+ whole list is here in the markup, so it works from a keyboard, survives a script that fails
113
+ to load badly enough to matter, and posts the same fields the handler has always read. %>
114
+ <% if (_edit && _eps.length) { %>
115
+ <form method="POST" action="<%= _base %>/routes/<%= encodeURIComponent(job.id) %>"
116
+ class="p-3 border-top" data-job-edit hidden>
117
+ <input type="hidden" name="_csrf" value="<%= _csrf() %>">
118
+
119
+ <div class="d-flex justify-content-between align-items-baseline mb-2">
120
+ <span class="small text-uppercase fw-semibold text-body-secondary" style="letter-spacing:.08em">
121
+ <%= job.label %> — the path, in order
122
+ </span>
123
+ <span class="small text-body-tertiary">first to answer serves</span>
124
+ </div>
125
+
126
+ <ol class="list-unstyled mb-2" data-job-list>
127
+ <% _path.forEach(function (id, i) { %>
128
+ <% var ep = _byId[id]; %>
129
+ <li class="d-flex align-items-center gap-2 border rounded px-2 py-2 mb-1 bg-body-tertiary"
130
+ draggable="true" data-job-item="<%= id %>">
131
+ <span class="text-body-tertiary" aria-hidden="true" style="cursor:grab">⠿</span>
132
+ <span class="badge rounded-pill border bg-body-secondary text-body-secondary font-monospace fw-normal"
133
+ data-job-rank><%= i + 1 %></span>
134
+ <span class="flex-grow-1 font-monospace small">
135
+ <%= id %>
136
+ <% if (ep && ep.label && ep.label !== id) { %><span class="text-body-tertiary"> · <%= ep.label %></span><% } %>
137
+ <%# AN ENDPOINT IN THE PATH BUT DISABLED IS KEPT, and said out loud. Dropping it on
138
+ save would quietly rewrite somebody's configuration because a machine happened to
139
+ be switched off this afternoon. %>
140
+ <% if (ep && !ep.enabled) { %>
141
+ <span class="badge rounded-pill border bg-warning-subtle text-warning-emphasis border-warning-subtle ms-1">disabled — skipped while off</span>
142
+ <% } %>
143
+ <% if (!ep) { %>
144
+ <span class="badge rounded-pill border bg-danger-subtle text-danger-emphasis border-danger-subtle ms-1">no such endpoint</span>
145
+ <% } %>
146
+ </span>
147
+ <span class="btn-group btn-group-sm" role="group">
148
+ <button type="button" class="btn btn-outline-secondary" data-job-up
149
+ aria-label="Move <%= id %> earlier">↑</button>
150
+ <button type="button" class="btn btn-outline-secondary" data-job-down
151
+ aria-label="Move <%= id %> later">↓</button>
152
+ <button type="button" class="btn btn-outline-secondary" data-job-remove
153
+ aria-label="Remove <%= id %> from this job">✕</button>
154
+ </span>
155
+ </li>
156
+ <% }) %>
157
+ </ol>
158
+
159
+ <p class="form-text mt-0" data-job-empty<% if (_path.length) { %> hidden<% } %>>
160
+ Nothing chosen, so this job uses <strong>every enabled endpoint</strong> in the order they
161
+ are listed above. That is a working configuration, not a missing one.
162
+ </p>
163
+
164
+ <% if (_rest.length) { %>
165
+ <div class="small text-uppercase fw-semibold text-body-secondary mt-3 mb-2" style="letter-spacing:.08em">Not in this job</div>
166
+ <div class="d-flex flex-wrap gap-2" data-job-pool>
167
+ <% _rest.forEach(function (e) { %>
168
+ <button type="button" class="btn btn-sm btn-outline-secondary font-monospace"
169
+ data-job-add="<%= e.id %>"<% if (!e.enabled) { %> disabled title="This endpoint is switched off"<% } %>>
170
+ + <%= e.id %><% if (!e.enabled) { %> (disabled)<% } %>
171
+ </button>
172
+ <% }) %>
173
+ </div>
174
+ <% } %>
175
+
176
+ <div class="d-flex gap-2 mt-3">
177
+ <button type="submit" class="btn btn-sm btn-primary">Save</button>
178
+ <button type="button" class="btn btn-sm btn-outline-secondary" data-job-cancel>Cancel</button>
179
+ </div>
180
+ </form>
181
+ <% } %>
182
+ </div>