@gajae-code/ai 0.11.1 → 0.11.3

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.
@@ -376,6 +376,9 @@ export async function processResponsesStream<TApi extends Api>(
376
376
  item: StreamItem;
377
377
  block: StreamBlock;
378
378
  blockContentIndex: number;
379
+ summaryBuffer: string;
380
+ rawBuffer: string;
381
+ summaryStarted: boolean;
379
382
  }
380
383
  // Per-item argument buffer keyed on stable item identity. Multiple tool-call
381
384
  // items can stream interleaved argument deltas in one response, so a single
@@ -412,7 +415,14 @@ export async function processResponsesStream<TApi extends Api>(
412
415
  };
413
416
  const registerEntry = (item: StreamItem, block: StreamBlock, outputIndex: number | undefined): ItemEntry => {
414
417
  output.content.push(block);
415
- const entry: ItemEntry = { item, block, blockContentIndex: output.content.length - 1 };
418
+ const entry: ItemEntry = {
419
+ item,
420
+ block,
421
+ blockContentIndex: output.content.length - 1,
422
+ summaryBuffer: "",
423
+ rawBuffer: "",
424
+ summaryStarted: false,
425
+ };
416
426
  // Primary key prefers the stable item id; if the wire omits it, fall back to
417
427
  // the positional index. A synthetic key keeps the entry addressable as lastKey
418
428
  // for continuation-style non-tool events even when neither is present.
@@ -480,9 +490,13 @@ export async function processResponsesStream<TApi extends Api>(
480
490
  }
481
491
  } else if (event.type === "response.reasoning_summary_part.added") {
482
492
  const entry = resolveEntry(event.item_id, event.output_index, "always");
483
- if (entry?.item.type === "reasoning") {
493
+ if (entry?.item.type === "reasoning" && entry.block.type === "thinking") {
484
494
  entry.item.summary = entry.item.summary || [];
485
495
  entry.item.summary.push(event.part);
496
+ if (!entry.summaryStarted) {
497
+ entry.summaryStarted = true;
498
+ stream.push({ type: "reasoning_summary_start", contentIndex: entry.blockContentIndex, partial: output });
499
+ }
486
500
  }
487
501
  } else if (event.type === "response.reasoning_summary_text.delta") {
488
502
  const entry = resolveEntry(event.item_id, event.output_index, "always");
@@ -491,9 +505,10 @@ export async function processResponsesStream<TApi extends Api>(
491
505
  const lastPart = entry.item.summary[entry.item.summary.length - 1];
492
506
  if (lastPart) {
493
507
  entry.block.thinking += event.delta;
508
+ entry.summaryBuffer += event.delta;
494
509
  lastPart.text += event.delta;
495
510
  stream.push({
496
- type: "thinking_delta",
511
+ type: "reasoning_summary_delta",
497
512
  contentIndex: entry.blockContentIndex,
498
513
  delta: event.delta,
499
514
  partial: output,
@@ -507,9 +522,10 @@ export async function processResponsesStream<TApi extends Api>(
507
522
  const lastPart = entry.item.summary[entry.item.summary.length - 1];
508
523
  if (lastPart) {
509
524
  entry.block.thinking += "\n\n";
525
+ entry.summaryBuffer += "\n\n";
510
526
  lastPart.text += "\n\n";
511
527
  stream.push({
512
- type: "thinking_delta",
528
+ type: "reasoning_summary_delta",
513
529
  contentIndex: entry.blockContentIndex,
514
530
  delta: "\n\n",
515
531
  partial: output,
@@ -522,6 +538,7 @@ export async function processResponsesStream<TApi extends Api>(
522
538
  const entry = resolveEntry(event.item_id, event.output_index, "always");
523
539
  if (entry?.item.type === "reasoning" && entry.block.type === "thinking") {
524
540
  entry.block.thinking += event.delta;
541
+ entry.rawBuffer += event.delta;
525
542
  stream.push({
526
543
  type: "thinking_delta",
527
544
  contentIndex: entry.blockContentIndex,
@@ -608,12 +625,15 @@ export async function processResponsesStream<TApi extends Api>(
608
625
  options?.onOutputItemDone?.(item);
609
626
  const entry = resolveEntry(item.id, event.output_index, "never");
610
627
  if (item.type === "reasoning") {
611
- const thinking =
612
- item.summary?.length > 0
613
- ? item.summary.map(part => part.text).join("\n\n")
614
- : item.content?.[0]?.type === "reasoning_text"
615
- ? (item.content[0].text ?? "")
616
- : "";
628
+ // Prefer the streamed summary buffer only when it carries real text. When it
629
+ // holds only synthetic separators (e.g. a part.done arrived before/without any
630
+ // summary_text delta), fall back to the canonical `item.summary` from
631
+ // output_item.done so the materialized summaryText is not blank/separator-only.
632
+ const bufferSummary = entry?.summaryBuffer ?? "";
633
+ const itemSummary = item.summary?.map(part => part.text).join("\n\n") ?? "";
634
+ const summaryText = bufferSummary.trim() ? bufferSummary : itemSummary;
635
+ const rawText =
636
+ entry?.rawBuffer || (item.content?.[0]?.type === "reasoning_text" ? (item.content[0].text ?? "") : "");
617
637
  const reasoningBlock =
618
638
  entry?.block.type === "thinking"
619
639
  ? entry.block
@@ -621,14 +641,53 @@ export async function processResponsesStream<TApi extends Api>(
621
641
  | ThinkingContent
622
642
  | undefined);
623
643
  if (reasoningBlock) {
624
- reasoningBlock.thinking = thinking;
644
+ const mutable = reasoningBlock as {
645
+ provenance?: "summary" | "raw" | "mixed";
646
+ summaryText?: string;
647
+ rawText?: string;
648
+ };
649
+ if (mutable.provenance === undefined) {
650
+ if (mutable.summaryText === undefined && summaryText) mutable.summaryText = summaryText;
651
+ if (mutable.rawText === undefined && rawText) mutable.rawText = rawText;
652
+ mutable.provenance =
653
+ summaryText && rawText ? "mixed" : summaryText ? "summary" : rawText ? "raw" : undefined;
654
+ }
655
+ // Finalized display string must exclude raw CoT when a summary exists.
656
+ // Derive it from the STORED write-once provenance fields (falling back to
657
+ // this event's locals only for a first classification) so a later or
658
+ // duplicate finalization carrying only raw can never overwrite a summary/
659
+ // mixed block's safe display with raw CoT. Raw-only stays raw.
660
+ {
661
+ const effSummary = mutable.summaryText ?? summaryText;
662
+ const effRaw = mutable.rawText ?? rawText;
663
+ reasoningBlock.thinking = mutable.provenance === "raw" ? effRaw : effSummary || effRaw;
664
+ }
625
665
  reasoningBlock.thinkingSignature = JSON.stringify(item);
626
666
  const reasoningBlockIndex =
627
667
  entry?.block === reasoningBlock ? entry.blockContentIndex : output.content.indexOf(reasoningBlock);
668
+ if (summaryText) {
669
+ // If the summary text came only from the canonical item.summary (no
670
+ // streamed summary deltas/part.added), no reasoning_summary_start was
671
+ // emitted. Emit one now so consumers that open a summary on start
672
+ // (e.g. the Responses SSE encoder) don't receive an orphaned end.
673
+ if (!entry?.summaryStarted) {
674
+ stream.push({
675
+ type: "reasoning_summary_start",
676
+ contentIndex: reasoningBlockIndex,
677
+ partial: output,
678
+ });
679
+ }
680
+ stream.push({
681
+ type: "reasoning_summary_end",
682
+ contentIndex: reasoningBlockIndex,
683
+ content: summaryText,
684
+ partial: output,
685
+ });
686
+ }
628
687
  stream.push({
629
688
  type: "thinking_end",
630
689
  contentIndex: reasoningBlockIndex,
631
- content: thinking,
690
+ content: reasoningBlock.thinking,
632
691
  partial: output,
633
692
  });
634
693
  }
@@ -10,13 +10,12 @@
10
10
  * translations impose on first-class pi-ai fields (service tier, cache
11
11
  * markers, thinking budgets, tool-choice variants, …).
12
12
  *
13
- * The streaming wire is {@link AssistantMessageEvent} serialized verbatim and
14
- * SSE-framed. Same type pi-ai already produces internally; the client feeds
15
- * each parsed event straight into `AssistantMessageEventStream.push()` with
16
- * no translation. Including `partial: AssistantMessage` on every delta is
17
- * O(N²) in turn length on the wire — acceptable for the loopback / sidecar
18
- * topology this transport is designed for; provider latency dominates the
19
- * actual cost.
13
+ * The streaming wire is {@link AssistantMessageEvent} serialized as SSE. Public
14
+ * projections omit private raw reasoning and serialized Responses reasoning
15
+ * signatures while preserving provider-displayable summaries and genuine opaque
16
+ * signatures. Including `partial: AssistantMessage` on every delta is O(N²) in
17
+ * turn length on the wire — acceptable for the loopback / sidecar topology this
18
+ * transport is designed for; provider latency dominates the actual cost.
20
19
  *
21
20
  * Endpoint contract:
22
21
  * POST /v1/pi/stream
@@ -25,7 +24,14 @@
25
24
  * 200 JSON (stream=false): { message: AssistantMessage }
26
25
  * 4xx/5xx: { error: { type, message } }
27
26
  */
28
- import type { AssistantMessageEventStream, Context, SimpleStreamOptions } from "../types";
27
+ import type {
28
+ AssistantMessage,
29
+ AssistantMessageEvent,
30
+ AssistantMessageEventStream,
31
+ Context,
32
+ SimpleStreamOptions,
33
+ ThinkingContent,
34
+ } from "../types";
29
35
 
30
36
  export interface PiNativeParsedRequest {
31
37
  modelId: string;
@@ -148,25 +154,272 @@ export function parseRequest(body: unknown, _headers?: Headers): PiNativeParsedR
148
154
  const SSE_ENCODER = new TextEncoder();
149
155
  const SSE_DONE = SSE_ENCODER.encode("data: [DONE]\n\n");
150
156
 
157
+ function isSerializedResponsesReasoningItem(signature: string): boolean {
158
+ try {
159
+ const parsed: unknown = JSON.parse(signature);
160
+ return (
161
+ typeof parsed === "object" &&
162
+ parsed !== null &&
163
+ !Array.isArray(parsed) &&
164
+ (parsed as { type?: unknown }).type === "reasoning"
165
+ );
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
151
171
  /**
152
- * Ship every {@link AssistantMessageEvent} verbatim, SSE-framed.
153
- *
154
- * No per-event re-shaping: the pi-native client is pi-ai itself, so the
155
- * canonical event type IS the wire type. Including the rolling
156
- * `partial: AssistantMessage` on every delta is quadratic in turn length
157
- * on the wire, but for the loopback / sidecar topology this transport
158
- * targets (containerized GJC → host gateway) the bandwidth cost is negligible
159
- * compared to provider latency —
160
- * and the client gets to feed the events straight into its existing
161
- * `AssistantMessageEventStream.push()` plumbing with zero translation.
172
+ * Clone a thinking block for public transport. Raw reasoning is private: omit
173
+ * raw-only blocks, retain only the displayable summary for mixed blocks, and
174
+ * never forward a serialized Responses reasoning item as a signature.
175
+ */
176
+ function isResponsesFamilyApi(api: AssistantMessage["api"]): boolean {
177
+ return api === "openai-responses" || api === "openai-codex-responses";
178
+ }
179
+
180
+ function sanitizeThinking(content: ThinkingContent, api: AssistantMessage["api"]): ThinkingContent | undefined {
181
+ if (isResponsesFamilyApi(api) && content.provenance === undefined) return undefined;
182
+ if (content.provenance === "raw") return undefined;
183
+
184
+ let thinking: string;
185
+ if (content.provenance === "mixed") {
186
+ if (content.summaryText === undefined) return undefined;
187
+ thinking = content.summaryText;
188
+ } else {
189
+ thinking = content.provenance === "summary" ? (content.summaryText ?? content.thinking) : content.thinking;
190
+ }
191
+ const signature =
192
+ content.thinkingSignature && isSerializedResponsesReasoningItem(content.thinkingSignature)
193
+ ? undefined
194
+ : content.thinkingSignature;
195
+ const { rawText: _rawText, thinkingSignature: _thinkingSignature, ...rest } = content;
196
+ return signature === undefined ? { ...rest, thinking } : { ...rest, thinking, thinkingSignature: signature };
197
+ }
198
+
199
+ function sanitizeMessage(message: AssistantMessage): AssistantMessage {
200
+ let changed = false;
201
+ const content: AssistantMessage["content"] = [];
202
+ for (const part of message.content) {
203
+ if (part.type !== "thinking") {
204
+ content.push(part);
205
+ continue;
206
+ }
207
+ const needsSanitizing =
208
+ (isResponsesFamilyApi(message.api) && part.provenance === undefined) ||
209
+ part.provenance !== undefined ||
210
+ part.rawText !== undefined ||
211
+ (part.thinkingSignature !== undefined && isSerializedResponsesReasoningItem(part.thinkingSignature));
212
+ if (!needsSanitizing) {
213
+ content.push(part);
214
+ continue;
215
+ }
216
+ const sanitized = sanitizeThinking(part, message.api);
217
+ changed = true;
218
+ if (sanitized !== undefined) content.push(sanitized);
219
+ }
220
+ return changed ? { ...message, content } : message;
221
+ }
222
+
223
+ function hasRawOrMixedThinking(partial: AssistantMessage, contentIndex: number): boolean {
224
+ const content = partial.content[contentIndex];
225
+ return content?.type === "thinking" && (content.provenance === "raw" || content.provenance === "mixed");
226
+ }
227
+
228
+ type ThinkingEvent = Extract<AssistantMessageEvent, { type: "thinking_start" | "thinking_delta" | "thinking_end" }>;
229
+
230
+ interface BufferedThinkingEvent {
231
+ event: ThinkingEvent;
232
+ sequence: number;
233
+ }
234
+
235
+ function isFinalSafeThinking(partial: AssistantMessage, contentIndex: number): boolean {
236
+ const content = partial.content[contentIndex];
237
+ return (
238
+ content?.type === "thinking" &&
239
+ (!isResponsesFamilyApi(partial.api) || content.provenance !== undefined) &&
240
+ !hasRawOrMixedThinking(partial, contentIndex)
241
+ );
242
+ }
243
+
244
+ function maskBufferedThinking(message: AssistantMessage, contentIndexes: ReadonlySet<number>): AssistantMessage {
245
+ let changed = false;
246
+ const content = message.content.map((part, contentIndex) => {
247
+ if (!contentIndexes.has(contentIndex) || part.type !== "thinking") return part;
248
+ changed = true;
249
+ return { type: "thinking" as const, thinking: "", ...(part.itemId ? { itemId: part.itemId } : {}) };
250
+ });
251
+ return changed ? { ...message, content } : message;
252
+ }
253
+
254
+ function maskBufferedThinkingInEvent(
255
+ event: AssistantMessageEvent,
256
+ contentIndexes: ReadonlySet<number>,
257
+ ): AssistantMessageEvent {
258
+ if (contentIndexes.size === 0) return event;
259
+ switch (event.type) {
260
+ case "done":
261
+ case "error":
262
+ case "toolChoiceIncapability":
263
+ return event;
264
+ case "start":
265
+ case "text_start":
266
+ case "text_delta":
267
+ case "text_end":
268
+ case "thinking_start":
269
+ case "thinking_delta":
270
+ case "thinking_end":
271
+ case "reasoning_summary_start":
272
+ case "reasoning_summary_delta":
273
+ case "reasoning_summary_end":
274
+ case "toolcall_start":
275
+ case "toolcall_delta":
276
+ case "toolcall_end":
277
+ return { ...event, partial: maskBufferedThinking(event.partial, contentIndexes) };
278
+ }
279
+ }
280
+
281
+ function withSummaryPartial<
282
+ T extends Extract<
283
+ AssistantMessageEvent,
284
+ { type: "reasoning_summary_start" | "reasoning_summary_delta" | "reasoning_summary_end" }
285
+ >,
286
+ >(event: T, contentIndexes: ReadonlySet<number>, summaryText: string): T {
287
+ const partial = maskBufferedThinking(event.partial, contentIndexes);
288
+ const content = [...partial.content];
289
+ const original = event.partial.content[event.contentIndex];
290
+ content[event.contentIndex] = {
291
+ type: "thinking",
292
+ thinking: summaryText,
293
+ provenance: "summary",
294
+ summaryText,
295
+ ...(original?.type === "thinking" && original.itemId ? { itemId: original.itemId } : {}),
296
+ };
297
+ return { ...event, partial: { ...partial, content } };
298
+ }
299
+
300
+ function sanitizeEvent(event: AssistantMessageEvent): AssistantMessageEvent | undefined {
301
+ switch (event.type) {
302
+ case "done":
303
+ return { ...event, message: sanitizeMessage(event.message) };
304
+ case "error":
305
+ return { ...event, error: sanitizeMessage(event.error) };
306
+ case "toolChoiceIncapability":
307
+ return event;
308
+ case "thinking_start":
309
+ case "thinking_delta":
310
+ case "thinking_end":
311
+ return hasRawOrMixedThinking(event.partial, event.contentIndex)
312
+ ? undefined
313
+ : { ...event, partial: sanitizeMessage(event.partial) };
314
+ case "start":
315
+ case "text_start":
316
+ case "text_delta":
317
+ case "text_end":
318
+ case "reasoning_summary_start":
319
+ case "reasoning_summary_delta":
320
+ case "reasoning_summary_end":
321
+ case "toolcall_start":
322
+ case "toolcall_delta":
323
+ case "toolcall_end":
324
+ return { ...event, partial: sanitizeMessage(event.partial) };
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Ship only public-safe {@link AssistantMessageEvent} projections. Unknown
330
+ * thinking blocks remain buffered until their terminal partial establishes that
331
+ * the provider-native block is safe; raw and mixed blocks never reach SSE.
162
332
  */
163
333
  export function encodeStream(events: AssistantMessageEventStream): ReadableStream<Uint8Array> {
164
334
  return new ReadableStream<Uint8Array>({
165
335
  async start(controller) {
336
+ const bufferedThinking = new Map<number, BufferedThinkingEvent[]>();
337
+ const summaryTextByIndex = new Map<number, string>();
338
+ let sequence = 0;
339
+ const write = (event: AssistantMessageEvent): void => {
340
+ const sanitized = sanitizeEvent(event);
341
+ if (sanitized !== undefined) {
342
+ controller.enqueue(SSE_ENCODER.encode(`data: ${JSON.stringify(sanitized)}\n\n`));
343
+ }
344
+ };
345
+ const emit = (event: AssistantMessageEvent): void => {
346
+ write(maskBufferedThinkingInEvent(event, new Set(bufferedThinking.keys())));
347
+ };
348
+ const flush = (buffered: BufferedThinkingEvent[]): void => {
349
+ for (const { event } of buffered.sort((a, b) => a.sequence - b.sequence)) emit(event);
350
+ };
351
+ const resolveBufferedThinking = (final: AssistantMessage): void => {
352
+ const ready: BufferedThinkingEvent[] = [];
353
+ for (const [contentIndex, buffered] of bufferedThinking) {
354
+ if (isFinalSafeThinking(final, contentIndex)) ready.push(...buffered);
355
+ }
356
+ bufferedThinking.clear();
357
+ flush(ready);
358
+ };
166
359
  try {
167
360
  for await (const event of events) {
168
- controller.enqueue(SSE_ENCODER.encode(`data: ${JSON.stringify(event)}\n\n`));
169
- if (event.type === "done" || event.type === "error") break;
361
+ switch (event.type) {
362
+ case "thinking_start":
363
+ case "thinking_delta": {
364
+ if (hasRawOrMixedThinking(event.partial, event.contentIndex)) {
365
+ bufferedThinking.delete(event.contentIndex);
366
+ break;
367
+ }
368
+ const buffered = bufferedThinking.get(event.contentIndex) ?? [];
369
+ buffered.push({ event, sequence: sequence++ });
370
+ bufferedThinking.set(event.contentIndex, buffered);
371
+ break;
372
+ }
373
+ case "thinking_end": {
374
+ const buffered = bufferedThinking.get(event.contentIndex) ?? [];
375
+ bufferedThinking.delete(event.contentIndex);
376
+ if (!isFinalSafeThinking(event.partial, event.contentIndex)) break;
377
+ buffered.push({ event, sequence: sequence++ });
378
+ flush(buffered);
379
+ break;
380
+ }
381
+ case "done":
382
+ resolveBufferedThinking(event.message);
383
+ summaryTextByIndex.clear();
384
+ emit(event);
385
+ controller.enqueue(SSE_DONE);
386
+ controller.close();
387
+ return;
388
+ case "error":
389
+ resolveBufferedThinking(event.error);
390
+ summaryTextByIndex.clear();
391
+ emit(event);
392
+ controller.enqueue(SSE_DONE);
393
+ controller.close();
394
+ return;
395
+ case "reasoning_summary_start": {
396
+ summaryTextByIndex.set(event.contentIndex, "");
397
+ write(withSummaryPartial(event, new Set(bufferedThinking.keys()), ""));
398
+ break;
399
+ }
400
+ case "reasoning_summary_delta": {
401
+ const summaryText = `${summaryTextByIndex.get(event.contentIndex) ?? ""}${event.delta}`;
402
+ summaryTextByIndex.set(event.contentIndex, summaryText);
403
+ write(withSummaryPartial(event, new Set(bufferedThinking.keys()), summaryText));
404
+ break;
405
+ }
406
+ case "reasoning_summary_end": {
407
+ const summaryText = event.content || summaryTextByIndex.get(event.contentIndex) || "";
408
+ summaryTextByIndex.delete(event.contentIndex);
409
+ write(withSummaryPartial(event, new Set(bufferedThinking.keys()), summaryText));
410
+ break;
411
+ }
412
+ case "start":
413
+ case "text_start":
414
+ case "text_delta":
415
+ case "text_end":
416
+ case "toolcall_start":
417
+ case "toolcall_delta":
418
+ case "toolcall_end":
419
+ case "toolChoiceIncapability":
420
+ emit(event);
421
+ break;
422
+ }
170
423
  }
171
424
  controller.enqueue(SSE_DONE);
172
425
  controller.close();
package/src/types.ts CHANGED
@@ -488,6 +488,9 @@ export interface ThinkingContent {
488
488
  thinking: string;
489
489
  thinkingSignature?: string; // e.g., for OpenAI responses, the reasoning item ID
490
490
  itemId?: string; // item.id from output_item.added, used to match output_item.done
491
+ readonly provenance?: "summary" | "raw" | "mixed";
492
+ readonly summaryText?: string;
493
+ readonly rawText?: string;
491
494
  }
492
495
 
493
496
  export interface RedactedThinkingContent {
@@ -729,6 +732,13 @@ export interface Tool<TParameters extends TSchema = TSchema> {
729
732
  * calls route correctly. Absent for regular JSON function tools.
730
733
  */
731
734
  customWireName?: string;
735
+ /**
736
+ * Optional safe projection for tool arguments or results. Extensions use this
737
+ * only for explicitly opt-in, display-safe summaries.
738
+ */
739
+ safeSummary?: (kind: "args" | "result", value: unknown) => string | undefined;
740
+ /** Allowlisted argument/result field names for a safe fallback summary. */
741
+ safeSummaryFields?: { args?: string[]; result?: string[] };
732
742
  }
733
743
 
734
744
  export interface Context {
@@ -745,6 +755,9 @@ export type AssistantMessageEvent =
745
755
  | { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
746
756
  | { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
747
757
  | { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }
758
+ | { type: "reasoning_summary_start"; contentIndex: number; partial: AssistantMessage }
759
+ | { type: "reasoning_summary_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
760
+ | { type: "reasoning_summary_end"; contentIndex: number; content: string; partial: AssistantMessage }
748
761
  | { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
749
762
  | { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
750
763
  | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
@@ -889,6 +902,12 @@ export interface AnthropicCompat extends ToolChoiceCompat {
889
902
  supportsForcedToolChoice?: boolean;
890
903
  /** Whether long prompt-cache retention (`ttl: "1h"`) is supported. Default: true for canonical Anthropic API. */
891
904
  supportsLongCacheRetention?: boolean;
905
+ /**
906
+ * Prompt-cache transport accepted by this Anthropic-compatible endpoint.
907
+ * Canonical Anthropic defaults to `"automatic"`; noncanonical endpoints default
908
+ * to `"none"` and must explicitly opt into generated `"explicit"` markers.
909
+ */
910
+ promptCacheMode?: "none" | "explicit" | "automatic";
892
911
  }
893
912
 
894
913
  /**
@@ -123,12 +123,16 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
123
123
 
124
124
  push(event: T): void {
125
125
  if (this.done) return;
126
-
127
- if (this.isComplete(event)) {
128
- this.done = true;
129
- this.resolveFinalResult(this.extractResult(event));
126
+ try {
127
+ if (this.isComplete(event)) {
128
+ const result = this.extractResult(event);
129
+ this.done = true;
130
+ this.resolveFinalResult(result);
131
+ }
132
+ } catch (error) {
133
+ this.fail(error);
134
+ return;
130
135
  }
131
-
132
136
  this.deliver(event);
133
137
  }
134
138