@pyai/sdk 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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" }));
@@ -272,6 +282,77 @@ export const OmniEvent = {
272
282
  /** Server fault frame. */
273
283
  Error: "error",
274
284
  };
285
+ const OMNI_TRANSCRIPT_MAX_BYTES = 16_384;
286
+ const OMNI_TRANSCRIPT_MAX_CHARS = 4_000;
287
+ function omniTranscriptText(value) {
288
+ return typeof value === "string"
289
+ && value.length > 0
290
+ && value.length <= OMNI_TRANSCRIPT_MAX_CHARS
291
+ && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
292
+ ? value
293
+ : null;
294
+ }
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. */
303
+ export function normalizeOmniTranscriptBody(bytes) {
304
+ if (bytes.byteLength === 0 || bytes.byteLength > OMNI_TRANSCRIPT_MAX_BYTES)
305
+ return null;
306
+ let decoded;
307
+ try {
308
+ decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
309
+ }
310
+ catch {
311
+ return null;
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.
315
+ if (!decoded.trimStart().startsWith("{")) {
316
+ const text = omniTranscriptText(decoded);
317
+ return text
318
+ ? { event: "transcript", role: "user", text, final: false, mode: "delta" }
319
+ : null;
320
+ }
321
+ // Keep accepting a bounded direct object for older bridges and recordings.
322
+ let value;
323
+ try {
324
+ value = JSON.parse(decoded);
325
+ }
326
+ catch {
327
+ return null;
328
+ }
329
+ if (!value || typeof value !== "object" || Array.isArray(value))
330
+ return null;
331
+ const payload = value;
332
+ if (payload.type !== undefined
333
+ || (payload.event !== undefined && payload.event !== "transcript"))
334
+ return null;
335
+ const role = omniTranscriptRole(payload.role ?? payload.speaker);
336
+ const mode = typeof payload.delta === "string" ? "delta" : "replace";
337
+ const text = omniTranscriptText(mode === "delta" ? payload.delta : typeof payload.text === "string" ? payload.text : payload.transcript);
338
+ if (!role || !text)
339
+ return null;
340
+ if (payload.final !== undefined && typeof payload.final !== "boolean")
341
+ return null;
342
+ if (payload.sequence !== undefined
343
+ && (typeof payload.sequence !== "number"
344
+ || !Number.isSafeInteger(payload.sequence)
345
+ || payload.sequence < 0))
346
+ return null;
347
+ return {
348
+ event: "transcript",
349
+ role,
350
+ text,
351
+ final: payload.final === true,
352
+ mode,
353
+ ...(payload.sequence === undefined ? {} : { sequence: payload.sequence }),
354
+ };
355
+ }
275
356
  /** Extract a byte view from a binary WS frame (Buffer / ArrayBuffer / typed
276
357
  * array). Returns null for a Blob or unknown (can't be read synchronously). */
277
358
  function omniToBytes(data) {
@@ -324,6 +405,8 @@ export class OmniConnection {
324
405
  closed = false;
325
406
  /** Serializes async Blob reads in {@link sendAudio} so frames stay ordered. */
326
407
  blobTail = Promise.resolve();
408
+ /** Serializes inbound Blob decoding so browser frames stay ordered. */
409
+ inboundTail = Promise.resolve();
327
410
  constructor(url, subprotocol, opts) {
328
411
  this.opts = opts;
329
412
  const WS = opts.webSocket ?? globalThis.WebSocket;
@@ -342,22 +425,34 @@ export class OmniConnection {
342
425
  }
343
426
  opts.onOpen?.();
344
427
  };
345
- this.ws.onmessage = (ev) => this.handleMessage(ev.data);
428
+ this.ws.onmessage = (ev) => {
429
+ const reportDecodeError = (error) => {
430
+ opts.onError?.(error instanceof Error ? error : new Error("Could not decode Omni frame"));
431
+ };
432
+ if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
433
+ this.inboundTail = this.inboundTail.then(() => this.handleMessage(ev.data)).catch(reportDecodeError);
434
+ }
435
+ else {
436
+ void this.handleMessage(ev.data).catch(reportDecodeError);
437
+ }
438
+ };
346
439
  this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
347
440
  this.ws.onclose = (ev) => {
348
441
  this.closed = true;
349
442
  opts.onClose?.(ev.code, ev.reason);
350
443
  };
351
444
  }
352
- handleMessage(data) {
445
+ async handleMessage(data) {
353
446
  // Server → client binary frames are TYPE-TAGGED by their first byte:
354
- // 0x01 = agent audio (PCM16) · 0x02 = transcript JSON · 0x03 = control JSON.
447
+ // 0x01 = agent audio (PCM16) · 0x02 = transcript UTF-8 · 0x03 = control JSON.
355
448
  // (Treating every binary frame as audio, the old behavior, plays the
356
449
  // 0x03/0x02 frames as a glitch and drops every event/transcript.)
357
450
  if (typeof data !== "string") {
358
- const bytes = omniToBytes(data);
451
+ const bytes = typeof Blob !== "undefined" && data instanceof Blob
452
+ ? new Uint8Array(await data.arrayBuffer())
453
+ : omniToBytes(data);
359
454
  if (!bytes) {
360
- this.opts.onAudio?.(data); // Blob/unknown, best-effort
455
+ this.opts.onError?.(new Error("Unsupported Omni binary frame"));
361
456
  return;
362
457
  }
363
458
  const tag = bytes[0];
@@ -365,36 +460,41 @@ export class OmniConnection {
365
460
  this.opts.onAudio?.(bytes.slice(1)); // copy → aligned PCM16
366
461
  return;
367
462
  }
368
- if (tag === 0x02 || tag === 0x03) {
463
+ if (tag === 0x02) {
464
+ const transcript = normalizeOmniTranscriptBody(bytes.subarray(1));
465
+ if (transcript)
466
+ this.dispatchFrame(transcript);
467
+ else
468
+ this.opts.onError?.(new Error("Unparseable Omni transcript frame"));
469
+ return;
470
+ }
471
+ if (tag === 0x03) {
369
472
  try {
370
- const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1)));
371
- // The 0x02 tag is authoritative for transcript even if the JSON omits `event`.
372
- this.dispatchFrame(tag === 0x02 ? { ...parsed, event: "transcript" } : parsed);
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
+ }
478
+ this.dispatchFrame(parsed);
373
479
  }
374
480
  catch {
375
481
  this.opts.onError?.(new Error("Unparseable Omni binary frame"));
376
482
  }
377
483
  return;
378
484
  }
379
- this.opts.onAudio?.(data); // untagged, forward-compat as audio
485
+ const tagName = tag === undefined ? "empty" : `0x${tag.toString(16).padStart(2, "0")}`;
486
+ this.opts.onError?.(new Error(`Ignored unknown Omni binary frame tag ${tagName}`));
380
487
  return;
381
488
  }
382
- // Text frame (e.g. a server-side broker relay).
383
- try {
384
- this.dispatchFrame(JSON.parse(data));
385
- }
386
- catch {
387
- this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
388
- }
489
+ this.opts.onError?.(new Error("Unexpected Omni text frame; server frames must use binary 0x01/0x02/0x03 tags"));
389
490
  }
390
491
  dispatchFrame(frame) {
391
- const raw = frame;
392
- const eventName = typeof raw.event === "string"
393
- ? raw.event
394
- : typeof raw.type === "string"
395
- ? raw.type
396
- : "";
397
- 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);
398
498
  switch (eventName) {
399
499
  case OmniEvent.Hello:
400
500
  this.opts.onHello?.(frame);
@@ -435,7 +535,10 @@ export class OmniConnection {
435
535
  * `{"event":"configure"}` is acked but silently dropped by the engine.)
436
536
  */
437
537
  configure(cfg) {
438
- 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 }));
439
542
  }
440
543
  /**
441
544
  * Stream a chunk of caller audio (PCM16 LE mono at the negotiated rate) as a
@@ -471,7 +574,12 @@ export class OmniConnection {
471
574
  * never `event`.
472
575
  */
473
576
  send(frame) {
474
- 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));
475
583
  }
476
584
  /** Close the session. */
477
585
  close(code = WSCloseCode.Normal, reason = "") {
@@ -565,25 +673,27 @@ export class PyAI {
565
673
  };
566
674
  // --- voices -------------------------------------------------------------
567
675
  voices = {
568
- list: (params = {}) => {
676
+ list: async (params = {}) => {
569
677
  const q = new URLSearchParams();
570
678
  if (params.gender)
571
679
  q.set("gender", params.gender);
572
680
  if (params.region)
573
681
  q.set("region", params.region);
574
682
  const qs = q.toString();
575
- 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) };
576
685
  },
577
- get: (id) => this.getJson(`/v1/voices/${encodeURIComponent(id)}`),
686
+ get: async (id) => normalizeVoice(await this.getJson(`/v1/voices/${encodeURIComponent(id)}`)),
578
687
  };
579
688
  // --- audio --------------------------------------------------------------
580
689
  audio = {
581
690
  /** Text-to-speech. Returns the raw audio bytes (default WAV). */
582
691
  speech: async (params) => {
692
+ assertActiveSpeechParams(params);
583
693
  const res = await this.request("/v1/audio/speech", {
584
694
  method: "POST",
585
695
  headers: this.authHeaders({ "Content-Type": "application/json" }),
586
- body: JSON.stringify({ model: "pyai-voice", ...params }),
696
+ body: JSON.stringify({ model: "pyai-speak", ...params }),
587
697
  });
588
698
  return res.arrayBuffer();
589
699
  },
@@ -596,10 +706,11 @@ export class PyAI {
596
706
  * iterable of Uint8Array chunks.
597
707
  */
598
708
  speechStream: async (params) => {
709
+ assertActiveSpeechParams(params);
599
710
  const res = await this.request("/v1/audio/speech", {
600
711
  method: "POST",
601
712
  headers: this.authHeaders({ "Content-Type": "application/json" }),
602
- body: JSON.stringify({ model: "pyai-voice", ...params, stream: true }),
713
+ body: JSON.stringify({ model: "pyai-speak", ...params, stream: true }),
603
714
  });
604
715
  if (!res.body)
605
716
  throw new PyAIError(res.status, "Response had no body to stream");
@@ -669,7 +780,7 @@ export class PyAI {
669
780
  clones = {
670
781
  /** List the org's cloned voices. */
671
782
  list: () => this.getJson("/v1/voice/clones"),
672
- /** Enroll a custom voice from reference audio (>= ~10s). Scope `voice:clone`. */
783
+ /** Enroll a custom voice from reference audio (>= ~10s). Scope `speak:clone`. */
673
784
  create: async (params) => {
674
785
  const form = new FormData();
675
786
  form.set("name", params.name);
@@ -689,7 +800,7 @@ export class PyAI {
689
800
  throw new PyAIError(404, `No cloned voice ${id}`, ErrorCode.NotFound);
690
801
  return match;
691
802
  },
692
- /** Delete a cloned voice (tenant-isolated). Scope `voice:clone`. */
803
+ /** Delete a cloned voice (tenant-isolated). Scope `speak:clone`. */
693
804
  delete: async (id) => {
694
805
  await this.deleteReq(`/v1/voice/clones/${encodeURIComponent(id)}`);
695
806
  },
@@ -898,34 +1009,31 @@ export class PyAI {
898
1009
  query.format = opts.format;
899
1010
  if (opts.rate)
900
1011
  query.rate = String(opts.rate);
901
- const url = this.realtimeURL({ product: "omni", sessionLabel: opts.sessionLabel, query });
1012
+ const url = this.realtimeURL({ sessionLabel: opts.sessionLabel, query });
902
1013
  const sub = opts.token ? `pyai-key.${opts.token}` : this.realtimeSubprotocol();
903
1014
  return new OmniConnection(url, sub, opts);
904
1015
  },
905
1016
  };
906
1017
  // --- realtime (WebSocket) ----------------------------------------------
907
- /** Build the realtime WebSocket URL for the chosen product. */
1018
+ /** Build the canonical Omni WebSocket URL. */
908
1019
  realtimeURL(opts = {}) {
909
1020
  const wsBase = this.baseURL.replace(/^http/, "ws");
910
1021
  const q = new URLSearchParams(opts.query ?? {});
911
- if ((opts.product ?? "omni") === "omni") {
912
- // Omni's native realtime surface is /v1/omni. The session is authorized by
913
- // the key's org (zero-state), sessionLabel is an optional opaque tag.
914
- // format/rate are load-bearing on the connect URL, so default to
915
- // browser-grade PCM16/24kHz.
916
- if (opts.sessionLabel)
917
- q.set("session_label", opts.sessionLabel);
918
- else if (opts.agentId)
919
- q.set("agent_id", opts.agentId); // deprecated alias
920
- if (!q.has("format"))
921
- q.set("format", "pcm16");
922
- if (!q.has("rate"))
923
- q.set("rate", "24000");
924
- const qs = q.toString();
925
- 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
+ }
926
1028
  }
927
- q.set("model", "pyai-flow-realtime");
928
- 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}` : ""}`;
929
1037
  }
930
1038
  /** The subprotocol that carries the key on a WS upgrade (browser-safe auth). */
931
1039
  realtimeSubprotocol() {
@@ -935,6 +1043,7 @@ export class PyAI {
935
1043
  hearStreamURL(opts = {}) {
936
1044
  const wsBase = this.baseURL.replace(/^http/, "ws");
937
1045
  const q = new URLSearchParams(opts.query ?? {});
1046
+ q.set("protocol", "pyai-hear-v1");
938
1047
  if (opts.model)
939
1048
  q.set("model", opts.model);
940
1049
  if (opts.language)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyai/sdk",
3
- "version": "0.2.2",
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",