@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/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
- * `mp3`. Omit `sample_rate` for the engine's native 24 kHz (`g711_*` is always
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
- * | `mp3` (default) | 8000/16000/24000/48000 | `audio/mpeg` |
71
- * | `wav` | 8000/16000/24000/48000 | `audio/wav` |
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?: string;
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 `mp3`.
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
- * Deterministic sampling seed for reproducible eval runs. Forward-compatible:
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
- * Sampling temperature (lower = more deterministic). Forward-compatible, * honored once the engine supports it, otherwise ignored.
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
- * @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.
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
- InvalidAgentId: "invalid_agent_id",
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.requests` or `cue.minutes`). */
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 = HearPartialFrame | HearFinalFrame | HearUsageFrame | HearErrorFrame;
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
- /** ISO-639-1 hint, e.g. "en". */
328
- language?: string;
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
- * 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.
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
- * Enable Cue knowledge-base grounding: sends `{type:"config",grounding:true}`
350
- * on open, after which `speech_final`/`final` frames carry a `grounding`
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 (after the optional grounding config). */
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, force-finalize with {@link HearStream.commit}, and flush+close
383
- * with {@link HearStream.close}. Construct via `pyai.audio.transcriptions.stream()`.
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
- /** Normalize the live UTF-8 0x02 body and the documented direct-object legacy shape. */
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
- type: "tool_call";
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 connecting with `session_label={agent_id}`;
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
- /** Connect-URL sample rate: 24000 browser, 16000 wideband telephony, 8000 G.711/Twilio. */
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 on `transcript` text frames. */
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
- // Text frame (e.g. a server-side broker relay).
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 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 });
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
- this.ws.send(omniControlFrame({ type: "configure", ...cfg }));
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
- this.ws.send(omniControlFrame(frame));
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
- /** PyAI's richer answered-by vocabulary (superset of Twilio's enum). */
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
- | "human_gatekeeper"
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
- /** A mid-call AMD decision event pushed on the stream (and to the webhook). */
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?: AmdAnsweredBy;
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
- return this.getJson(`/v1/voices${qs ? `?${qs}` : ""}`);
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> => this.getJson(`/v1/voices/${encodeURIComponent(id)}`),
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-voice", ...params }),
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-voice", ...params, stream: true }),
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
- language?: string;
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 `voice:clone`. */
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 `voice:clone`. */
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({ product: "omni", sessionLabel: opts.sessionLabel, query });
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 realtime WebSocket URL for the chosen product. */
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
- if ((opts.product ?? "omni") === "omni") {
1863
- // Omni's native realtime surface is /v1/omni. The session is authorized by
1864
- // the key's org (zero-state), sessionLabel is an optional opaque tag.
1865
- // format/rate are load-bearing on the connect URL, so default to
1866
- // browser-grade PCM16/24kHz.
1867
- if (opts.sessionLabel) q.set("session_label", opts.sessionLabel);
1868
- else if (opts.agentId) q.set("agent_id", opts.agentId); // deprecated alias
1869
- if (!q.has("format")) q.set("format", "pcm16");
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("model", "pyai-flow-realtime");
1875
- return `${wsBase}/v1/realtime?${q.toString()}`;
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));