@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.
- package/dist/commands/auth.js +2 -0
- package/dist/lib/api.js +61 -8
- package/dist/lib/validate-inputs.js +27 -1
- package/package.json +2 -2
package/dist/commands/auth.js
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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}`, {
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
59
|
+
"wavespeed": "^0.3.4"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/node": "^20.14.0",
|