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.
- package/dist/accounting-store-schema.d.ts +2 -2
- package/dist/accounting.d.ts +5 -5
- package/dist/accounting.js +4 -4
- package/dist/accounting.js.map +1 -1
- package/dist/circuit-breaker.d.ts +20 -0
- package/dist/circuit-breaker.js +49 -0
- package/dist/circuit-breaker.js.map +1 -1
- package/dist/cli.d.ts +4 -2
- package/dist/cli.js +317 -3
- package/dist/cli.js.map +1 -1
- package/dist/cooldown-clear.d.ts +30 -0
- package/dist/cooldown-clear.js +40 -0
- package/dist/cooldown-clear.js.map +1 -0
- package/dist/metadata.d.ts +2 -0
- package/dist/metadata.js +5 -1
- package/dist/metadata.js.map +1 -1
- package/dist/routes/admin.js +34 -0
- package/dist/routes/admin.js.map +1 -1
- package/dist/server.js +26 -6
- package/dist/server.js.map +1 -1
- package/dist/target-facts.d.ts +14 -0
- package/dist/target-facts.js +58 -0
- package/dist/target-facts.js.map +1 -1
- package/dist/usage-observer.d.ts +5 -3
- package/dist/usage-observer.js +452 -25
- package/dist/usage-observer.js.map +1 -1
- package/package.json +1 -1
package/dist/usage-observer.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Observe provider-reported
|
|
3
|
-
* the response path.
|
|
4
|
-
* a response chunk: it only keeps
|
|
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(
|
|
473
|
+
const value = JSON.parse(data);
|
|
94
474
|
if (protocol === "anthropic-messages") {
|
|
95
475
|
const root = asRecord(value);
|
|
96
|
-
if (root
|
|
97
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|