@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 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: "stock_sarah_style2" });
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: "stock_sarah_style2", response_format: "mp3" });
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
- Keys travel as a WebSocket subprotocol so this works in the browser:
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
- const ws = pyai.connectRealtime({ product: "omni", agentId: "agent_123" });
60
- ws.addEventListener("message", (e) => console.log(e.data));
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
- // Or build the pieces yourself for a custom WS library:
63
- const url = pyai.realtimeURL({ product: "omni", agentId: "agent_123" });
64
- const proto = pyai.realtimeSubprotocol();
73
+ omni.sendAudio(pcm16Chunk); // stream caller audio continuously (server-side VAD)
74
+ omni.sendDtmf("5");
75
+ omni.close();
65
76
  ```
66
77
 
67
- > Omni uses the native `wss://api.pyai.com/v1/omni` surface. `connectRealtime`
68
- > targets it by default (`product: "omni"`); `product: "flow"` uses
69
- > `/v1/realtime`. The older `/v2/omni/chat` URL is deprecated but still works.
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 a smoke tester that
95
- proves your key, the endpoint, and audio synthesis in one shot:
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 smoke
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 audio.speech — 45210 bytes of audio
103
- # All checks passed. Your key, the endpoint, and audio synthesis work.
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 stock_sarah_style2 --out hello.wav
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 smoke tester for the PyAI API: proves your key, the endpoint,
4
- * and audio synthesis in one command.
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 smoke tester for the PyAI API: proves your key, the endpoint,
4
- * and audio synthesis in one command.
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":