@cubicecho/agent-core 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -10,14 +10,16 @@
10
10
  * that differs between one server and the next.
11
11
  */
12
12
  export { buildBody, preselect, preview, resolveApiKey, runAgentLoop, } from "./agent-loop.js";
13
+ export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.js";
13
14
  export { capabilitiesFor, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
14
15
  export { configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.js";
15
- export { COMPACT_AT, compactTranscript, KEEP_RATIO, planCompaction, pruneToolResults, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.js";
16
+ export { applyCompaction, COMPACT_AT, compactTranscript, KEEP_RATIO, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.js";
17
+ export { continueTurn, isContinuable, } from "./continuation.js";
16
18
  export { errorMessage } from "./errors.js";
17
- export { configureEvents, emit, endRun, fold, history, resetEvents, watch, } from "./events.js";
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
+ export { configureEvents, emit, endRun, fold, history, resetEvents, runMetrics, watch, } from "./events.js";
20
+ export { assembleContext, configureHooks, consult, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.js";
19
21
  export { resetAll } from "./reset.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";
22
+ export { backoffMs, CHARS_PER_TOKEN, ContextOverflow, compact, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, toolsChars, } from "./retry.js";
21
23
  export { runTurn } from "./run-turn.js";
22
24
  export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.js";
23
25
  export { ask, askJson, clean, listLines, parseJson, resetHints, tryAsk, } from "./side-task.js";
@@ -26,4 +28,4 @@ export { streamTurn, } from "./stream.js";
26
28
  export { ALL_FENCES, DEFAULT_FENCES, FenceSplitter, stripThinking, THINK_FENCE, } from "./thinking.js";
27
29
  export { estimateTokens } from "./tokens.js";
28
30
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, } from "./tool-calls.js";
29
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
31
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
package/dist/reset.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Forgets everything this package remembers between calls.
3
3
  *
4
- * Five modules here keep state for the life of the process, each for a good reason and each
4
+ * Six modules here keep state for the life of the process, each for a good reason and each
5
5
  * with its own seam: the pooled clients and their model listings, the endpoints that turned
6
- * out not to take `stream_options` or a grammar, the models that refused the no-thinking
7
- * hints, the event bus, and the hooks' configured budget and preface. `resetClients`,
8
- * `resetCapabilities`, `resetHints`, `resetEvents` and `resetHooks` stay exported, because a
9
- * test that means to clear one thing should say so.
6
+ * out not to take `stream_options` or a grammar, each model's measured characters per token,
7
+ * the models that refused the no-thinking hints, the event bus, and the hooks' configured
8
+ * budget and preface. `resetClients`, `resetCapabilities`, `resetCalibration`, `resetHints`,
9
+ * `resetEvents` and `resetHooks` stay exported, because a test that means to clear one thing
10
+ * should say so.
10
11
  *
11
12
  * This is for the other case, which is every teardown. What they hold is *latched
12
13
  * refusals* — a fact one test taught the process about an endpoint, still true as far as the
@@ -15,7 +16,7 @@
15
16
  * one that reads the latch fails only when it happens to run second. `tests/side-task-hints.test.ts`
16
17
  * was written that way and only passed because every case had been handed a hostname of its own.
17
18
  *
18
- * It is also the seam that does not need finding again. A sixth module with a cache is a sixth
19
+ * It is also the seam that does not need finding again. A seventh module with a cache is a seventh
19
20
  * line here, rather than an edit to the teardown of three consumers who will not all notice.
20
21
  */
21
22
  export declare function resetAll(): void;
package/dist/reset.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { resetCalibration } from "./calibration.js";
1
2
  import { resetCapabilities } from "./capabilities.js";
2
3
  import { resetClients } from "./client.js";
3
4
  import { resetEvents } from "./events.js";
@@ -6,12 +7,13 @@ import { resetHints } from "./side-task.js";
6
7
  /**
7
8
  * Forgets everything this package remembers between calls.
8
9
  *
9
- * Five modules here keep state for the life of the process, each for a good reason and each
10
+ * Six modules here keep state for the life of the process, each for a good reason and each
10
11
  * with its own seam: the pooled clients and their model listings, the endpoints that turned
11
- * out not to take `stream_options` or a grammar, the models that refused the no-thinking
12
- * hints, the event bus, and the hooks' configured budget and preface. `resetClients`,
13
- * `resetCapabilities`, `resetHints`, `resetEvents` and `resetHooks` stay exported, because a
14
- * test that means to clear one thing should say so.
12
+ * out not to take `stream_options` or a grammar, each model's measured characters per token,
13
+ * the models that refused the no-thinking hints, the event bus, and the hooks' configured
14
+ * budget and preface. `resetClients`, `resetCapabilities`, `resetCalibration`, `resetHints`,
15
+ * `resetEvents` and `resetHooks` stay exported, because a test that means to clear one thing
16
+ * should say so.
15
17
  *
16
18
  * This is for the other case, which is every teardown. What they hold is *latched
17
19
  * refusals* — a fact one test taught the process about an endpoint, still true as far as the
@@ -20,12 +22,13 @@ import { resetHints } from "./side-task.js";
20
22
  * one that reads the latch fails only when it happens to run second. `tests/side-task-hints.test.ts`
21
23
  * was written that way and only passed because every case had been handed a hostname of its own.
22
24
  *
23
- * It is also the seam that does not need finding again. A sixth module with a cache is a sixth
25
+ * It is also the seam that does not need finding again. A seventh module with a cache is a seventh
24
26
  * line here, rather than an edit to the teardown of three consumers who will not all notice.
25
27
  */
26
28
  export function resetAll() {
27
29
  resetClients();
28
30
  resetCapabilities();
31
+ resetCalibration();
29
32
  resetHints();
30
33
  resetEvents();
31
34
  resetHooks();
package/dist/retry.d.ts CHANGED
@@ -29,10 +29,41 @@ export declare class ContextOverflow extends Error {
29
29
  * @param tokens The count to render.
30
30
  */
31
31
  export declare const compact: (tokens: number) => string;
32
+ /**
33
+ * The divisor behind `estimateTokens`, applied here to a character count rather than a string.
34
+ *
35
+ * The fallback, not the rule: once a turn has come back with a reported prompt count, `runTurn`
36
+ * divides by what that endpoint's model was measured at instead. See `charsPerTokenFor`.
37
+ */
38
+ export declare const CHARS_PER_TOKEN = 4;
39
+ /** What the two token estimates below take besides what they measure. */
40
+ export interface TokenEstimateOptions {
41
+ /**
42
+ * The divisor, `CHARS_PER_TOKEN` unless given — `charsPerTokenFor` for a model whose reported
43
+ * usage has calibrated it. A value that is not a number above zero is ignored.
44
+ */
45
+ charsPerToken?: number;
46
+ }
47
+ /**
48
+ * How many characters a tool array is worth, measured once per array.
49
+ *
50
+ * @param tools The tool definitions as they will be sent. An empty array is worth nothing.
51
+ */
52
+ export declare function toolsChars(tools: OpenAI.ChatCompletionTool[]): number;
53
+ /**
54
+ * How many characters a request is worth: the walk `requestTokens` divides, without the division.
55
+ *
56
+ * What calibration reads a reported prompt count against, since a ratio is only as good as the
57
+ * character count it was taken over agreeing with the one it is later applied to.
58
+ *
59
+ * @param body The request as it was sent, tools included.
60
+ */
61
+ export declare function requestChars(body: OpenAI.ChatCompletionCreateParamsStreaming): number;
32
62
  /**
33
63
  * What this request will cost the window, in tokens, near enough.
34
64
  *
35
- * See `estimateTokens` for why it is characters over four and which way it is wrong on purpose.
65
+ * See `estimateTokens` for why it is characters over four and which way it is wrong on purpose,
66
+ * and `charsPerTokenFor` for the divisor a model's own reported usage has measured instead.
36
67
  *
37
68
  * Summed by walking the body rather than by serialising it. `JSON.stringify` on the messages
38
69
  * built the entire transcript into a string on every call and threw it away having read nothing
@@ -45,8 +76,9 @@ export declare const compact: (tokens: number) => string;
45
76
  * over four.
46
77
  *
47
78
  * @param body The request as it will be sent, tools included.
79
+ * @param options The divisor, `CHARS_PER_TOKEN` when none is given.
48
80
  */
49
- export declare const requestTokens: (body: OpenAI.ChatCompletionCreateParamsStreaming) => number;
81
+ export declare const requestTokens: (body: OpenAI.ChatCompletionCreateParamsStreaming, { charsPerToken }?: TokenEstimateOptions) => number;
50
82
  /**
51
83
  * One message's estimated tokens, by the same count `requestTokens` sums for a whole request.
52
84
  *
@@ -54,8 +86,9 @@ export declare const requestTokens: (body: OpenAI.ChatCompletionCreateParamsStre
54
86
  * tail — where `estimateTokens` on the text alone would leave out the calls and the envelope.
55
87
  *
56
88
  * @param message The message as it will be sent.
89
+ * @param options The divisor, `CHARS_PER_TOKEN` when none is given.
57
90
  */
58
- export declare const messageTokens: (message: OpenAI.ChatCompletionMessageParam) => number;
91
+ export declare const messageTokens: (message: OpenAI.ChatCompletionMessageParam, { charsPerToken }?: TokenEstimateOptions) => number;
59
92
  /**
60
93
  * Whether a refusal means the request was too big, rather than merely refused.
61
94
  *
package/dist/retry.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import OpenAI from "openai";
2
- import { estimateTokens } from "./tokens.js";
3
2
  /**
4
3
  * Everything about a request failing that is not about what the request said.
5
4
  *
@@ -63,8 +62,15 @@ const REFUSAL_PART = 32;
63
62
  const REASONING_KEY = 23;
64
63
  /** The same for `"reasoning":"",`, OpenRouter's spelling of it. */
65
64
  const REASONING_ALT_KEY = 15;
66
- /** The divisor behind `estimateTokens`, applied here to a character count rather than a string. */
67
- const CHARS_PER_TOKEN = 4;
65
+ /**
66
+ * The divisor behind `estimateTokens`, applied here to a character count rather than a string.
67
+ *
68
+ * The fallback, not the rule: once a turn has come back with a reported prompt count, `runTurn`
69
+ * divides by what that endpoint's model was measured at instead. See `charsPerTokenFor`.
70
+ */
71
+ export const CHARS_PER_TOKEN = 4;
72
+ /** The divisor an option asked for, or the fallback when it asked for nothing usable. */
73
+ const divisor = (charsPerToken) => charsPerToken !== undefined && charsPerToken > 0 ? charsPerToken : CHARS_PER_TOKEN;
68
74
  /** How many characters one message is worth: its keys, and its content in whichever shape. */
69
75
  function messageChars(message) {
70
76
  let chars = message.role.length + ENVELOPE;
@@ -114,22 +120,47 @@ function messageChars(message) {
114
120
  * the array-level memoisation `tests/retry.test.ts` pins, which deliberately holds a mutated array
115
121
  * to its first reading. Sizing happens once per turn either way, so the miss costs one walk of the
116
122
  * schemas rather than a walk per attempt.
123
+ *
124
+ * Characters rather than tokens, so a calibrated divisor applies to the schemas without walking
125
+ * them again.
126
+ */
127
+ const toolLengths = new WeakMap();
128
+ /**
129
+ * How many characters a tool array is worth, measured once per array.
130
+ *
131
+ * @param tools The tool definitions as they will be sent. An empty array is worth nothing.
117
132
  */
118
- const toolTokens = new WeakMap();
119
- function toolsCost(tools) {
120
- const hit = toolTokens.get(tools);
133
+ export function toolsChars(tools) {
134
+ if (!tools.length)
135
+ return 0;
136
+ const hit = toolLengths.get(tools);
121
137
  if (hit !== undefined)
122
138
  return hit;
123
139
  // Schemas are arbitrarily shaped, so this one really is a serialisation — but it happens once
124
140
  // per tool array rather than once per turn.
125
- const cost = estimateTokens(JSON.stringify(tools));
126
- toolTokens.set(tools, cost);
127
- return cost;
141
+ const length = JSON.stringify(tools).length;
142
+ toolLengths.set(tools, length);
143
+ return length;
144
+ }
145
+ /**
146
+ * How many characters a request is worth: the walk `requestTokens` divides, without the division.
147
+ *
148
+ * What calibration reads a reported prompt count against, since a ratio is only as good as the
149
+ * character count it was taken over agreeing with the one it is later applied to.
150
+ *
151
+ * @param body The request as it was sent, tools included.
152
+ */
153
+ export function requestChars(body) {
154
+ let chars = 0;
155
+ for (const message of body.messages)
156
+ chars += messageChars(message);
157
+ return chars;
128
158
  }
129
159
  /**
130
160
  * What this request will cost the window, in tokens, near enough.
131
161
  *
132
- * See `estimateTokens` for why it is characters over four and which way it is wrong on purpose.
162
+ * See `estimateTokens` for why it is characters over four and which way it is wrong on purpose,
163
+ * and `charsPerTokenFor` for the divisor a model's own reported usage has measured instead.
133
164
  *
134
165
  * Summed by walking the body rather than by serialising it. `JSON.stringify` on the messages
135
166
  * built the entire transcript into a string on every call and threw it away having read nothing
@@ -142,14 +173,15 @@ function toolsCost(tools) {
142
173
  * over four.
143
174
  *
144
175
  * @param body The request as it will be sent, tools included.
176
+ * @param options The divisor, `CHARS_PER_TOKEN` when none is given.
145
177
  */
146
- export const requestTokens = (body) => {
178
+ export const requestTokens = (body, { charsPerToken } = {}) => {
147
179
  // Characters first and the division once at the end, rather than a rounded count per message:
148
180
  // `Math.ceil` on every one of a few hundred messages is a few hundred tokens of pure rounding.
149
- let chars = 0;
150
- for (const message of body.messages)
151
- chars += messageChars(message);
152
- return Math.ceil(chars / CHARS_PER_TOKEN) + (body.tools?.length ? toolsCost(body.tools) : 0);
181
+ // The tools are divided on their own, as they were when their tokens were cached, so the
182
+ // uncalibrated count is the one it always was.
183
+ const per = divisor(charsPerToken);
184
+ return Math.ceil(requestChars(body) / per) + Math.ceil(toolsChars(body.tools ?? []) / per);
153
185
  };
154
186
  /**
155
187
  * One message's estimated tokens, by the same count `requestTokens` sums for a whole request.
@@ -158,8 +190,9 @@ export const requestTokens = (body) => {
158
190
  * tail — where `estimateTokens` on the text alone would leave out the calls and the envelope.
159
191
  *
160
192
  * @param message The message as it will be sent.
193
+ * @param options The divisor, `CHARS_PER_TOKEN` when none is given.
161
194
  */
162
- export const messageTokens = (message) => Math.ceil(messageChars(message) / CHARS_PER_TOKEN);
195
+ export const messageTokens = (message, { charsPerToken } = {}) => Math.ceil(messageChars(message) / divisor(charsPerToken));
163
196
  /**
164
197
  * Servers refuse an over-long request in their own words; these are the ones worth reading as
165
198
  * that rather than as a broken request. Matched loosely — every one of them is some
@@ -22,6 +22,10 @@ import { type StreamTurnOptions, type Turn } from "./stream.ts";
22
22
  * attempt would say everything twice. That is what `produced` is, one box per attempt — set by
23
23
  * a chunk that carried something rather than by a chunk arriving, so the empty opening chunk
24
24
  * most servers send does not cost the retry.
25
+ *
26
+ * What the turn cost in attempts comes back on its usage — `wallMs`, `retries`, `timeouts` — and
27
+ * what its prompt was reported at calibrates the ratio the next request to that model is sized
28
+ * with. See `calibrate`.
25
29
  */
26
30
  /** A retry is not the same event as a downgrade, but a watcher wants to be told about both. */
27
31
  export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
package/dist/run-turn.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { calibrate, charsPerTokenFor } from "./calibration.js";
1
2
  import { negotiate } from "./capabilities.js";
2
3
  import { errorMessage } from "./errors.js";
3
- import { backoffMs, ContextOverflow, compact, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
4
+ import { backoffMs, ContextOverflow, compact, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
4
5
  import { streamTurn } from "./stream.js";
5
6
  /**
6
7
  * `request` is a callback rather than a body because the body has to be rebuilt from whatever
@@ -21,11 +22,16 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
21
22
  // does not change between attempts — so the first body is the one worth measuring, and
22
23
  // measuring the rest would only spend the walk again to reach the same answer.
23
24
  let sized = false;
25
+ // The body the answer was given to, for the calibration once it is in hand.
26
+ let sent;
24
27
  const measured = (capabilities, forModel) => {
25
28
  const body = request(capabilities, forModel);
29
+ sent = body;
26
30
  if (!sized && contextLimit >= SMALLEST_LIKELY_WINDOW) {
27
31
  sized = true;
28
- const needed = requestTokens(body);
32
+ const needed = requestTokens(body, {
33
+ charsPerToken: charsPerTokenFor(supports, body.model),
34
+ });
29
35
  // The endpoint refuses on the prompt plus the reply — llama.cpp sizes the slot with
30
36
  // `n_predict` in, OpenAI with the ceiling — so a prompt that fits the window but not the
31
37
  // window less the ceiling was let through here to be refused one round trip later, which
@@ -45,10 +51,17 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
45
51
  // When the server first said it was loading. Unset until then, and never reset: a model that
46
52
  // loads, fails and loads again has had its allowance.
47
53
  let loadingSince;
54
+ const started = Date.now();
55
+ let retries = 0;
56
+ let timeouts = 0;
48
57
  for (let attempt = 0;; attempt++) {
49
58
  const produced = { any: false };
50
59
  try {
51
- return await negotiate(supports, (capabilities, box, forModel) => streamTurn(client, measured(capabilities, forModel), { ...stream, produced: box }), { produced, onNotice, model, droppable });
60
+ const turn = await negotiate(supports, (capabilities, box, forModel) => streamTurn(client, measured(capabilities, forModel), { ...stream, produced: box }), { produced, onNotice, model, droppable });
61
+ if (sent)
62
+ calibrate(supports, sent, turn.usage.prompt);
63
+ Object.assign(turn.usage, { wallMs: Date.now() - started, retries, timeouts });
64
+ return turn;
52
65
  }
53
66
  catch (error) {
54
67
  // The abort is read before the classification, not after. A run stopped by its operator
@@ -85,6 +98,9 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
85
98
  }
86
99
  if (attempt >= maxRetries || !isTransient(error))
87
100
  throw error;
101
+ retries++;
102
+ if (error instanceof EndpointSilent)
103
+ timeouts++;
88
104
  const wait = backoffMs(attempt);
89
105
  // Reported in whatever unit reads as a number: the first backoff is under a second, and
90
106
  // "retrying in 0s" is what rounding it to seconds says.
@@ -15,6 +15,11 @@ export interface ModelSnapshot {
15
15
  chosenTemperature: boolean;
16
16
  refusedFields: string[];
17
17
  structuredOutput: boolean;
18
+ /**
19
+ * Continues a trailing assistant message. Absent in a snapshot taken before it was latched, which
20
+ * reads as not refused.
21
+ */
22
+ assistantPrefill: boolean;
18
23
  /** Takes the no-thinking hints `ask` sends. */
19
24
  thinkingHints: boolean;
20
25
  }
package/dist/snapshot.js CHANGED
@@ -16,6 +16,7 @@ const optimisticModel = () => ({
16
16
  chosenTemperature: true,
17
17
  refusedFields: [],
18
18
  structuredOutput: true,
19
+ assistantPrefill: true,
19
20
  thinkingHints: true,
20
21
  });
21
22
  const refusedAnything = (model) => !model.reasoningEffort ||
@@ -23,6 +24,7 @@ const refusedAnything = (model) => !model.reasoningEffort ||
23
24
  !model.chosenTemperature ||
24
25
  !model.thinkingHints ||
25
26
  !model.structuredOutput ||
27
+ !model.assistantPrefill ||
26
28
  model.refusedFields.length > 0;
27
29
  /**
28
30
  * Every refusal this process has latched, as a JSON-safe blob to store and hand back on boot.
@@ -48,6 +50,7 @@ export function exportCapabilities() {
48
50
  chosenTemperature: refused.chosenTemperature,
49
51
  refusedFields: [...refused.refusedFields].sort(),
50
52
  structuredOutput: refused.structuredOutput,
53
+ assistantPrefill: refused.assistantPrefill,
51
54
  };
52
55
  if (refusedAnything(model))
53
56
  models[name] = model;
@@ -109,6 +112,8 @@ export function importCapabilities(snapshot) {
109
112
  refused.chosenTemperature = false;
110
113
  if (model.structuredOutput === false)
111
114
  refused.structuredOutput = false;
115
+ if (model.assistantPrefill === false)
116
+ refused.assistantPrefill = false;
112
117
  if (Array.isArray(model.refusedFields)) {
113
118
  for (const field of model.refusedFields) {
114
119
  if (typeof field === "string")
package/dist/stream.d.ts CHANGED
@@ -10,7 +10,16 @@ import { type Fence } from "./thinking.ts";
10
10
  * hangs the turn until somebody presses stop. `EndpointSilent` and `timeoutMs` were exported for
11
11
  * this loop long before the loop itself was.
12
12
  */
13
- /** What a turn cost. Zero throughout means the server did not say. */
13
+ /**
14
+ * What a turn cost, and how it went. Zero throughout the four counts means the server did not say.
15
+ *
16
+ * The four counts are always there, as they always were. Everything after them is absent rather
17
+ * than zero when nothing reported or measured it, because a zero cache hit and a server that says
18
+ * nothing about its cache are different findings, and a consumer drawing one as the other is
19
+ * explaining a slow turn with a number nobody sent. Which layer fills each is said on the field:
20
+ * `streamTurn` reads what the endpoint sends, `runTurn` measures the attempts, and
21
+ * `runAgentLoop` compares one request with the one before it.
22
+ */
14
23
  export interface TurnUsage {
15
24
  prompt: number;
16
25
  completion: number;
@@ -20,9 +29,72 @@ export interface TurnUsage {
20
29
  *
21
30
  * The only way a caller can tell whether the prefix it is careful to keep still is actually
22
31
  * being reused: a prefix that stops hitting the cache otherwise shows up as a bill and nothing
23
- * else. Zero is also what a server that does not report it sends.
32
+ * else. Zero is also what a server that does not report it sends; `uncached` is the field whose
33
+ * presence says the count was reported. llama.cpp's `timings.cache_n` stands in for it where the
34
+ * usage block has none.
24
35
  */
25
36
  cached: number;
37
+ /**
38
+ * `prompt` less `cached`: the tokens actually prefilled, and the number a cache miss moves.
39
+ * Present only where both a cache count and the prompt were reported, so its absence is how a
40
+ * zero `cached` reads as unknown.
41
+ */
42
+ uncached?: number;
43
+ /** How much of `completion` was reasoning, where the usage block breaks it out. Never estimated. */
44
+ reasoningTokens?: number;
45
+ /** Prefill time, from llama.cpp's `timings`. On a local box it dominates once the cache misses. */
46
+ promptMs?: number;
47
+ /** Prefill speed, from the same `timings`. */
48
+ promptTokensPerSecond?: number;
49
+ /** Decode time, from the same `timings`. */
50
+ predictedMs?: number;
51
+ /** Decode speed, from the same `timings`. */
52
+ tokensPerSecond?: number;
53
+ /**
54
+ * Tokens a draft model or MTP head proposed, from the same `timings`. Against `draftAccepted`,
55
+ * whether speculative decoding is paying for itself.
56
+ */
57
+ draftTotal?: number;
58
+ /** How many of `draftTotal` the target model kept. */
59
+ draftAccepted?: number;
60
+ /**
61
+ * From sending the request to the first chunk that carried anything, measured by `streamTurn`.
62
+ * The latency a watcher feels, and absent on a turn that said nothing at all.
63
+ */
64
+ firstTokenMs?: number;
65
+ /** The whole turn as `runTurn` saw it, queue, retries, downgrades and loading wait included. */
66
+ wallMs?: number;
67
+ /** How many times `runTurn` sent a lost request again. A downgrade is not a retry. */
68
+ retries?: number;
69
+ /** How many of those were the endpoint going silent, rather than refusing or dropping it. */
70
+ timeouts?: number;
71
+ /** How many requests `continueTurn` joined onto the first. Absent on a turn not continued. */
72
+ continuations?: number;
73
+ /**
74
+ * What this request should have reused of the one before it in the loop: that request's prompt
75
+ * and its reply, which is what a cache holds once the reply is generated. Filled by
76
+ * `runAgentLoop` from the second request on, where the previous prompt was reported.
77
+ */
78
+ cacheExpected?: number;
79
+ /**
80
+ * `cached` came in under nine tenths of the previous request's prompt — the part of
81
+ * `cacheExpected` no re-rendering of the reply can disturb. Present only where both sides were
82
+ * reported, so a server that says nothing about its cache is never accused of losing it.
83
+ */
84
+ cacheBroken?: boolean;
85
+ /**
86
+ * What the loop changed that would explain `cacheBroken`, the earliest in the prompt first,
87
+ * since a change there is the one that costs the rest. `none-known` is the server's doing — an
88
+ * eviction, another client on the slot — or a change the loop cannot see. Only on a broken turn.
89
+ */
90
+ cacheBreakReason?: "tools-changed" | "system-changed" | "history-rewritten" | "none-known";
91
+ /** How many tools the request declared, filled by `runAgentLoop`. */
92
+ toolsDeclared?: number;
93
+ /**
94
+ * The declared tool block's estimated tokens. A chat template renders it ahead of the system
95
+ * prompt, so this is what every change to the tool list costs the cache.
96
+ */
97
+ toolSchemaTokens?: number;
26
98
  }
27
99
  /** One streamed turn, put back together into the shape a loop and a transcript work with. */
28
100
  export interface Turn {
package/dist/stream.js CHANGED
@@ -1,5 +1,18 @@
1
1
  import { EndpointSilent } from "./retry.js";
2
2
  import { DEFAULT_FENCES, FenceSplitter } from "./thinking.js";
3
+ /**
4
+ * The fields of `timings` a turn reports, under the names `TurnUsage` gives them. A value that is
5
+ * not a finite number is left out rather than guessed at.
6
+ */
7
+ 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"],
14
+ ];
15
+ const isCount = (value) => typeof value === "number" && Number.isFinite(value);
3
16
  /** The largest delay a timer takes, which is as close to none as the SDK's timeout option goes. */
4
17
  const NO_SDK_TIMEOUT = 2 ** 31 - 1;
5
18
  /**
@@ -40,7 +53,8 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
40
53
  };
41
54
  try {
42
55
  rearm(false);
43
- return await collect();
56
+ const started = Date.now();
57
+ return await collect(started);
44
58
  }
45
59
  catch (error) {
46
60
  // The caller's own stop has to stay distinguishable from ours: one is a run that was called
@@ -57,7 +71,7 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
57
71
  finally {
58
72
  clearTimeout(idle);
59
73
  }
60
- async function collect() {
74
+ async function collect(started) {
61
75
  // The SDK's own timer runs until the headers arrive, which for a stream is the end of
62
76
  // prefill, and it was set from the idle number. Where a watchdog is armed it covers that
63
77
  // wait already, with the allowance meant for it, so the SDK is told to leave it alone.
@@ -78,6 +92,10 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
78
92
  // its place in the order they arrived.
79
93
  const calls = [];
80
94
  const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
95
+ // The usage block's own cache count wins over llama.cpp's, which is the same number in the
96
+ // servers that send both; kept apart so the order the two arrive in does not decide.
97
+ let cacheReport;
98
+ let cacheTimings;
81
99
  let finishReason = "";
82
100
  for await (const chunk of stream) {
83
101
  // Rearmed on every chunk, latched below on only some: a priming chunk is the endpoint
@@ -93,8 +111,22 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
93
111
  usage.completion = chunk.usage.completion_tokens ?? 0;
94
112
  usage.total = chunk.usage.total_tokens ?? 0;
95
113
  const reported = chunk.usage;
96
- usage.cached =
97
- reported.prompt_tokens_details?.cached_tokens ?? reported.prompt_cache_hit_tokens ?? 0;
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;
98
130
  }
99
131
  // One choice, because that is what an agent loop asks for. A body with `n` above one
100
132
  // keeps only the first; nothing here is built to reassemble several at once.
@@ -116,8 +148,10 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
116
148
  // has accumulated, and losing a retry is the safer half of that trade. Set before the
117
149
  // callbacks, so a watcher that throws mid-token cannot be told the same token twice.
118
150
  const carried = Boolean(thinking || delta.content || delta.tool_calls?.length);
119
- if (carried && !talking)
151
+ if (carried && !talking) {
120
152
  rearm(true);
153
+ usage.firstTokenMs = Date.now() - started;
154
+ }
121
155
  if (produced && carried)
122
156
  produced.any = true;
123
157
  if (thinking) {
@@ -174,6 +208,12 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
174
208
  // cut off at the ceiling mid-scratchpad, it has no answer, and promoting the deliberation to
175
209
  // one is how a truncated turn gets recorded as output.
176
210
  report(splitter.finish());
211
+ const cached = cacheReport ?? cacheTimings;
212
+ if (cached !== undefined) {
213
+ usage.cached = cached;
214
+ if (usage.prompt > 0)
215
+ usage.uncached = Math.max(0, usage.prompt - cached);
216
+ }
177
217
  const minted = new Set();
178
218
  return {
179
219
  content: splitter.output,
@@ -56,16 +56,41 @@ export declare function catalogPrompt(catalog: CatalogServer[], loaded?: Readonl
56
56
  /**
57
57
  * A tool array with newly loaded definitions appended, in the order they were loaded.
58
58
  *
59
- * Never re-sorted and never rebuilt from a set. A template renders the tool array into the
60
- * prompt near its head, and a load that moved an earlier definition moved everything after it,
61
- * so the cache was lost from there on every load; appended, the definitions already sent stay
62
- * a prefix of the new array.
59
+ * Appended and never rebuilt from a set, so what a load adds is decided by the load and not by
60
+ * the shape of whatever collection the definitions came out of. Where the appended array ends up
61
+ * in the request is `orderTools`' business, which the loop applies after this.
63
62
  *
64
63
  * @param previous What the last request declared, `load_tools` included. Not written to.
65
64
  * @param matched The definitions to add. Ones whose name is already declared, here or earlier in
66
65
  * this list, are skipped rather than moved.
67
66
  */
68
67
  export declare function loadedTools(previous: readonly OpenAI.ChatCompletionTool[], matched: readonly OpenAI.ChatCompletionTool[]): OpenAI.ChatCompletionTool[];
68
+ /**
69
+ * How a tool array is ordered before it is sent: `true` by name, `false` as the caller built it,
70
+ * or a comparator over the two names.
71
+ */
72
+ export type ToolOrder = boolean | ((a: string, b: string) => number);
73
+ /**
74
+ * The tool array in a stable order, so the same set of tools renders the same way twice.
75
+ *
76
+ * A chat template renders the declared tools ahead of the system prompt, which makes the tool
77
+ * array the first thing a prompt cache has to match — and an array assembled from a map, from
78
+ * database rows, or from the order servers happened to connect in changes between processes and
79
+ * between reconnects. Every such change costs the cache for the whole transcript rather than for
80
+ * the tools alone, and nothing about the request the model sees is different. Ordering by name
81
+ * makes the array a property of the set instead of of how it was built, at the price of a load
82
+ * inserting rather than appending. Definitions come back by identity, so `sanitizeTools` still
83
+ * finds each one in its cache.
84
+ *
85
+ * `false` is for a caller that means its order: the model reads the array top to bottom, and a
86
+ * host may be putting what it wants reached for first at the front.
87
+ *
88
+ * @param tools The definitions to order. Not written to.
89
+ * @param order `true` for name order, `false` to leave it alone, or a comparator over the names.
90
+ * A tool that is not a function orders as the empty name.
91
+ * @returns `tools` itself when it is already in that order, so the common case copies nothing.
92
+ */
93
+ export declare function orderTools(tools: OpenAI.ChatCompletionTool[], order?: ToolOrder): OpenAI.ChatCompletionTool[];
69
94
  /**
70
95
  * The most a single `load_tools` call may pull in.
71
96
  *