@cubicecho/agent-core 2.4.0 → 2.6.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 +210 -2
- package/dist/agent-loop.d.ts +202 -0
- package/dist/agent-loop.js +355 -0
- package/dist/capabilities.d.ts +36 -2
- package/dist/capabilities.js +73 -19
- package/dist/client.d.ts +13 -0
- package/dist/client.js +11 -0
- package/dist/compaction.d.ts +133 -0
- package/dist/compaction.js +173 -0
- package/dist/config.d.ts +20 -0
- package/dist/events.d.ts +5 -0
- package/dist/hooks.d.ts +28 -0
- package/dist/hooks.js +33 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +8 -4
- package/dist/retry.d.ts +9 -0
- package/dist/retry.js +9 -0
- package/dist/run-turn.d.ts +9 -3
- package/dist/run-turn.js +11 -4
- package/dist/side-task.d.ts +35 -1
- package/dist/side-task.js +101 -32
- package/dist/snapshot.d.ts +57 -0
- package/dist/snapshot.js +123 -0
- package/dist/stream.d.ts +8 -0
- package/dist/stream.js +4 -1
- package/dist/tool-calls.d.ts +67 -0
- package/dist/tool-calls.js +346 -0
- package/dist/tool-loading.d.ts +21 -1
- package/dist/tool-loading.js +23 -6
- package/llms.txt +58 -0
- package/package.json +1 -1
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { capabilitiesFor } from "./capabilities.js";
|
|
2
|
+
import { getClient, NO_KEY, timeoutMs } from "./client.js";
|
|
3
|
+
import { errorMessage } from "./errors.js";
|
|
4
|
+
import { gather, notify, turnIndex, turnMessages, withContext, } from "./hooks.js";
|
|
5
|
+
import { runTurn } from "./run-turn.js";
|
|
6
|
+
import { relaxTools, sanitizeTools } from "./schema-compat.js";
|
|
7
|
+
import { askJson, tryAsk } from "./side-task.js";
|
|
8
|
+
import { parseToolArguments, recoverToolCalls } from "./tool-calls.js";
|
|
9
|
+
import { catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_PER_LOAD, PRESELECT_SCHEMA, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
|
|
10
|
+
/**
|
|
11
|
+
* The loop above a turn: send, run the tools the model asked for, send again, until it stops
|
|
12
|
+
* asking.
|
|
13
|
+
*
|
|
14
|
+
* Three servers wrote this loop separately after `runTurn` had already been pulled in here, and
|
|
15
|
+
* the copies drifted the way the turn's own had — one noticed a turn cut off at the ceiling and
|
|
16
|
+
* two did not, one tested the ceiling's spelling the other way round, one sent a reasoning effort
|
|
17
|
+
* and two never did. What is here is the part that does not know what the run is for; the
|
|
18
|
+
* prompts, the tools and what a run means stay with the caller.
|
|
19
|
+
*/
|
|
20
|
+
/** The fields `extraBody` may not override, because the loop's request is built around them. */
|
|
21
|
+
const RESERVED = new Set(["model", "messages", "stream", "tools"]);
|
|
22
|
+
/**
|
|
23
|
+
* The one place a streamed request's body is decided from a config and what the endpoint and
|
|
24
|
+
* the model have refused.
|
|
25
|
+
*
|
|
26
|
+
* Every field that negotiates lives here: the ceiling's two spellings, a temperature only a
|
|
27
|
+
* model that takes ours is sent, a reasoning effort only one that takes it is, `stream_options`
|
|
28
|
+
* only where the server has heard of it, relaxed schemas only where it could not build a grammar,
|
|
29
|
+
* and `extraBody` last, less whatever the model refused by name. The ceiling is tested
|
|
30
|
+
* `=== false` — `modelCapabilitiesFor` starts a model at `legacyTokenLimit: true` and an absent
|
|
31
|
+
* one has to read the same — which is the test one of the three copies had inverted.
|
|
32
|
+
*
|
|
33
|
+
* @param config What to ask for. `maxTokens` of zero or less sends no ceiling; `reasoningEffort`
|
|
34
|
+
* absent or `"off"` sends no effort.
|
|
35
|
+
* @param supports What the endpoint has refused, as `negotiate` hands it to `send`.
|
|
36
|
+
* @param refused What the model has refused, as `negotiate` hands it over. Absent is a model
|
|
37
|
+
* that has refused nothing.
|
|
38
|
+
* @param messages The request's messages, system prompt included, sent as they are.
|
|
39
|
+
* @param tools The tool definitions. Sanitised here — a lookup for a definition seen before —
|
|
40
|
+
* and relaxed where the endpoint needs it. Empty sends no `tools` field at all.
|
|
41
|
+
*/
|
|
42
|
+
export function buildBody(config, supports, refused, messages, tools = []) {
|
|
43
|
+
const declared = supports.strictSchemas ? sanitizeTools(tools) : relaxTools(sanitizeTools(tools));
|
|
44
|
+
const effort = config.reasoningEffort;
|
|
45
|
+
const extra = Object.entries(config.extraBody ?? {}).filter(([field]) => !RESERVED.has(field) && !refused?.refusedFields.has(field));
|
|
46
|
+
return {
|
|
47
|
+
...(config.maxTokens > 0
|
|
48
|
+
? refused?.legacyTokenLimit === false
|
|
49
|
+
? { max_completion_tokens: config.maxTokens }
|
|
50
|
+
: { max_tokens: config.maxTokens }
|
|
51
|
+
: {}),
|
|
52
|
+
...(refused?.chosenTemperature === false ? {} : { temperature: config.temperature }),
|
|
53
|
+
...(effort && effort !== "off" && refused?.reasoningEffort !== false
|
|
54
|
+
? { reasoning_effort: effort }
|
|
55
|
+
: {}),
|
|
56
|
+
...(supports.usageInStream ? { stream_options: { include_usage: true } } : {}),
|
|
57
|
+
...Object.fromEntries(extra),
|
|
58
|
+
model: config.model,
|
|
59
|
+
messages,
|
|
60
|
+
stream: true,
|
|
61
|
+
...(declared.length ? { tools: declared } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A long tool argument or result cut to what a watcher needs, with the full length said.
|
|
66
|
+
*
|
|
67
|
+
* For events, never for the transcript: the model reads the whole of what a tool returned.
|
|
68
|
+
*
|
|
69
|
+
* @param text What to show.
|
|
70
|
+
* @param limit Characters kept, 2000 by default. Text at or under it comes back as it was.
|
|
71
|
+
*/
|
|
72
|
+
export const preview = (text, limit = 2000) => text.length > limit ? `${text.slice(0, limit)}… (${text.length} chars)` : text;
|
|
73
|
+
/** A base URL as two settings rows would agree on it: trimmed, without the trailing slash. */
|
|
74
|
+
const sameUrl = (a, b) => a.trim().replace(/\/+$/, "") === b.trim().replace(/\/+$/, "");
|
|
75
|
+
/**
|
|
76
|
+
* The key to send, where an endpoint may inherit one from the settings it overrides.
|
|
77
|
+
*
|
|
78
|
+
* A credential issued for one endpoint has no business being posted to another. A profile that
|
|
79
|
+
* names its own `baseUrl` and no key of its own is sent `NO_KEY` — not the operator's key, and
|
|
80
|
+
* not `$OPENAI_API_KEY` — because "I pointed an agent at a friend's server and it sent my OpenAI
|
|
81
|
+
* key" is not a mistake worth being able to make, and a local server wants no key anyway. One on
|
|
82
|
+
* the same endpoint inherits the key as it inherits everything else, and the environment is the
|
|
83
|
+
* last word on the endpoint that was configured rather than overridden.
|
|
84
|
+
*
|
|
85
|
+
* @param own The endpoint as the agent or profile states it. Its own key always wins. An empty or
|
|
86
|
+
* absent `baseUrl` is one that inherits the endpoint too.
|
|
87
|
+
* @param inherited The settings it overrides. Absent treats `own` as the configured endpoint, so
|
|
88
|
+
* only its key and the environment's are in play.
|
|
89
|
+
* @param env Where `OPENAI_API_KEY` is read from, `process.env` by default.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveApiKey(own, inherited, env = process.env) {
|
|
92
|
+
if (own.apiKey)
|
|
93
|
+
return own.apiKey;
|
|
94
|
+
const baseUrl = own.baseUrl?.trim();
|
|
95
|
+
if (inherited && baseUrl && !sameUrl(baseUrl, inherited.baseUrl))
|
|
96
|
+
return NO_KEY;
|
|
97
|
+
return inherited?.apiKey || env.OPENAI_API_KEY || NO_KEY;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The tools a request is likely to need, picked by a small model before the run starts, or none.
|
|
101
|
+
*
|
|
102
|
+
* On-demand loading otherwise spends a round trip on reading the catalogue and calling
|
|
103
|
+
* `load_tools`; a small model reading the same catalogue usually names the right tools, and the
|
|
104
|
+
* task model opens with them in hand. A wrong guess costs a few hundred tokens for one run, and
|
|
105
|
+
* a failed one costs nothing — it is reported through `onNotice` and answered with an empty list,
|
|
106
|
+
* since a side task is never worth failing the run. A stop still throws.
|
|
107
|
+
*
|
|
108
|
+
* @param config The endpoint the preselector is reached through.
|
|
109
|
+
* @param model The preselector. An empty name picks nothing, which is what `toolSelectModel`
|
|
110
|
+
* means by empty.
|
|
111
|
+
* @param catalog The servers to choose from.
|
|
112
|
+
* @param prompt The request being planned for. Only its head is read; see `preselectInput`.
|
|
113
|
+
* @param options Cancellation, notices, the reply ceiling (256) and the cap the choice is held to
|
|
114
|
+
* (`MAX_PER_LOAD`).
|
|
115
|
+
*/
|
|
116
|
+
export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, maxPerLoad = MAX_PER_LOAD, } = {}) {
|
|
117
|
+
if (!model || !catalog.some((server) => server.tools.length > 0))
|
|
118
|
+
return [];
|
|
119
|
+
const reply = await tryAsk("preselect", () => askJson(config, model, preselectSystem(maxPerLoad), preselectInput(catalog, prompt), PRESELECT_SCHEMA, { name: "preselection", maxTokens, signal, onNotice }), { onNotice });
|
|
120
|
+
return preselection(reply, catalog, maxPerLoad);
|
|
121
|
+
}
|
|
122
|
+
/** Every numeric field of one usage added into another. */
|
|
123
|
+
const accumulate = (total, turn) => {
|
|
124
|
+
for (const key of Object.keys(turn))
|
|
125
|
+
total[key] += turn[key] ?? 0;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Runs a question to its answer: one `runTurn` per step, the tools it asks for between them,
|
|
129
|
+
* until a turn asks for none. Throws when `maxToolIterations` is spent, when stopped, and on
|
|
130
|
+
* whatever `runTurn` throws — `ContextOverflow` among them, however it was found out.
|
|
131
|
+
*
|
|
132
|
+
* On-demand loading is handled here, `load_tools` and all: the catalogue rides on the system
|
|
133
|
+
* prompt and marks what is loaded, a catalogued tool called without being loaded is loaded and
|
|
134
|
+
* run rather than refused, and a preselection shapes the first step. A turn cut off at
|
|
135
|
+
* `maxTokens` is said so as a notice, because it otherwise reads exactly like a finished one.
|
|
136
|
+
*
|
|
137
|
+
* @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
|
|
138
|
+
* and cancellation. See `AgentLoopOptions`.
|
|
139
|
+
*/
|
|
140
|
+
export async function runAgentLoop(options) {
|
|
141
|
+
const { config, system = "", tools = [], catalog = [], dispatch, hooks, signal } = options;
|
|
142
|
+
const { onEvent, onTurn, beforeStep, parallel = false, recoverToolCalls: recover = true, } = options;
|
|
143
|
+
const client = getClient(config);
|
|
144
|
+
const supports = capabilitiesFor(config.baseUrl, config.apiKey);
|
|
145
|
+
const maxRetries = Math.max(0, Number(config.maxRetries) || 0);
|
|
146
|
+
const notice = (text) => onEvent?.({ kind: "notice", text });
|
|
147
|
+
const onDemand = config.toolDiscovery === "ondemand" && catalog.length > 0;
|
|
148
|
+
const loaded = new Set(onDemand ? (options.loaded ?? []) : []);
|
|
149
|
+
const preselected = onDemand ? [...(options.preselected ?? [])] : [];
|
|
150
|
+
for (const name of preselected)
|
|
151
|
+
loaded.add(name);
|
|
152
|
+
const used = new Set();
|
|
153
|
+
const byName = (names) => tools.filter((tool) => tool.type === "function" && names.has(tool.function.name));
|
|
154
|
+
let messages = [...options.messages];
|
|
155
|
+
// Held by reference rather than by index, so a `beforeStep` that folds the head into a summary
|
|
156
|
+
// moves the question without losing it — and one that summarises the question away takes the
|
|
157
|
+
// hooks' context with it, which is right.
|
|
158
|
+
const question = messages.findLast((message) => message.role === "user");
|
|
159
|
+
const gathered = hooks
|
|
160
|
+
? await gather(hooks.run, hooks.events ?? ["beforeTurn"], hooks.context, {
|
|
161
|
+
signal,
|
|
162
|
+
onNote: hooks.onNote,
|
|
163
|
+
maxTokens: hooks.maxTokens,
|
|
164
|
+
})
|
|
165
|
+
: { context: "", notes: [] };
|
|
166
|
+
const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
|
|
167
|
+
const toolCalls = [];
|
|
168
|
+
const answered = new Map();
|
|
169
|
+
for (let step = 0; step < config.maxToolIterations; step++) {
|
|
170
|
+
// A stop aborts the request in flight, but a tool call already handed off runs to its own
|
|
171
|
+
// end — so the signal is read between steps as well.
|
|
172
|
+
signal?.throwIfAborted();
|
|
173
|
+
messages = (await beforeStep?.(messages, step)) ?? messages;
|
|
174
|
+
onEvent?.({ kind: "turn", text: `turn ${step + 1}` });
|
|
175
|
+
const routed = preselected.length > 0 && step === 0;
|
|
176
|
+
const declared = routed
|
|
177
|
+
? byName(new Set(preselected))
|
|
178
|
+
: onDemand
|
|
179
|
+
? [LOAD_TOOLS_DEFINITION, ...byName(loaded)]
|
|
180
|
+
: tools;
|
|
181
|
+
const prompt = onDemand && !routed ? `${system}\n\n${catalogPrompt(catalog, loaded)}`.trim() : system;
|
|
182
|
+
const request = [
|
|
183
|
+
...(prompt ? [{ role: "system", content: prompt }] : []),
|
|
184
|
+
...withContext(messages, question ? messages.indexOf(question) : -1, gathered.context, hooks?.preface),
|
|
185
|
+
];
|
|
186
|
+
const turn = await runTurn(client, supports, (supported, refused) => buildBody(config, supported, refused, request, declared), {
|
|
187
|
+
model: config.model,
|
|
188
|
+
droppable: Object.keys(config.extraBody ?? {}),
|
|
189
|
+
maxRetries,
|
|
190
|
+
contextLimit: config.contextLength ?? 0,
|
|
191
|
+
signal,
|
|
192
|
+
idleMs: timeoutMs(config),
|
|
193
|
+
onNotice: notice,
|
|
194
|
+
onThinking: (text) => onEvent?.({ kind: "thinking", text }),
|
|
195
|
+
onOutput: (text) => onEvent?.({ kind: "output", text }),
|
|
196
|
+
});
|
|
197
|
+
accumulate(usage, turn.usage);
|
|
198
|
+
if (turn.usage.total > 0 || turn.usage.prompt > 0 || turn.usage.completion > 0) {
|
|
199
|
+
onEvent?.({
|
|
200
|
+
kind: "usage",
|
|
201
|
+
usage: {
|
|
202
|
+
promptTokens: usage.prompt,
|
|
203
|
+
completionTokens: usage.completion,
|
|
204
|
+
totalTokens: usage.total,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (turn.finishReason === "length") {
|
|
209
|
+
notice(`the model stopped at maxTokens (${config.maxTokens}); this turn is cut short`);
|
|
210
|
+
}
|
|
211
|
+
// A call with no name is a fragment the server never finished sending: nothing to run, and
|
|
212
|
+
// an assistant message naming it would be answered by nothing.
|
|
213
|
+
let calls = turn.toolCalls.filter((call) => call.function.name);
|
|
214
|
+
let content = turn.content;
|
|
215
|
+
if (recover && !calls.length && content && (tools.length > 0 || onDemand)) {
|
|
216
|
+
const names = tools.flatMap((tool) => (tool.type === "function" ? [tool.function.name] : []));
|
|
217
|
+
const recovered = recoverToolCalls(content, {
|
|
218
|
+
names: onDemand ? [...names, LOAD_TOOLS] : names,
|
|
219
|
+
});
|
|
220
|
+
if (recovered.toolCalls.length) {
|
|
221
|
+
calls = recovered.toolCalls;
|
|
222
|
+
content = recovered.content;
|
|
223
|
+
notice(`recovered ${calls.length} tool call${calls.length === 1 ? "" : "s"} the model wrote as text; the server's tool-call parser does not match this model's template`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const shown = { ...turn, content, toolCalls: calls };
|
|
227
|
+
onTurn?.(shown, step);
|
|
228
|
+
// Read before the assistant message is written, so what is replayed on every later request
|
|
229
|
+
// is the repaired JSON: a server that parses replayed arguments refuses the almost-JSON, and
|
|
230
|
+
// one that could not be read at all is replayed as no arguments.
|
|
231
|
+
const parsed = calls.map((call) => {
|
|
232
|
+
try {
|
|
233
|
+
const args = parseToolArguments(call.function.arguments, {
|
|
234
|
+
finishReason: turn.finishReason,
|
|
235
|
+
});
|
|
236
|
+
return { call, args, normal: JSON.stringify(args) };
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
return { call, error, normal: "{}" };
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
messages.push({
|
|
243
|
+
role: "assistant",
|
|
244
|
+
content: content || null,
|
|
245
|
+
...(parsed.length
|
|
246
|
+
? {
|
|
247
|
+
tool_calls: parsed.map(({ call, normal }) => ({
|
|
248
|
+
...call,
|
|
249
|
+
function: { ...call.function, arguments: normal },
|
|
250
|
+
})),
|
|
251
|
+
}
|
|
252
|
+
: {}),
|
|
253
|
+
});
|
|
254
|
+
if (!calls.length) {
|
|
255
|
+
if (hooks) {
|
|
256
|
+
const at = question ? messages.indexOf(question) : -1;
|
|
257
|
+
// Not awaited: the answer is ready, and remembering it is not something to hold it for.
|
|
258
|
+
// `notify` never rejects.
|
|
259
|
+
void notify(hooks.run, "afterTurn", {
|
|
260
|
+
...hooks.context,
|
|
261
|
+
reply: turn.content,
|
|
262
|
+
...(at >= 0
|
|
263
|
+
? {
|
|
264
|
+
turn: {
|
|
265
|
+
index: turnIndex(messages, at),
|
|
266
|
+
messages: turnMessages(hooks.context.session.id, messages, at),
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
: {}),
|
|
270
|
+
}, hooks.onNote);
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
turn: shown,
|
|
274
|
+
messages,
|
|
275
|
+
usage,
|
|
276
|
+
toolCalls,
|
|
277
|
+
loaded: [...loaded],
|
|
278
|
+
used: [...used],
|
|
279
|
+
notes: gathered.notes,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const run = async ({ call, args, error: unreadable, normal }) => {
|
|
283
|
+
const { name, arguments: raw } = call.function;
|
|
284
|
+
onEvent?.({ kind: "tool-call", name, text: preview(raw) });
|
|
285
|
+
let content;
|
|
286
|
+
let ok = true;
|
|
287
|
+
try {
|
|
288
|
+
if (!args)
|
|
289
|
+
throw unreadable;
|
|
290
|
+
if (onDemand && name === LOAD_TOOLS) {
|
|
291
|
+
const resolved = expandNames(requestedNames(args), catalog);
|
|
292
|
+
for (const hit of resolved.matched)
|
|
293
|
+
loaded.add(hit);
|
|
294
|
+
content = loadResult(resolved, catalog);
|
|
295
|
+
ok = resolved.matched.length > 0;
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
// A model that skips `load_tools` and calls a catalogued tool by name is right about
|
|
299
|
+
// what it wants; load it and run it rather than refusing.
|
|
300
|
+
if (onDemand && inCatalog(catalog, name))
|
|
301
|
+
loaded.add(name);
|
|
302
|
+
used.add(name);
|
|
303
|
+
const request = { id: call.id, name, args, raw };
|
|
304
|
+
content = parallel
|
|
305
|
+
? await once(answered, `${name}${normal}`, () => dispatch(request, signal))
|
|
306
|
+
: await dispatch(request, signal);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (signal?.aborted)
|
|
311
|
+
throw error;
|
|
312
|
+
content = errorMessage(error);
|
|
313
|
+
ok = false;
|
|
314
|
+
}
|
|
315
|
+
onEvent?.({ kind: "tool-result", name, ok, text: preview(content) });
|
|
316
|
+
return { id: call.id, name, ok, content };
|
|
317
|
+
};
|
|
318
|
+
const outcomes = [];
|
|
319
|
+
if (parallel) {
|
|
320
|
+
signal?.throwIfAborted();
|
|
321
|
+
outcomes.push(...(await Promise.all(parsed.map(run))));
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
for (const call of parsed) {
|
|
325
|
+
signal?.throwIfAborted();
|
|
326
|
+
outcomes.push(await run(call));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
for (const { id, name, ok, content } of outcomes) {
|
|
330
|
+
toolCalls.push({ name, ok });
|
|
331
|
+
messages.push({ role: "tool", tool_call_id: id, content });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
throw new Error(`Stopped after ${config.maxToolIterations} tool iterations.`);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Makes a call at most once per key, sharing the in-flight promise so two identical calls in one
|
|
338
|
+
* step make one request between them. A call that rejected is forgotten, so asking again is a
|
|
339
|
+
* real retry rather than a replayed failure.
|
|
340
|
+
*/
|
|
341
|
+
async function once(answered, key, make) {
|
|
342
|
+
const previous = answered.get(key);
|
|
343
|
+
if (previous)
|
|
344
|
+
return previous;
|
|
345
|
+
const pending = make();
|
|
346
|
+
answered.set(key, pending);
|
|
347
|
+
try {
|
|
348
|
+
return await pending;
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
if (answered.get(key) === pending)
|
|
352
|
+
answered.delete(key);
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
}
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -65,6 +65,23 @@ export interface ModelCapabilities {
|
|
|
65
65
|
* refuses any other value, including the one a settings row has been showing all along.
|
|
66
66
|
*/
|
|
67
67
|
chosenTemperature: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Request fields the model's endpoint refused by name — `min_p` to OpenAI, say — which a body
|
|
70
|
+
* builder leaves out of what `extraBody` asks for. Only ever grows, which is this set's way of
|
|
71
|
+
* latching off.
|
|
72
|
+
*
|
|
73
|
+
* Only the fields a caller offered as droppable reach it (see `NegotiateOptions.droppable`),
|
|
74
|
+
* because a name read out of an error string is not otherwise something to stop sending: a
|
|
75
|
+
* refusal naming `messages` is a broken request, not a field the model can do without.
|
|
76
|
+
*/
|
|
77
|
+
refusedFields: Set<string>;
|
|
78
|
+
/**
|
|
79
|
+
* Takes a `response_format` of type `json_schema`, which `askJson` sends. An older model, or a
|
|
80
|
+
* proxy in front of one, refuses the field outright; the answer is to ask in words and parse the
|
|
81
|
+
* reply, which is what every structured answer did before. The model's rather than the
|
|
82
|
+
* endpoint's because one key reaches models that differ here, the way they differ on effort.
|
|
83
|
+
*/
|
|
84
|
+
structuredOutput: boolean;
|
|
68
85
|
}
|
|
69
86
|
/**
|
|
70
87
|
* What this endpoint is known not to support. The same object every time, so what `negotiate`
|
|
@@ -92,9 +109,16 @@ export declare function capabilitiesFor(baseUrl: string, apiKey?: string): Capab
|
|
|
92
109
|
* since that is the only name the refusal is about.
|
|
93
110
|
*/
|
|
94
111
|
export declare function modelCapabilitiesFor(supports: Capabilities, model: string): ModelCapabilities;
|
|
112
|
+
/** Every endpoint's capabilities by `endpointId`, the live objects, for `exportCapabilities`. */
|
|
113
|
+
export declare const knownCapabilities: () => ReadonlyMap<string, Capabilities>;
|
|
114
|
+
/**
|
|
115
|
+
* The capabilities of the endpoint with this `endpointId`, created optimistic if unseen — the way
|
|
116
|
+
* `importCapabilities` reaches an endpoint it has only a digest for.
|
|
117
|
+
*/
|
|
118
|
+
export declare function capabilitiesById(id: string): Capabilities;
|
|
95
119
|
/** Forgets every endpoint's capabilities. For tests, and for a settings change under test. */
|
|
96
120
|
export declare function resetCapabilities(): void;
|
|
97
|
-
/** What `negotiate` takes besides the request.
|
|
121
|
+
/** What `negotiate` takes besides the request. All optional. */
|
|
98
122
|
export interface NegotiateOptions {
|
|
99
123
|
/**
|
|
100
124
|
* The flag `send` will be given, for a caller that has to read it after `negotiate` returns.
|
|
@@ -125,6 +149,16 @@ export interface NegotiateOptions {
|
|
|
125
149
|
* model per endpoint, or one that only ever meets the endpoint's own refusals, needs no edit.
|
|
126
150
|
*/
|
|
127
151
|
model?: string;
|
|
152
|
+
/**
|
|
153
|
+
* The request fields a refusal may take away when the endpoint says it has never heard of
|
|
154
|
+
* them — what `extraBody` added, ordinarily. Such a name is latched into the model's
|
|
155
|
+
* `refusedFields` and the request is sent again, which `send` is expected to build without it.
|
|
156
|
+
*
|
|
157
|
+
* Opt-in by name, because the answer is to stop sending the field, and a name out of an error
|
|
158
|
+
* string that `send` does not know how to leave out would only be refused again. Needs `model`,
|
|
159
|
+
* since the latch is the model's.
|
|
160
|
+
*/
|
|
161
|
+
droppable?: Iterable<string>;
|
|
128
162
|
}
|
|
129
163
|
/**
|
|
130
164
|
* Sends a request, re-sending it each time the answer is this endpoint refusing something the
|
|
@@ -160,4 +194,4 @@ export interface NegotiateOptions {
|
|
|
160
194
|
* @param options `produced` for a caller with its own retry budget, `onNotice` for a watcher,
|
|
161
195
|
* `model` to negotiate the model's refusals alongside the endpoint's.
|
|
162
196
|
*/
|
|
163
|
-
export declare function negotiate<T>(supports: Capabilities, send: (supports: Capabilities, produced: Produced, model: ModelCapabilities | undefined) => Promise<T>, { produced, onNotice, model: name }?: NegotiateOptions): Promise<T>;
|
|
197
|
+
export declare function negotiate<T>(supports: Capabilities, send: (supports: Capabilities, produced: Produced, model: ModelCapabilities | undefined) => Promise<T>, { produced, onNotice, model: name, droppable }?: NegotiateOptions): Promise<T>;
|
package/dist/capabilities.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { endpointId } from "./client.js";
|
|
2
2
|
import { errorMessage } from "./errors.js";
|
|
3
3
|
import { isGrammarError } from "./schema-compat.js";
|
|
4
4
|
/**
|
|
5
5
|
* What each endpoint cannot do, remembered for the life of the process.
|
|
6
6
|
*
|
|
7
|
-
* Keyed by `
|
|
8
|
-
* about this one. A llama.cpp box that cannot
|
|
9
|
-
* both reachable from one settings row over its
|
|
10
|
-
* this afternoon to OpenAI this evening — and the
|
|
11
|
-
* pattern/format from the second one's requests, or
|
|
12
|
-
* rest of the process. Bounded by the number of
|
|
13
|
-
* row's worth.
|
|
7
|
+
* Keyed by `endpointId` — `endpointKey` hashed, so a snapshot holds no key — because these are
|
|
8
|
+
* facts about the server on the other end and not about this one. A llama.cpp box that cannot
|
|
9
|
+
* compile a grammar and a cloud API that can are both reachable from one settings row over its
|
|
10
|
+
* lifetime — an operator retargets it from Ollama this afternoon to OpenAI this evening — and the
|
|
11
|
+
* first one's refusal must not quietly strip pattern/format from the second one's requests, or
|
|
12
|
+
* silently cost it its token counts, for the rest of the process. Bounded by the number of
|
|
13
|
+
* endpoints ever configured, which is a settings row's worth.
|
|
14
14
|
*
|
|
15
15
|
* The API key is part of that identity, the same as it is for the client pool and the model
|
|
16
16
|
* listings. A router — LiteLLM, OpenRouter, a gateway with several boxes behind it — is free to
|
|
@@ -32,13 +32,7 @@ const capabilities = new Map();
|
|
|
32
32
|
* one that passes `undefined` share an entry rather than holding two.
|
|
33
33
|
*/
|
|
34
34
|
export function capabilitiesFor(baseUrl, apiKey) {
|
|
35
|
-
|
|
36
|
-
let known = capabilities.get(key);
|
|
37
|
-
if (!known) {
|
|
38
|
-
known = { strictSchemas: true, usageInStream: true, models: new Map() };
|
|
39
|
-
capabilities.set(key, known);
|
|
40
|
-
}
|
|
41
|
-
return known;
|
|
35
|
+
return capabilitiesById(endpointId({ baseUrl, apiKey }));
|
|
42
36
|
}
|
|
43
37
|
/**
|
|
44
38
|
* What this model on this endpoint is known not to support. The same object every time, so what
|
|
@@ -56,11 +50,31 @@ export function capabilitiesFor(baseUrl, apiKey) {
|
|
|
56
50
|
export function modelCapabilitiesFor(supports, model) {
|
|
57
51
|
let known = supports.models.get(model);
|
|
58
52
|
if (!known) {
|
|
59
|
-
known = {
|
|
53
|
+
known = {
|
|
54
|
+
reasoningEffort: true,
|
|
55
|
+
legacyTokenLimit: true,
|
|
56
|
+
chosenTemperature: true,
|
|
57
|
+
refusedFields: new Set(),
|
|
58
|
+
structuredOutput: true,
|
|
59
|
+
};
|
|
60
60
|
supports.models.set(model, known);
|
|
61
61
|
}
|
|
62
62
|
return known;
|
|
63
63
|
}
|
|
64
|
+
/** Every endpoint's capabilities by `endpointId`, the live objects, for `exportCapabilities`. */
|
|
65
|
+
export const knownCapabilities = () => capabilities;
|
|
66
|
+
/**
|
|
67
|
+
* The capabilities of the endpoint with this `endpointId`, created optimistic if unseen — the way
|
|
68
|
+
* `importCapabilities` reaches an endpoint it has only a digest for.
|
|
69
|
+
*/
|
|
70
|
+
export function capabilitiesById(id) {
|
|
71
|
+
let known = capabilities.get(id);
|
|
72
|
+
if (!known) {
|
|
73
|
+
known = { strictSchemas: true, usageInStream: true, models: new Map() };
|
|
74
|
+
capabilities.set(id, known);
|
|
75
|
+
}
|
|
76
|
+
return known;
|
|
77
|
+
}
|
|
64
78
|
/** Forgets every endpoint's capabilities. For tests, and for a settings change under test. */
|
|
65
79
|
export function resetCapabilities() {
|
|
66
80
|
capabilities.clear();
|
|
@@ -70,14 +84,23 @@ export function resetCapabilities() {
|
|
|
70
84
|
* Read positionally and only against another reading of the same two objects: what it answers is
|
|
71
85
|
* whether anything moved while the request was out, and a flag added to either interface later is
|
|
72
86
|
* compared without an edit here. `models` is not one of them — it is the second level, not a
|
|
73
|
-
* flag, and the map is the same object throughout.
|
|
87
|
+
* flag, and the map is the same object throughout. `refusedFields` is read by its size, since the
|
|
88
|
+
* set is the same object on both readings and only ever grows.
|
|
74
89
|
*/
|
|
75
90
|
const flagsOf = (supports, model) => [
|
|
76
91
|
...Object.values(supports).filter((value) => typeof value === "boolean"),
|
|
77
|
-
...(model
|
|
92
|
+
...(model
|
|
93
|
+
? Object.values(model).map((value) => (value instanceof Set ? value.size : value))
|
|
94
|
+
: []),
|
|
78
95
|
];
|
|
79
96
|
/** `stream_options` is named in the refusal by every server that has not heard of it. */
|
|
80
97
|
const REJECTS_USAGE = /stream_options/i;
|
|
98
|
+
/**
|
|
99
|
+
* `response_format` or its `json_schema` type refused, rather than the schema in it. A server
|
|
100
|
+
* that validates the schema and finds it wanting — `Invalid schema for response_format` — is
|
|
101
|
+
* telling the caller about their schema, and falling back to words for good would hide that.
|
|
102
|
+
*/
|
|
103
|
+
const rejectsResponseFormat = (detail) => /response_format|json_schema/i.test(detail) && !/invalid schema/i.test(detail);
|
|
81
104
|
/**
|
|
82
105
|
* A refusal of the *value* rather than of the field, which names the field either way.
|
|
83
106
|
*
|
|
@@ -138,6 +161,26 @@ const NAMES_TEMPERATURE = /(['"`])temperature\1|\btemperature\s+(?:is|does|must|
|
|
|
138
161
|
* costs one visible error, and a false positive quietly changes what every later request means.
|
|
139
162
|
*/
|
|
140
163
|
const refusesChosenTemperature = (detail) => NAMES_TEMPERATURE.test(detail) && /only the default/i.test(detail);
|
|
164
|
+
/**
|
|
165
|
+
* The fields a refusal names as ones the endpoint has never heard of, in the two wordings OpenAI
|
|
166
|
+
* has used: `Unrecognized request argument supplied: min_p` (or `arguments`, listing several) and
|
|
167
|
+
* `Unknown parameter: 'min_p'.` A nested name — `'chat_template_kwargs.enable_thinking'` — is
|
|
168
|
+
* answered at its top-level field, which is the only level a body builder leaves things out at.
|
|
169
|
+
*/
|
|
170
|
+
function unknownFields(detail) {
|
|
171
|
+
const listed = detail.match(/unrecognized request arguments? supplied:\s*([^\n]+)/i)?.[1];
|
|
172
|
+
const quoted = detail.match(/unknown parameter:\s*(['"`])([^'"`]+)\1/i)?.[2];
|
|
173
|
+
const names = listed ? listed.split(",") : quoted ? [quoted] : [];
|
|
174
|
+
return names
|
|
175
|
+
.map((name) => name
|
|
176
|
+
.trim()
|
|
177
|
+
.replace(/^['"`]|['"`.]+$/g, "")
|
|
178
|
+
.split(/[.[]/)[0])
|
|
179
|
+
.filter(Boolean);
|
|
180
|
+
}
|
|
181
|
+
/** Whether a refusal names a droppable field this model has not already refused. */
|
|
182
|
+
const refusesDroppable = (detail, droppable, refused) => droppable.size > 0 &&
|
|
183
|
+
unknownFields(detail).some((field) => droppable.has(field) && !refused.refusedFields.has(field));
|
|
141
184
|
/**
|
|
142
185
|
* Sends a request, re-sending it each time the answer is this endpoint refusing something the
|
|
143
186
|
* request can do without. Returns once the endpoint has answered, or throws if the refusal is
|
|
@@ -172,7 +215,8 @@ const refusesChosenTemperature = (detail) => NAMES_TEMPERATURE.test(detail) && /
|
|
|
172
215
|
* @param options `produced` for a caller with its own retry budget, `onNotice` for a watcher,
|
|
173
216
|
* `model` to negotiate the model's refusals alongside the endpoint's.
|
|
174
217
|
*/
|
|
175
|
-
export async function negotiate(supports, send, { produced = { any: false }, onNotice, model: name } = {}) {
|
|
218
|
+
export async function negotiate(supports, send, { produced = { any: false }, onNotice, model: name, droppable } = {}) {
|
|
219
|
+
const optional = new Set(droppable);
|
|
176
220
|
// The name and what it has refused, bound together because the notices below need both. They
|
|
177
221
|
// exist or are absent as one — the second is resolved from the first — but that is a fact
|
|
178
222
|
// about two locals, and narrowing one of those tells TypeScript nothing about the other.
|
|
@@ -211,6 +255,16 @@ export async function negotiate(supports, send, { produced = { any: false }, onN
|
|
|
211
255
|
named.refused.chosenTemperature = false;
|
|
212
256
|
onNotice?.(`${named.name} takes only its own temperature; retrying without ours`);
|
|
213
257
|
}
|
|
258
|
+
else if (named?.refused.structuredOutput && rejectsResponseFormat(detail)) {
|
|
259
|
+
named.refused.structuredOutput = false;
|
|
260
|
+
onNotice?.(`${named.name} does not take response_format; asking for JSON in words instead`);
|
|
261
|
+
}
|
|
262
|
+
else if (named && refusesDroppable(detail, optional, named.refused)) {
|
|
263
|
+
const fields = unknownFields(detail).filter((field) => optional.has(field) && !named.refused.refusedFields.has(field));
|
|
264
|
+
for (const field of fields)
|
|
265
|
+
named.refused.refusedFields.add(field);
|
|
266
|
+
onNotice?.(`${named.name} does not take ${fields.join(", ")}; retrying without ${fields.length > 1 ? "them" : "it"}`);
|
|
267
|
+
}
|
|
214
268
|
else if (flagsOf(supports, model).every((flag, index) => flag === sent[index])) {
|
|
215
269
|
throw error;
|
|
216
270
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -54,6 +54,19 @@ export declare const endpointKey: (config: {
|
|
|
54
54
|
baseUrl: string;
|
|
55
55
|
apiKey?: string;
|
|
56
56
|
}) => string;
|
|
57
|
+
/**
|
|
58
|
+
* `endpointKey` hashed, for the remembered facts that can leave the process.
|
|
59
|
+
*
|
|
60
|
+
* What an endpoint refused is exported by `exportCapabilities` to be written into a settings row
|
|
61
|
+
* or a file, and a key inside that blob is a credential copied somewhere nobody meant to keep one.
|
|
62
|
+
* A digest identifies the same endpoint on the next boot without saying what the key was.
|
|
63
|
+
*
|
|
64
|
+
* @param config Read for `baseUrl` and `apiKey` alone, as `endpointKey` reads it.
|
|
65
|
+
*/
|
|
66
|
+
export declare const endpointId: (config: {
|
|
67
|
+
baseUrl: string;
|
|
68
|
+
apiKey?: string;
|
|
69
|
+
}) => string;
|
|
57
70
|
/**
|
|
58
71
|
* Asks an endpoint what it serves, and remembers the answer.
|
|
59
72
|
*
|
package/dist/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import OpenAI from "openai";
|
|
2
3
|
/**
|
|
3
4
|
* The SDK insists on a non-empty key even where the server will not look at it. This is what it
|
|
@@ -161,6 +162,16 @@ const LISTING_MISS_MS = 30_000;
|
|
|
161
162
|
* it; see `listings`.
|
|
162
163
|
*/
|
|
163
164
|
export const endpointKey = (config) => JSON.stringify([config.baseUrl, config.apiKey || NO_KEY]);
|
|
165
|
+
/**
|
|
166
|
+
* `endpointKey` hashed, for the remembered facts that can leave the process.
|
|
167
|
+
*
|
|
168
|
+
* What an endpoint refused is exported by `exportCapabilities` to be written into a settings row
|
|
169
|
+
* or a file, and a key inside that blob is a credential copied somewhere nobody meant to keep one.
|
|
170
|
+
* A digest identifies the same endpoint on the next boot without saying what the key was.
|
|
171
|
+
*
|
|
172
|
+
* @param config Read for `baseUrl` and `apiKey` alone, as `endpointKey` reads it.
|
|
173
|
+
*/
|
|
174
|
+
export const endpointId = (config) => createHash("sha256").update(endpointKey(config)).digest("hex");
|
|
164
175
|
/**
|
|
165
176
|
* Asks an endpoint what it serves, and remembers the answer.
|
|
166
177
|
*
|