@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/src/index.ts
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
|
|
|
10
9
|
export interface PyAIOptions {
|
|
@@ -61,7 +60,7 @@ export interface TranscriptionJob {
|
|
|
61
60
|
|
|
62
61
|
/**
|
|
63
62
|
* Output container/codec for `audio.speech`. This is the **exact** set the
|
|
64
|
-
* server accepts on `POST /v1/audio/speech
|
|
63
|
+
* server accepts on `POST /v1/audio/speech`, any other value is rejected with
|
|
65
64
|
* `400 unsupported_format`. The default (when `response_format` is omitted) is
|
|
66
65
|
* `mp3`. Omit `sample_rate` for the engine's native 24 kHz (`g711_*` is always
|
|
67
66
|
* 8 kHz).
|
|
@@ -96,20 +95,20 @@ export interface SpeechParams {
|
|
|
96
95
|
model?: string;
|
|
97
96
|
/**
|
|
98
97
|
* Output container/codec, resampled+encoded server-side. One of
|
|
99
|
-
* {@link SpeechFormat}
|
|
98
|
+
* {@link SpeechFormat}, anything else is a `400 unsupported_format`. Omit for
|
|
100
99
|
* the default of `mp3`.
|
|
101
100
|
*
|
|
102
|
-
* `g711_ulaw`/`g711_alaw` return raw 8 kHz mono G.711
|
|
101
|
+
* `g711_ulaw`/`g711_alaw` return raw 8 kHz mono G.711, the bytes Twilio/SIP
|
|
103
102
|
* media streams expect, so you can hand the response straight to a telephony
|
|
104
103
|
* frame without a client-side resampler or μ-law encoder. `sample_rate` is
|
|
105
104
|
* forced to 8000 for those (omit it, or set exactly 8000). `pcm` is raw,
|
|
106
105
|
* headerless int16 LE mono at `sample_rate`. `mp3`/`opus` are buffered (not
|
|
107
|
-
* chunk-streamed)
|
|
106
|
+
* chunk-streamed), use them with `speech`, not `speechStream`.
|
|
108
107
|
*/
|
|
109
108
|
response_format?: SpeechFormat;
|
|
110
109
|
/**
|
|
111
110
|
* Output sample rate in Hz. One of {@link SpeechSampleRate}
|
|
112
|
-
* (8000/16000/24000/48000)
|
|
111
|
+
* (8000/16000/24000/48000), anything else is a `400`. Omit for the engine's
|
|
113
112
|
* native 24 kHz; `g711_*` is always 8000 (forced). Set `8000`/`16000` for
|
|
114
113
|
* telephony pipelines, most often with `response_format: "pcm"`.
|
|
115
114
|
*/
|
|
@@ -122,8 +121,7 @@ export interface SpeechParams {
|
|
|
122
121
|
*/
|
|
123
122
|
seed?: number;
|
|
124
123
|
/**
|
|
125
|
-
* Sampling temperature (lower = more deterministic). Forward-compatible
|
|
126
|
-
* honored once the engine supports it, otherwise ignored.
|
|
124
|
+
* Sampling temperature (lower = more deterministic). Forward-compatible, * honored once the engine supports it, otherwise ignored.
|
|
127
125
|
*/
|
|
128
126
|
temperature?: number;
|
|
129
127
|
}
|
|
@@ -139,16 +137,57 @@ export interface CreateJobParams {
|
|
|
139
137
|
}
|
|
140
138
|
|
|
141
139
|
export interface RealtimeOptions {
|
|
142
|
-
/** "omni" (agentic
|
|
140
|
+
/** "omni" (agentic voice) or "flow" (legacy voice duplex). Default "omni". */
|
|
143
141
|
product?: "omni" | "flow";
|
|
144
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* Optional opaque tag echoed to your `kb_endpoint` and recorded on the call.
|
|
144
|
+
* Omni is zero-state: the session is authorized by the key's org, so there is
|
|
145
|
+
* nothing to create first and this tag is never required.
|
|
146
|
+
*/
|
|
147
|
+
sessionLabel?: string;
|
|
148
|
+
/**
|
|
149
|
+
* @deprecated Use {@link sessionLabel}. Kept for back-compat, emitted as the
|
|
150
|
+
* `agent_id` query alias, which the gateway still accepts. Ignored if
|
|
151
|
+
* `sessionLabel` is set.
|
|
152
|
+
*/
|
|
145
153
|
agentId?: string;
|
|
146
154
|
/** Extra query params (e.g. format, rate). */
|
|
147
155
|
query?: Record<string, string>;
|
|
148
156
|
}
|
|
149
157
|
|
|
158
|
+
/** Parameters for minting an ephemeral browser Omni session token (server-side). */
|
|
159
|
+
export interface OmniSessionParams {
|
|
160
|
+
/**
|
|
161
|
+
* Browser origins (scheme://host[:port]) the minted token may connect from.
|
|
162
|
+
* Required and non-empty, a browser token must be origin-locked. `*` is not
|
|
163
|
+
* allowed.
|
|
164
|
+
*/
|
|
165
|
+
allowedOrigins: string[];
|
|
166
|
+
/** Token lifetime in seconds. Default 60 server-side; max 600. Keep it short. */
|
|
167
|
+
ttlSeconds?: number;
|
|
168
|
+
/** Optional opaque tag echoed back to your `kb_endpoint` and recorded on the call. */
|
|
169
|
+
sessionLabel?: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The result of {@link PyAI.omni}.createSession, a short-lived credential safe
|
|
174
|
+
* to hand to the browser. Use {@link OmniSession.token} as the WebSocket
|
|
175
|
+
* subprotocol `pyai-key.<token>` against {@link OmniSession.url}.
|
|
176
|
+
*/
|
|
177
|
+
export interface OmniSession {
|
|
178
|
+
object?: "omni.session";
|
|
179
|
+
/** The ephemeral session token. Short-lived + origin-locked; browser-safe. */
|
|
180
|
+
token: string;
|
|
181
|
+
/** Token expiry, Unix epoch milliseconds. */
|
|
182
|
+
expires_at: number;
|
|
183
|
+
/** The Omni realtime WebSocket URL to connect to. */
|
|
184
|
+
url: string;
|
|
185
|
+
/** Echoed back when supplied on the request. */
|
|
186
|
+
session_label?: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
150
189
|
/* ------------------------------------------------------------------------- *
|
|
151
|
-
* Stable enums
|
|
190
|
+
* Stable enums, mirror the server so callers branch on named constants, not
|
|
152
191
|
* magic strings, and a contract change surfaces in one place. These are plain
|
|
153
192
|
* `as const` objects (not TS `enum`s) so the source still runs directly under
|
|
154
193
|
* Node's type-stripping and stays tree-shakeable.
|
|
@@ -164,6 +203,8 @@ export const HearFrameType = {
|
|
|
164
203
|
SpeechFinal: "speech_final",
|
|
165
204
|
/** Corrected, full-context transcript following `speech_final`. */
|
|
166
205
|
Final: "final",
|
|
206
|
+
/** Final billed-usage summary, emitted just before a graceful close. */
|
|
207
|
+
Usage: "usage",
|
|
167
208
|
/** Server-side fault frame. */
|
|
168
209
|
Error: "error",
|
|
169
210
|
} as const;
|
|
@@ -184,7 +225,7 @@ export type WSCloseCode = (typeof WSCloseCode)[keyof typeof WSCloseCode];
|
|
|
184
225
|
|
|
185
226
|
/**
|
|
186
227
|
* Stable, machine-readable error `code`s (the documented contract). Branch on
|
|
187
|
-
* these. The set is treated as open
|
|
228
|
+
* these. The set is treated as open, `PyAIError.code` stays `string`, so a
|
|
188
229
|
* new server code never breaks the build, but the known ones are named here.
|
|
189
230
|
*/
|
|
190
231
|
export const ErrorCode = {
|
|
@@ -239,6 +280,22 @@ export interface HearFinalFrame {
|
|
|
239
280
|
grounding?: HearGroundingPassage[];
|
|
240
281
|
}
|
|
241
282
|
|
|
283
|
+
/** Final billed-usage summary (`usage`), emitted just before a graceful close
|
|
284
|
+
* so realtime spend can be reconciled in-band (a realtime WS carries no
|
|
285
|
+
* `x-pyai-units` response header). Best-effort: absent if there was no billable
|
|
286
|
+
* audio or the close was abnormal. */
|
|
287
|
+
export interface HearUsageFrame {
|
|
288
|
+
type: "usage";
|
|
289
|
+
/** `hear` for plain streaming, `cue` when grounding was enabled. */
|
|
290
|
+
product: "hear" | "cue";
|
|
291
|
+
/** The billed meter (`hear.requests` or `cue.minutes`). */
|
|
292
|
+
meter: string;
|
|
293
|
+
/** Summed active-speech audio billed for the session, in seconds. */
|
|
294
|
+
audio_seconds: number;
|
|
295
|
+
/** The same quantity in minutes. */
|
|
296
|
+
minutes: number;
|
|
297
|
+
}
|
|
298
|
+
|
|
242
299
|
/** Server fault frame (`error`). */
|
|
243
300
|
export interface HearErrorFrame {
|
|
244
301
|
type: "error";
|
|
@@ -246,10 +303,10 @@ export interface HearErrorFrame {
|
|
|
246
303
|
message: string;
|
|
247
304
|
}
|
|
248
305
|
|
|
249
|
-
export type HearFrame = HearPartialFrame | HearFinalFrame | HearErrorFrame;
|
|
306
|
+
export type HearFrame = HearPartialFrame | HearFinalFrame | HearUsageFrame | HearErrorFrame;
|
|
250
307
|
|
|
251
308
|
/**
|
|
252
|
-
* Minimal structural WebSocket
|
|
309
|
+
* Minimal structural WebSocket, matches both the browser/Node global
|
|
253
310
|
* `WebSocket` and the `ws` package, and lets tests inject a mock.
|
|
254
311
|
*/
|
|
255
312
|
export interface WebSocketLike {
|
|
@@ -275,12 +332,32 @@ export interface HearStreamOptions {
|
|
|
275
332
|
encoding?: "pcm16" | "opus";
|
|
276
333
|
/** Emit eager partial hypotheses. Default true server-side. */
|
|
277
334
|
interimResults?: boolean;
|
|
335
|
+
/**
|
|
336
|
+
* Format spoken numbers as digits in the transcript (e.g. "one two three" →
|
|
337
|
+
* "123"). Useful for voice agents that read back phone numbers, codes, and
|
|
338
|
+
* amounts. Default false (spoken form). Forwards `?numerals=true` on the URL.
|
|
339
|
+
*/
|
|
340
|
+
numerals?: boolean;
|
|
341
|
+
/**
|
|
342
|
+
* Turn-segmentation tuning: trailing-pause (ms, 50-2000) that ends an
|
|
343
|
+
* utterance. Forwards `?endpointing_ms=`, clamped + honored once the engine
|
|
344
|
+
* supports it; a no-op when omitted. Drive end-of-turn yourself with
|
|
345
|
+
* {@link HearStream.commit} for full control today.
|
|
346
|
+
*/
|
|
347
|
+
endpointingMs?: number;
|
|
278
348
|
/**
|
|
279
349
|
* Enable Cue knowledge-base grounding: sends `{type:"config",grounding:true}`
|
|
280
350
|
* on open, after which `speech_final`/`final` frames carry a `grounding`
|
|
281
351
|
* array. Bills a single `cue.minutes` line instead of the Hear rate.
|
|
282
352
|
*/
|
|
283
353
|
grounding?: boolean;
|
|
354
|
+
/** Cue: number of KB passages to retrieve per turn (1-20, default 3). */
|
|
355
|
+
groundingK?: number;
|
|
356
|
+
/** Cue: drop passages scoring below this (0-1, default 0 = keep all). */
|
|
357
|
+
groundingMinScore?: number;
|
|
358
|
+
/** Cue: max ms to wait for retrieval at the final before failing open to
|
|
359
|
+
* `grounding: []` (50-2000, default 450). */
|
|
360
|
+
groundingTimeoutMs?: number;
|
|
284
361
|
/** Extra query params merged onto the connect URL. */
|
|
285
362
|
query?: Record<string, string>;
|
|
286
363
|
/** Fired once the socket opens (after the optional grounding config). */
|
|
@@ -289,6 +366,8 @@ export interface HearStreamOptions {
|
|
|
289
366
|
onPartial?: (frame: HearPartialFrame) => void;
|
|
290
367
|
/** Fired on `speech_final` / `final`. */
|
|
291
368
|
onFinal?: (frame: HearFinalFrame) => void;
|
|
369
|
+
/** Fired on the final `usage` summary (in-band realtime reconciliation). */
|
|
370
|
+
onUsage?: (frame: HearUsageFrame) => void;
|
|
292
371
|
/** Fired on an `error` frame or a transport-level error. */
|
|
293
372
|
onError?: (err: HearErrorFrame | Error) => void;
|
|
294
373
|
/** Fired when the socket closes (code per `WSCloseCode`). */
|
|
@@ -320,7 +399,11 @@ export class HearStream {
|
|
|
320
399
|
this.ws.onopen = () => {
|
|
321
400
|
if (opts.grounding) {
|
|
322
401
|
try {
|
|
323
|
-
|
|
402
|
+
const cfg: Record<string, unknown> = { type: "config", grounding: true };
|
|
403
|
+
if (opts.groundingK != null) cfg.grounding_k = opts.groundingK;
|
|
404
|
+
if (opts.groundingMinScore != null) cfg.grounding_min_score = opts.groundingMinScore;
|
|
405
|
+
if (opts.groundingTimeoutMs != null) cfg.grounding_timeout_ms = opts.groundingTimeoutMs;
|
|
406
|
+
this.ws.send(JSON.stringify(cfg));
|
|
324
407
|
} catch {
|
|
325
408
|
/* surfaced via onerror */
|
|
326
409
|
}
|
|
@@ -354,11 +437,14 @@ export class HearStream {
|
|
|
354
437
|
case HearFrameType.Final:
|
|
355
438
|
this.opts.onFinal?.(frame);
|
|
356
439
|
break;
|
|
440
|
+
case HearFrameType.Usage:
|
|
441
|
+
this.opts.onUsage?.(frame);
|
|
442
|
+
break;
|
|
357
443
|
case HearFrameType.Error:
|
|
358
444
|
this.opts.onError?.(frame);
|
|
359
445
|
break;
|
|
360
446
|
default:
|
|
361
|
-
// Unknown/forward-compatible frame
|
|
447
|
+
// Unknown/forward-compatible frame, ignore.
|
|
362
448
|
break;
|
|
363
449
|
}
|
|
364
450
|
}
|
|
@@ -389,8 +475,448 @@ export class HearStream {
|
|
|
389
475
|
}
|
|
390
476
|
}
|
|
391
477
|
|
|
478
|
+
/**
|
|
479
|
+
* A live AMD (answering-machine-detection) stream. The wire is Twilio's Media
|
|
480
|
+
* Streams protocol, so if you're already on Twilio the usual path is one line of
|
|
481
|
+
* TwiML pointing `<Stream url="wss://api.pyai.com/v1/amd/stream">` at PyAI, no
|
|
482
|
+
* SDK needed. This helper is for server-side clients that fork the media
|
|
483
|
+
* themselves: send Twilio `start`/`media`/`stop` frames with {@link AmdStream.send}
|
|
484
|
+
* (or raw μ-law audio with {@link AmdStream.sendAudio}) and receive the `amd`
|
|
485
|
+
* decision via `onDecision`. Construct via `pyai.amd.stream()`.
|
|
486
|
+
*/
|
|
487
|
+
export class AmdStream {
|
|
488
|
+
private readonly ws: WebSocketLike;
|
|
489
|
+
private readonly opts: AmdStreamOptions;
|
|
490
|
+
private closed = false;
|
|
491
|
+
|
|
492
|
+
constructor(url: string, subprotocol: string, opts: AmdStreamOptions) {
|
|
493
|
+
this.opts = opts;
|
|
494
|
+
const WS = opts.webSocket ?? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket;
|
|
495
|
+
if (!WS) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
"No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to amd.stream()",
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
this.ws = new WS(url, [subprotocol]);
|
|
501
|
+
this.ws.onopen = () => opts.onOpen?.();
|
|
502
|
+
this.ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
503
|
+
this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
|
|
504
|
+
this.ws.onclose = (ev) => {
|
|
505
|
+
this.closed = true;
|
|
506
|
+
opts.onClose?.(ev.code, ev.reason);
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private handleMessage(data: unknown): void {
|
|
511
|
+
if (typeof data !== "string") return; // AMD decisions are JSON text frames
|
|
512
|
+
let frame: Record<string, unknown>;
|
|
513
|
+
try {
|
|
514
|
+
frame = JSON.parse(data) as Record<string, unknown>;
|
|
515
|
+
} catch {
|
|
516
|
+
this.opts.onError?.(new Error(`Unparseable AMD frame: ${data.slice(0, 120)}`));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (frame.event === "amd") this.opts.onDecision?.(frame as AmdDecisionEvent);
|
|
520
|
+
this.opts.onMessage?.(frame);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Send a Twilio Media Streams control/media frame (JSON) or a raw string. */
|
|
524
|
+
send(frame: string | Record<string, unknown>): void {
|
|
525
|
+
this.ws.send(typeof frame === "string" ? frame : JSON.stringify(frame));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Send a raw audio chunk (G.711 μ-law 8 kHz for the Twilio-native path). */
|
|
529
|
+
sendAudio(chunk: ArrayBufferLike | ArrayBufferView | Blob): void {
|
|
530
|
+
this.ws.send(chunk);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Close the socket. */
|
|
534
|
+
close(code: number = WSCloseCode.Normal, reason = ""): void {
|
|
535
|
+
if (!this.closed) this.ws.close(code, reason);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** The underlying socket (escape hatch for advanced use). */
|
|
539
|
+
get socket(): WebSocketLike {
|
|
540
|
+
return this.ws;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Current WebSocket readyState. */
|
|
544
|
+
get readyState(): number {
|
|
545
|
+
return this.ws.readyState;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* ------------------------------------------------------------------------- *
|
|
550
|
+
* Omni realtime (agentic voice), typed client over the /v1/omni WebSocket
|
|
551
|
+
* ------------------------------------------------------------------------- */
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Event names on Omni **server → client** frames. ⚠️ Note the asymmetry:
|
|
555
|
+
* inbound frames are keyed on `event`, but your **outbound** control frames
|
|
556
|
+
* (`configure`, `dtmf`, …) are keyed on `type`. {@link OmniConnection} handles
|
|
557
|
+
* both sides for you; this map is for matching frames in `onEvent`.
|
|
558
|
+
*/
|
|
559
|
+
export const OmniEvent = {
|
|
560
|
+
/** Handshake; advertises protocol version + audio formats. */
|
|
561
|
+
Hello: "hello",
|
|
562
|
+
/** Ack for your `configure` frame (echoes the resolved `voice_id`). */
|
|
563
|
+
Configured: "configured",
|
|
564
|
+
/** Session is live; includes the resolved agent + audio caps. */
|
|
565
|
+
SessionStarted: "session_started",
|
|
566
|
+
/** Turn boundary (user/assistant speaking). */
|
|
567
|
+
Turn: "turn",
|
|
568
|
+
/** Incremental/final transcript text. */
|
|
569
|
+
Transcript: "transcript",
|
|
570
|
+
/** User interrupted; assistant audio is being cut. */
|
|
571
|
+
BargeIn: "barge_in",
|
|
572
|
+
/** Alias for barge-in on some engine builds. */
|
|
573
|
+
Flush: "flush",
|
|
574
|
+
/** Engine requests a client-loop tool invocation. */
|
|
575
|
+
ToolCall: "tool_call",
|
|
576
|
+
/** Session is closing; see close code. */
|
|
577
|
+
SessionEnd: "session_end",
|
|
578
|
+
/** Server fault frame. */
|
|
579
|
+
Error: "error",
|
|
580
|
+
} as const;
|
|
581
|
+
export type OmniEvent = (typeof OmniEvent)[keyof typeof OmniEvent];
|
|
582
|
+
|
|
583
|
+
/** A server → client Omni frame. Keyed on `event` (not `type`); open for forward-compat. */
|
|
584
|
+
export interface OmniServerFrame {
|
|
585
|
+
event: string;
|
|
586
|
+
[k: string]: unknown;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** A binary agent-audio chunk delivered to {@link OmniConnectOptions.onAudio}. */
|
|
590
|
+
export type OmniAudioChunk = ArrayBuffer | ArrayBufferView | Blob;
|
|
591
|
+
|
|
592
|
+
/** Extract a byte view from a binary WS frame (Buffer / ArrayBuffer / typed
|
|
593
|
+
* array). Returns null for a Blob or unknown (can't be read synchronously). */
|
|
594
|
+
function omniToBytes(data: unknown): Uint8Array | null {
|
|
595
|
+
if (data instanceof Uint8Array) return data; // also covers Node Buffer
|
|
596
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
597
|
+
if (ArrayBuffer.isView(data)) {
|
|
598
|
+
const v = data as ArrayBufferView;
|
|
599
|
+
return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** A 0x01-prefixed audio frame, the engine's client→server framing for caller
|
|
605
|
+
* PCM16 LE (see OMNI_PROTOCOL_V2.md §2). */
|
|
606
|
+
function omniAudioFrame(pcm: Uint8Array): Uint8Array {
|
|
607
|
+
const out = new Uint8Array(pcm.length + 1);
|
|
608
|
+
out[0] = 0x01;
|
|
609
|
+
out.set(pcm, 1);
|
|
610
|
+
return out;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** A 0x03-prefixed control frame, the engine's client→server framing for
|
|
614
|
+
* `configure`/`dtmf`/`tool_result` (see OMNI_PROTOCOL_V2.md §2/§3). */
|
|
615
|
+
function omniControlFrame(obj: unknown): Uint8Array {
|
|
616
|
+
const json = new TextEncoder().encode(JSON.stringify(obj));
|
|
617
|
+
const out = new Uint8Array(json.length + 1);
|
|
618
|
+
out[0] = 0x03;
|
|
619
|
+
out.set(json, 1);
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export interface OmniToolDef {
|
|
624
|
+
name: string;
|
|
625
|
+
description?: string;
|
|
626
|
+
parameters?: Record<string, unknown>;
|
|
627
|
+
/** When set, engine-POST mode. Omit for client-loop (default). */
|
|
628
|
+
endpoint?: string;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export interface OmniToolCallFrame {
|
|
632
|
+
type: "tool_call";
|
|
633
|
+
call_id: string;
|
|
634
|
+
name: string;
|
|
635
|
+
arguments?: Record<string, unknown>;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* The agent config sent as the `configure` control frame. These are the wire
|
|
640
|
+
* (snake_case) fields the engine reads; it's an **open bag**, so forward-compat
|
|
641
|
+
* fields (e.g. `greeting`, or `model_tier` once the engine acks it) pass
|
|
642
|
+
* straight through. The SDK supplies the `{"type":"configure"}` envelope for
|
|
643
|
+
* you, which is the whole point: a hand-rolled `{"event":"configure"}` is
|
|
644
|
+
* acked but silently dropped, leaving the agent with no brain and zero turns.
|
|
645
|
+
*/
|
|
646
|
+
export interface OmniConfigure {
|
|
647
|
+
/** Voice to speak with (stock / clone / designed id). */
|
|
648
|
+
voice_id?: string;
|
|
649
|
+
/** System prompt / role + instructions for the agent. */
|
|
650
|
+
persona?: string;
|
|
651
|
+
/** Customer-hosted URL the engine calls per turn for grounding. */
|
|
652
|
+
kb_endpoint?: string;
|
|
653
|
+
/** Bearer the engine presents to `kb_endpoint`. */
|
|
654
|
+
kb_token?: string;
|
|
655
|
+
/**
|
|
656
|
+
* Session language, end to end (recognition, reasoning, voice). Also
|
|
657
|
+
* settable on the agent profile (`language` on `POST /v1/agents`), which
|
|
658
|
+
* applies automatically when connecting with `session_label={agent_id}`;
|
|
659
|
+
* an inline value here wins for the session. Default `en`. Fail-safe: an
|
|
660
|
+
* unknown/not-yet-enabled language falls back to `en` (the `configured`
|
|
661
|
+
* ack carries `language_active` + `language_fallback: true`), the call
|
|
662
|
+
* proceeds and bills as what was served. Per-language availability is
|
|
663
|
+
* staged, see the Language support reference.
|
|
664
|
+
*/
|
|
665
|
+
language?: "en" | "fr" | "es" | "de" | "hi";
|
|
666
|
+
/** Function calling definitions (client-loop when `endpoint` is omitted). */
|
|
667
|
+
tools?: OmniToolDef[];
|
|
668
|
+
/** Forward-compatible: any other key the engine honors. */
|
|
669
|
+
[k: string]: unknown;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export interface OmniConnectOptions {
|
|
673
|
+
/** Optional opaque per-session tag echoed to your `kb_endpoint`. Omni is zero-state. */
|
|
674
|
+
sessionLabel?: string;
|
|
675
|
+
/**
|
|
676
|
+
* Ephemeral browser token from `omni.createSession`. When set it is used as
|
|
677
|
+
* the WS subprotocol (`pyai-key.<token>`) instead of the client's secret key,
|
|
678
|
+
* so a page never holds a secret key.
|
|
679
|
+
*/
|
|
680
|
+
token?: string;
|
|
681
|
+
/** Connect-URL sample rate: 24000 browser, 16000 wideband telephony, 8000 G.711/Twilio. */
|
|
682
|
+
rate?: 24000 | 16000 | 8000;
|
|
683
|
+
/** Connect-URL audio format. Default `pcm16`. */
|
|
684
|
+
format?: "pcm16";
|
|
685
|
+
/**
|
|
686
|
+
* Agent config sent as `{"type":"configure",...}` the instant the socket
|
|
687
|
+
* opens. Omit to send it yourself later via {@link OmniConnection.configure}.
|
|
688
|
+
*/
|
|
689
|
+
configure?: OmniConfigure;
|
|
690
|
+
/** Extra query params merged onto the connect URL. */
|
|
691
|
+
query?: Record<string, string>;
|
|
692
|
+
/** Fired once the socket opens (after the optional auto-configure). */
|
|
693
|
+
onOpen?: () => void;
|
|
694
|
+
/** Fired for each binary agent-audio chunk, play it out as it arrives. */
|
|
695
|
+
onAudio?: (chunk: OmniAudioChunk) => void;
|
|
696
|
+
/** Fired on the `hello` handshake frame. */
|
|
697
|
+
onHello?: (frame: OmniServerFrame) => void;
|
|
698
|
+
/** Fired on the `configured` ack. */
|
|
699
|
+
onConfigured?: (frame: OmniServerFrame) => void;
|
|
700
|
+
/** Fired on `session_started`. */
|
|
701
|
+
onSessionStarted?: (frame: OmniServerFrame) => void;
|
|
702
|
+
/** Fired on `turn` boundaries. */
|
|
703
|
+
onTurn?: (frame: OmniServerFrame) => void;
|
|
704
|
+
/** Fired on `transcript` text frames. */
|
|
705
|
+
onTranscript?: (frame: OmniServerFrame) => void;
|
|
706
|
+
/** Fired on `barge_in` / `flush` (user interrupted). */
|
|
707
|
+
onBargeIn?: (frame: OmniServerFrame) => void;
|
|
708
|
+
/** Fired when the engine requests a client-loop tool invocation. */
|
|
709
|
+
onToolCall?: (frame: OmniToolCallFrame) => void;
|
|
710
|
+
/** Fired on `session_end`. */
|
|
711
|
+
onSessionEnd?: (frame: OmniServerFrame) => void;
|
|
712
|
+
/** Fired on EVERY JSON frame (including unknown/forward-compat ones). */
|
|
713
|
+
onEvent?: (frame: OmniServerFrame) => void;
|
|
714
|
+
/** Fired on an `error` frame or a transport-level error. */
|
|
715
|
+
onError?: (err: OmniServerFrame | Error) => void;
|
|
716
|
+
/** Fired when the socket closes (code per {@link WSCloseCode}). */
|
|
717
|
+
onClose?: (code: number, reason: string) => void;
|
|
718
|
+
/** Injectable WebSocket constructor (defaults to the global). */
|
|
719
|
+
webSocket?: WebSocketCtor;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* A live Omni agentic-voice session over `/v1/omni`. Hides the wire protocol's
|
|
724
|
+
* frame-key asymmetry: it sends control frames keyed on `type` (`configure`,
|
|
725
|
+
* `dtmf`) and parses server frames keyed on `event`, so you cannot trip the #1
|
|
726
|
+
* Omni integration bug (mirroring the server's `event` key on outbound, which
|
|
727
|
+
* is silently dropped). Construct via `pyai.omni.connect()`.
|
|
728
|
+
*
|
|
729
|
+
* @example
|
|
730
|
+
* const omni = pyai.omni.connect({
|
|
731
|
+
* rate: 16000,
|
|
732
|
+
* configure: { voice_id: "stock_emma_en_gb", persona: "You are a receptionist." },
|
|
733
|
+
* onAudio: (chunk) => speaker.write(chunk),
|
|
734
|
+
* onTranscript: (f) => console.log(f.text),
|
|
735
|
+
* });
|
|
736
|
+
* omni.sendAudio(pcm16Chunk); // stream caller audio continuously
|
|
737
|
+
*/
|
|
738
|
+
export class OmniConnection {
|
|
739
|
+
private readonly ws: WebSocketLike;
|
|
740
|
+
private readonly opts: OmniConnectOptions;
|
|
741
|
+
private closed = false;
|
|
742
|
+
/** Serializes async Blob reads in {@link sendAudio} so frames stay ordered. */
|
|
743
|
+
private blobTail: Promise<void> = Promise.resolve();
|
|
744
|
+
|
|
745
|
+
constructor(url: string, subprotocol: string, opts: OmniConnectOptions) {
|
|
746
|
+
this.opts = opts;
|
|
747
|
+
const WS = opts.webSocket ?? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket;
|
|
748
|
+
if (!WS) {
|
|
749
|
+
throw new Error(
|
|
750
|
+
"No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to omni.connect()",
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
this.ws = new WS(url, [subprotocol]);
|
|
754
|
+
this.ws.onopen = () => {
|
|
755
|
+
if (opts.configure) {
|
|
756
|
+
try {
|
|
757
|
+
this.configure(opts.configure);
|
|
758
|
+
} catch {
|
|
759
|
+
/* surfaced via onerror */
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
opts.onOpen?.();
|
|
763
|
+
};
|
|
764
|
+
this.ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
765
|
+
this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
|
|
766
|
+
this.ws.onclose = (ev) => {
|
|
767
|
+
this.closed = true;
|
|
768
|
+
opts.onClose?.(ev.code, ev.reason);
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private handleMessage(data: unknown): void {
|
|
773
|
+
// Server → client binary frames are TYPE-TAGGED by their first byte:
|
|
774
|
+
// 0x01 = agent audio (PCM16) · 0x02 = transcript JSON · 0x03 = control JSON.
|
|
775
|
+
// (Treating every binary frame as audio, the old behavior, plays the
|
|
776
|
+
// 0x03/0x02 frames as a glitch and drops every event/transcript.)
|
|
777
|
+
if (typeof data !== "string") {
|
|
778
|
+
const bytes = omniToBytes(data);
|
|
779
|
+
if (!bytes) {
|
|
780
|
+
this.opts.onAudio?.(data as OmniAudioChunk); // Blob/unknown, best-effort
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const tag = bytes[0];
|
|
784
|
+
if (tag === 0x01) {
|
|
785
|
+
this.opts.onAudio?.(bytes.slice(1) as OmniAudioChunk); // copy → aligned PCM16
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (tag === 0x02 || tag === 0x03) {
|
|
789
|
+
try {
|
|
790
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1))) as OmniServerFrame;
|
|
791
|
+
// The 0x02 tag is authoritative for transcript even if the JSON omits `event`.
|
|
792
|
+
this.dispatchFrame(tag === 0x02 ? { ...parsed, event: "transcript" } : parsed);
|
|
793
|
+
} catch {
|
|
794
|
+
this.opts.onError?.(new Error("Unparseable Omni binary frame"));
|
|
795
|
+
}
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
this.opts.onAudio?.(data as OmniAudioChunk); // untagged, forward-compat as audio
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
// Text frame (e.g. a server-side broker relay).
|
|
802
|
+
try {
|
|
803
|
+
this.dispatchFrame(JSON.parse(data) as OmniServerFrame);
|
|
804
|
+
} catch {
|
|
805
|
+
this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
private dispatchFrame(frame: OmniServerFrame): void {
|
|
810
|
+
const raw = frame as unknown as { event?: unknown; type?: unknown };
|
|
811
|
+
const eventName =
|
|
812
|
+
typeof raw.event === "string"
|
|
813
|
+
? raw.event
|
|
814
|
+
: typeof raw.type === "string"
|
|
815
|
+
? raw.type
|
|
816
|
+
: "";
|
|
817
|
+
this.opts.onEvent?.({ ...frame, event: eventName });
|
|
818
|
+
switch (eventName) {
|
|
819
|
+
case OmniEvent.Hello:
|
|
820
|
+
this.opts.onHello?.(frame);
|
|
821
|
+
break;
|
|
822
|
+
case OmniEvent.Configured:
|
|
823
|
+
this.opts.onConfigured?.(frame);
|
|
824
|
+
break;
|
|
825
|
+
case OmniEvent.SessionStarted:
|
|
826
|
+
this.opts.onSessionStarted?.(frame);
|
|
827
|
+
break;
|
|
828
|
+
case OmniEvent.Turn:
|
|
829
|
+
this.opts.onTurn?.(frame);
|
|
830
|
+
break;
|
|
831
|
+
case OmniEvent.Transcript:
|
|
832
|
+
this.opts.onTranscript?.(frame);
|
|
833
|
+
break;
|
|
834
|
+
case OmniEvent.BargeIn:
|
|
835
|
+
case OmniEvent.Flush:
|
|
836
|
+
this.opts.onBargeIn?.(frame);
|
|
837
|
+
break;
|
|
838
|
+
case OmniEvent.ToolCall:
|
|
839
|
+
this.opts.onToolCall?.(frame as unknown as OmniToolCallFrame);
|
|
840
|
+
break;
|
|
841
|
+
case OmniEvent.SessionEnd:
|
|
842
|
+
this.opts.onSessionEnd?.(frame);
|
|
843
|
+
break;
|
|
844
|
+
case OmniEvent.Error:
|
|
845
|
+
this.opts.onError?.(frame);
|
|
846
|
+
break;
|
|
847
|
+
default:
|
|
848
|
+
// Unknown/forward-compatible frame, already delivered via onEvent.
|
|
849
|
+
break;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Send (or update) the agent config. Always emitted as
|
|
855
|
+
* `{"type":"configure", ...}`, the correct key. (A hand-rolled
|
|
856
|
+
* `{"event":"configure"}` is acked but silently dropped by the engine.)
|
|
857
|
+
*/
|
|
858
|
+
configure(cfg: OmniConfigure): void {
|
|
859
|
+
this.ws.send(omniControlFrame({ type: "configure", ...cfg }));
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Stream a chunk of caller audio (PCM16 LE mono at the negotiated rate) as a
|
|
864
|
+
* `0x01`-prefixed frame. The engine demuxes on the first byte and has no
|
|
865
|
+
* default branch, so an untagged chunk is dropped with no error.
|
|
866
|
+
*/
|
|
867
|
+
sendAudio(chunk: ArrayBufferLike | ArrayBufferView | Blob): void {
|
|
868
|
+
const bytes = omniToBytes(chunk);
|
|
869
|
+
if (bytes) {
|
|
870
|
+
this.ws.send(omniAudioFrame(bytes));
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
// A Blob can only be read asynchronously; queue behind the previous read so
|
|
874
|
+
// frames still reach the engine in call order.
|
|
875
|
+
this.blobTail = this.blobTail
|
|
876
|
+
.then(async () => {
|
|
877
|
+
const buf = await (chunk as Blob).arrayBuffer();
|
|
878
|
+
this.ws.send(omniAudioFrame(new Uint8Array(buf)));
|
|
879
|
+
})
|
|
880
|
+
.catch((err) => this.opts.onError?.(err instanceof Error ? err : new Error(String(err))));
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** Send DTMF digits as a `{"type":"dtmf"}` control frame. */
|
|
884
|
+
sendDtmf(digits: string): void {
|
|
885
|
+
this.ws.send(omniControlFrame({ type: "dtmf", digits }));
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Reply to a client-loop {@link OmniEvent.ToolCall}. */
|
|
889
|
+
toolResult(callId: string, payload: { result?: unknown; error?: string }): void {
|
|
890
|
+
this.ws.send(omniControlFrame({ type: "tool_result", call_id: callId, ...payload }));
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Send an arbitrary control frame for forward-compat control types the SDK
|
|
895
|
+
* does not model yet. Reminder: client → server frames are keyed on `type`,
|
|
896
|
+
* never `event`.
|
|
897
|
+
*/
|
|
898
|
+
send(frame: Record<string, unknown>): void {
|
|
899
|
+
this.ws.send(omniControlFrame(frame));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Close the session. */
|
|
903
|
+
close(code: number = WSCloseCode.Normal, reason = ""): void {
|
|
904
|
+
if (!this.closed) this.ws.close(code, reason);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** The underlying socket (escape hatch for advanced use). */
|
|
908
|
+
get socket(): WebSocketLike {
|
|
909
|
+
return this.ws;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** Current WebSocket readyState. */
|
|
913
|
+
get readyState(): number {
|
|
914
|
+
return this.ws.readyState;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
392
918
|
/* ------------------------------------------------------------------------- *
|
|
393
|
-
* Key introspection
|
|
919
|
+
* Key introspection, GET /v1/me
|
|
394
920
|
* ------------------------------------------------------------------------- */
|
|
395
921
|
|
|
396
922
|
/** Shape of `GET /v1/me`. Fields are best-effort / forward-compatible. */
|
|
@@ -511,7 +1037,7 @@ export interface TraceBargeMetrics {
|
|
|
511
1037
|
|
|
512
1038
|
/**
|
|
513
1039
|
* Known timeline-turn roles. Left open (see {@link TraceTimelineTurn.role}) so a
|
|
514
|
-
* new server-side role never breaks the build
|
|
1040
|
+
* new server-side role never breaks the build, branch defensively.
|
|
515
1041
|
*/
|
|
516
1042
|
export type TraceTimelineRole = "agent" | "caller" | "system" | "tool";
|
|
517
1043
|
|
|
@@ -537,18 +1063,18 @@ export interface TraceTimelineTurn {
|
|
|
537
1063
|
|
|
538
1064
|
/**
|
|
539
1065
|
* Aggregate per-call quality metrics (eval scorecard-v1). All optional and
|
|
540
|
-
* forward-compatible
|
|
1066
|
+
* forward-compatible, present once the engine emits them.
|
|
541
1067
|
*/
|
|
542
1068
|
export interface QualityMetrics {
|
|
543
|
-
/** Word error rate vs. reference transcript, 0
|
|
1069
|
+
/** Word error rate vs. reference transcript, 0-1 (lower is better). */
|
|
544
1070
|
wer?: number;
|
|
545
1071
|
/** Representative time-to-first-audio across turns, ms. */
|
|
546
1072
|
ttfb_ms?: number;
|
|
547
1073
|
/** 95th-percentile end-to-end turn latency, ms. */
|
|
548
1074
|
turn_p95_ms?: number;
|
|
549
|
-
/** Barge-in recovery rate, 0
|
|
1075
|
+
/** Barge-in recovery rate, 0-1. */
|
|
550
1076
|
barge_recovery?: number;
|
|
551
|
-
/** Task-success score, 0
|
|
1077
|
+
/** Task-success score, 0-1. */
|
|
552
1078
|
task_success?: number;
|
|
553
1079
|
/** Composite voice-agent quality index (engine-defined scale). */
|
|
554
1080
|
vaqi?: number;
|
|
@@ -682,6 +1208,103 @@ export interface RecapCallTriggerInput {
|
|
|
682
1208
|
crm_fields?: Record<string, unknown>;
|
|
683
1209
|
}
|
|
684
1210
|
|
|
1211
|
+
// --- AMD (answering-machine detection) -----------------------------------
|
|
1212
|
+
|
|
1213
|
+
/** PyAI's richer answered-by vocabulary (superset of Twilio's enum). */
|
|
1214
|
+
export type AmdAnsweredBy =
|
|
1215
|
+
| "human"
|
|
1216
|
+
| "voicemail"
|
|
1217
|
+
| "live_voicemail"
|
|
1218
|
+
| "screening" // iPhone / Google Call Screen
|
|
1219
|
+
| "ivr"
|
|
1220
|
+
| "human_gatekeeper"
|
|
1221
|
+
| "sit_invalid" // dead / disconnected number
|
|
1222
|
+
| "fax"
|
|
1223
|
+
| "silence"
|
|
1224
|
+
| "unknown";
|
|
1225
|
+
|
|
1226
|
+
/** Twilio's `AnsweredBy` enum, echoed for drop-in migration parity. */
|
|
1227
|
+
export type AmdTwilioAnsweredBy =
|
|
1228
|
+
| "human"
|
|
1229
|
+
| "machine_start"
|
|
1230
|
+
| "machine_end_beep"
|
|
1231
|
+
| "machine_end_silence"
|
|
1232
|
+
| "machine_end_other"
|
|
1233
|
+
| "fax"
|
|
1234
|
+
| "unknown";
|
|
1235
|
+
|
|
1236
|
+
export interface AmdConfigInput {
|
|
1237
|
+
/** Operating point on the ROC curve, [0,1]. Near 0 = human-safe (never hang up
|
|
1238
|
+
* on a person); near 1 = fire `machine` fast. A per-call TwiML `<Parameter>`
|
|
1239
|
+
* overrides this. */
|
|
1240
|
+
aggressiveness?: number;
|
|
1241
|
+
/** Signed POST target for `amd.call.completed` events (https). */
|
|
1242
|
+
webhookUrl?: string | null;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
export interface AmdConfig {
|
|
1246
|
+
object?: "amd.config";
|
|
1247
|
+
aggressiveness?: number;
|
|
1248
|
+
webhook_url?: string | null;
|
|
1249
|
+
updated_at?: number;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
export interface AmdCallSummary {
|
|
1253
|
+
object?: "amd.call";
|
|
1254
|
+
call_id: string;
|
|
1255
|
+
session_label?: string | null;
|
|
1256
|
+
status?: "completed" | "failed";
|
|
1257
|
+
answered_by?: AmdAnsweredBy;
|
|
1258
|
+
/** Twilio-enum projection of `answered_by` for drop-in routing parity. */
|
|
1259
|
+
answered_by_twilio?: string | null;
|
|
1260
|
+
confidence?: number | null;
|
|
1261
|
+
/** Latency from answer to decision, in ms. */
|
|
1262
|
+
decision_ms?: number | null;
|
|
1263
|
+
created_at?: number;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
export interface AmdCall extends AmdCallSummary {
|
|
1267
|
+
/** Human-readable evidence, e.g. "machine phrase: 'leave a message' @1.2s". */
|
|
1268
|
+
reason?: string | null;
|
|
1269
|
+
aggressiveness?: number | null;
|
|
1270
|
+
started_at?: number | null;
|
|
1271
|
+
meta?: Record<string, unknown> | null;
|
|
1272
|
+
error?: string | null;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** A mid-call AMD decision event pushed on the stream (and to the webhook). */
|
|
1276
|
+
export interface AmdDecisionEvent {
|
|
1277
|
+
event?: "amd";
|
|
1278
|
+
call_id?: string;
|
|
1279
|
+
answered_by?: AmdAnsweredBy;
|
|
1280
|
+
answered_by_twilio?: string | null;
|
|
1281
|
+
confidence?: number | null;
|
|
1282
|
+
decision_ms?: number | null;
|
|
1283
|
+
reason?: string | null;
|
|
1284
|
+
[k: string]: unknown;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
export interface AmdStreamOptions {
|
|
1288
|
+
/** Per-call operating point override (0-1), sent on the connect URL. */
|
|
1289
|
+
aggressiveness?: number;
|
|
1290
|
+
/** Opaque per-call tag, echoed back on the decision. */
|
|
1291
|
+
sessionLabel?: string;
|
|
1292
|
+
/** Extra query params merged onto the connect URL. */
|
|
1293
|
+
query?: Record<string, string>;
|
|
1294
|
+
/** Fired once the socket opens. */
|
|
1295
|
+
onOpen?: () => void;
|
|
1296
|
+
/** Fired when PyAI emits the `amd` decision event mid-call. */
|
|
1297
|
+
onDecision?: (event: AmdDecisionEvent) => void;
|
|
1298
|
+
/** Fired on any other JSON frame (forward-compatible). */
|
|
1299
|
+
onMessage?: (frame: Record<string, unknown>) => void;
|
|
1300
|
+
/** Fired on a transport-level error. */
|
|
1301
|
+
onError?: (err: Error) => void;
|
|
1302
|
+
/** Fired when the socket closes. */
|
|
1303
|
+
onClose?: (code: number, reason: string) => void;
|
|
1304
|
+
/** Injectable WebSocket constructor (defaults to the global). */
|
|
1305
|
+
webSocket?: WebSocketCtor;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
685
1308
|
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
|
|
686
1309
|
|
|
687
1310
|
export class PyAI {
|
|
@@ -810,7 +1433,7 @@ export class PyAI {
|
|
|
810
1433
|
const res = await this.request("/v1/audio/speech", {
|
|
811
1434
|
method: "POST",
|
|
812
1435
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
813
|
-
body: JSON.stringify({ model: "pyai-voice", ...params }),
|
|
1436
|
+
body: JSON.stringify({ model: "pyai-voice", ...params, stream: true }),
|
|
814
1437
|
});
|
|
815
1438
|
if (!res.body) throw new PyAIError(res.status, "Response had no body to stream");
|
|
816
1439
|
return res.body as ReadableStream<Uint8Array>;
|
|
@@ -884,7 +1507,7 @@ export class PyAI {
|
|
|
884
1507
|
|
|
885
1508
|
/**
|
|
886
1509
|
* Introspect the calling key: scopes, environment, and limits. Useful for a
|
|
887
|
-
* preflight/doctor check. (New route; older deployments may 404
|
|
1510
|
+
* preflight/doctor check. (New route; older deployments may 404, handle it.)
|
|
888
1511
|
*/
|
|
889
1512
|
me = (): Promise<MeResponse> => this.getJson<MeResponse>("/v1/me");
|
|
890
1513
|
|
|
@@ -988,7 +1611,7 @@ export class PyAI {
|
|
|
988
1611
|
},
|
|
989
1612
|
},
|
|
990
1613
|
findings: {
|
|
991
|
-
/** List Tier-2 (async semantic) findings
|
|
1614
|
+
/** List Tier-2 (async semantic) findings, advisory, non-blocking. Scope `trace:read`. */
|
|
992
1615
|
list: (
|
|
993
1616
|
params: {
|
|
994
1617
|
checkId?: string;
|
|
@@ -1064,6 +1687,81 @@ export class PyAI {
|
|
|
1064
1687
|
},
|
|
1065
1688
|
};
|
|
1066
1689
|
|
|
1690
|
+
// --- amd (answering-machine detection) ---------------------------------
|
|
1691
|
+
|
|
1692
|
+
amd = {
|
|
1693
|
+
config: {
|
|
1694
|
+
/** The org's AMD operating point + webhook. Scope `amd:configure`. */
|
|
1695
|
+
get: (): Promise<AmdConfig> => this.getJson("/v1/amd/config"),
|
|
1696
|
+
/** Set the account-default `aggressiveness` (0-1) and webhook. Scope `amd:configure`. */
|
|
1697
|
+
set: (input: AmdConfigInput): Promise<AmdConfig> =>
|
|
1698
|
+
this.postJson("/v1/amd/config", {
|
|
1699
|
+
...(input.aggressiveness !== undefined ? { aggressiveness: input.aggressiveness } : {}),
|
|
1700
|
+
...(input.webhookUrl !== undefined ? { webhook_url: input.webhookUrl } : {}),
|
|
1701
|
+
}),
|
|
1702
|
+
},
|
|
1703
|
+
calls: {
|
|
1704
|
+
/** Recent AMD decisions, newest first. Scope `amd:read`. */
|
|
1705
|
+
list: (params: { limit?: number; cursor?: string; sessionLabel?: string } = {}): Promise<ListResponse<AmdCallSummary>> => {
|
|
1706
|
+
const q = new URLSearchParams();
|
|
1707
|
+
if (params.limit !== undefined) q.set("limit", String(params.limit));
|
|
1708
|
+
if (params.cursor) q.set("cursor", params.cursor);
|
|
1709
|
+
if (params.sessionLabel) q.set("session_label", params.sessionLabel);
|
|
1710
|
+
const qs = q.toString();
|
|
1711
|
+
return this.getJson(`/v1/amd/calls${qs ? `?${qs}` : ""}`);
|
|
1712
|
+
},
|
|
1713
|
+
/** The full decision (answered_by, reason, …) for one call. Scope `amd:read`. */
|
|
1714
|
+
get: (callId: string): Promise<AmdCall> =>
|
|
1715
|
+
this.getJson(`/v1/amd/calls/${encodeURIComponent(callId)}`),
|
|
1716
|
+
},
|
|
1717
|
+
/**
|
|
1718
|
+
* Open a live AMD stream over `/v1/amd/stream` (Twilio Media Streams
|
|
1719
|
+
* protocol). Server-side helper for forking media yourself; the common
|
|
1720
|
+
* Twilio path is one line of TwiML, no SDK. Scope `amd:detect`.
|
|
1721
|
+
*/
|
|
1722
|
+
stream: (opts: AmdStreamOptions = {}): AmdStream =>
|
|
1723
|
+
new AmdStream(this.amdStreamURL(opts), this.realtimeSubprotocol(), opts),
|
|
1724
|
+
};
|
|
1725
|
+
|
|
1726
|
+
// --- omni (agentic voice) ----------------------------------------------
|
|
1727
|
+
|
|
1728
|
+
omni = {
|
|
1729
|
+
/**
|
|
1730
|
+
* Mint an ephemeral, origin-locked Omni session token a browser can use to
|
|
1731
|
+
* open ONE realtime session **directly**, the public/private split for
|
|
1732
|
+
* realtime. **Call this from your server** with a secret key holding
|
|
1733
|
+
* `omni:session`; never ship the secret key to a page. Hand the returned
|
|
1734
|
+
* `token` to the browser, which connects with
|
|
1735
|
+
* `new WebSocket(session.url, ["pyai-key." + session.token])`. The token
|
|
1736
|
+
* expires after `ttlSeconds` (default 60s) and only works from
|
|
1737
|
+
* `allowedOrigins`. Scope `omni:session`.
|
|
1738
|
+
*/
|
|
1739
|
+
createSession: (params: OmniSessionParams): Promise<OmniSession> =>
|
|
1740
|
+
this.postJson("/v1/omni/sessions", {
|
|
1741
|
+
allowed_origins: params.allowedOrigins,
|
|
1742
|
+
...(params.ttlSeconds !== undefined ? { ttl_seconds: params.ttlSeconds } : {}),
|
|
1743
|
+
...(params.sessionLabel !== undefined ? { session_label: params.sessionLabel } : {}),
|
|
1744
|
+
}),
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* Open a live Omni agentic-voice session over `/v1/omni`. Returns an
|
|
1748
|
+
* {@link OmniConnection} that handles the wire protocol's frame-key
|
|
1749
|
+
* asymmetry for you, it sends `type`-keyed control frames (`configure`,
|
|
1750
|
+
* `dtmf`) and parses `event`-keyed server frames, so you can't trip the #1
|
|
1751
|
+
* Omni integration bug. Omni is zero-state: nothing to create first; the
|
|
1752
|
+
* agent's behavior travels in the `configure` frame. Pass `token` (from
|
|
1753
|
+
* `createSession`) to connect from a browser without the secret key.
|
|
1754
|
+
*/
|
|
1755
|
+
connect: (opts: OmniConnectOptions = {}): OmniConnection => {
|
|
1756
|
+
const query: Record<string, string> = { ...(opts.query ?? {}) };
|
|
1757
|
+
if (opts.format) query.format = opts.format;
|
|
1758
|
+
if (opts.rate) query.rate = String(opts.rate);
|
|
1759
|
+
const url = this.realtimeURL({ product: "omni", sessionLabel: opts.sessionLabel, query });
|
|
1760
|
+
const sub = opts.token ? `pyai-key.${opts.token}` : this.realtimeSubprotocol();
|
|
1761
|
+
return new OmniConnection(url, sub, opts);
|
|
1762
|
+
},
|
|
1763
|
+
};
|
|
1764
|
+
|
|
1067
1765
|
// --- realtime (WebSocket) ----------------------------------------------
|
|
1068
1766
|
|
|
1069
1767
|
/** Build the realtime WebSocket URL for the chosen product. */
|
|
@@ -1071,10 +1769,12 @@ export class PyAI {
|
|
|
1071
1769
|
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
1072
1770
|
const q = new URLSearchParams(opts.query ?? {});
|
|
1073
1771
|
if ((opts.product ?? "omni") === "omni") {
|
|
1074
|
-
// Omni's native realtime surface is /v1/omni.
|
|
1075
|
-
//
|
|
1076
|
-
// connect URL, so default to
|
|
1077
|
-
|
|
1772
|
+
// Omni's native realtime surface is /v1/omni. The session is authorized by
|
|
1773
|
+
// the key's org (zero-state), sessionLabel is an optional opaque tag.
|
|
1774
|
+
// format/rate are load-bearing on the connect URL, so default to
|
|
1775
|
+
// browser-grade PCM16/24kHz.
|
|
1776
|
+
if (opts.sessionLabel) q.set("session_label", opts.sessionLabel);
|
|
1777
|
+
else if (opts.agentId) q.set("agent_id", opts.agentId); // deprecated alias
|
|
1078
1778
|
if (!q.has("format")) q.set("format", "pcm16");
|
|
1079
1779
|
if (!q.has("rate")) q.set("rate", "24000");
|
|
1080
1780
|
const qs = q.toString();
|
|
@@ -1098,10 +1798,22 @@ export class PyAI {
|
|
|
1098
1798
|
if (opts.sampleRate !== undefined) q.set("sample_rate", String(opts.sampleRate));
|
|
1099
1799
|
if (opts.encoding) q.set("encoding", opts.encoding);
|
|
1100
1800
|
if (opts.interimResults !== undefined) q.set("interim_results", String(opts.interimResults));
|
|
1801
|
+
if (opts.numerals !== undefined) q.set("numerals", String(opts.numerals));
|
|
1802
|
+
if (opts.endpointingMs !== undefined) q.set("endpointing_ms", String(opts.endpointingMs));
|
|
1101
1803
|
const qs = q.toString();
|
|
1102
1804
|
return `${wsBase}/v1/audio/transcriptions/stream${qs ? `?${qs}` : ""}`;
|
|
1103
1805
|
}
|
|
1104
1806
|
|
|
1807
|
+
/** Build the AMD detection WebSocket URL (`/v1/amd/stream`). */
|
|
1808
|
+
amdStreamURL(opts: AmdStreamOptions = {}): string {
|
|
1809
|
+
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
1810
|
+
const q = new URLSearchParams(opts.query ?? {});
|
|
1811
|
+
if (opts.aggressiveness !== undefined) q.set("aggressiveness", String(opts.aggressiveness));
|
|
1812
|
+
if (opts.sessionLabel) q.set("session_label", opts.sessionLabel);
|
|
1813
|
+
const qs = q.toString();
|
|
1814
|
+
return `${wsBase}/v1/amd/stream${qs ? `?${qs}` : ""}`;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1105
1817
|
/**
|
|
1106
1818
|
* Open a realtime WebSocket. Uses the global WebSocket (browser, Node 22+).
|
|
1107
1819
|
* The key travels as a subprotocol so it works from the browser without
|