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.
@@ -1,6 +1,7 @@
1
- /** Zero-dependency app-facing AI client (ADR-0053). */
1
+ /** Zero-dependency app-facing AI client (ADR-0053 + ADR-0060). */
2
2
  import http, { type IncomingMessage } from "node:http";
3
3
  import https from "node:https";
4
+ import { parseLineStream, parseSseStream, type SseEvent } from "./api.js";
4
5
 
5
6
  export class AiError extends Error {}
6
7
  export class AiConfigError extends AiError {}
@@ -17,7 +18,69 @@ export interface ChatResponse {
17
18
  finishReason: string | null;
18
19
  raw: Record<string, unknown>;
19
20
  }
20
- export interface AiMessage { role: "system" | "user" | "assistant"; content: string }
21
+
22
+ /**
23
+ * A multimodal content part. `text` carries plain UTF-8 prose; `image`
24
+ * carries a `data:<media_type>;base64,<payload>` URI or an https:// URL
25
+ * (the client translates to each provider's shape, ADR-0060). `tool_result`
26
+ * carries the Anthropic-style return of a locally-executed tool call
27
+ * (ADR-0061); the client translates it to OpenAI's `{role: "tool", ...}`
28
+ * turn on non-Anthropic providers.
29
+ */
30
+ export type ContentPart =
31
+ | { type: "text"; text: string }
32
+ | { type: "image"; source: string }
33
+ | { type: "tool_result"; tool_use_id: string; content: string };
34
+
35
+ /** The value a caller may pass for `message.content`. ADR-0060. */
36
+ export type AiMessageContent = string | ContentPart[];
37
+
38
+ /**
39
+ * One conversation turn. The three "chat" roles carry a string OR a
40
+ * content-parts array (ADR-0060). The `tool` role is the OpenAI-style
41
+ * return of a tool call (ADR-0061); the client translates it to the
42
+ * Anthropic user-turn form when the current provider is Anthropic.
43
+ */
44
+ export type AiMessage =
45
+ | { role: "system" | "user" | "assistant"; content: AiMessageContent }
46
+ | { role: "tool"; tool_call_id: string; content: string };
47
+
48
+ /**
49
+ * A tool declaration the model may call (named `AiToolDeclaration` to
50
+ * stay out of the way of {@link ./ai.ts}'s existing `AiTool` interface
51
+ * for AI-coding-tool context installation). `parameters` is a JSON
52
+ * Schema object; it is passed to the provider unchanged (ADR-0061
53
+ * `parameters-passthrough`).
54
+ */
55
+ export interface AiToolDeclaration { name: string; description: string; parameters: Record<string, unknown> }
56
+
57
+ /**
58
+ * How the model picks a tool. Four Tina4 values that span the useful cases
59
+ * across providers (ADR-0061 wire-translation table):
60
+ * 'auto' — model may call any tool or answer with text
61
+ * 'none' — model must not call a tool (Anthropic omits `tools`)
62
+ * 'required' — model must call some tool
63
+ * {name: 'x'} — model must call tool 'x'
64
+ */
65
+ export type AiToolChoice = "auto" | "none" | "required" | { name: string };
66
+
67
+ /**
68
+ * One event yielded by `Ai.chat(stream: true)`. The four variants
69
+ * discriminated by `type`. Text deltas arrive per chunk (typewriter UX);
70
+ * `tool_call` fires once per call, aggregated from provider fragments;
71
+ * `done` fires exactly once after all deltas; `error` replaces `done` on
72
+ * mid-stream failure. ADR-0060.
73
+ */
74
+ export type AiEvent =
75
+ | { type: "text_delta"; text: string }
76
+ | { type: "tool_call"; id: string; name: string; args: Record<string, unknown> }
77
+ | {
78
+ type: "done";
79
+ finishReason: string;
80
+ usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
81
+ }
82
+ | { type: "error"; message: string; code?: string };
83
+
21
84
  export interface AiChatOptions {
22
85
  model?: string;
23
86
  temperature?: number;
@@ -25,6 +88,14 @@ export interface AiChatOptions {
25
88
  stream?: boolean;
26
89
  timeout?: number;
27
90
  provider?: "local" | "openai" | "anthropic";
91
+ /** Tools the model may call. ADR-0061 — translated per provider. */
92
+ tools?: AiToolDeclaration[];
93
+ /**
94
+ * How the model picks a tool. ADR-0061 — translated per provider. If
95
+ * `'none'` on Anthropic (which has no "none" mode), `tools` is omitted
96
+ * from the outbound body entirely.
97
+ */
98
+ toolChoice?: AiToolChoice;
28
99
  }
29
100
  export interface AiEmbedOptions { model?: string; timeout?: number; provider?: "local" | "openai" | "anthropic" }
30
101
 
@@ -40,10 +111,12 @@ interface Config {
40
111
  interface OpenResponse { response: IncomingMessage; cleanup: () => void }
41
112
 
42
113
  export class Ai {
43
- static chat(messages: AiMessage[], options: AiChatOptions & { stream: true }): AsyncGenerator<string>;
114
+ static chat(messages: AiMessage[], options: AiChatOptions & { stream: true }): AsyncGenerator<AiEvent>;
44
115
  static chat(messages: AiMessage[], options?: AiChatOptions & { stream?: false }): Promise<ChatResponse>;
45
- static chat(messages: AiMessage[], options: AiChatOptions = {}): Promise<ChatResponse> | AsyncGenerator<string> {
116
+ static chat(messages: AiMessage[], options: AiChatOptions = {}): Promise<ChatResponse> | AsyncGenerator<AiEvent> {
46
117
  this.validateMessages(messages);
118
+ if (options.tools !== undefined) this.validateTools(options.tools);
119
+ if (options.toolChoice !== undefined) this.validateToolChoice(options.toolChoice);
47
120
  const config = this.config("chat", options);
48
121
  const body = this.chatBody(config, messages, options);
49
122
  const headers = this.headers(config);
@@ -74,10 +147,123 @@ export class Ai {
74
147
  }
75
148
  }
76
149
 
150
+ /**
151
+ * Validate role + content shape. Content may be a string OR a non-empty
152
+ * list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
153
+ * ADR-0061). The `tool` role is the OpenAI-style tool-result turn
154
+ * (ADR-0061). Malformed parts fail fast with AiConfigError, never
155
+ * reaching the wire.
156
+ */
77
157
  private static validateMessages(messages: AiMessage[]): void {
78
- if (!Array.isArray(messages) || messages.length === 0 || !messages.every((message) => message && ["system", "user", "assistant"].includes(message.role) && typeof message.content === "string")) {
158
+ if (!Array.isArray(messages) || messages.length === 0) {
79
159
  throw new AiConfigError("AI messages must contain supported roles and string content");
80
160
  }
161
+ for (const raw of messages) {
162
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
163
+ throw new AiConfigError("AI messages must contain supported roles and string content");
164
+ }
165
+ const message = raw as Record<string, unknown>;
166
+ const role = message.role;
167
+ if (role === "tool") {
168
+ if (typeof message.tool_call_id !== "string" || message.tool_call_id.length === 0) {
169
+ throw new AiConfigError("AI tool message requires a non-empty string 'tool_call_id'");
170
+ }
171
+ if (typeof message.content !== "string") {
172
+ throw new AiConfigError("AI tool message requires a string 'content'");
173
+ }
174
+ continue;
175
+ }
176
+ if (role !== "system" && role !== "user" && role !== "assistant") {
177
+ throw new AiConfigError("AI messages must contain supported roles and string content");
178
+ }
179
+ this.validateContent(message.content);
180
+ }
181
+ }
182
+
183
+ private static validateContent(content: unknown): void {
184
+ if (typeof content === "string") return;
185
+ if (!Array.isArray(content) || content.length === 0) {
186
+ throw new AiConfigError("AI message content must be a string or a non-empty list of parts");
187
+ }
188
+ for (const part of content) {
189
+ if (!part || typeof part !== "object" || Array.isArray(part)) {
190
+ throw new AiConfigError("AI content part must be an object with type and text/source");
191
+ }
192
+ const record = part as Record<string, unknown>;
193
+ const partType = record.type;
194
+ if (partType === "text") {
195
+ if (typeof record.text !== "string") {
196
+ throw new AiConfigError("AI text content part requires a string 'text' field");
197
+ }
198
+ } else if (partType === "image") {
199
+ if (typeof record.source !== "string" || record.source.length === 0) {
200
+ throw new AiConfigError("AI image content part requires a non-empty string 'source' field");
201
+ }
202
+ if (!record.source.startsWith("data:") && !record.source.startsWith("https://")) {
203
+ throw new AiConfigError("AI image source must be a data: URI or an https:// URL");
204
+ }
205
+ if (record.source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(record.source)) {
206
+ throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
207
+ }
208
+ } else if (partType === "tool_result") {
209
+ if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
210
+ throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
211
+ }
212
+ if (typeof record.content !== "string") {
213
+ throw new AiConfigError("AI tool_result part requires a string 'content'");
214
+ }
215
+ } else {
216
+ throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Validate the outbound tool declarations (ADR-0061). Each tool needs a
223
+ * non-empty `name`, a string `description`, and a JSON-Schema-shaped
224
+ * `parameters` object. Malformed tools fail fast with AiConfigError,
225
+ * never reaching the wire.
226
+ */
227
+ private static validateTools(tools: unknown): void {
228
+ if (!Array.isArray(tools) || tools.length === 0) {
229
+ throw new AiConfigError("AI tools must be a non-empty list of {name, description, parameters}");
230
+ }
231
+ for (const tool of tools) {
232
+ if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
233
+ throw new AiConfigError("AI tool must be an object with name, description, parameters");
234
+ }
235
+ const record = tool as Record<string, unknown>;
236
+ if (typeof record.name !== "string" || record.name.length === 0) {
237
+ throw new AiConfigError("AI tool requires a non-empty string 'name'");
238
+ }
239
+ if (typeof record.description !== "string") {
240
+ throw new AiConfigError("AI tool requires a string 'description'");
241
+ }
242
+ if (!record.parameters || typeof record.parameters !== "object" || Array.isArray(record.parameters)) {
243
+ throw new AiConfigError("AI tool requires a JSON-Schema object 'parameters'");
244
+ }
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Validate the outbound tool_choice value (ADR-0061). The four accepted
250
+ * shapes are 'auto', 'none', 'required', and {name: 'x'}.
251
+ */
252
+ private static validateToolChoice(choice: unknown): void {
253
+ if (typeof choice === "string") {
254
+ if (choice !== "auto" && choice !== "none" && choice !== "required") {
255
+ throw new AiConfigError("AI toolChoice string must be 'auto', 'none', or 'required'");
256
+ }
257
+ return;
258
+ }
259
+ if (choice && typeof choice === "object" && !Array.isArray(choice)) {
260
+ const record = choice as Record<string, unknown>;
261
+ if (typeof record.name !== "string" || record.name.length === 0) {
262
+ throw new AiConfigError("AI toolChoice object requires a non-empty string 'name'");
263
+ }
264
+ return;
265
+ }
266
+ throw new AiConfigError("AI toolChoice must be 'auto'|'none'|'required' or {name: string}");
81
267
  }
82
268
 
83
269
  private static number(name: string, fallback: number, minimum: number): number {
@@ -123,19 +309,153 @@ export class Ai {
123
309
  return headers;
124
310
  }
125
311
 
312
+ /**
313
+ * Build the provider-specific request body from a Tina4-shaped message
314
+ * list plus optional tool declarations (ADR-0060 + ADR-0061).
315
+ *
316
+ * Content parts translate per provider:
317
+ * - OpenAI/local: image → {type:'image_url', image_url:{url}}
318
+ * - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
319
+ * String content is preserved verbatim in the OpenAI/local shape and
320
+ * likewise for Anthropic (both accept a bare string).
321
+ *
322
+ * Tool-result turns are normalised to the current provider's expected
323
+ * shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
324
+ * the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
325
+ * turn), so an agent-loop written against Tina4 never has to fork on
326
+ * TINA4_AI_PROVIDER (ADR-0061 wire translation).
327
+ */
126
328
  private static chatBody(config: Config, messages: AiMessage[], options: AiChatOptions): Record<string, unknown> {
127
- const body: Record<string, unknown> = { model: config.model, messages, stream: options.stream ?? false };
329
+ const normalized = this.normalizeMessagesForProvider(messages, config.provider);
330
+ const body: Record<string, unknown> = { model: config.model, messages: normalized, stream: options.stream ?? false };
128
331
  if (options.temperature !== undefined) body.temperature = options.temperature;
129
332
  if (options.maxTokens !== undefined) body.max_tokens = options.maxTokens;
130
333
  if (config.provider === "anthropic") {
131
- const system = messages.filter((message) => message.role === "system").map((message) => message.content);
132
- body.messages = messages.filter((message) => message.role !== "system");
334
+ const systemParts: string[] = [];
335
+ for (const message of messages) {
336
+ if (message.role !== "system") continue;
337
+ const content = message.content; // narrowed away from tool variant
338
+ systemParts.push(typeof content === "string" ? content : this.contentToPlainText(content));
339
+ }
340
+ body.messages = normalized.filter((message) => message.role !== "system");
133
341
  body.max_tokens = options.maxTokens ?? 1024;
134
- if (system.length) body.system = system.join("\n\n");
342
+ if (systemParts.length) body.system = systemParts.join("\n\n");
135
343
  }
344
+ this.applyTools(body, config.provider, options);
136
345
  return body;
137
346
  }
138
347
 
348
+ /**
349
+ * Normalise the Tina4-shaped messages into the provider's on-wire shape.
350
+ * The `tool` role and the `tool_result` content part are translated
351
+ * between the OpenAI and Anthropic forms so either input works against
352
+ * either provider (ADR-0061 return-path table).
353
+ */
354
+ private static normalizeMessagesForProvider(messages: AiMessage[], provider: Config["provider"]): Array<Record<string, unknown>> {
355
+ const out: Array<Record<string, unknown>> = [];
356
+ for (const message of messages) {
357
+ if (message.role === "tool") {
358
+ // OpenAI-style tool-result turn. Passthrough on OpenAI/local;
359
+ // translate to Anthropic's user-turn form on Anthropic.
360
+ if (provider === "anthropic") {
361
+ out.push({
362
+ role: "user",
363
+ content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }],
364
+ });
365
+ } else {
366
+ out.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
367
+ }
368
+ continue;
369
+ }
370
+ if (Array.isArray(message.content) && message.content.some((part) => part.type === "tool_result")) {
371
+ // Anthropic-style tool-result turn inside a user message.
372
+ // Passthrough on Anthropic; on OpenAI/local, split each tool_result
373
+ // part into its own {role:'tool', ...} turn.
374
+ if (provider === "anthropic") {
375
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
376
+ } else {
377
+ for (const part of message.content) {
378
+ if (part.type === "tool_result") {
379
+ out.push({ role: "tool", tool_call_id: part.tool_use_id, content: part.content });
380
+ }
381
+ }
382
+ }
383
+ continue;
384
+ }
385
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
386
+ }
387
+ return out;
388
+ }
389
+
390
+ /**
391
+ * Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
392
+ * translation tables) to the body in place. When toolChoice is 'none'
393
+ * on Anthropic (Anthropic has no "none" mode) the tools list is omitted
394
+ * entirely — the model cannot call what it cannot see.
395
+ */
396
+ private static applyTools(body: Record<string, unknown>, provider: Config["provider"], options: AiChatOptions): void {
397
+ const choice = options.toolChoice;
398
+ const suppressToolsForAnthropic = provider === "anthropic" && choice === "none";
399
+ if (options.tools !== undefined && !suppressToolsForAnthropic) {
400
+ body.tools = options.tools.map((tool) =>
401
+ provider === "anthropic"
402
+ ? { name: tool.name, description: tool.description, input_schema: tool.parameters }
403
+ : { type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters } },
404
+ );
405
+ }
406
+ if (choice === undefined) return;
407
+ if (provider === "anthropic") {
408
+ if (choice === "none") return; // omit tools + tool_choice
409
+ if (choice === "auto") body.tool_choice = { type: "auto" };
410
+ else if (choice === "required") body.tool_choice = { type: "any" };
411
+ else body.tool_choice = { type: "tool", name: choice.name };
412
+ } else {
413
+ if (typeof choice === "string") body.tool_choice = choice; // 'auto' | 'none' | 'required'
414
+ else body.tool_choice = { type: "function", function: { name: choice.name } };
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Translate one message content value into the provider's on-wire shape.
420
+ * A plain string is passed through (both providers accept a string
421
+ * content). A parts array becomes provider-native content blocks.
422
+ */
423
+ private static translateContent(content: AiMessageContent, provider: Config["provider"]): unknown {
424
+ if (typeof content === "string") return content;
425
+ if (provider === "anthropic") {
426
+ return content.map((part) => {
427
+ if (part.type === "text") return { type: "text", text: part.text };
428
+ if (part.type === "tool_result") return { type: "tool_result", tool_use_id: part.tool_use_id, content: part.content };
429
+ if (part.source.startsWith("data:")) {
430
+ const parsed = this.parseDataUri(part.source);
431
+ return { type: "image", source: { type: "base64", media_type: parsed.mediaType, data: parsed.data } };
432
+ }
433
+ return { type: "image", source: { type: "url", url: part.source } };
434
+ });
435
+ }
436
+ return content.map((part) => {
437
+ if (part.type === "text") return { type: "text", text: part.text };
438
+ if (part.type === "tool_result") {
439
+ // Reached only when a non-tool_result part sits next to a
440
+ // tool_result in a user message on OpenAI/local; the tool_result
441
+ // parts are split out by normalizeMessagesForProvider(), so this
442
+ // branch is a safe no-op fallback.
443
+ return { type: "text", text: part.content };
444
+ }
445
+ return { type: "image_url", image_url: { url: part.source } };
446
+ });
447
+ }
448
+
449
+ private static parseDataUri(source: string): { mediaType: string; data: string } {
450
+ const match = /^data:([^;,\s]+);base64,([A-Za-z0-9+/=]+)$/.exec(source);
451
+ if (!match) throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
452
+ return { mediaType: match[1], data: match[2] };
453
+ }
454
+
455
+ private static contentToPlainText(parts: ContentPart[]): string {
456
+ return parts.filter((part): part is { type: "text"; text: string } => part.type === "text").map((part) => part.text).join("\n\n");
457
+ }
458
+
139
459
  private static open(config: Config, deadline: number, headers: Record<string, string>, body: Record<string, unknown>): Promise<OpenResponse> {
140
460
  const remainingMs = deadline - performance.now();
141
461
  if (remainingMs <= 0) return Promise.reject(new AiTimeoutError("AI total request timeout expired"));
@@ -226,36 +546,13 @@ export class Ai {
226
546
  return this.normalizeChat(config.provider, await this.requestJson(config, headers, body));
227
547
  }
228
548
 
229
- private static streamDelta(provider: Config["provider"], data: string): { completed: boolean; text?: string } {
230
- if (data === "[DONE]") return { completed: true };
231
- let event: Record<string, unknown>;
232
- try { event = JSON.parse(data) as Record<string, unknown>; } catch { throw new AiParseError("AI provider returned malformed stream data"); }
233
- const text = provider === "anthropic"
234
- ? (event.type === "content_block_delta" ? (event.delta as Record<string, unknown>)?.text : undefined)
235
- : (((event.choices as Array<Record<string, unknown>>)?.[0]?.delta as Record<string, unknown>)?.content);
236
- if (text !== undefined && text !== null && typeof text !== "string") throw new AiParseError("AI provider returned malformed stream data");
237
- return { completed: false, text: text as string | undefined };
238
- }
239
-
240
- private static async *streamData(response: IncomingMessage): AsyncGenerator<string> {
241
- let buffer = "";
242
- for await (const chunk of response) {
243
- buffer += Buffer.from(chunk).toString("utf8");
244
- let newline: number;
245
- while ((newline = buffer.indexOf("\n")) >= 0) {
246
- const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1);
247
- if (line.startsWith("data:")) yield line.slice(5).trim();
248
- }
249
- }
250
- }
251
-
252
- private static streamError(error: unknown): AiError {
253
- if (error instanceof AiError) return error;
254
- if (error instanceof Error && error.name === "AbortError") return new AiTimeoutError("AI total request timeout expired");
255
- return new AiHTTPError(`AI transport failed (${error instanceof Error ? error.name : "Error"})`);
256
- }
257
-
258
- private static async *streamRequest(config: Config, headers: Record<string, string>, body: Record<string, unknown>): AsyncGenerator<string> {
549
+ /**
550
+ * Stream the response through the shared {@link parseSseStream} framer
551
+ * (ADR-0060 rule 5). Translates each SSE data payload into 0..N
552
+ * {@link AiEvent}s: text_delta per chunk, tool_call aggregated per
553
+ * index / block, exactly one done (or error) at the end.
554
+ */
555
+ private static async *streamRequest(config: Config, headers: Record<string, string>, body: Record<string, unknown>): AsyncGenerator<AiEvent> {
259
556
  const deadline = performance.now() + config.totalTimeout * 1000;
260
557
  let yielded = false;
261
558
  for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
@@ -268,21 +565,245 @@ export class Ai {
268
565
  if ((status === 429 || status >= 500) && attempt < config.maxRetries) { await this.retryDelay(opened.response.headers, deadline); opened.cleanup(); opened = null; continue; }
269
566
  throw new AiHTTPError(`AI provider returned HTTP ${status}`, status);
270
567
  }
271
- let completed = false;
272
- for await (const data of this.streamData(opened.response)) {
273
- const delta = this.streamDelta(config.provider, data);
274
- if (delta.completed) { completed = true; break; }
275
- if (delta.text === undefined) continue;
276
- yielded = true; yield delta.text;
568
+ const response = opened.response;
569
+ const chunks = this.responseChunks(response);
570
+ const events = parseSseStream(parseLineStream(chunks));
571
+ const aggregator = new AggregateState(config.provider);
572
+ let done = false;
573
+ try {
574
+ for await (const sseEvent of events) {
575
+ for (const emitted of aggregator.consume(sseEvent)) {
576
+ yielded = true;
577
+ yield emitted;
578
+ if (emitted.type === "done" || emitted.type === "error") { done = true; break; }
579
+ }
580
+ if (done) break;
581
+ }
582
+ } catch (error) {
583
+ if (yielded) {
584
+ yielded = true;
585
+ yield { type: "error", message: error instanceof AiParseError ? "AI provider returned malformed stream data" : `AI transport failed (${error instanceof Error ? error.name : "Error"})` };
586
+ opened.cleanup(); opened = null;
587
+ return;
588
+ }
589
+ throw error;
277
590
  }
278
591
  opened.cleanup(); opened = null;
279
- if (completed) return;
280
- throw new AiParseError("AI provider stream ended before [DONE]");
592
+ if (done) return;
593
+ // Stream ended without a terminator treat as mid-stream failure.
594
+ if (yielded) { yield { type: "error", message: "AI provider stream ended before completion" }; return; }
595
+ throw new AiParseError("AI provider stream ended before completion");
281
596
  } catch (error) {
282
597
  opened?.cleanup();
283
598
  const failure = this.streamError(error);
284
- if (failure instanceof AiParseError || (failure instanceof AiHTTPError && failure.status !== null) || yielded || attempt >= config.maxRetries) throw failure;
599
+ if (yielded) {
600
+ yield { type: "error", message: failure.message };
601
+ return;
602
+ }
603
+ if (failure instanceof AiParseError || (failure instanceof AiHTTPError && failure.status !== null) || attempt >= config.maxRetries) throw failure;
604
+ }
605
+ }
606
+ }
607
+
608
+ private static async *responseChunks(response: IncomingMessage): AsyncGenerator<Uint8Array> {
609
+ for await (const chunk of response) {
610
+ yield chunk as Uint8Array;
611
+ }
612
+ }
613
+
614
+ private static streamError(error: unknown): AiError {
615
+ if (error instanceof AiError) return error;
616
+ if (error instanceof Error && error.name === "AbortError") return new AiTimeoutError("AI total request timeout expired");
617
+ return new AiHTTPError(`AI transport failed (${error instanceof Error ? error.name : "Error"})`);
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Per-stream aggregation state. Encapsulates the buffering rules for
623
+ * OpenAI-style `tool_calls[i].function.arguments` fragments and
624
+ * Anthropic-style `content_block_delta` + `input_json_delta` fragments.
625
+ * ADR-0060 "tool_call aggregated" invariant.
626
+ */
627
+ class AggregateState {
628
+ private toolBuffers = new Map<string, { id: string; name: string; args: string }>();
629
+ private lastFinishReason: string | null = null;
630
+ private lastUsage: { promptTokens: number; completionTokens: number; totalTokens: number } | null = null;
631
+ private doneEmitted = false;
632
+
633
+ constructor(private readonly provider: Config["provider"]) {}
634
+
635
+ *consume(event: SseEvent): Iterable<AiEvent> {
636
+ const data = event.data;
637
+ if (data === "[DONE]") {
638
+ if (this.doneEmitted) return;
639
+ yield* this.flushRemainingToolCalls();
640
+ this.doneEmitted = true;
641
+ yield {
642
+ type: "done",
643
+ finishReason: this.lastFinishReason ?? "stop",
644
+ ...(this.lastUsage ? { usage: this.lastUsage } : {}),
645
+ };
646
+ return;
647
+ }
648
+ let payload: Record<string, unknown>;
649
+ try {
650
+ payload = JSON.parse(data) as Record<string, unknown>;
651
+ } catch {
652
+ throw new AiParseError("AI provider returned malformed stream data");
653
+ }
654
+ if (this.provider === "anthropic") {
655
+ yield* this.consumeAnthropic(payload);
656
+ } else {
657
+ yield* this.consumeOpenAi(payload);
658
+ }
659
+ }
660
+
661
+ private *consumeOpenAi(payload: Record<string, unknown>): Iterable<AiEvent> {
662
+ const choices = payload.choices as Array<Record<string, unknown>> | undefined;
663
+ if (!Array.isArray(choices) || choices.length === 0) return;
664
+ const choice = choices[0];
665
+ const delta = (choice.delta ?? {}) as Record<string, unknown>;
666
+ const content = delta.content;
667
+ if (typeof content === "string" && content.length > 0) {
668
+ yield { type: "text_delta", text: content };
669
+ }
670
+ const toolCalls = delta.tool_calls as Array<Record<string, unknown>> | undefined;
671
+ if (Array.isArray(toolCalls)) {
672
+ for (const call of toolCalls) {
673
+ const index = typeof call.index === "number" ? String(call.index) : String(this.toolBuffers.size);
674
+ const idFromCall = typeof call.id === "string" ? call.id : "";
675
+ const fn = (call.function ?? {}) as Record<string, unknown>;
676
+ const nameFromCall = typeof fn.name === "string" ? fn.name : "";
677
+ const argsFragment = typeof fn.arguments === "string" ? fn.arguments : "";
678
+ const existing = this.toolBuffers.get(index) ?? { id: "", name: "", args: "" };
679
+ if (idFromCall) existing.id = idFromCall;
680
+ if (nameFromCall) existing.name = nameFromCall;
681
+ existing.args += argsFragment;
682
+ this.toolBuffers.set(index, existing);
683
+ if (existing.name && existing.args) {
684
+ try {
685
+ const parsed = JSON.parse(existing.args) as unknown;
686
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
687
+ this.toolBuffers.delete(index);
688
+ yield { type: "tool_call", id: existing.id || `call_${index}`, name: existing.name, args: parsed as Record<string, unknown> };
689
+ }
690
+ } catch {
691
+ /* args not complete yet — keep buffering */
692
+ }
693
+ }
694
+ }
695
+ }
696
+ if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) {
697
+ this.lastFinishReason = choice.finish_reason;
698
+ }
699
+ const usage = payload.usage as Record<string, unknown> | undefined;
700
+ if (usage && typeof usage === "object") {
701
+ const promptTokens = Number(usage.prompt_tokens ?? 0);
702
+ const completionTokens = Number(usage.completion_tokens ?? 0);
703
+ const totalTokens = Number(usage.total_tokens ?? promptTokens + completionTokens);
704
+ if (Number.isFinite(promptTokens) && Number.isFinite(completionTokens)) {
705
+ this.lastUsage = { promptTokens, completionTokens, totalTokens };
706
+ }
707
+ }
708
+ }
709
+
710
+ private *consumeAnthropic(payload: Record<string, unknown>): Iterable<AiEvent> {
711
+ const type = payload.type;
712
+ if (type === "content_block_start") {
713
+ const block = (payload.content_block ?? {}) as Record<string, unknown>;
714
+ if (block.type === "tool_use") {
715
+ const index = String(payload.index ?? this.toolBuffers.size);
716
+ const id = typeof block.id === "string" ? block.id : `call_${index}`;
717
+ const name = typeof block.name === "string" ? block.name : "";
718
+ this.toolBuffers.set(index, { id, name, args: "" });
719
+ }
720
+ return;
721
+ }
722
+ if (type === "content_block_delta") {
723
+ const index = String(payload.index ?? 0);
724
+ const delta = (payload.delta ?? {}) as Record<string, unknown>;
725
+ if (delta.type === "text_delta" && typeof delta.text === "string" && delta.text.length > 0) {
726
+ yield { type: "text_delta", text: delta.text };
727
+ return;
728
+ }
729
+ if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
730
+ const existing = this.toolBuffers.get(index);
731
+ if (existing) existing.args += delta.partial_json;
732
+ }
733
+ return;
734
+ }
735
+ if (type === "content_block_stop") {
736
+ const index = String(payload.index ?? 0);
737
+ const existing = this.toolBuffers.get(index);
738
+ if (existing && existing.name) {
739
+ this.toolBuffers.delete(index);
740
+ try {
741
+ const parsed = existing.args ? JSON.parse(existing.args) : {};
742
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
743
+ yield { type: "tool_call", id: existing.id, name: existing.name, args: parsed as Record<string, unknown> };
744
+ return;
745
+ }
746
+ throw new Error();
747
+ } catch {
748
+ throw new AiParseError("AI provider returned malformed tool-call JSON");
749
+ }
750
+ }
751
+ return;
752
+ }
753
+ if (type === "message_delta") {
754
+ const delta = (payload.delta ?? {}) as Record<string, unknown>;
755
+ if (typeof delta.stop_reason === "string" && delta.stop_reason.length > 0) {
756
+ this.lastFinishReason = delta.stop_reason;
757
+ }
758
+ const usage = (payload.usage ?? {}) as Record<string, unknown>;
759
+ if (usage.output_tokens !== undefined || usage.input_tokens !== undefined) {
760
+ const promptTokens = Number(usage.input_tokens ?? this.lastUsage?.promptTokens ?? 0);
761
+ const completionTokens = Number(usage.output_tokens ?? this.lastUsage?.completionTokens ?? 0);
762
+ this.lastUsage = { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens };
763
+ }
764
+ return;
765
+ }
766
+ if (type === "message_stop") {
767
+ if (this.doneEmitted) return;
768
+ this.doneEmitted = true;
769
+ yield {
770
+ type: "done",
771
+ finishReason: this.lastFinishReason ?? "end_turn",
772
+ ...(this.lastUsage ? { usage: this.lastUsage } : {}),
773
+ };
774
+ return;
775
+ }
776
+ if (type === "message_start") {
777
+ const message = (payload.message ?? {}) as Record<string, unknown>;
778
+ const usage = (message.usage ?? {}) as Record<string, unknown>;
779
+ if (usage.input_tokens !== undefined || usage.output_tokens !== undefined) {
780
+ const promptTokens = Number(usage.input_tokens ?? 0);
781
+ const completionTokens = Number(usage.output_tokens ?? 0);
782
+ this.lastUsage = { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens };
783
+ }
784
+ return;
785
+ }
786
+ if (type === "error") {
787
+ const err = (payload.error ?? {}) as Record<string, unknown>;
788
+ throw new AiParseError(typeof err.message === "string" ? err.message : "AI provider signalled a stream error");
789
+ }
790
+ }
791
+
792
+ private *flushRemainingToolCalls(): Iterable<AiEvent> {
793
+ for (const [index, buffered] of this.toolBuffers) {
794
+ if (buffered.name && buffered.args) {
795
+ try {
796
+ const parsed = JSON.parse(buffered.args) as unknown;
797
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
798
+ yield { type: "tool_call", id: buffered.id || `call_${index}`, name: buffered.name, args: parsed as Record<string, unknown> };
799
+ continue;
800
+ }
801
+ } catch {
802
+ /* fall through to error */
803
+ }
804
+ throw new AiParseError("AI provider returned malformed tool-call JSON");
285
805
  }
286
806
  }
807
+ this.toolBuffers.clear();
287
808
  }
288
809
  }