@pyai/sdk 0.2.0 → 0.2.2
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 +115 -34
- package/dist/cli.d.ts +4 -3
- package/dist/cli.js +78 -28
- package/dist/index.d.ts +431 -22
- package/dist/index.js +426 -15
- package/package.json +3 -2
- package/src/cli.ts +77 -29
- package/src/index.ts +743 -31
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.
|
|
@@ -45,6 +44,8 @@ export const HearFrameType = {
|
|
|
45
44
|
SpeechFinal: "speech_final",
|
|
46
45
|
/** Corrected, full-context transcript following `speech_final`. */
|
|
47
46
|
Final: "final",
|
|
47
|
+
/** Final billed-usage summary, emitted just before a graceful close. */
|
|
48
|
+
Usage: "usage",
|
|
48
49
|
/** Server-side fault frame. */
|
|
49
50
|
Error: "error",
|
|
50
51
|
};
|
|
@@ -61,7 +62,7 @@ export const WSCloseCode = {
|
|
|
61
62
|
};
|
|
62
63
|
/**
|
|
63
64
|
* Stable, machine-readable error `code`s (the documented contract). Branch on
|
|
64
|
-
* these. The set is treated as open
|
|
65
|
+
* these. The set is treated as open, `PyAIError.code` stays `string`, so a
|
|
65
66
|
* new server code never breaks the build, but the known ones are named here.
|
|
66
67
|
*/
|
|
67
68
|
export const ErrorCode = {
|
|
@@ -99,7 +100,14 @@ export class HearStream {
|
|
|
99
100
|
this.ws.onopen = () => {
|
|
100
101
|
if (opts.grounding) {
|
|
101
102
|
try {
|
|
102
|
-
|
|
103
|
+
const cfg = { type: "config", grounding: true };
|
|
104
|
+
if (opts.groundingK != null)
|
|
105
|
+
cfg.grounding_k = opts.groundingK;
|
|
106
|
+
if (opts.groundingMinScore != null)
|
|
107
|
+
cfg.grounding_min_score = opts.groundingMinScore;
|
|
108
|
+
if (opts.groundingTimeoutMs != null)
|
|
109
|
+
cfg.grounding_timeout_ms = opts.groundingTimeoutMs;
|
|
110
|
+
this.ws.send(JSON.stringify(cfg));
|
|
103
111
|
}
|
|
104
112
|
catch {
|
|
105
113
|
/* surfaced via onerror */
|
|
@@ -135,11 +143,14 @@ export class HearStream {
|
|
|
135
143
|
case HearFrameType.Final:
|
|
136
144
|
this.opts.onFinal?.(frame);
|
|
137
145
|
break;
|
|
146
|
+
case HearFrameType.Usage:
|
|
147
|
+
this.opts.onUsage?.(frame);
|
|
148
|
+
break;
|
|
138
149
|
case HearFrameType.Error:
|
|
139
150
|
this.opts.onError?.(frame);
|
|
140
151
|
break;
|
|
141
152
|
default:
|
|
142
|
-
// Unknown/forward-compatible frame
|
|
153
|
+
// Unknown/forward-compatible frame, ignore.
|
|
143
154
|
break;
|
|
144
155
|
}
|
|
145
156
|
}
|
|
@@ -165,6 +176,317 @@ export class HearStream {
|
|
|
165
176
|
return this.ws.readyState;
|
|
166
177
|
}
|
|
167
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
|
+
}
|
|
244
|
+
/* ------------------------------------------------------------------------- *
|
|
245
|
+
* Omni realtime (agentic voice), typed client over the /v1/omni WebSocket
|
|
246
|
+
* ------------------------------------------------------------------------- */
|
|
247
|
+
/**
|
|
248
|
+
* Event names on Omni **server → client** frames. ⚠️ Note the asymmetry:
|
|
249
|
+
* inbound frames are keyed on `event`, but your **outbound** control frames
|
|
250
|
+
* (`configure`, `dtmf`, …) are keyed on `type`. {@link OmniConnection} handles
|
|
251
|
+
* both sides for you; this map is for matching frames in `onEvent`.
|
|
252
|
+
*/
|
|
253
|
+
export const OmniEvent = {
|
|
254
|
+
/** Handshake; advertises protocol version + audio formats. */
|
|
255
|
+
Hello: "hello",
|
|
256
|
+
/** Ack for your `configure` frame (echoes the resolved `voice_id`). */
|
|
257
|
+
Configured: "configured",
|
|
258
|
+
/** Session is live; includes the resolved agent + audio caps. */
|
|
259
|
+
SessionStarted: "session_started",
|
|
260
|
+
/** Turn boundary (user/assistant speaking). */
|
|
261
|
+
Turn: "turn",
|
|
262
|
+
/** Incremental/final transcript text. */
|
|
263
|
+
Transcript: "transcript",
|
|
264
|
+
/** User interrupted; assistant audio is being cut. */
|
|
265
|
+
BargeIn: "barge_in",
|
|
266
|
+
/** Alias for barge-in on some engine builds. */
|
|
267
|
+
Flush: "flush",
|
|
268
|
+
/** Engine requests a client-loop tool invocation. */
|
|
269
|
+
ToolCall: "tool_call",
|
|
270
|
+
/** Session is closing; see close code. */
|
|
271
|
+
SessionEnd: "session_end",
|
|
272
|
+
/** Server fault frame. */
|
|
273
|
+
Error: "error",
|
|
274
|
+
};
|
|
275
|
+
/** Extract a byte view from a binary WS frame (Buffer / ArrayBuffer / typed
|
|
276
|
+
* array). Returns null for a Blob or unknown (can't be read synchronously). */
|
|
277
|
+
function omniToBytes(data) {
|
|
278
|
+
if (data instanceof Uint8Array)
|
|
279
|
+
return data; // also covers Node Buffer
|
|
280
|
+
if (data instanceof ArrayBuffer)
|
|
281
|
+
return new Uint8Array(data);
|
|
282
|
+
if (ArrayBuffer.isView(data)) {
|
|
283
|
+
const v = data;
|
|
284
|
+
return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
/** A 0x01-prefixed audio frame, the engine's client→server framing for caller
|
|
289
|
+
* PCM16 LE (see OMNI_PROTOCOL_V2.md §2). */
|
|
290
|
+
function omniAudioFrame(pcm) {
|
|
291
|
+
const out = new Uint8Array(pcm.length + 1);
|
|
292
|
+
out[0] = 0x01;
|
|
293
|
+
out.set(pcm, 1);
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
/** A 0x03-prefixed control frame, the engine's client→server framing for
|
|
297
|
+
* `configure`/`dtmf`/`tool_result` (see OMNI_PROTOCOL_V2.md §2/§3). */
|
|
298
|
+
function omniControlFrame(obj) {
|
|
299
|
+
const json = new TextEncoder().encode(JSON.stringify(obj));
|
|
300
|
+
const out = new Uint8Array(json.length + 1);
|
|
301
|
+
out[0] = 0x03;
|
|
302
|
+
out.set(json, 1);
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* A live Omni agentic-voice session over `/v1/omni`. Hides the wire protocol's
|
|
307
|
+
* frame-key asymmetry: it sends control frames keyed on `type` (`configure`,
|
|
308
|
+
* `dtmf`) and parses server frames keyed on `event`, so you cannot trip the #1
|
|
309
|
+
* Omni integration bug (mirroring the server's `event` key on outbound, which
|
|
310
|
+
* is silently dropped). Construct via `pyai.omni.connect()`.
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* const omni = pyai.omni.connect({
|
|
314
|
+
* rate: 16000,
|
|
315
|
+
* configure: { voice_id: "stock_emma_en_gb", persona: "You are a receptionist." },
|
|
316
|
+
* onAudio: (chunk) => speaker.write(chunk),
|
|
317
|
+
* onTranscript: (f) => console.log(f.text),
|
|
318
|
+
* });
|
|
319
|
+
* omni.sendAudio(pcm16Chunk); // stream caller audio continuously
|
|
320
|
+
*/
|
|
321
|
+
export class OmniConnection {
|
|
322
|
+
ws;
|
|
323
|
+
opts;
|
|
324
|
+
closed = false;
|
|
325
|
+
/** Serializes async Blob reads in {@link sendAudio} so frames stay ordered. */
|
|
326
|
+
blobTail = Promise.resolve();
|
|
327
|
+
constructor(url, subprotocol, opts) {
|
|
328
|
+
this.opts = opts;
|
|
329
|
+
const WS = opts.webSocket ?? globalThis.WebSocket;
|
|
330
|
+
if (!WS) {
|
|
331
|
+
throw new Error("No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to omni.connect()");
|
|
332
|
+
}
|
|
333
|
+
this.ws = new WS(url, [subprotocol]);
|
|
334
|
+
this.ws.onopen = () => {
|
|
335
|
+
if (opts.configure) {
|
|
336
|
+
try {
|
|
337
|
+
this.configure(opts.configure);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
/* surfaced via onerror */
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
opts.onOpen?.();
|
|
344
|
+
};
|
|
345
|
+
this.ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
346
|
+
this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
|
|
347
|
+
this.ws.onclose = (ev) => {
|
|
348
|
+
this.closed = true;
|
|
349
|
+
opts.onClose?.(ev.code, ev.reason);
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
handleMessage(data) {
|
|
353
|
+
// Server → client binary frames are TYPE-TAGGED by their first byte:
|
|
354
|
+
// 0x01 = agent audio (PCM16) · 0x02 = transcript JSON · 0x03 = control JSON.
|
|
355
|
+
// (Treating every binary frame as audio, the old behavior, plays the
|
|
356
|
+
// 0x03/0x02 frames as a glitch and drops every event/transcript.)
|
|
357
|
+
if (typeof data !== "string") {
|
|
358
|
+
const bytes = omniToBytes(data);
|
|
359
|
+
if (!bytes) {
|
|
360
|
+
this.opts.onAudio?.(data); // Blob/unknown, best-effort
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const tag = bytes[0];
|
|
364
|
+
if (tag === 0x01) {
|
|
365
|
+
this.opts.onAudio?.(bytes.slice(1)); // copy → aligned PCM16
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (tag === 0x02 || tag === 0x03) {
|
|
369
|
+
try {
|
|
370
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1)));
|
|
371
|
+
// The 0x02 tag is authoritative for transcript even if the JSON omits `event`.
|
|
372
|
+
this.dispatchFrame(tag === 0x02 ? { ...parsed, event: "transcript" } : parsed);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
this.opts.onError?.(new Error("Unparseable Omni binary frame"));
|
|
376
|
+
}
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
this.opts.onAudio?.(data); // untagged, forward-compat as audio
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
// Text frame (e.g. a server-side broker relay).
|
|
383
|
+
try {
|
|
384
|
+
this.dispatchFrame(JSON.parse(data));
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
dispatchFrame(frame) {
|
|
391
|
+
const raw = frame;
|
|
392
|
+
const eventName = typeof raw.event === "string"
|
|
393
|
+
? raw.event
|
|
394
|
+
: typeof raw.type === "string"
|
|
395
|
+
? raw.type
|
|
396
|
+
: "";
|
|
397
|
+
this.opts.onEvent?.({ ...frame, event: eventName });
|
|
398
|
+
switch (eventName) {
|
|
399
|
+
case OmniEvent.Hello:
|
|
400
|
+
this.opts.onHello?.(frame);
|
|
401
|
+
break;
|
|
402
|
+
case OmniEvent.Configured:
|
|
403
|
+
this.opts.onConfigured?.(frame);
|
|
404
|
+
break;
|
|
405
|
+
case OmniEvent.SessionStarted:
|
|
406
|
+
this.opts.onSessionStarted?.(frame);
|
|
407
|
+
break;
|
|
408
|
+
case OmniEvent.Turn:
|
|
409
|
+
this.opts.onTurn?.(frame);
|
|
410
|
+
break;
|
|
411
|
+
case OmniEvent.Transcript:
|
|
412
|
+
this.opts.onTranscript?.(frame);
|
|
413
|
+
break;
|
|
414
|
+
case OmniEvent.BargeIn:
|
|
415
|
+
case OmniEvent.Flush:
|
|
416
|
+
this.opts.onBargeIn?.(frame);
|
|
417
|
+
break;
|
|
418
|
+
case OmniEvent.ToolCall:
|
|
419
|
+
this.opts.onToolCall?.(frame);
|
|
420
|
+
break;
|
|
421
|
+
case OmniEvent.SessionEnd:
|
|
422
|
+
this.opts.onSessionEnd?.(frame);
|
|
423
|
+
break;
|
|
424
|
+
case OmniEvent.Error:
|
|
425
|
+
this.opts.onError?.(frame);
|
|
426
|
+
break;
|
|
427
|
+
default:
|
|
428
|
+
// Unknown/forward-compatible frame, already delivered via onEvent.
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Send (or update) the agent config. Always emitted as
|
|
434
|
+
* `{"type":"configure", ...}`, the correct key. (A hand-rolled
|
|
435
|
+
* `{"event":"configure"}` is acked but silently dropped by the engine.)
|
|
436
|
+
*/
|
|
437
|
+
configure(cfg) {
|
|
438
|
+
this.ws.send(omniControlFrame({ type: "configure", ...cfg }));
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Stream a chunk of caller audio (PCM16 LE mono at the negotiated rate) as a
|
|
442
|
+
* `0x01`-prefixed frame. The engine demuxes on the first byte and has no
|
|
443
|
+
* default branch, so an untagged chunk is dropped with no error.
|
|
444
|
+
*/
|
|
445
|
+
sendAudio(chunk) {
|
|
446
|
+
const bytes = omniToBytes(chunk);
|
|
447
|
+
if (bytes) {
|
|
448
|
+
this.ws.send(omniAudioFrame(bytes));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
// A Blob can only be read asynchronously; queue behind the previous read so
|
|
452
|
+
// frames still reach the engine in call order.
|
|
453
|
+
this.blobTail = this.blobTail
|
|
454
|
+
.then(async () => {
|
|
455
|
+
const buf = await chunk.arrayBuffer();
|
|
456
|
+
this.ws.send(omniAudioFrame(new Uint8Array(buf)));
|
|
457
|
+
})
|
|
458
|
+
.catch((err) => this.opts.onError?.(err instanceof Error ? err : new Error(String(err))));
|
|
459
|
+
}
|
|
460
|
+
/** Send DTMF digits as a `{"type":"dtmf"}` control frame. */
|
|
461
|
+
sendDtmf(digits) {
|
|
462
|
+
this.ws.send(omniControlFrame({ type: "dtmf", digits }));
|
|
463
|
+
}
|
|
464
|
+
/** Reply to a client-loop {@link OmniEvent.ToolCall}. */
|
|
465
|
+
toolResult(callId, payload) {
|
|
466
|
+
this.ws.send(omniControlFrame({ type: "tool_result", call_id: callId, ...payload }));
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Send an arbitrary control frame for forward-compat control types the SDK
|
|
470
|
+
* does not model yet. Reminder: client → server frames are keyed on `type`,
|
|
471
|
+
* never `event`.
|
|
472
|
+
*/
|
|
473
|
+
send(frame) {
|
|
474
|
+
this.ws.send(omniControlFrame(frame));
|
|
475
|
+
}
|
|
476
|
+
/** Close the session. */
|
|
477
|
+
close(code = WSCloseCode.Normal, reason = "") {
|
|
478
|
+
if (!this.closed)
|
|
479
|
+
this.ws.close(code, reason);
|
|
480
|
+
}
|
|
481
|
+
/** The underlying socket (escape hatch for advanced use). */
|
|
482
|
+
get socket() {
|
|
483
|
+
return this.ws;
|
|
484
|
+
}
|
|
485
|
+
/** Current WebSocket readyState. */
|
|
486
|
+
get readyState() {
|
|
487
|
+
return this.ws.readyState;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
168
490
|
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
|
|
169
491
|
export class PyAI {
|
|
170
492
|
apiKey;
|
|
@@ -277,7 +599,7 @@ export class PyAI {
|
|
|
277
599
|
const res = await this.request("/v1/audio/speech", {
|
|
278
600
|
method: "POST",
|
|
279
601
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
280
|
-
body: JSON.stringify({ model: "pyai-voice", ...params }),
|
|
602
|
+
body: JSON.stringify({ model: "pyai-voice", ...params, stream: true }),
|
|
281
603
|
});
|
|
282
604
|
if (!res.body)
|
|
283
605
|
throw new PyAIError(res.status, "Response had no body to stream");
|
|
@@ -340,7 +662,7 @@ export class PyAI {
|
|
|
340
662
|
// --- key introspection --------------------------------------------------
|
|
341
663
|
/**
|
|
342
664
|
* Introspect the calling key: scopes, environment, and limits. Useful for a
|
|
343
|
-
* preflight/doctor check. (New route; older deployments may 404
|
|
665
|
+
* preflight/doctor check. (New route; older deployments may 404, handle it.)
|
|
344
666
|
*/
|
|
345
667
|
me = () => this.getJson("/v1/me");
|
|
346
668
|
// --- voice clones -------------------------------------------------------
|
|
@@ -442,7 +764,7 @@ export class PyAI {
|
|
|
442
764
|
},
|
|
443
765
|
},
|
|
444
766
|
findings: {
|
|
445
|
-
/** List Tier-2 (async semantic) findings
|
|
767
|
+
/** List Tier-2 (async semantic) findings, advisory, non-blocking. Scope `trace:read`. */
|
|
446
768
|
list: (params = {}) => {
|
|
447
769
|
const q = new URLSearchParams();
|
|
448
770
|
if (params.checkId)
|
|
@@ -510,17 +832,91 @@ export class PyAI {
|
|
|
510
832
|
trigger: (callId, input) => this.postJson(`/v1/recap/calls/${encodeURIComponent(callId)}`, input),
|
|
511
833
|
},
|
|
512
834
|
};
|
|
835
|
+
// --- amd (answering-machine detection) ---------------------------------
|
|
836
|
+
amd = {
|
|
837
|
+
config: {
|
|
838
|
+
/** The org's AMD operating point + webhook. Scope `amd:configure`. */
|
|
839
|
+
get: () => this.getJson("/v1/amd/config"),
|
|
840
|
+
/** Set the account-default `aggressiveness` (0-1) and webhook. Scope `amd:configure`. */
|
|
841
|
+
set: (input) => this.postJson("/v1/amd/config", {
|
|
842
|
+
...(input.aggressiveness !== undefined ? { aggressiveness: input.aggressiveness } : {}),
|
|
843
|
+
...(input.webhookUrl !== undefined ? { webhook_url: input.webhookUrl } : {}),
|
|
844
|
+
}),
|
|
845
|
+
},
|
|
846
|
+
calls: {
|
|
847
|
+
/** Recent AMD decisions, newest first. Scope `amd:read`. */
|
|
848
|
+
list: (params = {}) => {
|
|
849
|
+
const q = new URLSearchParams();
|
|
850
|
+
if (params.limit !== undefined)
|
|
851
|
+
q.set("limit", String(params.limit));
|
|
852
|
+
if (params.cursor)
|
|
853
|
+
q.set("cursor", params.cursor);
|
|
854
|
+
if (params.sessionLabel)
|
|
855
|
+
q.set("session_label", params.sessionLabel);
|
|
856
|
+
const qs = q.toString();
|
|
857
|
+
return this.getJson(`/v1/amd/calls${qs ? `?${qs}` : ""}`);
|
|
858
|
+
},
|
|
859
|
+
/** The full decision (answered_by, reason, …) for one call. Scope `amd:read`. */
|
|
860
|
+
get: (callId) => this.getJson(`/v1/amd/calls/${encodeURIComponent(callId)}`),
|
|
861
|
+
},
|
|
862
|
+
/**
|
|
863
|
+
* Open a live AMD stream over `/v1/amd/stream` (Twilio Media Streams
|
|
864
|
+
* protocol). Server-side helper for forking media yourself; the common
|
|
865
|
+
* Twilio path is one line of TwiML, no SDK. Scope `amd:detect`.
|
|
866
|
+
*/
|
|
867
|
+
stream: (opts = {}) => new AmdStream(this.amdStreamURL(opts), this.realtimeSubprotocol(), opts),
|
|
868
|
+
};
|
|
869
|
+
// --- omni (agentic voice) ----------------------------------------------
|
|
870
|
+
omni = {
|
|
871
|
+
/**
|
|
872
|
+
* Mint an ephemeral, origin-locked Omni session token a browser can use to
|
|
873
|
+
* open ONE realtime session **directly**, the public/private split for
|
|
874
|
+
* realtime. **Call this from your server** with a secret key holding
|
|
875
|
+
* `omni:session`; never ship the secret key to a page. Hand the returned
|
|
876
|
+
* `token` to the browser, which connects with
|
|
877
|
+
* `new WebSocket(session.url, ["pyai-key." + session.token])`. The token
|
|
878
|
+
* expires after `ttlSeconds` (default 60s) and only works from
|
|
879
|
+
* `allowedOrigins`. Scope `omni:session`.
|
|
880
|
+
*/
|
|
881
|
+
createSession: (params) => this.postJson("/v1/omni/sessions", {
|
|
882
|
+
allowed_origins: params.allowedOrigins,
|
|
883
|
+
...(params.ttlSeconds !== undefined ? { ttl_seconds: params.ttlSeconds } : {}),
|
|
884
|
+
...(params.sessionLabel !== undefined ? { session_label: params.sessionLabel } : {}),
|
|
885
|
+
}),
|
|
886
|
+
/**
|
|
887
|
+
* Open a live Omni agentic-voice session over `/v1/omni`. Returns an
|
|
888
|
+
* {@link OmniConnection} that handles the wire protocol's frame-key
|
|
889
|
+
* asymmetry for you, it sends `type`-keyed control frames (`configure`,
|
|
890
|
+
* `dtmf`) and parses `event`-keyed server frames, so you can't trip the #1
|
|
891
|
+
* Omni integration bug. Omni is zero-state: nothing to create first; the
|
|
892
|
+
* agent's behavior travels in the `configure` frame. Pass `token` (from
|
|
893
|
+
* `createSession`) to connect from a browser without the secret key.
|
|
894
|
+
*/
|
|
895
|
+
connect: (opts = {}) => {
|
|
896
|
+
const query = { ...(opts.query ?? {}) };
|
|
897
|
+
if (opts.format)
|
|
898
|
+
query.format = opts.format;
|
|
899
|
+
if (opts.rate)
|
|
900
|
+
query.rate = String(opts.rate);
|
|
901
|
+
const url = this.realtimeURL({ product: "omni", sessionLabel: opts.sessionLabel, query });
|
|
902
|
+
const sub = opts.token ? `pyai-key.${opts.token}` : this.realtimeSubprotocol();
|
|
903
|
+
return new OmniConnection(url, sub, opts);
|
|
904
|
+
},
|
|
905
|
+
};
|
|
513
906
|
// --- realtime (WebSocket) ----------------------------------------------
|
|
514
907
|
/** Build the realtime WebSocket URL for the chosen product. */
|
|
515
908
|
realtimeURL(opts = {}) {
|
|
516
909
|
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
517
910
|
const q = new URLSearchParams(opts.query ?? {});
|
|
518
911
|
if ((opts.product ?? "omni") === "omni") {
|
|
519
|
-
// Omni's native realtime surface is /v1/omni.
|
|
520
|
-
//
|
|
521
|
-
// connect URL, so default to
|
|
522
|
-
|
|
523
|
-
|
|
912
|
+
// Omni's native realtime surface is /v1/omni. The session is authorized by
|
|
913
|
+
// the key's org (zero-state), sessionLabel is an optional opaque tag.
|
|
914
|
+
// format/rate are load-bearing on the connect URL, so default to
|
|
915
|
+
// browser-grade PCM16/24kHz.
|
|
916
|
+
if (opts.sessionLabel)
|
|
917
|
+
q.set("session_label", opts.sessionLabel);
|
|
918
|
+
else if (opts.agentId)
|
|
919
|
+
q.set("agent_id", opts.agentId); // deprecated alias
|
|
524
920
|
if (!q.has("format"))
|
|
525
921
|
q.set("format", "pcm16");
|
|
526
922
|
if (!q.has("rate"))
|
|
@@ -549,9 +945,24 @@ export class PyAI {
|
|
|
549
945
|
q.set("encoding", opts.encoding);
|
|
550
946
|
if (opts.interimResults !== undefined)
|
|
551
947
|
q.set("interim_results", String(opts.interimResults));
|
|
948
|
+
if (opts.numerals !== undefined)
|
|
949
|
+
q.set("numerals", String(opts.numerals));
|
|
950
|
+
if (opts.endpointingMs !== undefined)
|
|
951
|
+
q.set("endpointing_ms", String(opts.endpointingMs));
|
|
552
952
|
const qs = q.toString();
|
|
553
953
|
return `${wsBase}/v1/audio/transcriptions/stream${qs ? `?${qs}` : ""}`;
|
|
554
954
|
}
|
|
955
|
+
/** Build the AMD detection WebSocket URL (`/v1/amd/stream`). */
|
|
956
|
+
amdStreamURL(opts = {}) {
|
|
957
|
+
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
958
|
+
const q = new URLSearchParams(opts.query ?? {});
|
|
959
|
+
if (opts.aggressiveness !== undefined)
|
|
960
|
+
q.set("aggressiveness", String(opts.aggressiveness));
|
|
961
|
+
if (opts.sessionLabel)
|
|
962
|
+
q.set("session_label", opts.sessionLabel);
|
|
963
|
+
const qs = q.toString();
|
|
964
|
+
return `${wsBase}/v1/amd/stream${qs ? `?${qs}` : ""}`;
|
|
965
|
+
}
|
|
555
966
|
/**
|
|
556
967
|
* Open a realtime WebSocket. Uses the global WebSocket (browser, Node 22+).
|
|
557
968
|
* 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.2",
|
|
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",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"homepage": "https://pyai.com",
|
|
24
24
|
"bugs": "https://github.com/atomsai/pyai-platform-backend/issues",
|
|
25
|
+
"author": "PyAI",
|
|
25
26
|
"scripts": {
|
|
26
27
|
"build": "tsc -p tsconfig.build.json",
|
|
27
28
|
"test": "node --test test/**/*.test.ts",
|