@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.
Files changed (71) hide show
  1. package/README.md +10 -7
  2. package/dist/agent-loop.d.ts +84 -205
  3. package/dist/agent-loop.js +288 -597
  4. package/dist/calibration.d.ts +14 -10
  5. package/dist/calibration.js +30 -23
  6. package/dist/capabilities.d.ts +111 -42
  7. package/dist/capabilities.js +281 -138
  8. package/dist/client.d.ts +104 -35
  9. package/dist/client.js +205 -74
  10. package/dist/compaction.d.ts +103 -60
  11. package/dist/compaction.js +160 -86
  12. package/dist/config.d.ts +48 -12
  13. package/dist/config.js +20 -1
  14. package/dist/continuation.d.ts +24 -16
  15. package/dist/continuation.js +70 -44
  16. package/dist/digest.d.ts +7 -0
  17. package/dist/digest.js +18 -0
  18. package/dist/errors.d.ts +24 -15
  19. package/dist/errors.js +23 -15
  20. package/dist/events.d.ts +100 -63
  21. package/dist/events.js +250 -120
  22. package/dist/guards.d.ts +35 -4
  23. package/dist/guards.js +38 -5
  24. package/dist/hook-events.d.ts +21 -5
  25. package/dist/hook-events.js +23 -9
  26. package/dist/hooks.d.ts +96 -73
  27. package/dist/hooks.js +202 -116
  28. package/dist/index.d.ts +30 -26
  29. package/dist/index.js +29 -24
  30. package/dist/ledger.d.ts +62 -30
  31. package/dist/ledger.js +163 -56
  32. package/dist/platform.d.ts +13 -0
  33. package/dist/platform.js +13 -0
  34. package/dist/preselect.d.ts +222 -0
  35. package/dist/preselect.js +290 -0
  36. package/dist/request-body.d.ts +32 -0
  37. package/dist/request-body.js +47 -0
  38. package/dist/reset.d.ts +1 -0
  39. package/dist/reset.js +7 -6
  40. package/dist/retry.d.ts +29 -138
  41. package/dist/retry.js +69 -282
  42. package/dist/run-calls.d.ts +123 -0
  43. package/dist/run-calls.js +351 -0
  44. package/dist/run-turn.d.ts +36 -15
  45. package/dist/run-turn.js +74 -37
  46. package/dist/runtime.d.ts +23 -17
  47. package/dist/runtime.js +34 -24
  48. package/dist/schema-compat.d.ts +35 -18
  49. package/dist/schema-compat.js +340 -175
  50. package/dist/scope.d.ts +28 -5
  51. package/dist/scope.js +37 -10
  52. package/dist/side-task.d.ts +123 -40
  53. package/dist/side-task.js +181 -63
  54. package/dist/snapshot.d.ts +10 -5
  55. package/dist/snapshot.js +73 -30
  56. package/dist/spec.d.ts +56 -25
  57. package/dist/spec.js +494 -215
  58. package/dist/stream.d.ts +66 -16
  59. package/dist/stream.js +220 -97
  60. package/dist/thinking.d.ts +23 -8
  61. package/dist/thinking.js +92 -44
  62. package/dist/tokens.d.ts +164 -6
  63. package/dist/tokens.js +319 -6
  64. package/dist/tool-calls.d.ts +82 -17
  65. package/dist/tool-calls.js +198 -83
  66. package/dist/tool-loading.d.ts +101 -189
  67. package/dist/tool-loading.js +277 -371
  68. package/dist/wire.d.ts +81 -0
  69. package/dist/wire.js +73 -0
  70. package/llms.txt +76 -39
  71. package/package.json +4 -4
package/dist/client.js CHANGED
@@ -1,24 +1,46 @@
1
- import { createHash } from "node:crypto";
2
- import OpenAI from "openai";
3
- import { isPositive } from "./guards.js";
4
- import { scoped } from "./scope.js";
1
+ import OpenAI from 'openai';
2
+ import { digestOf } from './digest.js';
3
+ import { isPositive } from './guards.js';
4
+ import { MS_PER_SECOND } from './platform.js';
5
+ import { assignSettings, scoped } from './scope.js';
6
+ import { HttpStatus } from './wire.js';
5
7
  /**
6
8
  * The SDK insists on a non-empty key even where the server will not look at it. This is what it
7
9
  * gets. It also reads as "this endpoint has no key" at a call site, which an empty string does
8
10
  * not — a caller must not let a local endpoint silently borrow the key meant for a paid one.
9
11
  */
10
- export const NO_KEY = "agent-core";
11
- /** A wait in the SDK's spelling: milliseconds, and `undefined` where zero or less means no limit. */
12
- const limitMs = (seconds) => (seconds > 0 ? seconds * 1000 : undefined);
12
+ export const NO_KEY = 'agent-core';
13
13
  /**
14
- * Zero, less, or absent means no limit, which the SDK spells as `undefined`.
14
+ * A wait in the SDK's spelling: milliseconds, and `undefined` where zero or less means no limit.
15
15
  *
16
- * @param config Read for `requestTimeoutSeconds` alone.
16
+ * @param seconds - The wait as a config states it.
17
+ * @returns Milliseconds, or `undefined` for zero or less.
18
+ */
19
+ const limitMs = (seconds) => (seconds > 0 ? seconds * MS_PER_SECOND : undefined);
20
+ /**
21
+ * An endpoint's request timeout in the SDK's spelling: milliseconds, and `undefined` for no limit.
22
+ *
23
+ * @param config - Read for `requestTimeoutSeconds` alone. Zero, less, or absent means no limit.
24
+ * @returns The request timeout in milliseconds, or `undefined` for no limit.
17
25
  */
18
26
  export const timeoutMs = (config) => limitMs(config.requestTimeoutSeconds ?? 0);
27
+ /**
28
+ * How long to wait out a model that is still loading, in milliseconds — `undefined` where the
29
+ * config has no opinion, and zero where it says not to wait at all.
30
+ *
31
+ * @param config - Read for `loadingTimeoutSeconds` alone. Zero or less is no wait.
32
+ * @returns Milliseconds: `undefined` only where the field is absent, and zero where it is zero or
33
+ * less.
34
+ *
35
+ * @remarks
36
+ * Zero rather than the SDK's `undefined` for "no wait", because here absent already means
37
+ * something else: `runTurn`'s own default.
38
+ */
39
+ export const loadingMs = (config) => config.loadingTimeoutSeconds === undefined ? undefined : (limitMs(config.loadingTimeoutSeconds) ?? 0);
19
40
  /**
20
41
  * A client per endpoint, made once and kept.
21
42
  *
43
+ * @remarks
22
44
  * The SDK holds its own connection pool, and a run makes a request per tool iteration on top of
23
45
  * whatever side tasks it asks for — building a fresh client for each of them throws that pool
24
46
  * away every time. A map rather than the single slot it would otherwise be, because agents each
@@ -36,6 +58,7 @@ const clients = scoped(() => new Map());
36
58
  /**
37
59
  * How many endpoints' clients are kept at once, until `configureClients` moves it.
38
60
  *
61
+ * @remarks
39
62
  * This is a backstop rather than a design. The key includes the API key, and every argument in
40
63
  * this file for what bounds these caches is "a settings row's worth" — true of the deployments
41
64
  * this was written for, and false the moment a consumer mints a key per *user*, where the map
@@ -65,8 +88,9 @@ const poolLimits = scoped(() => ({ ...CLIENT_DEFAULTS }));
65
88
  */
66
89
  const evict = () => {
67
90
  for (const oldest of clients().keys()) {
68
- if (clients().size <= poolLimits().maxClients)
91
+ if (clients().size <= poolLimits().maxClients) {
69
92
  break;
93
+ }
70
94
  clients().delete(oldest);
71
95
  }
72
96
  };
@@ -74,6 +98,13 @@ const evict = () => {
74
98
  * Changes what the client pool is held to, for a process whose endpoints are not shaped like the
75
99
  * deployments these defaults were chosen for.
76
100
  *
101
+ * @param [options] - The bounds to change. A field left out — or given anything that is not a number
102
+ * above zero — keeps what it has, so a half-built config narrows nothing. `Infinity` is a number
103
+ * above zero: as `maxClients` it lifts the bound, and as `listingMissMs` a miss is never asked
104
+ * about again until `resetClients`.
105
+ * @returns Everything in force afterwards, including what this call did not change.
106
+ *
107
+ * @remarks
77
108
  * One setting per runtime for the same reason `configureEvents` is: the pool is one thing per
78
109
  * runtime, and its size is a deployment's setting, said once at startup. A multi-tenant host
79
110
  * keying on a key per user raises `maxClients` so its tenants stop evicting each other's
@@ -83,27 +114,20 @@ const evict = () => {
83
114
  * A `maxClients` below the pool's current size evicts down to it at once, least recently asked
84
115
  * for first, as the next `getClient` would have. A shorter `listingMissMs` applies to misses
85
116
  * already remembered, since each is a timestamp compared against it when read.
86
- *
87
- * @param options The bounds to change. A field left out — or given anything that is not a number
88
- * above zero — keeps what it has, so a half-built config narrows nothing. `Infinity` is a number
89
- * above zero: as `maxClients` it lifts the bound, and as `listingMissMs` a miss is never asked
90
- * about again until `resetClients`.
91
- * @returns Everything in force afterwards, including what this call did not change.
92
117
  */
93
118
  export function configureClients(options = {}) {
94
- for (const [name, value] of Object.entries(options)) {
95
- if (isPositive(value))
96
- poolLimits()[name] = value;
97
- }
119
+ const inForce = assignSettings(poolLimits(), options);
98
120
  evict();
99
- return { ...poolLimits() };
121
+ return inForce;
100
122
  }
101
123
  /** How many idle windows the first chunk gets when `firstTokenSeconds` is not given. */
102
124
  export const FIRST_TOKEN_FACTOR = 5;
103
125
  /**
104
126
  * The wait for a streamed turn's first chunk, in the SDK's spelling: `undefined` is no limit.
105
127
  *
106
- * @param config Read for `firstTokenSeconds`, and `requestTimeoutSeconds` where that is absent.
128
+ * @param config - Read for `firstTokenSeconds`, and `requestTimeoutSeconds` where that is absent.
129
+ * @returns Milliseconds. `firstTokenSeconds` where it is given, zero or less being no limit;
130
+ * otherwise `FIRST_TOKEN_FACTOR` request timeouts, and no limit where the request has none.
107
131
  */
108
132
  export const firstTokenMs = (config) => {
109
133
  if (config.firstTokenSeconds === undefined) {
@@ -115,6 +139,11 @@ export const firstTokenMs = (config) => {
115
139
  /**
116
140
  * The client for an endpoint, built once and kept.
117
141
  *
142
+ * @param config - Where to send requests and how long to wait. An absent `apiKey` becomes `NO_KEY`.
143
+ * @returns The pooled client: the same object for the same base URL, key and timeout, until it is
144
+ * evicted or `resetClients` runs. Asking for it makes it the last in line to be evicted.
145
+ *
146
+ * @remarks
118
147
  * Pooled on the three fields that change how a request is sent, so agents sharing a server share
119
148
  * a connection and agents on different servers never share a client.
120
149
  *
@@ -124,8 +153,6 @@ export const firstTokenMs = (config) => {
124
153
  * connection pools rather than sharing them and holding every one of them — `maxClients` keeps
125
154
  * that from being unbounded, and it is the point at which a client of your own, built and held
126
155
  * per tenant, is the better answer than this.
127
- *
128
- * @param config Where to send requests and how long to wait. An absent `apiKey` becomes `NO_KEY`.
129
156
  */
130
157
  export function getClient(config) {
131
158
  const apiKey = config.apiKey || NO_KEY;
@@ -149,23 +176,30 @@ export function getClient(config) {
149
176
  /**
150
177
  * The context window, spelled every way a server spells it at the top of a listing entry.
151
178
  *
179
+ * @remarks
152
180
  * None of these is in the OpenAI listing schema, so every server that says anything says it as
153
181
  * an extra key of its own: `max_model_len` is vLLM, `context_length` OpenRouter, `n_ctx` the raw
154
182
  * llama bindings. Whichever turns up first is taken — a server reporting two of them is
155
183
  * reporting the same number twice. llama.cpp puts its number under `meta` instead, and LM Studio
156
184
  * and Ollama put none on this route at all; see `servedWindow`.
157
185
  */
158
- const CONTEXT_KEYS = [
159
- "context_length",
160
- "max_context_window",
161
- "max_model_len",
162
- "context_window",
163
- "n_ctx",
164
- ];
186
+ const CONTEXT_KEYS = ['context_length', 'max_context_window', 'max_model_len', 'context_window', 'n_ctx'];
187
+ /**
188
+ * A number above zero as itself and anything else as zero, which here means "not stated".
189
+ *
190
+ * @param value - Whatever a server put under a key. Absent, a string and a negative all read as
191
+ * zero.
192
+ * @returns The number, or 0.
193
+ */
165
194
  const positive = (value) => (isPositive(value) ? value : 0);
166
195
  /**
167
196
  * A listing entry's window, and whether it is only the one the model was trained with.
168
197
  *
198
+ * @param model - A listing entry as the server sent it, extra keys included. Not written to.
199
+ * @returns The window in tokens, and `trained` where it came from `meta.n_ctx_train`. An entry
200
+ * that states neither answers zero and `false`.
201
+ *
202
+ * @remarks
169
203
  * The two are told apart because they are trusted differently: a top-level key is taken as the
170
204
  * window being served and settles the lookup, and a trained one is a ceiling the server may be
171
205
  * running well under, so `contextLimitFor` still asks the native routes before settling for it.
@@ -174,8 +208,9 @@ function contextLengthOf(model) {
174
208
  const record = model;
175
209
  for (const key of CONTEXT_KEYS) {
176
210
  const value = positive(record[key]);
177
- if (value)
211
+ if (value) {
178
212
  return { contextLength: value, trained: false };
213
+ }
179
214
  }
180
215
  // llama.cpp's, and the window the model was trained with rather than the one it is served in:
181
216
  // a 256k model started at `-c 16384` lists 262144 here. Better than nothing, and why the
@@ -187,6 +222,7 @@ function contextLengthOf(model) {
187
222
  /**
188
223
  * The last listing from each endpoint, so a run can size its window without a round trip.
189
224
  *
225
+ * @remarks
190
226
  * Keyed by `endpointKey`, because two endpoints are two different sets of models and one of
191
227
  * them having answered says nothing about the other — and because a key can be the difference
192
228
  * between what a router will show one caller and another.
@@ -202,6 +238,7 @@ const listings = scoped(() => new Map());
202
238
  /**
203
239
  * The models in each endpoint's last listing whose window is only the trained one, by id.
204
240
  *
241
+ * @remarks
205
242
  * Kept beside `listings` rather than on `ModelInfo`, so what `listModels` hands back is the shape
206
243
  * it has always been. Replaced with each listing that lands, and left alone by one that fails.
207
244
  */
@@ -209,6 +246,7 @@ const trainedOnly = scoped(() => new Map());
209
246
  /**
210
247
  * When an endpoint was last asked about a model it did not name, keyed on the two together.
211
248
  *
249
+ * @remarks
212
250
  * `contextLimitFor` asks again whenever the listing does not hold the model, which is right for
213
251
  * a model that arrives late and wrong for one that is never coming. The second is the ordinary
214
252
  * case rather than the exotic one — a llama.cpp box served under a `-a` alias that does not
@@ -225,6 +263,13 @@ const misses = scoped(() => new Map());
225
263
  * What counts as one endpoint, everywhere in this package that has to remember something about
226
264
  * one — the model listings, `capabilities`, and the no-thinking hints in `side-task`.
227
265
  *
266
+ * @param config - Read for `baseUrl` and `apiKey` alone, and the key is optional here where
267
+ * `Endpoint` requires it — `capabilitiesFor` is handed a URL and maybe a key rather than a whole
268
+ * config, and absent and empty already mean the same thing. The timeout is deliberately not in
269
+ * it; see `listings`.
270
+ * @returns The URL and the key as one JSON string, the key in the clear.
271
+ *
272
+ * @remarks
228
273
  * The URL and the key together, because the key is part of what is on the other end rather than
229
274
  * only how it is paid for: a router is free to send two keys to two different backends, and then
230
275
  * what one of them refused is not a fact about the other. Absent reads as `NO_KEY`, so an
@@ -234,29 +279,36 @@ const misses = scoped(() => new Map());
234
279
  * is impossible in a URL or a key, and two endpoints must never collide on one entry. A host
235
280
  * keeping its own per-endpoint state keys it on this rather than on a copy of it, so the two
236
281
  * cannot drift apart. It holds the key in the clear; `endpointId` is the one to write down.
237
- *
238
- * @param config Read for `baseUrl` and `apiKey` alone, and the key is optional here where
239
- * `Endpoint` requires it — `capabilitiesFor` is handed a URL and maybe a key rather than a whole
240
- * config, and absent and empty already mean the same thing. The timeout is deliberately not in
241
- * it; see `listings`.
242
282
  */
243
283
  export const endpointKey = (config) => JSON.stringify([config.baseUrl, config.apiKey || NO_KEY]);
244
- /** One model on one endpoint, as the caches below key it: stringified, so neither half runs into the other. */
245
- const modelKey = (endpoint, model) => JSON.stringify([endpoint, model]);
284
+ /**
285
+ * One model on one endpoint, as every per-model cache keys it: stringified, so neither half runs
286
+ * into the other.
287
+ *
288
+ * @param endpoint - Whatever the cache names an endpoint by — `endpointKey` here, `endpointId`
289
+ * where the key may be written down.
290
+ * @param model - The model's name as the endpoint knows it.
291
+ * @returns The pair as a JSON array, which `JSON.parse` takes apart again.
292
+ */
293
+ export const modelKey = (endpoint, model) => JSON.stringify([endpoint, model]);
246
294
  /**
247
295
  * `endpointKey` hashed, for the remembered facts that can leave the process.
248
296
  *
297
+ * @param config - Read for `baseUrl` and `apiKey` alone, as `endpointKey` reads it.
298
+ * @returns The digest of that endpoint's `endpointKey`: the same for the same URL and key in any
299
+ * process, and no key to be read out of it.
300
+ *
301
+ * @remarks
249
302
  * What an endpoint refused is exported by `exportCapabilities` to be written into a settings row
250
303
  * or a file, and a key inside that blob is a credential copied somewhere nobody meant to keep one.
251
304
  * A digest identifies the same endpoint on the next boot without saying what the key was, and it
252
305
  * is how a host finds its own endpoint's entry in a `CapabilitySnapshot`.
253
- *
254
- * @param config Read for `baseUrl` and `apiKey` alone, as `endpointKey` reads it.
255
306
  */
256
- export const endpointId = (config) => createHash("sha256").update(endpointKey(config)).digest("hex");
307
+ export const endpointId = (config) => digestOf(endpointKey(config));
257
308
  /**
258
309
  * Served windows found by asking a server's own API, keyed on endpoint and model together.
259
310
  *
311
+ * @remarks
260
312
  * A model's entry stays until `resetClients`, like a listing; a server that answered without one
261
313
  * is asked again after `listingMissMs`, like a listing that did not name the model.
262
314
  */
@@ -266,6 +318,7 @@ const unserved = scoped(() => new Set());
266
318
  /**
267
319
  * Endpoints whose `/api/v0/models` answered with an error, and when.
268
320
  *
321
+ * @remarks
269
322
  * `gone` is a route the server does not have (`NOT_THERE`), which is a fact about the server and
270
323
  * stands until `resetClients`. Any other error stands for `listingMissMs` only, because it may be
271
324
  * one the server gets over, and LM Studio latched for good on a passing 5xx would have no window
@@ -275,21 +328,45 @@ const unserved = scoped(() => new Set());
275
328
  * window is never `unserved`, and without this its missing route was asked again with every miss.
276
329
  */
277
330
  const refusals = scoped(() => new Map());
278
- /** Whether `/api/v0/models` is to be left alone on this endpoint for now. */
331
+ /**
332
+ * Whether `/api/v0/models` is to be left alone on this endpoint for now.
333
+ *
334
+ * @param endpoint - The endpoint, by `endpointKey`.
335
+ * @returns `true` where the route last answered as one the server does not have, or with any other
336
+ * error inside `listingMissMs`; `false` for an endpoint with no error on record.
337
+ */
279
338
  const refused = (endpoint) => {
280
339
  const refusal = refusals().get(endpoint);
281
- return (refusal !== undefined && (refusal.gone || Date.now() - refusal.at < poolLimits().listingMissMs));
340
+ return refusal !== undefined && (refusal.gone || Date.now() - refusal.at < poolLimits().listingMissMs);
282
341
  };
283
342
  /** How long a probe may take before the window is taken from the listing instead. */
284
343
  const PROBE_TIMEOUT_MS = 10_000;
285
- /** The server root behind an OpenAI-compatible base URL, which is where the native APIs live. */
286
- const rootOf = (baseUrl) => baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
344
+ /**
345
+ * The server root behind an OpenAI-compatible base URL, which is where the native APIs live.
346
+ *
347
+ * @param baseUrl - The base URL as configured. Not trimmed of whitespace.
348
+ * @returns The URL less its trailing slashes and then one trailing `/v1`; one that ends in neither
349
+ * comes back as given.
350
+ */
351
+ const rootOf = (baseUrl) => baseUrl.replace(/\/+$/, '').replace(/\/v1$/, '');
287
352
  /** A route this server does not have, rather than one that failed to answer. */
288
- const NOT_THERE = new Set([404, 405, 501]);
353
+ const NOT_THERE = new Set([
354
+ HttpStatus.NotFound,
355
+ HttpStatus.MethodNotAllowed,
356
+ HttpStatus.NotImplemented,
357
+ ]);
289
358
  /**
290
- * Asks one native endpoint. `failed` is any answer that was not a 2xx and `missing` the ones among
291
- * them that mean a server without the route; `body` is absent for both, and for a 2xx that was not
292
- * JSON. A server that could not be reached throws.
359
+ * Asks one native endpoint.
360
+ *
361
+ * @param config - The endpoint. Its key goes as a bearer token where it has one, and its request
362
+ * timeout bounds the wait — `PROBE_TIMEOUT_MS` where it has none.
363
+ * @param path - The route and its query, from the server root rather than from the base URL.
364
+ * @returns How the server answered, with `body` only for a 2xx that parsed as JSON.
365
+ *
366
+ * @remarks
367
+ * `failed` is any answer that was not a 2xx and `missing` the ones among them that mean a server
368
+ * without the route; `body` is absent for both, and for a 2xx that was not JSON. A server that
369
+ * could not be reached throws.
293
370
  */
294
371
  async function probe(config, path) {
295
372
  const apiKey = config.apiKey || undefined;
@@ -297,8 +374,9 @@ async function probe(config, path) {
297
374
  headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {},
298
375
  signal: AbortSignal.timeout(timeoutMs(config) ?? PROBE_TIMEOUT_MS),
299
376
  });
300
- if (!response.ok)
377
+ if (!response.ok) {
301
378
  return { failed: true, missing: NOT_THERE.has(response.status) };
379
+ }
302
380
  try {
303
381
  return { failed: false, missing: false, body: await response.json() };
304
382
  }
@@ -310,6 +388,11 @@ async function probe(config, path) {
310
388
  * The window a local server's own API says it is serving a model in, for the servers whose
311
389
  * listing does not say.
312
390
  *
391
+ * @param config - The endpoint, plus the model whose window is wanted.
392
+ * @returns The window in tokens, or zero where none was found. Never rejects: a server that could
393
+ * not be reached answers zero too.
394
+ *
395
+ * @remarks
313
396
  * llama.cpp reports it on `/props` as `default_generation_settings.n_ctx`, per slot, and LM
314
397
  * Studio on `/api/v0/models` as `loaded_context_length` while the model is loaded. Both differ
315
398
  * from the trained window in the case that matters, a model started in a smaller one than it was
@@ -322,31 +405,32 @@ async function probe(config, path) {
322
405
  * other error. A server that could not be reached is not remembered at all.
323
406
  *
324
407
  * The listing is not read here; `contextLimitFor` reads it first and comes here after.
325
- *
326
- * @param config The endpoint, plus the model whose window is wanted.
327
408
  */
328
409
  export async function servedWindow(config) {
329
410
  const endpoint = endpointKey(config);
330
- if (unserved().has(endpoint))
411
+ if (unserved().has(endpoint)) {
331
412
  return 0;
413
+ }
332
414
  const key = modelKey(endpoint, config.model);
333
415
  const known = served().get(key);
334
- if (known && (known.window > 0 || Date.now() - known.at < poolLimits().listingMissMs))
416
+ if (known && (known.window > 0 || Date.now() - known.at < poolLimits().listingMissMs)) {
335
417
  return known.window;
418
+ }
336
419
  let window = 0;
337
420
  try {
338
421
  // Named, for a llama.cpp router serving several models; a single-model server ignores it.
339
422
  const props = await probe(config, `/props?model=${encodeURIComponent(config.model)}`);
340
- const settings = props.body
341
- ?.default_generation_settings;
423
+ const settings = props.body?.default_generation_settings;
342
424
  window = positive(settings?.n_ctx);
343
425
  if (!window) {
344
426
  if (!refused(endpoint)) {
345
- const lmstudio = await probe(config, "/api/v0/models");
346
- if (lmstudio.failed)
427
+ const lmstudio = await probe(config, '/api/v0/models');
428
+ if (lmstudio.failed) {
347
429
  refusals().set(endpoint, { at: Date.now(), gone: lmstudio.missing });
348
- else
430
+ }
431
+ else {
349
432
  refusals().delete(endpoint);
433
+ }
350
434
  const { data } = (lmstudio.body ?? {});
351
435
  const entry = Array.isArray(data)
352
436
  ? data.find((model) => model?.id === config.model)
@@ -368,19 +452,21 @@ export async function servedWindow(config) {
368
452
  /**
369
453
  * Asks an endpoint what it serves, and remembers the answer.
370
454
  *
371
- * @param config The endpoint to ask. Remembered per base URL and key, not per model.
455
+ * @param config - The endpoint to ask. Remembered per base URL and key, not per model.
456
+ * @returns The models in order of id, whatever order the endpoint listed them in.
372
457
  */
373
458
  export async function listModels(config) {
374
459
  const { data } = await getClient(config).models.list();
375
460
  const trained = new Set();
376
- const models = data
377
- .map((model) => {
461
+ const models = [];
462
+ for (const model of data) {
378
463
  const window = contextLengthOf(model);
379
- if (window.trained)
464
+ if (window.trained) {
380
465
  trained.add(model.id);
381
- return { id: model.id, contextLength: window.contextLength };
382
- })
383
- .sort((a, b) => a.id.localeCompare(b.id));
466
+ }
467
+ models.push({ id: model.id, contextLength: window.contextLength });
468
+ }
469
+ models.sort((a, b) => a.id.localeCompare(b.id));
384
470
  const key = endpointKey(config);
385
471
  listings().set(key, models);
386
472
  trainedOnly().set(key, trained);
@@ -389,6 +475,12 @@ export async function listModels(config) {
389
475
  /**
390
476
  * How much a model will read, in tokens. Zero means nobody knows.
391
477
  *
478
+ * @param config - The endpoint, plus the model whose window is wanted.
479
+ * @param [declared] - The operator's own number. Above zero it wins and the endpoint is not asked.
480
+ * @returns The window in tokens, or zero. Never rejects: a listing or a native route that failed
481
+ * is an unknown window.
482
+ *
483
+ * @remarks
392
484
  * `declared` is the operator's own number, and it wins outright: an endpoint can report the
393
485
  * window a model was *built* with while serving it in a much smaller one — llama.cpp will
394
486
  * happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
@@ -406,13 +498,11 @@ export async function listModels(config) {
406
498
  * are two more requests that a server stating its window in the listing never needed. Asked
407
499
  * first, they were also asked again every `listingMissMs` for the life of the process on a server
408
500
  * whose `/props` answers without a window, by any host that sizes the window per turn.
409
- *
410
- * @param config The endpoint, plus the model whose window is wanted.
411
- * @param declared The operator's own number. Above zero it wins and the endpoint is not asked.
412
501
  */
413
502
  export async function contextLimitFor(config, declared = 0) {
414
- if (declared > 0)
503
+ if (declared > 0) {
415
504
  return declared;
505
+ }
416
506
  const key = endpointKey(config);
417
507
  const listed = () => listings()
418
508
  .get(key)
@@ -439,10 +529,12 @@ export async function contextLimitFor(config, declared = 0) {
439
529
  // than the interval, which is a memory that never expires rather than one that expires in
440
530
  // half a minute.
441
531
  if (answered) {
442
- if (listed())
532
+ if (listed()) {
443
533
  misses().delete(missKey);
444
- else
534
+ }
535
+ else {
445
536
  misses().set(missKey, Date.now());
537
+ }
446
538
  }
447
539
  }
448
540
  }
@@ -450,8 +542,9 @@ export async function contextLimitFor(config, declared = 0) {
450
542
  const fromListing = entry?.contextLength ?? 0;
451
543
  // A top-level key settles it. A trained window does not: llama.cpp lists the one the model was
452
544
  // built with, and taking it over what `/props` says lets an overflow through the guard.
453
- if (fromListing > 0 && !trainedOnly().get(key)?.has(config.model))
545
+ if (fromListing > 0 && !trainedOnly().get(key)?.has(config.model)) {
454
546
  return fromListing;
547
+ }
455
548
  const window = await servedWindow(config);
456
549
  return window > 0 ? window : fromListing;
457
550
  }
@@ -469,3 +562,41 @@ export function resetClients() {
469
562
  unserved().clear();
470
563
  refusals().clear();
471
564
  }
565
+ /**
566
+ * A base URL as two settings rows would agree on it: trimmed, without the trailing slash.
567
+ *
568
+ * @param a - The URL as one row has it.
569
+ * @param b - The URL as the other has it.
570
+ * @returns `true` where the two differ in nothing but surrounding whitespace and trailing slashes.
571
+ * Case counts, and so does a `/v1`.
572
+ */
573
+ export const sameUrl = (a, b) => a.trim().replace(/\/+$/, '') === b.trim().replace(/\/+$/, '');
574
+ /**
575
+ * The key to send, where an endpoint may inherit one from the settings it overrides.
576
+ *
577
+ * @param own - The endpoint as the agent or profile states it. Its own key always wins. An empty or
578
+ * absent `baseUrl` is one that inherits the endpoint too.
579
+ * @param [inherited] - The settings it overrides. Absent treats `own` as the configured endpoint, so
580
+ * only its key and the environment's are in play.
581
+ * @param [env] - Where `OPENAI_API_KEY` is read from.
582
+ * @returns The key, never empty: `own`'s, else `NO_KEY` for an endpoint other than the inherited
583
+ * one, else the inherited key, then the environment's, then `NO_KEY`.
584
+ *
585
+ * @remarks
586
+ * A credential issued for one endpoint has no business being posted to another. A profile that
587
+ * names its own `baseUrl` and no key of its own is sent `NO_KEY` — not the operator's key, and
588
+ * not `$OPENAI_API_KEY` — because "I pointed an agent at a friend's server and it sent my OpenAI
589
+ * key" is not a mistake worth being able to make, and a local server wants no key anyway. One on
590
+ * the same endpoint inherits the key as it inherits everything else, and the environment is the
591
+ * last word on the endpoint that was configured rather than overridden.
592
+ */
593
+ export function resolveApiKey(own, inherited, env = process.env) {
594
+ if (own.apiKey) {
595
+ return own.apiKey;
596
+ }
597
+ const baseUrl = own.baseUrl?.trim();
598
+ if (inherited && baseUrl && !sameUrl(baseUrl, inherited.baseUrl)) {
599
+ return NO_KEY;
600
+ }
601
+ return inherited?.apiKey || env.OPENAI_API_KEY || NO_KEY;
602
+ }