@codehz/ai 0.2.0 → 0.2.1

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.1",
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,21 @@ 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));
445
- output.push(message);
460
+ events.push(factory.messageCompleted(currentMessageId));
461
+ if (accumulatedContent) {
462
+ output.push(message);
463
+ }
446
464
  }
447
465
 
448
466
  for (const pending of finalizedToolCalls) {
449
467
  const toolCall = toolCallItem(pending.id, pending.name, pending.args);
450
- events.push(factory.toolCallCompleted(toolCall));
468
+ events.push(factory.toolCallCompleted(pending.id));
451
469
  output.push(toolCall);
452
470
  }
453
471
 
@@ -469,14 +487,70 @@ export class ChatCompletionsAdapter extends AdapterBase {
469
487
  return { events, assistantReplayMessage };
470
488
  };
471
489
 
490
+ const emitCompleted = async function* (
491
+ stopReason: import("../index.js").StopReason | undefined,
492
+ assistantReplayMessage: ChatMessage | null,
493
+ rawResponseId: string | undefined,
494
+ ): AsyncIterable<AIStreamEvent> {
495
+ if (completedEmitted) {
496
+ yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
497
+ return;
498
+ }
499
+
500
+ completedEmitted = true;
501
+
502
+ const replay = [...replayFromOutput(output)];
503
+
504
+ if (assistantReplayMessage) {
505
+ replay.push(
506
+ opaqueItem("chat.completions", "replay", {
507
+ replaceCanonical: true,
508
+ messages: [assistantReplayMessage],
509
+ }),
510
+ );
511
+ }
512
+
513
+ const auxiliaryResult = await auxiliary.finalize(factory);
514
+ for (const event of auxiliaryResult.events) {
515
+ yield event;
516
+ }
517
+
518
+ const finalResponse = buildResponse(
519
+ request,
520
+ {
521
+ output,
522
+ replay,
523
+ stopReason,
524
+ usage: auxiliaryResult.usage,
525
+ billing: auxiliaryResult.billing,
526
+ auxiliary: auxiliaryResult.auxiliary,
527
+ warnings: auxiliaryResult.warnings,
528
+ metadataSources: auxiliaryResult.metadataSources,
529
+ rawResponseId,
530
+ },
531
+ factory,
532
+ );
533
+ yield factory.responseCompleted({
534
+ replay: finalResponse.replay,
535
+ stopReason: finalResponse.stopReason,
536
+ trace: finalResponse.backend,
537
+ usage: finalResponse.usage,
538
+ billing: finalResponse.billing,
539
+ auxiliary: finalResponse.auxiliary,
540
+ warnings: finalResponse.warnings,
541
+ });
542
+ };
543
+
472
544
  try {
473
545
  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;
546
+ const readResult = await reader.read().catch((err: unknown) => {
547
+ throw new AIStreamError(
548
+ `Failed to read response stream: ${err instanceof Error ? err.message : String(err)}`,
549
+ "STREAM_ERROR",
550
+ );
551
+ });
552
+ const { done, value } = readResult;
553
+ const { items: chunks, malformed: malformedEvents } = done ? parser.flush() : parser.feed(value as Uint8Array);
480
554
 
481
555
  const malformedWarning = emitMalformedStreamWarning(factory, {
482
556
  count: malformedEvents,
@@ -496,17 +570,38 @@ export class ChatCompletionsAdapter extends AdapterBase {
496
570
  }
497
571
 
498
572
  for (const choice of chunk.choices) {
499
- if (choice.index !== 0) continue;
573
+ if (choice.index !== 0) {
574
+ if (!warnedNonZeroChoice) {
575
+ yield factory.responseWarning(
576
+ `Chat Completions returned choice index ${choice.index}; only the first choice (index 0) is supported. This choice is ignored.`,
577
+ "MULTIPLE_CHOICES_IGNORED",
578
+ );
579
+ warnedNonZeroChoice = true;
580
+ }
581
+ continue;
582
+ }
583
+
584
+ if (completedEmitted) {
585
+ if (choice.finish_reason) {
586
+ yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
587
+ }
588
+ continue;
589
+ }
500
590
 
501
591
  const delta = choice.delta;
502
592
  const finishReason = choice.finish_reason;
503
593
  const reasoningDeltas = extractReasoningDeltas(delta);
504
594
 
505
- // 处理 role: assistant (首块标识)
506
- if (delta.role === "assistant" && typeof delta.content === "string" && !hasMessageStarted) {
595
+ const ensureMessageStarted = (): void => {
596
+ if (hasMessageStarted) return;
507
597
  currentMessageId = `msg-${chunk.id}`;
508
598
  hasMessageStarted = true;
509
599
  accumulatedContent = "";
600
+ };
601
+
602
+ // 处理 role: assistant(首块标识;不要求 content)
603
+ if (delta.role === "assistant" && !hasMessageStarted) {
604
+ ensureMessageStarted();
510
605
  yield factory.messageStarted(currentMessageId);
511
606
  }
512
607
 
@@ -532,16 +627,20 @@ export class ChatCompletionsAdapter extends AdapterBase {
532
627
  // 处理 content delta
533
628
  if (delta.content) {
534
629
  if (!hasMessageStarted) {
535
- currentMessageId = `msg-${chunk.id}`;
536
- hasMessageStarted = true;
630
+ ensureMessageStarted();
537
631
  yield factory.messageStarted(currentMessageId);
538
632
  }
539
633
  accumulatedContent += delta.content;
540
- yield factory.messageDelta(currentMessageId, delta.content);
634
+ yield factory.messageDelta(currentMessageId, textBlock(delta.content));
541
635
  }
542
636
 
543
637
  // 处理 tool_calls delta
544
638
  if (delta.tool_calls) {
639
+ if (!hasMessageStarted) {
640
+ ensureMessageStarted();
641
+ yield factory.messageStarted(currentMessageId);
642
+ }
643
+
545
644
  for (const tc of delta.tool_calls) {
546
645
  const idx = tc.index;
547
646
 
@@ -562,6 +661,11 @@ export class ChatCompletionsAdapter extends AdapterBase {
562
661
 
563
662
  // 处理 function_call delta (legacy format)
564
663
  if (delta.function_call) {
664
+ if (!hasMessageStarted) {
665
+ ensureMessageStarted();
666
+ yield factory.messageStarted(currentMessageId);
667
+ }
668
+
565
669
  if (delta.function_call.name) {
566
670
  const fcId = `fc-${chunk.id}-0`;
567
671
  pendingToolCalls.set(0, { id: fcId, name: delta.function_call.name, args: "" });
@@ -583,58 +687,31 @@ export class ChatCompletionsAdapter extends AdapterBase {
583
687
  yield event;
584
688
  }
585
689
 
586
- // 构建 stop reason
587
690
  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
- );
691
+ yield* emitCompleted(stopReason, assistantReplayMessage, chunk.id);
624
692
  }
625
693
  }
626
694
  }
695
+
696
+ if (done) {
697
+ streamDone = true;
698
+ break;
699
+ }
627
700
  }
628
701
  } finally {
629
- reader.releaseLock();
702
+ try {
703
+ if (!streamDone) await reader.cancel().catch(() => undefined);
704
+ } finally {
705
+ reader.releaseLock();
706
+ }
630
707
  }
631
708
 
632
- if (buffer.trim().length > 0) {
709
+ if (parser.getRemaining().trim().length > 0) {
633
710
  yield factory.responseWarning("Stream ended with an incomplete Chat Completions SSE frame", "STREAM_ERROR");
634
711
  }
635
712
 
636
713
  // 如果流结束时没有 finish_reason(断流),也尝试关闭
637
- if (hasMessageStarted || hasReasoningStarted || pendingToolCalls.size > 0) {
714
+ if (!completedEmitted && (hasMessageStarted || hasReasoningStarted || pendingToolCalls.size > 0)) {
638
715
  yield factory.responseWarning("Stream ended without a finish_reason", "INCOMPLETE_STREAM");
639
716
 
640
717
  const { events, assistantReplayMessage } = finalizePendingTurn();
@@ -642,37 +719,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
642
719
  yield event;
643
720
  }
644
721
 
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
- );
722
+ yield* emitCompleted(undefined, assistantReplayMessage, responseId);
676
723
  }
677
724
  }
678
725
  }