@pyai/sdk 0.2.1 → 0.2.3
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 +84 -25
- package/dist/cli.d.ts +4 -3
- package/dist/cli.js +78 -28
- package/dist/index.d.ts +212 -37
- package/dist/index.js +314 -30
- package/package.json +2 -2
- package/src/cli.ts +77 -29
- package/src/index.ts +481 -57
package/dist/index.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @pyai/sdk
|
|
2
|
+
* @pyai/sdk, official TypeScript/JavaScript client for the PyAI API.
|
|
3
3
|
*
|
|
4
4
|
* Thin, dependency-free wrapper over the public OpenAI-compatible surface at
|
|
5
5
|
* https://api.pyai.com (contract: https://api.pyai.com/openapi.json). Runs in
|
|
6
|
-
* the browser and Node 22+ (uses global fetch + WebSocket). Keys are opaque
|
|
7
|
-
* never parsed.
|
|
6
|
+
* the browser and Node 22+ (uses global fetch + WebSocket). Keys are opaque, * never parsed.
|
|
8
7
|
*/
|
|
9
8
|
/** Stable, machine-readable error. Branch on `code`, not `message`. */
|
|
10
9
|
export class PyAIError extends Error {
|
|
@@ -30,7 +29,7 @@ export const SPEECH_FORMATS = ["wav", "mp3", "opus", "aac", "flac", "pcm", "g711
|
|
|
30
29
|
/** Sample rates (Hz) the server accepts for `audio.speech` (`g711_*` is always 8 kHz). */
|
|
31
30
|
export const SPEECH_SAMPLE_RATES = [8000, 16000, 24000, 48000];
|
|
32
31
|
/* ------------------------------------------------------------------------- *
|
|
33
|
-
* Stable enums
|
|
32
|
+
* Stable enums, mirror the server so callers branch on named constants, not
|
|
34
33
|
* magic strings, and a contract change surfaces in one place. These are plain
|
|
35
34
|
* `as const` objects (not TS `enum`s) so the source still runs directly under
|
|
36
35
|
* Node's type-stripping and stays tree-shakeable.
|
|
@@ -63,7 +62,7 @@ export const WSCloseCode = {
|
|
|
63
62
|
};
|
|
64
63
|
/**
|
|
65
64
|
* Stable, machine-readable error `code`s (the documented contract). Branch on
|
|
66
|
-
* these. The set is treated as open
|
|
65
|
+
* these. The set is treated as open, `PyAIError.code` stays `string`, so a
|
|
67
66
|
* new server code never breaks the build, but the known ones are named here.
|
|
68
67
|
*/
|
|
69
68
|
export const ErrorCode = {
|
|
@@ -151,7 +150,7 @@ export class HearStream {
|
|
|
151
150
|
this.opts.onError?.(frame);
|
|
152
151
|
break;
|
|
153
152
|
default:
|
|
154
|
-
// Unknown/forward-compatible frame
|
|
153
|
+
// Unknown/forward-compatible frame, ignore.
|
|
155
154
|
break;
|
|
156
155
|
}
|
|
157
156
|
}
|
|
@@ -177,8 +176,73 @@ export class HearStream {
|
|
|
177
176
|
return this.ws.readyState;
|
|
178
177
|
}
|
|
179
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* A live AMD (answering-machine-detection) stream. The wire is Twilio's Media
|
|
181
|
+
* Streams protocol, so if you're already on Twilio the usual path is one line of
|
|
182
|
+
* TwiML pointing `<Stream url="wss://api.pyai.com/v1/amd/stream">` at PyAI, no
|
|
183
|
+
* SDK needed. This helper is for server-side clients that fork the media
|
|
184
|
+
* themselves: send Twilio `start`/`media`/`stop` frames with {@link AmdStream.send}
|
|
185
|
+
* (or raw μ-law audio with {@link AmdStream.sendAudio}) and receive the `amd`
|
|
186
|
+
* decision via `onDecision`. Construct via `pyai.amd.stream()`.
|
|
187
|
+
*/
|
|
188
|
+
export class AmdStream {
|
|
189
|
+
ws;
|
|
190
|
+
opts;
|
|
191
|
+
closed = false;
|
|
192
|
+
constructor(url, subprotocol, opts) {
|
|
193
|
+
this.opts = opts;
|
|
194
|
+
const WS = opts.webSocket ?? globalThis.WebSocket;
|
|
195
|
+
if (!WS) {
|
|
196
|
+
throw new Error("No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to amd.stream()");
|
|
197
|
+
}
|
|
198
|
+
this.ws = new WS(url, [subprotocol]);
|
|
199
|
+
this.ws.onopen = () => opts.onOpen?.();
|
|
200
|
+
this.ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
201
|
+
this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
|
|
202
|
+
this.ws.onclose = (ev) => {
|
|
203
|
+
this.closed = true;
|
|
204
|
+
opts.onClose?.(ev.code, ev.reason);
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
handleMessage(data) {
|
|
208
|
+
if (typeof data !== "string")
|
|
209
|
+
return; // AMD decisions are JSON text frames
|
|
210
|
+
let frame;
|
|
211
|
+
try {
|
|
212
|
+
frame = JSON.parse(data);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
this.opts.onError?.(new Error(`Unparseable AMD frame: ${data.slice(0, 120)}`));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (frame.event === "amd")
|
|
219
|
+
this.opts.onDecision?.(frame);
|
|
220
|
+
this.opts.onMessage?.(frame);
|
|
221
|
+
}
|
|
222
|
+
/** Send a Twilio Media Streams control/media frame (JSON) or a raw string. */
|
|
223
|
+
send(frame) {
|
|
224
|
+
this.ws.send(typeof frame === "string" ? frame : JSON.stringify(frame));
|
|
225
|
+
}
|
|
226
|
+
/** Send a raw audio chunk (G.711 μ-law 8 kHz for the Twilio-native path). */
|
|
227
|
+
sendAudio(chunk) {
|
|
228
|
+
this.ws.send(chunk);
|
|
229
|
+
}
|
|
230
|
+
/** Close the socket. */
|
|
231
|
+
close(code = WSCloseCode.Normal, reason = "") {
|
|
232
|
+
if (!this.closed)
|
|
233
|
+
this.ws.close(code, reason);
|
|
234
|
+
}
|
|
235
|
+
/** The underlying socket (escape hatch for advanced use). */
|
|
236
|
+
get socket() {
|
|
237
|
+
return this.ws;
|
|
238
|
+
}
|
|
239
|
+
/** Current WebSocket readyState. */
|
|
240
|
+
get readyState() {
|
|
241
|
+
return this.ws.readyState;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
180
244
|
/* ------------------------------------------------------------------------- *
|
|
181
|
-
* Omni realtime (agentic voice)
|
|
245
|
+
* Omni realtime (agentic voice), typed client over the /v1/omni WebSocket
|
|
182
246
|
* ------------------------------------------------------------------------- */
|
|
183
247
|
/**
|
|
184
248
|
* Event names on Omni **server → client** frames. ⚠️ Note the asymmetry:
|
|
@@ -201,11 +265,108 @@ export const OmniEvent = {
|
|
|
201
265
|
BargeIn: "barge_in",
|
|
202
266
|
/** Alias for barge-in on some engine builds. */
|
|
203
267
|
Flush: "flush",
|
|
268
|
+
/** Engine requests a client-loop tool invocation. */
|
|
269
|
+
ToolCall: "tool_call",
|
|
204
270
|
/** Session is closing; see close code. */
|
|
205
271
|
SessionEnd: "session_end",
|
|
206
272
|
/** Server fault frame. */
|
|
207
273
|
Error: "error",
|
|
208
274
|
};
|
|
275
|
+
const OMNI_TRANSCRIPT_MAX_BYTES = 16_384;
|
|
276
|
+
const OMNI_TRANSCRIPT_MAX_CHARS = 4_000;
|
|
277
|
+
function omniTranscriptRole(value) {
|
|
278
|
+
if (value === "user" || value === "caller" || value === "human")
|
|
279
|
+
return "user";
|
|
280
|
+
if (value === "assistant" || value === "agent")
|
|
281
|
+
return "assistant";
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
function omniTranscriptText(value) {
|
|
285
|
+
return typeof value === "string"
|
|
286
|
+
&& value.length > 0
|
|
287
|
+
&& value.length <= OMNI_TRANSCRIPT_MAX_CHARS
|
|
288
|
+
&& !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
|
|
289
|
+
? value
|
|
290
|
+
: null;
|
|
291
|
+
}
|
|
292
|
+
/** Normalize the live UTF-8 0x02 body and the documented direct-object legacy shape. */
|
|
293
|
+
export function normalizeOmniTranscriptBody(bytes) {
|
|
294
|
+
if (bytes.byteLength === 0 || bytes.byteLength > OMNI_TRANSCRIPT_MAX_BYTES)
|
|
295
|
+
return null;
|
|
296
|
+
let decoded;
|
|
297
|
+
try {
|
|
298
|
+
decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
if (!decoded.trimStart().startsWith("{")) {
|
|
304
|
+
const text = omniTranscriptText(decoded);
|
|
305
|
+
return text
|
|
306
|
+
? { event: "transcript", role: "user", text, final: false, mode: "delta" }
|
|
307
|
+
: null;
|
|
308
|
+
}
|
|
309
|
+
let value;
|
|
310
|
+
try {
|
|
311
|
+
value = JSON.parse(decoded);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
317
|
+
return null;
|
|
318
|
+
const payload = value;
|
|
319
|
+
const role = omniTranscriptRole(payload.role ?? payload.speaker);
|
|
320
|
+
const mode = typeof payload.delta === "string" ? "delta" : "replace";
|
|
321
|
+
const text = omniTranscriptText(mode === "delta" ? payload.delta : typeof payload.text === "string" ? payload.text : payload.transcript);
|
|
322
|
+
if (!role || !text)
|
|
323
|
+
return null;
|
|
324
|
+
if (payload.final !== undefined && typeof payload.final !== "boolean")
|
|
325
|
+
return null;
|
|
326
|
+
if (payload.sequence !== undefined
|
|
327
|
+
&& (typeof payload.sequence !== "number"
|
|
328
|
+
|| !Number.isSafeInteger(payload.sequence)
|
|
329
|
+
|| payload.sequence < 0))
|
|
330
|
+
return null;
|
|
331
|
+
return {
|
|
332
|
+
event: "transcript",
|
|
333
|
+
role,
|
|
334
|
+
text,
|
|
335
|
+
final: payload.final === true,
|
|
336
|
+
mode,
|
|
337
|
+
...(payload.sequence === undefined ? {} : { sequence: payload.sequence }),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/** Extract a byte view from a binary WS frame (Buffer / ArrayBuffer / typed
|
|
341
|
+
* array). Returns null for a Blob or unknown (can't be read synchronously). */
|
|
342
|
+
function omniToBytes(data) {
|
|
343
|
+
if (data instanceof Uint8Array)
|
|
344
|
+
return data; // also covers Node Buffer
|
|
345
|
+
if (data instanceof ArrayBuffer)
|
|
346
|
+
return new Uint8Array(data);
|
|
347
|
+
if (ArrayBuffer.isView(data)) {
|
|
348
|
+
const v = data;
|
|
349
|
+
return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
/** A 0x01-prefixed audio frame, the engine's client→server framing for caller
|
|
354
|
+
* PCM16 LE (see OMNI_PROTOCOL_V2.md §2). */
|
|
355
|
+
function omniAudioFrame(pcm) {
|
|
356
|
+
const out = new Uint8Array(pcm.length + 1);
|
|
357
|
+
out[0] = 0x01;
|
|
358
|
+
out.set(pcm, 1);
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
/** A 0x03-prefixed control frame, the engine's client→server framing for
|
|
362
|
+
* `configure`/`dtmf`/`tool_result` (see OMNI_PROTOCOL_V2.md §2/§3). */
|
|
363
|
+
function omniControlFrame(obj) {
|
|
364
|
+
const json = new TextEncoder().encode(JSON.stringify(obj));
|
|
365
|
+
const out = new Uint8Array(json.length + 1);
|
|
366
|
+
out[0] = 0x03;
|
|
367
|
+
out.set(json, 1);
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
209
370
|
/**
|
|
210
371
|
* A live Omni agentic-voice session over `/v1/omni`. Hides the wire protocol's
|
|
211
372
|
* frame-key asymmetry: it sends control frames keyed on `type` (`configure`,
|
|
@@ -226,6 +387,10 @@ export class OmniConnection {
|
|
|
226
387
|
ws;
|
|
227
388
|
opts;
|
|
228
389
|
closed = false;
|
|
390
|
+
/** Serializes async Blob reads in {@link sendAudio} so frames stay ordered. */
|
|
391
|
+
blobTail = Promise.resolve();
|
|
392
|
+
/** Serializes inbound Blob decoding so browser frames stay ordered. */
|
|
393
|
+
inboundTail = Promise.resolve();
|
|
229
394
|
constructor(url, subprotocol, opts) {
|
|
230
395
|
this.opts = opts;
|
|
231
396
|
const WS = opts.webSocket ?? globalThis.WebSocket;
|
|
@@ -244,29 +409,80 @@ export class OmniConnection {
|
|
|
244
409
|
}
|
|
245
410
|
opts.onOpen?.();
|
|
246
411
|
};
|
|
247
|
-
this.ws.onmessage = (ev) =>
|
|
412
|
+
this.ws.onmessage = (ev) => {
|
|
413
|
+
const reportDecodeError = (error) => {
|
|
414
|
+
opts.onError?.(error instanceof Error ? error : new Error("Could not decode Omni frame"));
|
|
415
|
+
};
|
|
416
|
+
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
|
|
417
|
+
this.inboundTail = this.inboundTail.then(() => this.handleMessage(ev.data)).catch(reportDecodeError);
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
void this.handleMessage(ev.data).catch(reportDecodeError);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
248
423
|
this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
|
|
249
424
|
this.ws.onclose = (ev) => {
|
|
250
425
|
this.closed = true;
|
|
251
426
|
opts.onClose?.(ev.code, ev.reason);
|
|
252
427
|
};
|
|
253
428
|
}
|
|
254
|
-
handleMessage(data) {
|
|
255
|
-
//
|
|
429
|
+
async handleMessage(data) {
|
|
430
|
+
// Server → client binary frames are TYPE-TAGGED by their first byte:
|
|
431
|
+
// 0x01 = agent audio (PCM16) · 0x02 = transcript UTF-8 · 0x03 = control JSON.
|
|
432
|
+
// (Treating every binary frame as audio, the old behavior, plays the
|
|
433
|
+
// 0x03/0x02 frames as a glitch and drops every event/transcript.)
|
|
256
434
|
if (typeof data !== "string") {
|
|
257
|
-
|
|
435
|
+
const bytes = typeof Blob !== "undefined" && data instanceof Blob
|
|
436
|
+
? new Uint8Array(await data.arrayBuffer())
|
|
437
|
+
: omniToBytes(data);
|
|
438
|
+
if (!bytes) {
|
|
439
|
+
this.opts.onError?.(new Error("Unsupported Omni binary frame"));
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const tag = bytes[0];
|
|
443
|
+
if (tag === 0x01) {
|
|
444
|
+
this.opts.onAudio?.(bytes.slice(1)); // copy → aligned PCM16
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (tag === 0x02) {
|
|
448
|
+
const transcript = normalizeOmniTranscriptBody(bytes.subarray(1));
|
|
449
|
+
if (transcript)
|
|
450
|
+
this.dispatchFrame(transcript);
|
|
451
|
+
else
|
|
452
|
+
this.opts.onError?.(new Error("Unparseable Omni transcript frame"));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (tag === 0x03) {
|
|
456
|
+
try {
|
|
457
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1)));
|
|
458
|
+
this.dispatchFrame(parsed);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
this.opts.onError?.(new Error("Unparseable Omni binary frame"));
|
|
462
|
+
}
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const tagName = tag === undefined ? "empty" : `0x${tag.toString(16).padStart(2, "0")}`;
|
|
466
|
+
this.opts.onError?.(new Error(`Ignored unknown Omni binary frame tag ${tagName}`));
|
|
258
467
|
return;
|
|
259
468
|
}
|
|
260
|
-
|
|
469
|
+
// Text frame (e.g. a server-side broker relay).
|
|
261
470
|
try {
|
|
262
|
-
|
|
471
|
+
this.dispatchFrame(JSON.parse(data));
|
|
263
472
|
}
|
|
264
473
|
catch {
|
|
265
474
|
this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
|
|
266
|
-
return;
|
|
267
475
|
}
|
|
268
|
-
|
|
269
|
-
|
|
476
|
+
}
|
|
477
|
+
dispatchFrame(frame) {
|
|
478
|
+
const raw = frame;
|
|
479
|
+
const eventName = typeof raw.event === "string"
|
|
480
|
+
? raw.event
|
|
481
|
+
: typeof raw.type === "string"
|
|
482
|
+
? raw.type
|
|
483
|
+
: "";
|
|
484
|
+
this.opts.onEvent?.({ ...frame, event: eventName });
|
|
485
|
+
switch (eventName) {
|
|
270
486
|
case OmniEvent.Hello:
|
|
271
487
|
this.opts.onHello?.(frame);
|
|
272
488
|
break;
|
|
@@ -286,6 +502,9 @@ export class OmniConnection {
|
|
|
286
502
|
case OmniEvent.Flush:
|
|
287
503
|
this.opts.onBargeIn?.(frame);
|
|
288
504
|
break;
|
|
505
|
+
case OmniEvent.ToolCall:
|
|
506
|
+
this.opts.onToolCall?.(frame);
|
|
507
|
+
break;
|
|
289
508
|
case OmniEvent.SessionEnd:
|
|
290
509
|
this.opts.onSessionEnd?.(frame);
|
|
291
510
|
break;
|
|
@@ -293,25 +512,45 @@ export class OmniConnection {
|
|
|
293
512
|
this.opts.onError?.(frame);
|
|
294
513
|
break;
|
|
295
514
|
default:
|
|
296
|
-
// Unknown/forward-compatible frame
|
|
515
|
+
// Unknown/forward-compatible frame, already delivered via onEvent.
|
|
297
516
|
break;
|
|
298
517
|
}
|
|
299
518
|
}
|
|
300
519
|
/**
|
|
301
520
|
* Send (or update) the agent config. Always emitted as
|
|
302
|
-
* `{"type":"configure", ...}
|
|
521
|
+
* `{"type":"configure", ...}`, the correct key. (A hand-rolled
|
|
303
522
|
* `{"event":"configure"}` is acked but silently dropped by the engine.)
|
|
304
523
|
*/
|
|
305
524
|
configure(cfg) {
|
|
306
|
-
this.ws.send(
|
|
525
|
+
this.ws.send(omniControlFrame({ type: "configure", ...cfg }));
|
|
307
526
|
}
|
|
308
|
-
/**
|
|
527
|
+
/**
|
|
528
|
+
* Stream a chunk of caller audio (PCM16 LE mono at the negotiated rate) as a
|
|
529
|
+
* `0x01`-prefixed frame. The engine demuxes on the first byte and has no
|
|
530
|
+
* default branch, so an untagged chunk is dropped with no error.
|
|
531
|
+
*/
|
|
309
532
|
sendAudio(chunk) {
|
|
310
|
-
|
|
533
|
+
const bytes = omniToBytes(chunk);
|
|
534
|
+
if (bytes) {
|
|
535
|
+
this.ws.send(omniAudioFrame(bytes));
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
// A Blob can only be read asynchronously; queue behind the previous read so
|
|
539
|
+
// frames still reach the engine in call order.
|
|
540
|
+
this.blobTail = this.blobTail
|
|
541
|
+
.then(async () => {
|
|
542
|
+
const buf = await chunk.arrayBuffer();
|
|
543
|
+
this.ws.send(omniAudioFrame(new Uint8Array(buf)));
|
|
544
|
+
})
|
|
545
|
+
.catch((err) => this.opts.onError?.(err instanceof Error ? err : new Error(String(err))));
|
|
311
546
|
}
|
|
312
547
|
/** Send DTMF digits as a `{"type":"dtmf"}` control frame. */
|
|
313
548
|
sendDtmf(digits) {
|
|
314
|
-
this.ws.send(
|
|
549
|
+
this.ws.send(omniControlFrame({ type: "dtmf", digits }));
|
|
550
|
+
}
|
|
551
|
+
/** Reply to a client-loop {@link OmniEvent.ToolCall}. */
|
|
552
|
+
toolResult(callId, payload) {
|
|
553
|
+
this.ws.send(omniControlFrame({ type: "tool_result", call_id: callId, ...payload }));
|
|
315
554
|
}
|
|
316
555
|
/**
|
|
317
556
|
* Send an arbitrary control frame for forward-compat control types the SDK
|
|
@@ -319,7 +558,7 @@ export class OmniConnection {
|
|
|
319
558
|
* never `event`.
|
|
320
559
|
*/
|
|
321
560
|
send(frame) {
|
|
322
|
-
this.ws.send(
|
|
561
|
+
this.ws.send(omniControlFrame(frame));
|
|
323
562
|
}
|
|
324
563
|
/** Close the session. */
|
|
325
564
|
close(code = WSCloseCode.Normal, reason = "") {
|
|
@@ -447,7 +686,7 @@ export class PyAI {
|
|
|
447
686
|
const res = await this.request("/v1/audio/speech", {
|
|
448
687
|
method: "POST",
|
|
449
688
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
450
|
-
body: JSON.stringify({ model: "pyai-voice", ...params }),
|
|
689
|
+
body: JSON.stringify({ model: "pyai-voice", ...params, stream: true }),
|
|
451
690
|
});
|
|
452
691
|
if (!res.body)
|
|
453
692
|
throw new PyAIError(res.status, "Response had no body to stream");
|
|
@@ -510,7 +749,7 @@ export class PyAI {
|
|
|
510
749
|
// --- key introspection --------------------------------------------------
|
|
511
750
|
/**
|
|
512
751
|
* Introspect the calling key: scopes, environment, and limits. Useful for a
|
|
513
|
-
* preflight/doctor check. (New route; older deployments may 404
|
|
752
|
+
* preflight/doctor check. (New route; older deployments may 404, handle it.)
|
|
514
753
|
*/
|
|
515
754
|
me = () => this.getJson("/v1/me");
|
|
516
755
|
// --- voice clones -------------------------------------------------------
|
|
@@ -612,7 +851,7 @@ export class PyAI {
|
|
|
612
851
|
},
|
|
613
852
|
},
|
|
614
853
|
findings: {
|
|
615
|
-
/** List Tier-2 (async semantic) findings
|
|
854
|
+
/** List Tier-2 (async semantic) findings, advisory, non-blocking. Scope `trace:read`. */
|
|
616
855
|
list: (params = {}) => {
|
|
617
856
|
const q = new URLSearchParams();
|
|
618
857
|
if (params.checkId)
|
|
@@ -680,11 +919,45 @@ export class PyAI {
|
|
|
680
919
|
trigger: (callId, input) => this.postJson(`/v1/recap/calls/${encodeURIComponent(callId)}`, input),
|
|
681
920
|
},
|
|
682
921
|
};
|
|
922
|
+
// --- amd (answering-machine detection) ---------------------------------
|
|
923
|
+
amd = {
|
|
924
|
+
config: {
|
|
925
|
+
/** The org's AMD operating point + webhook. Scope `amd:configure`. */
|
|
926
|
+
get: () => this.getJson("/v1/amd/config"),
|
|
927
|
+
/** Set the account-default `aggressiveness` (0-1) and webhook. Scope `amd:configure`. */
|
|
928
|
+
set: (input) => this.postJson("/v1/amd/config", {
|
|
929
|
+
...(input.aggressiveness !== undefined ? { aggressiveness: input.aggressiveness } : {}),
|
|
930
|
+
...(input.webhookUrl !== undefined ? { webhook_url: input.webhookUrl } : {}),
|
|
931
|
+
}),
|
|
932
|
+
},
|
|
933
|
+
calls: {
|
|
934
|
+
/** Recent AMD decisions, newest first. Scope `amd:read`. */
|
|
935
|
+
list: (params = {}) => {
|
|
936
|
+
const q = new URLSearchParams();
|
|
937
|
+
if (params.limit !== undefined)
|
|
938
|
+
q.set("limit", String(params.limit));
|
|
939
|
+
if (params.cursor)
|
|
940
|
+
q.set("cursor", params.cursor);
|
|
941
|
+
if (params.sessionLabel)
|
|
942
|
+
q.set("session_label", params.sessionLabel);
|
|
943
|
+
const qs = q.toString();
|
|
944
|
+
return this.getJson(`/v1/amd/calls${qs ? `?${qs}` : ""}`);
|
|
945
|
+
},
|
|
946
|
+
/** The full decision (answered_by, reason, …) for one call. Scope `amd:read`. */
|
|
947
|
+
get: (callId) => this.getJson(`/v1/amd/calls/${encodeURIComponent(callId)}`),
|
|
948
|
+
},
|
|
949
|
+
/**
|
|
950
|
+
* Open a live AMD stream over `/v1/amd/stream` (Twilio Media Streams
|
|
951
|
+
* protocol). Server-side helper for forking media yourself; the common
|
|
952
|
+
* Twilio path is one line of TwiML, no SDK. Scope `amd:detect`.
|
|
953
|
+
*/
|
|
954
|
+
stream: (opts = {}) => new AmdStream(this.amdStreamURL(opts), this.realtimeSubprotocol(), opts),
|
|
955
|
+
};
|
|
683
956
|
// --- omni (agentic voice) ----------------------------------------------
|
|
684
957
|
omni = {
|
|
685
958
|
/**
|
|
686
959
|
* Mint an ephemeral, origin-locked Omni session token a browser can use to
|
|
687
|
-
* open ONE realtime session **directly
|
|
960
|
+
* open ONE realtime session **directly**, the public/private split for
|
|
688
961
|
* realtime. **Call this from your server** with a secret key holding
|
|
689
962
|
* `omni:session`; never ship the secret key to a page. Hand the returned
|
|
690
963
|
* `token` to the browser, which connects with
|
|
@@ -700,8 +973,8 @@ export class PyAI {
|
|
|
700
973
|
/**
|
|
701
974
|
* Open a live Omni agentic-voice session over `/v1/omni`. Returns an
|
|
702
975
|
* {@link OmniConnection} that handles the wire protocol's frame-key
|
|
703
|
-
* asymmetry for you
|
|
704
|
-
* `dtmf`) and parses `event`-keyed server frames
|
|
976
|
+
* asymmetry for you, it sends `type`-keyed control frames (`configure`,
|
|
977
|
+
* `dtmf`) and parses `event`-keyed server frames, so you can't trip the #1
|
|
705
978
|
* Omni integration bug. Omni is zero-state: nothing to create first; the
|
|
706
979
|
* agent's behavior travels in the `configure` frame. Pass `token` (from
|
|
707
980
|
* `createSession`) to connect from a browser without the secret key.
|
|
@@ -724,7 +997,7 @@ export class PyAI {
|
|
|
724
997
|
const q = new URLSearchParams(opts.query ?? {});
|
|
725
998
|
if ((opts.product ?? "omni") === "omni") {
|
|
726
999
|
// Omni's native realtime surface is /v1/omni. The session is authorized by
|
|
727
|
-
// the key's org (zero-state)
|
|
1000
|
+
// the key's org (zero-state), sessionLabel is an optional opaque tag.
|
|
728
1001
|
// format/rate are load-bearing on the connect URL, so default to
|
|
729
1002
|
// browser-grade PCM16/24kHz.
|
|
730
1003
|
if (opts.sessionLabel)
|
|
@@ -766,6 +1039,17 @@ export class PyAI {
|
|
|
766
1039
|
const qs = q.toString();
|
|
767
1040
|
return `${wsBase}/v1/audio/transcriptions/stream${qs ? `?${qs}` : ""}`;
|
|
768
1041
|
}
|
|
1042
|
+
/** Build the AMD detection WebSocket URL (`/v1/amd/stream`). */
|
|
1043
|
+
amdStreamURL(opts = {}) {
|
|
1044
|
+
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
1045
|
+
const q = new URLSearchParams(opts.query ?? {});
|
|
1046
|
+
if (opts.aggressiveness !== undefined)
|
|
1047
|
+
q.set("aggressiveness", String(opts.aggressiveness));
|
|
1048
|
+
if (opts.sessionLabel)
|
|
1049
|
+
q.set("session_label", opts.sessionLabel);
|
|
1050
|
+
const qs = q.toString();
|
|
1051
|
+
return `${wsBase}/v1/amd/stream${qs ? `?${qs}` : ""}`;
|
|
1052
|
+
}
|
|
769
1053
|
/**
|
|
770
1054
|
* Open a realtime WebSocket. Uses the global WebSocket (browser, Node 22+).
|
|
771
1055
|
* The key travels as a subprotocol so it works from the browser without
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyai/sdk",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Official TypeScript/JavaScript SDK for PyAI
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "Official TypeScript/JavaScript SDK for PyAI, speech-to-text (Hear), text-to-speech (Speak), realtime voice agents (Omni), and call compliance (Trace).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|