@codehz/ai 0.2.0 → 0.2.2

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.
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { AdapterBase } from "../helpers/adapter-base.js";
19
- import { AIRequestError } from "../core/errors.js";
19
+ import { AIProviderError, AIRequestError, AIStreamError, WarningCode } from "../core/errors.js";
20
20
  import {
21
21
  textBlock,
22
22
  messageItem,
@@ -27,7 +27,10 @@ import {
27
27
  contentBlocksToText,
28
28
  } from "../helpers/mapping.js";
29
29
  import { emitMalformedStreamWarning } from "../helpers/adapter-auxiliary.js";
30
+ import { assertOpaqueReplayEnvelope, providerHttpError } from "../helpers/adapter-security.js";
30
31
  import { usageFromOllama } from "../helpers/usage-mapping.js";
32
+ import { NormalizedRequestMapper, splitLines, IncrementalStreamParser } from "../helpers/index.js";
33
+ import type { ProviderProfile } from "../helpers/index.js";
31
34
 
32
35
  import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
33
36
 
@@ -79,45 +82,24 @@ type OllamaTool = {
79
82
  };
80
83
  };
81
84
 
82
- function ensureOllamaTextBlocks(
83
- blocks: import("../index.js").ContentBlock[],
84
- field: string,
85
- ): import("../index.js").ContentBlock[] {
86
- for (let i = 0; i < blocks.length; i++) {
87
- const block = blocks[i];
88
- if (!block) continue;
89
- if (block.type !== "text" && block.type !== "json") {
90
- throw new AIRequestError(
91
- `ollama does not support ${field}[${i}] of type "${block.type}"; only text/json blocks are supported`,
92
- "UNSUPPORTED_CONTENT_BLOCK",
93
- );
94
- }
95
- }
96
-
97
- return blocks;
98
- }
99
-
100
- function ensureOllamaReasoningBlocks(
101
- blocks: import("../index.js").ContentBlock[],
102
- field: string,
103
- ): Array<Extract<import("../index.js").ContentBlock, { type: "text" }>> {
104
- return blocks.map((block, index) => {
105
- if (block.type !== "text") {
106
- throw new AIRequestError(
107
- `ollama does not support ${field}[${index}] of type "${block.type}"; reasoning only supports text blocks`,
108
- "UNSUPPORTED_CONTENT_BLOCK",
109
- );
110
- }
111
-
112
- return block;
113
- });
114
- }
85
+ // ── ProviderProfile & Mapper ────────────────────────────────────
86
+
87
+ const profile: ProviderProfile = {
88
+ kind: "ollama",
89
+ instructionsMode: "system_message",
90
+ supportedBlockTypes: ["text", "json"] as const,
91
+ reasoningBlockTypes: ["text"] as const,
92
+ capabilities: {
93
+ textStreaming: "native",
94
+ reasoningStreaming: "none",
95
+ toolCallStreaming: "synthetic",
96
+ replay: "opaque",
97
+ usage: "final",
98
+ toolResultOutcomes: ["success"],
99
+ },
100
+ };
115
101
 
116
- function instructionsToOllamaText(instructions: string | import("../index.js").InstructionBlock[]): string {
117
- return typeof instructions === "string"
118
- ? instructions
119
- : contentBlocksToText(ensureOllamaTextBlocks(instructions, "instructions"));
120
- }
102
+ const mapper = new NormalizedRequestMapper(profile);
121
103
 
122
104
  function parseOllamaToolArguments(item: import("../index.js").ToolCallItem): Record<string, unknown> {
123
105
  if (item.argumentsJson && typeof item.argumentsJson === "object" && item.argumentsJson !== null) {
@@ -139,15 +121,6 @@ function parseOllamaToolArguments(item: import("../index.js").ToolCallItem): Rec
139
121
  );
140
122
  }
141
123
 
142
- function assertOllamaToolResultOutcome(outcome: import("../index.js").ToolResultItem["outcome"]): void {
143
- if (outcome !== "success") {
144
- throw new AIRequestError(
145
- `ollama does not preserve tool_result outcome "${outcome}"; only "success" is supported`,
146
- "UNSUPPORTED_TOOL_RESULT_OUTCOME",
147
- );
148
- }
149
- }
150
-
151
124
  // ── Ollama 流式 chunk ─────────────────────────────────────────
152
125
 
153
126
  type OllamaChatChunk = {
@@ -169,50 +142,17 @@ type OllamaChatChunk = {
169
142
  eval_duration?: number;
170
143
  };
171
144
 
172
- // ── NDJSON 解析 ───────────────────────────────────────────────
173
-
174
- function parseOllamaNDJSON(buffer: string): { chunks: OllamaChatChunk[]; rest: string; malformedLines: number } {
175
- const chunks: OllamaChatChunk[] = [];
176
- let rest = buffer;
177
- let malformedLines = 0;
178
-
179
- while (true) {
180
- const lineEnd = rest.indexOf("\n");
181
- if (lineEnd === -1) break;
182
-
183
- const line = rest.slice(0, lineEnd).trim();
184
- rest = rest.slice(lineEnd + 1);
185
-
186
- if (!line) continue;
187
-
188
- try {
189
- const parsed = JSON.parse(line);
190
- // Ollama chunks have a "message" field in streaming mode
191
- if (parsed && typeof parsed === "object" && "message" in parsed) {
192
- chunks.push(parsed as OllamaChatChunk);
193
- } else {
194
- malformedLines++;
195
- }
196
- } catch {
197
- malformedLines++;
198
- }
199
- }
200
-
201
- return { chunks, rest, malformedLines };
202
- }
145
+ /** Opaque replay may carry optional local `id`s; wire tool_calls never include them. */
146
+ type OllamaReplayToolCall = OllamaToolCall & { id?: string };
203
147
 
204
- function rollbackTrailingAssistantMessages(messages: OllamaMessage[]): void {
205
- while (messages.length > 0 && messages[messages.length - 1]?.role === "assistant") {
206
- messages.pop();
207
- }
208
- }
209
-
210
- function isOllamaToolCalls(value: unknown): value is OllamaToolCall[] {
148
+ function isOllamaReplayToolCalls(value: unknown): value is OllamaReplayToolCall[] {
211
149
  return (
212
150
  Array.isArray(value) &&
213
151
  value.every((entry) => {
214
152
  if (!entry || typeof entry !== "object" || !("function" in entry)) return false;
215
153
  const fn = (entry as { function?: unknown }).function;
154
+ const id = (entry as { id?: unknown }).id;
155
+ if (id !== undefined && typeof id !== "string") return false;
216
156
  return (
217
157
  !!fn &&
218
158
  typeof fn === "object" &&
@@ -226,11 +166,20 @@ function isOllamaToolCalls(value: unknown): value is OllamaToolCall[] {
226
166
  );
227
167
  }
228
168
 
169
+ function toWireOllamaToolCalls(toolCalls: OllamaReplayToolCall[]): OllamaToolCall[] {
170
+ return toolCalls.map((tc) => ({
171
+ function: {
172
+ name: tc.function.name,
173
+ arguments: tc.function.arguments,
174
+ },
175
+ }));
176
+ }
177
+
229
178
  // ── Adapter ───────────────────────────────────────────────────
230
179
 
231
180
  export class OllamaAdapter extends AdapterBase {
232
181
  readonly kind = "ollama" as const;
233
- readonly nativeStreaming = true;
182
+ readonly capabilities = profile.capabilities;
234
183
 
235
184
  private baseUrl: string;
236
185
  private apiKey: string | undefined;
@@ -251,10 +200,12 @@ export class OllamaAdapter extends AdapterBase {
251
200
  }
252
201
 
253
202
  const messages: OllamaMessage[] = [];
203
+ /** Local-only name → call id queue for best-effort tool_result association (not sent to Ollama). */
204
+ const callIdsByName = new Map<string, string[]>();
254
205
 
255
206
  // handle instructions → system message
256
207
  if (request.instructions) {
257
- messages.push({ role: "system", content: instructionsToOllamaText(request.instructions) });
208
+ messages.push({ role: "system", content: mapper.mapInstructions(request.instructions) });
258
209
  }
259
210
 
260
211
  for (const item of request.input) {
@@ -263,7 +214,7 @@ export class OllamaAdapter extends AdapterBase {
263
214
  const role = item.role;
264
215
  messages.push({
265
216
  role,
266
- content: contentBlocksToText(ensureOllamaTextBlocks(item.content, `input message (${item.role}) content`)),
217
+ content: contentBlocksToText(mapper.ensureTextBlocks(item.content, `input message (${item.role}) content`)),
267
218
  });
268
219
  break;
269
220
  }
@@ -276,6 +227,9 @@ export class OllamaAdapter extends AdapterBase {
276
227
  arguments: parseOllamaToolArguments(item),
277
228
  },
278
229
  };
230
+ const queue = callIdsByName.get(item.name) ?? [];
231
+ queue.push(item.id);
232
+ callIdsByName.set(item.name, queue);
279
233
  if (lastAssistant) {
280
234
  lastAssistant.tool_calls = [...(lastAssistant.tool_calls ?? []), tc];
281
235
  } else {
@@ -284,10 +238,15 @@ export class OllamaAdapter extends AdapterBase {
284
238
  break;
285
239
  }
286
240
  case "tool_result": {
287
- assertOllamaToolResultOutcome(item.outcome);
241
+ mapper.assertToolResultOutcome(item.outcome);
242
+ // Best-effort: consume matching id from name queue when present (no wire call_id)
243
+ const queue = callIdsByName.get(item.toolName);
244
+ if (queue && queue.length > 0) {
245
+ queue.shift();
246
+ }
288
247
  messages.push({
289
248
  role: "tool",
290
- content: contentBlocksToText(ensureOllamaTextBlocks(item.content, `tool_result ${item.callId} content`)),
249
+ content: contentBlocksToText(mapper.ensureTextBlocks(item.content, `tool_result ${item.callId} content`)),
291
250
  });
292
251
  break;
293
252
  }
@@ -295,27 +254,42 @@ export class OllamaAdapter extends AdapterBase {
295
254
  // Ollama doesn't support reasoning in input; convert to text message
296
255
  messages.push({
297
256
  role: "assistant",
298
- content: contentBlocksToText(ensureOllamaReasoningBlocks(item.content, "reasoning content")),
257
+ content: contentBlocksToText(mapper.ensureReasoningBlocks(item.content, "reasoning content")),
299
258
  });
300
259
  break;
301
260
  }
302
261
  case "opaque": {
303
- // Best-effort restore from opaque replay
304
- if (
305
- item.source === "ollama" &&
306
- item.purpose === "replay" &&
307
- typeof item.payload === "object" &&
308
- item.payload !== null
309
- ) {
310
- const payload = item.payload as Record<string, unknown>;
311
- if (payload.role === "assistant" && typeof payload.content === "string") {
312
- rollbackTrailingAssistantMessages(messages);
313
- messages.push({
314
- role: "assistant",
315
- content: payload.content,
316
- tool_calls: isOllamaToolCalls(payload.tool_calls) ? payload.tool_calls : undefined,
317
- });
262
+ // Best-effort restore from opaque replay (local ids stripped before wire)
263
+ if (item.source !== "ollama" || item.purpose !== "replay") break;
264
+ assertOpaqueReplayEnvelope(item.payload);
265
+ const payload = item.payload as Record<string, unknown>;
266
+ if (payload.role === "assistant" && typeof payload.content === "string") {
267
+ mapper.rollbackTrailingAssistantMessages(messages);
268
+ let replayToolCalls: OllamaReplayToolCall[] | undefined;
269
+ if ("tool_calls" in payload && payload.tool_calls !== undefined) {
270
+ if (!isOllamaReplayToolCalls(payload.tool_calls)) {
271
+ throw new AIRequestError(
272
+ "Invalid opaque replay payload: tool_calls is not a valid ollama tool_calls array",
273
+ "INVALID_OPAQUE_REPLAY",
274
+ );
275
+ }
276
+ replayToolCalls = payload.tool_calls;
277
+ }
278
+ // Record name → id order for best-effort tool_result correlation (local only)
279
+ if (replayToolCalls) {
280
+ for (const tc of replayToolCalls) {
281
+ if (tc.id) {
282
+ const queue = callIdsByName.get(tc.function.name) ?? [];
283
+ queue.push(tc.id);
284
+ callIdsByName.set(tc.function.name, queue);
285
+ }
286
+ }
318
287
  }
288
+ messages.push({
289
+ role: "assistant",
290
+ content: payload.content,
291
+ tool_calls: replayToolCalls ? toWireOllamaToolCalls(replayToolCalls) : undefined,
292
+ });
319
293
  }
320
294
  break;
321
295
  }
@@ -358,6 +332,7 @@ export class OllamaAdapter extends AdapterBase {
358
332
  request: NormalizedRequest,
359
333
  ): AsyncIterable<AIStreamEvent> {
360
334
  const auxiliary = this.createAuxiliaryState(request);
335
+ let completedEmitted = false;
361
336
  if (request.metadata) {
362
337
  yield factory.responseWarning("Request metadata is not supported by the Ollama adapter", "UNSUPPORTED_METADATA");
363
338
  }
@@ -369,25 +344,44 @@ export class OllamaAdapter extends AdapterBase {
369
344
  headers.Authorization = `Bearer ${this.apiKey}`;
370
345
  }
371
346
 
372
- const response = await this.fetchFn(`${this.baseUrl}/api/chat`, {
373
- method: "POST",
374
- headers,
375
- body: JSON.stringify(providerRequest),
376
- });
347
+ let response: Response;
348
+
349
+ try {
350
+ response = await this.fetchFn(`${this.baseUrl}/api/chat`, {
351
+ method: "POST",
352
+ headers,
353
+ body: JSON.stringify(providerRequest),
354
+ });
355
+ } catch (err) {
356
+ throw new AIProviderError(err instanceof Error ? err.message : String(err), "PROVIDER_ERROR");
357
+ }
377
358
 
378
359
  if (!response.ok) {
379
- const errorText = await response.text().catch(() => "unknown error");
380
- throw new Error(`Ollama API error ${response.status}: ${errorText}`);
360
+ const errorBody = await response.text().catch(() => "");
361
+ throw providerHttpError(response.status, errorBody);
381
362
  }
382
363
 
383
364
  const reader = response.body?.getReader();
384
365
  if (!reader) {
385
- throw new Error("Response body is not readable");
366
+ throw new AIStreamError("Response body is not readable", "STREAM_ERROR");
386
367
  }
387
368
 
369
+ const parser = new IncrementalStreamParser<OllamaChatChunk>(splitLines, (item: string) => {
370
+ const trimmed = item.trim();
371
+ if (!trimmed) return { status: "ignored" };
372
+ try {
373
+ const parsed = JSON.parse(trimmed);
374
+ if (parsed && typeof parsed === "object" && "message" in parsed) {
375
+ return { status: "parsed", value: parsed as OllamaChatChunk };
376
+ }
377
+ return { status: "malformed" };
378
+ } catch {
379
+ return { status: "malformed" };
380
+ }
381
+ });
382
+
388
383
  const output: OutputItem[] = [];
389
- const decoder = new TextDecoder();
390
- let buffer = "";
384
+ let streamDone = false;
391
385
 
392
386
  // 累积状态
393
387
  let responseId: string | undefined;
@@ -397,15 +391,76 @@ export class OllamaAdapter extends AdapterBase {
397
391
 
398
392
  // tool_calls 累积(于 final chunk 到达)
399
393
  let pendingToolCalls: Array<{ id: string; name: string; argumentsText: string; argumentsJson?: unknown }> = [];
394
+ let toolCallIndex = 0;
395
+ const buildResponse = this.buildResponse.bind(this);
396
+
397
+ const emitCompleted = async function* (
398
+ stopReason: import("../index.js").StopReason | undefined,
399
+ rawResponseId: string | undefined,
400
+ ): AsyncIterable<AIStreamEvent> {
401
+ if (completedEmitted) {
402
+ yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
403
+ return;
404
+ }
405
+
406
+ completedEmitted = true;
407
+
408
+ const replay = replayFromOutput(output);
409
+
410
+ if (accumulatedContent || pendingToolCalls.length > 0) {
411
+ replay.push(
412
+ opaqueItem("ollama", "replay", {
413
+ role: "assistant",
414
+ content: accumulatedContent,
415
+ tool_calls: pendingToolCalls.map((tc) => ({
416
+ id: tc.id,
417
+ function: { name: tc.name, arguments: tc.argumentsJson },
418
+ })),
419
+ }),
420
+ );
421
+ }
422
+
423
+ const auxiliaryResult = await auxiliary.finalize(factory);
424
+ for (const event of auxiliaryResult.events) {
425
+ yield event;
426
+ }
427
+
428
+ const finalResponse = buildResponse(
429
+ request,
430
+ {
431
+ output,
432
+ replay,
433
+ stopReason,
434
+ usage: auxiliaryResult.usage,
435
+ billing: auxiliaryResult.billing,
436
+ auxiliary: auxiliaryResult.auxiliary,
437
+ warnings: auxiliaryResult.warnings,
438
+ metadataSources: auxiliaryResult.metadataSources,
439
+ rawResponseId,
440
+ },
441
+ factory,
442
+ );
443
+ yield factory.responseCompleted({
444
+ replay: finalResponse.replay,
445
+ stopReason: finalResponse.stopReason,
446
+ trace: finalResponse.backend,
447
+ usage: finalResponse.usage,
448
+ billing: finalResponse.billing,
449
+ auxiliary: finalResponse.auxiliary,
450
+ warnings: finalResponse.warnings,
451
+ });
452
+ };
400
453
 
401
454
  try {
402
455
  while (true) {
403
- const { done, value } = await reader.read();
404
- if (done) break;
405
-
406
- buffer += decoder.decode(value, { stream: true });
407
- const { chunks, rest, malformedLines } = parseOllamaNDJSON(buffer);
408
- buffer = rest;
456
+ const readResult = await reader.read().catch((err: unknown) => {
457
+ throw new AIStreamError(
458
+ `Failed to read response stream: ${err instanceof Error ? err.message : String(err)}`,
459
+ "STREAM_ERROR",
460
+ );
461
+ });
462
+ const { done, value } = readResult;
463
+ const { items: chunks, malformed: malformedLines } = done ? parser.flush() : parser.feed(value as Uint8Array);
409
464
 
410
465
  const malformedWarning = emitMalformedStreamWarning(factory, {
411
466
  count: malformedLines,
@@ -419,6 +474,13 @@ export class OllamaAdapter extends AdapterBase {
419
474
  for (const chunk of chunks) {
420
475
  responseId = chunk.created_at;
421
476
 
477
+ if (completedEmitted) {
478
+ if (chunk.done) {
479
+ yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
480
+ }
481
+ continue;
482
+ }
483
+
422
484
  const msg = chunk.message;
423
485
 
424
486
  // 处理 content delta
@@ -429,13 +491,13 @@ export class OllamaAdapter extends AdapterBase {
429
491
  yield factory.messageStarted(currentMessageId);
430
492
  }
431
493
  accumulatedContent += msg.content;
432
- yield factory.messageDelta(currentMessageId, msg.content);
494
+ yield factory.messageDelta(currentMessageId, textBlock(msg.content));
433
495
  }
434
496
 
435
497
  // 处理 tool_calls (整块到达,在最终 chunk 中)
436
498
  if (msg.tool_calls && msg.tool_calls.length > 0) {
437
499
  for (const tc of msg.tool_calls) {
438
- const tcId = `tc-${chunk.created_at}-${tc.function.name}`;
500
+ const tcId = `ollama-tc-${request.requestId}-${toolCallIndex++}`;
439
501
  const argsText = JSON.stringify(tc.function.arguments);
440
502
  pendingToolCalls.push({
441
503
  id: tcId,
@@ -458,18 +520,25 @@ export class OllamaAdapter extends AdapterBase {
458
520
  // 完成消息(如果有累积的内容或正在进行的消息)
459
521
  if (hasMessageStarted) {
460
522
  const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });
461
- yield factory.messageCompleted(message);
523
+ yield factory.messageCompleted(currentMessageId);
462
524
  if (accumulatedContent) {
463
525
  output.push(message);
464
526
  }
465
527
  }
466
528
 
529
+ if (pendingToolCalls.length > 0) {
530
+ yield factory.responseWarning(
531
+ `Ollama delivered ${pendingToolCalls.length} tool call(s) as a batch; tool_call streaming is not supported`,
532
+ WarningCode.TOOL_CALL_BATCHED,
533
+ );
534
+ }
535
+
467
536
  // 发出 tool_call 完成事件
468
537
  for (const pending of pendingToolCalls) {
469
538
  const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText, pending.argumentsJson);
470
539
  yield factory.toolCallStarted(pending.id, pending.name);
471
540
  yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
472
- yield factory.toolCallCompleted(toolCall);
541
+ yield factory.toolCallCompleted(pending.id);
473
542
  output.push(toolCall);
474
543
  }
475
544
 
@@ -494,44 +563,7 @@ export class OllamaAdapter extends AdapterBase {
494
563
  // 构建 stop reason
495
564
  const stopReason = chunk.done_reason ? mapStopReason(chunk.done_reason) : undefined;
496
565
 
497
- // 构建 replay
498
- const replay = replayFromOutput(output);
499
-
500
- // 附加 opaque replay(若有关联的 assistant 消息)
501
- if (accumulatedContent || pendingToolCalls.length > 0) {
502
- replay.push(
503
- opaqueItem("ollama", "replay", {
504
- role: "assistant",
505
- content: accumulatedContent,
506
- tool_calls: pendingToolCalls.map((tc) => ({
507
- function: { name: tc.name, arguments: tc.argumentsJson },
508
- })),
509
- }),
510
- );
511
- }
512
-
513
- const auxiliaryResult = await auxiliary.finalize(factory);
514
- for (const event of auxiliaryResult.events) {
515
- yield event;
516
- }
517
-
518
- yield factory.responseCompleted(
519
- this.buildResponse(
520
- request,
521
- {
522
- output,
523
- replay,
524
- stopReason,
525
- usage: auxiliaryResult.usage,
526
- billing: auxiliaryResult.billing,
527
- auxiliary: auxiliaryResult.auxiliary,
528
- warnings: auxiliaryResult.warnings,
529
- metadataSources: auxiliaryResult.metadataSources,
530
- rawResponseId: chunk.created_at,
531
- },
532
- factory,
533
- ),
534
- );
566
+ yield* emitCompleted(stopReason, chunk.created_at);
535
567
 
536
568
  // 重置累积状态
537
569
  accumulatedContent = "";
@@ -540,56 +572,52 @@ export class OllamaAdapter extends AdapterBase {
540
572
  pendingToolCalls = [];
541
573
  }
542
574
  }
575
+
576
+ if (done) {
577
+ streamDone = true;
578
+ break;
579
+ }
543
580
  }
544
581
  } finally {
545
- reader.releaseLock();
582
+ try {
583
+ if (!streamDone) await reader.cancel().catch(() => undefined);
584
+ } finally {
585
+ reader.releaseLock();
586
+ }
546
587
  }
547
588
 
548
- if (buffer.trim().length > 0) {
589
+ if (parser.getRemaining().trim().length > 0) {
549
590
  yield factory.responseWarning("Stream ended with an incomplete Ollama NDJSON line", "STREAM_ERROR");
550
591
  }
551
592
 
552
593
  // 流结束但无 done=true(断流保护)
553
- if (hasMessageStarted || pendingToolCalls.length > 0) {
594
+ if (!completedEmitted && (hasMessageStarted || pendingToolCalls.length > 0)) {
554
595
  yield factory.responseWarning("Stream ended without a done signal", "INCOMPLETE_STREAM");
555
596
 
556
597
  if (hasMessageStarted) {
557
598
  const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });
558
- yield factory.messageCompleted(message);
599
+ yield factory.messageCompleted(currentMessageId);
559
600
  if (accumulatedContent) {
560
601
  output.push(message);
561
602
  }
562
603
  }
563
604
 
605
+ if (pendingToolCalls.length > 0) {
606
+ yield factory.responseWarning(
607
+ `Ollama delivered ${pendingToolCalls.length} tool call(s) as a batch; tool_call streaming is not supported`,
608
+ WarningCode.TOOL_CALL_BATCHED,
609
+ );
610
+ }
611
+
564
612
  for (const pending of pendingToolCalls) {
565
613
  const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText, pending.argumentsJson);
566
614
  yield factory.toolCallStarted(pending.id, pending.name);
567
615
  yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
568
- yield factory.toolCallCompleted(toolCall);
616
+ yield factory.toolCallCompleted(pending.id);
569
617
  output.push(toolCall);
570
618
  }
571
619
 
572
- const replay = replayFromOutput(output);
573
- const auxiliaryResult = await auxiliary.finalize(factory);
574
- for (const event of auxiliaryResult.events) {
575
- yield event;
576
- }
577
- yield factory.responseCompleted(
578
- this.buildResponse(
579
- request,
580
- {
581
- output,
582
- replay,
583
- usage: auxiliaryResult.usage,
584
- billing: auxiliaryResult.billing,
585
- auxiliary: auxiliaryResult.auxiliary,
586
- warnings: auxiliaryResult.warnings,
587
- metadataSources: auxiliaryResult.metadataSources,
588
- rawResponseId: responseId,
589
- },
590
- factory,
591
- ),
592
- );
620
+ yield* emitCompleted(undefined, responseId);
593
621
  }
594
622
  }
595
623
  }