@pyai/sdk 0.1.2 → 0.2.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/README.md +156 -17
- package/dist/cli.d.ts +3 -2
- package/dist/cli.js +107 -2
- package/dist/index.d.ts +868 -7
- package/dist/index.js +587 -5
- package/package.json +2 -1
- package/src/cli.ts +115 -2
- package/src/index.ts +1220 -11
package/dist/index.js
CHANGED
|
@@ -21,6 +21,320 @@ export class PyAIError extends Error {
|
|
|
21
21
|
this.requestId = requestId;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* The runtime list of accepted `audio.speech` formats (mirrors {@link SpeechFormat}),
|
|
26
|
+
* for building dropdowns / validating input before a request. Branch on the
|
|
27
|
+
* named values; the order matches the contract doc.
|
|
28
|
+
*/
|
|
29
|
+
export const SPEECH_FORMATS = ["wav", "mp3", "opus", "aac", "flac", "pcm", "g711_ulaw", "g711_alaw"];
|
|
30
|
+
/** Sample rates (Hz) the server accepts for `audio.speech` (`g711_*` is always 8 kHz). */
|
|
31
|
+
export const SPEECH_SAMPLE_RATES = [8000, 16000, 24000, 48000];
|
|
32
|
+
/* ------------------------------------------------------------------------- *
|
|
33
|
+
* Stable enums — mirror the server so callers branch on named constants, not
|
|
34
|
+
* magic strings, and a contract change surfaces in one place. These are plain
|
|
35
|
+
* `as const` objects (not TS `enum`s) so the source still runs directly under
|
|
36
|
+
* Node's type-stripping and stays tree-shakeable.
|
|
37
|
+
* ------------------------------------------------------------------------- */
|
|
38
|
+
/** Frame `type`s emitted by the Hear streaming-STT WebSocket. */
|
|
39
|
+
export const HearFrameType = {
|
|
40
|
+
/** Eager live hypothesis for the current utterance. */
|
|
41
|
+
Partial: "partial",
|
|
42
|
+
/** Partial whose prefix has stabilized (won't be revised). */
|
|
43
|
+
PartialStable: "partial_stable",
|
|
44
|
+
/** Stable transcript at end-of-utterance (endpoint or commit). */
|
|
45
|
+
SpeechFinal: "speech_final",
|
|
46
|
+
/** Corrected, full-context transcript following `speech_final`. */
|
|
47
|
+
Final: "final",
|
|
48
|
+
/** Final billed-usage summary, emitted just before a graceful close. */
|
|
49
|
+
Usage: "usage",
|
|
50
|
+
/** Server-side fault frame. */
|
|
51
|
+
Error: "error",
|
|
52
|
+
};
|
|
53
|
+
/** WebSocket close codes used across the PyAI realtime/streaming surfaces. */
|
|
54
|
+
export const WSCloseCode = {
|
|
55
|
+
/** Normal closure. */
|
|
56
|
+
Normal: 1000,
|
|
57
|
+
/** Auth/policy: bad key, missing scope, or revoked token. */
|
|
58
|
+
PolicyViolation: 1008,
|
|
59
|
+
/** Engine/internal error. */
|
|
60
|
+
InternalError: 1011,
|
|
61
|
+
/** Over the concurrency cap (PyAI-specific; mirrors HTTP 429). */
|
|
62
|
+
OverCapacity: 4429,
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Stable, machine-readable error `code`s (the documented contract). Branch on
|
|
66
|
+
* these. The set is treated as open — `PyAIError.code` stays `string` — so a
|
|
67
|
+
* new server code never breaks the build, but the known ones are named here.
|
|
68
|
+
*/
|
|
69
|
+
export const ErrorCode = {
|
|
70
|
+
Unauthorized: "unauthorized",
|
|
71
|
+
Forbidden: "forbidden",
|
|
72
|
+
OriginNotAllowed: "origin_not_allowed",
|
|
73
|
+
InvalidAgentId: "invalid_agent_id",
|
|
74
|
+
CreditExhausted: "credit_exhausted",
|
|
75
|
+
KeyBudgetExceeded: "key_budget_exceeded",
|
|
76
|
+
InsufficientQuota: "insufficient_quota",
|
|
77
|
+
RateLimitExceeded: "rate_limit_exceeded",
|
|
78
|
+
ConcurrencyLimitExceeded: "concurrency_limit_exceeded",
|
|
79
|
+
DailyCapExceeded: "daily_cap_exceeded",
|
|
80
|
+
IdempotencyConflict: "idempotency_conflict",
|
|
81
|
+
NotFound: "not_found",
|
|
82
|
+
NumberInUse: "number_in_use",
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* A live Hear streaming-STT session. Hides the frame protocol: stream audio
|
|
86
|
+
* with {@link HearStream.sendAudio}, get `onPartial`/`onFinal`/`onError`
|
|
87
|
+
* callbacks, force-finalize with {@link HearStream.commit}, and flush+close
|
|
88
|
+
* with {@link HearStream.close}. Construct via `pyai.audio.transcriptions.stream()`.
|
|
89
|
+
*/
|
|
90
|
+
export class HearStream {
|
|
91
|
+
ws;
|
|
92
|
+
opts;
|
|
93
|
+
closed = false;
|
|
94
|
+
constructor(url, subprotocol, opts) {
|
|
95
|
+
this.opts = opts;
|
|
96
|
+
const WS = opts.webSocket ?? globalThis.WebSocket;
|
|
97
|
+
if (!WS) {
|
|
98
|
+
throw new Error("No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to transcriptions.stream()");
|
|
99
|
+
}
|
|
100
|
+
this.ws = new WS(url, [subprotocol]);
|
|
101
|
+
this.ws.onopen = () => {
|
|
102
|
+
if (opts.grounding) {
|
|
103
|
+
try {
|
|
104
|
+
const cfg = { type: "config", grounding: true };
|
|
105
|
+
if (opts.groundingK != null)
|
|
106
|
+
cfg.grounding_k = opts.groundingK;
|
|
107
|
+
if (opts.groundingMinScore != null)
|
|
108
|
+
cfg.grounding_min_score = opts.groundingMinScore;
|
|
109
|
+
if (opts.groundingTimeoutMs != null)
|
|
110
|
+
cfg.grounding_timeout_ms = opts.groundingTimeoutMs;
|
|
111
|
+
this.ws.send(JSON.stringify(cfg));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* surfaced via onerror */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
opts.onOpen?.();
|
|
118
|
+
};
|
|
119
|
+
this.ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
120
|
+
this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
|
|
121
|
+
this.ws.onclose = (ev) => {
|
|
122
|
+
this.closed = true;
|
|
123
|
+
opts.onClose?.(ev.code, ev.reason);
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
handleMessage(data) {
|
|
127
|
+
// Hear emits JSON text frames; ignore any unexpected binary.
|
|
128
|
+
if (typeof data !== "string")
|
|
129
|
+
return;
|
|
130
|
+
let frame;
|
|
131
|
+
try {
|
|
132
|
+
frame = JSON.parse(data);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
this.opts.onError?.(new Error(`Unparseable Hear frame: ${data.slice(0, 120)}`));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
switch (frame.type) {
|
|
139
|
+
case HearFrameType.Partial:
|
|
140
|
+
case HearFrameType.PartialStable:
|
|
141
|
+
this.opts.onPartial?.(frame);
|
|
142
|
+
break;
|
|
143
|
+
case HearFrameType.SpeechFinal:
|
|
144
|
+
case HearFrameType.Final:
|
|
145
|
+
this.opts.onFinal?.(frame);
|
|
146
|
+
break;
|
|
147
|
+
case HearFrameType.Usage:
|
|
148
|
+
this.opts.onUsage?.(frame);
|
|
149
|
+
break;
|
|
150
|
+
case HearFrameType.Error:
|
|
151
|
+
this.opts.onError?.(frame);
|
|
152
|
+
break;
|
|
153
|
+
default:
|
|
154
|
+
// Unknown/forward-compatible frame — ignore.
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Send a chunk of audio (PCM16 or opus per `encoding`). */
|
|
159
|
+
sendAudio(chunk) {
|
|
160
|
+
this.ws.send(chunk);
|
|
161
|
+
}
|
|
162
|
+
/** Force-finalize the current utterance (e.g. on VAD end-of-turn). */
|
|
163
|
+
commit() {
|
|
164
|
+
this.ws.send(JSON.stringify({ type: "commit" }));
|
|
165
|
+
}
|
|
166
|
+
/** Close the socket; the server flushes a final for any buffered audio. */
|
|
167
|
+
close(code = WSCloseCode.Normal, reason = "") {
|
|
168
|
+
if (!this.closed)
|
|
169
|
+
this.ws.close(code, reason);
|
|
170
|
+
}
|
|
171
|
+
/** The underlying socket (escape hatch for advanced use). */
|
|
172
|
+
get socket() {
|
|
173
|
+
return this.ws;
|
|
174
|
+
}
|
|
175
|
+
/** Current WebSocket readyState. */
|
|
176
|
+
get readyState() {
|
|
177
|
+
return this.ws.readyState;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/* ------------------------------------------------------------------------- *
|
|
181
|
+
* Omni realtime (agentic voice) — typed client over the /v1/omni WebSocket
|
|
182
|
+
* ------------------------------------------------------------------------- */
|
|
183
|
+
/**
|
|
184
|
+
* Event names on Omni **server → client** frames. ⚠️ Note the asymmetry:
|
|
185
|
+
* inbound frames are keyed on `event`, but your **outbound** control frames
|
|
186
|
+
* (`configure`, `dtmf`, …) are keyed on `type`. {@link OmniConnection} handles
|
|
187
|
+
* both sides for you; this map is for matching frames in `onEvent`.
|
|
188
|
+
*/
|
|
189
|
+
export const OmniEvent = {
|
|
190
|
+
/** Handshake; advertises protocol version + audio formats. */
|
|
191
|
+
Hello: "hello",
|
|
192
|
+
/** Ack for your `configure` frame (echoes the resolved `voice_id`). */
|
|
193
|
+
Configured: "configured",
|
|
194
|
+
/** Session is live; includes the resolved agent + audio caps. */
|
|
195
|
+
SessionStarted: "session_started",
|
|
196
|
+
/** Turn boundary (user/assistant speaking). */
|
|
197
|
+
Turn: "turn",
|
|
198
|
+
/** Incremental/final transcript text. */
|
|
199
|
+
Transcript: "transcript",
|
|
200
|
+
/** User interrupted; assistant audio is being cut. */
|
|
201
|
+
BargeIn: "barge_in",
|
|
202
|
+
/** Alias for barge-in on some engine builds. */
|
|
203
|
+
Flush: "flush",
|
|
204
|
+
/** Session is closing; see close code. */
|
|
205
|
+
SessionEnd: "session_end",
|
|
206
|
+
/** Server fault frame. */
|
|
207
|
+
Error: "error",
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* A live Omni agentic-voice session over `/v1/omni`. Hides the wire protocol's
|
|
211
|
+
* frame-key asymmetry: it sends control frames keyed on `type` (`configure`,
|
|
212
|
+
* `dtmf`) and parses server frames keyed on `event`, so you cannot trip the #1
|
|
213
|
+
* Omni integration bug (mirroring the server's `event` key on outbound, which
|
|
214
|
+
* is silently dropped). Construct via `pyai.omni.connect()`.
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* const omni = pyai.omni.connect({
|
|
218
|
+
* rate: 16000,
|
|
219
|
+
* configure: { voice_id: "stock_emma_en_gb", persona: "You are a receptionist." },
|
|
220
|
+
* onAudio: (chunk) => speaker.write(chunk),
|
|
221
|
+
* onTranscript: (f) => console.log(f.text),
|
|
222
|
+
* });
|
|
223
|
+
* omni.sendAudio(pcm16Chunk); // stream caller audio continuously
|
|
224
|
+
*/
|
|
225
|
+
export class OmniConnection {
|
|
226
|
+
ws;
|
|
227
|
+
opts;
|
|
228
|
+
closed = false;
|
|
229
|
+
constructor(url, subprotocol, opts) {
|
|
230
|
+
this.opts = opts;
|
|
231
|
+
const WS = opts.webSocket ?? globalThis.WebSocket;
|
|
232
|
+
if (!WS) {
|
|
233
|
+
throw new Error("No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to omni.connect()");
|
|
234
|
+
}
|
|
235
|
+
this.ws = new WS(url, [subprotocol]);
|
|
236
|
+
this.ws.onopen = () => {
|
|
237
|
+
if (opts.configure) {
|
|
238
|
+
try {
|
|
239
|
+
this.configure(opts.configure);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
/* surfaced via onerror */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
opts.onOpen?.();
|
|
246
|
+
};
|
|
247
|
+
this.ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
248
|
+
this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
|
|
249
|
+
this.ws.onclose = (ev) => {
|
|
250
|
+
this.closed = true;
|
|
251
|
+
opts.onClose?.(ev.code, ev.reason);
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
handleMessage(data) {
|
|
255
|
+
// Binary = agent audio (play it out). JSON text = an `event`-keyed frame.
|
|
256
|
+
if (typeof data !== "string") {
|
|
257
|
+
this.opts.onAudio?.(data);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
let frame;
|
|
261
|
+
try {
|
|
262
|
+
frame = JSON.parse(data);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
this.opts.onEvent?.(frame);
|
|
269
|
+
switch (frame.event) {
|
|
270
|
+
case OmniEvent.Hello:
|
|
271
|
+
this.opts.onHello?.(frame);
|
|
272
|
+
break;
|
|
273
|
+
case OmniEvent.Configured:
|
|
274
|
+
this.opts.onConfigured?.(frame);
|
|
275
|
+
break;
|
|
276
|
+
case OmniEvent.SessionStarted:
|
|
277
|
+
this.opts.onSessionStarted?.(frame);
|
|
278
|
+
break;
|
|
279
|
+
case OmniEvent.Turn:
|
|
280
|
+
this.opts.onTurn?.(frame);
|
|
281
|
+
break;
|
|
282
|
+
case OmniEvent.Transcript:
|
|
283
|
+
this.opts.onTranscript?.(frame);
|
|
284
|
+
break;
|
|
285
|
+
case OmniEvent.BargeIn:
|
|
286
|
+
case OmniEvent.Flush:
|
|
287
|
+
this.opts.onBargeIn?.(frame);
|
|
288
|
+
break;
|
|
289
|
+
case OmniEvent.SessionEnd:
|
|
290
|
+
this.opts.onSessionEnd?.(frame);
|
|
291
|
+
break;
|
|
292
|
+
case OmniEvent.Error:
|
|
293
|
+
this.opts.onError?.(frame);
|
|
294
|
+
break;
|
|
295
|
+
default:
|
|
296
|
+
// Unknown/forward-compatible frame — already delivered via onEvent.
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Send (or update) the agent config. Always emitted as
|
|
302
|
+
* `{"type":"configure", ...}` — the correct key. (A hand-rolled
|
|
303
|
+
* `{"event":"configure"}` is acked but silently dropped by the engine.)
|
|
304
|
+
*/
|
|
305
|
+
configure(cfg) {
|
|
306
|
+
this.ws.send(JSON.stringify({ type: "configure", ...cfg }));
|
|
307
|
+
}
|
|
308
|
+
/** Stream a chunk of caller audio (PCM16 LE mono at the negotiated rate). */
|
|
309
|
+
sendAudio(chunk) {
|
|
310
|
+
this.ws.send(chunk);
|
|
311
|
+
}
|
|
312
|
+
/** Send DTMF digits as a `{"type":"dtmf"}` control frame. */
|
|
313
|
+
sendDtmf(digits) {
|
|
314
|
+
this.ws.send(JSON.stringify({ type: "dtmf", digits }));
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Send an arbitrary control frame for forward-compat control types the SDK
|
|
318
|
+
* does not model yet. Reminder: client → server frames are keyed on `type`,
|
|
319
|
+
* never `event`.
|
|
320
|
+
*/
|
|
321
|
+
send(frame) {
|
|
322
|
+
this.ws.send(JSON.stringify(frame));
|
|
323
|
+
}
|
|
324
|
+
/** Close the session. */
|
|
325
|
+
close(code = WSCloseCode.Normal, reason = "") {
|
|
326
|
+
if (!this.closed)
|
|
327
|
+
this.ws.close(code, reason);
|
|
328
|
+
}
|
|
329
|
+
/** The underlying socket (escape hatch for advanced use). */
|
|
330
|
+
get socket() {
|
|
331
|
+
return this.ws;
|
|
332
|
+
}
|
|
333
|
+
/** Current WebSocket readyState. */
|
|
334
|
+
get readyState() {
|
|
335
|
+
return this.ws.readyState;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
24
338
|
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
|
|
25
339
|
export class PyAI {
|
|
26
340
|
apiKey;
|
|
@@ -74,6 +388,25 @@ export class PyAI {
|
|
|
74
388
|
const res = await this.request(path, { headers: this.authHeaders() });
|
|
75
389
|
return (await res.json());
|
|
76
390
|
}
|
|
391
|
+
async postJson(path, payload, extraHeaders = {}) {
|
|
392
|
+
const res = await this.request(path, {
|
|
393
|
+
method: "POST",
|
|
394
|
+
headers: this.authHeaders({ "Content-Type": "application/json", ...extraHeaders }),
|
|
395
|
+
body: JSON.stringify(payload),
|
|
396
|
+
});
|
|
397
|
+
return (await res.json());
|
|
398
|
+
}
|
|
399
|
+
async putJson(path, payload) {
|
|
400
|
+
const res = await this.request(path, {
|
|
401
|
+
method: "PUT",
|
|
402
|
+
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
403
|
+
body: JSON.stringify(payload),
|
|
404
|
+
});
|
|
405
|
+
return (await res.json());
|
|
406
|
+
}
|
|
407
|
+
deleteReq(path) {
|
|
408
|
+
return this.request(path, { method: "DELETE", headers: this.authHeaders() });
|
|
409
|
+
}
|
|
77
410
|
// --- models -------------------------------------------------------------
|
|
78
411
|
models = {
|
|
79
412
|
list: () => this.getJson("/v1/models"),
|
|
@@ -126,6 +459,14 @@ export class PyAI {
|
|
|
126
459
|
const form = new FormData();
|
|
127
460
|
form.set("file", params.file, params.filename ?? "audio.wav");
|
|
128
461
|
form.set("model", params.model ?? "pyai-hear");
|
|
462
|
+
if (params.language)
|
|
463
|
+
form.set("language", params.language);
|
|
464
|
+
if (params.response_format)
|
|
465
|
+
form.set("response_format", params.response_format);
|
|
466
|
+
if (params.seed !== undefined)
|
|
467
|
+
form.set("seed", String(params.seed));
|
|
468
|
+
if (params.temperature !== undefined)
|
|
469
|
+
form.set("temperature", String(params.temperature));
|
|
129
470
|
const res = await this.request("/v1/audio/transcriptions", {
|
|
130
471
|
method: "POST",
|
|
131
472
|
headers: this.authHeaders(),
|
|
@@ -133,6 +474,13 @@ export class PyAI {
|
|
|
133
474
|
});
|
|
134
475
|
return (await res.json());
|
|
135
476
|
},
|
|
477
|
+
/**
|
|
478
|
+
* Open a live streaming-STT WebSocket (Hear). Hides the frame protocol
|
|
479
|
+
* behind `onPartial`/`onFinal`/`onError`; stream audio with `sendAudio`,
|
|
480
|
+
* force-finalize with `commit()`, flush+close with `close()`. Set
|
|
481
|
+
* `grounding: true` for Cue (turn detection + KB context).
|
|
482
|
+
*/
|
|
483
|
+
stream: (opts = {}) => new HearStream(this.hearStreamURL(opts), this.realtimeSubprotocol(), opts),
|
|
136
484
|
},
|
|
137
485
|
};
|
|
138
486
|
// --- async transcription jobs ------------------------------------------
|
|
@@ -159,17 +507,230 @@ export class PyAI {
|
|
|
159
507
|
return this.getJson(`/v1/transcription/jobs${qs ? `?${qs}` : ""}`);
|
|
160
508
|
},
|
|
161
509
|
};
|
|
510
|
+
// --- key introspection --------------------------------------------------
|
|
511
|
+
/**
|
|
512
|
+
* Introspect the calling key: scopes, environment, and limits. Useful for a
|
|
513
|
+
* preflight/doctor check. (New route; older deployments may 404 — handle it.)
|
|
514
|
+
*/
|
|
515
|
+
me = () => this.getJson("/v1/me");
|
|
516
|
+
// --- voice clones -------------------------------------------------------
|
|
517
|
+
clones = {
|
|
518
|
+
/** List the org's cloned voices. */
|
|
519
|
+
list: () => this.getJson("/v1/voice/clones"),
|
|
520
|
+
/** Enroll a custom voice from reference audio (>= ~10s). Scope `voice:clone`. */
|
|
521
|
+
create: async (params) => {
|
|
522
|
+
const form = new FormData();
|
|
523
|
+
form.set("name", params.name);
|
|
524
|
+
form.set("file", params.file, params.filename ?? "sample.wav");
|
|
525
|
+
const res = await this.request("/v1/voice/clones", { method: "POST", headers: this.authHeaders(), body: form });
|
|
526
|
+
return (await res.json());
|
|
527
|
+
},
|
|
528
|
+
/**
|
|
529
|
+
* Fetch a single cloned voice by id. The API exposes no GET-by-id for
|
|
530
|
+
* clones, so this filters `list()` client-side and throws a 404 `PyAIError`
|
|
531
|
+
* when the id isn't found.
|
|
532
|
+
*/
|
|
533
|
+
get: async (id) => {
|
|
534
|
+
const { data } = await this.clones.list();
|
|
535
|
+
const match = data.find((v) => v.id === id);
|
|
536
|
+
if (!match)
|
|
537
|
+
throw new PyAIError(404, `No cloned voice ${id}`, ErrorCode.NotFound);
|
|
538
|
+
return match;
|
|
539
|
+
},
|
|
540
|
+
/** Delete a cloned voice (tenant-isolated). Scope `voice:clone`. */
|
|
541
|
+
delete: async (id) => {
|
|
542
|
+
await this.deleteReq(`/v1/voice/clones/${encodeURIComponent(id)}`);
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
// --- telephony (managed numbers) ---------------------------------------
|
|
546
|
+
telephony = {
|
|
547
|
+
numbers: {
|
|
548
|
+
/** Search the carrier's available US local numbers. */
|
|
549
|
+
available: (params = {}) => {
|
|
550
|
+
const q = new URLSearchParams();
|
|
551
|
+
if (params.areaCode)
|
|
552
|
+
q.set("area_code", params.areaCode);
|
|
553
|
+
if (params.contains)
|
|
554
|
+
q.set("contains", params.contains);
|
|
555
|
+
if (params.limit !== undefined)
|
|
556
|
+
q.set("limit", String(params.limit));
|
|
557
|
+
const qs = q.toString();
|
|
558
|
+
return this.getJson(`/v1/telephony/available${qs ? `?${qs}` : ""}`);
|
|
559
|
+
},
|
|
560
|
+
/** List the org's managed numbers (active only unless `includeReleased`). */
|
|
561
|
+
list: (params = {}) => {
|
|
562
|
+
const qs = params.includeReleased ? "?include_released=true" : "";
|
|
563
|
+
return this.getJson(`/v1/telephony/numbers${qs}`);
|
|
564
|
+
},
|
|
565
|
+
/** Provision (buy) a specific available number, optionally bound to an agent. */
|
|
566
|
+
buy: (params) => this.postJson("/v1/telephony/numbers", params),
|
|
567
|
+
/** Route a number to an agent (`agentId: null` to unassign). */
|
|
568
|
+
assign: (id, agentId) => this.postJson(`/v1/telephony/numbers/${encodeURIComponent(id)}/assign`, { agent_id: agentId }),
|
|
569
|
+
/** Release a number back to the carrier (idempotent). */
|
|
570
|
+
release: async (id) => {
|
|
571
|
+
const res = await this.deleteReq(`/v1/telephony/numbers/${encodeURIComponent(id)}`);
|
|
572
|
+
return (await res.json());
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
};
|
|
576
|
+
// --- trace (compliance) -------------------------------------------------
|
|
577
|
+
trace = {
|
|
578
|
+
interactions: {
|
|
579
|
+
/** List scanned interactions (scorecards), newest first. Scope `trace:read`. */
|
|
580
|
+
list: (params = {}) => {
|
|
581
|
+
const q = new URLSearchParams();
|
|
582
|
+
if (params.verdict)
|
|
583
|
+
q.set("verdict", params.verdict);
|
|
584
|
+
if (params.agentId)
|
|
585
|
+
q.set("agent_id", params.agentId);
|
|
586
|
+
if (params.limit !== undefined)
|
|
587
|
+
q.set("limit", String(params.limit));
|
|
588
|
+
if (params.cursor)
|
|
589
|
+
q.set("cursor", params.cursor);
|
|
590
|
+
const qs = q.toString();
|
|
591
|
+
return this.getJson(`/v1/trace/interactions${qs ? `?${qs}` : ""}`);
|
|
592
|
+
},
|
|
593
|
+
/** The full per-call evidence view (findings, redactions, audit hash). */
|
|
594
|
+
get: (id) => this.getJson(`/v1/trace/interactions/${encodeURIComponent(id)}`),
|
|
595
|
+
},
|
|
596
|
+
violations: {
|
|
597
|
+
/** Drill-down of every fired Tier-0 rule across scorecards. Scope `trace:read`. */
|
|
598
|
+
list: (params = {}) => {
|
|
599
|
+
const q = new URLSearchParams();
|
|
600
|
+
if (params.ruleId)
|
|
601
|
+
q.set("rule_id", params.ruleId);
|
|
602
|
+
if (params.severity)
|
|
603
|
+
q.set("severity", params.severity);
|
|
604
|
+
if (params.interactionId)
|
|
605
|
+
q.set("interaction_id", params.interactionId);
|
|
606
|
+
if (params.limit !== undefined)
|
|
607
|
+
q.set("limit", String(params.limit));
|
|
608
|
+
if (params.cursor)
|
|
609
|
+
q.set("cursor", params.cursor);
|
|
610
|
+
const qs = q.toString();
|
|
611
|
+
return this.getJson(`/v1/trace/violations${qs ? `?${qs}` : ""}`);
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
findings: {
|
|
615
|
+
/** List Tier-2 (async semantic) findings — advisory, non-blocking. Scope `trace:read`. */
|
|
616
|
+
list: (params = {}) => {
|
|
617
|
+
const q = new URLSearchParams();
|
|
618
|
+
if (params.checkId)
|
|
619
|
+
q.set("check_id", params.checkId);
|
|
620
|
+
if (params.action)
|
|
621
|
+
q.set("action", params.action);
|
|
622
|
+
if (params.severity)
|
|
623
|
+
q.set("severity", params.severity);
|
|
624
|
+
if (params.interactionId)
|
|
625
|
+
q.set("interaction_id", params.interactionId);
|
|
626
|
+
if (params.limit !== undefined)
|
|
627
|
+
q.set("limit", String(params.limit));
|
|
628
|
+
if (params.cursor)
|
|
629
|
+
q.set("cursor", params.cursor);
|
|
630
|
+
const qs = q.toString();
|
|
631
|
+
return this.getJson(`/v1/trace/findings${qs ? `?${qs}` : ""}`);
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
config: {
|
|
635
|
+
/** Read per-agent Trace config (omit `agentId` for the org default). Scope `trace:configure`. */
|
|
636
|
+
get: (agentId) => this.getJson(`/v1/trace/config${agentId ? `?agent_id=${encodeURIComponent(agentId)}` : ""}`),
|
|
637
|
+
/** Upsert per-agent Trace config. Scope `trace:configure`. */
|
|
638
|
+
set: (input) => this.putJson("/v1/trace/config", input),
|
|
639
|
+
},
|
|
640
|
+
rulePacks: {
|
|
641
|
+
/** List built-in + custom rule packs. Scope `trace:configure`. */
|
|
642
|
+
list: () => this.getJson("/v1/trace/rule-packs"),
|
|
643
|
+
/** Upload a custom rule pack (Trace DSL). Scope `trace:configure`. */
|
|
644
|
+
create: (spec) => this.postJson("/v1/trace/rule-packs", spec),
|
|
645
|
+
/** Resolve a rule pack by id (latest active, or pin `version`). */
|
|
646
|
+
get: (id, version) => this.getJson(`/v1/trace/rule-packs/${encodeURIComponent(id)}${version ? `?version=${encodeURIComponent(version)}` : ""}`),
|
|
647
|
+
},
|
|
648
|
+
/** Compliance exposure summary over a trailing window. Scope `trace:read`. */
|
|
649
|
+
exposure: (windowDays) => this.getJson(`/v1/trace/exposure${windowDays !== undefined ? `?window_days=${windowDays}` : ""}`),
|
|
650
|
+
/**
|
|
651
|
+
* Convenience: the per-call operational timeline (eval scorecard-v1). A thin
|
|
652
|
+
* wrapper over `interactions.get(id)` that returns the `timeline` array, or
|
|
653
|
+
* `[]` when the engine hasn't emitted one yet (forward-compatible). Scope
|
|
654
|
+
* `trace:read`.
|
|
655
|
+
*/
|
|
656
|
+
callTimeline: async (id) => {
|
|
657
|
+
const detail = await this.trace.interactions.get(id);
|
|
658
|
+
return detail.timeline ?? [];
|
|
659
|
+
},
|
|
660
|
+
};
|
|
661
|
+
// --- recap (conversation intelligence) --------------------------------
|
|
662
|
+
recap = {
|
|
663
|
+
config: {
|
|
664
|
+
get: () => this.getJson("/v1/recap/config"),
|
|
665
|
+
set: (input) => this.putJson("/v1/recap/config", input),
|
|
666
|
+
},
|
|
667
|
+
calls: {
|
|
668
|
+
list: (params = {}) => {
|
|
669
|
+
const q = new URLSearchParams();
|
|
670
|
+
if (params.limit !== undefined)
|
|
671
|
+
q.set("limit", String(params.limit));
|
|
672
|
+
if (params.cursor)
|
|
673
|
+
q.set("cursor", params.cursor);
|
|
674
|
+
if (params.status)
|
|
675
|
+
q.set("status", params.status);
|
|
676
|
+
const qs = q.toString();
|
|
677
|
+
return this.getJson(`/v1/recap/calls${qs ? `?${qs}` : ""}`);
|
|
678
|
+
},
|
|
679
|
+
get: (callId) => this.getJson(`/v1/recap/calls/${encodeURIComponent(callId)}`),
|
|
680
|
+
trigger: (callId, input) => this.postJson(`/v1/recap/calls/${encodeURIComponent(callId)}`, input),
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
// --- omni (agentic voice) ----------------------------------------------
|
|
684
|
+
omni = {
|
|
685
|
+
/**
|
|
686
|
+
* Mint an ephemeral, origin-locked Omni session token a browser can use to
|
|
687
|
+
* open ONE realtime session **directly** — the public/private split for
|
|
688
|
+
* realtime. **Call this from your server** with a secret key holding
|
|
689
|
+
* `omni:session`; never ship the secret key to a page. Hand the returned
|
|
690
|
+
* `token` to the browser, which connects with
|
|
691
|
+
* `new WebSocket(session.url, ["pyai-key." + session.token])`. The token
|
|
692
|
+
* expires after `ttlSeconds` (default 60s) and only works from
|
|
693
|
+
* `allowedOrigins`. Scope `omni:session`.
|
|
694
|
+
*/
|
|
695
|
+
createSession: (params) => this.postJson("/v1/omni/sessions", {
|
|
696
|
+
allowed_origins: params.allowedOrigins,
|
|
697
|
+
...(params.ttlSeconds !== undefined ? { ttl_seconds: params.ttlSeconds } : {}),
|
|
698
|
+
...(params.sessionLabel !== undefined ? { session_label: params.sessionLabel } : {}),
|
|
699
|
+
}),
|
|
700
|
+
/**
|
|
701
|
+
* Open a live Omni agentic-voice session over `/v1/omni`. Returns an
|
|
702
|
+
* {@link OmniConnection} that handles the wire protocol's frame-key
|
|
703
|
+
* asymmetry for you — it sends `type`-keyed control frames (`configure`,
|
|
704
|
+
* `dtmf`) and parses `event`-keyed server frames — so you can't trip the #1
|
|
705
|
+
* Omni integration bug. Omni is zero-state: nothing to create first; the
|
|
706
|
+
* agent's behavior travels in the `configure` frame. Pass `token` (from
|
|
707
|
+
* `createSession`) to connect from a browser without the secret key.
|
|
708
|
+
*/
|
|
709
|
+
connect: (opts = {}) => {
|
|
710
|
+
const query = { ...(opts.query ?? {}) };
|
|
711
|
+
if (opts.format)
|
|
712
|
+
query.format = opts.format;
|
|
713
|
+
if (opts.rate)
|
|
714
|
+
query.rate = String(opts.rate);
|
|
715
|
+
const url = this.realtimeURL({ product: "omni", sessionLabel: opts.sessionLabel, query });
|
|
716
|
+
const sub = opts.token ? `pyai-key.${opts.token}` : this.realtimeSubprotocol();
|
|
717
|
+
return new OmniConnection(url, sub, opts);
|
|
718
|
+
},
|
|
719
|
+
};
|
|
162
720
|
// --- realtime (WebSocket) ----------------------------------------------
|
|
163
721
|
/** Build the realtime WebSocket URL for the chosen product. */
|
|
164
722
|
realtimeURL(opts = {}) {
|
|
165
723
|
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
166
724
|
const q = new URLSearchParams(opts.query ?? {});
|
|
167
725
|
if ((opts.product ?? "omni") === "omni") {
|
|
168
|
-
// Omni's native realtime surface is /v1/omni.
|
|
169
|
-
//
|
|
170
|
-
// connect URL, so default to
|
|
171
|
-
|
|
172
|
-
|
|
726
|
+
// Omni's native realtime surface is /v1/omni. The session is authorized by
|
|
727
|
+
// the key's org (zero-state) — sessionLabel is an optional opaque tag.
|
|
728
|
+
// format/rate are load-bearing on the connect URL, so default to
|
|
729
|
+
// browser-grade PCM16/24kHz.
|
|
730
|
+
if (opts.sessionLabel)
|
|
731
|
+
q.set("session_label", opts.sessionLabel);
|
|
732
|
+
else if (opts.agentId)
|
|
733
|
+
q.set("agent_id", opts.agentId); // deprecated alias
|
|
173
734
|
if (!q.has("format"))
|
|
174
735
|
q.set("format", "pcm16");
|
|
175
736
|
if (!q.has("rate"))
|
|
@@ -184,6 +745,27 @@ export class PyAI {
|
|
|
184
745
|
realtimeSubprotocol() {
|
|
185
746
|
return `pyai-key.${this.apiKey}`;
|
|
186
747
|
}
|
|
748
|
+
/** Build the Hear streaming-STT WebSocket URL (`/v1/audio/transcriptions/stream`). */
|
|
749
|
+
hearStreamURL(opts = {}) {
|
|
750
|
+
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
751
|
+
const q = new URLSearchParams(opts.query ?? {});
|
|
752
|
+
if (opts.model)
|
|
753
|
+
q.set("model", opts.model);
|
|
754
|
+
if (opts.language)
|
|
755
|
+
q.set("language", opts.language);
|
|
756
|
+
if (opts.sampleRate !== undefined)
|
|
757
|
+
q.set("sample_rate", String(opts.sampleRate));
|
|
758
|
+
if (opts.encoding)
|
|
759
|
+
q.set("encoding", opts.encoding);
|
|
760
|
+
if (opts.interimResults !== undefined)
|
|
761
|
+
q.set("interim_results", String(opts.interimResults));
|
|
762
|
+
if (opts.numerals !== undefined)
|
|
763
|
+
q.set("numerals", String(opts.numerals));
|
|
764
|
+
if (opts.endpointingMs !== undefined)
|
|
765
|
+
q.set("endpointing_ms", String(opts.endpointingMs));
|
|
766
|
+
const qs = q.toString();
|
|
767
|
+
return `${wsBase}/v1/audio/transcriptions/stream${qs ? `?${qs}` : ""}`;
|
|
768
|
+
}
|
|
187
769
|
/**
|
|
188
770
|
* Open a realtime WebSocket. Uses the global WebSocket (browser, Node 22+).
|
|
189
771
|
* The key travels as a subprotocol so it works from the browser without
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyai/sdk",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"homepage": "https://pyai.com",
|
|
24
24
|
"bugs": "https://github.com/atomsai/pyai-platform-backend/issues",
|
|
25
|
+
"author": "PyAI",
|
|
25
26
|
"scripts": {
|
|
26
27
|
"build": "tsc -p tsconfig.build.json",
|
|
27
28
|
"test": "node --test test/**/*.test.ts",
|