@gajae-code/ai 0.11.0 → 0.11.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.
@@ -417,6 +417,62 @@ function mapStopReasonOut(reason: StopReason): "end_turn" | "max_tokens" | "tool
417
417
  }
418
418
  }
419
419
 
420
+ /**
421
+ * True when `signature` is one of OUR serialized OpenAI Responses reasoning-item
422
+ * envelopes (produced by the Responses/Codex decoders as
423
+ * `JSON.stringify(reasoningItem)` with `type: "reasoning"`). Such an envelope is
424
+ * NOT a valid Anthropic thinking signature and may embed raw chain-of-thought in
425
+ * `content[]`, so it must never be forwarded on Anthropic egress. Genuine opaque
426
+ * Anthropic signatures and any non-envelope string return false (fail safe).
427
+ */
428
+ function isSerializedResponsesReasoningItem(signature: string): boolean {
429
+ try {
430
+ const parsed: unknown = JSON.parse(signature);
431
+ return (
432
+ typeof parsed === "object" &&
433
+ parsed !== null &&
434
+ !Array.isArray(parsed) &&
435
+ (parsed as { type?: unknown }).type === "reasoning"
436
+ );
437
+ } catch {
438
+ return false;
439
+ }
440
+ }
441
+
442
+ /**
443
+ * A thinking-block signature safe to emit on Anthropic egress: cross-protocol
444
+ * serialized Responses reasoning envelopes are omitted (they can carry raw CoT);
445
+ * genuine opaque Anthropic signatures round-trip unchanged.
446
+ */
447
+ function safeThinkingSignature(signature: string | undefined): string | undefined {
448
+ if (!signature) return undefined;
449
+ if (isSerializedResponsesReasoningItem(signature)) return undefined;
450
+ return signature;
451
+ }
452
+
453
+ function isResponsesFamilyApi(api: AssistantMessage["api"]): boolean {
454
+ return api === "openai-responses" || api === "openai-codex-responses";
455
+ }
456
+
457
+ function safeThinkingText(content: ThinkingContent, api: AssistantMessage["api"]): string | undefined {
458
+ if (isResponsesFamilyApi(api) && content.provenance === undefined) return undefined;
459
+ if (content.provenance === "raw") return undefined;
460
+ if (content.provenance === "mixed") return content.summaryText;
461
+ if (content.provenance === "summary") return content.summaryText ?? content.thinking;
462
+ return content.thinking;
463
+ }
464
+
465
+ function hasRawOrMixedThinking(partial: AssistantMessage, contentIndex: number): boolean {
466
+ const content = partial.content[contentIndex];
467
+ return content?.type === "thinking" && (content.provenance === "raw" || content.provenance === "mixed");
468
+ }
469
+
470
+ /** Responses-family reasoning is untrusted until output_item.done assigns provenance. */
471
+ function hasUnfinalizedResponsesThinking(partial: AssistantMessage, contentIndex: number): boolean {
472
+ const content = partial.content[contentIndex];
473
+ return content?.type !== "thinking" || (isResponsesFamilyApi(partial.api) && content.provenance === undefined);
474
+ }
475
+
420
476
  function encodeContentBlocks(message: AssistantMessage): Record<string, unknown>[] {
421
477
  const blocks: Record<string, unknown>[] = [];
422
478
  for (const c of message.content) {
@@ -425,8 +481,11 @@ function encodeContentBlocks(message: AssistantMessage): Record<string, unknown>
425
481
  blocks.push({ type: "text", text: c.text });
426
482
  break;
427
483
  case "thinking": {
428
- const b: Record<string, unknown> = { type: "thinking", thinking: c.thinking };
429
- if (c.thinkingSignature) b.signature = c.thinkingSignature;
484
+ const thinking = safeThinkingText(c, message.api);
485
+ if (thinking === undefined) break;
486
+ const b: Record<string, unknown> = { type: "thinking", thinking };
487
+ const sig = safeThinkingSignature(c.thinkingSignature);
488
+ if (sig) b.signature = sig;
430
489
  blocks.push(b);
431
490
  break;
432
491
  }
@@ -495,6 +554,12 @@ export function encodeStream(
495
554
  const messageId = newMessageId();
496
555
  let started = false;
497
556
  const open = new Map<number, OpenBlock>();
557
+ // contentIndexes that already streamed a reasoning summary delta, so a
558
+ // final-only reasoning_summary_end does not duplicate streamed summary text.
559
+ const summaryDeltaSeen = new Set<number>();
560
+ // Responses assigns reasoning provenance only at output_item.done. Keep its
561
+ // pre-classification bytes out of this public compatibility stream.
562
+ const pendingThinkingDeltas = new Map<number, string[]>();
498
563
 
499
564
  const ensureStart = (partial: AssistantMessage) => {
500
565
  if (started) return;
@@ -524,6 +589,30 @@ export function encodeStream(
524
589
  open.delete(index);
525
590
  };
526
591
 
592
+ const openThinking = (partial: AssistantMessage, index: number) => {
593
+ if (open.has(index)) return;
594
+ ensureStart(partial);
595
+ open.set(index, { index, kind: "thinking" });
596
+ controller.enqueue(
597
+ sseFrame("content_block_start", {
598
+ type: "content_block_start",
599
+ index,
600
+ content_block: { type: "thinking", thinking: "" },
601
+ }),
602
+ );
603
+ };
604
+
605
+ const writeThinkingDelta = (index: number, thinking: string) => {
606
+ if (thinking.length === 0) return;
607
+ controller.enqueue(
608
+ sseFrame("content_block_delta", {
609
+ type: "content_block_delta",
610
+ index,
611
+ delta: { type: "thinking_delta", thinking },
612
+ }),
613
+ );
614
+ };
615
+
527
616
  try {
528
617
  for await (const ev of events) {
529
618
  switch (ev.type) {
@@ -555,18 +644,73 @@ export function encodeStream(
555
644
  closeBlock(ev.contentIndex);
556
645
  break;
557
646
  case "thinking_start": {
558
- ensureStart(ev.partial);
559
- open.set(ev.contentIndex, { index: ev.contentIndex, kind: "thinking" });
560
- controller.enqueue(
561
- sseFrame("content_block_start", {
562
- type: "content_block_start",
563
- index: ev.contentIndex,
564
- content_block: { type: "thinking", thinking: "" },
565
- }),
566
- );
647
+ if (hasRawOrMixedThinking(ev.partial, ev.contentIndex)) break;
648
+ if (hasUnfinalizedResponsesThinking(ev.partial, ev.contentIndex)) {
649
+ pendingThinkingDeltas.set(ev.contentIndex, []);
650
+ break;
651
+ }
652
+ openThinking(ev.partial, ev.contentIndex);
653
+ break;
654
+ }
655
+ case "thinking_delta": {
656
+ if (hasRawOrMixedThinking(ev.partial, ev.contentIndex)) break;
657
+ if (hasUnfinalizedResponsesThinking(ev.partial, ev.contentIndex)) {
658
+ const deltas = pendingThinkingDeltas.get(ev.contentIndex) ?? [];
659
+ deltas.push(ev.delta);
660
+ pendingThinkingDeltas.set(ev.contentIndex, deltas);
661
+ break;
662
+ }
663
+ writeThinkingDelta(ev.contentIndex, ev.delta);
664
+ break;
665
+ }
666
+ case "thinking_end": {
667
+ const unfinalized = hasUnfinalizedResponsesThinking(ev.partial, ev.contentIndex);
668
+ const rawOrMixed = hasRawOrMixedThinking(ev.partial, ev.contentIndex);
669
+ const pending = pendingThinkingDeltas.get(ev.contentIndex);
670
+ pendingThinkingDeltas.delete(ev.contentIndex);
671
+ if (rawOrMixed || unfinalized) {
672
+ closeBlock(ev.contentIndex);
673
+ break;
674
+ }
675
+ if (pending) {
676
+ openThinking(ev.partial, ev.contentIndex);
677
+ for (const delta of pending) writeThinkingDelta(ev.contentIndex, delta);
678
+ }
679
+ const c = ev.partial.content[ev.contentIndex];
680
+ const sig = c?.type === "thinking" ? safeThinkingSignature(c.thinkingSignature) : undefined;
681
+ if (sig) {
682
+ controller.enqueue(
683
+ sseFrame("content_block_delta", {
684
+ type: "content_block_delta",
685
+ index: ev.contentIndex,
686
+ delta: { type: "signature_delta", signature: sig },
687
+ }),
688
+ );
689
+ }
690
+ closeBlock(ev.contentIndex);
691
+ break;
692
+ }
693
+ case "reasoning_summary_start": {
694
+ // Provider-displayable summary reasoning surfaces as this format's
695
+ // native thinking channel. Open the thinking block if a thinking_start
696
+ // did not already (summary-only streams emit no thinking_start).
697
+ if (!open.has(ev.contentIndex)) {
698
+ ensureStart(ev.partial);
699
+ open.set(ev.contentIndex, { index: ev.contentIndex, kind: "thinking" });
700
+ controller.enqueue(
701
+ sseFrame("content_block_start", {
702
+ type: "content_block_start",
703
+ index: ev.contentIndex,
704
+ content_block: { type: "thinking", thinking: "" },
705
+ }),
706
+ );
707
+ }
567
708
  break;
568
709
  }
569
- case "thinking_delta":
710
+ case "reasoning_summary_delta":
711
+ // Only a non-whitespace delta counts as a delivered summary; a bare
712
+ // separator ("\n\n") must not suppress a later final-only end content.
713
+ if (ev.delta.trim().length > 0) summaryDeltaSeen.add(ev.contentIndex);
570
714
  controller.enqueue(
571
715
  sseFrame("content_block_delta", {
572
716
  type: "content_block_delta",
@@ -575,18 +719,31 @@ export function encodeStream(
575
719
  }),
576
720
  );
577
721
  break;
578
- case "thinking_end": {
579
- const c = ev.partial.content[ev.contentIndex];
580
- if (c?.type === "thinking" && c.thinkingSignature) {
722
+ case "reasoning_summary_end": {
723
+ // Final-only summary: text arrives only on the end event with no prior
724
+ // deltas. Ensure the thinking block is open, then surface the content as
725
+ // a thinking_delta (skip when deltas already streamed to avoid dup). The
726
+ // thinking block is closed by the subsequent thinking_end.
727
+ if (ev.content.length > 0 && !summaryDeltaSeen.has(ev.contentIndex)) {
728
+ if (!open.has(ev.contentIndex)) {
729
+ ensureStart(ev.partial);
730
+ open.set(ev.contentIndex, { index: ev.contentIndex, kind: "thinking" });
731
+ controller.enqueue(
732
+ sseFrame("content_block_start", {
733
+ type: "content_block_start",
734
+ index: ev.contentIndex,
735
+ content_block: { type: "thinking", thinking: "" },
736
+ }),
737
+ );
738
+ }
581
739
  controller.enqueue(
582
740
  sseFrame("content_block_delta", {
583
741
  type: "content_block_delta",
584
742
  index: ev.contentIndex,
585
- delta: { type: "signature_delta", signature: c.thinkingSignature },
743
+ delta: { type: "thinking_delta", thinking: ev.content },
586
744
  }),
587
745
  );
588
746
  }
589
- closeBlock(ev.contentIndex);
590
747
  break;
591
748
  }
592
749
  case "toolcall_start": {
@@ -621,6 +778,7 @@ export function encodeStream(
621
778
  break;
622
779
  case "done": {
623
780
  for (const idx of [...open.keys()]) closeBlock(idx);
781
+ pendingThinkingDeltas.clear();
624
782
  controller.enqueue(
625
783
  sseFrame("message_delta", {
626
784
  type: "message_delta",
@@ -636,6 +794,7 @@ export function encodeStream(
636
794
  }
637
795
  case "error": {
638
796
  const msg = ev.error.errorMessage ?? "stream error";
797
+ pendingThinkingDeltas.clear();
639
798
  controller.enqueue(
640
799
  sseFrame("error", { type: "error", error: { type: "api_error", message: msg } }),
641
800
  );
@@ -645,10 +804,12 @@ export function encodeStream(
645
804
  }
646
805
  }
647
806
  // stream ended without explicit done; close gracefully
807
+ pendingThinkingDeltas.clear();
648
808
  for (const idx of [...open.keys()]) closeBlock(idx);
649
809
  controller.enqueue(sseFrame("message_stop", { type: "message_stop" }));
650
810
  controller.close();
651
811
  } catch (err) {
812
+ pendingThinkingDeltas.clear();
652
813
  controller.enqueue(
653
814
  sseFrame("error", {
654
815
  type: "error",