@wavespeed/cli 0.4.24 → 0.4.26

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 CHANGED
@@ -42,6 +42,11 @@ npm install -g @wavespeed/cli
42
42
  # friendlier error path)
43
43
  curl -fsSL https://raw.githubusercontent.com/WaveSpeedAI/wavespeed-cli/main/install.sh | bash
44
44
 
45
+ # macOS / Linux with Homebrew (standalone executable, no Node.js needed)
46
+ brew tap wavespeedai/tap
47
+ brew trust wavespeedai/tap # recent Homebrew refuses formulae from an untrusted tap
48
+ brew install wavespeed
49
+
45
50
  # no Node.js/npm? install.sh falls back to a standalone executable on macOS/Linux;
46
51
  # on Windows (PowerShell):
47
52
  irm https://raw.githubusercontent.com/WaveSpeedAI/wavespeed-cli/main/install.ps1 | iex
@@ -103,28 +108,34 @@ Resolution: positional with `/` is a model ID; otherwise looked up as an alias.
103
108
 
104
109
  ## Recommended starting models
105
110
 
106
- | Use case | Model |
107
- |---|---|
108
- | Text → image | `bytedance/seedream-v5.0-pro` |
109
- | Image edit | `bytedance/seedream-v5.0-pro/edit` |
110
- | Text → video | `wavespeed-ai/minimax-h3/text-to-video` |
111
- | Image → video | `wavespeed-ai/minimax-h3/image-to-video` |
112
- | Video edit | `wavespeed-ai/minimax-h3/video-edit` |
113
- | Video extend | `wavespeed-ai/minimax-h3/video-extend` |
111
+ For plain images and videos, the shortcuts pick the endpoint for you and use the same models as the [WaveSpeed /chat](https://wavespeed.ai/chat) page:
112
+
113
+ ```bash
114
+ wavespeed generate image "a cyberpunk skyline at golden hour" --json
115
+ wavespeed edit image "replace the background with a sunlit kitchen" --image @./photo.jpg --json
116
+ wavespeed generate video "slow push-in on a lighthouse at dusk" --estimate --json # price first, nothing submitted
117
+ wavespeed edit video "make it snow" --video @./clip.mp4 --json
118
+ wavespeed extend video "the camera keeps rising" --video @./clip.mp4 --json
119
+ ```
120
+
121
+ | | Default | Alternatives (`-m`) |
122
+ |---|---|---|
123
+ | Image | `seedream-5-pro` | `gpt-image-2.5`, `nano-banana-2.1`, `nano-banana-pro`, `nano-banana-2`, `gpt-image-2` |
124
+ | Video | `seedance-2.5` | `seedance-2.5-turbo`, `kling-4.0`, `wan-3.0`, `wan-3.0-prime`, `minimax-h3` (cheap and fast, native stereo audio, 768p by default), Seedance 2.0 tiers |
114
125
 
115
- MiniMax H3 is the open-weights default: cheap, fast, and native stereo audio — the best place to start. When you need the highest quality, switch to `bytedance/seedance-2.5/*` (text-to-video, image-to-video, video-edit, video-extend). Browse alternatives with `wavespeed models <query>`.
126
+ For everything else (audio, 3D, upscaling, a specific endpoint), find the model with `wavespeed models <query>` and call it with `wavespeed run <model-id>`.
116
127
 
117
128
  ## Use from coding agents
118
129
 
119
- No MCP server, no daemon. The CLI **is** the interface — agents already know how to run shell commands.
130
+ The CLI needs no MCP server or daemon: agents already know how to run shell commands. If your agent supports MCP, the [WaveSpeed MCP server](https://github.com/WaveSpeedAI/mcp-server) offers the same actions as tools, and the skill covers both.
120
131
 
121
132
  ```bash
122
133
  wavespeed skill install # writes .claude/skills/wavespeed/SKILL.md and .agents/skills/wavespeed/SKILL.md
123
134
  ```
124
135
 
125
- The skill teaches the agent 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.
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.
126
137
 
127
- **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)).
128
139
 
129
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)).
130
141
 
@@ -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 and treat anything that isn't 401/403 as "key works".
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
- console.log(chalk.gray(' Double-check at ') + chalk.cyan(KEY_URL));
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);
@@ -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 <uuid>', 'Filter by 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
  }
@@ -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: [...ids]}')
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
- try {
35
- await deletePredictions(ids);
36
- spinner?.succeed(`Deleted ${ids.length} prediction${ids.length === 1 ? '' : 's'}.`);
37
- if (opts.json) {
38
- process.stdout.write(JSON.stringify({ deleted: ids }, null, 2) + '\n');
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
- spinner?.fail(err.message ?? String(err));
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: err.message ?? String(err) }, null, 2) + '\n');
73
+ process.stdout.write(JSON.stringify({ error: message, ...(partial ? payload(partial) : {}) }, null, 2) + '\n');
45
74
  }
46
75
  process.exit(1);
47
76
  }
@@ -22,6 +22,21 @@ function formatTime(iso) {
22
22
  return iso;
23
23
  }
24
24
  }
25
+ // A YYYY-MM-DD date `n` months back, for hints that must stay inside the
26
+ // API's search window.
27
+ function monthsAgo(n) {
28
+ const d = new Date();
29
+ d.setUTCMonth(d.getUTCMonth() - n);
30
+ d.setUTCDate(d.getUTCDate() + 1);
31
+ return d.toISOString().slice(0, 10);
32
+ }
33
+ /** True when --since reaches further back than the API's 3-month window. */
34
+ export function beyondWindow(since, until) {
35
+ const end = until ? new Date(toIso(until, true)) : new Date();
36
+ const oldest = new Date(end);
37
+ oldest.setUTCMonth(oldest.getUTCMonth() - 3);
38
+ return new Date(toIso(since)).getTime() < oldest.getTime();
39
+ }
25
40
  function firstOutput(item) {
26
41
  const out = item.outputs?.[0];
27
42
  if (typeof out === 'string')
@@ -31,12 +46,12 @@ function firstOutput(item) {
31
46
  export function registerHistory(program) {
32
47
  program
33
48
  .command('history')
34
- .description('List your recent predictions (default: last 24h)')
35
- .option('--page <n>', 'Page number', '1')
49
+ .description('List your recent predictions (default: last 24h; the API keeps only the last 3 months searchable)')
50
+ .option('--page <n>', 'Page number (the API serves at most page 100)', '1')
36
51
  .option('--limit <n>', 'Page size (1-100)', '20')
37
52
  .option('--model <id>', 'Filter by model ID')
38
53
  .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)')
54
+ .option('--since <time>', 'Predictions created after this time (ISO or YYYY-MM-DD; at most 3 months before --until)')
40
55
  .option('--until <time>', 'Predictions created before this time (ISO or YYYY-MM-DD)')
41
56
  .option('--json', 'Emit JSON: {page, items: [...]}')
42
57
  .action(async (opts) => {
@@ -63,6 +78,10 @@ export function registerHistory(program) {
63
78
  process.stdout.write(JSON.stringify(data, null, 2) + '\n');
64
79
  return;
65
80
  }
81
+ if (opts.since && beyondWindow(opts.since, opts.until)) {
82
+ console.error(chalk.yellow('Note: ') +
83
+ chalk.gray('the API searches at most 3 months before --until (or now); earlier predictions are not listed.'));
84
+ }
66
85
  const items = data.items ?? [];
67
86
  console.log();
68
87
  console.log(chalk.bold(`${items.length} prediction${items.length === 1 ? '' : 's'}`) +
@@ -72,7 +91,8 @@ export function registerHistory(program) {
72
91
  (opts.since || opts.until ? '' : chalk.gray(' · last 24h (widen with --since)')));
73
92
  console.log();
74
93
  if (items.length === 0) {
75
- console.log(chalk.gray(' no predictions in this range. Try --since "2026-01-01" for a wider window.'));
94
+ console.log(chalk.gray(` no predictions in this range. Widen it with --since (e.g. --since ${monthsAgo(3)}); ` +
95
+ 'the API searches at most the 3 months before --until, earlier dates are ignored.'));
76
96
  console.log();
77
97
  return;
78
98
  }
@@ -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)}`);
@@ -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, { sync: !!opts.sync });
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);
@@ -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
- console.log(' ' + chalk.gray('created: ') + chalk.white(formatTime(item.created_at)));
54
- if (item.executionTime !== undefined && item.executionTime > 0) {
55
- console.log(' ' + chalk.gray('elapsed: ') + chalk.white(`${item.executionTime}ms`));
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
- function cachePath() {
11
- return path.join(os.homedir(), '.cache', 'wavespeed', 'models.json');
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(cachePath(), 'utf8');
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(cachePath()), { recursive: true });
34
+ fs.mkdirSync(path.dirname(file), { recursive: true });
30
35
  const body = { fetched_at: Date.now(), base_url: baseUrl, models };
31
- fs.writeFileSync(cachePath(), JSON.stringify(body));
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(cachePath());
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
- await apiPost('/api/v3/predictions/delete', { ids });
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: opts.sync ? undefined : SUBMIT_TIMEOUT_MS,
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; submit is never 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 (item.status === 'failed' || item.status === 'cancelled' || item.status === 'timeout' || item.status === 'deleted') {
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)
@@ -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/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-01-01") must be widened here, not rejected
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
@@ -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 cacheFile() {
16
- return path.join(os.homedir(), '.cache', 'wavespeed', 'uploads.json');
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(cacheFile(), 'utf8'));
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(cacheFile()), { recursive: true });
29
- fs.writeFileSync(cacheFile(), JSON.stringify(entries));
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 entries = readCache();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavespeed/cli",
3
- "version": "0.4.24",
3
+ "version": "0.4.26",
4
4
  "description": "WaveSpeed CLI — one command to run any WaveSpeed AI model from your terminal.",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/skills/SKILL.md CHANGED
@@ -1,86 +1,97 @@
1
1
  ---
2
2
  name: wavespeed
3
- description: Generate or edit AI media (image, video, audio, 3D) by calling the wavespeed CLI on the user's machine. Use whenever the user asks to create, edit, animate, upscale, or transform a visual asset, generate audio/TTS/music, or produce marketing creatives. Every model on the WaveSpeed platform is one `wavespeed run <id>` call.
3
+ description: Generate or edit AI media (image, video, audio, 3D) on WaveSpeed, through the WaveSpeed MCP tools when they are connected or the wavespeed CLI on the user's machine. Use whenever the user asks to create, edit, animate, extend, upscale, or transform an image or video, generate audio/TTS/music or 3D, or produce marketing creatives.
4
4
  ---
5
5
 
6
6
  # WaveSpeed
7
7
 
8
- You have access to the `wavespeed` CLI. Every generation flows through one generic verb, `wavespeed run`, and the model id is always explicit. For plain image and video work there are also shortcut commands (see the end).
8
+ WaveSpeed runs every model on its platform. There are two ways in, and they do the same things:
9
9
 
10
- ## The three-step pattern
10
+ - **MCP tools** (`generate_image`, `generate_video`, …): use them when the WaveSpeed MCP server is connected. The hosted server cannot read local files: pass public URLs, or use the CLI for files on disk.
11
+ - **The `wavespeed` CLI**: use it when it is installed, for local files (`@./path`), batches and scripts.
11
12
 
12
- ```bash
13
- # 1. FIND a model — search the live catalog
14
- wavespeed models "seedream"
15
- wavespeed models --type image-to-video --popular
13
+ For plain image and video work, use the shortcuts. For anything else (audio, 3D, upscaling, a specific endpoint, exact parameters), use the generic run.
16
14
 
17
- # 2. INSPECT its inputs — dynamic schema, per model
18
- wavespeed run bytedance/seedream-v5.0-pro -h
15
+ ## Images and videos: the shortcuts
19
16
 
20
- # 3. RUN it — always pass --json so you can read the result
21
- wavespeed run bytedance/seedream-v5.0-pro \
22
- -p "a cyberpunk skyline at golden hour" \
23
- -i aspect_ratio="16:9" -i resolution="2k" --json
24
- ```
17
+ | Task | CLI | MCP tool |
18
+ |---|---|---|
19
+ | Image from a prompt (optionally with reference pictures) | `wavespeed generate image "<prompt>" [--image @./ref.jpg]` | `generate_image` |
20
+ | Edit, restyle, combine images | `wavespeed edit image "<prompt>" --image @./photo.jpg` | `edit_image` |
21
+ | Video from a prompt, a first/last frame, or references | `wavespeed generate video "<prompt>" [--first-frame …] [--ref-image …]` | `generate_video` |
22
+ | Edit a video | `wavespeed edit video "<prompt>" --video @./clip.mp4` | `edit_video` |
23
+ | Continue a video | `wavespeed extend video "<prompt>" --video @./clip.mp4` | `extend_video` |
24
+
25
+ They use the same models as the WaveSpeed /chat page and pick the endpoint (text, image, reference, edit) from the inputs you give. In the prompt, refer to media as `@image 1`, `@video 1`, `@audio 1`, in the order given ("put the product in @image 1 into the kitchen in @image 2").
26
+
27
+ - **Price first for videos:** CLI `--estimate --json`, MCP `estimate_only: true`. It returns the endpoint, the exact request and the price, and submits nothing.
28
+ - **Models:** pass `-m` / `model` only when the user names one; a named model is never switched. Without it the default is used, or another model only when the default cannot take the inputs.
29
+ - **Options:** `--aspect-ratio`, `--resolution`, `--duration` (CLI) or the same fields (MCP). Set them only when the user asks; values are snapped to what the model supports, and every change is reported.
30
+ - CLI: add `--json` to read the result, `--download` to save files.
25
31
 
26
- `run --json` returns `{ id, model, prompt, outputs: [url, ...], saved: [path, ...], elapsed_ms, raw }`. Keep `id` — it is the handle for `wavespeed show <id>` if anything is interrupted. Use the URL when the user wants a link. Add `--download` if they need bytes on disk.
32
+ ### Default models
27
33
 
28
- ## Recommended defaults
34
+ | | Default | Alternatives (`-m` / `model`) |
35
+ |---|---|---|
36
+ | Image | `seedream-5-pro` (Seedream 5 Pro) | `gpt-image-2.5` (GPT Image 2.5, the model behind ChatGPT Images; strong at text and photorealism), `nano-banana-2.1`, `nano-banana-pro`, `nano-banana-2`, `gpt-image-2` |
37
+ | Video | `seedance-2.5` (Seedance 2.5) | `seedance-2.5-turbo`, `kling-4.0`, `wan-3.0`, `wan-3.0-prime`, `minimax-h3` (cheap and fast, native stereo audio; defaults to 768p, its counterpart of 720p), older Seedance 2.0 tiers |
29
38
 
30
- | Use case | Model |
31
- |---|---|
32
- | Text → image | `bytedance/seedream-v5.0-pro` |
33
- | Image edit (instruction-driven) | `bytedance/seedream-v5.0-pro/edit` — requires `images: [url, ...]` |
34
- | Text → video | `wavespeed-ai/minimax-h3/text-to-video` |
35
- | Image → video | `wavespeed-ai/minimax-h3/image-to-video` — requires `image: url` |
36
- | Video edit (instruction-driven) | `wavespeed-ai/minimax-h3/video-edit` — requires `video: url` |
37
- | Video extend | `wavespeed-ai/minimax-h3/video-extend` — requires `video: url` |
39
+ Video resolution is 720p unless the user asks for another (MiniMax H3: 768p). Offer 480p for a cheaper draft.
38
40
 
39
- These are good starting points. MiniMax H3 is the open-weights default: cheap, fast, and native stereo audio — the best place to start. When you need the highest quality, switch to `bytedance/seedance-2.5/*` (text-to-video, image-to-video, video-edit, video-extend). Browse alternatives with `wavespeed models <query>`.
41
+ ## Everything else: the generic run
40
42
 
41
- ## Common recipes
43
+ Every model on the platform is one `wavespeed run <model-id>` (MCP: `run_model`). The model id is always explicit.
42
44
 
43
45
  ```bash
44
- # Edit an existing image — @path uploads the file and passes its URL (one step)
45
- wavespeed run bytedance/seedream-v5.0-pro/edit \
46
- -p "replace the background with a sunlit kitchen" \
47
- -i images='["@./input.jpg"]' --json
46
+ # 1. FIND a model in the live catalog (MCP: list_models)
47
+ wavespeed models "upscale"
48
+ wavespeed models --type text-to-audio --popular
48
49
 
49
- # Image-to-video — same @ marker for single-URL fields
50
- wavespeed run wavespeed-ai/minimax-h3/image-to-video \
51
- -p "subtle parallax, gentle wind" \
52
- -i image=@./hero.jpg -i duration=5 --json
50
+ # 2. INSPECT its inputs (MCP: get_model_schema)
51
+ wavespeed run <model-id> -h
53
52
 
54
- # Or upload separately when you need the URL itself
55
- URL=$(wavespeed upload ./hero.jpg --json | jq -r .url)
53
+ # 3. RUN it, with --json so you can read the result (MCP: run_model)
54
+ wavespeed run <model-id> -p "..." -i key=value --json
55
+ ```
56
+
57
+ `run --json` returns `{ id, model, prompt, outputs: [url, ...], saved: [path, ...], elapsed_ms, raw }`. Keep `id`: it is the handle for `wavespeed show <id>` (MCP: `get_prediction`) if anything is interrupted. Use the URL when the user wants a link; add `--download` when they need the file on disk.
58
+
59
+ ```bash
60
+ # Local files: @path uploads the file and passes its URL
61
+ wavespeed run <model-id> -i image=@./photo.jpg --json
62
+ wavespeed run <model-id> -i images='["@./a.jpg","@./b.jpg"]' --json
56
63
 
57
- # Save outputs locally with a template
58
- wavespeed run ... -p "..." --download "./out/{index}.{ext}"
64
+ # Or upload separately when you need the URL itself (MCP: upload_file / create_upload)
65
+ URL=$(wavespeed upload ./photo.jpg --json | jq -r .url)
66
+
67
+ # Save outputs with a template
68
+ wavespeed run <model-id> -p "..." --download "./out/{index}.{ext}"
59
69
  ```
60
70
 
71
+ The generic run never changes the user's prompt or inputs. The single exception is explicit: an `@path` value uploads that file and substitutes its hosted URL. Bare paths are never uploaded.
72
+
61
73
  ## Project config and aliases
62
74
 
63
75
  If `wavespeed.json` exists (created by `wavespeed init`):
64
76
 
65
- - **`defaultModel`** — lets `wavespeed run -p "…"` (no model arg) work.
66
- - **Aliases** — named shortcuts that bundle model + default inputs. Run `wavespeed aliases` to see what's defined. `wavespeed run <alias> -h` shows the resolved schema. CLI `-i k=v` overrides alias defaults.
67
-
68
- The CLI never modifies the user's prompt or inputs. The single exception is explicit: an `@path` value uploads that file and substitutes its hosted URL. Bare paths are never uploaded.
77
+ - **`defaultModel`** lets `wavespeed run -p "…"` (no model arg) work.
78
+ - **Aliases** are named shortcuts that bundle model + default inputs. Run `wavespeed aliases` to see them; `wavespeed run <alias> -h` shows the resolved schema. CLI `-i k=v` overrides alias defaults.
69
79
 
70
80
  ## Auth
71
81
 
72
- `wavespeed status` shows whether the user is signed in. If not, ask them to run `wavespeed login` (opens https://wavespeed.ai/accesskey). **Never** ask the user to paste an API key into the chat — the CLI handles it.
82
+ - CLI: `wavespeed status` shows whether the user is signed in. If not, ask them to run `wavespeed login` (opens https://wavespeed.ai/accesskey).
83
+ - MCP: if the tools ask for authorization, the user approves the connection once (in Claude Code: `/mcp`, pick `wavespeed`).
84
+ - **Never** ask the user to paste an API key into the chat.
73
85
 
74
- ## Pitfalls
86
+ ## Cost and credits
75
87
 
76
- - Local files: use `@./file.jpg` in `-i` values. Bare paths are NOT uploaded and the model will reject them.
77
- - Don't invent model IDs. Always confirm via `wavespeed models` or `wavespeed schema <id>` before running.
78
- - Use `--json` on every `run` so you can read `outputs[0]` programmatically.
79
- - `wavespeed delete` requires `--yes` when run non-interactively (that includes you).
80
- - Cost and spend: `wavespeed price <id> -i k=v --json` estimates a run without generating or charging; `wavespeed balance` shows remaining credit; `wavespeed usage` (totals, per-model) and `wavespeed billings` (per-charge records) show what was spent.
81
- - If a run fails with `Insufficient credits` (or says a top-up is required), the balance is too low: do not retry. Tell the user, and give them https://wavespeed.ai/top-up to add credits (`wavespeed balance` shows the balance and the link). You cannot pay for them.
82
- - 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` until its status is terminal (`completed`, `failed`, `cancelled`, `timeout`, `deleted`). If no id was returned, check recent predictions with `wavespeed history --json` (add `--model <id>`; 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.
88
+ - `wavespeed price <id> -i k=v --json` (MCP: `get_price`) estimates a run without generating or charging. The shortcuts have `--estimate` / `estimate_only`.
89
+ - `wavespeed balance` (MCP: `get_balance`) shows the remaining credit; `wavespeed usage` and `wavespeed billings` show what was spent.
90
+ - If a run fails with `Insufficient credits` (or says a top-up is required), the balance is too low: do not retry. Tell the user and give them https://wavespeed.ai/top-up. You cannot pay for them.
83
91
 
84
- ## Shortcuts for common image/video work
92
+ ## Pitfalls
85
93
 
86
- `wavespeed generate image|video`, `wavespeed edit image|video`, `wavespeed extend video` use the WaveSpeed /chat models and choose the endpoint from the inputs (`--image`, `--first-frame`, `--ref-image/--ref-video/--ref-audio`, `--video`; `@./path` uploads). Pass `-m` only when the user names a model. Run with `--estimate --json` first for videos; the request may be adapted to the model and the changes are printed. Use `wavespeed run <model>` for exact control.
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
+ - 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
+ - `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 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.