@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 CHANGED
@@ -68,6 +68,118 @@ const proto = pyai.realtimeSubprotocol();
68
68
  > targets it by default (`product: "omni"`); `product: "flow"` uses
69
69
  > `/v1/realtime`. The older `/v2/omni/chat` URL is deprecated but still works.
70
70
 
71
+ ## Streaming speech-to-text (Hear / Cue)
72
+
73
+ `transcriptions.stream()` hides the WebSocket frame protocol behind callbacks.
74
+ It opens `wss://api.pyai.com/v1/audio/transcriptions/stream` (key carried as the
75
+ WS subprotocol, so it works in the browser), routes the wire frames to
76
+ `onPartial`/`onFinal`/`onError`, and gives you `sendAudio`, `commit()`, and
77
+ `close()`:
78
+
79
+ ```ts
80
+ const hear = pyai.audio.transcriptions.stream({
81
+ sampleRate: 16000,
82
+ onPartial: (f) => console.log("…", f.text),
83
+ onFinal: (f) => console.log("✓", f.text, `(${f.audio_ms}ms)`),
84
+ onError: (e) => console.error(e),
85
+ });
86
+
87
+ micChunks.on("data", (pcm16) => hear.sendAudio(pcm16)); // binary frames
88
+ vad.on("end", () => hear.commit()); // force-finalize an utterance
89
+ // hear.close() also flushes a final for any buffered audio
90
+ ```
91
+
92
+ Frame `type`s, WS close codes, and error `code`s are exported as named
93
+ constants so you never hardcode a magic string:
94
+
95
+ ```ts
96
+ import { HearFrameType, WSCloseCode, ErrorCode } from "@pyai/sdk";
97
+ HearFrameType.SpeechFinal; // "speech_final"
98
+ WSCloseCode.OverCapacity; // 4429
99
+ ErrorCode.CreditExhausted; // "credit_exhausted"
100
+ ```
101
+
102
+ Set `grounding: true` to turn the stream into **Cue** (turn detection + KB
103
+ context): the SDK sends the grounding config on open and `final`/`speech_final`
104
+ frames then carry a `grounding` array of top KB passages.
105
+
106
+ In Node, pass a WebSocket implementation if there's no global one:
107
+ `transcriptions.stream({ webSocket: (await import("ws")).WebSocket })`.
108
+
109
+ ## Speak audio formats (incl. telephony G.711)
110
+
111
+ `audio.speech` encodes server-side into any of eight formats via `response_format`
112
+ — the audio comes back already in the shape you need, so telephony callers can
113
+ drop the hand-rolled resampler + μ-law encoder entirely:
114
+
115
+ ```ts
116
+ // Twilio/SIP-ready in one param: raw 8 kHz mono μ-law, no client-side DSP.
117
+ const ulaw = await pyai.audio.speech({
118
+ input: "Your appointment is confirmed.",
119
+ voice: "stock_sarah_style2",
120
+ response_format: "g711_ulaw", // -> audio/basic, forced 8 kHz
121
+ });
122
+ // base64-encode `ulaw` straight into a Twilio media frame.
123
+ ```
124
+
125
+ | `response_format` | sample rates (Hz) | Content-Type |
126
+ |---|---|---|
127
+ | `mp3` (default) | 8000 / 16000 / 24000 / 48000 | `audio/mpeg` |
128
+ | `wav` | 8000 / 16000 / 24000 / 48000 | `audio/wav` |
129
+ | `opus` | 8000 / 16000 / 24000 / 48000 | `audio/ogg` |
130
+ | `aac` | 8000 / 16000 / 24000 / 48000 | `audio/aac` |
131
+ | `flac` | 8000 / 16000 / 24000 / 48000 | `audio/flac` |
132
+ | `pcm` (raw int16 LE, no header) | 8000 / 16000 / 24000 / 48000 | `audio/pcm` |
133
+ | `g711_ulaw` | 8000 (forced) | `audio/basic` |
134
+ | `g711_alaw` | 8000 (forced) | `audio/basic` |
135
+
136
+ `sample_rate` is optional — omit it for the engine's native 24 kHz (`g711_*` is
137
+ always 8 kHz). The set is typed (`SpeechFormat`) and exported as `SPEECH_FORMATS`
138
+ / `SPEECH_SAMPLE_RATES` for dropdowns and validation. Any other value is a
139
+ `400 unsupported_format`; omit `response_format` for the default `mp3`.
140
+
141
+ > See [`examples/speak-telephony-formats`](../../examples/speak-telephony-formats)
142
+ > for the full before/after: ~120 lines of resampler + μ-law replaced by one
143
+ > param, with Node (`@pyai/twilio`), Python, and raw-curl snippets.
144
+
145
+ ## More APIs: clones, telephony, trace
146
+
147
+ ```ts
148
+ // Voice clones (Speak)
149
+ const { data: clones } = await pyai.clones.list();
150
+ const clone = await pyai.clones.create({ name: "Brand VO", file: refAudioBlob });
151
+ await pyai.clones.delete(clone.id);
152
+
153
+ // Managed phone numbers (Telephony)
154
+ const { data: avail } = await pyai.telephony.numbers.available({ areaCode: "415" });
155
+ const num = await pyai.telephony.numbers.buy({ phone_number: avail[0]!.phone_number, agent_id: "agent_123" });
156
+ await pyai.telephony.numbers.assign(num.id, "agent_123");
157
+ await pyai.telephony.numbers.release(num.id);
158
+
159
+ // Compliance (Trace)
160
+ const { data: calls } = await pyai.trace.interactions.list({ verdict: "FAIL" });
161
+ const detail = await pyai.trace.interactions.get(calls[0]!.id);
162
+ await pyai.trace.config.set({ agent_id: "agent_123", enabled: true });
163
+ const exposure = await pyai.trace.exposure(30);
164
+
165
+ // Per-call eval scorecard (timeline + quality metrics). These are additive and
166
+ // forward-compatible — present once the engine emits them, so reading them is
167
+ // always safe (the timeline reader returns [] until then).
168
+ const timeline = await pyai.trace.callTimeline(detail.id); // TraceTimelineTurn[]
169
+ const quality = detail.quality_metrics; // { wer?, ttfb_ms?, turn_p95_ms?, vaqi?, … }
170
+ ```
171
+
172
+ ## Reproducible runs (evals)
173
+
174
+ `audio.speech` and `audio.transcriptions.create` take optional `seed` and
175
+ `temperature` for deterministic eval runs. They're forward-compatible — honored
176
+ once the engine supports them and otherwise ignored — so it's always safe to send:
177
+
178
+ ```ts
179
+ await pyai.audio.speech({ input: "Hello", voice: "stock_sarah_style2", seed: 42, temperature: 0 });
180
+ await pyai.audio.transcriptions.create({ file: wavBlob, seed: 42 });
181
+ ```
182
+
71
183
  ## Errors
72
184
 
73
185
  Failures throw `PyAIError` with a stable `code` (branch on it, not the message):
@@ -91,16 +203,21 @@ Common codes: `unauthorized`, `forbidden`, `credit_exhausted`,
91
203
 
92
204
  ## CLI (`pyai`)
93
205
 
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:
206
+ Installing the package also provides a `pyai` command. `pyai doctor` runs a
207
+ deeper diagnosis it introspects your key/scopes via `GET /v1/me` (skipped
208
+ gracefully if the route isn't deployed yet), checks endpoint liveness, runs a
209
+ Speak→Hear round-trip, and prints remediation hints for any failure:
96
210
 
97
211
  ```bash
98
212
  export PYAI_API_KEY=pyai_test_...
99
- npx pyai smoke
213
+ npx pyai doctor
214
+ # PASS key (/v1/me) — env=test; 3 scope(s): hear:transcribe, voice:synthesize, hear:stream
100
215
  # PASS models.list — 12 models
101
216
  # 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.
217
+ # PASS speak→hear round-trip synth 45210 bytes "the quick brown fox…"
218
+ # Diagnosis: healthy. Key, endpoint, and a Speak→Hear round-trip all work.
219
+
220
+ npx pyai smoke # lighter: models + voices + speak
104
221
  ```
105
222
 
106
223
  Other commands:
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":