@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.
@@ -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
+ }
@@ -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. Both optional, both about telling someone. */
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>;
@@ -1,16 +1,16 @@
1
- import { endpointKey } from "./client.js";
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 `endpointKey`, because these are facts about the server on the other end and not
8
- * about this one. A llama.cpp box that cannot compile a grammar and a cloud API that can are
9
- * both reachable from one settings row over its lifetime — an operator retargets it from Ollama
10
- * this afternoon to OpenAI this evening — and the first one's refusal must not quietly strip
11
- * pattern/format from the second one's requests, or silently cost it its token counts, for the
12
- * rest of the process. Bounded by the number of endpoints ever configured, which is a settings
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
- const key = endpointKey({ baseUrl, apiKey });
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 = { reasoningEffort: true, legacyTokenLimit: true, chosenTemperature: true };
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 ? Object.values(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
  *