llm-sse 0.1.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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/index.cjs +303 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +99 -0
- package/dist/index.d.ts +99 -0
- package/dist/index.js +271 -0
- package/dist/index.js.map +1 -0
- package/package.json +75 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres
|
|
5
|
+
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-06-03
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `parseOpenAIStream`, `parseAnthropicStream`, `parseGeminiStream` and the
|
|
12
|
+
`parseStream(source, provider)` dispatcher — parse a provider stream into a
|
|
13
|
+
unified `AsyncGenerator<StreamEvent>`.
|
|
14
|
+
- `StreamEvent` model: `text`, `tool_call_start`, `tool_call_delta`, `finish`,
|
|
15
|
+
`error`.
|
|
16
|
+
- `collectStream(events)` — drain a stream into `{ text, toolCalls, finishReason }`.
|
|
17
|
+
- `sseData(source)` — a robust SSE parser exported for advanced use.
|
|
18
|
+
- Handles chunk-boundary splitting, CRLF, multi-line `data:` fields, comments,
|
|
19
|
+
and `Uint8Array` or string sources.
|
|
20
|
+
- Zero runtime dependencies; ESM + CJS builds with type declarations.
|
|
21
|
+
|
|
22
|
+
[0.1.0]: https://github.com/slegarraga/llm-sse/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sebastian Legarraga
|
|
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,88 @@
|
|
|
1
|
+
# llm-sse
|
|
2
|
+
|
|
3
|
+
> Parse streaming responses from OpenAI, Anthropic and Gemini into one unified event format. Text and tool-call deltas, handled. **Zero dependencies.**
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { parseOpenAIStream, collectStream } from 'llm-sse';
|
|
9
|
+
|
|
10
|
+
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
headers: {
|
|
13
|
+
Authorization: `Bearer ${key}`,
|
|
14
|
+
'content-type': 'application/json',
|
|
15
|
+
},
|
|
16
|
+
body: JSON.stringify({ model, messages, stream: true }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
for await (const event of parseOpenAIStream(res.body)) {
|
|
20
|
+
if (event.type === 'text') process.stdout.write(event.text);
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Why
|
|
25
|
+
|
|
26
|
+
- **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.
|
|
27
|
+
- **Tool calls just accumulate.** Streamed JSON argument fragments carry an `index`; concatenate by index (or let `collectStream` do it) to get the full call.
|
|
28
|
+
- **Correct SSE framing.** Robust to chunk boundaries splitting a line or event mid-way, CRLF, multi-line `data:` fields, comments and keep-alives.
|
|
29
|
+
- **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.
|
|
30
|
+
- **Zero dependencies**, ESM + CJS, fully typed.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm install llm-sse
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## API
|
|
39
|
+
|
|
40
|
+
### `parseOpenAIStream(source)` · `parseAnthropicStream(source)` · `parseGeminiStream(source)`
|
|
41
|
+
|
|
42
|
+
Each takes a `source` (`AsyncIterable<Uint8Array | string>` — `fetch().body` satisfies this) and returns an `AsyncGenerator<StreamEvent>`.
|
|
43
|
+
|
|
44
|
+
> Gemini: use the SSE form of the streaming endpoint (`streamGenerateContent?alt=sse`).
|
|
45
|
+
|
|
46
|
+
### `parseStream(source, provider)`
|
|
47
|
+
|
|
48
|
+
Same thing, dispatching on `provider` (`'openai' | 'anthropic' | 'gemini'`).
|
|
49
|
+
|
|
50
|
+
### `StreamEvent`
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
type StreamEvent =
|
|
54
|
+
| { type: 'text'; text: string }
|
|
55
|
+
| { type: 'tool_call_start'; index: number; id?: string; name?: string }
|
|
56
|
+
| { type: 'tool_call_delta'; index: number; argumentsDelta: string }
|
|
57
|
+
| { type: 'finish'; reason?: string }
|
|
58
|
+
| { type: 'error'; error: unknown };
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### `collectStream(events)`
|
|
62
|
+
|
|
63
|
+
Drains an event stream into a single message:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const { text, toolCalls, finishReason } = await collectStream(
|
|
67
|
+
parseAnthropicStream(res.body),
|
|
68
|
+
);
|
|
69
|
+
// toolCalls: { index, id?, name?, arguments }[] — arguments is the joined JSON string
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### `sseData(source)`
|
|
73
|
+
|
|
74
|
+
The underlying SSE parser, exported for advanced use: yields the `data` payload of each event as a string.
|
|
75
|
+
|
|
76
|
+
## Tool calls
|
|
77
|
+
|
|
78
|
+
All three providers are normalized to the same pattern: a `tool_call_start` (with `index`, and `id` / `name` when available) followed by one or more `tool_call_delta`s whose `argumentsDelta` strings concatenate into the call's JSON arguments. OpenAI and Anthropic fragment the arguments; Gemini sends them whole in a single delta. `collectStream` joins them for you.
|
|
79
|
+
|
|
80
|
+
## Related
|
|
81
|
+
|
|
82
|
+
- [`tool-schema`](https://www.npmjs.com/package/tool-schema) — convert a JSON Schema into OpenAI / Anthropic / Gemini / MCP tool schemas.
|
|
83
|
+
- [`llm-messages`](https://www.npmjs.com/package/llm-messages) — convert conversations and responses between providers.
|
|
84
|
+
- [`llm-errors`](https://www.npmjs.com/package/llm-errors) — normalize provider errors into one shape.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT © Sebastian Legarraga
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
collectStream: () => collectStream,
|
|
24
|
+
parseAnthropicStream: () => parseAnthropicStream,
|
|
25
|
+
parseGeminiStream: () => parseGeminiStream,
|
|
26
|
+
parseOpenAIStream: () => parseOpenAIStream,
|
|
27
|
+
parseStream: () => parseStream,
|
|
28
|
+
sseData: () => sseData
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(index_exports);
|
|
31
|
+
|
|
32
|
+
// src/providers/anthropic.ts
|
|
33
|
+
function mapAnthropic(event) {
|
|
34
|
+
const events = [];
|
|
35
|
+
switch (event?.type) {
|
|
36
|
+
case "content_block_start": {
|
|
37
|
+
const block = event.content_block;
|
|
38
|
+
if (block?.type === "tool_use") {
|
|
39
|
+
events.push({
|
|
40
|
+
type: "tool_call_start",
|
|
41
|
+
index: event.index ?? 0,
|
|
42
|
+
id: block.id,
|
|
43
|
+
name: block.name
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case "content_block_delta": {
|
|
49
|
+
const delta = event.delta;
|
|
50
|
+
if (delta?.type === "text_delta" && typeof delta.text === "string") {
|
|
51
|
+
events.push({ type: "text", text: delta.text });
|
|
52
|
+
} else if (delta?.type === "input_json_delta" && typeof delta.partial_json === "string") {
|
|
53
|
+
events.push({
|
|
54
|
+
type: "tool_call_delta",
|
|
55
|
+
index: event.index ?? 0,
|
|
56
|
+
argumentsDelta: delta.partial_json
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case "message_delta": {
|
|
62
|
+
const reason = event.delta?.stop_reason;
|
|
63
|
+
if (reason) {
|
|
64
|
+
events.push({ type: "finish", reason });
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case "error": {
|
|
69
|
+
events.push({ type: "error", error: event.error ?? event });
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return events;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/providers/gemini.ts
|
|
77
|
+
function mapGemini(chunk, state) {
|
|
78
|
+
const events = [];
|
|
79
|
+
const candidate = chunk?.candidates?.[0];
|
|
80
|
+
if (!candidate) {
|
|
81
|
+
return events;
|
|
82
|
+
}
|
|
83
|
+
const parts = candidate.content?.parts;
|
|
84
|
+
if (Array.isArray(parts)) {
|
|
85
|
+
for (const part of parts) {
|
|
86
|
+
if (typeof part.text === "string" && part.text.length > 0) {
|
|
87
|
+
events.push({ type: "text", text: part.text });
|
|
88
|
+
}
|
|
89
|
+
if (part.functionCall) {
|
|
90
|
+
const index = state.toolIndex++;
|
|
91
|
+
events.push({
|
|
92
|
+
type: "tool_call_start",
|
|
93
|
+
index,
|
|
94
|
+
name: part.functionCall.name
|
|
95
|
+
});
|
|
96
|
+
events.push({
|
|
97
|
+
type: "tool_call_delta",
|
|
98
|
+
index,
|
|
99
|
+
argumentsDelta: JSON.stringify(part.functionCall.args ?? {})
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (candidate.finishReason) {
|
|
105
|
+
events.push({ type: "finish", reason: candidate.finishReason });
|
|
106
|
+
}
|
|
107
|
+
return events;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// src/providers/openai.ts
|
|
111
|
+
function mapOpenAI(chunk) {
|
|
112
|
+
const events = [];
|
|
113
|
+
const choice = chunk?.choices?.[0];
|
|
114
|
+
if (!choice) {
|
|
115
|
+
return events;
|
|
116
|
+
}
|
|
117
|
+
const delta = choice.delta;
|
|
118
|
+
if (delta) {
|
|
119
|
+
if (typeof delta.content === "string" && delta.content.length > 0) {
|
|
120
|
+
events.push({ type: "text", text: delta.content });
|
|
121
|
+
}
|
|
122
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
123
|
+
for (const call of delta.tool_calls) {
|
|
124
|
+
const index = typeof call.index === "number" ? call.index : 0;
|
|
125
|
+
if (call.id !== void 0 || call.function?.name !== void 0) {
|
|
126
|
+
events.push({
|
|
127
|
+
type: "tool_call_start",
|
|
128
|
+
index,
|
|
129
|
+
id: call.id,
|
|
130
|
+
name: call.function?.name
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const args = call.function?.arguments;
|
|
134
|
+
if (typeof args === "string" && args.length > 0) {
|
|
135
|
+
events.push({ type: "tool_call_delta", index, argumentsDelta: args });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (choice.finish_reason) {
|
|
141
|
+
events.push({ type: "finish", reason: choice.finish_reason });
|
|
142
|
+
}
|
|
143
|
+
return events;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/sse.ts
|
|
147
|
+
async function* decodeChunks(source) {
|
|
148
|
+
const decoder = new TextDecoder();
|
|
149
|
+
for await (const chunk of source) {
|
|
150
|
+
if (typeof chunk === "string") {
|
|
151
|
+
yield chunk;
|
|
152
|
+
} else {
|
|
153
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
154
|
+
if (text) {
|
|
155
|
+
yield text;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const tail = decoder.decode();
|
|
160
|
+
if (tail) {
|
|
161
|
+
yield tail;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async function* sseData(source) {
|
|
165
|
+
let buffer = "";
|
|
166
|
+
let dataLines = [];
|
|
167
|
+
for await (const text of decodeChunks(source)) {
|
|
168
|
+
buffer += text;
|
|
169
|
+
let newline;
|
|
170
|
+
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
171
|
+
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
|
172
|
+
buffer = buffer.slice(newline + 1);
|
|
173
|
+
if (line === "") {
|
|
174
|
+
if (dataLines.length > 0) {
|
|
175
|
+
yield dataLines.join("\n");
|
|
176
|
+
dataLines = [];
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (line[0] === ":") {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (line.startsWith("data:")) {
|
|
184
|
+
dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const last = buffer.replace(/\r$/, "");
|
|
189
|
+
if (last.startsWith("data:")) {
|
|
190
|
+
dataLines.push(last.slice(5).replace(/^ /, ""));
|
|
191
|
+
}
|
|
192
|
+
if (dataLines.length > 0) {
|
|
193
|
+
yield dataLines.join("\n");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/parse.ts
|
|
198
|
+
async function* parseWith(source, map) {
|
|
199
|
+
for await (const data of sseData(source)) {
|
|
200
|
+
if (data === "[DONE]") {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
let payload;
|
|
204
|
+
try {
|
|
205
|
+
payload = JSON.parse(data);
|
|
206
|
+
} catch {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
for (const event of map(payload)) {
|
|
210
|
+
yield event;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function parseOpenAIStream(source) {
|
|
215
|
+
return parseWith(source, mapOpenAI);
|
|
216
|
+
}
|
|
217
|
+
function parseAnthropicStream(source) {
|
|
218
|
+
return parseWith(source, mapAnthropic);
|
|
219
|
+
}
|
|
220
|
+
async function* parseGeminiStream(source) {
|
|
221
|
+
const state = { toolIndex: 0 };
|
|
222
|
+
for await (const data of sseData(source)) {
|
|
223
|
+
if (data === "[DONE]") {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
let payload;
|
|
227
|
+
try {
|
|
228
|
+
payload = JSON.parse(data);
|
|
229
|
+
} catch {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
for (const event of mapGemini(payload, state)) {
|
|
233
|
+
yield event;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function parseStream(source, provider) {
|
|
238
|
+
switch (provider) {
|
|
239
|
+
case "openai":
|
|
240
|
+
return parseOpenAIStream(source);
|
|
241
|
+
case "anthropic":
|
|
242
|
+
return parseAnthropicStream(source);
|
|
243
|
+
case "gemini":
|
|
244
|
+
return parseGeminiStream(source);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/collect.ts
|
|
249
|
+
async function collectStream(events) {
|
|
250
|
+
let text = "";
|
|
251
|
+
let finishReason;
|
|
252
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
253
|
+
const order = [];
|
|
254
|
+
const ensure = (index) => {
|
|
255
|
+
let call = byIndex.get(index);
|
|
256
|
+
if (!call) {
|
|
257
|
+
call = { index, arguments: "" };
|
|
258
|
+
byIndex.set(index, call);
|
|
259
|
+
order.push(index);
|
|
260
|
+
}
|
|
261
|
+
return call;
|
|
262
|
+
};
|
|
263
|
+
for await (const event of events) {
|
|
264
|
+
switch (event.type) {
|
|
265
|
+
case "text":
|
|
266
|
+
text += event.text;
|
|
267
|
+
break;
|
|
268
|
+
case "tool_call_start": {
|
|
269
|
+
const call = ensure(event.index);
|
|
270
|
+
if (event.id !== void 0) {
|
|
271
|
+
call.id = event.id;
|
|
272
|
+
}
|
|
273
|
+
if (event.name !== void 0) {
|
|
274
|
+
call.name = event.name;
|
|
275
|
+
}
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
case "tool_call_delta":
|
|
279
|
+
ensure(event.index).arguments += event.argumentsDelta;
|
|
280
|
+
break;
|
|
281
|
+
case "finish":
|
|
282
|
+
finishReason = event.reason;
|
|
283
|
+
break;
|
|
284
|
+
case "error":
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
text,
|
|
290
|
+
toolCalls: order.map((index) => byIndex.get(index)),
|
|
291
|
+
finishReason
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
295
|
+
0 && (module.exports = {
|
|
296
|
+
collectStream,
|
|
297
|
+
parseAnthropicStream,
|
|
298
|
+
parseGeminiStream,
|
|
299
|
+
parseOpenAIStream,
|
|
300
|
+
parseStream,
|
|
301
|
+
sseData
|
|
302
|
+
});
|
|
303
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/providers/anthropic.ts","../src/providers/gemini.ts","../src/providers/openai.ts","../src/sse.ts","../src/parse.ts","../src/collect.ts"],"sourcesContent":["export {\n parseStream,\n parseOpenAIStream,\n parseAnthropicStream,\n parseGeminiStream,\n} from './parse.ts';\nexport { collectStream } from './collect.ts';\nexport { sseData } from './sse.ts';\nexport type {\n Provider,\n StreamEvent,\n CollectedMessage,\n CollectedToolCall,\n ChunkSource,\n} from './types.ts';\n","import type { StreamEvent } from '../types.ts';\n\n/**\n * Map one Anthropic Messages stream event into normalized events.\n *\n * Anthropic uses typed events: `content_block_start` opens a text or `tool_use`\n * block at an `index`, `content_block_delta` carries `text_delta` /\n * `input_json_delta` fragments, and `message_delta` carries the `stop_reason`.\n */\nexport function mapAnthropic(event: any): StreamEvent[] {\n const events: StreamEvent[] = [];\n\n switch (event?.type) {\n case 'content_block_start': {\n const block = event.content_block;\n if (block?.type === 'tool_use') {\n events.push({\n type: 'tool_call_start',\n index: event.index ?? 0,\n id: block.id,\n name: block.name,\n });\n }\n break;\n }\n case 'content_block_delta': {\n const delta = event.delta;\n if (delta?.type === 'text_delta' && typeof delta.text === 'string') {\n events.push({ type: 'text', text: delta.text });\n } else if (\n delta?.type === 'input_json_delta' &&\n typeof delta.partial_json === 'string'\n ) {\n events.push({\n type: 'tool_call_delta',\n index: event.index ?? 0,\n argumentsDelta: delta.partial_json,\n });\n }\n break;\n }\n case 'message_delta': {\n const reason = event.delta?.stop_reason;\n if (reason) {\n events.push({ type: 'finish', reason });\n }\n break;\n }\n case 'error': {\n events.push({ type: 'error', error: event.error ?? event });\n break;\n }\n }\n\n return events;\n}\n","import type { StreamEvent } from '../types.ts';\n\n/** Per-stream state for Gemini, which does not number its tool calls. */\nexport interface GeminiState {\n toolIndex: number;\n}\n\n/**\n * Map one Gemini `GenerateContentResponse` chunk into normalized events.\n *\n * Gemini streams `candidates[0].content.parts[]`: a part is either `text` or a\n * complete `functionCall` (`{ name, args }`) — it does not fragment arguments,\n * so the whole `args` object is emitted as a single tool-call delta. Calls are\n * numbered in the order they appear via `state`.\n */\nexport function mapGemini(chunk: any, state: GeminiState): StreamEvent[] {\n const events: StreamEvent[] = [];\n const candidate = chunk?.candidates?.[0];\n if (!candidate) {\n return events;\n }\n\n const parts = candidate.content?.parts;\n if (Array.isArray(parts)) {\n for (const part of parts) {\n if (typeof part.text === 'string' && part.text.length > 0) {\n events.push({ type: 'text', text: part.text });\n }\n if (part.functionCall) {\n const index = state.toolIndex++;\n events.push({\n type: 'tool_call_start',\n index,\n name: part.functionCall.name,\n });\n events.push({\n type: 'tool_call_delta',\n index,\n argumentsDelta: JSON.stringify(part.functionCall.args ?? {}),\n });\n }\n }\n }\n\n if (candidate.finishReason) {\n events.push({ type: 'finish', reason: candidate.finishReason });\n }\n return events;\n}\n","import type { StreamEvent } from '../types.ts';\n\n/**\n * Map one OpenAI `chat.completion.chunk` into normalized events.\n *\n * OpenAI streams a `choices[0].delta`: `content` carries text, and\n * `tool_calls[]` carry an `index`, an `id` + `function.name` on the first\n * fragment, then `function.arguments` fragments thereafter.\n */\nexport function mapOpenAI(chunk: any): StreamEvent[] {\n const events: StreamEvent[] = [];\n const choice = chunk?.choices?.[0];\n if (!choice) {\n return events;\n }\n\n const delta = choice.delta;\n if (delta) {\n if (typeof delta.content === 'string' && delta.content.length > 0) {\n events.push({ type: 'text', text: delta.content });\n }\n if (Array.isArray(delta.tool_calls)) {\n for (const call of delta.tool_calls) {\n const index = typeof call.index === 'number' ? call.index : 0;\n if (call.id !== undefined || call.function?.name !== undefined) {\n events.push({\n type: 'tool_call_start',\n index,\n id: call.id,\n name: call.function?.name,\n });\n }\n const args = call.function?.arguments;\n if (typeof args === 'string' && args.length > 0) {\n events.push({ type: 'tool_call_delta', index, argumentsDelta: args });\n }\n }\n }\n }\n\n if (choice.finish_reason) {\n events.push({ type: 'finish', reason: choice.finish_reason });\n }\n return events;\n}\n","import type { ChunkSource } from './types.ts';\n\n/**\n * Decode a mixed byte/string chunk source into text. `Uint8Array` chunks are\n * decoded with a streaming `TextDecoder` so a multibyte UTF-8 character split\n * across two chunks is reassembled correctly.\n */\nasync function* decodeChunks(source: ChunkSource): AsyncGenerator<string> {\n const decoder = new TextDecoder();\n for await (const chunk of source) {\n if (typeof chunk === 'string') {\n yield chunk;\n } else {\n const text = decoder.decode(chunk, { stream: true });\n if (text) {\n yield text;\n }\n }\n }\n const tail = decoder.decode();\n if (tail) {\n yield tail;\n }\n}\n\n/**\n * Parse a Server-Sent Events stream and yield the `data` payload of each event.\n *\n * Robust to the realities of streamed HTTP: events and lines split across\n * chunk boundaries are buffered until complete, multi-line `data:` fields are\n * joined with `\\n` (per the SSE spec), and comments (`:`) and other fields\n * (`event:`, `id:`, `retry:`) are ignored — the payload's own `type` field is\n * what the provider parsers key on.\n */\nexport async function* sseData(source: ChunkSource): AsyncGenerator<string> {\n let buffer = '';\n let dataLines: string[] = [];\n\n for await (const text of decodeChunks(source)) {\n buffer += text;\n\n let newline: number;\n while ((newline = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.slice(0, newline).replace(/\\r$/, '');\n buffer = buffer.slice(newline + 1);\n\n if (line === '') {\n // Blank line terminates an event.\n if (dataLines.length > 0) {\n yield dataLines.join('\\n');\n dataLines = [];\n }\n continue;\n }\n if (line[0] === ':') {\n continue; // comment\n }\n if (line.startsWith('data:')) {\n dataLines.push(line.slice(5).replace(/^ /, ''));\n }\n }\n }\n\n // A final event may arrive without a trailing blank line.\n const last = buffer.replace(/\\r$/, '');\n if (last.startsWith('data:')) {\n dataLines.push(last.slice(5).replace(/^ /, ''));\n }\n if (dataLines.length > 0) {\n yield dataLines.join('\\n');\n }\n}\n","import { mapAnthropic } from './providers/anthropic.ts';\nimport { mapGemini } from './providers/gemini.ts';\nimport { mapOpenAI } from './providers/openai.ts';\nimport { sseData } from './sse.ts';\nimport type { ChunkSource, Provider, StreamEvent } from './types.ts';\n\n/** Shared SSE-to-events driver for the stateless providers. */\nasync function* parseWith(\n source: ChunkSource,\n map: (payload: any) => StreamEvent[],\n): AsyncGenerator<StreamEvent> {\n for await (const data of sseData(source)) {\n if (data === '[DONE]') {\n return;\n }\n let payload: unknown;\n try {\n payload = JSON.parse(data);\n } catch {\n continue; // ignore keep-alive / non-JSON data lines\n }\n for (const event of map(payload)) {\n yield event;\n }\n }\n}\n\n/** Parse an OpenAI Chat Completions stream into normalized events. */\nexport function parseOpenAIStream(\n source: ChunkSource,\n): AsyncGenerator<StreamEvent> {\n return parseWith(source, mapOpenAI);\n}\n\n/** Parse an Anthropic Messages stream into normalized events. */\nexport function parseAnthropicStream(\n source: ChunkSource,\n): AsyncGenerator<StreamEvent> {\n return parseWith(source, mapAnthropic);\n}\n\n/** Parse a Gemini `streamGenerateContent` (SSE) stream into normalized events. */\nexport async function* parseGeminiStream(\n source: ChunkSource,\n): AsyncGenerator<StreamEvent> {\n const state = { toolIndex: 0 };\n for await (const data of sseData(source)) {\n if (data === '[DONE]') {\n return;\n }\n let payload: unknown;\n try {\n payload = JSON.parse(data);\n } catch {\n continue;\n }\n for (const event of mapGemini(payload, state)) {\n yield event;\n }\n }\n}\n\n/** Parse a provider stream into normalized events, dispatching on `provider`. */\nexport function parseStream(\n source: ChunkSource,\n provider: Provider,\n): AsyncGenerator<StreamEvent> {\n switch (provider) {\n case 'openai':\n return parseOpenAIStream(source);\n case 'anthropic':\n return parseAnthropicStream(source);\n case 'gemini':\n return parseGeminiStream(source);\n }\n}\n","import type {\n CollectedMessage,\n CollectedToolCall,\n StreamEvent,\n} from './types.ts';\n\n/**\n * Drain a normalized event stream into a single assistant message: all text\n * concatenated, tool calls accumulated by `index` (arguments joined into one\n * JSON string), and the final stop reason.\n *\n * `error` events are not accumulated here — iterate the events directly if you\n * need to react to them mid-stream.\n *\n * @example\n * ```ts\n * const { text, toolCalls } = await collectStream(parseOpenAIStream(res.body));\n * ```\n */\nexport async function collectStream(\n events: AsyncIterable<StreamEvent>,\n): Promise<CollectedMessage> {\n let text = '';\n let finishReason: string | undefined;\n const byIndex = new Map<number, CollectedToolCall>();\n const order: number[] = [];\n\n const ensure = (index: number): CollectedToolCall => {\n let call = byIndex.get(index);\n if (!call) {\n call = { index, arguments: '' };\n byIndex.set(index, call);\n order.push(index);\n }\n return call;\n };\n\n for await (const event of events) {\n switch (event.type) {\n case 'text':\n text += event.text;\n break;\n case 'tool_call_start': {\n const call = ensure(event.index);\n if (event.id !== undefined) {\n call.id = event.id;\n }\n if (event.name !== undefined) {\n call.name = event.name;\n }\n break;\n }\n case 'tool_call_delta':\n ensure(event.index).arguments += event.argumentsDelta;\n break;\n case 'finish':\n finishReason = event.reason;\n break;\n case 'error':\n break;\n }\n }\n\n return {\n text,\n toolCalls: order.map((index) => byIndex.get(index)!),\n finishReason,\n };\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** The provider whose streaming format is being parsed. */
|
|
2
|
+
type Provider = 'openai' | 'anthropic' | 'gemini';
|
|
3
|
+
/**
|
|
4
|
+
* A single normalized event emitted while parsing a provider stream.
|
|
5
|
+
*
|
|
6
|
+
* The three providers stream very differently — OpenAI sends `choices[].delta`
|
|
7
|
+
* chunks, Anthropic sends typed `content_block_*` / `message_*` events, Gemini
|
|
8
|
+
* sends `candidates[].content.parts` — but they all reduce to the same handful
|
|
9
|
+
* of things: text arrived, a tool call started, tool-call arguments arrived,
|
|
10
|
+
* the turn finished, or the provider reported an error.
|
|
11
|
+
*/
|
|
12
|
+
type StreamEvent =
|
|
13
|
+
/** A chunk of assistant text. */
|
|
14
|
+
{
|
|
15
|
+
type: 'text';
|
|
16
|
+
text: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A tool/function call began. `index` identifies the call within the turn so
|
|
20
|
+
* later {@link ToolCallDeltaEvent}s can be matched to it.
|
|
21
|
+
*/
|
|
22
|
+
| {
|
|
23
|
+
type: 'tool_call_start';
|
|
24
|
+
index: number;
|
|
25
|
+
id?: string;
|
|
26
|
+
name?: string;
|
|
27
|
+
}
|
|
28
|
+
/** A fragment of a tool call's JSON arguments (concatenate by `index`). */
|
|
29
|
+
| {
|
|
30
|
+
type: 'tool_call_delta';
|
|
31
|
+
index: number;
|
|
32
|
+
argumentsDelta: string;
|
|
33
|
+
}
|
|
34
|
+
/** The turn finished. `reason` is the provider's stop reason, if any. */
|
|
35
|
+
| {
|
|
36
|
+
type: 'finish';
|
|
37
|
+
reason?: string;
|
|
38
|
+
}
|
|
39
|
+
/** The provider emitted an error event mid-stream. */
|
|
40
|
+
| {
|
|
41
|
+
type: 'error';
|
|
42
|
+
error: unknown;
|
|
43
|
+
};
|
|
44
|
+
/** A fully accumulated tool call, produced by {@link collectStream}. */
|
|
45
|
+
interface CollectedToolCall {
|
|
46
|
+
index: number;
|
|
47
|
+
id?: string;
|
|
48
|
+
name?: string;
|
|
49
|
+
/** The concatenated raw JSON arguments string. */
|
|
50
|
+
arguments: string;
|
|
51
|
+
}
|
|
52
|
+
/** The result of draining a stream with {@link collectStream}. */
|
|
53
|
+
interface CollectedMessage {
|
|
54
|
+
/** All text deltas concatenated in order. */
|
|
55
|
+
text: string;
|
|
56
|
+
/** Tool calls accumulated in order of first appearance. */
|
|
57
|
+
toolCalls: CollectedToolCall[];
|
|
58
|
+
/** The stop reason from the final `finish` event, if any. */
|
|
59
|
+
finishReason?: string;
|
|
60
|
+
}
|
|
61
|
+
/** A source of stream bytes or text: the shape `fetch().body` and Node streams satisfy. */
|
|
62
|
+
type ChunkSource = AsyncIterable<Uint8Array | string>;
|
|
63
|
+
|
|
64
|
+
/** Parse an OpenAI Chat Completions stream into normalized events. */
|
|
65
|
+
declare function parseOpenAIStream(source: ChunkSource): AsyncGenerator<StreamEvent>;
|
|
66
|
+
/** Parse an Anthropic Messages stream into normalized events. */
|
|
67
|
+
declare function parseAnthropicStream(source: ChunkSource): AsyncGenerator<StreamEvent>;
|
|
68
|
+
/** Parse a Gemini `streamGenerateContent` (SSE) stream into normalized events. */
|
|
69
|
+
declare function parseGeminiStream(source: ChunkSource): AsyncGenerator<StreamEvent>;
|
|
70
|
+
/** Parse a provider stream into normalized events, dispatching on `provider`. */
|
|
71
|
+
declare function parseStream(source: ChunkSource, provider: Provider): AsyncGenerator<StreamEvent>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Drain a normalized event stream into a single assistant message: all text
|
|
75
|
+
* concatenated, tool calls accumulated by `index` (arguments joined into one
|
|
76
|
+
* JSON string), and the final stop reason.
|
|
77
|
+
*
|
|
78
|
+
* `error` events are not accumulated here — iterate the events directly if you
|
|
79
|
+
* need to react to them mid-stream.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```ts
|
|
83
|
+
* const { text, toolCalls } = await collectStream(parseOpenAIStream(res.body));
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
declare function collectStream(events: AsyncIterable<StreamEvent>): Promise<CollectedMessage>;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse a Server-Sent Events stream and yield the `data` payload of each event.
|
|
90
|
+
*
|
|
91
|
+
* Robust to the realities of streamed HTTP: events and lines split across
|
|
92
|
+
* chunk boundaries are buffered until complete, multi-line `data:` fields are
|
|
93
|
+
* joined with `\n` (per the SSE spec), and comments (`:`) and other fields
|
|
94
|
+
* (`event:`, `id:`, `retry:`) are ignored — the payload's own `type` field is
|
|
95
|
+
* what the provider parsers key on.
|
|
96
|
+
*/
|
|
97
|
+
declare function sseData(source: ChunkSource): AsyncGenerator<string>;
|
|
98
|
+
|
|
99
|
+
export { type ChunkSource, type CollectedMessage, type CollectedToolCall, type Provider, type StreamEvent, collectStream, parseAnthropicStream, parseGeminiStream, parseOpenAIStream, parseStream, sseData };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** The provider whose streaming format is being parsed. */
|
|
2
|
+
type Provider = 'openai' | 'anthropic' | 'gemini';
|
|
3
|
+
/**
|
|
4
|
+
* A single normalized event emitted while parsing a provider stream.
|
|
5
|
+
*
|
|
6
|
+
* The three providers stream very differently — OpenAI sends `choices[].delta`
|
|
7
|
+
* chunks, Anthropic sends typed `content_block_*` / `message_*` events, Gemini
|
|
8
|
+
* sends `candidates[].content.parts` — but they all reduce to the same handful
|
|
9
|
+
* of things: text arrived, a tool call started, tool-call arguments arrived,
|
|
10
|
+
* the turn finished, or the provider reported an error.
|
|
11
|
+
*/
|
|
12
|
+
type StreamEvent =
|
|
13
|
+
/** A chunk of assistant text. */
|
|
14
|
+
{
|
|
15
|
+
type: 'text';
|
|
16
|
+
text: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A tool/function call began. `index` identifies the call within the turn so
|
|
20
|
+
* later {@link ToolCallDeltaEvent}s can be matched to it.
|
|
21
|
+
*/
|
|
22
|
+
| {
|
|
23
|
+
type: 'tool_call_start';
|
|
24
|
+
index: number;
|
|
25
|
+
id?: string;
|
|
26
|
+
name?: string;
|
|
27
|
+
}
|
|
28
|
+
/** A fragment of a tool call's JSON arguments (concatenate by `index`). */
|
|
29
|
+
| {
|
|
30
|
+
type: 'tool_call_delta';
|
|
31
|
+
index: number;
|
|
32
|
+
argumentsDelta: string;
|
|
33
|
+
}
|
|
34
|
+
/** The turn finished. `reason` is the provider's stop reason, if any. */
|
|
35
|
+
| {
|
|
36
|
+
type: 'finish';
|
|
37
|
+
reason?: string;
|
|
38
|
+
}
|
|
39
|
+
/** The provider emitted an error event mid-stream. */
|
|
40
|
+
| {
|
|
41
|
+
type: 'error';
|
|
42
|
+
error: unknown;
|
|
43
|
+
};
|
|
44
|
+
/** A fully accumulated tool call, produced by {@link collectStream}. */
|
|
45
|
+
interface CollectedToolCall {
|
|
46
|
+
index: number;
|
|
47
|
+
id?: string;
|
|
48
|
+
name?: string;
|
|
49
|
+
/** The concatenated raw JSON arguments string. */
|
|
50
|
+
arguments: string;
|
|
51
|
+
}
|
|
52
|
+
/** The result of draining a stream with {@link collectStream}. */
|
|
53
|
+
interface CollectedMessage {
|
|
54
|
+
/** All text deltas concatenated in order. */
|
|
55
|
+
text: string;
|
|
56
|
+
/** Tool calls accumulated in order of first appearance. */
|
|
57
|
+
toolCalls: CollectedToolCall[];
|
|
58
|
+
/** The stop reason from the final `finish` event, if any. */
|
|
59
|
+
finishReason?: string;
|
|
60
|
+
}
|
|
61
|
+
/** A source of stream bytes or text: the shape `fetch().body` and Node streams satisfy. */
|
|
62
|
+
type ChunkSource = AsyncIterable<Uint8Array | string>;
|
|
63
|
+
|
|
64
|
+
/** Parse an OpenAI Chat Completions stream into normalized events. */
|
|
65
|
+
declare function parseOpenAIStream(source: ChunkSource): AsyncGenerator<StreamEvent>;
|
|
66
|
+
/** Parse an Anthropic Messages stream into normalized events. */
|
|
67
|
+
declare function parseAnthropicStream(source: ChunkSource): AsyncGenerator<StreamEvent>;
|
|
68
|
+
/** Parse a Gemini `streamGenerateContent` (SSE) stream into normalized events. */
|
|
69
|
+
declare function parseGeminiStream(source: ChunkSource): AsyncGenerator<StreamEvent>;
|
|
70
|
+
/** Parse a provider stream into normalized events, dispatching on `provider`. */
|
|
71
|
+
declare function parseStream(source: ChunkSource, provider: Provider): AsyncGenerator<StreamEvent>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Drain a normalized event stream into a single assistant message: all text
|
|
75
|
+
* concatenated, tool calls accumulated by `index` (arguments joined into one
|
|
76
|
+
* JSON string), and the final stop reason.
|
|
77
|
+
*
|
|
78
|
+
* `error` events are not accumulated here — iterate the events directly if you
|
|
79
|
+
* need to react to them mid-stream.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```ts
|
|
83
|
+
* const { text, toolCalls } = await collectStream(parseOpenAIStream(res.body));
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
declare function collectStream(events: AsyncIterable<StreamEvent>): Promise<CollectedMessage>;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse a Server-Sent Events stream and yield the `data` payload of each event.
|
|
90
|
+
*
|
|
91
|
+
* Robust to the realities of streamed HTTP: events and lines split across
|
|
92
|
+
* chunk boundaries are buffered until complete, multi-line `data:` fields are
|
|
93
|
+
* joined with `\n` (per the SSE spec), and comments (`:`) and other fields
|
|
94
|
+
* (`event:`, `id:`, `retry:`) are ignored — the payload's own `type` field is
|
|
95
|
+
* what the provider parsers key on.
|
|
96
|
+
*/
|
|
97
|
+
declare function sseData(source: ChunkSource): AsyncGenerator<string>;
|
|
98
|
+
|
|
99
|
+
export { type ChunkSource, type CollectedMessage, type CollectedToolCall, type Provider, type StreamEvent, collectStream, parseAnthropicStream, parseGeminiStream, parseOpenAIStream, parseStream, sseData };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// src/providers/anthropic.ts
|
|
2
|
+
function mapAnthropic(event) {
|
|
3
|
+
const events = [];
|
|
4
|
+
switch (event?.type) {
|
|
5
|
+
case "content_block_start": {
|
|
6
|
+
const block = event.content_block;
|
|
7
|
+
if (block?.type === "tool_use") {
|
|
8
|
+
events.push({
|
|
9
|
+
type: "tool_call_start",
|
|
10
|
+
index: event.index ?? 0,
|
|
11
|
+
id: block.id,
|
|
12
|
+
name: block.name
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
case "content_block_delta": {
|
|
18
|
+
const delta = event.delta;
|
|
19
|
+
if (delta?.type === "text_delta" && typeof delta.text === "string") {
|
|
20
|
+
events.push({ type: "text", text: delta.text });
|
|
21
|
+
} else if (delta?.type === "input_json_delta" && typeof delta.partial_json === "string") {
|
|
22
|
+
events.push({
|
|
23
|
+
type: "tool_call_delta",
|
|
24
|
+
index: event.index ?? 0,
|
|
25
|
+
argumentsDelta: delta.partial_json
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
case "message_delta": {
|
|
31
|
+
const reason = event.delta?.stop_reason;
|
|
32
|
+
if (reason) {
|
|
33
|
+
events.push({ type: "finish", reason });
|
|
34
|
+
}
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
case "error": {
|
|
38
|
+
events.push({ type: "error", error: event.error ?? event });
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return events;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/providers/gemini.ts
|
|
46
|
+
function mapGemini(chunk, state) {
|
|
47
|
+
const events = [];
|
|
48
|
+
const candidate = chunk?.candidates?.[0];
|
|
49
|
+
if (!candidate) {
|
|
50
|
+
return events;
|
|
51
|
+
}
|
|
52
|
+
const parts = candidate.content?.parts;
|
|
53
|
+
if (Array.isArray(parts)) {
|
|
54
|
+
for (const part of parts) {
|
|
55
|
+
if (typeof part.text === "string" && part.text.length > 0) {
|
|
56
|
+
events.push({ type: "text", text: part.text });
|
|
57
|
+
}
|
|
58
|
+
if (part.functionCall) {
|
|
59
|
+
const index = state.toolIndex++;
|
|
60
|
+
events.push({
|
|
61
|
+
type: "tool_call_start",
|
|
62
|
+
index,
|
|
63
|
+
name: part.functionCall.name
|
|
64
|
+
});
|
|
65
|
+
events.push({
|
|
66
|
+
type: "tool_call_delta",
|
|
67
|
+
index,
|
|
68
|
+
argumentsDelta: JSON.stringify(part.functionCall.args ?? {})
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (candidate.finishReason) {
|
|
74
|
+
events.push({ type: "finish", reason: candidate.finishReason });
|
|
75
|
+
}
|
|
76
|
+
return events;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/providers/openai.ts
|
|
80
|
+
function mapOpenAI(chunk) {
|
|
81
|
+
const events = [];
|
|
82
|
+
const choice = chunk?.choices?.[0];
|
|
83
|
+
if (!choice) {
|
|
84
|
+
return events;
|
|
85
|
+
}
|
|
86
|
+
const delta = choice.delta;
|
|
87
|
+
if (delta) {
|
|
88
|
+
if (typeof delta.content === "string" && delta.content.length > 0) {
|
|
89
|
+
events.push({ type: "text", text: delta.content });
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
92
|
+
for (const call of delta.tool_calls) {
|
|
93
|
+
const index = typeof call.index === "number" ? call.index : 0;
|
|
94
|
+
if (call.id !== void 0 || call.function?.name !== void 0) {
|
|
95
|
+
events.push({
|
|
96
|
+
type: "tool_call_start",
|
|
97
|
+
index,
|
|
98
|
+
id: call.id,
|
|
99
|
+
name: call.function?.name
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const args = call.function?.arguments;
|
|
103
|
+
if (typeof args === "string" && args.length > 0) {
|
|
104
|
+
events.push({ type: "tool_call_delta", index, argumentsDelta: args });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (choice.finish_reason) {
|
|
110
|
+
events.push({ type: "finish", reason: choice.finish_reason });
|
|
111
|
+
}
|
|
112
|
+
return events;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/sse.ts
|
|
116
|
+
async function* decodeChunks(source) {
|
|
117
|
+
const decoder = new TextDecoder();
|
|
118
|
+
for await (const chunk of source) {
|
|
119
|
+
if (typeof chunk === "string") {
|
|
120
|
+
yield chunk;
|
|
121
|
+
} else {
|
|
122
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
123
|
+
if (text) {
|
|
124
|
+
yield text;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const tail = decoder.decode();
|
|
129
|
+
if (tail) {
|
|
130
|
+
yield tail;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function* sseData(source) {
|
|
134
|
+
let buffer = "";
|
|
135
|
+
let dataLines = [];
|
|
136
|
+
for await (const text of decodeChunks(source)) {
|
|
137
|
+
buffer += text;
|
|
138
|
+
let newline;
|
|
139
|
+
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
140
|
+
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
|
141
|
+
buffer = buffer.slice(newline + 1);
|
|
142
|
+
if (line === "") {
|
|
143
|
+
if (dataLines.length > 0) {
|
|
144
|
+
yield dataLines.join("\n");
|
|
145
|
+
dataLines = [];
|
|
146
|
+
}
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (line[0] === ":") {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (line.startsWith("data:")) {
|
|
153
|
+
dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const last = buffer.replace(/\r$/, "");
|
|
158
|
+
if (last.startsWith("data:")) {
|
|
159
|
+
dataLines.push(last.slice(5).replace(/^ /, ""));
|
|
160
|
+
}
|
|
161
|
+
if (dataLines.length > 0) {
|
|
162
|
+
yield dataLines.join("\n");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/parse.ts
|
|
167
|
+
async function* parseWith(source, map) {
|
|
168
|
+
for await (const data of sseData(source)) {
|
|
169
|
+
if (data === "[DONE]") {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
let payload;
|
|
173
|
+
try {
|
|
174
|
+
payload = JSON.parse(data);
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
for (const event of map(payload)) {
|
|
179
|
+
yield event;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function parseOpenAIStream(source) {
|
|
184
|
+
return parseWith(source, mapOpenAI);
|
|
185
|
+
}
|
|
186
|
+
function parseAnthropicStream(source) {
|
|
187
|
+
return parseWith(source, mapAnthropic);
|
|
188
|
+
}
|
|
189
|
+
async function* parseGeminiStream(source) {
|
|
190
|
+
const state = { toolIndex: 0 };
|
|
191
|
+
for await (const data of sseData(source)) {
|
|
192
|
+
if (data === "[DONE]") {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
let payload;
|
|
196
|
+
try {
|
|
197
|
+
payload = JSON.parse(data);
|
|
198
|
+
} catch {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
for (const event of mapGemini(payload, state)) {
|
|
202
|
+
yield event;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function parseStream(source, provider) {
|
|
207
|
+
switch (provider) {
|
|
208
|
+
case "openai":
|
|
209
|
+
return parseOpenAIStream(source);
|
|
210
|
+
case "anthropic":
|
|
211
|
+
return parseAnthropicStream(source);
|
|
212
|
+
case "gemini":
|
|
213
|
+
return parseGeminiStream(source);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// src/collect.ts
|
|
218
|
+
async function collectStream(events) {
|
|
219
|
+
let text = "";
|
|
220
|
+
let finishReason;
|
|
221
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
222
|
+
const order = [];
|
|
223
|
+
const ensure = (index) => {
|
|
224
|
+
let call = byIndex.get(index);
|
|
225
|
+
if (!call) {
|
|
226
|
+
call = { index, arguments: "" };
|
|
227
|
+
byIndex.set(index, call);
|
|
228
|
+
order.push(index);
|
|
229
|
+
}
|
|
230
|
+
return call;
|
|
231
|
+
};
|
|
232
|
+
for await (const event of events) {
|
|
233
|
+
switch (event.type) {
|
|
234
|
+
case "text":
|
|
235
|
+
text += event.text;
|
|
236
|
+
break;
|
|
237
|
+
case "tool_call_start": {
|
|
238
|
+
const call = ensure(event.index);
|
|
239
|
+
if (event.id !== void 0) {
|
|
240
|
+
call.id = event.id;
|
|
241
|
+
}
|
|
242
|
+
if (event.name !== void 0) {
|
|
243
|
+
call.name = event.name;
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case "tool_call_delta":
|
|
248
|
+
ensure(event.index).arguments += event.argumentsDelta;
|
|
249
|
+
break;
|
|
250
|
+
case "finish":
|
|
251
|
+
finishReason = event.reason;
|
|
252
|
+
break;
|
|
253
|
+
case "error":
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
text,
|
|
259
|
+
toolCalls: order.map((index) => byIndex.get(index)),
|
|
260
|
+
finishReason
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
export {
|
|
264
|
+
collectStream,
|
|
265
|
+
parseAnthropicStream,
|
|
266
|
+
parseGeminiStream,
|
|
267
|
+
parseOpenAIStream,
|
|
268
|
+
parseStream,
|
|
269
|
+
sseData
|
|
270
|
+
};
|
|
271
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/providers/anthropic.ts","../src/providers/gemini.ts","../src/providers/openai.ts","../src/sse.ts","../src/parse.ts","../src/collect.ts"],"sourcesContent":["import type { StreamEvent } from '../types.ts';\n\n/**\n * Map one Anthropic Messages stream event into normalized events.\n *\n * Anthropic uses typed events: `content_block_start` opens a text or `tool_use`\n * block at an `index`, `content_block_delta` carries `text_delta` /\n * `input_json_delta` fragments, and `message_delta` carries the `stop_reason`.\n */\nexport function mapAnthropic(event: any): StreamEvent[] {\n const events: StreamEvent[] = [];\n\n switch (event?.type) {\n case 'content_block_start': {\n const block = event.content_block;\n if (block?.type === 'tool_use') {\n events.push({\n type: 'tool_call_start',\n index: event.index ?? 0,\n id: block.id,\n name: block.name,\n });\n }\n break;\n }\n case 'content_block_delta': {\n const delta = event.delta;\n if (delta?.type === 'text_delta' && typeof delta.text === 'string') {\n events.push({ type: 'text', text: delta.text });\n } else if (\n delta?.type === 'input_json_delta' &&\n typeof delta.partial_json === 'string'\n ) {\n events.push({\n type: 'tool_call_delta',\n index: event.index ?? 0,\n argumentsDelta: delta.partial_json,\n });\n }\n break;\n }\n case 'message_delta': {\n const reason = event.delta?.stop_reason;\n if (reason) {\n events.push({ type: 'finish', reason });\n }\n break;\n }\n case 'error': {\n events.push({ type: 'error', error: event.error ?? event });\n break;\n }\n }\n\n return events;\n}\n","import type { StreamEvent } from '../types.ts';\n\n/** Per-stream state for Gemini, which does not number its tool calls. */\nexport interface GeminiState {\n toolIndex: number;\n}\n\n/**\n * Map one Gemini `GenerateContentResponse` chunk into normalized events.\n *\n * Gemini streams `candidates[0].content.parts[]`: a part is either `text` or a\n * complete `functionCall` (`{ name, args }`) — it does not fragment arguments,\n * so the whole `args` object is emitted as a single tool-call delta. Calls are\n * numbered in the order they appear via `state`.\n */\nexport function mapGemini(chunk: any, state: GeminiState): StreamEvent[] {\n const events: StreamEvent[] = [];\n const candidate = chunk?.candidates?.[0];\n if (!candidate) {\n return events;\n }\n\n const parts = candidate.content?.parts;\n if (Array.isArray(parts)) {\n for (const part of parts) {\n if (typeof part.text === 'string' && part.text.length > 0) {\n events.push({ type: 'text', text: part.text });\n }\n if (part.functionCall) {\n const index = state.toolIndex++;\n events.push({\n type: 'tool_call_start',\n index,\n name: part.functionCall.name,\n });\n events.push({\n type: 'tool_call_delta',\n index,\n argumentsDelta: JSON.stringify(part.functionCall.args ?? {}),\n });\n }\n }\n }\n\n if (candidate.finishReason) {\n events.push({ type: 'finish', reason: candidate.finishReason });\n }\n return events;\n}\n","import type { StreamEvent } from '../types.ts';\n\n/**\n * Map one OpenAI `chat.completion.chunk` into normalized events.\n *\n * OpenAI streams a `choices[0].delta`: `content` carries text, and\n * `tool_calls[]` carry an `index`, an `id` + `function.name` on the first\n * fragment, then `function.arguments` fragments thereafter.\n */\nexport function mapOpenAI(chunk: any): StreamEvent[] {\n const events: StreamEvent[] = [];\n const choice = chunk?.choices?.[0];\n if (!choice) {\n return events;\n }\n\n const delta = choice.delta;\n if (delta) {\n if (typeof delta.content === 'string' && delta.content.length > 0) {\n events.push({ type: 'text', text: delta.content });\n }\n if (Array.isArray(delta.tool_calls)) {\n for (const call of delta.tool_calls) {\n const index = typeof call.index === 'number' ? call.index : 0;\n if (call.id !== undefined || call.function?.name !== undefined) {\n events.push({\n type: 'tool_call_start',\n index,\n id: call.id,\n name: call.function?.name,\n });\n }\n const args = call.function?.arguments;\n if (typeof args === 'string' && args.length > 0) {\n events.push({ type: 'tool_call_delta', index, argumentsDelta: args });\n }\n }\n }\n }\n\n if (choice.finish_reason) {\n events.push({ type: 'finish', reason: choice.finish_reason });\n }\n return events;\n}\n","import type { ChunkSource } from './types.ts';\n\n/**\n * Decode a mixed byte/string chunk source into text. `Uint8Array` chunks are\n * decoded with a streaming `TextDecoder` so a multibyte UTF-8 character split\n * across two chunks is reassembled correctly.\n */\nasync function* decodeChunks(source: ChunkSource): AsyncGenerator<string> {\n const decoder = new TextDecoder();\n for await (const chunk of source) {\n if (typeof chunk === 'string') {\n yield chunk;\n } else {\n const text = decoder.decode(chunk, { stream: true });\n if (text) {\n yield text;\n }\n }\n }\n const tail = decoder.decode();\n if (tail) {\n yield tail;\n }\n}\n\n/**\n * Parse a Server-Sent Events stream and yield the `data` payload of each event.\n *\n * Robust to the realities of streamed HTTP: events and lines split across\n * chunk boundaries are buffered until complete, multi-line `data:` fields are\n * joined with `\\n` (per the SSE spec), and comments (`:`) and other fields\n * (`event:`, `id:`, `retry:`) are ignored — the payload's own `type` field is\n * what the provider parsers key on.\n */\nexport async function* sseData(source: ChunkSource): AsyncGenerator<string> {\n let buffer = '';\n let dataLines: string[] = [];\n\n for await (const text of decodeChunks(source)) {\n buffer += text;\n\n let newline: number;\n while ((newline = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.slice(0, newline).replace(/\\r$/, '');\n buffer = buffer.slice(newline + 1);\n\n if (line === '') {\n // Blank line terminates an event.\n if (dataLines.length > 0) {\n yield dataLines.join('\\n');\n dataLines = [];\n }\n continue;\n }\n if (line[0] === ':') {\n continue; // comment\n }\n if (line.startsWith('data:')) {\n dataLines.push(line.slice(5).replace(/^ /, ''));\n }\n }\n }\n\n // A final event may arrive without a trailing blank line.\n const last = buffer.replace(/\\r$/, '');\n if (last.startsWith('data:')) {\n dataLines.push(last.slice(5).replace(/^ /, ''));\n }\n if (dataLines.length > 0) {\n yield dataLines.join('\\n');\n }\n}\n","import { mapAnthropic } from './providers/anthropic.ts';\nimport { mapGemini } from './providers/gemini.ts';\nimport { mapOpenAI } from './providers/openai.ts';\nimport { sseData } from './sse.ts';\nimport type { ChunkSource, Provider, StreamEvent } from './types.ts';\n\n/** Shared SSE-to-events driver for the stateless providers. */\nasync function* parseWith(\n source: ChunkSource,\n map: (payload: any) => StreamEvent[],\n): AsyncGenerator<StreamEvent> {\n for await (const data of sseData(source)) {\n if (data === '[DONE]') {\n return;\n }\n let payload: unknown;\n try {\n payload = JSON.parse(data);\n } catch {\n continue; // ignore keep-alive / non-JSON data lines\n }\n for (const event of map(payload)) {\n yield event;\n }\n }\n}\n\n/** Parse an OpenAI Chat Completions stream into normalized events. */\nexport function parseOpenAIStream(\n source: ChunkSource,\n): AsyncGenerator<StreamEvent> {\n return parseWith(source, mapOpenAI);\n}\n\n/** Parse an Anthropic Messages stream into normalized events. */\nexport function parseAnthropicStream(\n source: ChunkSource,\n): AsyncGenerator<StreamEvent> {\n return parseWith(source, mapAnthropic);\n}\n\n/** Parse a Gemini `streamGenerateContent` (SSE) stream into normalized events. */\nexport async function* parseGeminiStream(\n source: ChunkSource,\n): AsyncGenerator<StreamEvent> {\n const state = { toolIndex: 0 };\n for await (const data of sseData(source)) {\n if (data === '[DONE]') {\n return;\n }\n let payload: unknown;\n try {\n payload = JSON.parse(data);\n } catch {\n continue;\n }\n for (const event of mapGemini(payload, state)) {\n yield event;\n }\n }\n}\n\n/** Parse a provider stream into normalized events, dispatching on `provider`. */\nexport function parseStream(\n source: ChunkSource,\n provider: Provider,\n): AsyncGenerator<StreamEvent> {\n switch (provider) {\n case 'openai':\n return parseOpenAIStream(source);\n case 'anthropic':\n return parseAnthropicStream(source);\n case 'gemini':\n return parseGeminiStream(source);\n }\n}\n","import type {\n CollectedMessage,\n CollectedToolCall,\n StreamEvent,\n} from './types.ts';\n\n/**\n * Drain a normalized event stream into a single assistant message: all text\n * concatenated, tool calls accumulated by `index` (arguments joined into one\n * JSON string), and the final stop reason.\n *\n * `error` events are not accumulated here — iterate the events directly if you\n * need to react to them mid-stream.\n *\n * @example\n * ```ts\n * const { text, toolCalls } = await collectStream(parseOpenAIStream(res.body));\n * ```\n */\nexport async function collectStream(\n events: AsyncIterable<StreamEvent>,\n): Promise<CollectedMessage> {\n let text = '';\n let finishReason: string | undefined;\n const byIndex = new Map<number, CollectedToolCall>();\n const order: number[] = [];\n\n const ensure = (index: number): CollectedToolCall => {\n let call = byIndex.get(index);\n if (!call) {\n call = { index, arguments: '' };\n byIndex.set(index, call);\n order.push(index);\n }\n return call;\n };\n\n for await (const event of events) {\n switch (event.type) {\n case 'text':\n text += event.text;\n break;\n case 'tool_call_start': {\n const call = ensure(event.index);\n if (event.id !== undefined) {\n call.id = event.id;\n }\n if (event.name !== undefined) {\n call.name = event.name;\n }\n break;\n }\n case 'tool_call_delta':\n ensure(event.index).arguments += event.argumentsDelta;\n break;\n case 'finish':\n finishReason = event.reason;\n break;\n case 'error':\n break;\n }\n }\n\n return {\n text,\n toolCalls: order.map((index) => byIndex.get(index)!),\n finishReason,\n };\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "llm-sse",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Parse streaming SSE responses from OpenAI, Anthropic and Gemini into one unified event format. Text and tool-call deltas. Zero dependencies.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"openai",
|
|
7
|
+
"anthropic",
|
|
8
|
+
"claude",
|
|
9
|
+
"gemini",
|
|
10
|
+
"llm",
|
|
11
|
+
"stream",
|
|
12
|
+
"streaming",
|
|
13
|
+
"sse",
|
|
14
|
+
"server-sent-events",
|
|
15
|
+
"tool-calls",
|
|
16
|
+
"delta",
|
|
17
|
+
"provider",
|
|
18
|
+
"portability",
|
|
19
|
+
"ai",
|
|
20
|
+
"agents"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "Sebastian Legarraga",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/slegarraga/llm-sse.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/slegarraga/llm-sse#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/slegarraga/llm-sse/issues"
|
|
31
|
+
},
|
|
32
|
+
"type": "module",
|
|
33
|
+
"main": "./dist/index.cjs",
|
|
34
|
+
"module": "./dist/index.js",
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"exports": {
|
|
37
|
+
".": {
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"import": "./dist/index.js",
|
|
40
|
+
"require": "./dist/index.cjs"
|
|
41
|
+
},
|
|
42
|
+
"./package.json": "./package.json"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"README.md",
|
|
47
|
+
"LICENSE",
|
|
48
|
+
"CHANGELOG.md"
|
|
49
|
+
],
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=18"
|
|
52
|
+
},
|
|
53
|
+
"sideEffects": false,
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsup",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"test:watch": "vitest",
|
|
59
|
+
"lint": "eslint .",
|
|
60
|
+
"format": "prettier --write .",
|
|
61
|
+
"format:check": "prettier --check .",
|
|
62
|
+
"prepublishOnly": "npm run build",
|
|
63
|
+
"prepare": "npm run build"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@eslint/js": "^10.0.1",
|
|
67
|
+
"@types/node": "^25.9.1",
|
|
68
|
+
"eslint": "^10.4.1",
|
|
69
|
+
"prettier": "^3.4.2",
|
|
70
|
+
"tsup": "^8.3.5",
|
|
71
|
+
"typescript": "^5.7.2",
|
|
72
|
+
"typescript-eslint": "^8.60.0",
|
|
73
|
+
"vitest": "^2.1.8"
|
|
74
|
+
}
|
|
75
|
+
}
|