@cubicecho/agent-core 2.23.0 → 2.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -9
- package/dist/agent-loop.d.ts +84 -204
- package/dist/agent-loop.js +416 -643
- package/dist/calibration.d.ts +14 -10
- package/dist/calibration.js +30 -22
- package/dist/capabilities.d.ts +140 -35
- package/dist/capabilities.js +345 -152
- package/dist/client.d.ts +104 -35
- package/dist/client.js +210 -78
- package/dist/compaction.d.ts +103 -60
- package/dist/compaction.js +163 -93
- package/dist/config.d.ts +48 -12
- package/dist/config.js +20 -1
- package/dist/continuation.d.ts +25 -17
- package/dist/continuation.js +70 -47
- package/dist/digest.d.ts +7 -0
- package/dist/digest.js +18 -0
- package/dist/errors.d.ts +24 -15
- package/dist/errors.js +23 -15
- package/dist/events.d.ts +108 -58
- package/dist/events.js +265 -128
- package/dist/guards.d.ts +53 -0
- package/dist/guards.js +55 -0
- package/dist/hook-events.d.ts +38 -0
- package/dist/hook-events.js +36 -0
- package/dist/hooks.d.ts +105 -83
- package/dist/hooks.js +224 -139
- package/dist/index.d.ts +30 -25
- package/dist/index.js +29 -23
- package/dist/ledger.d.ts +62 -30
- package/dist/ledger.js +163 -56
- package/dist/platform.d.ts +13 -0
- package/dist/platform.js +13 -0
- package/dist/preselect.d.ts +222 -0
- package/dist/preselect.js +290 -0
- package/dist/request-body.d.ts +32 -0
- package/dist/request-body.js +47 -0
- package/dist/reset.d.ts +1 -0
- package/dist/reset.js +7 -6
- package/dist/retry.d.ts +40 -143
- package/dist/retry.js +79 -285
- package/dist/run-calls.d.ts +123 -0
- package/dist/run-calls.js +351 -0
- package/dist/run-turn.d.ts +44 -16
- package/dist/run-turn.js +74 -37
- package/dist/runtime.d.ts +23 -17
- package/dist/runtime.js +34 -24
- package/dist/schema-compat.d.ts +51 -11
- package/dist/schema-compat.js +375 -193
- package/dist/scope.d.ts +28 -5
- package/dist/scope.js +37 -10
- package/dist/side-task.d.ts +127 -39
- package/dist/side-task.js +189 -100
- package/dist/snapshot.d.ts +12 -7
- package/dist/snapshot.js +87 -65
- package/dist/spec.d.ts +66 -36
- package/dist/spec.js +514 -265
- package/dist/stream.d.ts +73 -16
- package/dist/stream.js +228 -98
- package/dist/thinking.d.ts +23 -8
- package/dist/thinking.js +96 -46
- package/dist/tokens.d.ts +171 -6
- package/dist/tokens.js +326 -6
- package/dist/tool-calls.d.ts +89 -15
- package/dist/tool-calls.js +205 -85
- package/dist/tool-loading.d.ts +109 -187
- package/dist/tool-loading.js +286 -372
- package/dist/wire.d.ts +81 -0
- package/dist/wire.js +73 -0
- package/llms.txt +87 -43
- package/package.json +4 -4
package/dist/stream.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type OpenAI from
|
|
2
|
-
import { type
|
|
3
|
-
import {
|
|
1
|
+
import type OpenAI from 'openai';
|
|
2
|
+
import { type Fence } from './thinking.ts';
|
|
3
|
+
import type { ContextBreakdown } from './tokens.ts';
|
|
4
|
+
import type { ToolCall } from './tool-calls.ts';
|
|
4
5
|
/**
|
|
5
6
|
* Reading one streamed turn back into a message.
|
|
6
7
|
*
|
|
@@ -11,9 +12,32 @@ import { type Fence } from "./thinking.ts";
|
|
|
11
12
|
* hangs the turn until somebody presses stop. `EndpointSilent` and `timeoutMs` were exported for
|
|
12
13
|
* this loop long before the loop itself was.
|
|
13
14
|
*/
|
|
15
|
+
/**
|
|
16
|
+
* What the loop changed that would explain a prompt cache that did not hold.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* In the order `breakReason` looks, the earliest in the prompt first, since a change there is the
|
|
20
|
+
* one that costs the rest.
|
|
21
|
+
*/
|
|
22
|
+
export declare const CacheBreakReason: {
|
|
23
|
+
/** The tool array is not the one the request before declared. */
|
|
24
|
+
readonly ToolsChanged: 'tools-changed';
|
|
25
|
+
/** The system messages the prompt opens with are not the ones it opened with before. */
|
|
26
|
+
readonly SystemChanged: 'system-changed';
|
|
27
|
+
/** A message the request before sent is gone or different. */
|
|
28
|
+
readonly HistoryRewritten: 'history-rewritten';
|
|
29
|
+
/**
|
|
30
|
+
* Nothing the loop can see: the server's doing — an eviction, another client on the slot — or
|
|
31
|
+
* a change made where the loop does not look.
|
|
32
|
+
*/
|
|
33
|
+
readonly NoneKnown: 'none-known';
|
|
34
|
+
};
|
|
35
|
+
/** Any one of the reasons in `CacheBreakReason`. */
|
|
36
|
+
export type CacheBreakReason = (typeof CacheBreakReason)[keyof typeof CacheBreakReason];
|
|
14
37
|
/**
|
|
15
38
|
* What a turn cost, and how it went. Zero throughout the four counts means the server did not say.
|
|
16
39
|
*
|
|
40
|
+
* @remarks
|
|
17
41
|
* The four counts are always there, as they always were. Everything after them is absent rather
|
|
18
42
|
* than zero when nothing reported or measured it, because a zero cache hit and a server that says
|
|
19
43
|
* nothing about its cache are different findings, and a consumer drawing one as the other is
|
|
@@ -28,6 +52,7 @@ export interface TurnUsage {
|
|
|
28
52
|
/**
|
|
29
53
|
* How much of `prompt` came from the endpoint's prompt cache — a part of it, not in addition.
|
|
30
54
|
*
|
|
55
|
+
* @remarks
|
|
31
56
|
* The only way a caller can tell whether the prefix it is careful to keep still is actually
|
|
32
57
|
* being reused: a prefix that stops hitting the cache otherwise shows up as a bill and nothing
|
|
33
58
|
* else. Zero is also what a server that does not report it sends; `uncached` is the field whose
|
|
@@ -83,12 +108,8 @@ export interface TurnUsage {
|
|
|
83
108
|
* reported, so a server that says nothing about its cache is never accused of losing it.
|
|
84
109
|
*/
|
|
85
110
|
cacheBroken?: boolean;
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
* since a change there is the one that costs the rest. `none-known` is the server's doing — an
|
|
89
|
-
* eviction, another client on the slot — or a change the loop cannot see. Only on a broken turn.
|
|
90
|
-
*/
|
|
91
|
-
cacheBreakReason?: "tools-changed" | "system-changed" | "history-rewritten" | "none-known";
|
|
111
|
+
/** What the loop changed that would explain `cacheBroken`. Only on a broken turn. */
|
|
112
|
+
cacheBreakReason?: CacheBreakReason;
|
|
92
113
|
/** How many tools the request declared, filled by `runAgentLoop`. */
|
|
93
114
|
toolsDeclared?: number;
|
|
94
115
|
/**
|
|
@@ -100,6 +121,7 @@ export interface TurnUsage {
|
|
|
100
121
|
* What the request was made of, as `contextTokens` cuts it, filled by `runAgentLoop`. Shares of
|
|
101
122
|
* the prompt the turn's first request reported, summing to it, or an estimate where none was.
|
|
102
123
|
*
|
|
124
|
+
* @remarks
|
|
103
125
|
* Measured on the request as first built, with the tools as `toolSchemaTokens` measures them. A
|
|
104
126
|
* continued turn's `prompt` is every request's summed, so it is larger than this `total`; and
|
|
105
127
|
* where a refusal was answered with a lesser body, the prompt is that body's and the proportions
|
|
@@ -107,6 +129,28 @@ export interface TurnUsage {
|
|
|
107
129
|
*/
|
|
108
130
|
context?: ContextBreakdown;
|
|
109
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* A usage with nothing counted yet, a fresh object each call since every caller adds into it.
|
|
134
|
+
*
|
|
135
|
+
* @returns The four counts at zero and no other field.
|
|
136
|
+
*
|
|
137
|
+
* @remarks
|
|
138
|
+
* Only the four counts every turn has: the optional fields stay absent, which is what says
|
|
139
|
+
* nothing reported them.
|
|
140
|
+
*/
|
|
141
|
+
export declare const noUsage: () => TurnUsage;
|
|
142
|
+
/**
|
|
143
|
+
* The four token counts of two usages, added.
|
|
144
|
+
*
|
|
145
|
+
* @param a - One usage. Neither is changed.
|
|
146
|
+
* @param b - The other.
|
|
147
|
+
* @returns A new usage holding the four sums, and none of the optional fields of either.
|
|
148
|
+
*
|
|
149
|
+
* @remarks
|
|
150
|
+
* Only those: the rest are measurements a sum of would mean nothing, or would mean something only
|
|
151
|
+
* `runMetrics` knows how to weigh.
|
|
152
|
+
*/
|
|
153
|
+
export declare const addCounts: (a: TurnUsage, b: TurnUsage) => TurnUsage;
|
|
110
154
|
/** One streamed turn, put back together into the shape a loop and a transcript work with. */
|
|
111
155
|
export interface Turn {
|
|
112
156
|
content: string;
|
|
@@ -116,12 +160,13 @@ export interface Turn {
|
|
|
116
160
|
* belongs to, every caller had to narrow before it could read `.function`, to rule out a
|
|
117
161
|
* custom call that this loop cannot produce. Still assignable wherever the union is wanted.
|
|
118
162
|
*/
|
|
119
|
-
toolCalls:
|
|
163
|
+
toolCalls: ToolCall[];
|
|
120
164
|
usage: TurnUsage;
|
|
121
165
|
/**
|
|
122
166
|
* Why the model stopped, in the endpoint's own words — `stop`, `length`, `tool_calls`, or `""`
|
|
123
167
|
* where it never said.
|
|
124
168
|
*
|
|
169
|
+
* @remarks
|
|
125
170
|
* Reported because `length` is otherwise invisible. A turn cut off at the token ceiling comes
|
|
126
171
|
* back as a well-formed `Turn` with truncated `content`, or with a tool call whose `arguments`
|
|
127
172
|
* stop mid-JSON — so the caller meets a parse failure with nothing to attribute it to. Being
|
|
@@ -134,6 +179,7 @@ export interface Turn {
|
|
|
134
179
|
* The model's scratchpad, as `onThinking` was told it, `""` where it deliberated in silence or
|
|
135
180
|
* not at all.
|
|
136
181
|
*
|
|
182
|
+
* @remarks
|
|
137
183
|
* Kept because some models want it back. gpt-oss and DeepSeek in thinking mode read the
|
|
138
184
|
* analysis behind a tool call off the assistant message on the next request, so a caller
|
|
139
185
|
* talking to one stores it as `reasoning_content` on that message for as long as the message
|
|
@@ -145,6 +191,7 @@ export interface Turn {
|
|
|
145
191
|
/**
|
|
146
192
|
* Whether the model has said anything a second attempt would say twice.
|
|
147
193
|
*
|
|
194
|
+
* @remarks
|
|
148
195
|
* A box rather than a return value because it has to be readable *while* the request is in
|
|
149
196
|
* flight: the rules in `retry.ts` are built on the premise that a stream which has already
|
|
150
197
|
* emitted tokens must never be replayed, and by the time a rejected promise is in hand the turn
|
|
@@ -171,8 +218,11 @@ export interface StreamTurnOptions {
|
|
|
171
218
|
*/
|
|
172
219
|
idleMs?: number;
|
|
173
220
|
/**
|
|
174
|
-
* Silence allowed before the first chunk
|
|
221
|
+
* Silence allowed before the first chunk; zero waits forever.
|
|
175
222
|
*
|
|
223
|
+
* @defaultValue `idleMs`
|
|
224
|
+
*
|
|
225
|
+
* @remarks
|
|
176
226
|
* The first wait is prefill, or a server loading the model, and is routinely many times the
|
|
177
227
|
* gap between tokens. See `Endpoint.firstTokenSeconds` and `firstTokenMs`.
|
|
178
228
|
*/
|
|
@@ -180,8 +230,11 @@ export interface StreamTurnOptions {
|
|
|
180
230
|
/** Set by the first chunk that carries anything, so a failed call knows if it can be retried. */
|
|
181
231
|
produced?: Produced;
|
|
182
232
|
/**
|
|
183
|
-
* The fences that mark a scratchpad written into `content
|
|
233
|
+
* The fences that mark a scratchpad written into `content`.
|
|
234
|
+
*
|
|
235
|
+
* @defaultValue `DEFAULT_FENCES`
|
|
184
236
|
*
|
|
237
|
+
* @remarks
|
|
185
238
|
* Text inside one goes to `onThinking` and `reasoning` rather than `onOutput` and `content`.
|
|
186
239
|
* `ALL_FENCES` adds `<thinking>` and `<reasoning>`, which a model can also be quoting; an
|
|
187
240
|
* empty list reads `content` as all answer.
|
|
@@ -202,6 +255,14 @@ export interface StreamTurnOptions {
|
|
|
202
255
|
* Runs one turn as a stream, reporting tokens as they arrive and assembling them back into a
|
|
203
256
|
* message.
|
|
204
257
|
*
|
|
258
|
+
* @param client - The pooled client for this endpoint.
|
|
259
|
+
* @param body - The request, which must set `stream: true`.
|
|
260
|
+
* @param [options] - Cancellation, the idle watchdog, and the token callbacks.
|
|
261
|
+
* @returns The assembled turn, once the stream has ended. Rejects with `EndpointSilent` where the
|
|
262
|
+
* endpoint was silent past its allowance, and with whatever the abort raised where `signal`
|
|
263
|
+
* stopped it — an aborted stream never comes back as a turn.
|
|
264
|
+
*
|
|
265
|
+
* @remarks
|
|
205
266
|
* Streaming buys no speed — nothing waits on the reply but the loop itself. It is what makes a
|
|
206
267
|
* run watchable: a run that stalls, loops, or reaches for the wrong tool says so while it is
|
|
207
268
|
* happening instead of only in the row it leaves behind.
|
|
@@ -209,9 +270,5 @@ export interface StreamTurnOptions {
|
|
|
209
270
|
* Two token callbacks rather than an event input, because a turn does not know which step of
|
|
210
271
|
* which run it is: `step` is the caller's flow concept, and wrapping these into an `emit` is one
|
|
211
272
|
* line at the call site.
|
|
212
|
-
*
|
|
213
|
-
* @param client The pooled client for this endpoint.
|
|
214
|
-
* @param body The request, which must set `stream: true`.
|
|
215
|
-
* @param options Cancellation, the idle watchdog, and the token callbacks.
|
|
216
273
|
*/
|
|
217
274
|
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,24 +1,205 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { MS_PER_SECOND } from './platform.js';
|
|
2
|
+
import { EndpointSilent } from './retry.js';
|
|
3
|
+
import { DEFAULT_FENCES, FenceSplitter, SPLIT_REASONING } from './thinking.js';
|
|
4
|
+
import { FUNCTION_TOOL } from './wire.js';
|
|
5
|
+
/**
|
|
6
|
+
* Reading one streamed turn back into a message.
|
|
7
|
+
*
|
|
8
|
+
* The rest of a request is the caller's — which model, which tools, which transcript — but the
|
|
9
|
+
* reading of the answer is the same everywhere, and it is fiddly in ways the API does not
|
|
10
|
+
* advertise: tool calls arrive in pieces, an aborted stream ends rather than throws, reasoning
|
|
11
|
+
* has two spellings and is in no published type, and an endpoint that stops answering mid-stream
|
|
12
|
+
* hangs the turn until somebody presses stop. `EndpointSilent` and `timeoutMs` were exported for
|
|
13
|
+
* this loop long before the loop itself was.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* What the loop changed that would explain a prompt cache that did not hold.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* In the order `breakReason` looks, the earliest in the prompt first, since a change there is the
|
|
20
|
+
* one that costs the rest.
|
|
21
|
+
*/
|
|
22
|
+
export const CacheBreakReason = {
|
|
23
|
+
/** The tool array is not the one the request before declared. */
|
|
24
|
+
ToolsChanged: 'tools-changed',
|
|
25
|
+
/** The system messages the prompt opens with are not the ones it opened with before. */
|
|
26
|
+
SystemChanged: 'system-changed',
|
|
27
|
+
/** A message the request before sent is gone or different. */
|
|
28
|
+
HistoryRewritten: 'history-rewritten',
|
|
29
|
+
/**
|
|
30
|
+
* Nothing the loop can see: the server's doing — an eviction, another client on the slot — or
|
|
31
|
+
* a change made where the loop does not look.
|
|
32
|
+
*/
|
|
33
|
+
NoneKnown: 'none-known',
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* A usage with nothing counted yet, a fresh object each call since every caller adds into it.
|
|
37
|
+
*
|
|
38
|
+
* @returns The four counts at zero and no other field.
|
|
39
|
+
*
|
|
40
|
+
* @remarks
|
|
41
|
+
* Only the four counts every turn has: the optional fields stay absent, which is what says
|
|
42
|
+
* nothing reported them.
|
|
43
|
+
*/
|
|
44
|
+
export const noUsage = () => ({ prompt: 0, completion: 0, total: 0, cached: 0 });
|
|
45
|
+
/**
|
|
46
|
+
* The four token counts of two usages, added.
|
|
47
|
+
*
|
|
48
|
+
* @param a - One usage. Neither is changed.
|
|
49
|
+
* @param b - The other.
|
|
50
|
+
* @returns A new usage holding the four sums, and none of the optional fields of either.
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* Only those: the rest are measurements a sum of would mean nothing, or would mean something only
|
|
54
|
+
* `runMetrics` knows how to weigh.
|
|
55
|
+
*/
|
|
56
|
+
export const addCounts = (a, b) => ({
|
|
57
|
+
prompt: a.prompt + b.prompt,
|
|
58
|
+
completion: a.completion + b.completion,
|
|
59
|
+
total: a.total + b.total,
|
|
60
|
+
cached: a.cached + b.cached,
|
|
61
|
+
});
|
|
3
62
|
/**
|
|
4
63
|
* The fields of `timings` a turn reports, under the names `TurnUsage` gives them. A value that is
|
|
5
64
|
* not a finite number is left out rather than guessed at.
|
|
6
65
|
*/
|
|
7
66
|
const TIMINGS = [
|
|
8
|
-
[
|
|
9
|
-
[
|
|
10
|
-
[
|
|
11
|
-
[
|
|
12
|
-
[
|
|
13
|
-
[
|
|
67
|
+
['prompt_ms', 'promptMs'],
|
|
68
|
+
['prompt_per_second', 'promptTokensPerSecond'],
|
|
69
|
+
['predicted_ms', 'predictedMs'],
|
|
70
|
+
['predicted_per_second', 'tokensPerSecond'],
|
|
71
|
+
['draft_n', 'draftTotal'],
|
|
72
|
+
['draft_n_accepted', 'draftAccepted'],
|
|
14
73
|
];
|
|
15
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Whether a value is a finite number, which is what a count or a timing off the wire has to be.
|
|
76
|
+
*
|
|
77
|
+
* @param value - Whatever a server put in a numeric field.
|
|
78
|
+
* @returns True for a number that is neither `NaN` nor infinite. Zero and a negative pass.
|
|
79
|
+
*/
|
|
80
|
+
const isCount = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
81
|
+
/**
|
|
82
|
+
* Reads a chunk's usage block and its `timings`, either of which most chunks carry neither of.
|
|
83
|
+
*
|
|
84
|
+
* @param chunk - One chunk of the stream. Not written to.
|
|
85
|
+
* @returns Only what this chunk said. `counts` is empty for a chunk with neither block; a usage
|
|
86
|
+
* block always sets the three token counts, zero for one it left out; and a cache count, a
|
|
87
|
+
* reasoning count or a timing is there only where the server sent a finite number.
|
|
88
|
+
*/
|
|
89
|
+
function chunkReport(chunk) {
|
|
90
|
+
const counts = {};
|
|
91
|
+
const report = { counts };
|
|
92
|
+
if (chunk.usage) {
|
|
93
|
+
counts.prompt = chunk.usage.prompt_tokens ?? 0;
|
|
94
|
+
counts.completion = chunk.usage.completion_tokens ?? 0;
|
|
95
|
+
counts.total = chunk.usage.total_tokens ?? 0;
|
|
96
|
+
const reported = chunk.usage;
|
|
97
|
+
const hit = reported.prompt_tokens_details?.cached_tokens ?? reported.prompt_cache_hit_tokens;
|
|
98
|
+
if (isCount(hit)) {
|
|
99
|
+
report.cacheReport = hit;
|
|
100
|
+
}
|
|
101
|
+
const reasoned = reported.completion_tokens_details?.reasoning_tokens;
|
|
102
|
+
if (isCount(reasoned)) {
|
|
103
|
+
counts.reasoningTokens = reasoned;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const { timings } = chunk;
|
|
107
|
+
if (timings) {
|
|
108
|
+
for (const [from, to] of TIMINGS) {
|
|
109
|
+
const value = timings[from];
|
|
110
|
+
if (isCount(value)) {
|
|
111
|
+
counts[to] = value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (isCount(timings.cache_n)) {
|
|
115
|
+
report.cacheTimings = timings.cache_n;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return report;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The position a fragment says its call is at, where the server sent a number.
|
|
122
|
+
*
|
|
123
|
+
* @param part - One tool-call fragment of a delta.
|
|
124
|
+
* @returns Its `index`, or `undefined` where the server sent none or something not a number.
|
|
125
|
+
*/
|
|
126
|
+
const fragmentIndex = (part) => typeof part.index === 'number' ? part.index : undefined;
|
|
127
|
+
/**
|
|
128
|
+
* The call a fragment belongs to, of those begun so far, or none where it opens a new one.
|
|
129
|
+
*
|
|
130
|
+
* @param calls - The calls begun so far, in arrival order. Not written to.
|
|
131
|
+
* @param part - The fragment to place.
|
|
132
|
+
* @returns The call to add the fragment to — the object in `calls`, not a copy — or `undefined`
|
|
133
|
+
* where the caller is to begin a new one.
|
|
134
|
+
*
|
|
135
|
+
* @remarks
|
|
136
|
+
* By `index` where the server sent a number, which the SDK types as required and servers have
|
|
137
|
+
* nonetheless left out: keyed on `undefined`, every call joined into one whose name and
|
|
138
|
+
* arguments were all of theirs run together. Without one, by `id`; failing that, a fragment
|
|
139
|
+
* naming a function opens a call and a bare run of arguments continues the latest. A server
|
|
140
|
+
* that sends whole calls one per chunk at index `0` makes the same mistake the other way, so
|
|
141
|
+
* a different id, or a name after arguments have begun, opens a new call under that index.
|
|
142
|
+
*/
|
|
143
|
+
function ownerOf(calls, part) {
|
|
144
|
+
const index = fragmentIndex(part);
|
|
145
|
+
const name = part.function?.name;
|
|
146
|
+
let known;
|
|
147
|
+
if (index !== undefined) {
|
|
148
|
+
known = calls.findLast((call) => call.index === index);
|
|
149
|
+
}
|
|
150
|
+
else if (part.id) {
|
|
151
|
+
known = calls.find((call) => call.id === part.id);
|
|
152
|
+
}
|
|
153
|
+
else if (!name) {
|
|
154
|
+
known = calls.at(-1);
|
|
155
|
+
}
|
|
156
|
+
const another = known && ((part.id && known.id && part.id !== known.id) || (name && known.arguments));
|
|
157
|
+
return another ? undefined : known;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* A turn's calls as they are handed back: in the order their indexes give, arrival order where a
|
|
161
|
+
* server sent none, each under an id of its own.
|
|
162
|
+
*
|
|
163
|
+
* @param calls - The calls as the stream left them, in arrival order. Not written to or reordered.
|
|
164
|
+
* @returns One function call per entry. An id is made up for a call the server gave none, and for
|
|
165
|
+
* one whose id an earlier call in the result already has.
|
|
166
|
+
*/
|
|
167
|
+
function assembledCalls(calls) {
|
|
168
|
+
const ordered = calls
|
|
169
|
+
.map((call, order) => ({ call, order }))
|
|
170
|
+
.sort((a, b) => (a.call.index ?? a.order) - (b.call.index ?? b.order) || a.order - b.order);
|
|
171
|
+
const minted = new Set();
|
|
172
|
+
const assembled = [];
|
|
173
|
+
for (const [position, { call }] of ordered.entries()) {
|
|
174
|
+
// A server that streams a call without an id still needs one for the result to answer,
|
|
175
|
+
// and two calls it put under one index must not be answered as one.
|
|
176
|
+
let id = call.id || `call_${call.index ?? position}`;
|
|
177
|
+
if (minted.has(id)) {
|
|
178
|
+
id = `call_${position}_${minted.size}`;
|
|
179
|
+
}
|
|
180
|
+
minted.add(id);
|
|
181
|
+
assembled.push({
|
|
182
|
+
id,
|
|
183
|
+
type: FUNCTION_TOOL,
|
|
184
|
+
function: { name: call.name, arguments: call.arguments },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return assembled;
|
|
188
|
+
}
|
|
16
189
|
/** The largest delay a timer takes, which is as close to none as the SDK's timeout option goes. */
|
|
17
|
-
const NO_SDK_TIMEOUT =
|
|
190
|
+
const NO_SDK_TIMEOUT = 2_147_483_647;
|
|
18
191
|
/**
|
|
19
192
|
* Runs one turn as a stream, reporting tokens as they arrive and assembling them back into a
|
|
20
193
|
* message.
|
|
21
194
|
*
|
|
195
|
+
* @param client - The pooled client for this endpoint.
|
|
196
|
+
* @param body - The request, which must set `stream: true`.
|
|
197
|
+
* @param [options] - Cancellation, the idle watchdog, and the token callbacks.
|
|
198
|
+
* @returns The assembled turn, once the stream has ended. Rejects with `EndpointSilent` where the
|
|
199
|
+
* endpoint was silent past its allowance, and with whatever the abort raised where `signal`
|
|
200
|
+
* stopped it — an aborted stream never comes back as a turn.
|
|
201
|
+
*
|
|
202
|
+
* @remarks
|
|
22
203
|
* Streaming buys no speed — nothing waits on the reply but the loop itself. It is what makes a
|
|
23
204
|
* run watchable: a run that stalls, loops, or reaches for the wrong tool says so while it is
|
|
24
205
|
* happening instead of only in the row it leaves behind.
|
|
@@ -26,10 +207,6 @@ const NO_SDK_TIMEOUT = 2 ** 31 - 1;
|
|
|
26
207
|
* Two token callbacks rather than an event input, because a turn does not know which step of
|
|
27
208
|
* which run it is: `step` is the caller's flow concept, and wrapping these into an `emit` is one
|
|
28
209
|
* line at the call site.
|
|
29
|
-
*
|
|
30
|
-
* @param client The pooled client for this endpoint.
|
|
31
|
-
* @param body The request, which must set `stream: true`.
|
|
32
|
-
* @param options Cancellation, the idle watchdog, and the token callbacks.
|
|
33
210
|
*/
|
|
34
211
|
export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, produced, fences = DEFAULT_FENCES, startInReasoning, onThinking, onOutput, } = {}) {
|
|
35
212
|
// Silence, not duration: the timer is rearmed on every chunk, so a model that is still
|
|
@@ -48,8 +225,9 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
|
|
|
48
225
|
talking ||= carried;
|
|
49
226
|
const ms = talking ? idleMs : first;
|
|
50
227
|
clearTimeout(idle);
|
|
51
|
-
if (ms)
|
|
228
|
+
if (ms) {
|
|
52
229
|
idle = setTimeout(() => watchdog.abort(), ms);
|
|
230
|
+
}
|
|
53
231
|
};
|
|
54
232
|
try {
|
|
55
233
|
rearm(false);
|
|
@@ -63,8 +241,8 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
|
|
|
63
241
|
// they read the row and disbelieve it.
|
|
64
242
|
if (watchdog.signal.aborted && !signal?.aborted) {
|
|
65
243
|
const waited = talking ? idleMs : first;
|
|
66
|
-
const before = talking || first === idleMs ?
|
|
67
|
-
throw new EndpointSilent(`the model endpoint sent nothing for ${(waited ?? 0) /
|
|
244
|
+
const before = talking || first === idleMs ? '' : ' before its first token';
|
|
245
|
+
throw new EndpointSilent(`the model endpoint sent nothing for ${(waited ?? 0) / MS_PER_SECOND}s${before}`);
|
|
68
246
|
}
|
|
69
247
|
throw error;
|
|
70
248
|
}
|
|
@@ -85,18 +263,19 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
|
|
|
85
263
|
const reasoning = [];
|
|
86
264
|
const splitter = new FenceSplitter(fences, { startInside: startInReasoning });
|
|
87
265
|
const report = (parts) => {
|
|
88
|
-
for (const part of parts)
|
|
89
|
-
(part.kind ===
|
|
266
|
+
for (const part of parts) {
|
|
267
|
+
(part.kind === SPLIT_REASONING ? onThinking : onOutput)?.(part.text);
|
|
268
|
+
}
|
|
90
269
|
};
|
|
91
270
|
// In arrival order, sorted by index at the end; a call from a server that sent none keeps
|
|
92
271
|
// its place in the order they arrived.
|
|
93
272
|
const calls = [];
|
|
94
|
-
const usage =
|
|
273
|
+
const usage = noUsage();
|
|
95
274
|
// The usage block's own cache count wins over llama.cpp's, which is the same number in the
|
|
96
275
|
// servers that send both; kept apart so the order the two arrive in does not decide.
|
|
97
276
|
let cacheReport;
|
|
98
277
|
let cacheTimings;
|
|
99
|
-
let finishReason =
|
|
278
|
+
let finishReason = '';
|
|
100
279
|
for await (const chunk of stream) {
|
|
101
280
|
// Rearmed on every chunk, latched below on only some: a priming chunk is the endpoint
|
|
102
281
|
// being alive, which is all the watchdog is asking about. Which allowance it rearms with
|
|
@@ -106,40 +285,24 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
|
|
|
106
285
|
// and the two agree there, but a server that reports cumulatively per chunk makes a sum
|
|
107
286
|
// of sums out of an accumulator — and a token count wrong by a factor of the chunk count
|
|
108
287
|
// is not a number anyone would attribute to the stream reader.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const reported = chunk.usage;
|
|
114
|
-
const hit = reported.prompt_tokens_details?.cached_tokens ?? reported.prompt_cache_hit_tokens;
|
|
115
|
-
if (isCount(hit))
|
|
116
|
-
cacheReport = hit;
|
|
117
|
-
const reasoned = reported.completion_tokens_details?.reasoning_tokens;
|
|
118
|
-
if (isCount(reasoned))
|
|
119
|
-
usage.reasoningTokens = reasoned;
|
|
120
|
-
}
|
|
121
|
-
const { timings } = chunk;
|
|
122
|
-
if (timings) {
|
|
123
|
-
for (const [from, to] of TIMINGS) {
|
|
124
|
-
const value = timings[from];
|
|
125
|
-
if (isCount(value))
|
|
126
|
-
usage[to] = value;
|
|
127
|
-
}
|
|
128
|
-
if (isCount(timings.cache_n))
|
|
129
|
-
cacheTimings = timings.cache_n;
|
|
130
|
-
}
|
|
288
|
+
const reported = chunkReport(chunk);
|
|
289
|
+
Object.assign(usage, reported.counts);
|
|
290
|
+
cacheReport = reported.cacheReport ?? cacheReport;
|
|
291
|
+
cacheTimings = reported.cacheTimings ?? cacheTimings;
|
|
131
292
|
// One choice, because that is what an agent loop asks for. A body with `n` above one
|
|
132
293
|
// keeps only the first; nothing here is built to reassemble several at once.
|
|
133
294
|
const choice = chunk.choices[0];
|
|
134
295
|
// Read before the delta guard rather than beside the content. The chunk that carries the
|
|
135
296
|
// reason usually carries an empty delta, and some servers send it with no delta at all —
|
|
136
297
|
// either of which the guard below skips, taking the reason with it.
|
|
137
|
-
if (choice?.finish_reason)
|
|
298
|
+
if (choice?.finish_reason) {
|
|
138
299
|
finishReason = choice.finish_reason;
|
|
300
|
+
}
|
|
139
301
|
const delta = choice?.delta;
|
|
140
|
-
if (!delta)
|
|
302
|
+
if (!delta) {
|
|
141
303
|
continue;
|
|
142
|
-
|
|
304
|
+
}
|
|
305
|
+
const thinking = delta.reasoning_content || delta.reasoning || '';
|
|
143
306
|
// Latched on what the chunk carried, not on its having arrived. Most OpenAI-compatible
|
|
144
307
|
// servers open a stream with a content-free `{"role":"assistant"}` before the first
|
|
145
308
|
// token; latching on that made an endpoint that primes and then wedges unrepeatable,
|
|
@@ -152,53 +315,35 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
|
|
|
152
315
|
rearm(true);
|
|
153
316
|
usage.firstTokenMs = Date.now() - started;
|
|
154
317
|
}
|
|
155
|
-
if (produced && carried)
|
|
318
|
+
if (produced && carried) {
|
|
156
319
|
produced.any = true;
|
|
320
|
+
}
|
|
157
321
|
if (thinking) {
|
|
158
322
|
reasoning.push(thinking);
|
|
159
323
|
onThinking?.(thinking);
|
|
160
324
|
}
|
|
161
|
-
if (delta.content)
|
|
325
|
+
if (delta.content) {
|
|
162
326
|
report(splitter.push(delta.content));
|
|
327
|
+
}
|
|
163
328
|
// Tool calls arrive in pieces, keyed by position: the id in one chunk, the name in
|
|
164
329
|
// another, the arguments spread across the next several.
|
|
165
330
|
for (const part of delta.tool_calls ?? []) {
|
|
166
|
-
|
|
167
|
-
if (
|
|
331
|
+
let call = ownerOf(calls, part);
|
|
332
|
+
if (!call) {
|
|
333
|
+
call = { index: fragmentIndex(part), id: '', name: '', arguments: '' };
|
|
334
|
+
calls.push(call);
|
|
335
|
+
}
|
|
336
|
+
if (part.id) {
|
|
168
337
|
call.id = part.id;
|
|
169
|
-
|
|
338
|
+
}
|
|
339
|
+
if (part.function?.name) {
|
|
170
340
|
call.name += part.function.name;
|
|
171
|
-
|
|
341
|
+
}
|
|
342
|
+
if (part.function?.arguments) {
|
|
172
343
|
call.arguments += part.function.arguments;
|
|
344
|
+
}
|
|
173
345
|
}
|
|
174
346
|
}
|
|
175
|
-
/**
|
|
176
|
-
* The call a fragment belongs to.
|
|
177
|
-
*
|
|
178
|
-
* By `index` where the server sent a number, which the SDK types as required and servers have
|
|
179
|
-
* nonetheless left out: keyed on `undefined`, every call joined into one whose name and
|
|
180
|
-
* arguments were all of theirs run together. Without one, by `id`; failing that, a fragment
|
|
181
|
-
* naming a function opens a call and a bare run of arguments continues the latest. A server
|
|
182
|
-
* that sends whole calls one per chunk at index `0` makes the same mistake the other way, so
|
|
183
|
-
* a different id, or a name after arguments have begun, opens a new call under that index.
|
|
184
|
-
*/
|
|
185
|
-
function fragmentOf(part) {
|
|
186
|
-
const index = typeof part.index === "number" ? part.index : undefined;
|
|
187
|
-
const name = part.function?.name;
|
|
188
|
-
const known = index !== undefined
|
|
189
|
-
? calls.findLast((call) => call.index === index)
|
|
190
|
-
: part.id
|
|
191
|
-
? calls.find((call) => call.id === part.id)
|
|
192
|
-
: name
|
|
193
|
-
? undefined
|
|
194
|
-
: calls.at(-1);
|
|
195
|
-
const another = known && ((part.id && known.id && part.id !== known.id) || (name && known.arguments));
|
|
196
|
-
if (known && !another)
|
|
197
|
-
return known;
|
|
198
|
-
const call = { index, id: "", name: "", arguments: "" };
|
|
199
|
-
calls.push(call);
|
|
200
|
-
return call;
|
|
201
|
-
}
|
|
202
347
|
// An aborted stream ends its iteration rather than throwing, so without this a turn cut off
|
|
203
348
|
// halfway — by the watchdog or by someone stopping the run — comes back looking like a
|
|
204
349
|
// complete one, and a truncated answer is recorded as the output. Nothing about the API
|
|
@@ -211,31 +356,16 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
|
|
|
211
356
|
const cached = cacheReport ?? cacheTimings;
|
|
212
357
|
if (cached !== undefined) {
|
|
213
358
|
usage.cached = cached;
|
|
214
|
-
if (usage.prompt > 0)
|
|
359
|
+
if (usage.prompt > 0) {
|
|
215
360
|
usage.uncached = Math.max(0, usage.prompt - cached);
|
|
361
|
+
}
|
|
216
362
|
}
|
|
217
|
-
const minted = new Set();
|
|
218
363
|
return {
|
|
219
364
|
content: splitter.output,
|
|
220
|
-
toolCalls: calls
|
|
221
|
-
.map((call, order) => ({ call, order }))
|
|
222
|
-
.sort((a, b) => (a.call.index ?? a.order) - (b.call.index ?? b.order) || a.order - b.order)
|
|
223
|
-
.map(({ call }, position) => {
|
|
224
|
-
// A server that streams a call without an id still needs one for the result to answer,
|
|
225
|
-
// and two calls it put under one index must not be answered as one.
|
|
226
|
-
let id = call.id || `call_${call.index ?? position}`;
|
|
227
|
-
if (minted.has(id))
|
|
228
|
-
id = `call_${position}_${minted.size}`;
|
|
229
|
-
minted.add(id);
|
|
230
|
-
return {
|
|
231
|
-
id,
|
|
232
|
-
type: "function",
|
|
233
|
-
function: { name: call.name, arguments: call.arguments },
|
|
234
|
-
};
|
|
235
|
-
}),
|
|
365
|
+
toolCalls: assembledCalls(calls),
|
|
236
366
|
usage,
|
|
237
367
|
finishReason,
|
|
238
|
-
reasoning: reasoning.join(
|
|
368
|
+
reasoning: reasoning.join('') + splitter.reasoning,
|
|
239
369
|
};
|
|
240
370
|
}
|
|
241
371
|
}
|