@msm-core/mini 0.5.2 → 0.9.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.
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Streaming — the four providers' wire formats reduced to pure functions (ب١).
3
+ *
4
+ * **Why this file exists at all.** A streamed answer and a non-streamed answer
5
+ * are the same answer delivered twice over. The only way to be sure of that is
6
+ * to be able to *prove* it, and you cannot prove anything about code that only
7
+ * runs with a socket open and a key in the environment. So every provider's
8
+ * stream is split in two here:
9
+ *
10
+ * - `…Delta(event)` — what text just arrived. Pure.
11
+ * - `accumulate…(events)` — the events folded back into the SAME object shape
12
+ * the provider's NON-streaming call returns. Pure.
13
+ *
14
+ * Feed `accumulate…` a fixed array of chunks in a test and you get an object
15
+ * that the brain's own post-call code — not a copy of it, the same lines —
16
+ * turns into a payload. That is what makes "the stream aggregates to what the
17
+ * non-stream returns" a checkable claim rather than a hope, and it is why the
18
+ * brains route BOTH paths into one `response` variable and one payload builder.
19
+ *
20
+ * **The read set is the contract.** Each `…Response` interface below is exactly
21
+ * the fields its brain reads off a provider response — no more. It is small on
22
+ * purpose: `id`, `created`, `finish_reason`, block ids and the rest are things
23
+ * a stream cannot reconstruct and no brain here consumes, so promising them
24
+ * would be a lie the compiler would happily keep.
25
+ *
26
+ * **Display, not truth (the governing limit of ب١).** Nothing in this file
27
+ * writes a log event, touches a fingerprint, or decides anything. Chunks are
28
+ * emitted for a human to look at; the payload the brain returns is the record.
29
+ *
30
+ * **Text only in v1.** Tool-call fragments are accumulated but never emitted —
31
+ * a half-arrived tool call is not something a consumer can do anything with,
32
+ * and `BrainChunk` says `{ text }` precisely so that stays true.
33
+ */
34
+ import type { BrainChunk } from "../core/types.js";
35
+ /** Where chunks go. Exactly `BrainRunInput.onChunk`, named for readability. */
36
+ export type ChunkSink = (chunk: BrainChunk) => void;
37
+ /**
38
+ * Read a provider stream to the end, emitting text as it arrives, and return
39
+ * every raw event for the accumulator.
40
+ *
41
+ * **Abort stops the consumption, and no chunk follows it.** The check sits
42
+ * before the emit, not after, so an abort that lands between two events ends
43
+ * the stream silently — the loop's `runWithTimeout` has already rejected by
44
+ * then and whatever is returned here is discarded. What must never happen is a
45
+ * chunk arriving at a consumer that has been told the call is over.
46
+ *
47
+ * Buffering every event is deliberate: one model response is small, and the
48
+ * accumulator that has to see all of them is the same function the tests feed
49
+ * by hand. A fold that ran only live would be a second implementation.
50
+ */
51
+ export declare function consumeStream<T>(source: AsyncIterable<T>, deltaOf: (event: T) => string, sink: ChunkSink, signal?: AbortSignal): Promise<T[]>;
52
+ /** One `chat.completions` stream chunk, reduced to what we consume. */
53
+ export interface OpenAIStreamChunk {
54
+ choices?: Array<{
55
+ delta?: {
56
+ content?: string | null;
57
+ tool_calls?: Array<{
58
+ index: number;
59
+ function?: {
60
+ name?: string;
61
+ arguments?: string;
62
+ };
63
+ }>;
64
+ };
65
+ }>;
66
+ usage?: {
67
+ prompt_tokens?: number;
68
+ completion_tokens?: number;
69
+ } | null;
70
+ }
71
+ /**
72
+ * What the OpenAI brain reads off a completion — streamed or not.
73
+ *
74
+ * `tool_calls` is `unknown[]` on purpose. OpenAI's own union now holds a CUSTOM
75
+ * tool call that carries no `function` at all, so no structural type both
76
+ * accepts the real `ChatCompletion` and promises a `function` field. The brain
77
+ * has narrowed each entry with its own cast since long before streaming
78
+ * existed — this simply stops pretending otherwise, and the non-streaming path
79
+ * needs no cast to satisfy it.
80
+ */
81
+ export interface OpenAICompletion {
82
+ choices: Array<{
83
+ message: {
84
+ content?: string | null;
85
+ tool_calls?: unknown[];
86
+ };
87
+ }>;
88
+ usage?: {
89
+ prompt_tokens?: number;
90
+ completion_tokens?: number;
91
+ } | null;
92
+ }
93
+ /**
94
+ * The text this chunk carries, or "" for a tool-call or usage-only chunk.
95
+ *
96
+ * **Why no fold here.** Two multiplicities are worth ruling out by name, since
97
+ * an extractor that silently reads "the first of several" is the exact bug this
98
+ * file was written to make impossible:
99
+ *
100
+ * - WITHIN a choice, `delta.content` is one string, never a list. Nothing to
101
+ * concatenate.
102
+ * - ACROSS choices, `choices` IS an array — but only when a request sets
103
+ * `n > 1`, which this brain never does. And if one ever did, several
104
+ * choices are ALTERNATIVE answers, not pieces of one: summing them would
105
+ * splice two different replies together. So `[0]` is right, and the rule
106
+ * that actually matters is that `accumulateOpenAI` reads `[0]` too — what
107
+ * the user watched is what the payload ends up holding, always.
108
+ */
109
+ export declare function openAIDelta(chunk: OpenAIStreamChunk): string;
110
+ /**
111
+ * Fold OpenAI chunks into the completion they describe.
112
+ *
113
+ * Tool calls arrive as fragments keyed by `index`: the name once, the arguments
114
+ * in pieces to be concatenated into the JSON string the non-streamed API hands
115
+ * over whole. Keyed by index and re-sorted rather than pushed in arrival order,
116
+ * because the model interleaves parallel calls and the ORDER of calls is the
117
+ * one thing س٤ made load-bearing.
118
+ *
119
+ * `content` stays `null` when no text delta ever arrived — that is what the
120
+ * non-streaming API returns for a pure tool-call response, and the two spellings
121
+ * must not differ.
122
+ */
123
+ export declare function accumulateOpenAI(chunks: readonly OpenAIStreamChunk[]): OpenAICompletion;
124
+ /**
125
+ * One Messages-API stream event, reduced to what we consume.
126
+ *
127
+ * `delta` is `unknown` for the same reason OpenAI's `tool_calls` is: the event
128
+ * union puts two unrelated shapes through one field name — a `message_delta`
129
+ * carries stop reason and stop sequence, a `content_block_delta` carries text
130
+ * or JSON fragments — and they share no property. Narrowing happens at the one
131
+ * place it is read (`blockDelta` below), which keeps the cast next to the check
132
+ * that justifies it and lets `RawMessageStreamEvent` flow in untouched.
133
+ */
134
+ export interface AnthropicStreamEvent {
135
+ type: string;
136
+ index?: number;
137
+ message?: {
138
+ usage?: {
139
+ input_tokens?: number;
140
+ output_tokens?: number;
141
+ };
142
+ };
143
+ content_block?: {
144
+ type?: string;
145
+ name?: string;
146
+ input?: unknown;
147
+ };
148
+ delta?: unknown;
149
+ usage?: {
150
+ output_tokens?: number | null;
151
+ };
152
+ }
153
+ /** What the Anthropic brain reads off a message — streamed or not. */
154
+ export interface AnthropicMessage {
155
+ content: Array<{
156
+ type: string;
157
+ text?: string;
158
+ name?: string;
159
+ input?: unknown;
160
+ }>;
161
+ usage: {
162
+ input_tokens: number;
163
+ output_tokens: number;
164
+ };
165
+ }
166
+ /**
167
+ * The text this event carries, or "" for anything else (thinking included).
168
+ *
169
+ * **Why no fold here.** One `content_block_delta` carries exactly one `delta`
170
+ * for exactly one `index` — the event type is `{ delta, index, type }`, not a
171
+ * list — so there is never more than one piece of text in an event and nothing
172
+ * to concatenate. Multiplicity in Claude's stream lives BETWEEN events, and
173
+ * that is `accumulateAnthropic`'s job, keyed by `index`.
174
+ */
175
+ export declare function anthropicDelta(event: AnthropicStreamEvent): string;
176
+ /**
177
+ * Fold Messages-API events into the message they describe.
178
+ *
179
+ * Blocks are addressed by `index` and rebuilt in index order: `text_delta`
180
+ * pieces concatenate into the block's text, `input_json_delta` pieces
181
+ * concatenate into the JSON that non-streamed responses deliver already parsed
182
+ * as `input`.
183
+ *
184
+ * Usage arrives in two halves — `input_tokens` at `message_start`, the final
185
+ * `output_tokens` at `message_delta` — so both are taken where they appear,
186
+ * with the later `message_delta` count winning.
187
+ *
188
+ * A tool block that never receives a delta ends as `input: {}`, which is what
189
+ * a zero-argument tool call looks like unstreamed.
190
+ */
191
+ export declare function accumulateAnthropic(events: readonly AnthropicStreamEvent[]): AnthropicMessage;
192
+ /**
193
+ * One part of a Gemini candidate — text or a function call.
194
+ *
195
+ * `args` is `unknown` rather than a record because that is what the SDK's own
196
+ * `FunctionCall.args` widens to (`object`), and the brain has always narrowed
197
+ * it with a cast at the point of use. Declaring it narrower here would only
198
+ * move the cast, not remove it.
199
+ */
200
+ export interface GeminiPart {
201
+ text?: string;
202
+ functionCall?: {
203
+ name: string;
204
+ args?: unknown;
205
+ };
206
+ }
207
+ /** What the Gemini brain reads off a response — streamed or not. */
208
+ export interface GeminiResponse {
209
+ candidates?: Array<{
210
+ content?: {
211
+ parts?: GeminiPart[];
212
+ };
213
+ }>;
214
+ usageMetadata?: {
215
+ promptTokenCount?: number;
216
+ candidatesTokenCount?: number;
217
+ };
218
+ }
219
+ /**
220
+ * The text this stream item carries, across ALL of its text parts.
221
+ *
222
+ * **This is the one extractor of the four that must fold**, and the fold is not
223
+ * defensive: Gemini's stream element carries an ARRAY of parts, and it really
224
+ * does put several — a sentence, a function call, a trailing sentence — into
225
+ * one candidate. Return `parts[0].text` and the watcher sees the first fragment
226
+ * of a paragraph while the payload holds all of it, which is the precise shape
227
+ * of "the stream lied to the user's face".
228
+ */
229
+ export declare function geminiDelta(item: GeminiResponse): string;
230
+ /**
231
+ * Fold Gemini stream items into the response they describe.
232
+ *
233
+ * **The text parts MERGE into one.** This is the whole reason this function is
234
+ * not a flat concatenation of `parts` arrays: the brain finds its answer with
235
+ * `parts.find(p => "text" in p)`, so a response left as fifty little text parts
236
+ * would return the first syllable of the answer and drop the rest. One part in,
237
+ * one part out — the same shape `generateContent` returns.
238
+ *
239
+ * The merged text keeps the POSITION of the first text part it saw, so a
240
+ * response that opened with a function call still reads in emitted order.
241
+ * (Order between text and calls does not reach the payload — the brain collects
242
+ * calls by filter and text by find — but a shape that reorders parts for no
243
+ * reason is a shape that will eventually be believed.)
244
+ *
245
+ * Usage is cumulative in Gemini's stream: the last item that carries it wins.
246
+ */
247
+ export declare function accumulateGemini(items: readonly GeminiResponse[]): GeminiResponse;
248
+ /** One NDJSON line off `/api/chat` with `stream: true`. */
249
+ export interface OllamaStreamLine {
250
+ message?: {
251
+ content?: string;
252
+ tool_calls?: Array<{
253
+ function?: {
254
+ name?: string;
255
+ arguments?: Record<string, unknown>;
256
+ };
257
+ }>;
258
+ };
259
+ prompt_eval_count?: number;
260
+ eval_count?: number;
261
+ }
262
+ /** What the Ollama brain reads off `/api/chat` — streamed or not. */
263
+ export interface OllamaChatResponse {
264
+ message?: {
265
+ content?: string;
266
+ tool_calls?: Array<{
267
+ function?: {
268
+ name?: string;
269
+ arguments?: Record<string, unknown>;
270
+ };
271
+ }>;
272
+ };
273
+ prompt_eval_count?: number;
274
+ eval_count?: number;
275
+ }
276
+ /**
277
+ * The text this line carries.
278
+ *
279
+ * **Why no fold here.** One NDJSON line carries one `message`, and that message
280
+ * carries one `content` string — neither is a list, so there is nothing to
281
+ * concatenate within a line. Ollama's multiplicity is one token per LINE, which
282
+ * is `accumulateOllama`'s job.
283
+ */
284
+ export declare function ollamaDelta(line: OllamaStreamLine): string;
285
+ /**
286
+ * Split whatever has arrived into whole NDJSON lines plus the leftover.
287
+ *
288
+ * Pure, and separate from the reader, because the bug this prevents is invisible
289
+ * in a happy-path test: a chunk boundary can fall anywhere, including the middle
290
+ * of a JSON object, and a splitter that forgets `rest` silently drops the token
291
+ * that straddled the seam. Blank lines are dropped; the trailing fragment is
292
+ * always handed back to be prefixed onto the next read.
293
+ */
294
+ export declare function splitNdjsonFrame(buffer: string): {
295
+ lines: string[];
296
+ rest: string;
297
+ };
298
+ /**
299
+ * Fold NDJSON lines into the single response `stream: false` would return.
300
+ *
301
+ * Ollama streams content token by token, but hands over tool calls whole (with
302
+ * `arguments` already an object, not a JSON string) — so calls are collected,
303
+ * never concatenated. Counts arrive on the final line; last one wins.
304
+ */
305
+ export declare function accumulateOllama(lines: readonly OllamaStreamLine[]): OllamaChatResponse;
306
+ /**
307
+ * Read an NDJSON body to the end, emitting text as it arrives.
308
+ *
309
+ * The Ollama brain owns no SDK, so this is the equivalent of `consumeStream`
310
+ * for a raw `fetch` body: decode, split on newlines, parse, emit, keep the
311
+ * remainder. A line that will not parse is skipped rather than thrown — a
312
+ * local server that emits a stray keep-alive should not fail a run — and the
313
+ * abort check sits before the emit for the same reason it does upstream.
314
+ */
315
+ export declare function consumeNdjson(body: AsyncIterable<Uint8Array> | ReadableStream<Uint8Array>, sink: ChunkSink, signal?: AbortSignal): Promise<OllamaStreamLine[]>;