@wavespeed/cli 0.4.25 → 0.4.27
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 +1 -1
- package/dist/commands/auth.js +14 -2
- package/dist/commands/balance.js +1 -1
- package/dist/commands/billings.js +4 -4
- package/dist/commands/delete.js +37 -8
- package/dist/commands/history.js +26 -5
- package/dist/commands/media.js +5 -1
- package/dist/commands/run.js +7 -1
- package/dist/commands/show.js +14 -8
- package/dist/lib/api.js +116 -18
- package/dist/lib/config.js +11 -0
- package/dist/lib/output.js +78 -2
- package/dist/lib/time.js +1 -1
- package/dist/lib/upload-cache.js +17 -9
- package/dist/lib/validate-inputs.js +14 -2
- package/package.json +1 -1
- package/skills/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -135,7 +135,7 @@ wavespeed skill install # writes .claude/skills/wavespeed/SKILL.md and .a
|
|
|
135
135
|
|
|
136
136
|
The skill teaches the agent the shortcuts for images and videos (`generate`, `edit`, `extend`, with the default models), and for everything else the three-step pattern: `models` to find, `run <id> -h` to discover params, `run <id> -p "…" --json` to execute. Every command supports `--json` for clean piping.
|
|
137
137
|
|
|
138
|
-
**OpenCode** loads the same file with no extra steps — `.claude/skills/` is on its skill search path.
|
|
138
|
+
**OpenCode** loads the same file with no extra steps — `.claude/skills/` is on its skill search path. For the MCP tools as well, add the plugin to your OpenCode config — `"plugin": ["@wavespeed/opencode"]` — and run `opencode mcp auth wavespeed` ([WaveSpeedAI/wavespeed-opencode](https://github.com/WaveSpeedAI/wavespeed-opencode)).
|
|
139
139
|
|
|
140
140
|
**Codex** reads `.agents/skills/`, which the same command writes. For a one-line install that also wires up the MCP server: `codex plugin marketplace add WaveSpeedAI/codex-plugin-wavespeed-cli` ([WaveSpeedAI/codex-plugin-wavespeed-cli](https://github.com/WaveSpeedAI/codex-plugin-wavespeed-cli)).
|
|
141
141
|
|
package/dist/commands/auth.js
CHANGED
|
@@ -34,7 +34,8 @@ async function readClipboardKey() {
|
|
|
34
34
|
}
|
|
35
35
|
async function validateKey(apiKey, baseUrl) {
|
|
36
36
|
// The platform doesn't expose a dedicated /whoami yet, so we ping a known
|
|
37
|
-
// authenticated endpoint
|
|
37
|
+
// authenticated endpoint: 2xx means "key works", 401/403 means rejected, and
|
|
38
|
+
// anything else (429, 5xx) means it could not be checked.
|
|
38
39
|
// Note this proves the key is live, not that it can run models: /balance is
|
|
39
40
|
// deliberately open to every key regardless of the creator's org role, so a
|
|
40
41
|
// Billing-created key logs in here and only fails at `run`, which now
|
|
@@ -52,6 +53,16 @@ async function validateKey(apiKey, baseUrl) {
|
|
|
52
53
|
if (res.status === 401 || res.status === 403) {
|
|
53
54
|
return { ok: false, status: res.status, reason: 'API rejected the key (401/403).' };
|
|
54
55
|
}
|
|
56
|
+
// Only a successful response proves the key. A 429 or 5xx says nothing
|
|
57
|
+
// about it, so the key is not stored as if it had been checked.
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
status: res.status,
|
|
62
|
+
unverified: true,
|
|
63
|
+
reason: `Could not verify the key right now (${res.status} ${res.statusText || 'error'} from ${baseUrl}). Try again shortly.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
55
66
|
return { ok: true, status: res.status };
|
|
56
67
|
}
|
|
57
68
|
async function loginFlow(opts) {
|
|
@@ -97,7 +108,8 @@ async function loginFlow(opts) {
|
|
|
97
108
|
if (!result.ok) {
|
|
98
109
|
spinner.fail(result.reason ?? 'The key did not authenticate.');
|
|
99
110
|
console.log();
|
|
100
|
-
|
|
111
|
+
if (!result.unverified)
|
|
112
|
+
console.log(chalk.gray(' Double-check at ') + chalk.cyan(KEY_URL));
|
|
101
113
|
console.log(chalk.gray(' Or set ') + chalk.cyan('WAVESPEED_API_KEY') + chalk.gray(' in your shell and skip login altogether.'));
|
|
102
114
|
console.log();
|
|
103
115
|
process.exit(1);
|
package/dist/commands/balance.js
CHANGED
|
@@ -29,7 +29,7 @@ export function registerBalance(program) {
|
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
31
|
console.log();
|
|
32
|
-
console.log(chalk.bold('Balance: ') + chalk.green(`$${balance}`));
|
|
32
|
+
console.log(chalk.bold('Balance: ') + chalk.green(`$${Number(balance).toFixed(2)}`));
|
|
33
33
|
console.log(chalk.gray('Top up: ') + chalk.cyan(TOPUP_URL));
|
|
34
34
|
console.log();
|
|
35
35
|
if (opts.topUp) {
|
|
@@ -18,13 +18,13 @@ function formatTime(iso) {
|
|
|
18
18
|
export function registerBillings(program) {
|
|
19
19
|
program
|
|
20
20
|
.command('billings')
|
|
21
|
-
.description('List itemized charges and refunds (billing records)')
|
|
21
|
+
.description('List itemized charges and refunds (billing records; the API covers at most the last 3 months)')
|
|
22
22
|
.option('--page <n>', 'Page number', '1')
|
|
23
23
|
.option('--limit <n>', 'Page size (1-100)', '20')
|
|
24
24
|
.option('--type <t>', 'Filter by type: deduct or refund')
|
|
25
|
-
.option('--model <
|
|
25
|
+
.option('--model <id>', 'Filter by model ID, as passed to `run` (e.g. bytedance/seedream-v5.0-pro)')
|
|
26
26
|
.option('--key <uuid>', 'Filter by the API key that generated the charge')
|
|
27
|
-
.option('--since <time>', 'Records after this time (ISO or YYYY-MM-DD)')
|
|
27
|
+
.option('--since <time>', 'Records after this time (ISO or YYYY-MM-DD; at most 3 months before --until, default: that whole window)')
|
|
28
28
|
.option('--until <time>', 'Records before this time (ISO or YYYY-MM-DD)')
|
|
29
29
|
.option('--json', 'Emit JSON: {page, has_more, items}')
|
|
30
30
|
.action(async (opts) => {
|
|
@@ -50,7 +50,7 @@ export function registerBillings(program) {
|
|
|
50
50
|
chalk.gray(` · page ${data.page}${data.has_more ? ' · more available (--page)' : ''}`));
|
|
51
51
|
console.log();
|
|
52
52
|
if (items.length === 0) {
|
|
53
|
-
console.log(chalk.gray(' no billing records in this range.'));
|
|
53
|
+
console.log(chalk.gray(' no billing records in this range (the API searches at most the 3 months before --until or now).'));
|
|
54
54
|
console.log();
|
|
55
55
|
return;
|
|
56
56
|
}
|
package/dist/commands/delete.js
CHANGED
|
@@ -8,7 +8,7 @@ export function registerDelete(program) {
|
|
|
8
8
|
.description('Delete one or more predictions from your history')
|
|
9
9
|
.argument('<id...>', 'Prediction ID(s) to delete')
|
|
10
10
|
.option('-y, --yes', 'Skip the confirmation prompt (required in scripts / non-interactive use)')
|
|
11
|
-
.option('--json', 'Emit JSON: {deleted
|
|
11
|
+
.option('--json', 'Emit JSON: {deleted_ids, deleted_count, not_deleted_ids} (deleted = deleted_ids)')
|
|
12
12
|
.action(async (ids, opts) => {
|
|
13
13
|
// --json controls output format only; it must never stand in for
|
|
14
14
|
// consent. Interactive terminals get the prompt, scripts must say -y.
|
|
@@ -31,17 +31,46 @@ export function registerDelete(program) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
const spinner = !opts.json ? ora(`Deleting ${ids.length} prediction${ids.length === 1 ? '' : 's'}…`).start() : null;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
// The server deletes only this organization's predictions that are not
|
|
35
|
+
// running, so report what it actually deleted, not what was asked.
|
|
36
|
+
const report = (r) => {
|
|
37
|
+
const n = r.deleted_ids.length;
|
|
38
|
+
if (r.not_deleted_ids.length === 0)
|
|
39
|
+
spinner?.succeed(`Deleted ${n} prediction${n === 1 ? '' : 's'}.`);
|
|
40
|
+
else if (n > 0)
|
|
41
|
+
spinner?.warn(`Deleted ${n} of ${n + r.not_deleted_ids.length} predictions.`);
|
|
42
|
+
else
|
|
43
|
+
spinner?.fail('No predictions were deleted.');
|
|
44
|
+
if (!opts.json && r.not_deleted_ids.length > 0) {
|
|
45
|
+
console.log(chalk.gray(' Not deleted (not found in this organization, still running, or already deleted):'));
|
|
46
|
+
for (const id of r.not_deleted_ids)
|
|
47
|
+
console.log(' ' + chalk.yellow(id));
|
|
39
48
|
}
|
|
49
|
+
};
|
|
50
|
+
const payload = (r) => ({
|
|
51
|
+
// `deleted` is the pre-0.4.26 key, now also the server's deleted ids.
|
|
52
|
+
deleted: r.deleted_ids,
|
|
53
|
+
deleted_ids: r.deleted_ids,
|
|
54
|
+
deleted_count: r.deleted_ids.length,
|
|
55
|
+
not_deleted_ids: r.not_deleted_ids,
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
const result = await deletePredictions(ids);
|
|
59
|
+
report(result);
|
|
60
|
+
if (opts.json)
|
|
61
|
+
process.stdout.write(JSON.stringify(payload(result), null, 2) + '\n');
|
|
62
|
+
if (result.deleted_ids.length === 0)
|
|
63
|
+
process.exit(1);
|
|
40
64
|
}
|
|
41
65
|
catch (err) {
|
|
42
|
-
|
|
66
|
+
const message = err.message ?? String(err);
|
|
67
|
+
const partial = err.partial;
|
|
68
|
+
spinner?.fail(message);
|
|
69
|
+
if (!opts.json && partial && partial.deleted_ids.length > 0) {
|
|
70
|
+
console.log(chalk.gray(` ${partial.deleted_ids.length} deleted before the error; the remaining ${partial.not_deleted_ids.length} were not.`));
|
|
71
|
+
}
|
|
43
72
|
if (opts.json) {
|
|
44
|
-
process.stdout.write(JSON.stringify({ error:
|
|
73
|
+
process.stdout.write(JSON.stringify({ error: message, ...(partial ? payload(partial) : {}) }, null, 2) + '\n');
|
|
45
74
|
}
|
|
46
75
|
process.exit(1);
|
|
47
76
|
}
|
package/dist/commands/history.js
CHANGED
|
@@ -2,6 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import ora from 'ora';
|
|
3
3
|
import { fetchHistory, PREDICTION_STATUSES } from '../lib/api.js';
|
|
4
4
|
import { toIso } from '../lib/time.js';
|
|
5
|
+
import { describeOutput } from '../lib/output.js';
|
|
5
6
|
function statusBadge(status) {
|
|
6
7
|
const map = {
|
|
7
8
|
completed: chalk.bgGreen.black,
|
|
@@ -22,21 +23,36 @@ function formatTime(iso) {
|
|
|
22
23
|
return iso;
|
|
23
24
|
}
|
|
24
25
|
}
|
|
26
|
+
// A YYYY-MM-DD date `n` months back, for hints that must stay inside the
|
|
27
|
+
// API's search window.
|
|
28
|
+
function monthsAgo(n) {
|
|
29
|
+
const d = new Date();
|
|
30
|
+
d.setUTCMonth(d.getUTCMonth() - n);
|
|
31
|
+
d.setUTCDate(d.getUTCDate() + 1);
|
|
32
|
+
return d.toISOString().slice(0, 10);
|
|
33
|
+
}
|
|
34
|
+
/** True when --since reaches further back than the API's 3-month window. */
|
|
35
|
+
export function beyondWindow(since, until) {
|
|
36
|
+
const end = until ? new Date(toIso(until, true)) : new Date();
|
|
37
|
+
const oldest = new Date(end);
|
|
38
|
+
oldest.setUTCMonth(oldest.getUTCMonth() - 3);
|
|
39
|
+
return new Date(toIso(since)).getTime() < oldest.getTime();
|
|
40
|
+
}
|
|
25
41
|
function firstOutput(item) {
|
|
26
42
|
const out = item.outputs?.[0];
|
|
27
43
|
if (typeof out === 'string')
|
|
28
|
-
return out;
|
|
44
|
+
return describeOutput(out);
|
|
29
45
|
return '';
|
|
30
46
|
}
|
|
31
47
|
export function registerHistory(program) {
|
|
32
48
|
program
|
|
33
49
|
.command('history')
|
|
34
|
-
.description('List your recent predictions (default: last 24h)')
|
|
35
|
-
.option('--page <n>', 'Page number', '1')
|
|
50
|
+
.description('List your recent predictions (default: last 24h; the API keeps only the last 3 months searchable)')
|
|
51
|
+
.option('--page <n>', 'Page number (the API serves at most page 100)', '1')
|
|
36
52
|
.option('--limit <n>', 'Page size (1-100)', '20')
|
|
37
53
|
.option('--model <id>', 'Filter by model ID')
|
|
38
54
|
.option('--status <s>', 'Filter by status (created, processing, completed, failed, cancelled, timeout, deleted)')
|
|
39
|
-
.option('--since <time>', 'Predictions created after this time (ISO or YYYY-MM-DD)')
|
|
55
|
+
.option('--since <time>', 'Predictions created after this time (ISO or YYYY-MM-DD; at most 3 months before --until)')
|
|
40
56
|
.option('--until <time>', 'Predictions created before this time (ISO or YYYY-MM-DD)')
|
|
41
57
|
.option('--json', 'Emit JSON: {page, items: [...]}')
|
|
42
58
|
.action(async (opts) => {
|
|
@@ -63,6 +79,10 @@ export function registerHistory(program) {
|
|
|
63
79
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
64
80
|
return;
|
|
65
81
|
}
|
|
82
|
+
if (opts.since && beyondWindow(opts.since, opts.until)) {
|
|
83
|
+
console.error(chalk.yellow('Note: ') +
|
|
84
|
+
chalk.gray('the API searches at most 3 months before --until (or now); earlier predictions are not listed.'));
|
|
85
|
+
}
|
|
66
86
|
const items = data.items ?? [];
|
|
67
87
|
console.log();
|
|
68
88
|
console.log(chalk.bold(`${items.length} prediction${items.length === 1 ? '' : 's'}`) +
|
|
@@ -72,7 +92,8 @@ export function registerHistory(program) {
|
|
|
72
92
|
(opts.since || opts.until ? '' : chalk.gray(' · last 24h (widen with --since)')));
|
|
73
93
|
console.log();
|
|
74
94
|
if (items.length === 0) {
|
|
75
|
-
console.log(chalk.gray(
|
|
95
|
+
console.log(chalk.gray(` no predictions in this range. Widen it with --since (e.g. --since ${monthsAgo(3)}); ` +
|
|
96
|
+
'the API searches at most the 3 months before --until, earlier dates are ignored.'));
|
|
76
97
|
console.log();
|
|
77
98
|
return;
|
|
78
99
|
}
|
package/dist/commands/media.js
CHANGED
|
@@ -86,7 +86,11 @@ async function execute(prompt, opts, build, media, rest) {
|
|
|
86
86
|
spinner.start();
|
|
87
87
|
let id;
|
|
88
88
|
try {
|
|
89
|
-
const submitted = await submitPrediction(plan.modelId, plan.input
|
|
89
|
+
const submitted = await submitPrediction(plan.modelId, plan.input, {
|
|
90
|
+
onRetry: (attempt, delayMs) => {
|
|
91
|
+
spinner.text = `Rate limited, retrying in ${Math.ceil(delayMs / 1000)}s (attempt ${attempt})…`;
|
|
92
|
+
},
|
|
93
|
+
});
|
|
90
94
|
id = submitted.id;
|
|
91
95
|
if (!id)
|
|
92
96
|
throw new Error(`No prediction ID in response: ${JSON.stringify(submitted)}`);
|
package/dist/commands/run.js
CHANGED
|
@@ -181,7 +181,13 @@ export function registerRun(program) {
|
|
|
181
181
|
const startedAt = Date.now();
|
|
182
182
|
let submitted;
|
|
183
183
|
try {
|
|
184
|
-
submitted = await submitPrediction(model, input, {
|
|
184
|
+
submitted = await submitPrediction(model, input, {
|
|
185
|
+
sync: !!opts.sync,
|
|
186
|
+
// 429 comes before any task exists, so it is safe to wait and resend.
|
|
187
|
+
onRetry: (attempt, delayMs) => {
|
|
188
|
+
spinner.text = `Rate limited, retrying in ${Math.ceil(delayMs / 1000)}s (attempt ${attempt})…`;
|
|
189
|
+
},
|
|
190
|
+
});
|
|
185
191
|
}
|
|
186
192
|
catch (err) {
|
|
187
193
|
const message = withInputSyntaxHint(err.message ?? String(err), opts.input);
|
package/dist/commands/show.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import ora from 'ora';
|
|
3
|
-
import { fetchPrediction } from '../lib/api.js';
|
|
3
|
+
import { fetchPrediction, inferenceMs, unavailableReason } from '../lib/api.js';
|
|
4
4
|
import { downloadOutputs, printOutputs } from '../lib/output.js';
|
|
5
5
|
import { downloadOptsFromFlag } from '../lib/download-flag.js';
|
|
6
6
|
function formatTime(iso) {
|
|
@@ -47,19 +47,25 @@ export function registerShow(program) {
|
|
|
47
47
|
return;
|
|
48
48
|
}
|
|
49
49
|
console.log();
|
|
50
|
-
console.log(chalk.bold('▶ ') + chalk.cyan(item.id));
|
|
51
|
-
console.log(' ' + chalk.gray('model: ') + chalk.white(item.model));
|
|
52
|
-
console.log(' ' + chalk.gray('status: ') + chalk.white(item.status));
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
console.log(chalk.bold('▶ ') + chalk.cyan(item.id || id));
|
|
51
|
+
console.log(' ' + chalk.gray('model: ') + chalk.white(item.model || '-'));
|
|
52
|
+
console.log(' ' + chalk.gray('status: ') + chalk.white(item.status || 'unavailable'));
|
|
53
|
+
if (item.created_at)
|
|
54
|
+
console.log(' ' + chalk.gray('created: ') + chalk.white(formatTime(item.created_at)));
|
|
55
|
+
const elapsed = inferenceMs(item);
|
|
56
|
+
if (elapsed !== undefined)
|
|
57
|
+
console.log(' ' + chalk.gray('elapsed: ') + chalk.white(`${elapsed}ms`));
|
|
57
58
|
const promptText = item.inputs?.prompt ??
|
|
58
59
|
item.input?.prompt;
|
|
59
60
|
if (promptText)
|
|
60
61
|
console.log(' ' + chalk.gray('prompt: ') + chalk.white(promptText));
|
|
62
|
+
// Archived records come back with no outputs (and, from older API
|
|
63
|
+
// versions, no status): say so instead of an unexplained blank.
|
|
64
|
+
const unavailable = unavailableReason(item);
|
|
61
65
|
if (item.error)
|
|
62
66
|
console.log(' ' + chalk.gray('error: ') + chalk.red(item.error));
|
|
67
|
+
else if (unavailable)
|
|
68
|
+
console.log(' ' + chalk.gray('note: ') + chalk.yellow(unavailable));
|
|
63
69
|
if (urls.length > 0)
|
|
64
70
|
printOutputs(saved, urls);
|
|
65
71
|
else
|
package/dist/lib/api.js
CHANGED
|
@@ -4,15 +4,20 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import os from 'node:os';
|
|
7
|
-
import { getApiKey, getBaseUrl } from './config.js';
|
|
7
|
+
import { callerCacheId, getApiKey, getBaseUrl } from './config.js';
|
|
8
8
|
import { clientAttributionHeaders } from './client-headers.js';
|
|
9
9
|
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// The catalog is per caller: the API serves internal users, paid
|
|
11
|
+
// organizations and everyone else different model lists. So the cache is one
|
|
12
|
+
// file per (base URL, API key), `models-<callerCacheId>.json`, shared with the
|
|
13
|
+
// MCP server (same name and format). The old shared `models.json` is left
|
|
14
|
+
// alone for older versions that still read it; this version never uses it.
|
|
15
|
+
export function modelsCacheFile(baseUrl, apiKey) {
|
|
16
|
+
return path.join(os.homedir(), '.cache', 'wavespeed', `models-${callerCacheId(baseUrl, apiKey)}.json`);
|
|
12
17
|
}
|
|
13
|
-
function readCache(baseUrl) {
|
|
18
|
+
function readCache(file, baseUrl) {
|
|
14
19
|
try {
|
|
15
|
-
const raw = fs.readFileSync(
|
|
20
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
16
21
|
const parsed = JSON.parse(raw);
|
|
17
22
|
if (parsed.base_url !== baseUrl)
|
|
18
23
|
return null;
|
|
@@ -24,11 +29,11 @@ function readCache(baseUrl) {
|
|
|
24
29
|
return null;
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
|
-
function writeCache(baseUrl, models) {
|
|
32
|
+
function writeCache(file, baseUrl, models) {
|
|
28
33
|
try {
|
|
29
|
-
fs.mkdirSync(path.dirname(
|
|
34
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
30
35
|
const body = { fetched_at: Date.now(), base_url: baseUrl, models };
|
|
31
|
-
fs.writeFileSync(
|
|
36
|
+
fs.writeFileSync(file, JSON.stringify(body));
|
|
32
37
|
}
|
|
33
38
|
catch {
|
|
34
39
|
/* best-effort cache; ignore failures */
|
|
@@ -36,14 +41,15 @@ function writeCache(baseUrl, models) {
|
|
|
36
41
|
}
|
|
37
42
|
export async function fetchModels(opts = {}) {
|
|
38
43
|
const baseUrl = getBaseUrl();
|
|
44
|
+
const apiKey = getApiKey();
|
|
45
|
+
if (!apiKey)
|
|
46
|
+
throw new Error('No API key configured. Run `wavespeed login`.');
|
|
47
|
+
const file = modelsCacheFile(baseUrl, apiKey);
|
|
39
48
|
if (!opts.noCache && !opts.refresh) {
|
|
40
|
-
const cached = readCache(baseUrl);
|
|
49
|
+
const cached = readCache(file, baseUrl);
|
|
41
50
|
if (cached)
|
|
42
51
|
return { models: cached, source: 'cache' };
|
|
43
52
|
}
|
|
44
|
-
const apiKey = getApiKey();
|
|
45
|
-
if (!apiKey)
|
|
46
|
-
throw new Error('No API key configured. Run `wavespeed login`.');
|
|
47
53
|
const res = await fetch(`${baseUrl}/api/v3/models`, {
|
|
48
54
|
headers: { Authorization: `Bearer ${apiKey}`, ...clientAttributionHeaders() },
|
|
49
55
|
signal: AbortSignal.timeout(GET_TIMEOUT_MS),
|
|
@@ -56,12 +62,16 @@ export async function fetchModels(opts = {}) {
|
|
|
56
62
|
throw new Error(json.message || `API returned code ${json.code}`);
|
|
57
63
|
}
|
|
58
64
|
if (!opts.noCache)
|
|
59
|
-
writeCache(baseUrl, json.data);
|
|
65
|
+
writeCache(file, baseUrl, json.data);
|
|
60
66
|
return { models: json.data, source: 'live' };
|
|
61
67
|
}
|
|
68
|
+
/** Removes the current key's catalog cache. */
|
|
62
69
|
export function clearModelsCache() {
|
|
70
|
+
const apiKey = getApiKey();
|
|
71
|
+
if (!apiKey)
|
|
72
|
+
return;
|
|
63
73
|
try {
|
|
64
|
-
fs.unlinkSync(
|
|
74
|
+
fs.unlinkSync(modelsCacheFile(getBaseUrl(), apiKey));
|
|
65
75
|
}
|
|
66
76
|
catch {
|
|
67
77
|
/* nothing to clear */
|
|
@@ -99,6 +109,9 @@ async function httpError(res, method, path) {
|
|
|
99
109
|
? new Error(detail)
|
|
100
110
|
: new Error(`${method} ${path} failed: ${res.status} ${res.statusText}`);
|
|
101
111
|
err.status = res.status;
|
|
112
|
+
const retryAfter = res.headers.get('retry-after');
|
|
113
|
+
if (retryAfter)
|
|
114
|
+
err.retryAfter = retryAfter;
|
|
102
115
|
return err;
|
|
103
116
|
}
|
|
104
117
|
async function apiGet(path) {
|
|
@@ -139,6 +152,37 @@ export async function fetchPricing(modelId, inputs) {
|
|
|
139
152
|
// legacy contract kept for old clients and absent from the public docs.
|
|
140
153
|
return apiPost('/api/v3/model/price', { model_id: modelId, inputs });
|
|
141
154
|
}
|
|
155
|
+
/** The run time in ms the API reports (`timings.inference`), or undefined when absent or zero. */
|
|
156
|
+
export function inferenceMs(item) {
|
|
157
|
+
const ms = item.timings?.inference;
|
|
158
|
+
return typeof ms === 'number' && ms > 0 ? ms : undefined;
|
|
159
|
+
}
|
|
160
|
+
// Statuses a prediction moves on from; anything else ends polling.
|
|
161
|
+
const IN_PROGRESS_STATUSES = new Set(['created', 'processing']);
|
|
162
|
+
const FAILED_STATUSES = new Set(['failed', 'cancelled', 'timeout', 'deleted']);
|
|
163
|
+
function hasOutputs(item) {
|
|
164
|
+
return Array.isArray(item.outputs) && item.outputs.length > 0;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Why a prediction record carries no usable result, or null when it does (or
|
|
168
|
+
* is still running, or failed with its own status). Covers records whose
|
|
169
|
+
* outputs are gone: an archived prediction comes back with an empty status
|
|
170
|
+
* (older API) or a completed status with no outputs and an error saying the
|
|
171
|
+
* outputs expired. The API's error text wins when it sent one.
|
|
172
|
+
*/
|
|
173
|
+
export function unavailableReason(item) {
|
|
174
|
+
const status = item.status ?? '';
|
|
175
|
+
if (status === 'completed') {
|
|
176
|
+
return !hasOutputs(item) && item.error ? item.error : null;
|
|
177
|
+
}
|
|
178
|
+
if (IN_PROGRESS_STATUSES.has(status) || FAILED_STATUSES.has(status))
|
|
179
|
+
return null;
|
|
180
|
+
if (item.error)
|
|
181
|
+
return item.error;
|
|
182
|
+
return status
|
|
183
|
+
? `Unexpected prediction status "${status}"; outputs are not available.`
|
|
184
|
+
: 'The outputs of this prediction have expired or are not available.';
|
|
185
|
+
}
|
|
142
186
|
export async function fetchHistory(query = {}) {
|
|
143
187
|
const now = new Date();
|
|
144
188
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
@@ -183,8 +227,31 @@ async function fetchPredictionWithRetry(id, retryDelayMs) {
|
|
|
183
227
|
}
|
|
184
228
|
}
|
|
185
229
|
}
|
|
230
|
+
// The API takes at most 100 ids per delete request.
|
|
231
|
+
export const DELETE_BATCH_SIZE = 100;
|
|
232
|
+
/**
|
|
233
|
+
* Delete predictions in batches of 100. The server soft-deletes only the
|
|
234
|
+
* caller organization's predictions that are not running and returns the ids
|
|
235
|
+
* it deleted, so the result reports those, not the ids asked for. A failed
|
|
236
|
+
* batch throws with the result so far attached as `partial`.
|
|
237
|
+
*/
|
|
186
238
|
export async function deletePredictions(ids) {
|
|
187
|
-
|
|
239
|
+
const unique = [...new Set(ids)];
|
|
240
|
+
const deleted = [];
|
|
241
|
+
for (let i = 0; i < unique.length; i += DELETE_BATCH_SIZE) {
|
|
242
|
+
const chunk = unique.slice(i, i + DELETE_BATCH_SIZE);
|
|
243
|
+
try {
|
|
244
|
+
const data = await apiPost('/api/v3/predictions/delete', { ids: chunk });
|
|
245
|
+
deleted.push(...(data?.deleted_ids ?? []));
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
const done = new Set(deleted);
|
|
249
|
+
err.partial = { deleted_ids: deleted, not_deleted_ids: unique.filter((id) => !done.has(id)) };
|
|
250
|
+
throw err;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const done = new Set(deleted);
|
|
254
|
+
return { deleted_ids: deleted, not_deleted_ids: unique.filter((id) => !done.has(id)) };
|
|
188
255
|
}
|
|
189
256
|
// Server-side prediction status enum (api-server internal/model/prediction.go).
|
|
190
257
|
// Validated client-side so `--status pending` fails with the real list instead
|
|
@@ -198,6 +265,18 @@ export const PREDICTION_STATUSES = [
|
|
|
198
265
|
'timeout',
|
|
199
266
|
'deleted',
|
|
200
267
|
];
|
|
268
|
+
// A submit is retried only on HTTP 429: the API's rate and concurrency
|
|
269
|
+
// limiters answer it before any prediction exists, so a retry cannot create a
|
|
270
|
+
// second task. Timeouts, network errors and 5xx are never retried — the task
|
|
271
|
+
// may already exist and be billed.
|
|
272
|
+
export const SUBMIT_429_RETRIES = 4;
|
|
273
|
+
const SUBMIT_RETRY_MAX_DELAY_MS = 10_000;
|
|
274
|
+
/** Wait before retry `attempt` (0-based): the server's Retry-After seconds when given, else 1s, 2s, 4s…; capped at 10s. */
|
|
275
|
+
export function submitRetryDelayMs(retryAfter, attempt) {
|
|
276
|
+
const seconds = retryAfter !== undefined && /^\s*\d+(\.\d+)?\s*$/.test(retryAfter) ? Number(retryAfter) : NaN;
|
|
277
|
+
const ms = Number.isFinite(seconds) ? seconds * 1000 : 1000 * 2 ** attempt;
|
|
278
|
+
return Math.min(ms, SUBMIT_RETRY_MAX_DELAY_MS);
|
|
279
|
+
}
|
|
201
280
|
/**
|
|
202
281
|
* Submit a prediction WITHOUT waiting for it. Split from polling so the
|
|
203
282
|
* caller can print the prediction ID the moment it exists — a Ctrl+C or
|
|
@@ -207,11 +286,26 @@ export async function submitPrediction(model, input, opts = {}) {
|
|
|
207
286
|
const body = { ...input };
|
|
208
287
|
if (opts.sync)
|
|
209
288
|
body.enable_sync_mode = true;
|
|
289
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
290
|
+
for (let attempt = 0;; attempt++) {
|
|
291
|
+
try {
|
|
292
|
+
return await submitOnce(model, body, !!opts.sync);
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
if (err?.status !== 429 || attempt >= SUBMIT_429_RETRIES)
|
|
296
|
+
throw err;
|
|
297
|
+
const delay = submitRetryDelayMs(err.retryAfter, attempt);
|
|
298
|
+
opts.onRetry?.(attempt + 1, delay);
|
|
299
|
+
await sleep(delay);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async function submitOnce(model, body, sync) {
|
|
210
304
|
// sync mode legitimately holds the request until the task finishes, so only
|
|
211
305
|
// async submits get the per-request timeout.
|
|
212
306
|
try {
|
|
213
307
|
return await apiPost(`/api/v3/${model}`, body, {
|
|
214
|
-
timeoutMs:
|
|
308
|
+
timeoutMs: sync ? undefined : SUBMIT_TIMEOUT_MS,
|
|
215
309
|
});
|
|
216
310
|
}
|
|
217
311
|
catch (err) {
|
|
@@ -225,16 +319,20 @@ export async function submitPrediction(model, input, opts = {}) {
|
|
|
225
319
|
/**
|
|
226
320
|
* Poll a prediction until it reaches a terminal status. `onTick` fires each
|
|
227
321
|
* round so the caller can keep a spinner honest. Transient status-read
|
|
228
|
-
* failures are retried
|
|
322
|
+
* failures are retried. An empty or unknown status, or a completed record
|
|
323
|
+
* whose outputs are gone, ends polling with an error instead of looping.
|
|
229
324
|
*/
|
|
230
325
|
export async function waitForPrediction(id, opts = {}) {
|
|
231
326
|
const interval = opts.intervalMs ?? 1000;
|
|
232
327
|
for (;;) {
|
|
233
328
|
const item = await fetchPredictionWithRetry(id, opts.retryDelayMs ?? 1000);
|
|
234
329
|
opts.onTick?.(item.status);
|
|
330
|
+
const unavailable = unavailableReason(item);
|
|
331
|
+
if (unavailable)
|
|
332
|
+
throw new Error(`${unavailable} (task_id: ${id})`);
|
|
235
333
|
if (item.status === 'completed')
|
|
236
334
|
return item;
|
|
237
|
-
if (
|
|
335
|
+
if (FAILED_STATUSES.has(item.status)) {
|
|
238
336
|
throw new Error(`Prediction ${item.status}${item.error ? `: ${item.error}` : ''} (task_id: ${id})`);
|
|
239
337
|
}
|
|
240
338
|
if (opts.signal?.cancelled)
|
package/dist/lib/config.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import Conf from 'conf';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
/**
|
|
6
|
+
* Names the caller's own cache files (catalog, uploads) in ~/.cache/wavespeed:
|
|
7
|
+
* the first 16 hex of sha256(base_url + "\n" + api_key). Catalogs and uploads
|
|
8
|
+
* differ per account, so two keys on one machine must not share a cache; only
|
|
9
|
+
* this hash is ever written, never the key. The MCP server (@wavespeed/mcp)
|
|
10
|
+
* uses the same scheme so both tools share a key's caches.
|
|
11
|
+
*/
|
|
12
|
+
export function callerCacheId(baseUrl, apiKey) {
|
|
13
|
+
return crypto.createHash('sha256').update(`${baseUrl}\n${apiKey ?? ''}`).digest('hex').slice(0, 16);
|
|
14
|
+
}
|
|
4
15
|
export const DEFAULTS = {
|
|
5
16
|
baseUrl: 'https://api.wavespeed.ai',
|
|
6
17
|
outputDir: 'wavespeed-output',
|
package/dist/lib/output.js
CHANGED
|
@@ -11,6 +11,73 @@ function extFromUrl(url, fallback) {
|
|
|
11
11
|
return m[1].toLowerCase();
|
|
12
12
|
return fallback;
|
|
13
13
|
}
|
|
14
|
+
// With enable_base64_output=true each URL output comes back as naked base64,
|
|
15
|
+
// but an output over 64 MiB or one the API failed to fetch stays a URL, and
|
|
16
|
+
// text/JSON outputs are unchanged — so the kind is decided per output.
|
|
17
|
+
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
18
|
+
const DATA_URI_RE = /^data:([^;,]*)(?:;[^,]*)?;base64,(.*)$/s;
|
|
19
|
+
export function isHttpUrl(value) {
|
|
20
|
+
return /^https?:\/\//i.test(value);
|
|
21
|
+
}
|
|
22
|
+
/** Naked base64 as returned for enable_base64_output (short strings are treated as text). */
|
|
23
|
+
export function isBase64Output(value) {
|
|
24
|
+
return value.length >= 64 && value.length % 4 === 0 && BASE64_RE.test(value);
|
|
25
|
+
}
|
|
26
|
+
const MAGIC = [
|
|
27
|
+
[[0x89, 0x50, 0x4e, 0x47], 'png'],
|
|
28
|
+
[[0xff, 0xd8, 0xff], 'jpg'],
|
|
29
|
+
[[0x47, 0x49, 0x46, 0x38], 'gif'],
|
|
30
|
+
[[0x1a, 0x45, 0xdf, 0xa3], 'webm'],
|
|
31
|
+
[[0x49, 0x44, 0x33], 'mp3'],
|
|
32
|
+
[[0x66, 0x4c, 0x61, 0x43], 'flac'],
|
|
33
|
+
[[0x4f, 0x67, 0x67, 0x53], 'ogg'],
|
|
34
|
+
[[0x67, 0x6c, 0x54, 0x46], 'glb'],
|
|
35
|
+
[[0x50, 0x4b, 0x03, 0x04], 'zip'],
|
|
36
|
+
[[0x25, 0x50, 0x44, 0x46], 'pdf'],
|
|
37
|
+
];
|
|
38
|
+
export function extFromBytes(buf, fallback) {
|
|
39
|
+
for (const [sig, ext] of MAGIC) {
|
|
40
|
+
if (sig.every((b, i) => buf[i] === b))
|
|
41
|
+
return ext;
|
|
42
|
+
}
|
|
43
|
+
const at = (o, s) => buf.subarray(o, o + s.length).toString('latin1') === s;
|
|
44
|
+
if (at(0, 'RIFF') && at(8, 'WEBP'))
|
|
45
|
+
return 'webp';
|
|
46
|
+
if (at(0, 'RIFF') && at(8, 'WAVE'))
|
|
47
|
+
return 'wav';
|
|
48
|
+
if (at(4, 'ftyp'))
|
|
49
|
+
return at(8, 'M4A') ? 'm4a' : at(8, 'qt') ? 'mov' : 'mp4';
|
|
50
|
+
if (buf.length >= 2 && buf[0] === 0xff && (buf[1] & 0xe0) === 0xe0)
|
|
51
|
+
return 'mp3';
|
|
52
|
+
return fallback;
|
|
53
|
+
}
|
|
54
|
+
const EXT_FROM_MIME = {
|
|
55
|
+
'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif',
|
|
56
|
+
'video/mp4': 'mp4', 'video/webm': 'webm', 'audio/mpeg': 'mp3', 'audio/wav': 'wav',
|
|
57
|
+
};
|
|
58
|
+
/** Bytes of an inline (base64 or data URI) output, or null when it is not inline. */
|
|
59
|
+
function inlineBytes(value, fallbackExt) {
|
|
60
|
+
const m = value.match(DATA_URI_RE);
|
|
61
|
+
if (m) {
|
|
62
|
+
const buf = Buffer.from(m[2], 'base64');
|
|
63
|
+
return { buf, ext: EXT_FROM_MIME[m[1].toLowerCase()] ?? extFromBytes(buf, fallbackExt) };
|
|
64
|
+
}
|
|
65
|
+
if (!isBase64Output(value))
|
|
66
|
+
return null;
|
|
67
|
+
const buf = Buffer.from(value, 'base64');
|
|
68
|
+
return { buf, ext: extFromBytes(buf, fallbackExt) };
|
|
69
|
+
}
|
|
70
|
+
/** Short label for an output: URLs as-is, inline data summarised instead of dumped. */
|
|
71
|
+
export function describeOutput(value) {
|
|
72
|
+
if (isHttpUrl(value))
|
|
73
|
+
return value;
|
|
74
|
+
const m = value.match(DATA_URI_RE);
|
|
75
|
+
const b64 = m ? m[2] : isBase64Output(value) ? value : null;
|
|
76
|
+
if (b64 === null)
|
|
77
|
+
return value;
|
|
78
|
+
const bytes = Math.floor((b64.length * 3) / 4);
|
|
79
|
+
return `(base64 data, ${bytes >= 1024 * 1024 ? (bytes / 1024 / 1024).toFixed(1) + ' MB' : Math.ceil(bytes / 1024) + ' KB'})`;
|
|
80
|
+
}
|
|
14
81
|
function slug(input) {
|
|
15
82
|
return input
|
|
16
83
|
.toLowerCase()
|
|
@@ -31,7 +98,8 @@ export async function downloadOutputs(urls, opts = {}) {
|
|
|
31
98
|
const saved = [];
|
|
32
99
|
for (let i = 0; i < urls.length; i++) {
|
|
33
100
|
const url = urls[i];
|
|
34
|
-
const
|
|
101
|
+
const inline = isHttpUrl(url) ? null : inlineBytes(url, opts.ext || 'bin');
|
|
102
|
+
const ext = inline ? inline.ext : extFromUrl(url, opts.ext || 'bin');
|
|
35
103
|
let filepath;
|
|
36
104
|
if (opts.template) {
|
|
37
105
|
const rendered = renderTemplate(opts.template, {
|
|
@@ -53,6 +121,14 @@ export async function downloadOutputs(urls, opts = {}) {
|
|
|
53
121
|
filepath = path.join(baseDir, `${ts}-${tag}${suffix}.${ext}`);
|
|
54
122
|
}
|
|
55
123
|
fs.mkdirSync(path.dirname(filepath), { recursive: true });
|
|
124
|
+
if (inline) {
|
|
125
|
+
fs.writeFileSync(filepath, inline.buf);
|
|
126
|
+
saved.push(filepath);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!isHttpUrl(url)) {
|
|
130
|
+
throw new Error(`Output ${i + 1} is neither a URL nor base64 data, so it cannot be downloaded: ${url.slice(0, 80)}`);
|
|
131
|
+
}
|
|
56
132
|
const res = await fetch(url);
|
|
57
133
|
if (!res.ok || !res.body) {
|
|
58
134
|
throw new Error(`Download failed for ${url}: ${res.status} ${res.statusText}`);
|
|
@@ -72,6 +148,6 @@ export function printOutputs(saved, remote) {
|
|
|
72
148
|
}
|
|
73
149
|
console.log(chalk.gray(saved.length ? 'Source URLs:' : 'URLs:'));
|
|
74
150
|
for (const u of remote)
|
|
75
|
-
console.log(' ' + chalk.cyan(u));
|
|
151
|
+
console.log(' ' + chalk.cyan(describeOutput(u)));
|
|
76
152
|
console.log();
|
|
77
153
|
}
|
package/dist/lib/time.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The predictions-search API is strict RFC3339, so bare dates the CLI's own
|
|
2
|
-
// hints suggest ("--since 2026-
|
|
2
|
+
// hints suggest ("--since 2026-09-01") must be widened here, not rejected
|
|
3
3
|
// there. Shared by history, billings, and usage so every time flag accepts
|
|
4
4
|
// the same two forms: full ISO 8601 or YYYY-MM-DD.
|
|
5
5
|
/** Accepts ISO 8601 or YYYY-MM-DD; returns RFC3339. Day-only values span the
|
package/dist/lib/upload-cache.js
CHANGED
|
@@ -7,26 +7,33 @@
|
|
|
7
7
|
//
|
|
8
8
|
// TTL is 24h: hosted media does not expire that fast, but a bounded window
|
|
9
9
|
// keeps the cache from ever serving a URL the platform has since removed.
|
|
10
|
+
//
|
|
11
|
+
// One file per caller, `uploads-<callerCacheId>.json` (a hash of base URL +
|
|
12
|
+
// API key, never the key itself): an upload belongs to the account that made
|
|
13
|
+
// it, so another key on the same machine must not reuse it. Same name and
|
|
14
|
+
// format as the MCP server, so both tools share a key's uploads. The old
|
|
15
|
+
// shared `uploads.json` is left alone for older versions.
|
|
10
16
|
import fs from 'node:fs';
|
|
11
17
|
import path from 'node:path';
|
|
12
18
|
import os from 'node:os';
|
|
13
19
|
import crypto from 'node:crypto';
|
|
20
|
+
import { callerCacheId, getApiKey, getBaseUrl } from './config.js';
|
|
14
21
|
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
15
|
-
function
|
|
16
|
-
return path.join(os.homedir(), '.cache', 'wavespeed',
|
|
22
|
+
export function uploadCacheFile(cacheId = callerCacheId(getBaseUrl(), getApiKey())) {
|
|
23
|
+
return path.join(os.homedir(), '.cache', 'wavespeed', `uploads-${cacheId}.json`);
|
|
17
24
|
}
|
|
18
|
-
function readCache() {
|
|
25
|
+
function readCache(file) {
|
|
19
26
|
try {
|
|
20
|
-
return JSON.parse(fs.readFileSync(
|
|
27
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
21
28
|
}
|
|
22
29
|
catch {
|
|
23
30
|
return {};
|
|
24
31
|
}
|
|
25
32
|
}
|
|
26
|
-
function writeCache(entries) {
|
|
33
|
+
function writeCache(file, entries) {
|
|
27
34
|
try {
|
|
28
|
-
fs.mkdirSync(path.dirname(
|
|
29
|
-
fs.writeFileSync(
|
|
35
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
36
|
+
fs.writeFileSync(file, JSON.stringify(entries));
|
|
30
37
|
}
|
|
31
38
|
catch {
|
|
32
39
|
/* best-effort; a failed cache write must never fail the upload */
|
|
@@ -41,7 +48,8 @@ export function hashFile(filePath) {
|
|
|
41
48
|
*/
|
|
42
49
|
export async function uploadWithCache(filePath, doUpload) {
|
|
43
50
|
const key = hashFile(filePath);
|
|
44
|
-
const
|
|
51
|
+
const file = uploadCacheFile();
|
|
52
|
+
const entries = readCache(file);
|
|
45
53
|
const hit = entries[key];
|
|
46
54
|
if (hit && Date.now() - hit.uploaded_at < TTL_MS) {
|
|
47
55
|
return { url: hit.url, cached: true };
|
|
@@ -53,6 +61,6 @@ export async function uploadWithCache(filePath, doUpload) {
|
|
|
53
61
|
if (Date.now() - v.uploaded_at >= TTL_MS)
|
|
54
62
|
delete entries[k];
|
|
55
63
|
}
|
|
56
|
-
writeCache(entries);
|
|
64
|
+
writeCache(file, entries);
|
|
57
65
|
return { url, cached: false };
|
|
58
66
|
}
|
|
@@ -52,6 +52,18 @@ export function requestSchemaAliases(model) {
|
|
|
52
52
|
}
|
|
53
53
|
return aliases;
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Request controls the API accepts for every model without any schema
|
|
57
|
+
* declaring them: `enable_sync_mode` and `enable_base64_output` survive the
|
|
58
|
+
* API's unknown-field whitelist, and `enable_content_filter` is read (and must
|
|
59
|
+
* be a boolean) before that whitelist runs. Rejecting them as typos would block
|
|
60
|
+
* valid requests.
|
|
61
|
+
*/
|
|
62
|
+
export const GLOBAL_CONTROL_INPUTS = new Set([
|
|
63
|
+
'enable_sync_mode',
|
|
64
|
+
'enable_base64_output',
|
|
65
|
+
'enable_content_filter',
|
|
66
|
+
]);
|
|
55
67
|
// Small edit distance for typo hints — inputs are short snake_case tokens, so
|
|
56
68
|
// a full Levenshtein over ≤40 keys is nothing.
|
|
57
69
|
function editDistance(a, b) {
|
|
@@ -86,12 +98,12 @@ export function findUnknownInputs(input, model) {
|
|
|
86
98
|
return null;
|
|
87
99
|
const knownSet = new Set(known);
|
|
88
100
|
const aliases = requestSchemaAliases(model);
|
|
89
|
-
const unknown = Object.keys(input).filter((k) => !knownSet.has(k) && !aliases.has(k));
|
|
101
|
+
const unknown = Object.keys(input).filter((k) => !knownSet.has(k) && !aliases.has(k) && !GLOBAL_CONTROL_INPUTS.has(k));
|
|
90
102
|
if (unknown.length === 0)
|
|
91
103
|
return null;
|
|
92
104
|
const suggestions = new Map();
|
|
93
105
|
for (const k of unknown) {
|
|
94
|
-
const hit = closest(k, known);
|
|
106
|
+
const hit = closest(k, [...known, ...GLOBAL_CONTROL_INPUTS]);
|
|
95
107
|
if (hit)
|
|
96
108
|
suggestions.set(k, hit);
|
|
97
109
|
}
|
package/package.json
CHANGED
package/skills/SKILL.md
CHANGED
|
@@ -94,4 +94,4 @@ If `wavespeed.json` exists (created by `wavespeed init`):
|
|
|
94
94
|
- Don't invent model IDs. Confirm with `wavespeed models` or `wavespeed schema <id>` (MCP: `list_models`, `get_model_schema`) before a generic run.
|
|
95
95
|
- Local files: use `@./file.jpg`. Bare paths are NOT uploaded and the model will reject them. The hosted MCP server cannot read local files at all.
|
|
96
96
|
- `wavespeed delete` requires `--yes` when run non-interactively (that includes you).
|
|
97
|
-
- A run that fails with a gateway or network error (502/503/504, connection reset, timeout) does NOT mean the task failed, and the submit request is never retried automatically, so re-running can create a duplicate paid task. Do not re-run immediately. If the error contains a task id (`task_id` / prediction id), keep checking it with `wavespeed show <id> --json` (MCP: `get_prediction`) until its status is terminal (`completed`, `failed`, `cancelled`, `timeout`, `deleted`). If no id was returned, check recent predictions with `wavespeed history --json` (MCP: `list_predictions`; add the model; default window is the last 24h) and re-run once only if none matches. A client-side wait timeout or an interrupted run does not mean the task failed: tasks can run for a long time, so do not give up after a fixed time.
|
|
97
|
+
- A run that fails with a gateway or network error (502/503/504, connection reset, timeout) does NOT mean the task failed, and the submit request is never retried automatically on those errors (only an HTTP 429 rate limit, which the API returns before any task exists, is retried), so re-running can create a duplicate paid task. Do not re-run immediately. If the error contains a task id (`task_id` / prediction id), keep checking it with `wavespeed show <id> --json` (MCP: `get_prediction`) until its status is terminal (`completed`, `failed`, `cancelled`, `timeout`, `deleted`). If no id was returned, check recent predictions with `wavespeed history --json` (MCP: `list_predictions`; add the model; default window is the last 24h) and re-run once only if none matches. A client-side wait timeout or an interrupted run does not mean the task failed: tasks can run for a long time, so do not give up after a fixed time.
|