@cubicecho/agent-core 2.21.0 → 2.21.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 +18 -9
- package/dist/client.d.ts +28 -10
- package/dist/client.js +109 -47
- package/llms.txt +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -279,15 +279,24 @@ const turn = await runTurn(client, supports, build, {
|
|
|
279
279
|
});
|
|
280
280
|
```
|
|
281
281
|
|
|
282
|
-
`contextLimitFor` answers the operator's number when there is one. Otherwise it
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
`
|
|
289
|
-
|
|
290
|
-
|
|
282
|
+
`contextLimitFor` answers the operator's number when there is one. Otherwise it reads the
|
|
283
|
+
`/v1/models` listing first. A window at the top of the model's entry (`context_length`,
|
|
284
|
+
`max_context_window`, `max_model_len`, `context_window` or `n_ctx`: vLLM's `max_model_len`,
|
|
285
|
+
OpenRouter's `context_length`) is taken as the one being served, and nothing else is asked.
|
|
286
|
+
|
|
287
|
+
Where the listing does not settle it, the server's own API is asked (`servedWindow`): llama.cpp's
|
|
288
|
+
`/props` (`default_generation_settings.n_ctx`), then LM Studio's `/api/v0/models`
|
|
289
|
+
(`loaded_context_length`). That covers a model listed with no window, which is LM Studio, and one
|
|
290
|
+
listed with only `meta.n_ctx_train`, which is llama.cpp. The trained window differs from the served
|
|
291
|
+
one in the case the guard exists for, a 256k model started at `-c 16384`, so it is used only when
|
|
292
|
+
neither native route gives a number. A server with neither route is latched and not asked again.
|
|
293
|
+
`/api/v0/models` is also dropped on its own: for the life of the process once it answers 404, 405
|
|
294
|
+
or 501, and for `listingMissMs` after any other error.
|
|
295
|
+
|
|
296
|
+
A top-level listing key is trusted without a second opinion, so on a server that puts a trained
|
|
297
|
+
window there, pass `contextLength`. Ollama reports no window on any route this reads, and truncates
|
|
298
|
+
an over-long prompt rather than refusing it, so on Ollama pass `contextLength` or there is no guard
|
|
299
|
+
at all.
|
|
291
300
|
|
|
292
301
|
What is weighed is the prompt plus the reply ceiling the body carries, under whichever spelling
|
|
293
302
|
was chosen, because that is what the endpoint weighs: a 30k prompt into a 32k window with
|
package/dist/client.d.ts
CHANGED
|
@@ -18,7 +18,8 @@ export interface ClientPoolOptions {
|
|
|
18
18
|
maxClients?: number;
|
|
19
19
|
/**
|
|
20
20
|
* How long, in milliseconds, a model an endpoint did not list — or a server that answered
|
|
21
|
-
* without a served window
|
|
21
|
+
* without a served window, or whose `/api/v0/models` answered with an error other than a
|
|
22
|
+
* missing route — is taken at its word before the endpoint is asked again.
|
|
22
23
|
*/
|
|
23
24
|
listingMissMs?: number;
|
|
24
25
|
}
|
|
@@ -66,7 +67,12 @@ export declare const firstTokenMs: (config: Pick<Endpoint, "requestTimeoutSecond
|
|
|
66
67
|
* @param config Where to send requests and how long to wait. An absent `apiKey` becomes `NO_KEY`.
|
|
67
68
|
*/
|
|
68
69
|
export declare function getClient(config: Endpoint): OpenAI;
|
|
69
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* A model an endpoint offers, and what it says the model will read. Zero means it did not say.
|
|
72
|
+
*
|
|
73
|
+
* The number is whatever the listing carried, which from llama.cpp is the trained window rather
|
|
74
|
+
* than the served one; `contextLimitFor` is the lookup that knows the difference.
|
|
75
|
+
*/
|
|
70
76
|
export interface ModelInfo {
|
|
71
77
|
id: string;
|
|
72
78
|
contextLength: number;
|
|
@@ -109,7 +115,8 @@ export declare const endpointId: (config: {
|
|
|
109
115
|
apiKey?: string;
|
|
110
116
|
}) => string;
|
|
111
117
|
/**
|
|
112
|
-
* The window a local server is
|
|
118
|
+
* The window a local server's own API says it is serving a model in, for the servers whose
|
|
119
|
+
* listing does not say.
|
|
113
120
|
*
|
|
114
121
|
* llama.cpp reports it on `/props` as `default_generation_settings.n_ctx`, per slot, and LM
|
|
115
122
|
* Studio on `/api/v0/models` as `loaded_context_length` while the model is loaded. Both differ
|
|
@@ -117,8 +124,12 @@ export declare const endpointId: (config: {
|
|
|
117
124
|
* built for, and reading the trained one lets an overflow through the guard meant to catch it.
|
|
118
125
|
* Ollama reports nothing on either; an operator there has to declare the window.
|
|
119
126
|
*
|
|
120
|
-
* Zero where no answer was found
|
|
121
|
-
* latched and not asked again
|
|
127
|
+
* Zero where no answer was found, which is remembered for `listingMissMs`. A server without
|
|
128
|
+
* either route is latched and not asked again. `/api/v0/models` is also dropped on its own: until
|
|
129
|
+
* `resetClients` once it answers as a route that is not there, and for `listingMissMs` after any
|
|
130
|
+
* other error. A server that could not be reached is not remembered at all.
|
|
131
|
+
*
|
|
132
|
+
* The listing is not read here; `contextLimitFor` reads it first and comes here after.
|
|
122
133
|
*
|
|
123
134
|
* @param config The endpoint, plus the model whose window is wanted.
|
|
124
135
|
*/
|
|
@@ -139,11 +150,18 @@ export declare function listModels(config: Endpoint): Promise<ModelInfo[]>;
|
|
|
139
150
|
* happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
|
|
140
151
|
* the honest-looking number is a run that fails at the endpoint instead.
|
|
141
152
|
*
|
|
142
|
-
* Otherwise the
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
153
|
+
* Otherwise the endpoint's listing — asked once, and again whenever it does not name this model,
|
|
154
|
+
* since a model can arrive after the first listing was taken. A window stated at the top of the
|
|
155
|
+
* model's entry is taken as the one being served, and nothing else is asked. Where the listing
|
|
156
|
+
* did not settle it — no window, only llama.cpp's trained one, no entry, no listing — the
|
|
157
|
+
* server's own API is asked (`servedWindow`), and what it says wins; the trained window is what
|
|
158
|
+
* is left when it says nothing. A server that will not list models still has to be able to run a
|
|
159
|
+
* turn: a failure here is an unknown window, not a failed run.
|
|
160
|
+
*
|
|
161
|
+
* The listing goes first because it is the one route every server here has, and the native ones
|
|
162
|
+
* are two more requests that a server stating its window in the listing never needed. Asked
|
|
163
|
+
* first, they were also asked again every `listingMissMs` for the life of the process on a server
|
|
164
|
+
* whose `/props` answers without a window, by any host that sizes the window per turn.
|
|
147
165
|
*
|
|
148
166
|
* @param config The endpoint, plus the model whose window is wanted.
|
|
149
167
|
* @param declared The operator's own number. Above zero it wins and the endpoint is not asked.
|
package/dist/client.js
CHANGED
|
@@ -162,18 +162,26 @@ const CONTEXT_KEYS = [
|
|
|
162
162
|
"n_ctx",
|
|
163
163
|
];
|
|
164
164
|
const positive = (value) => (typeof value === "number" && value > 0 ? value : 0);
|
|
165
|
+
/**
|
|
166
|
+
* A listing entry's window, and whether it is only the one the model was trained with.
|
|
167
|
+
*
|
|
168
|
+
* The two are told apart because they are trusted differently: a top-level key is taken as the
|
|
169
|
+
* window being served and settles the lookup, and a trained one is a ceiling the server may be
|
|
170
|
+
* running well under, so `contextLimitFor` still asks the native routes before settling for it.
|
|
171
|
+
*/
|
|
165
172
|
function contextLengthOf(model) {
|
|
166
173
|
const record = model;
|
|
167
174
|
for (const key of CONTEXT_KEYS) {
|
|
168
175
|
const value = positive(record[key]);
|
|
169
176
|
if (value)
|
|
170
|
-
return value;
|
|
177
|
+
return { contextLength: value, trained: false };
|
|
171
178
|
}
|
|
172
179
|
// llama.cpp's, and the window the model was trained with rather than the one it is served in:
|
|
173
180
|
// a 256k model started at `-c 16384` lists 262144 here. Better than nothing, and why the
|
|
174
|
-
// served window is asked for
|
|
181
|
+
// served window is still asked for after it. `meta` is `null` while the model loads.
|
|
175
182
|
const meta = record.meta;
|
|
176
|
-
|
|
183
|
+
const contextLength = positive(meta?.n_ctx_train);
|
|
184
|
+
return { contextLength, trained: contextLength > 0 };
|
|
177
185
|
}
|
|
178
186
|
/**
|
|
179
187
|
* The last listing from each endpoint, so a run can size its window without a round trip.
|
|
@@ -190,6 +198,13 @@ function contextLengthOf(model) {
|
|
|
190
198
|
* whatever was there rather than emptying it.
|
|
191
199
|
*/
|
|
192
200
|
const listings = new Map();
|
|
201
|
+
/**
|
|
202
|
+
* The models in each endpoint's last listing whose window is only the trained one, by id.
|
|
203
|
+
*
|
|
204
|
+
* Kept beside `listings` rather than on `ModelInfo`, so what `listModels` hands back is the shape
|
|
205
|
+
* it has always been. Replaced with each listing that lands, and left alone by one that fails.
|
|
206
|
+
*/
|
|
207
|
+
const trainedOnly = new Map();
|
|
193
208
|
/**
|
|
194
209
|
* When an endpoint was last asked about a model it did not name, keyed on the two together.
|
|
195
210
|
*
|
|
@@ -243,8 +258,25 @@ export const endpointId = (config) => createHash("sha256").update(endpointKey(co
|
|
|
243
258
|
* is asked again after `listingMissMs`, like a listing that did not name the model.
|
|
244
259
|
*/
|
|
245
260
|
const served = new Map();
|
|
246
|
-
/** Endpoints
|
|
261
|
+
/** Endpoints with neither native route, which are not asked for either again. */
|
|
247
262
|
const unserved = new Set();
|
|
263
|
+
/**
|
|
264
|
+
* Endpoints whose `/api/v0/models` answered with an error, and when.
|
|
265
|
+
*
|
|
266
|
+
* `gone` is a route the server does not have (`NOT_THERE`), which is a fact about the server and
|
|
267
|
+
* stands until `resetClients`. Any other error stands for `listingMissMs` only, because it may be
|
|
268
|
+
* one the server gets over, and LM Studio latched for good on a passing 5xx would have no window
|
|
269
|
+
* at all until the process restarted, its listing carrying none.
|
|
270
|
+
*
|
|
271
|
+
* Per endpoint and on its own, whatever `/props` said: a server whose `/props` answers without a
|
|
272
|
+
* window is never `unserved`, and without this its missing route was asked again with every miss.
|
|
273
|
+
*/
|
|
274
|
+
const refusals = new Map();
|
|
275
|
+
/** Whether `/api/v0/models` is to be left alone on this endpoint for now. */
|
|
276
|
+
const refused = (endpoint) => {
|
|
277
|
+
const refusal = refusals.get(endpoint);
|
|
278
|
+
return (refusal !== undefined && (refusal.gone || Date.now() - refusal.at < poolLimits.listingMissMs));
|
|
279
|
+
};
|
|
248
280
|
/** How long a probe may take before the window is taken from the listing instead. */
|
|
249
281
|
const PROBE_TIMEOUT_MS = 10_000;
|
|
250
282
|
/** The server root behind an OpenAI-compatible base URL, which is where the native APIs live. */
|
|
@@ -252,8 +284,9 @@ const rootOf = (baseUrl) => baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
|
252
284
|
/** A route this server does not have, rather than one that failed to answer. */
|
|
253
285
|
const NOT_THERE = new Set([404, 405, 501]);
|
|
254
286
|
/**
|
|
255
|
-
* Asks one native endpoint. `
|
|
256
|
-
*
|
|
287
|
+
* Asks one native endpoint. `failed` is any answer that was not a 2xx and `missing` the ones among
|
|
288
|
+
* them that mean a server without the route; `body` is absent for both, and for a 2xx that was not
|
|
289
|
+
* JSON. A server that could not be reached throws.
|
|
257
290
|
*/
|
|
258
291
|
async function probe(config, path) {
|
|
259
292
|
const apiKey = config.apiKey || undefined;
|
|
@@ -262,16 +295,17 @@ async function probe(config, path) {
|
|
|
262
295
|
signal: AbortSignal.timeout(timeoutMs(config) ?? PROBE_TIMEOUT_MS),
|
|
263
296
|
});
|
|
264
297
|
if (!response.ok)
|
|
265
|
-
return { missing: NOT_THERE.has(response.status) };
|
|
298
|
+
return { failed: true, missing: NOT_THERE.has(response.status) };
|
|
266
299
|
try {
|
|
267
|
-
return { missing: false, body: await response.json() };
|
|
300
|
+
return { failed: false, missing: false, body: await response.json() };
|
|
268
301
|
}
|
|
269
302
|
catch {
|
|
270
|
-
return { missing: false };
|
|
303
|
+
return { failed: false, missing: false };
|
|
271
304
|
}
|
|
272
305
|
}
|
|
273
306
|
/**
|
|
274
|
-
* The window a local server is
|
|
307
|
+
* The window a local server's own API says it is serving a model in, for the servers whose
|
|
308
|
+
* listing does not say.
|
|
275
309
|
*
|
|
276
310
|
* llama.cpp reports it on `/props` as `default_generation_settings.n_ctx`, per slot, and LM
|
|
277
311
|
* Studio on `/api/v0/models` as `loaded_context_length` while the model is loaded. Both differ
|
|
@@ -279,8 +313,12 @@ async function probe(config, path) {
|
|
|
279
313
|
* built for, and reading the trained one lets an overflow through the guard meant to catch it.
|
|
280
314
|
* Ollama reports nothing on either; an operator there has to declare the window.
|
|
281
315
|
*
|
|
282
|
-
* Zero where no answer was found
|
|
283
|
-
* latched and not asked again
|
|
316
|
+
* Zero where no answer was found, which is remembered for `listingMissMs`. A server without
|
|
317
|
+
* either route is latched and not asked again. `/api/v0/models` is also dropped on its own: until
|
|
318
|
+
* `resetClients` once it answers as a route that is not there, and for `listingMissMs` after any
|
|
319
|
+
* other error. A server that could not be reached is not remembered at all.
|
|
320
|
+
*
|
|
321
|
+
* The listing is not read here; `contextLimitFor` reads it first and comes here after.
|
|
284
322
|
*
|
|
285
323
|
* @param config The endpoint, plus the model whose window is wanted.
|
|
286
324
|
*/
|
|
@@ -300,13 +338,19 @@ export async function servedWindow(config) {
|
|
|
300
338
|
?.default_generation_settings;
|
|
301
339
|
window = positive(settings?.n_ctx);
|
|
302
340
|
if (!window) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
341
|
+
if (!refused(endpoint)) {
|
|
342
|
+
const lmstudio = await probe(config, "/api/v0/models");
|
|
343
|
+
if (lmstudio.failed)
|
|
344
|
+
refusals.set(endpoint, { at: Date.now(), gone: lmstudio.missing });
|
|
345
|
+
else
|
|
346
|
+
refusals.delete(endpoint);
|
|
347
|
+
const { data } = (lmstudio.body ?? {});
|
|
348
|
+
const entry = Array.isArray(data)
|
|
349
|
+
? data.find((model) => model?.id === config.model)
|
|
350
|
+
: undefined;
|
|
351
|
+
window = positive(entry?.loaded_context_length);
|
|
352
|
+
}
|
|
353
|
+
if (props.missing && refusals.get(endpoint)?.gone) {
|
|
310
354
|
unserved.add(endpoint);
|
|
311
355
|
return 0;
|
|
312
356
|
}
|
|
@@ -325,10 +369,18 @@ export async function servedWindow(config) {
|
|
|
325
369
|
*/
|
|
326
370
|
export async function listModels(config) {
|
|
327
371
|
const { data } = await getClient(config).models.list();
|
|
372
|
+
const trained = new Set();
|
|
328
373
|
const models = data
|
|
329
|
-
.map((model) =>
|
|
374
|
+
.map((model) => {
|
|
375
|
+
const window = contextLengthOf(model);
|
|
376
|
+
if (window.trained)
|
|
377
|
+
trained.add(model.id);
|
|
378
|
+
return { id: model.id, contextLength: window.contextLength };
|
|
379
|
+
})
|
|
330
380
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
331
|
-
|
|
381
|
+
const key = endpointKey(config);
|
|
382
|
+
listings.set(key, models);
|
|
383
|
+
trainedOnly.set(key, trained);
|
|
332
384
|
return models;
|
|
333
385
|
}
|
|
334
386
|
/**
|
|
@@ -339,11 +391,18 @@ export async function listModels(config) {
|
|
|
339
391
|
* happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
|
|
340
392
|
* the honest-looking number is a run that fails at the endpoint instead.
|
|
341
393
|
*
|
|
342
|
-
* Otherwise the
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
394
|
+
* Otherwise the endpoint's listing — asked once, and again whenever it does not name this model,
|
|
395
|
+
* since a model can arrive after the first listing was taken. A window stated at the top of the
|
|
396
|
+
* model's entry is taken as the one being served, and nothing else is asked. Where the listing
|
|
397
|
+
* did not settle it — no window, only llama.cpp's trained one, no entry, no listing — the
|
|
398
|
+
* server's own API is asked (`servedWindow`), and what it says wins; the trained window is what
|
|
399
|
+
* is left when it says nothing. A server that will not list models still has to be able to run a
|
|
400
|
+
* turn: a failure here is an unknown window, not a failed run.
|
|
401
|
+
*
|
|
402
|
+
* The listing goes first because it is the one route every server here has, and the native ones
|
|
403
|
+
* are two more requests that a server stating its window in the listing never needed. Asked
|
|
404
|
+
* first, they were also asked again every `listingMissMs` for the life of the process on a server
|
|
405
|
+
* whose `/props` answers without a window, by any host that sizes the window per turn.
|
|
347
406
|
*
|
|
348
407
|
* @param config The endpoint, plus the model whose window is wanted.
|
|
349
408
|
* @param declared The operator's own number. Above zero it wins and the endpoint is not asked.
|
|
@@ -351,9 +410,6 @@ export async function listModels(config) {
|
|
|
351
410
|
export async function contextLimitFor(config, declared = 0) {
|
|
352
411
|
if (declared > 0)
|
|
353
412
|
return declared;
|
|
354
|
-
const window = await servedWindow(config);
|
|
355
|
-
if (window > 0)
|
|
356
|
-
return window;
|
|
357
413
|
const key = endpointKey(config);
|
|
358
414
|
const listed = () => listings.get(key)?.find((model) => model.id === config.model);
|
|
359
415
|
// The listing is asked for again when it does not name this model, rather than only when
|
|
@@ -369,26 +425,30 @@ export async function contextLimitFor(config, declared = 0) {
|
|
|
369
425
|
// Asked again, but not on every call. A model that is never coming answers the same zero
|
|
370
426
|
// however often the endpoint is asked, and a caller sizing a window per turn pays a round
|
|
371
427
|
// trip for each of them; `listingMissMs` is how long that answer is allowed to stand.
|
|
372
|
-
if (asked
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
428
|
+
if (asked === undefined || Date.now() - asked >= poolLimits.listingMissMs) {
|
|
429
|
+
// A failure is not remembered: an endpoint that was down when the last run started is not
|
|
430
|
+
// an endpoint with no models, and a window nobody could ask about is not a failed run.
|
|
431
|
+
const answered = await listModels(config).then(() => true, () => false);
|
|
432
|
+
// Recorded where the endpoint was actually asked, and only there. Stamping it on the calls
|
|
433
|
+
// that skipped the request would push the deadline out ahead of any caller polling faster
|
|
434
|
+
// than the interval, which is a memory that never expires rather than one that expires in
|
|
435
|
+
// half a minute.
|
|
436
|
+
if (answered) {
|
|
437
|
+
if (listed())
|
|
438
|
+
misses.delete(missKey);
|
|
439
|
+
else
|
|
440
|
+
misses.set(missKey, Date.now());
|
|
441
|
+
}
|
|
381
442
|
}
|
|
382
|
-
// Recorded where the endpoint was actually asked, and only there. Stamping it on the calls
|
|
383
|
-
// that skipped the request would push the deadline out ahead of any caller polling faster
|
|
384
|
-
// than the interval, which is a memory that never expires rather than one that expires in
|
|
385
|
-
// half a minute.
|
|
386
|
-
if (listed())
|
|
387
|
-
misses.delete(missKey);
|
|
388
|
-
else
|
|
389
|
-
misses.set(missKey, Date.now());
|
|
390
443
|
}
|
|
391
|
-
|
|
444
|
+
const entry = listed();
|
|
445
|
+
const fromListing = entry?.contextLength ?? 0;
|
|
446
|
+
// A top-level key settles it. A trained window does not: llama.cpp lists the one the model was
|
|
447
|
+
// built with, and taking it over what `/props` says lets an overflow through the guard.
|
|
448
|
+
if (fromListing > 0 && !trainedOnly.get(key)?.has(config.model))
|
|
449
|
+
return fromListing;
|
|
450
|
+
const window = await servedWindow(config);
|
|
451
|
+
return window > 0 ? window : fromListing;
|
|
392
452
|
}
|
|
393
453
|
/**
|
|
394
454
|
* Forgets every cached client and listing, and puts `configureClients` back to the defaults. For
|
|
@@ -399,6 +459,8 @@ export function resetClients() {
|
|
|
399
459
|
clients.clear();
|
|
400
460
|
listings.clear();
|
|
401
461
|
misses.clear();
|
|
462
|
+
trainedOnly.clear();
|
|
402
463
|
served.clear();
|
|
403
464
|
unserved.clear();
|
|
465
|
+
refusals.clear();
|
|
404
466
|
}
|
package/llms.txt
CHANGED
|
@@ -70,7 +70,7 @@ The contract between whatever holds the tools and the loop that offers them to a
|
|
|
70
70
|
- `ModelInfo` (type) — A model an endpoint offers, and what it says the model will read.
|
|
71
71
|
- `NO_KEY` — The SDK insists on a non-empty key even where the server will not look at it.
|
|
72
72
|
- `resetClients` — Forgets every cached client and listing, and puts `configureClients` back to the defaults.
|
|
73
|
-
- `servedWindow` — The window a local server is
|
|
73
|
+
- `servedWindow` — The window a local server's own API says it is serving a model in, for the servers whose listing does not say.
|
|
74
74
|
- `timeoutMs` — Zero, less, or absent means no limit, which the SDK spells as `undefined`.
|
|
75
75
|
|
|
76
76
|
### compaction
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cubicecho/agent-core",
|
|
3
|
-
"version": "2.21.
|
|
3
|
+
"version": "2.21.1",
|
|
4
4
|
"description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openai",
|