@pyai/sdk 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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). */
@@ -49,10 +62,16 @@ export const HearFrameType = {
49
62
  /** Server-side fault frame. */
50
63
  Error: "error",
51
64
  };
52
- /** 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
+ */
53
70
  export const WSCloseCode = {
54
71
  /** Normal closure. */
55
72
  Normal: 1000,
73
+ /** Browser-safe private application code for a malformed peer protocol frame. */
74
+ ProtocolViolation: 4002,
56
75
  /** Auth/policy: bad key, missing scope, or revoked token. */
57
76
  PolicyViolation: 1008,
58
77
  /** Engine/internal error. */
@@ -69,7 +88,7 @@ export const ErrorCode = {
69
88
  Unauthorized: "unauthorized",
70
89
  Forbidden: "forbidden",
71
90
  OriginNotAllowed: "origin_not_allowed",
72
- InvalidAgentId: "invalid_agent_id",
91
+ InvalidSessionLabel: "invalid_session_label",
73
92
  CreditExhausted: "credit_exhausted",
74
93
  KeyBudgetExceeded: "key_budget_exceeded",
75
94
  InsufficientQuota: "insufficient_quota",
@@ -79,12 +98,29 @@ export const ErrorCode = {
79
98
  IdempotencyConflict: "idempotency_conflict",
80
99
  NotFound: "not_found",
81
100
  NumberInUse: "number_in_use",
101
+ UnsupportedToolTransport: "unsupported_tool_transport",
82
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";
83
117
  /**
84
118
  * A live Hear streaming-STT session. Hides the frame protocol: stream audio
85
119
  * 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()`.
120
+ * callbacks, update the silence floor with
121
+ * {@link HearStream.configureEndpointing}, force-finalize with
122
+ * {@link HearStream.commit}, and flush+close with {@link HearStream.close}.
123
+ * Construct via `pyai.audio.transcriptions.stream()`.
88
124
  */
89
125
  export class HearStream {
90
126
  ws;
@@ -92,27 +128,15 @@ export class HearStream {
92
128
  closed = false;
93
129
  constructor(url, subprotocol, opts) {
94
130
  this.opts = opts;
131
+ if (opts.grounding) {
132
+ throw new Error("Cue grounding is not active on the serving Hear stream; omit grounding until the API reference marks it active");
133
+ }
95
134
  const WS = opts.webSocket ?? globalThis.WebSocket;
96
135
  if (!WS) {
97
136
  throw new Error("No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to transcriptions.stream()");
98
137
  }
99
- this.ws = new WS(url, [subprotocol]);
138
+ this.ws = new WS(url, [REALTIME_SUBPROTOCOL_MARKER, subprotocol]);
100
139
  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
140
  opts.onOpen?.();
117
141
  };
118
142
  this.ws.onmessage = (ev) => this.handleMessage(ev.data);
@@ -135,6 +159,9 @@ export class HearStream {
135
159
  return;
136
160
  }
137
161
  switch (frame.type) {
162
+ case HearFrameType.ConfigAck:
163
+ this.opts.onConfigAck?.(frame);
164
+ break;
138
165
  case HearFrameType.Partial:
139
166
  case HearFrameType.PartialStable:
140
167
  this.opts.onPartial?.(frame);
@@ -158,6 +185,10 @@ export class HearStream {
158
185
  sendAudio(chunk) {
159
186
  this.ws.send(chunk);
160
187
  }
188
+ /** Change the minimum trailing-pause floor without reconnecting. */
189
+ configureEndpointing(endpointingMs) {
190
+ this.ws.send(JSON.stringify({ type: "config", endpointing_ms: endpointingMs }));
191
+ }
161
192
  /** Force-finalize the current utterance (e.g. on VAD end-of-turn). */
162
193
  commit() {
163
194
  this.ws.send(JSON.stringify({ type: "commit" }));
@@ -195,7 +226,7 @@ export class AmdStream {
195
226
  if (!WS) {
196
227
  throw new Error("No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to amd.stream()");
197
228
  }
198
- this.ws = new WS(url, [subprotocol]);
229
+ this.ws = new WS(url, [REALTIME_SUBPROTOCOL_MARKER, subprotocol]);
199
230
  this.ws.onopen = () => opts.onOpen?.();
200
231
  this.ws.onmessage = (ev) => this.handleMessage(ev.data);
201
232
  this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
@@ -267,6 +298,8 @@ export const OmniEvent = {
267
298
  Flush: "flush",
268
299
  /** Engine requests a client-loop tool invocation. */
269
300
  ToolCall: "tool_call",
301
+ /** A write tool is waiting for the caller to confirm; it has not run. */
302
+ ToolConfirmationRequired: "tool_confirmation_required",
270
303
  /** Session is closing; see close code. */
271
304
  SessionEnd: "session_end",
272
305
  /** Server fault frame. */
@@ -274,13 +307,6 @@ export const OmniEvent = {
274
307
  };
275
308
  const OMNI_TRANSCRIPT_MAX_BYTES = 16_384;
276
309
  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
310
  function omniTranscriptText(value) {
285
311
  return typeof value === "string"
286
312
  && value.length > 0
@@ -289,7 +315,14 @@ function omniTranscriptText(value) {
289
315
  ? value
290
316
  : null;
291
317
  }
292
- /** Normalize the live UTF-8 0x02 body and the documented direct-object legacy shape. */
318
+ function omniTranscriptRole(value) {
319
+ if (value === "user" || value === "caller" || value === "human")
320
+ return "user";
321
+ if (value === "assistant" || value === "agent")
322
+ return "assistant";
323
+ return null;
324
+ }
325
+ /** Normalize the live UTF-8 `0x02` text body and bounded legacy JSON bodies. */
293
326
  export function normalizeOmniTranscriptBody(bytes) {
294
327
  if (bytes.byteLength === 0 || bytes.byteLength > OMNI_TRANSCRIPT_MAX_BYTES)
295
328
  return null;
@@ -300,12 +333,15 @@ export function normalizeOmniTranscriptBody(bytes) {
300
333
  catch {
301
334
  return null;
302
335
  }
336
+ // The serving engine's canonical payload is plain UTF-8 caller text, not
337
+ // JSON. Each frame is a delta for the current caller turn.
303
338
  if (!decoded.trimStart().startsWith("{")) {
304
339
  const text = omniTranscriptText(decoded);
305
340
  return text
306
341
  ? { event: "transcript", role: "user", text, final: false, mode: "delta" }
307
342
  : null;
308
343
  }
344
+ // Keep accepting a bounded direct object for older bridges and recordings.
309
345
  let value;
310
346
  try {
311
347
  value = JSON.parse(decoded);
@@ -316,6 +352,9 @@ export function normalizeOmniTranscriptBody(bytes) {
316
352
  if (!value || typeof value !== "object" || Array.isArray(value))
317
353
  return null;
318
354
  const payload = value;
355
+ if (payload.type !== undefined
356
+ || (payload.event !== undefined && payload.event !== "transcript"))
357
+ return null;
319
358
  const role = omniTranscriptRole(payload.role ?? payload.speaker);
320
359
  const mode = typeof payload.delta === "string" ? "delta" : "replace";
321
360
  const text = omniTranscriptText(mode === "delta" ? payload.delta : typeof payload.text === "string" ? payload.text : payload.transcript);
@@ -397,7 +436,7 @@ export class OmniConnection {
397
436
  if (!WS) {
398
437
  throw new Error("No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to omni.connect()");
399
438
  }
400
- this.ws = new WS(url, [subprotocol]);
439
+ this.ws = new WS(url, [REALTIME_SUBPROTOCOL_MARKER, subprotocol]);
401
440
  this.ws.onopen = () => {
402
441
  if (opts.configure) {
403
442
  try {
@@ -454,7 +493,11 @@ export class OmniConnection {
454
493
  }
455
494
  if (tag === 0x03) {
456
495
  try {
457
- const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1)));
496
+ const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(1)));
497
+ if (parsed?.event === OmniEvent.Transcript) {
498
+ this.opts.onError?.(new Error("Omni transcript events must use a binary 0x02 frame"));
499
+ return;
500
+ }
458
501
  this.dispatchFrame(parsed);
459
502
  }
460
503
  catch {
@@ -466,22 +509,15 @@ export class OmniConnection {
466
509
  this.opts.onError?.(new Error(`Ignored unknown Omni binary frame tag ${tagName}`));
467
510
  return;
468
511
  }
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
- }
512
+ this.opts.onError?.(new Error("Unexpected Omni text frame; server frames must use binary 0x01/0x02/0x03 tags"));
476
513
  }
477
514
  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 });
515
+ const eventName = frame.event;
516
+ if (typeof eventName !== "string" || !eventName) {
517
+ this.opts.onError?.(new Error("Omni server control frame is missing its event key"));
518
+ return;
519
+ }
520
+ this.opts.onEvent?.(frame);
485
521
  switch (eventName) {
486
522
  case OmniEvent.Hello:
487
523
  this.opts.onHello?.(frame);
@@ -522,7 +558,10 @@ export class OmniConnection {
522
558
  * `{"event":"configure"}` is acked but silently dropped by the engine.)
523
559
  */
524
560
  configure(cfg) {
525
- this.ws.send(omniControlFrame({ type: "configure", ...cfg }));
561
+ const payload = { ...cfg };
562
+ delete payload.type;
563
+ delete payload.event;
564
+ this.ws.send(omniControlFrame({ type: "configure", ...payload }));
526
565
  }
527
566
  /**
528
567
  * Stream a chunk of caller audio (PCM16 LE mono at the negotiated rate) as a
@@ -558,7 +597,12 @@ export class OmniConnection {
558
597
  * never `event`.
559
598
  */
560
599
  send(frame) {
561
- this.ws.send(omniControlFrame(frame));
600
+ if (typeof frame.type !== "string" || !frame.type) {
601
+ throw new TypeError("Omni client control frames must have a non-empty type key");
602
+ }
603
+ const payload = { ...frame };
604
+ delete payload.event;
605
+ this.ws.send(omniControlFrame(payload));
562
606
  }
563
607
  /** Close the session. */
564
608
  close(code = WSCloseCode.Normal, reason = "") {
@@ -652,41 +696,73 @@ export class PyAI {
652
696
  };
653
697
  // --- voices -------------------------------------------------------------
654
698
  voices = {
655
- list: (params = {}) => {
699
+ list: async (params = {}) => {
656
700
  const q = new URLSearchParams();
657
701
  if (params.gender)
658
702
  q.set("gender", params.gender);
659
703
  if (params.region)
660
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);
661
711
  const qs = q.toString();
662
- return this.getJson(`/v1/voices${qs ? `?${qs}` : ""}`);
712
+ const page = await this.getJson(`/v1/voices${qs ? `?${qs}` : ""}`);
713
+ return { ...page, data: page.data.map(normalizeVoice) };
714
+ },
715
+ get: async (id) => normalizeVoice(await this.getJson(`/v1/voices/${encodeURIComponent(id)}`)),
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());
663
738
  },
664
- get: (id) => this.getJson(`/v1/voices/${encodeURIComponent(id)}`),
665
739
  };
666
740
  // --- audio --------------------------------------------------------------
667
741
  audio = {
668
742
  /** Text-to-speech. Returns the raw audio bytes (default WAV). */
669
743
  speech: async (params) => {
744
+ assertActiveSpeechParams(params);
670
745
  const res = await this.request("/v1/audio/speech", {
671
746
  method: "POST",
672
747
  headers: this.authHeaders({ "Content-Type": "application/json" }),
673
- body: JSON.stringify({ model: "pyai-voice", ...params }),
748
+ body: JSON.stringify({ model: "pyai-speak", ...params }),
674
749
  });
675
750
  return res.arrayBuffer();
676
751
  },
677
752
  /**
678
753
  * Text-to-speech, streamed. Resolves as soon as the response headers arrive
679
754
  * with the body as a `ReadableStream` of audio bytes, so you can start
680
- * playback or forward the audio at the first chunk (the engine's
681
- * time-to-first-byte is tens of ms) instead of buffering the whole clip.
682
- * Use `mp3` for the smoothest progressive playback. Returns an async
683
- * 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.
684
759
  */
685
760
  speechStream: async (params) => {
761
+ assertActiveSpeechParams(params);
686
762
  const res = await this.request("/v1/audio/speech", {
687
763
  method: "POST",
688
764
  headers: this.authHeaders({ "Content-Type": "application/json" }),
689
- body: JSON.stringify({ model: "pyai-voice", ...params, stream: true }),
765
+ body: JSON.stringify({ model: "pyai-speak", ...params, stream: true }),
690
766
  });
691
767
  if (!res.body)
692
768
  throw new PyAIError(res.status, "Response had no body to stream");
@@ -700,6 +776,16 @@ export class PyAI {
700
776
  form.set("model", params.model ?? "pyai-hear");
701
777
  if (params.language)
702
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(","));
703
789
  if (params.response_format)
704
790
  form.set("response_format", params.response_format);
705
791
  if (params.seed !== undefined)
@@ -756,7 +842,7 @@ export class PyAI {
756
842
  clones = {
757
843
  /** List the org's cloned voices. */
758
844
  list: () => this.getJson("/v1/voice/clones"),
759
- /** Enroll a custom voice from reference audio (>= ~10s). Scope `voice:clone`. */
845
+ /** Enroll a custom voice from reference audio (>= ~10s). Scope `speak:clone`. */
760
846
  create: async (params) => {
761
847
  const form = new FormData();
762
848
  form.set("name", params.name);
@@ -776,7 +862,7 @@ export class PyAI {
776
862
  throw new PyAIError(404, `No cloned voice ${id}`, ErrorCode.NotFound);
777
863
  return match;
778
864
  },
779
- /** Delete a cloned voice (tenant-isolated). Scope `voice:clone`. */
865
+ /** Delete a cloned voice (tenant-isolated). Scope `speak:clone`. */
780
866
  delete: async (id) => {
781
867
  await this.deleteReq(`/v1/voice/clones/${encodeURIComponent(id)}`);
782
868
  },
@@ -961,7 +1047,7 @@ export class PyAI {
961
1047
  * realtime. **Call this from your server** with a secret key holding
962
1048
  * `omni:session`; never ship the secret key to a page. Hand the returned
963
1049
  * `token` to the browser, which connects with
964
- * `new WebSocket(session.url, ["pyai-key." + session.token])`. The token
1050
+ * `new WebSocket(session.url, ["pyai.v1", "pyai-key." + session.token])`. The token
965
1051
  * expires after `ttlSeconds` (default 60s) and only works from
966
1052
  * `allowedOrigins`. Scope `omni:session`.
967
1053
  */
@@ -985,43 +1071,56 @@ export class PyAI {
985
1071
  query.format = opts.format;
986
1072
  if (opts.rate)
987
1073
  query.rate = String(opts.rate);
988
- const url = this.realtimeURL({ product: "omni", sessionLabel: opts.sessionLabel, query });
1074
+ const url = this.realtimeURL({ sessionLabel: opts.sessionLabel, query });
989
1075
  const sub = opts.token ? `pyai-key.${opts.token}` : this.realtimeSubprotocol();
990
1076
  return new OmniConnection(url, sub, opts);
991
1077
  },
992
1078
  };
993
1079
  // --- realtime (WebSocket) ----------------------------------------------
994
- /** Build the realtime WebSocket URL for the chosen product. */
1080
+ /** Build the canonical Omni WebSocket URL. */
995
1081
  realtimeURL(opts = {}) {
996
1082
  const wsBase = this.baseURL.replace(/^http/, "ws");
997
1083
  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}` : ""}`;
1084
+ // format/rate are load-bearing on the connect URL, so default to
1085
+ // browser-grade PCM16/24kHz.
1086
+ for (const key of ["agent", "agent_id", "agentId", "model", "access_token"]) {
1087
+ if (q.has(key)) {
1088
+ throw new Error(`Omni query parameter "${key}" is not supported; use sessionLabel, format/rate, or api_key`);
1089
+ }
1013
1090
  }
1014
- q.set("model", "pyai-flow-realtime");
1015
- return `${wsBase}/v1/realtime?${q.toString()}`;
1091
+ if (opts.sessionLabel)
1092
+ q.set("session_label", opts.sessionLabel);
1093
+ if (!q.has("format"))
1094
+ q.set("format", "pcm16");
1095
+ if (!q.has("rate"))
1096
+ q.set("rate", "24000");
1097
+ const qs = q.toString();
1098
+ return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
1016
1099
  }
1017
- /** 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. */
1018
1101
  realtimeSubprotocol() {
1019
1102
  return `pyai-key.${this.apiKey}`;
1020
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
+ }
1021
1116
  /** Build the Hear streaming-STT WebSocket URL (`/v1/audio/transcriptions/stream`). */
1022
1117
  hearStreamURL(opts = {}) {
1023
1118
  const wsBase = this.baseURL.replace(/^http/, "ws");
1024
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");
1123
+ q.set("protocol", "pyai-hear-v1");
1025
1124
  if (opts.model)
1026
1125
  q.set("model", opts.model);
1027
1126
  if (opts.language)
@@ -1034,6 +1133,15 @@ export class PyAI {
1034
1133
  q.set("interim_results", String(opts.interimResults));
1035
1134
  if (opts.numerals !== undefined)
1036
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
+ }
1037
1145
  if (opts.endpointingMs !== undefined)
1038
1146
  q.set("endpointing_ms", String(opts.endpointingMs));
1039
1147
  const qs = q.toString();
@@ -1058,8 +1166,8 @@ export class PyAI {
1058
1166
  connectRealtime(opts = {}) {
1059
1167
  const WS = globalThis.WebSocket;
1060
1168
  if (!WS)
1061
- throw new Error("No global WebSocket; use realtimeURL()/realtimeSubprotocol() with a WS library");
1062
- 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());
1063
1171
  }
1064
1172
  }
1065
1173
  export default PyAI;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyai/sdk",
3
- "version": "0.2.3",
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",
@@ -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",