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.
- package/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +683 -48
- package/packages/core/dist/index.js +686 -48
- package/packages/core/src/aiClient.ts +569 -48
- package/packages/core/src/api.ts +269 -0
- package/packages/core/src/index.ts +3 -3
- package/packages/orm/dist/index.js +683 -48
- package/types/core/src/aiClient.d.ts +158 -5
- package/types/core/src/api.d.ts +102 -0
- package/types/core/src/index.d.ts +3 -3
package/packages/core/src/api.ts
CHANGED
|
@@ -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";
|