@cubicecho/agent-core 2.10.0 → 2.12.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/hooks.d.ts CHANGED
@@ -89,6 +89,12 @@ export interface HookOutcome {
89
89
  inject: boolean;
90
90
  /** The most of `text` that is injected, in estimated tokens. */
91
91
  maxTokens: number;
92
+ /**
93
+ * Asks that what the event announces not happen. Only a `beforeCompact` hook's is read, and
94
+ * only by a host that waits for it — see `consult` — and never on an outcome that is not `ok`,
95
+ * since a hook that crashed has not said anything.
96
+ */
97
+ veto?: boolean;
92
98
  }
93
99
  /**
94
100
  * One hook's line for whoever is watching: the context it added, or why it added none. A hook
@@ -105,6 +111,8 @@ export interface HookNote {
105
111
  text?: string;
106
112
  /** Why it added nothing: it failed, timed out, or never ran. */
107
113
  error?: string;
114
+ /** Set when the hook vetoed what the event announced, and was waited for. See `consult`. */
115
+ veto?: true;
108
116
  }
109
117
  /**
110
118
  * Runs one event's hooks. Should resolve rather than reject — a hook failing is an outcome — but
@@ -297,7 +305,8 @@ export declare function gather(run: HookRunner, events: readonly HookEvent[], co
297
305
  * rejects, so a host can fire it without awaiting it.
298
306
  *
299
307
  * No signal: these run once the turn has been answered, and a reader who stops listening at that
300
- * point has not asked for the turn not to be remembered.
308
+ * point has not asked for the turn not to be remembered. Nor is a `veto` read, since whatever it
309
+ * would stop is already under way; `consult` is the one that waits for it.
301
310
  *
302
311
  * @param run Runs the event's hooks.
303
312
  * @param event `afterTurn`, `beforeCompact`, `sessionEnd` or `sessionDelete`. An injecting event
@@ -307,3 +316,22 @@ export declare function gather(run: HookRunner, events: readonly HookEvent[], co
307
316
  * @returns The same notes.
308
317
  */
309
318
  export declare function notify(run: HookRunner, event: HookEvent, context: HookContext, onNote?: (note: HookNote) => void): Promise<HookNote[]>;
319
+ /**
320
+ * Runs an event's hooks and waits for their say, for a host that will hold off when one of them
321
+ * vetoes.
322
+ *
323
+ * `notify` runs beside the thing it announces and cannot stop it; this runs ahead of it, so each
324
+ * hook's time is added to whatever waits on the answer. A host that does not mean to act on a
325
+ * veto should call `notify` instead. Never rejects: a runner that throws is noted as a failure,
326
+ * and a failure is not a veto — a memory server that is down has not asked for anything.
327
+ *
328
+ * @param run Runs the event's hooks.
329
+ * @param event What is about to happen. Only `beforeCompact` has anything a veto can stop.
330
+ * @param context What the hooks are told.
331
+ * @param onNote Hears each note: every failure, and every veto, naming the hook that made it.
332
+ * @returns The same notes, and `vetoed` when any `ok` outcome carried `veto`.
333
+ */
334
+ export declare function consult(run: HookRunner, event: HookEvent, context: HookContext, onNote?: (note: HookNote) => void): Promise<{
335
+ notes: HookNote[];
336
+ vetoed: boolean;
337
+ }>;
package/dist/hooks.js CHANGED
@@ -279,7 +279,8 @@ export async function gather(run, events, context, { signal, onNote, maxTokens,
279
279
  * rejects, so a host can fire it without awaiting it.
280
280
  *
281
281
  * No signal: these run once the turn has been answered, and a reader who stops listening at that
282
- * point has not asked for the turn not to be remembered.
282
+ * point has not asked for the turn not to be remembered. Nor is a `veto` read, since whatever it
283
+ * would stop is already under way; `consult` is the one that waits for it.
283
284
  *
284
285
  * @param run Runs the event's hooks.
285
286
  * @param event `afterTurn`, `beforeCompact`, `sessionEnd` or `sessionDelete`. An injecting event
@@ -297,3 +298,37 @@ export async function notify(run, event, context, onNote) {
297
298
  onNote?.(note);
298
299
  return notes;
299
300
  }
301
+ /**
302
+ * Runs an event's hooks and waits for their say, for a host that will hold off when one of them
303
+ * vetoes.
304
+ *
305
+ * `notify` runs beside the thing it announces and cannot stop it; this runs ahead of it, so each
306
+ * hook's time is added to whatever waits on the answer. A host that does not mean to act on a
307
+ * veto should call `notify` instead. Never rejects: a runner that throws is noted as a failure,
308
+ * and a failure is not a veto — a memory server that is down has not asked for anything.
309
+ *
310
+ * @param run Runs the event's hooks.
311
+ * @param event What is about to happen. Only `beforeCompact` has anything a veto can stop.
312
+ * @param context What the hooks are told.
313
+ * @param onNote Hears each note: every failure, and every veto, naming the hook that made it.
314
+ * @returns The same notes, and `vetoed` when any `ok` outcome carried `veto`.
315
+ */
316
+ export async function consult(run, event, context, onNote) {
317
+ const outcomes = await runSafely(run, event, context);
318
+ const notes = [];
319
+ for (const outcome of outcomes) {
320
+ if (!outcome.ok)
321
+ notes.push(assembleContext([outcome]).notes[0]);
322
+ else if (outcome.veto === true) {
323
+ notes.push({
324
+ event: outcome.event,
325
+ source: outcome.label,
326
+ hookId: outcome.hookId,
327
+ veto: true,
328
+ });
329
+ }
330
+ }
331
+ for (const note of notes)
332
+ onNote?.(note);
333
+ return { notes, vetoed: notes.some((note) => note.veto) };
334
+ }
package/dist/index.d.ts CHANGED
@@ -10,19 +10,21 @@
10
10
  * that differs between one server and the next.
11
11
  */
12
12
  export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type ToolCallOutcome, type ToolCallRequest, } from "./agent-loop.ts";
13
+ export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.ts";
13
14
  export { type Capabilities, capabilitiesFor, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
14
15
  export type { CatalogServer } from "./catalog.ts";
15
16
  export { type ClientPoolOptions, configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, type ModelInfo, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.ts";
16
17
  export { COMPACT_AT, type CompactionOptions, type CompactionPlan, compactTranscript, KEEP_RATIO, type PruneOptions, planCompaction, pruneToolResults, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.ts";
17
18
  export type { AgentConfig, Endpoint, ModelParams, RetryPolicy, ToolPolicy, } from "./config.ts";
19
+ export { type ContinueTurnOptions, continueTurn, isContinuable, } from "./continuation.ts";
18
20
  export { errorMessage } from "./errors.ts";
19
- export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, resetEvents, watch, } from "./events.ts";
20
- export { assembleContext, configureHooks, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, type HookContext, type HookEvent, type HookMessage, type HookNote, type HookOptions, type HookOutcome, type HookRunner, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.ts";
21
+ export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunMetrics, type RunMetricsOptions, type RunUsage, resetEvents, runMetrics, type TurnReport, watch, } from "./events.ts";
22
+ export { assembleContext, configureHooks, consult, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, type HookContext, type HookEvent, type HookMessage, type HookNote, type HookOptions, type HookOutcome, type HookRunner, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.ts";
21
23
  export { resetAll } from "./reset.ts";
22
- export { backoffMs, ContextOverflow, compact, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.ts";
24
+ export { backoffMs, CHARS_PER_TOKEN, ContextOverflow, compact, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, type TokenEstimateOptions, toolsChars, } from "./retry.ts";
23
25
  export { type RunTurnOptions, runTurn } from "./run-turn.ts";
24
26
  export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
25
- export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTaskOptions, tryAsk, } from "./side-task.ts";
27
+ export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTaskInput, type SideTaskOptions, tryAsk, } from "./side-task.ts";
26
28
  export { CAPABILITY_SNAPSHOT_VERSION, type CapabilitySnapshot, type EndpointSnapshot, exportCapabilities, importCapabilities, type ModelSnapshot, } from "./snapshot.ts";
27
29
  export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
28
30
  export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, type Split, stripThinking, THINK_FENCE, } from "./thinking.ts";
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
16
  export { COMPACT_AT, compactTranscript, KEEP_RATIO, planCompaction, pruneToolResults, 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";
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.
@@ -1,3 +1,4 @@
1
+ import OpenAI from "openai";
1
2
  import type { Endpoint } from "./config.ts";
2
3
  /** An (endpoint, model) pair as `noHints` holds it: `[endpointId, model]`, stringified. */
3
4
  export declare const hintKey: (endpoint: string, model: string) => string;
@@ -5,6 +6,18 @@ export declare const hintKey: (endpoint: string, model: string) => string;
5
6
  export declare const refusedHints: () => Set<string>;
6
7
  /** Test seam, alongside `resetClients` and `resetAll`: forget which models refused the hints. */
7
8
  export declare const resetHints: () => void;
9
+ /**
10
+ * The input a side task applies its instruction to: text, or the content parts a vision model
11
+ * reads.
12
+ *
13
+ * A string is the ordinary case and stays the cheapest thing to write. The array is what an
14
+ * image needs, because a page, a screenshot or a photo reaches an OpenAI-compatible server only
15
+ * as an `image_url` part alongside the text — there is no other spelling for it, and a caller
16
+ * with one otherwise has to leave this module and build the request itself. Nothing here reads
17
+ * the parts: they are handed to the SDK as given, and whether a model that cannot see rejects
18
+ * the image or answers without it is the server's decision, not this module's.
19
+ */
20
+ export type SideTaskInput = string | OpenAI.ChatCompletionContentPart[];
8
21
  /** What a side task may be given. All optional — one given none of them still runs. */
9
22
  export interface SideTaskOptions {
10
23
  /** Ceiling on the reply, default 512. These answers are meant to be short. */
@@ -33,10 +46,10 @@ export interface SideTaskOptions {
33
46
  * @param config Where to send it and how long to wait.
34
47
  * @param model The model to ask, usually smaller than the one running the work.
35
48
  * @param system The instruction.
36
- * @param user The input it applies to.
49
+ * @param user The input it applies to. Content parts where the model is being shown an image.
37
50
  * @param options Reply ceiling, temperature, cancellation, notices.
38
51
  */
39
- export declare function ask(config: Endpoint, model: string, system: string, user: string, options?: SideTaskOptions): Promise<string>;
52
+ export declare function ask(config: Endpoint, model: string, system: string, user: SideTaskInput, options?: SideTaskOptions): Promise<string>;
40
53
  /** What `askJson` takes besides a side task's options. */
41
54
  export interface AskJsonOptions extends SideTaskOptions {
42
55
  /** What the schema is called in the request, `answer` by default. Letters, digits, `_` and `-`. */
@@ -62,11 +75,11 @@ export interface AskJsonOptions extends SideTaskOptions {
62
75
  * @param config Where to send it and how long to wait.
63
76
  * @param model The model to ask.
64
77
  * @param system The instruction. The schema is appended to it.
65
- * @param user The input it applies to.
78
+ * @param user The input it applies to. Content parts where the model is being shown an image.
66
79
  * @param schema The JSON Schema of the answer. Its root is held to an object, as a tool's is.
67
80
  * @param options A side task's options, plus the schema's `name` and whether it is `strict`.
68
81
  */
69
- export declare function askJson<T>(config: Endpoint, model: string, system: string, user: string, schema: Record<string, unknown>, { name, strict, ...options }?: AskJsonOptions): Promise<T | undefined>;
82
+ export declare function askJson<T>(config: Endpoint, model: string, system: string, user: SideTaskInput, schema: Record<string, unknown>, { name, strict, ...options }?: AskJsonOptions): Promise<T | undefined>;
70
83
  /**
71
84
  * A side task is never worth failing the work it supports. Callers that can carry on without
72
85
  * an answer use this and get `undefined` instead of an exception.
package/dist/side-task.js CHANGED
@@ -71,7 +71,7 @@ function rejectedTheRequest(error) {
71
71
  * @param config Where to send it and how long to wait.
72
72
  * @param model The model to ask, usually smaller than the one running the work.
73
73
  * @param system The instruction.
74
- * @param user The input it applies to.
74
+ * @param user The input it applies to. Content parts where the model is being shown an image.
75
75
  * @param options Reply ceiling, temperature, cancellation, notices.
76
76
  */
77
77
  export function ask(config, model, system, user, options = {}) {
@@ -168,7 +168,7 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
168
168
  * @param config Where to send it and how long to wait.
169
169
  * @param model The model to ask.
170
170
  * @param system The instruction. The schema is appended to it.
171
- * @param user The input it applies to.
171
+ * @param user The input it applies to. Content parts where the model is being shown an image.
172
172
  * @param schema The JSON Schema of the answer. Its root is held to an object, as a tool's is.
173
173
  * @param options A side task's options, plus the schema's `name` and whether it is `strict`.
174
174
  */
@@ -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")