@cubicecho/agent-core 2.6.0 → 2.8.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 +59 -13
- package/dist/agent-loop.js +5 -1
- package/dist/client.d.ts +30 -4
- package/dist/client.js +119 -11
- package/dist/config.d.ts +25 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/retry.d.ts +15 -0
- package/dist/retry.js +36 -0
- package/dist/run-turn.d.ts +10 -1
- package/dist/run-turn.js +19 -2
- package/dist/side-task.js +4 -17
- package/dist/stream.d.ts +35 -2
- package/dist/stream.js +99 -28
- package/dist/thinking.d.ts +85 -0
- package/dist/thinking.js +182 -0
- package/llms.txt +19 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,13 +22,14 @@ only, Node >=22.
|
|
|
22
22
|
| --- | --- |
|
|
23
23
|
| `schema-compat` | Makes an MCP tool schema something a strict or grammar-constrained server will accept. `sanitizeTools`, `relaxTools`, `isGrammarError`. |
|
|
24
24
|
| `tool-loading` | On-demand tool discovery: a name-only catalogue plus a `load_tools` meta-tool, so a run pays for the schemas it asks for instead of all of them. |
|
|
25
|
-
| `stream` | Reads one streamed turn back into a message: token callbacks, tool-call reassembly, and the idle watchdog that turns a silent endpoint into `EndpointSilent`. |
|
|
25
|
+
| `stream` | Reads one streamed turn back into a message: token callbacks, tool-call reassembly, fenced reasoning taken out of the answer, and the idle watchdog that turns a silent endpoint into `EndpointSilent`. |
|
|
26
26
|
| `capabilities` | What an endpoint turned out not to support — and, under it, what one model on that endpoint did not — plus the loop that answers either when it says so. `capabilitiesFor`, `modelCapabilitiesFor`, `negotiate`. |
|
|
27
|
+
| `thinking` | Tells a scratchpad fenced inside `content` from the answer: `FenceSplitter` for a stream, `stripThinking` for a whole reply, and the fence tables both read. |
|
|
27
28
|
| `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. |
|
|
28
29
|
| `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. |
|
|
29
30
|
| `events` | The in-memory bus a watcher reads while a run happens: `emit`, `watch`, `history`, `fold`. A watcher's backlog is capped and reports its own gaps. |
|
|
30
|
-
| `client` | A pooled `OpenAI` client per endpoint, plus the context
|
|
31
|
-
| `retry` | What to do when a request is lost, refused or too big: `isTransient`, `backoffMs`, `ContextOverflow`, `EndpointSilent`, `requestTokens`. |
|
|
31
|
+
| `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. |
|
|
32
|
+
| `retry` | What to do when a request is lost, refused or too big: `isTransient`, `isModelLoading`, `backoffMs`, `ContextOverflow`, `EndpointSilent`, `requestTokens`. |
|
|
32
33
|
| `config` | The structural interfaces every function here asks for. |
|
|
33
34
|
| `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`. |
|
|
34
35
|
| `agent-loop` | `runAgentLoop`: the loop above a turn — `runTurn` per step, the tools between, `load_tools` and preselection handled, until the model stops asking. Plus the parts it is made of: `buildBody`, `preselect`, `preview`, and `resolveApiKey` for a caller deciding which key an endpoint gets. |
|
|
@@ -80,11 +81,28 @@ for: a router is free to send two keys to two different backends, and then what
|
|
|
80
81
|
refused is not a fact about the other. Absent and empty read the same, so a local server with no
|
|
81
82
|
key is one entry however its caller spells it.
|
|
82
83
|
|
|
83
|
-
`Turn` is `content`, `toolCalls`, `usage` and `
|
|
84
|
-
cut off at the token ceiling comes back looking exactly like a finished one, with
|
|
85
|
-
or — the case that bites — a tool call whose `arguments` stop mid-JSON, so the
|
|
86
|
-
parse failure with nothing to attribute it to. `finishReason` is `"length"` there,
|
|
87
|
-
endpoint never said.
|
|
84
|
+
`Turn` is `content`, `toolCalls`, `usage`, `finishReason` and `reasoning`. The fourth is worth
|
|
85
|
+
reading: a turn cut off at the token ceiling comes back looking exactly like a finished one, with
|
|
86
|
+
truncated prose or — the case that bites — a tool call whose `arguments` stop mid-JSON, so the
|
|
87
|
+
caller meets a parse failure with nothing to attribute it to. `finishReason` is `"length"` there,
|
|
88
|
+
`""` where the endpoint never said.
|
|
89
|
+
|
|
90
|
+
`reasoning` is the scratchpad `onThinking` was told, kept because two common families want it
|
|
91
|
+
back. gpt-oss and DeepSeek in thinking mode read the analysis behind a tool call off the assistant
|
|
92
|
+
message on the next request: store it as `reasoning_content` on that message while it ends in a
|
|
93
|
+
tool call, and drop it once the model has answered. Any other model is better off without it,
|
|
94
|
+
since it is context paid for on every turn. `requestTokens` counts it either way.
|
|
95
|
+
|
|
96
|
+
A server without a reasoning parser leaves the scratchpad in `content`, fenced, and then it is shown
|
|
97
|
+
as output, stored and sent back. `streamTurn` routes text inside a fence to `onThinking` and
|
|
98
|
+
`reasoning` instead, holding back the tail of a chunk that could be half a tag. `DEFAULT_FENCES` is
|
|
99
|
+
`<think>`, gpt-oss harmony's analysis channel served raw, and Kimi's `◁think▷`, none of which a
|
|
100
|
+
model writes as an answer; `ALL_FENCES` adds `<thinking>` and `<reasoning>`, which it can be
|
|
101
|
+
quoting, and is what the side tasks use. Pass `fences: []` to read `content` as all answer. A reply
|
|
102
|
+
cut off inside a fence has an empty `content`, not the deliberation promoted to one. A template that
|
|
103
|
+
opens `<think>` in the prompt leaves only the closing tag, so everything before it is moved to
|
|
104
|
+
`reasoning` when it arrives; `startInReasoning: true` says so up front, so `onOutput` is never told
|
|
105
|
+
it at all.
|
|
88
106
|
|
|
89
107
|
## What the model refuses, rather than the server
|
|
90
108
|
|
|
@@ -145,11 +163,21 @@ is worth another attempt. The content-free `{"role":"assistant"}` most servers o
|
|
|
145
163
|
does not set it: nothing has been shown to anybody yet, so an endpoint that primes the stream and
|
|
146
164
|
then wedges is retried like one that never answered at all.
|
|
147
165
|
|
|
148
|
-
`idleMs` is silence, not a deadline: the timer is rearmed on every chunk, so a model that is
|
|
149
|
-
|
|
166
|
+
`idleMs` is silence, not a deadline: the timer is rearmed on every chunk, so a model that is still
|
|
167
|
+
talking is never cut off however long it takes, and one that has stopped answering raises
|
|
150
168
|
`EndpointSilent` rather than hanging the run. `timeoutMs(config)` returns `undefined` for a
|
|
151
|
-
`requestTimeoutSeconds` of zero or absent, which waits forever — what a local model answering
|
|
152
|
-
|
|
169
|
+
`requestTimeoutSeconds` of zero or absent, which waits forever — what a local model answering slowly
|
|
170
|
+
needs.
|
|
171
|
+
|
|
172
|
+
The first chunk gets its own allowance, `firstChunkMs`, because the first wait is prefill: tens of
|
|
173
|
+
seconds for a long prompt on a local GPU, minutes on a CPU, and longer again when the server is
|
|
174
|
+
loading the model on demand. It holds until a chunk carries something, so an empty
|
|
175
|
+
`{"role":"assistant"}` sent before the prompt is read does not start the idle clock.
|
|
176
|
+
`firstTokenMs(config)` reads `firstTokenSeconds` off the endpoint, and five times
|
|
177
|
+
`requestTimeoutSeconds` where that is absent. With a watchdog armed, the SDK's own timer is switched
|
|
178
|
+
off for the stream; it runs until the headers arrive, which is the end of prefill, and used to
|
|
179
|
+
abandon one at the idle number. `requestTimeoutSeconds` still bounds calls that do not stream, side
|
|
180
|
+
tasks and model listings, the same way.
|
|
153
181
|
|
|
154
182
|
## Structured side tasks
|
|
155
183
|
|
|
@@ -191,6 +219,16 @@ const turn = await runTurn(client, supports, build, {
|
|
|
191
219
|
});
|
|
192
220
|
```
|
|
193
221
|
|
|
222
|
+
`contextLimitFor` answers the operator's number when there is one. Otherwise it asks for the window
|
|
223
|
+
the server is actually serving the model in (`servedWindow`): llama.cpp's `/props`
|
|
224
|
+
(`default_generation_settings.n_ctx`) and LM Studio's `/api/v0/models` (`loaded_context_length`).
|
|
225
|
+
That differs from the trained window in the case the guard exists for, a 256k model started at `-c
|
|
226
|
+
16384`. A server with neither route is latched and not asked again. Failing both, it reads the
|
|
227
|
+
`/v1/models` listing: `max_model_len` from vLLM, `context_length` from OpenRouter, and llama.cpp's
|
|
228
|
+
`meta.n_ctx_train`, which is only the trained window. Ollama reports no window on any route this
|
|
229
|
+
reads, and truncates an over-long prompt rather than refusing it, so on Ollama pass `contextLength`
|
|
230
|
+
or there is no guard at all.
|
|
231
|
+
|
|
194
232
|
What is weighed is the prompt plus the reply ceiling the body carries, under whichever spelling
|
|
195
233
|
was chosen, because that is what the endpoint weighs: a 30k prompt into a 32k window with
|
|
196
234
|
`max_tokens: 4096` is refused there, so it is refused here. A body with no ceiling reserves
|
|
@@ -209,6 +247,14 @@ refuses instead — one round trip later — and `runTurn` reads that refusal ba
|
|
|
209
247
|
original error as `cause`. A rate limit borrows those words and means the opposite ("Request too
|
|
210
248
|
large for gpt-4o ... on tokens per min"); that is ruled out and waited through as the 429 it is.
|
|
211
249
|
|
|
250
|
+
A server still loading the model is waited for on its own clock. llama.cpp answers 503 `Loading
|
|
251
|
+
model` (type `unavailable_error`) until the weights are mapped, thirty to ninety seconds for a large
|
|
252
|
+
model from a cold cache, and a router build says the same while it swaps models; `backoffMs` would
|
|
253
|
+
give up inside fifteen. `isModelLoading` recognises it, and `runTurn` polls every `LOADING_POLL_MS`
|
|
254
|
+
for up to `loadingTimeoutMs` (two minutes by default, zero to turn it off) without spending
|
|
255
|
+
`maxRetries`, with one notice at the start. `runAgentLoop` reads it as `loadingTimeoutSeconds` off
|
|
256
|
+
the config. A 503 that says nothing about loading stays on the ordinary backoff.
|
|
257
|
+
|
|
212
258
|
## The loop
|
|
213
259
|
|
|
214
260
|
`runAgentLoop` is the part of an agent that three servers had each written, and that had drifted
|
|
@@ -221,7 +267,7 @@ did. What it does not know is what the run is for — the prompt, the tools, and
|
|
|
221
267
|
import { runAgentLoop, emit } from "@cubicecho/agent-core";
|
|
222
268
|
|
|
223
269
|
const { turn, messages, usage, loaded } = await runAgentLoop({
|
|
224
|
-
config, // Endpoint & ModelParams & { maxToolIterations, toolDiscovery?, maxRetries?, contextLength? }
|
|
270
|
+
config, // Endpoint & ModelParams & { maxToolIterations, toolDiscovery?, maxRetries?, loadingTimeoutSeconds?, contextLength? }
|
|
225
271
|
system, // sent as the first message; on-demand mode appends the catalogue
|
|
226
272
|
messages: history, // ending in the question; not written to
|
|
227
273
|
tools, // every tool the run may reach
|
package/dist/agent-loop.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { capabilitiesFor } from "./capabilities.js";
|
|
2
|
-
import { getClient, NO_KEY, timeoutMs } from "./client.js";
|
|
2
|
+
import { firstTokenMs, getClient, NO_KEY, timeoutMs } from "./client.js";
|
|
3
3
|
import { errorMessage } from "./errors.js";
|
|
4
4
|
import { gather, notify, turnIndex, turnMessages, withContext, } from "./hooks.js";
|
|
5
5
|
import { runTurn } from "./run-turn.js";
|
|
@@ -187,9 +187,13 @@ export async function runAgentLoop(options) {
|
|
|
187
187
|
model: config.model,
|
|
188
188
|
droppable: Object.keys(config.extraBody ?? {}),
|
|
189
189
|
maxRetries,
|
|
190
|
+
...(config.loadingTimeoutSeconds === undefined
|
|
191
|
+
? {}
|
|
192
|
+
: { loadingTimeoutMs: Math.max(0, config.loadingTimeoutSeconds) * 1000 }),
|
|
190
193
|
contextLimit: config.contextLength ?? 0,
|
|
191
194
|
signal,
|
|
192
195
|
idleMs: timeoutMs(config),
|
|
196
|
+
firstChunkMs: firstTokenMs(config) ?? 0,
|
|
193
197
|
onNotice: notice,
|
|
194
198
|
onThinking: (text) => onEvent?.({ kind: "thinking", text }),
|
|
195
199
|
onOutput: (text) => onEvent?.({ kind: "output", text }),
|
package/dist/client.d.ts
CHANGED
|
@@ -12,6 +12,14 @@ export declare const NO_KEY = "agent-core";
|
|
|
12
12
|
* @param config Read for `requestTimeoutSeconds` alone.
|
|
13
13
|
*/
|
|
14
14
|
export declare const timeoutMs: (config: Pick<Endpoint, "requestTimeoutSeconds">) => number | undefined;
|
|
15
|
+
/** How many idle windows the first chunk gets when `firstTokenSeconds` is not given. */
|
|
16
|
+
export declare const FIRST_TOKEN_FACTOR = 5;
|
|
17
|
+
/**
|
|
18
|
+
* The wait for a streamed turn's first chunk, in the SDK's spelling: `undefined` is no limit.
|
|
19
|
+
*
|
|
20
|
+
* @param config Read for `firstTokenSeconds`, and `requestTimeoutSeconds` where that is absent.
|
|
21
|
+
*/
|
|
22
|
+
export declare const firstTokenMs: (config: Pick<Endpoint, "requestTimeoutSeconds" | "firstTokenSeconds">) => number | undefined;
|
|
15
23
|
/**
|
|
16
24
|
* The client for an endpoint, built once and kept.
|
|
17
25
|
*
|
|
@@ -67,6 +75,23 @@ export declare const endpointId: (config: {
|
|
|
67
75
|
baseUrl: string;
|
|
68
76
|
apiKey?: string;
|
|
69
77
|
}) => string;
|
|
78
|
+
/**
|
|
79
|
+
* The window a local server is actually serving a model in, which its listing does not say.
|
|
80
|
+
*
|
|
81
|
+
* llama.cpp reports it on `/props` as `default_generation_settings.n_ctx`, per slot, and LM
|
|
82
|
+
* Studio on `/api/v0/models` as `loaded_context_length` while the model is loaded. Both differ
|
|
83
|
+
* from the trained window in the case that matters, a model started in a smaller one than it was
|
|
84
|
+
* built for, and reading the trained one lets an overflow through the guard meant to catch it.
|
|
85
|
+
* Ollama reports nothing on either; an operator there has to declare the window.
|
|
86
|
+
*
|
|
87
|
+
* Zero where no answer was found. A server without either route, which is every hosted API, is
|
|
88
|
+
* latched and not asked again; one that could not be reached is not remembered at all.
|
|
89
|
+
*
|
|
90
|
+
* @param config The endpoint, plus the model whose window is wanted.
|
|
91
|
+
*/
|
|
92
|
+
export declare function servedWindow(config: Endpoint & {
|
|
93
|
+
model: string;
|
|
94
|
+
}): Promise<number>;
|
|
70
95
|
/**
|
|
71
96
|
* Asks an endpoint what it serves, and remembers the answer.
|
|
72
97
|
*
|
|
@@ -81,10 +106,11 @@ export declare function listModels(config: Endpoint): Promise<ModelInfo[]>;
|
|
|
81
106
|
* happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
|
|
82
107
|
* the honest-looking number is a run that fails at the endpoint instead.
|
|
83
108
|
*
|
|
84
|
-
* Otherwise the
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
109
|
+
* Otherwise the window the server is actually serving the model in, where it has an API that
|
|
110
|
+
* says (`servedWindow`), and failing that the endpoint's listing — asked once, and again whenever
|
|
111
|
+
* it does not name this model, since a model can arrive after the first listing was taken. A
|
|
112
|
+
* server that will not list models still has to be able to run a turn: a failure here is an
|
|
113
|
+
* unknown window, not a failed run.
|
|
88
114
|
*
|
|
89
115
|
* @param config The endpoint, plus the model whose window is wanted.
|
|
90
116
|
* @param declared The operator's own number. Above zero it wins and the endpoint is not asked.
|
package/dist/client.js
CHANGED
|
@@ -47,6 +47,20 @@ const clients = new Map();
|
|
|
47
47
|
* constraint.
|
|
48
48
|
*/
|
|
49
49
|
const MAX_CLIENTS = 32;
|
|
50
|
+
/** How many idle windows the first chunk gets when `firstTokenSeconds` is not given. */
|
|
51
|
+
export const FIRST_TOKEN_FACTOR = 5;
|
|
52
|
+
/**
|
|
53
|
+
* The wait for a streamed turn's first chunk, in the SDK's spelling: `undefined` is no limit.
|
|
54
|
+
*
|
|
55
|
+
* @param config Read for `firstTokenSeconds`, and `requestTimeoutSeconds` where that is absent.
|
|
56
|
+
*/
|
|
57
|
+
export const firstTokenMs = (config) => {
|
|
58
|
+
if (config.firstTokenSeconds === undefined) {
|
|
59
|
+
const idle = timeoutMs(config);
|
|
60
|
+
return idle === undefined ? undefined : idle * FIRST_TOKEN_FACTOR;
|
|
61
|
+
}
|
|
62
|
+
return config.firstTokenSeconds > 0 ? config.firstTokenSeconds * 1000 : undefined;
|
|
63
|
+
};
|
|
50
64
|
/**
|
|
51
65
|
* The client for an endpoint, built once and kept.
|
|
52
66
|
*
|
|
@@ -89,12 +103,13 @@ export function getClient(config) {
|
|
|
89
103
|
return client;
|
|
90
104
|
}
|
|
91
105
|
/**
|
|
92
|
-
* The context window, spelled every way a server spells it.
|
|
106
|
+
* The context window, spelled every way a server spells it at the top of a listing entry.
|
|
93
107
|
*
|
|
94
108
|
* None of these is in the OpenAI listing schema, so every server that says anything says it as
|
|
95
|
-
* an extra key of its own: `
|
|
96
|
-
*
|
|
97
|
-
*
|
|
109
|
+
* an extra key of its own: `max_model_len` is vLLM, `context_length` OpenRouter, `n_ctx` the raw
|
|
110
|
+
* llama bindings. Whichever turns up first is taken — a server reporting two of them is
|
|
111
|
+
* reporting the same number twice. llama.cpp puts its number under `meta` instead, and LM Studio
|
|
112
|
+
* and Ollama put none on this route at all; see `servedWindow`.
|
|
98
113
|
*/
|
|
99
114
|
const CONTEXT_KEYS = [
|
|
100
115
|
"context_length",
|
|
@@ -103,14 +118,19 @@ const CONTEXT_KEYS = [
|
|
|
103
118
|
"context_window",
|
|
104
119
|
"n_ctx",
|
|
105
120
|
];
|
|
121
|
+
const positive = (value) => (typeof value === "number" && value > 0 ? value : 0);
|
|
106
122
|
function contextLengthOf(model) {
|
|
107
123
|
const record = model;
|
|
108
124
|
for (const key of CONTEXT_KEYS) {
|
|
109
|
-
const value = record[key];
|
|
110
|
-
if (
|
|
125
|
+
const value = positive(record[key]);
|
|
126
|
+
if (value)
|
|
111
127
|
return value;
|
|
112
128
|
}
|
|
113
|
-
|
|
129
|
+
// llama.cpp's, and the window the model was trained with rather than the one it is served in:
|
|
130
|
+
// a 256k model started at `-c 16384` lists 262144 here. Better than nothing, and why the
|
|
131
|
+
// served window is asked for first. `meta` is `null` while the model loads.
|
|
132
|
+
const meta = record.meta;
|
|
133
|
+
return positive(meta?.n_ctx_train);
|
|
114
134
|
}
|
|
115
135
|
/**
|
|
116
136
|
* The last listing from each endpoint, so a run can size its window without a round trip.
|
|
@@ -172,6 +192,88 @@ export const endpointKey = (config) => JSON.stringify([config.baseUrl, config.ap
|
|
|
172
192
|
* @param config Read for `baseUrl` and `apiKey` alone, as `endpointKey` reads it.
|
|
173
193
|
*/
|
|
174
194
|
export const endpointId = (config) => createHash("sha256").update(endpointKey(config)).digest("hex");
|
|
195
|
+
/**
|
|
196
|
+
* Served windows found by asking a server's own API, keyed on endpoint and model together.
|
|
197
|
+
*
|
|
198
|
+
* A model's entry stays until `resetClients`, like a listing; a server that answered without one
|
|
199
|
+
* is asked again after `LISTING_MISS_MS`, like a listing that did not name the model.
|
|
200
|
+
*/
|
|
201
|
+
const served = new Map();
|
|
202
|
+
/** Endpoints that answered both probes with a refusal, and are not asked again. */
|
|
203
|
+
const unserved = new Set();
|
|
204
|
+
/** How long a probe may take before the window is taken from the listing instead. */
|
|
205
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
206
|
+
/** The server root behind an OpenAI-compatible base URL, which is where the native APIs live. */
|
|
207
|
+
const rootOf = (baseUrl) => baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
208
|
+
/** A route this server does not have, rather than one that failed to answer. */
|
|
209
|
+
const NOT_THERE = new Set([404, 405, 501]);
|
|
210
|
+
/**
|
|
211
|
+
* Asks one native endpoint. `missing` is a server without the route; `body` is absent for that
|
|
212
|
+
* and for any other refusal, and a server that could not be reached throws.
|
|
213
|
+
*/
|
|
214
|
+
async function probe(config, path) {
|
|
215
|
+
const apiKey = config.apiKey || undefined;
|
|
216
|
+
const response = await fetch(`${rootOf(config.baseUrl)}${path}`, {
|
|
217
|
+
headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {},
|
|
218
|
+
signal: AbortSignal.timeout(timeoutMs(config) ?? PROBE_TIMEOUT_MS),
|
|
219
|
+
});
|
|
220
|
+
if (!response.ok)
|
|
221
|
+
return { missing: NOT_THERE.has(response.status) };
|
|
222
|
+
try {
|
|
223
|
+
return { missing: false, body: await response.json() };
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return { missing: false };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The window a local server is actually serving a model in, which its listing does not say.
|
|
231
|
+
*
|
|
232
|
+
* llama.cpp reports it on `/props` as `default_generation_settings.n_ctx`, per slot, and LM
|
|
233
|
+
* Studio on `/api/v0/models` as `loaded_context_length` while the model is loaded. Both differ
|
|
234
|
+
* from the trained window in the case that matters, a model started in a smaller one than it was
|
|
235
|
+
* built for, and reading the trained one lets an overflow through the guard meant to catch it.
|
|
236
|
+
* Ollama reports nothing on either; an operator there has to declare the window.
|
|
237
|
+
*
|
|
238
|
+
* Zero where no answer was found. A server without either route, which is every hosted API, is
|
|
239
|
+
* latched and not asked again; one that could not be reached is not remembered at all.
|
|
240
|
+
*
|
|
241
|
+
* @param config The endpoint, plus the model whose window is wanted.
|
|
242
|
+
*/
|
|
243
|
+
export async function servedWindow(config) {
|
|
244
|
+
const endpoint = endpointKey(config);
|
|
245
|
+
if (unserved.has(endpoint))
|
|
246
|
+
return 0;
|
|
247
|
+
const key = JSON.stringify([endpoint, config.model]);
|
|
248
|
+
const known = served.get(key);
|
|
249
|
+
if (known && (known.window > 0 || Date.now() - known.at < LISTING_MISS_MS))
|
|
250
|
+
return known.window;
|
|
251
|
+
let window = 0;
|
|
252
|
+
try {
|
|
253
|
+
// Named, for a llama.cpp router serving several models; a single-model server ignores it.
|
|
254
|
+
const props = await probe(config, `/props?model=${encodeURIComponent(config.model)}`);
|
|
255
|
+
const settings = props.body
|
|
256
|
+
?.default_generation_settings;
|
|
257
|
+
window = positive(settings?.n_ctx);
|
|
258
|
+
if (!window) {
|
|
259
|
+
const lmstudio = await probe(config, "/api/v0/models");
|
|
260
|
+
const { data } = (lmstudio.body ?? {});
|
|
261
|
+
const entry = Array.isArray(data)
|
|
262
|
+
? data.find((model) => model?.id === config.model)
|
|
263
|
+
: undefined;
|
|
264
|
+
window = positive(entry?.loaded_context_length);
|
|
265
|
+
if (props.missing && lmstudio.missing) {
|
|
266
|
+
unserved.add(endpoint);
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
served.set(key, { window, at: Date.now() });
|
|
275
|
+
return window;
|
|
276
|
+
}
|
|
175
277
|
/**
|
|
176
278
|
* Asks an endpoint what it serves, and remembers the answer.
|
|
177
279
|
*
|
|
@@ -193,10 +295,11 @@ export async function listModels(config) {
|
|
|
193
295
|
* happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
|
|
194
296
|
* the honest-looking number is a run that fails at the endpoint instead.
|
|
195
297
|
*
|
|
196
|
-
* Otherwise the
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
298
|
+
* Otherwise the window the server is actually serving the model in, where it has an API that
|
|
299
|
+
* says (`servedWindow`), and failing that the endpoint's listing — asked once, and again whenever
|
|
300
|
+
* it does not name this model, since a model can arrive after the first listing was taken. A
|
|
301
|
+
* server that will not list models still has to be able to run a turn: a failure here is an
|
|
302
|
+
* unknown window, not a failed run.
|
|
200
303
|
*
|
|
201
304
|
* @param config The endpoint, plus the model whose window is wanted.
|
|
202
305
|
* @param declared The operator's own number. Above zero it wins and the endpoint is not asked.
|
|
@@ -204,6 +307,9 @@ export async function listModels(config) {
|
|
|
204
307
|
export async function contextLimitFor(config, declared = 0) {
|
|
205
308
|
if (declared > 0)
|
|
206
309
|
return declared;
|
|
310
|
+
const window = await servedWindow(config);
|
|
311
|
+
if (window > 0)
|
|
312
|
+
return window;
|
|
207
313
|
const key = endpointKey(config);
|
|
208
314
|
const listed = () => listings.get(key)?.find((model) => model.id === config.model);
|
|
209
315
|
// The listing is asked for again when it does not name this model, rather than only when
|
|
@@ -245,4 +351,6 @@ export function resetClients() {
|
|
|
245
351
|
clients.clear();
|
|
246
352
|
listings.clear();
|
|
247
353
|
misses.clear();
|
|
354
|
+
served.clear();
|
|
355
|
+
unserved.clear();
|
|
248
356
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -18,7 +18,13 @@ export interface Endpoint {
|
|
|
18
18
|
/** Empty is normal — a local server ignores it. See `getClient` for what is sent instead. */
|
|
19
19
|
apiKey: string;
|
|
20
20
|
/**
|
|
21
|
-
* Zero, less, or absent means no limit — what a local model
|
|
21
|
+
* How long an endpoint may go quiet. Zero, less, or absent means no limit — what a local model
|
|
22
|
+
* answering slowly needs.
|
|
23
|
+
*
|
|
24
|
+
* Not the whole request, which may take as long as the model keeps talking. On a streamed turn
|
|
25
|
+
* it is the silence allowed between chunks, the idle watchdog; the wait for the first chunk is
|
|
26
|
+
* `firstTokenSeconds`. On a call that does not stream, a side task or a model listing, it is
|
|
27
|
+
* the SDK's timer, which runs until the response headers arrive.
|
|
22
28
|
*
|
|
23
29
|
* Optional because a consumer that has no timeout to give should not have to invent one. Two
|
|
24
30
|
* of the three servers this was extracted from carry no such field, and requiring it made
|
|
@@ -26,6 +32,18 @@ export interface Endpoint {
|
|
|
26
32
|
* number standing in for an absent one.
|
|
27
33
|
*/
|
|
28
34
|
requestTimeoutSeconds?: number;
|
|
35
|
+
/**
|
|
36
|
+
* How long a streamed turn may wait for its first chunk. Absent is five times
|
|
37
|
+
* `requestTimeoutSeconds`; zero or less is no limit.
|
|
38
|
+
*
|
|
39
|
+
* Its own number because the first wait is prefill, and on a local server prefill of a long
|
|
40
|
+
* prompt is tens of seconds on a GPU and minutes on a CPU, where the gap between tokens is a
|
|
41
|
+
* fraction of a second. A server loading the model on demand, Ollama after `keep_alive` or LM
|
|
42
|
+
* Studio just in time, holds the request open for the same reason. One number for both waits
|
|
43
|
+
* was either too slow to notice a wedged stream or tight enough to abandon a prefill, and a
|
|
44
|
+
* retry pays for that prefill again from nothing.
|
|
45
|
+
*/
|
|
46
|
+
firstTokenSeconds?: number;
|
|
29
47
|
}
|
|
30
48
|
/** What to ask the model for. */
|
|
31
49
|
export interface ModelParams {
|
|
@@ -68,6 +86,12 @@ export interface ToolPolicy {
|
|
|
68
86
|
/** How many times a lost or refused request is worth sending again. See `retry.ts`. */
|
|
69
87
|
export interface RetryPolicy {
|
|
70
88
|
maxRetries: number;
|
|
89
|
+
/**
|
|
90
|
+
* How long to wait for a local server that says it is still loading the model, absent two
|
|
91
|
+
* minutes and zero not at all. Separate from `maxRetries`, which is sized for a request that
|
|
92
|
+
* was lost rather than for weights being read off a disk. See `isModelLoading`.
|
|
93
|
+
*/
|
|
94
|
+
loadingTimeoutSeconds?: number;
|
|
71
95
|
}
|
|
72
96
|
/**
|
|
73
97
|
* A whole agent configuration — every part, plus the two fields that belong to no group.
|
package/dist/index.d.ts
CHANGED
|
@@ -12,19 +12,20 @@
|
|
|
12
12
|
export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type ToolCallOutcome, type ToolCallRequest, } from "./agent-loop.ts";
|
|
13
13
|
export { type Capabilities, capabilitiesFor, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
|
|
14
14
|
export type { CatalogServer } from "./catalog.ts";
|
|
15
|
-
export { contextLimitFor, getClient, listModels, type ModelInfo, NO_KEY, resetClients, timeoutMs, } from "./client.ts";
|
|
15
|
+
export { contextLimitFor, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, type ModelInfo, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.ts";
|
|
16
16
|
export { COMPACT_AT, type CompactionOptions, type CompactionPlan, compactTranscript, KEEP_RATIO, type PruneOptions, planCompaction, pruneToolResults, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.ts";
|
|
17
17
|
export type { AgentConfig, Endpoint, ModelParams, RetryPolicy, ToolPolicy, } from "./config.ts";
|
|
18
18
|
export { errorMessage } from "./errors.ts";
|
|
19
19
|
export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, resetEvents, watch, } from "./events.ts";
|
|
20
20
|
export { assembleContext, configureHooks, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, type HookContext, type HookEvent, type HookMessage, type HookNote, type HookOptions, type HookOutcome, type HookRunner, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.ts";
|
|
21
21
|
export { resetAll } from "./reset.ts";
|
|
22
|
-
export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, messageTokens, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.ts";
|
|
22
|
+
export { backoffMs, ContextOverflow, compact, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.ts";
|
|
23
23
|
export { type RunTurnOptions, runTurn } from "./run-turn.ts";
|
|
24
24
|
export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
|
|
25
25
|
export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTaskOptions, tryAsk, } from "./side-task.ts";
|
|
26
26
|
export { CAPABILITY_SNAPSHOT_VERSION, type CapabilitySnapshot, type EndpointSnapshot, exportCapabilities, importCapabilities, type ModelSnapshot, } from "./snapshot.ts";
|
|
27
27
|
export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
|
|
28
|
+
export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, type Split, stripThinking, THINK_FENCE, } from "./thinking.ts";
|
|
28
29
|
export { estimateTokens } from "./tokens.ts";
|
|
29
30
|
export { parseToolArguments, recoverToolCalls, ToolArgumentsError, type ToolCall, } from "./tool-calls.ts";
|
|
30
31
|
export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.ts";
|
package/dist/index.js
CHANGED
|
@@ -11,18 +11,19 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export { buildBody, preselect, preview, resolveApiKey, runAgentLoop, } from "./agent-loop.js";
|
|
13
13
|
export { capabilitiesFor, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
|
|
14
|
-
export { contextLimitFor, getClient, listModels, NO_KEY, resetClients, timeoutMs, } from "./client.js";
|
|
14
|
+
export { contextLimitFor, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.js";
|
|
15
15
|
export { COMPACT_AT, compactTranscript, KEEP_RATIO, planCompaction, pruneToolResults, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.js";
|
|
16
16
|
export { errorMessage } from "./errors.js";
|
|
17
17
|
export { configureEvents, emit, endRun, fold, history, resetEvents, watch, } from "./events.js";
|
|
18
18
|
export { assembleContext, configureHooks, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.js";
|
|
19
19
|
export { resetAll } from "./reset.js";
|
|
20
|
-
export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, messageTokens, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
|
|
20
|
+
export { backoffMs, ContextOverflow, compact, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
|
|
21
21
|
export { runTurn } from "./run-turn.js";
|
|
22
22
|
export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.js";
|
|
23
23
|
export { ask, askJson, clean, listLines, parseJson, resetHints, tryAsk, } from "./side-task.js";
|
|
24
24
|
export { CAPABILITY_SNAPSHOT_VERSION, exportCapabilities, importCapabilities, } from "./snapshot.js";
|
|
25
25
|
export { streamTurn, } from "./stream.js";
|
|
26
|
+
export { ALL_FENCES, DEFAULT_FENCES, FenceSplitter, stripThinking, THINK_FENCE, } from "./thinking.js";
|
|
26
27
|
export { estimateTokens } from "./tokens.js";
|
|
27
28
|
export { parseToolArguments, recoverToolCalls, ToolArgumentsError, } from "./tool-calls.js";
|
|
28
29
|
export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
|
package/dist/retry.d.ts
CHANGED
|
@@ -93,6 +93,21 @@ export declare const SMALLEST_LIKELY_WINDOW = 8192;
|
|
|
93
93
|
* @param error The rejection, as caught. What is not an SDK error is not transient.
|
|
94
94
|
*/
|
|
95
95
|
export declare function isTransient(error: unknown): boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Whether a failure is a local server still loading the model, rather than one failing to serve.
|
|
98
|
+
*
|
|
99
|
+
* llama.cpp answers 503 `Loading model` with type `unavailable_error` from the moment it starts
|
|
100
|
+
* until the weights are mapped, and a router build says the same while it swaps models. That is
|
|
101
|
+
* thirty to ninety seconds for a large model from a cold page cache, and `backoffMs` gives up
|
|
102
|
+
* inside fifteen: sized for a busy host, not for one reading a file. A plain 503 is not this.
|
|
103
|
+
*
|
|
104
|
+
* @param error The rejection, as caught.
|
|
105
|
+
*/
|
|
106
|
+
export declare function isModelLoading(error: unknown): boolean;
|
|
107
|
+
/** How long to wait between asking a loading server again. */
|
|
108
|
+
export declare const LOADING_POLL_MS = 3000;
|
|
109
|
+
/** How long `runTurn` waits for a model to load unless told otherwise. */
|
|
110
|
+
export declare const LOADING_TIMEOUT_MS = 120000;
|
|
96
111
|
/**
|
|
97
112
|
* Exponential, with jitter so several tasks failing at once do not return in lockstep.
|
|
98
113
|
*
|
package/dist/retry.js
CHANGED
|
@@ -53,6 +53,16 @@ const TOOL_CALLS_KEY = 15;
|
|
|
53
53
|
const TEXT_PART = 26;
|
|
54
54
|
/** The same for `{"type":"refusal","refusal":""},` around a refusal part. */
|
|
55
55
|
const REFUSAL_PART = 32;
|
|
56
|
+
/**
|
|
57
|
+
* The same for `"reasoning_content":"",` around an assistant message's scratchpad.
|
|
58
|
+
*
|
|
59
|
+
* Not in the SDK's types, and passed back by the caller who keeps it: gpt-oss and DeepSeek in
|
|
60
|
+
* thinking mode want the analysis behind a tool call on the next request. Left uncounted, the
|
|
61
|
+
* guard came up short by the whole scratchpad on exactly the runs that follow that rule.
|
|
62
|
+
*/
|
|
63
|
+
const REASONING_KEY = 23;
|
|
64
|
+
/** The same for `"reasoning":"",`, OpenRouter's spelling of it. */
|
|
65
|
+
const REASONING_ALT_KEY = 15;
|
|
56
66
|
/** The divisor behind `estimateTokens`, applied here to a character count rather than a string. */
|
|
57
67
|
const CHARS_PER_TOKEN = 4;
|
|
58
68
|
/** How many characters one message is worth: its keys, and its content in whichever shape. */
|
|
@@ -76,6 +86,11 @@ function messageChars(message) {
|
|
|
76
86
|
chars += NAME_KEY + message.name.length;
|
|
77
87
|
if ("tool_call_id" in message && typeof message.tool_call_id === "string")
|
|
78
88
|
chars += TOOL_CALL_ID_KEY + message.tool_call_id.length;
|
|
89
|
+
const { reasoning_content: reasoning, reasoning: alternate } = message;
|
|
90
|
+
if (typeof reasoning === "string")
|
|
91
|
+
chars += REASONING_KEY + reasoning.length;
|
|
92
|
+
if (typeof alternate === "string")
|
|
93
|
+
chars += REASONING_ALT_KEY + alternate.length;
|
|
79
94
|
if ("tool_calls" in message && Array.isArray(message.tool_calls)) {
|
|
80
95
|
chars += TOOL_CALLS_KEY;
|
|
81
96
|
for (const call of message.tool_calls) {
|
|
@@ -214,6 +229,27 @@ export function isTransient(error) {
|
|
|
214
229
|
const { status } = error;
|
|
215
230
|
return status === 408 || status === 409 || status === 429 || (status ?? 0) >= 500;
|
|
216
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Whether a failure is a local server still loading the model, rather than one failing to serve.
|
|
234
|
+
*
|
|
235
|
+
* llama.cpp answers 503 `Loading model` with type `unavailable_error` from the moment it starts
|
|
236
|
+
* until the weights are mapped, and a router build says the same while it swaps models. That is
|
|
237
|
+
* thirty to ninety seconds for a large model from a cold page cache, and `backoffMs` gives up
|
|
238
|
+
* inside fifteen: sized for a busy host, not for one reading a file. A plain 503 is not this.
|
|
239
|
+
*
|
|
240
|
+
* @param error The rejection, as caught.
|
|
241
|
+
*/
|
|
242
|
+
export function isModelLoading(error) {
|
|
243
|
+
if (!(error instanceof OpenAI.APIError) || error.status !== 503)
|
|
244
|
+
return false;
|
|
245
|
+
const body = error.error;
|
|
246
|
+
return (body?.type === "unavailable_error" ||
|
|
247
|
+
/loading model|model is loading|unavailable_error/i.test(`${error.message} ${body?.message ?? ""}`));
|
|
248
|
+
}
|
|
249
|
+
/** How long to wait between asking a loading server again. */
|
|
250
|
+
export const LOADING_POLL_MS = 3000;
|
|
251
|
+
/** How long `runTurn` waits for a model to load unless told otherwise. */
|
|
252
|
+
export const LOADING_TIMEOUT_MS = 120_000;
|
|
217
253
|
/**
|
|
218
254
|
* Exponential, with jitter so several tasks failing at once do not return in lockstep.
|
|
219
255
|
*
|
package/dist/run-turn.d.ts
CHANGED
|
@@ -63,6 +63,15 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
|
|
|
63
63
|
* of `extraBody`, ordinarily. See `NegotiateOptions.droppable`; it needs `model` too.
|
|
64
64
|
*/
|
|
65
65
|
droppable?: Iterable<string>;
|
|
66
|
+
/**
|
|
67
|
+
* How long to wait on a server answering that the model is still loading, `LOADING_TIMEOUT_MS`
|
|
68
|
+
* unless given; zero gives up on the first such answer like any other 503.
|
|
69
|
+
*
|
|
70
|
+
* Polled every `LOADING_POLL_MS` without spending `maxRetries`, and announced once rather than
|
|
71
|
+
* per poll. A consumer that starts alongside its llama.cpp, or asks a router for a model it
|
|
72
|
+
* has to swap in, meets this on its first request every time.
|
|
73
|
+
*/
|
|
74
|
+
loadingTimeoutMs?: number;
|
|
66
75
|
}
|
|
67
76
|
/**
|
|
68
77
|
* `request` is a callback rather than a body because the body has to be rebuilt from whatever
|
|
@@ -77,4 +86,4 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
|
|
|
77
86
|
* @param options Retry budget, context limit, the model to negotiate for, notices, and the
|
|
78
87
|
* stream's own callbacks.
|
|
79
88
|
*/
|
|
80
|
-
export declare function runTurn(client: OpenAI, supports: Capabilities, request: (supports: Capabilities, model: ModelCapabilities | undefined) => OpenAI.ChatCompletionCreateParamsStreaming, { maxRetries, onNotice, contextLimit, model, droppable, ...stream }?: RunTurnOptions): Promise<Turn>;
|
|
89
|
+
export declare function runTurn(client: OpenAI, supports: Capabilities, request: (supports: Capabilities, model: ModelCapabilities | undefined) => OpenAI.ChatCompletionCreateParamsStreaming, { maxRetries, onNotice, contextLimit, model, droppable, loadingTimeoutMs, ...stream }?: RunTurnOptions): Promise<Turn>;
|
package/dist/run-turn.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { negotiate } from "./capabilities.js";
|
|
2
2
|
import { errorMessage } from "./errors.js";
|
|
3
|
-
import { backoffMs, ContextOverflow, compact, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
|
|
3
|
+
import { backoffMs, ContextOverflow, compact, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
|
|
4
4
|
import { streamTurn } from "./stream.js";
|
|
5
5
|
/**
|
|
6
6
|
* `request` is a callback rather than a body because the body has to be rebuilt from whatever
|
|
@@ -15,7 +15,7 @@ import { streamTurn } from "./stream.js";
|
|
|
15
15
|
* @param options Retry budget, context limit, the model to negotiate for, notices, and the
|
|
16
16
|
* stream's own callbacks.
|
|
17
17
|
*/
|
|
18
|
-
export async function runTurn(client, supports, request, { maxRetries = 0, onNotice, contextLimit = 0, model, droppable, ...stream } = {}) {
|
|
18
|
+
export async function runTurn(client, supports, request, { maxRetries = 0, onNotice, contextLimit = 0, model, droppable, loadingTimeoutMs = LOADING_TIMEOUT_MS, ...stream } = {}) {
|
|
19
19
|
// Sized once rather than per build. `request` is called again for every downgrade and every
|
|
20
20
|
// retry, but a downgraded body is strictly smaller than the one before it and the transcript
|
|
21
21
|
// does not change between attempts — so the first body is the one worth measuring, and
|
|
@@ -42,6 +42,9 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
|
|
|
42
42
|
}
|
|
43
43
|
return body;
|
|
44
44
|
};
|
|
45
|
+
// When the server first said it was loading. Unset until then, and never reset: a model that
|
|
46
|
+
// loads, fails and loads again has had its allowance.
|
|
47
|
+
let loadingSince;
|
|
45
48
|
for (let attempt = 0;; attempt++) {
|
|
46
49
|
const produced = { any: false };
|
|
47
50
|
try {
|
|
@@ -66,6 +69,20 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
|
|
|
66
69
|
if (!(error instanceof ContextOverflow) && isOverflow(errorMessage(error))) {
|
|
67
70
|
throw new ContextOverflow(errorMessage(error), { cause: error });
|
|
68
71
|
}
|
|
72
|
+
if (isModelLoading(error) && loadingTimeoutMs > 0) {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
if (loadingSince === undefined) {
|
|
75
|
+
loadingSince = now;
|
|
76
|
+
onNotice?.(`${model ?? "the model"} is still loading — waiting up to ${compact(loadingTimeoutMs / 1000)}s`);
|
|
77
|
+
}
|
|
78
|
+
if (now - loadingSince < loadingTimeoutMs) {
|
|
79
|
+
// Not an attempt: the request was never looked at, and a two-minute load would
|
|
80
|
+
// otherwise have to be bought with a retry budget meant for dropped connections.
|
|
81
|
+
attempt--;
|
|
82
|
+
await sleep(LOADING_POLL_MS, stream.signal);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
69
86
|
if (attempt >= maxRetries || !isTransient(error))
|
|
70
87
|
throw error;
|
|
71
88
|
const wait = backoffMs(attempt);
|
package/dist/side-task.js
CHANGED
|
@@ -4,6 +4,7 @@ import { endpointId, getClient } from "./client.js";
|
|
|
4
4
|
import { errorMessage } from "./errors.js";
|
|
5
5
|
import { isTransient } from "./retry.js";
|
|
6
6
|
import { relaxTools, sanitizeTools } from "./schema-compat.js";
|
|
7
|
+
import { stripThinking } from "./thinking.js";
|
|
7
8
|
/**
|
|
8
9
|
* One-shot calls that support a run without being one: picking tools, naming a session,
|
|
9
10
|
* summarising a transcript, proposing follow-ups. They share a shape — small prompt, short
|
|
@@ -64,23 +65,6 @@ function rejectedTheRequest(error) {
|
|
|
64
65
|
return false;
|
|
65
66
|
return error.status === 400 || error.status === 422;
|
|
66
67
|
}
|
|
67
|
-
/**
|
|
68
|
-
* Reasoning models that ignore the hints still fence their scratchpad; drop it.
|
|
69
|
-
*
|
|
70
|
-
* Including the fence that never closes. A side task answers under a small `max_tokens`, so a
|
|
71
|
-
* model that spends it deliberating is cut off mid-scratchpad and the closing tag never
|
|
72
|
-
* arrives — and the whole deliberation was then returned to the caller as the answer.
|
|
73
|
-
*/
|
|
74
|
-
const stripThinking = (text) => text
|
|
75
|
-
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
|
76
|
-
.replace(/<think>[\s\S]*$/i, "")
|
|
77
|
-
// And the fence that never opens. Several chat templates put the opening tag at the end of
|
|
78
|
-
// the prompt rather than leaving the model to write it, so what comes back is deliberation
|
|
79
|
-
// first and only the closing tag to mark where it stops. Neither pattern above matches that,
|
|
80
|
-
// and the whole scratchpad went to the caller as the answer — a session title, a tool
|
|
81
|
-
// preselection, a suggestion list. Every real `<think>` is gone by this point, so a `</think>`
|
|
82
|
-
// still here opened in the prompt; the first one is taken, which keeps the most text.
|
|
83
|
-
.replace(/^[\s\S]*?<\/think>/i, "");
|
|
84
68
|
/**
|
|
85
69
|
* Runs a side task and returns the reply text, thinking stripped. Throws like any request.
|
|
86
70
|
*
|
|
@@ -159,6 +143,9 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
|
|
|
159
143
|
modelCapabilitiesFor(supports, model).reasoningEffort = false;
|
|
160
144
|
}
|
|
161
145
|
const message = response.choices[0]?.message;
|
|
146
|
+
// Reasoning models that ignore the hints still fence their scratchpad. A side task answers
|
|
147
|
+
// under a small ceiling, so the fence often never closes, and a template that opened it in
|
|
148
|
+
// the prompt leaves only the close; either way the deliberation used to come back as the answer.
|
|
162
149
|
const answer = stripThinking(message?.content ?? "").trim();
|
|
163
150
|
// Nothing but scratchpad. Some servers put the deliberation in its own field and leave the
|
|
164
151
|
// content genuinely empty, in which case there is no answer to find anywhere else.
|
package/dist/stream.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type OpenAI from "openai";
|
|
2
|
+
import { type Fence } from "./thinking.ts";
|
|
2
3
|
/**
|
|
3
4
|
* Reading one streamed turn back into a message.
|
|
4
5
|
*
|
|
@@ -46,6 +47,17 @@ export interface Turn {
|
|
|
46
47
|
* handed over rather than raised.
|
|
47
48
|
*/
|
|
48
49
|
finishReason: string;
|
|
50
|
+
/**
|
|
51
|
+
* The model's scratchpad, as `onThinking` was told it, `""` where it deliberated in silence or
|
|
52
|
+
* not at all.
|
|
53
|
+
*
|
|
54
|
+
* Kept because some models want it back. gpt-oss and DeepSeek in thinking mode read the
|
|
55
|
+
* analysis behind a tool call off the assistant message on the next request, so a caller
|
|
56
|
+
* talking to one stores it as `reasoning_content` on that message for as long as the message
|
|
57
|
+
* ends in a tool call, and drops it once the model has given a final answer. A model that
|
|
58
|
+
* does not ask for it is better off without it: it is context, paid for on every turn.
|
|
59
|
+
*/
|
|
60
|
+
reasoning: string;
|
|
49
61
|
}
|
|
50
62
|
/**
|
|
51
63
|
* Whether the model has said anything a second attempt would say twice.
|
|
@@ -75,9 +87,30 @@ export interface StreamTurnOptions {
|
|
|
75
87
|
* needs.
|
|
76
88
|
*/
|
|
77
89
|
idleMs?: number;
|
|
90
|
+
/**
|
|
91
|
+
* Silence allowed before the first chunk, `idleMs` unless given; zero waits forever.
|
|
92
|
+
*
|
|
93
|
+
* The first wait is prefill, or a server loading the model, and is routinely many times the
|
|
94
|
+
* gap between tokens. See `Endpoint.firstTokenSeconds` and `firstTokenMs`.
|
|
95
|
+
*/
|
|
96
|
+
firstChunkMs?: number;
|
|
78
97
|
/** Set by the first chunk that carries anything, so a failed call knows if it can be retried. */
|
|
79
98
|
produced?: Produced;
|
|
80
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* The fences that mark a scratchpad written into `content`, `DEFAULT_FENCES` unless given.
|
|
101
|
+
*
|
|
102
|
+
* Text inside one goes to `onThinking` and `reasoning` rather than `onOutput` and `content`.
|
|
103
|
+
* `ALL_FENCES` adds `<thinking>` and `<reasoning>`, which a model can also be quoting; an
|
|
104
|
+
* empty list reads `content` as all answer.
|
|
105
|
+
*/
|
|
106
|
+
fences?: readonly Fence[];
|
|
107
|
+
/**
|
|
108
|
+
* The chat template opened the first fence in the prompt, so the reply starts inside it.
|
|
109
|
+
* Without this the scratchpad is still moved out of `content` once the closing tag arrives,
|
|
110
|
+
* but `onOutput` will have been told it first.
|
|
111
|
+
*/
|
|
112
|
+
startInReasoning?: boolean;
|
|
113
|
+
/** The model's scratchpad, as it arrives, from its own field or from a fence in `content`. */
|
|
81
114
|
onThinking?: (delta: string) => void;
|
|
82
115
|
/** The model's answer, as it arrives. */
|
|
83
116
|
onOutput?: (delta: string) => void;
|
|
@@ -98,4 +131,4 @@ export interface StreamTurnOptions {
|
|
|
98
131
|
* @param body The request, which must set `stream: true`.
|
|
99
132
|
* @param options Cancellation, the idle watchdog, and the token callbacks.
|
|
100
133
|
*/
|
|
101
|
-
export declare function streamTurn(client: OpenAI, body: OpenAI.ChatCompletionCreateParamsStreaming, { signal, idleMs, produced, onThinking, onOutput }?: StreamTurnOptions): Promise<Turn>;
|
|
134
|
+
export declare function streamTurn(client: OpenAI, body: OpenAI.ChatCompletionCreateParamsStreaming, { signal, idleMs, firstChunkMs, produced, fences, startInReasoning, onThinking, onOutput, }?: StreamTurnOptions): Promise<Turn>;
|
package/dist/stream.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { EndpointSilent } from "./retry.js";
|
|
2
|
+
import { DEFAULT_FENCES, FenceSplitter } from "./thinking.js";
|
|
3
|
+
/** The largest delay a timer takes, which is as close to none as the SDK's timeout option goes. */
|
|
4
|
+
const NO_SDK_TIMEOUT = 2 ** 31 - 1;
|
|
2
5
|
/**
|
|
3
6
|
* Runs one turn as a stream, reporting tokens as they arrive and assembling them back into a
|
|
4
7
|
* message.
|
|
@@ -15,7 +18,7 @@ import { EndpointSilent } from "./retry.js";
|
|
|
15
18
|
* @param body The request, which must set `stream: true`.
|
|
16
19
|
* @param options Cancellation, the idle watchdog, and the token callbacks.
|
|
17
20
|
*/
|
|
18
|
-
export async function streamTurn(client, body, { signal, idleMs, produced, onThinking, onOutput } = {}) {
|
|
21
|
+
export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, produced, fences = DEFAULT_FENCES, startInReasoning, onThinking, onOutput, } = {}) {
|
|
19
22
|
// Silence, not duration: the timer is rearmed on every chunk, so a model that is still
|
|
20
23
|
// talking is never cut off however long it takes, and one that has stopped talking does not
|
|
21
24
|
// hang the run until someone notices. A request that never answers at all is the same case
|
|
@@ -23,14 +26,20 @@ export async function streamTurn(client, body, { signal, idleMs, produced, onThi
|
|
|
23
26
|
const watchdog = new AbortController();
|
|
24
27
|
const linked = signal ? AbortSignal.any([signal, watchdog.signal]) : watchdog.signal;
|
|
25
28
|
let idle;
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
const first = firstChunkMs ?? idleMs;
|
|
30
|
+
// Until the model says something the wait is still prefill, however many empty chunks a
|
|
31
|
+
// server sends first: some open the stream with `{"role":"assistant"}` before they have read
|
|
32
|
+
// the prompt, and switching to the idle allowance on that abandoned the prefill it preceded.
|
|
33
|
+
let talking = false;
|
|
34
|
+
const rearm = (carried) => {
|
|
35
|
+
talking ||= carried;
|
|
36
|
+
const ms = talking ? idleMs : first;
|
|
29
37
|
clearTimeout(idle);
|
|
30
|
-
|
|
38
|
+
if (ms)
|
|
39
|
+
idle = setTimeout(() => watchdog.abort(), ms);
|
|
31
40
|
};
|
|
32
41
|
try {
|
|
33
|
-
rearm();
|
|
42
|
+
rearm(false);
|
|
34
43
|
return await collect();
|
|
35
44
|
}
|
|
36
45
|
catch (error) {
|
|
@@ -39,7 +48,9 @@ export async function streamTurn(client, body, { signal, idleMs, produced, onThi
|
|
|
39
48
|
// this backwards records a stopped run as an endpoint fault, which nobody notices until
|
|
40
49
|
// they read the row and disbelieve it.
|
|
41
50
|
if (watchdog.signal.aborted && !signal?.aborted) {
|
|
42
|
-
|
|
51
|
+
const waited = talking ? idleMs : first;
|
|
52
|
+
const before = talking || first === idleMs ? "" : " before its first token";
|
|
53
|
+
throw new EndpointSilent(`the model endpoint sent nothing for ${(waited ?? 0) / 1000}s${before}`);
|
|
43
54
|
}
|
|
44
55
|
throw error;
|
|
45
56
|
}
|
|
@@ -47,15 +58,32 @@ export async function streamTurn(client, body, { signal, idleMs, produced, onThi
|
|
|
47
58
|
clearTimeout(idle);
|
|
48
59
|
}
|
|
49
60
|
async function collect() {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
// The SDK's own timer runs until the headers arrive, which for a stream is the end of
|
|
62
|
+
// prefill, and it was set from the idle number. Where a watchdog is armed it covers that
|
|
63
|
+
// wait already, with the allowance meant for it, so the SDK is told to leave it alone.
|
|
64
|
+
const watched = first || idleMs;
|
|
65
|
+
const stream = await client.chat.completions.create(body, {
|
|
66
|
+
signal: linked,
|
|
67
|
+
...(watched ? { timeout: NO_SDK_TIMEOUT } : {}),
|
|
68
|
+
});
|
|
69
|
+
// The field's reasoning here, the fenced kind in the splitter, which can still move text
|
|
70
|
+
// already read as answer into reasoning when a closing tag turns up with no opening one.
|
|
71
|
+
const reasoning = [];
|
|
72
|
+
const splitter = new FenceSplitter(fences, { startInside: startInReasoning });
|
|
73
|
+
const report = (parts) => {
|
|
74
|
+
for (const part of parts)
|
|
75
|
+
(part.kind === "reasoning" ? onThinking : onOutput)?.(part.text);
|
|
76
|
+
};
|
|
77
|
+
// In arrival order, sorted by index at the end; a call from a server that sent none keeps
|
|
78
|
+
// its place in the order they arrived.
|
|
79
|
+
const calls = [];
|
|
53
80
|
const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
|
|
54
81
|
let finishReason = "";
|
|
55
82
|
for await (const chunk of stream) {
|
|
56
83
|
// Rearmed on every chunk, latched below on only some: a priming chunk is the endpoint
|
|
57
|
-
// being alive, which is all the watchdog is asking about.
|
|
58
|
-
|
|
84
|
+
// being alive, which is all the watchdog is asking about. Which allowance it rearms with
|
|
85
|
+
// moves from the first chunk's to the idle one when a chunk carries something.
|
|
86
|
+
rearm(false);
|
|
59
87
|
// Assigned rather than accumulated. `stream_options.include_usage` sends one final chunk
|
|
60
88
|
// and the two agree there, but a server that reports cumulatively per chunk makes a sum
|
|
61
89
|
// of sums out of an accumulator — and a token count wrong by a factor of the chunk count
|
|
@@ -87,44 +115,87 @@ export async function streamTurn(client, body, { signal, idleMs, produced, onThi
|
|
|
87
115
|
// fragments count even though no callback reports them: a partial call is state the turn
|
|
88
116
|
// has accumulated, and losing a retry is the safer half of that trade. Set before the
|
|
89
117
|
// callbacks, so a watcher that throws mid-token cannot be told the same token twice.
|
|
90
|
-
|
|
118
|
+
const carried = Boolean(thinking || delta.content || delta.tool_calls?.length);
|
|
119
|
+
if (carried && !talking)
|
|
120
|
+
rearm(true);
|
|
121
|
+
if (produced && carried)
|
|
91
122
|
produced.any = true;
|
|
92
|
-
if (thinking)
|
|
123
|
+
if (thinking) {
|
|
124
|
+
reasoning.push(thinking);
|
|
93
125
|
onThinking?.(thinking);
|
|
94
|
-
if (delta.content) {
|
|
95
|
-
content.push(delta.content);
|
|
96
|
-
onOutput?.(delta.content);
|
|
97
126
|
}
|
|
127
|
+
if (delta.content)
|
|
128
|
+
report(splitter.push(delta.content));
|
|
98
129
|
// Tool calls arrive in pieces, keyed by position: the id in one chunk, the name in
|
|
99
130
|
// another, the arguments spread across the next several.
|
|
100
131
|
for (const part of delta.tool_calls ?? []) {
|
|
101
|
-
const call =
|
|
132
|
+
const call = fragmentOf(part);
|
|
102
133
|
if (part.id)
|
|
103
134
|
call.id = part.id;
|
|
104
135
|
if (part.function?.name)
|
|
105
136
|
call.name += part.function.name;
|
|
106
137
|
if (part.function?.arguments)
|
|
107
138
|
call.arguments += part.function.arguments;
|
|
108
|
-
calls.set(part.index, call);
|
|
109
139
|
}
|
|
110
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* The call a fragment belongs to.
|
|
143
|
+
*
|
|
144
|
+
* By `index` where the server sent a number, which the SDK types as required and servers have
|
|
145
|
+
* nonetheless left out: keyed on `undefined`, every call joined into one whose name and
|
|
146
|
+
* arguments were all of theirs run together. Without one, by `id`; failing that, a fragment
|
|
147
|
+
* naming a function opens a call and a bare run of arguments continues the latest. A server
|
|
148
|
+
* that sends whole calls one per chunk at index `0` makes the same mistake the other way, so
|
|
149
|
+
* a different id, or a name after arguments have begun, opens a new call under that index.
|
|
150
|
+
*/
|
|
151
|
+
function fragmentOf(part) {
|
|
152
|
+
const index = typeof part.index === "number" ? part.index : undefined;
|
|
153
|
+
const name = part.function?.name;
|
|
154
|
+
const known = index !== undefined
|
|
155
|
+
? calls.findLast((call) => call.index === index)
|
|
156
|
+
: part.id
|
|
157
|
+
? calls.find((call) => call.id === part.id)
|
|
158
|
+
: name
|
|
159
|
+
? undefined
|
|
160
|
+
: calls.at(-1);
|
|
161
|
+
const another = known && ((part.id && known.id && part.id !== known.id) || (name && known.arguments));
|
|
162
|
+
if (known && !another)
|
|
163
|
+
return known;
|
|
164
|
+
const call = { index, id: "", name: "", arguments: "" };
|
|
165
|
+
calls.push(call);
|
|
166
|
+
return call;
|
|
167
|
+
}
|
|
111
168
|
// An aborted stream ends its iteration rather than throwing, so without this a turn cut off
|
|
112
169
|
// halfway — by the watchdog or by someone stopping the run — comes back looking like a
|
|
113
170
|
// complete one, and a truncated answer is recorded as the output. Nothing about the API
|
|
114
171
|
// says you have to know this.
|
|
115
172
|
linked.throwIfAborted();
|
|
173
|
+
// What was held back as a possible tag. A reply that ended inside a fence stays reasoning:
|
|
174
|
+
// cut off at the ceiling mid-scratchpad, it has no answer, and promoting the deliberation to
|
|
175
|
+
// one is how a truncated turn gets recorded as output.
|
|
176
|
+
report(splitter.finish());
|
|
177
|
+
const minted = new Set();
|
|
116
178
|
return {
|
|
117
|
-
content:
|
|
118
|
-
toolCalls:
|
|
119
|
-
.
|
|
120
|
-
.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
179
|
+
content: splitter.output,
|
|
180
|
+
toolCalls: calls
|
|
181
|
+
.map((call, order) => ({ call, order }))
|
|
182
|
+
.sort((a, b) => (a.call.index ?? a.order) - (b.call.index ?? b.order) || a.order - b.order)
|
|
183
|
+
.map(({ call }, position) => {
|
|
184
|
+
// A server that streams a call without an id still needs one for the result to answer,
|
|
185
|
+
// and two calls it put under one index must not be answered as one.
|
|
186
|
+
let id = call.id || `call_${call.index ?? position}`;
|
|
187
|
+
if (minted.has(id))
|
|
188
|
+
id = `call_${position}_${minted.size}`;
|
|
189
|
+
minted.add(id);
|
|
190
|
+
return {
|
|
191
|
+
id,
|
|
192
|
+
type: "function",
|
|
193
|
+
function: { name: call.name, arguments: call.arguments },
|
|
194
|
+
};
|
|
195
|
+
}),
|
|
126
196
|
usage,
|
|
127
197
|
finishReason,
|
|
198
|
+
reasoning: reasoning.join("") + splitter.reasoning,
|
|
128
199
|
};
|
|
129
200
|
}
|
|
130
201
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telling a model's scratchpad from its answer when both arrive in `content`.
|
|
3
|
+
*
|
|
4
|
+
* A server with a reasoning parser puts the deliberation in a field of its own. One without — a
|
|
5
|
+
* llama.cpp started with `--reasoning-format none`, a model whose template has no parser, most
|
|
6
|
+
* distills on any server — leaves it fenced inline, and then it is shown as output, stored in
|
|
7
|
+
* the transcript and sent back on the next turn, where it costs context and teaches the model to
|
|
8
|
+
* keep doing it. This is the one table of fences both the stream and the side tasks read.
|
|
9
|
+
*/
|
|
10
|
+
/** One way a model marks off its scratchpad inside `content`. */
|
|
11
|
+
export interface Fence {
|
|
12
|
+
open: string;
|
|
13
|
+
close: string;
|
|
14
|
+
}
|
|
15
|
+
/** DeepSeek, Qwen3, QwQ and most distills. */
|
|
16
|
+
export declare const THINK_FENCE: Fence;
|
|
17
|
+
/**
|
|
18
|
+
* The fences nobody writes by accident, and so the ones `streamTurn` reads by default.
|
|
19
|
+
*
|
|
20
|
+
* `<think>` opening a reply is never meant as output, and the other two are made of tokens that
|
|
21
|
+
* only a model's template produces: gpt-oss's harmony analysis channel, served raw, and Kimi's.
|
|
22
|
+
*/
|
|
23
|
+
export declare const DEFAULT_FENCES: readonly Fence[];
|
|
24
|
+
/**
|
|
25
|
+
* Every fence known, including the two plain-word ones some fine-tunes and prompt-instructed
|
|
26
|
+
* models use. Those can also be text the model is quoting, so they are opt-in for a stream,
|
|
27
|
+
* and on for a side task, whose answer is too short to be quoting anything.
|
|
28
|
+
*/
|
|
29
|
+
export declare const ALL_FENCES: readonly Fence[];
|
|
30
|
+
/** A piece of `content`, said to be one or the other. */
|
|
31
|
+
export interface Split {
|
|
32
|
+
kind: "reasoning" | "output";
|
|
33
|
+
text: string;
|
|
34
|
+
}
|
|
35
|
+
/** What `FenceSplitter` takes besides its fences. */
|
|
36
|
+
export interface FenceSplitterOptions {
|
|
37
|
+
/**
|
|
38
|
+
* The template already opened the first fence in the prompt, so the reply starts inside it.
|
|
39
|
+
*
|
|
40
|
+
* Several chat templates end the prompt with `<think>` rather than leaving the model to write
|
|
41
|
+
* it. Without this the splitter still catches it once `</think>` arrives, and moves what came
|
|
42
|
+
* before into `reasoning`, but a watcher will have been shown it as output by then.
|
|
43
|
+
*/
|
|
44
|
+
startInside?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A state machine over a stream of `content` that routes fenced text to reasoning.
|
|
48
|
+
*
|
|
49
|
+
* The reference shape is Vercel's `extractReasoningMiddleware`. A tag can be split across chunks,
|
|
50
|
+
* so the tail of each push that could be the start of one is held until the next push settles
|
|
51
|
+
* it; `finish` releases it. A reply cut off mid-scratchpad ends with the fence still open and its
|
|
52
|
+
* text still reasoning, rather than promoted to the answer.
|
|
53
|
+
*
|
|
54
|
+
* A closing tag with no opening one is the template having opened it in the prompt. When no
|
|
55
|
+
* fence has been seen yet, everything before it becomes reasoning retroactively in `output` and
|
|
56
|
+
* `reasoning`, though what was already handed out as output cannot be taken back.
|
|
57
|
+
*/
|
|
58
|
+
export declare class FenceSplitter {
|
|
59
|
+
#private;
|
|
60
|
+
/** The answer so far, with every fence taken out. */
|
|
61
|
+
output: string;
|
|
62
|
+
/** Everything that was inside a fence so far. */
|
|
63
|
+
reasoning: string;
|
|
64
|
+
/**
|
|
65
|
+
* @param fences The fences to read, `DEFAULT_FENCES` unless given; an empty list passes
|
|
66
|
+
* everything through as output.
|
|
67
|
+
* @param options Whether the reply starts inside the first fence.
|
|
68
|
+
*/
|
|
69
|
+
constructor(fences?: readonly Fence[], { startInside }?: FenceSplitterOptions);
|
|
70
|
+
/**
|
|
71
|
+
* Reads one more piece of content, returning what it settled, in order.
|
|
72
|
+
*
|
|
73
|
+
* @param text The next delta.
|
|
74
|
+
*/
|
|
75
|
+
push(text: string): Split[];
|
|
76
|
+
/** Releases whatever was held back as a possible tag, now that no more is coming. */
|
|
77
|
+
finish(): Split[];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* What is left of a complete reply once every scratchpad is taken out of it.
|
|
81
|
+
*
|
|
82
|
+
* @param text The whole reply.
|
|
83
|
+
* @param fences The fences to read, every known one unless given.
|
|
84
|
+
*/
|
|
85
|
+
export declare function stripThinking(text: string, fences?: readonly Fence[]): string;
|
package/dist/thinking.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telling a model's scratchpad from its answer when both arrive in `content`.
|
|
3
|
+
*
|
|
4
|
+
* A server with a reasoning parser puts the deliberation in a field of its own. One without — a
|
|
5
|
+
* llama.cpp started with `--reasoning-format none`, a model whose template has no parser, most
|
|
6
|
+
* distills on any server — leaves it fenced inline, and then it is shown as output, stored in
|
|
7
|
+
* the transcript and sent back on the next turn, where it costs context and teaches the model to
|
|
8
|
+
* keep doing it. This is the one table of fences both the stream and the side tasks read.
|
|
9
|
+
*/
|
|
10
|
+
/** DeepSeek, Qwen3, QwQ and most distills. */
|
|
11
|
+
export const THINK_FENCE = { open: "<think>", close: "</think>" };
|
|
12
|
+
/**
|
|
13
|
+
* The fences nobody writes by accident, and so the ones `streamTurn` reads by default.
|
|
14
|
+
*
|
|
15
|
+
* `<think>` opening a reply is never meant as output, and the other two are made of tokens that
|
|
16
|
+
* only a model's template produces: gpt-oss's harmony analysis channel, served raw, and Kimi's.
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_FENCES = [
|
|
19
|
+
THINK_FENCE,
|
|
20
|
+
{ open: "<|channel|>analysis<|message|>", close: "<|end|>" },
|
|
21
|
+
{ open: "◁think▷", close: "◁/think▷" },
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Every fence known, including the two plain-word ones some fine-tunes and prompt-instructed
|
|
25
|
+
* models use. Those can also be text the model is quoting, so they are opt-in for a stream,
|
|
26
|
+
* and on for a side task, whose answer is too short to be quoting anything.
|
|
27
|
+
*/
|
|
28
|
+
export const ALL_FENCES = [
|
|
29
|
+
...DEFAULT_FENCES,
|
|
30
|
+
{ open: "<thinking>", close: "</thinking>" },
|
|
31
|
+
{ open: "<reasoning>", close: "</reasoning>" },
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* Framing that is neither scratchpad nor answer, dropped wherever it turns up outside a fence.
|
|
35
|
+
*
|
|
36
|
+
* Only harmony has any: after the analysis channel closes, raw gpt-oss output announces the final
|
|
37
|
+
* channel before the answer and ends with a return token.
|
|
38
|
+
*/
|
|
39
|
+
const FRAMING = {
|
|
40
|
+
"<|channel|>analysis<|message|>": [
|
|
41
|
+
"<|start|>assistant",
|
|
42
|
+
"<|channel|>final<|message|>",
|
|
43
|
+
"<|return|>",
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* A state machine over a stream of `content` that routes fenced text to reasoning.
|
|
48
|
+
*
|
|
49
|
+
* The reference shape is Vercel's `extractReasoningMiddleware`. A tag can be split across chunks,
|
|
50
|
+
* so the tail of each push that could be the start of one is held until the next push settles
|
|
51
|
+
* it; `finish` releases it. A reply cut off mid-scratchpad ends with the fence still open and its
|
|
52
|
+
* text still reasoning, rather than promoted to the answer.
|
|
53
|
+
*
|
|
54
|
+
* A closing tag with no opening one is the template having opened it in the prompt. When no
|
|
55
|
+
* fence has been seen yet, everything before it becomes reasoning retroactively in `output` and
|
|
56
|
+
* `reasoning`, though what was already handed out as output cannot be taken back.
|
|
57
|
+
*/
|
|
58
|
+
export class FenceSplitter {
|
|
59
|
+
/** The answer so far, with every fence taken out. */
|
|
60
|
+
output = "";
|
|
61
|
+
/** Everything that was inside a fence so far. */
|
|
62
|
+
reasoning = "";
|
|
63
|
+
#fences;
|
|
64
|
+
#markers;
|
|
65
|
+
#inside;
|
|
66
|
+
#seenFence;
|
|
67
|
+
#held = "";
|
|
68
|
+
/**
|
|
69
|
+
* @param fences The fences to read, `DEFAULT_FENCES` unless given; an empty list passes
|
|
70
|
+
* everything through as output.
|
|
71
|
+
* @param options Whether the reply starts inside the first fence.
|
|
72
|
+
*/
|
|
73
|
+
constructor(fences = DEFAULT_FENCES, { startInside } = {}) {
|
|
74
|
+
this.#fences = fences;
|
|
75
|
+
this.#markers = fences.flatMap((fence) => [
|
|
76
|
+
fence.open,
|
|
77
|
+
fence.close,
|
|
78
|
+
...(FRAMING[fence.open] ?? []),
|
|
79
|
+
]);
|
|
80
|
+
this.#inside = startInside ? fences[0] : undefined;
|
|
81
|
+
this.#seenFence = this.#inside !== undefined;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Reads one more piece of content, returning what it settled, in order.
|
|
85
|
+
*
|
|
86
|
+
* @param text The next delta.
|
|
87
|
+
*/
|
|
88
|
+
push(text) {
|
|
89
|
+
const parts = [];
|
|
90
|
+
let rest = this.#held + text;
|
|
91
|
+
this.#held = "";
|
|
92
|
+
while (rest) {
|
|
93
|
+
const found = this.#next(rest);
|
|
94
|
+
if (!found) {
|
|
95
|
+
const keep = this.#partialTail(rest);
|
|
96
|
+
this.#emit(parts, rest.slice(0, rest.length - keep));
|
|
97
|
+
this.#held = rest.slice(rest.length - keep);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
this.#emit(parts, rest.slice(0, found.at));
|
|
101
|
+
rest = rest.slice(found.at + found.marker.length);
|
|
102
|
+
this.#take(found.marker);
|
|
103
|
+
}
|
|
104
|
+
return parts;
|
|
105
|
+
}
|
|
106
|
+
/** Releases whatever was held back as a possible tag, now that no more is coming. */
|
|
107
|
+
finish() {
|
|
108
|
+
const parts = [];
|
|
109
|
+
this.#emit(parts, this.#held);
|
|
110
|
+
this.#held = "";
|
|
111
|
+
return parts;
|
|
112
|
+
}
|
|
113
|
+
/** The earliest marker that means something in the current state. */
|
|
114
|
+
#next(text) {
|
|
115
|
+
const candidates = this.#inside ? [this.#inside.close] : this.#markers;
|
|
116
|
+
let best;
|
|
117
|
+
for (const marker of candidates) {
|
|
118
|
+
const at = text.indexOf(marker);
|
|
119
|
+
if (at === -1)
|
|
120
|
+
continue;
|
|
121
|
+
if (!best || at < best.at || (at === best.at && marker.length > best.marker.length))
|
|
122
|
+
best = { at, marker };
|
|
123
|
+
}
|
|
124
|
+
return best;
|
|
125
|
+
}
|
|
126
|
+
/** How much of the end of `text` could be the beginning of a marker. */
|
|
127
|
+
#partialTail(text) {
|
|
128
|
+
const candidates = this.#inside ? [this.#inside.close] : this.#markers;
|
|
129
|
+
let keep = 0;
|
|
130
|
+
for (const marker of candidates)
|
|
131
|
+
for (let length = Math.min(marker.length - 1, text.length); length > keep; length--)
|
|
132
|
+
if (text.endsWith(marker.slice(0, length))) {
|
|
133
|
+
keep = length;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
return keep;
|
|
137
|
+
}
|
|
138
|
+
#take(marker) {
|
|
139
|
+
if (this.#inside) {
|
|
140
|
+
this.#inside = undefined;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const opened = this.#fences.find((fence) => fence.open === marker);
|
|
144
|
+
if (opened) {
|
|
145
|
+
this.#inside = opened;
|
|
146
|
+
this.#seenFence = true;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// A close with no open. The first time, the template opened it in the prompt; after a fence
|
|
150
|
+
// has been read, it is a stray tag and only dropped.
|
|
151
|
+
const closes = this.#fences.some((fence) => fence.close === marker);
|
|
152
|
+
if (closes && !this.#seenFence) {
|
|
153
|
+
this.reasoning += this.output;
|
|
154
|
+
this.output = "";
|
|
155
|
+
}
|
|
156
|
+
if (closes)
|
|
157
|
+
this.#seenFence = true;
|
|
158
|
+
}
|
|
159
|
+
#emit(parts, text) {
|
|
160
|
+
if (!text)
|
|
161
|
+
return;
|
|
162
|
+
const kind = this.#inside ? "reasoning" : "output";
|
|
163
|
+
this[kind] += text;
|
|
164
|
+
const last = parts.at(-1);
|
|
165
|
+
if (last?.kind === kind)
|
|
166
|
+
last.text += text;
|
|
167
|
+
else
|
|
168
|
+
parts.push({ kind, text });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* What is left of a complete reply once every scratchpad is taken out of it.
|
|
173
|
+
*
|
|
174
|
+
* @param text The whole reply.
|
|
175
|
+
* @param fences The fences to read, every known one unless given.
|
|
176
|
+
*/
|
|
177
|
+
export function stripThinking(text, fences = ALL_FENCES) {
|
|
178
|
+
const splitter = new FenceSplitter(fences);
|
|
179
|
+
splitter.push(text);
|
|
180
|
+
splitter.finish();
|
|
181
|
+
return splitter.output;
|
|
182
|
+
}
|
package/llms.txt
CHANGED
|
@@ -45,11 +45,14 @@ The contract between whatever holds the tools and the loop that offers them to a
|
|
|
45
45
|
### client
|
|
46
46
|
|
|
47
47
|
- `contextLimitFor` — How much a model will read, in tokens.
|
|
48
|
+
- `FIRST_TOKEN_FACTOR` — How many idle windows the first chunk gets when `firstTokenSeconds` is not given.
|
|
49
|
+
- `firstTokenMs` — The wait for a streamed turn's first chunk, in the SDK's spelling: `undefined` is no limit.
|
|
48
50
|
- `getClient` — The client for an endpoint, built once and kept.
|
|
49
51
|
- `listModels` — Asks an endpoint what it serves, and remembers the answer.
|
|
50
52
|
- `ModelInfo` (type) — A model an endpoint offers, and what it says the model will read.
|
|
51
53
|
- `NO_KEY` — The SDK insists on a non-empty key even where the server will not look at it.
|
|
52
54
|
- `resetClients` — Forgets every cached client and listing.
|
|
55
|
+
- `servedWindow` — The window a local server is actually serving a model in, which its listing does not say.
|
|
53
56
|
- `timeoutMs` — Zero, less, or absent means no limit, which the SDK spells as `undefined`.
|
|
54
57
|
|
|
55
58
|
### compaction
|
|
@@ -139,8 +142,11 @@ Everything about a request failing that is not about what the request said.
|
|
|
139
142
|
- `ContextOverflow` — The request was bigger than the model will read.
|
|
140
143
|
- `compact` — 1234 → "1.2k".
|
|
141
144
|
- `EndpointSilent` — The endpoint stopped answering mid-request.
|
|
145
|
+
- `isModelLoading` — Whether a failure is a local server still loading the model, rather than one failing to serve.
|
|
142
146
|
- `isOverflow` — Whether a refusal means the request was too big, rather than merely refused.
|
|
143
147
|
- `isTransient` — Whether a failed request is worth trying again.
|
|
148
|
+
- `LOADING_POLL_MS` — How long to wait between asking a loading server again.
|
|
149
|
+
- `LOADING_TIMEOUT_MS` — How long `runTurn` waits for a model to load unless told otherwise.
|
|
144
150
|
- `messageTokens` — One message's estimated tokens, by the same count `requestTokens` sums for a whole request.
|
|
145
151
|
- `requestTokens` — What this request will cost the window, in tokens, near enough.
|
|
146
152
|
- `SMALLEST_LIKELY_WINDOW` — The smallest window worth believing in, and the floor under `runTurn`'s guard.
|
|
@@ -196,6 +202,19 @@ Reading one streamed turn back into a message.
|
|
|
196
202
|
- `Turn` (type) — One streamed turn, put back together into the shape a loop and a transcript work with.
|
|
197
203
|
- `TurnUsage` (type) — What a turn cost.
|
|
198
204
|
|
|
205
|
+
### thinking
|
|
206
|
+
|
|
207
|
+
Telling a model's scratchpad from its answer when both arrive in `content`.
|
|
208
|
+
|
|
209
|
+
- `ALL_FENCES` — Every fence known, including the two plain-word ones some fine-tunes and prompt-instructed models use.
|
|
210
|
+
- `DEFAULT_FENCES` — The fences nobody writes by accident, and so the ones `streamTurn` reads by default.
|
|
211
|
+
- `Fence` (type) — One way a model marks off its scratchpad inside `content`.
|
|
212
|
+
- `FenceSplitter` — A state machine over a stream of `content` that routes fenced text to reasoning.
|
|
213
|
+
- `FenceSplitterOptions` (type) — What `FenceSplitter` takes besides its fences.
|
|
214
|
+
- `Split` (type) — A piece of `content`, said to be one or the other.
|
|
215
|
+
- `stripThinking` — What is left of a complete reply once every scratchpad is taken out of it.
|
|
216
|
+
- `THINK_FENCE` — DeepSeek, Qwen3, QwQ and most distills.
|
|
217
|
+
|
|
199
218
|
### tokens
|
|
200
219
|
|
|
201
220
|
- `estimateTokens` — Rough token count.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cubicecho/agent-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.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",
|