@cubicecho/agent-core 2.24.0 → 2.25.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/README.md +10 -7
- package/dist/agent-loop.d.ts +84 -205
- package/dist/agent-loop.js +288 -597
- package/dist/calibration.d.ts +14 -10
- package/dist/calibration.js +30 -23
- package/dist/capabilities.d.ts +111 -42
- package/dist/capabilities.js +281 -138
- package/dist/client.d.ts +104 -35
- package/dist/client.js +205 -74
- package/dist/compaction.d.ts +103 -60
- package/dist/compaction.js +160 -86
- package/dist/config.d.ts +48 -12
- package/dist/config.js +20 -1
- package/dist/continuation.d.ts +24 -16
- package/dist/continuation.js +70 -44
- package/dist/digest.d.ts +7 -0
- package/dist/digest.js +18 -0
- package/dist/errors.d.ts +24 -15
- package/dist/errors.js +23 -15
- package/dist/events.d.ts +100 -63
- package/dist/events.js +250 -120
- package/dist/guards.d.ts +35 -4
- package/dist/guards.js +38 -5
- package/dist/hook-events.d.ts +21 -5
- package/dist/hook-events.js +23 -9
- package/dist/hooks.d.ts +96 -73
- package/dist/hooks.js +202 -116
- package/dist/index.d.ts +30 -26
- package/dist/index.js +29 -24
- package/dist/ledger.d.ts +62 -30
- package/dist/ledger.js +163 -56
- package/dist/platform.d.ts +13 -0
- package/dist/platform.js +13 -0
- package/dist/preselect.d.ts +222 -0
- package/dist/preselect.js +290 -0
- package/dist/request-body.d.ts +32 -0
- package/dist/request-body.js +47 -0
- package/dist/reset.d.ts +1 -0
- package/dist/reset.js +7 -6
- package/dist/retry.d.ts +29 -138
- package/dist/retry.js +69 -282
- package/dist/run-calls.d.ts +123 -0
- package/dist/run-calls.js +351 -0
- package/dist/run-turn.d.ts +36 -15
- package/dist/run-turn.js +74 -37
- package/dist/runtime.d.ts +23 -17
- package/dist/runtime.js +34 -24
- package/dist/schema-compat.d.ts +35 -18
- package/dist/schema-compat.js +340 -175
- package/dist/scope.d.ts +28 -5
- package/dist/scope.js +37 -10
- package/dist/side-task.d.ts +123 -40
- package/dist/side-task.js +181 -63
- package/dist/snapshot.d.ts +10 -5
- package/dist/snapshot.js +73 -30
- package/dist/spec.d.ts +56 -25
- package/dist/spec.js +494 -215
- package/dist/stream.d.ts +66 -16
- package/dist/stream.js +220 -97
- package/dist/thinking.d.ts +23 -8
- package/dist/thinking.js +92 -44
- package/dist/tokens.d.ts +164 -6
- package/dist/tokens.js +319 -6
- package/dist/tool-calls.d.ts +82 -17
- package/dist/tool-calls.js +198 -83
- package/dist/tool-loading.d.ts +101 -189
- package/dist/tool-loading.js +277 -371
- package/dist/wire.d.ts +81 -0
- package/dist/wire.js +73 -0
- package/llms.txt +76 -39
- package/package.json +4 -4
package/dist/capabilities.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { endpointId } from
|
|
2
|
-
import { errorMessage } from
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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.
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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 ===
|
|
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.
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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.
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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 = [
|
|
190
|
-
|
|
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'.`
|
|
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
|
-
|
|
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 ===
|
|
261
|
-
return
|
|
262
|
-
|
|
317
|
+
if (!asked || asked === EFFORT_OFF) {
|
|
318
|
+
return '';
|
|
319
|
+
}
|
|
320
|
+
if (!refused) {
|
|
263
321
|
return asked;
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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'.`
|
|
366
|
-
*
|
|
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)
|
|
370
|
-
const quoted = detail.match(/unknown parameter:\s*(['"`])([^'"`]+)\1/i)
|
|
371
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
542
|
+
}
|
|
543
|
+
for (const field of fields) {
|
|
411
544
|
named.refused.refusedFields.add(field);
|
|
412
|
-
|
|
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(
|
|
423
|
-
endpointAnswer(
|
|
424
|
-
modelAnswer(
|
|
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(
|
|
427
|
-
modelAnswer(
|
|
428
|
-
modelAnswer(
|
|
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
|
-
|
|
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
|