@pyai/sdk 0.2.1 → 0.2.3

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
@@ -1,18 +1,19 @@
1
1
  # @pyai/sdk
2
2
 
3
- Official TypeScript/JavaScript SDK for [PyAI](https://pyai.com) the all-in-one
3
+ Official TypeScript/JavaScript SDK for [PyAI](https://pyai.com), the all-in-one
4
4
  voice AI platform: lightning-fast speech-to-text, ultra-realistic text-to-speech,
5
5
  end-to-end realtime voice agents, and automatic call compliance. Zero
6
6
  dependencies; runs in the browser and Node 18+.
7
7
 
8
8
  ## PyAI products
9
9
 
10
- - **[Hear](https://pyai.com/models/hear)** Lightning-fast, telephony-native **speech-to-text**. Whisper-compatible transcription tuned for real phone-call audio, with live streaming partials so your app reacts mid-sentence, plus async batch transcription for big archives. `POST /v1/audio/transcriptions`
11
- - **[Speak](https://pyai.com/models/speak)** Ultra-realistic **text-to-speech** that starts speaking in tens of milliseconds. Stream lifelike, expressive voices, choose from 36 studio-quality presets, or clone any voice instantly for free. `POST /v1/audio/speech`
12
- - **[Omni](https://pyai.com/models/omni)** _(flagship)_ One **API for a complete, end-to-end voice AI agent**. A single WebSocket where your agent listens, thinks, and speaks grounded in your knowledge bases and tools, with human-like turn-taking and instant barge-in no STT, LLM, or TTS to stitch together yourself. `wss://api.pyai.com/v1/omni`
13
- - **[Trace](https://pyai.com/models/trace)** _(flagship)_ The **compliance API that keeps your AI agents safe**. Trace automatically checks every call for HIPAA, TCPA, and PII risks (plus your own brand-voice rules), flags the exact rule broken, redacts sensitive data, and seals each call with a tamper-evident audit trail so a risky conversation never slips through. `GET /v1/trace/interactions`
14
- - **[Cue](https://pyai.com/models/cue)** Realtime **turn detection + knowledge-grounded context** for your own stack. Bring your own LLM and voice; Cue nails the hard part knowing the instant a speaker finishes and surfacing the right context. `wss://api.pyai.com/v1/audio/transcriptions/stream`
15
- - **[Telephony](https://pyai.com/models/telephony)** Instant **managed phone numbers** for your voice agents. Provision a US number and route live calls straight into an Omni agent no carrier contracts, no telephony glue. `POST /v1/telephony/numbers`
10
+ - **[Hear](https://pyai.com/models/hear)**, Lightning-fast, telephony-native **speech-to-text**. Whisper-compatible transcription tuned for real phone-call audio, with live streaming partials so your app reacts mid-sentence, plus async batch transcription for big archives. `POST /v1/audio/transcriptions`
11
+ - **[Speak](https://pyai.com/models/speak)**, Ultra-realistic **text-to-speech** that starts speaking in tens of milliseconds. Stream lifelike, expressive voices, choose from 36 studio-quality presets, or clone any voice instantly, for free. `POST /v1/audio/speech`
12
+ - **[Omni](https://pyai.com/models/omni)** _(flagship)_, One **API for a complete, end-to-end voice AI agent**. A single WebSocket where your agent listens, thinks, and speaks, grounded in your knowledge bases and tools, with human-like turn-taking and instant barge-in, no STT, LLM, or TTS to stitch together yourself. `wss://api.pyai.com/v1/omni`
13
+ - **[Trace](https://pyai.com/models/trace)** _(flagship)_, The **compliance API that keeps your AI agents safe**. Trace automatically checks every call for HIPAA, TCPA, and PII risks (plus your own brand-voice rules), flags the exact rule broken, redacts sensitive data, and seals each call with a tamper-evident audit trail, so a risky conversation never slips through. `GET /v1/trace/interactions`
14
+ - **[Cue](https://pyai.com/models/cue)**, Realtime **turn detection + knowledge-grounded context** for your own stack. Bring your own LLM and voice; Cue nails the hard part, knowing the instant a speaker finishes and surfacing the right context. `wss://api.pyai.com/v1/audio/transcriptions/stream`
15
+ - **[AMD](https://pyai.com/models/amd)**, **Answering-machine detection** that tells your dialer who or what answered, human, voicemail, IVR, iPhone/Google screening, dead number, fax, in a fraction of Twilio's dead-air dwell, with the reason. A one-line-TwiML Twilio Media Streams drop-in; billed per answered call (first 5,000/month free). `wss://api.pyai.com/v1/amd/stream`
16
+ - **[Telephony](https://pyai.com/models/telephony)**, Instant **managed phone numbers** for your voice agents. Provision a US number and route live calls straight into an Omni agent, no carrier contracts, no telephony glue. `POST /v1/telephony/numbers`
16
17
 
17
18
  The contract is `https://api.pyai.com/openapi.json`. This SDK wraps it
18
19
  ergonomically with typed errors, automatic retries, and a realtime helper.
@@ -34,7 +35,7 @@ const pyai = new PyAI({ apiKey: process.env.PYAI_API_KEY! });
34
35
  const audio = await pyai.audio.speech({ input: "Hello from PyAI.", voice: "stock_emma_en_gb" });
35
36
  await Bun.write?.("hello.wav", audio); // or fs.writeFile in Node
36
37
 
37
- // Text-to-speech, streamed start playing/forwarding at the first chunk
38
+ // Text-to-speech, streamed, start playing/forwarding at the first chunk
38
39
  // (tens of ms) instead of waiting for the whole clip. Use mp3 for smooth
39
40
  // progressive playback.
40
41
  const stream = await pyai.audio.speechStream({ input: "Hello from PyAI.", voice: "stock_emma_en_gb", response_format: "mp3" });
@@ -51,21 +52,38 @@ const job = await pyai.transcriptionJobs.create(
51
52
  const done = await pyai.transcriptionJobs.get(job.job_id);
52
53
  ```
53
54
 
55
+ ## Use with MCP (AI coding agents)
56
+
57
+ Building this SDK *with* an AI coding agent (Cursor, Claude Code, Codex)? Add the
58
+ PyAI **MCP server** ([`@pyai/mcp`](https://www.npmjs.com/package/@pyai/mcp)) so
59
+ the agent can mint a free key and call PyAI as tools, no endpoint guessing, no
60
+ human setup step:
61
+
62
+ ```jsonc
63
+ // .cursor/mcp.json · or: claude mcp add pyai -- npx -y @pyai/mcp
64
+ { "mcpServers": { "pyai": { "command": "npx", "args": ["-y", "@pyai/mcp"] } } }
65
+ ```
66
+
67
+ With no key set, the server exposes `create_sandbox_key`, calls it, and adopts
68
+ the minted key for the session, then `get_started`, `list_voices`,
69
+ `synthesize_speech`, and the transcription tools work immediately. Full setup +
70
+ a runnable client: the [`mcp-quickstart`](https://github.com/atomsai/pyai-examples/tree/main/mcp-quickstart)
71
+ example.
72
+
54
73
  ## Realtime (Omni)
55
74
 
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`,
75
+ `omni.connect()` opens an agentic-voice session and hides the wire protocol, including its **frame-key asymmetry** (your control frames are keyed on `type`,
58
76
  the server's frames are keyed on `event`). It sends a `type`-keyed `configure`
59
77
  the instant the socket opens and routes server frames to typed callbacks, so you
60
78
  **can't** trip the #1 Omni integration bug (a hand-rolled `{"event":"configure"}`
61
79
  is acked but silently dropped, giving you a connected session with zero turns):
62
80
 
63
81
  ```ts
64
- // Omni is zero-state: the key's org authorizes the session nothing to create.
82
+ // Omni is zero-state: the key's org authorizes the session, nothing to create.
65
83
  const omni = pyai.omni.connect({
66
84
  rate: 16000, // 24000 browser · 16000 wideband telephony · 8000 G.711/Twilio
67
85
  configure: { voice_id: "stock_emma_en_gb", persona: "You are a receptionist." },
68
- onAudio: (chunk) => speaker.write(chunk), // binary agent audio play it out
86
+ onAudio: (chunk) => speaker.write(chunk), // binary agent audio, play it out
69
87
  onTranscript: (f) => console.log(f.text),
70
88
  onError: (e) => console.error(e),
71
89
  });
@@ -84,7 +102,7 @@ const omni = pyai.omni.connect({ token: session.token, configure: { voice_id, pe
84
102
  ```
85
103
 
86
104
  > 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
105
+ >, no agent to create, `sessionLabel` is an optional opaque tag (never
88
106
  > required). Need the raw socket? `pyai.realtimeURL({ product: "omni" })` +
89
107
  > `pyai.realtimeSubprotocol()` (or `pyai.connectRealtime()`) still work;
90
108
  > `product: "flow"` uses `/v1/realtime`. The older `/v2/omni/chat` URL and the
@@ -130,8 +148,7 @@ In Node, pass a WebSocket implementation if there's no global one:
130
148
 
131
149
  ## Speak audio formats (incl. telephony G.711)
132
150
 
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
151
+ `audio.speech` encodes server-side into any of eight formats via `response_format`, the audio comes back already in the shape you need, so telephony callers can
135
152
  drop the hand-rolled resampler + μ-law encoder entirely:
136
153
 
137
154
  ```ts
@@ -155,7 +172,7 @@ const ulaw = await pyai.audio.speech({
155
172
  | `g711_ulaw` | 8000 (forced) | `audio/basic` |
156
173
  | `g711_alaw` | 8000 (forced) | `audio/basic` |
157
174
 
158
- `sample_rate` is optional omit it for the engine's native 24 kHz (`g711_*` is
175
+ `sample_rate` is optional, omit it for the engine's native 24 kHz (`g711_*` is
159
176
  always 8 kHz). The set is typed (`SpeechFormat`) and exported as `SPEECH_FORMATS`
160
177
  / `SPEECH_SAMPLE_RATES` for dropdowns and validation. Any other value is a
161
178
  `400 unsupported_format`; omit `response_format` for the default `mp3`.
@@ -164,6 +181,48 @@ always 8 kHz). The set is typed (`SpeechFormat`) and exported as `SPEECH_FORMATS
164
181
  > for the full before/after: ~120 lines of resampler + μ-law replaced by one
165
182
  > param, with Node (`@pyai/twilio`), Python, and raw-curl snippets.
166
183
 
184
+ ## AMD (answering-machine detection)
185
+
186
+ Already on Twilio? The usual path is **one line of TwiML** pointing the call's
187
+ media at PyAI, no SDK needed. The `answered_by_twilio` field maps to Twilio's
188
+ exact `AnsweredBy` enum, so your routing logic doesn't change:
189
+
190
+ ```xml
191
+ <Response><Connect>
192
+ <Stream url="wss://api.pyai.com/v1/amd/stream">
193
+ <Parameter name="api_key" value="YOUR_PYAI_KEY"/>
194
+ <Parameter name="aggressiveness" value="0.25"/>
195
+ <Parameter name="webhook" value="https://you/amd-events"/>
196
+ </Stream>
197
+ </Connect></Response>
198
+ ```
199
+
200
+ (The key rides a `<Parameter>` because Twilio strips query strings from the
201
+ `<Stream>` URL; PyAI verifies it from the `start` frame before processing any
202
+ audio.)
203
+
204
+ From code, set the operating point and read decisions back:
205
+
206
+ ```ts
207
+ // One aggressiveness dial: near 0 = human-safe, near 1 = fire "machine" fast.
208
+ await pyai.amd.config.set({ aggressiveness: 0.25, webhookUrl: "https://you/amd-events" });
209
+
210
+ const { data: decisions } = await pyai.amd.calls.list({ sessionLabel: "sales" });
211
+ const decision = await pyai.amd.calls.get("C_123");
212
+ // decision.answered_by = "human" | "voicemail" | "screening" | "sit_invalid" | ...
213
+ // decision.answered_by_twilio = "human" | "machine_start" | ... (Twilio parity)
214
+ // decision.reason = "machine phrase: 'leave a message' @1.2s"
215
+
216
+ // Server-side helper if you fork the media yourself (Twilio Media Streams wire):
217
+ const stream = pyai.amd.stream({
218
+ aggressiveness: 0.25,
219
+ onDecision: (d) => console.log(d.answered_by, d.decision_ms, d.reason),
220
+ });
221
+ ```
222
+
223
+ Billed per **answered** call, first 5,000 answered calls/month free, then
224
+ $0.004/call; free when bundled with PyAI telephony/Omni.
225
+
167
226
  ## More APIs: clones, telephony, trace
168
227
 
169
228
  ```ts
@@ -185,7 +244,7 @@ await pyai.trace.config.set({ agent_id: "agent_123", enabled: true });
185
244
  const exposure = await pyai.trace.exposure(30);
186
245
 
187
246
  // Per-call eval scorecard (timeline + quality metrics). These are additive and
188
- // forward-compatible present once the engine emits them, so reading them is
247
+ // forward-compatible, present once the engine emits them, so reading them is
189
248
  // always safe (the timeline reader returns [] until then).
190
249
  const timeline = await pyai.trace.callTimeline(detail.id); // TraceTimelineTurn[]
191
250
  const quality = detail.quality_metrics; // { wer?, ttfb_ms?, turn_p95_ms?, vaqi?, … }
@@ -194,8 +253,8 @@ const quality = detail.quality_metrics; // { wer?, ttfb_ms?,
194
253
  ## Reproducible runs (evals)
195
254
 
196
255
  `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:
256
+ `temperature` for deterministic eval runs. They're forward-compatible, honored
257
+ once the engine supports them and otherwise ignored, so it's always safe to send:
199
258
 
200
259
  ```ts
201
260
  await pyai.audio.speech({ input: "Hello", voice: "stock_emma_en_gb", seed: 42, temperature: 0 });
@@ -213,7 +272,7 @@ try {
213
272
  await pyai.audio.speech({ input: "hi" });
214
273
  } catch (err) {
215
274
  if (err instanceof PyAIError && err.code === "credit_exhausted") {
216
- // out of prepaid credit add credit or use a sandbox key
275
+ // out of prepaid credit, add credit or use a sandbox key
217
276
  }
218
277
  }
219
278
  ```
@@ -226,17 +285,17 @@ Common codes: `unauthorized`, `forbidden`, `credit_exhausted`,
226
285
  ## CLI (`pyai`)
227
286
 
228
287
  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
288
+ deeper diagnosis, it introspects your key/scopes via `GET /v1/me` (skipped
230
289
  gracefully if the route isn't deployed yet), checks endpoint liveness, runs a
231
290
  Speak→Hear round-trip, and prints remediation hints for any failure:
232
291
 
233
292
  ```bash
234
293
  export PYAI_API_KEY=pyai_test_...
235
294
  npx pyai doctor
236
- # PASS key (/v1/me) env=test; 3 scope(s): hear:transcribe, voice:synthesize, hear:stream
237
- # PASS models.list 12 models
238
- # PASS voices.list 38 voices
239
- # PASS speak→hear round-trip synth 45210 bytes → "the quick brown fox…"
295
+ # PASS key (/v1/me), env=test; 3 scope(s): hear:transcribe, voice:synthesize, hear:stream
296
+ # PASS models.list, 12 models
297
+ # PASS voices.list, 38 voices
298
+ # PASS speak→hear round-trip, synth 45210 bytes → "the quick brown fox…"
240
299
  # Diagnosis: healthy. Key, endpoint, and a Speak→Hear round-trip all work.
241
300
 
242
301
  npx pyai smoke # lighter: models + voices + speak
package/dist/cli.d.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * pyai CLI for the PyAI API: proves your key, the endpoint, and audio in one
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 run models+voices+speak and report PASS/FAIL
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 uses the bundled SDK.
16
+ * Zero deps, uses the bundled SDK.
16
17
  */
17
18
  export {};
package/dist/cli.js CHANGED
@@ -1,18 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * pyai CLI for the PyAI API: proves your key, the endpoint, and audio in one
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 run models+voices+speak and report PASS/FAIL
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 uses the bundled SDK.
16
+ * Zero deps, uses the bundled SDK.
16
17
  */
17
18
  import { writeFile } from "node:fs/promises";
18
19
  import PyAI, { PyAIError } from "./index.js";
@@ -55,11 +56,12 @@ function fail(msg) {
55
56
  process.stderr.write(`pyai: ${msg}\n`);
56
57
  process.exit(1);
57
58
  }
58
- const USAGE = `pyai PyAI API CLI
59
+ const USAGE = `pyai, PyAI API CLI
59
60
 
60
61
  Usage:
61
62
  pyai doctor diagnose key/scopes + endpoint + Speak→Hear round-trip
62
- pyai smoke run a key/endpoint/audio smoke test
63
+ pyai smoke [--tolerate-upstream] run a key/endpoint/audio smoke test
64
+ (--tolerate-upstream: transient 5xx/429 warn, don't fail)
63
65
  pyai models list models
64
66
  pyai voices [--gender g] [--region r] list voices
65
67
  pyai speak --text T [--voice V] [--out f.wav]
@@ -105,17 +107,54 @@ async function cmdTranscribe(flags) {
105
107
  }
106
108
  out(`job ${job.job_id} still running after polling; check later.`);
107
109
  }
110
+ // Transient upstream conditions: a momentary engine/capacity blip or network
111
+ // hiccup, NOT a key/scope/contract problem. 5xx = engine unhealthy (e.g. Speak
112
+ // "503 service_unavailable"), 429 = rate/capacity, a non-PyAIError = network.
113
+ // These self-heal; the others (401/403/404/400) are real and must fail loudly.
114
+ const TRANSIENT_STATUSES = new Set([429, 500, 502, 503, 504]);
115
+ function isTransient(err) {
116
+ if (err instanceof PyAIError)
117
+ return TRANSIENT_STATUSES.has(err.status);
118
+ return true; // network / timeout / unknown, worth a retry, never a hard fail on its own
119
+ }
120
+ /** Retry `fn` on transient upstream errors with exponential backoff. Real
121
+ * (non-transient) errors throw immediately, we never paper over a bad key. */
122
+ async function withRetry(fn, attempts = 4, baseMs = 800) {
123
+ let lastErr;
124
+ for (let i = 0; i < attempts; i++) {
125
+ try {
126
+ return await fn();
127
+ }
128
+ catch (err) {
129
+ lastErr = err;
130
+ if (!isTransient(err) || i === attempts - 1)
131
+ throw err;
132
+ await new Promise((r) => setTimeout(r, baseMs * 2 ** i));
133
+ }
134
+ }
135
+ throw lastErr;
136
+ }
108
137
  /** The headline: prove key + endpoint + audio in one command. */
109
138
  async function cmdSmoke(flags) {
110
139
  const pyai = client(flags);
140
+ // CI/ops opt-in: a transient upstream blip (engine warming, a brief 503,
141
+ // rate-limit) should not red the build, it isn't the commit's fault. With this
142
+ // on, such failures are reported as WARN (exit 0); real key/scope/contract
143
+ // failures still FAIL (exit 1). Off by default so a developer running `pyai
144
+ // smoke` gets the strict, honest answer.
145
+ const tolerateUpstream = flags["tolerate-upstream"] === true || process.env.PYAI_SMOKE_TOLERATE_UPSTREAM === "1";
146
+ // Retry tuning is env-overridable (tests drive it fast; default rides brief blips).
147
+ const retryAttempts = Number(process.env.PYAI_SMOKE_RETRY_ATTEMPTS ?? 4);
148
+ const retryBaseMs = Number(process.env.PYAI_SMOKE_RETRY_BASE_MS ?? 800);
111
149
  const checks = [];
112
150
  const run = async (name, fn) => {
113
151
  try {
114
- checks.push({ name, ok: true, detail: await fn() });
152
+ checks.push({ name, status: "PASS", detail: await withRetry(fn, retryAttempts, retryBaseMs) });
115
153
  }
116
154
  catch (err) {
117
- const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""} ${err.message}` : err.message;
118
- checks.push({ name, ok: false, detail });
155
+ const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""} ${err.message}`.trim() : err.message;
156
+ const status = isTransient(err) && tolerateUpstream ? "WARN" : "FAIL";
157
+ checks.push({ name, status, detail });
119
158
  }
120
159
  };
121
160
  await run("models.list", async () => {
@@ -131,11 +170,22 @@ async function cmdSmoke(flags) {
131
170
  return `${Buffer.from(audio).byteLength} bytes of audio`;
132
171
  });
133
172
  for (const c of checks)
134
- out(`${c.ok ? "PASS" : "FAIL"} ${c.name} ${c.detail}`);
135
- const allOk = checks.every((c) => c.ok);
136
- out(allOk ? "\nAll checks passed. Your key, the endpoint, and audio synthesis work." : "\nSome checks failed (see above).");
137
- if (!allOk)
173
+ out(`${c.status} ${c.name}, ${c.detail}`);
174
+ const failed = checks.filter((c) => c.status === "FAIL");
175
+ const warned = checks.filter((c) => c.status === "WARN");
176
+ // GitHub Actions annotation: a tolerated blip is still surfaced in the run UI.
177
+ for (const c of warned)
178
+ out(`::warning title=PyAI smoke transient::${c.name}: ${c.detail}`);
179
+ if (failed.length === 0 && warned.length === 0) {
180
+ out("\nAll checks passed. Your key, the endpoint, and audio synthesis work.");
181
+ }
182
+ else if (failed.length === 0) {
183
+ out(`\n${warned.length} transient upstream issue(s) tolerated (self-healing engine blip), not failing the build.`);
184
+ }
185
+ else {
186
+ out("\nSome checks failed (see above).");
138
187
  process.exit(1);
188
+ }
139
189
  }
140
190
  /** Turn an error into an actionable, code-first remediation hint. */
141
191
  function remediation(err) {
@@ -143,35 +193,35 @@ function remediation(err) {
143
193
  return err?.message ?? String(err);
144
194
  switch (err.code) {
145
195
  case "unauthorized":
146
- return "Invalid or missing key check PYAI_API_KEY (a pyai_test_ or pyai_live_ key).";
196
+ return "Invalid or missing key, check PYAI_API_KEY (a pyai_test_ or pyai_live_ key).";
147
197
  case "forbidden":
148
- return "Key is missing a required scope add it to the key in the console.";
198
+ return "Key is missing a required scope, add it to the key in the console.";
149
199
  case "origin_not_allowed":
150
- return "Publishable token origin not allow-listed fix the allowed origins.";
200
+ return "Publishable token origin not allow-listed, fix the allowed origins.";
151
201
  case "credit_exhausted":
152
- return "Out of prepaid credit add credit, or use a pyai_test_ sandbox key.";
202
+ return "Out of prepaid credit, add credit, or use a pyai_test_ sandbox key.";
153
203
  case "key_budget_exceeded":
154
- return "Per-key monthly budget hit raise the budget in the console.";
204
+ return "Per-key monthly budget hit, raise the budget in the console.";
155
205
  case "insufficient_quota":
156
- return "Plan quota exhausted upgrade your plan.";
206
+ return "Plan quota exhausted, upgrade your plan.";
157
207
  case "rate_limit_exceeded":
158
- return "Rate limited back off and retry (honor Retry-After).";
208
+ return "Rate limited, back off and retry (honor Retry-After).";
159
209
  case "concurrency_limit_exceeded":
160
- return "Too many concurrent sessions retry shortly.";
210
+ return "Too many concurrent sessions, retry shortly.";
161
211
  case "daily_cap_exceeded":
162
- return "Daily cap reached wait until it resets.";
212
+ return "Daily cap reached, wait until it resets.";
163
213
  default:
164
214
  break;
165
215
  }
166
216
  switch (err.status) {
167
217
  case 401:
168
- return "Invalid or missing key check PYAI_API_KEY.";
218
+ return "Invalid or missing key, check PYAI_API_KEY.";
169
219
  case 403:
170
- return "Forbidden the key likely lacks the required scope.";
220
+ return "Forbidden, the key likely lacks the required scope.";
171
221
  case 404:
172
- return "Not found check PYAI_BASE_URL and the route.";
222
+ return "Not found, check PYAI_BASE_URL and the route.";
173
223
  case 429:
174
- return "Rate/concurrency limited back off and retry.";
224
+ return "Rate/concurrency limited, back off and retry.";
175
225
  default:
176
226
  return err.message;
177
227
  }
@@ -190,7 +240,7 @@ async function cmdDoctor(flags) {
190
240
  const pyai = client(flags);
191
241
  const checks = [];
192
242
  // (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.
243
+ // "not deployed here yet", skip it rather than failing the whole doctor.
194
244
  try {
195
245
  const me = await pyai.me();
196
246
  const scopes = Array.isArray(me.scopes) ? me.scopes : [];
@@ -225,7 +275,7 @@ async function cmdDoctor(flags) {
225
275
  return `synth ${bytes} bytes → "${text.length > 60 ? `${text.slice(0, 60)}…` : text}"`;
226
276
  });
227
277
  for (const c of checks) {
228
- out(`${c.status.padEnd(4)} ${c.name} ${c.detail}`);
278
+ out(`${c.status.padEnd(4)} ${c.name}, ${c.detail}`);
229
279
  if (c.hint)
230
280
  out(` ↳ ${c.hint}`);
231
281
  }
@@ -234,7 +284,7 @@ async function cmdDoctor(flags) {
234
284
  out("\nDiagnosis: healthy. Key, endpoint, and a Speak→Hear round-trip all work.");
235
285
  }
236
286
  else {
237
- out(`\nDiagnosis: ${failed.length} check(s) failed see the remediation hints above.`);
287
+ out(`\nDiagnosis: ${failed.length} check(s) failed, see the remediation hints above.`);
238
288
  process.exit(1);
239
289
  }
240
290
  }