@gajae-code/ai 0.18.10 → 0.18.12

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +1 -1
  3. package/dist/types/auth-broker/wire-schemas.d.ts +30 -0
  4. package/dist/types/auth-storage.d.ts +80 -8
  5. package/dist/types/bedrock-claude-cache-policy.d.ts +1 -1
  6. package/dist/types/model-cache.d.ts +6 -4
  7. package/dist/types/model-manager.d.ts +2 -0
  8. package/dist/types/openai-completions-compat.d.ts +2 -1
  9. package/dist/types/providers/anthropic.d.ts +10 -0
  10. package/dist/types/providers/aws-credential-config.d.ts +5 -0
  11. package/dist/types/providers/google-auth.d.ts +7 -0
  12. package/dist/types/providers/google-gemini-headers.d.ts +10 -5
  13. package/dist/types/providers/kiro-api-key.d.ts +5 -0
  14. package/dist/types/providers/openai-chat-server-schema.d.ts +2 -1
  15. package/dist/types/providers/openai-completions.d.ts +1 -1
  16. package/dist/types/providers/openai-responses-shared.d.ts +2 -1
  17. package/dist/types/stream.d.ts +33 -1
  18. package/dist/types/types.d.ts +26 -3
  19. package/dist/types/usage.d.ts +3 -0
  20. package/dist/types/utils/discovery/antigravity.d.ts +11 -0
  21. package/dist/types/utils/idle-iterator.d.ts +3 -2
  22. package/dist/types/utils/oauth/types.d.ts +9 -0
  23. package/package.json +4 -4
  24. package/src/auth-broker/remote-store.ts +62 -11
  25. package/src/auth-broker/wire-schemas.ts +2 -0
  26. package/src/auth-gateway/server.ts +3 -2
  27. package/src/auth-storage.ts +647 -194
  28. package/src/bedrock-claude-cache-policy.d.ts +1 -1
  29. package/src/bedrock-claude-cache-policy.ts +2 -2
  30. package/src/model-cache.ts +45 -10
  31. package/src/model-manager.ts +18 -6
  32. package/src/model-thinking.ts +37 -7
  33. package/src/models.json +2931 -1670
  34. package/src/openai-completions-compat.d.ts +2 -1
  35. package/src/openai-completions-compat.ts +5 -0
  36. package/src/provider-models/google.ts +7 -1
  37. package/src/provider-models/openai-compat.ts +26 -0
  38. package/src/providers/amazon-bedrock.ts +20 -11
  39. package/src/providers/anthropic.d.ts +10 -0
  40. package/src/providers/anthropic.ts +139 -4
  41. package/src/providers/aws-credential-config.d.ts +5 -0
  42. package/src/providers/aws-credential-config.ts +29 -1
  43. package/src/providers/google-auth.d.ts +7 -0
  44. package/src/providers/google-auth.ts +165 -4
  45. package/src/providers/google-gemini-headers.d.ts +10 -5
  46. package/src/providers/google-gemini-headers.ts +11 -6
  47. package/src/providers/google-vertex.ts +2 -4
  48. package/src/providers/kiro-api-key.d.ts +5 -0
  49. package/src/providers/kiro-api-key.ts +379 -68
  50. package/src/providers/ollama.ts +19 -9
  51. package/src/providers/openai-anthropic-shim.ts +1 -0
  52. package/src/providers/openai-chat-server-schema.ts +1 -1
  53. package/src/providers/openai-chat-server.ts +14 -2
  54. package/src/providers/openai-codex-responses.ts +21 -14
  55. package/src/providers/openai-completions.d.ts +1 -1
  56. package/src/providers/openai-completions.ts +65 -4
  57. package/src/providers/openai-responses-server.ts +10 -1
  58. package/src/providers/openai-responses-shared.d.ts +2 -1
  59. package/src/providers/openai-responses-shared.ts +42 -1
  60. package/src/providers/openai-responses.ts +51 -10
  61. package/src/providers/pi-native-server.ts +1 -1
  62. package/src/stream.d.ts +33 -1
  63. package/src/stream.ts +107 -13
  64. package/src/types.d.ts +26 -3
  65. package/src/types.ts +55 -2
  66. package/src/usage/claude.ts +4 -2
  67. package/src/usage.ts +3 -0
  68. package/src/utils/discovery/antigravity.ts +144 -2
  69. package/src/utils/idle-iterator.d.ts +3 -2
  70. package/src/utils/idle-iterator.ts +30 -10
  71. package/src/utils/oauth/anthropic.ts +23 -22
  72. package/src/utils/oauth/api-key-validation.ts +34 -11
  73. package/src/utils/oauth/types.d.ts +9 -0
  74. package/src/utils/oauth/types.ts +9 -0
  75. package/src/utils/parse-bind.d.ts +35 -0
@@ -444,6 +444,7 @@ type KiroStreamEvent =
444
444
  | { type: "toolUseInput"; data: { input: string } }
445
445
  | { type: "toolUseStop"; data: { stop: boolean } }
446
446
  | { type: "usage"; data: { inputTokens?: number; outputTokens?: number } }
447
+ | { type: "completion"; data: { stopReason: "COMPLETED" | "TOOL_USE" } }
447
448
  | {
448
449
  type: "refusal";
449
450
  data: { stopReason?: string; stopDetails?: { refusal?: { category?: string; explanation?: string } } };
@@ -482,6 +483,188 @@ function findJsonEnd(text: string, start: number): number {
482
483
  return -1;
483
484
  }
484
485
 
486
+ type JsonPrefixResult = "complete" | "incomplete" | "invalid";
487
+
488
+ /** Return whether an unfinished object is still a valid JSON prefix. */
489
+ function isValidJsonPrefix(text: string): boolean {
490
+ let position = 0;
491
+ const maxDepth = 128;
492
+
493
+ function skipWhitespace(): void {
494
+ while (position < text.length) {
495
+ const char = text[position];
496
+ if (char !== " " && char !== "\t" && char !== "\n" && char !== "\r") break;
497
+ position++;
498
+ }
499
+ }
500
+
501
+ function isDigit(char: string | undefined): boolean {
502
+ return char !== undefined && char >= "0" && char <= "9";
503
+ }
504
+
505
+ function isHexDigit(char: string | undefined): boolean {
506
+ return (
507
+ char !== undefined &&
508
+ ((char >= "0" && char <= "9") || (char >= "a" && char <= "f") || (char >= "A" && char <= "F"))
509
+ );
510
+ }
511
+
512
+ function parseString(): JsonPrefixResult {
513
+ if (text[position] !== '"') return "invalid";
514
+ position++;
515
+ while (position < text.length) {
516
+ const char = text[position];
517
+ if (char === '"') {
518
+ position++;
519
+ return "complete";
520
+ }
521
+ if (char === "\\") {
522
+ position++;
523
+ if (position >= text.length) return "incomplete";
524
+ const escaped = text[position];
525
+ if (escaped === "u") {
526
+ position++;
527
+ for (let i = 0; i < 4; i++) {
528
+ if (position >= text.length) return "incomplete";
529
+ if (!isHexDigit(text[position])) return "invalid";
530
+ position++;
531
+ }
532
+ continue;
533
+ }
534
+ if (escaped !== '"' && escaped !== "\\" && escaped !== "/" && !"bfnrt".includes(escaped ?? "")) {
535
+ return "invalid";
536
+ }
537
+ position++;
538
+ continue;
539
+ }
540
+ if (char !== undefined && char.charCodeAt(0) < 0x20) return "invalid";
541
+ position++;
542
+ }
543
+ return "incomplete";
544
+ }
545
+
546
+ function parseLiteral(literal: string): JsonPrefixResult {
547
+ for (const char of literal) {
548
+ if (position >= text.length) return "incomplete";
549
+ if (text[position] !== char) return "invalid";
550
+ position++;
551
+ }
552
+ return "complete";
553
+ }
554
+
555
+ function parseNumber(): JsonPrefixResult {
556
+ if (text[position] === "-") {
557
+ position++;
558
+ if (position >= text.length) return "incomplete";
559
+ }
560
+
561
+ if (text[position] === "0") {
562
+ position++;
563
+ if (isDigit(text[position])) return "invalid";
564
+ } else if (isDigit(text[position]) && text[position] !== "0") {
565
+ while (isDigit(text[position])) position++;
566
+ } else {
567
+ return "invalid";
568
+ }
569
+
570
+ if (text[position] === ".") {
571
+ position++;
572
+ if (position >= text.length) return "incomplete";
573
+ if (!isDigit(text[position])) return "invalid";
574
+ while (isDigit(text[position])) position++;
575
+ }
576
+
577
+ if (text[position] === "e" || text[position] === "E") {
578
+ position++;
579
+ if (position >= text.length) return "incomplete";
580
+ if (text[position] === "+" || text[position] === "-") position++;
581
+ if (position >= text.length) return "incomplete";
582
+ if (!isDigit(text[position])) return "invalid";
583
+ while (isDigit(text[position])) position++;
584
+ }
585
+ return "complete";
586
+ }
587
+
588
+ function parseValue(depth: number): JsonPrefixResult {
589
+ if (depth > maxDepth) return "incomplete";
590
+ skipWhitespace();
591
+ if (position >= text.length) return "incomplete";
592
+
593
+ const char = text[position];
594
+ if (char === "{") return parseObject(depth);
595
+ if (char === "[") return parseArray(depth);
596
+ if (char === '"') return parseString();
597
+ if (char === "t") return parseLiteral("true");
598
+ if (char === "f") return parseLiteral("false");
599
+ if (char === "n") return parseLiteral("null");
600
+ if (char === "-" || isDigit(char)) return parseNumber();
601
+ return "invalid";
602
+ }
603
+
604
+ function parseObject(depth: number): JsonPrefixResult {
605
+ if (depth > maxDepth) return "incomplete";
606
+ if (text[position] !== "{") return "invalid";
607
+ position++;
608
+ skipWhitespace();
609
+ if (position >= text.length) return "incomplete";
610
+ if (text[position] === "}") {
611
+ position++;
612
+ return "complete";
613
+ }
614
+
615
+ while (position < text.length) {
616
+ const key = parseString();
617
+ if (key !== "complete") return key;
618
+ skipWhitespace();
619
+ if (position >= text.length) return "incomplete";
620
+ if (text[position] !== ":") return "invalid";
621
+ position++;
622
+
623
+ const value = parseValue(depth + 1);
624
+ if (value !== "complete") return value;
625
+ skipWhitespace();
626
+ if (position >= text.length) return "incomplete";
627
+ if (text[position] === "}") {
628
+ position++;
629
+ return "complete";
630
+ }
631
+ if (text[position] !== ",") return "invalid";
632
+ position++;
633
+ skipWhitespace();
634
+ }
635
+ return "incomplete";
636
+ }
637
+
638
+ function parseArray(depth: number): JsonPrefixResult {
639
+ if (depth > maxDepth) return "incomplete";
640
+ if (text[position] !== "[") return "invalid";
641
+ position++;
642
+ skipWhitespace();
643
+ if (position >= text.length) return "incomplete";
644
+ if (text[position] === "]") {
645
+ position++;
646
+ return "complete";
647
+ }
648
+
649
+ while (position < text.length) {
650
+ const value = parseValue(depth + 1);
651
+ if (value !== "complete") return value;
652
+ skipWhitespace();
653
+ if (position >= text.length) return "incomplete";
654
+ if (text[position] === "]") {
655
+ position++;
656
+ return "complete";
657
+ }
658
+ if (text[position] !== ",") return "invalid";
659
+ position++;
660
+ skipWhitespace();
661
+ }
662
+ return "incomplete";
663
+ }
664
+
665
+ return parseObject(0) !== "invalid";
666
+ }
667
+
485
668
  export function parseKiroApiEvents(buffer: string): { events: KiroStreamEvent[]; remaining: string } {
486
669
  const events: KiroStreamEvent[] = [];
487
670
  let pos = 0;
@@ -497,8 +680,14 @@ export function parseKiroApiEvents(buffer: string): { events: KiroStreamEvent[];
497
680
  const end = findJsonEnd(buffer, start);
498
681
  if (end < 0) {
499
682
  // Unclosed brace at 'start'. Could be incomplete JSON waiting for more data, or junk/corruption.
500
- // Resync by looking for valid JSON objects within MAX_RESCAN_DISTANCE that parse successfully.
501
683
  const strayBraceEnd = Math.min(start + MAX_RESCAN_DISTANCE, buffer.length);
684
+ const incompleteCandidate = buffer.slice(start, strayBraceEnd);
685
+ if (isValidJsonPrefix(incompleteCandidate)) {
686
+ // Preserve valid incomplete envelopes; a complete nested child is not evidence that the
687
+ // outer frame is junk until its own JSON prefix is syntactically impossible.
688
+ return { events, remaining: incompleteCandidate };
689
+ }
690
+ // The prefix is syntactically impossible. Resync only after establishing invalid framing.
502
691
  let resyncPos = start + 1;
503
692
  let found = false;
504
693
  while (resyncPos < strayBraceEnd) {
@@ -525,7 +714,7 @@ export function parseKiroApiEvents(buffer: string): { events: KiroStreamEvent[];
525
714
  if (!found) {
526
715
  // No valid JSON found within MAX_RESCAN_DISTANCE. Retain the stray brace and data up to the cap
527
716
  // for the next parse call. Enforce the MAX_RESCAN_DISTANCE cap to prevent unbounded buffering.
528
- return { events, remaining: buffer.slice(start, start + MAX_RESCAN_DISTANCE) };
717
+ return { events, remaining: buffer.slice(start, strayBraceEnd) };
529
718
  }
530
719
  // Loop will continue with the resynced pos
531
720
  continue;
@@ -567,12 +756,14 @@ export function parseKiroApiEvents(buffer: string): { events: KiroStreamEvent[];
567
756
 
568
757
  // Check for refusal after usage so that usage is processed first and recorded before refusal terminates
569
758
  // This check is now order-independent: stopDetails can appear at any position in the JSON object
759
+ // Also handle detail-free CONTENT_FILTERED as a refusal (no stopDetails.refusal needed)
570
760
  if (
571
- typeof parsed.stopDetails === "object" &&
572
- parsed.stopDetails !== null &&
573
- (parsed.stopDetails as Record<string, unknown>).refusal
761
+ (typeof parsed.stopDetails === "object" &&
762
+ parsed.stopDetails !== null &&
763
+ (parsed.stopDetails as Record<string, unknown>).refusal) ||
764
+ parsed.stopReason === "CONTENT_FILTERED"
574
765
  ) {
575
- // Emit refusal event if stopDetails contains actual refusal data
766
+ // Emit refusal event if stopDetails contains actual refusal data or stopReason is CONTENT_FILTERED
576
767
  events.push({
577
768
  type: "refusal",
578
769
  data: {
@@ -580,7 +771,6 @@ export function parseKiroApiEvents(buffer: string): { events: KiroStreamEvent[];
580
771
  stopDetails: parsed.stopDetails as { refusal?: { category?: string; explanation?: string } },
581
772
  },
582
773
  });
583
- // Normal terminal metadata (stopReason: "COMPLETED", etc.) without refusal data is ignored
584
774
  } else if (parsed.error || parsed.Error) {
585
775
  events.push({
586
776
  type: "error",
@@ -589,6 +779,14 @@ export function parseKiroApiEvents(buffer: string): { events: KiroStreamEvent[];
589
779
  message: (parsed.message || parsed.Message) as string | undefined,
590
780
  },
591
781
  });
782
+ } else if (parsed.stopReason === "COMPLETED" || parsed.stopReason === "TOOL_USE") {
783
+ // Only accepted successful terminal reasons confirm buffered text.
784
+ events.push({
785
+ type: "completion",
786
+ data: {
787
+ stopReason: parsed.stopReason,
788
+ },
789
+ });
592
790
  }
593
791
  } catch {
594
792
  // JSON parsing failed for a balanced frame: could be a junk wrapper like '{junk{...}}'
@@ -801,12 +999,42 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
801
999
  timestamp: Date.now(),
802
1000
  };
803
1001
  const blocks = output.content as Block[];
1002
+ const snapshotOutput = (): AssistantMessage => ({
1003
+ ...output,
1004
+ content: output.content.map(block => ({ ...block })),
1005
+ usage: { ...output.usage, cost: { ...output.usage.cost } },
1006
+ });
804
1007
 
805
1008
  // Variables for streaming tool calls (initialized before try block so they're accessible in catch)
806
1009
  let currentTool: { id: string; name: string; input: string } | undefined;
807
1010
  let toolComplete = false; // Track whether the current tool has been completed (has stop flag)
808
- let toolcallIndex: number | undefined;
809
1011
  const pendingToolCalls: Array<{ input: string; toolCall: ToolCall; index: number }> = [];
1012
+ // Successful metadata fences later generation but text remains quarantined until clean EOF.
1013
+ let completionSeen = false;
1014
+ let completionStopReason: "COMPLETED" | "TOOL_USE" | undefined;
1015
+ let textIndex: number | undefined; // Track text block index (accessible in catch)
1016
+ let textContentConfirmed = false; // Track whether clean EOF committed text to blocks (accessible in catch)
1017
+ let textStartDeferred = false; // Track if text_start is deferred (accessible in catch)
1018
+ let pendingTextContent = ""; // Text buffer (accessible in catch)
1019
+ const pendingTextEvents: Array<{ type: "start" | "delta" | "end"; delta?: string; content?: string }> = []; // Deferred text events (accessible in catch)
1020
+ const removeUnconfirmedTextBlock = () => {
1021
+ if (textContentConfirmed || textIndex === undefined) return;
1022
+
1023
+ const removedIndex = textIndex;
1024
+ blocks.splice(removedIndex, 1);
1025
+ for (let i = removedIndex; i < blocks.length; i++) {
1026
+ const block = blocks[i] as Block;
1027
+ if (block && typeof block === "object" && "index" in block) {
1028
+ block.index = i;
1029
+ }
1030
+ }
1031
+ for (const pending of pendingToolCalls) {
1032
+ if (pending.index > removedIndex) pending.index--;
1033
+ }
1034
+ textIndex = undefined;
1035
+ pendingTextContent = "";
1036
+ pendingTextEvents.length = 0;
1037
+ };
810
1038
 
811
1039
  try {
812
1040
  // Use validated identity snapshot from trust check if available (no second read of getters),
@@ -873,13 +1101,14 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
873
1101
  }
874
1102
  if (!response.body) throw new Error("Kiro API key response has no body");
875
1103
 
876
- stream.push({ type: "start", partial: output });
1104
+ stream.push({ type: "start", partial: snapshotOutput() });
877
1105
  const reader = response.body.getReader();
878
1106
  const decoder = new TextDecoder();
879
1107
  let buffer = "";
880
1108
  let lastContent = "";
881
1109
  let thinkingIndex: number | undefined;
882
- let textIndex: number | undefined;
1110
+ // Note: textIndex, textContentConfirmed, textStartDeferred, pendingTextContent, pendingTextEvents
1111
+ // are declared before the try block so they're accessible in the catch block
883
1112
 
884
1113
  // Add tool to blocks without emitting events (deferred until stream end)
885
1114
  const addToolToBlocks = () => {
@@ -899,9 +1128,6 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
899
1128
  };
900
1129
  const index = blocks.length;
901
1130
  blocks.push({ ...toolCall, index });
902
- if (toolcallIndex === undefined) {
903
- toolcallIndex = index;
904
- }
905
1131
  const toolArgs =
906
1132
  typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
907
1133
  ? (parsed as Record<string, unknown>)
@@ -938,8 +1164,6 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
938
1164
  toolComplete = false; // Reset tool completion flag
939
1165
  };
940
1166
 
941
- let textStartDeferred = false; // Track if text_start has been deferred pending thinking
942
-
943
1167
  const appendText = (delta: string) => {
944
1168
  // Set firstTokenTime on first real (non-thinking) text delta
945
1169
  if (!firstTokenEmitted) {
@@ -951,51 +1175,70 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
951
1175
  }
952
1176
  if (textIndex === undefined) {
953
1177
  textIndex = blocks.length;
1178
+ // Create block with empty text (content will be added only after confirmation)
954
1179
  blocks.push({ type: "text", text: "", index: textIndex });
955
- // Defer text_start emission if thinking might come before text.
956
- // thinkingAccumulated is non-empty means we've seen thinking in this or prior content events.
957
- // We don't know yet if more thinking will come, so defer text_start until stream end.
958
- if (thinkingAccumulated.length === 0) {
959
- // No thinking yet; safe to emit text_start now
960
- stream.push({ type: "text_start", contentIndex: textIndex, partial: output });
961
- } else {
962
- // Thinking exists; defer text_start until we know thinking position
963
- textStartDeferred = true;
964
- }
965
- }
966
- const block = blocks[textIndex] as TextContent;
967
- block.text += delta;
968
- // Only emit text_delta if text_start was already emitted
969
- if (!textStartDeferred) {
970
- stream.push({ type: "text_delta", contentIndex: textIndex, delta, partial: output });
1180
+ // ALWAYS defer text_start until clean EOF. Terminal metadata may be followed
1181
+ // by a refusal in a later read, so it cannot safely publish text by itself.
1182
+ textStartDeferred = true;
971
1183
  }
1184
+ // Buffer text content separately (don't add to blocks yet)
1185
+ pendingTextContent += delta;
1186
+ // Don't emit text_delta yet; defer until stream end or terminal metadata is received.
1187
+ // Queue delta for later emission when confirmed.
1188
+ pendingTextEvents.push({ type: "delta", delta });
1189
+ };
1190
+
1191
+ // Commit pending text to blocks only at clean EOF, when a later refusal is ruled out.
1192
+ const commitPendingText = () => {
1193
+ if (textContentConfirmed || pendingTextContent.length === 0) return; // Already committed or no text
1194
+ textContentConfirmed = true;
1195
+ // Note: we don't populate block.text here to avoid it appearing in previously emitted events
1196
+ // The text will be populated after text_start is emitted in emitDeferredTextEvents
972
1197
  };
973
1198
 
974
1199
  // Emit deferred text events if text_start was not yet sent
975
1200
  const emitDeferredTextEvents = () => {
976
1201
  if (textStartDeferred && textIndex !== undefined && textIndex < blocks.length) {
977
1202
  const block = blocks[textIndex];
978
- if (block && block.type === "text") {
979
- stream.push({ type: "text_start", contentIndex: textIndex, partial: output });
980
- if (block.text.length > 0) {
981
- stream.push({
982
- type: "text_delta",
983
- contentIndex: textIndex,
984
- delta: block.text,
985
- partial: output,
986
- });
1203
+ // Only emit text events if we have pending text content and it's been confirmed
1204
+ if (block && block.type === "text" && pendingTextContent.length > 0 && textContentConfirmed) {
1205
+ // Emit text_start WITHOUT populating block.text yet
1206
+ // This ensures previously emitted events (like start) see the block as empty
1207
+ stream.push({ type: "text_start", contentIndex: textIndex, partial: snapshotOutput() });
1208
+ // Emit all pending text_delta events WITHOUT populating block.text yet
1209
+ // (The text will be populated later when text_end is emitted or at EOF)
1210
+ for (const evt of pendingTextEvents) {
1211
+ if (evt.type === "delta" && evt.delta) {
1212
+ stream.push({
1213
+ type: "text_delta",
1214
+ contentIndex: textIndex,
1215
+ delta: evt.delta,
1216
+ partial: snapshotOutput(),
1217
+ });
1218
+ }
987
1219
  }
988
- textStartDeferred = false;
989
1220
  }
1221
+ textStartDeferred = false;
990
1222
  }
991
1223
  };
992
1224
 
993
1225
  // Emit text_end if text block was opened
994
1226
  const closeTextBlock = () => {
995
- if (textIndex !== undefined) {
1227
+ if (textIndex !== undefined && textContentConfirmed) {
996
1228
  const block = blocks[textIndex] as TextContent;
997
- stream.push({ type: "text_end", contentIndex: textIndex, content: block.text, partial: output });
1229
+ // Ensure text is populated before emitting text_end
1230
+ if (block.text.length === 0 && pendingTextContent.length > 0) {
1231
+ block.text = pendingTextContent;
1232
+ }
1233
+ stream.push({
1234
+ type: "text_end",
1235
+ contentIndex: textIndex,
1236
+ content: block.text,
1237
+ partial: snapshotOutput(),
1238
+ });
998
1239
  textIndex = undefined;
1240
+ pendingTextContent = "";
1241
+ pendingTextEvents.length = 0;
999
1242
  }
1000
1243
  };
1001
1244
 
@@ -1063,7 +1306,6 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
1063
1306
  }
1064
1307
  // Update state tracking variables for shifted blocks
1065
1308
  if (textIndex !== undefined) textIndex += 1;
1066
- if (toolcallIndex !== undefined) toolcallIndex += 1;
1067
1309
  // Update indices in pending tool calls since all blocks shifted by 1
1068
1310
  for (const pending of pendingToolCalls) {
1069
1311
  pending.index += 1;
@@ -1098,6 +1340,21 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
1098
1340
  const hasTerminalEvent = events.some(e => e.type === "refusal");
1099
1341
 
1100
1342
  for (const event of events) {
1343
+ const isActiveToolContinuation =
1344
+ currentTool !== undefined &&
1345
+ completionStopReason === "TOOL_USE" &&
1346
+ !toolComplete &&
1347
+ (event.type === "toolUseInput" ||
1348
+ event.type === "toolUseStop" ||
1349
+ (event.type === "toolUse" && event.data.toolUseId === currentTool.id));
1350
+ if (
1351
+ completionSeen &&
1352
+ (event.type === "content" ||
1353
+ ((event.type === "toolUse" || event.type === "toolUseInput" || event.type === "toolUseStop") &&
1354
+ !isActiveToolContinuation))
1355
+ ) {
1356
+ continue;
1357
+ }
1101
1358
  if (event.type === "content") {
1102
1359
  if (!hasTerminalEvent) {
1103
1360
  if (event.data === lastContent) continue;
@@ -1110,7 +1367,12 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
1110
1367
  firstTokenEmitted = true;
1111
1368
  firstTokenTime = Date.now();
1112
1369
  }
1113
- if (currentTool && currentTool.id !== event.data.toolUseId) addToolToBlocks();
1370
+ if (currentTool && currentTool.id !== event.data.toolUseId) {
1371
+ // Only emit previous tool if it was completed
1372
+ if (toolComplete) addToolToBlocks();
1373
+ currentTool = undefined; // Clear the incomplete tool
1374
+ toolComplete = false; // Reset for new tool
1375
+ }
1114
1376
  if (!currentTool) {
1115
1377
  currentTool = { id: event.data.toolUseId, name: event.data.name, input: event.data.input };
1116
1378
  toolComplete = false; // New tool starts as incomplete
@@ -1128,26 +1390,34 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
1128
1390
  firstTokenTime = Date.now();
1129
1391
  }
1130
1392
  currentTool.input += event.data.input;
1131
- } else if (event.type === "toolUseStop" && event.data.stop) {
1393
+ } else if (event.type === "toolUseStop" && event.data.stop && !hasTerminalEvent) {
1132
1394
  toolComplete = true; // Mark tool as complete when toolUseStop is received
1133
1395
  addToolToBlocks();
1134
1396
  } else if (event.type === "usage") {
1135
1397
  if (event.data.inputTokens !== undefined) output.usage.input = event.data.inputTokens;
1136
1398
  if (event.data.outputTokens !== undefined) output.usage.output = event.data.outputTokens;
1137
1399
  output.usage.totalTokens = output.usage.input + output.usage.output;
1400
+ } else if (event.type === "completion" && !hasTerminalEvent) {
1401
+ // Successful terminal metadata fences later generation. Keep text quarantined
1402
+ // until clean EOF so a later refusal cannot depend on HTTP chunk boundaries.
1403
+ completionSeen = true;
1404
+ completionStopReason = event.data.stopReason;
1138
1405
  } else if (event.type === "refusal") {
1139
1406
  const refusalData = event.data as {
1140
1407
  stopReason?: string;
1141
1408
  stopDetails?: { refusal?: { category?: string; explanation?: string } };
1142
1409
  };
1143
- if (refusalData.stopDetails?.refusal) {
1144
- // Handle refusal: clear blocks and emit error (don't emit any text/tool that came before)
1410
+ if (refusalData.stopDetails?.refusal || refusalData.stopReason === "CONTENT_FILTERED") {
1411
+ // Handle refusal: clear pending content and emit error (don't emit any text/tool that came before)
1145
1412
  consumeContent(""); // Flush any pending thinking
1146
1413
  clearPendingToolCalls(); // DROP any pending tool call without emitting events
1414
+ // Clear pending text (it was never committed to blocks, so it's safe to discard)
1415
+ pendingTextContent = "";
1416
+ pendingTextEvents.length = 0;
1147
1417
 
1148
- const refusal = refusalData.stopDetails.refusal;
1149
- const category = refusal.category;
1150
- const explanation = refusal.explanation?.trim();
1418
+ const refusal = refusalData.stopDetails?.refusal;
1419
+ const category = refusal?.category;
1420
+ const explanation = refusal?.explanation?.trim();
1151
1421
  const label = category ? `Kiro refused the request (${category})` : "Kiro refused the request";
1152
1422
 
1153
1423
  output.stopReason = "error";
@@ -1181,27 +1451,34 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
1181
1451
  stream.end();
1182
1452
  return;
1183
1453
  }
1184
- } else if (event.type === "error") {
1185
- // On ordinary errors, flush pending COMPLETED tool events before the error terminal
1186
- // (refusals drop them, incomplete tools must not be emitted)
1187
- // Only emit the tool if it was explicitly completed (has stop flag)
1454
+ } else if (event.type === "error" && !hasTerminalEvent) {
1455
+ // An ordinary error is not clean EOF, so keep quarantined text private.
1456
+
1457
+ // Preserve only tools that were explicitly completed (have a stop flag).
1188
1458
  if (toolComplete) addToolToBlocks();
1459
+ removeUnconfirmedTextBlock();
1189
1460
  emitPendingToolCalls();
1190
1461
 
1191
- // Preserve any already-accumulated text in the error context
1192
- const accumulatedText = blocks
1193
- .filter((b): b is TextContent => b.type === "text")
1194
- .map(b => b.text)
1195
- .join("");
1196
1462
  const errorMsg = sanitizeKiroError(`${event.data.error}: ${event.data.message ?? ""}`, apiKey);
1197
- const fullErrorMsg = accumulatedText ? `${errorMsg}\n\nPartial output: ${accumulatedText}` : errorMsg;
1198
- throw new Error(fullErrorMsg);
1463
+ throw new Error(errorMsg);
1199
1464
  }
1200
1465
  }
1201
1466
  }
1202
1467
 
1203
- // Stream is done - add any pending tool, emit accumulated thinking, then deferred text, then close text block
1204
- addToolToBlocks();
1468
+ // Check for incomplete JSON frame in leftover buffer at EOF
1469
+ if (buffer.trim().length > 0) {
1470
+ // Non-whitespace content remains in buffer
1471
+ const openBraceIdx = buffer.indexOf("{");
1472
+ if (openBraceIdx >= 0 && findJsonEnd(buffer, openBraceIdx) < 0) {
1473
+ // Incomplete JSON frame at EOF (opening brace with no matching close)
1474
+ throw new Error(`Kiro API key stream truncated: incomplete JSON frame at EOF`);
1475
+ }
1476
+ }
1477
+
1478
+ // Clean EOF rules out a later refusal, so pending text can now be published.
1479
+ commitPendingText();
1480
+ // Emit tool only if explicitly completed (has stop flag); incomplete tools are discarded at EOF
1481
+ if (currentTool && toolComplete) addToolToBlocks();
1205
1482
  emitThinking();
1206
1483
  emitDeferredTextEvents();
1207
1484
  // Now emit all tool call events in order (safe since no refusal occurred)
@@ -1237,10 +1514,44 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
1237
1514
  stream.push({ type: "done", reason: output.stopReason as "stop" | "toolUse", message: output });
1238
1515
  stream.end();
1239
1516
  } catch (error) {
1240
- // On ordinary errors (including reader.read() throws), emit only COMPLETED tool events
1241
- // before the error terminal (same semantics as the ordinary-error flush in the event loop).
1242
- // Incomplete currentTool is NOT finalized/emitted (only completed tools in pendingToolCalls flush).
1243
- // Refusals drop them via clearPendingToolCalls, but ordinary errors preserve content consistency.
1517
+ // On ordinary errors (including reader.read() throws):
1518
+ // - Emit COMPLETED tool events (same semantics as in the event processing loop)
1519
+ // - Emit text only if clean EOF already confirmed it
1520
+ // - Incomplete currentTool is NOT finalized/emitted (only completed tools in pendingToolCalls flush).
1521
+ // - Unconfirmed text is suppressed (terminal metadata alone does not rule out a later refusal)
1522
+
1523
+ if (textContentConfirmed && textIndex !== undefined && textIndex < blocks.length) {
1524
+ const block = blocks[textIndex];
1525
+ if (block && block.type === "text") {
1526
+ if (textStartDeferred && pendingTextContent.length > 0) {
1527
+ stream.push({ type: "text_start", contentIndex: textIndex, partial: snapshotOutput() });
1528
+ // Emit all pending text_delta events
1529
+ for (const evt of pendingTextEvents) {
1530
+ if (evt.type === "delta" && evt.delta) {
1531
+ stream.push({
1532
+ type: "text_delta",
1533
+ contentIndex: textIndex,
1534
+ delta: evt.delta,
1535
+ partial: snapshotOutput(),
1536
+ });
1537
+ }
1538
+ }
1539
+ }
1540
+ if (block.text.length === 0 && pendingTextContent.length > 0) block.text = pendingTextContent;
1541
+ stream.push({
1542
+ type: "text_end",
1543
+ contentIndex: textIndex,
1544
+ content: block.text,
1545
+ partial: snapshotOutput(),
1546
+ });
1547
+ }
1548
+ textIndex = undefined;
1549
+ pendingTextContent = "";
1550
+ pendingTextEvents.length = 0;
1551
+ textStartDeferred = false;
1552
+ }
1553
+
1554
+ removeUnconfirmedTextBlock();
1244
1555
 
1245
1556
  // Emit all pending tool call events (inline of emitPendingToolCalls logic)
1246
1557
  for (const { input, toolCall, index } of pendingToolCalls) {