@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codehz/ai",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "module": "dist/index.mjs",
6
6
  "exports": {
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { AdapterBase } from "../helpers/adapter-base.js";
12
- import { AIRequestError } from "../core/errors.js";
12
+ import { AIProviderError, AIRequestError, AIStreamError } from "../core/errors.js";
13
13
  import {
14
14
  textBlock,
15
15
  messageItem,
@@ -21,7 +21,10 @@ import {
21
21
  contentBlocksToText,
22
22
  } from "../helpers/mapping.js";
23
23
  import { emitMalformedStreamWarning } from "../helpers/adapter-auxiliary.js";
24
+ import { assertOpaqueReplayEnvelope, providerHttpError } from "../helpers/adapter-security.js";
24
25
  import { usageFromChatCompletions } from "../helpers/usage-mapping.js";
26
+ import { NormalizedRequestMapper, splitLines, IncrementalStreamParser } from "../helpers/index.js";
27
+ import type { ProviderProfile } from "../helpers/index.js";
25
28
 
26
29
  import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
27
30
 
@@ -44,6 +47,7 @@ type ChatRequest = {
44
47
  temperature?: number;
45
48
  max_tokens?: number;
46
49
  stream: true;
50
+ n: 1;
47
51
  };
48
52
 
49
53
  type ChatMessage = {
@@ -114,75 +118,24 @@ type ReasoningFieldName = "reasoning" | "reasoning_content";
114
118
 
115
119
  const REASONING_FIELDS: readonly ReasoningFieldName[] = ["reasoning_content", "reasoning"];
116
120
 
117
- function assertChatToolResultOutcome(outcome: import("../index.js").ToolResultItem["outcome"]): void {
118
- if (outcome !== "success") {
119
- throw new AIRequestError(
120
- `chat-completions does not preserve tool_result outcome "${outcome}"; only "success" is supported`,
121
- "UNSUPPORTED_TOOL_RESULT_OUTCOME",
122
- );
123
- }
124
- }
125
-
126
- // ── SSE 解析 ──────────────────────────────────────────────────
127
-
128
- /**
129
- * Chat Completions 的简化 SSE 解析器。
130
- *
131
- * 约束:
132
- * - 每条 `data:` 行必须已经是一个完整 JSON 对象
133
- * - 允许传输层把单行拆成多个 chunk,但不接受 provider 把一个 JSON event 改写成多条 `data:` 行
134
- */
135
- function parseChatSSE(buffer: string): { chunks: ChatChunk[]; rest: string; malformedEvents: number } {
136
- const chunks: ChatChunk[] = [];
137
- let rest = buffer;
138
- let malformedEvents = 0;
139
-
140
- while (true) {
141
- const lineEnd = rest.indexOf("\n");
142
- if (lineEnd === -1) {
143
- // 没有更多完整行,剩余部分保留到下次
144
- break;
145
- }
146
-
147
- const line = rest.slice(0, lineEnd).trim();
148
- rest = rest.slice(lineEnd + 1);
149
-
150
- if (!line.startsWith("data: ")) continue;
151
-
152
- const data = line.slice(6).trim();
153
- if (data === "[DONE]") continue;
154
-
155
- try {
156
- chunks.push(JSON.parse(data));
157
- } catch {
158
- malformedEvents++;
159
- }
160
- }
161
-
162
- return { chunks, rest, malformedEvents };
163
- }
164
-
165
- function ensureTextCompatibleBlocks(
166
- blocks: import("../index.js").ContentBlock[],
167
- field: string,
168
- ): import("../index.js").ContentBlock[] {
169
- for (let i = 0; i < blocks.length; i++) {
170
- const block = blocks[i];
171
- if (!block) continue;
172
- if (block.type !== "text" && block.type !== "json") {
173
- throw new AIRequestError(
174
- `chat-completions does not support ${field}[${i}] of type "${block.type}"; only text/json blocks are supported`,
175
- "UNSUPPORTED_CONTENT_BLOCK",
176
- );
177
- }
178
- }
179
-
180
- return blocks;
181
- }
121
+ // ── ProviderProfile & Mapper ────────────────────────────────────
122
+
123
+ const profile: ProviderProfile = {
124
+ kind: "chat-completions",
125
+ instructionsMode: "system_message",
126
+ supportedBlockTypes: ["text", "json"] as const,
127
+ reasoningBlockTypes: ["text"] as const,
128
+ capabilities: {
129
+ textStreaming: "native",
130
+ reasoningStreaming: "native",
131
+ toolCallStreaming: "native",
132
+ replay: "opaque",
133
+ usage: "final",
134
+ toolResultOutcomes: ["success"],
135
+ },
136
+ };
182
137
 
183
- function contentBlocksToChatText(blocks: import("../index.js").ContentBlock[], field: string): string {
184
- return contentBlocksToText(ensureTextCompatibleBlocks(blocks, field));
185
- }
138
+ const mapper = new NormalizedRequestMapper(profile);
186
139
 
187
140
  function extractReasoningText(value: unknown): string {
188
141
  if (typeof value === "string") return value;
@@ -215,9 +168,51 @@ function extractReasoningDeltas(delta: ChatChunkChoice["delta"]): Array<{ field:
215
168
  return deltas;
216
169
  }
217
170
 
218
- function rollbackTrailingAssistantMessages(messages: ChatMessage[]): void {
219
- while (messages.length > 0 && messages[messages.length - 1]?.role === "assistant") {
220
- messages.pop();
171
+ function isChatReplayToolCall(value: unknown): value is ChatToolCall {
172
+ if (!value || typeof value !== "object") return false;
173
+ const entry = value as Record<string, unknown>;
174
+ if (typeof entry.id !== "string" || entry.type !== "function") return false;
175
+ const fn = entry.function;
176
+ if (!fn || typeof fn !== "object") return false;
177
+ const f = fn as Record<string, unknown>;
178
+ return typeof f.name === "string" && typeof f.arguments === "string";
179
+ }
180
+
181
+ function isChatReplayMessage(value: unknown): value is ChatMessage {
182
+ if (!value || typeof value !== "object") return false;
183
+ const msg = value as Record<string, unknown>;
184
+ const role = msg.role;
185
+ if (role !== "system" && role !== "user" && role !== "assistant" && role !== "tool") {
186
+ return false;
187
+ }
188
+ if (!(msg.content === null || typeof msg.content === "string")) {
189
+ return false;
190
+ }
191
+ if (msg.tool_calls !== undefined) {
192
+ if (!Array.isArray(msg.tool_calls) || !msg.tool_calls.every(isChatReplayToolCall)) {
193
+ return false;
194
+ }
195
+ }
196
+ if (msg.tool_call_id !== undefined && typeof msg.tool_call_id !== "string") {
197
+ return false;
198
+ }
199
+ if (msg.name !== undefined && typeof msg.name !== "string") {
200
+ return false;
201
+ }
202
+ return true;
203
+ }
204
+
205
+ function assertChatReplayMessages(messages: unknown, field: string): asserts messages is ChatMessage[] {
206
+ if (!Array.isArray(messages)) {
207
+ throw new AIRequestError(`Invalid opaque replay payload: ${field} must be an array`, "INVALID_OPAQUE_REPLAY");
208
+ }
209
+ for (let i = 0; i < messages.length; i++) {
210
+ if (!isChatReplayMessage(messages[i])) {
211
+ throw new AIRequestError(
212
+ `Invalid opaque replay payload: ${field}[${i}] is not a valid chat message`,
213
+ "INVALID_OPAQUE_REPLAY",
214
+ );
215
+ }
221
216
  }
222
217
  }
223
218
 
@@ -256,7 +251,7 @@ function buildAssistantReplayMessage(params: {
256
251
 
257
252
  export class ChatCompletionsAdapter extends AdapterBase {
258
253
  readonly kind = "chat-completions" as const;
259
- readonly nativeStreaming = true;
254
+ readonly capabilities = profile.capabilities;
260
255
 
261
256
  private apiKey: string;
262
257
  private baseUrl: string;
@@ -276,18 +271,16 @@ export class ChatCompletionsAdapter extends AdapterBase {
276
271
 
277
272
  // handle instructions → system message
278
273
  if (request.instructions) {
279
- const content =
280
- typeof request.instructions === "string"
281
- ? request.instructions
282
- : contentBlocksToChatText(request.instructions, "instructions");
283
- messages.push({ role: "system", content });
274
+ messages.push({ role: "system", content: mapper.mapInstructions(request.instructions) });
284
275
  }
285
276
 
286
277
  for (const item of request.input) {
287
278
  switch (item.type) {
288
279
  case "message": {
289
280
  const role = item.role;
290
- const text = contentBlocksToChatText(item.content, `input message (${item.role}) content`);
281
+ const text = contentBlocksToText(
282
+ mapper.ensureTextBlocks(item.content, `input message (${item.role}) content`),
283
+ );
291
284
  messages.push({ role, content: text || null });
292
285
  break;
293
286
  }
@@ -310,12 +303,12 @@ export class ChatCompletionsAdapter extends AdapterBase {
310
303
  break;
311
304
  }
312
305
  case "tool_result": {
313
- assertChatToolResultOutcome(item.outcome);
306
+ mapper.assertToolResultOutcome(item.outcome);
314
307
  messages.push({
315
308
  role: "tool",
316
309
  tool_call_id: item.callId,
317
310
  name: item.toolName,
318
- content: contentBlocksToChatText(item.content, `tool_result ${item.callId} content`),
311
+ content: contentBlocksToText(mapper.ensureTextBlocks(item.content, `tool_result ${item.callId} content`)),
319
312
  });
320
313
  break;
321
314
  }
@@ -324,25 +317,27 @@ export class ChatCompletionsAdapter extends AdapterBase {
324
317
  // Convert to a text message for best-effort
325
318
  messages.push({
326
319
  role: "assistant",
327
- content: contentBlocksToChatText(item.content, "reasoning content"),
320
+ content: contentBlocksToText(mapper.ensureTextBlocks(item.content, "reasoning content")),
328
321
  });
329
322
  break;
330
323
  }
331
324
  case "opaque": {
332
325
  // Try to restore from opaque replay
333
- if (item.purpose === "replay" && typeof item.payload === "object" && item.payload !== null) {
334
- const payload = item.payload as Record<string, unknown>;
335
- if (payload.role === "assistant" && typeof payload.content === "string") {
336
- messages.push({ role: "assistant", content: payload.content as string });
337
- } else if (payload.replaceCanonical === true && Array.isArray(payload.messages)) {
338
- rollbackTrailingAssistantMessages(messages);
339
- for (const m of payload.messages as ChatMessage[]) {
340
- messages.push(m);
341
- }
342
- } else if (Array.isArray(payload.messages)) {
343
- for (const m of payload.messages as ChatMessage[]) {
344
- messages.push(m);
345
- }
326
+ if (item.purpose !== "replay") break;
327
+ assertOpaqueReplayEnvelope(item.payload);
328
+ const payload = item.payload as Record<string, unknown>;
329
+ if (payload.role === "assistant" && typeof payload.content === "string") {
330
+ messages.push({ role: "assistant", content: payload.content });
331
+ } else if (payload.replaceCanonical === true && "messages" in payload) {
332
+ assertChatReplayMessages(payload.messages, "messages");
333
+ mapper.rollbackTrailingAssistantMessages(messages);
334
+ for (const m of payload.messages) {
335
+ messages.push(m);
336
+ }
337
+ } else if ("messages" in payload) {
338
+ assertChatReplayMessages(payload.messages, "messages");
339
+ for (const m of payload.messages) {
340
+ messages.push(m);
346
341
  }
347
342
  }
348
343
  break;
@@ -354,6 +349,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
354
349
  model: request.model,
355
350
  messages,
356
351
  stream: true,
352
+ n: 1,
357
353
  };
358
354
 
359
355
  if (request.tools && request.tools.length > 0) {
@@ -392,28 +388,45 @@ export class ChatCompletionsAdapter extends AdapterBase {
392
388
  request: NormalizedRequest,
393
389
  ): AsyncIterable<AIStreamEvent> {
394
390
  const auxiliary = this.createAuxiliaryState(request);
395
- const response = await this.fetchFn(`${this.baseUrl}/chat/completions`, {
396
- method: "POST",
397
- headers: {
398
- "Content-Type": "application/json",
399
- Authorization: `Bearer ${this.apiKey}`,
400
- },
401
- body: JSON.stringify(providerRequest),
402
- });
391
+ let response: Response;
392
+
393
+ try {
394
+ response = await this.fetchFn(`${this.baseUrl}/chat/completions`, {
395
+ method: "POST",
396
+ headers: {
397
+ "Content-Type": "application/json",
398
+ Authorization: `Bearer ${this.apiKey}`,
399
+ },
400
+ body: JSON.stringify(providerRequest),
401
+ });
402
+ } catch (err) {
403
+ throw new AIProviderError(err instanceof Error ? err.message : String(err), "PROVIDER_ERROR");
404
+ }
403
405
 
404
406
  if (!response.ok) {
405
- const errorText = await response.text().catch(() => "unknown error");
406
- throw new Error(`Chat Completions API error ${response.status}: ${errorText}`);
407
+ const errorBody = await response.text().catch(() => "");
408
+ throw providerHttpError(response.status, errorBody);
407
409
  }
408
410
 
409
411
  const reader = response.body?.getReader();
410
412
  if (!reader) {
411
- throw new Error("Response body is not readable");
413
+ throw new AIStreamError("Response body is not readable", "STREAM_ERROR");
412
414
  }
413
415
 
416
+ const parser = new IncrementalStreamParser<ChatChunk>(splitLines, (item: string) => {
417
+ const trimmed = item.trim();
418
+ if (!trimmed.startsWith("data: ")) return { status: "ignored" };
419
+ const data = trimmed.slice(6).trim();
420
+ if (data === "[DONE]") return { status: "ignored" };
421
+ try {
422
+ return { status: "parsed", value: JSON.parse(data) as ChatChunk };
423
+ } catch {
424
+ return { status: "malformed" };
425
+ }
426
+ });
427
+
414
428
  const output: OutputItem[] = [];
415
- const decoder = new TextDecoder();
416
- let buffer = "";
429
+ let streamDone = false;
417
430
 
418
431
  // 累积状态 — 支持多 choice,此处只取 index 0
419
432
  let responseId: string | undefined;
@@ -423,6 +436,9 @@ export class ChatCompletionsAdapter extends AdapterBase {
423
436
  let currentReasoningId = "";
424
437
  let hasMessageStarted = false;
425
438
  let hasReasoningStarted = false;
439
+ let completedEmitted = false;
440
+ let warnedNonZeroChoice = false;
441
+ const buildResponse = this.buildResponse.bind(this);
426
442
 
427
443
  // tool_calls 累积: tool call index → { id, name, args }
428
444
  const pendingToolCalls = new Map<number, PendingToolCall>();
@@ -435,19 +451,19 @@ export class ChatCompletionsAdapter extends AdapterBase {
435
451
 
436
452
  if (hasReasoningStarted && accumulatedReasoning) {
437
453
  const reasoning = reasoningItem([textBlock(accumulatedReasoning)], "full", currentReasoningId);
438
- events.push(factory.reasoningCompleted(reasoning));
454
+ events.push(factory.reasoningCompleted(currentReasoningId));
439
455
  output.push(reasoning);
440
456
  }
441
457
 
442
- if (hasMessageStarted && accumulatedContent) {
458
+ if (hasMessageStarted) {
443
459
  const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });
444
- events.push(factory.messageCompleted(message));
460
+ events.push(factory.messageCompleted(currentMessageId));
445
461
  output.push(message);
446
462
  }
447
463
 
448
464
  for (const pending of finalizedToolCalls) {
449
465
  const toolCall = toolCallItem(pending.id, pending.name, pending.args);
450
- events.push(factory.toolCallCompleted(toolCall));
466
+ events.push(factory.toolCallCompleted(pending.id));
451
467
  output.push(toolCall);
452
468
  }
453
469
 
@@ -469,14 +485,70 @@ export class ChatCompletionsAdapter extends AdapterBase {
469
485
  return { events, assistantReplayMessage };
470
486
  };
471
487
 
488
+ const emitCompleted = async function* (
489
+ stopReason: import("../index.js").StopReason | undefined,
490
+ assistantReplayMessage: ChatMessage | null,
491
+ rawResponseId: string | undefined,
492
+ ): AsyncIterable<AIStreamEvent> {
493
+ if (completedEmitted) {
494
+ yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
495
+ return;
496
+ }
497
+
498
+ completedEmitted = true;
499
+
500
+ const replay = [...replayFromOutput(output)];
501
+
502
+ if (assistantReplayMessage) {
503
+ replay.push(
504
+ opaqueItem("chat.completions", "replay", {
505
+ replaceCanonical: true,
506
+ messages: [assistantReplayMessage],
507
+ }),
508
+ );
509
+ }
510
+
511
+ const auxiliaryResult = await auxiliary.finalize(factory);
512
+ for (const event of auxiliaryResult.events) {
513
+ yield event;
514
+ }
515
+
516
+ const finalResponse = buildResponse(
517
+ request,
518
+ {
519
+ output,
520
+ replay,
521
+ stopReason,
522
+ usage: auxiliaryResult.usage,
523
+ billing: auxiliaryResult.billing,
524
+ auxiliary: auxiliaryResult.auxiliary,
525
+ warnings: auxiliaryResult.warnings,
526
+ metadataSources: auxiliaryResult.metadataSources,
527
+ rawResponseId,
528
+ },
529
+ factory,
530
+ );
531
+ yield factory.responseCompleted({
532
+ replay: finalResponse.replay,
533
+ stopReason: finalResponse.stopReason,
534
+ trace: finalResponse.backend,
535
+ usage: finalResponse.usage,
536
+ billing: finalResponse.billing,
537
+ auxiliary: finalResponse.auxiliary,
538
+ warnings: finalResponse.warnings,
539
+ });
540
+ };
541
+
472
542
  try {
473
543
  while (true) {
474
- const { done, value } = await reader.read();
475
- if (done) break;
476
-
477
- buffer += decoder.decode(value, { stream: true });
478
- const { chunks, rest, malformedEvents } = parseChatSSE(buffer);
479
- buffer = rest;
544
+ const readResult = await reader.read().catch((err: unknown) => {
545
+ throw new AIStreamError(
546
+ `Failed to read response stream: ${err instanceof Error ? err.message : String(err)}`,
547
+ "STREAM_ERROR",
548
+ );
549
+ });
550
+ const { done, value } = readResult;
551
+ const { items: chunks, malformed: malformedEvents } = done ? parser.flush() : parser.feed(value as Uint8Array);
480
552
 
481
553
  const malformedWarning = emitMalformedStreamWarning(factory, {
482
554
  count: malformedEvents,
@@ -496,19 +568,34 @@ export class ChatCompletionsAdapter extends AdapterBase {
496
568
  }
497
569
 
498
570
  for (const choice of chunk.choices) {
499
- if (choice.index !== 0) continue;
571
+ if (choice.index !== 0) {
572
+ if (!warnedNonZeroChoice) {
573
+ yield factory.responseWarning(
574
+ `Chat Completions returned choice index ${choice.index}; only the first choice (index 0) is supported. This choice is ignored.`,
575
+ "MULTIPLE_CHOICES_IGNORED",
576
+ );
577
+ warnedNonZeroChoice = true;
578
+ }
579
+ continue;
580
+ }
581
+
582
+ if (completedEmitted) {
583
+ if (choice.finish_reason) {
584
+ yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
585
+ }
586
+ continue;
587
+ }
500
588
 
501
589
  const delta = choice.delta;
502
590
  const finishReason = choice.finish_reason;
503
591
  const reasoningDeltas = extractReasoningDeltas(delta);
504
592
 
505
- // 处理 role: assistant (首块标识)
506
- if (delta.role === "assistant" && typeof delta.content === "string" && !hasMessageStarted) {
593
+ const ensureMessageStarted = (): void => {
594
+ if (hasMessageStarted) return;
507
595
  currentMessageId = `msg-${chunk.id}`;
508
596
  hasMessageStarted = true;
509
597
  accumulatedContent = "";
510
- yield factory.messageStarted(currentMessageId);
511
- }
598
+ };
512
599
 
513
600
  // 处理 third-party reasoning delta
514
601
  if (reasoningDeltas.length > 0) {
@@ -532,16 +619,20 @@ export class ChatCompletionsAdapter extends AdapterBase {
532
619
  // 处理 content delta
533
620
  if (delta.content) {
534
621
  if (!hasMessageStarted) {
535
- currentMessageId = `msg-${chunk.id}`;
536
- hasMessageStarted = true;
622
+ ensureMessageStarted();
537
623
  yield factory.messageStarted(currentMessageId);
538
624
  }
539
625
  accumulatedContent += delta.content;
540
- yield factory.messageDelta(currentMessageId, delta.content);
626
+ yield factory.messageDelta(currentMessageId, textBlock(delta.content));
541
627
  }
542
628
 
543
629
  // 处理 tool_calls delta
544
630
  if (delta.tool_calls) {
631
+ if (!hasMessageStarted) {
632
+ ensureMessageStarted();
633
+ yield factory.messageStarted(currentMessageId);
634
+ }
635
+
545
636
  for (const tc of delta.tool_calls) {
546
637
  const idx = tc.index;
547
638
 
@@ -562,6 +653,11 @@ export class ChatCompletionsAdapter extends AdapterBase {
562
653
 
563
654
  // 处理 function_call delta (legacy format)
564
655
  if (delta.function_call) {
656
+ if (!hasMessageStarted) {
657
+ ensureMessageStarted();
658
+ yield factory.messageStarted(currentMessageId);
659
+ }
660
+
565
661
  if (delta.function_call.name) {
566
662
  const fcId = `fc-${chunk.id}-0`;
567
663
  pendingToolCalls.set(0, { id: fcId, name: delta.function_call.name, args: "" });
@@ -583,58 +679,31 @@ export class ChatCompletionsAdapter extends AdapterBase {
583
679
  yield event;
584
680
  }
585
681
 
586
- // 构建 stop reason
587
682
  const stopReason = mapStopReason(finishReason);
588
-
589
- // 构建 replay
590
- const replay = [...replayFromOutput(output)];
591
-
592
- // 附加 opaque replay
593
- if (assistantReplayMessage) {
594
- replay.push(
595
- opaqueItem("chat.completions", "replay", {
596
- replaceCanonical: true,
597
- messages: [assistantReplayMessage],
598
- }),
599
- );
600
- }
601
-
602
- const auxiliaryResult = await auxiliary.finalize(factory);
603
- for (const event of auxiliaryResult.events) {
604
- yield event;
605
- }
606
-
607
- yield factory.responseCompleted(
608
- this.buildResponse(
609
- request,
610
- {
611
- output,
612
- replay,
613
- stopReason,
614
- usage: auxiliaryResult.usage,
615
- billing: auxiliaryResult.billing,
616
- auxiliary: auxiliaryResult.auxiliary,
617
- warnings: auxiliaryResult.warnings,
618
- metadataSources: auxiliaryResult.metadataSources,
619
- rawResponseId: chunk.id,
620
- },
621
- factory,
622
- ),
623
- );
683
+ yield* emitCompleted(stopReason, assistantReplayMessage, chunk.id);
624
684
  }
625
685
  }
626
686
  }
687
+
688
+ if (done) {
689
+ streamDone = true;
690
+ break;
691
+ }
627
692
  }
628
693
  } finally {
629
- reader.releaseLock();
694
+ try {
695
+ if (!streamDone) await reader.cancel().catch(() => undefined);
696
+ } finally {
697
+ reader.releaseLock();
698
+ }
630
699
  }
631
700
 
632
- if (buffer.trim().length > 0) {
701
+ if (parser.getRemaining().trim().length > 0) {
633
702
  yield factory.responseWarning("Stream ended with an incomplete Chat Completions SSE frame", "STREAM_ERROR");
634
703
  }
635
704
 
636
705
  // 如果流结束时没有 finish_reason(断流),也尝试关闭
637
- if (hasMessageStarted || hasReasoningStarted || pendingToolCalls.size > 0) {
706
+ if (!completedEmitted && (hasMessageStarted || hasReasoningStarted || pendingToolCalls.size > 0)) {
638
707
  yield factory.responseWarning("Stream ended without a finish_reason", "INCOMPLETE_STREAM");
639
708
 
640
709
  const { events, assistantReplayMessage } = finalizePendingTurn();
@@ -642,37 +711,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
642
711
  yield event;
643
712
  }
644
713
 
645
- const replay = [...replayFromOutput(output)];
646
- if (assistantReplayMessage) {
647
- replay.push(
648
- opaqueItem("chat.completions", "replay", {
649
- replaceCanonical: true,
650
- messages: [assistantReplayMessage],
651
- }),
652
- );
653
- }
654
-
655
- const auxiliaryResult = await auxiliary.finalize(factory);
656
- for (const event of auxiliaryResult.events) {
657
- yield event;
658
- }
659
-
660
- yield factory.responseCompleted(
661
- this.buildResponse(
662
- request,
663
- {
664
- output,
665
- replay,
666
- usage: auxiliaryResult.usage,
667
- billing: auxiliaryResult.billing,
668
- auxiliary: auxiliaryResult.auxiliary,
669
- warnings: auxiliaryResult.warnings,
670
- metadataSources: auxiliaryResult.metadataSources,
671
- rawResponseId: responseId,
672
- },
673
- factory,
674
- ),
675
- );
714
+ yield* emitCompleted(undefined, assistantReplayMessage, responseId);
676
715
  }
677
716
  }
678
717
  }