@pyai/sdk 0.2.0 → 0.2.2
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 +115 -34
- package/dist/cli.d.ts +4 -3
- package/dist/cli.js +78 -28
- package/dist/index.d.ts +431 -22
- package/dist/index.js +426 -15
- package/package.json +3 -2
- package/src/cli.ts +77 -29
- package/src/index.ts +743 -31
package/README.md
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
# @pyai/sdk
|
|
2
2
|
|
|
3
|
-
Official TypeScript/JavaScript SDK for [PyAI](https://pyai.com)
|
|
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)
|
|
11
|
-
- **[Speak](https://pyai.com/models/speak)
|
|
12
|
-
- **[Omni](https://pyai.com/models/omni)** _(flagship)_
|
|
13
|
-
- **[Trace](https://pyai.com/models/trace)** _(flagship)_
|
|
14
|
-
- **[Cue](https://pyai.com/models/cue)
|
|
15
|
-
- **[
|
|
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.
|
|
@@ -31,13 +32,13 @@ import PyAI from "@pyai/sdk";
|
|
|
31
32
|
const pyai = new PyAI({ apiKey: process.env.PYAI_API_KEY! });
|
|
32
33
|
|
|
33
34
|
// Text-to-speech
|
|
34
|
-
const audio = await pyai.audio.speech({ input: "Hello from PyAI.", voice: "
|
|
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
|
|
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
|
-
const stream = await pyai.audio.speechStream({ input: "Hello from PyAI.", voice: "
|
|
41
|
+
const stream = await pyai.audio.speechStream({ input: "Hello from PyAI.", voice: "stock_emma_en_gb", response_format: "mp3" });
|
|
41
42
|
for await (const chunk of stream) writeToSpeakerOrResponse(chunk);
|
|
42
43
|
|
|
43
44
|
// Voices
|
|
@@ -51,22 +52,61 @@ 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
|
-
|
|
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`,
|
|
76
|
+
the server's frames are keyed on `event`). It sends a `type`-keyed `configure`
|
|
77
|
+
the instant the socket opens and routes server frames to typed callbacks, so you
|
|
78
|
+
**can't** trip the #1 Omni integration bug (a hand-rolled `{"event":"configure"}`
|
|
79
|
+
is acked but silently dropped, giving you a connected session with zero turns):
|
|
57
80
|
|
|
58
81
|
```ts
|
|
59
|
-
|
|
60
|
-
|
|
82
|
+
// Omni is zero-state: the key's org authorizes the session, nothing to create.
|
|
83
|
+
const omni = pyai.omni.connect({
|
|
84
|
+
rate: 16000, // 24000 browser · 16000 wideband telephony · 8000 G.711/Twilio
|
|
85
|
+
configure: { voice_id: "stock_emma_en_gb", persona: "You are a receptionist." },
|
|
86
|
+
onAudio: (chunk) => speaker.write(chunk), // binary agent audio, play it out
|
|
87
|
+
onTranscript: (f) => console.log(f.text),
|
|
88
|
+
onError: (e) => console.error(e),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
omni.sendAudio(pcm16Chunk); // stream caller audio continuously (server-side VAD)
|
|
92
|
+
omni.sendDtmf("5");
|
|
93
|
+
omni.close();
|
|
94
|
+
```
|
|
61
95
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
96
|
+
From the browser, mint an ephemeral token server-side with
|
|
97
|
+
`pyai.omni.createSession({ allowedOrigins })` and pass it as `token` so the page
|
|
98
|
+
never holds a secret key:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
const omni = pyai.omni.connect({ token: session.token, configure: { voice_id, persona } });
|
|
65
102
|
```
|
|
66
103
|
|
|
67
|
-
> Omni uses the native `wss://api.pyai.com/v1/omni` surface
|
|
68
|
-
|
|
69
|
-
>
|
|
104
|
+
> Omni uses the native `wss://api.pyai.com/v1/omni` surface and is **zero-state**
|
|
105
|
+
>, no agent to create, `sessionLabel` is an optional opaque tag (never
|
|
106
|
+
> required). Need the raw socket? `pyai.realtimeURL({ product: "omni" })` +
|
|
107
|
+
> `pyai.realtimeSubprotocol()` (or `pyai.connectRealtime()`) still work;
|
|
108
|
+
> `product: "flow"` uses `/v1/realtime`. The older `/v2/omni/chat` URL and the
|
|
109
|
+
> `agentId` option are deprecated but still work.
|
|
70
110
|
|
|
71
111
|
## Streaming speech-to-text (Hear / Cue)
|
|
72
112
|
|
|
@@ -108,15 +148,14 @@ In Node, pass a WebSocket implementation if there's no global one:
|
|
|
108
148
|
|
|
109
149
|
## Speak audio formats (incl. telephony G.711)
|
|
110
150
|
|
|
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
|
|
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
|
|
113
152
|
drop the hand-rolled resampler + μ-law encoder entirely:
|
|
114
153
|
|
|
115
154
|
```ts
|
|
116
155
|
// Twilio/SIP-ready in one param: raw 8 kHz mono μ-law, no client-side DSP.
|
|
117
156
|
const ulaw = await pyai.audio.speech({
|
|
118
157
|
input: "Your appointment is confirmed.",
|
|
119
|
-
voice: "
|
|
158
|
+
voice: "stock_emma_en_gb",
|
|
120
159
|
response_format: "g711_ulaw", // -> audio/basic, forced 8 kHz
|
|
121
160
|
});
|
|
122
161
|
// base64-encode `ulaw` straight into a Twilio media frame.
|
|
@@ -133,7 +172,7 @@ const ulaw = await pyai.audio.speech({
|
|
|
133
172
|
| `g711_ulaw` | 8000 (forced) | `audio/basic` |
|
|
134
173
|
| `g711_alaw` | 8000 (forced) | `audio/basic` |
|
|
135
174
|
|
|
136
|
-
`sample_rate` is optional
|
|
175
|
+
`sample_rate` is optional, omit it for the engine's native 24 kHz (`g711_*` is
|
|
137
176
|
always 8 kHz). The set is typed (`SpeechFormat`) and exported as `SPEECH_FORMATS`
|
|
138
177
|
/ `SPEECH_SAMPLE_RATES` for dropdowns and validation. Any other value is a
|
|
139
178
|
`400 unsupported_format`; omit `response_format` for the default `mp3`.
|
|
@@ -142,6 +181,48 @@ always 8 kHz). The set is typed (`SpeechFormat`) and exported as `SPEECH_FORMATS
|
|
|
142
181
|
> for the full before/after: ~120 lines of resampler + μ-law replaced by one
|
|
143
182
|
> param, with Node (`@pyai/twilio`), Python, and raw-curl snippets.
|
|
144
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
|
+
|
|
145
226
|
## More APIs: clones, telephony, trace
|
|
146
227
|
|
|
147
228
|
```ts
|
|
@@ -163,7 +244,7 @@ await pyai.trace.config.set({ agent_id: "agent_123", enabled: true });
|
|
|
163
244
|
const exposure = await pyai.trace.exposure(30);
|
|
164
245
|
|
|
165
246
|
// Per-call eval scorecard (timeline + quality metrics). These are additive and
|
|
166
|
-
// forward-compatible
|
|
247
|
+
// forward-compatible, present once the engine emits them, so reading them is
|
|
167
248
|
// always safe (the timeline reader returns [] until then).
|
|
168
249
|
const timeline = await pyai.trace.callTimeline(detail.id); // TraceTimelineTurn[]
|
|
169
250
|
const quality = detail.quality_metrics; // { wer?, ttfb_ms?, turn_p95_ms?, vaqi?, … }
|
|
@@ -172,11 +253,11 @@ const quality = detail.quality_metrics; // { wer?, ttfb_ms?,
|
|
|
172
253
|
## Reproducible runs (evals)
|
|
173
254
|
|
|
174
255
|
`audio.speech` and `audio.transcriptions.create` take optional `seed` and
|
|
175
|
-
`temperature` for deterministic eval runs. They're forward-compatible
|
|
176
|
-
once the engine supports them and otherwise ignored
|
|
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:
|
|
177
258
|
|
|
178
259
|
```ts
|
|
179
|
-
await pyai.audio.speech({ input: "Hello", voice: "
|
|
260
|
+
await pyai.audio.speech({ input: "Hello", voice: "stock_emma_en_gb", seed: 42, temperature: 0 });
|
|
180
261
|
await pyai.audio.transcriptions.create({ file: wavBlob, seed: 42 });
|
|
181
262
|
```
|
|
182
263
|
|
|
@@ -191,7 +272,7 @@ try {
|
|
|
191
272
|
await pyai.audio.speech({ input: "hi" });
|
|
192
273
|
} catch (err) {
|
|
193
274
|
if (err instanceof PyAIError && err.code === "credit_exhausted") {
|
|
194
|
-
// out of prepaid credit
|
|
275
|
+
// out of prepaid credit, add credit or use a sandbox key
|
|
195
276
|
}
|
|
196
277
|
}
|
|
197
278
|
```
|
|
@@ -204,17 +285,17 @@ Common codes: `unauthorized`, `forbidden`, `credit_exhausted`,
|
|
|
204
285
|
## CLI (`pyai`)
|
|
205
286
|
|
|
206
287
|
Installing the package also provides a `pyai` command. `pyai doctor` runs a
|
|
207
|
-
deeper diagnosis
|
|
288
|
+
deeper diagnosis, it introspects your key/scopes via `GET /v1/me` (skipped
|
|
208
289
|
gracefully if the route isn't deployed yet), checks endpoint liveness, runs a
|
|
209
290
|
Speak→Hear round-trip, and prints remediation hints for any failure:
|
|
210
291
|
|
|
211
292
|
```bash
|
|
212
293
|
export PYAI_API_KEY=pyai_test_...
|
|
213
294
|
npx pyai doctor
|
|
214
|
-
# PASS key (/v1/me)
|
|
215
|
-
# PASS models.list
|
|
216
|
-
# PASS voices.list
|
|
217
|
-
# PASS speak→hear round-trip
|
|
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…"
|
|
218
299
|
# Diagnosis: healthy. Key, endpoint, and a Speak→Hear round-trip all work.
|
|
219
300
|
|
|
220
301
|
npx pyai smoke # lighter: models + voices + speak
|
|
@@ -225,7 +306,7 @@ Other commands:
|
|
|
225
306
|
```bash
|
|
226
307
|
pyai models
|
|
227
308
|
pyai voices --gender female --region en_us
|
|
228
|
-
pyai speak --text "Hello" --voice
|
|
309
|
+
pyai speak --text "Hello" --voice stock_emma_en_gb --out hello.wav
|
|
229
310
|
pyai transcribe --url https://example.com/call.wav --diarize --poll
|
|
230
311
|
```
|
|
231
312
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* pyai
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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}
|
|
118
|
-
|
|
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.
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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
|
|
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
|
|
200
|
+
return "Publishable token origin not allow-listed, fix the allowed origins.";
|
|
151
201
|
case "credit_exhausted":
|
|
152
|
-
return "Out of prepaid credit
|
|
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
|
|
204
|
+
return "Per-key monthly budget hit, raise the budget in the console.";
|
|
155
205
|
case "insufficient_quota":
|
|
156
|
-
return "Plan quota exhausted
|
|
206
|
+
return "Plan quota exhausted, upgrade your plan.";
|
|
157
207
|
case "rate_limit_exceeded":
|
|
158
|
-
return "Rate limited
|
|
208
|
+
return "Rate limited, back off and retry (honor Retry-After).";
|
|
159
209
|
case "concurrency_limit_exceeded":
|
|
160
|
-
return "Too many concurrent sessions
|
|
210
|
+
return "Too many concurrent sessions, retry shortly.";
|
|
161
211
|
case "daily_cap_exceeded":
|
|
162
|
-
return "Daily cap reached
|
|
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
|
|
218
|
+
return "Invalid or missing key, check PYAI_API_KEY.";
|
|
169
219
|
case 403:
|
|
170
|
-
return "Forbidden
|
|
220
|
+
return "Forbidden, the key likely lacks the required scope.";
|
|
171
221
|
case 404:
|
|
172
|
-
return "Not found
|
|
222
|
+
return "Not found, check PYAI_BASE_URL and the route.";
|
|
173
223
|
case 429:
|
|
174
|
-
return "Rate/concurrency limited
|
|
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"
|
|
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}
|
|
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
|
|
287
|
+
out(`\nDiagnosis: ${failed.length} check(s) failed, see the remediation hints above.`);
|
|
238
288
|
process.exit(1);
|
|
239
289
|
}
|
|
240
290
|
}
|