@argosvix/sdk 0.4.22-alpha.0 → 0.5.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/README.md +4 -9
- package/dist/aiSdkMiddleware.d.ts +36 -20
- package/dist/aiSdkMiddleware.d.ts.map +1 -1
- package/dist/aiSdkMiddleware.js +98 -31
- package/dist/aiSdkMiddleware.js.map +1 -1
- package/dist/approvals.d.ts +37 -29
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +24 -18
- package/dist/approvals.js.map +1 -1
- package/dist/budgetGate.d.ts +30 -24
- package/dist/budgetGate.d.ts.map +1 -1
- package/dist/budgetGate.js +118 -92
- package/dist/budgetGate.js.map +1 -1
- package/dist/client.d.ts +15 -10
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +271 -222
- package/dist/client.js.map +1 -1
- package/dist/context.d.ts +59 -48
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +72 -58
- package/dist/context.js.map +1 -1
- package/dist/flush.d.ts +6 -4
- package/dist/flush.d.ts.map +1 -1
- package/dist/flush.js +6 -4
- package/dist/flush.js.map +1 -1
- package/dist/ids.d.ts +3 -2
- package/dist/ids.d.ts.map +1 -1
- package/dist/ids.js +6 -5
- package/dist/ids.js.map +1 -1
- package/dist/langchainCallback.d.ts +14 -13
- package/dist/langchainCallback.d.ts.map +1 -1
- package/dist/langchainCallback.js +22 -17
- package/dist/langchainCallback.js.map +1 -1
- package/dist/policyScan.d.ts +29 -22
- package/dist/policyScan.d.ts.map +1 -1
- package/dist/policyScan.js +47 -38
- package/dist/policyScan.js.map +1 -1
- package/dist/pricing.d.ts +5 -5
- package/dist/pricing.d.ts.map +1 -1
- package/dist/pricing.js +24 -22
- package/dist/pricing.js.map +1 -1
- package/dist/prompts.d.ts +20 -17
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +21 -17
- package/dist/prompts.js.map +1 -1
- package/dist/query.d.ts +21 -21
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +14 -14
- package/dist/recorder.d.ts +39 -19
- package/dist/recorder.d.ts.map +1 -1
- package/dist/recorder.js +120 -69
- package/dist/recorder.js.map +1 -1
- package/dist/redaction.d.ts +35 -33
- package/dist/redaction.d.ts.map +1 -1
- package/dist/redaction.js +73 -64
- package/dist/redaction.js.map +1 -1
- package/dist/types.d.ts +131 -114
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +9 -9
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +9 -9
- package/dist/version.js.map +1 -1
- package/package.json +8 -2
package/dist/client.js
CHANGED
|
@@ -32,8 +32,8 @@ export function wrap(client, config = {}) {
|
|
|
32
32
|
return client;
|
|
33
33
|
}
|
|
34
34
|
const recorder = new Recorder(config);
|
|
35
|
-
//
|
|
36
|
-
//
|
|
35
|
+
// Register this recorder as the sink for observations emitted by withSpan
|
|
36
|
+
// (observations[] are sent through this recorder's ingest path).
|
|
37
37
|
_registerObservationSink(recorder);
|
|
38
38
|
switch (provider) {
|
|
39
39
|
case "openai": {
|
|
@@ -61,11 +61,11 @@ export function wrap(client, config = {}) {
|
|
|
61
61
|
break;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
64
|
+
// Plaintext capture coverage (streaming support added 2026-07): in addition to
|
|
65
|
+
// the non-streaming success path of all 4 providers, promptBody / completionBody
|
|
66
|
+
// are also extracted on the streaming paths that have an existing stream wrapper
|
|
67
|
+
// (OpenAI Chat / Anthropic / Mistral / Gemini legacy + new SDK). OpenAI Responses
|
|
68
|
+
// streaming has no stream wrapper yet, so plaintext capture is unsupported there too.
|
|
69
69
|
wrappedClients.set(client, recorder);
|
|
70
70
|
return client;
|
|
71
71
|
}
|
|
@@ -73,9 +73,10 @@ export function getRecorder(client) {
|
|
|
73
73
|
return wrappedClients.get(client) ?? null;
|
|
74
74
|
}
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
76
|
+
* Internal: registration hook for integrations that do not go through wrap()
|
|
77
|
+
* (e.g. the Vercel AI SDK middleware) to put their own Recorder into the WeakMap
|
|
78
|
+
* used by flushClient() / getRecorder(). This lets `flushClient(middleware)`
|
|
79
|
+
* work through the same path as wrapped clients.
|
|
79
80
|
*/
|
|
80
81
|
export function _registerRecorderFor(obj, recorder) {
|
|
81
82
|
wrappedClients.set(obj, recorder);
|
|
@@ -115,21 +116,22 @@ function detectProvider(client, override) {
|
|
|
115
116
|
}
|
|
116
117
|
function buildTraceMeta(config) {
|
|
117
118
|
const meta = {};
|
|
118
|
-
//
|
|
119
|
-
// autoContext:false
|
|
119
|
+
// Automatic context propagation: traceId resolution order is explicit config
|
|
120
|
+
// first, then ambient (inside withTrace). autoContext:false ignores the
|
|
121
|
+
// ambient context and restores the previous static behavior.
|
|
120
122
|
const ambient = config.autoContext === false ? undefined : getAmbientTraceContext();
|
|
121
123
|
const traceId = config.traceId ?? ambient?.traceId;
|
|
122
124
|
if (traceId) {
|
|
123
125
|
meta.traceId = traceId;
|
|
124
|
-
// trace
|
|
126
|
+
// A call that belongs to a trace gets its own span (it becomes a node of the trace). An explicit spanId takes priority.
|
|
125
127
|
meta.spanId = config.spanId ?? generateId();
|
|
126
|
-
//
|
|
128
|
+
// Parent resolution: explicit config first, then the ambient span (set by withSpan etc.; undefined when directly under the trace).
|
|
127
129
|
const parentSpanId = config.parentSpanId ?? ambient?.spanId;
|
|
128
130
|
if (parentSpanId)
|
|
129
131
|
meta.parentSpanId = parentSpanId;
|
|
130
132
|
}
|
|
131
133
|
else {
|
|
132
|
-
// trace
|
|
134
|
+
// Standalone call without a trace context: previous behavior (only explicitly provided values are forwarded).
|
|
133
135
|
if (config.spanId)
|
|
134
136
|
meta.spanId = config.spanId;
|
|
135
137
|
if (config.parentSpanId)
|
|
@@ -140,15 +142,17 @@ function buildTraceMeta(config) {
|
|
|
140
142
|
return meta;
|
|
141
143
|
}
|
|
142
144
|
/**
|
|
143
|
-
*
|
|
144
|
-
* provider
|
|
145
|
-
*
|
|
146
|
-
* - OpenAI: safety_identifier (
|
|
147
|
-
* - Anthropic: metadata.user_id (
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
145
|
+
* Automatic end-user identifier capture (added 2026-06-17, refined in review).
|
|
146
|
+
* For each provider, only fields designed to hold opaque identifiers are read,
|
|
147
|
+
* and the value is forwarded to aggregation as the userId tag:
|
|
148
|
+
* - OpenAI: safety_identifier (OpenAI's recommended stable identifier; hashing recommended)
|
|
149
|
+
* - Anthropic: metadata.user_id (per spec: no PII, must be opaque, max 256 chars)
|
|
150
|
+
* The legacy OpenAI `user` field is excluded from automatic capture: it often
|
|
151
|
+
* carries PII (emails / raw IDs), so auto-forwarding it could leak data beyond
|
|
152
|
+
* what was consented to (pass an explicit tags.userId if needed).
|
|
153
|
+
* Providers are matched explicitly; Gemini / Mistral are always a no-op (so we
|
|
154
|
+
* never mistakenly pick up another provider's field names). Values are trimmed
|
|
155
|
+
* to 256 characters to match the backend limit.
|
|
152
156
|
*/
|
|
153
157
|
function pickEndUserId(provider, requestArgs) {
|
|
154
158
|
if (!requestArgs || typeof requestArgs !== "object")
|
|
@@ -172,14 +176,15 @@ function pickEndUserId(provider, requestArgs) {
|
|
|
172
176
|
return typeof cand === "string" ? cand.slice(0, 256) : undefined;
|
|
173
177
|
}
|
|
174
178
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* config.tags.userId
|
|
179
|
+
* Build the tags placed on a record. Starting from config.tags, the userId is
|
|
180
|
+
* filled in from the provider-specific field unless captureUserId is explicitly
|
|
181
|
+
* false. An existing explicit config.tags.userId takes priority (automatic
|
|
182
|
+
* capture never overwrites it).
|
|
178
183
|
*
|
|
179
|
-
*
|
|
180
|
-
* success / error / stream
|
|
181
|
-
*
|
|
182
|
-
* userId
|
|
184
|
+
* Call this exactly once at each wrapper's entry point and share the resolved
|
|
185
|
+
* tags across all success / error / stream records: requestArgs must not be
|
|
186
|
+
* re-read after an await, so a caller that reuses or mutates the request object
|
|
187
|
+
* cannot cause the call to be recorded under another user's userId.
|
|
183
188
|
*/
|
|
184
189
|
function buildTags(config, provider, requestArgs) {
|
|
185
190
|
const tags = { ...(config.tags ?? {}) };
|
|
@@ -188,8 +193,9 @@ function buildTags(config, provider, requestArgs) {
|
|
|
188
193
|
if (native !== undefined)
|
|
189
194
|
tags.userId = native;
|
|
190
195
|
}
|
|
191
|
-
// withPrompt()
|
|
192
|
-
//
|
|
196
|
+
// Inside withPrompt(), the prompt tag ({name}@v{version}) is attached
|
|
197
|
+
// automatically. An explicit tags.prompt takes priority (never overwritten).
|
|
198
|
+
// This is the basis for per-version quality/cost comparison (2026-07-02).
|
|
193
199
|
if (tags.prompt === undefined) {
|
|
194
200
|
const ambientPrompt = getAmbientPromptTag();
|
|
195
201
|
if (ambientPrompt !== undefined)
|
|
@@ -198,22 +204,23 @@ function buildTags(config, provider, requestArgs) {
|
|
|
198
204
|
return tags;
|
|
199
205
|
}
|
|
200
206
|
/**
|
|
201
|
-
*
|
|
207
|
+
* SDK-side opt-in extraction helpers for the Pro+ plaintext storage feature.
|
|
202
208
|
*
|
|
203
|
-
* captureContent
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
209
|
+
* Called by the wrappers when captureContent is true; extracts prompt /
|
|
210
|
+
* completion / tool calls as strings (and JSON strings) from each provider's
|
|
211
|
+
* request / response shapes. PII redaction is applied centrally in the
|
|
212
|
+
* Recorder, so these helpers only return the raw strings.
|
|
207
213
|
*
|
|
208
|
-
*
|
|
209
|
-
* - openai (Chat Completions + Responses)
|
|
210
|
-
* - anthropic (Messages)
|
|
211
|
-
* - mistral (chat.complete)
|
|
212
|
-
* - gemini (legacy generateContent +
|
|
213
|
-
* - streaming
|
|
214
|
-
* (
|
|
215
|
-
*
|
|
216
|
-
*
|
|
214
|
+
* Supported providers (extended 2026-07-02 to cover non-streaming for all 4):
|
|
215
|
+
* - openai (Chat Completions + Responses): prompt + completion + tool calls
|
|
216
|
+
* - anthropic (Messages): prompt + completion
|
|
217
|
+
* - mistral (chat.complete): prompt + completion
|
|
218
|
+
* - gemini (legacy generateContent + new SDK models.generateContent): prompt + completion
|
|
219
|
+
* - streaming paths (added 2026-07): the existing stream wrappers' finalize
|
|
220
|
+
* step attaches promptBody (snapshotted at entry) + completionBody
|
|
221
|
+
* (accumulated text deltas, 256KB cap). Streaming assembly of tool-call
|
|
222
|
+
* arguments is out of scope. OpenAI Responses streaming is unsupported
|
|
223
|
+
* because it has no stream wrapper.
|
|
217
224
|
*/
|
|
218
225
|
function extractOpenAIChatPromptBody(requestArgs) {
|
|
219
226
|
if (!Array.isArray(requestArgs.messages) || requestArgs.messages.length === 0) {
|
|
@@ -236,7 +243,7 @@ function extractOpenAIChatCompletionBody(response) {
|
|
|
236
243
|
const content = first?.message?.content;
|
|
237
244
|
if (typeof content === "string")
|
|
238
245
|
return content;
|
|
239
|
-
// content
|
|
246
|
+
// When content is an array (multi-modal), forward it as a JSON string
|
|
240
247
|
if (Array.isArray(content)) {
|
|
241
248
|
try {
|
|
242
249
|
return JSON.stringify(content);
|
|
@@ -273,14 +280,16 @@ function extractOpenAIChatToolCalls(response) {
|
|
|
273
280
|
return result.length > 0 ? result : undefined;
|
|
274
281
|
}
|
|
275
282
|
/**
|
|
276
|
-
*
|
|
283
|
+
* captureContent extraction for the OpenAI Responses API (added in v0.3.0-alpha.2).
|
|
277
284
|
*
|
|
278
|
-
* - request.input
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
285
|
+
* - request.input is either a string (a simple prompt) or an array
|
|
286
|
+
* (multi-modal / structured). Strings are forwarded as-is; arrays are
|
|
287
|
+
* JSON-stringified.
|
|
288
|
+
* - response.output_text (aggregated text, introduced in SDK 0.4.x) is the
|
|
289
|
+
* first choice; otherwise walk the response.output array and join the text
|
|
290
|
+
* of output_text-type items.
|
|
291
|
+
* - Tool calls are extracted (name + arguments) from items with
|
|
292
|
+
* type === "function_call" inside response.output.
|
|
284
293
|
*/
|
|
285
294
|
function extractOpenAIResponsesPromptBody(requestArgs) {
|
|
286
295
|
if (requestArgs.input === undefined || requestArgs.input === null)
|
|
@@ -385,7 +394,7 @@ function extractAnthropicCompletionBody(response) {
|
|
|
385
394
|
}
|
|
386
395
|
if (texts.length > 0)
|
|
387
396
|
return texts.join("\n");
|
|
388
|
-
// text block
|
|
397
|
+
// If there is no text block, forward the whole content as a JSON string
|
|
389
398
|
try {
|
|
390
399
|
return JSON.stringify(content);
|
|
391
400
|
}
|
|
@@ -396,12 +405,12 @@ function extractAnthropicCompletionBody(response) {
|
|
|
396
405
|
return undefined;
|
|
397
406
|
}
|
|
398
407
|
/**
|
|
399
|
-
* 2026-07-02
|
|
400
|
-
*
|
|
401
|
-
*
|
|
408
|
+
* Parity fix (2026-07-02): captureContent extraction was added to the
|
|
409
|
+
* non-streaming success paths of Mistral / Gemini as well (same level as
|
|
410
|
+
* OpenAI / Anthropic). PII redaction is still applied centrally in the Recorder.
|
|
402
411
|
*/
|
|
403
412
|
function extractMistralPromptBody(requestArgs) {
|
|
404
|
-
// Mistral chat.complete
|
|
413
|
+
// Mistral chat.complete takes an OpenAI-Chat-compatible messages array.
|
|
405
414
|
if (!Array.isArray(requestArgs.messages) || requestArgs.messages.length === 0) {
|
|
406
415
|
return undefined;
|
|
407
416
|
}
|
|
@@ -417,15 +426,16 @@ function extractMistralCompletionBody(response) {
|
|
|
417
426
|
?.message?.content;
|
|
418
427
|
if (typeof content === "string")
|
|
419
428
|
return content;
|
|
420
|
-
// ContentChunk[](multi-modal)
|
|
429
|
+
// ContentChunk[] (multi-modal) is forwarded as a JSON string
|
|
421
430
|
if (Array.isArray(content))
|
|
422
431
|
return safeStringify(content);
|
|
423
432
|
return undefined;
|
|
424
433
|
}
|
|
425
434
|
/**
|
|
426
|
-
* Gemini
|
|
427
|
-
* string
|
|
428
|
-
*
|
|
435
|
+
* Gemini prompt extraction. The legacy `@google/generative-ai` generateContent
|
|
436
|
+
* accepts a string or { contents }; the new `@google/genai` takes
|
|
437
|
+
* { model, contents }. In both cases the contents (or the raw string) are
|
|
438
|
+
* stringified and forwarded.
|
|
429
439
|
*/
|
|
430
440
|
function extractGeminiPromptBody(requestArgs) {
|
|
431
441
|
if (typeof requestArgs === "string")
|
|
@@ -440,10 +450,10 @@ function extractGeminiPromptBody(requestArgs) {
|
|
|
440
450
|
return safeStringify(contents);
|
|
441
451
|
}
|
|
442
452
|
/**
|
|
443
|
-
* Gemini
|
|
444
|
-
* { candidates: [{ content: { parts: [{ text }] } }] }
|
|
445
|
-
*
|
|
446
|
-
* JSON
|
|
453
|
+
* Gemini completion extraction. On legacy it is result.response; on the new SDK
|
|
454
|
+
* the result itself holds { candidates: [{ content: { parts: [{ text }] } }] },
|
|
455
|
+
* so the caller passes whichever object carries candidates. Text parts are
|
|
456
|
+
* joined; if there are none, the parts are forwarded as a JSON string.
|
|
447
457
|
*/
|
|
448
458
|
function extractGeminiCompletionBody(response) {
|
|
449
459
|
if (!response || typeof response !== "object")
|
|
@@ -474,22 +484,25 @@ function safeStringify(value) {
|
|
|
474
484
|
}
|
|
475
485
|
}
|
|
476
486
|
// ============================================================
|
|
477
|
-
//
|
|
487
|
+
// Streaming plaintext capture (designed 2026-07)
|
|
478
488
|
//
|
|
479
|
-
// promptBody
|
|
480
|
-
// completionBody
|
|
481
|
-
//
|
|
482
|
-
//
|
|
489
|
+
// promptBody is snapshotted at stream entry via the existing extract*PromptBody
|
|
490
|
+
// helpers; completionBody is obtained by simply adding text-delta accumulation
|
|
491
|
+
// to the existing stream wrappers (which already observe every chunk for usage
|
|
492
|
+
// aggregation) — no new interception points are introduced.
|
|
493
|
+
// Redaction is still applied centrally at Recorder flush time.
|
|
483
494
|
// ============================================================
|
|
484
495
|
/**
|
|
485
|
-
*
|
|
486
|
-
*
|
|
496
|
+
* Accumulation buffer cap (262,144 bytes = 256KB), aligned with the ingest-side
|
|
497
|
+
* body limit. Structurally prevents a runaway stream from letting the wrapper
|
|
498
|
+
* eat the host application's memory.
|
|
487
499
|
*/
|
|
488
500
|
const STREAM_CAPTURE_MAX_BYTES = 262_144;
|
|
489
501
|
const STREAM_TRUNCATED_MARKER = "…[truncated]";
|
|
490
502
|
/**
|
|
491
|
-
* UTF-
|
|
492
|
-
*
|
|
503
|
+
* Count the UTF-8 byte length by scanning UTF-16 code units (avoids a
|
|
504
|
+
* TextEncoder allocation per delta). A surrogate pair counts as one code point
|
|
505
|
+
* of 4 bytes.
|
|
493
506
|
*/
|
|
494
507
|
function utf8ByteLength(text) {
|
|
495
508
|
let bytes = 0;
|
|
@@ -500,7 +513,7 @@ function utf8ByteLength(text) {
|
|
|
500
513
|
else if (code < 0x800)
|
|
501
514
|
bytes += 2;
|
|
502
515
|
else if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
|
|
503
|
-
//
|
|
516
|
+
// Surrogate pair: 4 bytes (consumes the following low surrogate)
|
|
504
517
|
bytes += 4;
|
|
505
518
|
i++;
|
|
506
519
|
}
|
|
@@ -510,8 +523,8 @@ function utf8ByteLength(text) {
|
|
|
510
523
|
return bytes;
|
|
511
524
|
}
|
|
512
525
|
/**
|
|
513
|
-
* streaming
|
|
514
|
-
* result()
|
|
526
|
+
* Accumulation buffer for streaming text deltas. Anything beyond the 256KB cap
|
|
527
|
+
* is dropped, and result() returns the body with a truncated marker appended.
|
|
515
528
|
*/
|
|
516
529
|
class StreamTextAccumulator {
|
|
517
530
|
parts = [];
|
|
@@ -526,7 +539,7 @@ class StreamTextAccumulator {
|
|
|
526
539
|
this.bytes += size;
|
|
527
540
|
return;
|
|
528
541
|
}
|
|
529
|
-
//
|
|
542
|
+
// A delta that crosses the cap is appended up to a code point boundary as far as it fits; the rest is dropped.
|
|
530
543
|
let remaining = STREAM_CAPTURE_MAX_BYTES - this.bytes;
|
|
531
544
|
let head = "";
|
|
532
545
|
for (const ch of text) {
|
|
@@ -543,8 +556,9 @@ class StreamTextAccumulator {
|
|
|
543
556
|
this.truncated = true;
|
|
544
557
|
}
|
|
545
558
|
/**
|
|
546
|
-
*
|
|
547
|
-
* interrupted
|
|
559
|
+
* Return the accumulated body (undefined if empty, i.e. not placed on the record).
|
|
560
|
+
* interrupted: when returning the body captured so far after an exception or
|
|
561
|
+
* early termination, the truncated marker is always appended.
|
|
548
562
|
*/
|
|
549
563
|
result(interrupted = false) {
|
|
550
564
|
if (this.parts.length === 0)
|
|
@@ -554,8 +568,9 @@ class StreamTextAccumulator {
|
|
|
554
568
|
}
|
|
555
569
|
}
|
|
556
570
|
/**
|
|
557
|
-
*
|
|
558
|
-
*
|
|
571
|
+
* Create the capture state at stream entry. When captureContent is not enabled,
|
|
572
|
+
* returns undefined so both extraction and accumulation cost stay at zero
|
|
573
|
+
* (promptBody extraction is deferred behind a thunk).
|
|
559
574
|
*/
|
|
560
575
|
function buildStreamCapture(config, extractPromptBody) {
|
|
561
576
|
if (config.captureContent !== true)
|
|
@@ -567,8 +582,9 @@ function buildStreamCapture(config, extractPromptBody) {
|
|
|
567
582
|
return capture;
|
|
568
583
|
}
|
|
569
584
|
/**
|
|
570
|
-
*
|
|
571
|
-
*
|
|
585
|
+
* Attach promptBody / completionBody to a stream finalize / error record.
|
|
586
|
+
* Redaction happens not here but centrally at Recorder flush time (same policy
|
|
587
|
+
* as the non-streaming path).
|
|
572
588
|
*/
|
|
573
589
|
function applyStreamCapture(record, capture, interrupted) {
|
|
574
590
|
if (!capture)
|
|
@@ -579,16 +595,17 @@ function applyStreamCapture(record, capture, interrupted) {
|
|
|
579
595
|
if (completionBody !== undefined)
|
|
580
596
|
record.completionBody = completionBody;
|
|
581
597
|
}
|
|
582
|
-
/** OpenAI Chat stream
|
|
598
|
+
/** Text delta of an OpenAI Chat stream chunk (choices[0].delta.content, strings only). */
|
|
583
599
|
function extractOpenAIStreamDeltaText(chunk) {
|
|
584
600
|
const delta = chunk.choices?.[0]?.delta;
|
|
585
601
|
const content = delta?.content;
|
|
586
602
|
return typeof content === "string" && content.length > 0 ? content : undefined;
|
|
587
603
|
}
|
|
588
604
|
/**
|
|
589
|
-
* Gemini stream
|
|
590
|
-
* legacy
|
|
591
|
-
*
|
|
605
|
+
* Text delta of a Gemini stream chunk. The new SDK exposes chunk.text (a string
|
|
606
|
+
* property); legacy exposes chunk.text() (a function that may throw, e.g. on a
|
|
607
|
+
* safety block). If neither yields text, concatenate
|
|
608
|
+
* candidates[0].content.parts[].text (no JSON fallback — deltas only).
|
|
592
609
|
*/
|
|
593
610
|
function extractGeminiStreamChunkText(chunk) {
|
|
594
611
|
if (!chunk || typeof chunk !== "object")
|
|
@@ -603,7 +620,7 @@ function extractGeminiStreamChunkText(chunk) {
|
|
|
603
620
|
return v;
|
|
604
621
|
}
|
|
605
622
|
catch {
|
|
606
|
-
//
|
|
623
|
+
// Legacy chunk.text() throws when there is no candidate etc. Give up on accumulating and continue.
|
|
607
624
|
}
|
|
608
625
|
}
|
|
609
626
|
const candidates = chunk.candidates;
|
|
@@ -648,8 +665,9 @@ function isOpenAIChatLike(client) {
|
|
|
648
665
|
return typeof c?.chat?.completions?.create === "function";
|
|
649
666
|
}
|
|
650
667
|
/**
|
|
651
|
-
*
|
|
652
|
-
*
|
|
668
|
+
* Extract reasoning / audio tokens from OpenAI usage (undefined when absent).
|
|
669
|
+
* Audio is the sum of input (prompt) and output (completion) tokens. Zero
|
|
670
|
+
* values are not recorded (keeps the column NULL).
|
|
653
671
|
*/
|
|
654
672
|
function extractRichTokens(usage) {
|
|
655
673
|
const out = {};
|
|
@@ -664,8 +682,9 @@ function extractRichTokens(usage) {
|
|
|
664
682
|
return out;
|
|
665
683
|
}
|
|
666
684
|
/**
|
|
667
|
-
*
|
|
668
|
-
*
|
|
685
|
+
* Whether a streaming chunk carries the "first token" (content / tool / audio /
|
|
686
|
+
* refusal). Not triggered by the leading role-only delta (so TTFT is measured
|
|
687
|
+
* correctly).
|
|
669
688
|
*/
|
|
670
689
|
function chunkHasStreamedOutput(chunk) {
|
|
671
690
|
const choices = chunk.choices;
|
|
@@ -681,14 +700,15 @@ function chunkHasStreamedOutput(chunk) {
|
|
|
681
700
|
return delta.tool_calls != null || delta.function_call != null || delta.audio != null;
|
|
682
701
|
}
|
|
683
702
|
/**
|
|
684
|
-
*
|
|
685
|
-
* OpenAI chat.completions.stream())
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
703
|
+
* The high-level streaming helpers (Anthropic messages.stream() /
|
|
704
|
+
* OpenAI chat.completions.stream()) are not wrapped and bypass the budget/policy
|
|
705
|
+
* gate. They return synchronously (event-emitter style), which makes it hard to
|
|
706
|
+
* safely insert an async gate internally, so when a gate is configured we warn
|
|
707
|
+
* once at wrap time and suggest switching to .create({ stream: true }) (the
|
|
708
|
+
* gated path). .create stream:true and Responses streaming are already gated.
|
|
689
709
|
*/
|
|
690
710
|
let warnedGateStreamHelper = false;
|
|
691
|
-
/** Test-only: reset the
|
|
711
|
+
/** Test-only: reset the stream-helper warn-once flag. Do not call from production. */
|
|
692
712
|
export function __resetStreamHelperWarning() {
|
|
693
713
|
warnedGateStreamHelper = false;
|
|
694
714
|
}
|
|
@@ -700,7 +720,7 @@ function warnIfGatedStreamHelper(config, hasStreamHelper, label) {
|
|
|
700
720
|
warnedGateStreamHelper = true;
|
|
701
721
|
// eslint-disable-next-line no-console
|
|
702
722
|
console.warn(`[argosvix] ${label} bypasses the budget/policy gate (not wrapped). ` +
|
|
703
|
-
`Use .create({ stream: true }) for gated streaming
|
|
723
|
+
`Use .create({ stream: true }) for gated streaming.`);
|
|
704
724
|
}
|
|
705
725
|
function hasTee(v) {
|
|
706
726
|
return (v !== null &&
|
|
@@ -713,21 +733,23 @@ function isThenable(v) {
|
|
|
713
733
|
typeof v.then === "function");
|
|
714
734
|
}
|
|
715
735
|
/**
|
|
716
|
-
*
|
|
736
|
+
* Deferred return value of the full-compat wrap (2026-07).
|
|
717
737
|
*
|
|
718
|
-
* gate
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
738
|
+
* Paths involving a gate wait or a stream tee transform cannot return the
|
|
739
|
+
* original APIPromise as-is. Even then, .withResponse() is preserved via
|
|
740
|
+
* delegation in addition to then/catch/finally (data goes through mapData —
|
|
741
|
+
* for streams it is swapped for the user branch of the tee). Only
|
|
742
|
+
* .asResponse() (direct reads of the raw Response) remains unsupported, since
|
|
743
|
+
* it is structurally incompatible with observation (parsing); this is
|
|
744
|
+
* disclosed in the docs.
|
|
723
745
|
*/
|
|
724
746
|
function makeCompatAPIPromise(result, sourceBox, mapData) {
|
|
725
|
-
//
|
|
747
|
+
// Return a real Promise (compatible with instanceof Promise / util.types.isPromise).
|
|
726
748
|
const out = result.then((v) => v);
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
749
|
+
// Sacrificial catch: prevents the rejection of the always-attached settled
|
|
750
|
+
// chain (used for recording) from becoming a Node unhandledRejection when
|
|
751
|
+
// the user only uses withResponse() or never awaits at all. The user's own
|
|
752
|
+
// await still receives the same rejection.
|
|
731
753
|
void out.catch(() => { });
|
|
732
754
|
out.withResponse = () => sourceBox.then((box) => {
|
|
733
755
|
const api = box.api;
|
|
@@ -748,28 +770,32 @@ function makeCompatAPIPromise(result, sourceBox, mapData) {
|
|
|
748
770
|
return out;
|
|
749
771
|
}
|
|
750
772
|
/**
|
|
751
|
-
*
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
* withResponse
|
|
773
|
+
* Error for asResponse() on the deferred compat object. An API that lets the
|
|
774
|
+
* user read the raw Response body directly is structurally incompatible with
|
|
775
|
+
* the parse needed for recording (the body would be consumed twice), so on the
|
|
776
|
+
* compat side we fail loudly instead of breaking silently.
|
|
777
|
+
* Warning: do not touch asResponse on the original APIPromise returned by the
|
|
778
|
+
* identity fast path — the real openai-node withResponse() calls asResponse()
|
|
779
|
+
* internally, so overriding it would break withResponse too (caught in a smoke
|
|
780
|
+
* test against the real package). That case is handled by docs disclosure.
|
|
757
781
|
*/
|
|
758
782
|
const UNSUPPORTED_AS_RESPONSE = "[argosvix] asResponse() is not supported on wrapped clients (the raw body " +
|
|
759
783
|
"would be consumed twice). Use withResponse(), or call this endpoint on an " +
|
|
760
784
|
"unwrapped client.";
|
|
761
785
|
/**
|
|
762
|
-
*
|
|
763
|
-
*
|
|
786
|
+
* Run a recording generator (wrapOpenAIStream etc.) to completion in the
|
|
787
|
+
* background. Used solely to consume the observation branch of a tee.
|
|
788
|
+
* Recording and error recording are fully handled inside the generator, so
|
|
789
|
+
* this just spins it silently.
|
|
764
790
|
*/
|
|
765
791
|
async function drainRecordingStream(gen) {
|
|
766
792
|
try {
|
|
767
793
|
for await (const _chunk of gen) {
|
|
768
|
-
/*
|
|
794
|
+
/* Consume the observation branch only (yielded values are discarded) */
|
|
769
795
|
}
|
|
770
796
|
}
|
|
771
797
|
catch {
|
|
772
|
-
/*
|
|
798
|
+
/* Error recording is already done inside the generator */
|
|
773
799
|
}
|
|
774
800
|
}
|
|
775
801
|
function wrapOpenAIChat(client, recorder, config) {
|
|
@@ -782,12 +808,13 @@ function wrapOpenAIChat(client, recorder, config) {
|
|
|
782
808
|
const callTags = buildTags(config, "openai", requestArgs);
|
|
783
809
|
const id = generateId();
|
|
784
810
|
const isStream = requestArgs.stream === true;
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
811
|
+
// Without stream_options.include_usage, OpenAI returns no usage at all for
|
|
812
|
+
// streams, so streaming calls would be recorded as 0 tokens / $0 — silently
|
|
813
|
+
// under-counting cost (and letting streaming pass the budget gate). Inject
|
|
814
|
+
// include_usage for streams (respecting an explicit client setting).
|
|
815
|
+
// Warning: when we injected it, the trailing usage-only chunk OpenAI appends
|
|
816
|
+
// (choices=[]) is not yielded to the host, so naive consumers that assume
|
|
817
|
+
// choices[0] don't break. (2026-06-28)
|
|
791
818
|
const usageInjected = isStream && requestArgs.stream_options?.include_usage === undefined;
|
|
792
819
|
if (usageInjected) {
|
|
793
820
|
args = [...args];
|
|
@@ -804,7 +831,7 @@ function wrapOpenAIChat(client, recorder, config) {
|
|
|
804
831
|
const model = r.model || requestArgs.model || "unknown";
|
|
805
832
|
const promptTokens = r.usage?.prompt_tokens ?? 0;
|
|
806
833
|
const completionTokens = r.usage?.completion_tokens ?? 0;
|
|
807
|
-
// OpenAI: prompt_tokens
|
|
834
|
+
// OpenAI: prompt_tokens is the total including cached tokens; cached is a subset.
|
|
808
835
|
const cachedReadTokens = r.usage?.prompt_tokens_details?.cached_tokens ?? 0;
|
|
809
836
|
const cost = calculateCostWithCache("openai", model, promptTokens, completionTokens, cachedReadTokens, 0);
|
|
810
837
|
const record = {
|
|
@@ -838,7 +865,7 @@ function wrapOpenAIChat(client, recorder, config) {
|
|
|
838
865
|
recorder.record(record);
|
|
839
866
|
}
|
|
840
867
|
catch {
|
|
841
|
-
/*
|
|
868
|
+
/* Recording is best-effort — never break the host's call */
|
|
842
869
|
}
|
|
843
870
|
};
|
|
844
871
|
const recordFailure = (err) => {
|
|
@@ -862,12 +889,12 @@ function wrapOpenAIChat(client, recorder, config) {
|
|
|
862
889
|
});
|
|
863
890
|
}
|
|
864
891
|
catch {
|
|
865
|
-
/*
|
|
892
|
+
/* Recording is best-effort */
|
|
866
893
|
}
|
|
867
894
|
};
|
|
868
|
-
// ----
|
|
869
|
-
//
|
|
870
|
-
//
|
|
895
|
+
// ---- Non-stream + gate disabled: identity fast path ----
|
|
896
|
+
// Return the original APIPromise as-is (.withResponse() / .asResponse() stay real).
|
|
897
|
+
// Recording is done via an observer .then (multiple thens on the same promise are safe).
|
|
871
898
|
if (!isStream && !recorder.budgetGate.isActive) {
|
|
872
899
|
let ret;
|
|
873
900
|
try {
|
|
@@ -884,9 +911,10 @@ function wrapOpenAIChat(client, recorder, config) {
|
|
|
884
911
|
recordSuccess(ret);
|
|
885
912
|
return ret;
|
|
886
913
|
}
|
|
887
|
-
// ----
|
|
888
|
-
// async
|
|
889
|
-
// { api }
|
|
914
|
+
// ---- Deferred path (gate enabled or stream) ----
|
|
915
|
+
// An async function's return auto-flattens thenables, which would erase the
|
|
916
|
+
// APIPromise, so wrap it in an { api } box to preserve its identity (for
|
|
917
|
+
// withResponse delegation).
|
|
890
918
|
const sourceBox = (async () => {
|
|
891
919
|
await recorder.budgetGate.check({ model: requestArgs.model, payload: requestArgs });
|
|
892
920
|
return { api: originalCreate(...args) };
|
|
@@ -906,32 +934,35 @@ function wrapOpenAIChat(client, recorder, config) {
|
|
|
906
934
|
// ---- stream ----
|
|
907
935
|
const capture = buildStreamCapture(config, () => extractOpenAIChatPromptBody(requestArgs));
|
|
908
936
|
const wrapForRecord = (raw) => wrapOpenAIStream(raw, recorder, requestArgs, start, id, callTags, traceMeta, usageInjected, capture);
|
|
909
|
-
// tee
|
|
937
|
+
// The tee transform runs exactly once across paths (await / withResponse().data).
|
|
910
938
|
let transformDone = false;
|
|
911
939
|
let transformedValue;
|
|
912
940
|
const transformOnce = (raw) => {
|
|
913
941
|
if (transformDone)
|
|
914
942
|
return transformedValue;
|
|
915
|
-
//
|
|
916
|
-
//
|
|
943
|
+
// Do not tee when usage was injected: the injected usage-only chunk
|
|
944
|
+
// (choices=[]) would pass straight through to the user branch and break
|
|
945
|
+
// naive consumers (regression guard, 2026-06-28).
|
|
917
946
|
if (!usageInjected && hasTee(raw)) {
|
|
918
947
|
try {
|
|
919
948
|
const [obs, user] = raw.tee();
|
|
920
|
-
//
|
|
949
|
+
// Commit the flag only after tee succeeds (so a custom stream that throws synchronously cannot poison it).
|
|
921
950
|
transformDone = true;
|
|
922
951
|
transformedValue = user;
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
// .controller
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
//
|
|
952
|
+
// Drain the observation branch with the existing recording generator
|
|
953
|
+
// (no duplication of recording logic). The user branch stays a real
|
|
954
|
+
// Stream, so .tee() / .toReadableStream() / .controller all keep
|
|
955
|
+
// working. Even if the user breaks early, the observation branch runs
|
|
956
|
+
// to completion, so usage is fully recorded.
|
|
957
|
+
// Memory note: while the observation branch drains ahead, all chunks
|
|
958
|
+
// for the unconsumed user branch pile up in tee's internal queue
|
|
959
|
+
// (bounded by the whole response). Accepted as a trade-off for
|
|
960
|
+
// reliable recording (disclosed in the docs).
|
|
930
961
|
void drainRecordingStream(wrapForRecord(obs));
|
|
931
962
|
return user;
|
|
932
963
|
}
|
|
933
964
|
catch {
|
|
934
|
-
/* tee
|
|
965
|
+
/* tee failed — fall back to the legacy path */
|
|
935
966
|
}
|
|
936
967
|
}
|
|
937
968
|
transformDone = true;
|
|
@@ -948,16 +979,19 @@ function wrapOpenAIChat(client, recorder, config) {
|
|
|
948
979
|
};
|
|
949
980
|
}
|
|
950
981
|
async function* wrapOpenAIStream(stream, recorder, requestArgs, start, id,
|
|
951
|
-
//
|
|
952
|
-
// wrapper
|
|
982
|
+
// The stream record's userId must come from the callTags resolved at the
|
|
983
|
+
// wrapper entry, not at generator consumption start (eliminates re-reading
|
|
984
|
+
// requestArgs after an await).
|
|
953
985
|
callTags,
|
|
954
|
-
//
|
|
955
|
-
//
|
|
986
|
+
// The trace fields also use the meta snapshotted at entry, so consuming the
|
|
987
|
+
// stream outside withTrace can neither lose the ambient context nor attribute
|
|
988
|
+
// the call to the wrong trace.
|
|
956
989
|
traceMeta,
|
|
957
|
-
//
|
|
958
|
-
//
|
|
990
|
+
// Whether we injected include_usage. When true, the trailing usage-only chunk
|
|
991
|
+
// is observed but not yielded to the host (keeps the consumer's stream shape
|
|
992
|
+
// unchanged).
|
|
959
993
|
usageInjected = false,
|
|
960
|
-
//
|
|
994
|
+
// Streaming plaintext capture (passed only when captureContent=true).
|
|
961
995
|
capture) {
|
|
962
996
|
let finalModel = requestArgs.model || "unknown";
|
|
963
997
|
let promptTokens = 0;
|
|
@@ -965,14 +999,16 @@ capture) {
|
|
|
965
999
|
let cachedReadTokens = 0;
|
|
966
1000
|
let reportedTotal;
|
|
967
1001
|
let lastUsage;
|
|
968
|
-
//
|
|
1002
|
+
// TTFT: arrival time of the first chunk carrying a content delta, minus start time.
|
|
969
1003
|
let ttftMs;
|
|
970
|
-
//
|
|
1004
|
+
// Whether the stream ran to completion (stays false on early break / abandon → the body gets the truncated marker).
|
|
971
1005
|
let completed = false;
|
|
972
|
-
//
|
|
973
|
-
// .return()
|
|
974
|
-
//
|
|
975
|
-
//
|
|
1006
|
+
// If the consumer breaks early or abandons the stream, the generator gets
|
|
1007
|
+
// .return()ed at the yield and the post-loop recording is skipped, so a
|
|
1008
|
+
// billed call would never be recorded (and the budget gate's spend would
|
|
1009
|
+
// leak too). With finally + the recorded flag, the call is recorded exactly
|
|
1010
|
+
// once with the observed tokens on normal completion, error, and early
|
|
1011
|
+
// termination alike (matches the Python SDK). (2026-06-28)
|
|
976
1012
|
let recorded = false;
|
|
977
1013
|
const recordOnce = () => {
|
|
978
1014
|
if (recorded)
|
|
@@ -1003,8 +1039,9 @@ capture) {
|
|
|
1003
1039
|
try {
|
|
1004
1040
|
for await (const chunk of stream) {
|
|
1005
1041
|
if (ttftMs === undefined && chunkHasStreamedOutput(chunk)) {
|
|
1006
|
-
//
|
|
1007
|
-
//
|
|
1042
|
+
// The first chunk carrying content / tool / audio counts as the first
|
|
1043
|
+
// token. Not triggered by the leading role-only chunk (avoids
|
|
1044
|
+
// underestimating TTFT).
|
|
1008
1045
|
ttftMs = Date.now() - start;
|
|
1009
1046
|
}
|
|
1010
1047
|
capture?.acc.push(extractOpenAIStreamDeltaText(chunk));
|
|
@@ -1021,23 +1058,25 @@ capture) {
|
|
|
1021
1058
|
cachedReadTokens = chunk.usage.prompt_tokens_details.cached_tokens;
|
|
1022
1059
|
}
|
|
1023
1060
|
}
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1061
|
+
// The trailing usage-only chunk (empty choices) produced by the
|
|
1062
|
+
// include_usage we injected is observed only and not passed to the host
|
|
1063
|
+
// (if we did not inject it — the client set it themselves — it is
|
|
1064
|
+
// yielded as-is).
|
|
1026
1065
|
if (usageInjected && chunk.usage != null && (chunk.choices?.length ?? 0) === 0) {
|
|
1027
1066
|
continue;
|
|
1028
1067
|
}
|
|
1029
1068
|
yield chunk;
|
|
1030
1069
|
}
|
|
1031
|
-
// controller.abort()
|
|
1032
|
-
//
|
|
1033
|
-
// (
|
|
1070
|
+
// The openai-node Stream swallows controller.abort() and treats it as a
|
|
1071
|
+
// normal end, so execution reaches here. Record it as truncated rather than
|
|
1072
|
+
// a clean success (prevents recording under-counted usage as a success).
|
|
1034
1073
|
const aborted = stream.controller
|
|
1035
1074
|
?.signal?.aborted === true;
|
|
1036
1075
|
completed = !aborted;
|
|
1037
1076
|
recordOnce();
|
|
1038
1077
|
}
|
|
1039
1078
|
catch (err) {
|
|
1040
|
-
recorded = true; // error record
|
|
1079
|
+
recorded = true; // finalized by the error record (suppresses recordOnce in finally)
|
|
1041
1080
|
const errorDetails = extractErrorDetails(err);
|
|
1042
1081
|
const errorRecord = {
|
|
1043
1082
|
id,
|
|
@@ -1055,13 +1094,13 @@ capture) {
|
|
|
1055
1094
|
...(errorDetails ? { errorDetails } : {}),
|
|
1056
1095
|
requestMeta: buildOpenAIRequestMeta(requestArgs),
|
|
1057
1096
|
};
|
|
1058
|
-
//
|
|
1097
|
+
// When cut off by an exception, put the body captured so far + the truncated marker on the record.
|
|
1059
1098
|
applyStreamCapture(errorRecord, capture, true);
|
|
1060
1099
|
recorder.record(errorRecord);
|
|
1061
1100
|
throw err;
|
|
1062
1101
|
}
|
|
1063
1102
|
finally {
|
|
1064
|
-
//
|
|
1103
|
+
// Early break / abandon path. No-op if the stream already completed normally or errored.
|
|
1065
1104
|
recordOnce();
|
|
1066
1105
|
}
|
|
1067
1106
|
}
|
|
@@ -1111,7 +1150,7 @@ function wrapOpenAIResponses(client, recorder, config) {
|
|
|
1111
1150
|
});
|
|
1112
1151
|
}
|
|
1113
1152
|
catch {
|
|
1114
|
-
/*
|
|
1153
|
+
/* Recording is best-effort */
|
|
1115
1154
|
}
|
|
1116
1155
|
};
|
|
1117
1156
|
const recordSuccess = (response0) => {
|
|
@@ -1153,10 +1192,10 @@ function wrapOpenAIResponses(client, recorder, config) {
|
|
|
1153
1192
|
recorder.record(record);
|
|
1154
1193
|
}
|
|
1155
1194
|
catch {
|
|
1156
|
-
/*
|
|
1195
|
+
/* Recording is best-effort */
|
|
1157
1196
|
}
|
|
1158
1197
|
};
|
|
1159
|
-
// ----
|
|
1198
|
+
// ---- Non-stream + gate disabled: identity fast path (same shape as chat) ----
|
|
1160
1199
|
if (!isStream && !recorder.budgetGate.isActive) {
|
|
1161
1200
|
let ret;
|
|
1162
1201
|
try {
|
|
@@ -1173,7 +1212,7 @@ function wrapOpenAIResponses(client, recorder, config) {
|
|
|
1173
1212
|
recordSuccess(ret);
|
|
1174
1213
|
return ret;
|
|
1175
1214
|
}
|
|
1176
|
-
// ----
|
|
1215
|
+
// ---- Deferred path (gate enabled or stream) ----
|
|
1177
1216
|
const sourceBox = (async () => {
|
|
1178
1217
|
await recorder.budgetGate.check({ model: requestArgs.model, payload: requestArgs });
|
|
1179
1218
|
return { api: originalCreate(...args) };
|
|
@@ -1207,7 +1246,7 @@ function wrapOpenAIResponses(client, recorder, config) {
|
|
|
1207
1246
|
return user;
|
|
1208
1247
|
}
|
|
1209
1248
|
catch {
|
|
1210
|
-
/* tee
|
|
1249
|
+
/* tee failed — fall back to the legacy path */
|
|
1211
1250
|
}
|
|
1212
1251
|
}
|
|
1213
1252
|
transformDone = true;
|
|
@@ -1224,10 +1263,12 @@ function wrapOpenAIResponses(client, recorder, config) {
|
|
|
1224
1263
|
};
|
|
1225
1264
|
}
|
|
1226
1265
|
/**
|
|
1227
|
-
* Responses API
|
|
1228
|
-
* response.output_text.delta / refusal.delta
|
|
1229
|
-
*
|
|
1230
|
-
*
|
|
1266
|
+
* Recording generator for Responses API streams (extracted from inline code in
|
|
1267
|
+
* 2026-07). Accumulates response.output_text.delta / refusal.delta and
|
|
1268
|
+
* finalizes with the usage from response.completed. Also handles error /
|
|
1269
|
+
* response.failed / response.incomplete events. On the full-compat path this
|
|
1270
|
+
* drains the tee's observation branch while the user branch stays the real
|
|
1271
|
+
* Stream that is returned.
|
|
1231
1272
|
*/
|
|
1232
1273
|
async function* wrapOpenAIResponsesStream(stream, recorder, requestArgs, start, id, callTags, traceMeta, capture) {
|
|
1233
1274
|
{
|
|
@@ -1237,7 +1278,7 @@ async function* wrapOpenAIResponsesStream(stream, recorder, requestArgs, start,
|
|
|
1237
1278
|
let usage;
|
|
1238
1279
|
let completed = false;
|
|
1239
1280
|
let recorded = false;
|
|
1240
|
-
//
|
|
1281
|
+
// Handle terminal failures arriving as stream events, and the case where completion was never observed.
|
|
1241
1282
|
let sawCompleted = false;
|
|
1242
1283
|
let incomplete = false;
|
|
1243
1284
|
let streamError;
|
|
@@ -1245,7 +1286,7 @@ async function* wrapOpenAIResponsesStream(stream, recorder, requestArgs, start,
|
|
|
1245
1286
|
if (recorded)
|
|
1246
1287
|
return;
|
|
1247
1288
|
recorded = true;
|
|
1248
|
-
//
|
|
1289
|
+
// Turn error/failed stream events into an error record and suppress the success record.
|
|
1249
1290
|
if (streamError !== undefined) {
|
|
1250
1291
|
const errRecord = {
|
|
1251
1292
|
id,
|
|
@@ -1293,7 +1334,7 @@ async function* wrapOpenAIResponsesStream(stream, recorder, requestArgs, start,
|
|
|
1293
1334
|
try {
|
|
1294
1335
|
for await (const event of stream) {
|
|
1295
1336
|
const ev = event;
|
|
1296
|
-
//
|
|
1337
|
+
// Content deltas include both output_text and refusal (refusal was missing originally).
|
|
1297
1338
|
if ((ev.type === "response.output_text.delta" ||
|
|
1298
1339
|
ev.type === "response.refusal.delta") &&
|
|
1299
1340
|
typeof ev.delta === "string") {
|
|
@@ -1309,7 +1350,7 @@ async function* wrapOpenAIResponsesStream(stream, recorder, requestArgs, start,
|
|
|
1309
1350
|
incomplete = ev.type === "response.incomplete";
|
|
1310
1351
|
}
|
|
1311
1352
|
else if (ev.type === "error" || ev.type === "response.failed") {
|
|
1312
|
-
//
|
|
1353
|
+
// Record terminal errors that arrive as stream events.
|
|
1313
1354
|
const respErr = ev.response?.error?.message;
|
|
1314
1355
|
streamError =
|
|
1315
1356
|
typeof respErr === "string"
|
|
@@ -1392,8 +1433,8 @@ function wrapAnthropic(client, recorder, config) {
|
|
|
1392
1433
|
const r = response;
|
|
1393
1434
|
const latencyMs = Date.now() - start;
|
|
1394
1435
|
const model = r.model || requestArgs.model || "unknown";
|
|
1395
|
-
// Anthropic: input_tokens
|
|
1396
|
-
// promptTokens(
|
|
1436
|
+
// Anthropic: input_tokens counts only non-cached input. Cache reads/writes
|
|
1437
|
+
// are reported separately, so add them back into promptTokens (total input).
|
|
1397
1438
|
const cachedReadTokens = r.usage?.cache_read_input_tokens ?? 0;
|
|
1398
1439
|
const cachedWriteTokens = r.usage?.cache_creation_input_tokens ?? 0;
|
|
1399
1440
|
const promptTokens = (r.usage?.input_tokens ?? 0) + cachedReadTokens + cachedWriteTokens;
|
|
@@ -1450,17 +1491,17 @@ function wrapAnthropic(client, recorder, config) {
|
|
|
1450
1491
|
};
|
|
1451
1492
|
}
|
|
1452
1493
|
async function* wrapAnthropicStream(stream, recorder, requestArgs, start, id, callTags, traceMeta,
|
|
1453
|
-
//
|
|
1494
|
+
// Streaming plaintext capture (passed only when captureContent=true).
|
|
1454
1495
|
capture) {
|
|
1455
1496
|
let model = requestArgs.model || "unknown";
|
|
1456
|
-
// Anthropic: input_tokens
|
|
1497
|
+
// Anthropic: input_tokens counts only non-cached input. Cache reads/writes are reported separately.
|
|
1457
1498
|
let inputTokens = 0;
|
|
1458
1499
|
let cachedReadTokens = 0;
|
|
1459
1500
|
let cachedWriteTokens = 0;
|
|
1460
1501
|
let completionTokens = 0;
|
|
1461
|
-
//
|
|
1502
|
+
// Whether the stream ran to completion (stays false on early break / abandon → the body gets the truncated marker).
|
|
1462
1503
|
let completed = false;
|
|
1463
|
-
//
|
|
1504
|
+
// Record exactly once with the observed tokens even on early break (same as OpenAI; 2026-06-28).
|
|
1464
1505
|
let recorded = false;
|
|
1465
1506
|
const recordOnce = () => {
|
|
1466
1507
|
if (recorded)
|
|
@@ -1494,7 +1535,7 @@ capture) {
|
|
|
1494
1535
|
event.type === "content_block_delta" &&
|
|
1495
1536
|
event.delta?.type === "text_delta" &&
|
|
1496
1537
|
typeof event.delta.text === "string") {
|
|
1497
|
-
//
|
|
1538
|
+
// Accumulate text deltas only (tool-argument input_json_delta etc. are out of scope).
|
|
1498
1539
|
capture.acc.push(event.delta.text);
|
|
1499
1540
|
}
|
|
1500
1541
|
if (event.type === "message_start" && event.message) {
|
|
@@ -1537,7 +1578,7 @@ capture) {
|
|
|
1537
1578
|
...(errorDetails ? { errorDetails } : {}),
|
|
1538
1579
|
requestMeta: buildAnthropicRequestMeta(requestArgs),
|
|
1539
1580
|
};
|
|
1540
|
-
//
|
|
1581
|
+
// When cut off by an exception, put the body captured so far + the truncated marker on the record.
|
|
1541
1582
|
applyStreamCapture(errorRecord, capture, true);
|
|
1542
1583
|
recorder.record(errorRecord);
|
|
1543
1584
|
throw err;
|
|
@@ -1634,7 +1675,7 @@ function wrapMistral(client, recorder, config) {
|
|
|
1634
1675
|
return wrapMistralStream(stream, recorder, requestArgs, start, id, callTags, buildTraceMeta(config), buildStreamCapture(config, () => extractMistralPromptBody(requestArgs)));
|
|
1635
1676
|
}
|
|
1636
1677
|
catch (err) {
|
|
1637
|
-
//
|
|
1678
|
+
// Stream initialization error (auth/validation/connection)
|
|
1638
1679
|
const errorDetails = extractErrorDetails(err);
|
|
1639
1680
|
recorder.record({
|
|
1640
1681
|
id,
|
|
@@ -1658,15 +1699,15 @@ function wrapMistral(client, recorder, config) {
|
|
|
1658
1699
|
}
|
|
1659
1700
|
}
|
|
1660
1701
|
async function* wrapMistralStream(stream, recorder, requestArgs, start, id, callTags, traceMeta,
|
|
1661
|
-
//
|
|
1702
|
+
// Streaming plaintext capture (passed only when captureContent=true).
|
|
1662
1703
|
capture) {
|
|
1663
1704
|
let model = requestArgs.model || "unknown";
|
|
1664
1705
|
let promptTokens = 0;
|
|
1665
1706
|
let completionTokens = 0;
|
|
1666
1707
|
let reportedTotal;
|
|
1667
|
-
//
|
|
1708
|
+
// Whether the stream ran to completion (stays false on early break / abandon → the body gets the truncated marker).
|
|
1668
1709
|
let completed = false;
|
|
1669
|
-
//
|
|
1710
|
+
// Record exactly once with the observed tokens even on early break (same as OpenAI; 2026-06-28).
|
|
1670
1711
|
let recorded = false;
|
|
1671
1712
|
const recordOnce = () => {
|
|
1672
1713
|
if (recorded)
|
|
@@ -1693,7 +1734,7 @@ capture) {
|
|
|
1693
1734
|
for await (const chunk of stream) {
|
|
1694
1735
|
const inner = chunk.data ?? chunk;
|
|
1695
1736
|
if (capture) {
|
|
1696
|
-
// Mistral
|
|
1737
|
+
// Mistral uses the OpenAI-Chat-compatible delta shape (choices[0].delta.content, strings only).
|
|
1697
1738
|
const delta = inner.choices?.[0]?.delta;
|
|
1698
1739
|
if (typeof delta?.content === "string")
|
|
1699
1740
|
capture.acc.push(delta.content);
|
|
@@ -1731,7 +1772,7 @@ capture) {
|
|
|
1731
1772
|
...(errorDetails ? { errorDetails } : {}),
|
|
1732
1773
|
requestMeta: buildMistralRequestMeta(requestArgs),
|
|
1733
1774
|
};
|
|
1734
|
-
//
|
|
1775
|
+
// When cut off by an exception, put the body captured so far + the truncated marker on the record.
|
|
1735
1776
|
applyStreamCapture(errorRecord, capture, true);
|
|
1736
1777
|
recorder.record(errorRecord);
|
|
1737
1778
|
throw err;
|
|
@@ -1843,20 +1884,24 @@ function wrapGeminiLegacyModel(model, modelName, recorder, config) {
|
|
|
1843
1884
|
const result = (await originalStream(...args));
|
|
1844
1885
|
if (!result.stream)
|
|
1845
1886
|
return result;
|
|
1846
|
-
//
|
|
1847
|
-
//
|
|
1848
|
-
//
|
|
1887
|
+
// If the consumer breaks early or abandons the stream, the generator
|
|
1888
|
+
// gets .return()ed and the post-loop recording is skipped, so a billed
|
|
1889
|
+
// call would never be recorded (as with the other 3 providers,
|
|
1890
|
+
// recordOnce() + finally record exactly once on completion, error, or
|
|
1891
|
+
// early termination).
|
|
1849
1892
|
const originalStreamRef = result.stream;
|
|
1850
|
-
//
|
|
1893
|
+
// The trace fields are snapshotted at stream creation (the entry point, where the ambient context is still active).
|
|
1851
1894
|
const traceMeta = buildTraceMeta(config);
|
|
1852
|
-
//
|
|
1895
|
+
// Streaming plaintext capture: promptBody snapshotted at entry + delta accumulation.
|
|
1853
1896
|
const capture = buildStreamCapture(config, () => extractGeminiPromptBody(requestArgs));
|
|
1854
1897
|
const wrappedStream = (async function* () {
|
|
1855
1898
|
let completed = false;
|
|
1856
1899
|
let recorded = false;
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1859
|
-
// result.response
|
|
1900
|
+
// Legacy Gemini only exposes usage via result.response after the
|
|
1901
|
+
// stream completes, so on early break we record 0 tokens (+ the
|
|
1902
|
+
// truncated marker). result.response is awaited only on full
|
|
1903
|
+
// completion (awaiting it after an early break can hang or consume
|
|
1904
|
+
// extra).
|
|
1860
1905
|
let promptTokens = 0;
|
|
1861
1906
|
let completionTokens = 0;
|
|
1862
1907
|
let cachedReadTokens = 0;
|
|
@@ -1918,13 +1963,13 @@ function wrapGeminiLegacyModel(model, modelName, recorder, config) {
|
|
|
1918
1963
|
...(errorDetails ? { errorDetails } : {}),
|
|
1919
1964
|
requestMeta: buildGeminiRequestMeta(requestArgs),
|
|
1920
1965
|
};
|
|
1921
|
-
//
|
|
1966
|
+
// When cut off by an exception, put the body captured so far + the truncated marker on the record.
|
|
1922
1967
|
applyStreamCapture(errorRecord, capture, true);
|
|
1923
1968
|
recorder.record(errorRecord);
|
|
1924
1969
|
throw err;
|
|
1925
1970
|
}
|
|
1926
1971
|
finally {
|
|
1927
|
-
//
|
|
1972
|
+
// Early break / abandon path. No-op if the stream already completed normally or errored.
|
|
1928
1973
|
recordOnce();
|
|
1929
1974
|
}
|
|
1930
1975
|
})();
|
|
@@ -2033,10 +2078,10 @@ function wrapGeminiNew(client, recorder, config) {
|
|
|
2033
2078
|
try {
|
|
2034
2079
|
await recorder.budgetGate.check({ model: modelName, payload: requestArgs });
|
|
2035
2080
|
const stream = (await originalStream(...args));
|
|
2036
|
-
//
|
|
2037
|
-
//
|
|
2081
|
+
// Fix: AsyncGenerator wrap with finally-record + error handling tied to consumption
|
|
2082
|
+
// The trace fields are snapshotted at stream creation (the entry point, where the ambient context is still active).
|
|
2038
2083
|
const traceMeta = buildTraceMeta(config);
|
|
2039
|
-
//
|
|
2084
|
+
// Streaming plaintext capture: promptBody snapshotted at entry + delta accumulation.
|
|
2040
2085
|
const capture = buildStreamCapture(config, () => extractGeminiPromptBody(requestArgs));
|
|
2041
2086
|
return (async function* () {
|
|
2042
2087
|
let lastUsage;
|
|
@@ -2100,13 +2145,13 @@ function wrapGeminiNew(client, recorder, config) {
|
|
|
2100
2145
|
...(errorDetails ? { errorDetails } : {}),
|
|
2101
2146
|
requestMeta: buildGeminiNewRequestMeta(requestArgs),
|
|
2102
2147
|
};
|
|
2103
|
-
//
|
|
2148
|
+
// When cut off by an exception, put the body captured so far + the truncated marker on the record.
|
|
2104
2149
|
applyStreamCapture(errorRecord, capture, true);
|
|
2105
2150
|
recorder.record(errorRecord);
|
|
2106
2151
|
throw err;
|
|
2107
2152
|
}
|
|
2108
2153
|
finally {
|
|
2109
|
-
//
|
|
2154
|
+
// Early break / abandon path. No-op if the stream already completed normally or errored.
|
|
2110
2155
|
recordOnce();
|
|
2111
2156
|
}
|
|
2112
2157
|
})();
|
|
@@ -2161,11 +2206,15 @@ function buildGeminiNewRequestMeta(requestArgs) {
|
|
|
2161
2206
|
/**
|
|
2162
2207
|
* Time-ordered record ID generator.
|
|
2163
2208
|
*
|
|
2164
|
-
*
|
|
2165
|
-
*
|
|
2166
|
-
*
|
|
2167
|
-
*
|
|
2168
|
-
*
|
|
2169
|
-
*
|
|
2209
|
+
* Fix: the previous implementation used a `Date.now()` prefix + a
|
|
2210
|
+
* `Math.random().toString(36).slice(2,10)` suffix, which had two weaknesses:
|
|
2211
|
+
* (1) non-cryptographic randomness, and (2) depending on the value the suffix
|
|
2212
|
+
* could be shorter than 8 characters, dropping the effective entropy well
|
|
2213
|
+
* below 41 bits. The backend silently skips collisions via
|
|
2214
|
+
* `INSERT ... ON CONFLICT(account_id, id) DO NOTHING`, so a real call that
|
|
2215
|
+
* collides would vanish from billing, quota, and observation. Cryptographic
|
|
2216
|
+
* randomness (crypto.getRandomValues / randomUUID) raises collision resistance
|
|
2217
|
+
* to the 128-bit class. The leading time prefix is kept so ordering stays
|
|
2218
|
+
* chronological.
|
|
2170
2219
|
*/
|
|
2171
2220
|
//# sourceMappingURL=client.js.map
|