@pyai/sdk 0.2.1 → 0.2.2
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 +84 -25
- package/dist/cli.d.ts +4 -3
- package/dist/cli.js +78 -28
- package/dist/index.d.ts +199 -37
- package/dist/index.js +225 -28
- package/package.json +2 -2
- package/src/cli.ts +77 -29
- package/src/index.ts +388 -55
package/dist/index.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @pyai/sdk
|
|
2
|
+
* @pyai/sdk, official TypeScript/JavaScript client for the PyAI API.
|
|
3
3
|
*
|
|
4
4
|
* Thin, dependency-free wrapper over the public OpenAI-compatible surface at
|
|
5
5
|
* https://api.pyai.com (contract: https://api.pyai.com/openapi.json). Runs in
|
|
6
|
-
* the browser and Node 22+ (uses global fetch + WebSocket). Keys are opaque
|
|
7
|
-
* never parsed.
|
|
6
|
+
* the browser and Node 22+ (uses global fetch + WebSocket). Keys are opaque, * never parsed.
|
|
8
7
|
*/
|
|
9
8
|
/** Stable, machine-readable error. Branch on `code`, not `message`. */
|
|
10
9
|
export class PyAIError extends Error {
|
|
@@ -30,7 +29,7 @@ export const SPEECH_FORMATS = ["wav", "mp3", "opus", "aac", "flac", "pcm", "g711
|
|
|
30
29
|
/** Sample rates (Hz) the server accepts for `audio.speech` (`g711_*` is always 8 kHz). */
|
|
31
30
|
export const SPEECH_SAMPLE_RATES = [8000, 16000, 24000, 48000];
|
|
32
31
|
/* ------------------------------------------------------------------------- *
|
|
33
|
-
* Stable enums
|
|
32
|
+
* Stable enums, mirror the server so callers branch on named constants, not
|
|
34
33
|
* magic strings, and a contract change surfaces in one place. These are plain
|
|
35
34
|
* `as const` objects (not TS `enum`s) so the source still runs directly under
|
|
36
35
|
* Node's type-stripping and stays tree-shakeable.
|
|
@@ -63,7 +62,7 @@ export const WSCloseCode = {
|
|
|
63
62
|
};
|
|
64
63
|
/**
|
|
65
64
|
* Stable, machine-readable error `code`s (the documented contract). Branch on
|
|
66
|
-
* these. The set is treated as open
|
|
65
|
+
* these. The set is treated as open, `PyAIError.code` stays `string`, so a
|
|
67
66
|
* new server code never breaks the build, but the known ones are named here.
|
|
68
67
|
*/
|
|
69
68
|
export const ErrorCode = {
|
|
@@ -151,7 +150,7 @@ export class HearStream {
|
|
|
151
150
|
this.opts.onError?.(frame);
|
|
152
151
|
break;
|
|
153
152
|
default:
|
|
154
|
-
// Unknown/forward-compatible frame
|
|
153
|
+
// Unknown/forward-compatible frame, ignore.
|
|
155
154
|
break;
|
|
156
155
|
}
|
|
157
156
|
}
|
|
@@ -177,8 +176,73 @@ export class HearStream {
|
|
|
177
176
|
return this.ws.readyState;
|
|
178
177
|
}
|
|
179
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* A live AMD (answering-machine-detection) stream. The wire is Twilio's Media
|
|
181
|
+
* Streams protocol, so if you're already on Twilio the usual path is one line of
|
|
182
|
+
* TwiML pointing `<Stream url="wss://api.pyai.com/v1/amd/stream">` at PyAI, no
|
|
183
|
+
* SDK needed. This helper is for server-side clients that fork the media
|
|
184
|
+
* themselves: send Twilio `start`/`media`/`stop` frames with {@link AmdStream.send}
|
|
185
|
+
* (or raw μ-law audio with {@link AmdStream.sendAudio}) and receive the `amd`
|
|
186
|
+
* decision via `onDecision`. Construct via `pyai.amd.stream()`.
|
|
187
|
+
*/
|
|
188
|
+
export class AmdStream {
|
|
189
|
+
ws;
|
|
190
|
+
opts;
|
|
191
|
+
closed = false;
|
|
192
|
+
constructor(url, subprotocol, opts) {
|
|
193
|
+
this.opts = opts;
|
|
194
|
+
const WS = opts.webSocket ?? globalThis.WebSocket;
|
|
195
|
+
if (!WS) {
|
|
196
|
+
throw new Error("No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to amd.stream()");
|
|
197
|
+
}
|
|
198
|
+
this.ws = new WS(url, [subprotocol]);
|
|
199
|
+
this.ws.onopen = () => opts.onOpen?.();
|
|
200
|
+
this.ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
201
|
+
this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
|
|
202
|
+
this.ws.onclose = (ev) => {
|
|
203
|
+
this.closed = true;
|
|
204
|
+
opts.onClose?.(ev.code, ev.reason);
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
handleMessage(data) {
|
|
208
|
+
if (typeof data !== "string")
|
|
209
|
+
return; // AMD decisions are JSON text frames
|
|
210
|
+
let frame;
|
|
211
|
+
try {
|
|
212
|
+
frame = JSON.parse(data);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
this.opts.onError?.(new Error(`Unparseable AMD frame: ${data.slice(0, 120)}`));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (frame.event === "amd")
|
|
219
|
+
this.opts.onDecision?.(frame);
|
|
220
|
+
this.opts.onMessage?.(frame);
|
|
221
|
+
}
|
|
222
|
+
/** Send a Twilio Media Streams control/media frame (JSON) or a raw string. */
|
|
223
|
+
send(frame) {
|
|
224
|
+
this.ws.send(typeof frame === "string" ? frame : JSON.stringify(frame));
|
|
225
|
+
}
|
|
226
|
+
/** Send a raw audio chunk (G.711 μ-law 8 kHz for the Twilio-native path). */
|
|
227
|
+
sendAudio(chunk) {
|
|
228
|
+
this.ws.send(chunk);
|
|
229
|
+
}
|
|
230
|
+
/** Close the socket. */
|
|
231
|
+
close(code = WSCloseCode.Normal, reason = "") {
|
|
232
|
+
if (!this.closed)
|
|
233
|
+
this.ws.close(code, reason);
|
|
234
|
+
}
|
|
235
|
+
/** The underlying socket (escape hatch for advanced use). */
|
|
236
|
+
get socket() {
|
|
237
|
+
return this.ws;
|
|
238
|
+
}
|
|
239
|
+
/** Current WebSocket readyState. */
|
|
240
|
+
get readyState() {
|
|
241
|
+
return this.ws.readyState;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
180
244
|
/* ------------------------------------------------------------------------- *
|
|
181
|
-
* Omni realtime (agentic voice)
|
|
245
|
+
* Omni realtime (agentic voice), typed client over the /v1/omni WebSocket
|
|
182
246
|
* ------------------------------------------------------------------------- */
|
|
183
247
|
/**
|
|
184
248
|
* Event names on Omni **server → client** frames. ⚠️ Note the asymmetry:
|
|
@@ -201,11 +265,43 @@ export const OmniEvent = {
|
|
|
201
265
|
BargeIn: "barge_in",
|
|
202
266
|
/** Alias for barge-in on some engine builds. */
|
|
203
267
|
Flush: "flush",
|
|
268
|
+
/** Engine requests a client-loop tool invocation. */
|
|
269
|
+
ToolCall: "tool_call",
|
|
204
270
|
/** Session is closing; see close code. */
|
|
205
271
|
SessionEnd: "session_end",
|
|
206
272
|
/** Server fault frame. */
|
|
207
273
|
Error: "error",
|
|
208
274
|
};
|
|
275
|
+
/** Extract a byte view from a binary WS frame (Buffer / ArrayBuffer / typed
|
|
276
|
+
* array). Returns null for a Blob or unknown (can't be read synchronously). */
|
|
277
|
+
function omniToBytes(data) {
|
|
278
|
+
if (data instanceof Uint8Array)
|
|
279
|
+
return data; // also covers Node Buffer
|
|
280
|
+
if (data instanceof ArrayBuffer)
|
|
281
|
+
return new Uint8Array(data);
|
|
282
|
+
if (ArrayBuffer.isView(data)) {
|
|
283
|
+
const v = data;
|
|
284
|
+
return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
/** A 0x01-prefixed audio frame, the engine's client→server framing for caller
|
|
289
|
+
* PCM16 LE (see OMNI_PROTOCOL_V2.md §2). */
|
|
290
|
+
function omniAudioFrame(pcm) {
|
|
291
|
+
const out = new Uint8Array(pcm.length + 1);
|
|
292
|
+
out[0] = 0x01;
|
|
293
|
+
out.set(pcm, 1);
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
/** A 0x03-prefixed control frame, the engine's client→server framing for
|
|
297
|
+
* `configure`/`dtmf`/`tool_result` (see OMNI_PROTOCOL_V2.md §2/§3). */
|
|
298
|
+
function omniControlFrame(obj) {
|
|
299
|
+
const json = new TextEncoder().encode(JSON.stringify(obj));
|
|
300
|
+
const out = new Uint8Array(json.length + 1);
|
|
301
|
+
out[0] = 0x03;
|
|
302
|
+
out.set(json, 1);
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
209
305
|
/**
|
|
210
306
|
* A live Omni agentic-voice session over `/v1/omni`. Hides the wire protocol's
|
|
211
307
|
* frame-key asymmetry: it sends control frames keyed on `type` (`configure`,
|
|
@@ -226,6 +322,8 @@ export class OmniConnection {
|
|
|
226
322
|
ws;
|
|
227
323
|
opts;
|
|
228
324
|
closed = false;
|
|
325
|
+
/** Serializes async Blob reads in {@link sendAudio} so frames stay ordered. */
|
|
326
|
+
blobTail = Promise.resolve();
|
|
229
327
|
constructor(url, subprotocol, opts) {
|
|
230
328
|
this.opts = opts;
|
|
231
329
|
const WS = opts.webSocket ?? globalThis.WebSocket;
|
|
@@ -252,21 +350,52 @@ export class OmniConnection {
|
|
|
252
350
|
};
|
|
253
351
|
}
|
|
254
352
|
handleMessage(data) {
|
|
255
|
-
//
|
|
353
|
+
// Server → client binary frames are TYPE-TAGGED by their first byte:
|
|
354
|
+
// 0x01 = agent audio (PCM16) · 0x02 = transcript JSON · 0x03 = control JSON.
|
|
355
|
+
// (Treating every binary frame as audio, the old behavior, plays the
|
|
356
|
+
// 0x03/0x02 frames as a glitch and drops every event/transcript.)
|
|
256
357
|
if (typeof data !== "string") {
|
|
257
|
-
|
|
358
|
+
const bytes = omniToBytes(data);
|
|
359
|
+
if (!bytes) {
|
|
360
|
+
this.opts.onAudio?.(data); // Blob/unknown, best-effort
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const tag = bytes[0];
|
|
364
|
+
if (tag === 0x01) {
|
|
365
|
+
this.opts.onAudio?.(bytes.slice(1)); // copy → aligned PCM16
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (tag === 0x02 || tag === 0x03) {
|
|
369
|
+
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);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
this.opts.onError?.(new Error("Unparseable Omni binary frame"));
|
|
376
|
+
}
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
this.opts.onAudio?.(data); // untagged, forward-compat as audio
|
|
258
380
|
return;
|
|
259
381
|
}
|
|
260
|
-
|
|
382
|
+
// Text frame (e.g. a server-side broker relay).
|
|
261
383
|
try {
|
|
262
|
-
|
|
384
|
+
this.dispatchFrame(JSON.parse(data));
|
|
263
385
|
}
|
|
264
386
|
catch {
|
|
265
387
|
this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
|
|
266
|
-
return;
|
|
267
388
|
}
|
|
268
|
-
|
|
269
|
-
|
|
389
|
+
}
|
|
390
|
+
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 });
|
|
398
|
+
switch (eventName) {
|
|
270
399
|
case OmniEvent.Hello:
|
|
271
400
|
this.opts.onHello?.(frame);
|
|
272
401
|
break;
|
|
@@ -286,6 +415,9 @@ export class OmniConnection {
|
|
|
286
415
|
case OmniEvent.Flush:
|
|
287
416
|
this.opts.onBargeIn?.(frame);
|
|
288
417
|
break;
|
|
418
|
+
case OmniEvent.ToolCall:
|
|
419
|
+
this.opts.onToolCall?.(frame);
|
|
420
|
+
break;
|
|
289
421
|
case OmniEvent.SessionEnd:
|
|
290
422
|
this.opts.onSessionEnd?.(frame);
|
|
291
423
|
break;
|
|
@@ -293,25 +425,45 @@ export class OmniConnection {
|
|
|
293
425
|
this.opts.onError?.(frame);
|
|
294
426
|
break;
|
|
295
427
|
default:
|
|
296
|
-
// Unknown/forward-compatible frame
|
|
428
|
+
// Unknown/forward-compatible frame, already delivered via onEvent.
|
|
297
429
|
break;
|
|
298
430
|
}
|
|
299
431
|
}
|
|
300
432
|
/**
|
|
301
433
|
* Send (or update) the agent config. Always emitted as
|
|
302
|
-
* `{"type":"configure", ...}
|
|
434
|
+
* `{"type":"configure", ...}`, the correct key. (A hand-rolled
|
|
303
435
|
* `{"event":"configure"}` is acked but silently dropped by the engine.)
|
|
304
436
|
*/
|
|
305
437
|
configure(cfg) {
|
|
306
|
-
this.ws.send(
|
|
438
|
+
this.ws.send(omniControlFrame({ type: "configure", ...cfg }));
|
|
307
439
|
}
|
|
308
|
-
/**
|
|
440
|
+
/**
|
|
441
|
+
* Stream a chunk of caller audio (PCM16 LE mono at the negotiated rate) as a
|
|
442
|
+
* `0x01`-prefixed frame. The engine demuxes on the first byte and has no
|
|
443
|
+
* default branch, so an untagged chunk is dropped with no error.
|
|
444
|
+
*/
|
|
309
445
|
sendAudio(chunk) {
|
|
310
|
-
|
|
446
|
+
const bytes = omniToBytes(chunk);
|
|
447
|
+
if (bytes) {
|
|
448
|
+
this.ws.send(omniAudioFrame(bytes));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
// A Blob can only be read asynchronously; queue behind the previous read so
|
|
452
|
+
// frames still reach the engine in call order.
|
|
453
|
+
this.blobTail = this.blobTail
|
|
454
|
+
.then(async () => {
|
|
455
|
+
const buf = await chunk.arrayBuffer();
|
|
456
|
+
this.ws.send(omniAudioFrame(new Uint8Array(buf)));
|
|
457
|
+
})
|
|
458
|
+
.catch((err) => this.opts.onError?.(err instanceof Error ? err : new Error(String(err))));
|
|
311
459
|
}
|
|
312
460
|
/** Send DTMF digits as a `{"type":"dtmf"}` control frame. */
|
|
313
461
|
sendDtmf(digits) {
|
|
314
|
-
this.ws.send(
|
|
462
|
+
this.ws.send(omniControlFrame({ type: "dtmf", digits }));
|
|
463
|
+
}
|
|
464
|
+
/** Reply to a client-loop {@link OmniEvent.ToolCall}. */
|
|
465
|
+
toolResult(callId, payload) {
|
|
466
|
+
this.ws.send(omniControlFrame({ type: "tool_result", call_id: callId, ...payload }));
|
|
315
467
|
}
|
|
316
468
|
/**
|
|
317
469
|
* Send an arbitrary control frame for forward-compat control types the SDK
|
|
@@ -319,7 +471,7 @@ export class OmniConnection {
|
|
|
319
471
|
* never `event`.
|
|
320
472
|
*/
|
|
321
473
|
send(frame) {
|
|
322
|
-
this.ws.send(
|
|
474
|
+
this.ws.send(omniControlFrame(frame));
|
|
323
475
|
}
|
|
324
476
|
/** Close the session. */
|
|
325
477
|
close(code = WSCloseCode.Normal, reason = "") {
|
|
@@ -447,7 +599,7 @@ export class PyAI {
|
|
|
447
599
|
const res = await this.request("/v1/audio/speech", {
|
|
448
600
|
method: "POST",
|
|
449
601
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
450
|
-
body: JSON.stringify({ model: "pyai-voice", ...params }),
|
|
602
|
+
body: JSON.stringify({ model: "pyai-voice", ...params, stream: true }),
|
|
451
603
|
});
|
|
452
604
|
if (!res.body)
|
|
453
605
|
throw new PyAIError(res.status, "Response had no body to stream");
|
|
@@ -510,7 +662,7 @@ export class PyAI {
|
|
|
510
662
|
// --- key introspection --------------------------------------------------
|
|
511
663
|
/**
|
|
512
664
|
* Introspect the calling key: scopes, environment, and limits. Useful for a
|
|
513
|
-
* preflight/doctor check. (New route; older deployments may 404
|
|
665
|
+
* preflight/doctor check. (New route; older deployments may 404, handle it.)
|
|
514
666
|
*/
|
|
515
667
|
me = () => this.getJson("/v1/me");
|
|
516
668
|
// --- voice clones -------------------------------------------------------
|
|
@@ -612,7 +764,7 @@ export class PyAI {
|
|
|
612
764
|
},
|
|
613
765
|
},
|
|
614
766
|
findings: {
|
|
615
|
-
/** List Tier-2 (async semantic) findings
|
|
767
|
+
/** List Tier-2 (async semantic) findings, advisory, non-blocking. Scope `trace:read`. */
|
|
616
768
|
list: (params = {}) => {
|
|
617
769
|
const q = new URLSearchParams();
|
|
618
770
|
if (params.checkId)
|
|
@@ -680,11 +832,45 @@ export class PyAI {
|
|
|
680
832
|
trigger: (callId, input) => this.postJson(`/v1/recap/calls/${encodeURIComponent(callId)}`, input),
|
|
681
833
|
},
|
|
682
834
|
};
|
|
835
|
+
// --- amd (answering-machine detection) ---------------------------------
|
|
836
|
+
amd = {
|
|
837
|
+
config: {
|
|
838
|
+
/** The org's AMD operating point + webhook. Scope `amd:configure`. */
|
|
839
|
+
get: () => this.getJson("/v1/amd/config"),
|
|
840
|
+
/** Set the account-default `aggressiveness` (0-1) and webhook. Scope `amd:configure`. */
|
|
841
|
+
set: (input) => this.postJson("/v1/amd/config", {
|
|
842
|
+
...(input.aggressiveness !== undefined ? { aggressiveness: input.aggressiveness } : {}),
|
|
843
|
+
...(input.webhookUrl !== undefined ? { webhook_url: input.webhookUrl } : {}),
|
|
844
|
+
}),
|
|
845
|
+
},
|
|
846
|
+
calls: {
|
|
847
|
+
/** Recent AMD decisions, newest first. Scope `amd:read`. */
|
|
848
|
+
list: (params = {}) => {
|
|
849
|
+
const q = new URLSearchParams();
|
|
850
|
+
if (params.limit !== undefined)
|
|
851
|
+
q.set("limit", String(params.limit));
|
|
852
|
+
if (params.cursor)
|
|
853
|
+
q.set("cursor", params.cursor);
|
|
854
|
+
if (params.sessionLabel)
|
|
855
|
+
q.set("session_label", params.sessionLabel);
|
|
856
|
+
const qs = q.toString();
|
|
857
|
+
return this.getJson(`/v1/amd/calls${qs ? `?${qs}` : ""}`);
|
|
858
|
+
},
|
|
859
|
+
/** The full decision (answered_by, reason, …) for one call. Scope `amd:read`. */
|
|
860
|
+
get: (callId) => this.getJson(`/v1/amd/calls/${encodeURIComponent(callId)}`),
|
|
861
|
+
},
|
|
862
|
+
/**
|
|
863
|
+
* Open a live AMD stream over `/v1/amd/stream` (Twilio Media Streams
|
|
864
|
+
* protocol). Server-side helper for forking media yourself; the common
|
|
865
|
+
* Twilio path is one line of TwiML, no SDK. Scope `amd:detect`.
|
|
866
|
+
*/
|
|
867
|
+
stream: (opts = {}) => new AmdStream(this.amdStreamURL(opts), this.realtimeSubprotocol(), opts),
|
|
868
|
+
};
|
|
683
869
|
// --- omni (agentic voice) ----------------------------------------------
|
|
684
870
|
omni = {
|
|
685
871
|
/**
|
|
686
872
|
* Mint an ephemeral, origin-locked Omni session token a browser can use to
|
|
687
|
-
* open ONE realtime session **directly
|
|
873
|
+
* open ONE realtime session **directly**, the public/private split for
|
|
688
874
|
* realtime. **Call this from your server** with a secret key holding
|
|
689
875
|
* `omni:session`; never ship the secret key to a page. Hand the returned
|
|
690
876
|
* `token` to the browser, which connects with
|
|
@@ -700,8 +886,8 @@ export class PyAI {
|
|
|
700
886
|
/**
|
|
701
887
|
* Open a live Omni agentic-voice session over `/v1/omni`. Returns an
|
|
702
888
|
* {@link OmniConnection} that handles the wire protocol's frame-key
|
|
703
|
-
* asymmetry for you
|
|
704
|
-
* `dtmf`) and parses `event`-keyed server frames
|
|
889
|
+
* asymmetry for you, it sends `type`-keyed control frames (`configure`,
|
|
890
|
+
* `dtmf`) and parses `event`-keyed server frames, so you can't trip the #1
|
|
705
891
|
* Omni integration bug. Omni is zero-state: nothing to create first; the
|
|
706
892
|
* agent's behavior travels in the `configure` frame. Pass `token` (from
|
|
707
893
|
* `createSession`) to connect from a browser without the secret key.
|
|
@@ -724,7 +910,7 @@ export class PyAI {
|
|
|
724
910
|
const q = new URLSearchParams(opts.query ?? {});
|
|
725
911
|
if ((opts.product ?? "omni") === "omni") {
|
|
726
912
|
// Omni's native realtime surface is /v1/omni. The session is authorized by
|
|
727
|
-
// the key's org (zero-state)
|
|
913
|
+
// the key's org (zero-state), sessionLabel is an optional opaque tag.
|
|
728
914
|
// format/rate are load-bearing on the connect URL, so default to
|
|
729
915
|
// browser-grade PCM16/24kHz.
|
|
730
916
|
if (opts.sessionLabel)
|
|
@@ -766,6 +952,17 @@ export class PyAI {
|
|
|
766
952
|
const qs = q.toString();
|
|
767
953
|
return `${wsBase}/v1/audio/transcriptions/stream${qs ? `?${qs}` : ""}`;
|
|
768
954
|
}
|
|
955
|
+
/** Build the AMD detection WebSocket URL (`/v1/amd/stream`). */
|
|
956
|
+
amdStreamURL(opts = {}) {
|
|
957
|
+
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
958
|
+
const q = new URLSearchParams(opts.query ?? {});
|
|
959
|
+
if (opts.aggressiveness !== undefined)
|
|
960
|
+
q.set("aggressiveness", String(opts.aggressiveness));
|
|
961
|
+
if (opts.sessionLabel)
|
|
962
|
+
q.set("session_label", opts.sessionLabel);
|
|
963
|
+
const qs = q.toString();
|
|
964
|
+
return `${wsBase}/v1/amd/stream${qs ? `?${qs}` : ""}`;
|
|
965
|
+
}
|
|
769
966
|
/**
|
|
770
967
|
* Open a realtime WebSocket. Uses the global WebSocket (browser, Node 22+).
|
|
771
968
|
* The key travels as a subprotocol so it works from the browser without
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyai/sdk",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Official TypeScript/JavaScript SDK for PyAI
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
7
7
|
"types": "dist/index.d.ts",
|
package/src/cli.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* pyai
|
|
3
|
+
* pyai, CLI for the PyAI API: proves your key, the endpoint, and audio in one
|
|
4
4
|
* command (`smoke`) or runs a deeper diagnosis with remediation hints (`doctor`).
|
|
5
5
|
*
|
|
6
6
|
* Commands:
|
|
7
7
|
* pyai doctor diagnose key/scopes + endpoint + Speak→Hear round-trip
|
|
8
|
-
* pyai smoke
|
|
8
|
+
* pyai smoke [--tolerate-upstream] run models+voices+speak and report PASS/FAIL
|
|
9
|
+
* (--tolerate-upstream: transient 5xx/429 → WARN, not FAIL)
|
|
9
10
|
* pyai models list models
|
|
10
11
|
* pyai voices [--gender g --region r] list voices
|
|
11
12
|
* pyai speak --text T [--voice V] [--out f.wav]
|
|
12
13
|
* pyai transcribe --url U [--diarize] [--poll]
|
|
13
14
|
*
|
|
14
15
|
* Auth: PYAI_API_KEY env (or --api-key). Base URL: PYAI_BASE_URL (or --base-url).
|
|
15
|
-
* Zero deps
|
|
16
|
+
* Zero deps, uses the bundled SDK.
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
import { writeFile } from "node:fs/promises";
|
|
@@ -64,11 +65,12 @@ function fail(msg: string): never {
|
|
|
64
65
|
process.exit(1);
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
const USAGE = `pyai
|
|
68
|
+
const USAGE = `pyai, PyAI API CLI
|
|
68
69
|
|
|
69
70
|
Usage:
|
|
70
71
|
pyai doctor diagnose key/scopes + endpoint + Speak→Hear round-trip
|
|
71
|
-
pyai smoke
|
|
72
|
+
pyai smoke [--tolerate-upstream] run a key/endpoint/audio smoke test
|
|
73
|
+
(--tolerate-upstream: transient 5xx/429 warn, don't fail)
|
|
72
74
|
pyai models list models
|
|
73
75
|
pyai voices [--gender g] [--region r] list voices
|
|
74
76
|
pyai speak --text T [--voice V] [--out f.wav]
|
|
@@ -116,16 +118,53 @@ async function cmdTranscribe(flags: Flags): Promise<void> {
|
|
|
116
118
|
out(`job ${job.job_id} still running after polling; check later.`);
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
// Transient upstream conditions: a momentary engine/capacity blip or network
|
|
122
|
+
// hiccup, NOT a key/scope/contract problem. 5xx = engine unhealthy (e.g. Speak
|
|
123
|
+
// "503 service_unavailable"), 429 = rate/capacity, a non-PyAIError = network.
|
|
124
|
+
// These self-heal; the others (401/403/404/400) are real and must fail loudly.
|
|
125
|
+
const TRANSIENT_STATUSES = new Set([429, 500, 502, 503, 504]);
|
|
126
|
+
function isTransient(err: unknown): boolean {
|
|
127
|
+
if (err instanceof PyAIError) return TRANSIENT_STATUSES.has(err.status);
|
|
128
|
+
return true; // network / timeout / unknown, worth a retry, never a hard fail on its own
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Retry `fn` on transient upstream errors with exponential backoff. Real
|
|
132
|
+
* (non-transient) errors throw immediately, we never paper over a bad key. */
|
|
133
|
+
async function withRetry<T>(fn: () => Promise<T>, attempts = 4, baseMs = 800): Promise<T> {
|
|
134
|
+
let lastErr: unknown;
|
|
135
|
+
for (let i = 0; i < attempts; i++) {
|
|
136
|
+
try {
|
|
137
|
+
return await fn();
|
|
138
|
+
} catch (err) {
|
|
139
|
+
lastErr = err;
|
|
140
|
+
if (!isTransient(err) || i === attempts - 1) throw err;
|
|
141
|
+
await new Promise((r) => setTimeout(r, baseMs * 2 ** i));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
throw lastErr;
|
|
145
|
+
}
|
|
146
|
+
|
|
119
147
|
/** The headline: prove key + endpoint + audio in one command. */
|
|
120
148
|
async function cmdSmoke(flags: Flags): Promise<void> {
|
|
121
149
|
const pyai = client(flags);
|
|
122
|
-
|
|
150
|
+
// CI/ops opt-in: a transient upstream blip (engine warming, a brief 503,
|
|
151
|
+
// rate-limit) should not red the build, it isn't the commit's fault. With this
|
|
152
|
+
// on, such failures are reported as WARN (exit 0); real key/scope/contract
|
|
153
|
+
// failures still FAIL (exit 1). Off by default so a developer running `pyai
|
|
154
|
+
// smoke` gets the strict, honest answer.
|
|
155
|
+
const tolerateUpstream = flags["tolerate-upstream"] === true || process.env.PYAI_SMOKE_TOLERATE_UPSTREAM === "1";
|
|
156
|
+
// Retry tuning is env-overridable (tests drive it fast; default rides brief blips).
|
|
157
|
+
const retryAttempts = Number(process.env.PYAI_SMOKE_RETRY_ATTEMPTS ?? 4);
|
|
158
|
+
const retryBaseMs = Number(process.env.PYAI_SMOKE_RETRY_BASE_MS ?? 800);
|
|
159
|
+
type Status = "PASS" | "WARN" | "FAIL";
|
|
160
|
+
const checks: Array<{ name: string; status: Status; detail: string }> = [];
|
|
123
161
|
const run = async (name: string, fn: () => Promise<string>) => {
|
|
124
162
|
try {
|
|
125
|
-
checks.push({ name,
|
|
163
|
+
checks.push({ name, status: "PASS", detail: await withRetry(fn, retryAttempts, retryBaseMs) });
|
|
126
164
|
} catch (err) {
|
|
127
|
-
const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""} ${err.message}
|
|
128
|
-
|
|
165
|
+
const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""} ${err.message}`.trim() : (err as Error).message;
|
|
166
|
+
const status: Status = isTransient(err) && tolerateUpstream ? "WARN" : "FAIL";
|
|
167
|
+
checks.push({ name, status, detail });
|
|
129
168
|
}
|
|
130
169
|
};
|
|
131
170
|
|
|
@@ -142,10 +181,19 @@ async function cmdSmoke(flags: Flags): Promise<void> {
|
|
|
142
181
|
return `${Buffer.from(audio).byteLength} bytes of audio`;
|
|
143
182
|
});
|
|
144
183
|
|
|
145
|
-
for (const c of checks) out(`${c.
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
184
|
+
for (const c of checks) out(`${c.status} ${c.name}, ${c.detail}`);
|
|
185
|
+
const failed = checks.filter((c) => c.status === "FAIL");
|
|
186
|
+
const warned = checks.filter((c) => c.status === "WARN");
|
|
187
|
+
// GitHub Actions annotation: a tolerated blip is still surfaced in the run UI.
|
|
188
|
+
for (const c of warned) out(`::warning title=PyAI smoke transient::${c.name}: ${c.detail}`);
|
|
189
|
+
if (failed.length === 0 && warned.length === 0) {
|
|
190
|
+
out("\nAll checks passed. Your key, the endpoint, and audio synthesis work.");
|
|
191
|
+
} else if (failed.length === 0) {
|
|
192
|
+
out(`\n${warned.length} transient upstream issue(s) tolerated (self-healing engine blip), not failing the build.`);
|
|
193
|
+
} else {
|
|
194
|
+
out("\nSome checks failed (see above).");
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
149
197
|
}
|
|
150
198
|
|
|
151
199
|
/** Turn an error into an actionable, code-first remediation hint. */
|
|
@@ -153,35 +201,35 @@ function remediation(err: unknown): string {
|
|
|
153
201
|
if (!(err instanceof PyAIError)) return (err as Error)?.message ?? String(err);
|
|
154
202
|
switch (err.code) {
|
|
155
203
|
case "unauthorized":
|
|
156
|
-
return "Invalid or missing key
|
|
204
|
+
return "Invalid or missing key, check PYAI_API_KEY (a pyai_test_ or pyai_live_ key).";
|
|
157
205
|
case "forbidden":
|
|
158
|
-
return "Key is missing a required scope
|
|
206
|
+
return "Key is missing a required scope, add it to the key in the console.";
|
|
159
207
|
case "origin_not_allowed":
|
|
160
|
-
return "Publishable token origin not allow-listed
|
|
208
|
+
return "Publishable token origin not allow-listed, fix the allowed origins.";
|
|
161
209
|
case "credit_exhausted":
|
|
162
|
-
return "Out of prepaid credit
|
|
210
|
+
return "Out of prepaid credit, add credit, or use a pyai_test_ sandbox key.";
|
|
163
211
|
case "key_budget_exceeded":
|
|
164
|
-
return "Per-key monthly budget hit
|
|
212
|
+
return "Per-key monthly budget hit, raise the budget in the console.";
|
|
165
213
|
case "insufficient_quota":
|
|
166
|
-
return "Plan quota exhausted
|
|
214
|
+
return "Plan quota exhausted, upgrade your plan.";
|
|
167
215
|
case "rate_limit_exceeded":
|
|
168
|
-
return "Rate limited
|
|
216
|
+
return "Rate limited, back off and retry (honor Retry-After).";
|
|
169
217
|
case "concurrency_limit_exceeded":
|
|
170
|
-
return "Too many concurrent sessions
|
|
218
|
+
return "Too many concurrent sessions, retry shortly.";
|
|
171
219
|
case "daily_cap_exceeded":
|
|
172
|
-
return "Daily cap reached
|
|
220
|
+
return "Daily cap reached, wait until it resets.";
|
|
173
221
|
default:
|
|
174
222
|
break;
|
|
175
223
|
}
|
|
176
224
|
switch (err.status) {
|
|
177
225
|
case 401:
|
|
178
|
-
return "Invalid or missing key
|
|
226
|
+
return "Invalid or missing key, check PYAI_API_KEY.";
|
|
179
227
|
case 403:
|
|
180
|
-
return "Forbidden
|
|
228
|
+
return "Forbidden, the key likely lacks the required scope.";
|
|
181
229
|
case 404:
|
|
182
|
-
return "Not found
|
|
230
|
+
return "Not found, check PYAI_BASE_URL and the route.";
|
|
183
231
|
case 429:
|
|
184
|
-
return "Rate/concurrency limited
|
|
232
|
+
return "Rate/concurrency limited, back off and retry.";
|
|
185
233
|
default:
|
|
186
234
|
return err.message;
|
|
187
235
|
}
|
|
@@ -210,7 +258,7 @@ async function cmdDoctor(flags: Flags): Promise<void> {
|
|
|
210
258
|
const checks: DoctorCheck[] = [];
|
|
211
259
|
|
|
212
260
|
// (a) Key validity + scopes via GET /v1/me. The route is new, so a 404 means
|
|
213
|
-
// "not deployed here yet"
|
|
261
|
+
// "not deployed here yet", skip it rather than failing the whole doctor.
|
|
214
262
|
try {
|
|
215
263
|
const me = await pyai.me();
|
|
216
264
|
const scopes = Array.isArray(me.scopes) ? me.scopes : [];
|
|
@@ -245,14 +293,14 @@ async function cmdDoctor(flags: Flags): Promise<void> {
|
|
|
245
293
|
});
|
|
246
294
|
|
|
247
295
|
for (const c of checks) {
|
|
248
|
-
out(`${c.status.padEnd(4)} ${c.name}
|
|
296
|
+
out(`${c.status.padEnd(4)} ${c.name}, ${c.detail}`);
|
|
249
297
|
if (c.hint) out(` ↳ ${c.hint}`);
|
|
250
298
|
}
|
|
251
299
|
const failed = checks.filter((c) => c.status === "FAIL");
|
|
252
300
|
if (failed.length === 0) {
|
|
253
301
|
out("\nDiagnosis: healthy. Key, endpoint, and a Speak→Hear round-trip all work.");
|
|
254
302
|
} else {
|
|
255
|
-
out(`\nDiagnosis: ${failed.length} check(s) failed
|
|
303
|
+
out(`\nDiagnosis: ${failed.length} check(s) failed, see the remediation hints above.`);
|
|
256
304
|
process.exit(1);
|
|
257
305
|
}
|
|
258
306
|
}
|