@cubicecho/agent-core 2.24.0 → 2.25.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.
Files changed (71) hide show
  1. package/README.md +10 -7
  2. package/dist/agent-loop.d.ts +84 -205
  3. package/dist/agent-loop.js +288 -597
  4. package/dist/calibration.d.ts +14 -10
  5. package/dist/calibration.js +30 -23
  6. package/dist/capabilities.d.ts +111 -42
  7. package/dist/capabilities.js +281 -138
  8. package/dist/client.d.ts +104 -35
  9. package/dist/client.js +205 -74
  10. package/dist/compaction.d.ts +103 -60
  11. package/dist/compaction.js +160 -86
  12. package/dist/config.d.ts +48 -12
  13. package/dist/config.js +20 -1
  14. package/dist/continuation.d.ts +24 -16
  15. package/dist/continuation.js +70 -44
  16. package/dist/digest.d.ts +7 -0
  17. package/dist/digest.js +18 -0
  18. package/dist/errors.d.ts +24 -15
  19. package/dist/errors.js +23 -15
  20. package/dist/events.d.ts +100 -63
  21. package/dist/events.js +250 -120
  22. package/dist/guards.d.ts +49 -4
  23. package/dist/guards.js +60 -5
  24. package/dist/hook-events.d.ts +21 -5
  25. package/dist/hook-events.js +23 -9
  26. package/dist/hooks.d.ts +96 -73
  27. package/dist/hooks.js +202 -116
  28. package/dist/index.d.ts +30 -26
  29. package/dist/index.js +29 -24
  30. package/dist/ledger.d.ts +62 -30
  31. package/dist/ledger.js +163 -56
  32. package/dist/platform.d.ts +15 -0
  33. package/dist/platform.js +15 -0
  34. package/dist/preselect.d.ts +223 -0
  35. package/dist/preselect.js +291 -0
  36. package/dist/request-body.d.ts +32 -0
  37. package/dist/request-body.js +47 -0
  38. package/dist/reset.d.ts +1 -0
  39. package/dist/reset.js +7 -6
  40. package/dist/retry.d.ts +44 -138
  41. package/dist/retry.js +83 -281
  42. package/dist/run-calls.d.ts +123 -0
  43. package/dist/run-calls.js +351 -0
  44. package/dist/run-turn.d.ts +36 -15
  45. package/dist/run-turn.js +74 -37
  46. package/dist/runtime.d.ts +23 -17
  47. package/dist/runtime.js +34 -24
  48. package/dist/schema-compat.d.ts +35 -18
  49. package/dist/schema-compat.js +346 -176
  50. package/dist/scope.d.ts +28 -5
  51. package/dist/scope.js +37 -10
  52. package/dist/side-task.d.ts +124 -40
  53. package/dist/side-task.js +186 -66
  54. package/dist/snapshot.d.ts +10 -5
  55. package/dist/snapshot.js +73 -30
  56. package/dist/spec.d.ts +56 -25
  57. package/dist/spec.js +494 -215
  58. package/dist/stream.d.ts +66 -16
  59. package/dist/stream.js +220 -97
  60. package/dist/thinking.d.ts +23 -8
  61. package/dist/thinking.js +92 -44
  62. package/dist/tokens.d.ts +164 -6
  63. package/dist/tokens.js +319 -6
  64. package/dist/tool-calls.d.ts +90 -17
  65. package/dist/tool-calls.js +207 -84
  66. package/dist/tool-loading.d.ts +101 -189
  67. package/dist/tool-loading.js +257 -371
  68. package/dist/wire.d.ts +81 -0
  69. package/dist/wire.js +73 -0
  70. package/llms.txt +76 -39
  71. package/package.json +4 -4
@@ -1,10 +1,12 @@
1
- import { endpointId } from "./client.js";
2
- import { errorMessage } from "./errors.js";
3
- import { isGrammarError } from "./schema-compat.js";
4
- import { scoped } from "./scope.js";
1
+ import { endpointId } from './client.js';
2
+ import { errorMessage } from './errors.js';
3
+ import { getOrCreate } from './guards.js';
4
+ import { isGrammarError } from './schema-compat.js';
5
+ import { scoped } from './scope.js';
5
6
  /**
6
7
  * A model's on/off flags as it starts out, every one optimistic.
7
8
  *
9
+ * @remarks
8
10
  * A record over every boolean on `ModelCapabilities`, so a flag added there does not compile until
9
11
  * it is listed here — and this is the list a snapshot is exported, imported and judged empty by,
10
12
  * where each of those used to name the flags again and one could be missed.
@@ -18,35 +20,21 @@ export const OPTIMISTIC_MODEL = {
18
20
  };
19
21
  /** The names of those flags, for a caller that reads or latches each in turn. */
20
22
  export const MODEL_FLAGS = Object.keys(OPTIMISTIC_MODEL);
21
- /**
22
- * What each endpoint cannot do, remembered for the life of the process.
23
- *
24
- * Keyed by `endpointId` — `endpointKey` hashed, so a snapshot holds no key — because these are
25
- * facts about the server on the other end and not about this one. A llama.cpp box that cannot
26
- * compile a grammar and a cloud API that can are both reachable from one settings row over its
27
- * lifetime — an operator retargets it from Ollama this afternoon to OpenAI this evening — and the
28
- * first one's refusal must not quietly strip pattern/format from the second one's requests, or
29
- * silently cost it its token counts, for the rest of the process. Bounded by the number of
30
- * endpoints ever configured, which is a settings row's worth.
31
- *
32
- * The API key is part of that identity, the same as it is for the client pool and the model
33
- * listings. A router — LiteLLM, OpenRouter, a gateway with several boxes behind it — is free to
34
- * send two keys to two different backends, and then what one of them refused is not a fact about
35
- * the other. Keyed on the URL alone the first caller through the gateway latched for everyone
36
- * behind it, including the `models` map underneath, which is the level where two keys through
37
- * one host are most likely to differ at all.
38
- */
39
23
  const capabilities = scoped(() => new Map());
40
24
  /**
41
- * What this endpoint is known not to support. The same object every time, so what `negotiate`
42
- * latches off stays off.
25
+ * What this endpoint is known not to support.
43
26
  *
44
- * @param baseUrl Where the endpoint is. The two flags on it are per-server; what is per-model
27
+ * @param baseUrl - Where the endpoint is. The two flags on it are per-server; what is per-model
45
28
  * hangs off `models`, which `modelCapabilitiesFor` reads.
46
- * @param apiKey The rest of the endpoint's identity. Optional, because it changes nothing for
29
+ * @param [apiKey] - The rest of the endpoint's identity. Optional, because it changes nothing for
47
30
  * the ordinary case of one key per base URL and a caller with one need not thread it through;
48
31
  * absent reads as `NO_KEY`, exactly as it does in `getClient`, so an endpoint with no key and
49
32
  * one that passes `undefined` share an entry rather than holding two.
33
+ * @returns The endpoint's live entry, made with both flags on and no models the first time it is
34
+ * asked for.
35
+ *
36
+ * @remarks
37
+ * The same object every time, so what `negotiate` latches off stays off.
50
38
  */
51
39
  export function capabilitiesFor(baseUrl, apiKey) {
52
40
  return capabilitiesById(endpointId({ baseUrl, apiKey }));
@@ -55,49 +43,60 @@ export function capabilitiesFor(baseUrl, apiKey) {
55
43
  * What this model on this endpoint is known not to support. The same object every time, so what
56
44
  * `negotiate` latches off stays off.
57
45
  *
46
+ * @param supports - The endpoint's own, as `capabilitiesFor` hands it over.
47
+ * @param model - The name the endpoint knows the model as — whatever goes in the request body,
48
+ * since that is the only name the refusal is about.
49
+ * @returns The model's live entry under `supports.models`, made with every flag on and nothing
50
+ * refused the first time it is asked for.
51
+ *
52
+ * @remarks
58
53
  * Nested under the endpoint rather than keyed by name alone, because `gpt-4o` at OpenAI and
59
54
  * `gpt-4o` behind a proxy need not be the same weights — and a proxy is free to answer to a name
60
55
  * it does not really serve. One that refused a reasoning effort must not speak for the other.
61
56
  * Bounded by the models actually asked for on that endpoint, which is what a dropdown holds.
62
- *
63
- * @param supports The endpoint's own, as `capabilitiesFor` hands it over.
64
- * @param model The name the endpoint knows the model as — whatever goes in the request body,
65
- * since that is the only name the refusal is about.
66
57
  */
67
58
  export function modelCapabilitiesFor(supports, model) {
68
- let known = supports.models.get(model);
69
- if (!known) {
70
- known = { ...OPTIMISTIC_MODEL, refusedFields: new Set(), refusedEfforts: new Set() };
71
- supports.models.set(model, known);
72
- }
73
- return known;
59
+ return getOrCreate(supports.models, model, () => ({
60
+ ...OPTIMISTIC_MODEL,
61
+ refusedFields: new Set(),
62
+ refusedEfforts: new Set(),
63
+ }));
74
64
  }
75
- /** Every endpoint's capabilities by `endpointId`, the live objects, for `exportCapabilities`. */
65
+ /**
66
+ * Every endpoint's capabilities by `endpointId`, the live objects, for `exportCapabilities`.
67
+ *
68
+ * @returns The map itself rather than a copy, read-only by type alone: what is latched or forgotten
69
+ * afterwards shows through it.
70
+ */
76
71
  export const knownCapabilities = () => capabilities();
77
72
  /**
78
73
  * The capabilities of the endpoint with this `endpointId`, created optimistic if unseen — the way
79
74
  * `importCapabilities` reaches an endpoint it has only a digest for.
75
+ *
76
+ * @param id - The digest `endpointId` gives. Not checked: any string opens an entry under itself.
77
+ * @returns The live entry, the same object `capabilitiesFor` hands over for that endpoint.
80
78
  */
81
79
  export function capabilitiesById(id) {
82
- let known = capabilities().get(id);
83
- if (!known) {
84
- known = { strictSchemas: true, usageInStream: true, models: new Map(), since: Date.now() };
85
- capabilities().set(id, known);
86
- }
87
- return known;
80
+ return getOrCreate(capabilities(), id, () => ({
81
+ strictSchemas: true,
82
+ usageInStream: true,
83
+ models: new Map(),
84
+ since: Date.now(),
85
+ }));
88
86
  }
89
87
  /**
90
88
  * Forgets what one endpoint refused, or every endpoint's when told none.
91
89
  *
90
+ * @param [endpoint] - Whose to forget, by the same identity `capabilitiesFor` takes. Absent clears
91
+ * every endpoint, which is what tests and `resetAll` mean by it.
92
+ * @returns Whether there was anything to forget.
93
+ *
94
+ * @remarks
92
95
  * A latch never unlatches on its own, so a server upgraded behind the same URL — a newer
93
96
  * llama.cpp that compiles the grammar, a proxy that has learned `stream_options` — keeps being
94
97
  * sent the downgraded request for the life of the process. This is the seam for a consumer that
95
98
  * *knows* it changed: a settings row saved, a health check that reads a new build string, an
96
99
  * operator pressing a button. See `expireCapabilities` for the case where nobody knows.
97
- *
98
- * @param endpoint Whose to forget, by the same identity `capabilitiesFor` takes. Absent clears
99
- * every endpoint, which is what tests and `resetAll` mean by it.
100
- * @returns Whether there was anything to forget.
101
100
  */
102
101
  export function resetCapabilities(endpoint) {
103
102
  if (!endpoint) {
@@ -110,6 +109,11 @@ export function resetCapabilities(endpoint) {
110
109
  /**
111
110
  * Forgets every endpoint whose entry is older than this, so the next request finds out again.
112
111
  *
112
+ * @param maxAgeMs - How old an entry may be. Zero or less expires everything.
113
+ * @param [now] - The clock, for tests.
114
+ * @returns How many endpoints were forgotten.
115
+ *
116
+ * @remarks
113
117
  * The other half of the problem `resetCapabilities` solves: a server upgraded behind the same URL
114
118
  * with nobody to notice. What an expiry costs is one round trip per endpoint and model — the next
115
119
  * request carries the field again, and a server that still refuses it refuses it once and
@@ -119,16 +123,13 @@ export function resetCapabilities(endpoint) {
119
123
  *
120
124
  * Nothing calls this on a timer. When to sweep is the consumer's, the same way how stale a
121
125
  * snapshot is too stale is, and a sweep costs a walk of one settings row's worth of entries.
122
- *
123
- * @param maxAgeMs How old an entry may be. Zero or less expires everything.
124
- * @param now The clock, for tests.
125
- * @returns How many endpoints were forgotten.
126
126
  */
127
127
  export function expireCapabilities(maxAgeMs, now = Date.now()) {
128
128
  let dropped = 0;
129
129
  for (const [id, known] of capabilities()) {
130
- if (now - known.since < maxAgeMs)
130
+ if (now - known.since < maxAgeMs) {
131
131
  continue;
132
+ }
132
133
  capabilities().delete(id);
133
134
  dropped++;
134
135
  }
@@ -136,6 +137,15 @@ export function expireCapabilities(maxAgeMs, now = Date.now()) {
136
137
  }
137
138
  /**
138
139
  * Every latching flag in play on one attempt, endpoint and model together, in a stable order.
140
+ *
141
+ * @param supports - The endpoint's. Only its booleans are read, which leaves `models` and `since`
142
+ * out of the reading.
143
+ * @param model - The named model's. Absent, for a request that named none, the reading is the
144
+ * endpoint's alone.
145
+ * @returns A fresh array: the endpoint's booleans, then every value on the model, a set standing
146
+ * as its size.
147
+ *
148
+ * @remarks
139
149
  * Read positionally and only against another reading of the same two objects: what it answers is
140
150
  * whether anything moved while the request was out, and a flag added to either interface later is
141
151
  * compared without an edit here. `models` is not one of them — it is the second level, not a
@@ -143,22 +153,26 @@ export function expireCapabilities(maxAgeMs, now = Date.now()) {
143
153
  * set is the same object on both readings and only ever grows.
144
154
  */
145
155
  const flagsOf = (supports, model) => [
146
- ...Object.values(supports).filter((value) => typeof value === "boolean"),
147
- ...(model
148
- ? Object.values(model).map((value) => (value instanceof Set ? value.size : value))
149
- : []),
156
+ ...Object.values(supports).filter((value) => typeof value === 'boolean'),
157
+ ...(model ? Object.values(model).map((value) => (value instanceof Set ? value.size : value)) : []),
150
158
  ];
151
159
  /** `stream_options` is named in the refusal by every server that has not heard of it. */
152
160
  const REJECTS_USAGE = /stream_options/i;
153
161
  /**
154
- * `response_format` or its `json_schema` type refused, rather than the schema in it. A server
155
- * that validates the schema and finds it wanting — `Invalid schema for response_format` — is
156
- * telling the caller about their schema, and falling back to words for good would hide that.
162
+ * `response_format` or its `json_schema` type refused, rather than the schema in it.
163
+ *
164
+ * @param detail - The server's error text.
165
+ * @returns `true` where the text names either; `false` where it also says `invalid schema`.
166
+ *
167
+ * @remarks
168
+ * A server that validates the schema and finds it wanting — `Invalid schema for response_format`
169
+ * — is telling the caller about their schema, and falling back to words for good would hide that.
157
170
  */
158
171
  const rejectsResponseFormat = (detail) => /response_format|json_schema/i.test(detail) && !/invalid schema/i.test(detail);
159
172
  /**
160
173
  * A refusal of the *value* rather than of the field, which names the field either way.
161
174
  *
175
+ * @remarks
162
176
  * `Unsupported value: 'reasoning_effort' does not support 'none' with this model. Supported
163
177
  * values are: 'minimal', 'low', 'medium', and 'high'.` A model that answers this reasons
164
178
  * perfectly well; it was handed an effort off a list this package does not know. Dropping the
@@ -169,16 +183,26 @@ const rejectsResponseFormat = (detail) => /response_format|json_schema/i.test(de
169
183
  */
170
184
  const REFUSED_VALUE = /unsupported value|invalid value|supported values/i;
171
185
  /**
172
- * A model that cannot reason refuses the field by name — and only the field. See `REFUSED_VALUE`
173
- * for the refusal that names it too and means the opposite, which is the caller's to see rather
174
- * than ours to work around. `does not support` is deliberately not the marker: a proxy that
175
- * words a real field refusal as `this model does not support reasoning_effort` has to keep
176
- * latching.
186
+ * A model that cannot reason refuses the field by name — and only the field.
187
+ *
188
+ * @param detail - The server's error text.
189
+ * @returns `true` where the text names `reasoning_effort` and is not a refusal of its value.
190
+ *
191
+ * @remarks
192
+ * See `REFUSED_VALUE` for the refusal that names it too and means the opposite, which is the
193
+ * caller's to see rather than ours to work around. `does not support` is deliberately not the
194
+ * marker: a proxy that words a real field refusal as `this model does not support
195
+ * reasoning_effort` has to keep latching.
177
196
  */
178
197
  const rejectsEffort = (detail) => /reasoning_effort/i.test(detail) && !REFUSED_VALUE.test(detail);
198
+ /** The setting that asks for no `reasoning_effort` field at all. Ours, and never sent. */
199
+ export const EFFORT_OFF = 'off';
200
+ /** The lowest effort the wire has a word for, which is what a side task asks for. */
201
+ export const EFFORT_NONE = 'none';
179
202
  /**
180
203
  * The efforts this package can place, cheapest first. A substitution only ever walks *up* it.
181
204
  *
205
+ * @remarks
182
206
  * Not a list of what any model takes — `medium` is refused by a model whose list is
183
207
  * `minimal, low, high`, and `xhigh` is real and deliberately absent. It is the order the values
184
208
  * OpenAI has shipped stand in, which is all a step needs to know. A value not on it cannot be
@@ -186,21 +210,37 @@ const rejectsEffort = (detail) => /reasoning_effort/i.test(detail) && !REFUSED_V
186
210
  * refused `xhigh` to `high` would quietly answer a question with less deliberation than whoever
187
211
  * typed it asked for, where stepping up from `none` to `minimal` only costs tokens and says so.
188
212
  */
189
- export const EFFORT_LADDER = ["none", "minimal", "low", "medium", "high"];
190
- const rankOf = (effort) => EFFORT_LADDER.indexOf(effort.toLowerCase());
213
+ export const EFFORT_LADDER = [EFFORT_NONE, 'minimal', 'low', 'medium', 'high'];
214
+ /** The ladder as plain strings, so a value that is not on it can be looked for. */
215
+ const LADDER = EFFORT_LADDER;
216
+ /**
217
+ * Where an effort stands on the ladder, whatever case it was written in.
218
+ *
219
+ * @param effort - The effort as a config or a refusal spelled it.
220
+ * @returns Its index in `EFFORT_LADDER`, or -1 for one the ladder cannot place.
221
+ */
222
+ const rankOf = (effort) => LADDER.indexOf(effort.toLowerCase());
191
223
  /**
192
224
  * The efforts a refusal lists as this model's: `Supported values are: 'minimal', 'low', 'medium',
193
- * and 'high'.` Quoted or bare, separated by commas and a trailing `and` or `or`.
225
+ * and 'high'.`
226
+ *
227
+ * @param detail - The server's error text.
228
+ * @returns The listed efforts the ladder can place, lowercased and in the order the refusal gave
229
+ * them; `undefined` where it lists none, or none that can be placed.
230
+ *
231
+ * @remarks
232
+ * Quoted or bare, separated by commas and a trailing `and` or `or`.
194
233
  */
195
234
  function listedEfforts(detail) {
196
235
  const listed = detail.match(/supported values(?:\s+\w+)?\s*(?:are|is|include)?\s*:?\s*([^\n.]+)/i)?.[1];
197
- if (!listed)
236
+ if (!listed) {
198
237
  return undefined;
238
+ }
199
239
  const values = listed
200
240
  .split(/,|\band\b|\bor\b/)
201
241
  .map((value) => value
202
242
  .trim()
203
- .replace(/^['"`]+|['"`]+$/g, "")
243
+ .replace(/^['"`]+|['"`]+$/g, '')
204
244
  .toLowerCase())
205
245
  .filter((value) => rankOf(value) >= 0);
206
246
  return values.length ? values : undefined;
@@ -209,13 +249,16 @@ function listedEfforts(detail) {
209
249
  * Which effort a refusal says was refused, in the two shapes the wording takes: the field quoted
210
250
  * then `does not support 'none'`, and `reasoning_effort: none`.
211
251
  *
252
+ * @param detail - The refusal.
253
+ * @param [supported] - What the same refusal listed, which the value cannot be one of.
254
+ * @returns The value, lowercased; `undefined` where the refusal names none in either shape, or
255
+ * names one that `supported` holds.
256
+ *
257
+ * @remarks
212
258
  * Read rather than remembered, because `negotiate` builds no request and so does not know what
213
259
  * went out — and a proxy that rewrites the value before passing it on is refusing the one it sent
214
260
  * rather than the one it was given. A refusal that names no value steps nothing: guessing which
215
261
  * rung was refused is how a ladder walks past the value that would have worked.
216
- *
217
- * @param detail The refusal.
218
- * @param supported What the same refusal listed, which the value cannot be one of.
219
262
  */
220
263
  function refusedEffortValue(detail, supported = []) {
221
264
  const found = detail.match(/reasoning_effort['"`]?\s*(?:does not support|is not supported with|:)\s*['"`]?([\w-]+)/i)?.[1];
@@ -226,91 +269,122 @@ function refusedEffortValue(detail, supported = []) {
226
269
  * The cheapest effort above this one that the model has not refused, or `undefined` when the
227
270
  * ladder is out of rungs.
228
271
  *
272
+ * @param refused - What the model has refused and, where a refusal published one, its list. Not
273
+ * written to.
274
+ * @param asked - The effort to step up from, in any case. One the ladder cannot place steps
275
+ * nowhere.
276
+ * @returns The effort to send instead, lowercased as the list or the ladder holds it; `undefined`
277
+ * too where `asked` is not on the ladder.
278
+ *
279
+ * @remarks
229
280
  * Above, never below: see `EFFORT_LADDER`. Where a refusal published a list that is the whole of
230
281
  * what is tried, so the step lands in one request; where it published none the ladder is walked a
231
282
  * rung at a time, each refusal latching the rung it named.
232
283
  */
233
284
  function nextEffort(refused, asked) {
234
285
  const rank = rankOf(asked);
235
- if (rank < 0)
286
+ if (rank < 0) {
236
287
  return undefined;
288
+ }
237
289
  let best;
238
290
  for (const value of refused.supportedEfforts ?? EFFORT_LADDER) {
239
291
  const at = rankOf(value);
240
- if (at <= rank || refused.refusedEfforts.has(value))
292
+ if (at <= rank || refused.refusedEfforts.has(value)) {
241
293
  continue;
242
- if (best === undefined || at < rankOf(best))
294
+ }
295
+ if (best === undefined || at < rankOf(best)) {
243
296
  best = value;
297
+ }
244
298
  }
245
299
  return best;
246
300
  }
247
301
  /**
248
302
  * What to actually put in `reasoning_effort` for a model that has refused the value asked for.
249
303
  *
304
+ * @param refused - What the model has refused, as `negotiate` hands it over. Absent is a model that
305
+ * has refused nothing.
306
+ * @param asked - What the config asks for. `"off"` and absent mean no effort.
307
+ * @returns The value for the field, or the empty string for no field. `asked` itself, as written,
308
+ * where the model has not refused it — and where it has but there is no rung left to step to.
309
+ *
310
+ * @remarks
250
311
  * The caller's own value, until this model has said that value is not one of its own; then the
251
312
  * cheapest it will take that is at least as much deliberation. A model that takes no effort at
252
313
  * all, and an absent or `"off"` setting, both answer the empty string, which is the body
253
314
  * builders' signal to send no field.
254
- *
255
- * @param refused What the model has refused, as `negotiate` hands it over. Absent is a model that
256
- * has refused nothing.
257
- * @param asked What the config asks for. `"off"` and absent mean no effort.
258
315
  */
259
316
  export function effortFor(refused, asked) {
260
- if (!asked || asked === "off")
261
- return "";
262
- if (!refused)
317
+ if (!asked || asked === EFFORT_OFF) {
318
+ return '';
319
+ }
320
+ if (!refused) {
263
321
  return asked;
264
- if (!refused.reasoningEffort)
265
- return "";
322
+ }
323
+ if (!refused.reasoningEffort) {
324
+ return '';
325
+ }
266
326
  const known = refused.supportedEfforts;
267
327
  const listed = known ? known.includes(asked.toLowerCase()) : true;
268
- if (listed && !refused.refusedEfforts.has(asked.toLowerCase()))
328
+ if (listed && !refused.refusedEfforts.has(asked.toLowerCase())) {
269
329
  return asked;
330
+ }
270
331
  return nextEffort(refused, asked) ?? asked;
271
332
  }
272
333
  /**
273
334
  * The reply ceiling and the temperature as this model takes them, to spread into a request body.
274
335
  *
336
+ * @param refused - What the model has refused, as `negotiate` hands it over. Absent is a model that
337
+ * has refused nothing.
338
+ * @param maxTokens - The ceiling. Zero or less sends none and leaves it to the server: a zero sent
339
+ * as `max_tokens: 0` asks for an empty reply.
340
+ * @param temperature - What to sample at, where the model takes one that was picked for it.
341
+ * @returns A fresh object holding the ceiling under one of its two names, or under neither, and
342
+ * `temperature` unless the model refused ours.
343
+ *
344
+ * @remarks
275
345
  * The reasoning models want the ceiling spelled the other way, and one that will only run at the
276
346
  * temperature it was built with is sent none. Both are tested `=== false`, so a caller that named
277
347
  * no model sends what one that has refused nothing is sent; see `ModelCapabilities.legacyTokenLimit`.
278
- *
279
- * @param refused What the model has refused, as `negotiate` hands it over. Absent is a model that
280
- * has refused nothing.
281
- * @param maxTokens The ceiling. Zero or less sends none and leaves it to the server: a zero sent
282
- * as `max_tokens: 0` asks for an empty reply.
283
- * @param temperature What to sample at, where the model takes one that was picked for it.
284
348
  */
285
- export const ceilingAndTemperature = (refused, maxTokens, temperature) => ({
286
- ...(maxTokens > 0
287
- ? refused?.legacyTokenLimit === false
288
- ? { max_completion_tokens: maxTokens }
289
- : { max_tokens: maxTokens }
290
- : {}),
291
- ...(refused?.chosenTemperature === false ? {} : { temperature }),
292
- });
349
+ export const ceilingAndTemperature = (refused, maxTokens, temperature) => {
350
+ const ceiling = refused?.legacyTokenLimit === false ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens };
351
+ return {
352
+ ...(maxTokens > 0 ? ceiling : {}),
353
+ ...(refused?.chosenTemperature === false ? {} : { temperature }),
354
+ };
355
+ };
293
356
  /**
294
357
  * How to answer a refused effort *value*, or `undefined` when there is nothing new to learn or
295
358
  * nowhere left to go — in which case the refusal is the caller's, as it was before this existed.
296
359
  *
360
+ * @param detail - The server's error text.
361
+ * @param refused - What the model has refused so far. Read and never written to: the next rung is
362
+ * worked out against a copy.
363
+ * @returns The value to latch, what would be sent once it is, and the list the refusal published
364
+ * where it published one.
365
+ *
366
+ * @remarks
297
367
  * Pure, so `negotiate` can work out whether this refusal is one of its own before deciding to
298
368
  * answer it, and latch only in the branch it takes.
299
369
  */
300
370
  function planEffortStep(detail, refused) {
301
- if (!refused.reasoningEffort)
371
+ if (!refused.reasoningEffort) {
302
372
  return undefined;
303
- if (!/reasoning_effort/i.test(detail) || !REFUSED_VALUE.test(detail))
373
+ }
374
+ if (!/reasoning_effort/i.test(detail) || !REFUSED_VALUE.test(detail)) {
304
375
  return undefined;
376
+ }
305
377
  const supported = listedEfforts(detail);
306
378
  const value = refusedEffortValue(detail, supported);
307
- if (value === undefined)
379
+ if (value === undefined) {
308
380
  return undefined;
381
+ }
309
382
  // Something has to be new, or a server that answers every request by naming an effort nobody
310
383
  // sent would have `negotiate` re-send for as long as it kept saying it.
311
384
  const fresh = supported?.join() !== refused.supportedEfforts?.join();
312
- if (refused.refusedEfforts.has(value) && !fresh)
385
+ if (refused.refusedEfforts.has(value) && !fresh) {
313
386
  return undefined;
387
+ }
314
388
  const next = nextEffort({
315
389
  ...refused,
316
390
  refusedEfforts: new Set(refused.refusedEfforts).add(value),
@@ -322,6 +396,10 @@ function planEffortStep(detail, refused) {
322
396
  * Read only alongside the name it is asking for: `'max_tokens' is not supported with this model.
323
397
  * Use 'max_completion_tokens' instead.`
324
398
  *
399
+ * @param detail - The server's error text.
400
+ * @returns `true` where the text names both spellings; `max_tokens` on its own is not enough.
401
+ *
402
+ * @remarks
325
403
  * A bare `max_tokens` complaint is also how a server says the *number* was too large —
326
404
  * `max_tokens is too large: 200000. This model supports at most 16384.` — and the answer to that
327
405
  * is not to send the same number under a different name. It is to let the error out, where
@@ -331,6 +409,7 @@ const wantsCompletionLimit = (detail) => /max_tokens/i.test(detail) && /max_comp
331
409
  /**
332
410
  * Temperature named as the field being refused, rather than mentioned in passing.
333
411
  *
412
+ * @remarks
334
413
  * A bare search of the message is not that question. `Unsupported value: 'top_p' does not
335
414
  * support 3 with this model. Adjust temperature instead.` refuses another field and merely says
336
415
  * the word, and it disabled ours — the word is ordinary English about a model, so any advice,
@@ -341,6 +420,11 @@ const NAMES_TEMPERATURE = /(['"`])temperature\1|\btemperature\s+(?:is|does|must|
341
420
  /**
342
421
  * `'temperature' does not support 0.7 with this model. Only the default (1) is supported.`
343
422
  *
423
+ * @param detail - The server's error text.
424
+ * @returns `true` where the text names temperature as the field refused and says `only the
425
+ * default`; either without the other is `false`.
426
+ *
427
+ * @remarks
344
428
  * The qualifier is load-bearing, and `does not support` is not the qualifier: that is how a
345
429
  * server words a refusal of the *value* — `Invalid value: 'temperature' does not support 5.
346
430
  * Supported values are between 0 and 2.` — which is the caller's mistake to see rather than
@@ -362,70 +446,121 @@ const refusesChosenTemperature = (detail) => NAMES_TEMPERATURE.test(detail) && /
362
446
  /**
363
447
  * The fields a refusal names as ones the endpoint has never heard of, in the two wordings OpenAI
364
448
  * has used: `Unrecognized request argument supplied: min_p` (or `arguments`, listing several) and
365
- * `Unknown parameter: 'min_p'.` A nested name — `'chat_template_kwargs.enable_thinking'` — is
366
- * answered at its top-level field, which is the only level a body builder leaves things out at.
449
+ * `Unknown parameter: 'min_p'.`
450
+ *
451
+ * @param detail - The server's error text.
452
+ * @returns The top-level names, in the refusal's order and not deduplicated; empty where it is in
453
+ * neither wording.
454
+ *
455
+ * @remarks
456
+ * A nested name — `'chat_template_kwargs.enable_thinking'` — is answered at its top-level field,
457
+ * which is the only level a body builder leaves things out at.
367
458
  */
368
459
  function unknownFields(detail) {
369
- const listed = detail.match(/unrecognized request arguments? supplied:\s*([^\n]+)/i)?.[1];
370
- const quoted = detail.match(/unknown parameter:\s*(['"`])([^'"`]+)\1/i)?.[2];
371
- const names = listed ? listed.split(",") : quoted ? [quoted] : [];
460
+ const [, listed] = detail.match(/unrecognized request arguments? supplied:\s*([^\n]+)/i) ?? [];
461
+ const [, , quoted] = detail.match(/unknown parameter:\s*(['"`])([^'"`]+)\1/i) ?? [];
462
+ let names = [];
463
+ if (listed) {
464
+ names = listed.split(',');
465
+ }
466
+ else if (quoted) {
467
+ names = [quoted];
468
+ }
372
469
  return names
373
470
  .map((name) => name
374
471
  .trim()
375
- .replace(/^['"`]|['"`.]+$/g, "")
472
+ .replace(/^['"`]|['"`.]+$/g, '')
376
473
  .split(/[.[]/)[0])
377
474
  .filter(Boolean);
378
475
  }
379
- /** The answer to a refusal that is the endpoint's: a flag still set, and a wording that clears it. */
476
+ /**
477
+ * The answer to a refusal that is the endpoint's: a flag still set, and a wording that clears it.
478
+ *
479
+ * @param flag - Which of the endpoint's flags the answer latches off.
480
+ * @param matches - Whether an error text is this refusal. Not asked once the flag is off.
481
+ * @param notice - What a watcher is told when it latches.
482
+ * @returns An `Answer` that clears the flag on `supports` and returns the notice, or returns
483
+ * `undefined` having touched nothing.
484
+ */
380
485
  const endpointAnswer = (flag, matches, notice) => ({ detail, supports }) => {
381
- if (!supports[flag] || !matches(detail))
486
+ if (!supports[flag] || !matches(detail)) {
382
487
  return undefined;
488
+ }
383
489
  supports[flag] = false;
384
490
  return notice;
385
491
  };
386
- /** The same for a refusal that is the model's, which only a request that named one can meet. */
492
+ /**
493
+ * The same for a refusal that is the model's, which only a request that named one can meet.
494
+ *
495
+ * @param flag - Which of the model's flags the answer latches off.
496
+ * @param matches - Whether an error text is this refusal. Not asked once the flag is off, or where
497
+ * no model was named.
498
+ * @param notice - Builds what a watcher is told, from the model's name as the request gave it.
499
+ * @returns An `Answer` that clears the flag on the named model and returns the notice, or returns
500
+ * `undefined` having touched nothing.
501
+ */
387
502
  const modelAnswer = (flag, matches, notice) => ({ detail, named }) => {
388
- if (!named?.refused[flag] || !matches(detail))
503
+ if (!named?.refused[flag] || !matches(detail)) {
389
504
  return undefined;
505
+ }
390
506
  named.refused[flag] = false;
391
507
  return notice(named.name);
392
508
  };
393
- /** An effort refused by value: latched, so `effortFor` sends the next rung down. */
509
+ /**
510
+ * An effort refused by value: latched, so `effortFor` sends the next rung up.
511
+ *
512
+ * @param refusal - The refusal. Its named model is written to: the value joins `refusedEfforts`,
513
+ * and a list the refusal published replaces `supportedEfforts`.
514
+ * @returns The notice, naming the effort refused and the one tried next; `undefined`, having
515
+ * latched nothing, where no model was named or `planEffortStep` has no step to take.
516
+ */
394
517
  const effortStepAnswer = ({ detail, named }) => {
395
518
  const stepped = named && planEffortStep(detail, named.refused);
396
- if (!named || !stepped)
519
+ if (!named || !stepped) {
397
520
  return undefined;
521
+ }
398
522
  named.refused.refusedEfforts.add(stepped.value);
399
- if (stepped.supported)
523
+ if (stepped.supported) {
400
524
  named.refused.supportedEfforts = stepped.supported;
525
+ }
401
526
  return `${named.name} does not reason at ${stepped.value}; retrying at ${stepped.next}`;
402
527
  };
403
- /** Fields the endpoint has never heard of, where they are droppable and not refused already. */
528
+ /**
529
+ * Fields the endpoint has never heard of, where they are droppable and not refused already.
530
+ *
531
+ * @param refusal - The refusal. Each such field it names joins the named model's `refusedFields`.
532
+ * @returns The notice, listing those fields; `undefined`, having latched nothing, where no model
533
+ * was named, nothing is droppable, or the refusal names no field that is both droppable and new.
534
+ */
404
535
  const droppedFieldsAnswer = ({ detail, named, droppable }) => {
405
- if (!named || droppable.size === 0)
536
+ if (!named || droppable.size === 0) {
406
537
  return undefined;
538
+ }
407
539
  const fields = unknownFields(detail).filter((field) => droppable.has(field) && !named.refused.refusedFields.has(field));
408
- if (!fields.length)
540
+ if (!fields.length) {
409
541
  return undefined;
410
- for (const field of fields)
542
+ }
543
+ for (const field of fields) {
411
544
  named.refused.refusedFields.add(field);
412
- return `${named.name} does not take ${fields.join(", ")}; retrying without ${fields.length > 1 ? "them" : "it"}`;
545
+ }
546
+ return `${named.name} does not take ${fields.join(', ')}; retrying without ${fields.length > 1 ? 'them' : 'it'}`;
413
547
  };
414
548
  /**
415
549
  * Every refusal `negotiate` answers, in the order it asks. The first to answer is the only one.
416
550
  *
551
+ * @remarks
417
552
  * The order is behaviour rather than layout: one error text can satisfy two of these — a message
418
553
  * about `reasoning_effort` is a refused field to one and a refused value to the next — and the
419
554
  * earlier row is the reading that wins.
420
555
  */
421
556
  const ANSWERS = [
422
- endpointAnswer("strictSchemas", isGrammarError, "server could not build a grammar; retrying without pattern/format"),
423
- endpointAnswer("usageInStream", (detail) => REJECTS_USAGE.test(detail), "server rejected stream_options; token counts unavailable"),
424
- modelAnswer("reasoningEffort", rejectsEffort, (name) => `${name} does not take a reasoning effort; retrying without one`),
557
+ endpointAnswer('strictSchemas', isGrammarError, 'server could not build a grammar; retrying without pattern/format'),
558
+ endpointAnswer('usageInStream', (detail) => REJECTS_USAGE.test(detail), 'server rejected stream_options; token counts unavailable'),
559
+ modelAnswer('reasoningEffort', rejectsEffort, (name) => `${name} does not take a reasoning effort; retrying without one`),
425
560
  effortStepAnswer,
426
- modelAnswer("legacyTokenLimit", wantsCompletionLimit, (name) => `${name} wants max_completion_tokens; retrying with the limit spelled that way`),
427
- modelAnswer("chosenTemperature", refusesChosenTemperature, (name) => `${name} takes only its own temperature; retrying without ours`),
428
- modelAnswer("structuredOutput", rejectsResponseFormat, (name) => `${name} does not take response_format; asking for JSON in words instead`),
561
+ modelAnswer('legacyTokenLimit', wantsCompletionLimit, (name) => `${name} wants max_completion_tokens; retrying with the limit spelled that way`),
562
+ modelAnswer('chosenTemperature', refusesChosenTemperature, (name) => `${name} takes only its own temperature; retrying without ours`),
563
+ modelAnswer('structuredOutput', rejectsResponseFormat, (name) => `${name} does not take response_format; asking for JSON in words instead`),
429
564
  droppedFieldsAnswer,
430
565
  ];
431
566
  /**
@@ -433,6 +568,17 @@ const ANSWERS = [
433
568
  * request can do without. Returns once the endpoint has answered, or throws if the refusal is
434
569
  * not one of ours.
435
570
  *
571
+ * @typeParam T - What `send` resolves, which is handed back as it came.
572
+ * @param supports - What this endpoint has already refused. Latched off further as it refuses more.
573
+ * @param send - Builds and sends the request. Called again per downgrade, never once tokens
574
+ * have arrived. Its third argument is what the named model has refused, absent when no model
575
+ * was named.
576
+ * @param [options] - `produced` for a caller with its own retry budget, `onNotice` for a watcher,
577
+ * `model` to negotiate the model's refusals alongside the endpoint's.
578
+ * @returns What the first `send` to succeed resolved. A rejection is `send`'s own error, rethrown
579
+ * as it was caught.
580
+ *
581
+ * @remarks
436
582
  * A loop rather than one retry. A server that has heard of neither `stream_options` nor a
437
583
  * grammar keyword complains about them one at a time, and answering only the first leaves the
438
584
  * second to fail the request — so the first run against such an endpoint is spent discovering
@@ -454,13 +600,6 @@ const ANSWERS = [
454
600
  * ever one flag in a turn — the same box `streamTurn` sets and the re-send below reads — and a
455
601
  * caller that passed it to only one of the two got a turn that had already streamed tokens sent
456
602
  * again, silently, with the watcher seeing every one of them twice.
457
- *
458
- * @param supports What this endpoint has already refused. Latched off further as it refuses more.
459
- * @param send Builds and sends the request. Called again per downgrade, never once tokens
460
- * have arrived. Its third argument is what the named model has refused, absent when no model
461
- * was named.
462
- * @param options `produced` for a caller with its own retry budget, `onNotice` for a watcher,
463
- * `model` to negotiate the model's refusals alongside the endpoint's.
464
603
  */
465
604
  export async function negotiate(supports, send, { produced = { any: false }, onNotice, model: name, droppable } = {}) {
466
605
  const optional = new Set(droppable);
@@ -476,8 +615,9 @@ export async function negotiate(supports, send, { produced = { any: false }, onN
476
615
  return await send(supports, produced, model);
477
616
  }
478
617
  catch (error) {
479
- if (produced.any)
618
+ if (produced.any) {
480
619
  throw error;
620
+ }
481
621
  const refusal = {
482
622
  detail: errorMessage(error),
483
623
  supports,
@@ -487,13 +627,16 @@ export async function negotiate(supports, send, { produced = { any: false }, onN
487
627
  let notice;
488
628
  for (const answer of ANSWERS) {
489
629
  notice = answer(refusal);
490
- if (notice !== undefined)
630
+ if (notice !== undefined) {
491
631
  break;
632
+ }
492
633
  }
493
- if (notice !== undefined)
634
+ if (notice !== undefined) {
494
635
  onNotice?.(notice);
495
- else if (flagsOf(supports, model).every((flag, index) => flag === sent[index]))
636
+ }
637
+ else if (flagsOf(supports, model).every((flag, index) => flag === sent[index])) {
496
638
  throw error;
639
+ }
497
640
  // Otherwise the refusal was answered by whoever got there first, and this attempt was
498
641
  // built before the answer existed. Two runs opening on a fresh llama.cpp box both get the
499
642
  // grammar error; the first latches it off and re-sends, and the second used to find the