@pyai/sdk 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -20,6 +20,10 @@ export class PyAIError extends Error {
20
20
  this.requestId = requestId;
21
21
  }
22
22
  }
23
+ function normalizeVoice(value) {
24
+ const voiceId = String(value.voice_id ?? value.id ?? "");
25
+ return { ...value, voice_id: voiceId, id: voiceId };
26
+ }
23
27
  /**
24
28
  * The runtime list of accepted `audio.speech` formats (mirrors {@link SpeechFormat}),
25
29
  * for building dropdowns / validating input before a request. Branch on the
@@ -28,6 +32,13 @@ export class PyAIError extends Error {
28
32
  export const SPEECH_FORMATS = ["wav", "mp3", "opus", "aac", "flac", "pcm", "g711_ulaw", "g711_alaw"];
29
33
  /** Sample rates (Hz) the server accepts for `audio.speech` (`g711_*` is always 8 kHz). */
30
34
  export const SPEECH_SAMPLE_RATES = [8000, 16000, 24000, 48000];
35
+ function assertActiveSpeechParams(params) {
36
+ for (const field of ["speed", "seed", "temperature"]) {
37
+ if (params[field] !== undefined) {
38
+ throw new Error(`${field} is reserved but not active on Speak`);
39
+ }
40
+ }
41
+ }
31
42
  /* ------------------------------------------------------------------------- *
32
43
  * Stable enums, mirror the server so callers branch on named constants, not
33
44
  * magic strings, and a contract change surfaces in one place. These are plain
@@ -36,6 +47,8 @@ export const SPEECH_SAMPLE_RATES = [8000, 16000, 24000, 48000];
36
47
  * ------------------------------------------------------------------------- */
37
48
  /** Frame `type`s emitted by the Hear streaming-STT WebSocket. */
38
49
  export const HearFrameType = {
50
+ /** Applied endpointing configuration and validation warnings. */
51
+ ConfigAck: "config_ack",
39
52
  /** Eager live hypothesis for the current utterance. */
40
53
  Partial: "partial",
41
54
  /** Partial whose prefix has stabilized (won't be revised). */
@@ -69,7 +82,7 @@ export const ErrorCode = {
69
82
  Unauthorized: "unauthorized",
70
83
  Forbidden: "forbidden",
71
84
  OriginNotAllowed: "origin_not_allowed",
72
- InvalidAgentId: "invalid_agent_id",
85
+ InvalidSessionLabel: "invalid_session_label",
73
86
  CreditExhausted: "credit_exhausted",
74
87
  KeyBudgetExceeded: "key_budget_exceeded",
75
88
  InsufficientQuota: "insufficient_quota",
@@ -83,8 +96,10 @@ export const ErrorCode = {
83
96
  /**
84
97
  * A live Hear streaming-STT session. Hides the frame protocol: stream audio
85
98
  * with {@link HearStream.sendAudio}, get `onPartial`/`onFinal`/`onError`
86
- * callbacks, force-finalize with {@link HearStream.commit}, and flush+close
87
- * with {@link HearStream.close}. Construct via `pyai.audio.transcriptions.stream()`.
99
+ * callbacks, update the silence floor with
100
+ * {@link HearStream.configureEndpointing}, force-finalize with
101
+ * {@link HearStream.commit}, and flush+close with {@link HearStream.close}.
102
+ * Construct via `pyai.audio.transcriptions.stream()`.
88
103
  */
89
104
  export class HearStream {
90
105
  ws;
@@ -92,27 +107,15 @@ export class HearStream {
92
107
  closed = false;
93
108
  constructor(url, subprotocol, opts) {
94
109
  this.opts = opts;
110
+ if (opts.grounding) {
111
+ throw new Error("Cue grounding is not active on the serving Hear stream; omit grounding until the API reference marks it active");
112
+ }
95
113
  const WS = opts.webSocket ?? globalThis.WebSocket;
96
114
  if (!WS) {
97
115
  throw new Error("No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to transcriptions.stream()");
98
116
  }
99
117
  this.ws = new WS(url, [subprotocol]);
100
118
  this.ws.onopen = () => {
101
- if (opts.grounding) {
102
- try {
103
- const cfg = { type: "config", grounding: true };
104
- if (opts.groundingK != null)
105
- cfg.grounding_k = opts.groundingK;
106
- if (opts.groundingMinScore != null)
107
- cfg.grounding_min_score = opts.groundingMinScore;
108
- if (opts.groundingTimeoutMs != null)
109
- cfg.grounding_timeout_ms = opts.groundingTimeoutMs;
110
- this.ws.send(JSON.stringify(cfg));
111
- }
112
- catch {
113
- /* surfaced via onerror */
114
- }
115
- }
116
119
  opts.onOpen?.();
117
120
  };
118
121
  this.ws.onmessage = (ev) => this.handleMessage(ev.data);
@@ -135,6 +138,9 @@ export class HearStream {
135
138
  return;
136
139
  }
137
140
  switch (frame.type) {
141
+ case HearFrameType.ConfigAck:
142
+ this.opts.onConfigAck?.(frame);
143
+ break;
138
144
  case HearFrameType.Partial:
139
145
  case HearFrameType.PartialStable:
140
146
  this.opts.onPartial?.(frame);
@@ -158,6 +164,10 @@ export class HearStream {
158
164
  sendAudio(chunk) {
159
165
  this.ws.send(chunk);
160
166
  }
167
+ /** Change the minimum trailing-pause floor without reconnecting. */
168
+ configureEndpointing(endpointingMs) {
169
+ this.ws.send(JSON.stringify({ type: "config", endpointing_ms: endpointingMs }));
170
+ }
161
171
  /** Force-finalize the current utterance (e.g. on VAD end-of-turn). */
162
172
  commit() {
163
173
  this.ws.send(JSON.stringify({ type: "commit" }));
@@ -274,13 +284,6 @@ export const OmniEvent = {
274
284
  };
275
285
  const OMNI_TRANSCRIPT_MAX_BYTES = 16_384;
276
286
  const OMNI_TRANSCRIPT_MAX_CHARS = 4_000;
277
- function omniTranscriptRole(value) {
278
- if (value === "user" || value === "caller" || value === "human")
279
- return "user";
280
- if (value === "assistant" || value === "agent")
281
- return "assistant";
282
- return null;
283
- }
284
287
  function omniTranscriptText(value) {
285
288
  return typeof value === "string"
286
289
  && value.length > 0
@@ -289,7 +292,14 @@ function omniTranscriptText(value) {
289
292
  ? value
290
293
  : null;
291
294
  }
292
- /** Normalize the live UTF-8 0x02 body and the documented direct-object legacy shape. */
295
+ function omniTranscriptRole(value) {
296
+ if (value === "user" || value === "caller" || value === "human")
297
+ return "user";
298
+ if (value === "assistant" || value === "agent")
299
+ return "assistant";
300
+ return null;
301
+ }
302
+ /** Normalize the live UTF-8 `0x02` text body and bounded legacy JSON bodies. */
293
303
  export function normalizeOmniTranscriptBody(bytes) {
294
304
  if (bytes.byteLength === 0 || bytes.byteLength > OMNI_TRANSCRIPT_MAX_BYTES)
295
305
  return null;
@@ -300,12 +310,15 @@ export function normalizeOmniTranscriptBody(bytes) {
300
310
  catch {
301
311
  return null;
302
312
  }
313
+ // The serving engine's canonical payload is plain UTF-8 caller text, not
314
+ // JSON. Each frame is a delta for the current caller turn.
303
315
  if (!decoded.trimStart().startsWith("{")) {
304
316
  const text = omniTranscriptText(decoded);
305
317
  return text
306
318
  ? { event: "transcript", role: "user", text, final: false, mode: "delta" }
307
319
  : null;
308
320
  }
321
+ // Keep accepting a bounded direct object for older bridges and recordings.
309
322
  let value;
310
323
  try {
311
324
  value = JSON.parse(decoded);
@@ -316,6 +329,9 @@ export function normalizeOmniTranscriptBody(bytes) {
316
329
  if (!value || typeof value !== "object" || Array.isArray(value))
317
330
  return null;
318
331
  const payload = value;
332
+ if (payload.type !== undefined
333
+ || (payload.event !== undefined && payload.event !== "transcript"))
334
+ return null;
319
335
  const role = omniTranscriptRole(payload.role ?? payload.speaker);
320
336
  const mode = typeof payload.delta === "string" ? "delta" : "replace";
321
337
  const text = omniTranscriptText(mode === "delta" ? payload.delta : typeof payload.text === "string" ? payload.text : payload.transcript);
@@ -454,7 +470,11 @@ export class OmniConnection {
454
470
  }
455
471
  if (tag === 0x03) {
456
472
  try {
457
- const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1)));
473
+ const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(1)));
474
+ if (parsed?.event === OmniEvent.Transcript) {
475
+ this.opts.onError?.(new Error("Omni transcript events must use a binary 0x02 frame"));
476
+ return;
477
+ }
458
478
  this.dispatchFrame(parsed);
459
479
  }
460
480
  catch {
@@ -466,22 +486,15 @@ export class OmniConnection {
466
486
  this.opts.onError?.(new Error(`Ignored unknown Omni binary frame tag ${tagName}`));
467
487
  return;
468
488
  }
469
- // Text frame (e.g. a server-side broker relay).
470
- try {
471
- this.dispatchFrame(JSON.parse(data));
472
- }
473
- catch {
474
- this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
475
- }
489
+ this.opts.onError?.(new Error("Unexpected Omni text frame; server frames must use binary 0x01/0x02/0x03 tags"));
476
490
  }
477
491
  dispatchFrame(frame) {
478
- const raw = frame;
479
- const eventName = typeof raw.event === "string"
480
- ? raw.event
481
- : typeof raw.type === "string"
482
- ? raw.type
483
- : "";
484
- this.opts.onEvent?.({ ...frame, event: eventName });
492
+ const eventName = frame.event;
493
+ if (typeof eventName !== "string" || !eventName) {
494
+ this.opts.onError?.(new Error("Omni server control frame is missing its event key"));
495
+ return;
496
+ }
497
+ this.opts.onEvent?.(frame);
485
498
  switch (eventName) {
486
499
  case OmniEvent.Hello:
487
500
  this.opts.onHello?.(frame);
@@ -522,7 +535,10 @@ export class OmniConnection {
522
535
  * `{"event":"configure"}` is acked but silently dropped by the engine.)
523
536
  */
524
537
  configure(cfg) {
525
- this.ws.send(omniControlFrame({ type: "configure", ...cfg }));
538
+ const payload = { ...cfg };
539
+ delete payload.type;
540
+ delete payload.event;
541
+ this.ws.send(omniControlFrame({ type: "configure", ...payload }));
526
542
  }
527
543
  /**
528
544
  * Stream a chunk of caller audio (PCM16 LE mono at the negotiated rate) as a
@@ -558,7 +574,12 @@ export class OmniConnection {
558
574
  * never `event`.
559
575
  */
560
576
  send(frame) {
561
- this.ws.send(omniControlFrame(frame));
577
+ if (typeof frame.type !== "string" || !frame.type) {
578
+ throw new TypeError("Omni client control frames must have a non-empty type key");
579
+ }
580
+ const payload = { ...frame };
581
+ delete payload.event;
582
+ this.ws.send(omniControlFrame(payload));
562
583
  }
563
584
  /** Close the session. */
564
585
  close(code = WSCloseCode.Normal, reason = "") {
@@ -652,25 +673,27 @@ export class PyAI {
652
673
  };
653
674
  // --- voices -------------------------------------------------------------
654
675
  voices = {
655
- list: (params = {}) => {
676
+ list: async (params = {}) => {
656
677
  const q = new URLSearchParams();
657
678
  if (params.gender)
658
679
  q.set("gender", params.gender);
659
680
  if (params.region)
660
681
  q.set("region", params.region);
661
682
  const qs = q.toString();
662
- return this.getJson(`/v1/voices${qs ? `?${qs}` : ""}`);
683
+ const page = await this.getJson(`/v1/voices${qs ? `?${qs}` : ""}`);
684
+ return { ...page, data: page.data.map(normalizeVoice) };
663
685
  },
664
- get: (id) => this.getJson(`/v1/voices/${encodeURIComponent(id)}`),
686
+ get: async (id) => normalizeVoice(await this.getJson(`/v1/voices/${encodeURIComponent(id)}`)),
665
687
  };
666
688
  // --- audio --------------------------------------------------------------
667
689
  audio = {
668
690
  /** Text-to-speech. Returns the raw audio bytes (default WAV). */
669
691
  speech: async (params) => {
692
+ assertActiveSpeechParams(params);
670
693
  const res = await this.request("/v1/audio/speech", {
671
694
  method: "POST",
672
695
  headers: this.authHeaders({ "Content-Type": "application/json" }),
673
- body: JSON.stringify({ model: "pyai-voice", ...params }),
696
+ body: JSON.stringify({ model: "pyai-speak", ...params }),
674
697
  });
675
698
  return res.arrayBuffer();
676
699
  },
@@ -683,10 +706,11 @@ export class PyAI {
683
706
  * iterable of Uint8Array chunks.
684
707
  */
685
708
  speechStream: async (params) => {
709
+ assertActiveSpeechParams(params);
686
710
  const res = await this.request("/v1/audio/speech", {
687
711
  method: "POST",
688
712
  headers: this.authHeaders({ "Content-Type": "application/json" }),
689
- body: JSON.stringify({ model: "pyai-voice", ...params, stream: true }),
713
+ body: JSON.stringify({ model: "pyai-speak", ...params, stream: true }),
690
714
  });
691
715
  if (!res.body)
692
716
  throw new PyAIError(res.status, "Response had no body to stream");
@@ -756,7 +780,7 @@ export class PyAI {
756
780
  clones = {
757
781
  /** List the org's cloned voices. */
758
782
  list: () => this.getJson("/v1/voice/clones"),
759
- /** Enroll a custom voice from reference audio (>= ~10s). Scope `voice:clone`. */
783
+ /** Enroll a custom voice from reference audio (>= ~10s). Scope `speak:clone`. */
760
784
  create: async (params) => {
761
785
  const form = new FormData();
762
786
  form.set("name", params.name);
@@ -776,7 +800,7 @@ export class PyAI {
776
800
  throw new PyAIError(404, `No cloned voice ${id}`, ErrorCode.NotFound);
777
801
  return match;
778
802
  },
779
- /** Delete a cloned voice (tenant-isolated). Scope `voice:clone`. */
803
+ /** Delete a cloned voice (tenant-isolated). Scope `speak:clone`. */
780
804
  delete: async (id) => {
781
805
  await this.deleteReq(`/v1/voice/clones/${encodeURIComponent(id)}`);
782
806
  },
@@ -985,34 +1009,31 @@ export class PyAI {
985
1009
  query.format = opts.format;
986
1010
  if (opts.rate)
987
1011
  query.rate = String(opts.rate);
988
- const url = this.realtimeURL({ product: "omni", sessionLabel: opts.sessionLabel, query });
1012
+ const url = this.realtimeURL({ sessionLabel: opts.sessionLabel, query });
989
1013
  const sub = opts.token ? `pyai-key.${opts.token}` : this.realtimeSubprotocol();
990
1014
  return new OmniConnection(url, sub, opts);
991
1015
  },
992
1016
  };
993
1017
  // --- realtime (WebSocket) ----------------------------------------------
994
- /** Build the realtime WebSocket URL for the chosen product. */
1018
+ /** Build the canonical Omni WebSocket URL. */
995
1019
  realtimeURL(opts = {}) {
996
1020
  const wsBase = this.baseURL.replace(/^http/, "ws");
997
1021
  const q = new URLSearchParams(opts.query ?? {});
998
- if ((opts.product ?? "omni") === "omni") {
999
- // Omni's native realtime surface is /v1/omni. The session is authorized by
1000
- // the key's org (zero-state), sessionLabel is an optional opaque tag.
1001
- // format/rate are load-bearing on the connect URL, so default to
1002
- // browser-grade PCM16/24kHz.
1003
- if (opts.sessionLabel)
1004
- q.set("session_label", opts.sessionLabel);
1005
- else if (opts.agentId)
1006
- q.set("agent_id", opts.agentId); // deprecated alias
1007
- if (!q.has("format"))
1008
- q.set("format", "pcm16");
1009
- if (!q.has("rate"))
1010
- q.set("rate", "24000");
1011
- const qs = q.toString();
1012
- return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
1022
+ // format/rate are load-bearing on the connect URL, so default to
1023
+ // browser-grade PCM16/24kHz.
1024
+ for (const key of ["agent", "agent_id", "agentId", "model", "access_token"]) {
1025
+ if (q.has(key)) {
1026
+ throw new Error(`Omni query parameter "${key}" is not supported; use sessionLabel, format/rate, or api_key`);
1027
+ }
1013
1028
  }
1014
- q.set("model", "pyai-flow-realtime");
1015
- return `${wsBase}/v1/realtime?${q.toString()}`;
1029
+ if (opts.sessionLabel)
1030
+ q.set("session_label", opts.sessionLabel);
1031
+ if (!q.has("format"))
1032
+ q.set("format", "pcm16");
1033
+ if (!q.has("rate"))
1034
+ q.set("rate", "24000");
1035
+ const qs = q.toString();
1036
+ return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
1016
1037
  }
1017
1038
  /** The subprotocol that carries the key on a WS upgrade (browser-safe auth). */
1018
1039
  realtimeSubprotocol() {
@@ -1022,6 +1043,7 @@ export class PyAI {
1022
1043
  hearStreamURL(opts = {}) {
1023
1044
  const wsBase = this.baseURL.replace(/^http/, "ws");
1024
1045
  const q = new URLSearchParams(opts.query ?? {});
1046
+ q.set("protocol", "pyai-hear-v1");
1025
1047
  if (opts.model)
1026
1048
  q.set("model", opts.model);
1027
1049
  if (opts.language)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyai/sdk",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
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",
@@ -14,7 +14,11 @@
14
14
  "bin": {
15
15
  "pyai": "dist/cli.js"
16
16
  },
17
- "files": ["dist", "src", "README.md"],
17
+ "files": [
18
+ "dist",
19
+ "src",
20
+ "README.md"
21
+ ],
18
22
  "repository": {
19
23
  "type": "git",
20
24
  "url": "git+https://github.com/atomsai/pyai-platform-backend.git",
@@ -31,7 +35,18 @@
31
35
  "engines": {
32
36
  "node": ">=18"
33
37
  },
34
- "keywords": ["pyai", "voice-ai", "tts", "stt", "speech", "voice-agents", "realtime", "transcription", "compliance", "openai-compatible"],
38
+ "keywords": [
39
+ "pyai",
40
+ "voice-ai",
41
+ "tts",
42
+ "stt",
43
+ "speech",
44
+ "voice-agents",
45
+ "realtime",
46
+ "transcription",
47
+ "compliance",
48
+ "openai-compatible"
49
+ ],
35
50
  "license": "MIT",
36
51
  "devDependencies": {
37
52
  "@types/node": "^22.0.0",