llm-relay 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Observe provider-reported completion usage without putting an observer in
3
- * the response path. In particular, this module never changes (or buffers)
4
- * a response chunk: it only keeps a small amount of parsing state alongside
2
+ * Observe provider-reported usage and relay-estimated model output without
3
+ * changing the response path. In particular, this module never changes (or
4
+ * buffers) a response chunk: it only keeps bounded parsing state alongside
5
5
  * the stream.
6
6
  */
7
+ import { estimateTokensFromCharacters } from "./metadata.js";
7
8
  export function createUsageAccumulator() {
8
9
  return {
9
10
  inputTokens: undefined,
@@ -12,11 +13,15 @@ export function createUsageAccumulator() {
12
13
  cacheCreationInputTokens: undefined,
13
14
  cacheReadInputTokens: undefined,
14
15
  completionTokens: undefined,
16
+ estimatedOutputTokens: undefined,
15
17
  };
16
18
  }
17
19
  const MAX_SSE_FRAME = 16 * 1024;
18
20
  const MAX_BUFFERED_JSON = 1024 * 1024;
19
21
  const MAX_DECODE_SLICE = 4096;
22
+ const MAX_PENDING_ARGUMENT_CHARS = 64 * 1024;
23
+ const MAX_PENDING_TOOL_CALLS = 128;
24
+ const MAX_STREAMED_TEXT_FIELDS = 128;
20
25
  function validCompletionTokens(value) {
21
26
  return (typeof value === "number" &&
22
27
  Number.isFinite(value) &&
@@ -36,6 +41,365 @@ function recordField(accumulator, field, value) {
36
41
  function asRecord(value) {
37
42
  return value && typeof value === "object" ? value : undefined;
38
43
  }
44
+ function publishOutputEstimate(accumulator, state) {
45
+ try {
46
+ accumulator.estimatedOutputTokens = state.overflow
47
+ || state.pendingArguments.size > 0
48
+ || state.characters <= 0
49
+ ? undefined
50
+ : estimateTokensFromCharacters(state.characters);
51
+ }
52
+ catch {
53
+ // A malformed/hostile accumulator must not affect pass-through.
54
+ }
55
+ }
56
+ function isBase64DataUrl(value) {
57
+ if (value.length < 13 || value.slice(0, 5).toLowerCase() !== "data:")
58
+ return false;
59
+ const comma = value.indexOf(",", 5);
60
+ return comma >= 12 && value.slice(comma - 7, comma).toLowerCase() === ";base64";
61
+ }
62
+ function sanitizedArgumentJson(value) {
63
+ let removedBinary = false;
64
+ try {
65
+ const json = JSON.stringify(value, (key, nestedValue) => {
66
+ if (typeof nestedValue === "string" && (key === "data" || isBase64DataUrl(nestedValue))) {
67
+ removedBinary = true;
68
+ return "";
69
+ }
70
+ return nestedValue;
71
+ });
72
+ return { characters: json?.length ?? 0, removedBinary };
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ }
78
+ function argumentCharacters(value) {
79
+ if (typeof value === "string") {
80
+ if (isBase64DataUrl(value))
81
+ return 0;
82
+ if (value.length === 0 || value.trim().length === 0)
83
+ return value.length;
84
+ try {
85
+ const parsed = JSON.parse(value);
86
+ const sanitized = sanitizedArgumentJson(parsed);
87
+ if (!sanitized)
88
+ return undefined;
89
+ return sanitized.removedBinary ? sanitized.characters : value.length;
90
+ }
91
+ catch {
92
+ // Malformed JSON cannot be semantically inspected for escaped binary
93
+ // fields. Unknown is safer than a knowingly inflated raw-length total.
94
+ return undefined;
95
+ }
96
+ }
97
+ return sanitizedArgumentJson(value)?.characters;
98
+ }
99
+ function recordOutputCharacters(accumulator, state, characters) {
100
+ if (state.overflow || characters <= 0 || !Number.isSafeInteger(characters))
101
+ return;
102
+ const next = state.characters + characters;
103
+ if (!Number.isSafeInteger(next)) {
104
+ taintOutputEstimate(accumulator, state);
105
+ return;
106
+ }
107
+ state.characters = next;
108
+ publishOutputEstimate(accumulator, state);
109
+ }
110
+ function taintOutputEstimate(accumulator, state) {
111
+ state.overflow = true;
112
+ state.pendingArgumentCharacters = 0;
113
+ state.pendingArguments.clear();
114
+ state.streamedTexts.clear();
115
+ try {
116
+ accumulator.estimatedOutputTokens = undefined;
117
+ }
118
+ catch {
119
+ // A malformed/hostile accumulator must not affect pass-through.
120
+ }
121
+ }
122
+ function recordOutputText(accumulator, state, value) {
123
+ if (typeof value !== "string" || isBase64DataUrl(value))
124
+ return;
125
+ recordOutputCharacters(accumulator, state, value.length);
126
+ }
127
+ /**
128
+ * A streamed logical string may be split before `data:...;base64,` becomes
129
+ * recognizable. Count partial text immediately, but retain only enough state
130
+ * to roll it back if later fragments prove the whole string is binary.
131
+ */
132
+ function appendStreamedOutputText(accumulator, state, key, value) {
133
+ if (state.overflow || typeof value !== "string" || value.length === 0)
134
+ return;
135
+ let text = state.streamedTexts.get(key);
136
+ if (!text) {
137
+ if (state.streamedTexts.size >= MAX_STREAMED_TEXT_FIELDS) {
138
+ taintOutputEstimate(accumulator, state);
139
+ return;
140
+ }
141
+ text = {
142
+ disposition: "candidate",
143
+ heldCharacters: 0,
144
+ prefix: "",
145
+ headerTail: "",
146
+ };
147
+ state.streamedTexts.set(key, text);
148
+ }
149
+ if (text.disposition === "count") {
150
+ recordOutputCharacters(accumulator, state, value.length);
151
+ return;
152
+ }
153
+ if (text.disposition === "skip")
154
+ return;
155
+ const heldCharacters = text.heldCharacters + value.length;
156
+ const totalCharacters = state.characters + value.length;
157
+ if (!Number.isSafeInteger(heldCharacters) || !Number.isSafeInteger(totalCharacters)) {
158
+ taintOutputEstimate(accumulator, state);
159
+ return;
160
+ }
161
+ text.heldCharacters = heldCharacters;
162
+ state.characters = totalCharacters;
163
+ for (const character of value) {
164
+ if (text.prefix.length < 5) {
165
+ text.prefix += character;
166
+ if (text.prefix.toLowerCase() !== "data:".slice(0, text.prefix.length)) {
167
+ text.disposition = "count";
168
+ text.heldCharacters = 0;
169
+ publishOutputEstimate(accumulator, state);
170
+ return;
171
+ }
172
+ continue;
173
+ }
174
+ if (character === ",") {
175
+ if (text.headerTail.toLowerCase() === ";base64") {
176
+ text.disposition = "skip";
177
+ state.characters -= text.heldCharacters;
178
+ text.heldCharacters = 0;
179
+ publishOutputEstimate(accumulator, state);
180
+ }
181
+ else {
182
+ text.disposition = "count";
183
+ text.heldCharacters = 0;
184
+ publishOutputEstimate(accumulator, state);
185
+ }
186
+ return;
187
+ }
188
+ text.headerTail = `${text.headerTail}${character}`.slice(-7);
189
+ }
190
+ publishOutputEstimate(accumulator, state);
191
+ }
192
+ function flushStreamedOutputText(state, key) {
193
+ const text = state.streamedTexts.get(key);
194
+ if (!text)
195
+ return;
196
+ state.streamedTexts.delete(key);
197
+ }
198
+ function flushStreamedOutputTextPrefix(state, prefix) {
199
+ for (const key of [...state.streamedTexts.keys()]) {
200
+ if (key.startsWith(`${prefix}:`))
201
+ flushStreamedOutputText(state, key);
202
+ }
203
+ }
204
+ function flushAllStreamedOutputText(state) {
205
+ for (const key of [...state.streamedTexts.keys()]) {
206
+ flushStreamedOutputText(state, key);
207
+ }
208
+ }
209
+ function recordToolArguments(accumulator, state, value) {
210
+ const characters = argumentCharacters(value);
211
+ if (characters === undefined) {
212
+ taintOutputEstimate(accumulator, state);
213
+ return;
214
+ }
215
+ recordOutputCharacters(accumulator, state, characters);
216
+ }
217
+ function appendToolArguments(accumulator, state, key, value) {
218
+ if (state.overflow)
219
+ return;
220
+ if (typeof value !== "string") {
221
+ recordToolArguments(accumulator, state, value);
222
+ return;
223
+ }
224
+ if (value.length === 0)
225
+ return;
226
+ const prior = state.pendingArguments.get(key);
227
+ if (prior === undefined && state.pendingArguments.size >= MAX_PENDING_TOOL_CALLS) {
228
+ taintOutputEstimate(accumulator, state);
229
+ return;
230
+ }
231
+ const nextSize = state.pendingArgumentCharacters + value.length;
232
+ if (!Number.isSafeInteger(nextSize) || nextSize > MAX_PENDING_ARGUMENT_CHARS) {
233
+ taintOutputEstimate(accumulator, state);
234
+ return;
235
+ }
236
+ state.pendingArguments.set(key, (prior ?? "") + value);
237
+ state.pendingArgumentCharacters = nextSize;
238
+ // Until the complete JSON can be sanitized, a partial tool argument could be
239
+ // base64. Hide the whole estimate rather than publish a knowingly partial total.
240
+ publishOutputEstimate(accumulator, state);
241
+ }
242
+ function flushToolArguments(accumulator, state, key) {
243
+ const value = state.pendingArguments.get(key);
244
+ if (value === undefined)
245
+ return;
246
+ state.pendingArguments.delete(key);
247
+ state.pendingArgumentCharacters -= value.length;
248
+ recordToolArguments(accumulator, state, value);
249
+ publishOutputEstimate(accumulator, state);
250
+ }
251
+ function flushAllToolArguments(accumulator, state) {
252
+ for (const key of [...state.pendingArguments.keys()]) {
253
+ flushToolArguments(accumulator, state, key);
254
+ }
255
+ }
256
+ function flushToolArgumentsPrefix(accumulator, state, prefix) {
257
+ for (const key of [...state.pendingArguments.keys()]) {
258
+ if (key.startsWith(`${prefix}:`))
259
+ flushToolArguments(accumulator, state, key);
260
+ }
261
+ }
262
+ function streamArgumentKey(prefix, value, fallback) {
263
+ const index = typeof value === "number" && Number.isSafeInteger(value) && value >= 0
264
+ ? value
265
+ : fallback;
266
+ return `${prefix}:${index}`;
267
+ }
268
+ function isAnthropicToolUseBlock(type) {
269
+ return type === "tool_use" || type === "server_tool_use" || type === "mcp_tool_use";
270
+ }
271
+ function inspectAnthropicOutput(root, accumulator, state) {
272
+ const inspectBlock = (value, includeToolArguments) => {
273
+ const block = asRecord(value);
274
+ if (!block)
275
+ return;
276
+ if (block.type === "text")
277
+ recordOutputText(accumulator, state, block.text);
278
+ else if (block.type === "thinking")
279
+ recordOutputText(accumulator, state, block.thinking);
280
+ else if (includeToolArguments && isAnthropicToolUseBlock(block.type)) {
281
+ recordToolArguments(accumulator, state, block.input);
282
+ }
283
+ };
284
+ if (root.type === "message") {
285
+ if (Array.isArray(root.content)) {
286
+ root.content.forEach((block) => inspectBlock(block, true));
287
+ }
288
+ return;
289
+ }
290
+ const key = streamArgumentKey("anthropic", root.index, "unknown");
291
+ if (root.type === "content_block_start") {
292
+ // Anthropic streams seed tool-use blocks with protocol-owned `input: {}`;
293
+ // the authored argument JSON arrives in input_json_delta frames.
294
+ const block = asRecord(root.content_block);
295
+ if (block?.type === "text") {
296
+ appendStreamedOutputText(accumulator, state, `${key}:text`, block.text);
297
+ }
298
+ else if (block?.type === "thinking") {
299
+ appendStreamedOutputText(accumulator, state, `${key}:thinking`, block.thinking);
300
+ }
301
+ return;
302
+ }
303
+ if (root.type === "content_block_stop") {
304
+ flushToolArguments(accumulator, state, key);
305
+ flushStreamedOutputTextPrefix(state, key);
306
+ return;
307
+ }
308
+ if (root.type !== "content_block_delta")
309
+ return;
310
+ const delta = asRecord(root.delta);
311
+ if (!delta)
312
+ return;
313
+ if (delta.type === "text_delta") {
314
+ appendStreamedOutputText(accumulator, state, `${key}:text`, delta.text);
315
+ }
316
+ else if (delta.type === "thinking_delta") {
317
+ appendStreamedOutputText(accumulator, state, `${key}:thinking`, delta.thinking);
318
+ }
319
+ else if (delta.type === "input_json_delta") {
320
+ appendToolArguments(accumulator, state, key, delta.partial_json);
321
+ }
322
+ }
323
+ function inspectOpenAiMessage(value, accumulator, state, streamKeyPrefix) {
324
+ const message = asRecord(value);
325
+ if (!message)
326
+ return;
327
+ if (typeof message.content === "string") {
328
+ if (streamKeyPrefix) {
329
+ appendStreamedOutputText(accumulator, state, `${streamKeyPrefix}:content`, message.content);
330
+ }
331
+ else {
332
+ recordOutputText(accumulator, state, message.content);
333
+ }
334
+ }
335
+ else if (Array.isArray(message.content)) {
336
+ for (const [partOffset, partValue] of message.content.entries()) {
337
+ const part = asRecord(partValue);
338
+ if (part && (part.type === "text" || part.type === "output_text")) {
339
+ if (streamKeyPrefix) {
340
+ const key = streamArgumentKey(`${streamKeyPrefix}:content`, part.index, partOffset);
341
+ appendStreamedOutputText(accumulator, state, key, part.text);
342
+ }
343
+ else {
344
+ recordOutputText(accumulator, state, part.text);
345
+ }
346
+ }
347
+ }
348
+ }
349
+ // Providers use these as alternate spellings. Prefer the explicit Chat field
350
+ // so a host emitting both aliases cannot double the same reasoning text.
351
+ const reasoningContent = typeof message.reasoning_content === "string"
352
+ && message.reasoning_content.length > 0
353
+ ? message.reasoning_content
354
+ : message.reasoning;
355
+ if (streamKeyPrefix) {
356
+ appendStreamedOutputText(accumulator, state, `${streamKeyPrefix}:reasoning`, reasoningContent);
357
+ appendStreamedOutputText(accumulator, state, `${streamKeyPrefix}:refusal`, message.refusal);
358
+ }
359
+ else {
360
+ recordOutputText(accumulator, state, reasoningContent);
361
+ recordOutputText(accumulator, state, message.refusal);
362
+ }
363
+ const functionCall = asRecord(message.function_call);
364
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
365
+ if (toolCalls.length === 0 && functionCall) {
366
+ if (streamKeyPrefix) {
367
+ appendToolArguments(accumulator, state, `${streamKeyPrefix}:function`, functionCall.arguments);
368
+ }
369
+ else {
370
+ recordToolArguments(accumulator, state, functionCall.arguments);
371
+ }
372
+ }
373
+ for (const [callOffset, callValue] of toolCalls.entries()) {
374
+ const call = asRecord(callValue);
375
+ const fn = call && asRecord(call.function);
376
+ if (!fn)
377
+ continue;
378
+ if (streamKeyPrefix) {
379
+ const key = streamArgumentKey(streamKeyPrefix, call?.index, callOffset);
380
+ appendToolArguments(accumulator, state, key, fn.arguments);
381
+ }
382
+ else {
383
+ recordToolArguments(accumulator, state, fn.arguments);
384
+ }
385
+ }
386
+ }
387
+ function inspectOpenAiOutput(root, accumulator, state, streamed) {
388
+ if (!Array.isArray(root.choices))
389
+ return;
390
+ for (const [choiceOffset, choiceValue] of root.choices.entries()) {
391
+ const choice = asRecord(choiceValue);
392
+ if (!choice)
393
+ continue;
394
+ inspectOpenAiMessage(choice.message, accumulator, state);
395
+ const prefix = streamArgumentKey("openai", choice.index, choiceOffset);
396
+ inspectOpenAiMessage(choice.delta, accumulator, state, streamed ? prefix : undefined);
397
+ if (streamed && choice.finish_reason !== undefined && choice.finish_reason !== null) {
398
+ flushToolArgumentsPrefix(accumulator, state, prefix);
399
+ flushStreamedOutputTextPrefix(state, prefix);
400
+ }
401
+ }
402
+ }
39
403
  function usageRecords(value, protocol) {
40
404
  const root = asRecord(value);
41
405
  if (!root)
@@ -73,7 +437,19 @@ function inspectUsageRecord(usage, protocol, accumulator) {
73
437
  if (details)
74
438
  recordField(accumulator, "cachedInputTokens", details.cached_tokens);
75
439
  }
76
- function inspectJson(value, protocol, accumulator) {
440
+ function inspectJson(value, protocol, accumulator, estimate, streamed = false) {
441
+ try {
442
+ const root = asRecord(value);
443
+ if (root) {
444
+ if (protocol === "anthropic-messages")
445
+ inspectAnthropicOutput(root, accumulator, estimate);
446
+ else
447
+ inspectOpenAiOutput(root, accumulator, estimate, streamed);
448
+ }
449
+ }
450
+ catch {
451
+ taintOutputEstimate(accumulator, estimate);
452
+ }
77
453
  try {
78
454
  for (const usage of usageRecords(value, protocol)) {
79
455
  inspectUsageRecord(usage, protocol, accumulator);
@@ -86,24 +462,45 @@ function inspectJson(value, protocol, accumulator) {
86
462
  function newSseState() {
87
463
  return { event: undefined, data: [], size: 0, overflow: false };
88
464
  }
89
- function inspectSseFrame(state, protocol, accumulator) {
465
+ function inspectSseFrame(state, protocol, accumulator, estimate) {
90
466
  if (state.overflow || state.data.length === 0)
91
467
  return;
468
+ const data = state.data.join("\n");
469
+ const trimmed = data.trim();
470
+ if (trimmed.length === 0 || trimmed === "[DONE]")
471
+ return;
92
472
  try {
93
- const value = JSON.parse(state.data.join("\n"));
473
+ const value = JSON.parse(data);
94
474
  if (protocol === "anthropic-messages") {
95
475
  const root = asRecord(value);
96
- if (root && (state.event === "message_start" || state.event === "message_delta" || root.type === "message_start" || root.type === "message_delta")) {
97
- inspectJson(root, protocol, accumulator);
476
+ if (root) {
477
+ try {
478
+ inspectAnthropicOutput(root, accumulator, estimate);
479
+ }
480
+ catch {
481
+ taintOutputEstimate(accumulator, estimate);
482
+ }
483
+ if (state.event === "message_start" || state.event === "message_delta" || root.type === "message_start" || root.type === "message_delta") {
484
+ try {
485
+ for (const usage of usageRecords(root, protocol)) {
486
+ inspectUsageRecord(usage, protocol, accumulator);
487
+ }
488
+ }
489
+ catch {
490
+ // A reported-usage observer failure is isolated from pass-through.
491
+ }
492
+ }
98
493
  }
99
494
  }
100
495
  else {
101
496
  // The only non-Anthropic protocol is openai-chat: every frame is inspected.
102
- inspectJson(value, protocol, accumulator);
497
+ inspectJson(value, protocol, accumulator, estimate, true);
103
498
  }
104
499
  }
105
500
  catch {
106
- // Malformed/incomplete SSE data is simply not a usage report.
501
+ // A skipped frame might have carried content. Preserve traffic, but do not
502
+ // leave a partial estimate looking complete.
503
+ taintOutputEstimate(accumulator, estimate);
107
504
  }
108
505
  }
109
506
  function resetSse(state) {
@@ -112,9 +509,9 @@ function resetSse(state) {
112
509
  state.size = 0;
113
510
  state.overflow = false;
114
511
  }
115
- function processSseLine(line, terminatorBytes, state, protocol, accumulator) {
512
+ function processSseLine(line, terminatorBytes, state, protocol, accumulator, estimate) {
116
513
  if (line === "") {
117
- inspectSseFrame(state, protocol, accumulator);
514
+ inspectSseFrame(state, protocol, accumulator, estimate);
118
515
  resetSse(state);
119
516
  return;
120
517
  }
@@ -124,6 +521,7 @@ function processSseLine(line, terminatorBytes, state, protocol, accumulator) {
124
521
  if (state.size > MAX_SSE_FRAME) {
125
522
  state.overflow = true;
126
523
  state.data = [];
524
+ taintOutputEstimate(accumulator, estimate);
127
525
  return;
128
526
  }
129
527
  if (state.overflow)
@@ -135,8 +533,9 @@ function processSseLine(line, terminatorBytes, state, protocol, accumulator) {
135
533
  state.data.push(line.slice(5).replace(/^ /, ""));
136
534
  }
137
535
  }
138
- function makeSseObserver(protocol, accumulator) {
536
+ function makeSseObserver(protocol, accumulator, estimate) {
139
537
  const decoder = new TextDecoder();
538
+ const utf8Validator = new TextDecoder("utf-8", { fatal: true });
140
539
  let text = "";
141
540
  let discardUntilBreak = false;
142
541
  const state = newSseState();
@@ -187,25 +586,28 @@ function makeSseObserver(protocol, accumulator) {
187
586
  state.overflow = true;
188
587
  text = "";
189
588
  discardUntilBreak = true;
589
+ taintOutputEstimate(accumulator, estimate);
190
590
  }
191
591
  break;
192
592
  }
193
593
  const line = text.slice(0, index);
194
594
  text = text.slice(index + terminatorLength);
195
- processSseLine(line, terminatorLength, state, protocol, accumulator);
595
+ processSseLine(line, terminatorLength, state, protocol, accumulator, estimate);
196
596
  }
197
597
  if (final && text.length > 0) {
198
598
  if (state.size + new TextEncoder().encode(text).byteLength <= MAX_SSE_FRAME) {
199
- processSseLine(text, 0, state, protocol, accumulator);
599
+ processSseLine(text, 0, state, protocol, accumulator, estimate);
200
600
  }
201
- else
601
+ else {
202
602
  state.overflow = true;
603
+ taintOutputEstimate(accumulator, estimate);
604
+ }
203
605
  text = "";
204
606
  }
205
607
  if (final) {
206
608
  // A final line without the customary trailing blank line is still a
207
609
  // useful terminal event in practice.
208
- inspectSseFrame(state, protocol, accumulator);
610
+ inspectSseFrame(state, protocol, accumulator, estimate);
209
611
  resetSse(state);
210
612
  }
211
613
  };
@@ -217,26 +619,41 @@ function makeSseObserver(protocol, accumulator) {
217
619
  // code point split between these slices.
218
620
  for (let offset = 0; offset < bytes.byteLength; offset += MAX_DECODE_SLICE) {
219
621
  const end = Math.min(offset + MAX_DECODE_SLICE, bytes.byteLength);
220
- text += decoder.decode(bytes.subarray(offset, end), { stream: true });
622
+ const slice = bytes.subarray(offset, end);
623
+ try {
624
+ utf8Validator.decode(slice, { stream: true });
625
+ }
626
+ catch {
627
+ taintOutputEstimate(accumulator, estimate);
628
+ }
629
+ text += decoder.decode(slice, { stream: true });
221
630
  drain(false);
222
631
  }
223
632
  }
224
633
  catch {
225
- // Ignore parser failures, retaining pass-through behavior.
634
+ taintOutputEstimate(accumulator, estimate);
226
635
  }
227
636
  },
228
637
  finish() {
229
638
  try {
639
+ try {
640
+ utf8Validator.decode();
641
+ }
642
+ catch {
643
+ taintOutputEstimate(accumulator, estimate);
644
+ }
230
645
  text += decoder.decode();
231
646
  drain(true);
647
+ flushAllStreamedOutputText(estimate);
648
+ flushAllToolArguments(accumulator, estimate);
232
649
  }
233
650
  catch {
234
- // Ignore parser failures.
651
+ taintOutputEstimate(accumulator, estimate);
235
652
  }
236
653
  },
237
654
  };
238
655
  }
239
- function makeJsonObserver(protocol, accumulator) {
656
+ function makeJsonObserver(protocol, accumulator, estimate) {
240
657
  const chunks = [];
241
658
  let size = 0;
242
659
  let overflow = false;
@@ -244,6 +661,8 @@ function makeJsonObserver(protocol, accumulator) {
244
661
  push(bytes) {
245
662
  if (overflow)
246
663
  return;
664
+ if (bytes.byteLength === 0)
665
+ return;
247
666
  if (size + bytes.byteLength > MAX_BUFFERED_JSON) {
248
667
  overflow = true;
249
668
  chunks.length = 0;
@@ -262,10 +681,11 @@ function makeJsonObserver(protocol, accumulator) {
262
681
  bytes.set(chunk, offset);
263
682
  offset += chunk.byteLength;
264
683
  }
265
- inspectJson(JSON.parse(new TextDecoder().decode(bytes)), protocol, accumulator);
684
+ const json = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
685
+ inspectJson(JSON.parse(json), protocol, accumulator, estimate);
266
686
  }
267
687
  catch {
268
- // Empty, malformed, or non-UTF-8 JSON is not a report.
688
+ taintOutputEstimate(accumulator, estimate);
269
689
  }
270
690
  },
271
691
  };
@@ -277,9 +697,16 @@ function makeJsonObserver(protocol, accumulator) {
277
697
  export function observeUsage(response, protocol, accumulator, options = {}) {
278
698
  if (!response.body)
279
699
  return response;
700
+ const estimate = {
701
+ characters: 0,
702
+ overflow: false,
703
+ pendingArgumentCharacters: 0,
704
+ pendingArguments: new Map(),
705
+ streamedTexts: new Map(),
706
+ };
280
707
  const observer = options.streamed
281
- ? makeSseObserver(protocol, accumulator)
282
- : makeJsonObserver(protocol, accumulator);
708
+ ? makeSseObserver(protocol, accumulator, estimate)
709
+ : makeJsonObserver(protocol, accumulator, estimate);
283
710
  const transform = new TransformStream({
284
711
  transform(chunk, controller) {
285
712
  // Observer failures are isolated; errors from the upstream body are