@codehz/ai 0.3.0 → 0.4.0

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.3.0",
3
+ "version": "0.4.0",
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 { AIProviderError, AIRequestError, AIStreamError } from "../core/errors.js";
12
+ import { AIRequestError } from "../core/errors.js";
13
13
  import {
14
14
  textBlock,
15
15
  messageItem,
@@ -18,14 +18,18 @@ import {
18
18
  opaqueItem,
19
19
  replayFromOutput,
20
20
  mapStopReason,
21
- contentBlocksToText,
22
21
  } from "../helpers/mapping.js";
23
- import { emitMalformedStreamWarning } from "../helpers/adapter-auxiliary.js";
24
- import { assertOpaqueReplayEnvelope, providerHttpError } from "../helpers/adapter-security.js";
22
+ import { assertOpaqueReplayEnvelope } from "../helpers/adapter-security.js";
25
23
  import { usageFromChatCompletions } from "../helpers/usage-mapping.js";
26
- import { NormalizedRequestMapper, splitLines, IncrementalStreamParser } from "../helpers/index.js";
24
+ import {
25
+ NormalizedRequestMapper,
26
+ createChatCompletionsSseParser,
27
+ openProviderJsonStream,
28
+ iterateProviderStreamBatches,
29
+ createCompletionGate,
30
+ } from "../helpers/index.js";
27
31
 
28
- import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
32
+ import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn, StopReason } from "../index.js";
29
33
 
30
34
  // ── 类型 ──────────────────────────────────────────────────────
31
35
 
@@ -260,9 +264,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
260
264
  switch (item.type) {
261
265
  case "message": {
262
266
  const role = item.role;
263
- const text = contentBlocksToText(
264
- mapper.ensureTextBlocks(item.content, `input message (${item.role}) content`),
265
- );
267
+ const text = mapper.textFromBlocks(item.content, `input message (${item.role}) content`);
266
268
  messages.push({ role, content: text || null });
267
269
  break;
268
270
  }
@@ -289,7 +291,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
289
291
  role: "tool",
290
292
  tool_call_id: item.callId,
291
293
  name: item.toolName,
292
- content: contentBlocksToText(mapper.ensureTextBlocks(item.content, `tool_result ${item.callId} content`)),
294
+ content: mapper.textFromBlocks(item.content, `tool_result ${item.callId} content`),
293
295
  });
294
296
  break;
295
297
  }
@@ -298,7 +300,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
298
300
  // Convert to a text message for best-effort
299
301
  messages.push({
300
302
  role: "assistant",
301
- content: contentBlocksToText(mapper.ensureTextBlocks(item.content, "reasoning content")),
303
+ content: mapper.textFromBlocks(item.content, "reasoning content"),
302
304
  });
303
305
  break;
304
306
  }
@@ -333,26 +335,23 @@ export class ChatCompletionsAdapter extends AdapterBase {
333
335
  n: 1,
334
336
  };
335
337
 
336
- if (request.tools && request.tools.length > 0) {
337
- body.tools = request.tools.map(
338
- (t): ChatTool => ({
339
- type: "function",
340
- function: {
341
- name: t.name,
342
- description: t.description,
343
- parameters: t.inputSchema as Record<string, unknown>,
344
- },
345
- }),
346
- );
347
- }
338
+ body.tools = mapper.mapToolsIfPresent(
339
+ request.tools,
340
+ (t): ChatTool => ({
341
+ type: "function",
342
+ function: {
343
+ name: t.name,
344
+ description: t.description,
345
+ parameters: t.inputSchema as Record<string, unknown>,
346
+ },
347
+ }),
348
+ );
348
349
 
349
- if (request.toolChoice) {
350
- if (request.toolChoice === "auto") body.tool_choice = "auto";
351
- else if (request.toolChoice === "none") body.tool_choice = "none";
352
- else if (request.toolChoice.type === "tool") {
353
- body.tool_choice = { type: "function", function: { name: request.toolChoice.name } };
354
- }
355
- }
350
+ body.tool_choice = mapper.mapToolChoice<Exclude<ChatRequest["tool_choice"], undefined>>(request.toolChoice, {
351
+ auto: "auto",
352
+ none: "none",
353
+ tool: (name) => ({ type: "function" as const, function: { name } }),
354
+ });
356
355
 
357
356
  if (request.temperature !== undefined) body.temperature = request.temperature;
358
357
  if (request.maxOutputTokens !== undefined) body.max_tokens = request.maxOutputTokens;
@@ -369,46 +368,21 @@ export class ChatCompletionsAdapter extends AdapterBase {
369
368
  request: NormalizedRequest,
370
369
  ): AsyncIterable<AIStreamEvent> {
371
370
  const auxiliary = this.createAuxiliaryState(request);
372
- let response: Response;
373
-
374
- try {
375
- response = await this.fetchFn(`${this.baseUrl}/chat/completions`, {
376
- method: "POST",
377
- headers: {
378
- "Content-Type": "application/json",
379
- Authorization: `Bearer ${this.apiKey}`,
380
- },
381
- body: JSON.stringify(providerRequest),
382
- signal: request.signal,
383
- });
384
- } catch (err) {
385
- throw new AIProviderError(err instanceof Error ? err.message : String(err), "PROVIDER_ERROR");
386
- }
387
-
388
- if (!response.ok) {
389
- const errorBody = await response.text().catch(() => "");
390
- throw providerHttpError(response.status, errorBody);
391
- }
392
-
393
- const reader = response.body?.getReader();
394
- if (!reader) {
395
- throw new AIStreamError("Response body is not readable", "STREAM_ERROR");
396
- }
397
-
398
- const parser = new IncrementalStreamParser<ChatChunk>(splitLines, (item: string) => {
399
- const trimmed = item.trim();
400
- if (!trimmed.startsWith("data: ")) return { status: "ignored" };
401
- const data = trimmed.slice(6).trim();
402
- if (data === "[DONE]") return { status: "ignored" };
403
- try {
404
- return { status: "parsed", value: JSON.parse(data) as ChatChunk };
405
- } catch {
406
- return { status: "malformed" };
407
- }
371
+ const gate = createCompletionGate();
372
+
373
+ const { reader } = await openProviderJsonStream({
374
+ fetchFn: this.fetchFn,
375
+ url: `${this.baseUrl}/chat/completions`,
376
+ headers: {
377
+ "Content-Type": "application/json",
378
+ Authorization: `Bearer ${this.apiKey}`,
379
+ },
380
+ body: providerRequest,
381
+ signal: request.signal,
408
382
  });
409
383
 
384
+ const parser = createChatCompletionsSseParser<ChatChunk>();
410
385
  const output: OutputItem[] = [];
411
- let streamDone = false;
412
386
 
413
387
  // 累积状态 — 支持多 choice,此处只取 index 0
414
388
  let responseId: string | undefined;
@@ -418,9 +392,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
418
392
  let currentReasoningId = "";
419
393
  let hasMessageStarted = false;
420
394
  let hasReasoningStarted = false;
421
- let completedEmitted = false;
422
395
  let warnedNonZeroChoice = false;
423
- const buildResponse = this.buildResponse.bind(this);
424
396
 
425
397
  // tool_calls 累积: tool call index → { id, name, args }
426
398
  const pendingToolCalls = new Map<number, PendingToolCall>();
@@ -468,19 +440,17 @@ export class ChatCompletionsAdapter extends AdapterBase {
468
440
  };
469
441
 
470
442
  const emitCompleted = async function* (
471
- stopReason: import("../index.js").StopReason | undefined,
443
+ this: ChatCompletionsAdapter,
444
+ stopReason: StopReason | undefined,
472
445
  assistantReplayMessage: ChatMessage | null,
473
446
  rawResponseId: string | undefined,
474
447
  ): AsyncIterable<AIStreamEvent> {
475
- if (completedEmitted) {
448
+ if (!gate.tryComplete()) {
476
449
  yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
477
450
  return;
478
451
  }
479
452
 
480
- completedEmitted = true;
481
-
482
453
  const replay = [...replayFromOutput(output)];
483
-
484
454
  if (assistantReplayMessage) {
485
455
  replay.push(
486
456
  opaqueItem("chat.completions", "replay", {
@@ -490,209 +460,145 @@ export class ChatCompletionsAdapter extends AdapterBase {
490
460
  );
491
461
  }
492
462
 
493
- const auxiliaryResult = await auxiliary.finalize(factory);
494
- for (const event of auxiliaryResult.events) {
495
- yield event;
496
- }
497
-
498
- const finalResponse = buildResponse(
499
- request,
500
- {
501
- output,
502
- replay,
503
- stopReason,
504
- usage: auxiliaryResult.usage,
505
- billing: auxiliaryResult.billing,
506
- auxiliary: auxiliaryResult.auxiliary,
507
- warnings: auxiliaryResult.warnings,
508
- metadataSources: auxiliaryResult.metadataSources,
509
- rawResponseId,
510
- },
511
- factory,
512
- );
513
- yield factory.responseCompleted({
514
- replay: finalResponse.replay,
515
- stopReason: finalResponse.stopReason,
516
- trace: finalResponse.backend,
517
- usage: finalResponse.usage,
518
- billing: finalResponse.billing,
519
- auxiliary: finalResponse.auxiliary,
520
- warnings: finalResponse.warnings,
463
+ yield* this.emitStreamCompleted(factory, request, auxiliary, {
464
+ output,
465
+ replay,
466
+ stopReason,
467
+ rawResponseId,
521
468
  });
522
- };
523
-
524
- try {
525
- while (true) {
526
- const readResult = await reader.read().catch((err: unknown) => {
527
- throw new AIStreamError(
528
- `Failed to read response stream: ${err instanceof Error ? err.message : String(err)}`,
529
- "STREAM_ERROR",
530
- );
531
- });
532
- const { done, value } = readResult;
533
- const { items: chunks, malformed: malformedEvents } = done ? parser.flush() : parser.feed(value as Uint8Array);
534
-
535
- const malformedWarning = emitMalformedStreamWarning(factory, {
536
- count: malformedEvents,
537
- providerLabel: "Chat Completions",
538
- transportLabel: "SSE event(s)",
539
- });
540
- if (malformedWarning) {
541
- yield malformedWarning;
469
+ }.bind(this);
470
+
471
+ for await (const batch of iterateProviderStreamBatches({
472
+ reader,
473
+ parser,
474
+ factory,
475
+ providerLabel: "Chat Completions",
476
+ transportLabel: "SSE event(s)",
477
+ incompleteMessage: "Stream ended with an incomplete Chat Completions SSE frame",
478
+ })) {
479
+ for (const warning of batch.warnings) yield warning;
480
+
481
+ for (const chunk of batch.items) {
482
+ responseId = chunk.id;
483
+
484
+ if (chunk.usage) {
485
+ auxiliary.recordUsage(usageFromChatCompletions(chunk.usage), "final", chunk.usage);
542
486
  }
543
487
 
544
- for (const chunk of chunks) {
545
- responseId = chunk.id;
546
-
547
- // usage 可能在最终 chunk 中
548
- if (chunk.usage) {
549
- auxiliary.recordUsage(usageFromChatCompletions(chunk.usage), "final", chunk.usage);
488
+ for (const choice of chunk.choices) {
489
+ if (choice.index !== 0) {
490
+ if (!warnedNonZeroChoice) {
491
+ yield factory.responseWarning(
492
+ `Chat Completions returned choice index ${choice.index}; only the first choice (index 0) is supported. This choice is ignored.`,
493
+ "MULTIPLE_CHOICES_IGNORED",
494
+ );
495
+ warnedNonZeroChoice = true;
496
+ }
497
+ continue;
550
498
  }
551
499
 
552
- for (const choice of chunk.choices) {
553
- if (choice.index !== 0) {
554
- if (!warnedNonZeroChoice) {
555
- yield factory.responseWarning(
556
- `Chat Completions returned choice index ${choice.index}; only the first choice (index 0) is supported. This choice is ignored.`,
557
- "MULTIPLE_CHOICES_IGNORED",
558
- );
559
- warnedNonZeroChoice = true;
560
- }
561
- continue;
500
+ if (gate.completed) {
501
+ if (choice.finish_reason) {
502
+ yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
562
503
  }
504
+ continue;
505
+ }
563
506
 
564
- if (completedEmitted) {
565
- if (choice.finish_reason) {
566
- yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
567
- }
568
- continue;
569
- }
507
+ const delta = choice.delta;
508
+ const finishReason = choice.finish_reason;
509
+ const reasoningDeltas = extractReasoningDeltas(delta);
570
510
 
571
- const delta = choice.delta;
572
- const finishReason = choice.finish_reason;
573
- const reasoningDeltas = extractReasoningDeltas(delta);
574
-
575
- const ensureMessageStarted = (): void => {
576
- if (hasMessageStarted) return;
577
- currentMessageId = `msg-${chunk.id}`;
578
- hasMessageStarted = true;
579
- accumulatedContent = "";
580
- };
581
-
582
- // 处理 third-party reasoning delta
583
- if (reasoningDeltas.length > 0) {
584
- if (!hasReasoningStarted) {
585
- currentReasoningId = `reason-${chunk.id}`;
586
- hasReasoningStarted = true;
587
- accumulatedReasoning = "";
588
- yield factory.reasoningStarted(currentReasoningId, "full");
589
- }
511
+ const ensureMessageStarted = (): void => {
512
+ if (hasMessageStarted) return;
513
+ currentMessageId = `msg-${chunk.id}`;
514
+ hasMessageStarted = true;
515
+ accumulatedContent = "";
516
+ };
590
517
 
591
- for (const reasoningDelta of reasoningDeltas) {
592
- accumulatedReasoning += reasoningDelta.text;
593
- reasoningByField.set(
594
- reasoningDelta.field,
595
- (reasoningByField.get(reasoningDelta.field) ?? "") + reasoningDelta.text,
596
- );
597
- yield factory.reasoningDelta(currentReasoningId, textBlock(reasoningDelta.text));
598
- }
518
+ if (reasoningDeltas.length > 0) {
519
+ if (!hasReasoningStarted) {
520
+ currentReasoningId = `reason-${chunk.id}`;
521
+ hasReasoningStarted = true;
522
+ accumulatedReasoning = "";
523
+ yield factory.reasoningStarted(currentReasoningId, "full");
599
524
  }
600
525
 
601
- // 处理 content delta
602
- if (delta.content) {
603
- if (!hasMessageStarted) {
604
- ensureMessageStarted();
605
- yield factory.messageStarted(currentMessageId);
606
- }
607
- accumulatedContent += delta.content;
608
- yield factory.messageDelta(currentMessageId, textBlock(delta.content));
526
+ for (const reasoningDelta of reasoningDeltas) {
527
+ accumulatedReasoning += reasoningDelta.text;
528
+ reasoningByField.set(
529
+ reasoningDelta.field,
530
+ (reasoningByField.get(reasoningDelta.field) ?? "") + reasoningDelta.text,
531
+ );
532
+ yield factory.reasoningDelta(currentReasoningId, textBlock(reasoningDelta.text));
609
533
  }
534
+ }
610
535
 
611
- // 处理 tool_calls delta
612
- if (delta.tool_calls) {
613
- if (!hasMessageStarted) {
614
- ensureMessageStarted();
615
- yield factory.messageStarted(currentMessageId);
616
- }
617
-
618
- for (const tc of delta.tool_calls) {
619
- const idx = tc.index;
620
-
621
- if (tc.id) {
622
- pendingToolCalls.set(idx, { id: tc.id, name: tc.function?.name ?? "", args: "" });
623
- yield factory.toolCallStarted(tc.id, tc.function?.name ?? "");
624
- }
536
+ if (delta.content) {
537
+ if (!hasMessageStarted) {
538
+ ensureMessageStarted();
539
+ yield factory.messageStarted(currentMessageId);
540
+ }
541
+ accumulatedContent += delta.content;
542
+ yield factory.messageDelta(currentMessageId, textBlock(delta.content));
543
+ }
625
544
 
626
- if (tc.function?.arguments) {
627
- const pending = pendingToolCalls.get(idx);
628
- if (pending) {
629
- pending.args += tc.function.arguments;
630
- yield factory.toolCallDelta(pending.id, { argumentsText: tc.function.arguments });
631
- }
632
- }
633
- }
545
+ if (delta.tool_calls) {
546
+ if (!hasMessageStarted) {
547
+ ensureMessageStarted();
548
+ yield factory.messageStarted(currentMessageId);
634
549
  }
635
550
 
636
- // 处理 function_call delta (legacy format)
637
- if (delta.function_call) {
638
- if (!hasMessageStarted) {
639
- ensureMessageStarted();
640
- yield factory.messageStarted(currentMessageId);
641
- }
551
+ for (const tc of delta.tool_calls) {
552
+ const idx = tc.index;
642
553
 
643
- if (delta.function_call.name) {
644
- const fcId = `fc-${chunk.id}-0`;
645
- pendingToolCalls.set(0, { id: fcId, name: delta.function_call.name, args: "" });
646
- yield factory.toolCallStarted(fcId, delta.function_call.name);
554
+ if (tc.id) {
555
+ pendingToolCalls.set(idx, { id: tc.id, name: tc.function?.name ?? "", args: "" });
556
+ yield factory.toolCallStarted(tc.id, tc.function?.name ?? "");
647
557
  }
648
- if (delta.function_call.arguments) {
649
- const pending = pendingToolCalls.get(0);
558
+
559
+ if (tc.function?.arguments) {
560
+ const pending = pendingToolCalls.get(idx);
650
561
  if (pending) {
651
- pending.args += delta.function_call.arguments;
652
- yield factory.toolCallDelta(pending.id, { argumentsText: delta.function_call.arguments });
562
+ pending.args += tc.function.arguments;
563
+ yield factory.toolCallDelta(pending.id, { argumentsText: tc.function.arguments });
653
564
  }
654
565
  }
655
566
  }
567
+ }
656
568
 
657
- // 处理 finish_reason
658
- if (finishReason && finishReason !== null) {
659
- const { events, assistantReplayMessage } = finalizePendingTurn();
660
- for (const event of events) {
661
- yield event;
662
- }
569
+ if (delta.function_call) {
570
+ if (!hasMessageStarted) {
571
+ ensureMessageStarted();
572
+ yield factory.messageStarted(currentMessageId);
573
+ }
663
574
 
664
- const stopReason = mapStopReason(finishReason);
665
- yield* emitCompleted(stopReason, assistantReplayMessage, chunk.id);
575
+ if (delta.function_call.name) {
576
+ const fcId = `fc-${chunk.id}-0`;
577
+ pendingToolCalls.set(0, { id: fcId, name: delta.function_call.name, args: "" });
578
+ yield factory.toolCallStarted(fcId, delta.function_call.name);
579
+ }
580
+ if (delta.function_call.arguments) {
581
+ const pending = pendingToolCalls.get(0);
582
+ if (pending) {
583
+ pending.args += delta.function_call.arguments;
584
+ yield factory.toolCallDelta(pending.id, { argumentsText: delta.function_call.arguments });
585
+ }
666
586
  }
667
587
  }
668
- }
669
588
 
670
- if (done) {
671
- streamDone = true;
672
- break;
589
+ if (finishReason && finishReason !== null) {
590
+ const { events, assistantReplayMessage } = finalizePendingTurn();
591
+ for (const event of events) yield event;
592
+ yield* emitCompleted(mapStopReason(finishReason), assistantReplayMessage, chunk.id);
593
+ }
673
594
  }
674
595
  }
675
- } finally {
676
- try {
677
- if (!streamDone) await reader.cancel().catch(() => undefined);
678
- } finally {
679
- reader.releaseLock();
680
- }
681
- }
682
-
683
- if (parser.getRemaining().trim().length > 0) {
684
- yield factory.responseWarning("Stream ended with an incomplete Chat Completions SSE frame", "STREAM_ERROR");
685
596
  }
686
597
 
687
- // 如果流结束时没有 finish_reason(断流),也尝试关闭
688
- if (!completedEmitted && (hasMessageStarted || hasReasoningStarted || pendingToolCalls.size > 0)) {
598
+ if (!gate.completed && (hasMessageStarted || hasReasoningStarted || pendingToolCalls.size > 0)) {
689
599
  yield factory.responseWarning("Stream ended without a finish_reason", "INCOMPLETE_STREAM");
690
-
691
600
  const { events, assistantReplayMessage } = finalizePendingTurn();
692
- for (const event of events) {
693
- yield event;
694
- }
695
-
601
+ for (const event of events) yield event;
696
602
  yield* emitCompleted(undefined, assistantReplayMessage, responseId);
697
603
  }
698
604
  }