@pyai/sdk 0.2.3 → 0.3.1
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 +35 -19
- package/dist/index.d.ts +97 -45
- package/dist/index.js +91 -69
- package/package.json +18 -3
- package/src/index.ts +200 -101
package/src/index.ts
CHANGED
|
@@ -34,6 +34,9 @@ export class PyAIError extends Error {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export interface Voice {
|
|
37
|
+
/** Canonical catalog identifier returned by GET /v1/voices. */
|
|
38
|
+
voice_id: string;
|
|
39
|
+
/** SDK compatibility alias, always equal to voice_id. */
|
|
37
40
|
id: string;
|
|
38
41
|
name?: string;
|
|
39
42
|
gender?: string;
|
|
@@ -41,6 +44,11 @@ export interface Voice {
|
|
|
41
44
|
[k: string]: unknown;
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
function normalizeVoice(value: Record<string, unknown>): Voice {
|
|
48
|
+
const voiceId = String(value.voice_id ?? value.id ?? "");
|
|
49
|
+
return { ...value, voice_id: voiceId, id: voiceId } as Voice;
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
export interface ListResponse<T> {
|
|
45
53
|
object: "list";
|
|
46
54
|
data: T[];
|
|
@@ -62,13 +70,13 @@ export interface TranscriptionJob {
|
|
|
62
70
|
* Output container/codec for `audio.speech`. This is the **exact** set the
|
|
63
71
|
* server accepts on `POST /v1/audio/speech`, any other value is rejected with
|
|
64
72
|
* `400 unsupported_format`. The default (when `response_format` is omitted) is
|
|
65
|
-
* `
|
|
73
|
+
* `wav`. Omit `sample_rate` for the engine's native 24 kHz (`g711_*` is always
|
|
66
74
|
* 8 kHz).
|
|
67
75
|
*
|
|
68
76
|
* | format | rates (Hz) | Content-Type |
|
|
69
77
|
* |---|---|---|
|
|
70
|
-
* | `
|
|
71
|
-
* | `
|
|
78
|
+
* | `wav` (default) | 8000/16000/24000/48000 | `audio/wav` |
|
|
79
|
+
* | `mp3` | 8000/16000/24000/48000 | `audio/mpeg` |
|
|
72
80
|
* | `opus` | 8000/16000/24000/48000 | `audio/ogg` |
|
|
73
81
|
* | `aac` | 8000/16000/24000/48000 | `audio/aac` |
|
|
74
82
|
* | `flac` | 8000/16000/24000/48000 | `audio/flac` |
|
|
@@ -89,14 +97,17 @@ export const SPEECH_FORMATS = ["wav", "mp3", "opus", "aac", "flac", "pcm", "g711
|
|
|
89
97
|
export const SPEECH_SAMPLE_RATES = [8000, 16000, 24000, 48000] as const;
|
|
90
98
|
export type SpeechSampleRate = (typeof SPEECH_SAMPLE_RATES)[number];
|
|
91
99
|
|
|
100
|
+
/** Canonical Speak model plus the intentional OpenAI drop-in aliases. */
|
|
101
|
+
export type SpeakModel = "pyai-speak" | "tts-1" | "tts-1-hd";
|
|
102
|
+
|
|
92
103
|
export interface SpeechParams {
|
|
93
104
|
input: string;
|
|
94
105
|
voice?: string;
|
|
95
|
-
model?:
|
|
106
|
+
model?: SpeakModel;
|
|
96
107
|
/**
|
|
97
108
|
* Output container/codec, resampled+encoded server-side. One of
|
|
98
109
|
* {@link SpeechFormat}, anything else is a `400 unsupported_format`. Omit for
|
|
99
|
-
* the default of `
|
|
110
|
+
* the default of `wav`.
|
|
100
111
|
*
|
|
101
112
|
* `g711_ulaw`/`g711_alaw` return raw 8 kHz mono G.711, the bytes Twilio/SIP
|
|
102
113
|
* media streams expect, so you can hand the response straight to a telephony
|
|
@@ -113,19 +124,26 @@ export interface SpeechParams {
|
|
|
113
124
|
* telephony pipelines, most often with `response_format: "pcm"`.
|
|
114
125
|
*/
|
|
115
126
|
sample_rate?: SpeechSampleRate;
|
|
127
|
+
/** Reserved; currently returns `400 unsupported_parameter` when provided. */
|
|
116
128
|
speed?: number;
|
|
117
129
|
/**
|
|
118
|
-
*
|
|
119
|
-
* honored once the engine supports it (otherwise ignored server-side), so it's
|
|
120
|
-
* always safe to send.
|
|
130
|
+
* Reserved; currently returns `400 unsupported_parameter` when provided.
|
|
121
131
|
*/
|
|
122
132
|
seed?: number;
|
|
123
133
|
/**
|
|
124
|
-
*
|
|
134
|
+
* Reserved; currently returns `400 unsupported_parameter` when provided.
|
|
125
135
|
*/
|
|
126
136
|
temperature?: number;
|
|
127
137
|
}
|
|
128
138
|
|
|
139
|
+
function assertActiveSpeechParams(params: SpeechParams): void {
|
|
140
|
+
for (const field of ["speed", "seed", "temperature"] as const) {
|
|
141
|
+
if (params[field] !== undefined) {
|
|
142
|
+
throw new Error(`${field} is reserved but not active on Speak`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
129
147
|
export interface CreateJobParams {
|
|
130
148
|
audio_url: string;
|
|
131
149
|
model?: string;
|
|
@@ -137,8 +155,6 @@ export interface CreateJobParams {
|
|
|
137
155
|
}
|
|
138
156
|
|
|
139
157
|
export interface RealtimeOptions {
|
|
140
|
-
/** "omni" (agentic voice) or "flow" (legacy voice duplex). Default "omni". */
|
|
141
|
-
product?: "omni" | "flow";
|
|
142
158
|
/**
|
|
143
159
|
* Optional opaque tag echoed to your `kb_endpoint` and recorded on the call.
|
|
144
160
|
* Omni is zero-state: the session is authorized by the key's org, so there is
|
|
@@ -146,12 +162,10 @@ export interface RealtimeOptions {
|
|
|
146
162
|
*/
|
|
147
163
|
sessionLabel?: string;
|
|
148
164
|
/**
|
|
149
|
-
*
|
|
150
|
-
* `
|
|
151
|
-
*
|
|
165
|
+
* Extra canonical query params (`format`, `rate`, or the intentional
|
|
166
|
+
* server-side `api_key` auth option). Retired connect aliases and model/token
|
|
167
|
+
* selectors are rejected.
|
|
152
168
|
*/
|
|
153
|
-
agentId?: string;
|
|
154
|
-
/** Extra query params (e.g. format, rate). */
|
|
155
169
|
query?: Record<string, string>;
|
|
156
170
|
}
|
|
157
171
|
|
|
@@ -195,6 +209,8 @@ export interface OmniSession {
|
|
|
195
209
|
|
|
196
210
|
/** Frame `type`s emitted by the Hear streaming-STT WebSocket. */
|
|
197
211
|
export const HearFrameType = {
|
|
212
|
+
/** Applied endpointing configuration and validation warnings. */
|
|
213
|
+
ConfigAck: "config_ack",
|
|
198
214
|
/** Eager live hypothesis for the current utterance. */
|
|
199
215
|
Partial: "partial",
|
|
200
216
|
/** Partial whose prefix has stabilized (won't be revised). */
|
|
@@ -232,7 +248,7 @@ export const ErrorCode = {
|
|
|
232
248
|
Unauthorized: "unauthorized",
|
|
233
249
|
Forbidden: "forbidden",
|
|
234
250
|
OriginNotAllowed: "origin_not_allowed",
|
|
235
|
-
|
|
251
|
+
InvalidSessionLabel: "invalid_session_label",
|
|
236
252
|
CreditExhausted: "credit_exhausted",
|
|
237
253
|
KeyBudgetExceeded: "key_budget_exceeded",
|
|
238
254
|
InsufficientQuota: "insufficient_quota",
|
|
@@ -255,6 +271,24 @@ export interface HearGroundingPassage {
|
|
|
255
271
|
score: number;
|
|
256
272
|
}
|
|
257
273
|
|
|
274
|
+
/** One endpointing validation result echoed in a `config_ack` frame. */
|
|
275
|
+
export interface HearConfigWarning {
|
|
276
|
+
field: string;
|
|
277
|
+
value: unknown;
|
|
278
|
+
effective?: number;
|
|
279
|
+
reason: "clamped_to_range" | "not_a_number" | "unknown_config_field" | (string & {});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Applied endpointing settings after connect-time or mid-session config. */
|
|
283
|
+
export interface HearConfigAckFrame {
|
|
284
|
+
type: "config_ack";
|
|
285
|
+
endpointing_ms: number;
|
|
286
|
+
effective_floor_ms: number;
|
|
287
|
+
effective_ceiling_ms: number;
|
|
288
|
+
score_interval_ms: number;
|
|
289
|
+
warnings: HearConfigWarning[];
|
|
290
|
+
}
|
|
291
|
+
|
|
258
292
|
/** Live hypothesis frame (`partial` / `partial_stable`). */
|
|
259
293
|
export interface HearPartialFrame {
|
|
260
294
|
type: "partial" | "partial_stable";
|
|
@@ -269,6 +303,12 @@ export interface HearPartialFrame {
|
|
|
269
303
|
}
|
|
270
304
|
|
|
271
305
|
/** Finalized-utterance frame (`speech_final` / `final`). */
|
|
306
|
+
export type HearEndpointReason =
|
|
307
|
+
| "peak_te_early"
|
|
308
|
+
| "silence_backstop"
|
|
309
|
+
| "commit"
|
|
310
|
+
| (string & {});
|
|
311
|
+
|
|
272
312
|
export interface HearFinalFrame {
|
|
273
313
|
type: "speech_final" | "final";
|
|
274
314
|
text: string;
|
|
@@ -276,6 +316,8 @@ export interface HearFinalFrame {
|
|
|
276
316
|
t_ms: number;
|
|
277
317
|
/** Active-speech length of the utterance (the billed signal), ms. */
|
|
278
318
|
audio_ms: number;
|
|
319
|
+
/** Why the utterance ended. Log this when tuning automatic endpointing. */
|
|
320
|
+
endpoint_reason: HearEndpointReason;
|
|
279
321
|
/** Present only with Cue grounding enabled (top KB passages). */
|
|
280
322
|
grounding?: HearGroundingPassage[];
|
|
281
323
|
}
|
|
@@ -288,7 +330,7 @@ export interface HearUsageFrame {
|
|
|
288
330
|
type: "usage";
|
|
289
331
|
/** `hear` for plain streaming, `cue` when grounding was enabled. */
|
|
290
332
|
product: "hear" | "cue";
|
|
291
|
-
/** The billed meter (`hear.
|
|
333
|
+
/** The billed meter (`hear.minutes` or `cue.minutes`). */
|
|
292
334
|
meter: string;
|
|
293
335
|
/** Summed active-speech audio billed for the session, in seconds. */
|
|
294
336
|
audio_seconds: number;
|
|
@@ -303,7 +345,12 @@ export interface HearErrorFrame {
|
|
|
303
345
|
message: string;
|
|
304
346
|
}
|
|
305
347
|
|
|
306
|
-
export type HearFrame =
|
|
348
|
+
export type HearFrame =
|
|
349
|
+
| HearConfigAckFrame
|
|
350
|
+
| HearPartialFrame
|
|
351
|
+
| HearFinalFrame
|
|
352
|
+
| HearUsageFrame
|
|
353
|
+
| HearErrorFrame;
|
|
307
354
|
|
|
308
355
|
/**
|
|
309
356
|
* Minimal structural WebSocket, matches both the browser/Node global
|
|
@@ -324,8 +371,12 @@ export type WebSocketCtor = new (url: string, protocols?: string | string[]) =>
|
|
|
324
371
|
export interface HearStreamOptions {
|
|
325
372
|
/** Streaming STT model. Server default `pyai-hear`. */
|
|
326
373
|
model?: string;
|
|
327
|
-
/**
|
|
328
|
-
|
|
374
|
+
/**
|
|
375
|
+
* Hear is English-only. Set `"en"` explicitly; omission also means English
|
|
376
|
+
* and does not enable language detection. Other values receive
|
|
377
|
+
* `400 unsupported_language`.
|
|
378
|
+
*/
|
|
379
|
+
language?: "en";
|
|
329
380
|
/** Input PCM sample rate in Hz. Default 16000 server-side. */
|
|
330
381
|
sampleRate?: number;
|
|
331
382
|
/** Audio frame encoding. Default "pcm16". */
|
|
@@ -339,16 +390,14 @@ export interface HearStreamOptions {
|
|
|
339
390
|
*/
|
|
340
391
|
numerals?: boolean;
|
|
341
392
|
/**
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
* {@link HearStream.commit} for full control today.
|
|
393
|
+
* Minimum trailing-pause length before an utterance may end (50-5000 ms).
|
|
394
|
+
* Turn detection may wait longer, bounded at `max(endpointingMs, 1500)`.
|
|
395
|
+
* The server confirms the applied value through `onConfigAck`.
|
|
346
396
|
*/
|
|
347
397
|
endpointingMs?: number;
|
|
348
398
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
* array. Bills a single `cue.minutes` line instead of the Hear rate.
|
|
399
|
+
* Reserved Cue grounding configuration. Grounding is not active on the
|
|
400
|
+
* serving Hear stream; do not rely on grounding frames or Cue metering yet.
|
|
352
401
|
*/
|
|
353
402
|
grounding?: boolean;
|
|
354
403
|
/** Cue: number of KB passages to retrieve per turn (1-20, default 3). */
|
|
@@ -360,8 +409,11 @@ export interface HearStreamOptions {
|
|
|
360
409
|
groundingTimeoutMs?: number;
|
|
361
410
|
/** Extra query params merged onto the connect URL. */
|
|
362
411
|
query?: Record<string, string>;
|
|
363
|
-
/** Fired once the socket opens
|
|
412
|
+
/** Fired once the socket opens. */
|
|
364
413
|
onOpen?: () => void;
|
|
414
|
+
/** Fired after connect-time or mid-session endpointing config. Assert that
|
|
415
|
+
* `warnings` is empty before relying on the requested floor. */
|
|
416
|
+
onConfigAck?: (frame: HearConfigAckFrame) => void;
|
|
365
417
|
/** Fired on `partial` / `partial_stable`. */
|
|
366
418
|
onPartial?: (frame: HearPartialFrame) => void;
|
|
367
419
|
/** Fired on `speech_final` / `final`. */
|
|
@@ -379,8 +431,10 @@ export interface HearStreamOptions {
|
|
|
379
431
|
/**
|
|
380
432
|
* A live Hear streaming-STT session. Hides the frame protocol: stream audio
|
|
381
433
|
* with {@link HearStream.sendAudio}, get `onPartial`/`onFinal`/`onError`
|
|
382
|
-
* callbacks,
|
|
383
|
-
*
|
|
434
|
+
* callbacks, update the silence floor with
|
|
435
|
+
* {@link HearStream.configureEndpointing}, force-finalize with
|
|
436
|
+
* {@link HearStream.commit}, and flush+close with {@link HearStream.close}.
|
|
437
|
+
* Construct via `pyai.audio.transcriptions.stream()`.
|
|
384
438
|
*/
|
|
385
439
|
export class HearStream {
|
|
386
440
|
private readonly ws: WebSocketLike;
|
|
@@ -389,6 +443,11 @@ export class HearStream {
|
|
|
389
443
|
|
|
390
444
|
constructor(url: string, subprotocol: string, opts: HearStreamOptions) {
|
|
391
445
|
this.opts = opts;
|
|
446
|
+
if (opts.grounding) {
|
|
447
|
+
throw new Error(
|
|
448
|
+
"Cue grounding is not active on the serving Hear stream; omit grounding until the API reference marks it active",
|
|
449
|
+
);
|
|
450
|
+
}
|
|
392
451
|
const WS = opts.webSocket ?? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket;
|
|
393
452
|
if (!WS) {
|
|
394
453
|
throw new Error(
|
|
@@ -397,17 +456,6 @@ export class HearStream {
|
|
|
397
456
|
}
|
|
398
457
|
this.ws = new WS(url, [subprotocol]);
|
|
399
458
|
this.ws.onopen = () => {
|
|
400
|
-
if (opts.grounding) {
|
|
401
|
-
try {
|
|
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));
|
|
407
|
-
} catch {
|
|
408
|
-
/* surfaced via onerror */
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
459
|
opts.onOpen?.();
|
|
412
460
|
};
|
|
413
461
|
this.ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
@@ -429,6 +477,9 @@ export class HearStream {
|
|
|
429
477
|
return;
|
|
430
478
|
}
|
|
431
479
|
switch (frame.type) {
|
|
480
|
+
case HearFrameType.ConfigAck:
|
|
481
|
+
this.opts.onConfigAck?.(frame);
|
|
482
|
+
break;
|
|
432
483
|
case HearFrameType.Partial:
|
|
433
484
|
case HearFrameType.PartialStable:
|
|
434
485
|
this.opts.onPartial?.(frame);
|
|
@@ -454,6 +505,11 @@ export class HearStream {
|
|
|
454
505
|
this.ws.send(chunk);
|
|
455
506
|
}
|
|
456
507
|
|
|
508
|
+
/** Change the minimum trailing-pause floor without reconnecting. */
|
|
509
|
+
configureEndpointing(endpointingMs: number): void {
|
|
510
|
+
this.ws.send(JSON.stringify({ type: "config", endpointing_ms: endpointingMs }));
|
|
511
|
+
}
|
|
512
|
+
|
|
457
513
|
/** Force-finalize the current utterance (e.g. on VAD end-of-turn). */
|
|
458
514
|
commit(): void {
|
|
459
515
|
this.ws.send(JSON.stringify({ type: "commit" }));
|
|
@@ -586,25 +642,23 @@ export interface OmniServerFrame {
|
|
|
586
642
|
[k: string]: unknown;
|
|
587
643
|
}
|
|
588
644
|
|
|
589
|
-
/** Canonical, sanitized transcript delivered by the native Omni demux.
|
|
645
|
+
/** Canonical, sanitized transcript delivered by the native Omni demux.
|
|
646
|
+
* The live wire sends caller text deltas; the JSON fields are SDK-owned
|
|
647
|
+
* normalization so applications don't have to special-case the byte payload. */
|
|
590
648
|
export interface OmniTranscriptFrame extends OmniServerFrame {
|
|
591
649
|
event: "transcript";
|
|
592
650
|
role: "user" | "assistant";
|
|
593
651
|
text: string;
|
|
594
652
|
final: boolean;
|
|
653
|
+
/** Live text frames append; legacy JSON frames replace unless they use `delta`. */
|
|
595
654
|
mode: "delta" | "replace";
|
|
655
|
+
/** Optional ordering hint on legacy JSON frames. */
|
|
596
656
|
sequence?: number;
|
|
597
657
|
}
|
|
598
658
|
|
|
599
659
|
const OMNI_TRANSCRIPT_MAX_BYTES = 16_384;
|
|
600
660
|
const OMNI_TRANSCRIPT_MAX_CHARS = 4_000;
|
|
601
661
|
|
|
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
662
|
function omniTranscriptText(value: unknown): string | null {
|
|
609
663
|
return typeof value === "string"
|
|
610
664
|
&& value.length > 0
|
|
@@ -614,7 +668,13 @@ function omniTranscriptText(value: unknown): string | null {
|
|
|
614
668
|
: null;
|
|
615
669
|
}
|
|
616
670
|
|
|
617
|
-
|
|
671
|
+
function omniTranscriptRole(value: unknown): "user" | "assistant" | null {
|
|
672
|
+
if (value === "user" || value === "caller" || value === "human") return "user";
|
|
673
|
+
if (value === "assistant" || value === "agent") return "assistant";
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Normalize the live UTF-8 `0x02` text body and bounded legacy JSON bodies. */
|
|
618
678
|
export function normalizeOmniTranscriptBody(bytes: Uint8Array): OmniTranscriptFrame | null {
|
|
619
679
|
if (bytes.byteLength === 0 || bytes.byteLength > OMNI_TRANSCRIPT_MAX_BYTES) return null;
|
|
620
680
|
let decoded: string;
|
|
@@ -623,12 +683,16 @@ export function normalizeOmniTranscriptBody(bytes: Uint8Array): OmniTranscriptFr
|
|
|
623
683
|
} catch {
|
|
624
684
|
return null;
|
|
625
685
|
}
|
|
686
|
+
// The serving engine's canonical payload is plain UTF-8 caller text, not
|
|
687
|
+
// JSON. Each frame is a delta for the current caller turn.
|
|
626
688
|
if (!decoded.trimStart().startsWith("{")) {
|
|
627
689
|
const text = omniTranscriptText(decoded);
|
|
628
690
|
return text
|
|
629
691
|
? { event: "transcript", role: "user", text, final: false, mode: "delta" }
|
|
630
692
|
: null;
|
|
631
693
|
}
|
|
694
|
+
|
|
695
|
+
// Keep accepting a bounded direct object for older bridges and recordings.
|
|
632
696
|
let value: unknown;
|
|
633
697
|
try {
|
|
634
698
|
value = JSON.parse(decoded);
|
|
@@ -637,6 +701,8 @@ export function normalizeOmniTranscriptBody(bytes: Uint8Array): OmniTranscriptFr
|
|
|
637
701
|
}
|
|
638
702
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
639
703
|
const payload = value as Record<string, unknown>;
|
|
704
|
+
if (payload.type !== undefined
|
|
705
|
+
|| (payload.event !== undefined && payload.event !== "transcript")) return null;
|
|
640
706
|
const role = omniTranscriptRole(payload.role ?? payload.speaker);
|
|
641
707
|
const mode = typeof payload.delta === "string" ? "delta" : "replace";
|
|
642
708
|
const text = omniTranscriptText(
|
|
@@ -701,7 +767,7 @@ export interface OmniToolDef {
|
|
|
701
767
|
}
|
|
702
768
|
|
|
703
769
|
export interface OmniToolCallFrame {
|
|
704
|
-
|
|
770
|
+
event: "tool_call";
|
|
705
771
|
call_id: string;
|
|
706
772
|
name: string;
|
|
707
773
|
arguments?: Record<string, unknown>;
|
|
@@ -727,7 +793,7 @@ export interface OmniConfigure {
|
|
|
727
793
|
/**
|
|
728
794
|
* Session language, end to end (recognition, reasoning, voice). Also
|
|
729
795
|
* settable on the agent profile (`language` on `POST /v1/agents`), which
|
|
730
|
-
* applies automatically when
|
|
796
|
+
* applies automatically when `session_label` is the saved profile id;
|
|
731
797
|
* an inline value here wins for the session. Default `en`. Fail-safe: an
|
|
732
798
|
* unknown/not-yet-enabled language falls back to `en` (the `configured`
|
|
733
799
|
* ack carries `language_active` + `language_fallback: true`), the call
|
|
@@ -750,7 +816,11 @@ export interface OmniConnectOptions {
|
|
|
750
816
|
* so a page never holds a secret key.
|
|
751
817
|
*/
|
|
752
818
|
token?: string;
|
|
753
|
-
/**
|
|
819
|
+
/**
|
|
820
|
+
* Caller-input sample rate. `24000` and `16000` sessions receive agent audio
|
|
821
|
+
* at 24 kHz; `8000` sessions receive 8 kHz. Read `hello.audio_out` rather than
|
|
822
|
+
* assuming output matches this value.
|
|
823
|
+
*/
|
|
754
824
|
rate?: 24000 | 16000 | 8000;
|
|
755
825
|
/** Connect-URL audio format. Default `pcm16`. */
|
|
756
826
|
format?: "pcm16";
|
|
@@ -773,7 +843,7 @@ export interface OmniConnectOptions {
|
|
|
773
843
|
onSessionStarted?: (frame: OmniServerFrame) => void;
|
|
774
844
|
/** Fired on `turn` boundaries. */
|
|
775
845
|
onTurn?: (frame: OmniServerFrame) => void;
|
|
776
|
-
/** Fired
|
|
846
|
+
/** Fired for each normalized caller-transcript delta (`0x02` plain UTF-8 live). */
|
|
777
847
|
onTranscript?: (frame: OmniServerFrame) => void;
|
|
778
848
|
/** Fired on `barge_in` / `flush` (user interrupted). */
|
|
779
849
|
onBargeIn?: (frame: OmniServerFrame) => void;
|
|
@@ -878,7 +948,11 @@ export class OmniConnection {
|
|
|
878
948
|
}
|
|
879
949
|
if (tag === 0x03) {
|
|
880
950
|
try {
|
|
881
|
-
const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1))) as OmniServerFrame;
|
|
951
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(1))) as OmniServerFrame;
|
|
952
|
+
if (parsed?.event === OmniEvent.Transcript) {
|
|
953
|
+
this.opts.onError?.(new Error("Omni transcript events must use a binary 0x02 frame"));
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
882
956
|
this.dispatchFrame(parsed);
|
|
883
957
|
} catch {
|
|
884
958
|
this.opts.onError?.(new Error("Unparseable Omni binary frame"));
|
|
@@ -889,23 +963,16 @@ export class OmniConnection {
|
|
|
889
963
|
this.opts.onError?.(new Error(`Ignored unknown Omni binary frame tag ${tagName}`));
|
|
890
964
|
return;
|
|
891
965
|
}
|
|
892
|
-
|
|
893
|
-
try {
|
|
894
|
-
this.dispatchFrame(JSON.parse(data) as OmniServerFrame);
|
|
895
|
-
} catch {
|
|
896
|
-
this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
|
|
897
|
-
}
|
|
966
|
+
this.opts.onError?.(new Error("Unexpected Omni text frame; server frames must use binary 0x01/0x02/0x03 tags"));
|
|
898
967
|
}
|
|
899
968
|
|
|
900
969
|
private dispatchFrame(frame: OmniServerFrame): void {
|
|
901
|
-
const
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
: "";
|
|
908
|
-
this.opts.onEvent?.({ ...frame, event: eventName });
|
|
970
|
+
const eventName = (frame as { event?: unknown }).event;
|
|
971
|
+
if (typeof eventName !== "string" || !eventName) {
|
|
972
|
+
this.opts.onError?.(new Error("Omni server control frame is missing its event key"));
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
this.opts.onEvent?.(frame);
|
|
909
976
|
switch (eventName) {
|
|
910
977
|
case OmniEvent.Hello:
|
|
911
978
|
this.opts.onHello?.(frame);
|
|
@@ -947,7 +1014,10 @@ export class OmniConnection {
|
|
|
947
1014
|
* `{"event":"configure"}` is acked but silently dropped by the engine.)
|
|
948
1015
|
*/
|
|
949
1016
|
configure(cfg: OmniConfigure): void {
|
|
950
|
-
|
|
1017
|
+
const payload: Record<string, unknown> = { ...cfg };
|
|
1018
|
+
delete payload.type;
|
|
1019
|
+
delete payload.event;
|
|
1020
|
+
this.ws.send(omniControlFrame({ type: "configure", ...payload }));
|
|
951
1021
|
}
|
|
952
1022
|
|
|
953
1023
|
/**
|
|
@@ -987,7 +1057,12 @@ export class OmniConnection {
|
|
|
987
1057
|
* never `event`.
|
|
988
1058
|
*/
|
|
989
1059
|
send(frame: Record<string, unknown>): void {
|
|
990
|
-
|
|
1060
|
+
if (typeof frame.type !== "string" || !frame.type) {
|
|
1061
|
+
throw new TypeError("Omni client control frames must have a non-empty type key");
|
|
1062
|
+
}
|
|
1063
|
+
const payload = { ...frame };
|
|
1064
|
+
delete payload.event;
|
|
1065
|
+
this.ws.send(omniControlFrame(payload));
|
|
991
1066
|
}
|
|
992
1067
|
|
|
993
1068
|
/** Close the session. */
|
|
@@ -1301,19 +1376,23 @@ export interface RecapCallTriggerInput {
|
|
|
1301
1376
|
|
|
1302
1377
|
// --- AMD (answering-machine detection) -----------------------------------
|
|
1303
1378
|
|
|
1304
|
-
/**
|
|
1379
|
+
/**
|
|
1380
|
+
* The answered-by vocabulary on stored call records and the
|
|
1381
|
+
* `amd.call.completed` webhook: the routing classes plus the machine subtypes.
|
|
1382
|
+
*/
|
|
1305
1383
|
export type AmdAnsweredBy =
|
|
1306
1384
|
| "human"
|
|
1385
|
+
| "machine"
|
|
1307
1386
|
| "voicemail"
|
|
1308
|
-
| "live_voicemail"
|
|
1309
1387
|
| "screening" // iPhone / Google Call Screen
|
|
1310
1388
|
| "ivr"
|
|
1311
|
-
| "
|
|
1389
|
+
| "music" // hold music
|
|
1312
1390
|
| "sit_invalid" // dead / disconnected number
|
|
1313
|
-
| "fax"
|
|
1314
|
-
| "silence"
|
|
1315
1391
|
| "unknown";
|
|
1316
1392
|
|
|
1393
|
+
/** The routing classes pushed on the mid-call wire event (`event: "amd"`). */
|
|
1394
|
+
export type AmdWireAnsweredBy = "human" | "machine" | "sit_invalid" | "unknown";
|
|
1395
|
+
|
|
1317
1396
|
/** Twilio's `AnsweredBy` enum, echoed for drop-in migration parity. */
|
|
1318
1397
|
export type AmdTwilioAnsweredBy =
|
|
1319
1398
|
| "human"
|
|
@@ -1363,11 +1442,16 @@ export interface AmdCall extends AmdCallSummary {
|
|
|
1363
1442
|
error?: string | null;
|
|
1364
1443
|
}
|
|
1365
1444
|
|
|
1366
|
-
/**
|
|
1445
|
+
/**
|
|
1446
|
+
* A mid-call AMD decision event pushed on the stream (and to the per-call
|
|
1447
|
+
* TwiML `webhook`). Carries the coarse routing class; the machine subtype
|
|
1448
|
+
* (`voicemail`/`ivr`/`screening`/`music`) is on the stored call record
|
|
1449
|
+
* (`AmdCall`) and the `amd.call.completed` webhook instead.
|
|
1450
|
+
*/
|
|
1367
1451
|
export interface AmdDecisionEvent {
|
|
1368
1452
|
event?: "amd";
|
|
1369
1453
|
call_id?: string;
|
|
1370
|
-
answered_by?:
|
|
1454
|
+
answered_by?: AmdWireAnsweredBy;
|
|
1371
1455
|
answered_by_twilio?: string | null;
|
|
1372
1456
|
confidence?: number | null;
|
|
1373
1457
|
decision_ms?: number | null;
|
|
@@ -1490,14 +1574,22 @@ export class PyAI {
|
|
|
1490
1574
|
// --- voices -------------------------------------------------------------
|
|
1491
1575
|
|
|
1492
1576
|
voices = {
|
|
1493
|
-
list: (params: { gender?: string; region?: string } = {}): Promise<ListResponse<Voice>> => {
|
|
1577
|
+
list: async (params: { gender?: string; region?: string } = {}): Promise<ListResponse<Voice>> => {
|
|
1494
1578
|
const q = new URLSearchParams();
|
|
1495
1579
|
if (params.gender) q.set("gender", params.gender);
|
|
1496
1580
|
if (params.region) q.set("region", params.region);
|
|
1497
1581
|
const qs = q.toString();
|
|
1498
|
-
|
|
1582
|
+
const page = await this.getJson<ListResponse<Record<string, unknown>>>(
|
|
1583
|
+
`/v1/voices${qs ? `?${qs}` : ""}`,
|
|
1584
|
+
);
|
|
1585
|
+
return { ...page, data: page.data.map(normalizeVoice) };
|
|
1499
1586
|
},
|
|
1500
|
-
get: (id: string): Promise<Voice> =>
|
|
1587
|
+
get: async (id: string): Promise<Voice> =>
|
|
1588
|
+
normalizeVoice(
|
|
1589
|
+
await this.getJson<Record<string, unknown>>(
|
|
1590
|
+
`/v1/voices/${encodeURIComponent(id)}`,
|
|
1591
|
+
),
|
|
1592
|
+
),
|
|
1501
1593
|
};
|
|
1502
1594
|
|
|
1503
1595
|
// --- audio --------------------------------------------------------------
|
|
@@ -1505,10 +1597,11 @@ export class PyAI {
|
|
|
1505
1597
|
audio = {
|
|
1506
1598
|
/** Text-to-speech. Returns the raw audio bytes (default WAV). */
|
|
1507
1599
|
speech: async (params: SpeechParams): Promise<ArrayBuffer> => {
|
|
1600
|
+
assertActiveSpeechParams(params);
|
|
1508
1601
|
const res = await this.request("/v1/audio/speech", {
|
|
1509
1602
|
method: "POST",
|
|
1510
1603
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
1511
|
-
body: JSON.stringify({ model: "pyai-
|
|
1604
|
+
body: JSON.stringify({ model: "pyai-speak", ...params }),
|
|
1512
1605
|
});
|
|
1513
1606
|
return res.arrayBuffer();
|
|
1514
1607
|
},
|
|
@@ -1521,10 +1614,11 @@ export class PyAI {
|
|
|
1521
1614
|
* iterable of Uint8Array chunks.
|
|
1522
1615
|
*/
|
|
1523
1616
|
speechStream: async (params: SpeechParams): Promise<ReadableStream<Uint8Array>> => {
|
|
1617
|
+
assertActiveSpeechParams(params);
|
|
1524
1618
|
const res = await this.request("/v1/audio/speech", {
|
|
1525
1619
|
method: "POST",
|
|
1526
1620
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
1527
|
-
body: JSON.stringify({ model: "pyai-
|
|
1621
|
+
body: JSON.stringify({ model: "pyai-speak", ...params, stream: true }),
|
|
1528
1622
|
});
|
|
1529
1623
|
if (!res.body) throw new PyAIError(res.status, "Response had no body to stream");
|
|
1530
1624
|
return res.body as ReadableStream<Uint8Array>;
|
|
@@ -1535,7 +1629,11 @@ export class PyAI {
|
|
|
1535
1629
|
file: Blob;
|
|
1536
1630
|
filename?: string;
|
|
1537
1631
|
model?: string;
|
|
1538
|
-
|
|
1632
|
+
/**
|
|
1633
|
+
* Hear is English-only. Omission means English, not auto-detect; other
|
|
1634
|
+
* values receive `400 unsupported_language`.
|
|
1635
|
+
*/
|
|
1636
|
+
language?: "en";
|
|
1539
1637
|
response_format?: "json" | "text" | "verbose_json";
|
|
1540
1638
|
/**
|
|
1541
1639
|
* Deterministic seed for reproducible eval runs. Forward-compatible:
|
|
@@ -1607,7 +1705,7 @@ export class PyAI {
|
|
|
1607
1705
|
clones = {
|
|
1608
1706
|
/** List the org's cloned voices. */
|
|
1609
1707
|
list: (): Promise<ListResponse<Voice>> => this.getJson("/v1/voice/clones"),
|
|
1610
|
-
/** Enroll a custom voice from reference audio (>= ~10s). Scope `
|
|
1708
|
+
/** Enroll a custom voice from reference audio (>= ~10s). Scope `speak:clone`. */
|
|
1611
1709
|
create: async (params: { name: string; file: Blob; filename?: string }): Promise<Voice> => {
|
|
1612
1710
|
const form = new FormData();
|
|
1613
1711
|
form.set("name", params.name);
|
|
@@ -1626,7 +1724,7 @@ export class PyAI {
|
|
|
1626
1724
|
if (!match) throw new PyAIError(404, `No cloned voice ${id}`, ErrorCode.NotFound);
|
|
1627
1725
|
return match;
|
|
1628
1726
|
},
|
|
1629
|
-
/** Delete a cloned voice (tenant-isolated). Scope `
|
|
1727
|
+
/** Delete a cloned voice (tenant-isolated). Scope `speak:clone`. */
|
|
1630
1728
|
delete: async (id: string): Promise<void> => {
|
|
1631
1729
|
await this.deleteReq(`/v1/voice/clones/${encodeURIComponent(id)}`);
|
|
1632
1730
|
},
|
|
@@ -1847,7 +1945,7 @@ export class PyAI {
|
|
|
1847
1945
|
const query: Record<string, string> = { ...(opts.query ?? {}) };
|
|
1848
1946
|
if (opts.format) query.format = opts.format;
|
|
1849
1947
|
if (opts.rate) query.rate = String(opts.rate);
|
|
1850
|
-
const url = this.realtimeURL({
|
|
1948
|
+
const url = this.realtimeURL({ sessionLabel: opts.sessionLabel, query });
|
|
1851
1949
|
const sub = opts.token ? `pyai-key.${opts.token}` : this.realtimeSubprotocol();
|
|
1852
1950
|
return new OmniConnection(url, sub, opts);
|
|
1853
1951
|
},
|
|
@@ -1855,24 +1953,24 @@ export class PyAI {
|
|
|
1855
1953
|
|
|
1856
1954
|
// --- realtime (WebSocket) ----------------------------------------------
|
|
1857
1955
|
|
|
1858
|
-
/** Build the
|
|
1956
|
+
/** Build the canonical Omni WebSocket URL. */
|
|
1859
1957
|
realtimeURL(opts: RealtimeOptions = {}): string {
|
|
1860
1958
|
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
1861
1959
|
const q = new URLSearchParams(opts.query ?? {});
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
if (!q.has("rate")) q.set("rate", "24000");
|
|
1871
|
-
const qs = q.toString();
|
|
1872
|
-
return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
|
|
1960
|
+
// format/rate are load-bearing on the connect URL, so default to
|
|
1961
|
+
// browser-grade PCM16/24kHz.
|
|
1962
|
+
for (const key of ["agent", "agent_id", "agentId", "model", "access_token"]) {
|
|
1963
|
+
if (q.has(key)) {
|
|
1964
|
+
throw new Error(
|
|
1965
|
+
`Omni query parameter "${key}" is not supported; use sessionLabel, format/rate, or api_key`,
|
|
1966
|
+
);
|
|
1967
|
+
}
|
|
1873
1968
|
}
|
|
1874
|
-
q.set("
|
|
1875
|
-
|
|
1969
|
+
if (opts.sessionLabel) q.set("session_label", opts.sessionLabel);
|
|
1970
|
+
if (!q.has("format")) q.set("format", "pcm16");
|
|
1971
|
+
if (!q.has("rate")) q.set("rate", "24000");
|
|
1972
|
+
const qs = q.toString();
|
|
1973
|
+
return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
|
|
1876
1974
|
}
|
|
1877
1975
|
|
|
1878
1976
|
/** The subprotocol that carries the key on a WS upgrade (browser-safe auth). */
|
|
@@ -1884,6 +1982,7 @@ export class PyAI {
|
|
|
1884
1982
|
hearStreamURL(opts: HearStreamOptions = {}): string {
|
|
1885
1983
|
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
1886
1984
|
const q = new URLSearchParams(opts.query ?? {});
|
|
1985
|
+
q.set("protocol", "pyai-hear-v1");
|
|
1887
1986
|
if (opts.model) q.set("model", opts.model);
|
|
1888
1987
|
if (opts.language) q.set("language", opts.language);
|
|
1889
1988
|
if (opts.sampleRate !== undefined) q.set("sample_rate", String(opts.sampleRate));
|