@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 +156 -17
- package/dist/cli.d.ts +3 -2
- package/dist/cli.js +107 -2
- package/dist/index.d.ts +868 -7
- package/dist/index.js +587 -5
- package/package.json +2 -1
- package/src/cli.ts +115 -2
- package/src/index.ts +1220 -11
package/src/cli.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* pyai — CLI
|
|
4
|
-
*
|
|
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
|
|
@@ -66,6 +67,7 @@ function fail(msg: string): never {
|
|
|
66
67
|
const USAGE = `pyai — PyAI API CLI
|
|
67
68
|
|
|
68
69
|
Usage:
|
|
70
|
+
pyai doctor diagnose key/scopes + endpoint + Speak→Hear round-trip
|
|
69
71
|
pyai smoke run a key/endpoint/audio smoke test
|
|
70
72
|
pyai models list models
|
|
71
73
|
pyai voices [--gender g] [--region r] list voices
|
|
@@ -146,10 +148,121 @@ async function cmdSmoke(flags: Flags): Promise<void> {
|
|
|
146
148
|
if (!allOk) process.exit(1);
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
/** Turn an error into an actionable, code-first remediation hint. */
|
|
152
|
+
function remediation(err: unknown): string {
|
|
153
|
+
if (!(err instanceof PyAIError)) return (err as Error)?.message ?? String(err);
|
|
154
|
+
switch (err.code) {
|
|
155
|
+
case "unauthorized":
|
|
156
|
+
return "Invalid or missing key — check PYAI_API_KEY (a pyai_test_ or pyai_live_ key).";
|
|
157
|
+
case "forbidden":
|
|
158
|
+
return "Key is missing a required scope — add it to the key in the console.";
|
|
159
|
+
case "origin_not_allowed":
|
|
160
|
+
return "Publishable token origin not allow-listed — fix the allowed origins.";
|
|
161
|
+
case "credit_exhausted":
|
|
162
|
+
return "Out of prepaid credit — add credit, or use a pyai_test_ sandbox key.";
|
|
163
|
+
case "key_budget_exceeded":
|
|
164
|
+
return "Per-key monthly budget hit — raise the budget in the console.";
|
|
165
|
+
case "insufficient_quota":
|
|
166
|
+
return "Plan quota exhausted — upgrade your plan.";
|
|
167
|
+
case "rate_limit_exceeded":
|
|
168
|
+
return "Rate limited — back off and retry (honor Retry-After).";
|
|
169
|
+
case "concurrency_limit_exceeded":
|
|
170
|
+
return "Too many concurrent sessions — retry shortly.";
|
|
171
|
+
case "daily_cap_exceeded":
|
|
172
|
+
return "Daily cap reached — wait until it resets.";
|
|
173
|
+
default:
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
switch (err.status) {
|
|
177
|
+
case 401:
|
|
178
|
+
return "Invalid or missing key — check PYAI_API_KEY.";
|
|
179
|
+
case 403:
|
|
180
|
+
return "Forbidden — the key likely lacks the required scope.";
|
|
181
|
+
case 404:
|
|
182
|
+
return "Not found — check PYAI_BASE_URL and the route.";
|
|
183
|
+
case 429:
|
|
184
|
+
return "Rate/concurrency limited — back off and retry.";
|
|
185
|
+
default:
|
|
186
|
+
return err.message;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface DoctorCheck {
|
|
191
|
+
name: string;
|
|
192
|
+
status: "PASS" | "FAIL" | "SKIP";
|
|
193
|
+
detail: string;
|
|
194
|
+
hint?: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function doctorCheck(checks: DoctorCheck[], name: string, fn: () => Promise<string>): Promise<void> {
|
|
198
|
+
try {
|
|
199
|
+
checks.push({ name, status: "PASS", detail: await fn() });
|
|
200
|
+
} catch (err) {
|
|
201
|
+
const detail =
|
|
202
|
+
err instanceof PyAIError ? `${err.status} ${err.code ?? ""} ${err.message}`.trim() : (err as Error).message;
|
|
203
|
+
checks.push({ name, status: "FAIL", detail, hint: remediation(err) });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Deeper than smoke: key/scopes, endpoint liveness, and a Speak→Hear round-trip. */
|
|
208
|
+
async function cmdDoctor(flags: Flags): Promise<void> {
|
|
209
|
+
const pyai = client(flags);
|
|
210
|
+
const checks: DoctorCheck[] = [];
|
|
211
|
+
|
|
212
|
+
// (a) Key validity + scopes via GET /v1/me. The route is new, so a 404 means
|
|
213
|
+
// "not deployed here yet" — skip it rather than failing the whole doctor.
|
|
214
|
+
try {
|
|
215
|
+
const me = await pyai.me();
|
|
216
|
+
const scopes = Array.isArray(me.scopes) ? me.scopes : [];
|
|
217
|
+
const env = me.environment ?? me.env ?? "unknown";
|
|
218
|
+
checks.push({
|
|
219
|
+
name: "key (/v1/me)",
|
|
220
|
+
status: "PASS",
|
|
221
|
+
detail: `env=${env}; ${scopes.length} scope(s)${scopes.length ? `: ${scopes.join(", ")}` : ""}`,
|
|
222
|
+
});
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if (err instanceof PyAIError && err.status === 404) {
|
|
225
|
+
checks.push({ name: "key (/v1/me)", status: "SKIP", detail: "introspection route not on this deployment" });
|
|
226
|
+
} else {
|
|
227
|
+
const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""}`.trim() : (err as Error).message;
|
|
228
|
+
checks.push({ name: "key (/v1/me)", status: "FAIL", detail, hint: remediation(err) });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// (b) Endpoint liveness.
|
|
233
|
+
await doctorCheck(checks, "models.list", async () => `${(await pyai.models.list()).data.length} models`);
|
|
234
|
+
await doctorCheck(checks, "voices.list", async () => `${(await pyai.voices.list()).data.length} voices`);
|
|
235
|
+
|
|
236
|
+
// (c) Speak -> Hear round-trip: synthesize a sentence, then transcribe it.
|
|
237
|
+
await doctorCheck(checks, "speak→hear round-trip", async () => {
|
|
238
|
+
const audio = await pyai.audio.speech({ input: "The quick brown fox jumps over the lazy dog." });
|
|
239
|
+
const bytes = Buffer.from(audio).byteLength;
|
|
240
|
+
const blob = new Blob([audio], { type: "audio/wav" });
|
|
241
|
+
const tr = await pyai.audio.transcriptions.create({ file: blob, filename: "doctor.wav" });
|
|
242
|
+
const text = (tr.text ?? "").trim();
|
|
243
|
+
if (!text) throw new Error(`synthesized ${bytes} bytes but transcription came back empty`);
|
|
244
|
+
return `synth ${bytes} bytes → "${text.length > 60 ? `${text.slice(0, 60)}…` : text}"`;
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
for (const c of checks) {
|
|
248
|
+
out(`${c.status.padEnd(4)} ${c.name} — ${c.detail}`);
|
|
249
|
+
if (c.hint) out(` ↳ ${c.hint}`);
|
|
250
|
+
}
|
|
251
|
+
const failed = checks.filter((c) => c.status === "FAIL");
|
|
252
|
+
if (failed.length === 0) {
|
|
253
|
+
out("\nDiagnosis: healthy. Key, endpoint, and a Speak→Hear round-trip all work.");
|
|
254
|
+
} else {
|
|
255
|
+
out(`\nDiagnosis: ${failed.length} check(s) failed — see the remediation hints above.`);
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
149
260
|
async function main(): Promise<void> {
|
|
150
261
|
const flags = parseArgs(process.argv.slice(2));
|
|
151
262
|
const cmd = (flags._ as string[])[0];
|
|
152
263
|
switch (cmd) {
|
|
264
|
+
case "doctor":
|
|
265
|
+
return cmdDoctor(flags);
|
|
153
266
|
case "smoke":
|
|
154
267
|
return cmdSmoke(flags);
|
|
155
268
|
case "models":
|