@pyai/sdk 0.3.1 → 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/README.md CHANGED
@@ -131,6 +131,7 @@ WS subprotocol, so it works in the browser), routes the wire frames to
131
131
  const hear = pyai.audio.transcriptions.stream({
132
132
  sampleRate: 16000,
133
133
  endpointingMs: 800, // minimum trailing pause; may wait up to max(800, 1500) ms
134
+ vocabulary: ["Nguyen", "SKU-99"],
134
135
  onConfigAck: (ack) => {
135
136
  if (ack.warnings.length) throw new Error(JSON.stringify(ack.warnings));
136
137
  },
@@ -145,6 +146,35 @@ vad.on("end", () => hear.commit()); // optional forced final
145
146
  // hear.close() also flushes a final for any buffered audio
146
147
  ```
147
148
 
149
+ Streaming uses up to five sanitized vocabulary terms. To store organization
150
+ suggestions, use a key with `hear:configure` and set explicit activation
151
+ profiles first:
152
+
153
+ ```ts
154
+ await pyai.hear.vocabulary.set({
155
+ terms: ["Nguyen", "SKU-99"],
156
+ enabledFor: ["batch", "hear_stream"],
157
+ });
158
+ ```
159
+
160
+ Request-level terms come first. Stored suggestions fill remaining slots up to
161
+ five. The effective list is fixed when a stream opens or a batch job is created.
162
+ Organization Hear terms are not used by Omni and are not populated
163
+ automatically from CRM or dialer data. A managed Agent can opt in with its own
164
+ list:
165
+
166
+ ```ts
167
+ const agent = await pyai.agents.create({
168
+ name: "Front desk",
169
+ vocabulary: ["Nguyen", "Acme Dental", "SKU-99"],
170
+ });
171
+
172
+ await pyai.agents.update(agent.agent_id, { vocabulary: [] });
173
+ ```
174
+
175
+ The Agent list is sanitized to at most five effective terms and fixed when a
176
+ new session starts. An empty list turns the feature off.
177
+
148
178
  Frame `type`s, WS close codes, and error `code`s are exported as named
149
179
  constants so you never hardcode a magic string:
150
180
 
package/dist/index.d.ts CHANGED
@@ -31,6 +31,20 @@ export interface Voice {
31
31
  name?: string;
32
32
  gender?: string;
33
33
  region?: string;
34
+ language?: string;
35
+ /** Customer-facing quality tier. */
36
+ tier?: "standard" | "natural";
37
+ /** Permanent convenience inputs accepted on the advertised surfaces. */
38
+ aliases?: string[];
39
+ /** Product surfaces on which this stock voice can be selected. */
40
+ available_on?: Array<"speak" | "omni">;
41
+ /** Accepted Speak delivery modes. Empty means the voice has no Speak surface. */
42
+ synthesis_modes?: Array<"streaming" | "async">;
43
+ /** Voice-specific amount on top of the selected product's base rate. */
44
+ pricing?: {
45
+ included_in_base_price: boolean;
46
+ additional_price_usd_per_minute: number;
47
+ };
34
48
  [k: string]: unknown;
35
49
  }
36
50
  export interface ListResponse<T> {
@@ -82,6 +96,13 @@ export interface SpeechParams {
82
96
  input: string;
83
97
  voice?: string;
84
98
  model?: SpeakModel;
99
+ /**
100
+ * Delivery mode. The API defaults to true and every catalog voice accepts
101
+ * both values. Set false when you want one complete buffered body with a
102
+ * `Content-Length`. A voice whose serving fleet has no streaming lane is
103
+ * rendered buffered either way and says so with `x-pyai-stream: buffered`.
104
+ */
105
+ stream?: boolean;
85
106
  /**
86
107
  * Output container/codec, resampled+encoded server-side. One of
87
108
  * {@link SpeechFormat}, anything else is a `400 unsupported_format`. Omit for
@@ -119,8 +140,57 @@ export interface CreateJobParams {
119
140
  diarize?: boolean;
120
141
  channel?: boolean;
121
142
  numerals?: boolean;
143
+ smart_format?: boolean;
144
+ dictation?: boolean;
145
+ drop_fillers?: boolean;
146
+ /**
147
+ * Per-job names, brands, products, or distinctive terms. PyAI keeps up to
148
+ * five valid entries after trimming, deduplication, and common-word
149
+ * filtering.
150
+ */
151
+ vocabulary?: string[];
122
152
  output_formats?: Array<"json" | "srt" | "vtt">;
123
153
  webhook_url?: string;
154
+ call_id?: string;
155
+ pack_id?: string;
156
+ call_direction?: "inbound" | "outbound";
157
+ customer_name?: string;
158
+ }
159
+ export type HearVocabularyProfile = "batch" | "hear_stream";
160
+ export interface HearVocabularyInput {
161
+ /** Organization-owned names, brands, products, or distinctive terms. */
162
+ terms: string[];
163
+ /** Stored terms remain inert unless a use case is selected here. */
164
+ enabledFor: HearVocabularyProfile[];
165
+ }
166
+ export interface HearVocabulary {
167
+ object: "hear.vocabulary";
168
+ /** Sanitized stored list, at most five terms. */
169
+ terms: string[];
170
+ /** Use cases that may add stored suggestions. */
171
+ enabled_for: HearVocabularyProfile[];
172
+ /** Unix milliseconds, or null before the first save. */
173
+ updated_at: number | null;
174
+ }
175
+ export interface AgentConfig {
176
+ name?: string;
177
+ persona_system_prompt?: string | null;
178
+ greeting?: string | null;
179
+ voice_id?: string | null;
180
+ language?: "en" | "fr" | "es" | "de" | "hi" | null;
181
+ /**
182
+ * Opt-in speech-recognition vocabulary. PyAI sanitizes and keeps at most
183
+ * five terms. An empty list or null turns it off.
184
+ */
185
+ vocabulary?: string[] | null;
186
+ [key: string]: unknown;
187
+ }
188
+ export interface Agent {
189
+ object: "agent";
190
+ agent_id: string;
191
+ name: string;
192
+ vocabulary: string[];
193
+ [key: string]: unknown;
124
194
  }
125
195
  export interface RealtimeOptions {
126
196
  /**
@@ -183,10 +253,16 @@ export declare const HearFrameType: {
183
253
  readonly Error: "error";
184
254
  };
185
255
  export type HearFrameType = (typeof HearFrameType)[keyof typeof HearFrameType];
186
- /** WebSocket close codes used across the PyAI realtime/streaming surfaces. */
256
+ /**
257
+ * WebSocket close codes used across the PyAI realtime/streaming surfaces.
258
+ * Browser callers may initiate only Normal or values in 3000-4999; the
259
+ * standards-reserved values below are server close observations.
260
+ */
187
261
  export declare const WSCloseCode: {
188
262
  /** Normal closure. */
189
263
  readonly Normal: 1000;
264
+ /** Browser-safe private application code for a malformed peer protocol frame. */
265
+ readonly ProtocolViolation: 4002;
190
266
  /** Auth/policy: bad key, missing scope, or revoked token. */
191
267
  readonly PolicyViolation: 1008;
192
268
  /** Engine/internal error. */
@@ -214,6 +290,7 @@ export declare const ErrorCode: {
214
290
  readonly IdempotencyConflict: "idempotency_conflict";
215
291
  readonly NotFound: "not_found";
216
292
  readonly NumberInUse: "number_in_use";
293
+ readonly UnsupportedToolTransport: "unsupported_tool_transport";
217
294
  };
218
295
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
219
296
  /** A top-3 knowledge-base passage attached to Cue (grounded) finals. */
@@ -303,28 +380,59 @@ export interface WebSocketLike {
303
380
  reason: string;
304
381
  }) => void) | null;
305
382
  }
383
+ /**
384
+ * The non-secret marker every PyAI realtime client offers ALONGSIDE its
385
+ * `pyai-key.<KEY>` credential token.
386
+ *
387
+ * A browser cannot set `Authorization` on a WebSocket, so the key rides in
388
+ * `Sec-WebSocket-Protocol`. RFC 6455 makes the server echo the subprotocol it
389
+ * SELECTS, so a server that selects the credential publishes the credential —
390
+ * which api.pyai.com did until 2026-09-07. The edge now echoes only this
391
+ * marker. Offering it is not optional for Node clients: `ws` throws
392
+ * "Server sent no subprotocol" when it offered subprotocols and the 101
393
+ * selected none, and RFC 6455 lets the server select only a value the client
394
+ * actually offered.
395
+ */
396
+ export declare const REALTIME_SUBPROTOCOL_MARKER = "pyai.v1";
306
397
  export type WebSocketCtor = new (url: string, protocols?: string | string[]) => WebSocketLike;
307
398
  export interface HearStreamOptions {
308
399
  /** Streaming STT model. Server default `pyai-hear`. */
309
400
  model?: string;
310
- /**
311
- * Hear is English-only. Set `"en"` explicitly; omission also means English
312
- * and does not enable language detection. Other values receive
313
- * `400 unsupported_language`.
314
- */
315
- language?: "en";
401
+ /** Omit or use auto for automatic detection; an explicit code pins recognition. */
402
+ language?: "auto" | "en" | "es" | "fr" | "de" | "hi" | "it" | "pt" | "nl";
316
403
  /** Input PCM sample rate in Hz. Default 16000 server-side. */
317
404
  sampleRate?: number;
318
405
  /** Audio frame encoding. Default "pcm16". */
319
- encoding?: "pcm16" | "opus";
406
+ encoding?: "pcm16";
320
407
  /** Emit eager partial hypotheses. Default true server-side. */
321
408
  interimResults?: boolean;
322
409
  /**
323
- * Format spoken numbers as digits in the transcript (e.g. "one two three" →
324
- * "123"). Useful for voice agents that read back phone numbers, codes, and
325
- * amounts. Default false (spoken form). Forwards `?numerals=true` on the URL.
410
+ * Tri-state number formatting on **final** transcripts. `true` forces digits,
411
+ * `false` keeps spoken form, omitted keeps the live engine default (ITN on).
412
+ * Never applied to interim partials. Independent of `smartFormat`.
326
413
  */
327
414
  numerals?: boolean;
415
+ /**
416
+ * Opt-in English punctuation and sentence capitalization on **final**
417
+ * transcripts only. Interim partials are never formatted. Default false.
418
+ */
419
+ smartFormat?: boolean;
420
+ /**
421
+ * Spoken punctuation commands (`period`, `comma`, `new paragraph`,
422
+ * `question mark`) on finals only. Separate from `smartFormat`. Off by default.
423
+ */
424
+ dictation?: boolean;
425
+ /**
426
+ * Strip `um` / `uh` / `umm` / `uhh` / `er` on finals. Off by default.
427
+ * Do not enable on legal or compliance audio by default.
428
+ */
429
+ dropFillers?: boolean;
430
+ /**
431
+ * Per-session names, brands, products, or other distinctive terms. PyAI
432
+ * sanitizes up to five effective terms. Request terms come first, then stored
433
+ * `hear_stream` suggestions fill remaining slots. The list is fixed at open.
434
+ */
435
+ vocabulary?: string[];
328
436
  /**
329
437
  * Minimum trailing-pause length before an utterance may end (50-5000 ms).
330
438
  * Turn detection may wait longer, bounded at `max(endpointingMs, 1500)`.
@@ -439,6 +547,8 @@ export declare const OmniEvent: {
439
547
  readonly Flush: "flush";
440
548
  /** Engine requests a client-loop tool invocation. */
441
549
  readonly ToolCall: "tool_call";
550
+ /** A write tool is waiting for the caller to confirm; it has not run. */
551
+ readonly ToolConfirmationRequired: "tool_confirmation_required";
442
552
  /** Session is closing; see close code. */
443
553
  readonly SessionEnd: "session_end";
444
554
  /** Server fault frame. */
@@ -471,8 +581,6 @@ export interface OmniToolDef {
471
581
  name: string;
472
582
  description?: string;
473
583
  parameters?: Record<string, unknown>;
474
- /** When set, engine-POST mode. Omit for client-loop (default). */
475
- endpoint?: string;
476
584
  }
477
585
  export interface OmniToolCallFrame {
478
586
  event: "tool_call";
@@ -508,7 +616,9 @@ export interface OmniConfigure {
508
616
  * staged, see the Language support reference.
509
617
  */
510
618
  language?: "en" | "fr" | "es" | "de" | "hi";
511
- /** Function calling definitions (client-loop when `endpoint` is omitted). */
619
+ /** Function calling: hosted catalog names, client-loop schemas, or names of
620
+ * server tools already registered with POST /v1/tools. An inline `endpoint`
621
+ * is rejected with `unsupported_tool_transport`. */
512
622
  tools?: OmniToolDef[];
513
623
  /** Forward-compatible: any other key the engine honors. */
514
624
  [k: string]: unknown;
@@ -874,8 +984,45 @@ export interface RecapCallSummary {
874
984
  created_at?: number;
875
985
  completed_at?: number | null;
876
986
  }
987
+ export interface RecapActionItem {
988
+ owner?: string | null;
989
+ task: string;
990
+ due?: string | null;
991
+ }
992
+ export interface RecapTalkRatio {
993
+ agent: number;
994
+ customer: number;
995
+ }
996
+ export interface RecapSignal {
997
+ kind: string;
998
+ text: string;
999
+ at_s?: number | null;
1000
+ }
1001
+ export interface RecapRecord {
1002
+ format: "recap.record.v1";
1003
+ tldr: string | null;
1004
+ summary: string | null;
1005
+ action_items: RecapActionItem[];
1006
+ disposition: string | null;
1007
+ next_steps: string | null;
1008
+ talk_ratio: RecapTalkRatio | null;
1009
+ signals: RecapSignal[];
1010
+ fields: Record<string, unknown>;
1011
+ rep?: Record<string, unknown>;
1012
+ manager?: Record<string, unknown>;
1013
+ ops?: Record<string, unknown>;
1014
+ }
877
1015
  export interface RecapCall extends RecapCallSummary {
878
- record?: unknown;
1016
+ record?: RecapRecord;
1017
+ transcript?: {
1018
+ format: "utterances.v1";
1019
+ utterances: Array<{
1020
+ speaker_role: "agent" | "customer";
1021
+ text: string;
1022
+ offset_s: number;
1023
+ duration_s: number;
1024
+ }>;
1025
+ };
879
1026
  error?: string | null;
880
1027
  crm_write_status?: string | null;
881
1028
  }
@@ -994,19 +1141,43 @@ export declare class PyAI {
994
1141
  list: (params?: {
995
1142
  gender?: string;
996
1143
  region?: string;
1144
+ language?: string;
1145
+ tier?: "standard" | "natural";
1146
+ q?: string;
997
1147
  }) => Promise<ListResponse<Voice>>;
998
1148
  get: (id: string) => Promise<Voice>;
999
1149
  };
1150
+ hear: {
1151
+ vocabulary: {
1152
+ /** Read organization-owned vocabulary. Scope `hear:configure`. */
1153
+ get: () => Promise<HearVocabulary>;
1154
+ /** Replace vocabulary and activation profiles. Scope `hear:configure`. */
1155
+ set: (input: HearVocabularyInput) => Promise<HearVocabulary>;
1156
+ };
1157
+ };
1158
+ agents: {
1159
+ list: () => Promise<ListResponse<Agent>>;
1160
+ get: (agentId: string) => Promise<Agent>;
1161
+ create: (input: AgentConfig & {
1162
+ name: string;
1163
+ }) => Promise<Agent>;
1164
+ update: (agentId: string, patch: AgentConfig) => Promise<Agent>;
1165
+ delete: (agentId: string) => Promise<{
1166
+ object: "agent.deleted";
1167
+ agent_id: string;
1168
+ deleted: boolean;
1169
+ }>;
1170
+ };
1000
1171
  audio: {
1001
1172
  /** Text-to-speech. Returns the raw audio bytes (default WAV). */
1002
1173
  speech: (params: SpeechParams) => Promise<ArrayBuffer>;
1003
1174
  /**
1004
1175
  * Text-to-speech, streamed. Resolves as soon as the response headers arrive
1005
1176
  * with the body as a `ReadableStream` of audio bytes, so you can start
1006
- * playback or forward the audio at the first chunk (the engine's
1007
- * time-to-first-byte is tens of ms) instead of buffering the whole clip.
1008
- * Use `mp3` for the smoothest progressive playback. Returns an async
1009
- * iterable of Uint8Array chunks.
1177
+ * playback or forward the audio at the first chunk. Use `pcm`, `wav`,
1178
+ * or G.711 for streaming; `mp3` and `opus` are buffered server-side.
1179
+ * Consume the stream immediately and cancel its reader when stopping early.
1180
+ * HTTP connection reuse and protocol negotiation are controlled by fetch.
1010
1181
  */
1011
1182
  speechStream: (params: SpeechParams) => Promise<ReadableStream<Uint8Array>>;
1012
1183
  /** Synchronous speech-to-text (multipart upload). */
@@ -1015,11 +1186,13 @@ export declare class PyAI {
1015
1186
  file: Blob;
1016
1187
  filename?: string;
1017
1188
  model?: string;
1018
- /**
1019
- * Hear is English-only. Omission means English, not auto-detect; other
1020
- * values receive `400 unsupported_language`.
1021
- */
1022
- language?: "en";
1189
+ /** Optional language hint. Omission enables automatic detection. */
1190
+ language?: "en" | "es" | "fr" | "de" | "hi" | "it" | "pt" | "nl";
1191
+ numerals?: boolean;
1192
+ smart_format?: boolean;
1193
+ dictation?: boolean;
1194
+ drop_fillers?: boolean;
1195
+ vocabulary?: string[];
1023
1196
  response_format?: "json" | "text" | "verbose_json";
1024
1197
  /**
1025
1198
  * Deterministic seed for reproducible eval runs. Forward-compatible:
@@ -1200,7 +1373,7 @@ export declare class PyAI {
1200
1373
  * realtime. **Call this from your server** with a secret key holding
1201
1374
  * `omni:session`; never ship the secret key to a page. Hand the returned
1202
1375
  * `token` to the browser, which connects with
1203
- * `new WebSocket(session.url, ["pyai-key." + session.token])`. The token
1376
+ * `new WebSocket(session.url, ["pyai.v1", "pyai-key." + session.token])`. The token
1204
1377
  * expires after `ttlSeconds` (default 60s) and only works from
1205
1378
  * `allowedOrigins`. Scope `omni:session`.
1206
1379
  */
@@ -1218,8 +1391,15 @@ export declare class PyAI {
1218
1391
  };
1219
1392
  /** Build the canonical Omni WebSocket URL. */
1220
1393
  realtimeURL(opts?: RealtimeOptions): string;
1221
- /** The subprotocol that carries the key on a WS upgrade (browser-safe auth). */
1394
+ /** The subprotocol token that carries the key on a WS upgrade. */
1222
1395
  realtimeSubprotocol(): string;
1396
+ /**
1397
+ * The full subprotocol list to open a PyAI WebSocket with:
1398
+ * `[marker, credential]`. Always pass BOTH — see
1399
+ * {@link REALTIME_SUBPROTOCOL_MARKER}. Pass `token` to use an ephemeral
1400
+ * session token (from `omni.createSession`) instead of the secret key.
1401
+ */
1402
+ realtimeSubprotocols(token?: string): string[];
1223
1403
  /** Build the Hear streaming-STT WebSocket URL (`/v1/audio/transcriptions/stream`). */
1224
1404
  hearStreamURL(opts?: HearStreamOptions): string;
1225
1405
  /** Build the AMD detection WebSocket URL (`/v1/amd/stream`). */
package/dist/index.js CHANGED
@@ -62,10 +62,16 @@ export const HearFrameType = {
62
62
  /** Server-side fault frame. */
63
63
  Error: "error",
64
64
  };
65
- /** WebSocket close codes used across the PyAI realtime/streaming surfaces. */
65
+ /**
66
+ * WebSocket close codes used across the PyAI realtime/streaming surfaces.
67
+ * Browser callers may initiate only Normal or values in 3000-4999; the
68
+ * standards-reserved values below are server close observations.
69
+ */
66
70
  export const WSCloseCode = {
67
71
  /** Normal closure. */
68
72
  Normal: 1000,
73
+ /** Browser-safe private application code for a malformed peer protocol frame. */
74
+ ProtocolViolation: 4002,
69
75
  /** Auth/policy: bad key, missing scope, or revoked token. */
70
76
  PolicyViolation: 1008,
71
77
  /** Engine/internal error. */
@@ -92,7 +98,22 @@ export const ErrorCode = {
92
98
  IdempotencyConflict: "idempotency_conflict",
93
99
  NotFound: "not_found",
94
100
  NumberInUse: "number_in_use",
101
+ UnsupportedToolTransport: "unsupported_tool_transport",
95
102
  };
103
+ /**
104
+ * The non-secret marker every PyAI realtime client offers ALONGSIDE its
105
+ * `pyai-key.<KEY>` credential token.
106
+ *
107
+ * A browser cannot set `Authorization` on a WebSocket, so the key rides in
108
+ * `Sec-WebSocket-Protocol`. RFC 6455 makes the server echo the subprotocol it
109
+ * SELECTS, so a server that selects the credential publishes the credential —
110
+ * which api.pyai.com did until 2026-09-07. The edge now echoes only this
111
+ * marker. Offering it is not optional for Node clients: `ws` throws
112
+ * "Server sent no subprotocol" when it offered subprotocols and the 101
113
+ * selected none, and RFC 6455 lets the server select only a value the client
114
+ * actually offered.
115
+ */
116
+ export const REALTIME_SUBPROTOCOL_MARKER = "pyai.v1";
96
117
  /**
97
118
  * A live Hear streaming-STT session. Hides the frame protocol: stream audio
98
119
  * with {@link HearStream.sendAudio}, get `onPartial`/`onFinal`/`onError`
@@ -114,7 +135,7 @@ export class HearStream {
114
135
  if (!WS) {
115
136
  throw new Error("No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to transcriptions.stream()");
116
137
  }
117
- this.ws = new WS(url, [subprotocol]);
138
+ this.ws = new WS(url, [REALTIME_SUBPROTOCOL_MARKER, subprotocol]);
118
139
  this.ws.onopen = () => {
119
140
  opts.onOpen?.();
120
141
  };
@@ -205,7 +226,7 @@ export class AmdStream {
205
226
  if (!WS) {
206
227
  throw new Error("No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to amd.stream()");
207
228
  }
208
- this.ws = new WS(url, [subprotocol]);
229
+ this.ws = new WS(url, [REALTIME_SUBPROTOCOL_MARKER, subprotocol]);
209
230
  this.ws.onopen = () => opts.onOpen?.();
210
231
  this.ws.onmessage = (ev) => this.handleMessage(ev.data);
211
232
  this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
@@ -277,6 +298,8 @@ export const OmniEvent = {
277
298
  Flush: "flush",
278
299
  /** Engine requests a client-loop tool invocation. */
279
300
  ToolCall: "tool_call",
301
+ /** A write tool is waiting for the caller to confirm; it has not run. */
302
+ ToolConfirmationRequired: "tool_confirmation_required",
280
303
  /** Session is closing; see close code. */
281
304
  SessionEnd: "session_end",
282
305
  /** Server fault frame. */
@@ -413,7 +436,7 @@ export class OmniConnection {
413
436
  if (!WS) {
414
437
  throw new Error("No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to omni.connect()");
415
438
  }
416
- this.ws = new WS(url, [subprotocol]);
439
+ this.ws = new WS(url, [REALTIME_SUBPROTOCOL_MARKER, subprotocol]);
417
440
  this.ws.onopen = () => {
418
441
  if (opts.configure) {
419
442
  try {
@@ -679,12 +702,41 @@ export class PyAI {
679
702
  q.set("gender", params.gender);
680
703
  if (params.region)
681
704
  q.set("region", params.region);
705
+ if (params.language)
706
+ q.set("language", params.language);
707
+ if (params.tier)
708
+ q.set("tier", params.tier);
709
+ if (params.q)
710
+ q.set("q", params.q);
682
711
  const qs = q.toString();
683
712
  const page = await this.getJson(`/v1/voices${qs ? `?${qs}` : ""}`);
684
713
  return { ...page, data: page.data.map(normalizeVoice) };
685
714
  },
686
715
  get: async (id) => normalizeVoice(await this.getJson(`/v1/voices/${encodeURIComponent(id)}`)),
687
716
  };
717
+ // --- Hear organization settings ----------------------------------------
718
+ hear = {
719
+ vocabulary: {
720
+ /** Read organization-owned vocabulary. Scope `hear:configure`. */
721
+ get: () => this.getJson("/v1/hear/vocabulary"),
722
+ /** Replace vocabulary and activation profiles. Scope `hear:configure`. */
723
+ set: (input) => this.putJson("/v1/hear/vocabulary", {
724
+ terms: input.terms,
725
+ enabled_for: input.enabledFor,
726
+ }),
727
+ },
728
+ };
729
+ // --- managed Agents ----------------------------------------------------
730
+ agents = {
731
+ list: () => this.getJson("/v1/agents"),
732
+ get: (agentId) => this.getJson(`/v1/agents/${encodeURIComponent(agentId)}`),
733
+ create: (input) => this.postJson("/v1/agents", input),
734
+ update: (agentId, patch) => this.postJson(`/v1/agents/${encodeURIComponent(agentId)}`, patch),
735
+ delete: async (agentId) => {
736
+ const response = await this.deleteReq(`/v1/agents/${encodeURIComponent(agentId)}`);
737
+ return (await response.json());
738
+ },
739
+ };
688
740
  // --- audio --------------------------------------------------------------
689
741
  audio = {
690
742
  /** Text-to-speech. Returns the raw audio bytes (default WAV). */
@@ -700,10 +752,10 @@ export class PyAI {
700
752
  /**
701
753
  * Text-to-speech, streamed. Resolves as soon as the response headers arrive
702
754
  * with the body as a `ReadableStream` of audio bytes, so you can start
703
- * playback or forward the audio at the first chunk (the engine's
704
- * time-to-first-byte is tens of ms) instead of buffering the whole clip.
705
- * Use `mp3` for the smoothest progressive playback. Returns an async
706
- * iterable of Uint8Array chunks.
755
+ * playback or forward the audio at the first chunk. Use `pcm`, `wav`,
756
+ * or G.711 for streaming; `mp3` and `opus` are buffered server-side.
757
+ * Consume the stream immediately and cancel its reader when stopping early.
758
+ * HTTP connection reuse and protocol negotiation are controlled by fetch.
707
759
  */
708
760
  speechStream: async (params) => {
709
761
  assertActiveSpeechParams(params);
@@ -724,6 +776,16 @@ export class PyAI {
724
776
  form.set("model", params.model ?? "pyai-hear");
725
777
  if (params.language)
726
778
  form.set("language", params.language);
779
+ if (params.numerals !== undefined)
780
+ form.set("numerals", String(params.numerals));
781
+ if (params.smart_format !== undefined)
782
+ form.set("smart_format", String(params.smart_format));
783
+ if (params.dictation !== undefined)
784
+ form.set("dictation", String(params.dictation));
785
+ if (params.drop_fillers !== undefined)
786
+ form.set("drop_fillers", String(params.drop_fillers));
787
+ if (params.vocabulary?.length)
788
+ form.set("vocabulary", params.vocabulary.join(","));
727
789
  if (params.response_format)
728
790
  form.set("response_format", params.response_format);
729
791
  if (params.seed !== undefined)
@@ -985,7 +1047,7 @@ export class PyAI {
985
1047
  * realtime. **Call this from your server** with a secret key holding
986
1048
  * `omni:session`; never ship the secret key to a page. Hand the returned
987
1049
  * `token` to the browser, which connects with
988
- * `new WebSocket(session.url, ["pyai-key." + session.token])`. The token
1050
+ * `new WebSocket(session.url, ["pyai.v1", "pyai-key." + session.token])`. The token
989
1051
  * expires after `ttlSeconds` (default 60s) and only works from
990
1052
  * `allowedOrigins`. Scope `omni:session`.
991
1053
  */
@@ -1035,14 +1097,29 @@ export class PyAI {
1035
1097
  const qs = q.toString();
1036
1098
  return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
1037
1099
  }
1038
- /** The subprotocol that carries the key on a WS upgrade (browser-safe auth). */
1100
+ /** The subprotocol token that carries the key on a WS upgrade. */
1039
1101
  realtimeSubprotocol() {
1040
1102
  return `pyai-key.${this.apiKey}`;
1041
1103
  }
1104
+ /**
1105
+ * The full subprotocol list to open a PyAI WebSocket with:
1106
+ * `[marker, credential]`. Always pass BOTH — see
1107
+ * {@link REALTIME_SUBPROTOCOL_MARKER}. Pass `token` to use an ephemeral
1108
+ * session token (from `omni.createSession`) instead of the secret key.
1109
+ */
1110
+ realtimeSubprotocols(token) {
1111
+ return [
1112
+ REALTIME_SUBPROTOCOL_MARKER,
1113
+ token ? `pyai-key.${token}` : this.realtimeSubprotocol(),
1114
+ ];
1115
+ }
1042
1116
  /** Build the Hear streaming-STT WebSocket URL (`/v1/audio/transcriptions/stream`). */
1043
1117
  hearStreamURL(opts = {}) {
1044
1118
  const wsBase = this.baseURL.replace(/^http/, "ws");
1045
1119
  const q = new URLSearchParams(opts.query ?? {});
1120
+ // `context` is private to PyAI's adapter-to-server hop. Never expose or
1121
+ // forward it from the public SDK escape hatch.
1122
+ q.delete("context");
1046
1123
  q.set("protocol", "pyai-hear-v1");
1047
1124
  if (opts.model)
1048
1125
  q.set("model", opts.model);
@@ -1056,6 +1133,15 @@ export class PyAI {
1056
1133
  q.set("interim_results", String(opts.interimResults));
1057
1134
  if (opts.numerals !== undefined)
1058
1135
  q.set("numerals", String(opts.numerals));
1136
+ if (opts.smartFormat !== undefined)
1137
+ q.set("smart_format", String(opts.smartFormat));
1138
+ if (opts.dictation !== undefined)
1139
+ q.set("dictation", String(opts.dictation));
1140
+ if (opts.dropFillers !== undefined)
1141
+ q.set("drop_fillers", String(opts.dropFillers));
1142
+ if (opts.vocabulary?.length) {
1143
+ q.set("vocabulary", JSON.stringify(opts.vocabulary));
1144
+ }
1059
1145
  if (opts.endpointingMs !== undefined)
1060
1146
  q.set("endpointing_ms", String(opts.endpointingMs));
1061
1147
  const qs = q.toString();
@@ -1080,8 +1166,8 @@ export class PyAI {
1080
1166
  connectRealtime(opts = {}) {
1081
1167
  const WS = globalThis.WebSocket;
1082
1168
  if (!WS)
1083
- throw new Error("No global WebSocket; use realtimeURL()/realtimeSubprotocol() with a WS library");
1084
- return new WS(this.realtimeURL(opts), [this.realtimeSubprotocol()]);
1169
+ throw new Error("No global WebSocket; use realtimeURL()/realtimeSubprotocols() with a WS library");
1170
+ return new WS(this.realtimeURL(opts), this.realtimeSubprotocols());
1085
1171
  }
1086
1172
  }
1087
1173
  export default PyAI;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyai/sdk",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Official TypeScript/JavaScript SDK for PyAI, speech-to-text (Hear), text-to-speech (Speak), realtime voice agents (Omni), and call compliance (Trace).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/index.ts CHANGED
@@ -41,6 +41,20 @@ export interface Voice {
41
41
  name?: string;
42
42
  gender?: string;
43
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
+ };
44
58
  [k: string]: unknown;
45
59
  }
46
60
 
@@ -104,6 +118,13 @@ export interface SpeechParams {
104
118
  input: string;
105
119
  voice?: string;
106
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;
107
128
  /**
108
129
  * Output container/codec, resampled+encoded server-side. One of
109
130
  * {@link SpeechFormat}, anything else is a `400 unsupported_format`. Omit for
@@ -150,8 +171,62 @@ export interface CreateJobParams {
150
171
  diarize?: boolean;
151
172
  channel?: boolean;
152
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[];
153
183
  output_formats?: Array<"json" | "srt" | "vtt">;
154
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;
155
230
  }
156
231
 
157
232
  export interface RealtimeOptions {
@@ -226,10 +301,16 @@ export const HearFrameType = {
226
301
  } as const;
227
302
  export type HearFrameType = (typeof HearFrameType)[keyof typeof HearFrameType];
228
303
 
229
- /** 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
+ */
230
309
  export const WSCloseCode = {
231
310
  /** Normal closure. */
232
311
  Normal: 1000,
312
+ /** Browser-safe private application code for a malformed peer protocol frame. */
313
+ ProtocolViolation: 4002,
233
314
  /** Auth/policy: bad key, missing scope, or revoked token. */
234
315
  PolicyViolation: 1008,
235
316
  /** Engine/internal error. */
@@ -258,6 +339,7 @@ export const ErrorCode = {
258
339
  IdempotencyConflict: "idempotency_conflict",
259
340
  NotFound: "not_found",
260
341
  NumberInUse: "number_in_use",
342
+ UnsupportedToolTransport: "unsupported_tool_transport",
261
343
  } as const;
262
344
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
263
345
 
@@ -366,29 +448,61 @@ export interface WebSocketLike {
366
448
  onclose: ((ev: { code: number; reason: string }) => void) | null;
367
449
  }
368
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
+
369
466
  export type WebSocketCtor = new (url: string, protocols?: string | string[]) => WebSocketLike;
370
467
 
371
468
  export interface HearStreamOptions {
372
469
  /** Streaming STT model. Server default `pyai-hear`. */
373
470
  model?: 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";
471
+ /** Omit or use auto for automatic detection; an explicit code pins recognition. */
472
+ language?: "auto" | "en" | "es" | "fr" | "de" | "hi" | "it" | "pt" | "nl";
380
473
  /** Input PCM sample rate in Hz. Default 16000 server-side. */
381
474
  sampleRate?: number;
382
475
  /** Audio frame encoding. Default "pcm16". */
383
- encoding?: "pcm16" | "opus";
476
+ encoding?: "pcm16";
384
477
  /** Emit eager partial hypotheses. Default true server-side. */
385
478
  interimResults?: boolean;
386
479
  /**
387
- * Format spoken numbers as digits in the transcript (e.g. "one two three" →
388
- * "123"). Useful for voice agents that read back phone numbers, codes, and
389
- * 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`.
390
483
  */
391
484
  numerals?: boolean;
485
+ /**
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[];
392
506
  /**
393
507
  * Minimum trailing-pause length before an utterance may end (50-5000 ms).
394
508
  * Turn detection may wait longer, bounded at `max(endpointingMs, 1500)`.
@@ -454,7 +568,7 @@ export class HearStream {
454
568
  "No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to transcriptions.stream()",
455
569
  );
456
570
  }
457
- this.ws = new WS(url, [subprotocol]);
571
+ this.ws = new WS(url, [REALTIME_SUBPROTOCOL_MARKER, subprotocol]);
458
572
  this.ws.onopen = () => {
459
573
  opts.onOpen?.();
460
574
  };
@@ -553,7 +667,7 @@ export class AmdStream {
553
667
  "No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to amd.stream()",
554
668
  );
555
669
  }
556
- this.ws = new WS(url, [subprotocol]);
670
+ this.ws = new WS(url, [REALTIME_SUBPROTOCOL_MARKER, subprotocol]);
557
671
  this.ws.onopen = () => opts.onOpen?.();
558
672
  this.ws.onmessage = (ev) => this.handleMessage(ev.data);
559
673
  this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
@@ -629,6 +743,8 @@ export const OmniEvent = {
629
743
  Flush: "flush",
630
744
  /** Engine requests a client-loop tool invocation. */
631
745
  ToolCall: "tool_call",
746
+ /** A write tool is waiting for the caller to confirm; it has not run. */
747
+ ToolConfirmationRequired: "tool_confirmation_required",
632
748
  /** Session is closing; see close code. */
633
749
  SessionEnd: "session_end",
634
750
  /** Server fault frame. */
@@ -762,8 +878,6 @@ export interface OmniToolDef {
762
878
  name: string;
763
879
  description?: string;
764
880
  parameters?: Record<string, unknown>;
765
- /** When set, engine-POST mode. Omit for client-loop (default). */
766
- endpoint?: string;
767
881
  }
768
882
 
769
883
  export interface OmniToolCallFrame {
@@ -801,7 +915,9 @@ export interface OmniConfigure {
801
915
  * staged, see the Language support reference.
802
916
  */
803
917
  language?: "en" | "fr" | "es" | "de" | "hi";
804
- /** 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`. */
805
921
  tools?: OmniToolDef[];
806
922
  /** Forward-compatible: any other key the engine honors. */
807
923
  [k: string]: unknown;
@@ -894,7 +1010,7 @@ export class OmniConnection {
894
1010
  "No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to omni.connect()",
895
1011
  );
896
1012
  }
897
- this.ws = new WS(url, [subprotocol]);
1013
+ this.ws = new WS(url, [REALTIME_SUBPROTOCOL_MARKER, subprotocol]);
898
1014
  this.ws.onopen = () => {
899
1015
  if (opts.configure) {
900
1016
  try {
@@ -1359,8 +1475,41 @@ export interface RecapCallSummary {
1359
1475
  completed_at?: number | null;
1360
1476
  }
1361
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
+
1362
1510
  export interface RecapCall extends RecapCallSummary {
1363
- record?: unknown;
1511
+ record?: RecapRecord;
1512
+ transcript?: { format: "utterances.v1"; utterances: Array<{ speaker_role: "agent" | "customer"; text: string; offset_s: number; duration_s: number }> };
1364
1513
  error?: string | null;
1365
1514
  crm_write_status?: string | null;
1366
1515
  }
@@ -1574,10 +1723,19 @@ export class PyAI {
1574
1723
  // --- voices -------------------------------------------------------------
1575
1724
 
1576
1725
  voices = {
1577
- list: async (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>> => {
1578
1733
  const q = new URLSearchParams();
1579
1734
  if (params.gender) q.set("gender", params.gender);
1580
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);
1581
1739
  const qs = q.toString();
1582
1740
  const page = await this.getJson<ListResponse<Record<string, unknown>>>(
1583
1741
  `/v1/voices${qs ? `?${qs}` : ""}`,
@@ -1592,6 +1750,46 @@ export class PyAI {
1592
1750
  ),
1593
1751
  };
1594
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
+ };
1790
+ },
1791
+ };
1792
+
1595
1793
  // --- audio --------------------------------------------------------------
1596
1794
 
1597
1795
  audio = {
@@ -1608,10 +1806,10 @@ export class PyAI {
1608
1806
  /**
1609
1807
  * Text-to-speech, streamed. Resolves as soon as the response headers arrive
1610
1808
  * with the body as a `ReadableStream` of audio bytes, so you can start
1611
- * playback or forward the audio at the first chunk (the engine's
1612
- * time-to-first-byte is tens of ms) instead of buffering the whole clip.
1613
- * Use `mp3` for the smoothest progressive playback. Returns an async
1614
- * 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.
1615
1813
  */
1616
1814
  speechStream: async (params: SpeechParams): Promise<ReadableStream<Uint8Array>> => {
1617
1815
  assertActiveSpeechParams(params);
@@ -1629,11 +1827,13 @@ export class PyAI {
1629
1827
  file: Blob;
1630
1828
  filename?: string;
1631
1829
  model?: string;
1632
- /**
1633
- * Hear is English-only. Omission means English, not auto-detect; other
1634
- * values receive `400 unsupported_language`.
1635
- */
1636
- language?: "en";
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[];
1637
1837
  response_format?: "json" | "text" | "verbose_json";
1638
1838
  /**
1639
1839
  * Deterministic seed for reproducible eval runs. Forward-compatible:
@@ -1647,6 +1847,11 @@ export class PyAI {
1647
1847
  form.set("file", params.file, params.filename ?? "audio.wav");
1648
1848
  form.set("model", params.model ?? "pyai-hear");
1649
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(","));
1650
1855
  if (params.response_format) form.set("response_format", params.response_format);
1651
1856
  if (params.seed !== undefined) form.set("seed", String(params.seed));
1652
1857
  if (params.temperature !== undefined) form.set("temperature", String(params.temperature));
@@ -1921,7 +2126,7 @@ export class PyAI {
1921
2126
  * realtime. **Call this from your server** with a secret key holding
1922
2127
  * `omni:session`; never ship the secret key to a page. Hand the returned
1923
2128
  * `token` to the browser, which connects with
1924
- * `new WebSocket(session.url, ["pyai-key." + session.token])`. The token
2129
+ * `new WebSocket(session.url, ["pyai.v1", "pyai-key." + session.token])`. The token
1925
2130
  * expires after `ttlSeconds` (default 60s) and only works from
1926
2131
  * `allowedOrigins`. Scope `omni:session`.
1927
2132
  */
@@ -1973,15 +2178,31 @@ export class PyAI {
1973
2178
  return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
1974
2179
  }
1975
2180
 
1976
- /** 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. */
1977
2182
  realtimeSubprotocol(): string {
1978
2183
  return `pyai-key.${this.apiKey}`;
1979
2184
  }
1980
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
+
1981
2199
  /** Build the Hear streaming-STT WebSocket URL (`/v1/audio/transcriptions/stream`). */
1982
2200
  hearStreamURL(opts: HearStreamOptions = {}): string {
1983
2201
  const wsBase = this.baseURL.replace(/^http/, "ws");
1984
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");
1985
2206
  q.set("protocol", "pyai-hear-v1");
1986
2207
  if (opts.model) q.set("model", opts.model);
1987
2208
  if (opts.language) q.set("language", opts.language);
@@ -1989,6 +2210,12 @@ export class PyAI {
1989
2210
  if (opts.encoding) q.set("encoding", opts.encoding);
1990
2211
  if (opts.interimResults !== undefined) q.set("interim_results", String(opts.interimResults));
1991
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
+ }
1992
2219
  if (opts.endpointingMs !== undefined) q.set("endpointing_ms", String(opts.endpointingMs));
1993
2220
  const qs = q.toString();
1994
2221
  return `${wsBase}/v1/audio/transcriptions/stream${qs ? `?${qs}` : ""}`;
@@ -2011,8 +2238,8 @@ export class PyAI {
2011
2238
  */
2012
2239
  connectRealtime(opts: RealtimeOptions = {}): WebSocket {
2013
2240
  const WS = (globalThis as { WebSocket?: typeof WebSocket }).WebSocket;
2014
- if (!WS) throw new Error("No global WebSocket; use realtimeURL()/realtimeSubprotocol() with a WS library");
2015
- 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());
2016
2243
  }
2017
2244
  }
2018
2245