@pyai/sdk 0.2.2 → 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,6 +642,88 @@ export interface OmniServerFrame {
586
642
  [k: string]: unknown;
587
643
  }
588
644
 
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. */
648
+ export interface OmniTranscriptFrame extends OmniServerFrame {
649
+ event: "transcript";
650
+ role: "user" | "assistant";
651
+ text: string;
652
+ final: boolean;
653
+ /** Live text frames append; legacy JSON frames replace unless they use `delta`. */
654
+ mode: "delta" | "replace";
655
+ /** Optional ordering hint on legacy JSON frames. */
656
+ sequence?: number;
657
+ }
658
+
659
+ const OMNI_TRANSCRIPT_MAX_BYTES = 16_384;
660
+ const OMNI_TRANSCRIPT_MAX_CHARS = 4_000;
661
+
662
+ function omniTranscriptText(value: unknown): string | null {
663
+ return typeof value === "string"
664
+ && value.length > 0
665
+ && value.length <= OMNI_TRANSCRIPT_MAX_CHARS
666
+ && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
667
+ ? value
668
+ : null;
669
+ }
670
+
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. */
678
+ export function normalizeOmniTranscriptBody(bytes: Uint8Array): OmniTranscriptFrame | null {
679
+ if (bytes.byteLength === 0 || bytes.byteLength > OMNI_TRANSCRIPT_MAX_BYTES) return null;
680
+ let decoded: string;
681
+ try {
682
+ decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
683
+ } catch {
684
+ return null;
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.
688
+ if (!decoded.trimStart().startsWith("{")) {
689
+ const text = omniTranscriptText(decoded);
690
+ return text
691
+ ? { event: "transcript", role: "user", text, final: false, mode: "delta" }
692
+ : null;
693
+ }
694
+
695
+ // Keep accepting a bounded direct object for older bridges and recordings.
696
+ let value: unknown;
697
+ try {
698
+ value = JSON.parse(decoded);
699
+ } catch {
700
+ return null;
701
+ }
702
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
703
+ const payload = value as Record<string, unknown>;
704
+ if (payload.type !== undefined
705
+ || (payload.event !== undefined && payload.event !== "transcript")) return null;
706
+ const role = omniTranscriptRole(payload.role ?? payload.speaker);
707
+ const mode = typeof payload.delta === "string" ? "delta" : "replace";
708
+ const text = omniTranscriptText(
709
+ mode === "delta" ? payload.delta : typeof payload.text === "string" ? payload.text : payload.transcript,
710
+ );
711
+ if (!role || !text) return null;
712
+ if (payload.final !== undefined && typeof payload.final !== "boolean") return null;
713
+ if (payload.sequence !== undefined
714
+ && (typeof payload.sequence !== "number"
715
+ || !Number.isSafeInteger(payload.sequence)
716
+ || payload.sequence < 0)) return null;
717
+ return {
718
+ event: "transcript",
719
+ role,
720
+ text,
721
+ final: payload.final === true,
722
+ mode,
723
+ ...(payload.sequence === undefined ? {} : { sequence: payload.sequence as number }),
724
+ };
725
+ }
726
+
589
727
  /** A binary agent-audio chunk delivered to {@link OmniConnectOptions.onAudio}. */
590
728
  export type OmniAudioChunk = ArrayBuffer | ArrayBufferView | Blob;
591
729
 
@@ -629,7 +767,7 @@ export interface OmniToolDef {
629
767
  }
630
768
 
631
769
  export interface OmniToolCallFrame {
632
- type: "tool_call";
770
+ event: "tool_call";
633
771
  call_id: string;
634
772
  name: string;
635
773
  arguments?: Record<string, unknown>;
@@ -655,7 +793,7 @@ export interface OmniConfigure {
655
793
  /**
656
794
  * Session language, end to end (recognition, reasoning, voice). Also
657
795
  * settable on the agent profile (`language` on `POST /v1/agents`), which
658
- * applies automatically when connecting with `session_label={agent_id}`;
796
+ * applies automatically when `session_label` is the saved profile id;
659
797
  * an inline value here wins for the session. Default `en`. Fail-safe: an
660
798
  * unknown/not-yet-enabled language falls back to `en` (the `configured`
661
799
  * ack carries `language_active` + `language_fallback: true`), the call
@@ -678,7 +816,11 @@ export interface OmniConnectOptions {
678
816
  * so a page never holds a secret key.
679
817
  */
680
818
  token?: string;
681
- /** 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
+ */
682
824
  rate?: 24000 | 16000 | 8000;
683
825
  /** Connect-URL audio format. Default `pcm16`. */
684
826
  format?: "pcm16";
@@ -701,7 +843,7 @@ export interface OmniConnectOptions {
701
843
  onSessionStarted?: (frame: OmniServerFrame) => void;
702
844
  /** Fired on `turn` boundaries. */
703
845
  onTurn?: (frame: OmniServerFrame) => void;
704
- /** Fired on `transcript` text frames. */
846
+ /** Fired for each normalized caller-transcript delta (`0x02` plain UTF-8 live). */
705
847
  onTranscript?: (frame: OmniServerFrame) => void;
706
848
  /** Fired on `barge_in` / `flush` (user interrupted). */
707
849
  onBargeIn?: (frame: OmniServerFrame) => void;
@@ -741,6 +883,8 @@ export class OmniConnection {
741
883
  private closed = false;
742
884
  /** Serializes async Blob reads in {@link sendAudio} so frames stay ordered. */
743
885
  private blobTail: Promise<void> = Promise.resolve();
886
+ /** Serializes inbound Blob decoding so browser frames stay ordered. */
887
+ private inboundTail: Promise<void> = Promise.resolve();
744
888
 
745
889
  constructor(url: string, subprotocol: string, opts: OmniConnectOptions) {
746
890
  this.opts = opts;
@@ -761,7 +905,16 @@ export class OmniConnection {
761
905
  }
762
906
  opts.onOpen?.();
763
907
  };
764
- this.ws.onmessage = (ev) => this.handleMessage(ev.data);
908
+ this.ws.onmessage = (ev) => {
909
+ const reportDecodeError = (error: unknown) => {
910
+ opts.onError?.(error instanceof Error ? error : new Error("Could not decode Omni frame"));
911
+ };
912
+ if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
913
+ this.inboundTail = this.inboundTail.then(() => this.handleMessage(ev.data)).catch(reportDecodeError);
914
+ } else {
915
+ void this.handleMessage(ev.data).catch(reportDecodeError);
916
+ }
917
+ };
765
918
  this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
766
919
  this.ws.onclose = (ev) => {
767
920
  this.closed = true;
@@ -769,15 +922,17 @@ export class OmniConnection {
769
922
  };
770
923
  }
771
924
 
772
- private handleMessage(data: unknown): void {
925
+ private async handleMessage(data: unknown): Promise<void> {
773
926
  // Server → client binary frames are TYPE-TAGGED by their first byte:
774
- // 0x01 = agent audio (PCM16) · 0x02 = transcript JSON · 0x03 = control JSON.
927
+ // 0x01 = agent audio (PCM16) · 0x02 = transcript UTF-8 · 0x03 = control JSON.
775
928
  // (Treating every binary frame as audio, the old behavior, plays the
776
929
  // 0x03/0x02 frames as a glitch and drops every event/transcript.)
777
930
  if (typeof data !== "string") {
778
- const bytes = omniToBytes(data);
931
+ const bytes = typeof Blob !== "undefined" && data instanceof Blob
932
+ ? new Uint8Array(await data.arrayBuffer())
933
+ : omniToBytes(data);
779
934
  if (!bytes) {
780
- this.opts.onAudio?.(data as OmniAudioChunk); // Blob/unknown, best-effort
935
+ this.opts.onError?.(new Error("Unsupported Omni binary frame"));
781
936
  return;
782
937
  }
783
938
  const tag = bytes[0];
@@ -785,36 +940,39 @@ export class OmniConnection {
785
940
  this.opts.onAudio?.(bytes.slice(1) as OmniAudioChunk); // copy → aligned PCM16
786
941
  return;
787
942
  }
788
- if (tag === 0x02 || tag === 0x03) {
943
+ if (tag === 0x02) {
944
+ const transcript = normalizeOmniTranscriptBody(bytes.subarray(1));
945
+ if (transcript) this.dispatchFrame(transcript);
946
+ else this.opts.onError?.(new Error("Unparseable Omni transcript frame"));
947
+ return;
948
+ }
949
+ if (tag === 0x03) {
789
950
  try {
790
- const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1))) as OmniServerFrame;
791
- // The 0x02 tag is authoritative for transcript even if the JSON omits `event`.
792
- this.dispatchFrame(tag === 0x02 ? { ...parsed, event: "transcript" } : parsed);
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
+ }
956
+ this.dispatchFrame(parsed);
793
957
  } catch {
794
958
  this.opts.onError?.(new Error("Unparseable Omni binary frame"));
795
959
  }
796
960
  return;
797
961
  }
798
- this.opts.onAudio?.(data as OmniAudioChunk); // untagged, forward-compat as audio
962
+ const tagName = tag === undefined ? "empty" : `0x${tag.toString(16).padStart(2, "0")}`;
963
+ this.opts.onError?.(new Error(`Ignored unknown Omni binary frame tag ${tagName}`));
799
964
  return;
800
965
  }
801
- // Text frame (e.g. a server-side broker relay).
802
- try {
803
- this.dispatchFrame(JSON.parse(data) as OmniServerFrame);
804
- } catch {
805
- this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
806
- }
966
+ this.opts.onError?.(new Error("Unexpected Omni text frame; server frames must use binary 0x01/0x02/0x03 tags"));
807
967
  }
808
968
 
809
969
  private dispatchFrame(frame: OmniServerFrame): void {
810
- const raw = frame as unknown as { event?: unknown; type?: unknown };
811
- const eventName =
812
- typeof raw.event === "string"
813
- ? raw.event
814
- : typeof raw.type === "string"
815
- ? raw.type
816
- : "";
817
- this.opts.onEvent?.({ ...frame, event: eventName });
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);
818
976
  switch (eventName) {
819
977
  case OmniEvent.Hello:
820
978
  this.opts.onHello?.(frame);
@@ -856,7 +1014,10 @@ export class OmniConnection {
856
1014
  * `{"event":"configure"}` is acked but silently dropped by the engine.)
857
1015
  */
858
1016
  configure(cfg: OmniConfigure): void {
859
- 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 }));
860
1021
  }
861
1022
 
862
1023
  /**
@@ -896,7 +1057,12 @@ export class OmniConnection {
896
1057
  * never `event`.
897
1058
  */
898
1059
  send(frame: Record<string, unknown>): void {
899
- 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));
900
1066
  }
901
1067
 
902
1068
  /** Close the session. */
@@ -1210,19 +1376,23 @@ export interface RecapCallTriggerInput {
1210
1376
 
1211
1377
  // --- AMD (answering-machine detection) -----------------------------------
1212
1378
 
1213
- /** 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
+ */
1214
1383
  export type AmdAnsweredBy =
1215
1384
  | "human"
1385
+ | "machine"
1216
1386
  | "voicemail"
1217
- | "live_voicemail"
1218
1387
  | "screening" // iPhone / Google Call Screen
1219
1388
  | "ivr"
1220
- | "human_gatekeeper"
1389
+ | "music" // hold music
1221
1390
  | "sit_invalid" // dead / disconnected number
1222
- | "fax"
1223
- | "silence"
1224
1391
  | "unknown";
1225
1392
 
1393
+ /** The routing classes pushed on the mid-call wire event (`event: "amd"`). */
1394
+ export type AmdWireAnsweredBy = "human" | "machine" | "sit_invalid" | "unknown";
1395
+
1226
1396
  /** Twilio's `AnsweredBy` enum, echoed for drop-in migration parity. */
1227
1397
  export type AmdTwilioAnsweredBy =
1228
1398
  | "human"
@@ -1272,11 +1442,16 @@ export interface AmdCall extends AmdCallSummary {
1272
1442
  error?: string | null;
1273
1443
  }
1274
1444
 
1275
- /** 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
+ */
1276
1451
  export interface AmdDecisionEvent {
1277
1452
  event?: "amd";
1278
1453
  call_id?: string;
1279
- answered_by?: AmdAnsweredBy;
1454
+ answered_by?: AmdWireAnsweredBy;
1280
1455
  answered_by_twilio?: string | null;
1281
1456
  confidence?: number | null;
1282
1457
  decision_ms?: number | null;
@@ -1399,14 +1574,22 @@ export class PyAI {
1399
1574
  // --- voices -------------------------------------------------------------
1400
1575
 
1401
1576
  voices = {
1402
- list: (params: { gender?: string; region?: string } = {}): Promise<ListResponse<Voice>> => {
1577
+ list: async (params: { gender?: string; region?: string } = {}): Promise<ListResponse<Voice>> => {
1403
1578
  const q = new URLSearchParams();
1404
1579
  if (params.gender) q.set("gender", params.gender);
1405
1580
  if (params.region) q.set("region", params.region);
1406
1581
  const qs = q.toString();
1407
- 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) };
1408
1586
  },
1409
- 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
+ ),
1410
1593
  };
1411
1594
 
1412
1595
  // --- audio --------------------------------------------------------------
@@ -1414,10 +1597,11 @@ export class PyAI {
1414
1597
  audio = {
1415
1598
  /** Text-to-speech. Returns the raw audio bytes (default WAV). */
1416
1599
  speech: async (params: SpeechParams): Promise<ArrayBuffer> => {
1600
+ assertActiveSpeechParams(params);
1417
1601
  const res = await this.request("/v1/audio/speech", {
1418
1602
  method: "POST",
1419
1603
  headers: this.authHeaders({ "Content-Type": "application/json" }),
1420
- body: JSON.stringify({ model: "pyai-voice", ...params }),
1604
+ body: JSON.stringify({ model: "pyai-speak", ...params }),
1421
1605
  });
1422
1606
  return res.arrayBuffer();
1423
1607
  },
@@ -1430,10 +1614,11 @@ export class PyAI {
1430
1614
  * iterable of Uint8Array chunks.
1431
1615
  */
1432
1616
  speechStream: async (params: SpeechParams): Promise<ReadableStream<Uint8Array>> => {
1617
+ assertActiveSpeechParams(params);
1433
1618
  const res = await this.request("/v1/audio/speech", {
1434
1619
  method: "POST",
1435
1620
  headers: this.authHeaders({ "Content-Type": "application/json" }),
1436
- body: JSON.stringify({ model: "pyai-voice", ...params, stream: true }),
1621
+ body: JSON.stringify({ model: "pyai-speak", ...params, stream: true }),
1437
1622
  });
1438
1623
  if (!res.body) throw new PyAIError(res.status, "Response had no body to stream");
1439
1624
  return res.body as ReadableStream<Uint8Array>;
@@ -1444,7 +1629,11 @@ export class PyAI {
1444
1629
  file: Blob;
1445
1630
  filename?: string;
1446
1631
  model?: string;
1447
- 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";
1448
1637
  response_format?: "json" | "text" | "verbose_json";
1449
1638
  /**
1450
1639
  * Deterministic seed for reproducible eval runs. Forward-compatible:
@@ -1516,7 +1705,7 @@ export class PyAI {
1516
1705
  clones = {
1517
1706
  /** List the org's cloned voices. */
1518
1707
  list: (): Promise<ListResponse<Voice>> => this.getJson("/v1/voice/clones"),
1519
- /** Enroll a custom voice from reference audio (>= ~10s). Scope `voice:clone`. */
1708
+ /** Enroll a custom voice from reference audio (>= ~10s). Scope `speak:clone`. */
1520
1709
  create: async (params: { name: string; file: Blob; filename?: string }): Promise<Voice> => {
1521
1710
  const form = new FormData();
1522
1711
  form.set("name", params.name);
@@ -1535,7 +1724,7 @@ export class PyAI {
1535
1724
  if (!match) throw new PyAIError(404, `No cloned voice ${id}`, ErrorCode.NotFound);
1536
1725
  return match;
1537
1726
  },
1538
- /** Delete a cloned voice (tenant-isolated). Scope `voice:clone`. */
1727
+ /** Delete a cloned voice (tenant-isolated). Scope `speak:clone`. */
1539
1728
  delete: async (id: string): Promise<void> => {
1540
1729
  await this.deleteReq(`/v1/voice/clones/${encodeURIComponent(id)}`);
1541
1730
  },
@@ -1756,7 +1945,7 @@ export class PyAI {
1756
1945
  const query: Record<string, string> = { ...(opts.query ?? {}) };
1757
1946
  if (opts.format) query.format = opts.format;
1758
1947
  if (opts.rate) query.rate = String(opts.rate);
1759
- const url = this.realtimeURL({ product: "omni", sessionLabel: opts.sessionLabel, query });
1948
+ const url = this.realtimeURL({ sessionLabel: opts.sessionLabel, query });
1760
1949
  const sub = opts.token ? `pyai-key.${opts.token}` : this.realtimeSubprotocol();
1761
1950
  return new OmniConnection(url, sub, opts);
1762
1951
  },
@@ -1764,24 +1953,24 @@ export class PyAI {
1764
1953
 
1765
1954
  // --- realtime (WebSocket) ----------------------------------------------
1766
1955
 
1767
- /** Build the realtime WebSocket URL for the chosen product. */
1956
+ /** Build the canonical Omni WebSocket URL. */
1768
1957
  realtimeURL(opts: RealtimeOptions = {}): string {
1769
1958
  const wsBase = this.baseURL.replace(/^http/, "ws");
1770
1959
  const q = new URLSearchParams(opts.query ?? {});
1771
- if ((opts.product ?? "omni") === "omni") {
1772
- // Omni's native realtime surface is /v1/omni. The session is authorized by
1773
- // the key's org (zero-state), sessionLabel is an optional opaque tag.
1774
- // format/rate are load-bearing on the connect URL, so default to
1775
- // browser-grade PCM16/24kHz.
1776
- if (opts.sessionLabel) q.set("session_label", opts.sessionLabel);
1777
- else if (opts.agentId) q.set("agent_id", opts.agentId); // deprecated alias
1778
- if (!q.has("format")) q.set("format", "pcm16");
1779
- if (!q.has("rate")) q.set("rate", "24000");
1780
- const qs = q.toString();
1781
- 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
+ }
1782
1968
  }
1783
- q.set("model", "pyai-flow-realtime");
1784
- 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}` : ""}`;
1785
1974
  }
1786
1975
 
1787
1976
  /** The subprotocol that carries the key on a WS upgrade (browser-safe auth). */
@@ -1793,6 +1982,7 @@ export class PyAI {
1793
1982
  hearStreamURL(opts: HearStreamOptions = {}): string {
1794
1983
  const wsBase = this.baseURL.replace(/^http/, "ws");
1795
1984
  const q = new URLSearchParams(opts.query ?? {});
1985
+ q.set("protocol", "pyai-hear-v1");
1796
1986
  if (opts.model) q.set("model", opts.model);
1797
1987
  if (opts.language) q.set("language", opts.language);
1798
1988
  if (opts.sampleRate !== undefined) q.set("sample_rate", String(opts.sampleRate));