@pyai/sdk 0.3.1 → 0.5.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
@@ -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