@jxsuite/ai 0.39.3 → 0.39.4

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.
@@ -145,15 +145,18 @@ interface OpenAIChatRequestBody {
145
145
  }
146
146
 
147
147
  /** A tool-call fragment inside an OpenAI streaming `delta`. */
148
- interface OpenAIToolCallDelta {
148
+ export interface OpenAIToolCallDelta {
149
149
  index: number;
150
150
  id?: string;
151
151
  type?: string;
152
152
  function?: { name?: string; arguments?: string };
153
153
  }
154
154
 
155
- /** A single OpenAI chat-completions streaming chunk (the JSON after `data: `). */
156
- interface OpenAIStreamChunk {
155
+ /**
156
+ * A single OpenAI chat-completions streaming chunk (the JSON after `data: `). Exported as a type
157
+ * only, for the gateway's normalizer, which reads the same chunks.
158
+ */
159
+ export interface OpenAIStreamChunk {
157
160
  choices?: {
158
161
  index?: number;
159
162
  delta?: {
@@ -184,7 +187,7 @@ export interface OpenAIUsage {
184
187
  * which is not the same as zero.
185
188
  */
186
189
  export function usageEventFromOpenAI(usage?: OpenAIUsage | null): StreamUsageEvent | null {
187
- if (!usage || typeof usage.prompt_tokens !== "number") {
190
+ if (!usage || !isCount(usage.prompt_tokens)) {
188
191
  return null;
189
192
  }
190
193
  const event: StreamUsageEvent = {
@@ -193,11 +196,11 @@ export function usageEventFromOpenAI(usage?: OpenAIUsage | null): StreamUsageEve
193
196
  outputTokens: usage.completion_tokens ?? 0,
194
197
  };
195
198
  const cached = usage.prompt_tokens_details?.cached_tokens;
196
- if (typeof cached === "number") {
199
+ if (isCount(cached)) {
197
200
  event.cachedInputTokens = cached;
198
201
  }
199
202
  const reasoning = usage.completion_tokens_details?.reasoning_tokens;
200
- if (typeof reasoning === "number") {
203
+ if (isCount(reasoning)) {
201
204
  event.reasoningTokens = reasoning;
202
205
  }
203
206
  return event;
@@ -215,58 +218,139 @@ function stopReasonFromOpenAI(reason: string | null | undefined): string | null
215
218
  return null;
216
219
  }
217
220
 
218
- /** Error response body — the proxy's flat `{ error: "..." }` or OpenAI's `{ error: { message } }`. */
219
- interface ErrorResponseBody {
220
- error?: string | { message?: string; code?: string; type?: string };
221
- /**
222
- * The proxy's machine code, alongside its human `error` — `cf_reconnect_required` and friends.
223
- *
224
- * A backend that knows WHY it refused says so here, and a client can only act on the reason it is
225
- * given: the whole point of the code is that the Reconnect affordance appears without the user
226
- * reading a sentence and deciding what it meant.
227
- */
228
- code?: string;
229
- /**
230
- * An array-shaped envelope, as Cloudflare's own REST API uses for a BYOK base URL pointed
231
- * directly at it: `{ success: false, errors: [{ code, message }] }`, with no top-level `error`
232
- * key at all. Reachable only when `baseUrl` bypasses the Studio proxy and talks to such a
233
- * provider directly — the proxy itself normalizes this shape before it ever reaches a client.
234
- */
235
- errors?: { code?: number; message?: string }[];
221
+ /**
222
+ * True when a figure the provider sent is a number. A chunk is upstream JSON, so a figure its
223
+ * interface calls a number is checked before it is reported.
224
+ *
225
+ * @param {unknown} value - A usage figure, as the provider sent it
226
+ * @returns {boolean}
227
+ */
228
+ function isCount(value: unknown): value is number {
229
+ return typeof value === "number";
230
+ }
231
+
232
+ /**
233
+ * True when a member of a parsed body or chunk is a string, empty or not.
234
+ *
235
+ * @param {unknown} value - A member, as sent
236
+ * @returns {boolean}
237
+ */
238
+ function isText(value: unknown): value is string {
239
+ return typeof value === "string";
240
+ }
241
+
242
+ /**
243
+ * True when a member of a parsed error body is a non-empty string, the only `code` worth reading.
244
+ *
245
+ * @param {unknown} value - A `code` member, as sent
246
+ * @returns {boolean}
247
+ */
248
+ function isSentence(value: unknown): value is string {
249
+ return isText(value) && value !== "";
250
+ }
251
+
252
+ /**
253
+ * True when a parsed value is a JSON object (or array) whose members may be probed with `in`; false
254
+ * for `null` and every primitive.
255
+ *
256
+ * @param {unknown} value - A parsed body, or one of its members
257
+ * @returns {boolean}
258
+ */
259
+ function isMemberBag(value: unknown): value is object {
260
+ return typeof value === "object" && value !== null;
261
+ }
262
+
263
+ /**
264
+ * True when a parsed `errors` member is an array.
265
+ *
266
+ * @param {unknown} value - The parsed body's `errors` member
267
+ * @returns {boolean}
268
+ */
269
+ function isEntryList(value: unknown): value is readonly unknown[] {
270
+ return Array.isArray(value);
236
271
  }
237
272
 
238
273
  /**
239
274
  * Extract a human-readable message from an error body, falling back to the raw body (or `fallback`
240
275
  * when the body is empty).
276
+ *
277
+ * The body is the proxy's flat `{ error: "..." }`, OpenAI's `{ error: { message } }`, or the
278
+ * array-shaped envelope Cloudflare's own REST API uses for a BYOK base URL pointed directly at it:
279
+ * `{ success: false, errors: [{ code, message }] }`, with no top-level `error` key at all. That
280
+ * last one is reachable only when `baseUrl` bypasses the Studio proxy, which normalizes the shape
281
+ * before it ever reaches a client.
241
282
  */
242
283
  function extractErrorMessage(rawBody: string, fallback: string): string {
243
284
  if (!rawBody) {
244
285
  return fallback;
245
286
  }
287
+ let parsed: unknown;
246
288
  try {
247
- const { error, errors } = JSON.parse(rawBody) as ErrorResponseBody;
248
- if (typeof error === "string") {
249
- return error;
250
- }
251
- if (error) {
252
- const { message } = error;
253
- if (message) {
254
- return message;
289
+ parsed = JSON.parse(rawBody);
290
+ } catch {
291
+ /* Not JSON — use the raw body. */
292
+ return rawBody;
293
+ }
294
+ // JSON `null`, a primitive or an array carries neither member, so it is the raw body.
295
+ if (isMemberBag(parsed)) {
296
+ if ("error" in parsed) {
297
+ const { error } = parsed;
298
+ if (isText(error)) {
299
+ return error;
300
+ }
301
+ if (isMemberBag(error) && "message" in error && Boolean(error.message)) {
302
+ return String(error.message);
255
303
  }
256
304
  }
257
- const [first] = errors ?? [];
258
- if (first) {
259
- const { message } = first;
260
- if (message) {
261
- return message;
305
+ if ("errors" in parsed && isEntryList(parsed.errors)) {
306
+ const [first] = parsed.errors;
307
+ if (isMemberBag(first) && "message" in first && Boolean(first.message)) {
308
+ return String(first.message);
262
309
  }
263
310
  }
264
- } catch {
265
- /* Not JSON — use the raw body. */
266
311
  }
267
312
  return rawBody;
268
313
  }
269
314
 
315
+ /**
316
+ * The proxy's machine code in an error body (`cf_reconnect_required` and friends), or "" when the
317
+ * body carries none.
318
+ *
319
+ * A backend that knows WHY it refused says so in `code`, beside its human `error`, and a client can
320
+ * only act on the reason it is given: the whole point of the code is that the Reconnect affordance
321
+ * appears without the user reading a sentence and deciding what it meant.
322
+ */
323
+ function extractMachineCode(rawBody: string): string {
324
+ let parsed: unknown;
325
+ try {
326
+ parsed = JSON.parse(rawBody);
327
+ } catch {
328
+ /* Not JSON — there is no code. */
329
+ return "";
330
+ }
331
+ return isMemberBag(parsed) && "code" in parsed && isSentence(parsed.code) ? parsed.code : "";
332
+ }
333
+
334
+ /**
335
+ * The event for an exchange that failed: `done: cancelled` for an abort, otherwise an `error` event
336
+ * whose message is `prefix` and the failure's own message.
337
+ *
338
+ * @param {"Network error" | "Stream error"} prefix - Which half of the exchange failed
339
+ * @param {unknown} cause - What the request or the read rejected with
340
+ * @returns {StreamEvent}
341
+ */
342
+ function failureEvent(prefix: "Network error" | "Stream error", cause: unknown): StreamEvent {
343
+ /* SAFETY: `fetch` and a body reader reject with an Error (a TypeError, or the abort's
344
+ AbortError DOMException), and the reads in a stream throw only TypeErrors. Only `name` and
345
+ `message` are read, so a custom non-null abort reason that is not an Error reads both as
346
+ undefined, as it always has. */
347
+ const failure = cause as Error;
348
+ if (failure.name === "AbortError") {
349
+ return { type: "done", stopReason: "cancelled" };
350
+ }
351
+ return { type: "error", message: `${prefix}: ${failure.message}` };
352
+ }
353
+
270
354
  // ─── Type definitions ────────────────────────────────────────────────────────
271
355
 
272
356
  /**
@@ -369,7 +453,7 @@ export function createOpenAIStreamingClient({
369
453
  body.temperature = temperature;
370
454
  }
371
455
 
372
- if (tools && tools.length > 0) {
456
+ if (tools.length > 0) {
373
457
  body.tools = tools;
374
458
  body.tool_choice = "auto";
375
459
  body.parallel_tool_calls = true;
@@ -389,14 +473,7 @@ export function createOpenAIStreamingClient({
389
473
  signal,
390
474
  });
391
475
  } catch (error) {
392
- if ((error as Error).name === "AbortError") {
393
- yield { type: "done", stopReason: "cancelled" };
394
- return;
395
- }
396
- yield {
397
- type: "error",
398
- message: `Network error: ${(error as Error).message}`,
399
- };
476
+ yield failureEvent("Network error", error);
400
477
  return;
401
478
  }
402
479
 
@@ -458,13 +535,8 @@ export function createOpenAIStreamingClient({
458
535
  }
459
536
 
460
537
  try {
461
- while (true) {
462
- const { done, value } = await reader.read();
463
- if (done) {
464
- break;
465
- }
466
-
467
- buffer += decoder.decode(value, { stream: true });
538
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
539
+ buffer += decoder.decode(chunk.value, { stream: true });
468
540
  const lines = buffer.split("\n");
469
541
  // Keep the last (potentially incomplete) line in the buffer
470
542
  buffer = lines.pop() || "";
@@ -483,6 +555,10 @@ export function createOpenAIStreamingClient({
483
555
 
484
556
  let parsed: OpenAIStreamChunk;
485
557
  try {
558
+ /* SAFETY: the interface is the provider's wire shape, and this loop only reads it: each
559
+ member passes optional chaining, a truthiness test or a predicate before it shapes an
560
+ event, and a chunk too malformed to read (JSON `null`, a non-iterable `tool_calls`)
561
+ throws into the catch below, which reports it as a stream error. */
486
562
  parsed = JSON.parse(dataStr) as OpenAIStreamChunk;
487
563
  } catch {
488
564
  continue; // Skip unparseable chunks
@@ -505,7 +581,7 @@ export function createOpenAIStreamingClient({
505
581
 
506
582
  // Chain-of-thought, under either of the two names providers give it.
507
583
  const reasoning = delta?.reasoning_content ?? delta?.reasoning;
508
- if (typeof reasoning === "string" && reasoning) {
584
+ if (isText(reasoning) && reasoning) {
509
585
  yield { type: "reasoning", content: reasoning };
510
586
  }
511
587
 
@@ -553,14 +629,7 @@ export function createOpenAIStreamingClient({
553
629
  yield* finish();
554
630
  } catch (error) {
555
631
  void reader.cancel();
556
- if ((error as Error).name === "AbortError") {
557
- yield { type: "done", stopReason: "cancelled" };
558
- return;
559
- }
560
- yield {
561
- type: "error",
562
- message: `Stream error: ${(error as Error).message}`,
563
- };
632
+ yield failureEvent("Stream error", error);
564
633
  }
565
634
  }
566
635
 
@@ -570,22 +639,25 @@ export function createOpenAIStreamingClient({
570
639
  // ─── Anthropic implementation (stub for v1) ──────────────────────────────────
571
640
 
572
641
  /**
573
- * Creates an Anthropic-compatible streaming client. Stub for v1 — throws on use. Full
574
- * implementation in v2 handles Anthropic's content_block delta model.
575
- *
576
- * @param {object} _opts
577
- * @param {string} _opts.baseUrl
578
- * @param {string} _opts.apiKey
579
- * @returns {StreamingClient}
642
+ * The credentials a provider endpoint is opened with: its base URL and the API key it takes, two
643
+ * fields that belong together. Studio keys its model listing on the same pair.
580
644
  */
581
- export interface AnthropicStreamingClientOptions {
645
+ export interface AiCredentials {
582
646
  baseUrl: string;
583
647
  apiKey: string;
584
648
  }
585
649
 
586
- export function createAnthropicStreamingClient(
587
- _opts: AnthropicStreamingClientOptions,
588
- ): StreamingClient {
650
+ /** The Anthropic client's options: the provider credentials and nothing more, for now. */
651
+ export type { AiCredentials as AnthropicStreamingClientOptions };
652
+
653
+ /**
654
+ * Creates an Anthropic-compatible streaming client. Stub for v1 — throws on use. Full
655
+ * implementation in v2 handles Anthropic's content_block delta model.
656
+ *
657
+ * @param {AiCredentials} _opts
658
+ * @returns {StreamingClient}
659
+ */
660
+ export function createAnthropicStreamingClient(_opts: AiCredentials): StreamingClient {
589
661
  /**
590
662
  * @yields {StreamEvent} Normalized stream events.
591
663
  * @returns {AsyncGenerator<StreamEvent>}
@@ -631,6 +703,27 @@ export interface ProxyStreamingClientOptions {
631
703
  sessionId?: string | undefined;
632
704
  }
633
705
 
706
+ /**
707
+ * The headers a proxy request carries: always the content type, plus `X-Api-Key`, `X-Api-Base-URL`
708
+ * and the session header `AI_SESSION_HEADER` names, each only when the client was given its value.
709
+ * The optional names go through the index signature, as a `fetch` init's record takes them.
710
+ */
711
+ interface ProxyRequestHeaders extends Record<string, string> {
712
+ "Content-Type": string;
713
+ }
714
+
715
+ /**
716
+ * True when a proxy's `chatUrl` is a lookup to call rather than the URL itself.
717
+ *
718
+ * @param {ProxyStreamingClientOptions["chatUrl"]} chatUrl
719
+ * @returns {boolean}
720
+ */
721
+ function isUrlLookup(
722
+ chatUrl: ProxyStreamingClientOptions["chatUrl"],
723
+ ): chatUrl is () => string | Promise<string> {
724
+ return typeof chatUrl === "function";
725
+ }
726
+
634
727
  export function createProxyStreamingClient({
635
728
  chatUrl,
636
729
  model = "gpt-4o",
@@ -655,7 +748,7 @@ export function createProxyStreamingClient({
655
748
  systemPrompt: string,
656
749
  signal: AbortSignal,
657
750
  ): AsyncGenerator<StreamEvent> {
658
- const headers: Record<string, string> = { "Content-Type": "application/json" };
751
+ const headers: ProxyRequestHeaders = { "Content-Type": "application/json" };
659
752
  // Client-supplied key (e.g. from Studio settings) — the proxy also falls back to OPENAI_API_KEY.
660
753
  if (apiKey) {
661
754
  headers["X-Api-Key"] = apiKey;
@@ -671,20 +764,20 @@ export function createProxyStreamingClient({
671
764
  headers[AI_SESSION_HEADER] = sessionId;
672
765
  }
673
766
 
674
- resolvedUrl ??= Promise.resolve(typeof chatUrl === "function" ? chatUrl() : chatUrl);
767
+ resolvedUrl ??= Promise.resolve(isUrlLookup(chatUrl) ? chatUrl() : chatUrl);
675
768
  let url: string;
676
769
  try {
677
770
  url = await resolvedUrl;
678
771
  } catch (error) {
679
772
  // A lookup that failed after the turn was stopped is part of the stop, not an error.
680
- if (signal?.aborted) {
773
+ if (signal.aborted) {
681
774
  yield { type: "done", stopReason: "cancelled" };
682
775
  return;
683
776
  }
684
777
  throw error;
685
778
  }
686
779
  // Stopped while the URL was being resolved: nothing has been sent, and nothing will be.
687
- if (signal?.aborted) {
780
+ if (signal.aborted) {
688
781
  yield { type: "done", stopReason: "cancelled" };
689
782
  return;
690
783
  }
@@ -698,14 +791,7 @@ export function createProxyStreamingClient({
698
791
  signal,
699
792
  });
700
793
  } catch (error) {
701
- if ((error as Error).name === "AbortError") {
702
- yield { type: "done", stopReason: "cancelled" };
703
- return;
704
- }
705
- yield {
706
- type: "error",
707
- message: `Network error: ${(error as Error).message}`,
708
- };
794
+ yield failureEvent("Network error", error);
709
795
  return;
710
796
  }
711
797
 
@@ -722,15 +808,7 @@ export function createProxyStreamingClient({
722
808
  unconditionally, so a proxy answering 401 `{ code: "cf_reconnect_required" }` reached the
723
809
  client as `"401"` — indistinguishable from a bad BYOK key, and the one reading that would
724
810
  have put a Reconnect button on screen was thrown away at the only place it arrived. */
725
- let machineCode = "";
726
- try {
727
- const { code } = JSON.parse(errorBody) as ErrorResponseBody;
728
- if (typeof code === "string" && code) {
729
- machineCode = code;
730
- }
731
- } catch {
732
- /* Not JSON — use the raw body. */
733
- }
811
+ const machineCode = extractMachineCode(errorBody);
734
812
  yield {
735
813
  type: "error",
736
814
  message: cleanMessage,
@@ -749,13 +827,8 @@ export function createProxyStreamingClient({
749
827
  let buffer = "";
750
828
 
751
829
  try {
752
- while (true) {
753
- const { done, value } = await reader.read();
754
- if (done) {
755
- break;
756
- }
757
-
758
- buffer += decoder.decode(value, { stream: true });
830
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
831
+ buffer += decoder.decode(chunk.value, { stream: true });
759
832
  const lines = buffer.split("\n");
760
833
  buffer = lines.pop() || "";
761
834
 
@@ -768,6 +841,10 @@ export function createProxyStreamingClient({
768
841
  const dataStr = trimmed.slice(6);
769
842
  let event: StreamEvent;
770
843
  try {
844
+ /* SAFETY: the proxy is the chat route, whose every `data:` line is one StreamEvent
845
+ (`encodeSse` frames exactly that). The event is relayed as sent and only `type` is
846
+ read; a line of JSON `null` is relayed, then throws at that read into the catch
847
+ below. */
771
848
  event = JSON.parse(dataStr) as StreamEvent;
772
849
  } catch {
773
850
  continue; // Skip unparseable chunks
@@ -782,14 +859,7 @@ export function createProxyStreamingClient({
782
859
  }
783
860
  } catch (error) {
784
861
  void reader.cancel();
785
- if ((error as Error).name === "AbortError") {
786
- yield { type: "done", stopReason: "cancelled" };
787
- return;
788
- }
789
- yield {
790
- type: "error",
791
- message: `Stream error: ${(error as Error).message}`,
792
- };
862
+ yield failureEvent("Stream error", error);
793
863
  }
794
864
  }
795
865