@pyai/sdk 0.1.2 → 0.2.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 +122 -5
- package/dist/cli.d.ts +3 -2
- package/dist/cli.js +107 -2
- package/dist/index.d.ts +619 -5
- package/dist/index.js +368 -0
- package/package.json +1 -1
- package/src/cli.ts +115 -2
- package/src/index.ts +835 -5
package/dist/index.js
CHANGED
|
@@ -21,6 +21,150 @@ 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
|
+
/** Server-side fault frame. */
|
|
49
|
+
Error: "error",
|
|
50
|
+
};
|
|
51
|
+
/** WebSocket close codes used across the PyAI realtime/streaming surfaces. */
|
|
52
|
+
export const WSCloseCode = {
|
|
53
|
+
/** Normal closure. */
|
|
54
|
+
Normal: 1000,
|
|
55
|
+
/** Auth/policy: bad key, missing scope, or revoked token. */
|
|
56
|
+
PolicyViolation: 1008,
|
|
57
|
+
/** Engine/internal error. */
|
|
58
|
+
InternalError: 1011,
|
|
59
|
+
/** Over the concurrency cap (PyAI-specific; mirrors HTTP 429). */
|
|
60
|
+
OverCapacity: 4429,
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Stable, machine-readable error `code`s (the documented contract). Branch on
|
|
64
|
+
* these. The set is treated as open — `PyAIError.code` stays `string` — so a
|
|
65
|
+
* new server code never breaks the build, but the known ones are named here.
|
|
66
|
+
*/
|
|
67
|
+
export const ErrorCode = {
|
|
68
|
+
Unauthorized: "unauthorized",
|
|
69
|
+
Forbidden: "forbidden",
|
|
70
|
+
OriginNotAllowed: "origin_not_allowed",
|
|
71
|
+
InvalidAgentId: "invalid_agent_id",
|
|
72
|
+
CreditExhausted: "credit_exhausted",
|
|
73
|
+
KeyBudgetExceeded: "key_budget_exceeded",
|
|
74
|
+
InsufficientQuota: "insufficient_quota",
|
|
75
|
+
RateLimitExceeded: "rate_limit_exceeded",
|
|
76
|
+
ConcurrencyLimitExceeded: "concurrency_limit_exceeded",
|
|
77
|
+
DailyCapExceeded: "daily_cap_exceeded",
|
|
78
|
+
IdempotencyConflict: "idempotency_conflict",
|
|
79
|
+
NotFound: "not_found",
|
|
80
|
+
NumberInUse: "number_in_use",
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* A live Hear streaming-STT session. Hides the frame protocol: stream audio
|
|
84
|
+
* with {@link HearStream.sendAudio}, get `onPartial`/`onFinal`/`onError`
|
|
85
|
+
* callbacks, force-finalize with {@link HearStream.commit}, and flush+close
|
|
86
|
+
* with {@link HearStream.close}. Construct via `pyai.audio.transcriptions.stream()`.
|
|
87
|
+
*/
|
|
88
|
+
export class HearStream {
|
|
89
|
+
ws;
|
|
90
|
+
opts;
|
|
91
|
+
closed = false;
|
|
92
|
+
constructor(url, subprotocol, opts) {
|
|
93
|
+
this.opts = opts;
|
|
94
|
+
const WS = opts.webSocket ?? globalThis.WebSocket;
|
|
95
|
+
if (!WS) {
|
|
96
|
+
throw new Error("No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to transcriptions.stream()");
|
|
97
|
+
}
|
|
98
|
+
this.ws = new WS(url, [subprotocol]);
|
|
99
|
+
this.ws.onopen = () => {
|
|
100
|
+
if (opts.grounding) {
|
|
101
|
+
try {
|
|
102
|
+
this.ws.send(JSON.stringify({ type: "config", grounding: true }));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* surfaced via onerror */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
opts.onOpen?.();
|
|
109
|
+
};
|
|
110
|
+
this.ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
111
|
+
this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
|
|
112
|
+
this.ws.onclose = (ev) => {
|
|
113
|
+
this.closed = true;
|
|
114
|
+
opts.onClose?.(ev.code, ev.reason);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
handleMessage(data) {
|
|
118
|
+
// Hear emits JSON text frames; ignore any unexpected binary.
|
|
119
|
+
if (typeof data !== "string")
|
|
120
|
+
return;
|
|
121
|
+
let frame;
|
|
122
|
+
try {
|
|
123
|
+
frame = JSON.parse(data);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
this.opts.onError?.(new Error(`Unparseable Hear frame: ${data.slice(0, 120)}`));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
switch (frame.type) {
|
|
130
|
+
case HearFrameType.Partial:
|
|
131
|
+
case HearFrameType.PartialStable:
|
|
132
|
+
this.opts.onPartial?.(frame);
|
|
133
|
+
break;
|
|
134
|
+
case HearFrameType.SpeechFinal:
|
|
135
|
+
case HearFrameType.Final:
|
|
136
|
+
this.opts.onFinal?.(frame);
|
|
137
|
+
break;
|
|
138
|
+
case HearFrameType.Error:
|
|
139
|
+
this.opts.onError?.(frame);
|
|
140
|
+
break;
|
|
141
|
+
default:
|
|
142
|
+
// Unknown/forward-compatible frame — ignore.
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Send a chunk of audio (PCM16 or opus per `encoding`). */
|
|
147
|
+
sendAudio(chunk) {
|
|
148
|
+
this.ws.send(chunk);
|
|
149
|
+
}
|
|
150
|
+
/** Force-finalize the current utterance (e.g. on VAD end-of-turn). */
|
|
151
|
+
commit() {
|
|
152
|
+
this.ws.send(JSON.stringify({ type: "commit" }));
|
|
153
|
+
}
|
|
154
|
+
/** Close the socket; the server flushes a final for any buffered audio. */
|
|
155
|
+
close(code = WSCloseCode.Normal, reason = "") {
|
|
156
|
+
if (!this.closed)
|
|
157
|
+
this.ws.close(code, reason);
|
|
158
|
+
}
|
|
159
|
+
/** The underlying socket (escape hatch for advanced use). */
|
|
160
|
+
get socket() {
|
|
161
|
+
return this.ws;
|
|
162
|
+
}
|
|
163
|
+
/** Current WebSocket readyState. */
|
|
164
|
+
get readyState() {
|
|
165
|
+
return this.ws.readyState;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
24
168
|
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
|
|
25
169
|
export class PyAI {
|
|
26
170
|
apiKey;
|
|
@@ -74,6 +218,25 @@ export class PyAI {
|
|
|
74
218
|
const res = await this.request(path, { headers: this.authHeaders() });
|
|
75
219
|
return (await res.json());
|
|
76
220
|
}
|
|
221
|
+
async postJson(path, payload, extraHeaders = {}) {
|
|
222
|
+
const res = await this.request(path, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: this.authHeaders({ "Content-Type": "application/json", ...extraHeaders }),
|
|
225
|
+
body: JSON.stringify(payload),
|
|
226
|
+
});
|
|
227
|
+
return (await res.json());
|
|
228
|
+
}
|
|
229
|
+
async putJson(path, payload) {
|
|
230
|
+
const res = await this.request(path, {
|
|
231
|
+
method: "PUT",
|
|
232
|
+
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
233
|
+
body: JSON.stringify(payload),
|
|
234
|
+
});
|
|
235
|
+
return (await res.json());
|
|
236
|
+
}
|
|
237
|
+
deleteReq(path) {
|
|
238
|
+
return this.request(path, { method: "DELETE", headers: this.authHeaders() });
|
|
239
|
+
}
|
|
77
240
|
// --- models -------------------------------------------------------------
|
|
78
241
|
models = {
|
|
79
242
|
list: () => this.getJson("/v1/models"),
|
|
@@ -126,6 +289,14 @@ export class PyAI {
|
|
|
126
289
|
const form = new FormData();
|
|
127
290
|
form.set("file", params.file, params.filename ?? "audio.wav");
|
|
128
291
|
form.set("model", params.model ?? "pyai-hear");
|
|
292
|
+
if (params.language)
|
|
293
|
+
form.set("language", params.language);
|
|
294
|
+
if (params.response_format)
|
|
295
|
+
form.set("response_format", params.response_format);
|
|
296
|
+
if (params.seed !== undefined)
|
|
297
|
+
form.set("seed", String(params.seed));
|
|
298
|
+
if (params.temperature !== undefined)
|
|
299
|
+
form.set("temperature", String(params.temperature));
|
|
129
300
|
const res = await this.request("/v1/audio/transcriptions", {
|
|
130
301
|
method: "POST",
|
|
131
302
|
headers: this.authHeaders(),
|
|
@@ -133,6 +304,13 @@ export class PyAI {
|
|
|
133
304
|
});
|
|
134
305
|
return (await res.json());
|
|
135
306
|
},
|
|
307
|
+
/**
|
|
308
|
+
* Open a live streaming-STT WebSocket (Hear). Hides the frame protocol
|
|
309
|
+
* behind `onPartial`/`onFinal`/`onError`; stream audio with `sendAudio`,
|
|
310
|
+
* force-finalize with `commit()`, flush+close with `close()`. Set
|
|
311
|
+
* `grounding: true` for Cue (turn detection + KB context).
|
|
312
|
+
*/
|
|
313
|
+
stream: (opts = {}) => new HearStream(this.hearStreamURL(opts), this.realtimeSubprotocol(), opts),
|
|
136
314
|
},
|
|
137
315
|
};
|
|
138
316
|
// --- async transcription jobs ------------------------------------------
|
|
@@ -159,6 +337,179 @@ export class PyAI {
|
|
|
159
337
|
return this.getJson(`/v1/transcription/jobs${qs ? `?${qs}` : ""}`);
|
|
160
338
|
},
|
|
161
339
|
};
|
|
340
|
+
// --- key introspection --------------------------------------------------
|
|
341
|
+
/**
|
|
342
|
+
* Introspect the calling key: scopes, environment, and limits. Useful for a
|
|
343
|
+
* preflight/doctor check. (New route; older deployments may 404 — handle it.)
|
|
344
|
+
*/
|
|
345
|
+
me = () => this.getJson("/v1/me");
|
|
346
|
+
// --- voice clones -------------------------------------------------------
|
|
347
|
+
clones = {
|
|
348
|
+
/** List the org's cloned voices. */
|
|
349
|
+
list: () => this.getJson("/v1/voice/clones"),
|
|
350
|
+
/** Enroll a custom voice from reference audio (>= ~10s). Scope `voice:clone`. */
|
|
351
|
+
create: async (params) => {
|
|
352
|
+
const form = new FormData();
|
|
353
|
+
form.set("name", params.name);
|
|
354
|
+
form.set("file", params.file, params.filename ?? "sample.wav");
|
|
355
|
+
const res = await this.request("/v1/voice/clones", { method: "POST", headers: this.authHeaders(), body: form });
|
|
356
|
+
return (await res.json());
|
|
357
|
+
},
|
|
358
|
+
/**
|
|
359
|
+
* Fetch a single cloned voice by id. The API exposes no GET-by-id for
|
|
360
|
+
* clones, so this filters `list()` client-side and throws a 404 `PyAIError`
|
|
361
|
+
* when the id isn't found.
|
|
362
|
+
*/
|
|
363
|
+
get: async (id) => {
|
|
364
|
+
const { data } = await this.clones.list();
|
|
365
|
+
const match = data.find((v) => v.id === id);
|
|
366
|
+
if (!match)
|
|
367
|
+
throw new PyAIError(404, `No cloned voice ${id}`, ErrorCode.NotFound);
|
|
368
|
+
return match;
|
|
369
|
+
},
|
|
370
|
+
/** Delete a cloned voice (tenant-isolated). Scope `voice:clone`. */
|
|
371
|
+
delete: async (id) => {
|
|
372
|
+
await this.deleteReq(`/v1/voice/clones/${encodeURIComponent(id)}`);
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
// --- telephony (managed numbers) ---------------------------------------
|
|
376
|
+
telephony = {
|
|
377
|
+
numbers: {
|
|
378
|
+
/** Search the carrier's available US local numbers. */
|
|
379
|
+
available: (params = {}) => {
|
|
380
|
+
const q = new URLSearchParams();
|
|
381
|
+
if (params.areaCode)
|
|
382
|
+
q.set("area_code", params.areaCode);
|
|
383
|
+
if (params.contains)
|
|
384
|
+
q.set("contains", params.contains);
|
|
385
|
+
if (params.limit !== undefined)
|
|
386
|
+
q.set("limit", String(params.limit));
|
|
387
|
+
const qs = q.toString();
|
|
388
|
+
return this.getJson(`/v1/telephony/available${qs ? `?${qs}` : ""}`);
|
|
389
|
+
},
|
|
390
|
+
/** List the org's managed numbers (active only unless `includeReleased`). */
|
|
391
|
+
list: (params = {}) => {
|
|
392
|
+
const qs = params.includeReleased ? "?include_released=true" : "";
|
|
393
|
+
return this.getJson(`/v1/telephony/numbers${qs}`);
|
|
394
|
+
},
|
|
395
|
+
/** Provision (buy) a specific available number, optionally bound to an agent. */
|
|
396
|
+
buy: (params) => this.postJson("/v1/telephony/numbers", params),
|
|
397
|
+
/** Route a number to an agent (`agentId: null` to unassign). */
|
|
398
|
+
assign: (id, agentId) => this.postJson(`/v1/telephony/numbers/${encodeURIComponent(id)}/assign`, { agent_id: agentId }),
|
|
399
|
+
/** Release a number back to the carrier (idempotent). */
|
|
400
|
+
release: async (id) => {
|
|
401
|
+
const res = await this.deleteReq(`/v1/telephony/numbers/${encodeURIComponent(id)}`);
|
|
402
|
+
return (await res.json());
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
// --- trace (compliance) -------------------------------------------------
|
|
407
|
+
trace = {
|
|
408
|
+
interactions: {
|
|
409
|
+
/** List scanned interactions (scorecards), newest first. Scope `trace:read`. */
|
|
410
|
+
list: (params = {}) => {
|
|
411
|
+
const q = new URLSearchParams();
|
|
412
|
+
if (params.verdict)
|
|
413
|
+
q.set("verdict", params.verdict);
|
|
414
|
+
if (params.agentId)
|
|
415
|
+
q.set("agent_id", params.agentId);
|
|
416
|
+
if (params.limit !== undefined)
|
|
417
|
+
q.set("limit", String(params.limit));
|
|
418
|
+
if (params.cursor)
|
|
419
|
+
q.set("cursor", params.cursor);
|
|
420
|
+
const qs = q.toString();
|
|
421
|
+
return this.getJson(`/v1/trace/interactions${qs ? `?${qs}` : ""}`);
|
|
422
|
+
},
|
|
423
|
+
/** The full per-call evidence view (findings, redactions, audit hash). */
|
|
424
|
+
get: (id) => this.getJson(`/v1/trace/interactions/${encodeURIComponent(id)}`),
|
|
425
|
+
},
|
|
426
|
+
violations: {
|
|
427
|
+
/** Drill-down of every fired Tier-0 rule across scorecards. Scope `trace:read`. */
|
|
428
|
+
list: (params = {}) => {
|
|
429
|
+
const q = new URLSearchParams();
|
|
430
|
+
if (params.ruleId)
|
|
431
|
+
q.set("rule_id", params.ruleId);
|
|
432
|
+
if (params.severity)
|
|
433
|
+
q.set("severity", params.severity);
|
|
434
|
+
if (params.interactionId)
|
|
435
|
+
q.set("interaction_id", params.interactionId);
|
|
436
|
+
if (params.limit !== undefined)
|
|
437
|
+
q.set("limit", String(params.limit));
|
|
438
|
+
if (params.cursor)
|
|
439
|
+
q.set("cursor", params.cursor);
|
|
440
|
+
const qs = q.toString();
|
|
441
|
+
return this.getJson(`/v1/trace/violations${qs ? `?${qs}` : ""}`);
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
findings: {
|
|
445
|
+
/** List Tier-2 (async semantic) findings — advisory, non-blocking. Scope `trace:read`. */
|
|
446
|
+
list: (params = {}) => {
|
|
447
|
+
const q = new URLSearchParams();
|
|
448
|
+
if (params.checkId)
|
|
449
|
+
q.set("check_id", params.checkId);
|
|
450
|
+
if (params.action)
|
|
451
|
+
q.set("action", params.action);
|
|
452
|
+
if (params.severity)
|
|
453
|
+
q.set("severity", params.severity);
|
|
454
|
+
if (params.interactionId)
|
|
455
|
+
q.set("interaction_id", params.interactionId);
|
|
456
|
+
if (params.limit !== undefined)
|
|
457
|
+
q.set("limit", String(params.limit));
|
|
458
|
+
if (params.cursor)
|
|
459
|
+
q.set("cursor", params.cursor);
|
|
460
|
+
const qs = q.toString();
|
|
461
|
+
return this.getJson(`/v1/trace/findings${qs ? `?${qs}` : ""}`);
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
config: {
|
|
465
|
+
/** Read per-agent Trace config (omit `agentId` for the org default). Scope `trace:configure`. */
|
|
466
|
+
get: (agentId) => this.getJson(`/v1/trace/config${agentId ? `?agent_id=${encodeURIComponent(agentId)}` : ""}`),
|
|
467
|
+
/** Upsert per-agent Trace config. Scope `trace:configure`. */
|
|
468
|
+
set: (input) => this.putJson("/v1/trace/config", input),
|
|
469
|
+
},
|
|
470
|
+
rulePacks: {
|
|
471
|
+
/** List built-in + custom rule packs. Scope `trace:configure`. */
|
|
472
|
+
list: () => this.getJson("/v1/trace/rule-packs"),
|
|
473
|
+
/** Upload a custom rule pack (Trace DSL). Scope `trace:configure`. */
|
|
474
|
+
create: (spec) => this.postJson("/v1/trace/rule-packs", spec),
|
|
475
|
+
/** Resolve a rule pack by id (latest active, or pin `version`). */
|
|
476
|
+
get: (id, version) => this.getJson(`/v1/trace/rule-packs/${encodeURIComponent(id)}${version ? `?version=${encodeURIComponent(version)}` : ""}`),
|
|
477
|
+
},
|
|
478
|
+
/** Compliance exposure summary over a trailing window. Scope `trace:read`. */
|
|
479
|
+
exposure: (windowDays) => this.getJson(`/v1/trace/exposure${windowDays !== undefined ? `?window_days=${windowDays}` : ""}`),
|
|
480
|
+
/**
|
|
481
|
+
* Convenience: the per-call operational timeline (eval scorecard-v1). A thin
|
|
482
|
+
* wrapper over `interactions.get(id)` that returns the `timeline` array, or
|
|
483
|
+
* `[]` when the engine hasn't emitted one yet (forward-compatible). Scope
|
|
484
|
+
* `trace:read`.
|
|
485
|
+
*/
|
|
486
|
+
callTimeline: async (id) => {
|
|
487
|
+
const detail = await this.trace.interactions.get(id);
|
|
488
|
+
return detail.timeline ?? [];
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
// --- recap (conversation intelligence) --------------------------------
|
|
492
|
+
recap = {
|
|
493
|
+
config: {
|
|
494
|
+
get: () => this.getJson("/v1/recap/config"),
|
|
495
|
+
set: (input) => this.putJson("/v1/recap/config", input),
|
|
496
|
+
},
|
|
497
|
+
calls: {
|
|
498
|
+
list: (params = {}) => {
|
|
499
|
+
const q = new URLSearchParams();
|
|
500
|
+
if (params.limit !== undefined)
|
|
501
|
+
q.set("limit", String(params.limit));
|
|
502
|
+
if (params.cursor)
|
|
503
|
+
q.set("cursor", params.cursor);
|
|
504
|
+
if (params.status)
|
|
505
|
+
q.set("status", params.status);
|
|
506
|
+
const qs = q.toString();
|
|
507
|
+
return this.getJson(`/v1/recap/calls${qs ? `?${qs}` : ""}`);
|
|
508
|
+
},
|
|
509
|
+
get: (callId) => this.getJson(`/v1/recap/calls/${encodeURIComponent(callId)}`),
|
|
510
|
+
trigger: (callId, input) => this.postJson(`/v1/recap/calls/${encodeURIComponent(callId)}`, input),
|
|
511
|
+
},
|
|
512
|
+
};
|
|
162
513
|
// --- realtime (WebSocket) ----------------------------------------------
|
|
163
514
|
/** Build the realtime WebSocket URL for the chosen product. */
|
|
164
515
|
realtimeURL(opts = {}) {
|
|
@@ -184,6 +535,23 @@ export class PyAI {
|
|
|
184
535
|
realtimeSubprotocol() {
|
|
185
536
|
return `pyai-key.${this.apiKey}`;
|
|
186
537
|
}
|
|
538
|
+
/** Build the Hear streaming-STT WebSocket URL (`/v1/audio/transcriptions/stream`). */
|
|
539
|
+
hearStreamURL(opts = {}) {
|
|
540
|
+
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
541
|
+
const q = new URLSearchParams(opts.query ?? {});
|
|
542
|
+
if (opts.model)
|
|
543
|
+
q.set("model", opts.model);
|
|
544
|
+
if (opts.language)
|
|
545
|
+
q.set("language", opts.language);
|
|
546
|
+
if (opts.sampleRate !== undefined)
|
|
547
|
+
q.set("sample_rate", String(opts.sampleRate));
|
|
548
|
+
if (opts.encoding)
|
|
549
|
+
q.set("encoding", opts.encoding);
|
|
550
|
+
if (opts.interimResults !== undefined)
|
|
551
|
+
q.set("interim_results", String(opts.interimResults));
|
|
552
|
+
const qs = q.toString();
|
|
553
|
+
return `${wsBase}/v1/audio/transcriptions/stream${qs ? `?${qs}` : ""}`;
|
|
554
|
+
}
|
|
187
555
|
/**
|
|
188
556
|
* Open a realtime WebSocket. Uses the global WebSocket (browser, Node 22+).
|
|
189
557
|
* 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.
|
|
3
|
+
"version": "0.2.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",
|
package/src/cli.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* pyai — CLI
|
|
4
|
-
*
|
|
3
|
+
* pyai — CLI for the PyAI API: proves your key, the endpoint, and audio in one
|
|
4
|
+
* command (`smoke`) or runs a deeper diagnosis with remediation hints (`doctor`).
|
|
5
5
|
*
|
|
6
6
|
* Commands:
|
|
7
|
+
* pyai doctor diagnose key/scopes + endpoint + Speak→Hear round-trip
|
|
7
8
|
* pyai smoke run models+voices+speak and report PASS/FAIL
|
|
8
9
|
* pyai models list models
|
|
9
10
|
* pyai voices [--gender g --region r] list voices
|
|
@@ -66,6 +67,7 @@ function fail(msg: string): never {
|
|
|
66
67
|
const USAGE = `pyai — PyAI API CLI
|
|
67
68
|
|
|
68
69
|
Usage:
|
|
70
|
+
pyai doctor diagnose key/scopes + endpoint + Speak→Hear round-trip
|
|
69
71
|
pyai smoke run a key/endpoint/audio smoke test
|
|
70
72
|
pyai models list models
|
|
71
73
|
pyai voices [--gender g] [--region r] list voices
|
|
@@ -146,10 +148,121 @@ async function cmdSmoke(flags: Flags): Promise<void> {
|
|
|
146
148
|
if (!allOk) process.exit(1);
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
/** Turn an error into an actionable, code-first remediation hint. */
|
|
152
|
+
function remediation(err: unknown): string {
|
|
153
|
+
if (!(err instanceof PyAIError)) return (err as Error)?.message ?? String(err);
|
|
154
|
+
switch (err.code) {
|
|
155
|
+
case "unauthorized":
|
|
156
|
+
return "Invalid or missing key — check PYAI_API_KEY (a pyai_test_ or pyai_live_ key).";
|
|
157
|
+
case "forbidden":
|
|
158
|
+
return "Key is missing a required scope — add it to the key in the console.";
|
|
159
|
+
case "origin_not_allowed":
|
|
160
|
+
return "Publishable token origin not allow-listed — fix the allowed origins.";
|
|
161
|
+
case "credit_exhausted":
|
|
162
|
+
return "Out of prepaid credit — add credit, or use a pyai_test_ sandbox key.";
|
|
163
|
+
case "key_budget_exceeded":
|
|
164
|
+
return "Per-key monthly budget hit — raise the budget in the console.";
|
|
165
|
+
case "insufficient_quota":
|
|
166
|
+
return "Plan quota exhausted — upgrade your plan.";
|
|
167
|
+
case "rate_limit_exceeded":
|
|
168
|
+
return "Rate limited — back off and retry (honor Retry-After).";
|
|
169
|
+
case "concurrency_limit_exceeded":
|
|
170
|
+
return "Too many concurrent sessions — retry shortly.";
|
|
171
|
+
case "daily_cap_exceeded":
|
|
172
|
+
return "Daily cap reached — wait until it resets.";
|
|
173
|
+
default:
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
switch (err.status) {
|
|
177
|
+
case 401:
|
|
178
|
+
return "Invalid or missing key — check PYAI_API_KEY.";
|
|
179
|
+
case 403:
|
|
180
|
+
return "Forbidden — the key likely lacks the required scope.";
|
|
181
|
+
case 404:
|
|
182
|
+
return "Not found — check PYAI_BASE_URL and the route.";
|
|
183
|
+
case 429:
|
|
184
|
+
return "Rate/concurrency limited — back off and retry.";
|
|
185
|
+
default:
|
|
186
|
+
return err.message;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface DoctorCheck {
|
|
191
|
+
name: string;
|
|
192
|
+
status: "PASS" | "FAIL" | "SKIP";
|
|
193
|
+
detail: string;
|
|
194
|
+
hint?: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function doctorCheck(checks: DoctorCheck[], name: string, fn: () => Promise<string>): Promise<void> {
|
|
198
|
+
try {
|
|
199
|
+
checks.push({ name, status: "PASS", detail: await fn() });
|
|
200
|
+
} catch (err) {
|
|
201
|
+
const detail =
|
|
202
|
+
err instanceof PyAIError ? `${err.status} ${err.code ?? ""} ${err.message}`.trim() : (err as Error).message;
|
|
203
|
+
checks.push({ name, status: "FAIL", detail, hint: remediation(err) });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Deeper than smoke: key/scopes, endpoint liveness, and a Speak→Hear round-trip. */
|
|
208
|
+
async function cmdDoctor(flags: Flags): Promise<void> {
|
|
209
|
+
const pyai = client(flags);
|
|
210
|
+
const checks: DoctorCheck[] = [];
|
|
211
|
+
|
|
212
|
+
// (a) Key validity + scopes via GET /v1/me. The route is new, so a 404 means
|
|
213
|
+
// "not deployed here yet" — skip it rather than failing the whole doctor.
|
|
214
|
+
try {
|
|
215
|
+
const me = await pyai.me();
|
|
216
|
+
const scopes = Array.isArray(me.scopes) ? me.scopes : [];
|
|
217
|
+
const env = me.environment ?? me.env ?? "unknown";
|
|
218
|
+
checks.push({
|
|
219
|
+
name: "key (/v1/me)",
|
|
220
|
+
status: "PASS",
|
|
221
|
+
detail: `env=${env}; ${scopes.length} scope(s)${scopes.length ? `: ${scopes.join(", ")}` : ""}`,
|
|
222
|
+
});
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if (err instanceof PyAIError && err.status === 404) {
|
|
225
|
+
checks.push({ name: "key (/v1/me)", status: "SKIP", detail: "introspection route not on this deployment" });
|
|
226
|
+
} else {
|
|
227
|
+
const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""}`.trim() : (err as Error).message;
|
|
228
|
+
checks.push({ name: "key (/v1/me)", status: "FAIL", detail, hint: remediation(err) });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// (b) Endpoint liveness.
|
|
233
|
+
await doctorCheck(checks, "models.list", async () => `${(await pyai.models.list()).data.length} models`);
|
|
234
|
+
await doctorCheck(checks, "voices.list", async () => `${(await pyai.voices.list()).data.length} voices`);
|
|
235
|
+
|
|
236
|
+
// (c) Speak -> Hear round-trip: synthesize a sentence, then transcribe it.
|
|
237
|
+
await doctorCheck(checks, "speak→hear round-trip", async () => {
|
|
238
|
+
const audio = await pyai.audio.speech({ input: "The quick brown fox jumps over the lazy dog." });
|
|
239
|
+
const bytes = Buffer.from(audio).byteLength;
|
|
240
|
+
const blob = new Blob([audio], { type: "audio/wav" });
|
|
241
|
+
const tr = await pyai.audio.transcriptions.create({ file: blob, filename: "doctor.wav" });
|
|
242
|
+
const text = (tr.text ?? "").trim();
|
|
243
|
+
if (!text) throw new Error(`synthesized ${bytes} bytes but transcription came back empty`);
|
|
244
|
+
return `synth ${bytes} bytes → "${text.length > 60 ? `${text.slice(0, 60)}…` : text}"`;
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
for (const c of checks) {
|
|
248
|
+
out(`${c.status.padEnd(4)} ${c.name} — ${c.detail}`);
|
|
249
|
+
if (c.hint) out(` ↳ ${c.hint}`);
|
|
250
|
+
}
|
|
251
|
+
const failed = checks.filter((c) => c.status === "FAIL");
|
|
252
|
+
if (failed.length === 0) {
|
|
253
|
+
out("\nDiagnosis: healthy. Key, endpoint, and a Speak→Hear round-trip all work.");
|
|
254
|
+
} else {
|
|
255
|
+
out(`\nDiagnosis: ${failed.length} check(s) failed — see the remediation hints above.`);
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
149
260
|
async function main(): Promise<void> {
|
|
150
261
|
const flags = parseArgs(process.argv.slice(2));
|
|
151
262
|
const cmd = (flags._ as string[])[0];
|
|
152
263
|
switch (cmd) {
|
|
264
|
+
case "doctor":
|
|
265
|
+
return cmdDoctor(flags);
|
|
153
266
|
case "smoke":
|
|
154
267
|
return cmdSmoke(flags);
|
|
155
268
|
case "models":
|