@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/src/cli.ts
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
|
|
|
18
19
|
import { writeFile } from "node:fs/promises";
|
|
@@ -64,11 +65,12 @@ function fail(msg: string): never {
|
|
|
64
65
|
process.exit(1);
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
const USAGE = `pyai
|
|
68
|
+
const USAGE = `pyai, PyAI API CLI
|
|
68
69
|
|
|
69
70
|
Usage:
|
|
70
71
|
pyai doctor diagnose key/scopes + endpoint + Speak→Hear round-trip
|
|
71
|
-
pyai smoke
|
|
72
|
+
pyai smoke [--tolerate-upstream] run a key/endpoint/audio smoke test
|
|
73
|
+
(--tolerate-upstream: transient 5xx/429 warn, don't fail)
|
|
72
74
|
pyai models list models
|
|
73
75
|
pyai voices [--gender g] [--region r] list voices
|
|
74
76
|
pyai speak --text T [--voice V] [--out f.wav]
|
|
@@ -116,16 +118,53 @@ async function cmdTranscribe(flags: Flags): Promise<void> {
|
|
|
116
118
|
out(`job ${job.job_id} still running after polling; check later.`);
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
// Transient upstream conditions: a momentary engine/capacity blip or network
|
|
122
|
+
// hiccup, NOT a key/scope/contract problem. 5xx = engine unhealthy (e.g. Speak
|
|
123
|
+
// "503 service_unavailable"), 429 = rate/capacity, a non-PyAIError = network.
|
|
124
|
+
// These self-heal; the others (401/403/404/400) are real and must fail loudly.
|
|
125
|
+
const TRANSIENT_STATUSES = new Set([429, 500, 502, 503, 504]);
|
|
126
|
+
function isTransient(err: unknown): boolean {
|
|
127
|
+
if (err instanceof PyAIError) return TRANSIENT_STATUSES.has(err.status);
|
|
128
|
+
return true; // network / timeout / unknown, worth a retry, never a hard fail on its own
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Retry `fn` on transient upstream errors with exponential backoff. Real
|
|
132
|
+
* (non-transient) errors throw immediately, we never paper over a bad key. */
|
|
133
|
+
async function withRetry<T>(fn: () => Promise<T>, attempts = 4, baseMs = 800): Promise<T> {
|
|
134
|
+
let lastErr: unknown;
|
|
135
|
+
for (let i = 0; i < attempts; i++) {
|
|
136
|
+
try {
|
|
137
|
+
return await fn();
|
|
138
|
+
} catch (err) {
|
|
139
|
+
lastErr = err;
|
|
140
|
+
if (!isTransient(err) || i === attempts - 1) throw err;
|
|
141
|
+
await new Promise((r) => setTimeout(r, baseMs * 2 ** i));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
throw lastErr;
|
|
145
|
+
}
|
|
146
|
+
|
|
119
147
|
/** The headline: prove key + endpoint + audio in one command. */
|
|
120
148
|
async function cmdSmoke(flags: Flags): Promise<void> {
|
|
121
149
|
const pyai = client(flags);
|
|
122
|
-
|
|
150
|
+
// CI/ops opt-in: a transient upstream blip (engine warming, a brief 503,
|
|
151
|
+
// rate-limit) should not red the build, it isn't the commit's fault. With this
|
|
152
|
+
// on, such failures are reported as WARN (exit 0); real key/scope/contract
|
|
153
|
+
// failures still FAIL (exit 1). Off by default so a developer running `pyai
|
|
154
|
+
// smoke` gets the strict, honest answer.
|
|
155
|
+
const tolerateUpstream = flags["tolerate-upstream"] === true || process.env.PYAI_SMOKE_TOLERATE_UPSTREAM === "1";
|
|
156
|
+
// Retry tuning is env-overridable (tests drive it fast; default rides brief blips).
|
|
157
|
+
const retryAttempts = Number(process.env.PYAI_SMOKE_RETRY_ATTEMPTS ?? 4);
|
|
158
|
+
const retryBaseMs = Number(process.env.PYAI_SMOKE_RETRY_BASE_MS ?? 800);
|
|
159
|
+
type Status = "PASS" | "WARN" | "FAIL";
|
|
160
|
+
const checks: Array<{ name: string; status: Status; detail: string }> = [];
|
|
123
161
|
const run = async (name: string, fn: () => Promise<string>) => {
|
|
124
162
|
try {
|
|
125
|
-
checks.push({ name,
|
|
163
|
+
checks.push({ name, status: "PASS", detail: await withRetry(fn, retryAttempts, retryBaseMs) });
|
|
126
164
|
} catch (err) {
|
|
127
|
-
const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""} ${err.message}
|
|
128
|
-
|
|
165
|
+
const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""} ${err.message}`.trim() : (err as Error).message;
|
|
166
|
+
const status: Status = isTransient(err) && tolerateUpstream ? "WARN" : "FAIL";
|
|
167
|
+
checks.push({ name, status, detail });
|
|
129
168
|
}
|
|
130
169
|
};
|
|
131
170
|
|
|
@@ -142,10 +181,19 @@ async function cmdSmoke(flags: Flags): Promise<void> {
|
|
|
142
181
|
return `${Buffer.from(audio).byteLength} bytes of audio`;
|
|
143
182
|
});
|
|
144
183
|
|
|
145
|
-
for (const c of checks) out(`${c.
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
184
|
+
for (const c of checks) out(`${c.status} ${c.name}, ${c.detail}`);
|
|
185
|
+
const failed = checks.filter((c) => c.status === "FAIL");
|
|
186
|
+
const warned = checks.filter((c) => c.status === "WARN");
|
|
187
|
+
// GitHub Actions annotation: a tolerated blip is still surfaced in the run UI.
|
|
188
|
+
for (const c of warned) out(`::warning title=PyAI smoke transient::${c.name}: ${c.detail}`);
|
|
189
|
+
if (failed.length === 0 && warned.length === 0) {
|
|
190
|
+
out("\nAll checks passed. Your key, the endpoint, and audio synthesis work.");
|
|
191
|
+
} else if (failed.length === 0) {
|
|
192
|
+
out(`\n${warned.length} transient upstream issue(s) tolerated (self-healing engine blip), not failing the build.`);
|
|
193
|
+
} else {
|
|
194
|
+
out("\nSome checks failed (see above).");
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
149
197
|
}
|
|
150
198
|
|
|
151
199
|
/** Turn an error into an actionable, code-first remediation hint. */
|
|
@@ -153,35 +201,35 @@ function remediation(err: unknown): string {
|
|
|
153
201
|
if (!(err instanceof PyAIError)) return (err as Error)?.message ?? String(err);
|
|
154
202
|
switch (err.code) {
|
|
155
203
|
case "unauthorized":
|
|
156
|
-
return "Invalid or missing key
|
|
204
|
+
return "Invalid or missing key, check PYAI_API_KEY (a pyai_test_ or pyai_live_ key).";
|
|
157
205
|
case "forbidden":
|
|
158
|
-
return "Key is missing a required scope
|
|
206
|
+
return "Key is missing a required scope, add it to the key in the console.";
|
|
159
207
|
case "origin_not_allowed":
|
|
160
|
-
return "Publishable token origin not allow-listed
|
|
208
|
+
return "Publishable token origin not allow-listed, fix the allowed origins.";
|
|
161
209
|
case "credit_exhausted":
|
|
162
|
-
return "Out of prepaid credit
|
|
210
|
+
return "Out of prepaid credit, add credit, or use a pyai_test_ sandbox key.";
|
|
163
211
|
case "key_budget_exceeded":
|
|
164
|
-
return "Per-key monthly budget hit
|
|
212
|
+
return "Per-key monthly budget hit, raise the budget in the console.";
|
|
165
213
|
case "insufficient_quota":
|
|
166
|
-
return "Plan quota exhausted
|
|
214
|
+
return "Plan quota exhausted, upgrade your plan.";
|
|
167
215
|
case "rate_limit_exceeded":
|
|
168
|
-
return "Rate limited
|
|
216
|
+
return "Rate limited, back off and retry (honor Retry-After).";
|
|
169
217
|
case "concurrency_limit_exceeded":
|
|
170
|
-
return "Too many concurrent sessions
|
|
218
|
+
return "Too many concurrent sessions, retry shortly.";
|
|
171
219
|
case "daily_cap_exceeded":
|
|
172
|
-
return "Daily cap reached
|
|
220
|
+
return "Daily cap reached, wait until it resets.";
|
|
173
221
|
default:
|
|
174
222
|
break;
|
|
175
223
|
}
|
|
176
224
|
switch (err.status) {
|
|
177
225
|
case 401:
|
|
178
|
-
return "Invalid or missing key
|
|
226
|
+
return "Invalid or missing key, check PYAI_API_KEY.";
|
|
179
227
|
case 403:
|
|
180
|
-
return "Forbidden
|
|
228
|
+
return "Forbidden, the key likely lacks the required scope.";
|
|
181
229
|
case 404:
|
|
182
|
-
return "Not found
|
|
230
|
+
return "Not found, check PYAI_BASE_URL and the route.";
|
|
183
231
|
case 429:
|
|
184
|
-
return "Rate/concurrency limited
|
|
232
|
+
return "Rate/concurrency limited, back off and retry.";
|
|
185
233
|
default:
|
|
186
234
|
return err.message;
|
|
187
235
|
}
|
|
@@ -210,7 +258,7 @@ async function cmdDoctor(flags: Flags): Promise<void> {
|
|
|
210
258
|
const checks: DoctorCheck[] = [];
|
|
211
259
|
|
|
212
260
|
// (a) Key validity + scopes via GET /v1/me. The route is new, so a 404 means
|
|
213
|
-
// "not deployed here yet"
|
|
261
|
+
// "not deployed here yet", skip it rather than failing the whole doctor.
|
|
214
262
|
try {
|
|
215
263
|
const me = await pyai.me();
|
|
216
264
|
const scopes = Array.isArray(me.scopes) ? me.scopes : [];
|
|
@@ -245,14 +293,14 @@ async function cmdDoctor(flags: Flags): Promise<void> {
|
|
|
245
293
|
});
|
|
246
294
|
|
|
247
295
|
for (const c of checks) {
|
|
248
|
-
out(`${c.status.padEnd(4)} ${c.name}
|
|
296
|
+
out(`${c.status.padEnd(4)} ${c.name}, ${c.detail}`);
|
|
249
297
|
if (c.hint) out(` ↳ ${c.hint}`);
|
|
250
298
|
}
|
|
251
299
|
const failed = checks.filter((c) => c.status === "FAIL");
|
|
252
300
|
if (failed.length === 0) {
|
|
253
301
|
out("\nDiagnosis: healthy. Key, endpoint, and a Speak→Hear round-trip all work.");
|
|
254
302
|
} else {
|
|
255
|
-
out(`\nDiagnosis: ${failed.length} check(s) failed
|
|
303
|
+
out(`\nDiagnosis: ${failed.length} check(s) failed, see the remediation hints above.`);
|
|
256
304
|
process.exit(1);
|
|
257
305
|
}
|
|
258
306
|
}
|