@codehz/ai 0.2.4 → 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.2.4",
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,15 +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";
27
- import type { ProviderProfile } from "../helpers/index.js";
24
+ import {
25
+ NormalizedRequestMapper,
26
+ createChatCompletionsSseParser,
27
+ openProviderJsonStream,
28
+ iterateProviderStreamBatches,
29
+ createCompletionGate,
30
+ } from "../helpers/index.js";
28
31
 
29
- import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
32
+ import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn, StopReason } from "../index.js";
30
33
 
31
34
  // ── 类型 ──────────────────────────────────────────────────────
32
35
 
@@ -118,24 +121,7 @@ type ReasoningFieldName = "reasoning" | "reasoning_content";
118
121
 
119
122
  const REASONING_FIELDS: readonly ReasoningFieldName[] = ["reasoning_content", "reasoning"];
120
123
 
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
- };
137
-
138
- const mapper = new NormalizedRequestMapper(profile);
124
+ const mapper = new NormalizedRequestMapper("chat-completions");
139
125
 
140
126
  function extractReasoningText(value: unknown): string {
141
127
  if (typeof value === "string") return value;
@@ -251,7 +237,7 @@ function buildAssistantReplayMessage(params: {
251
237
 
252
238
  export class ChatCompletionsAdapter extends AdapterBase {
253
239
  readonly kind = "chat-completions" as const;
254
- readonly capabilities = profile.capabilities;
240
+ readonly isSyntheticStream = false;
255
241
 
256
242
  private apiKey: string;
257
243
  private baseUrl: string;
@@ -278,9 +264,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
278
264
  switch (item.type) {
279
265
  case "message": {
280
266
  const role = item.role;
281
- const text = contentBlocksToText(
282
- mapper.ensureTextBlocks(item.content, `input message (${item.role}) content`),
283
- );
267
+ const text = mapper.textFromBlocks(item.content, `input message (${item.role}) content`);
284
268
  messages.push({ role, content: text || null });
285
269
  break;
286
270
  }
@@ -303,12 +287,11 @@ export class ChatCompletionsAdapter extends AdapterBase {
303
287
  break;
304
288
  }
305
289
  case "tool_result": {
306
- mapper.assertToolResultOutcome(item.outcome);
307
290
  messages.push({
308
291
  role: "tool",
309
292
  tool_call_id: item.callId,
310
293
  name: item.toolName,
311
- content: contentBlocksToText(mapper.ensureTextBlocks(item.content, `tool_result ${item.callId} content`)),
294
+ content: mapper.textFromBlocks(item.content, `tool_result ${item.callId} content`),
312
295
  });
313
296
  break;
314
297
  }
@@ -317,13 +300,13 @@ export class ChatCompletionsAdapter extends AdapterBase {
317
300
  // Convert to a text message for best-effort
318
301
  messages.push({
319
302
  role: "assistant",
320
- content: contentBlocksToText(mapper.ensureTextBlocks(item.content, "reasoning content")),
303
+ content: mapper.textFromBlocks(item.content, "reasoning content"),
321
304
  });
322
305
  break;
323
306
  }
324
307
  case "opaque": {
325
308
  // Try to restore from opaque replay
326
- if (item.purpose !== "replay") break;
309
+ if (item.source !== "chat.completions" || item.purpose !== "replay") break;
327
310
  assertOpaqueReplayEnvelope(item.payload);
328
311
  const payload = item.payload as Record<string, unknown>;
329
312
  if (payload.role === "assistant" && typeof payload.content === "string") {
@@ -352,26 +335,23 @@ export class ChatCompletionsAdapter extends AdapterBase {
352
335
  n: 1,
353
336
  };
354
337
 
355
- if (request.tools && request.tools.length > 0) {
356
- body.tools = request.tools.map(
357
- (t): ChatTool => ({
358
- type: "function",
359
- function: {
360
- name: t.name,
361
- description: t.description,
362
- parameters: t.inputSchema as Record<string, unknown>,
363
- },
364
- }),
365
- );
366
- }
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
+ );
367
349
 
368
- if (request.toolChoice) {
369
- if (request.toolChoice === "auto") body.tool_choice = "auto";
370
- else if (request.toolChoice === "none") body.tool_choice = "none";
371
- else if (request.toolChoice.type === "tool") {
372
- body.tool_choice = { type: "function", function: { name: request.toolChoice.name } };
373
- }
374
- }
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
+ });
375
355
 
376
356
  if (request.temperature !== undefined) body.temperature = request.temperature;
377
357
  if (request.maxOutputTokens !== undefined) body.max_tokens = request.maxOutputTokens;
@@ -388,46 +368,21 @@ export class ChatCompletionsAdapter extends AdapterBase {
388
368
  request: NormalizedRequest,
389
369
  ): AsyncIterable<AIStreamEvent> {
390
370
  const auxiliary = this.createAuxiliaryState(request);
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
- signal: request.signal,
402
- });
403
- } catch (err) {
404
- throw new AIProviderError(err instanceof Error ? err.message : String(err), "PROVIDER_ERROR");
405
- }
406
-
407
- if (!response.ok) {
408
- const errorBody = await response.text().catch(() => "");
409
- throw providerHttpError(response.status, errorBody);
410
- }
411
-
412
- const reader = response.body?.getReader();
413
- if (!reader) {
414
- throw new AIStreamError("Response body is not readable", "STREAM_ERROR");
415
- }
416
-
417
- const parser = new IncrementalStreamParser<ChatChunk>(splitLines, (item: string) => {
418
- const trimmed = item.trim();
419
- if (!trimmed.startsWith("data: ")) return { status: "ignored" };
420
- const data = trimmed.slice(6).trim();
421
- if (data === "[DONE]") return { status: "ignored" };
422
- try {
423
- return { status: "parsed", value: JSON.parse(data) as ChatChunk };
424
- } catch {
425
- return { status: "malformed" };
426
- }
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,
427
382
  });
428
383
 
384
+ const parser = createChatCompletionsSseParser<ChatChunk>();
429
385
  const output: OutputItem[] = [];
430
- let streamDone = false;
431
386
 
432
387
  // 累积状态 — 支持多 choice,此处只取 index 0
433
388
  let responseId: string | undefined;
@@ -437,9 +392,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
437
392
  let currentReasoningId = "";
438
393
  let hasMessageStarted = false;
439
394
  let hasReasoningStarted = false;
440
- let completedEmitted = false;
441
395
  let warnedNonZeroChoice = false;
442
- const buildResponse = this.buildResponse.bind(this);
443
396
 
444
397
  // tool_calls 累积: tool call index → { id, name, args }
445
398
  const pendingToolCalls = new Map<number, PendingToolCall>();
@@ -487,19 +440,17 @@ export class ChatCompletionsAdapter extends AdapterBase {
487
440
  };
488
441
 
489
442
  const emitCompleted = async function* (
490
- stopReason: import("../index.js").StopReason | undefined,
443
+ this: ChatCompletionsAdapter,
444
+ stopReason: StopReason | undefined,
491
445
  assistantReplayMessage: ChatMessage | null,
492
446
  rawResponseId: string | undefined,
493
447
  ): AsyncIterable<AIStreamEvent> {
494
- if (completedEmitted) {
448
+ if (!gate.tryComplete()) {
495
449
  yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
496
450
  return;
497
451
  }
498
452
 
499
- completedEmitted = true;
500
-
501
453
  const replay = [...replayFromOutput(output)];
502
-
503
454
  if (assistantReplayMessage) {
504
455
  replay.push(
505
456
  opaqueItem("chat.completions", "replay", {
@@ -509,209 +460,145 @@ export class ChatCompletionsAdapter extends AdapterBase {
509
460
  );
510
461
  }
511
462
 
512
- const auxiliaryResult = await auxiliary.finalize(factory);
513
- for (const event of auxiliaryResult.events) {
514
- yield event;
515
- }
516
-
517
- const finalResponse = buildResponse(
518
- request,
519
- {
520
- output,
521
- replay,
522
- stopReason,
523
- usage: auxiliaryResult.usage,
524
- billing: auxiliaryResult.billing,
525
- auxiliary: auxiliaryResult.auxiliary,
526
- warnings: auxiliaryResult.warnings,
527
- metadataSources: auxiliaryResult.metadataSources,
528
- rawResponseId,
529
- },
530
- factory,
531
- );
532
- yield factory.responseCompleted({
533
- replay: finalResponse.replay,
534
- stopReason: finalResponse.stopReason,
535
- trace: finalResponse.backend,
536
- usage: finalResponse.usage,
537
- billing: finalResponse.billing,
538
- auxiliary: finalResponse.auxiliary,
539
- warnings: finalResponse.warnings,
463
+ yield* this.emitStreamCompleted(factory, request, auxiliary, {
464
+ output,
465
+ replay,
466
+ stopReason,
467
+ rawResponseId,
540
468
  });
541
- };
542
-
543
- try {
544
- while (true) {
545
- const readResult = await reader.read().catch((err: unknown) => {
546
- throw new AIStreamError(
547
- `Failed to read response stream: ${err instanceof Error ? err.message : String(err)}`,
548
- "STREAM_ERROR",
549
- );
550
- });
551
- const { done, value } = readResult;
552
- const { items: chunks, malformed: malformedEvents } = done ? parser.flush() : parser.feed(value as Uint8Array);
553
-
554
- const malformedWarning = emitMalformedStreamWarning(factory, {
555
- count: malformedEvents,
556
- providerLabel: "Chat Completions",
557
- transportLabel: "SSE event(s)",
558
- });
559
- if (malformedWarning) {
560
- 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);
561
486
  }
562
487
 
563
- for (const chunk of chunks) {
564
- responseId = chunk.id;
565
-
566
- // usage 可能在最终 chunk 中
567
- if (chunk.usage) {
568
- 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;
569
498
  }
570
499
 
571
- for (const choice of chunk.choices) {
572
- if (choice.index !== 0) {
573
- if (!warnedNonZeroChoice) {
574
- yield factory.responseWarning(
575
- `Chat Completions returned choice index ${choice.index}; only the first choice (index 0) is supported. This choice is ignored.`,
576
- "MULTIPLE_CHOICES_IGNORED",
577
- );
578
- warnedNonZeroChoice = true;
579
- }
580
- continue;
500
+ if (gate.completed) {
501
+ if (choice.finish_reason) {
502
+ yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
581
503
  }
504
+ continue;
505
+ }
582
506
 
583
- if (completedEmitted) {
584
- if (choice.finish_reason) {
585
- yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
586
- }
587
- continue;
588
- }
507
+ const delta = choice.delta;
508
+ const finishReason = choice.finish_reason;
509
+ const reasoningDeltas = extractReasoningDeltas(delta);
589
510
 
590
- const delta = choice.delta;
591
- const finishReason = choice.finish_reason;
592
- const reasoningDeltas = extractReasoningDeltas(delta);
593
-
594
- const ensureMessageStarted = (): void => {
595
- if (hasMessageStarted) return;
596
- currentMessageId = `msg-${chunk.id}`;
597
- hasMessageStarted = true;
598
- accumulatedContent = "";
599
- };
600
-
601
- // 处理 third-party reasoning delta
602
- if (reasoningDeltas.length > 0) {
603
- if (!hasReasoningStarted) {
604
- currentReasoningId = `reason-${chunk.id}`;
605
- hasReasoningStarted = true;
606
- accumulatedReasoning = "";
607
- yield factory.reasoningStarted(currentReasoningId, "full");
608
- }
511
+ const ensureMessageStarted = (): void => {
512
+ if (hasMessageStarted) return;
513
+ currentMessageId = `msg-${chunk.id}`;
514
+ hasMessageStarted = true;
515
+ accumulatedContent = "";
516
+ };
609
517
 
610
- for (const reasoningDelta of reasoningDeltas) {
611
- accumulatedReasoning += reasoningDelta.text;
612
- reasoningByField.set(
613
- reasoningDelta.field,
614
- (reasoningByField.get(reasoningDelta.field) ?? "") + reasoningDelta.text,
615
- );
616
- yield factory.reasoningDelta(currentReasoningId, textBlock(reasoningDelta.text));
617
- }
518
+ if (reasoningDeltas.length > 0) {
519
+ if (!hasReasoningStarted) {
520
+ currentReasoningId = `reason-${chunk.id}`;
521
+ hasReasoningStarted = true;
522
+ accumulatedReasoning = "";
523
+ yield factory.reasoningStarted(currentReasoningId, "full");
618
524
  }
619
525
 
620
- // 处理 content delta
621
- if (delta.content) {
622
- if (!hasMessageStarted) {
623
- ensureMessageStarted();
624
- yield factory.messageStarted(currentMessageId);
625
- }
626
- accumulatedContent += delta.content;
627
- 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));
628
533
  }
534
+ }
629
535
 
630
- // 处理 tool_calls delta
631
- if (delta.tool_calls) {
632
- if (!hasMessageStarted) {
633
- ensureMessageStarted();
634
- yield factory.messageStarted(currentMessageId);
635
- }
636
-
637
- for (const tc of delta.tool_calls) {
638
- const idx = tc.index;
639
-
640
- if (tc.id) {
641
- pendingToolCalls.set(idx, { id: tc.id, name: tc.function?.name ?? "", args: "" });
642
- yield factory.toolCallStarted(tc.id, tc.function?.name ?? "");
643
- }
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
+ }
644
544
 
645
- if (tc.function?.arguments) {
646
- const pending = pendingToolCalls.get(idx);
647
- if (pending) {
648
- pending.args += tc.function.arguments;
649
- yield factory.toolCallDelta(pending.id, { argumentsText: tc.function.arguments });
650
- }
651
- }
652
- }
545
+ if (delta.tool_calls) {
546
+ if (!hasMessageStarted) {
547
+ ensureMessageStarted();
548
+ yield factory.messageStarted(currentMessageId);
653
549
  }
654
550
 
655
- // 处理 function_call delta (legacy format)
656
- if (delta.function_call) {
657
- if (!hasMessageStarted) {
658
- ensureMessageStarted();
659
- yield factory.messageStarted(currentMessageId);
660
- }
551
+ for (const tc of delta.tool_calls) {
552
+ const idx = tc.index;
661
553
 
662
- if (delta.function_call.name) {
663
- const fcId = `fc-${chunk.id}-0`;
664
- pendingToolCalls.set(0, { id: fcId, name: delta.function_call.name, args: "" });
665
- 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 ?? "");
666
557
  }
667
- if (delta.function_call.arguments) {
668
- const pending = pendingToolCalls.get(0);
558
+
559
+ if (tc.function?.arguments) {
560
+ const pending = pendingToolCalls.get(idx);
669
561
  if (pending) {
670
- pending.args += delta.function_call.arguments;
671
- 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 });
672
564
  }
673
565
  }
674
566
  }
567
+ }
675
568
 
676
- // 处理 finish_reason
677
- if (finishReason && finishReason !== null) {
678
- const { events, assistantReplayMessage } = finalizePendingTurn();
679
- for (const event of events) {
680
- yield event;
681
- }
569
+ if (delta.function_call) {
570
+ if (!hasMessageStarted) {
571
+ ensureMessageStarted();
572
+ yield factory.messageStarted(currentMessageId);
573
+ }
682
574
 
683
- const stopReason = mapStopReason(finishReason);
684
- 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
+ }
685
586
  }
686
587
  }
687
- }
688
588
 
689
- if (done) {
690
- streamDone = true;
691
- 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
+ }
692
594
  }
693
595
  }
694
- } finally {
695
- try {
696
- if (!streamDone) await reader.cancel().catch(() => undefined);
697
- } finally {
698
- reader.releaseLock();
699
- }
700
- }
701
-
702
- if (parser.getRemaining().trim().length > 0) {
703
- yield factory.responseWarning("Stream ended with an incomplete Chat Completions SSE frame", "STREAM_ERROR");
704
596
  }
705
597
 
706
- // 如果流结束时没有 finish_reason(断流),也尝试关闭
707
- if (!completedEmitted && (hasMessageStarted || hasReasoningStarted || pendingToolCalls.size > 0)) {
598
+ if (!gate.completed && (hasMessageStarted || hasReasoningStarted || pendingToolCalls.size > 0)) {
708
599
  yield factory.responseWarning("Stream ended without a finish_reason", "INCOMPLETE_STREAM");
709
-
710
600
  const { events, assistantReplayMessage } = finalizePendingTurn();
711
- for (const event of events) {
712
- yield event;
713
- }
714
-
601
+ for (const event of events) yield event;
715
602
  yield* emitCompleted(undefined, assistantReplayMessage, responseId);
716
603
  }
717
604
  }