@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.
@@ -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
+ }