@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.
Files changed (71) hide show
  1. package/README.md +14 -9
  2. package/dist/agent-loop.d.ts +84 -204
  3. package/dist/agent-loop.js +416 -643
  4. package/dist/calibration.d.ts +14 -10
  5. package/dist/calibration.js +30 -22
  6. package/dist/capabilities.d.ts +140 -35
  7. package/dist/capabilities.js +345 -152
  8. package/dist/client.d.ts +104 -35
  9. package/dist/client.js +210 -78
  10. package/dist/compaction.d.ts +103 -60
  11. package/dist/compaction.js +163 -93
  12. package/dist/config.d.ts +48 -12
  13. package/dist/config.js +20 -1
  14. package/dist/continuation.d.ts +25 -17
  15. package/dist/continuation.js +70 -47
  16. package/dist/digest.d.ts +7 -0
  17. package/dist/digest.js +18 -0
  18. package/dist/errors.d.ts +24 -15
  19. package/dist/errors.js +23 -15
  20. package/dist/events.d.ts +108 -58
  21. package/dist/events.js +265 -128
  22. package/dist/guards.d.ts +53 -0
  23. package/dist/guards.js +55 -0
  24. package/dist/hook-events.d.ts +38 -0
  25. package/dist/hook-events.js +36 -0
  26. package/dist/hooks.d.ts +105 -83
  27. package/dist/hooks.js +224 -139
  28. package/dist/index.d.ts +30 -25
  29. package/dist/index.js +29 -23
  30. package/dist/ledger.d.ts +62 -30
  31. package/dist/ledger.js +163 -56
  32. package/dist/platform.d.ts +13 -0
  33. package/dist/platform.js +13 -0
  34. package/dist/preselect.d.ts +222 -0
  35. package/dist/preselect.js +290 -0
  36. package/dist/request-body.d.ts +32 -0
  37. package/dist/request-body.js +47 -0
  38. package/dist/reset.d.ts +1 -0
  39. package/dist/reset.js +7 -6
  40. package/dist/retry.d.ts +40 -143
  41. package/dist/retry.js +79 -285
  42. package/dist/run-calls.d.ts +123 -0
  43. package/dist/run-calls.js +351 -0
  44. package/dist/run-turn.d.ts +44 -16
  45. package/dist/run-turn.js +74 -37
  46. package/dist/runtime.d.ts +23 -17
  47. package/dist/runtime.js +34 -24
  48. package/dist/schema-compat.d.ts +51 -11
  49. package/dist/schema-compat.js +375 -193
  50. package/dist/scope.d.ts +28 -5
  51. package/dist/scope.js +37 -10
  52. package/dist/side-task.d.ts +127 -39
  53. package/dist/side-task.js +189 -100
  54. package/dist/snapshot.d.ts +12 -7
  55. package/dist/snapshot.js +87 -65
  56. package/dist/spec.d.ts +66 -36
  57. package/dist/spec.js +514 -265
  58. package/dist/stream.d.ts +73 -16
  59. package/dist/stream.js +228 -98
  60. package/dist/thinking.d.ts +23 -8
  61. package/dist/thinking.js +96 -46
  62. package/dist/tokens.d.ts +171 -6
  63. package/dist/tokens.js +326 -6
  64. package/dist/tool-calls.d.ts +89 -15
  65. package/dist/tool-calls.js +205 -85
  66. package/dist/tool-loading.d.ts +109 -187
  67. package/dist/tool-loading.js +286 -372
  68. package/dist/wire.d.ts +81 -0
  69. package/dist/wire.js +73 -0
  70. package/llms.txt +87 -43
  71. package/package.json +4 -4
package/dist/stream.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type OpenAI from "openai";
2
- import { type ContextBreakdown } from "./retry.ts";
3
- import { type Fence } from "./thinking.ts";
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
- * What the loop changed that would explain `cacheBroken`, the earliest in the prompt first,
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: OpenAI.ChatCompletionMessageFunctionToolCall[];
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, `idleMs` unless given; zero waits forever.
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`, `DEFAULT_FENCES` unless given.
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 { EndpointSilent } from "./retry.js";
2
- import { DEFAULT_FENCES, FenceSplitter } from "./thinking.js";
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
- ["prompt_ms", "promptMs"],
9
- ["prompt_per_second", "promptTokensPerSecond"],
10
- ["predicted_ms", "predictedMs"],
11
- ["predicted_per_second", "tokensPerSecond"],
12
- ["draft_n", "draftTotal"],
13
- ["draft_n_accepted", "draftAccepted"],
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
- const isCount = (value) => typeof value === "number" && Number.isFinite(value);
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 = 2 ** 31 - 1;
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 ? "" : " before its first token";
67
- throw new EndpointSilent(`the model endpoint sent nothing for ${(waited ?? 0) / 1000}s${before}`);
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 === "reasoning" ? onThinking : onOutput)?.(part.text);
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 = { prompt: 0, completion: 0, total: 0, cached: 0 };
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
- if (chunk.usage) {
110
- usage.prompt = chunk.usage.prompt_tokens ?? 0;
111
- usage.completion = chunk.usage.completion_tokens ?? 0;
112
- usage.total = chunk.usage.total_tokens ?? 0;
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
- const thinking = delta.reasoning_content || delta.reasoning || "";
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
- const call = fragmentOf(part);
167
- if (part.id)
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
- if (part.function?.name)
338
+ }
339
+ if (part.function?.name) {
170
340
  call.name += part.function.name;
171
- if (part.function?.arguments)
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("") + splitter.reasoning,
368
+ reasoning: reasoning.join('') + splitter.reasoning,
239
369
  };
240
370
  }
241
371
  }