@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/README.md +65 -19
- package/dist/index.d.ts +292 -60
- package/dist/index.js +189 -81
- package/package.json +18 -3
- package/src/index.ts +447 -121
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
|
-
/**
|
|
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
|
-
|
|
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,
|
|
87
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
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-
|
|
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 `
|
|
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 `
|
|
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({
|
|
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
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
1015
|
-
|
|
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
|
|
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()/
|
|
1062
|
-
return new WS(this.realtimeURL(opts),
|
|
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.
|
|
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": [
|
|
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": [
|
|
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",
|