@pyai/sdk 0.2.3 → 0.4.0

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,13 +34,35 @@ 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;
40
43
  region?: string;
44
+ language?: string;
45
+ /** Customer-facing quality tier. */
46
+ tier?: "standard" | "natural";
47
+ /** Permanent convenience inputs accepted on the advertised surfaces. */
48
+ aliases?: string[];
49
+ /** Product surfaces on which this stock voice can be selected. */
50
+ available_on?: Array<"speak" | "omni">;
51
+ /** Accepted Speak delivery modes. Empty means the voice has no Speak surface. */
52
+ synthesis_modes?: Array<"streaming" | "async">;
53
+ /** Voice-specific amount on top of the selected product's base rate. */
54
+ pricing?: {
55
+ included_in_base_price: boolean;
56
+ additional_price_usd_per_minute: number;
57
+ };
41
58
  [k: string]: unknown;
42
59
  }
43
60
 
61
+ function normalizeVoice(value: Record<string, unknown>): Voice {
62
+ const voiceId = String(value.voice_id ?? value.id ?? "");
63
+ return { ...value, voice_id: voiceId, id: voiceId } as Voice;
64
+ }
65
+
44
66
  export interface ListResponse<T> {
45
67
  object: "list";
46
68
  data: T[];
@@ -62,13 +84,13 @@ export interface TranscriptionJob {
62
84
  * Output container/codec for `audio.speech`. This is the **exact** set the
63
85
  * server accepts on `POST /v1/audio/speech`, any other value is rejected with
64
86
  * `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
87
+ * `wav`. Omit `sample_rate` for the engine's native 24 kHz (`g711_*` is always
66
88
  * 8 kHz).
67
89
  *
68
90
  * | format | rates (Hz) | Content-Type |
69
91
  * |---|---|---|
70
- * | `mp3` (default) | 8000/16000/24000/48000 | `audio/mpeg` |
71
- * | `wav` | 8000/16000/24000/48000 | `audio/wav` |
92
+ * | `wav` (default) | 8000/16000/24000/48000 | `audio/wav` |
93
+ * | `mp3` | 8000/16000/24000/48000 | `audio/mpeg` |
72
94
  * | `opus` | 8000/16000/24000/48000 | `audio/ogg` |
73
95
  * | `aac` | 8000/16000/24000/48000 | `audio/aac` |
74
96
  * | `flac` | 8000/16000/24000/48000 | `audio/flac` |
@@ -89,14 +111,24 @@ export const SPEECH_FORMATS = ["wav", "mp3", "opus", "aac", "flac", "pcm", "g711
89
111
  export const SPEECH_SAMPLE_RATES = [8000, 16000, 24000, 48000] as const;
90
112
  export type SpeechSampleRate = (typeof SPEECH_SAMPLE_RATES)[number];
91
113
 
114
+ /** Canonical Speak model plus the intentional OpenAI drop-in aliases. */
115
+ export type SpeakModel = "pyai-speak" | "tts-1" | "tts-1-hd";
116
+
92
117
  export interface SpeechParams {
93
118
  input: string;
94
119
  voice?: string;
95
- model?: string;
120
+ model?: SpeakModel;
121
+ /**
122
+ * Delivery mode. The API defaults to true and every catalog voice accepts
123
+ * both values. Set false when you want one complete buffered body with a
124
+ * `Content-Length`. A voice whose serving fleet has no streaming lane is
125
+ * rendered buffered either way and says so with `x-pyai-stream: buffered`.
126
+ */
127
+ stream?: boolean;
96
128
  /**
97
129
  * Output container/codec, resampled+encoded server-side. One of
98
130
  * {@link SpeechFormat}, anything else is a `400 unsupported_format`. Omit for
99
- * the default of `mp3`.
131
+ * the default of `wav`.
100
132
  *
101
133
  * `g711_ulaw`/`g711_alaw` return raw 8 kHz mono G.711, the bytes Twilio/SIP
102
134
  * media streams expect, so you can hand the response straight to a telephony
@@ -113,32 +145,91 @@ export interface SpeechParams {
113
145
  * telephony pipelines, most often with `response_format: "pcm"`.
114
146
  */
115
147
  sample_rate?: SpeechSampleRate;
148
+ /** Reserved; currently returns `400 unsupported_parameter` when provided. */
116
149
  speed?: number;
117
150
  /**
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.
151
+ * Reserved; currently returns `400 unsupported_parameter` when provided.
121
152
  */
122
153
  seed?: number;
123
154
  /**
124
- * Sampling temperature (lower = more deterministic). Forward-compatible, * honored once the engine supports it, otherwise ignored.
155
+ * Reserved; currently returns `400 unsupported_parameter` when provided.
125
156
  */
126
157
  temperature?: number;
127
158
  }
128
159
 
160
+ function assertActiveSpeechParams(params: SpeechParams): void {
161
+ for (const field of ["speed", "seed", "temperature"] as const) {
162
+ if (params[field] !== undefined) {
163
+ throw new Error(`${field} is reserved but not active on Speak`);
164
+ }
165
+ }
166
+ }
167
+
129
168
  export interface CreateJobParams {
130
169
  audio_url: string;
131
170
  model?: string;
132
171
  diarize?: boolean;
133
172
  channel?: boolean;
134
173
  numerals?: boolean;
174
+ smart_format?: boolean;
175
+ dictation?: boolean;
176
+ drop_fillers?: boolean;
177
+ /**
178
+ * Per-job names, brands, products, or distinctive terms. PyAI keeps up to
179
+ * five valid entries after trimming, deduplication, and common-word
180
+ * filtering.
181
+ */
182
+ vocabulary?: string[];
135
183
  output_formats?: Array<"json" | "srt" | "vtt">;
136
184
  webhook_url?: string;
185
+ call_id?: string;
186
+ pack_id?: string;
187
+ call_direction?: "inbound" | "outbound";
188
+ customer_name?: string;
189
+ }
190
+
191
+ export type HearVocabularyProfile = "batch" | "hear_stream";
192
+
193
+ export interface HearVocabularyInput {
194
+ /** Organization-owned names, brands, products, or distinctive terms. */
195
+ terms: string[];
196
+ /** Stored terms remain inert unless a use case is selected here. */
197
+ enabledFor: HearVocabularyProfile[];
198
+ }
199
+
200
+ export interface HearVocabulary {
201
+ object: "hear.vocabulary";
202
+ /** Sanitized stored list, at most five terms. */
203
+ terms: string[];
204
+ /** Use cases that may add stored suggestions. */
205
+ enabled_for: HearVocabularyProfile[];
206
+ /** Unix milliseconds, or null before the first save. */
207
+ updated_at: number | null;
208
+ }
209
+
210
+ export interface AgentConfig {
211
+ name?: string;
212
+ persona_system_prompt?: string | null;
213
+ greeting?: string | null;
214
+ voice_id?: string | null;
215
+ language?: "en" | "fr" | "es" | "de" | "hi" | null;
216
+ /**
217
+ * Opt-in speech-recognition vocabulary. PyAI sanitizes and keeps at most
218
+ * five terms. An empty list or null turns it off.
219
+ */
220
+ vocabulary?: string[] | null;
221
+ [key: string]: unknown;
222
+ }
223
+
224
+ export interface Agent {
225
+ object: "agent";
226
+ agent_id: string;
227
+ name: string;
228
+ vocabulary: string[];
229
+ [key: string]: unknown;
137
230
  }
138
231
 
139
232
  export interface RealtimeOptions {
140
- /** "omni" (agentic voice) or "flow" (legacy voice duplex). Default "omni". */
141
- product?: "omni" | "flow";
142
233
  /**
143
234
  * Optional opaque tag echoed to your `kb_endpoint` and recorded on the call.
144
235
  * Omni is zero-state: the session is authorized by the key's org, so there is
@@ -146,12 +237,10 @@ export interface RealtimeOptions {
146
237
  */
147
238
  sessionLabel?: string;
148
239
  /**
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.
240
+ * Extra canonical query params (`format`, `rate`, or the intentional
241
+ * server-side `api_key` auth option). Retired connect aliases and model/token
242
+ * selectors are rejected.
152
243
  */
153
- agentId?: string;
154
- /** Extra query params (e.g. format, rate). */
155
244
  query?: Record<string, string>;
156
245
  }
157
246
 
@@ -195,6 +284,8 @@ export interface OmniSession {
195
284
 
196
285
  /** Frame `type`s emitted by the Hear streaming-STT WebSocket. */
197
286
  export const HearFrameType = {
287
+ /** Applied endpointing configuration and validation warnings. */
288
+ ConfigAck: "config_ack",
198
289
  /** Eager live hypothesis for the current utterance. */
199
290
  Partial: "partial",
200
291
  /** Partial whose prefix has stabilized (won't be revised). */
@@ -210,10 +301,16 @@ export const HearFrameType = {
210
301
  } as const;
211
302
  export type HearFrameType = (typeof HearFrameType)[keyof typeof HearFrameType];
212
303
 
213
- /** WebSocket close codes used across the PyAI realtime/streaming surfaces. */
304
+ /**
305
+ * WebSocket close codes used across the PyAI realtime/streaming surfaces.
306
+ * Browser callers may initiate only Normal or values in 3000-4999; the
307
+ * standards-reserved values below are server close observations.
308
+ */
214
309
  export const WSCloseCode = {
215
310
  /** Normal closure. */
216
311
  Normal: 1000,
312
+ /** Browser-safe private application code for a malformed peer protocol frame. */
313
+ ProtocolViolation: 4002,
217
314
  /** Auth/policy: bad key, missing scope, or revoked token. */
218
315
  PolicyViolation: 1008,
219
316
  /** Engine/internal error. */
@@ -232,7 +329,7 @@ export const ErrorCode = {
232
329
  Unauthorized: "unauthorized",
233
330
  Forbidden: "forbidden",
234
331
  OriginNotAllowed: "origin_not_allowed",
235
- InvalidAgentId: "invalid_agent_id",
332
+ InvalidSessionLabel: "invalid_session_label",
236
333
  CreditExhausted: "credit_exhausted",
237
334
  KeyBudgetExceeded: "key_budget_exceeded",
238
335
  InsufficientQuota: "insufficient_quota",
@@ -242,6 +339,7 @@ export const ErrorCode = {
242
339
  IdempotencyConflict: "idempotency_conflict",
243
340
  NotFound: "not_found",
244
341
  NumberInUse: "number_in_use",
342
+ UnsupportedToolTransport: "unsupported_tool_transport",
245
343
  } as const;
246
344
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
247
345
 
@@ -255,6 +353,24 @@ export interface HearGroundingPassage {
255
353
  score: number;
256
354
  }
257
355
 
356
+ /** One endpointing validation result echoed in a `config_ack` frame. */
357
+ export interface HearConfigWarning {
358
+ field: string;
359
+ value: unknown;
360
+ effective?: number;
361
+ reason: "clamped_to_range" | "not_a_number" | "unknown_config_field" | (string & {});
362
+ }
363
+
364
+ /** Applied endpointing settings after connect-time or mid-session config. */
365
+ export interface HearConfigAckFrame {
366
+ type: "config_ack";
367
+ endpointing_ms: number;
368
+ effective_floor_ms: number;
369
+ effective_ceiling_ms: number;
370
+ score_interval_ms: number;
371
+ warnings: HearConfigWarning[];
372
+ }
373
+
258
374
  /** Live hypothesis frame (`partial` / `partial_stable`). */
259
375
  export interface HearPartialFrame {
260
376
  type: "partial" | "partial_stable";
@@ -269,6 +385,12 @@ export interface HearPartialFrame {
269
385
  }
270
386
 
271
387
  /** Finalized-utterance frame (`speech_final` / `final`). */
388
+ export type HearEndpointReason =
389
+ | "peak_te_early"
390
+ | "silence_backstop"
391
+ | "commit"
392
+ | (string & {});
393
+
272
394
  export interface HearFinalFrame {
273
395
  type: "speech_final" | "final";
274
396
  text: string;
@@ -276,6 +398,8 @@ export interface HearFinalFrame {
276
398
  t_ms: number;
277
399
  /** Active-speech length of the utterance (the billed signal), ms. */
278
400
  audio_ms: number;
401
+ /** Why the utterance ended. Log this when tuning automatic endpointing. */
402
+ endpoint_reason: HearEndpointReason;
279
403
  /** Present only with Cue grounding enabled (top KB passages). */
280
404
  grounding?: HearGroundingPassage[];
281
405
  }
@@ -288,7 +412,7 @@ export interface HearUsageFrame {
288
412
  type: "usage";
289
413
  /** `hear` for plain streaming, `cue` when grounding was enabled. */
290
414
  product: "hear" | "cue";
291
- /** The billed meter (`hear.requests` or `cue.minutes`). */
415
+ /** The billed meter (`hear.minutes` or `cue.minutes`). */
292
416
  meter: string;
293
417
  /** Summed active-speech audio billed for the session, in seconds. */
294
418
  audio_seconds: number;
@@ -303,7 +427,12 @@ export interface HearErrorFrame {
303
427
  message: string;
304
428
  }
305
429
 
306
- export type HearFrame = HearPartialFrame | HearFinalFrame | HearUsageFrame | HearErrorFrame;
430
+ export type HearFrame =
431
+ | HearConfigAckFrame
432
+ | HearPartialFrame
433
+ | HearFinalFrame
434
+ | HearUsageFrame
435
+ | HearErrorFrame;
307
436
 
308
437
  /**
309
438
  * Minimal structural WebSocket, matches both the browser/Node global
@@ -319,36 +448,70 @@ export interface WebSocketLike {
319
448
  onclose: ((ev: { code: number; reason: string }) => void) | null;
320
449
  }
321
450
 
451
+ /**
452
+ * The non-secret marker every PyAI realtime client offers ALONGSIDE its
453
+ * `pyai-key.<KEY>` credential token.
454
+ *
455
+ * A browser cannot set `Authorization` on a WebSocket, so the key rides in
456
+ * `Sec-WebSocket-Protocol`. RFC 6455 makes the server echo the subprotocol it
457
+ * SELECTS, so a server that selects the credential publishes the credential —
458
+ * which api.pyai.com did until 2026-09-07. The edge now echoes only this
459
+ * marker. Offering it is not optional for Node clients: `ws` throws
460
+ * "Server sent no subprotocol" when it offered subprotocols and the 101
461
+ * selected none, and RFC 6455 lets the server select only a value the client
462
+ * actually offered.
463
+ */
464
+ export const REALTIME_SUBPROTOCOL_MARKER = "pyai.v1";
465
+
322
466
  export type WebSocketCtor = new (url: string, protocols?: string | string[]) => WebSocketLike;
323
467
 
324
468
  export interface HearStreamOptions {
325
469
  /** Streaming STT model. Server default `pyai-hear`. */
326
470
  model?: string;
327
- /** ISO-639-1 hint, e.g. "en". */
328
- language?: string;
471
+ /** Omit or use auto for automatic detection; an explicit code pins recognition. */
472
+ language?: "auto" | "en" | "es" | "fr" | "de" | "hi" | "it" | "pt" | "nl";
329
473
  /** Input PCM sample rate in Hz. Default 16000 server-side. */
330
474
  sampleRate?: number;
331
475
  /** Audio frame encoding. Default "pcm16". */
332
- encoding?: "pcm16" | "opus";
476
+ encoding?: "pcm16";
333
477
  /** Emit eager partial hypotheses. Default true server-side. */
334
478
  interimResults?: boolean;
335
479
  /**
336
- * Format spoken numbers as digits in the transcript (e.g. "one two three" →
337
- * "123"). Useful for voice agents that read back phone numbers, codes, and
338
- * amounts. Default false (spoken form). Forwards `?numerals=true` on the URL.
480
+ * Tri-state number formatting on **final** transcripts. `true` forces digits,
481
+ * `false` keeps spoken form, omitted keeps the live engine default (ITN on).
482
+ * Never applied to interim partials. Independent of `smartFormat`.
339
483
  */
340
484
  numerals?: boolean;
341
485
  /**
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.
486
+ * Opt-in English punctuation and sentence capitalization on **final**
487
+ * transcripts only. Interim partials are never formatted. Default false.
488
+ */
489
+ smartFormat?: boolean;
490
+ /**
491
+ * Spoken punctuation commands (`period`, `comma`, `new paragraph`,
492
+ * `question mark`) on finals only. Separate from `smartFormat`. Off by default.
493
+ */
494
+ dictation?: boolean;
495
+ /**
496
+ * Strip `um` / `uh` / `umm` / `uhh` / `er` on finals. Off by default.
497
+ * Do not enable on legal or compliance audio by default.
498
+ */
499
+ dropFillers?: boolean;
500
+ /**
501
+ * Per-session names, brands, products, or other distinctive terms. PyAI
502
+ * sanitizes up to five effective terms. Request terms come first, then stored
503
+ * `hear_stream` suggestions fill remaining slots. The list is fixed at open.
504
+ */
505
+ vocabulary?: string[];
506
+ /**
507
+ * Minimum trailing-pause length before an utterance may end (50-5000 ms).
508
+ * Turn detection may wait longer, bounded at `max(endpointingMs, 1500)`.
509
+ * The server confirms the applied value through `onConfigAck`.
346
510
  */
347
511
  endpointingMs?: number;
348
512
  /**
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.
513
+ * Reserved Cue grounding configuration. Grounding is not active on the
514
+ * serving Hear stream; do not rely on grounding frames or Cue metering yet.
352
515
  */
353
516
  grounding?: boolean;
354
517
  /** Cue: number of KB passages to retrieve per turn (1-20, default 3). */
@@ -360,8 +523,11 @@ export interface HearStreamOptions {
360
523
  groundingTimeoutMs?: number;
361
524
  /** Extra query params merged onto the connect URL. */
362
525
  query?: Record<string, string>;
363
- /** Fired once the socket opens (after the optional grounding config). */
526
+ /** Fired once the socket opens. */
364
527
  onOpen?: () => void;
528
+ /** Fired after connect-time or mid-session endpointing config. Assert that
529
+ * `warnings` is empty before relying on the requested floor. */
530
+ onConfigAck?: (frame: HearConfigAckFrame) => void;
365
531
  /** Fired on `partial` / `partial_stable`. */
366
532
  onPartial?: (frame: HearPartialFrame) => void;
367
533
  /** Fired on `speech_final` / `final`. */
@@ -379,8 +545,10 @@ export interface HearStreamOptions {
379
545
  /**
380
546
  * A live Hear streaming-STT session. Hides the frame protocol: stream audio
381
547
  * 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()`.
548
+ * callbacks, update the silence floor with
549
+ * {@link HearStream.configureEndpointing}, force-finalize with
550
+ * {@link HearStream.commit}, and flush+close with {@link HearStream.close}.
551
+ * Construct via `pyai.audio.transcriptions.stream()`.
384
552
  */
385
553
  export class HearStream {
386
554
  private readonly ws: WebSocketLike;
@@ -389,25 +557,19 @@ export class HearStream {
389
557
 
390
558
  constructor(url: string, subprotocol: string, opts: HearStreamOptions) {
391
559
  this.opts = opts;
560
+ if (opts.grounding) {
561
+ throw new Error(
562
+ "Cue grounding is not active on the serving Hear stream; omit grounding until the API reference marks it active",
563
+ );
564
+ }
392
565
  const WS = opts.webSocket ?? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket;
393
566
  if (!WS) {
394
567
  throw new Error(
395
568
  "No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to transcriptions.stream()",
396
569
  );
397
570
  }
398
- this.ws = new WS(url, [subprotocol]);
571
+ this.ws = new WS(url, [REALTIME_SUBPROTOCOL_MARKER, subprotocol]);
399
572
  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
573
  opts.onOpen?.();
412
574
  };
413
575
  this.ws.onmessage = (ev) => this.handleMessage(ev.data);
@@ -429,6 +591,9 @@ export class HearStream {
429
591
  return;
430
592
  }
431
593
  switch (frame.type) {
594
+ case HearFrameType.ConfigAck:
595
+ this.opts.onConfigAck?.(frame);
596
+ break;
432
597
  case HearFrameType.Partial:
433
598
  case HearFrameType.PartialStable:
434
599
  this.opts.onPartial?.(frame);
@@ -454,6 +619,11 @@ export class HearStream {
454
619
  this.ws.send(chunk);
455
620
  }
456
621
 
622
+ /** Change the minimum trailing-pause floor without reconnecting. */
623
+ configureEndpointing(endpointingMs: number): void {
624
+ this.ws.send(JSON.stringify({ type: "config", endpointing_ms: endpointingMs }));
625
+ }
626
+
457
627
  /** Force-finalize the current utterance (e.g. on VAD end-of-turn). */
458
628
  commit(): void {
459
629
  this.ws.send(JSON.stringify({ type: "commit" }));
@@ -497,7 +667,7 @@ export class AmdStream {
497
667
  "No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to amd.stream()",
498
668
  );
499
669
  }
500
- this.ws = new WS(url, [subprotocol]);
670
+ this.ws = new WS(url, [REALTIME_SUBPROTOCOL_MARKER, subprotocol]);
501
671
  this.ws.onopen = () => opts.onOpen?.();
502
672
  this.ws.onmessage = (ev) => this.handleMessage(ev.data);
503
673
  this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
@@ -573,6 +743,8 @@ export const OmniEvent = {
573
743
  Flush: "flush",
574
744
  /** Engine requests a client-loop tool invocation. */
575
745
  ToolCall: "tool_call",
746
+ /** A write tool is waiting for the caller to confirm; it has not run. */
747
+ ToolConfirmationRequired: "tool_confirmation_required",
576
748
  /** Session is closing; see close code. */
577
749
  SessionEnd: "session_end",
578
750
  /** Server fault frame. */
@@ -586,25 +758,23 @@ export interface OmniServerFrame {
586
758
  [k: string]: unknown;
587
759
  }
588
760
 
589
- /** Canonical, sanitized transcript delivered by the native Omni demux. */
761
+ /** Canonical, sanitized transcript delivered by the native Omni demux.
762
+ * The live wire sends caller text deltas; the JSON fields are SDK-owned
763
+ * normalization so applications don't have to special-case the byte payload. */
590
764
  export interface OmniTranscriptFrame extends OmniServerFrame {
591
765
  event: "transcript";
592
766
  role: "user" | "assistant";
593
767
  text: string;
594
768
  final: boolean;
769
+ /** Live text frames append; legacy JSON frames replace unless they use `delta`. */
595
770
  mode: "delta" | "replace";
771
+ /** Optional ordering hint on legacy JSON frames. */
596
772
  sequence?: number;
597
773
  }
598
774
 
599
775
  const OMNI_TRANSCRIPT_MAX_BYTES = 16_384;
600
776
  const OMNI_TRANSCRIPT_MAX_CHARS = 4_000;
601
777
 
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
778
  function omniTranscriptText(value: unknown): string | null {
609
779
  return typeof value === "string"
610
780
  && value.length > 0
@@ -614,7 +784,13 @@ function omniTranscriptText(value: unknown): string | null {
614
784
  : null;
615
785
  }
616
786
 
617
- /** Normalize the live UTF-8 0x02 body and the documented direct-object legacy shape. */
787
+ function omniTranscriptRole(value: unknown): "user" | "assistant" | null {
788
+ if (value === "user" || value === "caller" || value === "human") return "user";
789
+ if (value === "assistant" || value === "agent") return "assistant";
790
+ return null;
791
+ }
792
+
793
+ /** Normalize the live UTF-8 `0x02` text body and bounded legacy JSON bodies. */
618
794
  export function normalizeOmniTranscriptBody(bytes: Uint8Array): OmniTranscriptFrame | null {
619
795
  if (bytes.byteLength === 0 || bytes.byteLength > OMNI_TRANSCRIPT_MAX_BYTES) return null;
620
796
  let decoded: string;
@@ -623,12 +799,16 @@ export function normalizeOmniTranscriptBody(bytes: Uint8Array): OmniTranscriptFr
623
799
  } catch {
624
800
  return null;
625
801
  }
802
+ // The serving engine's canonical payload is plain UTF-8 caller text, not
803
+ // JSON. Each frame is a delta for the current caller turn.
626
804
  if (!decoded.trimStart().startsWith("{")) {
627
805
  const text = omniTranscriptText(decoded);
628
806
  return text
629
807
  ? { event: "transcript", role: "user", text, final: false, mode: "delta" }
630
808
  : null;
631
809
  }
810
+
811
+ // Keep accepting a bounded direct object for older bridges and recordings.
632
812
  let value: unknown;
633
813
  try {
634
814
  value = JSON.parse(decoded);
@@ -637,6 +817,8 @@ export function normalizeOmniTranscriptBody(bytes: Uint8Array): OmniTranscriptFr
637
817
  }
638
818
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
639
819
  const payload = value as Record<string, unknown>;
820
+ if (payload.type !== undefined
821
+ || (payload.event !== undefined && payload.event !== "transcript")) return null;
640
822
  const role = omniTranscriptRole(payload.role ?? payload.speaker);
641
823
  const mode = typeof payload.delta === "string" ? "delta" : "replace";
642
824
  const text = omniTranscriptText(
@@ -696,12 +878,10 @@ export interface OmniToolDef {
696
878
  name: string;
697
879
  description?: string;
698
880
  parameters?: Record<string, unknown>;
699
- /** When set, engine-POST mode. Omit for client-loop (default). */
700
- endpoint?: string;
701
881
  }
702
882
 
703
883
  export interface OmniToolCallFrame {
704
- type: "tool_call";
884
+ event: "tool_call";
705
885
  call_id: string;
706
886
  name: string;
707
887
  arguments?: Record<string, unknown>;
@@ -727,7 +907,7 @@ export interface OmniConfigure {
727
907
  /**
728
908
  * Session language, end to end (recognition, reasoning, voice). Also
729
909
  * settable on the agent profile (`language` on `POST /v1/agents`), which
730
- * applies automatically when connecting with `session_label={agent_id}`;
910
+ * applies automatically when `session_label` is the saved profile id;
731
911
  * an inline value here wins for the session. Default `en`. Fail-safe: an
732
912
  * unknown/not-yet-enabled language falls back to `en` (the `configured`
733
913
  * ack carries `language_active` + `language_fallback: true`), the call
@@ -735,7 +915,9 @@ export interface OmniConfigure {
735
915
  * staged, see the Language support reference.
736
916
  */
737
917
  language?: "en" | "fr" | "es" | "de" | "hi";
738
- /** Function calling definitions (client-loop when `endpoint` is omitted). */
918
+ /** Function calling: hosted catalog names, client-loop schemas, or names of
919
+ * server tools already registered with POST /v1/tools. An inline `endpoint`
920
+ * is rejected with `unsupported_tool_transport`. */
739
921
  tools?: OmniToolDef[];
740
922
  /** Forward-compatible: any other key the engine honors. */
741
923
  [k: string]: unknown;
@@ -750,7 +932,11 @@ export interface OmniConnectOptions {
750
932
  * so a page never holds a secret key.
751
933
  */
752
934
  token?: string;
753
- /** Connect-URL sample rate: 24000 browser, 16000 wideband telephony, 8000 G.711/Twilio. */
935
+ /**
936
+ * Caller-input sample rate. `24000` and `16000` sessions receive agent audio
937
+ * at 24 kHz; `8000` sessions receive 8 kHz. Read `hello.audio_out` rather than
938
+ * assuming output matches this value.
939
+ */
754
940
  rate?: 24000 | 16000 | 8000;
755
941
  /** Connect-URL audio format. Default `pcm16`. */
756
942
  format?: "pcm16";
@@ -773,7 +959,7 @@ export interface OmniConnectOptions {
773
959
  onSessionStarted?: (frame: OmniServerFrame) => void;
774
960
  /** Fired on `turn` boundaries. */
775
961
  onTurn?: (frame: OmniServerFrame) => void;
776
- /** Fired on `transcript` text frames. */
962
+ /** Fired for each normalized caller-transcript delta (`0x02` plain UTF-8 live). */
777
963
  onTranscript?: (frame: OmniServerFrame) => void;
778
964
  /** Fired on `barge_in` / `flush` (user interrupted). */
779
965
  onBargeIn?: (frame: OmniServerFrame) => void;
@@ -824,7 +1010,7 @@ export class OmniConnection {
824
1010
  "No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to omni.connect()",
825
1011
  );
826
1012
  }
827
- this.ws = new WS(url, [subprotocol]);
1013
+ this.ws = new WS(url, [REALTIME_SUBPROTOCOL_MARKER, subprotocol]);
828
1014
  this.ws.onopen = () => {
829
1015
  if (opts.configure) {
830
1016
  try {
@@ -878,7 +1064,11 @@ export class OmniConnection {
878
1064
  }
879
1065
  if (tag === 0x03) {
880
1066
  try {
881
- const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1))) as OmniServerFrame;
1067
+ const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(1))) as OmniServerFrame;
1068
+ if (parsed?.event === OmniEvent.Transcript) {
1069
+ this.opts.onError?.(new Error("Omni transcript events must use a binary 0x02 frame"));
1070
+ return;
1071
+ }
882
1072
  this.dispatchFrame(parsed);
883
1073
  } catch {
884
1074
  this.opts.onError?.(new Error("Unparseable Omni binary frame"));
@@ -889,23 +1079,16 @@ export class OmniConnection {
889
1079
  this.opts.onError?.(new Error(`Ignored unknown Omni binary frame tag ${tagName}`));
890
1080
  return;
891
1081
  }
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
- }
1082
+ this.opts.onError?.(new Error("Unexpected Omni text frame; server frames must use binary 0x01/0x02/0x03 tags"));
898
1083
  }
899
1084
 
900
1085
  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 });
1086
+ const eventName = (frame as { event?: unknown }).event;
1087
+ if (typeof eventName !== "string" || !eventName) {
1088
+ this.opts.onError?.(new Error("Omni server control frame is missing its event key"));
1089
+ return;
1090
+ }
1091
+ this.opts.onEvent?.(frame);
909
1092
  switch (eventName) {
910
1093
  case OmniEvent.Hello:
911
1094
  this.opts.onHello?.(frame);
@@ -947,7 +1130,10 @@ export class OmniConnection {
947
1130
  * `{"event":"configure"}` is acked but silently dropped by the engine.)
948
1131
  */
949
1132
  configure(cfg: OmniConfigure): void {
950
- this.ws.send(omniControlFrame({ type: "configure", ...cfg }));
1133
+ const payload: Record<string, unknown> = { ...cfg };
1134
+ delete payload.type;
1135
+ delete payload.event;
1136
+ this.ws.send(omniControlFrame({ type: "configure", ...payload }));
951
1137
  }
952
1138
 
953
1139
  /**
@@ -987,7 +1173,12 @@ export class OmniConnection {
987
1173
  * never `event`.
988
1174
  */
989
1175
  send(frame: Record<string, unknown>): void {
990
- this.ws.send(omniControlFrame(frame));
1176
+ if (typeof frame.type !== "string" || !frame.type) {
1177
+ throw new TypeError("Omni client control frames must have a non-empty type key");
1178
+ }
1179
+ const payload = { ...frame };
1180
+ delete payload.event;
1181
+ this.ws.send(omniControlFrame(payload));
991
1182
  }
992
1183
 
993
1184
  /** Close the session. */
@@ -1284,8 +1475,41 @@ export interface RecapCallSummary {
1284
1475
  completed_at?: number | null;
1285
1476
  }
1286
1477
 
1478
+ export interface RecapActionItem {
1479
+ owner?: string | null;
1480
+ task: string;
1481
+ due?: string | null;
1482
+ }
1483
+
1484
+ export interface RecapTalkRatio {
1485
+ agent: number;
1486
+ customer: number;
1487
+ }
1488
+
1489
+ export interface RecapSignal {
1490
+ kind: string;
1491
+ text: string;
1492
+ at_s?: number | null;
1493
+ }
1494
+
1495
+ export interface RecapRecord {
1496
+ format: "recap.record.v1";
1497
+ tldr: string | null;
1498
+ summary: string | null;
1499
+ action_items: RecapActionItem[];
1500
+ disposition: string | null;
1501
+ next_steps: string | null;
1502
+ talk_ratio: RecapTalkRatio | null;
1503
+ signals: RecapSignal[];
1504
+ fields: Record<string, unknown>;
1505
+ rep?: Record<string, unknown>;
1506
+ manager?: Record<string, unknown>;
1507
+ ops?: Record<string, unknown>;
1508
+ }
1509
+
1287
1510
  export interface RecapCall extends RecapCallSummary {
1288
- record?: unknown;
1511
+ record?: RecapRecord;
1512
+ transcript?: { format: "utterances.v1"; utterances: Array<{ speaker_role: "agent" | "customer"; text: string; offset_s: number; duration_s: number }> };
1289
1513
  error?: string | null;
1290
1514
  crm_write_status?: string | null;
1291
1515
  }
@@ -1301,19 +1525,23 @@ export interface RecapCallTriggerInput {
1301
1525
 
1302
1526
  // --- AMD (answering-machine detection) -----------------------------------
1303
1527
 
1304
- /** PyAI's richer answered-by vocabulary (superset of Twilio's enum). */
1528
+ /**
1529
+ * The answered-by vocabulary on stored call records and the
1530
+ * `amd.call.completed` webhook: the routing classes plus the machine subtypes.
1531
+ */
1305
1532
  export type AmdAnsweredBy =
1306
1533
  | "human"
1534
+ | "machine"
1307
1535
  | "voicemail"
1308
- | "live_voicemail"
1309
1536
  | "screening" // iPhone / Google Call Screen
1310
1537
  | "ivr"
1311
- | "human_gatekeeper"
1538
+ | "music" // hold music
1312
1539
  | "sit_invalid" // dead / disconnected number
1313
- | "fax"
1314
- | "silence"
1315
1540
  | "unknown";
1316
1541
 
1542
+ /** The routing classes pushed on the mid-call wire event (`event: "amd"`). */
1543
+ export type AmdWireAnsweredBy = "human" | "machine" | "sit_invalid" | "unknown";
1544
+
1317
1545
  /** Twilio's `AnsweredBy` enum, echoed for drop-in migration parity. */
1318
1546
  export type AmdTwilioAnsweredBy =
1319
1547
  | "human"
@@ -1363,11 +1591,16 @@ export interface AmdCall extends AmdCallSummary {
1363
1591
  error?: string | null;
1364
1592
  }
1365
1593
 
1366
- /** A mid-call AMD decision event pushed on the stream (and to the webhook). */
1594
+ /**
1595
+ * A mid-call AMD decision event pushed on the stream (and to the per-call
1596
+ * TwiML `webhook`). Carries the coarse routing class; the machine subtype
1597
+ * (`voicemail`/`ivr`/`screening`/`music`) is on the stored call record
1598
+ * (`AmdCall`) and the `amd.call.completed` webhook instead.
1599
+ */
1367
1600
  export interface AmdDecisionEvent {
1368
1601
  event?: "amd";
1369
1602
  call_id?: string;
1370
- answered_by?: AmdAnsweredBy;
1603
+ answered_by?: AmdWireAnsweredBy;
1371
1604
  answered_by_twilio?: string | null;
1372
1605
  confidence?: number | null;
1373
1606
  decision_ms?: number | null;
@@ -1490,14 +1723,71 @@ export class PyAI {
1490
1723
  // --- voices -------------------------------------------------------------
1491
1724
 
1492
1725
  voices = {
1493
- list: (params: { gender?: string; region?: string } = {}): Promise<ListResponse<Voice>> => {
1726
+ list: async (params: {
1727
+ gender?: string;
1728
+ region?: string;
1729
+ language?: string;
1730
+ tier?: "standard" | "natural";
1731
+ q?: string;
1732
+ } = {}): Promise<ListResponse<Voice>> => {
1494
1733
  const q = new URLSearchParams();
1495
1734
  if (params.gender) q.set("gender", params.gender);
1496
1735
  if (params.region) q.set("region", params.region);
1736
+ if (params.language) q.set("language", params.language);
1737
+ if (params.tier) q.set("tier", params.tier);
1738
+ if (params.q) q.set("q", params.q);
1497
1739
  const qs = q.toString();
1498
- return this.getJson(`/v1/voices${qs ? `?${qs}` : ""}`);
1740
+ const page = await this.getJson<ListResponse<Record<string, unknown>>>(
1741
+ `/v1/voices${qs ? `?${qs}` : ""}`,
1742
+ );
1743
+ return { ...page, data: page.data.map(normalizeVoice) };
1744
+ },
1745
+ get: async (id: string): Promise<Voice> =>
1746
+ normalizeVoice(
1747
+ await this.getJson<Record<string, unknown>>(
1748
+ `/v1/voices/${encodeURIComponent(id)}`,
1749
+ ),
1750
+ ),
1751
+ };
1752
+
1753
+ // --- Hear organization settings ----------------------------------------
1754
+
1755
+ hear = {
1756
+ vocabulary: {
1757
+ /** Read organization-owned vocabulary. Scope `hear:configure`. */
1758
+ get: (): Promise<HearVocabulary> =>
1759
+ this.getJson("/v1/hear/vocabulary"),
1760
+ /** Replace vocabulary and activation profiles. Scope `hear:configure`. */
1761
+ set: (input: HearVocabularyInput): Promise<HearVocabulary> =>
1762
+ this.putJson("/v1/hear/vocabulary", {
1763
+ terms: input.terms,
1764
+ enabled_for: input.enabledFor,
1765
+ }),
1766
+ },
1767
+ };
1768
+
1769
+ // --- managed Agents ----------------------------------------------------
1770
+
1771
+ agents = {
1772
+ list: (): Promise<ListResponse<Agent>> => this.getJson("/v1/agents"),
1773
+ get: (agentId: string): Promise<Agent> =>
1774
+ this.getJson(`/v1/agents/${encodeURIComponent(agentId)}`),
1775
+ create: (input: AgentConfig & { name: string }): Promise<Agent> =>
1776
+ this.postJson("/v1/agents", input),
1777
+ update: (agentId: string, patch: AgentConfig): Promise<Agent> =>
1778
+ this.postJson(`/v1/agents/${encodeURIComponent(agentId)}`, patch),
1779
+ delete: async (
1780
+ agentId: string,
1781
+ ): Promise<{ object: "agent.deleted"; agent_id: string; deleted: boolean }> => {
1782
+ const response = await this.deleteReq(
1783
+ `/v1/agents/${encodeURIComponent(agentId)}`,
1784
+ );
1785
+ return (await response.json()) as {
1786
+ object: "agent.deleted";
1787
+ agent_id: string;
1788
+ deleted: boolean;
1789
+ };
1499
1790
  },
1500
- get: (id: string): Promise<Voice> => this.getJson(`/v1/voices/${encodeURIComponent(id)}`),
1501
1791
  };
1502
1792
 
1503
1793
  // --- audio --------------------------------------------------------------
@@ -1505,26 +1795,28 @@ export class PyAI {
1505
1795
  audio = {
1506
1796
  /** Text-to-speech. Returns the raw audio bytes (default WAV). */
1507
1797
  speech: async (params: SpeechParams): Promise<ArrayBuffer> => {
1798
+ assertActiveSpeechParams(params);
1508
1799
  const res = await this.request("/v1/audio/speech", {
1509
1800
  method: "POST",
1510
1801
  headers: this.authHeaders({ "Content-Type": "application/json" }),
1511
- body: JSON.stringify({ model: "pyai-voice", ...params }),
1802
+ body: JSON.stringify({ model: "pyai-speak", ...params }),
1512
1803
  });
1513
1804
  return res.arrayBuffer();
1514
1805
  },
1515
1806
  /**
1516
1807
  * Text-to-speech, streamed. Resolves as soon as the response headers arrive
1517
1808
  * with the body as a `ReadableStream` of audio bytes, so you can start
1518
- * playback or forward the audio at the first chunk (the engine's
1519
- * time-to-first-byte is tens of ms) instead of buffering the whole clip.
1520
- * Use `mp3` for the smoothest progressive playback. Returns an async
1521
- * iterable of Uint8Array chunks.
1809
+ * playback or forward the audio at the first chunk. Use `pcm`, `wav`,
1810
+ * or G.711 for streaming; `mp3` and `opus` are buffered server-side.
1811
+ * Consume the stream immediately and cancel its reader when stopping early.
1812
+ * HTTP connection reuse and protocol negotiation are controlled by fetch.
1522
1813
  */
1523
1814
  speechStream: async (params: SpeechParams): Promise<ReadableStream<Uint8Array>> => {
1815
+ assertActiveSpeechParams(params);
1524
1816
  const res = await this.request("/v1/audio/speech", {
1525
1817
  method: "POST",
1526
1818
  headers: this.authHeaders({ "Content-Type": "application/json" }),
1527
- body: JSON.stringify({ model: "pyai-voice", ...params, stream: true }),
1819
+ body: JSON.stringify({ model: "pyai-speak", ...params, stream: true }),
1528
1820
  });
1529
1821
  if (!res.body) throw new PyAIError(res.status, "Response had no body to stream");
1530
1822
  return res.body as ReadableStream<Uint8Array>;
@@ -1535,7 +1827,13 @@ export class PyAI {
1535
1827
  file: Blob;
1536
1828
  filename?: string;
1537
1829
  model?: string;
1538
- language?: string;
1830
+ /** Optional language hint. Omission enables automatic detection. */
1831
+ language?: "en" | "es" | "fr" | "de" | "hi" | "it" | "pt" | "nl";
1832
+ numerals?: boolean;
1833
+ smart_format?: boolean;
1834
+ dictation?: boolean;
1835
+ drop_fillers?: boolean;
1836
+ vocabulary?: string[];
1539
1837
  response_format?: "json" | "text" | "verbose_json";
1540
1838
  /**
1541
1839
  * Deterministic seed for reproducible eval runs. Forward-compatible:
@@ -1549,6 +1847,11 @@ export class PyAI {
1549
1847
  form.set("file", params.file, params.filename ?? "audio.wav");
1550
1848
  form.set("model", params.model ?? "pyai-hear");
1551
1849
  if (params.language) form.set("language", params.language);
1850
+ if (params.numerals !== undefined) form.set("numerals", String(params.numerals));
1851
+ if (params.smart_format !== undefined) form.set("smart_format", String(params.smart_format));
1852
+ if (params.dictation !== undefined) form.set("dictation", String(params.dictation));
1853
+ if (params.drop_fillers !== undefined) form.set("drop_fillers", String(params.drop_fillers));
1854
+ if (params.vocabulary?.length) form.set("vocabulary", params.vocabulary.join(","));
1552
1855
  if (params.response_format) form.set("response_format", params.response_format);
1553
1856
  if (params.seed !== undefined) form.set("seed", String(params.seed));
1554
1857
  if (params.temperature !== undefined) form.set("temperature", String(params.temperature));
@@ -1607,7 +1910,7 @@ export class PyAI {
1607
1910
  clones = {
1608
1911
  /** List the org's cloned voices. */
1609
1912
  list: (): Promise<ListResponse<Voice>> => this.getJson("/v1/voice/clones"),
1610
- /** Enroll a custom voice from reference audio (>= ~10s). Scope `voice:clone`. */
1913
+ /** Enroll a custom voice from reference audio (>= ~10s). Scope `speak:clone`. */
1611
1914
  create: async (params: { name: string; file: Blob; filename?: string }): Promise<Voice> => {
1612
1915
  const form = new FormData();
1613
1916
  form.set("name", params.name);
@@ -1626,7 +1929,7 @@ export class PyAI {
1626
1929
  if (!match) throw new PyAIError(404, `No cloned voice ${id}`, ErrorCode.NotFound);
1627
1930
  return match;
1628
1931
  },
1629
- /** Delete a cloned voice (tenant-isolated). Scope `voice:clone`. */
1932
+ /** Delete a cloned voice (tenant-isolated). Scope `speak:clone`. */
1630
1933
  delete: async (id: string): Promise<void> => {
1631
1934
  await this.deleteReq(`/v1/voice/clones/${encodeURIComponent(id)}`);
1632
1935
  },
@@ -1823,7 +2126,7 @@ export class PyAI {
1823
2126
  * realtime. **Call this from your server** with a secret key holding
1824
2127
  * `omni:session`; never ship the secret key to a page. Hand the returned
1825
2128
  * `token` to the browser, which connects with
1826
- * `new WebSocket(session.url, ["pyai-key." + session.token])`. The token
2129
+ * `new WebSocket(session.url, ["pyai.v1", "pyai-key." + session.token])`. The token
1827
2130
  * expires after `ttlSeconds` (default 60s) and only works from
1828
2131
  * `allowedOrigins`. Scope `omni:session`.
1829
2132
  */
@@ -1847,7 +2150,7 @@ export class PyAI {
1847
2150
  const query: Record<string, string> = { ...(opts.query ?? {}) };
1848
2151
  if (opts.format) query.format = opts.format;
1849
2152
  if (opts.rate) query.rate = String(opts.rate);
1850
- const url = this.realtimeURL({ product: "omni", sessionLabel: opts.sessionLabel, query });
2153
+ const url = this.realtimeURL({ sessionLabel: opts.sessionLabel, query });
1851
2154
  const sub = opts.token ? `pyai-key.${opts.token}` : this.realtimeSubprotocol();
1852
2155
  return new OmniConnection(url, sub, opts);
1853
2156
  },
@@ -1855,41 +2158,64 @@ export class PyAI {
1855
2158
 
1856
2159
  // --- realtime (WebSocket) ----------------------------------------------
1857
2160
 
1858
- /** Build the realtime WebSocket URL for the chosen product. */
2161
+ /** Build the canonical Omni WebSocket URL. */
1859
2162
  realtimeURL(opts: RealtimeOptions = {}): string {
1860
2163
  const wsBase = this.baseURL.replace(/^http/, "ws");
1861
2164
  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}` : ""}`;
2165
+ // format/rate are load-bearing on the connect URL, so default to
2166
+ // browser-grade PCM16/24kHz.
2167
+ for (const key of ["agent", "agent_id", "agentId", "model", "access_token"]) {
2168
+ if (q.has(key)) {
2169
+ throw new Error(
2170
+ `Omni query parameter "${key}" is not supported; use sessionLabel, format/rate, or api_key`,
2171
+ );
2172
+ }
1873
2173
  }
1874
- q.set("model", "pyai-flow-realtime");
1875
- return `${wsBase}/v1/realtime?${q.toString()}`;
2174
+ if (opts.sessionLabel) q.set("session_label", opts.sessionLabel);
2175
+ if (!q.has("format")) q.set("format", "pcm16");
2176
+ if (!q.has("rate")) q.set("rate", "24000");
2177
+ const qs = q.toString();
2178
+ return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
1876
2179
  }
1877
2180
 
1878
- /** The subprotocol that carries the key on a WS upgrade (browser-safe auth). */
2181
+ /** The subprotocol token that carries the key on a WS upgrade. */
1879
2182
  realtimeSubprotocol(): string {
1880
2183
  return `pyai-key.${this.apiKey}`;
1881
2184
  }
1882
2185
 
2186
+ /**
2187
+ * The full subprotocol list to open a PyAI WebSocket with:
2188
+ * `[marker, credential]`. Always pass BOTH — see
2189
+ * {@link REALTIME_SUBPROTOCOL_MARKER}. Pass `token` to use an ephemeral
2190
+ * session token (from `omni.createSession`) instead of the secret key.
2191
+ */
2192
+ realtimeSubprotocols(token?: string): string[] {
2193
+ return [
2194
+ REALTIME_SUBPROTOCOL_MARKER,
2195
+ token ? `pyai-key.${token}` : this.realtimeSubprotocol(),
2196
+ ];
2197
+ }
2198
+
1883
2199
  /** Build the Hear streaming-STT WebSocket URL (`/v1/audio/transcriptions/stream`). */
1884
2200
  hearStreamURL(opts: HearStreamOptions = {}): string {
1885
2201
  const wsBase = this.baseURL.replace(/^http/, "ws");
1886
2202
  const q = new URLSearchParams(opts.query ?? {});
2203
+ // `context` is private to PyAI's adapter-to-server hop. Never expose or
2204
+ // forward it from the public SDK escape hatch.
2205
+ q.delete("context");
2206
+ q.set("protocol", "pyai-hear-v1");
1887
2207
  if (opts.model) q.set("model", opts.model);
1888
2208
  if (opts.language) q.set("language", opts.language);
1889
2209
  if (opts.sampleRate !== undefined) q.set("sample_rate", String(opts.sampleRate));
1890
2210
  if (opts.encoding) q.set("encoding", opts.encoding);
1891
2211
  if (opts.interimResults !== undefined) q.set("interim_results", String(opts.interimResults));
1892
2212
  if (opts.numerals !== undefined) q.set("numerals", String(opts.numerals));
2213
+ if (opts.smartFormat !== undefined) q.set("smart_format", String(opts.smartFormat));
2214
+ if (opts.dictation !== undefined) q.set("dictation", String(opts.dictation));
2215
+ if (opts.dropFillers !== undefined) q.set("drop_fillers", String(opts.dropFillers));
2216
+ if (opts.vocabulary?.length) {
2217
+ q.set("vocabulary", JSON.stringify(opts.vocabulary));
2218
+ }
1893
2219
  if (opts.endpointingMs !== undefined) q.set("endpointing_ms", String(opts.endpointingMs));
1894
2220
  const qs = q.toString();
1895
2221
  return `${wsBase}/v1/audio/transcriptions/stream${qs ? `?${qs}` : ""}`;
@@ -1912,8 +2238,8 @@ export class PyAI {
1912
2238
  */
1913
2239
  connectRealtime(opts: RealtimeOptions = {}): WebSocket {
1914
2240
  const WS = (globalThis as { WebSocket?: typeof WebSocket }).WebSocket;
1915
- if (!WS) throw new Error("No global WebSocket; use realtimeURL()/realtimeSubprotocol() with a WS library");
1916
- return new WS(this.realtimeURL(opts), [this.realtimeSubprotocol()]);
2241
+ if (!WS) throw new Error("No global WebSocket; use realtimeURL()/realtimeSubprotocols() with a WS library");
2242
+ return new WS(this.realtimeURL(opts), this.realtimeSubprotocols());
1917
2243
  }
1918
2244
  }
1919
2245