generalcontext 0.2.0 → 0.3.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/index.js CHANGED
@@ -12,9 +12,11 @@
12
12
  // the background. Raw fetch capture is opt-in with instrumentFetch: true. Your agent never waits on General Context, and a failure on our side is
13
13
  // logged and dropped, never thrown. No runtime dependencies.
14
14
  import { AsyncLocalStorage } from 'node:async_hooks';
15
- import { createHmac, randomBytes, randomUUID } from 'node:crypto';
15
+ import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
16
+ import { request as httpRequest } from 'node:http';
17
+ import { request as httpsRequest } from 'node:https';
16
18
  import { createRequire } from 'node:module';
17
- export const VERSION = '0.2.0';
19
+ export const VERSION = '0.3.0';
18
20
  const BATCH = 20;
19
21
  const MAX_QUEUE = 5000;
20
22
  const MAX_CALL_BYTES = 4_000_000; // the server rejects larger calls
@@ -23,6 +25,8 @@ const MAX_BATCH_BYTES = 4_000_000;
23
25
  const SEND_TIMEOUT_MS = 15_000;
24
26
  const DIRECT_TIMEOUT_MS = 10_000; // reportOutcome and logEvent
25
27
  const EXIT_FLUSH_MS = 2_000;
28
+ const FLUSH_TIMEOUT_MS = 10_000; // flush() default, like the Python SDK
29
+ const RETRY_PAUSE_MS = 30_000; // after the collector fails to answer, wait this long before sending again
26
30
  const DEFAULT_BASE_URL = 'https://generalcontextlabs.com';
27
31
  const config = {
28
32
  apiKey: undefined,
@@ -41,6 +45,10 @@ let timer = null;
41
45
  let originalFetch = globalThis.fetch;
42
46
  let exitHooked = false;
43
47
  let stopped = false; // set when the exit flush runs out of time
48
+ let pausedUntil = 0; // background sends wait until then after a failed send; flush() still tries
49
+ // Prepared calls the collector refused for now (busy, failing, unreachable). Sent before the queue.
50
+ const retry = [];
51
+ let retryBytes = 0;
44
52
  const inflight = new Set();
45
53
  const apiKey = () => config.apiKey ?? process.env.GENERALCONTEXT_API_KEY;
46
54
  // Explicit option, then env var, then default. Trailing slashes are trimmed without a regex.
@@ -90,13 +98,58 @@ export function initialize(options = {}) {
90
98
  if (!secureUrl(baseUrl()))
91
99
  warn(`baseUrl ${baseUrl()} is not https; nothing will be sent`);
92
100
  const modules = options.instrumentModules ?? autoload();
101
+ // openai, @anthropic-ai/sdk and @google/genai ship separate CommonJS and ES module builds with their own classes.
102
+ // require() above found the CommonJS ones, which is what a CommonJS app uses. An ES module app (import OpenAI
103
+ // from 'openai') gets the other build, so patch that too. patchMethod skips a method already patched.
104
+ // The imports settle a few ticks after initialize() returns, before an app's own `await import(...)` of the
105
+ // same module resumes. A call made synchronously right after initialize(), before any await, can run
106
+ // unrecorded: await ready() first in that case.
107
+ // OpenAI Realtime (voice) connection classes live in their own entry points. openai/realtime/ws needs the
108
+ // optional `ws` package, so either may be missing.
109
+ if (!options.instrumentModules || options.instrumentModules.openAI) {
110
+ const require = createRequire(import.meta.url);
111
+ for (const name of REALTIME_MODULES) {
112
+ try {
113
+ patchRealtime(require(name));
114
+ }
115
+ catch { /* not installed */ }
116
+ }
117
+ const realtime = Promise.all(REALTIME_MODULES.map(name => import(name).then(patchRealtime, () => { }))).then(() => { });
118
+ pending = pending.then(() => realtime);
119
+ }
120
+ if (!options.instrumentModules) {
121
+ const esm = [['openai', patchOpenAI], ['@anthropic-ai/sdk', patchAnthropic]];
122
+ if (modules.googleGenAI)
123
+ esm.push(['@google/genai', patchGemini]);
124
+ // @aws-sdk/client-bedrock-runtime resolves to one class from both require() and import(), so it needs nothing here.
125
+ let found = Object.values(modules).some(Boolean);
126
+ const realtime = pending;
127
+ pending = Promise.all(esm.map(([name, patch]) => import(name).then(m => { found = true; patch(m); }, () => { })))
128
+ .then(() => { if (!found)
129
+ warn(NO_SDK); }).then(() => realtime);
130
+ }
93
131
  if (modules.openAI)
94
132
  patchOpenAI(modules.openAI);
95
133
  if (modules.anthropic)
96
134
  patchAnthropic(modules.anthropic);
135
+ if (modules.googleGenAI)
136
+ patchGemini(modules.googleGenAI);
137
+ if (modules.bedrockRuntime)
138
+ patchBedrock(modules.bedrockRuntime);
97
139
  if (options.instrumentFetch ?? false)
98
140
  patchFetch();
99
141
  }
142
+ let pending = Promise.resolve();
143
+ // Resolution is from this file's location, so an SDK installed in another project's node_modules is not found.
144
+ const NO_SDK = 'found no model SDK to record (openai, @anthropic-ai/sdk, @google/genai, @aws-sdk/client-bedrock-runtime). '
145
+ + 'Install generalcontext in the same project as your model SDK, or pass instrumentModules.';
146
+ /**
147
+ * Resolves once initialize() has patched every SDK build it found, including the ES module builds it loads
148
+ * asynchronously. Await it when a model call may run before the app's next await after initialize(). Never rejects.
149
+ */
150
+ export function ready() {
151
+ return pending;
152
+ }
100
153
  // Load installed SDKs when the caller did not pass them. Bundlers can hide these; pass instrumentModules there.
101
154
  function autoload() {
102
155
  const require = createRequire(import.meta.url);
@@ -106,7 +159,10 @@ function autoload() {
106
159
  catch {
107
160
  return undefined;
108
161
  } };
109
- return { openAI: load('openai'), anthropic: load('@anthropic-ai/sdk') };
162
+ return {
163
+ openAI: load('openai'), anthropic: load('@anthropic-ai/sdk'),
164
+ googleGenAI: load('@google/genai'), bedrockRuntime: load('@aws-sdk/client-bedrock-runtime'),
165
+ };
110
166
  }
111
167
  // Accept a module namespace, its default export, or the client class itself.
112
168
  function clientClass(value, name) {
@@ -124,6 +180,45 @@ function patchAnthropic(value) {
124
180
  const Anthropic = clientClass(value, 'Anthropic');
125
181
  // messages.stream() calls messages.create({ stream: true }) internally, so it is covered too.
126
182
  patchMethod(Anthropic?.Messages?.prototype, 'create', 'anthropic');
183
+ // client.beta.messages: create, parse, stream, toolRunner and threads all post through beta.messages.create.
184
+ patchMethod(Anthropic?.Beta?.Messages?.prototype, 'create', 'anthropic');
185
+ }
186
+ const exported = (value, name) => {
187
+ const v = value;
188
+ return v?.[name] ?? v?.default?.[name] ?? (typeof v === 'function' && v.name === name ? v : undefined);
189
+ };
190
+ // @google/genai assigns generateContent and generateContentStream as instance properties in the Models
191
+ // constructor. A setter on the prototype sees that assignment and stores the recording version instead.
192
+ function patchGemini(value) {
193
+ const proto = exported(value, 'Models')?.prototype;
194
+ if (!proto)
195
+ return;
196
+ const wrappers = {
197
+ generateContent: fn => recording(fn, 'gemini'),
198
+ generateContentStream: fn => geminiStreamRecording(fn),
199
+ };
200
+ for (const [name, wrapFn] of Object.entries(wrappers)) {
201
+ const existing = Object.getOwnPropertyDescriptor(proto, name);
202
+ if (existing?.set && existing.set[MARK])
203
+ continue;
204
+ if (typeof existing?.value === 'function') {
205
+ patchMethod(proto, name, 'gemini');
206
+ continue;
207
+ }
208
+ const set = function (fn) {
209
+ const v = typeof fn === 'function' && !fn[MARK] ? wrapFn(fn) : fn;
210
+ Object.defineProperty(this, name, { value: v, writable: true, configurable: true, enumerable: true });
211
+ };
212
+ set[MARK] = true;
213
+ Object.defineProperty(proto, name, { get: () => undefined, set, configurable: true });
214
+ }
215
+ }
216
+ function patchBedrock(value) {
217
+ const proto = exported(value, 'BedrockRuntimeClient')?.prototype;
218
+ const original = proto?.send;
219
+ if (typeof original !== 'function' || original[MARK])
220
+ return;
221
+ proto.send = bedrockRecording(original);
127
222
  }
128
223
  const MARK = Symbol.for('generalcontext.recorded');
129
224
  function patchMethod(owner, name, api) {
@@ -142,6 +237,9 @@ export function withSession(session, run) {
142
237
  tags.channel = s.channel;
143
238
  if (s.product)
144
239
  tags.product = s.product;
240
+ const account = s.account == null ? '' : String(s.account).trim().slice(0, 120);
241
+ if (account)
242
+ tags.account = account;
145
243
  if (s.metadata)
146
244
  tags.metadata = s.metadata;
147
245
  return scope.run({ ...scope.getStore(), tags }, run);
@@ -158,6 +256,54 @@ export function conversation(tags, run) {
158
256
  export function task(name, run) {
159
257
  return scope.run({ ...scope.getStore(), task: name }, run);
160
258
  }
259
+ const CLIP_OVER = 200_000; // strings longer than this many characters are clipped
260
+ const CLIP_HEAD = 100_000;
261
+ const CLIP_TAIL = 20_000;
262
+ const BINARY_MIN = 256; // shorter base64 payloads are left alone
263
+ const BASE64 = /^[A-Za-z0-9+/_-]+={0,2}$/;
264
+ const DATA_URL = /^data:([^,;]*)((?:;[^,;]*)*);base64,/;
265
+ const omitted = (payload) => `[omitted ${payload.length} bytes sha256:${createHash('sha256').update(payload).digest('hex').slice(0, 12)}]`;
266
+ /**
267
+ * The copy of a call that is recorded. Never changes `value`, and never touches what the caller sends to the provider.
268
+ * Agents resend the whole run on every call, so without this, later calls pass the per-call limit and are lost.
269
+ *
270
+ * - Inline binary becomes a placeholder that keeps the shape: a base64 data URL keeps its prefix
271
+ * (`data:image/png;base64,[omitted N bytes sha256:ab12cd34ef56]`), and a base64 `data`, `result` or
272
+ * `b64_json` field (Anthropic `source.data`, Gemini `inlineData.data`, OpenAI `input_audio.data`, Responses
273
+ * `image_generation_call.result`) becomes `[omitted N bytes sha256:...]`. N is the length of the base64 text
274
+ * and the hash is the first 12 hex digits of the sha256 of that text, so the same image gets the same placeholder
275
+ * in every call. Media types stay. Binary is never recorded: it does not help review and it is a privacy risk.
276
+ * - Any string over 200,000 characters keeps its first 100,000 and last 20,000 characters around
277
+ * `\n[... clipped N characters ...]\n`.
278
+ *
279
+ * Runs before beforeSend and redaction. A call still over the limit afterwards is dropped.
280
+ */
281
+ export function compactForRecording(value, key, depth = 0) {
282
+ if (typeof value === 'string') {
283
+ if (value.length > BINARY_MIN) {
284
+ const url = DATA_URL.exec(value.slice(0, 512));
285
+ if (url)
286
+ return value.slice(0, url[0].length) + omitted(value.slice(url[0].length));
287
+ if ((key === 'data' || key === 'result' || key === 'b64_json') && BASE64.test(value))
288
+ return omitted(value);
289
+ }
290
+ if (value.length > CLIP_OVER)
291
+ return `${value.slice(0, CLIP_HEAD)}\n[... clipped ${value.length - CLIP_HEAD - CLIP_TAIL} characters ...]\n${value.slice(-CLIP_TAIL)}`;
292
+ return value;
293
+ }
294
+ if (value === null || typeof value !== 'object' || depth > 64)
295
+ return value;
296
+ if (typeof value.toJSON === 'function')
297
+ value = value.toJSON();
298
+ if (value === null || typeof value !== 'object')
299
+ return compactForRecording(value, key, depth + 1);
300
+ if (Array.isArray(value))
301
+ return value.map(item => compactForRecording(item, undefined, depth + 1));
302
+ const out = {};
303
+ for (const [k, v] of Object.entries(value))
304
+ out[k] = compactForRecording(v, k, depth + 1);
305
+ return out;
306
+ }
161
307
  // Runs before the caller's await continues, so only take the snapshot here. Redaction and beforeSend run at send time.
162
308
  function record(call, at, scrub) {
163
309
  try {
@@ -165,7 +311,7 @@ function record(call, at, scrub) {
165
311
  warnLimited('no-key', 'GENERALCONTEXT_API_KEY is not set; a model call was not recorded');
166
312
  return;
167
313
  }
168
- const json = JSON.stringify({ ...call, conversation: at?.tags ?? null, task: at?.task ?? null });
314
+ const json = JSON.stringify(compactForRecording({ ...call, request: withoutRequestOptions(call.request), conversation: at?.tags ?? null, task: at?.task ?? null }));
169
315
  const bytes = Buffer.byteLength(json);
170
316
  if (bytes > MAX_CALL_BYTES) {
171
317
  warnLimited('oversized', `a model call of ${bytes} bytes is over the ${MAX_CALL_BYTES} byte limit and was dropped`);
@@ -178,6 +324,8 @@ function record(call, at, scrub) {
178
324
  queue.push({ json, bytes, scrub });
179
325
  queuedBytes += bytes;
180
326
  hookExit();
327
+ if (Date.now() < pausedUntil)
328
+ return; // the collector just failed; a timer resumes sending
181
329
  if (config.disableBatch || queue.length >= BATCH)
182
330
  void send();
183
331
  else if (!timer) {
@@ -201,6 +349,43 @@ function hookExit() {
201
349
  void flush().finally(() => clearTimeout(cap));
202
350
  });
203
351
  }
352
+ // Credentials are never sent, whatever `redact` says. They reach a recording when an app pastes a key into a
353
+ // prompt, a tool returns one, or a request carries one in its options. Each pattern needs the provider's
354
+ // fixed prefix, so ordinary text is never touched. The key name stays, so a reader knows one was there.
355
+ const SECRETS = [
356
+ ['anthropic-key', /\bsk-ant-[A-Za-z0-9_-]{20,}/g],
357
+ ['openai-key', /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/g],
358
+ ['stripe-key', /\b[rsp]k_(?:live|test)_[A-Za-z0-9]{16,}/g],
359
+ ['google-key', /\bAIza[A-Za-z0-9_-]{35}\b/g],
360
+ ['aws-key', /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g],
361
+ ['github-token', /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})/g],
362
+ ['slack-token', /\bxox[abposr]-[A-Za-z0-9-]{10,}/g],
363
+ ['generalcontext-key', /\bgck_[a-f0-9]{48}\b/g],
364
+ ['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
365
+ ['bearer-token', /(?<=\bBearer\s)[A-Za-z0-9._~+/-]{20,}=*/g],
366
+ // A password inside a connection string or URL: postgres://user:PASSWORD@host. Only the password goes.
367
+ ['password', /(?<=\b[a-z][a-z0-9+.-]{1,20}:\/\/[^\s:/@]{1,64}:)[^\s@/]{1,128}(?=@)/g],
368
+ ['private-key', /-----BEGIN [A-Z ]{0,20}PRIVATE KEY-----(?:[A-Za-z0-9+/=\s:,]|-(?!-)){1,16384}?-----END [A-Z ]{0,20}PRIVATE KEY-----/g],
369
+ ];
370
+ export function maskSecrets(text) {
371
+ if (text.length < 16)
372
+ return text;
373
+ return SECRETS.reduce((out, [kind, pattern]) => out.replace(pattern, `<${kind}>`), text);
374
+ }
375
+ // Request options that carry credentials or transport settings, not what the model was asked. Dropped from the
376
+ // recorded request: Gemini's httpOptions (per-request headers) and anything spelled like a header map.
377
+ const REQUEST_OPTIONS = new Set(['httpOptions', 'http_options', 'headers', 'extra_headers', 'extraHeaders', 'extra_query', 'extraQuery', 'abortSignal', 'signal']);
378
+ function withoutRequestOptions(value, depth = 0) {
379
+ if (!value || typeof value !== 'object' || depth > 3)
380
+ return value;
381
+ if (Array.isArray(value))
382
+ return value;
383
+ const out = {};
384
+ for (const [k, v] of Object.entries(value))
385
+ if (!REQUEST_OPTIONS.has(k))
386
+ out[k] = k === 'config' ? withoutRequestOptions(v, depth + 1) : v;
387
+ return out;
388
+ }
204
389
  // Apply beforeSend and redaction. Returns the JSON to send, or null to drop.
205
390
  function prepare(entry) {
206
391
  let call = JSON.parse(entry.json);
@@ -215,7 +400,59 @@ function prepare(entry) {
215
400
  if (call == null)
216
401
  return null;
217
402
  }
218
- return JSON.stringify(scrubbed(call, entry.scrub === undefined ? config.scrub : entry.scrub));
403
+ const scrub = entry.scrub === undefined ? config.scrub : entry.scrub;
404
+ return JSON.stringify(scrubbed(call, scrub ? (text) => scrub(maskSecrets(text)) : maskSecrets));
405
+ }
406
+ // Answers worth retrying: the collector is busy (429) or failing (5xx). Anything else (a bad key, a body too
407
+ // large) will fail again the same way, so that batch is dropped with a warning.
408
+ const retryable = (status) => status === 429 || status === 408 || status >= 500;
409
+ // Retry-After in seconds (or an HTTP date), kept between 1 s and 5 minutes.
410
+ function retryAfterMs(value) {
411
+ const raw = Array.isArray(value) ? value[0] : value;
412
+ if (!raw)
413
+ return undefined;
414
+ const seconds = /^\d+$/.test(raw.trim()) ? Number(raw) * 1000 : Date.parse(raw) - Date.now();
415
+ return Number.isFinite(seconds) ? Math.min(300_000, Math.max(1000, seconds)) : undefined;
416
+ }
417
+ // While a caller awaits flush(), reportOutcome() or logEvent(), our sockets keep the process alive
418
+ // so the await can finish. Otherwise they are unref'd: background sending never keeps a finished
419
+ // process alive, even against a collector that accepts and never answers.
420
+ let waiters = 0;
421
+ const sockets = new Set();
422
+ function holdOpen(work) {
423
+ if (waiters++ === 0)
424
+ for (const socket of sockets)
425
+ socket.ref();
426
+ return work.finally(() => { if (--waiters === 0)
427
+ for (const socket of sockets)
428
+ socket.unref(); });
429
+ }
430
+ /**
431
+ * POST to the collector with node:http(s), not fetch, so the socket can be unref'd (see above).
432
+ * Redirects are never followed (node:http does not follow them), so the key never reaches another host.
433
+ */
434
+ function postRaw(url, body, signal) {
435
+ return new Promise((resolve, reject) => {
436
+ const target = new URL(url);
437
+ const send = target.protocol === 'https:' ? httpsRequest : httpRequest;
438
+ const req = send(target, {
439
+ method: 'POST', signal, agent: false,
440
+ headers: { Authorization: `Bearer ${apiKey()}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
441
+ }, res => {
442
+ res.resume(); // drain, so the socket is freed
443
+ res.on('end', () => resolve({ ok: (res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300, status: res.statusCode ?? 0,
444
+ retryAfterMs: retryAfterMs(res.headers['retry-after']) }));
445
+ res.on('error', reject);
446
+ });
447
+ req.on('socket', socket => {
448
+ sockets.add(socket);
449
+ socket.once('close', () => sockets.delete(socket));
450
+ if (waiters === 0)
451
+ socket.unref();
452
+ });
453
+ req.on('error', reject);
454
+ req.end(body);
455
+ });
219
456
  }
220
457
  async function post(path, body, timeoutMs) {
221
458
  const url = `${baseUrl()}${path}`;
@@ -230,13 +467,7 @@ async function post(path, body, timeoutMs) {
230
467
  timeout.unref?.();
231
468
  inflight.add(controller);
232
469
  try {
233
- return await originalFetch(url, {
234
- method: 'POST',
235
- headers: { Authorization: `Bearer ${apiKey()}`, 'Content-Type': 'application/json' },
236
- body,
237
- redirect: 'error', // never forward the API key to another host
238
- signal: controller.signal,
239
- });
470
+ return await postRaw(url, body, controller.signal);
240
471
  }
241
472
  catch (error) {
242
473
  warnLimited(`send:${path}`, `could not reach ${path}: ${error.message}`);
@@ -252,10 +483,11 @@ function send() {
252
483
  return sending;
253
484
  sending = (async () => {
254
485
  const envelope = JSON.stringify({ sdk: `node/${VERSION}`, app: config.appName });
255
- while (queue.length && !stopped) {
256
- // Up to BATCH calls and MAX_BATCH_BYTES per request.
257
- const parts = [];
258
- let size = envelope.length + 16;
486
+ while ((queue.length || retry.length) && !stopped) {
487
+ // Up to BATCH calls and MAX_BATCH_BYTES per request. Calls refused earlier go first, already prepared.
488
+ const parts = retry.splice(0, BATCH);
489
+ retryBytes = retry.reduce((sum, part) => sum + part.length, 0);
490
+ let size = envelope.length + 16 + parts.reduce((sum, part) => sum + part.length + 1, 0);
259
491
  while (queue.length && parts.length < BATCH) {
260
492
  const entry = queue[0];
261
493
  let json;
@@ -284,24 +516,57 @@ function send() {
284
516
  continue;
285
517
  const body = `{"calls":[${parts.join(',')}],${envelope.slice(1)}`;
286
518
  const response = await post('/api/platform-collect/sdk', body, SEND_TIMEOUT_MS);
287
- if (response && !response.ok)
519
+ if (response && !response.ok && !retryable(response.status))
288
520
  warnLimited('rejected', `${parts.length} calls not accepted: ${response.status}`);
521
+ if ((!response || (!response.ok && retryable(response.status))) && !stopped) {
522
+ // Busy, failing, unreachable, hung or timed out: put this batch back at the front, already prepared, and
523
+ // try again after a pause. Nothing recorded is lost to a deploy or a brief outage.
524
+ retry.unshift(...parts);
525
+ retryBytes += parts.reduce((sum, part) => sum + part.length, 0);
526
+ if (retryBytes > MAX_QUEUE_BYTES) {
527
+ const dropped = retry.splice(BATCH * 10);
528
+ retryBytes = retry.reduce((sum, part) => sum + part.length, 0);
529
+ warnLimited('queue-full', `${dropped.length} calls dropped while the collector was unavailable`);
530
+ }
531
+ const pause = response?.retryAfterMs ?? RETRY_PAUSE_MS;
532
+ // Unreachable, hung or timed out. Stop here instead of trying every queued batch in turn (15 s each),
533
+ // which kept the process alive for minutes after the app finished. The queue stays bounded and is
534
+ // retried after a pause, on flush(), or at exit.
535
+ pausedUntil = Date.now() + pause;
536
+ const resume = setTimeout(() => { if (queue.length || retry.length)
537
+ void send(); }, pause);
538
+ resume.unref?.();
539
+ break;
540
+ }
289
541
  }
290
542
  })().catch(() => { }).finally(() => { sending = null; });
291
543
  return sending;
292
544
  }
293
- /** Wait until recorded calls are sent. Call before a serverless function or script exits. Never throws. */
294
- export async function flush() {
545
+ /**
546
+ * Wait until recorded calls are sent, for at most `timeoutMs` (default 10 s). Call before a serverless
547
+ * function or script exits. Resolves true when the queue was sent, false at the deadline. Never throws.
548
+ * Sending continues in the background after the deadline.
549
+ */
550
+ export async function flush(timeoutMs = FLUSH_TIMEOUT_MS) {
295
551
  if (timer) {
296
552
  clearTimeout(timer);
297
553
  timer = null;
298
554
  }
555
+ let deadline;
299
556
  try {
300
- await send();
557
+ const sent = send().then(() => queue.length === 0 && retry.length === 0, () => false);
558
+ // The deadline timer stays referenced: an awaited flush() must be able to finish and report.
559
+ const late = new Promise(resolve => { deadline = setTimeout(() => resolve(false), Math.max(0, timeoutMs)); });
560
+ return await holdOpen(Promise.race([sent, late]));
561
+ }
562
+ catch {
563
+ return false;
564
+ }
565
+ finally {
566
+ clearTimeout(deadline);
301
567
  }
302
- catch { /* send already logs */ }
303
568
  }
304
- const scrubText = (value) => (config.scrub ? config.scrub(value) : value);
569
+ const scrubText = (value) => (config.scrub ? config.scrub(maskSecrets(value)) : maskSecrets(value));
305
570
  /** Mark how a session ended. Returns true when the platform accepted it. Never throws. */
306
571
  export async function reportOutcome(sessionId, outcome, note) {
307
572
  try {
@@ -311,7 +576,7 @@ export async function reportOutcome(sessionId, outcome, note) {
311
576
  }
312
577
  await flush();
313
578
  const body = { session_id: scrubText(String(sessionId)), outcome, note: note == null ? null : scrubText(note) };
314
- const response = await post('/api/platform-collect/outcomes', JSON.stringify(body), DIRECT_TIMEOUT_MS);
579
+ const response = await holdOpen(post('/api/platform-collect/outcomes', JSON.stringify(body), DIRECT_TIMEOUT_MS));
315
580
  return Boolean(response?.ok);
316
581
  }
317
582
  catch {
@@ -327,12 +592,13 @@ export async function logEvent(event) {
327
592
  }
328
593
  await flush();
329
594
  const timestamp = event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp ?? new Date().toISOString();
595
+ const sessionId = scrubText(String(event.sessionId));
330
596
  const properties = Object.fromEntries(Object.entries(event.properties ?? {}).map(([k, v]) => [k, typeof v === 'string' ? scrubText(v) : v]));
331
597
  const body = { events: [{
332
598
  event_id: event.eventId ?? randomUUID(), event_type: scrubText(event.eventType), timestamp,
333
- session_id: scrubText(String(event.sessionId)), properties,
599
+ session_id: sessionId, properties,
334
600
  }] };
335
- const response = await post('/api/platform-collect/events', JSON.stringify(body), DIRECT_TIMEOUT_MS);
601
+ const response = await holdOpen(post('/api/platform-collect/events', JSON.stringify(body), DIRECT_TIMEOUT_MS));
336
602
  return Boolean(response?.ok);
337
603
  }
338
604
  catch {
@@ -349,9 +615,45 @@ function recording(create, api, scrub) {
349
615
  const reply = scope.run({ ...at, inSdkCall: true }, () => create.apply(this, args));
350
616
  if (!reply || typeof reply.then !== 'function')
351
617
  return reply;
618
+ // The SDKs' APIPromise reads the body inside parseResponse. Helpers such as parse() build a new promise that
619
+ // calls it again, so keep the first result: the body is read once and both sides get the same object.
620
+ const api_ = reply;
621
+ if (typeof api_.parseResponse === 'function') {
622
+ try {
623
+ const parseOnce = api_.parseResponse;
624
+ const seen = new WeakMap();
625
+ const parse = function (...a) {
626
+ const key = a[1];
627
+ if (!key || typeof key !== 'object')
628
+ return parseOnce.apply(this, a);
629
+ if (!seen.has(key))
630
+ seen.set(key, parseOnce.apply(this, a));
631
+ return seen.get(key);
632
+ };
633
+ api_.parseResponse = parse;
634
+ const thenUnwrap = api_._thenUnwrap;
635
+ if (typeof thenUnwrap === 'function') {
636
+ api_._thenUnwrap = function (transform) {
637
+ const next = thenUnwrap.call(this, transform);
638
+ if (next && typeof next.parseResponse === 'function') {
639
+ next.parseResponse = async (client, props) => {
640
+ const parsed = await parse.call(reply, client, props);
641
+ const out = transform(parsed, props);
642
+ const id = parsed?._request_id;
643
+ if (id && out && typeof out === 'object' && !('_request_id' in out))
644
+ Object.defineProperty(out, '_request_id', { value: id, enumerable: false });
645
+ return out;
646
+ };
647
+ }
648
+ return next;
649
+ };
650
+ }
651
+ }
652
+ catch { /* leave the SDK promise as it was */ }
653
+ }
352
654
  reply.then(response => {
353
- const done = (result) => record({ api, request, response: result, started_at: started, ended_at: new Date().toISOString(), error: null }, at, scrub);
354
- if (request?.stream && isStream(response))
655
+ const done = (result, error = null) => record({ api, request, response: api === 'gemini' ? plainGemini(result) : result, started_at: started, ended_at: new Date().toISOString(), error }, at, scrub);
656
+ if (request?.stream && (isStream(response) || isLegacyStream(response)))
355
657
  observeStream(response, api, done);
356
658
  else
357
659
  done(response);
@@ -364,13 +666,18 @@ function recording(create, api, scrub) {
364
666
  return wrapped;
365
667
  }
366
668
  const isStream = (value) => typeof value?.iterator === 'function';
669
+ // openai before 4.15 has no `iterator` field: the Stream class only defines [Symbol.asyncIterator].
670
+ const isLegacyStream = (value) => !!value && typeof value === 'object' && typeof value[Symbol.asyncIterator] === 'function';
367
671
  // Both SDKs' Stream classes read chunks through `this.iterator`, including tee() and toReadableStream().
368
672
  // Replacing it keeps the object, its type and its methods, and lets us see each chunk once.
673
+ // A stream the caller stops early (break, return or a throw inside for await, which aborts the request) or
674
+ // that fails is still recorded, with what was assembled so far and an error. The caller's exception is untouched.
369
675
  function observeStream(stream, api, done) {
370
- const source = stream.iterator.bind(stream);
371
- stream.iterator = async function* () {
676
+ const legacy = !isStream(stream);
677
+ const source = legacy ? stream[Symbol.asyncIterator].bind(stream) : stream.iterator.bind(stream);
678
+ const observed = async function* () {
372
679
  const assemble = api === 'anthropic' ? anthropicAssembler() : openAIAssembler();
373
- let finished = false;
680
+ let error = 'stream ended early: the caller stopped reading before the response completed';
374
681
  try {
375
682
  for await (const chunk of { [Symbol.asyncIterator]: source }) {
376
683
  try {
@@ -379,23 +686,42 @@ function observeStream(stream, api, done) {
379
686
  catch { /* never break the caller's stream */ }
380
687
  yield chunk;
381
688
  }
382
- finished = true;
689
+ error = null;
690
+ }
691
+ catch (failure) {
692
+ error = String(failure?.message ?? failure);
693
+ throw failure;
383
694
  }
384
695
  finally {
385
- if (finished)
386
- done(assemble.result());
696
+ let result = null;
697
+ try {
698
+ result = assemble.result();
699
+ }
700
+ catch { /* record the error alone */ }
701
+ done(result, error);
387
702
  }
388
703
  };
704
+ // An own property shadows the class method on this instance only.
705
+ if (legacy)
706
+ Object.defineProperty(stream, Symbol.asyncIterator, { value: observed, writable: true, configurable: true });
707
+ else
708
+ stream.iterator = observed;
389
709
  }
390
710
  function openAIAssembler() {
391
- let model, id, usage = null, final = null;
711
+ let model, id, usage = null, final = null, partial = null;
712
+ const items = [];
392
713
  const choices = [];
393
714
  return {
394
715
  add(chunk) {
395
716
  // Responses API events: the final response arrives whole.
717
+ // Keep the latest snapshot and finished output items too, so a stream stopped early still records what arrived.
396
718
  if (chunk.type) {
397
719
  if (chunk.type === 'response.completed')
398
720
  final = chunk.response;
721
+ else if (chunk.response)
722
+ partial = chunk.response;
723
+ else if (chunk.type === 'response.output_item.done' && chunk.item)
724
+ items[chunk.output_index ?? items.length] = chunk.item;
399
725
  return;
400
726
  }
401
727
  model ??= chunk.model;
@@ -423,7 +749,7 @@ function openAIAssembler() {
423
749
  choice.finish_reason = c.finish_reason;
424
750
  }
425
751
  },
426
- result: () => final ?? ({ id, object: 'chat.completion', model, choices: choices.filter(Boolean), usage }),
752
+ result: () => final ?? (partial ? { ...partial, output: items.filter(Boolean) } : { id, object: 'chat.completion', model, choices: choices.filter(Boolean), usage }),
427
753
  };
428
754
  }
429
755
  function anthropicAssembler() {
@@ -530,6 +856,635 @@ function assembleSSE(text) {
530
856
  }
531
857
  return assemble.result();
532
858
  }
859
+ // generateContentStream resolves to an async generator. Hand the caller a generator that yields the same chunks.
860
+ function geminiStreamRecording(create, scrub) {
861
+ const wrapped = function (...args) {
862
+ const started = new Date().toISOString();
863
+ const at = scope.getStore();
864
+ const request = args[0];
865
+ const reply = scope.run({ ...at, inSdkCall: true }, () => create.apply(this, args));
866
+ if (!reply || typeof reply.then !== 'function')
867
+ return reply;
868
+ const fail = (error) => record({ api: 'gemini', request, response: null, started_at: started, ended_at: new Date().toISOString(), error: String(error?.message ?? error) }, at, scrub);
869
+ return reply.then(source => {
870
+ if (!source || typeof source[Symbol.asyncIterator] !== 'function')
871
+ return source;
872
+ return (async function* () {
873
+ const assemble = geminiAssembler();
874
+ let finished = false;
875
+ try {
876
+ for await (const chunk of source) {
877
+ try {
878
+ assemble.add(chunk);
879
+ }
880
+ catch { /* never break the caller's stream */ }
881
+ yield chunk;
882
+ }
883
+ finished = true;
884
+ }
885
+ catch (error) {
886
+ fail(error);
887
+ throw error;
888
+ }
889
+ finally {
890
+ if (finished)
891
+ record({ api: 'gemini', request, response: assemble.result(), started_at: started, ended_at: new Date().toISOString(), error: null }, at, scrub);
892
+ }
893
+ })();
894
+ }, error => { fail(error); throw error; });
895
+ };
896
+ wrapped[MARK] = true;
897
+ return wrapped;
898
+ }
899
+ // The response as plain JSON, without the raw HTTP headers the SDK attaches.
900
+ function plainGemini(response) {
901
+ if (!response || typeof response !== 'object')
902
+ return response;
903
+ const { sdkHttpResponse, ...rest } = response;
904
+ return rest;
905
+ }
906
+ function geminiAssembler() {
907
+ const out = { candidates: [] };
908
+ const parts = [];
909
+ let candidate = null;
910
+ return {
911
+ add(chunk) {
912
+ const c = plainGemini(chunk);
913
+ if (c.usageMetadata)
914
+ out.usageMetadata = c.usageMetadata;
915
+ if (c.modelVersion)
916
+ out.modelVersion = c.modelVersion;
917
+ if (c.responseId)
918
+ out.responseId = c.responseId;
919
+ const first = c.candidates?.[0];
920
+ if (!first)
921
+ return;
922
+ candidate ??= { content: { role: 'model', parts } };
923
+ if (first.content?.role)
924
+ candidate.content.role = first.content.role;
925
+ if (first.finishReason)
926
+ candidate.finishReason = first.finishReason;
927
+ for (const part of first.content?.parts ?? []) {
928
+ const last = parts[parts.length - 1];
929
+ if (typeof part.text === 'string' && last && typeof last.text === 'string' && Boolean(last.thought) === Boolean(part.thought) && Object.keys(part).every(k => k === 'text' || k === 'thought'))
930
+ last.text += part.text;
931
+ else
932
+ parts.push({ ...part });
933
+ }
934
+ },
935
+ result: () => ({ ...out, candidates: candidate ? [candidate] : [] }),
936
+ };
937
+ }
938
+ // Images and documents arrive as bytes. Send a marker in their place, never the bytes.
939
+ function omitBytes(value, depth = 0) {
940
+ if (value instanceof Uint8Array || value instanceof ArrayBuffer || ArrayBuffer.isView(value))
941
+ return { omitted: 'bytes' };
942
+ if (depth > 32 || value === null || typeof value !== 'object')
943
+ return value;
944
+ if (Array.isArray(value))
945
+ return value.map(item => omitBytes(item, depth + 1));
946
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, omitBytes(v, depth + 1)]));
947
+ }
948
+ const CONVERSE = new Set(['ConverseCommand', 'ConverseStreamCommand']);
949
+ function bedrockRecording(send, scrub) {
950
+ const wrapped = function (...args) {
951
+ const command = args[0];
952
+ const name = command?.constructor?.name ?? '';
953
+ const input = command?.input;
954
+ // By class name, or by input shape when a bundler renamed the classes. InvokeModel has a body, not messages.
955
+ const converse = CONVERSE.has(name) || (typeof input?.modelId === 'string' && Array.isArray(input?.messages) && !('body' in input));
956
+ if (!converse)
957
+ return send.apply(this, args);
958
+ const started = new Date().toISOString();
959
+ const at = scope.getStore();
960
+ let request = null;
961
+ try {
962
+ request = omitBytes(command?.input);
963
+ }
964
+ catch { /* record without the request */ }
965
+ const reply = scope.run({ ...at, inSdkCall: true }, () => send.apply(this, args));
966
+ if (!reply || typeof reply.then !== 'function')
967
+ return reply;
968
+ const done = (response, error) => record({ api: 'bedrock-converse', request, response, started_at: started, ended_at: new Date().toISOString(), error }, at, scrub);
969
+ reply.then(output => {
970
+ try {
971
+ const { $metadata, ...rest } = output ?? {};
972
+ const stream = rest.stream;
973
+ if (stream && typeof stream[Symbol.asyncIterator] === 'function') {
974
+ output.stream = (async function* () {
975
+ const assemble = converseAssembler();
976
+ let finished = false;
977
+ try {
978
+ for await (const event of stream) {
979
+ try {
980
+ assemble.add(event);
981
+ }
982
+ catch { /* never break the caller's stream */ }
983
+ yield event;
984
+ }
985
+ finished = true;
986
+ }
987
+ catch (error) {
988
+ done(null, String(error?.message ?? error));
989
+ throw error;
990
+ }
991
+ finally {
992
+ if (finished)
993
+ done(assemble.result(), null);
994
+ }
995
+ })();
996
+ }
997
+ else
998
+ done(omitBytes(rest), null);
999
+ }
1000
+ catch (error) {
1001
+ warnLimited('record', `could not record a model call: ${error.message}`);
1002
+ }
1003
+ }, error => done(null, String(error?.message ?? error)));
1004
+ return reply;
1005
+ };
1006
+ wrapped[MARK] = true;
1007
+ return wrapped;
1008
+ }
1009
+ function converseAssembler() {
1010
+ const content = [];
1011
+ const json = [];
1012
+ const out = { output: { message: { role: 'assistant', content } }, stopReason: null };
1013
+ return {
1014
+ add(event) {
1015
+ if (event.messageStart?.role)
1016
+ out.output.message.role = event.messageStart.role;
1017
+ const start = event.contentBlockStart;
1018
+ if (start) {
1019
+ const tool = start.start?.toolUse;
1020
+ content[start.contentBlockIndex ?? content.length] = tool ? { toolUse: { toolUseId: tool.toolUseId, name: tool.name, input: {} } } : {};
1021
+ }
1022
+ const delta = event.contentBlockDelta;
1023
+ if (delta) {
1024
+ const i = delta.contentBlockIndex ?? 0, d = delta.delta ?? {};
1025
+ const block = content[i] ??= {};
1026
+ if (typeof d.text === 'string')
1027
+ block.text = (block.text ?? '') + d.text;
1028
+ if (d.toolUse) {
1029
+ block.toolUse ??= { input: {} };
1030
+ json[i] = (json[i] ?? '') + (d.toolUse.input ?? '');
1031
+ }
1032
+ if (d.reasoningContent) {
1033
+ const r = block.reasoningContent ??= { reasoningText: { text: '' } };
1034
+ if (d.reasoningContent.text)
1035
+ r.reasoningText.text += d.reasoningContent.text;
1036
+ if (d.reasoningContent.signature)
1037
+ r.reasoningText.signature = d.reasoningContent.signature;
1038
+ }
1039
+ }
1040
+ const stop = event.contentBlockStop;
1041
+ if (stop && json[stop.contentBlockIndex] && content[stop.contentBlockIndex]?.toolUse) {
1042
+ try {
1043
+ content[stop.contentBlockIndex].toolUse.input = JSON.parse(json[stop.contentBlockIndex]);
1044
+ }
1045
+ catch {
1046
+ content[stop.contentBlockIndex].toolUse.input = json[stop.contentBlockIndex];
1047
+ }
1048
+ }
1049
+ if (event.messageStop)
1050
+ out.stopReason = event.messageStop.stopReason ?? null;
1051
+ if (event.metadata?.usage)
1052
+ out.usage = event.metadata.usage;
1053
+ if (event.metadata?.metrics)
1054
+ out.metrics = event.metadata.metrics;
1055
+ },
1056
+ result: () => ({ ...out, output: { message: { ...out.output.message, content: content.filter(Boolean) } } }),
1057
+ };
1058
+ }
1059
+ // ---- OpenAI Realtime (voice) ----------------------------------------------------------------
1060
+ //
1061
+ // A realtime session is one WebSocket carrying many responses. Each response.done becomes one 'openai-realtime'
1062
+ // call shaped like a Responses call: request { model, instructions, tools, input } and response { id, output, usage },
1063
+ // so the platform folds a whole phone call into one conversation. Only text is kept: transcripts, text, tool calls
1064
+ // and tool results. Audio bytes are never read into this state. Events are observed, never changed or delayed.
1065
+ const REALTIME_MODULES = ['openai/realtime/ws', 'openai/realtime/websocket'];
1066
+ const REALTIME_MAX_ITEMS = 200;
1067
+ // A finished response is recorded once the next response starts, the socket closes, or this long after
1068
+ // response.done, so an interruption that arrives while its audio is still playing is on the call.
1069
+ const REALTIME_HOLD_MS = 60_000;
1070
+ const RT_STATE = Symbol.for('generalcontext.realtime');
1071
+ const rtLive = new Set();
1072
+ let rtExitHooked = false;
1073
+ function patchRealtime(value) {
1074
+ const v = value;
1075
+ for (const cls of [v?.OpenAIRealtimeWS, v?.OpenAIRealtimeWebSocket, v?.default?.OpenAIRealtimeWS, v?.default?.OpenAIRealtimeWebSocket]) {
1076
+ const proto = cls?.prototype;
1077
+ if (!proto || typeof proto.send !== 'function' || typeof proto._emit !== 'function' || Object.prototype.hasOwnProperty.call(proto, MARK))
1078
+ continue;
1079
+ Object.defineProperty(proto, MARK, { value: true });
1080
+ patchRealtimeMethods(proto, undefined);
1081
+ // The constructor assigns this.socket in the app's async scope, so that is where the session id is read.
1082
+ // Automatic reconnects assign it again; the state (and its session) stays the same.
1083
+ const SOCKET = Symbol('socket');
1084
+ Object.defineProperty(proto, 'socket', {
1085
+ configurable: true,
1086
+ get() { return this[SOCKET]; },
1087
+ set(socket) {
1088
+ Object.defineProperty(this, SOCKET, { value: socket, writable: true, configurable: true });
1089
+ try {
1090
+ watchSocketClose(this, rtState(this, undefined), socket);
1091
+ }
1092
+ catch { /* never break the app */ }
1093
+ },
1094
+ });
1095
+ }
1096
+ }
1097
+ function patchRealtimeMethods(owner, scrub) {
1098
+ const emit = owner._emit, send = owner.send, sendRef = owner.sendResponseCreate;
1099
+ if (typeof emit === 'function' && !emit[MARK]) {
1100
+ owner._emit = function (name, ...args) {
1101
+ try {
1102
+ if (name === 'event')
1103
+ rtServerEvent(rtState(this, scrub), args[0]);
1104
+ else if (name === 'close')
1105
+ rtClose(rtState(this, scrub)); // connections with automatic reconnects
1106
+ }
1107
+ catch { /* never break the app */ }
1108
+ return emit.call(this, name, ...args);
1109
+ };
1110
+ owner._emit[MARK] = true;
1111
+ }
1112
+ for (const [key, original] of [['send', send], ['sendResponseCreate', sendRef]]) {
1113
+ if (typeof original !== 'function' || original[MARK])
1114
+ continue;
1115
+ owner[key] = function (event, ...rest) {
1116
+ try {
1117
+ rtClientEvent(rtState(this, scrub), event);
1118
+ }
1119
+ catch { /* never break the app */ }
1120
+ return original.call(this, event, ...rest);
1121
+ };
1122
+ owner[key][MARK] = true;
1123
+ }
1124
+ }
1125
+ function rtState(conn, scrub) {
1126
+ let state = conn[RT_STATE];
1127
+ if (!state) {
1128
+ state = { at: scope.getStore(), scrub, entries: [], seq: 0, speechStoppedAt: null, current: null, held: null, closed: false, bytesPerMs: 48, sentSeq: 0 };
1129
+ try {
1130
+ state.model = new URL(String(conn.url)).searchParams.get('model') ?? undefined;
1131
+ }
1132
+ catch { /* no url yet */ }
1133
+ Object.defineProperty(conn, RT_STATE, { value: state, configurable: true });
1134
+ }
1135
+ else if (scrub !== undefined && state.scrub === undefined)
1136
+ state.scrub = scrub;
1137
+ return state;
1138
+ }
1139
+ const rtWatched = new WeakSet();
1140
+ function watchSocketClose(conn, state, socket) {
1141
+ if (!socket || typeof socket !== 'object' || rtWatched.has(socket))
1142
+ return;
1143
+ rtWatched.add(socket);
1144
+ const onClose = () => { if (conn.socket === socket || conn.socket === undefined)
1145
+ rtClose(state); };
1146
+ if (typeof socket.on === 'function')
1147
+ socket.on('close', onClose);
1148
+ else if (typeof socket.addEventListener === 'function')
1149
+ socket.addEventListener('close', onClose);
1150
+ }
1151
+ const rtText = (item) => {
1152
+ const parts = [];
1153
+ for (const part of Array.isArray(item?.content) ? item.content : []) {
1154
+ const text = typeof part?.text === 'string' ? part.text : typeof part?.transcript === 'string' ? part.transcript : null;
1155
+ if (text)
1156
+ parts.push(text);
1157
+ }
1158
+ return parts.length ? parts.join('') : null;
1159
+ };
1160
+ const rtHasAudio = (item) => (Array.isArray(item?.content) ? item.content : []).some((p) => /audio/.test(String(p?.type)));
1161
+ // A realtime item as a Responses item, text only. Returns null when there is no text yet (audio awaiting its transcript).
1162
+ function rtConvert(item) {
1163
+ if (!item || typeof item !== 'object')
1164
+ return null;
1165
+ if (item.type === 'function_call')
1166
+ return { type: 'function_call', call_id: item.call_id, name: item.name, arguments: item.arguments ?? '' };
1167
+ if (item.type === 'function_call_output')
1168
+ return { type: 'function_call_output', call_id: item.call_id, output: item.output };
1169
+ if (item.type !== 'message')
1170
+ return null;
1171
+ const audio = rtHasAudio(item);
1172
+ // Caller audio with no transcript (input transcription is off, or it has not arrived yet) is kept as an
1173
+ // empty spoken turn, so the call still shows that the caller spoke. A transcript fills it in later.
1174
+ const text = rtText(item) ?? (audio && item.role !== 'assistant' ? '' : null);
1175
+ if (text == null)
1176
+ return null;
1177
+ if (item.role === 'assistant')
1178
+ return { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], ...(audio ? { audio: true } : {}) };
1179
+ return { role: item.role ?? 'user', content: [{ type: 'input_text', text }], ...(audio ? { audio: true } : {}) };
1180
+ }
1181
+ function rtEntry(state, id, match) {
1182
+ let entry = id ? state.entries.find(e => e.id === id) : undefined;
1183
+ if (!entry && match)
1184
+ entry = state.entries.find(e => !e.id && match(e));
1185
+ if (entry) {
1186
+ if (id)
1187
+ entry.id = id;
1188
+ return entry;
1189
+ }
1190
+ entry = { id: id ?? null, item: null, seq: ++state.seq };
1191
+ state.entries.push(entry);
1192
+ if (state.entries.length > REALTIME_MAX_ITEMS)
1193
+ state.entries.splice(0, state.entries.length - REALTIME_MAX_ITEMS);
1194
+ return entry;
1195
+ }
1196
+ function rtUpsert(state, raw, responseId) {
1197
+ const id = typeof raw?.id === 'string' ? raw.id : null;
1198
+ const converted = rtConvert(raw);
1199
+ // The app's own conversation.item.create usually has no id. Match the server's echo of it by content.
1200
+ const same = converted && JSON.stringify(converted);
1201
+ const entry = rtEntry(state, id, e => !!same && !!e.item && JSON.stringify(e.item) === same);
1202
+ // An untranscribed echo of an item never erases a transcript that already arrived.
1203
+ const blank = converted && 'role' in converted && converted.role !== 'assistant' && !converted.content?.[0]?.text;
1204
+ if (converted && !(blank && entry.item))
1205
+ entry.item = { ...entry.item, ...converted };
1206
+ if (responseId)
1207
+ entry.response = responseId;
1208
+ }
1209
+ function rtServerEvent(state, event) {
1210
+ const type = typeof event?.type === 'string' ? event.type : '';
1211
+ const now = new Date().toISOString();
1212
+ switch (type) {
1213
+ case 'session.created':
1214
+ case 'session.updated': {
1215
+ const s = event.session ?? {};
1216
+ if (typeof s.model === 'string')
1217
+ state.model = s.model;
1218
+ if ('instructions' in s)
1219
+ state.instructions = s.instructions;
1220
+ if ('tools' in s)
1221
+ state.tools = s.tools;
1222
+ rtFormat(state, s);
1223
+ break;
1224
+ }
1225
+ case 'conversation.item.added':
1226
+ case 'conversation.item.created':
1227
+ case 'conversation.item.done': {
1228
+ const r = state.current;
1229
+ const own = r && typeof event.item?.id === 'string' && r.output_ids.has(event.item.id) ? r.id : undefined;
1230
+ rtUpsert(state, event.item, own);
1231
+ break;
1232
+ }
1233
+ case 'conversation.item.input_audio_transcription.completed': {
1234
+ const entry = rtEntry(state, event.item_id);
1235
+ entry.item = { role: 'user', content: [{ type: 'input_text', text: String(event.transcript ?? '') }], audio: true, at: now };
1236
+ break;
1237
+ }
1238
+ // Every user turn starts with speech_started. It interrupts the response being generated, or the last one
1239
+ // while its audio is still playing (audio length estimated from the delta sizes, the bytes are not kept).
1240
+ case 'input_audio_buffer.speech_started': {
1241
+ const r = state.current ?? state.held;
1242
+ if (r && (r === state.current || Date.now() < rtPlaybackEnd(state, r)))
1243
+ r.voice.interrupted = true;
1244
+ break;
1245
+ }
1246
+ case 'input_audio_buffer.speech_stopped':
1247
+ state.speechStoppedAt = now;
1248
+ break;
1249
+ case 'response.created': {
1250
+ rtRelease(state);
1251
+ if (state.current)
1252
+ rtFinish(state, 'a new response started before response.done');
1253
+ state.current = rtStart(state, event.response?.id, now);
1254
+ break;
1255
+ }
1256
+ case 'response.output_item.added':
1257
+ case 'response.output_item.done':
1258
+ rtOwn(state, event.response_id, event.item?.id);
1259
+ rtUpsert(state, event.item, rtResponseFor(state, event.response_id)?.id);
1260
+ break;
1261
+ case 'response.output_audio.delta':
1262
+ case 'response.audio.delta': {
1263
+ const r = rtResponseFor(state, event.response_id);
1264
+ if (r && !r.voice.first_audio_at) {
1265
+ r.voice.first_audio_at = now;
1266
+ r.firstAudioMs = Date.now();
1267
+ // The caller has been waiting since they stopped speaking, through any silent response before
1268
+ // this one (a tool call): the wait ends at the first audio they hear.
1269
+ if (state.speechStoppedAt)
1270
+ r.voice.speech_stopped_at = state.speechStoppedAt;
1271
+ state.speechStoppedAt = null;
1272
+ }
1273
+ if (r && typeof event.delta === 'string')
1274
+ r.audioBytes += Math.floor(event.delta.length * 3 / 4);
1275
+ break;
1276
+ }
1277
+ case 'response.output_audio_transcript.done':
1278
+ case 'response.audio_transcript.done':
1279
+ case 'response.output_text.done':
1280
+ case 'response.text.done': {
1281
+ const r = rtResponseFor(state, event.response_id);
1282
+ rtOwn(state, event.response_id, event.item_id);
1283
+ const entry = rtEntry(state, event.item_id);
1284
+ const text = String(event.transcript ?? event.text ?? '');
1285
+ const audio = type.includes('audio');
1286
+ entry.item = { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], ...(audio ? { audio: true } : {}) };
1287
+ if (r)
1288
+ entry.response = r.id;
1289
+ break;
1290
+ }
1291
+ case 'response.function_call_arguments.done': {
1292
+ rtOwn(state, event.response_id, event.item_id);
1293
+ const entry = rtEntry(state, event.item_id);
1294
+ entry.item = { type: 'function_call', call_id: event.call_id, name: event.name, arguments: event.arguments ?? '' };
1295
+ const r = rtResponseFor(state, event.response_id);
1296
+ if (r)
1297
+ entry.response = r.id;
1298
+ break;
1299
+ }
1300
+ case 'conversation.item.truncated':
1301
+ rtTruncate(state, event.item_id, event.audio_end_ms);
1302
+ break;
1303
+ case 'error': {
1304
+ const r = state.current;
1305
+ if (r)
1306
+ r.error = `realtime error: ${String(event.error?.message ?? 'unknown error')}${event.error?.code ? ` (${event.error.code})` : ''}`;
1307
+ break;
1308
+ }
1309
+ case 'response.done': {
1310
+ const res = event.response ?? {};
1311
+ let r = rtResponseFor(state, res.id);
1312
+ if (!r)
1313
+ r = state.current = rtStart(state, res.id, now);
1314
+ for (const item of Array.isArray(res.output) ? res.output : []) {
1315
+ rtOwn(state, r.id, item?.id);
1316
+ rtUpsert(state, item, r.id);
1317
+ }
1318
+ r.ended_at = now;
1319
+ r.usage = res.usage ?? null;
1320
+ if (res.status === 'failed' && !r.error)
1321
+ r.error = `response failed: ${JSON.stringify(res.status_details ?? null)}`;
1322
+ if (res.status === 'cancelled')
1323
+ r.voice.interrupted = true;
1324
+ if (state.current === r)
1325
+ state.current = null;
1326
+ rtHold(state, r);
1327
+ break;
1328
+ }
1329
+ }
1330
+ }
1331
+ function rtClientEvent(state, event) {
1332
+ switch (event?.type) {
1333
+ case 'session.update': {
1334
+ const s = event.session ?? {};
1335
+ if (typeof s.model === 'string')
1336
+ state.model = s.model;
1337
+ if ('instructions' in s)
1338
+ state.instructions = s.instructions;
1339
+ if ('tools' in s)
1340
+ state.tools = s.tools;
1341
+ rtFormat(state, s);
1342
+ break;
1343
+ }
1344
+ case 'conversation.item.create':
1345
+ rtUpsert(state, event.item);
1346
+ break;
1347
+ case 'response.create': {
1348
+ const r = event.response ?? {};
1349
+ if ('instructions' in r || 'tools' in r)
1350
+ state.next = { instructions: r.instructions, tools: r.tools };
1351
+ break;
1352
+ }
1353
+ case 'response.cancel':
1354
+ rtInterrupt(state);
1355
+ break;
1356
+ case 'conversation.item.truncate':
1357
+ rtTruncate(state, event.item_id, event.audio_end_ms);
1358
+ break;
1359
+ }
1360
+ }
1361
+ function rtStart(state, id, now) {
1362
+ const r = {
1363
+ id: typeof id === 'string' ? id : `resp_${randomUUID()}`, seq: ++state.seq, started_at: now, error: null, output_ids: new Set(), audioBytes: 0,
1364
+ voice: { speech_stopped_at: state.speechStoppedAt, first_audio_at: null, interrupted: false },
1365
+ };
1366
+ if (state.next) {
1367
+ r.instructions = state.next.instructions;
1368
+ r.tools = state.next.tools;
1369
+ state.next = undefined;
1370
+ }
1371
+ rtLive.add(state);
1372
+ return r;
1373
+ }
1374
+ // Output audio is pcm16 at 24 kHz (48 bytes per ms) unless the session picked G.711 (8 bytes per ms).
1375
+ function rtFormat(state, session) {
1376
+ const format = session?.audio?.output?.format?.type ?? session?.output_audio_format;
1377
+ if (typeof format === 'string')
1378
+ state.bytesPerMs = /pcmu|pcma|g711/.test(format) ? 8 : 48;
1379
+ }
1380
+ const rtPlaybackEnd = (state, r) => r.firstAudioMs === undefined ? 0 : r.firstAudioMs + r.audioBytes / state.bytesPerMs;
1381
+ const rtResponseFor = (state, id) => [state.current, state.held].find(r => r && (!id || r.id === id)) ?? null;
1382
+ function rtOwn(state, responseId, itemId) {
1383
+ const r = rtResponseFor(state, responseId);
1384
+ if (r && typeof itemId === 'string')
1385
+ r.output_ids.add(itemId);
1386
+ }
1387
+ // The response the caller is hearing: the one in flight, else the last finished one still playing.
1388
+ function rtInterrupt(state) {
1389
+ const r = state.current ?? state.held;
1390
+ if (r)
1391
+ r.voice.interrupted = true;
1392
+ }
1393
+ function rtTruncate(state, itemId, ms) {
1394
+ const r = [state.current, state.held].find(x => x && typeof itemId === 'string' && x.output_ids.has(itemId)) ?? state.current ?? state.held;
1395
+ if (!r)
1396
+ return;
1397
+ r.voice.interrupted = true;
1398
+ if (typeof ms === 'number')
1399
+ r.voice.truncated_at_ms = ms;
1400
+ }
1401
+ function rtHold(state, r) {
1402
+ rtRelease(state);
1403
+ state.held = r;
1404
+ r.timer = setTimeout(() => { if (state.held === r)
1405
+ rtRelease(state); }, REALTIME_HOLD_MS);
1406
+ r.timer.unref?.();
1407
+ if (!rtExitHooked) {
1408
+ rtExitHooked = true;
1409
+ process.once('beforeExit', () => {
1410
+ for (const s of rtLive) {
1411
+ rtRelease(s);
1412
+ }
1413
+ if (queue.length)
1414
+ void flush(EXIT_FLUSH_MS);
1415
+ });
1416
+ }
1417
+ }
1418
+ function rtRelease(state) {
1419
+ const r = state.held;
1420
+ if (!r)
1421
+ return;
1422
+ state.held = null;
1423
+ if (r.timer)
1424
+ clearTimeout(r.timer);
1425
+ rtRecord(state, r);
1426
+ if (!state.current)
1427
+ rtLive.delete(state);
1428
+ }
1429
+ function rtFinish(state, error) {
1430
+ const r = state.current;
1431
+ if (!r)
1432
+ return;
1433
+ state.current = null;
1434
+ r.ended_at = new Date().toISOString();
1435
+ r.error ??= error;
1436
+ rtRecord(state, r);
1437
+ }
1438
+ function rtClose(state) {
1439
+ if (state.closed)
1440
+ return;
1441
+ state.closed = true;
1442
+ rtRelease(state);
1443
+ rtFinish(state, 'connection closed before response.done');
1444
+ rtLive.delete(state);
1445
+ }
1446
+ function rtRecord(state, r) {
1447
+ try {
1448
+ const input = [], output = [];
1449
+ // Timing goes on the stored assistant item too, so every later call's input still carries it.
1450
+ const stopped = r.voice.speech_stopped_at ? Date.parse(r.voice.speech_stopped_at) : NaN;
1451
+ const audio = r.voice.first_audio_at ? Date.parse(r.voice.first_audio_at) : NaN;
1452
+ const timing = {
1453
+ latency_ms: Number.isFinite(stopped) && Number.isFinite(audio) ? audio - stopped : null, interrupted: r.voice.interrupted,
1454
+ };
1455
+ if (r.voice.truncated_at_ms !== undefined)
1456
+ timing.truncated_at_ms = r.voice.truncated_at_ms;
1457
+ for (const e of state.entries) {
1458
+ if (!e.item)
1459
+ continue;
1460
+ const mine = e.response === r.id || (e.id && r.output_ids.has(e.id));
1461
+ if (mine && e.item.role === 'assistant')
1462
+ e.item = { ...e.item, voice: timing };
1463
+ if (mine)
1464
+ output.push(e.item);
1465
+ else if (e.seq < r.seq && e.seq > state.sentSeq)
1466
+ input.push(e.item);
1467
+ }
1468
+ const request = {
1469
+ model: state.model ?? null,
1470
+ instructions: r.instructions ?? state.instructions ?? null,
1471
+ tools: r.tools ?? state.tools ?? [],
1472
+ input,
1473
+ };
1474
+ // After the first reply, only what is new since the last one, chained to it, as the Responses API does.
1475
+ if (state.lastId)
1476
+ request.previous_response_id = state.lastId;
1477
+ state.lastId = r.id;
1478
+ state.sentSeq = Math.max(state.sentSeq, ...state.entries.filter(e => e.item && (e.seq < r.seq || e.response === r.id || (e.id && r.output_ids.has(e.id)))).map(e => e.seq));
1479
+ record({
1480
+ api: 'openai-realtime', request, response: { id: r.id, output, usage: r.usage ?? null },
1481
+ started_at: r.started_at, ended_at: r.ended_at ?? new Date().toISOString(), error: r.error, voice: { ...r.voice },
1482
+ }, state.at?.tags && !state.at.tags.channel ? { ...state.at, tags: { ...state.at.tags, channel: 'voice' } } : state.at, state.scrub);
1483
+ }
1484
+ catch (error) {
1485
+ warnLimited('record', `could not record a realtime response: ${error.message}`);
1486
+ }
1487
+ }
533
1488
  const PATTERNS = [
534
1489
  // Bounded repeats and a token-start lookbehind keep this linear on adversarial input.
535
1490
  ['email', /(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,63}){0,8}\.[A-Za-z]{2,24}/g],
@@ -581,11 +1536,32 @@ export function wrap(client, options = {}) {
581
1536
  owners.push([target.responses, 'openai-responses']);
582
1537
  if (!target.chat && target.messages?.create)
583
1538
  owners.push([target.messages, 'anthropic']);
584
- if (!owners.length)
585
- throw new TypeError('wrap expects an OpenAI or Anthropic client');
1539
+ if (!target.chat && target.beta?.messages?.create)
1540
+ owners.push([target.beta.messages, 'anthropic']);
1541
+ const models = target.models;
1542
+ const gemini = typeof models?.generateContent === 'function';
1543
+ const bedrock = typeof target.send === 'function' && target.config && !gemini && !owners.length;
1544
+ // An OpenAIRealtimeWS or OpenAIRealtimeWebSocket connection. Its session is the one wrap() runs in.
1545
+ const realtime = typeof target.send === 'function' && typeof target._emit === 'function' && 'socket' in target;
1546
+ if (realtime) {
1547
+ patchRealtimeMethods(target, scrub);
1548
+ try {
1549
+ watchSocketClose(target, rtState(target, scrub), target.socket);
1550
+ }
1551
+ catch { /* never break the app */ }
1552
+ return client;
1553
+ }
1554
+ if (!owners.length && !gemini && !bedrock)
1555
+ throw new TypeError('wrap expects an OpenAI, Anthropic, Google Gen AI or Bedrock Runtime client, or an OpenAI Realtime connection');
586
1556
  for (const [owner, api] of owners) {
587
1557
  if (!owner.create[MARK])
588
1558
  owner.create = recording(owner.create.bind(owner), api, scrub);
589
1559
  }
1560
+ if (gemini && !models.generateContent[MARK])
1561
+ models.generateContent = recording(models.generateContent.bind(models), 'gemini', scrub);
1562
+ if (gemini && typeof models.generateContentStream === 'function' && !models.generateContentStream[MARK])
1563
+ models.generateContentStream = geminiStreamRecording(models.generateContentStream.bind(models), scrub);
1564
+ if (bedrock && !target.send[MARK])
1565
+ target.send = bedrockRecording(target.send.bind(target), scrub);
590
1566
  return client;
591
1567
  }