@cubicecho/agent-core 4.11.0 → 4.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -6
- package/dist/core/config.d.ts +34 -1
- package/dist/core/config.js +45 -0
- package/dist/endpoint/client.d.ts +74 -6
- package/dist/endpoint/client.js +181 -15
- package/dist/endpoint/slots.js +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/spec/columns.d.ts +162 -0
- package/dist/spec/columns.js +275 -0
- package/dist/wire/wire.d.ts +1 -0
- package/dist/wire/wire.js +1 -0
- package/llms.txt +19 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -30,11 +30,11 @@ only, Node >=22.
|
|
|
30
30
|
| `side-task` | One-shot calls that support a run without being one — small prompt, short answer, no tools, never worth failing the run over. `askJson` holds the answer to a schema where the server can, and `taskCall` turns the settings an agent spec gives a task into the call that honours them. |
|
|
31
31
|
| `hooks` | The host's side of lifecycle hooks: `gather` before a request and `notify` after, the shared context budget, `withContext` to put what they add on the turn's question, `untrusted` to fence text nobody vouched for, and `turnMessages` to hand them a transcript. Running a hook is a runner the caller passes. |
|
|
32
32
|
| `events` | The in-memory bus a watcher reads while a run happens: `emit`, `watch`, `history`, `fold`, and `runMetrics` for what a run cost. A watcher's backlog is capped and reports its own gaps. |
|
|
33
|
-
| `client` | A pooled `OpenAI` client per endpoint, plus the context window: the served one where a local server says, the listed one otherwise, and their caches. And `resolveApiKey`, for a caller deciding which key an endpoint gets. |
|
|
33
|
+
| `client` | A pooled `OpenAI` client per endpoint, plus the context window: the served one where a local server says, the listed one otherwise, and their caches. And `resolveApiKey`, for a caller deciding which key an endpoint gets, and `probeEndpoint`, for listing one a form has not saved yet. |
|
|
34
34
|
| `retry` | What to do when a request is lost, refused or too big: `isTransient`, `isModelLoading`, `backoffMs`, `ContextOverflow`, `EndpointSilent`. |
|
|
35
35
|
| `calibration` | How many characters a token is worth on one model, learned from the prompt counts its endpoint reports: `charsPerTokenFor`, `calibrate`. |
|
|
36
36
|
| `continuation` | `continueTurn`: carries on an answer the token ceiling cut off, by prefilling it as a trailing assistant message. |
|
|
37
|
-
| `config` | The structural interfaces every function here asks for. |
|
|
37
|
+
| `config` | The structural interfaces every function here asks for, and `normalizeBaseUrl`, the one rule for when two base URLs are one endpoint. |
|
|
38
38
|
| `run-turn` | `runTurn`: one turn with the retry loop around the negotiation around the stream. The whole loop, for a caller that wants it rather than its parts. Sizes the request against an opt-in `contextLimit`. |
|
|
39
39
|
| `agent-loop` | `runAgentLoop`: the loop above a turn — `runTurn` per step, the tools between, `load_tools` and preselection handled, until the model stops asking. |
|
|
40
40
|
| `request-body` | `buildBody`: the one place a streamed request's body is decided from a config and what the endpoint and the model have refused. |
|
|
@@ -44,7 +44,7 @@ only, Node >=22.
|
|
|
44
44
|
| `compaction` | Keeping a long run inside its window: `pruneToolResults` clears stale tool results, `planCompaction` and `compactTranscript` fold the oldest stretch into a summary. |
|
|
45
45
|
| `ledger` | What stretches of a transcript cost, read off the prompt counts the server reported rather than estimated: `recordRequest`, `tokensBetween`, `estimateFrom`, `rebaseLedger`. |
|
|
46
46
|
| `snapshot` | `exportCapabilities` and `importCapabilities`: the latched refusals as a JSON blob a consumer stores, so a restart need not learn them again. |
|
|
47
|
-
| `spec` | `parseSpec`, `resolveAgentSpec` and `exportSpec`: an agent as a versioned JSON document, layered into the flat config the loop takes. Imports nothing but types, and is published separately at `@cubicecho/agent-core/spec`. |
|
|
47
|
+
| `spec` | `parseSpec`, `resolveAgentSpec` and `exportSpec`: an agent as a versioned JSON document, layered into the flat config the loop takes. Imports nothing but types, and is published separately at `@cubicecho/agent-core/spec`. `defineSpecColumns` maps a stored row onto a layer and back, at `@cubicecho/agent-core/spec/columns`. |
|
|
48
48
|
| `reset` | `resetAll`: drops every cache and latch in one call, so a teardown cannot forget one. |
|
|
49
49
|
| `runtime` | `createRuntime`: a second set of those caches, with the functions that use them as methods — for a host whose tenants must not share a client pool, a latch or an event bus. `defaultRuntime` is the one the top-level functions use. |
|
|
50
50
|
| `tokens` | How big a request is, estimated: `estimateTokens` — characters over four, deliberately low — and what is built on it for everything here that has to guess at a window: `messageTokens`, `requestTokens`, `contextTokens`. |
|
|
@@ -306,7 +306,7 @@ const turn = await runTurn(client, supports, build, {
|
|
|
306
306
|
|
|
307
307
|
`contextLimitFor` answers the operator's number when there is one. Otherwise it reads the
|
|
308
308
|
`/v1/models` listing first. A window at the top of the model's entry (`context_length`,
|
|
309
|
-
`max_context_window`, `max_model_len`, `context_window` or `n_ctx`: vLLM's `max_model_len`,
|
|
309
|
+
`max_context_length`, `max_context_window`, `max_model_len`, `context_window` or `n_ctx`: vLLM's `max_model_len`,
|
|
310
310
|
OpenRouter's `context_length`) is taken as the one being served, and nothing else is asked.
|
|
311
311
|
|
|
312
312
|
Where the listing does not settle it, the server's own API is asked (`servedWindow`): llama.cpp's
|
|
@@ -316,7 +316,10 @@ listed with only `meta.n_ctx_train`, which is llama.cpp. The trained window diff
|
|
|
316
316
|
one in the case the guard exists for, a 256k model started at `-c 16384`, so it is used only when
|
|
317
317
|
neither native route gives a number. A server with neither route is latched and not asked again.
|
|
318
318
|
`/api/v0/models` is also dropped on its own: for the life of the process once it answers 404, 405
|
|
319
|
-
or 501, and for `listingMissMs` after any other error.
|
|
319
|
+
or 501, and for `listingMissMs` after any other error. A 200 that is not JSON counts as a missing
|
|
320
|
+
route: it is what a proxy with a web app of its own (llm-router) answers on every path it does not
|
|
321
|
+
route. Neither route is sent `NO_KEY` as a bearer; an endpoint with no key is asked with no
|
|
322
|
+
`Authorization` header at all.
|
|
320
323
|
|
|
321
324
|
A host that sizes the window before each run passes the run's signal as the third argument, so a
|
|
322
325
|
stopped run does not wait on the lookup:
|
|
@@ -335,6 +338,33 @@ nothing would otherwise hold the lookup for that long, or for ever where there i
|
|
|
335
338
|
bound `contextLimitFor` answers zero, as it does for a server it could not reach, and
|
|
336
339
|
`listModels` rejects with a `TimeoutError`.
|
|
337
340
|
|
|
341
|
+
### Listing an endpoint that is not saved yet
|
|
342
|
+
|
|
343
|
+
`listModels` is for an endpoint that is configured: it pools a client per URL and remembers what
|
|
344
|
+
came back. A settings form asking at each keystroke would leave a client behind for every
|
|
345
|
+
half-typed URL, so it asks `probeEndpoint` instead, which makes one plain request and remembers
|
|
346
|
+
nothing:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
import { EndpointProbeError, ProbeFailure, probeEndpoint } from "@cubicecho/agent-core";
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
const models = await probeEndpoint({ baseUrl: typed, apiKey: typedKey }, { signal });
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (error instanceof EndpointProbeError) {
|
|
355
|
+
// error.kind: invalid-url, unreachable, timeout, unauthorized, redirected, not-openai, failed.
|
|
356
|
+
// error.message is a sentence naming the endpoint by its origin, fit to show under the field.
|
|
357
|
+
return { problem: error.message };
|
|
358
|
+
}
|
|
359
|
+
throw error; // the signal's own reason
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
It refuses a URL `normalizeBaseUrl` refuses before asking anybody, follows no redirect (so a typed
|
|
364
|
+
key is never carried on to a host nobody typed), reads both OpenAI's `{ data: [...] }` and
|
|
365
|
+
Ollama's native `{ models: [...] }` (by `name` where an entry has no `id`), and hands back each id
|
|
366
|
+
once, sorted, at most `limit` of them (500 unless told). It is bounded as a lookup is.
|
|
367
|
+
|
|
338
368
|
A top-level listing key is trusted without a second opinion, so on a server that puts a trained
|
|
339
369
|
window there, pass `contextLength`. Ollama reports no window on any route this reads, and truncates
|
|
340
370
|
an over-long prompt rather than refusing it, so on Ollama pass `contextLength` or there is no guard
|
|
@@ -909,6 +939,12 @@ it encodes is the conservative one: an endpoint's own key wins; one that names a
|
|
|
909
939
|
own, different from the settings it inherits from, gets `NO_KEY` rather than the operator's key or
|
|
910
940
|
`$OPENAI_API_KEY`; anything else inherits.
|
|
911
941
|
|
|
942
|
+
"Different" is decided by `normalizeBaseUrl`, the same rule `endpointKey` and every cache here
|
|
943
|
+
compares by: http or https, no credentials, query or fragment, scheme and host in lower case, no
|
|
944
|
+
default port and no trailing slash. A host storing a base URL should store what it returns, and
|
|
945
|
+
refuse a URL it answers `null` for. A URL it refuses still equals itself, trimmed of whitespace
|
|
946
|
+
and trailing slashes, so a row saved before the rule still finds its own key.
|
|
947
|
+
|
|
912
948
|
## Fields this interface cannot spell
|
|
913
949
|
|
|
914
950
|
`ModelParams` has `temperature`, `maxTokens` and `reasoningEffort`. Everything else a model card or
|
|
@@ -1364,6 +1400,50 @@ a form can load it. Nothing in `src/` imports it — the same direction the conf
|
|
|
1364
1400
|
what lets a document travel between hosts without the loop growing an opinion about where an agent
|
|
1365
1401
|
comes from.
|
|
1366
1402
|
|
|
1403
|
+
### From a row, and back
|
|
1404
|
+
|
|
1405
|
+
A document has one spelling of "inherit" — the field is absent — and a database row cannot leave a
|
|
1406
|
+
column out, so every host stores a marker instead: `""` for a text column, `-1` where zero is a real
|
|
1407
|
+
answer, the word `inherit` in an enum, `null` in a nullable one. `defineSpecColumns` is the table
|
|
1408
|
+
from those columns to the fields they feed, with each column's marker a parameter, so the projection
|
|
1409
|
+
is declared once rather than written out in both directions:
|
|
1410
|
+
|
|
1411
|
+
```ts
|
|
1412
|
+
import { defineSpecColumns } from "@cubicecho/agent-core/spec/columns";
|
|
1413
|
+
|
|
1414
|
+
const profile = defineSpecColumns<Agent>(
|
|
1415
|
+
{
|
|
1416
|
+
model: { path: "model.model", inherit: "" },
|
|
1417
|
+
maxTokens: { path: "model.maxTokens", inherit: -1 },
|
|
1418
|
+
temperature: { path: "model.temperature", inherit: null },
|
|
1419
|
+
toolDiscovery: { path: "tools.discovery", inherit: "inherit" },
|
|
1420
|
+
systemPrompt: { path: "prompt.system", inherit: "" },
|
|
1421
|
+
compactionModel: { path: "tasks.compaction.model", inherit: "" },
|
|
1422
|
+
toolExecution: { path: ["extensions", "com.example.host", "toolExecution"], inherit: "inherit" },
|
|
1423
|
+
},
|
|
1424
|
+
{ handled: ["bundle", "tools.servers"] },
|
|
1425
|
+
);
|
|
1426
|
+
|
|
1427
|
+
resolveAgentSpec([settings.toLayer(settingsRow), profile.toLayer(agentRow)]); // markers say nothing
|
|
1428
|
+
const row = profile.fromLayer(spec); // an absent field comes back as its column's marker
|
|
1429
|
+
const lost = profile.dropped(spec); // [{ path: "model.extraBody", value: {...} }, ...]
|
|
1430
|
+
```
|
|
1431
|
+
|
|
1432
|
+
The table is keyed by column, so a column the row does not have, a marker of the wrong type and a
|
|
1433
|
+
path the format has no field at are all type errors. A column with no `inherit` says every value it
|
|
1434
|
+
holds, which is a host's bottom layer; `null` and `undefined` are absent whatever the marker. A
|
|
1435
|
+
column that stores something other than its field — a switch for a discovery mode — converts with
|
|
1436
|
+
`toSpec` and `fromSpec`, and either returning `undefined` says nothing: that is how a marker that
|
|
1437
|
+
is a range (a trimmed blank, any negative) is spelled, and how a column refuses a value it cannot
|
|
1438
|
+
hold. `prompt.<id>` is one part's text; an extension's reverse-domain key has dots of its own, so
|
|
1439
|
+
its path is written as segments.
|
|
1440
|
+
|
|
1441
|
+
`dropped` is the import preview's list: what a document says that `fromLayer` will not keep, in the
|
|
1442
|
+
document's order, and never silently. A field with no column is reported at its path, a task or
|
|
1443
|
+
extension with nothing mapped in it whole, and a value a column's `fromSpec` refused carries that
|
|
1444
|
+
column's name. What the host reads by hand — a bundle, `tools.servers` resolved to its own row ids —
|
|
1445
|
+
goes in `handled` and is left out.
|
|
1446
|
+
|
|
1367
1447
|
### Its side tasks
|
|
1368
1448
|
|
|
1369
1449
|
`tasks.<key>` configures a side task apart from the main model — its own model, endpoint, ceiling,
|
|
@@ -1473,7 +1553,7 @@ call returns everything in force. Lowering `maxClients` below the pool's current
|
|
|
1473
1553
|
it at once, least recently used first. `resetClients` (and `resetAll`) puts the defaults back.
|
|
1474
1554
|
|
|
1475
1555
|
A host that keeps per-endpoint state of its own should key it on `endpointKey` — the JSON of the base
|
|
1476
|
-
URL and the key, an absent key read as `NO_KEY` — rather than rebuilding that string, so the two
|
|
1556
|
+
URL as `normalizeBaseUrl` writes it and the key, an absent key read as `NO_KEY` — rather than rebuilding that string, so the two
|
|
1477
1557
|
cannot drift. `endpointKey` holds the key in the clear; `endpointId`, its SHA-256 digest, is the one
|
|
1478
1558
|
that is safe to write down.
|
|
1479
1559
|
|
package/dist/core/config.d.ts
CHANGED
|
@@ -57,11 +57,44 @@ export interface Endpoint {
|
|
|
57
57
|
* not in it, since a different patience is not a different server.
|
|
58
58
|
*/
|
|
59
59
|
export interface EndpointIdentity {
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* The base URL as given. Compared as `baseUrlKey` has it, so a trailing slash, a host's case and
|
|
62
|
+
* a default port are the same endpoint, and a path's case and a `/v1` are not.
|
|
63
|
+
*/
|
|
61
64
|
baseUrl: string;
|
|
62
65
|
/** Absent and empty mean the same: no key, which `endpointKey` reads as `NO_KEY`. */
|
|
63
66
|
apiKey?: string;
|
|
64
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* A base URL worth storing: http or https, no credentials, query or fragment, no trailing slash.
|
|
70
|
+
*
|
|
71
|
+
* @param raw - The URL as typed. Surrounding whitespace is trimmed.
|
|
72
|
+
* @returns The URL as the WHATWG parser writes it, less its trailing slashes — scheme and host in
|
|
73
|
+
* lower case, a default port dropped — or `null` for anything that is not such a URL.
|
|
74
|
+
*
|
|
75
|
+
* @remarks
|
|
76
|
+
* One rule for every host, because "a stored key only goes to its own endpoint" is decided by
|
|
77
|
+
* comparing two of these, and four hosts writing four rules had begun to disagree about it. A key
|
|
78
|
+
* in the URL is a credential nobody meant to store; a query or a fragment is one more thing two
|
|
79
|
+
* copies of the same endpoint could differ in, and the SDK appends its paths after it.
|
|
80
|
+
*
|
|
81
|
+
* A host that stores a base URL stores this, and a form that refuses one refuses on `null`, so what
|
|
82
|
+
* is saved is already in the form every comparison here reads.
|
|
83
|
+
*/
|
|
84
|
+
export declare function normalizeBaseUrl(raw: string): string | null;
|
|
85
|
+
/**
|
|
86
|
+
* A base URL in the form two of them are compared in, valid or not.
|
|
87
|
+
*
|
|
88
|
+
* @param raw - The URL as configured.
|
|
89
|
+
* @returns `normalizeBaseUrl`'s answer where it has one, and otherwise the URL trimmed of
|
|
90
|
+
* whitespace and trailing slashes, which is the rule every comparison here used before.
|
|
91
|
+
*
|
|
92
|
+
* @remarks
|
|
93
|
+
* A URL the normaliser refuses is still somebody's endpoint — an old settings row, a key written
|
|
94
|
+
* into the URL — and it still has to equal itself, so it falls back to the older, looser rule
|
|
95
|
+
* rather than to "unequal to everything".
|
|
96
|
+
*/
|
|
97
|
+
export declare const baseUrlKey: (raw: string) => string;
|
|
65
98
|
/**
|
|
66
99
|
* The request fields `extraBody` may not set, because the loop's request is built around them.
|
|
67
100
|
*
|
package/dist/core/config.js
CHANGED
|
@@ -11,6 +11,51 @@
|
|
|
11
11
|
* it structurally. Nothing imports a config *type* from a consumer, and no consumer has to grow
|
|
12
12
|
* a field it has no use for in order to call `getClient`.
|
|
13
13
|
*/
|
|
14
|
+
/**
|
|
15
|
+
* A base URL worth storing: http or https, no credentials, query or fragment, no trailing slash.
|
|
16
|
+
*
|
|
17
|
+
* @param raw - The URL as typed. Surrounding whitespace is trimmed.
|
|
18
|
+
* @returns The URL as the WHATWG parser writes it, less its trailing slashes — scheme and host in
|
|
19
|
+
* lower case, a default port dropped — or `null` for anything that is not such a URL.
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* One rule for every host, because "a stored key only goes to its own endpoint" is decided by
|
|
23
|
+
* comparing two of these, and four hosts writing four rules had begun to disagree about it. A key
|
|
24
|
+
* in the URL is a credential nobody meant to store; a query or a fragment is one more thing two
|
|
25
|
+
* copies of the same endpoint could differ in, and the SDK appends its paths after it.
|
|
26
|
+
*
|
|
27
|
+
* A host that stores a base URL stores this, and a form that refuses one refuses on `null`, so what
|
|
28
|
+
* is saved is already in the form every comparison here reads.
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeBaseUrl(raw) {
|
|
31
|
+
let url;
|
|
32
|
+
try {
|
|
33
|
+
url = new URL(raw.trim());
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return url.toString().replace(/\/+$/, '');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A base URL in the form two of them are compared in, valid or not.
|
|
48
|
+
*
|
|
49
|
+
* @param raw - The URL as configured.
|
|
50
|
+
* @returns `normalizeBaseUrl`'s answer where it has one, and otherwise the URL trimmed of
|
|
51
|
+
* whitespace and trailing slashes, which is the rule every comparison here used before.
|
|
52
|
+
*
|
|
53
|
+
* @remarks
|
|
54
|
+
* A URL the normaliser refuses is still somebody's endpoint — an old settings row, a key written
|
|
55
|
+
* into the URL — and it still has to equal itself, so it falls back to the older, looser rule
|
|
56
|
+
* rather than to "unequal to everything".
|
|
57
|
+
*/
|
|
58
|
+
export const baseUrlKey = (raw) => normalizeBaseUrl(raw) ?? raw.trim().replace(/\/+$/, '');
|
|
14
59
|
/**
|
|
15
60
|
* The request fields `extraBody` may not set, because the loop's request is built around them.
|
|
16
61
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
|
-
import type
|
|
2
|
+
import { type Endpoint, type EndpointIdentity, type RetryPolicy } from '../core/config.ts';
|
|
3
3
|
/**
|
|
4
4
|
* The SDK insists on a non-empty key even where the server will not look at it. This is what it
|
|
5
5
|
* gets. It also reads as "this endpoint has no key" at a call site, which an empty string does
|
|
@@ -112,13 +112,15 @@ export interface ModelInfo {
|
|
|
112
112
|
* `Endpoint` requires it — `capabilitiesFor` is handed a URL and maybe a key rather than a whole
|
|
113
113
|
* config, and absent and empty already mean the same thing. The timeout is deliberately not in
|
|
114
114
|
* it; see `listings`.
|
|
115
|
-
* @returns The URL and the key as one JSON string, the key in the clear.
|
|
115
|
+
* @returns The URL as `baseUrlKey` writes it and the key, as one JSON string, the key in the clear.
|
|
116
116
|
*
|
|
117
117
|
* @remarks
|
|
118
118
|
* The URL and the key together, because the key is part of what is on the other end rather than
|
|
119
119
|
* only how it is paid for: a router is free to send two keys to two different backends, and then
|
|
120
120
|
* what one of them refused is not a fact about the other. Absent reads as `NO_KEY`, so an
|
|
121
|
-
* endpoint with no key and one that passes `undefined` are one entry rather than two.
|
|
121
|
+
* endpoint with no key and one that passes `undefined` are one entry rather than two. The URL is
|
|
122
|
+
* normalised for the same reason: `…/v1/` and `…/v1` are one server, and two entries for it would
|
|
123
|
+
* learn every refusal twice.
|
|
122
124
|
*
|
|
123
125
|
* Stringified rather than joined on a separator, for the reason `getClient` gives: no character
|
|
124
126
|
* is impossible in a URL or a key, and two endpoints must never collide on one entry. A host
|
|
@@ -207,6 +209,72 @@ export declare function servedSlots(config: Endpoint & {
|
|
|
207
209
|
* request's own error otherwise.
|
|
208
210
|
*/
|
|
209
211
|
export declare function listModels(config: Endpoint, { signal }?: LookupOptions): Promise<ModelInfo[]>;
|
|
212
|
+
/** Why `probeEndpoint` could not list an endpoint's models, in words a form can branch on. */
|
|
213
|
+
export declare const ProbeFailure: {
|
|
214
|
+
/** Not an http or https URL, or one with credentials, a query or a fragment in it. */
|
|
215
|
+
readonly InvalidUrl: 'invalid-url';
|
|
216
|
+
/** Nothing answered: no such host, a refused connection, a TLS failure. */
|
|
217
|
+
readonly Unreachable: 'unreachable';
|
|
218
|
+
/** Something answered, but not inside the lookup's bound. */
|
|
219
|
+
readonly Timeout: 'timeout';
|
|
220
|
+
/** A 401 or a 403: the key is wrong, or one is needed. */
|
|
221
|
+
readonly Unauthorized: 'unauthorized';
|
|
222
|
+
/** A redirect, which is not followed, so the key is never carried to a host nobody typed. */
|
|
223
|
+
readonly Redirected: 'redirected';
|
|
224
|
+
/** An answer that is not a model listing: no such route, a web page, JSON of another shape. */
|
|
225
|
+
readonly NotOpenAi: 'not-openai';
|
|
226
|
+
/** Any other error status, which is on the error as `status`. */
|
|
227
|
+
readonly Failed: 'failed';
|
|
228
|
+
};
|
|
229
|
+
export type ProbeFailure = (typeof ProbeFailure)[keyof typeof ProbeFailure];
|
|
230
|
+
/**
|
|
231
|
+
* `probeEndpoint` could not list an endpoint's models; `kind` says why.
|
|
232
|
+
*
|
|
233
|
+
* @remarks
|
|
234
|
+
* The message is a sentence for a person, naming the endpoint by its origin only, so it can go
|
|
235
|
+
* under a form field as it is. `kind` is for a form that words it itself.
|
|
236
|
+
*/
|
|
237
|
+
export declare class EndpointProbeError extends Error {
|
|
238
|
+
readonly name: string;
|
|
239
|
+
readonly kind: ProbeFailure;
|
|
240
|
+
/** The HTTP status, where the endpoint answered with one. */
|
|
241
|
+
readonly status?: number;
|
|
242
|
+
/**
|
|
243
|
+
* @param kind - Why the listing failed.
|
|
244
|
+
* @param message - The same, as a sentence.
|
|
245
|
+
* @param [status] - The status the endpoint answered with, where it answered.
|
|
246
|
+
*/
|
|
247
|
+
constructor(kind: ProbeFailure, message: string, status?: number);
|
|
248
|
+
}
|
|
249
|
+
/** What `probeEndpoint` takes besides the endpoint. */
|
|
250
|
+
export interface ProbeOptions extends LookupOptions {
|
|
251
|
+
/** How many models come back at most, the first by id. Defaults to `PROBE_MODEL_LIMIT`. */
|
|
252
|
+
limit?: number;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Asks an endpoint nobody has saved yet which models it serves, for a form a person is typing it
|
|
256
|
+
* into.
|
|
257
|
+
*
|
|
258
|
+
* @param endpoint - The endpoint as typed. Its key goes as a bearer token where it has one other
|
|
259
|
+
* than `NO_KEY`; its `requestTimeoutSeconds` bounds the request where that is shorter than
|
|
260
|
+
* `lookupTimeoutMs`.
|
|
261
|
+
* @param [options] - Cancellation, and how many models to hand back. See `ProbeOptions`.
|
|
262
|
+
* @returns The models in order of id, one entry per id, at most `options.limit` of them. Rejects
|
|
263
|
+
* with the reason of `options.signal` where that stopped it, and with an `EndpointProbeError`
|
|
264
|
+
* for every other failure.
|
|
265
|
+
*
|
|
266
|
+
* @remarks
|
|
267
|
+
* `listModels` is for an endpoint that is configured: it pools a client per URL and remembers the
|
|
268
|
+
* listing, and a form asking at every keystroke would leave a client and a listing behind for
|
|
269
|
+
* each half-typed URL. This makes one plain request and remembers nothing, so asking again asks
|
|
270
|
+
* again.
|
|
271
|
+
*
|
|
272
|
+
* Redirects are not followed, so a typed key is never carried on to a host nobody typed. Both
|
|
273
|
+
* listing shapes are read: OpenAI's `{ data: [...] }` by `id`, and Ollama's native
|
|
274
|
+
* `{ models: [...] }` by `name` where an entry has no `id`. A window is read from the keys
|
|
275
|
+
* `contextLimitFor` reads, and an id listed twice keeps the window one of its entries stated.
|
|
276
|
+
*/
|
|
277
|
+
export declare function probeEndpoint(endpoint: EndpointIdentity & Pick<Endpoint, 'requestTimeoutSeconds'>, { signal, limit }?: ProbeOptions): Promise<ModelInfo[]>;
|
|
210
278
|
/**
|
|
211
279
|
* How much a model will read, in tokens. Zero means nobody knows.
|
|
212
280
|
*
|
|
@@ -245,12 +313,12 @@ export declare function contextLimitFor(config: Endpoint & {
|
|
|
245
313
|
*/
|
|
246
314
|
export declare function resetClients(): void;
|
|
247
315
|
/**
|
|
248
|
-
*
|
|
316
|
+
* Whether two base URLs are one endpoint, by `baseUrlKey`.
|
|
249
317
|
*
|
|
250
318
|
* @param a - The URL as one row has it.
|
|
251
319
|
* @param b - The URL as the other has it.
|
|
252
|
-
* @returns `true` where the two differ in nothing but surrounding whitespace
|
|
253
|
-
*
|
|
320
|
+
* @returns `true` where the two differ in nothing but surrounding whitespace, trailing slashes, the
|
|
321
|
+
* case of the scheme and host, and a default port. A path's case counts, and so does a `/v1`.
|
|
254
322
|
*/
|
|
255
323
|
export declare const sameUrl: (a: string, b: string) => boolean;
|
|
256
324
|
/**
|
package/dist/endpoint/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
|
+
import { baseUrlKey, normalizeBaseUrl, } from '../core/config.js';
|
|
2
3
|
import { digestOf } from '../core/digest.js';
|
|
3
4
|
import { byCodeUnit, isPositive } from '../core/guards.js';
|
|
4
5
|
import { MS_PER_SECOND } from '../core/platform.js';
|
|
@@ -192,7 +193,14 @@ export function getClient(config) {
|
|
|
192
193
|
* reporting the same number twice. llama.cpp puts its number under `meta` instead, and LM Studio
|
|
193
194
|
* and Ollama put none on this route at all; see `servedWindow`.
|
|
194
195
|
*/
|
|
195
|
-
const CONTEXT_KEYS = [
|
|
196
|
+
const CONTEXT_KEYS = [
|
|
197
|
+
'context_length',
|
|
198
|
+
'max_context_length',
|
|
199
|
+
'max_context_window',
|
|
200
|
+
'max_model_len',
|
|
201
|
+
'context_window',
|
|
202
|
+
'n_ctx',
|
|
203
|
+
];
|
|
196
204
|
/**
|
|
197
205
|
* A number above zero as itself and anything else as zero, which here means "not stated".
|
|
198
206
|
*
|
|
@@ -276,20 +284,22 @@ const misses = scoped(() => new Map());
|
|
|
276
284
|
* `Endpoint` requires it — `capabilitiesFor` is handed a URL and maybe a key rather than a whole
|
|
277
285
|
* config, and absent and empty already mean the same thing. The timeout is deliberately not in
|
|
278
286
|
* it; see `listings`.
|
|
279
|
-
* @returns The URL and the key as one JSON string, the key in the clear.
|
|
287
|
+
* @returns The URL as `baseUrlKey` writes it and the key, as one JSON string, the key in the clear.
|
|
280
288
|
*
|
|
281
289
|
* @remarks
|
|
282
290
|
* The URL and the key together, because the key is part of what is on the other end rather than
|
|
283
291
|
* only how it is paid for: a router is free to send two keys to two different backends, and then
|
|
284
292
|
* what one of them refused is not a fact about the other. Absent reads as `NO_KEY`, so an
|
|
285
|
-
* endpoint with no key and one that passes `undefined` are one entry rather than two.
|
|
293
|
+
* endpoint with no key and one that passes `undefined` are one entry rather than two. The URL is
|
|
294
|
+
* normalised for the same reason: `…/v1/` and `…/v1` are one server, and two entries for it would
|
|
295
|
+
* learn every refusal twice.
|
|
286
296
|
*
|
|
287
297
|
* Stringified rather than joined on a separator, for the reason `getClient` gives: no character
|
|
288
298
|
* is impossible in a URL or a key, and two endpoints must never collide on one entry. A host
|
|
289
299
|
* keeping its own per-endpoint state keys it on this rather than on a copy of it, so the two
|
|
290
300
|
* cannot drift apart. It holds the key in the clear; `endpointId` is the one to write down.
|
|
291
301
|
*/
|
|
292
|
-
export const endpointKey = (config) => JSON.stringify([config.baseUrl, config.apiKey || NO_KEY]);
|
|
302
|
+
export const endpointKey = (config) => JSON.stringify([baseUrlKey(config.baseUrl), config.apiKey || NO_KEY]);
|
|
293
303
|
/**
|
|
294
304
|
* One model on one endpoint, as every per-model cache keys it: stringified, so neither half runs
|
|
295
305
|
* into the other.
|
|
@@ -402,23 +412,39 @@ const NOT_THERE = new Set([
|
|
|
402
412
|
HttpStatus.MethodNotAllowed,
|
|
403
413
|
HttpStatus.NotImplemented,
|
|
404
414
|
]);
|
|
415
|
+
/**
|
|
416
|
+
* The headers that carry an endpoint's key, and none where it has none.
|
|
417
|
+
*
|
|
418
|
+
* @param apiKey - The key as configured. Empty, absent and `NO_KEY` all mean there is none.
|
|
419
|
+
* @returns A bearer `authorization` header, or no headers at all.
|
|
420
|
+
*
|
|
421
|
+
* @remarks
|
|
422
|
+
* `NO_KEY` is the SDK's placeholder, not a credential, and a request made outside the SDK has no
|
|
423
|
+
* reason to send it: a server that checks keys refuses `Bearer agent-core` as it refuses a wrong
|
|
424
|
+
* one, where it might have answered an unauthenticated route.
|
|
425
|
+
*/
|
|
426
|
+
const authHeaders = (apiKey) => apiKey && apiKey !== NO_KEY ? { authorization: `Bearer ${apiKey}` } : {};
|
|
405
427
|
/**
|
|
406
428
|
* Asks one native endpoint.
|
|
407
429
|
*
|
|
408
|
-
* @param config - The endpoint. Its key goes as a bearer token where it has one
|
|
430
|
+
* @param config - The endpoint. Its key goes as a bearer token where it has one other than `NO_KEY`.
|
|
409
431
|
* @param path - The route and its query, from the server root rather than from the base URL.
|
|
410
432
|
* @param within - The lookup's signal. Fired, the request throws as an unreachable server does.
|
|
411
433
|
* @returns How the server answered, with `body` only for a 2xx that parsed as JSON.
|
|
412
434
|
*
|
|
413
435
|
* @remarks
|
|
414
|
-
* `failed` is any answer that was not a 2xx and `missing` the ones among them
|
|
415
|
-
* without the route; `body` is absent for
|
|
416
|
-
*
|
|
436
|
+
* `failed` is any answer that was not a 2xx, or was not JSON, and `missing` the ones among them
|
|
437
|
+
* that mean a server without the route; `body` is absent for all of them. A server that could not
|
|
438
|
+
* be reached throws.
|
|
439
|
+
*
|
|
440
|
+
* A 2xx that is not JSON is a missing route rather than a route with nothing to say. It is what a
|
|
441
|
+
* proxy with a web app of its own answers on every path it does not route — llm-router serves its
|
|
442
|
+
* `index.html` for `/props` with a 200 — and read as "no window" it was asked again every
|
|
443
|
+
* `listingMissMs` instead of being latched as a server without the route.
|
|
417
444
|
*/
|
|
418
445
|
async function probe(config, path, within) {
|
|
419
|
-
const apiKey = config.apiKey || undefined;
|
|
420
446
|
const response = await fetch(`${rootOf(config.baseUrl)}${path}`, {
|
|
421
|
-
headers: apiKey
|
|
447
|
+
headers: authHeaders(config.apiKey),
|
|
422
448
|
signal: within,
|
|
423
449
|
});
|
|
424
450
|
if (!response.ok) {
|
|
@@ -428,7 +454,9 @@ async function probe(config, path, within) {
|
|
|
428
454
|
return { failed: false, missing: false, body: await response.json() };
|
|
429
455
|
}
|
|
430
456
|
catch {
|
|
431
|
-
|
|
457
|
+
// A fired signal surfaces here too, mid-body, and is a server that stopped answering.
|
|
458
|
+
within.throwIfAborted();
|
|
459
|
+
return { failed: true, missing: true };
|
|
432
460
|
}
|
|
433
461
|
}
|
|
434
462
|
/**
|
|
@@ -594,6 +622,144 @@ async function readListing(config, within) {
|
|
|
594
622
|
trainedOnly().set(key, trained);
|
|
595
623
|
return models;
|
|
596
624
|
}
|
|
625
|
+
/** Why `probeEndpoint` could not list an endpoint's models, in words a form can branch on. */
|
|
626
|
+
export const ProbeFailure = {
|
|
627
|
+
/** Not an http or https URL, or one with credentials, a query or a fragment in it. */
|
|
628
|
+
InvalidUrl: 'invalid-url',
|
|
629
|
+
/** Nothing answered: no such host, a refused connection, a TLS failure. */
|
|
630
|
+
Unreachable: 'unreachable',
|
|
631
|
+
/** Something answered, but not inside the lookup's bound. */
|
|
632
|
+
Timeout: 'timeout',
|
|
633
|
+
/** A 401 or a 403: the key is wrong, or one is needed. */
|
|
634
|
+
Unauthorized: 'unauthorized',
|
|
635
|
+
/** A redirect, which is not followed, so the key is never carried to a host nobody typed. */
|
|
636
|
+
Redirected: 'redirected',
|
|
637
|
+
/** An answer that is not a model listing: no such route, a web page, JSON of another shape. */
|
|
638
|
+
NotOpenAi: 'not-openai',
|
|
639
|
+
/** Any other error status, which is on the error as `status`. */
|
|
640
|
+
Failed: 'failed',
|
|
641
|
+
};
|
|
642
|
+
/**
|
|
643
|
+
* `probeEndpoint` could not list an endpoint's models; `kind` says why.
|
|
644
|
+
*
|
|
645
|
+
* @remarks
|
|
646
|
+
* The message is a sentence for a person, naming the endpoint by its origin only, so it can go
|
|
647
|
+
* under a form field as it is. `kind` is for a form that words it itself.
|
|
648
|
+
*/
|
|
649
|
+
export class EndpointProbeError extends Error {
|
|
650
|
+
name = 'EndpointProbeError';
|
|
651
|
+
kind;
|
|
652
|
+
/** The HTTP status, where the endpoint answered with one. */
|
|
653
|
+
status;
|
|
654
|
+
/**
|
|
655
|
+
* @param kind - Why the listing failed.
|
|
656
|
+
* @param message - The same, as a sentence.
|
|
657
|
+
* @param [status] - The status the endpoint answered with, where it answered.
|
|
658
|
+
*/
|
|
659
|
+
constructor(kind, message, status) {
|
|
660
|
+
super(message);
|
|
661
|
+
this.kind = kind;
|
|
662
|
+
if (status !== undefined) {
|
|
663
|
+
this.status = status;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/** How many models `probeEndpoint` hands back unless told otherwise: enough for any picker. */
|
|
668
|
+
const PROBE_MODEL_LIMIT = 500;
|
|
669
|
+
/** The statuses that mean the key, not the server, is what is wrong. */
|
|
670
|
+
const UNAUTHORIZED = new Set([HttpStatus.Unauthorized, HttpStatus.Forbidden]);
|
|
671
|
+
/** The range a redirect is answered in. */
|
|
672
|
+
const REDIRECT_MIN = 300;
|
|
673
|
+
const REDIRECT_MAX = 399;
|
|
674
|
+
/**
|
|
675
|
+
* Asks an endpoint nobody has saved yet which models it serves, for a form a person is typing it
|
|
676
|
+
* into.
|
|
677
|
+
*
|
|
678
|
+
* @param endpoint - The endpoint as typed. Its key goes as a bearer token where it has one other
|
|
679
|
+
* than `NO_KEY`; its `requestTimeoutSeconds` bounds the request where that is shorter than
|
|
680
|
+
* `lookupTimeoutMs`.
|
|
681
|
+
* @param [options] - Cancellation, and how many models to hand back. See `ProbeOptions`.
|
|
682
|
+
* @returns The models in order of id, one entry per id, at most `options.limit` of them. Rejects
|
|
683
|
+
* with the reason of `options.signal` where that stopped it, and with an `EndpointProbeError`
|
|
684
|
+
* for every other failure.
|
|
685
|
+
*
|
|
686
|
+
* @remarks
|
|
687
|
+
* `listModels` is for an endpoint that is configured: it pools a client per URL and remembers the
|
|
688
|
+
* listing, and a form asking at every keystroke would leave a client and a listing behind for
|
|
689
|
+
* each half-typed URL. This makes one plain request and remembers nothing, so asking again asks
|
|
690
|
+
* again.
|
|
691
|
+
*
|
|
692
|
+
* Redirects are not followed, so a typed key is never carried on to a host nobody typed. Both
|
|
693
|
+
* listing shapes are read: OpenAI's `{ data: [...] }` by `id`, and Ollama's native
|
|
694
|
+
* `{ models: [...] }` by `name` where an entry has no `id`. A window is read from the keys
|
|
695
|
+
* `contextLimitFor` reads, and an id listed twice keeps the window one of its entries stated.
|
|
696
|
+
*/
|
|
697
|
+
export async function probeEndpoint(endpoint, { signal, limit = PROBE_MODEL_LIMIT } = {}) {
|
|
698
|
+
const baseUrl = normalizeBaseUrl(endpoint.baseUrl);
|
|
699
|
+
if (baseUrl === null) {
|
|
700
|
+
throw new EndpointProbeError(ProbeFailure.InvalidUrl, 'A base URL is http:// or https://, with no credentials, query or fragment in it.');
|
|
701
|
+
}
|
|
702
|
+
const url = new URL(`${baseUrl}/models`);
|
|
703
|
+
const within = lookupSignal(endpoint, signal);
|
|
704
|
+
/** The failure for a request that threw: the caller's stop, the bound, or no answer at all. */
|
|
705
|
+
const thrown = () => {
|
|
706
|
+
signal?.throwIfAborted();
|
|
707
|
+
return within.aborted
|
|
708
|
+
? new EndpointProbeError(ProbeFailure.Timeout, `${url.origin} did not answer in time.`)
|
|
709
|
+
: new EndpointProbeError(ProbeFailure.Unreachable, `Could not reach ${url.origin}.`);
|
|
710
|
+
};
|
|
711
|
+
let response;
|
|
712
|
+
try {
|
|
713
|
+
response = await fetch(url, { headers: authHeaders(endpoint.apiKey), signal: within, redirect: 'manual' });
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
throw thrown();
|
|
717
|
+
}
|
|
718
|
+
const { status } = response;
|
|
719
|
+
// A browser hands a refused redirect back as an opaque response with status zero.
|
|
720
|
+
if (response.type === 'opaqueredirect' || (status >= REDIRECT_MIN && status <= REDIRECT_MAX)) {
|
|
721
|
+
const location = response.headers.get('location');
|
|
722
|
+
throw new EndpointProbeError(ProbeFailure.Redirected, `${url.origin} redirected the request${location ? ` to ${location}` : ''}; use the URL it redirects to.`, status || undefined);
|
|
723
|
+
}
|
|
724
|
+
if (UNAUTHORIZED.has(status)) {
|
|
725
|
+
throw new EndpointProbeError(ProbeFailure.Unauthorized, `${url.origin} refused the key (${status}).`, status);
|
|
726
|
+
}
|
|
727
|
+
if (NOT_THERE.has(status)) {
|
|
728
|
+
throw new EndpointProbeError(ProbeFailure.NotOpenAi, `${url.origin} has no model listing there (${status}); is the base URL missing a /v1?`, status);
|
|
729
|
+
}
|
|
730
|
+
if (response.ok === false) {
|
|
731
|
+
throw new EndpointProbeError(ProbeFailure.Failed, `${url.origin} answered ${status} when asked for its models.`, status);
|
|
732
|
+
}
|
|
733
|
+
let body;
|
|
734
|
+
try {
|
|
735
|
+
body = await response.json();
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
if (within.aborted) {
|
|
739
|
+
throw thrown();
|
|
740
|
+
}
|
|
741
|
+
body = undefined;
|
|
742
|
+
}
|
|
743
|
+
const { data, models } = (body ?? {});
|
|
744
|
+
const rows = Array.isArray(data) ? data : Array.isArray(models) ? models : undefined;
|
|
745
|
+
if (rows === undefined) {
|
|
746
|
+
throw new EndpointProbeError(ProbeFailure.NotOpenAi, `${url.origin} did not answer with a list of models.`);
|
|
747
|
+
}
|
|
748
|
+
const found = new Map();
|
|
749
|
+
for (const row of rows) {
|
|
750
|
+
if (row === null || typeof row !== 'object') {
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
const entry = row;
|
|
754
|
+
const id = typeof entry.id === 'string' ? entry.id : typeof entry.name === 'string' ? entry.name : '';
|
|
755
|
+
if (id === '') {
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
const { contextLength } = contextLengthOf(row);
|
|
759
|
+
found.set(id, { id, contextLength: contextLength || (found.get(id)?.contextLength ?? 0) });
|
|
760
|
+
}
|
|
761
|
+
return [...found.values()].sort((a, b) => byCodeUnit(a.id, b.id)).slice(0, limit);
|
|
762
|
+
}
|
|
597
763
|
/**
|
|
598
764
|
* How much a model will read, in tokens. Zero means nobody knows.
|
|
599
765
|
*
|
|
@@ -692,14 +858,14 @@ export function resetClients() {
|
|
|
692
858
|
refusals().clear();
|
|
693
859
|
}
|
|
694
860
|
/**
|
|
695
|
-
*
|
|
861
|
+
* Whether two base URLs are one endpoint, by `baseUrlKey`.
|
|
696
862
|
*
|
|
697
863
|
* @param a - The URL as one row has it.
|
|
698
864
|
* @param b - The URL as the other has it.
|
|
699
|
-
* @returns `true` where the two differ in nothing but surrounding whitespace
|
|
700
|
-
*
|
|
865
|
+
* @returns `true` where the two differ in nothing but surrounding whitespace, trailing slashes, the
|
|
866
|
+
* case of the scheme and host, and a default port. A path's case counts, and so does a `/v1`.
|
|
701
867
|
*/
|
|
702
|
-
export const sameUrl = (a, b) => a
|
|
868
|
+
export const sameUrl = (a, b) => baseUrlKey(a) === baseUrlKey(b);
|
|
703
869
|
/**
|
|
704
870
|
* The key to send, where an endpoint may inherit one from the settings it overrides.
|
|
705
871
|
*
|
package/dist/endpoint/slots.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { baseUrlKey } from '../core/config.js';
|
|
1
2
|
import { scoped } from '../core/scope.js';
|
|
2
3
|
import { modelKey } from './client.js';
|
|
3
4
|
/**
|
|
@@ -22,14 +23,14 @@ const pools = scoped(() => ({ slots: new Map(), clock: 0 }));
|
|
|
22
23
|
*
|
|
23
24
|
* @param endpoint - The server, read for `baseUrl` alone.
|
|
24
25
|
* @param model - The model, which a llama.cpp router serves from a process with slots of its own.
|
|
25
|
-
* @returns The key: the base URL
|
|
26
|
+
* @returns The key: the base URL as `baseUrlKey` writes it, and the model.
|
|
26
27
|
*
|
|
27
28
|
* @remarks
|
|
28
29
|
* The API key is left out where every other per-endpoint cache keys on it. Two keys reaching
|
|
29
30
|
* different servers through one URL cost only a slot left unused; two keys reaching the same
|
|
30
31
|
* server under two allocators hand one slot to two runs, which is the collision this exists to stop.
|
|
31
32
|
*/
|
|
32
|
-
const poolKey = (endpoint, model) => modelKey(endpoint.baseUrl
|
|
33
|
+
const poolKey = (endpoint, model) => modelKey(baseUrlKey(endpoint.baseUrl), model);
|
|
33
34
|
/**
|
|
34
35
|
* The slot allocator for one endpoint and model, sized to what its `/props` said.
|
|
35
36
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export { applyCompaction, COMPACT_AT, type CompactionOptions, type CompactionPlan, type CompactionRecord, type CompactionRunOptions, compactTranscript, KEEP_RATIO, type PruneOptions, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, } from './context/compaction.ts';
|
|
13
13
|
export { estimateFrom, type LedgerEntry, type LedgerEstimateOptions, type LedgerRequest, rebaseLedger, recordRequest, type TokenLedger, tokensBetween, } from './context/ledger.ts';
|
|
14
|
-
export { type AgentConfig, type Endpoint, type ModelParams, type RetryPolicy, ToolDiscovery, type ToolPolicy, } from './core/config.ts';
|
|
14
|
+
export { type AgentConfig, type Endpoint, type ModelParams, normalizeBaseUrl, type RetryPolicy, ToolDiscovery, type ToolPolicy, } from './core/config.ts';
|
|
15
15
|
export { calibrate, charsPerTokenFor, resetCalibration } from './endpoint/calibration.ts';
|
|
16
16
|
export { type Capabilities, capabilitiesFor, EFFORT_LADDER, EFFORT_NONE, EFFORT_OFF, effortFor, expireCapabilities, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from './endpoint/capabilities.ts';
|
|
17
|
-
export { type ClientPoolOptions, configureClients, contextLimitFor, endpointId, endpointKey, firstTokenMs, getClient, type LookupOptions, listModels, type ModelInfo, NO_KEY, resetClients, resolveApiKey, servedSlots, servedWindow, timeoutMs, } from './endpoint/client.ts';
|
|
17
|
+
export { type ClientPoolOptions, configureClients, contextLimitFor, EndpointProbeError, endpointId, endpointKey, firstTokenMs, getClient, type LookupOptions, listModels, type ModelInfo, NO_KEY, ProbeFailure, type ProbeOptions, probeEndpoint, resetClients, resolveApiKey, servedSlots, servedWindow, timeoutMs, } from './endpoint/client.ts';
|
|
18
18
|
export { isGrammarError, relaxSchema, relaxTools, sanitizeSchema, sanitizeTools, } from './endpoint/schema-compat.ts';
|
|
19
19
|
export { CACHE_PROMPT_FIELD, resetSlots, SESSION_HEADER, SLOT_FIELD, type SlotAllocator, slotAllocator, } from './endpoint/slots.ts';
|
|
20
20
|
export { HOOK_EVENTS, HookEvent, INJECT_EVENTS } from './hooks/hook-events.ts';
|
|
@@ -26,6 +26,7 @@ export { preview, type ToolDispatch } from './run/run-calls.ts';
|
|
|
26
26
|
export { resetAll } from './runtime/reset.ts';
|
|
27
27
|
export { createRuntime, defaultRuntime, type Runtime, type RuntimeOptions } from './runtime/runtime.ts';
|
|
28
28
|
export { CAPABILITY_SNAPSHOT_VERSION, type CapabilitySnapshot, type EndpointSnapshot, exportCapabilities, importCapabilities, type ModelSnapshot, } from './runtime/snapshot.ts';
|
|
29
|
+
export { defineSpecColumns, type ExtensionPath, type SpecColumn, type SpecColumnMapping, type SpecColumns, type SpecColumnsOptions, type SpecDrop, type SpecPath, type SpecSection, } from './spec/columns.ts';
|
|
29
30
|
export { AGENT_SPEC, AGENT_SPEC_VERSION, AGENT_TASKS, type AgentHook, type AgentSpec, type EndpointSpec, type ExportSpecOptions, exportSpec, findSpecSecrets, type ModelSpec, type ParsedSpec, type ParseSpecOptions, type PromptPart, parseSpec, REF_FILE, REF_URL, RESOLVED_DEFAULTS, type ResolvedAgent, type ResolvedTask, type RetrySpec, resolveAgentSpec, type SecretHint, SecretSign, SPEC_EVENTS, type SpecBundle, type SpecServer, type TaskSpec, type ToolsSpec, } from './spec/spec.ts';
|
|
30
31
|
export type { CatalogServer } from './tools/catalog.ts';
|
|
31
32
|
export { SERVER_INSTRUCTION_TOKENS, type ServerInstructionsOptions, serverInstructionsPrompt, } from './tools/instructions.ts';
|
package/dist/index.js
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export { applyCompaction, COMPACT_AT, compactTranscript, KEEP_RATIO, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, } from './context/compaction.js';
|
|
13
13
|
export { estimateFrom, rebaseLedger, recordRequest, tokensBetween, } from './context/ledger.js';
|
|
14
|
-
export { ToolDiscovery, } from './core/config.js';
|
|
14
|
+
export { normalizeBaseUrl, ToolDiscovery, } from './core/config.js';
|
|
15
15
|
export { calibrate, charsPerTokenFor, resetCalibration } from './endpoint/calibration.js';
|
|
16
16
|
export { capabilitiesFor, EFFORT_LADDER, EFFORT_NONE, EFFORT_OFF, effortFor, expireCapabilities, modelCapabilitiesFor, negotiate, resetCapabilities, } from './endpoint/capabilities.js';
|
|
17
|
-
export { configureClients, contextLimitFor, endpointId, endpointKey, firstTokenMs, getClient, listModels, NO_KEY, resetClients, resolveApiKey, servedSlots, servedWindow, timeoutMs, } from './endpoint/client.js';
|
|
17
|
+
export { configureClients, contextLimitFor, EndpointProbeError, endpointId, endpointKey, firstTokenMs, getClient, listModels, NO_KEY, ProbeFailure, probeEndpoint, resetClients, resolveApiKey, servedSlots, servedWindow, timeoutMs, } from './endpoint/client.js';
|
|
18
18
|
export { isGrammarError, relaxSchema, relaxTools, sanitizeSchema, sanitizeTools, } from './endpoint/schema-compat.js';
|
|
19
19
|
export { CACHE_PROMPT_FIELD, resetSlots, SESSION_HEADER, SLOT_FIELD, slotAllocator, } from './endpoint/slots.js';
|
|
20
20
|
export { HOOK_EVENTS, HookEvent, INJECT_EVENTS } from './hooks/hook-events.js';
|
|
@@ -26,6 +26,7 @@ export { preview } from './run/run-calls.js';
|
|
|
26
26
|
export { resetAll } from './runtime/reset.js';
|
|
27
27
|
export { createRuntime, defaultRuntime } from './runtime/runtime.js';
|
|
28
28
|
export { CAPABILITY_SNAPSHOT_VERSION, exportCapabilities, importCapabilities, } from './runtime/snapshot.js';
|
|
29
|
+
export { defineSpecColumns, } from './spec/columns.js';
|
|
29
30
|
export { AGENT_SPEC, AGENT_SPEC_VERSION, AGENT_TASKS, exportSpec, findSpecSecrets, parseSpec, REF_FILE, REF_URL, RESOLVED_DEFAULTS, resolveAgentSpec, SecretSign, SPEC_EVENTS, } from './spec/spec.js';
|
|
30
31
|
export { SERVER_INSTRUCTION_TOKENS, serverInstructionsPrompt, } from './tools/instructions.js';
|
|
31
32
|
export { PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselect, preselectByKeywords, preselectInput, preselection, preselectSystem, } from './tools/preselect.js';
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { type AgentSpec, type EndpointSpec, type ModelSpec, type RetrySpec, type TaskSpec, type ToolsSpec } from './spec.ts';
|
|
2
|
+
/**
|
|
3
|
+
* A host's stored agent, read as a spec layer and written back: one table of columns, and the
|
|
4
|
+
* marker each column stores for "inherit".
|
|
5
|
+
*
|
|
6
|
+
* Every host with profiles had written this projection by hand, three times with three markers —
|
|
7
|
+
* `""` and `-1` and the word `inherit` in one, `null` in the next, none at all in the third — and
|
|
8
|
+
* the import half of it once more, with a list of what a row could not keep. The merge was already
|
|
9
|
+
* `resolveAgentSpec`; what each repeated was which column feeds which field, and how a column at
|
|
10
|
+
* its marker becomes a field the layer does not have, which is the only spelling of "inherit" a
|
|
11
|
+
* spec knows.
|
|
12
|
+
*
|
|
13
|
+
* Browser-safe for the reason `spec.ts` is: it imports that module and a guard, and nothing that
|
|
14
|
+
* needs Node, so a form can preview an import with it.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Where in a spec a column lives, as a dotted path. A path the format has no field for does not
|
|
18
|
+
* typecheck.
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* `prompt.<id>` is the text of the prompt part with that id, since a row holds a prompt as one
|
|
22
|
+
* string and a spec holds it as named layers. A task name is everything between `tasks.` and the
|
|
23
|
+
* next dot, so a task whose name has a dot in it cannot be addressed — nor can an extension's
|
|
24
|
+
* reverse-domain key, which is what `ExtensionPath` is for.
|
|
25
|
+
*/
|
|
26
|
+
export type SpecPath = 'id' | 'name' | 'description' | `endpoint.${keyof EndpointSpec}` | `model.${keyof ModelSpec}` | `prompt.${string}` | `tools.${keyof ToolsSpec}` | `retry.${keyof RetrySpec}` | `tasks.${string}.endpoint.${keyof EndpointSpec}` | `tasks.${string}.${Exclude<keyof TaskSpec, 'endpoint'>}`;
|
|
27
|
+
/**
|
|
28
|
+
* A path into a host's own extension, as segments, because its key is a reverse domain and has
|
|
29
|
+
* dots of its own.
|
|
30
|
+
*/
|
|
31
|
+
export type ExtensionPath = readonly ['extensions', string, ...string[]];
|
|
32
|
+
/** The top-level keys of a spec, for naming a whole section a host reads by hand. */
|
|
33
|
+
export type SpecSection = '$schema' | 'id' | 'name' | 'description' | 'endpoint' | 'model' | 'prompt' | 'tools' | 'retry' | 'tasks' | 'hooks' | 'bundle' | 'extensions' | 'requires';
|
|
34
|
+
/**
|
|
35
|
+
* One column: where its value lives in a spec, what it stores for "inherit", and how its value
|
|
36
|
+
* crosses when the column and the field do not hold the same thing.
|
|
37
|
+
*
|
|
38
|
+
* @typeParam Value - The column's own type, which `inherit` and both conversions are checked
|
|
39
|
+
* against.
|
|
40
|
+
*/
|
|
41
|
+
export interface SpecColumn<Value> {
|
|
42
|
+
path: SpecPath | ExtensionPath;
|
|
43
|
+
/**
|
|
44
|
+
* What the column stores for "say nothing here": a value at it is a field the layer leaves out,
|
|
45
|
+
* and a field the layer leaves out reads back as it. Compared with `===`. Left out, the column
|
|
46
|
+
* has no marker — every value it holds is said, which is a host's bottom layer — and an absent
|
|
47
|
+
* field leaves the column out of what `fromLayer` returns.
|
|
48
|
+
*
|
|
49
|
+
* @remarks
|
|
50
|
+
* `null` and `undefined` are absent whatever the marker is, as they are to `parseSpec`, so a
|
|
51
|
+
* nullable column needs no marker to inherit and `inherit: null` only adds the read-back.
|
|
52
|
+
*/
|
|
53
|
+
inherit?: Value;
|
|
54
|
+
/**
|
|
55
|
+
* The column's value as the field holds it, for a column that stores something else — a switch
|
|
56
|
+
* for a mode, say. Called for a value that is neither absent nor the marker, and `undefined`
|
|
57
|
+
* back says nothing, which is how a marker that is a range rather than one value (a trimmed
|
|
58
|
+
* blank, any negative) is spelled. Left out, the value crosses as it is.
|
|
59
|
+
*/
|
|
60
|
+
toSpec?: (value: Exclude<Value, null | undefined>) => unknown;
|
|
61
|
+
/**
|
|
62
|
+
* The field's value as the column would store it, or `undefined` where the column cannot hold
|
|
63
|
+
* it — which `fromLayer` writes as the marker and `dropped` reports against this column. Left
|
|
64
|
+
* out, the value crosses as `parseSpec` left it, and the path is what says its type.
|
|
65
|
+
*/
|
|
66
|
+
fromSpec?: (value: unknown) => Value | undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A row's columns, each mapped to a field: keyed by column, so a name the row does not have is an
|
|
70
|
+
* excess property and a marker of the wrong type does not typecheck.
|
|
71
|
+
*
|
|
72
|
+
* @typeParam Row - The stored row. Only the columns named here are read or written.
|
|
73
|
+
*/
|
|
74
|
+
export type SpecColumns<Row> = {
|
|
75
|
+
[Column in keyof Row]?: SpecColumn<Row[Column]>;
|
|
76
|
+
};
|
|
77
|
+
/** What `defineSpecColumns` takes besides the columns. */
|
|
78
|
+
export interface SpecColumnsOptions {
|
|
79
|
+
/**
|
|
80
|
+
* What the host reads by hand, so `dropped` does not report it: a section (`bundle`), a path
|
|
81
|
+
* (`tools.servers`, where a list of slugs becomes a list of row ids) or an extension. `spec` is
|
|
82
|
+
* never reported, and needs no entry here.
|
|
83
|
+
*/
|
|
84
|
+
handled?: readonly (SpecSection | SpecPath | `tasks.${string}` | ExtensionPath)[];
|
|
85
|
+
}
|
|
86
|
+
/** One thing a document says that a row will not keep. */
|
|
87
|
+
export interface SpecDrop<Row> {
|
|
88
|
+
/** Where it is, in `parseSpec`'s spelling: `model.extraBody`, `prompt[1]`, `hooks[0]`. */
|
|
89
|
+
path: string;
|
|
90
|
+
/** What the document held there. */
|
|
91
|
+
value: unknown;
|
|
92
|
+
/**
|
|
93
|
+
* The column that refused it: present when there is a column for the field and its `fromSpec`
|
|
94
|
+
* could not store this value, absent when there is no column for the field at all.
|
|
95
|
+
*/
|
|
96
|
+
column?: keyof Row;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A row's column table, both ways.
|
|
100
|
+
*
|
|
101
|
+
* @typeParam Row - The stored row.
|
|
102
|
+
*/
|
|
103
|
+
export interface SpecColumnMapping<Row> {
|
|
104
|
+
/**
|
|
105
|
+
* The row as a spec layer, saying only what the row says.
|
|
106
|
+
*
|
|
107
|
+
* @param row - The row, or any part of it: a column the row does not carry says nothing.
|
|
108
|
+
* @returns A layer holding every column that is neither absent nor at its marker. A section
|
|
109
|
+
* nothing was said in is left out, and the layer is not yet parsed.
|
|
110
|
+
*/
|
|
111
|
+
toLayer(row: Partial<Row>): AgentSpec;
|
|
112
|
+
/**
|
|
113
|
+
* A spec layer as the row would store it.
|
|
114
|
+
*
|
|
115
|
+
* @param layer - A layer `parseSpec` has read, so each field is of its path's type.
|
|
116
|
+
* @returns Every mapped column: its field's value, or its marker where the field is absent or
|
|
117
|
+
* `fromSpec` refused it. A column with no marker is left out where its field is absent.
|
|
118
|
+
*/
|
|
119
|
+
fromLayer(layer: AgentSpec): Partial<Row>;
|
|
120
|
+
/**
|
|
121
|
+
* What a spec says that `fromLayer` would not keep: a field with no column, and a value a column
|
|
122
|
+
* refused.
|
|
123
|
+
*
|
|
124
|
+
* @param spec - A layer `parseSpec` has read.
|
|
125
|
+
* @returns Each loss once, in the document's order. Empty when the row keeps everything.
|
|
126
|
+
*
|
|
127
|
+
* @remarks
|
|
128
|
+
* A field this format names is reported at its own path, a section with nothing mapped in it
|
|
129
|
+
* whole beneath that: an `extraBody` no column takes is one entry, not one per key. A prompt
|
|
130
|
+
* part is a column's when its id is, and its `ref` is reported, since a column holds a text.
|
|
131
|
+
*/
|
|
132
|
+
dropped(spec: AgentSpec): SpecDrop<Row>[];
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* A column table from a stored row to a spec layer and back, with each column's "inherit" marker
|
|
136
|
+
* a parameter.
|
|
137
|
+
*
|
|
138
|
+
* @typeParam Row - The stored row: a settings row, a profile, an override layer.
|
|
139
|
+
* @param columns - Each mapped column, keyed by its name on the row.
|
|
140
|
+
* @param [options] - What the host reads by hand, outside the table.
|
|
141
|
+
* @returns `toLayer`, `fromLayer` and `dropped` over that one table.
|
|
142
|
+
* @throws When two columns write the same field, or one writes a section another writes inside.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* ```ts
|
|
146
|
+
* const profile = defineSpecColumns<Agent>({
|
|
147
|
+
* model: { path: 'model.model', inherit: '' },
|
|
148
|
+
* maxTokens: { path: 'model.maxTokens', inherit: -1 },
|
|
149
|
+
* toolDiscovery: { path: 'tools.discovery', inherit: 'inherit' },
|
|
150
|
+
* compactionModel: { path: 'tasks.compaction.model', inherit: '' },
|
|
151
|
+
* toolExecution: { path: ['extensions', 'com.example.host', 'toolExecution'], inherit: 'inherit' },
|
|
152
|
+
* });
|
|
153
|
+
* resolveAgentSpec([settings.toLayer(row), profile.toLayer(agent)]);
|
|
154
|
+
* ```
|
|
155
|
+
*
|
|
156
|
+
* @remarks
|
|
157
|
+
* Keyed by column rather than by field, so the marker and the conversions are typed against that
|
|
158
|
+
* column's own type and a column cannot be mapped twice. A column feeds one field: a row that
|
|
159
|
+
* spreads one field over several columns, or a record column over many fields, reads that part by
|
|
160
|
+
* hand and names it in `handled`.
|
|
161
|
+
*/
|
|
162
|
+
export declare function defineSpecColumns<Row>(columns: SpecColumns<Row>, { handled }?: SpecColumnsOptions): SpecColumnMapping<Row>;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { isRecord } from '../core/guards.js';
|
|
2
|
+
import { AGENT_SPEC, } from './spec.js';
|
|
3
|
+
/**
|
|
4
|
+
* The sections `dropped` always walks into, so a field the format names is reported at its own
|
|
5
|
+
* path. Anything deeper — one task, one extension — is walked into only where a column is mapped
|
|
6
|
+
* beneath it, and is otherwise reported whole: a side task this host does not run is one loss.
|
|
7
|
+
*/
|
|
8
|
+
const STRUCTURAL = ['endpoint', 'model', 'tools', 'retry', 'tasks', 'extensions'];
|
|
9
|
+
/** The section whose entries are named layers of text, addressed by `prompt.<id>`. */
|
|
10
|
+
const PROMPT = 'prompt';
|
|
11
|
+
/** The section whose entries are hooks, which `dropped` reports one by one. */
|
|
12
|
+
const HOOKS = 'hooks';
|
|
13
|
+
/** The key every layer declares itself under, which belongs to no column. */
|
|
14
|
+
const TOKEN = 'spec';
|
|
15
|
+
/**
|
|
16
|
+
* Absent and null are the same thing, as they are to `parseSpec`.
|
|
17
|
+
*
|
|
18
|
+
* @param value - A column's or a field's value.
|
|
19
|
+
* @returns `true` for `undefined` and `null` only.
|
|
20
|
+
*/
|
|
21
|
+
const absent = (value) => value === undefined || value === null;
|
|
22
|
+
/**
|
|
23
|
+
* A path as segments.
|
|
24
|
+
*
|
|
25
|
+
* @param path - A dotted path or an extension's segments.
|
|
26
|
+
* @returns The segments: a prompt id is one segment whatever it holds, and a task name the text up
|
|
27
|
+
* to the next dot.
|
|
28
|
+
*/
|
|
29
|
+
function segmentsOf(path) {
|
|
30
|
+
if (typeof path !== 'string') {
|
|
31
|
+
return path;
|
|
32
|
+
}
|
|
33
|
+
const prefix = `${PROMPT}.`;
|
|
34
|
+
if (path.startsWith(prefix)) {
|
|
35
|
+
return [PROMPT, path.slice(prefix.length)];
|
|
36
|
+
}
|
|
37
|
+
return path.split('.');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Whether one path is the other or lies beneath it.
|
|
41
|
+
*
|
|
42
|
+
* @param prefix - The shorter path.
|
|
43
|
+
* @param path - The path that may lie beneath it.
|
|
44
|
+
* @returns `true` when every segment of `prefix` begins `path`.
|
|
45
|
+
*/
|
|
46
|
+
const startsWith = (prefix, path) => prefix.length <= path.length && prefix.every((segment, index) => segment === path[index]);
|
|
47
|
+
/**
|
|
48
|
+
* Whether two paths are the same segments.
|
|
49
|
+
*
|
|
50
|
+
* @param a - One path.
|
|
51
|
+
* @param b - The other.
|
|
52
|
+
* @returns `true` when they are the same length and agree throughout.
|
|
53
|
+
*/
|
|
54
|
+
const samePath = (a, b) => a.length === b.length && startsWith(a, b);
|
|
55
|
+
/**
|
|
56
|
+
* The value at a path in a layer.
|
|
57
|
+
*
|
|
58
|
+
* @param layer - The layer.
|
|
59
|
+
* @param segments - Where to look. `['prompt', id]` reads that part's text.
|
|
60
|
+
* @returns What is there, or `undefined`.
|
|
61
|
+
*/
|
|
62
|
+
function read(layer, segments) {
|
|
63
|
+
if (segments[0] === PROMPT) {
|
|
64
|
+
return layer.prompt?.find((part) => part.id === segments[1])?.text;
|
|
65
|
+
}
|
|
66
|
+
let held = layer;
|
|
67
|
+
for (const segment of segments) {
|
|
68
|
+
if (isRecord(held) === false) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
held = held[segment];
|
|
72
|
+
}
|
|
73
|
+
return held;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Puts a value at a path in a layer being built, making the sections on the way.
|
|
77
|
+
*
|
|
78
|
+
* @param layer - The layer, as a plain object.
|
|
79
|
+
* @param segments - Where it goes. `['prompt', id]` appends a part.
|
|
80
|
+
* @param value - What goes there.
|
|
81
|
+
*/
|
|
82
|
+
function write(layer, segments, value) {
|
|
83
|
+
if (segments[0] === PROMPT) {
|
|
84
|
+
const parts = Array.isArray(layer.prompt) ? layer.prompt : [];
|
|
85
|
+
layer.prompt = [...parts, { id: segments[1], text: value }];
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
let section = layer;
|
|
89
|
+
for (const segment of segments.slice(0, -1)) {
|
|
90
|
+
const next = section[segment];
|
|
91
|
+
section[segment] = isRecord(next) ? next : {};
|
|
92
|
+
section = section[segment];
|
|
93
|
+
}
|
|
94
|
+
section[segments[segments.length - 1]] = value;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Refuses a table that cannot be read both ways.
|
|
98
|
+
*
|
|
99
|
+
* @param entries - The table's columns.
|
|
100
|
+
* @throws When a path has an empty segment, or one column's path is another's or lies beneath it —
|
|
101
|
+
* two columns writing one field, or one writing a section another writes inside.
|
|
102
|
+
*/
|
|
103
|
+
function checkTable(entries) {
|
|
104
|
+
for (const [index, entry] of entries.entries()) {
|
|
105
|
+
if (entry.segments.some((segment) => segment === '')) {
|
|
106
|
+
throw new Error(`defineSpecColumns: ${String(entry.column)} has an empty segment in its path`);
|
|
107
|
+
}
|
|
108
|
+
for (const other of entries.slice(index + 1)) {
|
|
109
|
+
if (startsWith(entry.segments, other.segments) || startsWith(other.segments, entry.segments)) {
|
|
110
|
+
throw new Error(`defineSpecColumns: ${String(entry.column)} and ${String(other.column)} write the same field`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A column table from a stored row to a spec layer and back, with each column's "inherit" marker
|
|
117
|
+
* a parameter.
|
|
118
|
+
*
|
|
119
|
+
* @typeParam Row - The stored row: a settings row, a profile, an override layer.
|
|
120
|
+
* @param columns - Each mapped column, keyed by its name on the row.
|
|
121
|
+
* @param [options] - What the host reads by hand, outside the table.
|
|
122
|
+
* @returns `toLayer`, `fromLayer` and `dropped` over that one table.
|
|
123
|
+
* @throws When two columns write the same field, or one writes a section another writes inside.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* const profile = defineSpecColumns<Agent>({
|
|
128
|
+
* model: { path: 'model.model', inherit: '' },
|
|
129
|
+
* maxTokens: { path: 'model.maxTokens', inherit: -1 },
|
|
130
|
+
* toolDiscovery: { path: 'tools.discovery', inherit: 'inherit' },
|
|
131
|
+
* compactionModel: { path: 'tasks.compaction.model', inherit: '' },
|
|
132
|
+
* toolExecution: { path: ['extensions', 'com.example.host', 'toolExecution'], inherit: 'inherit' },
|
|
133
|
+
* });
|
|
134
|
+
* resolveAgentSpec([settings.toLayer(row), profile.toLayer(agent)]);
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* @remarks
|
|
138
|
+
* Keyed by column rather than by field, so the marker and the conversions are typed against that
|
|
139
|
+
* column's own type and a column cannot be mapped twice. A column feeds one field: a row that
|
|
140
|
+
* spreads one field over several columns, or a record column over many fields, reads that part by
|
|
141
|
+
* hand and names it in `handled`.
|
|
142
|
+
*/
|
|
143
|
+
export function defineSpecColumns(columns, { handled = [] } = {}) {
|
|
144
|
+
const entries = [];
|
|
145
|
+
for (const column of Object.keys(columns)) {
|
|
146
|
+
const definition = columns[column];
|
|
147
|
+
if (definition) {
|
|
148
|
+
entries.push({ column, segments: segmentsOf(definition.path), hasMarker: 'inherit' in definition, definition });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
checkTable(entries);
|
|
152
|
+
const handledPaths = handled.map(segmentsOf);
|
|
153
|
+
/**
|
|
154
|
+
* What a column stores for a field's value.
|
|
155
|
+
*
|
|
156
|
+
* @param entry - The column.
|
|
157
|
+
* @param value - The field's value, present.
|
|
158
|
+
* @returns The column's value, or `undefined` where it refused it.
|
|
159
|
+
*/
|
|
160
|
+
const stored = (entry, value) => entry.definition.fromSpec ? entry.definition.fromSpec(value) : value;
|
|
161
|
+
return {
|
|
162
|
+
toLayer(row) {
|
|
163
|
+
const layer = { spec: AGENT_SPEC };
|
|
164
|
+
for (const { column, segments, hasMarker, definition } of entries) {
|
|
165
|
+
const value = row[column];
|
|
166
|
+
const inherits = absent(value) || (hasMarker && value === definition.inherit);
|
|
167
|
+
if (inherits) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const said = definition.toSpec ? definition.toSpec(value) : value;
|
|
171
|
+
if (absent(said) === false) {
|
|
172
|
+
write(layer, segments, said);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Built as a plain object because the paths are data; each field is the column's value, of
|
|
176
|
+
// its path's type when the host's `toSpec` says so, and `parseSpec` is the check on that.
|
|
177
|
+
return layer;
|
|
178
|
+
},
|
|
179
|
+
fromLayer(layer) {
|
|
180
|
+
const row = {};
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
const value = read(layer, entry.segments);
|
|
183
|
+
const kept = absent(value) ? undefined : stored(entry, value);
|
|
184
|
+
if (kept !== undefined) {
|
|
185
|
+
row[entry.column] = kept;
|
|
186
|
+
}
|
|
187
|
+
else if (entry.hasMarker) {
|
|
188
|
+
row[entry.column] = entry.definition.inherit;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return row;
|
|
192
|
+
},
|
|
193
|
+
dropped(spec) {
|
|
194
|
+
const drops = [];
|
|
195
|
+
const isHandled = (path) => handledPaths.some((prefix) => startsWith(prefix, path));
|
|
196
|
+
const columnAt = (path) => entries.find((entry) => samePath(entry.segments, path));
|
|
197
|
+
const mappedBeneath = (path) => entries.some((entry) => entry.segments.length > path.length && startsWith(path, entry.segments));
|
|
198
|
+
const isStructural = (path) => path.length === 0 || (path.length === 1 && STRUCTURAL.includes(path[0]));
|
|
199
|
+
/**
|
|
200
|
+
* Reports a column's refusal of a value, if it refuses it.
|
|
201
|
+
*
|
|
202
|
+
* @param entry - The column at this path.
|
|
203
|
+
* @param path - The path as `parseSpec` spells it.
|
|
204
|
+
* @param value - The field's value, present.
|
|
205
|
+
*/
|
|
206
|
+
const offer = (entry, path, value) => {
|
|
207
|
+
if (stored(entry, value) === undefined) {
|
|
208
|
+
drops.push({ path, value, column: entry.column });
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
/**
|
|
212
|
+
* Reports every part of a prompt no column takes, and every reference one does.
|
|
213
|
+
*
|
|
214
|
+
* @param parts - The layer's prompt.
|
|
215
|
+
*/
|
|
216
|
+
const walkPrompt = (parts) => {
|
|
217
|
+
for (const [index, part] of parts.entries()) {
|
|
218
|
+
const path = [PROMPT, part.id];
|
|
219
|
+
if (isHandled(path)) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const entry = columnAt(path);
|
|
223
|
+
if (entry === undefined) {
|
|
224
|
+
drops.push({ path: `${PROMPT}[${index}]`, value: part });
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (absent(part.text) === false) {
|
|
228
|
+
offer(entry, `${PROMPT}[${index}].text`, part.text);
|
|
229
|
+
}
|
|
230
|
+
if (part.ref) {
|
|
231
|
+
drops.push({ path: `${PROMPT}[${index}].ref`, value: part.ref });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* Reports what lies at a path and is not kept, descending where something beneath is mapped.
|
|
237
|
+
*
|
|
238
|
+
* @param value - What the document holds at the path.
|
|
239
|
+
* @param path - Where it is, as segments.
|
|
240
|
+
*/
|
|
241
|
+
const walk = (value, path) => {
|
|
242
|
+
if (absent(value) || isHandled(path)) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const entry = columnAt(path);
|
|
246
|
+
if (entry) {
|
|
247
|
+
offer(entry, path.join('.'), value);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (path.length === 1 && path[0] === PROMPT && Array.isArray(value)) {
|
|
251
|
+
walkPrompt(value);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (path.length === 1 && path[0] === HOOKS && Array.isArray(value)) {
|
|
255
|
+
for (const [index, hook] of value.entries()) {
|
|
256
|
+
drops.push({ path: `${HOOKS}[${index}]`, value: hook });
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (isRecord(value) && (isStructural(path) || mappedBeneath(path))) {
|
|
261
|
+
for (const [key, held] of Object.entries(value)) {
|
|
262
|
+
const isToken = path.length === 0 && key === TOKEN;
|
|
263
|
+
if (isToken === false) {
|
|
264
|
+
walk(held, [...path, key]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
drops.push({ path: path.join('.'), value });
|
|
270
|
+
};
|
|
271
|
+
walk(spec, []);
|
|
272
|
+
return drops;
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
package/dist/wire/wire.d.ts
CHANGED
package/dist/wire/wire.js
CHANGED
package/llms.txt
CHANGED
|
@@ -50,6 +50,7 @@ What this package needs to know about a caller's configuration.
|
|
|
50
50
|
- `AgentConfig` (type) — A whole agent configuration — every part, plus the two fields that belong to no group.
|
|
51
51
|
- `Endpoint` (type) — Where to send a request and how long to wait.
|
|
52
52
|
- `ModelParams` (type) — What to ask the model for.
|
|
53
|
+
- `normalizeBaseUrl` — A base URL worth storing: http or https, no credentials, query or fragment, no trailing slash.
|
|
53
54
|
- `RetryPolicy` (type) — How many times a lost or refused request is worth sending again.
|
|
54
55
|
- `ToolDiscovery` — Any one of the modes in `ToolDiscovery`.
|
|
55
56
|
- `ToolPolicy` (type) — How tools reach the model, and how long it may keep calling them.
|
|
@@ -84,6 +85,7 @@ What an endpoint turned out not to support, and answering it when it says so.
|
|
|
84
85
|
- `ClientPoolOptions` (type) — What the client pool is held to across a process.
|
|
85
86
|
- `configureClients` — Changes what the client pool is held to, for a process whose endpoints are not shaped like the deployments these defaults were chosen for.
|
|
86
87
|
- `contextLimitFor` — How much a model will read, in tokens.
|
|
88
|
+
- `EndpointProbeError` — `probeEndpoint` could not list an endpoint's models; `kind` says why.
|
|
87
89
|
- `endpointId` — `endpointKey` hashed, for the remembered facts that can leave the process.
|
|
88
90
|
- `endpointKey` — What counts as one endpoint, everywhere in this package that has to remember something about one — the model listings, `capabilities`, and the no-thinking hints in `side-task`.
|
|
89
91
|
- `firstTokenMs` — The wait for a streamed turn's first chunk, in the SDK's spelling: `undefined` is no limit.
|
|
@@ -92,6 +94,9 @@ What an endpoint turned out not to support, and answering it when it says so.
|
|
|
92
94
|
- `listModels` — Asks an endpoint what it serves, and remembers the answer.
|
|
93
95
|
- `ModelInfo` (type) — A model an endpoint offers, and what it says the model will read.
|
|
94
96
|
- `NO_KEY` — The SDK insists on a non-empty key even where the server will not look at it.
|
|
97
|
+
- `ProbeFailure` — Why `probeEndpoint` could not list an endpoint's models, in words a form can branch on.
|
|
98
|
+
- `ProbeOptions` (type) — What `probeEndpoint` takes besides the endpoint.
|
|
99
|
+
- `probeEndpoint` — Asks an endpoint nobody has saved yet which models it serves, for a form a person is typing it into.
|
|
95
100
|
- `resetClients` — Forgets every cached client and listing, and puts `configureClients` back to the defaults.
|
|
96
101
|
- `resolveApiKey` — The key to send, where an endpoint may inherit one from the settings it overrides.
|
|
97
102
|
- `servedSlots` — How many slots a llama.cpp server runs a model in, for pinning a session to one of them.
|
|
@@ -221,6 +226,20 @@ What endpoints and models refused, carried across a restart.
|
|
|
221
226
|
- `importCapabilities` — Latches what a stored snapshot says was refused, on top of whatever this process has learned.
|
|
222
227
|
- `ModelSnapshot` (type) — What one model on an endpoint refused.
|
|
223
228
|
|
|
229
|
+
### columns
|
|
230
|
+
|
|
231
|
+
A host's stored agent, read as a spec layer and written back: one table of columns, and the marker each column stores for "inherit".
|
|
232
|
+
|
|
233
|
+
- `defineSpecColumns` — A column table from a stored row to a spec layer and back, with each column's "inherit" marker a parameter.
|
|
234
|
+
- `ExtensionPath` (type) — A path into a host's own extension, as segments, because its key is a reverse domain and has dots of its own.
|
|
235
|
+
- `SpecColumn` (type) — One column: where its value lives in a spec, what it stores for "inherit", and how its value crosses when the column and the field do not hold the same thing.
|
|
236
|
+
- `SpecColumnMapping` (type) — A row's column table, both ways.
|
|
237
|
+
- `SpecColumns` (type) — A row's columns, each mapped to a field: keyed by column, so a name the row does not have is an excess property and a marker of the wrong type does not typecheck.
|
|
238
|
+
- `SpecColumnsOptions` (type) — What `defineSpecColumns` takes besides the columns.
|
|
239
|
+
- `SpecDrop` (type) — One thing a document says that a row will not keep.
|
|
240
|
+
- `SpecPath` (type) — Where in a spec a column lives, as a dotted path.
|
|
241
|
+
- `SpecSection` (type) — The top-level keys of a spec, for naming a whole section a host reads by hand.
|
|
242
|
+
|
|
224
243
|
### spec
|
|
225
244
|
|
|
226
245
|
A JSON document that defines an agent, and the rules for reading one.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cubicecho/agent-core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.13.0",
|
|
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",
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
"./spec": {
|
|
36
36
|
"types": "./dist/spec/spec.d.ts",
|
|
37
37
|
"import": "./dist/spec/spec.js"
|
|
38
|
+
},
|
|
39
|
+
"./spec/columns": {
|
|
40
|
+
"types": "./dist/spec/columns.d.ts",
|
|
41
|
+
"import": "./dist/spec/columns.js"
|
|
38
42
|
}
|
|
39
43
|
},
|
|
40
44
|
"files": [
|