@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/README.md
CHANGED
|
@@ -31,13 +31,13 @@ import PyAI from "@pyai/sdk";
|
|
|
31
31
|
const pyai = new PyAI({ apiKey: process.env.PYAI_API_KEY! });
|
|
32
32
|
|
|
33
33
|
// Text-to-speech
|
|
34
|
-
const audio = await pyai.audio.speech({ input: "Hello from PyAI.", voice: "
|
|
34
|
+
const audio = await pyai.audio.speech({ input: "Hello from PyAI.", voice: "stock_emma_en_gb" });
|
|
35
35
|
await Bun.write?.("hello.wav", audio); // or fs.writeFile in Node
|
|
36
36
|
|
|
37
37
|
// Text-to-speech, streamed — start playing/forwarding at the first chunk
|
|
38
38
|
// (tens of ms) instead of waiting for the whole clip. Use mp3 for smooth
|
|
39
39
|
// progressive playback.
|
|
40
|
-
const stream = await pyai.audio.speechStream({ input: "Hello from PyAI.", voice: "
|
|
40
|
+
const stream = await pyai.audio.speechStream({ input: "Hello from PyAI.", voice: "stock_emma_en_gb", response_format: "mp3" });
|
|
41
41
|
for await (const chunk of stream) writeToSpeakerOrResponse(chunk);
|
|
42
42
|
|
|
43
43
|
// Voices
|
|
@@ -53,20 +53,154 @@ const done = await pyai.transcriptionJobs.get(job.job_id);
|
|
|
53
53
|
|
|
54
54
|
## Realtime (Omni)
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
`omni.connect()` opens an agentic-voice session and hides the wire protocol —
|
|
57
|
+
including its **frame-key asymmetry** (your control frames are keyed on `type`,
|
|
58
|
+
the server's frames are keyed on `event`). It sends a `type`-keyed `configure`
|
|
59
|
+
the instant the socket opens and routes server frames to typed callbacks, so you
|
|
60
|
+
**can't** trip the #1 Omni integration bug (a hand-rolled `{"event":"configure"}`
|
|
61
|
+
is acked but silently dropped, giving you a connected session with zero turns):
|
|
57
62
|
|
|
58
63
|
```ts
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
// Omni is zero-state: the key's org authorizes the session — nothing to create.
|
|
65
|
+
const omni = pyai.omni.connect({
|
|
66
|
+
rate: 16000, // 24000 browser · 16000 wideband telephony · 8000 G.711/Twilio
|
|
67
|
+
configure: { voice_id: "stock_emma_en_gb", persona: "You are a receptionist." },
|
|
68
|
+
onAudio: (chunk) => speaker.write(chunk), // binary agent audio — play it out
|
|
69
|
+
onTranscript: (f) => console.log(f.text),
|
|
70
|
+
onError: (e) => console.error(e),
|
|
71
|
+
});
|
|
61
72
|
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
omni.sendAudio(pcm16Chunk); // stream caller audio continuously (server-side VAD)
|
|
74
|
+
omni.sendDtmf("5");
|
|
75
|
+
omni.close();
|
|
65
76
|
```
|
|
66
77
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
From the browser, mint an ephemeral token server-side with
|
|
79
|
+
`pyai.omni.createSession({ allowedOrigins })` and pass it as `token` so the page
|
|
80
|
+
never holds a secret key:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const omni = pyai.omni.connect({ token: session.token, configure: { voice_id, persona } });
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
> Omni uses the native `wss://api.pyai.com/v1/omni` surface and is **zero-state**
|
|
87
|
+
> — no agent to create, `sessionLabel` is an optional opaque tag (never
|
|
88
|
+
> required). Need the raw socket? `pyai.realtimeURL({ product: "omni" })` +
|
|
89
|
+
> `pyai.realtimeSubprotocol()` (or `pyai.connectRealtime()`) still work;
|
|
90
|
+
> `product: "flow"` uses `/v1/realtime`. The older `/v2/omni/chat` URL and the
|
|
91
|
+
> `agentId` option are deprecated but still work.
|
|
92
|
+
|
|
93
|
+
## Streaming speech-to-text (Hear / Cue)
|
|
94
|
+
|
|
95
|
+
`transcriptions.stream()` hides the WebSocket frame protocol behind callbacks.
|
|
96
|
+
It opens `wss://api.pyai.com/v1/audio/transcriptions/stream` (key carried as the
|
|
97
|
+
WS subprotocol, so it works in the browser), routes the wire frames to
|
|
98
|
+
`onPartial`/`onFinal`/`onError`, and gives you `sendAudio`, `commit()`, and
|
|
99
|
+
`close()`:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const hear = pyai.audio.transcriptions.stream({
|
|
103
|
+
sampleRate: 16000,
|
|
104
|
+
onPartial: (f) => console.log("…", f.text),
|
|
105
|
+
onFinal: (f) => console.log("✓", f.text, `(${f.audio_ms}ms)`),
|
|
106
|
+
onError: (e) => console.error(e),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
micChunks.on("data", (pcm16) => hear.sendAudio(pcm16)); // binary frames
|
|
110
|
+
vad.on("end", () => hear.commit()); // force-finalize an utterance
|
|
111
|
+
// hear.close() also flushes a final for any buffered audio
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Frame `type`s, WS close codes, and error `code`s are exported as named
|
|
115
|
+
constants so you never hardcode a magic string:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { HearFrameType, WSCloseCode, ErrorCode } from "@pyai/sdk";
|
|
119
|
+
HearFrameType.SpeechFinal; // "speech_final"
|
|
120
|
+
WSCloseCode.OverCapacity; // 4429
|
|
121
|
+
ErrorCode.CreditExhausted; // "credit_exhausted"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Set `grounding: true` to turn the stream into **Cue** (turn detection + KB
|
|
125
|
+
context): the SDK sends the grounding config on open and `final`/`speech_final`
|
|
126
|
+
frames then carry a `grounding` array of top KB passages.
|
|
127
|
+
|
|
128
|
+
In Node, pass a WebSocket implementation if there's no global one:
|
|
129
|
+
`transcriptions.stream({ webSocket: (await import("ws")).WebSocket })`.
|
|
130
|
+
|
|
131
|
+
## Speak audio formats (incl. telephony G.711)
|
|
132
|
+
|
|
133
|
+
`audio.speech` encodes server-side into any of eight formats via `response_format`
|
|
134
|
+
— the audio comes back already in the shape you need, so telephony callers can
|
|
135
|
+
drop the hand-rolled resampler + μ-law encoder entirely:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// Twilio/SIP-ready in one param: raw 8 kHz mono μ-law, no client-side DSP.
|
|
139
|
+
const ulaw = await pyai.audio.speech({
|
|
140
|
+
input: "Your appointment is confirmed.",
|
|
141
|
+
voice: "stock_emma_en_gb",
|
|
142
|
+
response_format: "g711_ulaw", // -> audio/basic, forced 8 kHz
|
|
143
|
+
});
|
|
144
|
+
// base64-encode `ulaw` straight into a Twilio media frame.
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
| `response_format` | sample rates (Hz) | Content-Type |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| `mp3` (default) | 8000 / 16000 / 24000 / 48000 | `audio/mpeg` |
|
|
150
|
+
| `wav` | 8000 / 16000 / 24000 / 48000 | `audio/wav` |
|
|
151
|
+
| `opus` | 8000 / 16000 / 24000 / 48000 | `audio/ogg` |
|
|
152
|
+
| `aac` | 8000 / 16000 / 24000 / 48000 | `audio/aac` |
|
|
153
|
+
| `flac` | 8000 / 16000 / 24000 / 48000 | `audio/flac` |
|
|
154
|
+
| `pcm` (raw int16 LE, no header) | 8000 / 16000 / 24000 / 48000 | `audio/pcm` |
|
|
155
|
+
| `g711_ulaw` | 8000 (forced) | `audio/basic` |
|
|
156
|
+
| `g711_alaw` | 8000 (forced) | `audio/basic` |
|
|
157
|
+
|
|
158
|
+
`sample_rate` is optional — omit it for the engine's native 24 kHz (`g711_*` is
|
|
159
|
+
always 8 kHz). The set is typed (`SpeechFormat`) and exported as `SPEECH_FORMATS`
|
|
160
|
+
/ `SPEECH_SAMPLE_RATES` for dropdowns and validation. Any other value is a
|
|
161
|
+
`400 unsupported_format`; omit `response_format` for the default `mp3`.
|
|
162
|
+
|
|
163
|
+
> See [`examples/speak-telephony-formats`](../../examples/speak-telephony-formats)
|
|
164
|
+
> for the full before/after: ~120 lines of resampler + μ-law replaced by one
|
|
165
|
+
> param, with Node (`@pyai/twilio`), Python, and raw-curl snippets.
|
|
166
|
+
|
|
167
|
+
## More APIs: clones, telephony, trace
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// Voice clones (Speak)
|
|
171
|
+
const { data: clones } = await pyai.clones.list();
|
|
172
|
+
const clone = await pyai.clones.create({ name: "Brand VO", file: refAudioBlob });
|
|
173
|
+
await pyai.clones.delete(clone.id);
|
|
174
|
+
|
|
175
|
+
// Managed phone numbers (Telephony)
|
|
176
|
+
const { data: avail } = await pyai.telephony.numbers.available({ areaCode: "415" });
|
|
177
|
+
const num = await pyai.telephony.numbers.buy({ phone_number: avail[0]!.phone_number, agent_id: "agent_123" });
|
|
178
|
+
await pyai.telephony.numbers.assign(num.id, "agent_123");
|
|
179
|
+
await pyai.telephony.numbers.release(num.id);
|
|
180
|
+
|
|
181
|
+
// Compliance (Trace)
|
|
182
|
+
const { data: calls } = await pyai.trace.interactions.list({ verdict: "FAIL" });
|
|
183
|
+
const detail = await pyai.trace.interactions.get(calls[0]!.id);
|
|
184
|
+
await pyai.trace.config.set({ agent_id: "agent_123", enabled: true });
|
|
185
|
+
const exposure = await pyai.trace.exposure(30);
|
|
186
|
+
|
|
187
|
+
// Per-call eval scorecard (timeline + quality metrics). These are additive and
|
|
188
|
+
// forward-compatible — present once the engine emits them, so reading them is
|
|
189
|
+
// always safe (the timeline reader returns [] until then).
|
|
190
|
+
const timeline = await pyai.trace.callTimeline(detail.id); // TraceTimelineTurn[]
|
|
191
|
+
const quality = detail.quality_metrics; // { wer?, ttfb_ms?, turn_p95_ms?, vaqi?, … }
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Reproducible runs (evals)
|
|
195
|
+
|
|
196
|
+
`audio.speech` and `audio.transcriptions.create` take optional `seed` and
|
|
197
|
+
`temperature` for deterministic eval runs. They're forward-compatible — honored
|
|
198
|
+
once the engine supports them and otherwise ignored — so it's always safe to send:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
await pyai.audio.speech({ input: "Hello", voice: "stock_emma_en_gb", seed: 42, temperature: 0 });
|
|
202
|
+
await pyai.audio.transcriptions.create({ file: wavBlob, seed: 42 });
|
|
203
|
+
```
|
|
70
204
|
|
|
71
205
|
## Errors
|
|
72
206
|
|
|
@@ -91,16 +225,21 @@ Common codes: `unauthorized`, `forbidden`, `credit_exhausted`,
|
|
|
91
225
|
|
|
92
226
|
## CLI (`pyai`)
|
|
93
227
|
|
|
94
|
-
Installing the package also provides a `pyai` command
|
|
95
|
-
|
|
228
|
+
Installing the package also provides a `pyai` command. `pyai doctor` runs a
|
|
229
|
+
deeper diagnosis — it introspects your key/scopes via `GET /v1/me` (skipped
|
|
230
|
+
gracefully if the route isn't deployed yet), checks endpoint liveness, runs a
|
|
231
|
+
Speak→Hear round-trip, and prints remediation hints for any failure:
|
|
96
232
|
|
|
97
233
|
```bash
|
|
98
234
|
export PYAI_API_KEY=pyai_test_...
|
|
99
|
-
npx pyai
|
|
235
|
+
npx pyai doctor
|
|
236
|
+
# PASS key (/v1/me) — env=test; 3 scope(s): hear:transcribe, voice:synthesize, hear:stream
|
|
100
237
|
# PASS models.list — 12 models
|
|
101
238
|
# PASS voices.list — 38 voices
|
|
102
|
-
# PASS
|
|
103
|
-
#
|
|
239
|
+
# PASS speak→hear round-trip — synth 45210 bytes → "the quick brown fox…"
|
|
240
|
+
# Diagnosis: healthy. Key, endpoint, and a Speak→Hear round-trip all work.
|
|
241
|
+
|
|
242
|
+
npx pyai smoke # lighter: models + voices + speak
|
|
104
243
|
```
|
|
105
244
|
|
|
106
245
|
Other commands:
|
|
@@ -108,7 +247,7 @@ Other commands:
|
|
|
108
247
|
```bash
|
|
109
248
|
pyai models
|
|
110
249
|
pyai voices --gender female --region en_us
|
|
111
|
-
pyai speak --text "Hello" --voice
|
|
250
|
+
pyai speak --text "Hello" --voice stock_emma_en_gb --out hello.wav
|
|
112
251
|
pyai transcribe --url https://example.com/call.wav --diarize --poll
|
|
113
252
|
```
|
|
114
253
|
|
package/dist/cli.d.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
|
package/dist/cli.js
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
|
|
@@ -57,6 +58,7 @@ function fail(msg) {
|
|
|
57
58
|
const USAGE = `pyai — PyAI API CLI
|
|
58
59
|
|
|
59
60
|
Usage:
|
|
61
|
+
pyai doctor diagnose key/scopes + endpoint + Speak→Hear round-trip
|
|
60
62
|
pyai smoke run a key/endpoint/audio smoke test
|
|
61
63
|
pyai models list models
|
|
62
64
|
pyai voices [--gender g] [--region r] list voices
|
|
@@ -135,10 +137,113 @@ async function cmdSmoke(flags) {
|
|
|
135
137
|
if (!allOk)
|
|
136
138
|
process.exit(1);
|
|
137
139
|
}
|
|
140
|
+
/** Turn an error into an actionable, code-first remediation hint. */
|
|
141
|
+
function remediation(err) {
|
|
142
|
+
if (!(err instanceof PyAIError))
|
|
143
|
+
return err?.message ?? String(err);
|
|
144
|
+
switch (err.code) {
|
|
145
|
+
case "unauthorized":
|
|
146
|
+
return "Invalid or missing key — check PYAI_API_KEY (a pyai_test_ or pyai_live_ key).";
|
|
147
|
+
case "forbidden":
|
|
148
|
+
return "Key is missing a required scope — add it to the key in the console.";
|
|
149
|
+
case "origin_not_allowed":
|
|
150
|
+
return "Publishable token origin not allow-listed — fix the allowed origins.";
|
|
151
|
+
case "credit_exhausted":
|
|
152
|
+
return "Out of prepaid credit — add credit, or use a pyai_test_ sandbox key.";
|
|
153
|
+
case "key_budget_exceeded":
|
|
154
|
+
return "Per-key monthly budget hit — raise the budget in the console.";
|
|
155
|
+
case "insufficient_quota":
|
|
156
|
+
return "Plan quota exhausted — upgrade your plan.";
|
|
157
|
+
case "rate_limit_exceeded":
|
|
158
|
+
return "Rate limited — back off and retry (honor Retry-After).";
|
|
159
|
+
case "concurrency_limit_exceeded":
|
|
160
|
+
return "Too many concurrent sessions — retry shortly.";
|
|
161
|
+
case "daily_cap_exceeded":
|
|
162
|
+
return "Daily cap reached — wait until it resets.";
|
|
163
|
+
default:
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
switch (err.status) {
|
|
167
|
+
case 401:
|
|
168
|
+
return "Invalid or missing key — check PYAI_API_KEY.";
|
|
169
|
+
case 403:
|
|
170
|
+
return "Forbidden — the key likely lacks the required scope.";
|
|
171
|
+
case 404:
|
|
172
|
+
return "Not found — check PYAI_BASE_URL and the route.";
|
|
173
|
+
case 429:
|
|
174
|
+
return "Rate/concurrency limited — back off and retry.";
|
|
175
|
+
default:
|
|
176
|
+
return err.message;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function doctorCheck(checks, name, fn) {
|
|
180
|
+
try {
|
|
181
|
+
checks.push({ name, status: "PASS", detail: await fn() });
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""} ${err.message}`.trim() : err.message;
|
|
185
|
+
checks.push({ name, status: "FAIL", detail, hint: remediation(err) });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Deeper than smoke: key/scopes, endpoint liveness, and a Speak→Hear round-trip. */
|
|
189
|
+
async function cmdDoctor(flags) {
|
|
190
|
+
const pyai = client(flags);
|
|
191
|
+
const checks = [];
|
|
192
|
+
// (a) Key validity + scopes via GET /v1/me. The route is new, so a 404 means
|
|
193
|
+
// "not deployed here yet" — skip it rather than failing the whole doctor.
|
|
194
|
+
try {
|
|
195
|
+
const me = await pyai.me();
|
|
196
|
+
const scopes = Array.isArray(me.scopes) ? me.scopes : [];
|
|
197
|
+
const env = me.environment ?? me.env ?? "unknown";
|
|
198
|
+
checks.push({
|
|
199
|
+
name: "key (/v1/me)",
|
|
200
|
+
status: "PASS",
|
|
201
|
+
detail: `env=${env}; ${scopes.length} scope(s)${scopes.length ? `: ${scopes.join(", ")}` : ""}`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
if (err instanceof PyAIError && err.status === 404) {
|
|
206
|
+
checks.push({ name: "key (/v1/me)", status: "SKIP", detail: "introspection route not on this deployment" });
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""}`.trim() : err.message;
|
|
210
|
+
checks.push({ name: "key (/v1/me)", status: "FAIL", detail, hint: remediation(err) });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// (b) Endpoint liveness.
|
|
214
|
+
await doctorCheck(checks, "models.list", async () => `${(await pyai.models.list()).data.length} models`);
|
|
215
|
+
await doctorCheck(checks, "voices.list", async () => `${(await pyai.voices.list()).data.length} voices`);
|
|
216
|
+
// (c) Speak -> Hear round-trip: synthesize a sentence, then transcribe it.
|
|
217
|
+
await doctorCheck(checks, "speak→hear round-trip", async () => {
|
|
218
|
+
const audio = await pyai.audio.speech({ input: "The quick brown fox jumps over the lazy dog." });
|
|
219
|
+
const bytes = Buffer.from(audio).byteLength;
|
|
220
|
+
const blob = new Blob([audio], { type: "audio/wav" });
|
|
221
|
+
const tr = await pyai.audio.transcriptions.create({ file: blob, filename: "doctor.wav" });
|
|
222
|
+
const text = (tr.text ?? "").trim();
|
|
223
|
+
if (!text)
|
|
224
|
+
throw new Error(`synthesized ${bytes} bytes but transcription came back empty`);
|
|
225
|
+
return `synth ${bytes} bytes → "${text.length > 60 ? `${text.slice(0, 60)}…` : text}"`;
|
|
226
|
+
});
|
|
227
|
+
for (const c of checks) {
|
|
228
|
+
out(`${c.status.padEnd(4)} ${c.name} — ${c.detail}`);
|
|
229
|
+
if (c.hint)
|
|
230
|
+
out(` ↳ ${c.hint}`);
|
|
231
|
+
}
|
|
232
|
+
const failed = checks.filter((c) => c.status === "FAIL");
|
|
233
|
+
if (failed.length === 0) {
|
|
234
|
+
out("\nDiagnosis: healthy. Key, endpoint, and a Speak→Hear round-trip all work.");
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
out(`\nDiagnosis: ${failed.length} check(s) failed — see the remediation hints above.`);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
138
241
|
async function main() {
|
|
139
242
|
const flags = parseArgs(process.argv.slice(2));
|
|
140
243
|
const cmd = flags._[0];
|
|
141
244
|
switch (cmd) {
|
|
245
|
+
case "doctor":
|
|
246
|
+
return cmdDoctor(flags);
|
|
142
247
|
case "smoke":
|
|
143
248
|
return cmdSmoke(flags);
|
|
144
249
|
case "models":
|