@mlx-node/lm 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mlx-node/lm
2
2
 
3
- High-level language model inference for Node.js on Apple Silicon. Supports Qwen3 and Qwen3.5 (Dense and MoE) with streaming, tool calling, and profiling — all running locally on Metal GPU.
3
+ High-level language model inference for Node.js on Apple Silicon. Supports Qwen3, Qwen3.5 (Dense and MoE), LFM2, and Gemma4 with streaming, multi-turn chat sessions, tool calling, and profiling — all running locally on Metal GPU.
4
4
 
5
5
  ## Requirements
6
6
 
@@ -15,26 +15,31 @@ npm install @mlx-node/lm
15
15
 
16
16
  ## Quick Start
17
17
 
18
- ```typescript
19
- import { loadModel } from '@mlx-node/lm';
18
+ Multi-turn chat runs through `ChatSession`, which owns the server-side KV cache and hides the session bookkeeping behind `send()` / `sendStream()`. The `loadSession()` convenience wrapper loads the model and constructs the session in one step:
20
19
 
21
- const model = await loadModel('./models/Qwen3-0.6B');
20
+ ```typescript
21
+ import { loadSession } from '@mlx-node/lm';
22
22
 
23
- const result = model.chat([{ role: 'user', content: 'What is the capital of France?' }]);
23
+ const session = await loadSession('./models/Qwen3-0.6B');
24
24
 
25
+ const result = await session.send('What is the capital of France?');
25
26
  console.log(result.text);
27
+
28
+ // Follow-ups reuse the live KV cache — no prompt replay.
29
+ const followUp = await session.send('And its population?');
30
+ console.log(followUp.text);
26
31
  ```
27
32
 
28
33
  ## Streaming
29
34
 
30
- Qwen3.5 models support token-by-token streaming via `AsyncGenerator`:
35
+ Every generative model wrapper supports token-by-token streaming via `session.sendStream()`, which yields an `AsyncGenerator<ChatStreamEvent>`:
31
36
 
32
37
  ```typescript
33
- import { loadModel } from '@mlx-node/lm';
38
+ import { loadSession } from '@mlx-node/lm';
34
39
 
35
- const model = await loadModel('./models/Qwen3.5-0.6B');
40
+ const session = await loadSession('./models/Qwen3.5-0.8B');
36
41
 
37
- for await (const event of model.chatStream(messages, config)) {
42
+ for await (const event of session.sendStream('Write a haiku about TypeScript.')) {
38
43
  if (!event.done) {
39
44
  process.stdout.write(event.text);
40
45
  } else {
@@ -43,16 +48,16 @@ for await (const event of model.chatStream(messages, config)) {
43
48
  }
44
49
  ```
45
50
 
46
- Breaking out of the loop automatically cancels generation.
51
+ Breaking out of the loop automatically cancels generation. The session tracks its turn state so the next `send()` / `sendStream()` continues the same conversation against the live cache.
47
52
 
48
53
  ## Tool Calling
49
54
 
50
- OpenAI-compatible function calling with `createToolDefinition`:
55
+ OpenAI-compatible function calling with `createToolDefinition`. Tool-result turns feed back through the same session via `sendToolResult()`, which dispatches a native `chatSessionContinueTool` against the live KV cache:
51
56
 
52
57
  ```typescript
53
- import { loadModel, createToolDefinition, formatToolResponse } from '@mlx-node/lm';
58
+ import { loadSession, createToolDefinition } from '@mlx-node/lm';
54
59
 
55
- const model = await loadModel('./models/Qwen3-0.6B');
60
+ const session = await loadSession('./models/Qwen3-0.6B');
56
61
 
57
62
  const tools = [
58
63
  createToolDefinition(
@@ -65,37 +70,52 @@ const tools = [
65
70
  ),
66
71
  ];
67
72
 
68
- const result = model.chat([{ role: 'user', content: 'What is the weather in Tokyo?' }], { tools });
69
-
70
- // If the model calls a tool, execute it and continue
71
- if (result.toolCalls?.length) {
72
- const toolResult = executeMyTool(result.toolCalls[0]);
73
- const followUp = model.chat(
74
- [
75
- ...messages,
76
- { role: 'assistant', content: result.rawText },
77
- { role: 'tool', content: formatToolResponse(toolResult) },
78
- ],
79
- { tools },
73
+ const result = await session.send('What is the weather in Tokyo?', { config: { tools } });
74
+
75
+ // The chat-session API only supports exactly one tool call per assistant turn:
76
+ // each `sendToolResult` dispatch immediately re-opens the assistant turn, so
77
+ // feeding a second result for the same turn would interleave a new assistant
78
+ // reply between the two results. `ChatSession` enforces this at runtime — a
79
+ // subsequent `sendToolResult*` after a multi-call turn throws with a clear
80
+ // error — and the caller must refuse multi-call turns up front. Tighten the
81
+ // prompt or tool spec so the model emits at most one call per turn.
82
+ const okCalls = result.toolCalls?.filter((tc) => tc.status === 'ok') ?? [];
83
+ if (okCalls.length > 1) {
84
+ throw new Error(
85
+ `ChatSession only supports one tool call per assistant turn; ` +
86
+ `model emitted ${okCalls.length}. Tighten the prompt or tool spec.`,
80
87
  );
81
88
  }
89
+ const call = okCalls[0];
90
+ if (call) {
91
+ const toolOutput = JSON.stringify(await executeMyTool(call));
92
+ const followUp = await session.sendToolResult(call.id, toolOutput, { config: { tools } });
93
+ console.log(followUp.text);
94
+ }
82
95
  ```
83
96
 
84
97
  ## Model Loading
85
98
 
86
- `loadModel()` auto-detects the model architecture from `config.json`:
99
+ `loadModel()` auto-detects the model architecture from `config.json`. Use `loadSession()` when you want an ergonomic `ChatSession` handle in one step, or load a concrete model class and construct `new ChatSession(model)` when you need a reference to both the model and the session (e.g. for `generate()` calls, training, or model metadata):
87
100
 
88
101
  ```typescript
89
- import { loadModel, Qwen35Model, Qwen35MoeModel } from '@mlx-node/lm';
102
+ import { loadSession, ChatSession, Qwen35Model, Qwen35MoeModel } from '@mlx-node/lm';
90
103
 
91
- // Auto-detect (reads config.json model_type field)
92
- const model = await loadModel('./models/Qwen3-0.6B');
104
+ // Convenience: auto-detect architecture and wrap in a ChatSession.
105
+ const session = await loadSession('./models/Qwen3-0.6B', { system: 'Be concise.' });
93
106
 
94
- // Or load a specific architecture directly
107
+ // Or load a specific architecture directly — every generative model wrapper
108
+ // structurally satisfies ChatSession's SessionCapableModel bound.
95
109
  const dense = await Qwen35Model.load('./models/Qwen3.5-0.8B');
96
110
  const moe = await Qwen35MoeModel.load('./models/Qwen3.5-35B-A3B');
111
+ const denseSession = new ChatSession(dense);
112
+ const moeSession = new ChatSession(moe);
97
113
  ```
98
114
 
115
+ `loadSession()` rejects embedding models (`HarrierModel`) and the native `QianfanOCRModel` — for the VLM case, import `QianfanOCRModel` from `@mlx-node/vlm` and wrap it with `new ChatSession(...)` directly.
116
+
117
+ `ChatSession` accepts an options bag with `{ system?, defaultConfig? }`. The system prompt is injected on the first turn and never re-sent. Per-call config passed to `send()` / `sendStream()` shallow-merges on top of `defaultConfig`. Call `session.reset()` to wipe the KV cache and start a fresh conversation.
118
+
99
119
  ### Pre-defined Configs
100
120
 
101
121
  ```typescript
@@ -104,8 +124,8 @@ import { QWEN3_CONFIGS, QWEN35_CONFIGS, getQwen3Config, getQwen35Config } from '
104
124
  // Available Qwen3 configs: 'qwen3-0.6b', 'qwen3-1.7b', 'qwen3-7b'
105
125
  const config = getQwen3Config('qwen3-0.6b');
106
126
 
107
- // Available Qwen3.5 configs: 'qwen3.5-0.8b'
108
- const config35 = getQwen35Config('qwen3.5-0.8b');
127
+ // Available Qwen3.5 configs: 'qwen3.5-0.6b'
128
+ const config35 = getQwen35Config('qwen3.5-0.6b');
109
129
  ```
110
130
 
111
131
  ## Profiling
@@ -128,12 +148,16 @@ Or set `MLX_PROFILE_DECODE=1` to auto-enable and write a report on exit.
128
148
 
129
149
  ### Classes
130
150
 
131
- | Class | Description |
132
- | ---------------- | -------------------------------------------------------------------------------- |
133
- | `loadModel()` | Auto-detect and load any supported model from disk |
134
- | `Qwen3Model` | Qwen3 inference — `generate()`, `chat()`, paged attention, speculative decoding |
135
- | `Qwen35Model` | Qwen3.5 Dense — `generate()`, `chat()`, `chatStream()` with compiled C++ forward |
136
- | `Qwen35MoeModel` | Qwen3.5 MoEsame API as Dense with expert routing |
151
+ | Class | Description |
152
+ | ---------------- | --------------------------------------------------------------------------------- |
153
+ | `loadModel()` | Auto-detect and load any supported model from disk |
154
+ | `loadSession()` | `loadModel()` + `new ChatSession(model)` in one step |
155
+ | `ChatSession<M>` | Multi-turn chat wrapper — `send()`, `sendStream()`, `sendToolResult()`, `reset()` |
156
+ | `Qwen3Model` | Qwen3 inference`generate()`, paged attention, speculative decoding |
157
+ | `Qwen35Model` | Qwen3.5 Dense — `generate()` with compiled C++ forward |
158
+ | `Qwen35MoeModel` | Qwen3.5 MoE — `generate()` with compiled C++ forward and expert routing |
159
+ | `Gemma4Model` | Gemma4 inference — `generate()` |
160
+ | `Lfm2Model` | LFM2.5 hybrid conv+attention inference — `generate()` |
137
161
 
138
162
  ### Streaming Types
139
163
 
@@ -179,8 +203,6 @@ function createToolDefinition(
179
203
  properties?: Record<string, FunctionParameterProperty>,
180
204
  required?: string[],
181
205
  ): ToolDefinition;
182
-
183
- function formatToolResponse(content: string): string;
184
206
  ```
185
207
 
186
208
  ### Functions
@@ -188,18 +210,21 @@ function formatToolResponse(content: string): string;
188
210
  | Function | Description |
189
211
  | ------------------------ | ---------------------------------------------------- |
190
212
  | `createToolDefinition()` | Create an OpenAI-compatible tool definition |
191
- | `formatToolResponse()` | Wrap tool output in `<tool_response>` tags |
192
213
  | `detectModelType()` | Read `config.json` and return the `model_type` field |
193
214
  | `enableProfiling()` | Start profiling with auto-report on exit |
194
215
  | `disableProfiling()` | Stop profiling and write JSON report |
195
216
 
196
217
  ## Supported Models
197
218
 
198
- | Model | `chat()` | `chatStream()` | Training | Notes |
199
- | ------------- | :------: | :------------: | :------: | ------------------------------------- |
200
- | Qwen3 | Yes | No | GRPO/SFT | Paged attention, speculative decoding |
201
- | Qwen3.5 Dense | Yes | Yes | GRPO/SFT | Compiled C++ forward, VLM variant |
202
- | Qwen3.5 MoE | Yes | Yes | GRPO/SFT | Compiled C++ forward, expert routing |
219
+ Every generative model wrapper exposes the same `ChatSession<M>` surface — `send()`, `sendStream()`, and `sendToolResult()` all work against any of the models below.
220
+
221
+ | Model | `generate()` | `ChatSession` | Training | Notes |
222
+ | ------------- | :----------: | :-----------: | :------: | ------------------------------------- |
223
+ | Qwen3 | Yes | Yes | GRPO/SFT | Paged attention, speculative decoding |
224
+ | Qwen3.5 Dense | Yes | Yes | GRPO/SFT | Compiled C++ forward, VLM variant |
225
+ | Qwen3.5 MoE | Yes | Yes | GRPO/SFT | Compiled C++ forward, expert routing |
226
+ | Gemma4 | Yes | Yes | No | Streaming chat via session |
227
+ | LFM2.5 | Yes | Yes | No | Hybrid conv + attention architecture |
203
228
 
204
229
  ## Performance
205
230
 
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Generic server-side chat session wrapper.
3
+ *
4
+ * `ChatSession<M>` is the cross-model chat-session wrapper. It works
5
+ * against any model that exposes the uniform chat-session NAPI
6
+ * surface — `chatSessionStart`,
7
+ * `chatSessionContinue`, `chatSessionContinueTool`, and their
8
+ * streaming variants plus `resetCaches`. See `SessionCapableModel`
9
+ * below.
10
+ *
11
+ * Design notes:
12
+ *
13
+ * - The session tracks its own `ChatMessage[]` history on the
14
+ * TypeScript side. In the common text-continue case the history
15
+ * is only appended to and never read back — each `send()` on
16
+ * turn >= 1 issues a cheap `chatSessionContinue` delta against
17
+ * the live KV cache. The history is kept purely so the
18
+ * image-change mid-session path can call `chatSessionStart` with
19
+ * the full rebuilt history for a clean re-prefill.
20
+ *
21
+ * - An image hash (`lastImagesKey`) tracks the images bound to the
22
+ * current cache. A `send()` call whose image set has changed
23
+ * (different bytes or different ordering) triggers a full
24
+ * restart: `resetCaches()` → push the new user message (with
25
+ * images) to history → `chatSessionStart(history)`.
26
+ *
27
+ * - Text-only `send()` on turn >= 1 takes the cheap delta path.
28
+ *
29
+ * - `sendToolResult` always dispatches `chatSessionContinueTool`,
30
+ * since tool turns never change image state. The session enforces
31
+ * a strict unresolved-ok-tool-call contract at runtime, driven by
32
+ * `unresolvedOkToolCallCount` (derived from `ChatResult.toolCalls`
33
+ * after each turn via `countOkToolCalls` /
34
+ * `computeTrailingAssistantUnresolvedToolCallCount`):
35
+ *
36
+ * * `null` — the trailing assistant turn has no outstanding ok
37
+ * tool call. Plain `send()` / `sendStream()` are the only
38
+ * valid entry points; `sendToolResult*()` throws because
39
+ * there is nothing for the result to resolve.
40
+ * * `1` — exactly one outstanding ok tool call. Plain `send()` /
41
+ * `sendStream()` throw (they would orphan the call);
42
+ * `sendToolResult*()` is the sole valid forward step and
43
+ * dispatches the tool result through the native session.
44
+ * * `>1` — a multi-tool-call fan-out that the chat-session API
45
+ * cannot progress incrementally (each `sendToolResult*` would
46
+ * re-open the assistant turn and weave new replies between
47
+ * the sibling results). Both `send()` / `sendStream()` and
48
+ * `sendToolResult*()` throw. The only valid recovery is
49
+ * `reset()` or `primeHistory()` + `startFromHistory*()` with
50
+ * a fully-resolved conversation — there is no "advance past
51
+ * the broken turn" path. This mirrors the native ChatML delta
52
+ * format which would otherwise silently corrupt multi-call
53
+ * conversations.
54
+ *
55
+ * - `sawFinal` gates `turnCount` advance on the streaming path, so
56
+ * the session refuses to advance when the stream throws
57
+ * mid-decode or yields a final chunk with
58
+ * `finishReason: 'error'`.
59
+ *
60
+ * - The `inFlight` guard rejects concurrent `send()` /
61
+ * `sendStream()` calls at the class level. The native side
62
+ * serializes cache mutation on a single worker thread, so a
63
+ * second in-flight call would race the first's cache-save step.
64
+ *
65
+ * - **Cold-restart primitives.** `primeHistory()` plus
66
+ * `startFromHistory()` / `startFromHistoryStream()` let a caller
67
+ * seed a fresh session with an externally-reconstructed history
68
+ * (e.g. a server `ResponseStore` chain) and replay it through the
69
+ * native `chatSessionStart` path without going through `send()`.
70
+ * These are intended for server-side `SessionRegistry` cache-miss
71
+ * cold-start; normal usage stays on `send` / `sendStream` /
72
+ * `sendToolResult` / `reset`.
73
+ *
74
+ * ## Typical usage
75
+ *
76
+ * ```typescript
77
+ * import { Qwen35Model, ChatSession } from '@mlx-node/lm';
78
+ *
79
+ * const model = await Qwen35Model.load('./models/qwen3.5-0.8b');
80
+ * const session = new ChatSession(model, { system: 'Be concise.' });
81
+ * const r1 = await session.send('Say hi in one word.');
82
+ * const r2 = await session.send('Another word?');
83
+ * await session.reset();
84
+ * ```
85
+ */
86
+ import type { ChatConfig, ChatMessage, ChatResult } from '@mlx-node/core';
87
+ import type { ChatStreamEvent } from './stream.js';
88
+ /**
89
+ * Structural interface matched by every generative model wrapper
90
+ * (`Qwen35Model`, `Qwen35MoeModel`, `Lfm2Model`, `Gemma4Model`,
91
+ * `Qwen3Model`, and the Qianfan-OCR VLM wrapper). `ChatSession<M>` is
92
+ * generic over `M extends SessionCapableModel` so each session
93
+ * instance statically binds to a specific model's concrete type
94
+ * (handy for IDE autocomplete) while the implementation remains
95
+ * fully structural.
96
+ */
97
+ export interface SessionCapableModel {
98
+ chatSessionStart(messages: ChatMessage[], config?: ChatConfig | null): Promise<ChatResult>;
99
+ chatSessionContinue(userMessage: string, images: Uint8Array[] | null, config?: ChatConfig | null): Promise<ChatResult>;
100
+ chatSessionContinueTool(toolCallId: string, content: string, config?: ChatConfig | null): Promise<ChatResult>;
101
+ /**
102
+ * The optional `signal` parameter on every streaming entry point is
103
+ * plumbed into the `_runChatStream` fast-abort path in the wrapper
104
+ * implementations. Callers that need client-disconnect-aware
105
+ * cancellation (e.g. HTTP endpoints flipping an AbortController on
106
+ * socket close) can attach one here and the native decode unwinds
107
+ * at the next safepoint via `ChatStreamHandle.cancel()`. Non-signal
108
+ * callers (the common direct-use path) just omit it.
109
+ */
110
+ chatStreamSessionStart(messages: ChatMessage[], config?: ChatConfig | null, signal?: AbortSignal): AsyncGenerator<ChatStreamEvent>;
111
+ chatStreamSessionContinue(userMessage: string, images: Uint8Array[] | null, config?: ChatConfig | null, signal?: AbortSignal): AsyncGenerator<ChatStreamEvent>;
112
+ chatStreamSessionContinueTool(toolCallId: string, content: string, config?: ChatConfig | null, signal?: AbortSignal): AsyncGenerator<ChatStreamEvent>;
113
+ resetCaches(): void;
114
+ }
115
+ /** Per-call options for {@link ChatSession#send} / `sendStream`. */
116
+ export interface SendOptions {
117
+ /**
118
+ * Optional image bytes attached to this user turn. When the image
119
+ * set differs from the session's current `lastImagesKey`, the
120
+ * session forcibly restarts via `chatSessionStart`.
121
+ */
122
+ images?: Uint8Array[];
123
+ /**
124
+ * Per-call `ChatConfig` overlay applied on top of the session's
125
+ * `defaultConfig`. `reuseCache` is always forced on regardless of
126
+ * what the caller passes.
127
+ */
128
+ config?: ChatConfig;
129
+ /**
130
+ * Optional AbortSignal plumbed into the streaming fast-abort path.
131
+ *
132
+ * Only honored by the streaming entry points (`sendStream`,
133
+ * `sendToolResultStream`, `startFromHistoryStream`) — the
134
+ * non-streaming `send` / `sendToolResult` / `startFromHistory`
135
+ * calls have NO native cancel surface, so a signal passed to them
136
+ * is ignored. Pass one here and the inner `_runChatStream`
137
+ * adapter wakes from `waitForItem()` on abort, calls
138
+ * `handle.cancel()` on the native handle, and unwinds the stream
139
+ * without throwing an AbortError — the outer consumer's `for await`
140
+ * just ends early. Intended for HTTP endpoints that flip a
141
+ * controller on `res.once('close', …)` so client disconnect stops
142
+ * the native decode at the next safepoint rather than running it
143
+ * to completion under the per-model mutex.
144
+ */
145
+ signal?: AbortSignal;
146
+ }
147
+ /** Constructor options for {@link ChatSession}. */
148
+ export interface ChatSessionOptions {
149
+ /**
150
+ * Optional system prompt prepended as the first message on turn 1.
151
+ * Subsequent turns don't re-inject the system prompt — the cache
152
+ * already holds it.
153
+ */
154
+ system?: string;
155
+ /**
156
+ * Default `ChatConfig` applied to every `send()` / `sendStream()`
157
+ * / `sendToolResult()` call. Per-call config is shallow-merged on
158
+ * top of this, and `reuseCache` is forced on.
159
+ */
160
+ defaultConfig?: ChatConfig;
161
+ }
162
+ /**
163
+ * Cross-model chat session. See module docstring for design notes.
164
+ *
165
+ * The generic parameter `M` statically captures the concrete model
166
+ * type so the structural interface stays as expressive as the
167
+ * concrete one. Internally the class only uses the
168
+ * `SessionCapableModel` surface.
169
+ */
170
+ export declare class ChatSession<M extends SessionCapableModel = SessionCapableModel> {
171
+ private readonly model;
172
+ private readonly system;
173
+ private readonly defaultConfig;
174
+ /**
175
+ * Full conversation history tracked on the TS side. Appended to on
176
+ * every successful turn. Only read back when the image-change path
177
+ * triggers a restart — normal text continues use the server-side
178
+ * cache, not this array.
179
+ */
180
+ private history;
181
+ /**
182
+ * Hex-encoded byte-identity key of the image set currently bound
183
+ * to the server's KV cache (FNV-1a 64-bit; see `computeImagesKey`).
184
+ * `null` when no images are cached. A `send()` whose new key
185
+ * differs triggers a full `chatSessionStart` restart.
186
+ */
187
+ private lastImagesKey;
188
+ private turnCount;
189
+ private inFlight;
190
+ /**
191
+ * Count of `ok` tool calls emitted by the prior assistant turn, or
192
+ * `null` when the prior turn produced none. Gates every continuation
193
+ * entry point on the tool-call resolution invariant because each
194
+ * native `chat_session_continue*` dispatch re-opens the assistant
195
+ * turn:
196
+ *
197
+ * - A plain text `send` / `sendStream` after ANY outstanding tool
198
+ * call would orphan the call(s) by weaving a fresh user turn
199
+ * between the assistant's `tool_call` and any response.
200
+ * - A `sendToolResult` / `sendToolResultStream` is only servable
201
+ * when exactly one tool call is outstanding. A multi-call
202
+ * fan-out (`> 1`) cannot be resolved one result at a time — the
203
+ * siblings would be separated by fresh assistant replies — so
204
+ * those entry points also reject.
205
+ *
206
+ * Cleared on every successful commit whose new turn emits zero `ok`
207
+ * tool calls, and on `reset()`. See `assertCanSendPlain` /
208
+ * `assertCanSendToolResult` for the per-entry-point gate logic.
209
+ */
210
+ private unresolvedOkToolCallCount;
211
+ constructor(model: M, options?: ChatSessionOptions);
212
+ /**
213
+ * Number of completed turns. Increments only after a successful
214
+ * round-trip — in-flight or failed calls leave this untouched.
215
+ */
216
+ get turns(): number;
217
+ /** Whether the session currently has images bound to its cache. */
218
+ get hasImages(): boolean;
219
+ /**
220
+ * Count of `ok` tool calls from the most recent assistant turn, or
221
+ * `null` when the trailing turn produced none. Non-null means the
222
+ * session is parked on an unresolved tool-call turn and the only
223
+ * forward-progress move is `sendToolResult*()` against one of the
224
+ * outstanding ids — and only when the count is exactly 1. A
225
+ * multi-call fan-out (`> 1`) cannot be served by the chat-session
226
+ * API at all; server endpoints should pre-check this getter and
227
+ * route around a fan-out via `reset()` + `primeHistory()` +
228
+ * `startFromHistory()` cold replay that resolves every sibling in
229
+ * one atomic jinja render.
230
+ *
231
+ * The flag updates after every successful `send` / `sendStream` /
232
+ * `sendToolResult` / `sendToolResultStream` / `startFromHistory*`
233
+ * commit, and after `primeHistory()` (from the trailing assistant
234
+ * message's `toolCalls.length`). `reset()` clears it.
235
+ */
236
+ get pendingUnresolvedToolCallCount(): number | null;
237
+ /**
238
+ * Send a user message and resolve with the assistant reply.
239
+ *
240
+ * Turn 0 and any turn whose image set has changed dispatch through
241
+ * `chatSessionStart` with the full history. All other turns use
242
+ * the cheap `chatSessionContinue` delta path.
243
+ */
244
+ send(userMessage: string, opts?: SendOptions): Promise<ChatResult>;
245
+ /**
246
+ * Streaming variant of {@link ChatSession#send}.
247
+ *
248
+ * Routing matches `send()`. The assistant reply is accumulated
249
+ * from stream deltas and pushed to `history` only after a
250
+ * successful terminal chunk (`done: true` with non-error
251
+ * `finishReason`). Caller break, mid-stream exceptions, and error
252
+ * finishes all leave `turnCount` untouched and the history
253
+ * un-appended for the turn so the next call re-routes through the
254
+ * start path.
255
+ */
256
+ sendStream(userMessage: string, opts?: SendOptions): AsyncGenerator<ChatStreamEvent>;
257
+ /**
258
+ * Send a tool-result turn. Always dispatches
259
+ * `chatSessionContinueTool` — tool turns never change image state,
260
+ * so there is no restart path here.
261
+ *
262
+ * Rejects if the prior assistant turn emitted more than one `ok`
263
+ * tool call: the chat-session API only supports exactly one tool
264
+ * call per assistant turn because each `sendToolResult` dispatch
265
+ * immediately re-opens the assistant turn, so responding to the
266
+ * remaining calls would interleave new assistant replies between
267
+ * the results and corrupt the conversation structure. Callers that
268
+ * hit this must tighten the prompt / tool spec or reset the
269
+ * session.
270
+ *
271
+ * Appends a `{ role: 'tool', ... }` message to history on success.
272
+ */
273
+ sendToolResult(toolCallId: string, content: string, opts?: {
274
+ config?: ChatConfig;
275
+ }): Promise<ChatResult>;
276
+ /** Streaming variant of {@link ChatSession#sendToolResult}. */
277
+ sendToolResultStream(toolCallId: string, content: string, opts?: {
278
+ config?: ChatConfig;
279
+ signal?: AbortSignal;
280
+ }): AsyncGenerator<ChatStreamEvent>;
281
+ /**
282
+ * Reset the session state.
283
+ *
284
+ * Clears the underlying model's KV caches and wipes local history,
285
+ * image key, and turn counter so the next `send()` goes through
286
+ * `chatSessionStart` again.
287
+ *
288
+ * Returns `Promise<void>` for an async-friendly signature even
289
+ * though `resetCaches()` is currently synchronous.
290
+ */
291
+ reset(): Promise<void>;
292
+ /**
293
+ * Prime the session history without running inference.
294
+ *
295
+ * Used by the server-side `SessionRegistry` cold-start fallback: when
296
+ * a request arrives with a `previous_response_id` that the cache has
297
+ * missed, the endpoint reconstructs the full conversation from the
298
+ * `ResponseStore` and primes a fresh session with it, then calls
299
+ * `startFromHistory()` to replay it through the native KV cache.
300
+ *
301
+ * Rejects if the session is in flight or has already taken a turn.
302
+ * Replaces the internal history with a shallow copy of `messages`.
303
+ */
304
+ primeHistory(messages: ChatMessage[]): void;
305
+ /**
306
+ * Run a cold-start `chatSessionStart` using the currently primed
307
+ * history.
308
+ *
309
+ * Intended pairing with {@link primeHistory}: call
310
+ * `primeHistory(fullHistory)` first, then `startFromHistory()` to
311
+ * replay the conversation through the native chat-session API. The
312
+ * final history entry must be a user or tool turn — this is what the
313
+ * native side treats as the "current input" to generate against.
314
+ *
315
+ * Pushes the assistant reply onto the history, advances `turnCount`
316
+ * to 1, and computes `lastImagesKey` from the most recent user
317
+ * message that carries images (so subsequent text-only continues
318
+ * stay on the delta path, and subsequent image turns correctly
319
+ * trigger restart).
320
+ */
321
+ startFromHistory(config?: ChatConfig): Promise<ChatResult>;
322
+ /**
323
+ * Streaming counterpart to {@link startFromHistory}.
324
+ *
325
+ * Iterates `model.chatStreamSessionStart(history.slice(), config)`,
326
+ * accumulates text, and only commits history + `turnCount` +
327
+ * `lastImagesKey` in the `finally` block when a successful terminal
328
+ * chunk was observed (`done: true` with non-error finishReason).
329
+ * Because history is primed (not appended to), rollback on failure
330
+ * is a no-op: the primed state stays intact so the caller can retry.
331
+ */
332
+ startFromHistoryStream(config?: ChatConfig, signal?: AbortSignal): AsyncGenerator<ChatStreamEvent>;
333
+ /**
334
+ * Gate plain-text continuation entry points (`send`, `sendStream`)
335
+ * on the tool-call resolution invariant. Any outstanding `ok` tool
336
+ * call from the prior assistant turn — single or multi — makes a
337
+ * plain text continuation unsafe: the native chat-session API
338
+ * re-opens the assistant turn on each continue, so a new user delta
339
+ * would weave a fresh user message between the assistant's
340
+ * `tool_call` and any response, orphaning the call. Callers must
341
+ * resolve outstanding calls via `sendToolResult*()` (single-call
342
+ * case) or re-enter via `reset()` + `primeHistory()` +
343
+ * `startFromHistory()` with a resolved conversation (multi-call
344
+ * fan-out). `reset()` clears the flag and `startFromHistory*`
345
+ * overwrites it via `recordToolCallFanout` on the new response, so
346
+ * legitimate recovery paths are unaffected.
347
+ */
348
+ private assertCanSendPlain;
349
+ /**
350
+ * Gate tool-result entry points (`sendToolResult`,
351
+ * `sendToolResultStream`) on the single-tool-call-per-turn
352
+ * invariant. Exactly one outstanding tool call is servable — that
353
+ * is the case these methods exist for.
354
+ *
355
+ * Zero outstanding calls (`null`) is also unservable: without a
356
+ * preceding assistant turn that emitted a tool call, a tool-result
357
+ * dispatch would synthesize a `<tool_response>` delta for a call
358
+ * that never existed, corrupting the conversation structure. The
359
+ * native backends do not authenticate `tool_call_id` against prior
360
+ * state — several simply append the tool-response delta verbatim —
361
+ * so rejecting here is the only gate that prevents forged tool
362
+ * state from reaching the model. Callers that want to start a
363
+ * conversation on a resolved tool turn must prime an unresolved
364
+ * single-call assistant turn via `primeHistory()` +
365
+ * `startFromHistory()` first.
366
+ *
367
+ * A multi-call fan-out (`> 1`) cannot be resolved one result at a
368
+ * time because each `sendToolResult` dispatch immediately re-opens
369
+ * the assistant turn, so responding to the siblings would
370
+ * interleave new assistant replies between the results.
371
+ */
372
+ private assertCanSendToolResult;
373
+ /**
374
+ * Inspect a just-committed turn's tool calls and store the count of
375
+ * `ok` entries in `unresolvedOkToolCallCount`. Any non-zero count
376
+ * parks the session on an unresolved tool-call turn, which gates
377
+ * the next entry point:
378
+ *
379
+ * - count === 0 → flag is `null`: `send`/`sendStream` ok,
380
+ * `sendToolResult*` throws (no outstanding call to resolve)
381
+ * - count === 1 → `send`/`sendStream` throw; `sendToolResult*` ok
382
+ * - count > 1 → every entry point throws (fan-out unservable)
383
+ *
384
+ * See `assertCanSendPlain` / `assertCanSendToolResult` for the full
385
+ * rationale.
386
+ */
387
+ private recordToolCallFanout;
388
+ /**
389
+ * Merge default + per-call config and force `reuseCache: true`.
390
+ * The session path is a session-reuse operation by construction —
391
+ * `reuseCache: false` on the continue path would wipe the very
392
+ * cache the delta depends on.
393
+ */
394
+ private mergeConfig;
395
+ /**
396
+ * Shared start-path logic for `send()`. Handles both the turn-0
397
+ * first-ever-send case and the image-change mid-session restart
398
+ * case. The image-change restart preserves prior history so the
399
+ * native side gets the full conversation re-rendered with the new
400
+ * image set.
401
+ */
402
+ private runStartPath;
403
+ /** Streaming counterpart to {@link runStartPath}. */
404
+ private runStartStreamPath;
405
+ /**
406
+ * Shared pre-start bookkeeping for both `send()` and `sendStream()`:
407
+ *
408
+ * - On an image-change restart (turn >= 1), reset the native KV
409
+ * caches so the new image set gets a fresh prefill. History is
410
+ * intentionally preserved — `chatSessionStart` receives the full
411
+ * accumulated conversation plus the new user turn so the jinja
412
+ * render walks every prior turn and every prior image again
413
+ * (see plan's Turn 3 example: "full jinja on 3-turn history +
414
+ * image B"). `lastImagesKey` will be overwritten by the
415
+ * successful start path right after, and `turnCount` is
416
+ * incremented by the start path the same way as for any other
417
+ * turn.
418
+ * - On a fresh / reset history, re-inject the system prompt.
419
+ */
420
+ private prepareStartPath;
421
+ /** Build a user `ChatMessage` with or without attached images. */
422
+ private buildUserMessage;
423
+ /**
424
+ * Walk the history backward to find the most recent user message
425
+ * with images and return its FNV-1a key. Used by
426
+ * {@link startFromHistory} and {@link startFromHistoryStream} to
427
+ * hydrate `lastImagesKey` after a cold replay, so subsequent delta
428
+ * continues correctly detect image changes.
429
+ */
430
+ private computeTrailingImagesKey;
431
+ /**
432
+ * Derive the post-prime value of `unresolvedOkToolCallCount` from
433
+ * the primed history. Walks backward to the most recent assistant
434
+ * turn, then walks forward from that assistant to the end of history
435
+ * subtracting any `tool:` message that references one of the turn's
436
+ * `call_id`s. A fully-resolved history (every outstanding id matched
437
+ * by a sibling `tool:` message) returns `null`; any leftover count is
438
+ * the number of still-unresolved tool calls.
439
+ *
440
+ * Matches the runtime `recordToolCallFanout` semantics on the hot
441
+ * path: zero unresolved → `null` (no pending obligation); one →
442
+ * `1` (servable via `sendToolResult*()` only); two or more → the
443
+ * count itself (unservable fan-out — must be resolved via cold
444
+ * replay). The distinction between "ok" vs. other statuses only
445
+ * exists in the live `ToolCallResult[]` emitted by the native side —
446
+ * the persisted `ChatMessage.toolCalls` on an assistant message only
447
+ * carries successfully parsed calls (i.e. what would have been "ok"
448
+ * in the original live turn), so counting the array length is
449
+ * equivalent. Tool calls whose `id` is missing or empty can't be
450
+ * matched against subsequent `tool_call_id`s, so in that case we
451
+ * fall back to returning the raw `calls.length` (err safe).
452
+ */
453
+ private computeTrailingAssistantUnresolvedToolCallCount;
454
+ }
455
+ //# sourceMappingURL=chat-session.d.ts.map