llm-sse 0.4.7 → 0.4.9
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/CHANGELOG.md +12 -0
- package/README.md +175 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,18 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.9] - 2026-06-29
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Documentation: replace em dashes with standard punctuation for consistent house style.
|
|
14
|
+
|
|
15
|
+
## [0.4.8] - 2026-06-29
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Documentation: add provider-SDK recipes (OpenAI, Anthropic, Vercel AI SDK), a "why not alternatives" section, complete cross-links to the sibling packages, and install / bundle-size badges. No code changes.
|
|
20
|
+
|
|
9
21
|
## [0.4.7] - 2026-06-29
|
|
10
22
|
|
|
11
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -4,18 +4,24 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/llm-sse)
|
|
5
5
|
[](https://github.com/slegarraga/llm-sse/actions/workflows/ci.yml)
|
|
6
6
|
[](https://scorecard.dev/viewer/?uri=github.com/slegarraga/llm-sse)
|
|
7
|
+
[](https://packagephobia.com/result?p=llm-sse)
|
|
8
|
+
[](https://bundlephobia.com/package/llm-sse)
|
|
7
9
|
[](./LICENSE)
|
|
8
10
|
[](./package.json)
|
|
9
11
|
|
|
10
|
-
>
|
|
12
|
+
> Zero-dependency SSE parser that turns OpenAI, Anthropic, Gemini and any OpenAI-compatible stream into one unified event shape: text deltas, reasoning, tool-call fragments and finish reasons, handled the same way regardless of provider.
|
|
11
13
|
|
|
12
|
-
Security posture is tracked in [docs/security-posture.md](
|
|
14
|
+
Security posture is tracked in [docs/security-posture.md](https://github.com/slegarraga/llm-sse/blob/main/docs/security-posture.md),
|
|
13
15
|
including CodeQL, OpenSSF Scorecard, Dependabot and branch rules.
|
|
14
16
|
|
|
15
|
-
Each provider streams differently. OpenAI sends `choices[].delta` chunks, Anthropic sends typed `content_block_*` / `message_*` events, Gemini sends `candidates[].content.parts
|
|
17
|
+
Each provider streams differently. OpenAI sends `choices[].delta` chunks, Anthropic sends typed `content_block_*` / `message_*` events, Gemini sends `candidates[].content.parts`, and the SSE framing, tool-call argument fragments and stop reasons are all shaped differently. `llm-sse` turns any of them into the same small set of events, so your streaming UI or agent loop stays provider-agnostic.
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
### OpenAI
|
|
16
22
|
|
|
17
23
|
```ts
|
|
18
|
-
import { parseOpenAIStream
|
|
24
|
+
import { parseOpenAIStream } from 'llm-sse';
|
|
19
25
|
|
|
20
26
|
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
21
27
|
method: 'POST',
|
|
@@ -23,7 +29,80 @@ const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
|
23
29
|
Authorization: `Bearer ${key}`,
|
|
24
30
|
'content-type': 'application/json',
|
|
25
31
|
},
|
|
26
|
-
body: JSON.stringify({ model, messages, stream: true }),
|
|
32
|
+
body: JSON.stringify({ model: 'gpt-4o', messages, stream: true }),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
for await (const event of parseOpenAIStream(res.body)) {
|
|
36
|
+
if (event.type === 'text') process.stdout.write(event.text);
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Anthropic
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { parseAnthropicStream } from 'llm-sse';
|
|
44
|
+
|
|
45
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: {
|
|
48
|
+
'x-api-key': key,
|
|
49
|
+
'anthropic-version': '2023-06-01',
|
|
50
|
+
'content-type': 'application/json',
|
|
51
|
+
},
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
model: 'claude-opus-4-8',
|
|
54
|
+
max_tokens: 1024,
|
|
55
|
+
stream: true,
|
|
56
|
+
messages,
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
for await (const event of parseAnthropicStream(res.body)) {
|
|
61
|
+
if (event.type === 'text') process.stdout.write(event.text);
|
|
62
|
+
if (event.type === 'reasoning') process.stderr.write(event.text); // extended thinking
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Gemini
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { parseGeminiStream } from 'llm-sse';
|
|
70
|
+
|
|
71
|
+
// Use the SSE endpoint: streamGenerateContent?alt=sse
|
|
72
|
+
const res = await fetch(
|
|
73
|
+
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse&key=${key}`,
|
|
74
|
+
{
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'content-type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
for await (const event of parseGeminiStream(res.body)) {
|
|
84
|
+
if (event.type === 'text') process.stdout.write(event.text);
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### OpenAI-compatible providers (Groq, DeepSeek, Ollama, OpenRouter...)
|
|
89
|
+
|
|
90
|
+
Any provider that speaks the OpenAI chunk format works with `parseOpenAIStream`. DeepSeek R1 reasoning tokens surface as `reasoning` events automatically.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { parseOpenAIStream } from 'llm-sse';
|
|
94
|
+
|
|
95
|
+
const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: {
|
|
98
|
+
Authorization: `Bearer ${key}`,
|
|
99
|
+
'content-type': 'application/json',
|
|
100
|
+
},
|
|
101
|
+
body: JSON.stringify({
|
|
102
|
+
model: 'llama-3.3-70b-versatile',
|
|
103
|
+
messages,
|
|
104
|
+
stream: true,
|
|
105
|
+
}),
|
|
27
106
|
});
|
|
28
107
|
|
|
29
108
|
for await (const event of parseOpenAIStream(res.body)) {
|
|
@@ -33,13 +112,17 @@ for await (const event of parseOpenAIStream(res.body)) {
|
|
|
33
112
|
|
|
34
113
|
## Why
|
|
35
114
|
|
|
36
|
-
- **One event shape, three providers.** `text`, `tool_call_start`, `tool_call_delta`, `finish`, `error
|
|
115
|
+
- **One event shape, three providers.** `text`, `tool_call_start`, `tool_call_delta`, `finish`, `error`, the same whether the bytes came from OpenAI, Anthropic or Gemini.
|
|
37
116
|
- **Tool calls just accumulate.** Streamed JSON argument fragments carry an `index`; concatenate by index (or let `collectStream` do it) to get the full call.
|
|
38
117
|
- **Correct SSE framing.** Robust to chunk boundaries splitting a line or event mid-way, CRLF, multi-line `data:` fields, comments and keep-alives.
|
|
39
118
|
- **Fixture-backed provider coverage.** Public OpenAI, Anthropic and Gemini `.sse` fixtures exercise text, reasoning, tool-call arguments and finish reasons.
|
|
40
|
-
- **Bytes or strings.** Feed it a `fetch()` `ReadableStream<Uint8Array>`, a Node stream, or an async iterable of strings
|
|
119
|
+
- **Bytes or strings.** Feed it a `fetch()` `ReadableStream<Uint8Array>`, a Node stream, or an async iterable of strings. Multibyte UTF-8 split across chunks is handled.
|
|
41
120
|
- **Zero dependencies**, ESM + CJS, fully typed.
|
|
42
121
|
|
|
122
|
+
## Why not the provider SDK?
|
|
123
|
+
|
|
124
|
+
The official SDKs (openai, @anthropic-ai/sdk, @google/generative-ai) each ship their own streaming abstraction, so combining two providers means learning two APIs and writing two adapter paths in your agent or UI. `llm-sse` is a thin, zero-dependency layer that normalizes the wire format only. You keep your own fetch/retry/auth logic, and every provider becomes the same three event types. If you are already using a provider SDK exclusively and do not plan to switch, the SDK's streaming helpers may be sufficient; this library is most useful when you need provider portability, a minimal footprint, or control over the HTTP layer.
|
|
125
|
+
|
|
43
126
|
## Install
|
|
44
127
|
|
|
45
128
|
```sh
|
|
@@ -50,11 +133,11 @@ npm install llm-sse
|
|
|
50
133
|
|
|
51
134
|
### `parseOpenAIStream(source)` · `parseAnthropicStream(source)` · `parseGeminiStream(source)`
|
|
52
135
|
|
|
53
|
-
Each takes a `source` (`AsyncIterable<Uint8Array | string
|
|
136
|
+
Each takes a `source` (`AsyncIterable<Uint8Array | string>`, `fetch().body` satisfies this) and returns an `AsyncGenerator<StreamEvent>`.
|
|
54
137
|
|
|
55
138
|
> Gemini: use the SSE form of the streaming endpoint (`streamGenerateContent?alt=sse`).
|
|
56
139
|
|
|
57
|
-
> **OpenAI-compatible providers** (Groq, DeepSeek, OpenRouter, Together, Fireworks, Ollama,
|
|
140
|
+
> **OpenAI-compatible providers** (Groq, DeepSeek, OpenRouter, Together, Fireworks, Ollama, ...) emit the same chunk format; use `parseOpenAIStream` for them. Reasoning models that stream `reasoning_content` (e.g. DeepSeek R1) surface as `reasoning` events.
|
|
58
141
|
|
|
59
142
|
### `parseStream(source, provider)`
|
|
60
143
|
|
|
@@ -72,7 +155,7 @@ type StreamEvent =
|
|
|
72
155
|
| { type: 'error'; error: unknown };
|
|
73
156
|
```
|
|
74
157
|
|
|
75
|
-
> `reasoning` carries the model's thinking
|
|
158
|
+
> `reasoning` carries the model's thinking (Anthropic extended thinking `thinking_delta` and Gemini `thought` parts) separately from `text`, so you can render it in its own affordance or drop it.
|
|
76
159
|
|
|
77
160
|
### `collectStream(events)`
|
|
78
161
|
|
|
@@ -82,12 +165,12 @@ Drains an event stream into a single message:
|
|
|
82
165
|
const { text, reasoning, toolCalls, finishReason } = await collectStream(
|
|
83
166
|
parseAnthropicStream(res.body),
|
|
84
167
|
);
|
|
85
|
-
// toolCalls: { index, id?, name?, arguments }[]
|
|
168
|
+
// toolCalls: { index, id?, name?, arguments }[], arguments is the joined JSON string
|
|
86
169
|
```
|
|
87
170
|
|
|
88
171
|
### `toAssistantMessage(collected)`
|
|
89
172
|
|
|
90
|
-
Turn a collected stream into a standard OpenAI-shape assistant message
|
|
173
|
+
Turn a collected stream into a standard OpenAI-shape assistant message, the format `llm-messages` treats as canonical, so a streamed response composes straight back into your history or into a different provider:
|
|
91
174
|
|
|
92
175
|
```ts
|
|
93
176
|
import { collectStream, toAssistantMessage } from 'llm-sse';
|
|
@@ -103,6 +186,81 @@ const claudeBody = toAnthropic([...history, message]); // continue on Claude
|
|
|
103
186
|
|
|
104
187
|
The underlying SSE parser, exported for advanced use: yields the `data` payload of each event as a string.
|
|
105
188
|
|
|
189
|
+
## Recipes
|
|
190
|
+
|
|
191
|
+
### Consume a Node.js `http` / `https` response body
|
|
192
|
+
|
|
193
|
+
Node `IncomingMessage` is an async iterable of `Buffer`, which satisfies `AsyncIterable<Uint8Array>`:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import https from 'node:https';
|
|
197
|
+
import { parseOpenAIStream } from 'llm-sse';
|
|
198
|
+
|
|
199
|
+
function streamCompletion(options: https.RequestOptions, body: string) {
|
|
200
|
+
return new Promise<void>((resolve, reject) => {
|
|
201
|
+
const req = https.request(options, async (res) => {
|
|
202
|
+
for await (const event of parseOpenAIStream(res)) {
|
|
203
|
+
if (event.type === 'text') process.stdout.write(event.text);
|
|
204
|
+
}
|
|
205
|
+
resolve();
|
|
206
|
+
});
|
|
207
|
+
req.on('error', reject);
|
|
208
|
+
req.write(body);
|
|
209
|
+
req.end();
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Agent tool-call loop
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
import { parseOpenAIStream, collectStream, toAssistantMessage } from 'llm-sse';
|
|
218
|
+
|
|
219
|
+
async function agentLoop(messages: unknown[]) {
|
|
220
|
+
while (true) {
|
|
221
|
+
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: {
|
|
224
|
+
Authorization: `Bearer ${key}`,
|
|
225
|
+
'content-type': 'application/json',
|
|
226
|
+
},
|
|
227
|
+
body: JSON.stringify({ model: 'gpt-4o', messages, tools, stream: true }),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const collected = await collectStream(parseOpenAIStream(res.body));
|
|
231
|
+
messages.push(toAssistantMessage(collected));
|
|
232
|
+
|
|
233
|
+
if (!collected.toolCalls.length) {
|
|
234
|
+
console.log(collected.text);
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const call of collected.toolCalls) {
|
|
239
|
+
const result = await dispatch(call.name, JSON.parse(call.arguments));
|
|
240
|
+
messages.push({
|
|
241
|
+
role: 'tool',
|
|
242
|
+
tool_call_id: call.id,
|
|
243
|
+
content: JSON.stringify(result),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Provider-agnostic wrapper
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
import { parseStream, collectStream, type Provider } from 'llm-sse';
|
|
254
|
+
|
|
255
|
+
async function complete(provider: Provider, fetchBody: Response) {
|
|
256
|
+
return collectStream(parseStream(fetchBody.body, provider));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// same call site for any provider
|
|
260
|
+
const result = await complete('anthropic', anthropicResponse);
|
|
261
|
+
const result2 = await complete('openai', openaiResponse);
|
|
262
|
+
```
|
|
263
|
+
|
|
106
264
|
## Caveats
|
|
107
265
|
|
|
108
266
|
- Non-JSON `data:` payloads are treated as keep-alives and skipped by provider parsers.
|
|
@@ -117,7 +275,7 @@ All three providers are normalized to the same pattern: a `tool_call_start` (wit
|
|
|
117
275
|
|
|
118
276
|
## Fixture corpus
|
|
119
277
|
|
|
120
|
-
The package includes a small public fixture corpus under [`fixtures/`](
|
|
278
|
+
The package includes a small public fixture corpus under [`fixtures/`](https://github.com/slegarraga/llm-sse/tree/main/fixtures):
|
|
121
279
|
|
|
122
280
|
- `openai-weather-tool.sse`
|
|
123
281
|
- `anthropic-weather-tool.sse`
|
|
@@ -131,9 +289,10 @@ so contributors can change parsers with a stable cross-provider contract.
|
|
|
131
289
|
|
|
132
290
|
## Related
|
|
133
291
|
|
|
134
|
-
- [`
|
|
135
|
-
- [`
|
|
136
|
-
- [`llm-
|
|
292
|
+
- [`json-from-llm`](https://www.npmjs.com/package/json-from-llm): extract valid JSON from an LLM response, even inside reasoning tags, fenced blocks or prose
|
|
293
|
+
- [`tool-schema`](https://www.npmjs.com/package/tool-schema): convert a JSON Schema into a provider tool / function-calling schema for OpenAI, Anthropic, Gemini and MCP
|
|
294
|
+
- [`llm-messages`](https://www.npmjs.com/package/llm-messages): convert chat messages between OpenAI, Anthropic and Gemini formats
|
|
295
|
+
- [`llm-errors`](https://www.npmjs.com/package/llm-errors): normalize provider errors (rate limits, retries, status) into one shape
|
|
137
296
|
|
|
138
297
|
## License
|
|
139
298
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llm-sse",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"description": "Parse streaming SSE responses from OpenAI, Anthropic, Gemini and OpenAI-compatible providers into one unified event format. Text, reasoning and tool-call deltas. Zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openai",
|