@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/src/cli.ts 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
 
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 PyAI API CLI
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 run a key/endpoint/audio smoke test
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
- const checks: Array<{ name: string; ok: boolean; detail: string }> = [];
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, ok: true, detail: await fn() });
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}` : (err as Error).message;
128
- checks.push({ name, ok: false, detail });
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.ok ? "PASS" : "FAIL"} ${c.name} ${c.detail}`);
146
- const allOk = checks.every((c) => c.ok);
147
- out(allOk ? "\nAll checks passed. Your key, the endpoint, and audio synthesis work." : "\nSome checks failed (see above).");
148
- if (!allOk) process.exit(1);
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 check PYAI_API_KEY (a pyai_test_ or pyai_live_ 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 add it to the key in the console.";
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 fix the allowed origins.";
208
+ return "Publishable token origin not allow-listed, fix the allowed origins.";
161
209
  case "credit_exhausted":
162
- return "Out of prepaid credit add credit, or use a pyai_test_ sandbox key.";
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 raise the budget in the console.";
212
+ return "Per-key monthly budget hit, raise the budget in the console.";
165
213
  case "insufficient_quota":
166
- return "Plan quota exhausted upgrade your plan.";
214
+ return "Plan quota exhausted, upgrade your plan.";
167
215
  case "rate_limit_exceeded":
168
- return "Rate limited back off and retry (honor Retry-After).";
216
+ return "Rate limited, back off and retry (honor Retry-After).";
169
217
  case "concurrency_limit_exceeded":
170
- return "Too many concurrent sessions retry shortly.";
218
+ return "Too many concurrent sessions, retry shortly.";
171
219
  case "daily_cap_exceeded":
172
- return "Daily cap reached wait until it resets.";
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 check PYAI_API_KEY.";
226
+ return "Invalid or missing key, check PYAI_API_KEY.";
179
227
  case 403:
180
- return "Forbidden the key likely lacks the required scope.";
228
+ return "Forbidden, the key likely lacks the required scope.";
181
229
  case 404:
182
- return "Not found check PYAI_BASE_URL and the route.";
230
+ return "Not found, check PYAI_BASE_URL and the route.";
183
231
  case 429:
184
- return "Rate/concurrency limited back off and retry.";
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" skip it rather than failing the whole doctor.
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} ${c.detail}`);
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 see the remediation hints above.`);
303
+ out(`\nDiagnosis: ${failed.length} check(s) failed, see the remediation hints above.`);
256
304
  process.exit(1);
257
305
  }
258
306
  }