@jxsuite/ai 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/dist/index.js +1531 -0
- package/dist/index.js.map +14 -0
- package/package.json +34 -0
- package/src/chat-state.ts +416 -0
- package/src/index.ts +21 -0
- package/src/streaming-client.ts +561 -0
- package/src/tools.ts +280 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming-client.js — Provider-agnostic streaming LLM client abstraction
|
|
3
|
+
*
|
|
4
|
+
* Defines the StreamingClient interface (as JSDoc typedefs), the StreamEvent union type,
|
|
5
|
+
* and concrete implementations for OpenAI and Anthropic. Designed upfront so switching
|
|
6
|
+
* providers is a new implementation, not a refactor.
|
|
7
|
+
*
|
|
8
|
+
* @license MIT
|
|
9
|
+
* @module @jxsuite/ai/streaming-client
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type StreamEvent =
|
|
13
|
+
| StreamDeltaEvent
|
|
14
|
+
| StreamToolCallStartEvent
|
|
15
|
+
| StreamToolCallDeltaEvent
|
|
16
|
+
| StreamToolCallEndEvent
|
|
17
|
+
| StreamDoneEvent
|
|
18
|
+
| StreamErrorEvent;
|
|
19
|
+
|
|
20
|
+
interface StreamDeltaEvent {
|
|
21
|
+
type: "delta";
|
|
22
|
+
content: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface StreamToolCallStartEvent {
|
|
26
|
+
type: "tool_call_start";
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface StreamToolCallDeltaEvent {
|
|
32
|
+
type: "tool_call_delta";
|
|
33
|
+
id: string;
|
|
34
|
+
args: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface StreamToolCallEndEvent {
|
|
38
|
+
type: "tool_call_end";
|
|
39
|
+
id: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface StreamDoneEvent {
|
|
43
|
+
type: "done";
|
|
44
|
+
stopReason: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface StreamErrorEvent {
|
|
48
|
+
type: "error";
|
|
49
|
+
message: string;
|
|
50
|
+
code?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A streaming LLM client. Implementations handle provider-specific SSE formats and emit normalized
|
|
55
|
+
* StreamEvents.
|
|
56
|
+
*/
|
|
57
|
+
export interface StreamingClient {
|
|
58
|
+
streamChat: (
|
|
59
|
+
messages: object[],
|
|
60
|
+
tools: object[],
|
|
61
|
+
systemPrompt: string,
|
|
62
|
+
signal: AbortSignal,
|
|
63
|
+
) => AsyncGenerator<StreamEvent>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** OpenAI chat-completions request body, built incrementally below. */
|
|
67
|
+
interface OpenAIChatRequestBody {
|
|
68
|
+
model: string;
|
|
69
|
+
messages: object[];
|
|
70
|
+
stream: boolean;
|
|
71
|
+
stream_options: { include_usage: boolean };
|
|
72
|
+
temperature?: number;
|
|
73
|
+
tools?: object[];
|
|
74
|
+
tool_choice?: string;
|
|
75
|
+
parallel_tool_calls?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A tool-call fragment inside an OpenAI streaming `delta`. */
|
|
79
|
+
interface OpenAIToolCallDelta {
|
|
80
|
+
index: number;
|
|
81
|
+
id?: string;
|
|
82
|
+
type?: string;
|
|
83
|
+
function?: { name?: string; arguments?: string };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A single OpenAI chat-completions streaming chunk (the JSON after `data: `). */
|
|
87
|
+
interface OpenAIStreamChunk {
|
|
88
|
+
choices?: {
|
|
89
|
+
index?: number;
|
|
90
|
+
delta?: { content?: string; tool_calls?: OpenAIToolCallDelta[] };
|
|
91
|
+
finish_reason?: string | null;
|
|
92
|
+
}[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Error response body — the proxy's flat `{ error: "..." }` or OpenAI's `{ error: { message } }`. */
|
|
96
|
+
interface ErrorResponseBody {
|
|
97
|
+
error?: string | { message?: string; code?: string; type?: string };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Type definitions ────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* All possible stream event types.
|
|
104
|
+
*
|
|
105
|
+
* @readonly
|
|
106
|
+
* @enum {string}
|
|
107
|
+
*/
|
|
108
|
+
export const STREAM_EVENT_TYPES = {
|
|
109
|
+
DELTA: "delta",
|
|
110
|
+
TOOL_CALL_START: "tool_call_start",
|
|
111
|
+
TOOL_CALL_DELTA: "tool_call_delta",
|
|
112
|
+
TOOL_CALL_END: "tool_call_end",
|
|
113
|
+
DONE: "done",
|
|
114
|
+
ERROR: "error",
|
|
115
|
+
} as const;
|
|
116
|
+
|
|
117
|
+
// ─── StreamingClient interface (documented typedef) ─────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A streaming LLM client. Implementations handle provider-specific SSE formats and emit normalized
|
|
121
|
+
* StreamEvents.
|
|
122
|
+
*
|
|
123
|
+
* @interface StreamingClient
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @function StreamingClient#streamChat
|
|
128
|
+
* @param {object[]} messages - Conversation messages in OpenAI format
|
|
129
|
+
* @param {object[]} tools - Tool definitions in OpenAI function-calling format
|
|
130
|
+
* @param {string} systemPrompt - System prompt text
|
|
131
|
+
* @param {AbortSignal} signal - AbortSignal for cancellation
|
|
132
|
+
* @returns {AsyncGenerator<StreamEvent>} Async generator of stream events
|
|
133
|
+
*/
|
|
134
|
+
|
|
135
|
+
// ─── OpenAI implementation ───────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Creates an OpenAI-compatible streaming client that fetches from a given base URL with the
|
|
139
|
+
* provided API key, transforms OpenAI SSE into normalized StreamEvents, and accumulates partial
|
|
140
|
+
* tool call argument JSON.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} opts
|
|
143
|
+
* @param {string} opts.baseUrl - OpenAI-compatible API base URL (e.g. "https://api.openai.com/v1")
|
|
144
|
+
* @param {string} opts.apiKey - API key
|
|
145
|
+
* @param {string} [opts.model] - Default model if not specified per-request
|
|
146
|
+
* @param {number} [opts.temperature] - Sampling temperature, forwarded only when defined. Omit for
|
|
147
|
+
* reasoning models (GPT-5.x, o-series) that reject a custom temperature; set 0 for
|
|
148
|
+
* near-deterministic eval runs.
|
|
149
|
+
* @returns {StreamingClient}
|
|
150
|
+
*/
|
|
151
|
+
export function createOpenAIStreamingClient({
|
|
152
|
+
baseUrl,
|
|
153
|
+
apiKey,
|
|
154
|
+
model = "gpt-4o",
|
|
155
|
+
temperature,
|
|
156
|
+
}: {
|
|
157
|
+
baseUrl: string;
|
|
158
|
+
apiKey: string;
|
|
159
|
+
model?: string;
|
|
160
|
+
temperature?: number | undefined;
|
|
161
|
+
}): StreamingClient {
|
|
162
|
+
/**
|
|
163
|
+
* @param {object[]} messages
|
|
164
|
+
* @param {object[]} tools
|
|
165
|
+
* @param {string} systemPrompt
|
|
166
|
+
* @param {AbortSignal} signal
|
|
167
|
+
* @yields {StreamEvent} Normalized stream events.
|
|
168
|
+
* @returns {AsyncGenerator<StreamEvent>}
|
|
169
|
+
*/
|
|
170
|
+
async function* streamChat(
|
|
171
|
+
messages: object[],
|
|
172
|
+
tools: object[],
|
|
173
|
+
systemPrompt: string,
|
|
174
|
+
signal: AbortSignal,
|
|
175
|
+
): AsyncGenerator<StreamEvent> {
|
|
176
|
+
const url = `${baseUrl}/chat/completions`;
|
|
177
|
+
const body: OpenAIChatRequestBody = {
|
|
178
|
+
model,
|
|
179
|
+
messages: [{ role: "system", content: systemPrompt }, ...messages],
|
|
180
|
+
stream: true,
|
|
181
|
+
stream_options: { include_usage: true },
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Forwarded only when provided — reasoning models (GPT-5.x, o-series) reject a custom
|
|
185
|
+
// Temperature, so callers that target those models simply leave it undefined.
|
|
186
|
+
if (temperature !== undefined) {
|
|
187
|
+
body.temperature = temperature;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (tools && tools.length > 0) {
|
|
191
|
+
body.tools = tools;
|
|
192
|
+
body.tool_choice = "auto";
|
|
193
|
+
body.parallel_tool_calls = true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let response;
|
|
197
|
+
try {
|
|
198
|
+
response = await fetch(url, {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: {
|
|
201
|
+
"Content-Type": "application/json",
|
|
202
|
+
Authorization: `Bearer ${apiKey}`,
|
|
203
|
+
},
|
|
204
|
+
body: JSON.stringify(body),
|
|
205
|
+
signal,
|
|
206
|
+
});
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if ((error as Error).name === "AbortError") {
|
|
209
|
+
yield { type: "done", stopReason: "cancelled" };
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
yield {
|
|
213
|
+
type: "error",
|
|
214
|
+
message: `Network error: ${(error as Error).message}`,
|
|
215
|
+
};
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
let errorBody = "";
|
|
221
|
+
try {
|
|
222
|
+
errorBody = await response.text();
|
|
223
|
+
} catch {
|
|
224
|
+
/* Ignore */
|
|
225
|
+
}
|
|
226
|
+
yield {
|
|
227
|
+
type: "error",
|
|
228
|
+
message: `API error ${response.status}: ${errorBody || response.statusText}`,
|
|
229
|
+
code: String(response.status),
|
|
230
|
+
};
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const reader = response.body?.getReader();
|
|
235
|
+
if (!reader) {
|
|
236
|
+
yield { type: "error", message: "No response body" };
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const decoder = new TextDecoder();
|
|
241
|
+
let buffer = "";
|
|
242
|
+
|
|
243
|
+
/** @type {Map<string, { id: string; name: string; args: string }>} */
|
|
244
|
+
const pendingToolCalls = new Map<number, { id: string; name: string; args: string }>();
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
while (true) {
|
|
248
|
+
const { done, value } = await reader.read();
|
|
249
|
+
if (done) {
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
buffer += decoder.decode(value, { stream: true });
|
|
254
|
+
const lines = buffer.split("\n");
|
|
255
|
+
// Keep the last (potentially incomplete) line in the buffer
|
|
256
|
+
buffer = lines.pop() || "";
|
|
257
|
+
|
|
258
|
+
for (const line of lines) {
|
|
259
|
+
const trimmed = line.trim();
|
|
260
|
+
if (!trimmed || !trimmed.startsWith("data: ")) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const dataStr = trimmed.slice(6);
|
|
265
|
+
if (dataStr === "[DONE]") {
|
|
266
|
+
// Emit any pending tool call ends before done
|
|
267
|
+
for (const tc of pendingToolCalls.values()) {
|
|
268
|
+
yield { type: "tool_call_end", id: tc.id };
|
|
269
|
+
}
|
|
270
|
+
pendingToolCalls.clear();
|
|
271
|
+
yield { type: "done", stopReason: "stop" };
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let parsed: OpenAIStreamChunk;
|
|
276
|
+
try {
|
|
277
|
+
parsed = JSON.parse(dataStr) as OpenAIStreamChunk;
|
|
278
|
+
} catch {
|
|
279
|
+
continue; // Skip unparseable chunks
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const choice = parsed.choices?.[0];
|
|
283
|
+
if (!choice) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const { delta } = choice;
|
|
288
|
+
if (!delta) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Text content
|
|
293
|
+
if (delta.content) {
|
|
294
|
+
yield { type: "delta", content: delta.content };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Tool calls
|
|
298
|
+
if (delta.tool_calls) {
|
|
299
|
+
for (const tc of delta.tool_calls) {
|
|
300
|
+
const existing = pendingToolCalls.get(tc.index);
|
|
301
|
+
|
|
302
|
+
if (tc.id) {
|
|
303
|
+
// First appearance of this tool call
|
|
304
|
+
const entry = {
|
|
305
|
+
id: tc.id,
|
|
306
|
+
name: tc.function?.name || "",
|
|
307
|
+
args: tc.function?.arguments || "",
|
|
308
|
+
};
|
|
309
|
+
pendingToolCalls.set(tc.index, entry);
|
|
310
|
+
|
|
311
|
+
yield { type: "tool_call_start", id: tc.id, name: entry.name };
|
|
312
|
+
|
|
313
|
+
if (entry.args) {
|
|
314
|
+
yield { type: "tool_call_delta", id: tc.id, args: entry.args };
|
|
315
|
+
}
|
|
316
|
+
} else if (existing && tc.function?.arguments) {
|
|
317
|
+
// Subsequent argument fragment
|
|
318
|
+
existing.args += tc.function.arguments;
|
|
319
|
+
yield {
|
|
320
|
+
type: "tool_call_delta",
|
|
321
|
+
id: existing.id,
|
|
322
|
+
args: tc.function.arguments,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Finish reason
|
|
329
|
+
if (choice.finish_reason === "tool_calls") {
|
|
330
|
+
// Emit pending tool call ends
|
|
331
|
+
for (const tc of pendingToolCalls.values()) {
|
|
332
|
+
yield { type: "tool_call_end", id: tc.id };
|
|
333
|
+
}
|
|
334
|
+
pendingToolCalls.clear();
|
|
335
|
+
yield { type: "done", stopReason: "tool_calls" };
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (choice.finish_reason === "stop" || choice.finish_reason === "length") {
|
|
340
|
+
// Emit any pending tool call ends
|
|
341
|
+
for (const tc of pendingToolCalls.values()) {
|
|
342
|
+
yield { type: "tool_call_end", id: tc.id };
|
|
343
|
+
}
|
|
344
|
+
pendingToolCalls.clear();
|
|
345
|
+
yield {
|
|
346
|
+
type: "done",
|
|
347
|
+
stopReason: choice.finish_reason === "length" ? "length" : "stop",
|
|
348
|
+
};
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Stream ended without explicit finish_reason
|
|
355
|
+
for (const tc of pendingToolCalls.values()) {
|
|
356
|
+
yield { type: "tool_call_end", id: tc.id };
|
|
357
|
+
}
|
|
358
|
+
pendingToolCalls.clear();
|
|
359
|
+
yield { type: "done", stopReason: "stop" };
|
|
360
|
+
} catch (error) {
|
|
361
|
+
void reader.cancel();
|
|
362
|
+
if ((error as Error).name === "AbortError") {
|
|
363
|
+
yield { type: "done", stopReason: "cancelled" };
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
yield {
|
|
367
|
+
type: "error",
|
|
368
|
+
message: `Stream error: ${(error as Error).message}`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return { streamChat };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ─── Anthropic implementation (stub for v1) ──────────────────────────────────
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Creates an Anthropic-compatible streaming client. Stub for v1 — throws on use. Full
|
|
380
|
+
* implementation in v2 handles Anthropic's content_block delta model.
|
|
381
|
+
*
|
|
382
|
+
* @param {object} _opts
|
|
383
|
+
* @param {string} _opts.baseUrl
|
|
384
|
+
* @param {string} _opts.apiKey
|
|
385
|
+
* @returns {StreamingClient}
|
|
386
|
+
*/
|
|
387
|
+
export function createAnthropicStreamingClient({
|
|
388
|
+
baseUrl: _,
|
|
389
|
+
apiKey: __,
|
|
390
|
+
}: {
|
|
391
|
+
baseUrl: string;
|
|
392
|
+
apiKey: string;
|
|
393
|
+
}): StreamingClient {
|
|
394
|
+
/**
|
|
395
|
+
* @yields {StreamEvent} Normalized stream events.
|
|
396
|
+
* @returns {AsyncGenerator<StreamEvent>}
|
|
397
|
+
*/
|
|
398
|
+
async function* streamChat(): AsyncGenerator<StreamEvent> {
|
|
399
|
+
yield {
|
|
400
|
+
type: "error",
|
|
401
|
+
message: "Anthropic provider is not yet implemented (planned for v2). Use OpenAI.",
|
|
402
|
+
code: "NOT_IMPLEMENTED",
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return { streamChat };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ─── Proxy implementation ────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Creates a streaming client that POSTs to a same-origin (or platform-resolved) proxy endpoint
|
|
413
|
+
* which already speaks the normalized StreamEvent SSE format (e.g. `/__studio/ai/chat`, see
|
|
414
|
+
* `@jxsuite/server/ai-api`). No API key handling here — the proxy owns provider credentials.
|
|
415
|
+
*
|
|
416
|
+
* @param {object} opts
|
|
417
|
+
* @param {string} opts.chatUrl - URL to POST `{ messages, tools, systemPrompt, model }` to
|
|
418
|
+
* @param {string} [opts.model] - Default model if not specified per-request
|
|
419
|
+
* @param {string} [opts.apiKey] - Optional client-supplied key, sent as the `X-Api-Key` header
|
|
420
|
+
* @param {string} [opts.baseUrl] - Optional OpenAI-compatible base URL, sent as `X-Api-Base-URL`
|
|
421
|
+
* @returns {StreamingClient}
|
|
422
|
+
*/
|
|
423
|
+
export function createProxyStreamingClient({
|
|
424
|
+
chatUrl,
|
|
425
|
+
model = "gpt-4o",
|
|
426
|
+
apiKey,
|
|
427
|
+
baseUrl,
|
|
428
|
+
}: {
|
|
429
|
+
chatUrl: string;
|
|
430
|
+
model?: string;
|
|
431
|
+
apiKey?: string | undefined;
|
|
432
|
+
baseUrl?: string | undefined;
|
|
433
|
+
}): StreamingClient {
|
|
434
|
+
/**
|
|
435
|
+
* @param {object[]} messages
|
|
436
|
+
* @param {object[]} tools
|
|
437
|
+
* @param {string} systemPrompt
|
|
438
|
+
* @param {AbortSignal} signal
|
|
439
|
+
* @yields {StreamEvent} Normalized stream events.
|
|
440
|
+
* @returns {AsyncGenerator<StreamEvent>}
|
|
441
|
+
*/
|
|
442
|
+
async function* streamChat(
|
|
443
|
+
messages: object[],
|
|
444
|
+
tools: object[],
|
|
445
|
+
systemPrompt: string,
|
|
446
|
+
signal: AbortSignal,
|
|
447
|
+
): AsyncGenerator<StreamEvent> {
|
|
448
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
449
|
+
// Client-supplied key (e.g. from Studio settings) — the proxy also falls back to OPENAI_API_KEY.
|
|
450
|
+
if (apiKey) {
|
|
451
|
+
headers["X-Api-Key"] = apiKey;
|
|
452
|
+
}
|
|
453
|
+
// Optional OpenAI-compatible endpoint override (local LLM, OpenRouter, Azure, …).
|
|
454
|
+
if (baseUrl) {
|
|
455
|
+
headers["X-Api-Base-URL"] = baseUrl;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
let response;
|
|
459
|
+
try {
|
|
460
|
+
response = await fetch(chatUrl, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
headers,
|
|
463
|
+
body: JSON.stringify({ messages, tools, systemPrompt, model }),
|
|
464
|
+
signal,
|
|
465
|
+
});
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if ((error as Error).name === "AbortError") {
|
|
468
|
+
yield { type: "done", stopReason: "cancelled" };
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
yield {
|
|
472
|
+
type: "error",
|
|
473
|
+
message: `Network error: ${(error as Error).message}`,
|
|
474
|
+
};
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (!response.ok) {
|
|
479
|
+
let errorBody = "";
|
|
480
|
+
try {
|
|
481
|
+
errorBody = await response.text();
|
|
482
|
+
} catch {
|
|
483
|
+
/* Ignore */
|
|
484
|
+
}
|
|
485
|
+
// Try to extract a clean message from JSON error bodies (e.g. the proxy's
|
|
486
|
+
// { error: "..." } shape or OpenAI's { error: { message: "..." } }).
|
|
487
|
+
let cleanMessage = errorBody || response.statusText;
|
|
488
|
+
try {
|
|
489
|
+
const parsed = JSON.parse(errorBody) as ErrorResponseBody;
|
|
490
|
+
if (typeof parsed.error === "string") {
|
|
491
|
+
cleanMessage = parsed.error;
|
|
492
|
+
} else if (parsed.error?.message) {
|
|
493
|
+
cleanMessage = parsed.error.message;
|
|
494
|
+
}
|
|
495
|
+
} catch {
|
|
496
|
+
/* Not JSON — use the raw body. */
|
|
497
|
+
}
|
|
498
|
+
yield {
|
|
499
|
+
type: "error",
|
|
500
|
+
message: cleanMessage,
|
|
501
|
+
code: String(response.status),
|
|
502
|
+
};
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const reader = response.body?.getReader();
|
|
507
|
+
if (!reader) {
|
|
508
|
+
yield { type: "error", message: "No response body" };
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const decoder = new TextDecoder();
|
|
513
|
+
let buffer = "";
|
|
514
|
+
|
|
515
|
+
try {
|
|
516
|
+
while (true) {
|
|
517
|
+
const { done, value } = await reader.read();
|
|
518
|
+
if (done) {
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
buffer += decoder.decode(value, { stream: true });
|
|
523
|
+
const lines = buffer.split("\n");
|
|
524
|
+
buffer = lines.pop() || "";
|
|
525
|
+
|
|
526
|
+
for (const line of lines) {
|
|
527
|
+
const trimmed = line.trim();
|
|
528
|
+
if (!trimmed || !trimmed.startsWith("data: ")) {
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const dataStr = trimmed.slice(6);
|
|
533
|
+
let event: StreamEvent;
|
|
534
|
+
try {
|
|
535
|
+
event = JSON.parse(dataStr) as StreamEvent;
|
|
536
|
+
} catch {
|
|
537
|
+
continue; // Skip unparseable chunks
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
yield event;
|
|
541
|
+
|
|
542
|
+
if (event.type === "done" || event.type === "error") {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
} catch (error) {
|
|
548
|
+
void reader.cancel();
|
|
549
|
+
if ((error as Error).name === "AbortError") {
|
|
550
|
+
yield { type: "done", stopReason: "cancelled" };
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
yield {
|
|
554
|
+
type: "error",
|
|
555
|
+
message: `Stream error: ${(error as Error).message}`,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return { streamChat };
|
|
561
|
+
}
|