@wavespeed/cli 0.4.9 → 0.4.11

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.
@@ -6,6 +6,7 @@ import clipboard from 'clipboardy';
6
6
  import { userConfig, getBaseUrl, getApiKey, loadProjectConfig, PROJECT_FILE } from '../lib/config.js';
7
7
  import { LINKS } from '../lib/links.js';
8
8
  import { clientAttributionHeaders } from '../lib/client-headers.js';
9
+ import { GET_TIMEOUT_MS } from '../lib/api.js';
9
10
  const KEY_URL = 'https://wavespeed.ai/accesskey';
10
11
  function mask(key) {
11
12
  if (key.length < 12)
@@ -42,6 +43,7 @@ async function validateKey(apiKey, baseUrl) {
42
43
  try {
43
44
  res = await fetch(`${baseUrl}/api/v3/balance`, {
44
45
  headers: { Authorization: `Bearer ${apiKey}`, ...clientAttributionHeaders() },
46
+ signal: AbortSignal.timeout(GET_TIMEOUT_MS),
45
47
  });
46
48
  }
47
49
  catch (err) {
package/dist/lib/api.js CHANGED
@@ -46,6 +46,7 @@ export async function fetchModels(opts = {}) {
46
46
  throw new Error('No API key configured. Run `wavespeed login`.');
47
47
  const res = await fetch(`${baseUrl}/api/v3/models`, {
48
48
  headers: { Authorization: `Bearer ${apiKey}`, ...clientAttributionHeaders() },
49
+ signal: AbortSignal.timeout(GET_TIMEOUT_MS),
49
50
  });
50
51
  if (!res.ok) {
51
52
  throw await httpError(res, 'GET', '/api/v3/models');
@@ -66,6 +67,12 @@ export function clearModelsCache() {
66
67
  /* nothing to clear */
67
68
  }
68
69
  }
70
+ // Per-request timeouts (no overall cap). A hung socket would otherwise stall
71
+ // the CLI forever. Read-only GETs are far below 30s in practice (result p99.9
72
+ // < 1s); the async submit gets more headroom because a timeout there is an
73
+ // ambiguous outcome, not a clean failure.
74
+ export const GET_TIMEOUT_MS = 30_000;
75
+ export const SUBMIT_TIMEOUT_MS = 60_000;
69
76
  // --- Account + prediction endpoints (not exposed by the wavespeed SDK) ---
70
77
  function authHeaders() {
71
78
  const apiKey = getApiKey();
@@ -88,12 +95,17 @@ async function httpError(res, method, path) {
88
95
  catch {
89
96
  /* non-JSON body (gateway error page, empty response) — fall back below */
90
97
  }
91
- if (detail)
92
- return new Error(detail);
93
- return new Error(`${method} ${path} failed: ${res.status} ${res.statusText}`);
98
+ const err = detail
99
+ ? new Error(detail)
100
+ : new Error(`${method} ${path} failed: ${res.status} ${res.statusText}`);
101
+ err.status = res.status;
102
+ return err;
94
103
  }
95
104
  async function apiGet(path) {
96
- const res = await fetch(`${getBaseUrl()}${path}`, { headers: authHeaders() });
105
+ const res = await fetch(`${getBaseUrl()}${path}`, {
106
+ headers: authHeaders(),
107
+ signal: AbortSignal.timeout(GET_TIMEOUT_MS),
108
+ });
97
109
  if (!res.ok)
98
110
  throw await httpError(res, 'GET', path);
99
111
  const json = (await res.json());
@@ -101,11 +113,12 @@ async function apiGet(path) {
101
113
  throw new Error(json.message || `API returned code ${json.code}`);
102
114
  return json.data;
103
115
  }
104
- async function apiPost(path, body) {
116
+ async function apiPost(path, body, opts = {}) {
105
117
  const res = await fetch(`${getBaseUrl()}${path}`, {
106
118
  method: 'POST',
107
119
  headers: { ...authHeaders(), 'Content-Type': 'application/json' },
108
120
  body: JSON.stringify(body),
121
+ signal: opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined,
109
122
  });
110
123
  if (!res.ok)
111
124
  throw await httpError(res, 'POST', path);
@@ -140,6 +153,32 @@ export async function fetchHistory(query = {}) {
140
153
  export async function fetchPrediction(id) {
141
154
  return apiGet(`/api/v3/predictions/${id}/result`);
142
155
  }
156
+ // Same set as wavespeed-python. A thrown error with no HTTP status is a
157
+ // network failure or timeout; envelope/JSON errors carry no status either but
158
+ // never reach here as a transient fault, so only fetch-level errors count.
159
+ const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
160
+ const MAX_POLL_RETRIES = 5;
161
+ function isTransient(err) {
162
+ if (typeof err?.status === 'number')
163
+ return RETRYABLE_STATUS.has(err.status);
164
+ return err instanceof TypeError || err?.name === 'TimeoutError' || err?.name === 'AbortError';
165
+ }
166
+ async function fetchPredictionWithRetry(id, retryDelayMs) {
167
+ for (let attempt = 0;; attempt++) {
168
+ try {
169
+ return await fetchPrediction(id);
170
+ }
171
+ catch (err) {
172
+ const reason = err?.message ?? String(err);
173
+ if (!isTransient(err))
174
+ throw new Error(`${reason} (task_id: ${id})`);
175
+ if (attempt >= MAX_POLL_RETRIES) {
176
+ throw new Error(`Status check failed after ${attempt + 1} attempts: ${reason} (task_id: ${id}) — the task may still be running`);
177
+ }
178
+ await new Promise((r) => setTimeout(r, retryDelayMs * (attempt + 1)));
179
+ }
180
+ }
181
+ }
143
182
  export async function deletePredictions(ids) {
144
183
  await apiPost('/api/v3/predictions/delete', { ids });
145
184
  }
@@ -164,16 +203,30 @@ export async function submitPrediction(model, input, opts = {}) {
164
203
  const body = { ...input };
165
204
  if (opts.sync)
166
205
  body.enable_sync_mode = true;
167
- return apiPost(`/api/v3/${model}`, body);
206
+ // sync mode legitimately holds the request until the task finishes, so only
207
+ // async submits get the per-request timeout.
208
+ try {
209
+ return await apiPost(`/api/v3/${model}`, body, {
210
+ timeoutMs: opts.sync ? undefined : SUBMIT_TIMEOUT_MS,
211
+ });
212
+ }
213
+ catch (err) {
214
+ // Never retried: the server may already have created (and billed) the task.
215
+ if (err?.name === 'TimeoutError') {
216
+ throw new Error(`Submission outcome unknown: no response within ${SUBMIT_TIMEOUT_MS / 1000}s. The task may have been created — run \`wavespeed history\` to check before retrying.`);
217
+ }
218
+ throw err;
219
+ }
168
220
  }
169
221
  /**
170
222
  * Poll a prediction until it reaches a terminal status. `onTick` fires each
171
- * round so the caller can keep a spinner honest.
223
+ * round so the caller can keep a spinner honest. Transient status-read
224
+ * failures are retried; submit is never retried.
172
225
  */
173
226
  export async function waitForPrediction(id, opts = {}) {
174
227
  const interval = opts.intervalMs ?? 1000;
175
228
  for (;;) {
176
- const item = await fetchPrediction(id);
229
+ const item = await fetchPredictionWithRetry(id, opts.retryDelayMs ?? 1000);
177
230
  opts.onTick?.(item.status);
178
231
  if (item.status === 'completed')
179
232
  return item;
@@ -16,6 +16,31 @@ export function requestSchemaProps(model) {
16
16
  const keys = Object.keys(props);
17
17
  return keys.length > 0 ? keys : null;
18
18
  }
19
+ /**
20
+ * Legacy field names a model still accepts, as `legacy -> canonical`.
21
+ *
22
+ * The API renames public fields without breaking old callers by declaring the
23
+ * old name in the property's `x-aliases` (e.g. `last_image` carries
24
+ * `["end_image"]`) and folding it into the canonical name before validation.
25
+ * A legacy name is therefore a valid input, not a typo — it just is not listed
26
+ * as an accepted input, so schema-driven UIs show only the current name.
27
+ */
28
+ export function requestSchemaAliases(model) {
29
+ const aliases = new Map();
30
+ const props = model?.api_schema?.api_schemas?.[0]?.request_schema?.properties;
31
+ if (!props)
32
+ return aliases;
33
+ for (const [name, def] of Object.entries(props)) {
34
+ const declared = def?.['x-aliases'];
35
+ if (!Array.isArray(declared))
36
+ continue;
37
+ for (const alias of declared) {
38
+ if (typeof alias === 'string' && alias !== name)
39
+ aliases.set(alias, name);
40
+ }
41
+ }
42
+ return aliases;
43
+ }
19
44
  // Small edit distance for typo hints — inputs are short snake_case tokens, so
20
45
  // a full Levenshtein over ≤40 keys is nothing.
21
46
  function editDistance(a, b) {
@@ -49,7 +74,8 @@ export function findUnknownInputs(input, model) {
49
74
  if (!known)
50
75
  return null;
51
76
  const knownSet = new Set(known);
52
- const unknown = Object.keys(input).filter((k) => !knownSet.has(k));
77
+ const aliases = requestSchemaAliases(model);
78
+ const unknown = Object.keys(input).filter((k) => !knownSet.has(k) && !aliases.has(k));
53
79
  if (unknown.length === 0)
54
80
  return null;
55
81
  const suggestions = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavespeed/cli",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "WaveSpeed CLI — one command to run any WaveSpeed AI model from your terminal.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -56,7 +56,7 @@
56
56
  "conf": "^13.0.0",
57
57
  "open": "^10.1.0",
58
58
  "ora": "^8.1.0",
59
- "wavespeed": "^0.3.0"
59
+ "wavespeed": "^0.3.4"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/node": "^20.14.0",