@cubicecho/agent-core 2.24.0 → 2.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/dist/agent-loop.d.ts +84 -205
- package/dist/agent-loop.js +288 -597
- package/dist/calibration.d.ts +14 -10
- package/dist/calibration.js +30 -23
- package/dist/capabilities.d.ts +111 -42
- package/dist/capabilities.js +281 -138
- package/dist/client.d.ts +104 -35
- package/dist/client.js +205 -74
- package/dist/compaction.d.ts +103 -60
- package/dist/compaction.js +160 -86
- package/dist/config.d.ts +48 -12
- package/dist/config.js +20 -1
- package/dist/continuation.d.ts +24 -16
- package/dist/continuation.js +70 -44
- package/dist/digest.d.ts +7 -0
- package/dist/digest.js +18 -0
- package/dist/errors.d.ts +24 -15
- package/dist/errors.js +23 -15
- package/dist/events.d.ts +100 -63
- package/dist/events.js +250 -120
- package/dist/guards.d.ts +49 -4
- package/dist/guards.js +60 -5
- package/dist/hook-events.d.ts +21 -5
- package/dist/hook-events.js +23 -9
- package/dist/hooks.d.ts +96 -73
- package/dist/hooks.js +202 -116
- package/dist/index.d.ts +30 -26
- package/dist/index.js +29 -24
- package/dist/ledger.d.ts +62 -30
- package/dist/ledger.js +163 -56
- package/dist/platform.d.ts +15 -0
- package/dist/platform.js +15 -0
- package/dist/preselect.d.ts +223 -0
- package/dist/preselect.js +291 -0
- package/dist/request-body.d.ts +32 -0
- package/dist/request-body.js +47 -0
- package/dist/reset.d.ts +1 -0
- package/dist/reset.js +7 -6
- package/dist/retry.d.ts +44 -138
- package/dist/retry.js +83 -281
- package/dist/run-calls.d.ts +123 -0
- package/dist/run-calls.js +351 -0
- package/dist/run-turn.d.ts +36 -15
- package/dist/run-turn.js +74 -37
- package/dist/runtime.d.ts +23 -17
- package/dist/runtime.js +34 -24
- package/dist/schema-compat.d.ts +35 -18
- package/dist/schema-compat.js +346 -176
- package/dist/scope.d.ts +28 -5
- package/dist/scope.js +37 -10
- package/dist/side-task.d.ts +124 -40
- package/dist/side-task.js +186 -66
- package/dist/snapshot.d.ts +10 -5
- package/dist/snapshot.js +73 -30
- package/dist/spec.d.ts +56 -25
- package/dist/spec.js +494 -215
- package/dist/stream.d.ts +66 -16
- package/dist/stream.js +220 -97
- package/dist/thinking.d.ts +23 -8
- package/dist/thinking.js +92 -44
- package/dist/tokens.d.ts +164 -6
- package/dist/tokens.js +319 -6
- package/dist/tool-calls.d.ts +90 -17
- package/dist/tool-calls.js +207 -84
- package/dist/tool-loading.d.ts +101 -189
- package/dist/tool-loading.js +257 -371
- package/dist/wire.d.ts +81 -0
- package/dist/wire.js +73 -0
- package/llms.txt +76 -39
- package/package.json +4 -4
package/dist/client.js
CHANGED
|
@@ -1,24 +1,46 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import { isPositive } from
|
|
4
|
-
import {
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import { digestOf } from './digest.js';
|
|
3
|
+
import { byCodeUnit, 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 =
|
|
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
|
-
*
|
|
14
|
+
* A wait in the SDK's spelling: milliseconds, and `undefined` where zero or less means no limit.
|
|
15
15
|
*
|
|
16
|
-
* @param
|
|
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
|
-
|
|
95
|
-
if (isPositive(value))
|
|
96
|
-
poolLimits()[name] = value;
|
|
97
|
-
}
|
|
119
|
+
const inForce = assignSettings(poolLimits(), options);
|
|
98
120
|
evict();
|
|
99
|
-
return
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
/**
|
|
245
|
-
|
|
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) =>
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
286
|
-
|
|
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([
|
|
353
|
+
const NOT_THERE = new Set([
|
|
354
|
+
HttpStatus.NotFound,
|
|
355
|
+
HttpStatus.MethodNotAllowed,
|
|
356
|
+
HttpStatus.NotImplemented,
|
|
357
|
+
]);
|
|
289
358
|
/**
|
|
290
|
-
* Asks one native endpoint.
|
|
291
|
-
*
|
|
292
|
-
*
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
377
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
466
|
+
}
|
|
467
|
+
models.push({ id: model.id, contextLength: window.contextLength });
|
|
468
|
+
}
|
|
469
|
+
models.sort((a, b) => byCodeUnit(a.id, 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
|
-
|
|
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
|
+
}
|