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.
@@ -27611,11 +27611,100 @@ function buildMultipartBody(boundary, fieldName, filename, fileContent, contentT
27611
27611
  parts.push(Buffer.from(delimiter2 + "--" + crlf, "utf-8"));
27612
27612
  return Buffer.concat(parts);
27613
27613
  }
27614
- var RETRY_STATUSES, DOWNLOAD_CHUNK_SIZE, MAX_REDIRECTS, STRIP_ON_CROSS_ORIGIN, MIME_BY_EXT, Api;
27614
+ async function* parseLineStream(chunks) {
27615
+ const decoder = new TextDecoder("utf-8");
27616
+ let buffer = "";
27617
+ for await (const chunk of chunks) {
27618
+ buffer += decoder.decode(chunk, { stream: true });
27619
+ let idx;
27620
+ while ((idx = buffer.indexOf("\n")) >= 0) {
27621
+ let line = buffer.slice(0, idx);
27622
+ buffer = buffer.slice(idx + 1);
27623
+ if (line.endsWith("\r")) {
27624
+ line = line.slice(0, -1);
27625
+ }
27626
+ yield line;
27627
+ }
27628
+ }
27629
+ buffer += decoder.decode();
27630
+ if (buffer.length > 0) {
27631
+ if (buffer.endsWith("\r")) {
27632
+ buffer = buffer.slice(0, -1);
27633
+ }
27634
+ yield buffer;
27635
+ }
27636
+ }
27637
+ async function* parseSseStream(lines) {
27638
+ let dataParts = [];
27639
+ let event;
27640
+ let id;
27641
+ let retry;
27642
+ let has = false;
27643
+ const emit = () => {
27644
+ if (!has) return null;
27645
+ const ev = { data: dataParts.join("\n") };
27646
+ if (event !== void 0) ev.event = event;
27647
+ if (id !== void 0) ev.id = id;
27648
+ if (retry !== void 0) ev.retry = retry;
27649
+ return ev;
27650
+ };
27651
+ const reset2 = () => {
27652
+ dataParts = [];
27653
+ event = void 0;
27654
+ id = void 0;
27655
+ retry = void 0;
27656
+ has = false;
27657
+ };
27658
+ for await (const line of lines) {
27659
+ if (line === "") {
27660
+ const ev = emit();
27661
+ if (ev) yield ev;
27662
+ reset2();
27663
+ continue;
27664
+ }
27665
+ if (line.startsWith(":")) continue;
27666
+ const colon = line.indexOf(":");
27667
+ const field = colon < 0 ? line : line.slice(0, colon);
27668
+ let value = colon < 0 ? "" : line.slice(colon + 1);
27669
+ if (value.startsWith(" ")) value = value.slice(1);
27670
+ switch (field) {
27671
+ case "data":
27672
+ dataParts.push(value);
27673
+ has = true;
27674
+ break;
27675
+ case "event":
27676
+ event = value;
27677
+ has = true;
27678
+ break;
27679
+ case "id":
27680
+ id = value;
27681
+ has = true;
27682
+ break;
27683
+ case "retry": {
27684
+ const parsed = Number(value);
27685
+ if (Number.isFinite(parsed) && parsed >= 0) {
27686
+ retry = parsed;
27687
+ has = true;
27688
+ }
27689
+ break;
27690
+ }
27691
+ }
27692
+ }
27693
+ const trailing = emit();
27694
+ if (trailing) yield trailing;
27695
+ }
27696
+ var ApiStreamError, RETRY_STATUSES, DOWNLOAD_CHUNK_SIZE, MAX_REDIRECTS, STRIP_ON_CROSS_ORIGIN, MIME_BY_EXT, Api;
27615
27697
  var init_api = __esm({
27616
27698
  "../core/src/api.ts"() {
27617
27699
  "use strict";
27618
27700
  init_version();
27701
+ ApiStreamError = class extends Error {
27702
+ constructor(message, status2 = null) {
27703
+ super(message);
27704
+ this.status = status2;
27705
+ this.name = "ApiStreamError";
27706
+ }
27707
+ };
27619
27708
  RETRY_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
27620
27709
  DOWNLOAD_CHUNK_SIZE = 64 * 1024;
27621
27710
  MAX_REDIRECTS = 10;
@@ -27934,7 +28023,130 @@ var init_api = __esm({
27934
28023
  }
27935
28024
  return { http_code: code, headers: respHeaders, error: null, path: destPath };
27936
28025
  }
28026
+ /**
28027
+ * Stream a response body as raw bytes. Yields the chunks the transport
28028
+ * delivers, in order, never buffered whole. Ends cleanly on EOF and
28029
+ * throws on a transport failure or a non-2xx status (body drained
28030
+ * first). No JSON decoding, no line splitting, no framing —
28031
+ * {@link streamLines} and {@link streamSse} build on this primitive.
28032
+ *
28033
+ * Closing the iterator before EOF (a `break` out of a `for await`)
28034
+ * destroys the underlying socket, so a caller who takes only the
28035
+ * first few chunks never leaks the connection.
28036
+ *
28037
+ * `opts.timeout` bounds the whole stream duration (default
28038
+ * `TINA4_API_TIMEOUT` or the client `timeout`); `opts.connectTimeout`
28039
+ * bounds just the connect + headers phase (default
28040
+ * `TINA4_API_CONNECT_TIMEOUT` or 10s).
28041
+ */
28042
+ async *streamBytes(path8, opts = {}) {
28043
+ const url = this.buildUrl(path8);
28044
+ const method = (opts.method ?? "GET").toUpperCase();
28045
+ const contentType = opts.contentType ?? "application/json";
28046
+ const { headers, data } = this.buildRequest(method, contentType, opts.body, opts.headers);
28047
+ const totalSec = this.streamSeconds(opts.timeout, "TINA4_API_TIMEOUT", this.timeout);
28048
+ const connectSec = this.streamSeconds(opts.connectTimeout, "TINA4_API_CONNECT_TIMEOUT", 10);
28049
+ const opened = await this.openStreamRequest(method, url, headers, data, connectSec);
28050
+ const res = opened.res;
28051
+ const status2 = res.statusCode ?? 0;
28052
+ this.storeCookies(res.headers["set-cookie"]);
28053
+ if (status2 < 200 || status2 >= 300) {
28054
+ res.resume();
28055
+ throw new ApiStreamError(`stream failed with HTTP ${status2}`, status2);
28056
+ }
28057
+ let totalTimer = null;
28058
+ if (totalSec > 0) {
28059
+ totalTimer = setTimeout(() => {
28060
+ res.destroy(new ApiStreamError(`stream total timeout after ${totalSec}s`, null));
28061
+ }, totalSec * 1e3);
28062
+ }
28063
+ try {
28064
+ for await (const chunk of res) {
28065
+ yield chunk;
28066
+ }
28067
+ } finally {
28068
+ if (totalTimer) clearTimeout(totalTimer);
28069
+ if (!res.destroyed) res.destroy();
28070
+ }
28071
+ }
28072
+ /**
28073
+ * Stream the response body as UTF-8 lines. Splits on LF or CRLF;
28074
+ * buffers a multibyte codepoint that lands across a chunk boundary;
28075
+ * yields a trailing line without a terminator on EOF. Built on
28076
+ * {@link streamBytes} plus the shared {@link parseLineStream}.
28077
+ */
28078
+ async *streamLines(path8, opts = {}) {
28079
+ yield* parseLineStream(this.streamBytes(path8, opts));
28080
+ }
28081
+ /**
28082
+ * Stream the response as SSE (Server-Sent Events). Yields one
28083
+ * {@link SseEvent} per event boundary (blank line) or on EOF for a
28084
+ * trailing event. `data:[DONE]` is delivered as an ordinary event
28085
+ * with `data === "[DONE]"` and the iterator ends on the next EOF.
28086
+ * Built on {@link streamLines} plus the shared {@link parseSseStream}.
28087
+ */
28088
+ async *streamSse(path8, opts = {}) {
28089
+ yield* parseSseStream(this.streamLines(path8, opts));
28090
+ }
27937
28091
  // ── Internal helpers ──────────────────────────────────────────────
28092
+ /**
28093
+ * Resolve a stream duration from (in order): explicit `opts` field,
28094
+ * the named env var, then the fallback. Zero disables. A non-numeric
28095
+ * or negative env value warns via a fallback rather than throwing —
28096
+ * a bad env var must not brick every streaming call.
28097
+ */
28098
+ streamSeconds(explicit, envName, fallback) {
28099
+ if (explicit !== void 0) {
28100
+ return Number.isFinite(explicit) && explicit >= 0 ? Number(explicit) : fallback;
28101
+ }
28102
+ const raw = process.env[envName];
28103
+ if (raw === void 0) return fallback;
28104
+ const n = Number(raw);
28105
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
28106
+ }
28107
+ /**
28108
+ * Open a streaming HTTP request. Returns the raw
28109
+ * {@link http.IncomingMessage} once headers arrive. Redirects are NOT
28110
+ * followed on streams (a caller who needs a redirect should do a
28111
+ * regular GET first). Connect phase is bounded by `connectSec`;
28112
+ * body-phase timeout is applied by the caller (streamBytes) via
28113
+ * `res.destroy()`.
28114
+ */
28115
+ openStreamRequest(method, url, headers, data, connectSec) {
28116
+ return new Promise((resolve20, reject) => {
28117
+ let parsed;
28118
+ try {
28119
+ parsed = new URL2(url);
28120
+ } catch (err) {
28121
+ reject(err instanceof Error ? err : new Error(String(err)));
28122
+ return;
28123
+ }
28124
+ const isHttps = parsed.protocol === "https:";
28125
+ const protocolModule = isHttps ? https : http;
28126
+ const options = {
28127
+ hostname: parsed.hostname,
28128
+ port: parsed.port || (isHttps ? 443 : 80),
28129
+ path: parsed.pathname + parsed.search,
28130
+ method,
28131
+ headers,
28132
+ timeout: connectSec > 0 ? connectSec * 1e3 : void 0
28133
+ };
28134
+ if (isHttps && this.ignoreSsl) {
28135
+ options.rejectUnauthorized = false;
28136
+ }
28137
+ const req2 = protocolModule.request(options, (res) => {
28138
+ resolve20({ res });
28139
+ });
28140
+ req2.on("timeout", () => {
28141
+ req2.destroy(new ApiStreamError(`stream connect timeout after ${connectSec}s`, null));
28142
+ });
28143
+ req2.on("error", (err) => {
28144
+ reject(err);
28145
+ });
28146
+ if (data) req2.write(data);
28147
+ req2.end();
28148
+ });
28149
+ }
27938
28150
  buildUrl(path8) {
27939
28151
  if (path8.startsWith("http://") || path8.startsWith("https://")) {
27940
28152
  return path8;
@@ -30597,10 +30809,11 @@ export default class User {
30597
30809
  // ../core/src/aiClient.ts
30598
30810
  import http2 from "node:http";
30599
30811
  import https2 from "node:https";
30600
- var AiError, AiConfigError, AiTimeoutError, AiParseError, AiHTTPError, Ai;
30812
+ var AiError, AiConfigError, AiTimeoutError, AiParseError, AiHTTPError, Ai, AggregateState;
30601
30813
  var init_aiClient = __esm({
30602
30814
  "../core/src/aiClient.ts"() {
30603
30815
  "use strict";
30816
+ init_api();
30604
30817
  AiError = class extends Error {
30605
30818
  };
30606
30819
  AiConfigError = class extends AiError {
@@ -30618,6 +30831,8 @@ var init_aiClient = __esm({
30618
30831
  Ai = class {
30619
30832
  static chat(messages, options = {}) {
30620
30833
  this.validateMessages(messages);
30834
+ if (options.tools !== void 0) this.validateTools(options.tools);
30835
+ if (options.toolChoice !== void 0) this.validateToolChoice(options.toolChoice);
30621
30836
  const config = this.config("chat", options);
30622
30837
  const body = this.chatBody(config, messages, options);
30623
30838
  const headers = this.headers(config);
@@ -30645,10 +30860,120 @@ var init_aiClient = __esm({
30645
30860
  throw new AiParseError("AI provider returned a malformed embedding response");
30646
30861
  }
30647
30862
  }
30863
+ /**
30864
+ * Validate role + content shape. Content may be a string OR a non-empty
30865
+ * list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
30866
+ * ADR-0061). The `tool` role is the OpenAI-style tool-result turn
30867
+ * (ADR-0061). Malformed parts fail fast with AiConfigError, never
30868
+ * reaching the wire.
30869
+ */
30648
30870
  static validateMessages(messages) {
30649
- if (!Array.isArray(messages) || messages.length === 0 || !messages.every((message) => message && ["system", "user", "assistant"].includes(message.role) && typeof message.content === "string")) {
30871
+ if (!Array.isArray(messages) || messages.length === 0) {
30650
30872
  throw new AiConfigError("AI messages must contain supported roles and string content");
30651
30873
  }
30874
+ for (const raw of messages) {
30875
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
30876
+ throw new AiConfigError("AI messages must contain supported roles and string content");
30877
+ }
30878
+ const message = raw;
30879
+ const role = message.role;
30880
+ if (role === "tool") {
30881
+ if (typeof message.tool_call_id !== "string" || message.tool_call_id.length === 0) {
30882
+ throw new AiConfigError("AI tool message requires a non-empty string 'tool_call_id'");
30883
+ }
30884
+ if (typeof message.content !== "string") {
30885
+ throw new AiConfigError("AI tool message requires a string 'content'");
30886
+ }
30887
+ continue;
30888
+ }
30889
+ if (role !== "system" && role !== "user" && role !== "assistant") {
30890
+ throw new AiConfigError("AI messages must contain supported roles and string content");
30891
+ }
30892
+ this.validateContent(message.content);
30893
+ }
30894
+ }
30895
+ static validateContent(content) {
30896
+ if (typeof content === "string") return;
30897
+ if (!Array.isArray(content) || content.length === 0) {
30898
+ throw new AiConfigError("AI message content must be a string or a non-empty list of parts");
30899
+ }
30900
+ for (const part of content) {
30901
+ if (!part || typeof part !== "object" || Array.isArray(part)) {
30902
+ throw new AiConfigError("AI content part must be an object with type and text/source");
30903
+ }
30904
+ const record = part;
30905
+ const partType = record.type;
30906
+ if (partType === "text") {
30907
+ if (typeof record.text !== "string") {
30908
+ throw new AiConfigError("AI text content part requires a string 'text' field");
30909
+ }
30910
+ } else if (partType === "image") {
30911
+ if (typeof record.source !== "string" || record.source.length === 0) {
30912
+ throw new AiConfigError("AI image content part requires a non-empty string 'source' field");
30913
+ }
30914
+ if (!record.source.startsWith("data:") && !record.source.startsWith("https://")) {
30915
+ throw new AiConfigError("AI image source must be a data: URI or an https:// URL");
30916
+ }
30917
+ if (record.source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(record.source)) {
30918
+ throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
30919
+ }
30920
+ } else if (partType === "tool_result") {
30921
+ if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
30922
+ throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
30923
+ }
30924
+ if (typeof record.content !== "string") {
30925
+ throw new AiConfigError("AI tool_result part requires a string 'content'");
30926
+ }
30927
+ } else {
30928
+ throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
30929
+ }
30930
+ }
30931
+ }
30932
+ /**
30933
+ * Validate the outbound tool declarations (ADR-0061). Each tool needs a
30934
+ * non-empty `name`, a string `description`, and a JSON-Schema-shaped
30935
+ * `parameters` object. Malformed tools fail fast with AiConfigError,
30936
+ * never reaching the wire.
30937
+ */
30938
+ static validateTools(tools) {
30939
+ if (!Array.isArray(tools) || tools.length === 0) {
30940
+ throw new AiConfigError("AI tools must be a non-empty list of {name, description, parameters}");
30941
+ }
30942
+ for (const tool of tools) {
30943
+ if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
30944
+ throw new AiConfigError("AI tool must be an object with name, description, parameters");
30945
+ }
30946
+ const record = tool;
30947
+ if (typeof record.name !== "string" || record.name.length === 0) {
30948
+ throw new AiConfigError("AI tool requires a non-empty string 'name'");
30949
+ }
30950
+ if (typeof record.description !== "string") {
30951
+ throw new AiConfigError("AI tool requires a string 'description'");
30952
+ }
30953
+ if (!record.parameters || typeof record.parameters !== "object" || Array.isArray(record.parameters)) {
30954
+ throw new AiConfigError("AI tool requires a JSON-Schema object 'parameters'");
30955
+ }
30956
+ }
30957
+ }
30958
+ /**
30959
+ * Validate the outbound tool_choice value (ADR-0061). The four accepted
30960
+ * shapes are 'auto', 'none', 'required', and {name: 'x'}.
30961
+ */
30962
+ static validateToolChoice(choice) {
30963
+ if (typeof choice === "string") {
30964
+ if (choice !== "auto" && choice !== "none" && choice !== "required") {
30965
+ throw new AiConfigError("AI toolChoice string must be 'auto', 'none', or 'required'");
30966
+ }
30967
+ return;
30968
+ }
30969
+ if (choice && typeof choice === "object" && !Array.isArray(choice)) {
30970
+ const record = choice;
30971
+ if (typeof record.name !== "string" || record.name.length === 0) {
30972
+ throw new AiConfigError("AI toolChoice object requires a non-empty string 'name'");
30973
+ }
30974
+ return;
30975
+ }
30976
+ throw new AiConfigError("AI toolChoice must be 'auto'|'none'|'required' or {name: string}");
30652
30977
  }
30653
30978
  static number(name, fallback, minimum) {
30654
30979
  const value = process.env[name] === void 0 ? fallback : Number(process.env[name]);
@@ -30696,18 +31021,136 @@ var init_aiClient = __esm({
30696
31021
  }
30697
31022
  return headers;
30698
31023
  }
31024
+ /**
31025
+ * Build the provider-specific request body from a Tina4-shaped message
31026
+ * list plus optional tool declarations (ADR-0060 + ADR-0061).
31027
+ *
31028
+ * Content parts translate per provider:
31029
+ * - OpenAI/local: image → {type:'image_url', image_url:{url}}
31030
+ * - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
31031
+ * String content is preserved verbatim in the OpenAI/local shape and
31032
+ * likewise for Anthropic (both accept a bare string).
31033
+ *
31034
+ * Tool-result turns are normalised to the current provider's expected
31035
+ * shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
31036
+ * the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
31037
+ * turn), so an agent-loop written against Tina4 never has to fork on
31038
+ * TINA4_AI_PROVIDER (ADR-0061 wire translation).
31039
+ */
30699
31040
  static chatBody(config, messages, options) {
30700
- const body = { model: config.model, messages, stream: options.stream ?? false };
31041
+ const normalized = this.normalizeMessagesForProvider(messages, config.provider);
31042
+ const body = { model: config.model, messages: normalized, stream: options.stream ?? false };
30701
31043
  if (options.temperature !== void 0) body.temperature = options.temperature;
30702
31044
  if (options.maxTokens !== void 0) body.max_tokens = options.maxTokens;
30703
31045
  if (config.provider === "anthropic") {
30704
- const system = messages.filter((message) => message.role === "system").map((message) => message.content);
30705
- body.messages = messages.filter((message) => message.role !== "system");
31046
+ const systemParts = [];
31047
+ for (const message of messages) {
31048
+ if (message.role !== "system") continue;
31049
+ const content = message.content;
31050
+ systemParts.push(typeof content === "string" ? content : this.contentToPlainText(content));
31051
+ }
31052
+ body.messages = normalized.filter((message) => message.role !== "system");
30706
31053
  body.max_tokens = options.maxTokens ?? 1024;
30707
- if (system.length) body.system = system.join("\n\n");
31054
+ if (systemParts.length) body.system = systemParts.join("\n\n");
30708
31055
  }
31056
+ this.applyTools(body, config.provider, options);
30709
31057
  return body;
30710
31058
  }
31059
+ /**
31060
+ * Normalise the Tina4-shaped messages into the provider's on-wire shape.
31061
+ * The `tool` role and the `tool_result` content part are translated
31062
+ * between the OpenAI and Anthropic forms so either input works against
31063
+ * either provider (ADR-0061 return-path table).
31064
+ */
31065
+ static normalizeMessagesForProvider(messages, provider) {
31066
+ const out = [];
31067
+ for (const message of messages) {
31068
+ if (message.role === "tool") {
31069
+ if (provider === "anthropic") {
31070
+ out.push({
31071
+ role: "user",
31072
+ content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }]
31073
+ });
31074
+ } else {
31075
+ out.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
31076
+ }
31077
+ continue;
31078
+ }
31079
+ if (Array.isArray(message.content) && message.content.some((part) => part.type === "tool_result")) {
31080
+ if (provider === "anthropic") {
31081
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
31082
+ } else {
31083
+ for (const part of message.content) {
31084
+ if (part.type === "tool_result") {
31085
+ out.push({ role: "tool", tool_call_id: part.tool_use_id, content: part.content });
31086
+ }
31087
+ }
31088
+ }
31089
+ continue;
31090
+ }
31091
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
31092
+ }
31093
+ return out;
31094
+ }
31095
+ /**
31096
+ * Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
31097
+ * translation tables) to the body in place. When toolChoice is 'none'
31098
+ * on Anthropic (Anthropic has no "none" mode) the tools list is omitted
31099
+ * entirely — the model cannot call what it cannot see.
31100
+ */
31101
+ static applyTools(body, provider, options) {
31102
+ const choice = options.toolChoice;
31103
+ const suppressToolsForAnthropic = provider === "anthropic" && choice === "none";
31104
+ if (options.tools !== void 0 && !suppressToolsForAnthropic) {
31105
+ body.tools = options.tools.map(
31106
+ (tool) => provider === "anthropic" ? { name: tool.name, description: tool.description, input_schema: tool.parameters } : { type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters } }
31107
+ );
31108
+ }
31109
+ if (choice === void 0) return;
31110
+ if (provider === "anthropic") {
31111
+ if (choice === "none") return;
31112
+ if (choice === "auto") body.tool_choice = { type: "auto" };
31113
+ else if (choice === "required") body.tool_choice = { type: "any" };
31114
+ else body.tool_choice = { type: "tool", name: choice.name };
31115
+ } else {
31116
+ if (typeof choice === "string") body.tool_choice = choice;
31117
+ else body.tool_choice = { type: "function", function: { name: choice.name } };
31118
+ }
31119
+ }
31120
+ /**
31121
+ * Translate one message content value into the provider's on-wire shape.
31122
+ * A plain string is passed through (both providers accept a string
31123
+ * content). A parts array becomes provider-native content blocks.
31124
+ */
31125
+ static translateContent(content, provider) {
31126
+ if (typeof content === "string") return content;
31127
+ if (provider === "anthropic") {
31128
+ return content.map((part) => {
31129
+ if (part.type === "text") return { type: "text", text: part.text };
31130
+ if (part.type === "tool_result") return { type: "tool_result", tool_use_id: part.tool_use_id, content: part.content };
31131
+ if (part.source.startsWith("data:")) {
31132
+ const parsed = this.parseDataUri(part.source);
31133
+ return { type: "image", source: { type: "base64", media_type: parsed.mediaType, data: parsed.data } };
31134
+ }
31135
+ return { type: "image", source: { type: "url", url: part.source } };
31136
+ });
31137
+ }
31138
+ return content.map((part) => {
31139
+ if (part.type === "text") return { type: "text", text: part.text };
31140
+ if (part.type === "tool_result") {
31141
+ return { type: "text", text: part.content };
31142
+ }
31143
+ return { type: "image_url", image_url: { url: part.source } };
31144
+ });
31145
+ }
31146
+ static parseDataUri(source) {
31147
+ const match = /^data:([^;,\s]+);base64,([A-Za-z0-9+/=]+)$/.exec(source);
31148
+ if (!match) throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
31149
+ return { mediaType: match[1], data: match[2] };
31150
+ }
31151
+ static contentToPlainText(parts) {
31152
+ return parts.filter((part) => part.type === "text").map((part) => part.text).join("\n\n");
31153
+ }
30711
31154
  static open(config, deadline, headers, body) {
30712
31155
  const remainingMs = deadline - performance.now();
30713
31156
  if (remainingMs <= 0) return Promise.reject(new AiTimeoutError("AI total request timeout expired"));
@@ -30807,35 +31250,12 @@ var init_aiClient = __esm({
30807
31250
  static async chatResponse(config, headers, body) {
30808
31251
  return this.normalizeChat(config.provider, await this.requestJson(config, headers, body));
30809
31252
  }
30810
- static streamDelta(provider, data) {
30811
- if (data === "[DONE]") return { completed: true };
30812
- let event;
30813
- try {
30814
- event = JSON.parse(data);
30815
- } catch {
30816
- throw new AiParseError("AI provider returned malformed stream data");
30817
- }
30818
- const text = provider === "anthropic" ? event.type === "content_block_delta" ? event.delta?.text : void 0 : event.choices?.[0]?.delta?.content;
30819
- if (text !== void 0 && text !== null && typeof text !== "string") throw new AiParseError("AI provider returned malformed stream data");
30820
- return { completed: false, text };
30821
- }
30822
- static async *streamData(response) {
30823
- let buffer = "";
30824
- for await (const chunk of response) {
30825
- buffer += Buffer.from(chunk).toString("utf8");
30826
- let newline;
30827
- while ((newline = buffer.indexOf("\n")) >= 0) {
30828
- const line = buffer.slice(0, newline).trim();
30829
- buffer = buffer.slice(newline + 1);
30830
- if (line.startsWith("data:")) yield line.slice(5).trim();
30831
- }
30832
- }
30833
- }
30834
- static streamError(error) {
30835
- if (error instanceof AiError) return error;
30836
- if (error instanceof Error && error.name === "AbortError") return new AiTimeoutError("AI total request timeout expired");
30837
- return new AiHTTPError(`AI transport failed (${error instanceof Error ? error.name : "Error"})`);
30838
- }
31253
+ /**
31254
+ * Stream the response through the shared {@link parseSseStream} framer
31255
+ * (ADR-0060 rule 5). Translates each SSE data payload into 0..N
31256
+ * {@link AiEvent}s: text_delta per chunk, tool_call aggregated per
31257
+ * index / block, exactly one done (or error) at the end.
31258
+ */
30839
31259
  static async *streamRequest(config, headers, body) {
30840
31260
  const deadline = performance.now() + config.totalTimeout * 1e3;
30841
31261
  let yielded = false;
@@ -30854,27 +31274,239 @@ var init_aiClient = __esm({
30854
31274
  }
30855
31275
  throw new AiHTTPError(`AI provider returned HTTP ${status2}`, status2);
30856
31276
  }
30857
- let completed = false;
30858
- for await (const data of this.streamData(opened.response)) {
30859
- const delta = this.streamDelta(config.provider, data);
30860
- if (delta.completed) {
30861
- completed = true;
30862
- break;
31277
+ const response = opened.response;
31278
+ const chunks = this.responseChunks(response);
31279
+ const events = parseSseStream(parseLineStream(chunks));
31280
+ const aggregator = new AggregateState(config.provider);
31281
+ let done = false;
31282
+ try {
31283
+ for await (const sseEvent of events) {
31284
+ for (const emitted of aggregator.consume(sseEvent)) {
31285
+ yielded = true;
31286
+ yield emitted;
31287
+ if (emitted.type === "done" || emitted.type === "error") {
31288
+ done = true;
31289
+ break;
31290
+ }
31291
+ }
31292
+ if (done) break;
31293
+ }
31294
+ } catch (error) {
31295
+ if (yielded) {
31296
+ yielded = true;
31297
+ yield { type: "error", message: error instanceof AiParseError ? "AI provider returned malformed stream data" : `AI transport failed (${error instanceof Error ? error.name : "Error"})` };
31298
+ opened.cleanup();
31299
+ opened = null;
31300
+ return;
30863
31301
  }
30864
- if (delta.text === void 0) continue;
30865
- yielded = true;
30866
- yield delta.text;
31302
+ throw error;
30867
31303
  }
30868
31304
  opened.cleanup();
30869
31305
  opened = null;
30870
- if (completed) return;
30871
- throw new AiParseError("AI provider stream ended before [DONE]");
31306
+ if (done) return;
31307
+ if (yielded) {
31308
+ yield { type: "error", message: "AI provider stream ended before completion" };
31309
+ return;
31310
+ }
31311
+ throw new AiParseError("AI provider stream ended before completion");
30872
31312
  } catch (error) {
30873
31313
  opened?.cleanup();
30874
31314
  const failure = this.streamError(error);
30875
- if (failure instanceof AiParseError || failure instanceof AiHTTPError && failure.status !== null || yielded || attempt >= config.maxRetries) throw failure;
31315
+ if (yielded) {
31316
+ yield { type: "error", message: failure.message };
31317
+ return;
31318
+ }
31319
+ if (failure instanceof AiParseError || failure instanceof AiHTTPError && failure.status !== null || attempt >= config.maxRetries) throw failure;
31320
+ }
31321
+ }
31322
+ }
31323
+ static async *responseChunks(response) {
31324
+ for await (const chunk of response) {
31325
+ yield chunk;
31326
+ }
31327
+ }
31328
+ static streamError(error) {
31329
+ if (error instanceof AiError) return error;
31330
+ if (error instanceof Error && error.name === "AbortError") return new AiTimeoutError("AI total request timeout expired");
31331
+ return new AiHTTPError(`AI transport failed (${error instanceof Error ? error.name : "Error"})`);
31332
+ }
31333
+ };
31334
+ AggregateState = class {
31335
+ constructor(provider) {
31336
+ this.provider = provider;
31337
+ }
31338
+ toolBuffers = /* @__PURE__ */ new Map();
31339
+ lastFinishReason = null;
31340
+ lastUsage = null;
31341
+ doneEmitted = false;
31342
+ *consume(event) {
31343
+ const data = event.data;
31344
+ if (data === "[DONE]") {
31345
+ if (this.doneEmitted) return;
31346
+ yield* this.flushRemainingToolCalls();
31347
+ this.doneEmitted = true;
31348
+ yield {
31349
+ type: "done",
31350
+ finishReason: this.lastFinishReason ?? "stop",
31351
+ ...this.lastUsage ? { usage: this.lastUsage } : {}
31352
+ };
31353
+ return;
31354
+ }
31355
+ let payload;
31356
+ try {
31357
+ payload = JSON.parse(data);
31358
+ } catch {
31359
+ throw new AiParseError("AI provider returned malformed stream data");
31360
+ }
31361
+ if (this.provider === "anthropic") {
31362
+ yield* this.consumeAnthropic(payload);
31363
+ } else {
31364
+ yield* this.consumeOpenAi(payload);
31365
+ }
31366
+ }
31367
+ *consumeOpenAi(payload) {
31368
+ const choices = payload.choices;
31369
+ if (!Array.isArray(choices) || choices.length === 0) return;
31370
+ const choice = choices[0];
31371
+ const delta = choice.delta ?? {};
31372
+ const content = delta.content;
31373
+ if (typeof content === "string" && content.length > 0) {
31374
+ yield { type: "text_delta", text: content };
31375
+ }
31376
+ const toolCalls = delta.tool_calls;
31377
+ if (Array.isArray(toolCalls)) {
31378
+ for (const call of toolCalls) {
31379
+ const index = typeof call.index === "number" ? String(call.index) : String(this.toolBuffers.size);
31380
+ const idFromCall = typeof call.id === "string" ? call.id : "";
31381
+ const fn = call.function ?? {};
31382
+ const nameFromCall = typeof fn.name === "string" ? fn.name : "";
31383
+ const argsFragment = typeof fn.arguments === "string" ? fn.arguments : "";
31384
+ const existing = this.toolBuffers.get(index) ?? { id: "", name: "", args: "" };
31385
+ if (idFromCall) existing.id = idFromCall;
31386
+ if (nameFromCall) existing.name = nameFromCall;
31387
+ existing.args += argsFragment;
31388
+ this.toolBuffers.set(index, existing);
31389
+ if (existing.name && existing.args) {
31390
+ try {
31391
+ const parsed = JSON.parse(existing.args);
31392
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
31393
+ this.toolBuffers.delete(index);
31394
+ yield { type: "tool_call", id: existing.id || `call_${index}`, name: existing.name, args: parsed };
31395
+ }
31396
+ } catch {
31397
+ }
31398
+ }
31399
+ }
31400
+ }
31401
+ if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) {
31402
+ this.lastFinishReason = choice.finish_reason;
31403
+ }
31404
+ const usage = payload.usage;
31405
+ if (usage && typeof usage === "object") {
31406
+ const promptTokens = Number(usage.prompt_tokens ?? 0);
31407
+ const completionTokens = Number(usage.completion_tokens ?? 0);
31408
+ const totalTokens = Number(usage.total_tokens ?? promptTokens + completionTokens);
31409
+ if (Number.isFinite(promptTokens) && Number.isFinite(completionTokens)) {
31410
+ this.lastUsage = { promptTokens, completionTokens, totalTokens };
31411
+ }
31412
+ }
31413
+ }
31414
+ *consumeAnthropic(payload) {
31415
+ const type2 = payload.type;
31416
+ if (type2 === "content_block_start") {
31417
+ const block = payload.content_block ?? {};
31418
+ if (block.type === "tool_use") {
31419
+ const index = String(payload.index ?? this.toolBuffers.size);
31420
+ const id = typeof block.id === "string" ? block.id : `call_${index}`;
31421
+ const name = typeof block.name === "string" ? block.name : "";
31422
+ this.toolBuffers.set(index, { id, name, args: "" });
31423
+ }
31424
+ return;
31425
+ }
31426
+ if (type2 === "content_block_delta") {
31427
+ const index = String(payload.index ?? 0);
31428
+ const delta = payload.delta ?? {};
31429
+ if (delta.type === "text_delta" && typeof delta.text === "string" && delta.text.length > 0) {
31430
+ yield { type: "text_delta", text: delta.text };
31431
+ return;
31432
+ }
31433
+ if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
31434
+ const existing = this.toolBuffers.get(index);
31435
+ if (existing) existing.args += delta.partial_json;
31436
+ }
31437
+ return;
31438
+ }
31439
+ if (type2 === "content_block_stop") {
31440
+ const index = String(payload.index ?? 0);
31441
+ const existing = this.toolBuffers.get(index);
31442
+ if (existing && existing.name) {
31443
+ this.toolBuffers.delete(index);
31444
+ try {
31445
+ const parsed = existing.args ? JSON.parse(existing.args) : {};
31446
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
31447
+ yield { type: "tool_call", id: existing.id, name: existing.name, args: parsed };
31448
+ return;
31449
+ }
31450
+ throw new Error();
31451
+ } catch {
31452
+ throw new AiParseError("AI provider returned malformed tool-call JSON");
31453
+ }
31454
+ }
31455
+ return;
31456
+ }
31457
+ if (type2 === "message_delta") {
31458
+ const delta = payload.delta ?? {};
31459
+ if (typeof delta.stop_reason === "string" && delta.stop_reason.length > 0) {
31460
+ this.lastFinishReason = delta.stop_reason;
31461
+ }
31462
+ const usage = payload.usage ?? {};
31463
+ if (usage.output_tokens !== void 0 || usage.input_tokens !== void 0) {
31464
+ const promptTokens = Number(usage.input_tokens ?? this.lastUsage?.promptTokens ?? 0);
31465
+ const completionTokens = Number(usage.output_tokens ?? this.lastUsage?.completionTokens ?? 0);
31466
+ this.lastUsage = { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens };
31467
+ }
31468
+ return;
31469
+ }
31470
+ if (type2 === "message_stop") {
31471
+ if (this.doneEmitted) return;
31472
+ this.doneEmitted = true;
31473
+ yield {
31474
+ type: "done",
31475
+ finishReason: this.lastFinishReason ?? "end_turn",
31476
+ ...this.lastUsage ? { usage: this.lastUsage } : {}
31477
+ };
31478
+ return;
31479
+ }
31480
+ if (type2 === "message_start") {
31481
+ const message = payload.message ?? {};
31482
+ const usage = message.usage ?? {};
31483
+ if (usage.input_tokens !== void 0 || usage.output_tokens !== void 0) {
31484
+ const promptTokens = Number(usage.input_tokens ?? 0);
31485
+ const completionTokens = Number(usage.output_tokens ?? 0);
31486
+ this.lastUsage = { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens };
31487
+ }
31488
+ return;
31489
+ }
31490
+ if (type2 === "error") {
31491
+ const err = payload.error ?? {};
31492
+ throw new AiParseError(typeof err.message === "string" ? err.message : "AI provider signalled a stream error");
31493
+ }
31494
+ }
31495
+ *flushRemainingToolCalls() {
31496
+ for (const [index, buffered] of this.toolBuffers) {
31497
+ if (buffered.name && buffered.args) {
31498
+ try {
31499
+ const parsed = JSON.parse(buffered.args);
31500
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
31501
+ yield { type: "tool_call", id: buffered.id || `call_${index}`, name: buffered.name, args: parsed };
31502
+ continue;
31503
+ }
31504
+ } catch {
31505
+ }
31506
+ throw new AiParseError("AI provider returned malformed tool-call JSON");
30876
31507
  }
30877
31508
  }
31509
+ this.toolBuffers.clear();
30878
31510
  }
30879
31511
  };
30880
31512
  }
@@ -32590,6 +33222,7 @@ __export(src_exports2, {
32590
33222
  AiParseError: () => AiParseError,
32591
33223
  AiTimeoutError: () => AiTimeoutError,
32592
33224
  Api: () => Api,
33225
+ ApiStreamError: () => ApiStreamError,
32593
33226
  Auth: () => Auth,
32594
33227
  CANONICAL_SESSION_BACKENDS: () => CANONICAL_SESSION_BACKENDS,
32595
33228
  CLOSE_GOING_AWAY: () => CLOSE_GOING_AWAY,
@@ -32819,7 +33452,9 @@ __export(src_exports2, {
32819
33452
  originAllowed: () => originAllowed,
32820
33453
  parseAmqpUrl: () => parseAmqpUrl,
32821
33454
  parseFrame: () => parseFrame,
33455
+ parseLineStream: () => parseLineStream,
32822
33456
  parseMultipart: () => parseMultipart,
33457
+ parseSseStream: () => parseSseStream,
32823
33458
  parseUpgradeHeaders: () => parseUpgradeHeaders,
32824
33459
  patch: () => patch,
32825
33460
  pidfilePath: () => pidfilePath,