tina4-nodejs 3.13.112 → 3.13.114

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.
@@ -29,6 +29,43 @@ export interface ApiResult {
29
29
  error: string | null;
30
30
  }
31
31
 
32
+ /**
33
+ * Options for the streaming primitives ({@link Api.streamBytes},
34
+ * {@link Api.streamLines}, {@link Api.streamSse}). All fields optional.
35
+ * `timeout` bounds the WHOLE stream (headers + body), matching
36
+ * `TINA4_API_TIMEOUT`; `connectTimeout` bounds only the connection +
37
+ * headers-arrival phase, matching `TINA4_API_CONNECT_TIMEOUT`.
38
+ */
39
+ export interface StreamOptions {
40
+ method?: string;
41
+ body?: unknown;
42
+ headers?: Record<string, string>;
43
+ contentType?: string;
44
+ timeout?: number;
45
+ connectTimeout?: number;
46
+ }
47
+
48
+ /**
49
+ * One SSE event yielded by {@link Api.streamSse}. `data` is always present
50
+ * (multi-line `data:` fields are concatenated with `\n`). `event`, `id`,
51
+ * `retry` are set only when the corresponding SSE field appeared. `retry`
52
+ * is a number (milliseconds) per the SSE spec.
53
+ */
54
+ export interface SseEvent {
55
+ data: string;
56
+ event?: string;
57
+ id?: string;
58
+ retry?: number;
59
+ }
60
+
61
+ /** Raised by the streaming primitives on a non-2xx status. */
62
+ export class ApiStreamError extends Error {
63
+ constructor(message: string, public readonly status: number | null = null) {
64
+ super(message);
65
+ this.name = "ApiStreamError";
66
+ }
67
+ }
68
+
32
69
  /**
33
70
  * Result of {@link Api.download}. There is no `body` field — the response
34
71
  * body went to disk. `path` is the destination on success and `null` on any
@@ -271,6 +308,104 @@ function buildMultipartBody(
271
308
  return Buffer.concat(parts);
272
309
  }
273
310
 
311
+ /**
312
+ * Split an async byte iterable into UTF-8 lines. Handles LF and CRLF; a
313
+ * multibyte codepoint that lands across a chunk boundary buffers across the
314
+ * split (TextDecoder({stream: true})). A trailing line without a terminator
315
+ * yields on EOF.
316
+ *
317
+ * Exported so {@link Api} instance methods AND `Ai.chat` streaming share
318
+ * one framer — ADR-0060's "no duplicate framing code" rule.
319
+ */
320
+ export async function* parseLineStream(chunks: AsyncIterable<Uint8Array>): AsyncGenerator<string> {
321
+ const decoder = new TextDecoder("utf-8");
322
+ let buffer = "";
323
+ for await (const chunk of chunks) {
324
+ buffer += decoder.decode(chunk as Uint8Array, { stream: true });
325
+ let idx: number;
326
+ while ((idx = buffer.indexOf("\n")) >= 0) {
327
+ let line = buffer.slice(0, idx);
328
+ buffer = buffer.slice(idx + 1);
329
+ if (line.endsWith("\r")) {
330
+ line = line.slice(0, -1);
331
+ }
332
+ yield line;
333
+ }
334
+ }
335
+ buffer += decoder.decode();
336
+ if (buffer.length > 0) {
337
+ if (buffer.endsWith("\r")) {
338
+ buffer = buffer.slice(0, -1);
339
+ }
340
+ yield buffer;
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Parse SSE (Server-Sent Events) framing from a line iterable. Yields
346
+ * one {@link SseEvent} per event boundary (blank line) or on EOF for a
347
+ * final trailing event. `:` comment lines are ignored. Fields are
348
+ * `data` (multi-line concatenated with `\n`), `event`, `id`, `retry`.
349
+ *
350
+ * Follows the WHATWG SSE parsing algorithm closely enough for every LLM
351
+ * provider (OpenAI, Anthropic, local): one leading space after the colon
352
+ * is stripped, unknown fields are ignored, malformed `retry:` values are
353
+ * ignored.
354
+ */
355
+ export async function* parseSseStream(lines: AsyncIterable<string>): AsyncGenerator<SseEvent> {
356
+ let dataParts: string[] = [];
357
+ let event: string | undefined;
358
+ let id: string | undefined;
359
+ let retry: number | undefined;
360
+ let has = false;
361
+ const emit = (): SseEvent | null => {
362
+ if (!has) return null;
363
+ const ev: SseEvent = { data: dataParts.join("\n") };
364
+ if (event !== undefined) ev.event = event;
365
+ if (id !== undefined) ev.id = id;
366
+ if (retry !== undefined) ev.retry = retry;
367
+ return ev;
368
+ };
369
+ const reset = (): void => { dataParts = []; event = undefined; id = undefined; retry = undefined; has = false; };
370
+ for await (const line of lines) {
371
+ if (line === "") {
372
+ const ev = emit();
373
+ if (ev) yield ev;
374
+ reset();
375
+ continue;
376
+ }
377
+ if (line.startsWith(":")) continue;
378
+ const colon = line.indexOf(":");
379
+ const field = colon < 0 ? line : line.slice(0, colon);
380
+ let value = colon < 0 ? "" : line.slice(colon + 1);
381
+ if (value.startsWith(" ")) value = value.slice(1);
382
+ switch (field) {
383
+ case "data":
384
+ dataParts.push(value);
385
+ has = true;
386
+ break;
387
+ case "event":
388
+ event = value;
389
+ has = true;
390
+ break;
391
+ case "id":
392
+ id = value;
393
+ has = true;
394
+ break;
395
+ case "retry": {
396
+ const parsed = Number(value);
397
+ if (Number.isFinite(parsed) && parsed >= 0) {
398
+ retry = parsed;
399
+ has = true;
400
+ }
401
+ break;
402
+ }
403
+ }
404
+ }
405
+ const trailing = emit();
406
+ if (trailing) yield trailing;
407
+ }
408
+
274
409
  /** Outcome of a single network exchange (after any redirects are followed). */
275
410
  type NetworkResult =
276
411
  | { kind: "response"; res: http.IncomingMessage }
@@ -605,8 +740,142 @@ export class Api {
605
740
  return { http_code: code, headers: respHeaders, error: null, path: destPath };
606
741
  }
607
742
 
743
+ /**
744
+ * Stream a response body as raw bytes. Yields the chunks the transport
745
+ * delivers, in order, never buffered whole. Ends cleanly on EOF and
746
+ * throws on a transport failure or a non-2xx status (body drained
747
+ * first). No JSON decoding, no line splitting, no framing —
748
+ * {@link streamLines} and {@link streamSse} build on this primitive.
749
+ *
750
+ * Closing the iterator before EOF (a `break` out of a `for await`)
751
+ * destroys the underlying socket, so a caller who takes only the
752
+ * first few chunks never leaks the connection.
753
+ *
754
+ * `opts.timeout` bounds the whole stream duration (default
755
+ * `TINA4_API_TIMEOUT` or the client `timeout`); `opts.connectTimeout`
756
+ * bounds just the connect + headers phase (default
757
+ * `TINA4_API_CONNECT_TIMEOUT` or 10s).
758
+ */
759
+ async *streamBytes(path: string, opts: StreamOptions = {}): AsyncGenerator<Uint8Array> {
760
+ const url = this.buildUrl(path);
761
+ const method = (opts.method ?? "GET").toUpperCase();
762
+ const contentType = opts.contentType ?? "application/json";
763
+ const { headers, data } = this.buildRequest(method, contentType, opts.body, opts.headers);
764
+ const totalSec = this.streamSeconds(opts.timeout, "TINA4_API_TIMEOUT", this.timeout);
765
+ const connectSec = this.streamSeconds(opts.connectTimeout, "TINA4_API_CONNECT_TIMEOUT", 10);
766
+ const opened = await this.openStreamRequest(method, url, headers, data, connectSec);
767
+ const res = opened.res;
768
+ const status = res.statusCode ?? 0;
769
+ this.storeCookies(res.headers["set-cookie"]);
770
+ if (status < 200 || status >= 300) {
771
+ res.resume();
772
+ throw new ApiStreamError(`stream failed with HTTP ${status}`, status);
773
+ }
774
+ let totalTimer: NodeJS.Timeout | null = null;
775
+ if (totalSec > 0) {
776
+ totalTimer = setTimeout(() => {
777
+ res.destroy(new ApiStreamError(`stream total timeout after ${totalSec}s`, null));
778
+ }, totalSec * 1000);
779
+ }
780
+ try {
781
+ for await (const chunk of res) {
782
+ yield chunk as Uint8Array;
783
+ }
784
+ } finally {
785
+ if (totalTimer) clearTimeout(totalTimer);
786
+ if (!res.destroyed) res.destroy();
787
+ }
788
+ }
789
+
790
+ /**
791
+ * Stream the response body as UTF-8 lines. Splits on LF or CRLF;
792
+ * buffers a multibyte codepoint that lands across a chunk boundary;
793
+ * yields a trailing line without a terminator on EOF. Built on
794
+ * {@link streamBytes} plus the shared {@link parseLineStream}.
795
+ */
796
+ async *streamLines(path: string, opts: StreamOptions = {}): AsyncGenerator<string> {
797
+ yield* parseLineStream(this.streamBytes(path, opts));
798
+ }
799
+
800
+ /**
801
+ * Stream the response as SSE (Server-Sent Events). Yields one
802
+ * {@link SseEvent} per event boundary (blank line) or on EOF for a
803
+ * trailing event. `data:[DONE]` is delivered as an ordinary event
804
+ * with `data === "[DONE]"` and the iterator ends on the next EOF.
805
+ * Built on {@link streamLines} plus the shared {@link parseSseStream}.
806
+ */
807
+ async *streamSse(path: string, opts: StreamOptions = {}): AsyncGenerator<SseEvent> {
808
+ yield* parseSseStream(this.streamLines(path, opts));
809
+ }
810
+
608
811
  // ── Internal helpers ──────────────────────────────────────────────
609
812
 
813
+ /**
814
+ * Resolve a stream duration from (in order): explicit `opts` field,
815
+ * the named env var, then the fallback. Zero disables. A non-numeric
816
+ * or negative env value warns via a fallback rather than throwing —
817
+ * a bad env var must not brick every streaming call.
818
+ */
819
+ private streamSeconds(explicit: number | undefined, envName: string, fallback: number): number {
820
+ if (explicit !== undefined) {
821
+ return Number.isFinite(explicit) && explicit >= 0 ? Number(explicit) : fallback;
822
+ }
823
+ const raw = process.env[envName];
824
+ if (raw === undefined) return fallback;
825
+ const n = Number(raw);
826
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
827
+ }
828
+
829
+ /**
830
+ * Open a streaming HTTP request. Returns the raw
831
+ * {@link http.IncomingMessage} once headers arrive. Redirects are NOT
832
+ * followed on streams (a caller who needs a redirect should do a
833
+ * regular GET first). Connect phase is bounded by `connectSec`;
834
+ * body-phase timeout is applied by the caller (streamBytes) via
835
+ * `res.destroy()`.
836
+ */
837
+ private openStreamRequest(
838
+ method: string,
839
+ url: string,
840
+ headers: Record<string, string>,
841
+ data: Buffer | undefined,
842
+ connectSec: number,
843
+ ): Promise<{ res: http.IncomingMessage }> {
844
+ return new Promise((resolve, reject) => {
845
+ let parsed: URL;
846
+ try {
847
+ parsed = new URL(url);
848
+ } catch (err) {
849
+ reject(err instanceof Error ? err : new Error(String(err)));
850
+ return;
851
+ }
852
+ const isHttps = parsed.protocol === "https:";
853
+ const protocolModule = isHttps ? https : http;
854
+ const options: http.RequestOptions = {
855
+ hostname: parsed.hostname,
856
+ port: parsed.port || (isHttps ? 443 : 80),
857
+ path: parsed.pathname + parsed.search,
858
+ method,
859
+ headers,
860
+ timeout: connectSec > 0 ? connectSec * 1000 : undefined,
861
+ };
862
+ if (isHttps && this.ignoreSsl) {
863
+ (options as https.RequestOptions).rejectUnauthorized = false;
864
+ }
865
+ const req = protocolModule.request(options, (res) => {
866
+ resolve({ res });
867
+ });
868
+ req.on("timeout", () => {
869
+ req.destroy(new ApiStreamError(`stream connect timeout after ${connectSec}s`, null));
870
+ });
871
+ req.on("error", (err) => {
872
+ reject(err);
873
+ });
874
+ if (data) req.write(data);
875
+ req.end();
876
+ });
877
+ }
878
+
610
879
  private buildUrl(path: string): string {
611
880
  if (path.startsWith("http://") || path.startsWith("https://")) {
612
881
  return path;
@@ -81,8 +81,8 @@ export { ServiceRunner, Tina4Service, matchCronField, matchesCron } from "./serv
81
81
  export type { ServiceOptions, ServiceContext, ServiceHandler, ServiceInfo } from "./service.js";
82
82
  export { responseCache, clearCache, cacheStats, cacheGet, cacheSet, cacheDelete, cacheClear, cacheBackendStats, sweep, createBackend, _resetBackend } from "./cache.js";
83
83
  export type { ResponseCacheConfig, CacheBackend } from "./cache.js";
84
- export { Api } from "./api.js";
85
- export type { ApiResult, ApiOptions, ApiTransport, DownloadResult, UploadOptions } from "./api.js";
84
+ export { Api, ApiStreamError, parseLineStream, parseSseStream } from "./api.js";
85
+ export type { ApiResult, ApiOptions, ApiTransport, DownloadResult, UploadOptions, StreamOptions, SseEvent } from "./api.js";
86
86
  export { Context, defaultContext, existingContext, fts5Supported, _sharedContexts } from "./context/index.js";
87
87
  export type { SearchHit } from "./context/index.js";
88
88
  export { Events } from "./events.js";
@@ -110,7 +110,7 @@ export type { AiTool } from "./ai.js";
110
110
  export { Sso, SSO, SsoError } from "./sso.js";
111
111
  export type { SsoOptions } from "./sso.js";
112
112
  export { Ai, AiError, AiConfigError, AiHTTPError, AiTimeoutError, AiParseError } from "./aiClient.js";
113
- export type { ChatResponse, AiMessage, AiChatOptions, AiEmbedOptions } from "./aiClient.js";
113
+ export type { ChatResponse, AiMessage, AiChatOptions, AiEmbedOptions, AiEvent, ContentPart, AiMessageContent, AiToolDeclaration, AiToolChoice } from "./aiClient.js";
114
114
  export type { ImapMessage, ImapFullMessage, ImapAttachment } from "./messenger.js";
115
115
  export { LiteBackend } from "./queueBackends/liteBackend.js";
116
116
  export { RabbitMQBackend, parseAmqpUrl } from "./queueBackends/rabbitmqBackend.js";