@struct-ai/sdk 0.3.0 → 0.3.17

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.
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.patch = patch;
4
4
  exports.unpatch = unpatch;
5
+ exports.wrapCreate = wrapCreate;
6
+ exports.wrapStream = wrapStream;
5
7
  exports._wrapCreateForTest = _wrapCreateForTest;
6
8
  exports._wrapStreamForTest = _wrapStreamForTest;
7
9
  exports._setActivePatchCtxForTest = _setActivePatchCtxForTest;
@@ -110,6 +112,10 @@ function wrapCreate(original) {
110
112
  if (!patchCtx) {
111
113
  return original.call(this, params, opts);
112
114
  }
115
+ if ((0, context_js_1.isGenAiSuppressed)()) {
116
+ // A framework layer owns this chat span — run the call, emit no span.
117
+ return original.call(this, params, opts);
118
+ }
113
119
  // If params.stream is true, Messages.create delegates to the streaming path.
114
120
  // We still need to instrument the returned stream like we do in messages.stream.
115
121
  if (params && params.stream === true) {
@@ -139,18 +145,74 @@ function wrapCreate(original) {
139
145
  // user/system message logs back to the chat span.
140
146
  return api_1.context.with(liveCtx, () => {
141
147
  (0, core_js_1.safe)(() => setChatRequestAttrs(liveSpan, params, sdk, logger), "anthropic.create.set_request_attrs", internalLogger);
142
- const promise = Promise.resolve(original.call(this, params, opts));
143
- return promise.then((result) => {
144
- (0, core_js_1.safe)(() => setChatResponseAttrs(liveSpan, sdk, result, logger), "anthropic.create.set_response_attrs", internalLogger);
145
- (0, core_js_1.safe)(() => liveSpan.setStatus({ code: api_1.SpanStatusCode.OK }), "anthropic.create.set_ok_status", internalLogger);
146
- (0, core_js_1.safe)(() => liveSpan.end(), "anthropic.create.span_end", internalLogger);
147
- return result;
148
- }, (err) => {
148
+ let rawResult;
149
+ try {
150
+ rawResult = original.call(this, params, opts);
151
+ }
152
+ catch (err) {
153
+ // Sync throw from the original call (e.g. a validation error thrown
154
+ // before any request goes out) would otherwise leak the span — no
155
+ // promise is ever created to drive the .then/.catch below.
149
156
  (0, core_js_1.safe)(() => recordErrorOnSpan(liveSpan, err), "anthropic.create.record_error", internalLogger);
150
157
  (0, core_js_1.safe)(() => liveSpan.end(), "anthropic.create.span_end_on_error", internalLogger);
151
- // User's API exception always propagates unchanged.
152
158
  throw err;
153
- });
159
+ }
160
+ // Observe the SDK's own return value (Anthropic's APIPromise) for
161
+ // telemetry, then hand it BACK UNCHANGED — replacing it with a native
162
+ // `Promise.resolve(...).then(...)` strips APIPromise methods
163
+ // (.withResponse/.asResponse), breaking customer code only when
164
+ // instrumentation is enabled. Our observer chain must not create an
165
+ // unhandled rejection. (The prior code returned a native Promise here;
166
+ // this is adjacent hardening of pre-existing behavior — the identity
167
+ // hazard predates the streaming work.)
168
+ //
169
+ // Both the `.then` PROPERTY READ (`rt.then`) and the `.then(...)` CALL
170
+ // that registers our observer run SYNCHRONOUSLY on the success path of
171
+ // a customer's `create()` call, and were previously unguarded — a
172
+ // hostile thenable (a `.then` accessor that throws on read, or a
173
+ // `.then` function that throws synchronously when invoked) would turn
174
+ // a SUCCESSFUL Anthropic request into a synchronous exception thrown
175
+ // out of `wrappedCreate`. Wrap the read + registration so a broken
176
+ // thenable degrades instead of crashing the host: close the span with
177
+ // no telemetry and hand back rawResult UNCHANGED — never
178
+ // `Promise.resolve(...)`, which would also strip APIPromise
179
+ // identity/methods.
180
+ try {
181
+ const rt = rawResult;
182
+ if (rt && typeof rt.then === "function") {
183
+ rawResult.then((result) => {
184
+ (0, core_js_1.safe)(() => setChatResponseAttrs(liveSpan, sdk, result, logger), "anthropic.create.set_response_attrs", internalLogger);
185
+ (0, core_js_1.safe)(() => liveSpan.setStatus({ code: api_1.SpanStatusCode.OK }), "anthropic.create.set_ok_status", internalLogger);
186
+ (0, core_js_1.safe)(() => liveSpan.end(), "anthropic.create.span_end", internalLogger);
187
+ }, (err) => {
188
+ (0, core_js_1.safe)(() => recordErrorOnSpan(liveSpan, err), "anthropic.create.record_error", internalLogger);
189
+ (0, core_js_1.safe)(() => liveSpan.end(), "anthropic.create.span_end_on_error", internalLogger);
190
+ // Observer branch only — do NOT rethrow; the original APIPromise
191
+ // still rejects to the customer independently.
192
+ });
193
+ return rawResult;
194
+ }
195
+ }
196
+ catch {
197
+ // Reading `.then` or invoking it threw (hostile thenable). The
198
+ // underlying call already succeeded (rawResult exists) — this is an
199
+ // instrumentation-only failure, not an API error. Degrade like the
200
+ // other host-crash guards in this file (e.g. wrapStream's dispatch
201
+ // region below): close the span with no telemetry, hand back
202
+ // rawResult untouched.
203
+ (0, core_js_1.safe)(() => liveSpan.setStatus({ code: api_1.SpanStatusCode.OK }), "anthropic.create.set_ok_status", internalLogger);
204
+ (0, core_js_1.safe)(() => liveSpan.end(), "anthropic.create.span_end", internalLogger);
205
+ return rawResult;
206
+ }
207
+ // Non-thenable return (a synchronous / mocked result, or any SDK shape
208
+ // that isn't a promise): run the response telemetry inline on it, close
209
+ // the span, and hand it back untouched — matching the prior
210
+ // `Promise.resolve(...).then(...)` behavior, which unified thenable and
211
+ // non-thenable returns.
212
+ (0, core_js_1.safe)(() => setChatResponseAttrs(liveSpan, sdk, rawResult, logger), "anthropic.create.set_response_attrs", internalLogger);
213
+ (0, core_js_1.safe)(() => liveSpan.setStatus({ code: api_1.SpanStatusCode.OK }), "anthropic.create.set_ok_status", internalLogger);
214
+ (0, core_js_1.safe)(() => liveSpan.end(), "anthropic.create.span_end", internalLogger);
215
+ return rawResult;
154
216
  });
155
217
  };
156
218
  }
@@ -160,6 +222,10 @@ function wrapStream(original) {
160
222
  if (!patchCtx) {
161
223
  return original.call(this, params, opts);
162
224
  }
225
+ if ((0, context_js_1.isGenAiSuppressed)()) {
226
+ // A framework layer owns this chat span — run the call, emit no span.
227
+ return original.call(this, params, opts);
228
+ }
163
229
  const { tracer, sdk, logger } = patchCtx;
164
230
  const internalLogger = sdk.getInternalLogger();
165
231
  const model = params?.model ?? "unknown";
@@ -179,13 +245,6 @@ function wrapStream(original) {
179
245
  const liveSpan = span;
180
246
  const liveCtx = ctx;
181
247
  const storeSnapshot = (0, context_js_1.snapshotStore)();
182
- // Enter the span's context BEFORE request-side log emission so logs
183
- // link back to the chat span via TraceId. Same rationale as wrapCreate.
184
- // @anthropic-ai/sdk's `stream()` returns a MessageStream synchronously.
185
- const stream = api_1.context.with(liveCtx, () => {
186
- (0, core_js_1.safe)(() => setChatRequestAttrs(liveSpan, params, sdk, logger), "anthropic.stream.set_request_attrs", internalLogger);
187
- return original.call(this, params, opts);
188
- });
189
248
  let finalized = false;
190
249
  const finalize = (result, err) => {
191
250
  if (finalized)
@@ -205,43 +264,407 @@ function wrapStream(original) {
205
264
  (0, core_js_1.safe)(() => liveSpan.end(), "anthropic.stream.span_end", internalLogger);
206
265
  });
207
266
  };
208
- const ee = stream;
209
- if (ee && typeof ee.on === "function") {
210
- ee.on("finalMessage", (msg) => {
211
- finalize(msg, undefined);
212
- });
213
- ee.on("error", (err) => {
214
- finalize(undefined, err);
267
+ // Enter the span's context BEFORE request-side log emission so logs
268
+ // link back to the chat span via TraceId. Same rationale as wrapCreate.
269
+ // @anthropic-ai/sdk's `stream()` returns a MessageStream synchronously,
270
+ // but `messages.create({stream:true})` (delegated here from wrapCreate)
271
+ // returns an APIPromise-like thenable resolving to a raw event Stream —
272
+ // both shapes are handled below.
273
+ //
274
+ // Suppress nested instrumentation: the real `Messages.prototype.stream()`
275
+ // (`original` here) internally calls `MessageStream.createMessage`, whose
276
+ // executor synchronously invokes `messages.create({ ...params, stream:
277
+ // true }, ...)` — on the SAME patched `Messages` prototype — before its
278
+ // first `await` yields control. That resolves to our `wrapCreate`
279
+ // wrapper, which would otherwise see `params.stream === true` and
280
+ // re-delegate to a brand-new `wrapStream(original)`, instrumenting the
281
+ // same underlying raw stream a second time (2 chat spans, same
282
+ // gen_ai.response.id). `runWithContext({ suppressGenAi: true }, ...)`
283
+ // scopes an ALS store patch around exactly this synchronous call (plus
284
+ // any promise chain it kicks off internally, since AsyncLocalStorage
285
+ // propagates into `.then` continuations created within the `run()`
286
+ // callback) — `wrapCreate` checks `isGenAiSuppressed()` before its
287
+ // `stream === true` branch and, when suppressed, falls through to
288
+ // calling the TRUE raw `create` directly (no span, no re-wrap). This
289
+ // must NOT leak into user code: the store patch is popped the instant
290
+ // `original.call(...)` returns (it returns the MessageStream
291
+ // synchronously), so by the time control returns to the caller of
292
+ // `.stream()`, suppression is already off again — user code that later
293
+ // iterates the stream, or that itself calls `messages.create`/`.stream`,
294
+ // runs with a clean (unsuppressed) ALS store.
295
+ let stream;
296
+ try {
297
+ stream = api_1.context.with(liveCtx, () => {
298
+ (0, core_js_1.safe)(() => setChatRequestAttrs(liveSpan, params, sdk, logger), "anthropic.stream.set_request_attrs", internalLogger);
299
+ return (0, context_js_1.runWithContext)({ suppressGenAi: true }, () => original.call(this, params, opts));
215
300
  });
216
- // If the stream is created and never iterated / finalized, at least
217
- // don't hang on test exit — "end" fires after iteration completes.
218
- ee.on("end", () => {
219
- // Only fires if no finalMessage / error fired first (rare path).
301
+ }
302
+ catch (err) {
303
+ // Sync throw from the original call would otherwise leak the span —
304
+ // no stream/promise is ever produced to drive finalize() below.
305
+ finalize(undefined, err);
306
+ throw err;
307
+ }
308
+ // The entire branch-dispatch region below — reading `ee.on`/`.then`/
309
+ // `Symbol.asyncIterator`, registering `.on(...)` listeners, invoking
310
+ // `.then(...)`, and calling `instrumentSyncIterable` — runs SYNCHRONOUSLY
311
+ // on the success path of the customer's `.stream()`/`.create()` call, and
312
+ // was previously unguarded (round-4 only hardened the ASYNC observer
313
+ // callbacks registered inside it, e.g. the onFulfilled/onRejected
314
+ // handlers below). A hostile/broken stream-like object — a throwing
315
+ // `.on`/`.then`/`Symbol.asyncIterator` getter, or a `.on()` registration
316
+ // call that itself throws — would throw synchronously OUT of the
317
+ // customer's call. Wrap the whole dispatch in try/catch so a successful
318
+ // request never becomes a hard failure purely because OUR observer
319
+ // wiring couldn't attach: degrade to no telemetry and hand back the
320
+ // ORIGINAL stream object unchanged.
321
+ try {
322
+ const ee = stream;
323
+ // Emitter path wins first — a real MessageStream (`.on`) is never
324
+ // thenable, but check `.on` before `.then` defensively in case some
325
+ // future stream shape exposes both.
326
+ if (ee && typeof ee.on === "function") {
327
+ ee.on("finalMessage", (msg) => {
328
+ finalize(msg, undefined);
329
+ });
330
+ ee.on("error", (err) => {
331
+ finalize(undefined, err);
332
+ });
333
+ // Abort fires before "end" on an aborted stream — without this
334
+ // listener the "end" handler below finalizes with OK status even
335
+ // though the request was aborted. `finalized` makes the subsequent
336
+ // "end" a no-op.
337
+ ee.on("abort", (err) => {
338
+ finalize(undefined, err ?? new Error("aborted"));
339
+ });
340
+ // If the stream is created and never iterated / finalized, at least
341
+ // don't hang on test exit — "end" fires after iteration completes.
342
+ ee.on("end", () => {
343
+ // Only fires if no finalMessage / error / abort fired first (rare
344
+ // path for a healthy, fully-drained stream).
345
+ finalize(undefined, undefined);
346
+ });
347
+ }
348
+ else if (ee && typeof ee.then === "function") {
349
+ // `create({stream:true})` shape: an APIPromise-like thenable that
350
+ // resolves to a raw event Stream (no `.on`, no synchronous
351
+ // asyncIterator until resolved). Observe the resolution to instrument
352
+ // the resolved Stream BY MUTATION, but return the ORIGINAL thenable —
353
+ // Anthropic's APIPromise exposes methods like `.withResponse()` /
354
+ // `.asResponse()` that a native `Promise.resolve(...).then(...)` would
355
+ // strip, breaking customer code only when instrumentation is enabled.
356
+ // Our observer registers synchronously here, before the caller can
357
+ // await, so instrumentRawStream mutates the same resolved object the
358
+ // caller receives, before they iterate it.
359
+ const thenable = stream;
360
+ const observerChain = thenable.then((raw) => {
361
+ // instrumentRawStream can itself throw at points outside its OWN
362
+ // internal try/catch (e.g. a hostile `Symbol.asyncIterator`
363
+ // getter, read before that internal guard) — this must not
364
+ // escape THIS onFulfilled callback: an uncaught throw here
365
+ // becomes an unhandled rejection on the promise `.then()`
366
+ // returns, even though the customer's original APIPromise
367
+ // resolved successfully — a successful request must never crash
368
+ // the host. Degrade: end the span with no telemetry and swallow.
369
+ try {
370
+ instrumentRawStream(raw);
371
+ }
372
+ catch {
373
+ finalize(undefined, undefined);
374
+ }
375
+ }, (err) => {
376
+ // Observe the rejection to finalize the span, but do NOT rethrow:
377
+ // this observer branch must never surface as an unhandled
378
+ // rejection. The original thenable still rejects to the caller
379
+ // independently.
380
+ finalize(undefined, err);
381
+ });
382
+ // Belt-and-suspenders: both branches above are already guarded to
383
+ // never throw/reject, so this derived promise should never reject —
384
+ // but chain a no-op `.catch` anyway so this internal observer chain
385
+ // can NEVER become an unhandled-rejection source, even under future
386
+ // edits to the branches above.
387
+ observerChain.catch?.(() => { });
388
+ return stream;
389
+ }
390
+ else if (ee && typeof ee[Symbol.asyncIterator] === "function") {
391
+ // Wrap the async iterator so each .next() runs inside the captured
392
+ // ALS store.
393
+ instrumentSyncIterable(ee);
394
+ }
395
+ return stream;
396
+ }
397
+ catch {
398
+ finalize(undefined, undefined);
399
+ return stream;
400
+ }
401
+ function instrumentRawStream(raw) {
402
+ const it = raw;
403
+ if (!it || typeof it[Symbol.asyncIterator] !== "function") {
404
+ finalize(undefined, undefined); // not iterable — end the span rather than leak it
405
+ return raw;
406
+ }
407
+ // A raw stream that is awaited but never iterated would otherwise leak
408
+ // its span — finalize() only fires through the iterator hooks below.
409
+ // Anthropic's Stream carries an AbortController; close the span if the
410
+ // request is aborted before/without iteration. Residual (documented,
411
+ // accepted): a stream awaited, never iterated, AND never aborted stays
412
+ // open until process exit — no lifecycle signal exists to close it.
413
+ //
414
+ // This whole section reads a `controller` getter and calls
415
+ // `addEventListener` on whatever object the SDK (or a hostile/broken
416
+ // caller-supplied stand-in) hands us — both are fallible and, unlike
417
+ // the `Symbol.asyncIterator` assignment below, were previously
418
+ // unguarded. Wrap it so a throwing getter degrades to "no abort-driven
419
+ // finalize wiring" rather than aborting instrumentRawStream entirely
420
+ // (which would also skip the Symbol.asyncIterator instrumentation
421
+ // below and lose the whole stream's telemetry, not just the abort
422
+ // hook). The caller (the observer's onFulfilled, or the synchronous
423
+ // `.on()`/asyncIterator paths in wrapStream) also guards its own call
424
+ // to instrumentRawStream, so this is defense-in-depth, not the only
425
+ // guard.
426
+ try {
427
+ const ctrl = raw.controller;
428
+ const sig = ctrl?.signal;
429
+ if (sig) {
430
+ if (sig.aborted) {
431
+ finalize(undefined, sig.reason ?? new Error("aborted"));
432
+ }
433
+ else {
434
+ sig.addEventListener("abort", () => finalize(undefined, sig.reason ?? new Error("aborted")), { once: true });
435
+ }
436
+ }
437
+ }
438
+ catch {
439
+ // Swallow — no abort-driven finalize wiring for this stream, but
440
+ // fall through to installing the Symbol.asyncIterator override
441
+ // below so normal iteration still drives accumulate()/finalize().
442
+ }
443
+ const acc = {};
444
+ const originalIter = it[Symbol.asyncIterator].bind(it);
445
+ try {
446
+ it[Symbol.asyncIterator] = () => {
447
+ const inner = originalIter();
448
+ return {
449
+ next: () => (0, context_js_1.runWithStore)(storeSnapshot, () => inner.next().then((res) => {
450
+ // accumulate()/accToResponseMessage() inspect provider-owned
451
+ // values (a proxy getter can throw; accToResponseMessage
452
+ // JSON.parses input_json_delta, which may be malformed). A
453
+ // successfully yielded item must NEVER become a rejected
454
+ // next(): on any instrumentation failure, degrade telemetry
455
+ // (finalize with no result) and return the original res.
456
+ try {
457
+ if (!res.done)
458
+ accumulate(acc, res.value);
459
+ else
460
+ finalize(accToResponseMessage(acc), undefined);
461
+ }
462
+ catch {
463
+ finalize(undefined, undefined);
464
+ }
465
+ return res;
466
+ }, (err) => {
467
+ finalize(undefined, err);
468
+ throw err;
469
+ })),
470
+ return: (v) => (0, context_js_1.runWithStore)(storeSnapshot, () => {
471
+ // Settle the inner iterator's cleanup BEFORE finalizing, so a
472
+ // cleanup rejection is recorded as ERROR rather than the span
473
+ // being closed OK while the caller receives a rejection.
474
+ const ret = inner.return
475
+ ? inner.return(v)
476
+ : Promise.resolve({ value: v, done: true });
477
+ return Promise.resolve(ret).then((res) => {
478
+ finalize(accToResponseMessage(acc), undefined); // early break — close OK
479
+ return res;
480
+ }, (err) => {
481
+ finalize(undefined, err); // cleanup rejected — record ERROR
482
+ throw err; // propagate the original rejection unchanged
483
+ });
484
+ }),
485
+ throw: (e) => (0, context_js_1.runWithStore)(storeSnapshot, () => {
486
+ // The thrown value IS the error condition, so finalize ERROR
487
+ // with it regardless of the inner iterator's cleanup outcome.
488
+ finalize(undefined, e);
489
+ return inner.throw ? inner.throw(e) : Promise.reject(e);
490
+ }),
491
+ };
492
+ };
493
+ }
494
+ catch {
495
+ // The resolved stream object is frozen/sealed or the property is
496
+ // non-writable — assigning Symbol.asyncIterator threw. A successful
497
+ // request must never become a rejected promise because OUR
498
+ // instrumentation couldn't attach; degrade gracefully (end the span
499
+ // with no telemetry) and hand back the untouched object. No Proxy
500
+ // fallback — that would change the stream's identity, which is its
501
+ // own hazard.
220
502
  finalize(undefined, undefined);
221
- });
503
+ }
504
+ return raw;
222
505
  }
223
- // Wrap the async iterator so each .next() runs inside the captured ALS store.
224
- if (ee && typeof ee[Symbol.asyncIterator] === "function") {
225
- const originalIter = ee[Symbol.asyncIterator].bind(ee);
226
- ee[Symbol.asyncIterator] = () => {
227
- const inner = originalIter();
228
- const wrapped = {
229
- next() {
230
- return (0, context_js_1.runWithStore)(storeSnapshot, () => inner.next());
231
- },
232
- return(value) {
233
- return (0, context_js_1.runWithStore)(storeSnapshot, () => inner.return ? inner.return(value) : Promise.resolve({ value, done: true }));
234
- },
235
- throw(err) {
236
- return (0, context_js_1.runWithStore)(storeSnapshot, () => inner.throw
237
- ? inner.throw(err)
238
- : Promise.reject(err));
239
- },
506
+ function instrumentSyncIterable(target) {
507
+ const originalIter = target[Symbol.asyncIterator].bind(target);
508
+ try {
509
+ target[Symbol.asyncIterator] = () => {
510
+ const inner = originalIter();
511
+ const wrapped = {
512
+ next() {
513
+ return (0, context_js_1.runWithStore)(storeSnapshot, () => inner.next());
514
+ },
515
+ return(value) {
516
+ return (0, context_js_1.runWithStore)(storeSnapshot, () => inner.return
517
+ ? inner.return(value)
518
+ : Promise.resolve({ value, done: true }));
519
+ },
520
+ throw(err) {
521
+ return (0, context_js_1.runWithStore)(storeSnapshot, () => inner.throw ? inner.throw(err) : Promise.reject(err));
522
+ },
523
+ };
524
+ return wrapped;
240
525
  };
241
- return wrapped;
526
+ }
527
+ catch {
528
+ // Same hazard class as instrumentRawStream above: the target is
529
+ // frozen/sealed or the property is non-writable. This call happens
530
+ // synchronously inside wrapStream itself (not inside a promise
531
+ // callback), so an uncaught throw here would surface as a
532
+ // SYNCHRONOUS exception out of the user's `.stream()`/`.create()`
533
+ // call — turning a successful request into a hard failure. Degrade
534
+ // gracefully instead: end the span with no telemetry and leave the
535
+ // iterable completely untouched (the failed assignment never took
536
+ // effect).
537
+ finalize(undefined, undefined);
538
+ }
539
+ }
540
+ function accumulate(acc, ev) {
541
+ const e = ev;
542
+ if (e?.type === "message_start" && e.message) {
543
+ acc.id = e.message.id;
544
+ acc.model = e.message.model;
545
+ acc.inputTokens = e.message.usage?.input_tokens;
546
+ // Cached `create({stream:true})` requests carry cache token counts
547
+ // on message_start's usage (never on message_delta's) — without
548
+ // these, the synthesized ResponseMessage under-reports input usage
549
+ // (setChatResponseAttrs adds them into the total) and omits both
550
+ // cache attributes entirely, unlike the non-streaming and
551
+ // MessageStream (`.on("finalMessage")`) paths, which pass the SDK's
552
+ // own parsed usage straight through.
553
+ acc.cacheReadTokens = e.message.usage?.cache_read_input_tokens;
554
+ acc.cacheCreationTokens = e.message.usage?.cache_creation_input_tokens;
555
+ }
556
+ else if (e?.type === "content_block_start" && typeof e.index === "number") {
557
+ // Assemble content blocks so the synthesized response carries
558
+ // `content` — without it, setChatResponseAttrs's `if (response.content)`
559
+ // block never runs on this raw-stream path, dropping
560
+ // gen_ai.output.messages, the gen_ai.choice event, and (critically)
561
+ // recordPendingToolCalls, so a streamed tool_use never links to its
562
+ // execute_tool span. Mirrors MessageStream's finalMessage assembly,
563
+ // minimally: text blocks and tool_use blocks only.
564
+ const blocks = (acc.blocks ??= {});
565
+ const cb = e.content_block;
566
+ if (cb?.type === "tool_use") {
567
+ blocks[e.index] = {
568
+ type: "tool_use",
569
+ id: cb.id,
570
+ name: cb.name,
571
+ jsonBuf: "",
572
+ };
573
+ }
574
+ else if (cb?.type === "text") {
575
+ blocks[e.index] = { type: "text", text: cb.text ?? "" };
576
+ }
577
+ else if (cb?.type) {
578
+ blocks[e.index] = { type: cb.type };
579
+ }
580
+ }
581
+ else if (e?.type === "content_block_delta" && typeof e.index === "number") {
582
+ const blocks = (acc.blocks ??= {});
583
+ const b = blocks[e.index];
584
+ if (!b)
585
+ return;
586
+ if (e.delta?.type === "text_delta" && typeof e.delta.text === "string") {
587
+ b.text = (b.text ?? "") + e.delta.text;
588
+ }
589
+ else if (e.delta?.type === "input_json_delta" &&
590
+ typeof e.delta.partial_json === "string") {
591
+ b.jsonBuf = (b.jsonBuf ?? "") + e.delta.partial_json;
592
+ }
593
+ }
594
+ else if (e?.type === "content_block_stop" && typeof e.index === "number") {
595
+ const blocks = (acc.blocks ??= {});
596
+ const b = blocks[e.index];
597
+ if (b?.type === "tool_use") {
598
+ // Parse the accumulated partial_json into the tool_use input; fall
599
+ // back to the raw string if the model streamed malformed JSON (the
600
+ // block still links via id/name, which is what tool-call linkage
601
+ // needs).
602
+ try {
603
+ b.input = b.jsonBuf ? JSON.parse(b.jsonBuf) : {};
604
+ }
605
+ catch {
606
+ b.input = b.jsonBuf;
607
+ }
608
+ }
609
+ }
610
+ else if (e?.type === "message_delta") {
611
+ if (e.delta?.stop_reason)
612
+ acc.stopReason = e.delta.stop_reason;
613
+ if (e.usage?.output_tokens !== undefined) {
614
+ acc.outputTokens = e.usage.output_tokens;
615
+ }
616
+ }
617
+ }
618
+ // NOTE (parity, intentional divergence — self-audit round 5, item G):
619
+ // this synthesizes a FULL ResponseMessage (content, usage, stop_reason,
620
+ // id, model) from the raw SSE event stream, matching what the
621
+ // non-streaming `create()` path and `MessageStream`'s `finalMessage`
622
+ // event already carry. struct-sdk-python's `create(stream=True)` raw
623
+ // path does NOT do this reconstruction yet and emits none of these
624
+ // response-side attributes for that one call shape. This is TS being
625
+ // MORE complete than Python here, not a TS bug — do not remove it to
626
+ // "match" Python. Bringing Python's raw-stream path up to parity is a
627
+ // separate, independent follow-up.
628
+ function accToResponseMessage(acc) {
629
+ const blocks = acc.blocks;
630
+ if (acc.id === undefined && acc.inputTokens === undefined && !blocks) {
631
+ return undefined;
632
+ }
633
+ // Rebuild content in ascending block index, shaped like the SDK's own
634
+ // parsed message content so setChatResponseAttrs / iterToolUses read it
635
+ // identically to the non-streaming path.
636
+ let content;
637
+ if (blocks) {
638
+ content = Object.keys(blocks)
639
+ .map((k) => Number(k))
640
+ .sort((a, b) => a - b)
641
+ .map((i) => {
642
+ const b = blocks[i];
643
+ if (b.type === "tool_use") {
644
+ return { type: "tool_use", id: b.id, name: b.name, input: b.input ?? {} };
645
+ }
646
+ if (b.type === "text") {
647
+ return { type: "text", text: b.text ?? "" };
648
+ }
649
+ return { type: b.type };
650
+ });
651
+ }
652
+ return {
653
+ id: acc.id,
654
+ model: acc.model,
655
+ stop_reason: acc.stopReason,
656
+ content: content,
657
+ usage: {
658
+ input_tokens: acc.inputTokens,
659
+ output_tokens: acc.outputTokens,
660
+ // Raw fields only — setChatResponseAttrs remains the single place
661
+ // that adds these into the reported total, matching the
662
+ // non-streaming/MessageStream contract exactly.
663
+ cache_read_input_tokens: acc.cacheReadTokens,
664
+ cache_creation_input_tokens: acc.cacheCreationTokens,
665
+ },
242
666
  };
243
667
  }
244
- return stream;
245
668
  };
246
669
  }
247
670
  function setChatRequestAttrs(span, params, sdk, logger) {