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/README.md +32 -87
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +175 -0
- package/dist/index.d.ts +52 -9
- package/dist/index.js +1013 -37
- package/package.json +27 -4
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
294
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
371
|
-
|
|
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
|
|
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
|
-
|
|
689
|
+
error = null;
|
|
690
|
+
}
|
|
691
|
+
catch (failure) {
|
|
692
|
+
error = String(failure?.message ?? failure);
|
|
693
|
+
throw failure;
|
|
383
694
|
}
|
|
384
695
|
finally {
|
|
385
|
-
|
|
386
|
-
|
|
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 (!
|
|
585
|
-
|
|
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
|
}
|