llm-stream-assemble 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +390 -0
  3. package/dist/adapters/anthropic.cjs +312 -0
  4. package/dist/adapters/anthropic.cjs.map +1 -0
  5. package/dist/adapters/anthropic.d.cts +11 -0
  6. package/dist/adapters/anthropic.d.ts +11 -0
  7. package/dist/adapters/anthropic.js +310 -0
  8. package/dist/adapters/anthropic.js.map +1 -0
  9. package/dist/adapters/openai-chat.cjs +499 -0
  10. package/dist/adapters/openai-chat.cjs.map +1 -0
  11. package/dist/adapters/openai-chat.d.cts +9 -0
  12. package/dist/adapters/openai-chat.d.ts +9 -0
  13. package/dist/adapters/openai-chat.js +497 -0
  14. package/dist/adapters/openai-chat.js.map +1 -0
  15. package/dist/adapters/openai-compatible.cjs +519 -0
  16. package/dist/adapters/openai-compatible.cjs.map +1 -0
  17. package/dist/adapters/openai-compatible.d.cts +15 -0
  18. package/dist/adapters/openai-compatible.d.ts +15 -0
  19. package/dist/adapters/openai-compatible.js +517 -0
  20. package/dist/adapters/openai-compatible.js.map +1 -0
  21. package/dist/adapters/openai-responses.cjs +444 -0
  22. package/dist/adapters/openai-responses.cjs.map +1 -0
  23. package/dist/adapters/openai-responses.d.cts +8 -0
  24. package/dist/adapters/openai-responses.d.ts +8 -0
  25. package/dist/adapters/openai-responses.js +442 -0
  26. package/dist/adapters/openai-responses.js.map +1 -0
  27. package/dist/core/index.cjs +816 -0
  28. package/dist/core/index.cjs.map +1 -0
  29. package/dist/core/index.d.cts +18 -0
  30. package/dist/core/index.d.ts +18 -0
  31. package/dist/core/index.js +808 -0
  32. package/dist/core/index.js.map +1 -0
  33. package/dist/index.cjs +2238 -0
  34. package/dist/index.cjs.map +1 -0
  35. package/dist/index.d.cts +66 -0
  36. package/dist/index.d.ts +66 -0
  37. package/dist/index.js +2206 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/types-CskRfrmD.d.cts +176 -0
  40. package/dist/types-CskRfrmD.d.ts +176 -0
  41. package/package.json +95 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ladislav Kostolny <01laky@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,390 @@
1
+ # llm-stream-assemble
2
+
3
+ ![core](https://img.shields.io/badge/core-1.0.0-blue)
4
+ ![node](https://img.shields.io/badge/node-%3E%3D18-339933)
5
+ ![runtime deps](https://img.shields.io/badge/runtime_deps-0-brightgreen)
6
+ ![tests](https://img.shields.io/badge/tests-547%2B_passing-brightgreen)
7
+ [![ci](https://github.com/01laky/llm-stream-assemble/actions/workflows/ci.yml/badge.svg)](https://github.com/01laky/llm-stream-assemble/actions/workflows/ci.yml)
8
+ ![status](https://img.shields.io/badge/status-stable_1.0.0-brightgreen)
9
+
10
+ A zero-dependency TypeScript library that normalizes LLM streaming responses — text, tool calls, reasoning, JSON, usage, errors, and non-streaming payloads — into unified events.
11
+
12
+ **Status:** Stable `1.0.0`. Core, OpenAI Chat, OpenAI-compatible, Anthropic Messages, OpenAI Responses adapters, transforms, replay helpers, and examples are production-ready. Pin semver ranges as usual and review [CHANGELOG.md](./CHANGELOG.md) before major upgrades.
13
+
14
+ > A zero-dependency TypeScript layer for assembling OpenAI, Anthropic, and compatible LLM streams into unified events for text, tool calls, reasoning, JSON, usage, errors, and non-streaming responses - so you can stop hand-rolling provider parsers and keep one clean, typed event model across LLM apps, agents, proxies, and backends.
15
+
16
+ ## How it works
17
+
18
+ Raw provider bytes enter through a **thin adapter**, get assembled into **typed events**, and leave through the same transform layer whether you stream live, replay fixtures, or proxy to a browser.
19
+
20
+ ```mermaid
21
+ flowchart TB
22
+ subgraph PROVIDERS["LLM providers"]
23
+ direction LR
24
+ PC["OpenAI Chat"]
25
+ PA["Anthropic Messages"]
26
+ PO["OpenAI-compatible hosts"]
27
+ PR["OpenAI Responses"]
28
+ end
29
+
30
+ subgraph ADAPTERS["Adapters · parseChunk / parseResponse"]
31
+ direction LR
32
+ AC["openaiChatAdapter"]
33
+ AA["anthropicAdapter"]
34
+ AO["openaiCompatibleAdapter"]
35
+ AR["openaiResponsesAdapter"]
36
+ end
37
+
38
+ subgraph CORE["Core assembly · provider-agnostic"]
39
+ direction TB
40
+ STREAM(["ReadableStream / SSE"])
41
+ JSON(["JSON response body"])
42
+ PARSE["parseSSE · parsePartialJSON"]
43
+ RAW["RawChunk[]"]
44
+ ASM["EventAssembler"]
45
+ EVENTS(["StreamEvent stream"])
46
+ STREAM --> PARSE --> RAW --> ASM --> EVENTS
47
+ JSON --> RAW
48
+ end
49
+
50
+ subgraph TRANSFORMS["Transforms & helpers"]
51
+ direction LR
52
+ COL["collectStream"]
53
+ TAP["tapEvents"]
54
+ SSEOUT["toSSE"]
55
+ REPLAY["assembleFromFile"]
56
+ end
57
+
58
+ subgraph APPS["Your application"]
59
+ direction LR
60
+ UI["Chat UI"]
61
+ AGENT["Agents & tool routing"]
62
+ PROXY["Backend proxy"]
63
+ OBS["Logs & metrics"]
64
+ end
65
+
66
+ PC --> AC
67
+ PA --> AA
68
+ PO --> AO
69
+ PR --> AR
70
+ PARSE --> AC
71
+ PARSE --> AA
72
+ PARSE --> AO
73
+ PARSE --> AR
74
+ AC --> RAW
75
+ AA --> RAW
76
+ AO --> RAW
77
+ AR --> RAW
78
+ JSON --> AC
79
+ JSON --> AA
80
+ JSON --> AO
81
+ JSON --> AR
82
+ EVENTS --> COL
83
+ EVENTS --> TAP
84
+ EVENTS --> SSEOUT
85
+ EVENTS --> REPLAY
86
+ COL --> UI
87
+ COL --> AGENT
88
+ SSEOUT --> PROXY
89
+ TAP --> OBS
90
+
91
+ classDef provider fill:#0f172a,stroke:#38bdf8,stroke-width:2px,color:#e0f2fe
92
+ classDef adapter fill:#1e1b4b,stroke:#818cf8,stroke-width:2px,color:#ede9fe
93
+ classDef core fill:#042f2e,stroke:#2dd4bf,stroke-width:2px,color:#ccfbf1
94
+ classDef io fill:#134e4a,stroke:#5eead4,stroke-width:2px,color:#f0fdfa
95
+ classDef transform fill:#431407,stroke:#fb923c,stroke-width:2px,color:#ffedd5
96
+ classDef app fill:#172554,stroke:#60a5fa,stroke-width:2px,color:#dbeafe
97
+
98
+ class PC,PA,PO,PR provider
99
+ class AC,AA,AO,AR adapter
100
+ class PARSE,RAW,ASM core
101
+ class STREAM,JSON,EVENTS io
102
+ class COL,TAP,SSEOUT,REPLAY transform
103
+ class UI,AGENT,PROXY,OBS app
104
+ ```
105
+
106
+ Every adapter maps provider-specific fragments into the same **`StreamEvent`** union — one event model for streaming and non-streaming code paths:
107
+
108
+ ```mermaid
109
+ mindmap
110
+ root((StreamEvent))
111
+ Text
112
+ text.delta
113
+ text.done
114
+ Reasoning
115
+ reasoning.delta
116
+ reasoning.done
117
+ Tools
118
+ tool_call.start
119
+ tool_call.args.delta
120
+ tool_call.done
121
+ Structured
122
+ json.delta
123
+ json.done
124
+ Control
125
+ message.start
126
+ metadata
127
+ usage
128
+ finish
129
+ error
130
+ Safety
131
+ refusal.delta
132
+ refusal.done
133
+ ```
134
+
135
+ **Design constraints:** adapters never accumulate cross-chunk state beyond id/index reconciliation; assembly, buffering, and `.done` emission live in core. No HTTP client, no tool execution, no UI — just the stream layer.
136
+
137
+ ## Install
138
+
139
+ ```bash
140
+ pnpm add llm-stream-assemble
141
+ # or npm install llm-stream-assemble
142
+ ```
143
+
144
+ ## Requirements
145
+
146
+ - Node.js 18+
147
+
148
+ ## Documentation
149
+
150
+ - [Product & technical proposal](./docs/proposal.md)
151
+ - [Post-1.0 provider roadmap (proposal)](./docs/post-1.0-provider-roadmap.md)
152
+ - [Provider compatibility matrix](./docs/compatibility.md)
153
+ - [Adapter author guide](./docs/adapter-guide.md)
154
+
155
+ ## Core Usage
156
+
157
+ The core pipeline works with any adapter that emits `RawChunk[]`, including the built-in OpenAI Chat, OpenAI-compatible, Anthropic Messages, and OpenAI Responses adapters:
158
+
159
+ ```ts
160
+ import { assembleFromPayloads, type StreamAdapter } from "llm-stream-assemble";
161
+
162
+ const adapter: StreamAdapter = {
163
+ parseChunk(raw) {
164
+ const data = JSON.parse(raw) as { text?: string };
165
+ return data.text ? [{ kind: "text-delta", text: data.text }] : [];
166
+ },
167
+ };
168
+
169
+ for await (const event of assembleFromPayloads(payloads, adapter)) {
170
+ if (event.type === "text.delta") process.stdout.write(event.text);
171
+ }
172
+ ```
173
+
174
+ Assembly buffers completed text, reasoning, JSON, and tool-call arguments so it can emit final `.done` events. Use `maxBufferBytes` to cap those buffers for untrusted or unusually large streams.
175
+
176
+ ## Quickstart
177
+
178
+ ```ts
179
+ import { assembleStream, openaiChatAdapter } from "llm-stream-assemble";
180
+
181
+ for await (const event of assembleStream(response.body!, openaiChatAdapter())) {
182
+ if (event.type === "text.delta") process.stdout.write(event.text);
183
+ }
184
+ ```
185
+
186
+ ## OpenAI Chat Usage
187
+
188
+ `openaiChatAdapter()` parses OpenAI Chat Completions payloads. Create one adapter instance per request/stream because it keeps minimal state for metadata and tool-call indexes.
189
+
190
+ ```ts
191
+ import { assembleStream, openaiChatAdapter } from "llm-stream-assemble";
192
+
193
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
194
+ method: "POST",
195
+ headers: {
196
+ Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
197
+ "Content-Type": "application/json",
198
+ },
199
+ body: JSON.stringify({
200
+ model: "gpt-4o-mini",
201
+ messages,
202
+ stream: true,
203
+ stream_options: { include_usage: true },
204
+ }),
205
+ });
206
+
207
+ for await (const event of assembleStream(response.body!, openaiChatAdapter())) {
208
+ if (event.type === "text.delta") process.stdout.write(event.text);
209
+ }
210
+ ```
211
+
212
+ Streaming usage requires `stream_options: { include_usage: true }` on the OpenAI request. JSON mode content is exposed by OpenAI as normal content deltas, so use `openaiChatAdapter({ jsonMode: true })` when you want content mapped to `json.*` events.
213
+
214
+ ## OpenAI-Compatible Usage
215
+
216
+ `openaiCompatibleAdapter()` supports OpenAI-shaped Chat Completions APIs with best-effort provider presets. Create one adapter instance per request/stream.
217
+
218
+ ```ts
219
+ import { assembleStream, openaiCompatibleAdapter } from "llm-stream-assemble";
220
+
221
+ const adapter = openaiCompatibleAdapter({
222
+ provider: "openrouter",
223
+ });
224
+
225
+ for await (const event of assembleStream(response.body!, adapter)) {
226
+ if (event.type === "text.delta") process.stdout.write(event.text);
227
+ }
228
+ ```
229
+
230
+ Provider presets:
231
+
232
+ | Preset | Intended hosts | Notes |
233
+ | ------------ | ----------------------------- | ----------------------------------------------------------- |
234
+ | `generic` | Any OpenAI-shaped API | Loose defaults, best first try |
235
+ | `openrouter` | OpenRouter | Mostly OpenAI-shaped; provider-specific metadata may appear |
236
+ | `groq` | Groq OpenAI-compatible API | OpenAI-like; usage can vary by endpoint/model |
237
+ | `ollama` | Ollama `/v1/chat/completions` | Local host, metadata may be sparse |
238
+ | `lmstudio` | LM Studio local server | Local host, metadata/usage may be sparse |
239
+ | `together` | Together AI | OpenAI-like, reasoning fields may vary |
240
+ | `fireworks` | Fireworks AI | OpenAI-like, usage/details may vary |
241
+
242
+ Strict vs loose configuration:
243
+
244
+ ```ts
245
+ // Loose default: good for local/open-source OpenAI-compatible hosts.
246
+ openaiCompatibleAdapter({ provider: "ollama" });
247
+
248
+ // Stricter mode: useful when unexpected payload shapes should fail fast.
249
+ openaiCompatibleAdapter({
250
+ provider: "generic",
251
+ allowMissingMetadata: false,
252
+ looseErrorShape: false,
253
+ useChoicePositionFallback: false,
254
+ });
255
+ ```
256
+
257
+ Known limitations:
258
+
259
+ - Provider presets are fixture-tested and best-effort; CI does not call live provider APIs.
260
+ - Hosts can change OpenAI-compatible dialects without notice.
261
+ - Non-string reasoning payloads are skipped.
262
+ - Multi-choice terminal behavior is limited by the current core single terminal finish event.
263
+ - Missing tool ids are tolerated because core can synthesize stable ids by index.
264
+
265
+ ## Anthropic Messages Usage
266
+
267
+ `anthropicAdapter()` parses Anthropic Messages streaming events and non-streaming responses. Create one adapter instance per request/stream.
268
+
269
+ ```ts
270
+ import { anthropicAdapter, assembleStream } from "llm-stream-assemble";
271
+
272
+ for await (const event of assembleStream(response.body!, anthropicAdapter())) {
273
+ if (event.type === "text.delta") process.stdout.write(event.text);
274
+ }
275
+ ```
276
+
277
+ Anthropic tool calls are emitted from `tool_use` content blocks. Fine-grained tool input streaming is supported through `input_json_delta`; partial input may be invalid JSON until the block ends, and core handles those partial previews best-effort. Thinking blocks map to `reasoning.*` events with `variant: "detail"`.
278
+
279
+ ## OpenAI Responses Usage
280
+
281
+ `openaiResponsesAdapter()` parses OpenAI Responses API streaming events and non-streaming response objects. It focuses on output text and function call argument streams; Realtime, audio, and multimodal binary output are out of scope.
282
+
283
+ ```ts
284
+ import { assembleStream, openaiResponsesAdapter } from "llm-stream-assemble";
285
+
286
+ for await (const event of assembleStream(response.body!, openaiResponsesAdapter())) {
287
+ if (event.type === "tool_call.args.delta") console.log(event.delta);
288
+ }
289
+ ```
290
+
291
+ Use `openaiResponsesAdapter({ jsonMode: true })` to map output text to `json.*` events. Reasoning support is best-effort for string summary/detail fields. Create a new adapter instance per stream.
292
+
293
+ ## Collecting a Stream
294
+
295
+ `collectStream()` materializes a full event stream into text, reasoning, refusals, JSON, tool calls, latest usage, and finish reason. It buffers full output in memory and aggregates multi-choice text in event order; it is not a per-choice collector and does not currently collect metadata.
296
+
297
+ ```ts
298
+ import { collectStream } from "llm-stream-assemble";
299
+
300
+ const result = await collectStream(events);
301
+ console.log(result.text, result.toolCalls, result.finishReason);
302
+ ```
303
+
304
+ ## Tapping Events
305
+
306
+ `tapEvents()` lets you observe events for logging or metrics without changing the stream.
307
+
308
+ ```ts
309
+ import { tapEvents } from "llm-stream-assemble";
310
+
311
+ for await (const event of tapEvents(events, (event) => console.debug(event.type))) {
312
+ // consume normally
313
+ }
314
+ ```
315
+
316
+ ## Forwarding Unified SSE
317
+
318
+ `toSSE()` serializes unified `StreamEvent` objects as `data: <json>` SSE messages. It does not currently emit named SSE `event:` fields, and it emits unified event JSON rather than raw provider SSE.
319
+
320
+ ```ts
321
+ import { toSSE } from "llm-stream-assemble";
322
+
323
+ return new Response(toSSE(events, { sanitizeErrors: true }), {
324
+ headers: { "Content-Type": "text/event-stream" },
325
+ });
326
+ ```
327
+
328
+ Use `sanitizeErrors: true` when forwarding events to browsers so raw provider internals are not exposed.
329
+
330
+ ## Replaying Fixtures
331
+
332
+ `assembleFromFile()` is a Node/dev replay helper for local `.sse` and `.json` fixtures. It uses `node:fs/promises`, so avoid it in browser bundles; a dedicated browser/edge entry point can be added later if needed.
333
+
334
+ ```ts
335
+ import { assembleFromFile, openaiChatAdapter } from "llm-stream-assemble";
336
+
337
+ for await (const event of assembleFromFile(
338
+ "test/fixtures/openai-chat/text-basic.sse",
339
+ openaiChatAdapter(),
340
+ )) {
341
+ console.log(event);
342
+ }
343
+ ```
344
+
345
+ ## Examples
346
+
347
+ - `examples/node-fetch/openai-chat.ts`
348
+ - `examples/node-fetch/openai-compatible.ts`
349
+ - `examples/node-fetch/anthropic.ts`
350
+ - `examples/node-fetch/replay-fixture.ts`
351
+ - `examples/proxy-safety/web-standard-proxy.ts`
352
+ - `examples/proxy-safety/browser-client.ts`
353
+
354
+ Proxy safety:
355
+
356
+ - Use `toSSE(events, { sanitizeErrors: true })` for browser-facing streams.
357
+ - Use `tapEvents` for server-side observation and logging.
358
+ - Never forward raw provider errors or upstream non-OK response bodies to browsers.
359
+ - CORS headers are application-specific and intentionally omitted from the Web-standard example.
360
+
361
+ ## Non-goals
362
+
363
+ - No HTTP client, auth, retries, or provider SDK wrapper.
364
+ - No agent loop, tool execution, memory, or persistence.
365
+ - No UI framework, React hooks, or browser components.
366
+ - No multimodal binary/audio/video parsing.
367
+
368
+ ## Development
369
+
370
+ ```bash
371
+ pnpm install
372
+ pnpm verify
373
+ ```
374
+
375
+ Scripts:
376
+
377
+ | Command | Description |
378
+ | ------------------ | -------------------------------------- |
379
+ | `pnpm verify` | lint + typecheck + test + build |
380
+ | `pnpm verify:deps` | fail if runtime dependencies are added |
381
+ | `pnpm test` | Vitest smoke tests |
382
+ | `pnpm build` | tsup → ESM + CJS + declarations |
383
+
384
+ ## Author
385
+
386
+ **Ladislav Kostolny** — [01laky@gmail.com](mailto:01laky@gmail.com) · [GitHub @01laky](https://github.com/01laky)
387
+
388
+ ## License
389
+
390
+ MIT — see [LICENSE](./LICENSE). Copyright (c) 2026 Ladislav Kostolny.