@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/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
|
}
|
|
@@ -148,7 +146,7 @@ export interface RealtimeOptions {
|
|
|
148
146
|
*/
|
|
149
147
|
sessionLabel?: string;
|
|
150
148
|
/**
|
|
151
|
-
* @deprecated Use {@link sessionLabel}. Kept for back-compat
|
|
149
|
+
* @deprecated Use {@link sessionLabel}. Kept for back-compat, emitted as the
|
|
152
150
|
* `agent_id` query alias, which the gateway still accepts. Ignored if
|
|
153
151
|
* `sessionLabel` is set.
|
|
154
152
|
*/
|
|
@@ -161,7 +159,7 @@ export interface RealtimeOptions {
|
|
|
161
159
|
export interface OmniSessionParams {
|
|
162
160
|
/**
|
|
163
161
|
* Browser origins (scheme://host[:port]) the minted token may connect from.
|
|
164
|
-
* Required and non-empty
|
|
162
|
+
* Required and non-empty, a browser token must be origin-locked. `*` is not
|
|
165
163
|
* allowed.
|
|
166
164
|
*/
|
|
167
165
|
allowedOrigins: string[];
|
|
@@ -172,7 +170,7 @@ export interface OmniSessionParams {
|
|
|
172
170
|
}
|
|
173
171
|
|
|
174
172
|
/**
|
|
175
|
-
* The result of {@link PyAI.omni}.createSession
|
|
173
|
+
* The result of {@link PyAI.omni}.createSession, a short-lived credential safe
|
|
176
174
|
* to hand to the browser. Use {@link OmniSession.token} as the WebSocket
|
|
177
175
|
* subprotocol `pyai-key.<token>` against {@link OmniSession.url}.
|
|
178
176
|
*/
|
|
@@ -189,7 +187,7 @@ export interface OmniSession {
|
|
|
189
187
|
}
|
|
190
188
|
|
|
191
189
|
/* ------------------------------------------------------------------------- *
|
|
192
|
-
* Stable enums
|
|
190
|
+
* Stable enums, mirror the server so callers branch on named constants, not
|
|
193
191
|
* magic strings, and a contract change surfaces in one place. These are plain
|
|
194
192
|
* `as const` objects (not TS `enum`s) so the source still runs directly under
|
|
195
193
|
* Node's type-stripping and stays tree-shakeable.
|
|
@@ -227,7 +225,7 @@ export type WSCloseCode = (typeof WSCloseCode)[keyof typeof WSCloseCode];
|
|
|
227
225
|
|
|
228
226
|
/**
|
|
229
227
|
* Stable, machine-readable error `code`s (the documented contract). Branch on
|
|
230
|
-
* these. The set is treated as open
|
|
228
|
+
* these. The set is treated as open, `PyAIError.code` stays `string`, so a
|
|
231
229
|
* new server code never breaks the build, but the known ones are named here.
|
|
232
230
|
*/
|
|
233
231
|
export const ErrorCode = {
|
|
@@ -308,7 +306,7 @@ export interface HearErrorFrame {
|
|
|
308
306
|
export type HearFrame = HearPartialFrame | HearFinalFrame | HearUsageFrame | HearErrorFrame;
|
|
309
307
|
|
|
310
308
|
/**
|
|
311
|
-
* Minimal structural WebSocket
|
|
309
|
+
* Minimal structural WebSocket, matches both the browser/Node global
|
|
312
310
|
* `WebSocket` and the `ws` package, and lets tests inject a mock.
|
|
313
311
|
*/
|
|
314
312
|
export interface WebSocketLike {
|
|
@@ -341,8 +339,8 @@ export interface HearStreamOptions {
|
|
|
341
339
|
*/
|
|
342
340
|
numerals?: boolean;
|
|
343
341
|
/**
|
|
344
|
-
* Turn-segmentation tuning: trailing-pause (ms, 50
|
|
345
|
-
* utterance. Forwards `?endpointing_ms
|
|
342
|
+
* Turn-segmentation tuning: trailing-pause (ms, 50-2000) that ends an
|
|
343
|
+
* utterance. Forwards `?endpointing_ms=`, clamped + honored once the engine
|
|
346
344
|
* supports it; a no-op when omitted. Drive end-of-turn yourself with
|
|
347
345
|
* {@link HearStream.commit} for full control today.
|
|
348
346
|
*/
|
|
@@ -353,12 +351,12 @@ export interface HearStreamOptions {
|
|
|
353
351
|
* array. Bills a single `cue.minutes` line instead of the Hear rate.
|
|
354
352
|
*/
|
|
355
353
|
grounding?: boolean;
|
|
356
|
-
/** Cue: number of KB passages to retrieve per turn (1
|
|
354
|
+
/** Cue: number of KB passages to retrieve per turn (1-20, default 3). */
|
|
357
355
|
groundingK?: number;
|
|
358
|
-
/** Cue: drop passages scoring below this (0
|
|
356
|
+
/** Cue: drop passages scoring below this (0-1, default 0 = keep all). */
|
|
359
357
|
groundingMinScore?: number;
|
|
360
358
|
/** Cue: max ms to wait for retrieval at the final before failing open to
|
|
361
|
-
* `grounding: []` (50
|
|
359
|
+
* `grounding: []` (50-2000, default 450). */
|
|
362
360
|
groundingTimeoutMs?: number;
|
|
363
361
|
/** Extra query params merged onto the connect URL. */
|
|
364
362
|
query?: Record<string, string>;
|
|
@@ -446,7 +444,7 @@ export class HearStream {
|
|
|
446
444
|
this.opts.onError?.(frame);
|
|
447
445
|
break;
|
|
448
446
|
default:
|
|
449
|
-
// Unknown/forward-compatible frame
|
|
447
|
+
// Unknown/forward-compatible frame, ignore.
|
|
450
448
|
break;
|
|
451
449
|
}
|
|
452
450
|
}
|
|
@@ -477,8 +475,79 @@ export class HearStream {
|
|
|
477
475
|
}
|
|
478
476
|
}
|
|
479
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
|
+
|
|
480
549
|
/* ------------------------------------------------------------------------- *
|
|
481
|
-
* Omni realtime (agentic voice)
|
|
550
|
+
* Omni realtime (agentic voice), typed client over the /v1/omni WebSocket
|
|
482
551
|
* ------------------------------------------------------------------------- */
|
|
483
552
|
|
|
484
553
|
/**
|
|
@@ -502,6 +571,8 @@ export const OmniEvent = {
|
|
|
502
571
|
BargeIn: "barge_in",
|
|
503
572
|
/** Alias for barge-in on some engine builds. */
|
|
504
573
|
Flush: "flush",
|
|
574
|
+
/** Engine requests a client-loop tool invocation. */
|
|
575
|
+
ToolCall: "tool_call",
|
|
505
576
|
/** Session is closing; see close code. */
|
|
506
577
|
SessionEnd: "session_end",
|
|
507
578
|
/** Server fault frame. */
|
|
@@ -515,16 +586,134 @@ export interface OmniServerFrame {
|
|
|
515
586
|
[k: string]: unknown;
|
|
516
587
|
}
|
|
517
588
|
|
|
589
|
+
/** Canonical, sanitized transcript delivered by the native Omni demux. */
|
|
590
|
+
export interface OmniTranscriptFrame extends OmniServerFrame {
|
|
591
|
+
event: "transcript";
|
|
592
|
+
role: "user" | "assistant";
|
|
593
|
+
text: string;
|
|
594
|
+
final: boolean;
|
|
595
|
+
mode: "delta" | "replace";
|
|
596
|
+
sequence?: number;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const OMNI_TRANSCRIPT_MAX_BYTES = 16_384;
|
|
600
|
+
const OMNI_TRANSCRIPT_MAX_CHARS = 4_000;
|
|
601
|
+
|
|
602
|
+
function omniTranscriptRole(value: unknown): "user" | "assistant" | null {
|
|
603
|
+
if (value === "user" || value === "caller" || value === "human") return "user";
|
|
604
|
+
if (value === "assistant" || value === "agent") return "assistant";
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function omniTranscriptText(value: unknown): string | null {
|
|
609
|
+
return typeof value === "string"
|
|
610
|
+
&& value.length > 0
|
|
611
|
+
&& value.length <= OMNI_TRANSCRIPT_MAX_CHARS
|
|
612
|
+
&& !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
|
|
613
|
+
? value
|
|
614
|
+
: null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Normalize the live UTF-8 0x02 body and the documented direct-object legacy shape. */
|
|
618
|
+
export function normalizeOmniTranscriptBody(bytes: Uint8Array): OmniTranscriptFrame | null {
|
|
619
|
+
if (bytes.byteLength === 0 || bytes.byteLength > OMNI_TRANSCRIPT_MAX_BYTES) return null;
|
|
620
|
+
let decoded: string;
|
|
621
|
+
try {
|
|
622
|
+
decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
623
|
+
} catch {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
if (!decoded.trimStart().startsWith("{")) {
|
|
627
|
+
const text = omniTranscriptText(decoded);
|
|
628
|
+
return text
|
|
629
|
+
? { event: "transcript", role: "user", text, final: false, mode: "delta" }
|
|
630
|
+
: null;
|
|
631
|
+
}
|
|
632
|
+
let value: unknown;
|
|
633
|
+
try {
|
|
634
|
+
value = JSON.parse(decoded);
|
|
635
|
+
} catch {
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
639
|
+
const payload = value as Record<string, unknown>;
|
|
640
|
+
const role = omniTranscriptRole(payload.role ?? payload.speaker);
|
|
641
|
+
const mode = typeof payload.delta === "string" ? "delta" : "replace";
|
|
642
|
+
const text = omniTranscriptText(
|
|
643
|
+
mode === "delta" ? payload.delta : typeof payload.text === "string" ? payload.text : payload.transcript,
|
|
644
|
+
);
|
|
645
|
+
if (!role || !text) return null;
|
|
646
|
+
if (payload.final !== undefined && typeof payload.final !== "boolean") return null;
|
|
647
|
+
if (payload.sequence !== undefined
|
|
648
|
+
&& (typeof payload.sequence !== "number"
|
|
649
|
+
|| !Number.isSafeInteger(payload.sequence)
|
|
650
|
+
|| payload.sequence < 0)) return null;
|
|
651
|
+
return {
|
|
652
|
+
event: "transcript",
|
|
653
|
+
role,
|
|
654
|
+
text,
|
|
655
|
+
final: payload.final === true,
|
|
656
|
+
mode,
|
|
657
|
+
...(payload.sequence === undefined ? {} : { sequence: payload.sequence as number }),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
518
661
|
/** A binary agent-audio chunk delivered to {@link OmniConnectOptions.onAudio}. */
|
|
519
662
|
export type OmniAudioChunk = ArrayBuffer | ArrayBufferView | Blob;
|
|
520
663
|
|
|
664
|
+
/** Extract a byte view from a binary WS frame (Buffer / ArrayBuffer / typed
|
|
665
|
+
* array). Returns null for a Blob or unknown (can't be read synchronously). */
|
|
666
|
+
function omniToBytes(data: unknown): Uint8Array | null {
|
|
667
|
+
if (data instanceof Uint8Array) return data; // also covers Node Buffer
|
|
668
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
669
|
+
if (ArrayBuffer.isView(data)) {
|
|
670
|
+
const v = data as ArrayBufferView;
|
|
671
|
+
return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
|
|
672
|
+
}
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** A 0x01-prefixed audio frame, the engine's client→server framing for caller
|
|
677
|
+
* PCM16 LE (see OMNI_PROTOCOL_V2.md §2). */
|
|
678
|
+
function omniAudioFrame(pcm: Uint8Array): Uint8Array {
|
|
679
|
+
const out = new Uint8Array(pcm.length + 1);
|
|
680
|
+
out[0] = 0x01;
|
|
681
|
+
out.set(pcm, 1);
|
|
682
|
+
return out;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** A 0x03-prefixed control frame, the engine's client→server framing for
|
|
686
|
+
* `configure`/`dtmf`/`tool_result` (see OMNI_PROTOCOL_V2.md §2/§3). */
|
|
687
|
+
function omniControlFrame(obj: unknown): Uint8Array {
|
|
688
|
+
const json = new TextEncoder().encode(JSON.stringify(obj));
|
|
689
|
+
const out = new Uint8Array(json.length + 1);
|
|
690
|
+
out[0] = 0x03;
|
|
691
|
+
out.set(json, 1);
|
|
692
|
+
return out;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export interface OmniToolDef {
|
|
696
|
+
name: string;
|
|
697
|
+
description?: string;
|
|
698
|
+
parameters?: Record<string, unknown>;
|
|
699
|
+
/** When set, engine-POST mode. Omit for client-loop (default). */
|
|
700
|
+
endpoint?: string;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export interface OmniToolCallFrame {
|
|
704
|
+
type: "tool_call";
|
|
705
|
+
call_id: string;
|
|
706
|
+
name: string;
|
|
707
|
+
arguments?: Record<string, unknown>;
|
|
708
|
+
}
|
|
709
|
+
|
|
521
710
|
/**
|
|
522
711
|
* The agent config sent as the `configure` control frame. These are the wire
|
|
523
712
|
* (snake_case) fields the engine reads; it's an **open bag**, so forward-compat
|
|
524
|
-
* fields (e.g. `greeting
|
|
525
|
-
* through. The SDK supplies the `{"type":"configure"}` envelope for
|
|
526
|
-
* is the whole point: a hand-rolled `{"event":"configure"}` is
|
|
527
|
-
* silently dropped, leaving the agent with no brain and zero turns.
|
|
713
|
+
* fields (e.g. `greeting`, or `model_tier` once the engine acks it) pass
|
|
714
|
+
* straight through. The SDK supplies the `{"type":"configure"}` envelope for
|
|
715
|
+
* you, which is the whole point: a hand-rolled `{"event":"configure"}` is
|
|
716
|
+
* acked but silently dropped, leaving the agent with no brain and zero turns.
|
|
528
717
|
*/
|
|
529
718
|
export interface OmniConfigure {
|
|
530
719
|
/** Voice to speak with (stock / clone / designed id). */
|
|
@@ -535,6 +724,19 @@ export interface OmniConfigure {
|
|
|
535
724
|
kb_endpoint?: string;
|
|
536
725
|
/** Bearer the engine presents to `kb_endpoint`. */
|
|
537
726
|
kb_token?: string;
|
|
727
|
+
/**
|
|
728
|
+
* Session language, end to end (recognition, reasoning, voice). Also
|
|
729
|
+
* settable on the agent profile (`language` on `POST /v1/agents`), which
|
|
730
|
+
* applies automatically when connecting with `session_label={agent_id}`;
|
|
731
|
+
* an inline value here wins for the session. Default `en`. Fail-safe: an
|
|
732
|
+
* unknown/not-yet-enabled language falls back to `en` (the `configured`
|
|
733
|
+
* ack carries `language_active` + `language_fallback: true`), the call
|
|
734
|
+
* proceeds and bills as what was served. Per-language availability is
|
|
735
|
+
* staged, see the Language support reference.
|
|
736
|
+
*/
|
|
737
|
+
language?: "en" | "fr" | "es" | "de" | "hi";
|
|
738
|
+
/** Function calling definitions (client-loop when `endpoint` is omitted). */
|
|
739
|
+
tools?: OmniToolDef[];
|
|
538
740
|
/** Forward-compatible: any other key the engine honors. */
|
|
539
741
|
[k: string]: unknown;
|
|
540
742
|
}
|
|
@@ -561,7 +763,7 @@ export interface OmniConnectOptions {
|
|
|
561
763
|
query?: Record<string, string>;
|
|
562
764
|
/** Fired once the socket opens (after the optional auto-configure). */
|
|
563
765
|
onOpen?: () => void;
|
|
564
|
-
/** Fired for each binary agent-audio chunk
|
|
766
|
+
/** Fired for each binary agent-audio chunk, play it out as it arrives. */
|
|
565
767
|
onAudio?: (chunk: OmniAudioChunk) => void;
|
|
566
768
|
/** Fired on the `hello` handshake frame. */
|
|
567
769
|
onHello?: (frame: OmniServerFrame) => void;
|
|
@@ -575,6 +777,8 @@ export interface OmniConnectOptions {
|
|
|
575
777
|
onTranscript?: (frame: OmniServerFrame) => void;
|
|
576
778
|
/** Fired on `barge_in` / `flush` (user interrupted). */
|
|
577
779
|
onBargeIn?: (frame: OmniServerFrame) => void;
|
|
780
|
+
/** Fired when the engine requests a client-loop tool invocation. */
|
|
781
|
+
onToolCall?: (frame: OmniToolCallFrame) => void;
|
|
578
782
|
/** Fired on `session_end`. */
|
|
579
783
|
onSessionEnd?: (frame: OmniServerFrame) => void;
|
|
580
784
|
/** Fired on EVERY JSON frame (including unknown/forward-compat ones). */
|
|
@@ -607,6 +811,10 @@ export class OmniConnection {
|
|
|
607
811
|
private readonly ws: WebSocketLike;
|
|
608
812
|
private readonly opts: OmniConnectOptions;
|
|
609
813
|
private closed = false;
|
|
814
|
+
/** Serializes async Blob reads in {@link sendAudio} so frames stay ordered. */
|
|
815
|
+
private blobTail: Promise<void> = Promise.resolve();
|
|
816
|
+
/** Serializes inbound Blob decoding so browser frames stay ordered. */
|
|
817
|
+
private inboundTail: Promise<void> = Promise.resolve();
|
|
610
818
|
|
|
611
819
|
constructor(url: string, subprotocol: string, opts: OmniConnectOptions) {
|
|
612
820
|
this.opts = opts;
|
|
@@ -627,7 +835,16 @@ export class OmniConnection {
|
|
|
627
835
|
}
|
|
628
836
|
opts.onOpen?.();
|
|
629
837
|
};
|
|
630
|
-
this.ws.onmessage = (ev) =>
|
|
838
|
+
this.ws.onmessage = (ev) => {
|
|
839
|
+
const reportDecodeError = (error: unknown) => {
|
|
840
|
+
opts.onError?.(error instanceof Error ? error : new Error("Could not decode Omni frame"));
|
|
841
|
+
};
|
|
842
|
+
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
|
|
843
|
+
this.inboundTail = this.inboundTail.then(() => this.handleMessage(ev.data)).catch(reportDecodeError);
|
|
844
|
+
} else {
|
|
845
|
+
void this.handleMessage(ev.data).catch(reportDecodeError);
|
|
846
|
+
}
|
|
847
|
+
};
|
|
631
848
|
this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
|
|
632
849
|
this.ws.onclose = (ev) => {
|
|
633
850
|
this.closed = true;
|
|
@@ -635,21 +852,61 @@ export class OmniConnection {
|
|
|
635
852
|
};
|
|
636
853
|
}
|
|
637
854
|
|
|
638
|
-
private handleMessage(data: unknown): void {
|
|
639
|
-
//
|
|
855
|
+
private async handleMessage(data: unknown): Promise<void> {
|
|
856
|
+
// Server → client binary frames are TYPE-TAGGED by their first byte:
|
|
857
|
+
// 0x01 = agent audio (PCM16) · 0x02 = transcript UTF-8 · 0x03 = control JSON.
|
|
858
|
+
// (Treating every binary frame as audio, the old behavior, plays the
|
|
859
|
+
// 0x03/0x02 frames as a glitch and drops every event/transcript.)
|
|
640
860
|
if (typeof data !== "string") {
|
|
641
|
-
|
|
861
|
+
const bytes = typeof Blob !== "undefined" && data instanceof Blob
|
|
862
|
+
? new Uint8Array(await data.arrayBuffer())
|
|
863
|
+
: omniToBytes(data);
|
|
864
|
+
if (!bytes) {
|
|
865
|
+
this.opts.onError?.(new Error("Unsupported Omni binary frame"));
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const tag = bytes[0];
|
|
869
|
+
if (tag === 0x01) {
|
|
870
|
+
this.opts.onAudio?.(bytes.slice(1) as OmniAudioChunk); // copy → aligned PCM16
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
if (tag === 0x02) {
|
|
874
|
+
const transcript = normalizeOmniTranscriptBody(bytes.subarray(1));
|
|
875
|
+
if (transcript) this.dispatchFrame(transcript);
|
|
876
|
+
else this.opts.onError?.(new Error("Unparseable Omni transcript frame"));
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (tag === 0x03) {
|
|
880
|
+
try {
|
|
881
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1))) as OmniServerFrame;
|
|
882
|
+
this.dispatchFrame(parsed);
|
|
883
|
+
} catch {
|
|
884
|
+
this.opts.onError?.(new Error("Unparseable Omni binary frame"));
|
|
885
|
+
}
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
const tagName = tag === undefined ? "empty" : `0x${tag.toString(16).padStart(2, "0")}`;
|
|
889
|
+
this.opts.onError?.(new Error(`Ignored unknown Omni binary frame tag ${tagName}`));
|
|
642
890
|
return;
|
|
643
891
|
}
|
|
644
|
-
|
|
892
|
+
// Text frame (e.g. a server-side broker relay).
|
|
645
893
|
try {
|
|
646
|
-
|
|
894
|
+
this.dispatchFrame(JSON.parse(data) as OmniServerFrame);
|
|
647
895
|
} catch {
|
|
648
896
|
this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
|
|
649
|
-
return;
|
|
650
897
|
}
|
|
651
|
-
|
|
652
|
-
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
private dispatchFrame(frame: OmniServerFrame): void {
|
|
901
|
+
const raw = frame as unknown as { event?: unknown; type?: unknown };
|
|
902
|
+
const eventName =
|
|
903
|
+
typeof raw.event === "string"
|
|
904
|
+
? raw.event
|
|
905
|
+
: typeof raw.type === "string"
|
|
906
|
+
? raw.type
|
|
907
|
+
: "";
|
|
908
|
+
this.opts.onEvent?.({ ...frame, event: eventName });
|
|
909
|
+
switch (eventName) {
|
|
653
910
|
case OmniEvent.Hello:
|
|
654
911
|
this.opts.onHello?.(frame);
|
|
655
912
|
break;
|
|
@@ -669,6 +926,9 @@ export class OmniConnection {
|
|
|
669
926
|
case OmniEvent.Flush:
|
|
670
927
|
this.opts.onBargeIn?.(frame);
|
|
671
928
|
break;
|
|
929
|
+
case OmniEvent.ToolCall:
|
|
930
|
+
this.opts.onToolCall?.(frame as unknown as OmniToolCallFrame);
|
|
931
|
+
break;
|
|
672
932
|
case OmniEvent.SessionEnd:
|
|
673
933
|
this.opts.onSessionEnd?.(frame);
|
|
674
934
|
break;
|
|
@@ -676,28 +936,49 @@ export class OmniConnection {
|
|
|
676
936
|
this.opts.onError?.(frame);
|
|
677
937
|
break;
|
|
678
938
|
default:
|
|
679
|
-
// Unknown/forward-compatible frame
|
|
939
|
+
// Unknown/forward-compatible frame, already delivered via onEvent.
|
|
680
940
|
break;
|
|
681
941
|
}
|
|
682
942
|
}
|
|
683
943
|
|
|
684
944
|
/**
|
|
685
945
|
* Send (or update) the agent config. Always emitted as
|
|
686
|
-
* `{"type":"configure", ...}
|
|
946
|
+
* `{"type":"configure", ...}`, the correct key. (A hand-rolled
|
|
687
947
|
* `{"event":"configure"}` is acked but silently dropped by the engine.)
|
|
688
948
|
*/
|
|
689
949
|
configure(cfg: OmniConfigure): void {
|
|
690
|
-
this.ws.send(
|
|
950
|
+
this.ws.send(omniControlFrame({ type: "configure", ...cfg }));
|
|
691
951
|
}
|
|
692
952
|
|
|
693
|
-
/**
|
|
953
|
+
/**
|
|
954
|
+
* Stream a chunk of caller audio (PCM16 LE mono at the negotiated rate) as a
|
|
955
|
+
* `0x01`-prefixed frame. The engine demuxes on the first byte and has no
|
|
956
|
+
* default branch, so an untagged chunk is dropped with no error.
|
|
957
|
+
*/
|
|
694
958
|
sendAudio(chunk: ArrayBufferLike | ArrayBufferView | Blob): void {
|
|
695
|
-
|
|
959
|
+
const bytes = omniToBytes(chunk);
|
|
960
|
+
if (bytes) {
|
|
961
|
+
this.ws.send(omniAudioFrame(bytes));
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
// A Blob can only be read asynchronously; queue behind the previous read so
|
|
965
|
+
// frames still reach the engine in call order.
|
|
966
|
+
this.blobTail = this.blobTail
|
|
967
|
+
.then(async () => {
|
|
968
|
+
const buf = await (chunk as Blob).arrayBuffer();
|
|
969
|
+
this.ws.send(omniAudioFrame(new Uint8Array(buf)));
|
|
970
|
+
})
|
|
971
|
+
.catch((err) => this.opts.onError?.(err instanceof Error ? err : new Error(String(err))));
|
|
696
972
|
}
|
|
697
973
|
|
|
698
974
|
/** Send DTMF digits as a `{"type":"dtmf"}` control frame. */
|
|
699
975
|
sendDtmf(digits: string): void {
|
|
700
|
-
this.ws.send(
|
|
976
|
+
this.ws.send(omniControlFrame({ type: "dtmf", digits }));
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** Reply to a client-loop {@link OmniEvent.ToolCall}. */
|
|
980
|
+
toolResult(callId: string, payload: { result?: unknown; error?: string }): void {
|
|
981
|
+
this.ws.send(omniControlFrame({ type: "tool_result", call_id: callId, ...payload }));
|
|
701
982
|
}
|
|
702
983
|
|
|
703
984
|
/**
|
|
@@ -706,7 +987,7 @@ export class OmniConnection {
|
|
|
706
987
|
* never `event`.
|
|
707
988
|
*/
|
|
708
989
|
send(frame: Record<string, unknown>): void {
|
|
709
|
-
this.ws.send(
|
|
990
|
+
this.ws.send(omniControlFrame(frame));
|
|
710
991
|
}
|
|
711
992
|
|
|
712
993
|
/** Close the session. */
|
|
@@ -726,7 +1007,7 @@ export class OmniConnection {
|
|
|
726
1007
|
}
|
|
727
1008
|
|
|
728
1009
|
/* ------------------------------------------------------------------------- *
|
|
729
|
-
* Key introspection
|
|
1010
|
+
* Key introspection, GET /v1/me
|
|
730
1011
|
* ------------------------------------------------------------------------- */
|
|
731
1012
|
|
|
732
1013
|
/** Shape of `GET /v1/me`. Fields are best-effort / forward-compatible. */
|
|
@@ -847,7 +1128,7 @@ export interface TraceBargeMetrics {
|
|
|
847
1128
|
|
|
848
1129
|
/**
|
|
849
1130
|
* Known timeline-turn roles. Left open (see {@link TraceTimelineTurn.role}) so a
|
|
850
|
-
* new server-side role never breaks the build
|
|
1131
|
+
* new server-side role never breaks the build, branch defensively.
|
|
851
1132
|
*/
|
|
852
1133
|
export type TraceTimelineRole = "agent" | "caller" | "system" | "tool";
|
|
853
1134
|
|
|
@@ -873,18 +1154,18 @@ export interface TraceTimelineTurn {
|
|
|
873
1154
|
|
|
874
1155
|
/**
|
|
875
1156
|
* Aggregate per-call quality metrics (eval scorecard-v1). All optional and
|
|
876
|
-
* forward-compatible
|
|
1157
|
+
* forward-compatible, present once the engine emits them.
|
|
877
1158
|
*/
|
|
878
1159
|
export interface QualityMetrics {
|
|
879
|
-
/** Word error rate vs. reference transcript, 0
|
|
1160
|
+
/** Word error rate vs. reference transcript, 0-1 (lower is better). */
|
|
880
1161
|
wer?: number;
|
|
881
1162
|
/** Representative time-to-first-audio across turns, ms. */
|
|
882
1163
|
ttfb_ms?: number;
|
|
883
1164
|
/** 95th-percentile end-to-end turn latency, ms. */
|
|
884
1165
|
turn_p95_ms?: number;
|
|
885
|
-
/** Barge-in recovery rate, 0
|
|
1166
|
+
/** Barge-in recovery rate, 0-1. */
|
|
886
1167
|
barge_recovery?: number;
|
|
887
|
-
/** Task-success score, 0
|
|
1168
|
+
/** Task-success score, 0-1. */
|
|
888
1169
|
task_success?: number;
|
|
889
1170
|
/** Composite voice-agent quality index (engine-defined scale). */
|
|
890
1171
|
vaqi?: number;
|
|
@@ -1018,6 +1299,103 @@ export interface RecapCallTriggerInput {
|
|
|
1018
1299
|
crm_fields?: Record<string, unknown>;
|
|
1019
1300
|
}
|
|
1020
1301
|
|
|
1302
|
+
// --- AMD (answering-machine detection) -----------------------------------
|
|
1303
|
+
|
|
1304
|
+
/** PyAI's richer answered-by vocabulary (superset of Twilio's enum). */
|
|
1305
|
+
export type AmdAnsweredBy =
|
|
1306
|
+
| "human"
|
|
1307
|
+
| "voicemail"
|
|
1308
|
+
| "live_voicemail"
|
|
1309
|
+
| "screening" // iPhone / Google Call Screen
|
|
1310
|
+
| "ivr"
|
|
1311
|
+
| "human_gatekeeper"
|
|
1312
|
+
| "sit_invalid" // dead / disconnected number
|
|
1313
|
+
| "fax"
|
|
1314
|
+
| "silence"
|
|
1315
|
+
| "unknown";
|
|
1316
|
+
|
|
1317
|
+
/** Twilio's `AnsweredBy` enum, echoed for drop-in migration parity. */
|
|
1318
|
+
export type AmdTwilioAnsweredBy =
|
|
1319
|
+
| "human"
|
|
1320
|
+
| "machine_start"
|
|
1321
|
+
| "machine_end_beep"
|
|
1322
|
+
| "machine_end_silence"
|
|
1323
|
+
| "machine_end_other"
|
|
1324
|
+
| "fax"
|
|
1325
|
+
| "unknown";
|
|
1326
|
+
|
|
1327
|
+
export interface AmdConfigInput {
|
|
1328
|
+
/** Operating point on the ROC curve, [0,1]. Near 0 = human-safe (never hang up
|
|
1329
|
+
* on a person); near 1 = fire `machine` fast. A per-call TwiML `<Parameter>`
|
|
1330
|
+
* overrides this. */
|
|
1331
|
+
aggressiveness?: number;
|
|
1332
|
+
/** Signed POST target for `amd.call.completed` events (https). */
|
|
1333
|
+
webhookUrl?: string | null;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
export interface AmdConfig {
|
|
1337
|
+
object?: "amd.config";
|
|
1338
|
+
aggressiveness?: number;
|
|
1339
|
+
webhook_url?: string | null;
|
|
1340
|
+
updated_at?: number;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
export interface AmdCallSummary {
|
|
1344
|
+
object?: "amd.call";
|
|
1345
|
+
call_id: string;
|
|
1346
|
+
session_label?: string | null;
|
|
1347
|
+
status?: "completed" | "failed";
|
|
1348
|
+
answered_by?: AmdAnsweredBy;
|
|
1349
|
+
/** Twilio-enum projection of `answered_by` for drop-in routing parity. */
|
|
1350
|
+
answered_by_twilio?: string | null;
|
|
1351
|
+
confidence?: number | null;
|
|
1352
|
+
/** Latency from answer to decision, in ms. */
|
|
1353
|
+
decision_ms?: number | null;
|
|
1354
|
+
created_at?: number;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
export interface AmdCall extends AmdCallSummary {
|
|
1358
|
+
/** Human-readable evidence, e.g. "machine phrase: 'leave a message' @1.2s". */
|
|
1359
|
+
reason?: string | null;
|
|
1360
|
+
aggressiveness?: number | null;
|
|
1361
|
+
started_at?: number | null;
|
|
1362
|
+
meta?: Record<string, unknown> | null;
|
|
1363
|
+
error?: string | null;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/** A mid-call AMD decision event pushed on the stream (and to the webhook). */
|
|
1367
|
+
export interface AmdDecisionEvent {
|
|
1368
|
+
event?: "amd";
|
|
1369
|
+
call_id?: string;
|
|
1370
|
+
answered_by?: AmdAnsweredBy;
|
|
1371
|
+
answered_by_twilio?: string | null;
|
|
1372
|
+
confidence?: number | null;
|
|
1373
|
+
decision_ms?: number | null;
|
|
1374
|
+
reason?: string | null;
|
|
1375
|
+
[k: string]: unknown;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
export interface AmdStreamOptions {
|
|
1379
|
+
/** Per-call operating point override (0-1), sent on the connect URL. */
|
|
1380
|
+
aggressiveness?: number;
|
|
1381
|
+
/** Opaque per-call tag, echoed back on the decision. */
|
|
1382
|
+
sessionLabel?: string;
|
|
1383
|
+
/** Extra query params merged onto the connect URL. */
|
|
1384
|
+
query?: Record<string, string>;
|
|
1385
|
+
/** Fired once the socket opens. */
|
|
1386
|
+
onOpen?: () => void;
|
|
1387
|
+
/** Fired when PyAI emits the `amd` decision event mid-call. */
|
|
1388
|
+
onDecision?: (event: AmdDecisionEvent) => void;
|
|
1389
|
+
/** Fired on any other JSON frame (forward-compatible). */
|
|
1390
|
+
onMessage?: (frame: Record<string, unknown>) => void;
|
|
1391
|
+
/** Fired on a transport-level error. */
|
|
1392
|
+
onError?: (err: Error) => void;
|
|
1393
|
+
/** Fired when the socket closes. */
|
|
1394
|
+
onClose?: (code: number, reason: string) => void;
|
|
1395
|
+
/** Injectable WebSocket constructor (defaults to the global). */
|
|
1396
|
+
webSocket?: WebSocketCtor;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1021
1399
|
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
|
|
1022
1400
|
|
|
1023
1401
|
export class PyAI {
|
|
@@ -1146,7 +1524,7 @@ export class PyAI {
|
|
|
1146
1524
|
const res = await this.request("/v1/audio/speech", {
|
|
1147
1525
|
method: "POST",
|
|
1148
1526
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
1149
|
-
body: JSON.stringify({ model: "pyai-voice", ...params }),
|
|
1527
|
+
body: JSON.stringify({ model: "pyai-voice", ...params, stream: true }),
|
|
1150
1528
|
});
|
|
1151
1529
|
if (!res.body) throw new PyAIError(res.status, "Response had no body to stream");
|
|
1152
1530
|
return res.body as ReadableStream<Uint8Array>;
|
|
@@ -1220,7 +1598,7 @@ export class PyAI {
|
|
|
1220
1598
|
|
|
1221
1599
|
/**
|
|
1222
1600
|
* Introspect the calling key: scopes, environment, and limits. Useful for a
|
|
1223
|
-
* preflight/doctor check. (New route; older deployments may 404
|
|
1601
|
+
* preflight/doctor check. (New route; older deployments may 404, handle it.)
|
|
1224
1602
|
*/
|
|
1225
1603
|
me = (): Promise<MeResponse> => this.getJson<MeResponse>("/v1/me");
|
|
1226
1604
|
|
|
@@ -1324,7 +1702,7 @@ export class PyAI {
|
|
|
1324
1702
|
},
|
|
1325
1703
|
},
|
|
1326
1704
|
findings: {
|
|
1327
|
-
/** List Tier-2 (async semantic) findings
|
|
1705
|
+
/** List Tier-2 (async semantic) findings, advisory, non-blocking. Scope `trace:read`. */
|
|
1328
1706
|
list: (
|
|
1329
1707
|
params: {
|
|
1330
1708
|
checkId?: string;
|
|
@@ -1400,12 +1778,48 @@ export class PyAI {
|
|
|
1400
1778
|
},
|
|
1401
1779
|
};
|
|
1402
1780
|
|
|
1781
|
+
// --- amd (answering-machine detection) ---------------------------------
|
|
1782
|
+
|
|
1783
|
+
amd = {
|
|
1784
|
+
config: {
|
|
1785
|
+
/** The org's AMD operating point + webhook. Scope `amd:configure`. */
|
|
1786
|
+
get: (): Promise<AmdConfig> => this.getJson("/v1/amd/config"),
|
|
1787
|
+
/** Set the account-default `aggressiveness` (0-1) and webhook. Scope `amd:configure`. */
|
|
1788
|
+
set: (input: AmdConfigInput): Promise<AmdConfig> =>
|
|
1789
|
+
this.postJson("/v1/amd/config", {
|
|
1790
|
+
...(input.aggressiveness !== undefined ? { aggressiveness: input.aggressiveness } : {}),
|
|
1791
|
+
...(input.webhookUrl !== undefined ? { webhook_url: input.webhookUrl } : {}),
|
|
1792
|
+
}),
|
|
1793
|
+
},
|
|
1794
|
+
calls: {
|
|
1795
|
+
/** Recent AMD decisions, newest first. Scope `amd:read`. */
|
|
1796
|
+
list: (params: { limit?: number; cursor?: string; sessionLabel?: string } = {}): Promise<ListResponse<AmdCallSummary>> => {
|
|
1797
|
+
const q = new URLSearchParams();
|
|
1798
|
+
if (params.limit !== undefined) q.set("limit", String(params.limit));
|
|
1799
|
+
if (params.cursor) q.set("cursor", params.cursor);
|
|
1800
|
+
if (params.sessionLabel) q.set("session_label", params.sessionLabel);
|
|
1801
|
+
const qs = q.toString();
|
|
1802
|
+
return this.getJson(`/v1/amd/calls${qs ? `?${qs}` : ""}`);
|
|
1803
|
+
},
|
|
1804
|
+
/** The full decision (answered_by, reason, …) for one call. Scope `amd:read`. */
|
|
1805
|
+
get: (callId: string): Promise<AmdCall> =>
|
|
1806
|
+
this.getJson(`/v1/amd/calls/${encodeURIComponent(callId)}`),
|
|
1807
|
+
},
|
|
1808
|
+
/**
|
|
1809
|
+
* Open a live AMD stream over `/v1/amd/stream` (Twilio Media Streams
|
|
1810
|
+
* protocol). Server-side helper for forking media yourself; the common
|
|
1811
|
+
* Twilio path is one line of TwiML, no SDK. Scope `amd:detect`.
|
|
1812
|
+
*/
|
|
1813
|
+
stream: (opts: AmdStreamOptions = {}): AmdStream =>
|
|
1814
|
+
new AmdStream(this.amdStreamURL(opts), this.realtimeSubprotocol(), opts),
|
|
1815
|
+
};
|
|
1816
|
+
|
|
1403
1817
|
// --- omni (agentic voice) ----------------------------------------------
|
|
1404
1818
|
|
|
1405
1819
|
omni = {
|
|
1406
1820
|
/**
|
|
1407
1821
|
* Mint an ephemeral, origin-locked Omni session token a browser can use to
|
|
1408
|
-
* open ONE realtime session **directly
|
|
1822
|
+
* open ONE realtime session **directly**, the public/private split for
|
|
1409
1823
|
* realtime. **Call this from your server** with a secret key holding
|
|
1410
1824
|
* `omni:session`; never ship the secret key to a page. Hand the returned
|
|
1411
1825
|
* `token` to the browser, which connects with
|
|
@@ -1423,8 +1837,8 @@ export class PyAI {
|
|
|
1423
1837
|
/**
|
|
1424
1838
|
* Open a live Omni agentic-voice session over `/v1/omni`. Returns an
|
|
1425
1839
|
* {@link OmniConnection} that handles the wire protocol's frame-key
|
|
1426
|
-
* asymmetry for you
|
|
1427
|
-
* `dtmf`) and parses `event`-keyed server frames
|
|
1840
|
+
* asymmetry for you, it sends `type`-keyed control frames (`configure`,
|
|
1841
|
+
* `dtmf`) and parses `event`-keyed server frames, so you can't trip the #1
|
|
1428
1842
|
* Omni integration bug. Omni is zero-state: nothing to create first; the
|
|
1429
1843
|
* agent's behavior travels in the `configure` frame. Pass `token` (from
|
|
1430
1844
|
* `createSession`) to connect from a browser without the secret key.
|
|
@@ -1447,7 +1861,7 @@ export class PyAI {
|
|
|
1447
1861
|
const q = new URLSearchParams(opts.query ?? {});
|
|
1448
1862
|
if ((opts.product ?? "omni") === "omni") {
|
|
1449
1863
|
// Omni's native realtime surface is /v1/omni. The session is authorized by
|
|
1450
|
-
// the key's org (zero-state)
|
|
1864
|
+
// the key's org (zero-state), sessionLabel is an optional opaque tag.
|
|
1451
1865
|
// format/rate are load-bearing on the connect URL, so default to
|
|
1452
1866
|
// browser-grade PCM16/24kHz.
|
|
1453
1867
|
if (opts.sessionLabel) q.set("session_label", opts.sessionLabel);
|
|
@@ -1481,6 +1895,16 @@ export class PyAI {
|
|
|
1481
1895
|
return `${wsBase}/v1/audio/transcriptions/stream${qs ? `?${qs}` : ""}`;
|
|
1482
1896
|
}
|
|
1483
1897
|
|
|
1898
|
+
/** Build the AMD detection WebSocket URL (`/v1/amd/stream`). */
|
|
1899
|
+
amdStreamURL(opts: AmdStreamOptions = {}): string {
|
|
1900
|
+
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
1901
|
+
const q = new URLSearchParams(opts.query ?? {});
|
|
1902
|
+
if (opts.aggressiveness !== undefined) q.set("aggressiveness", String(opts.aggressiveness));
|
|
1903
|
+
if (opts.sessionLabel) q.set("session_label", opts.sessionLabel);
|
|
1904
|
+
const qs = q.toString();
|
|
1905
|
+
return `${wsBase}/v1/amd/stream${qs ? `?${qs}` : ""}`;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1484
1908
|
/**
|
|
1485
1909
|
* Open a realtime WebSocket. Uses the global WebSocket (browser, Node 22+).
|
|
1486
1910
|
* The key travels as a subprotocol so it works from the browser without
|