@wavespeed/cli 0.3.0 → 0.4.1

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
@@ -90,9 +90,9 @@ wavespeed run ... -p "..." --json | jq '.outputs[0]'
90
90
  - **`defaultModel`** — `wavespeed run -p "…"` (no model arg) uses this. Can itself be an alias name.
91
91
  - **`aliases`** — your own shortcuts. `wavespeed run hero -p "…"` expands to model + input. CLI `-i k=v` flags override. `wavespeed run hero -h` shows the resolved schema. List them with `wavespeed aliases`.
92
92
 
93
- Resolution: positional with `/` is a model ID; otherwise looked up as an alias. Merge order on inputs: `alias.input` → `--input-file` → `-i k=v` → `-p`. **The CLI never rewrites your prompt or input values**, with one explicit exception: local file paths become hosted URLs.
93
+ Resolution: positional with `/` is a model ID; otherwise looked up as an alias. Merge order on inputs: `alias.input` → `--input-file` → `-i k=v` → `-p`. **The CLI never rewrites your prompt or input values.** The one way to hand it a local file is the explicit `@` marker below — bare paths are passed through untouched, never uploaded.
94
94
 
95
- **Local files as inputs.** `-i image=./cat.png` uploads the file first and submits its URL — no separate `wavespeed upload` step. A bare value is only treated as a file when it has a media extension (image/video/audio) *and* exists on disk; prompt fields never auto-detect. Prefix with `@` (`-i image=@./cat.png`) to force an upload of any file — then a missing file is an error instead of a pass-through. The same file referenced twice uploads once.
95
+ **Local files as inputs.** `-i image=@./cat.png` (curl-style) uploads the file and submits its hosted URL — no separate `wavespeed upload` step. Only `@`-prefixed values upload; a missing `@` file is an error. Identical bytes are content-hash cached for 24h, so repeated runs (and `price --upload`) reuse one upload and spare your quota.
96
96
 
97
97
  ## Recommended starting models
98
98
 
@@ -133,11 +133,13 @@ wavespeed models [query] Browse the live catalog (cached 1h)
133
133
  wavespeed aliases List aliases from wavespeed.json + user config
134
134
 
135
135
  # files
136
- wavespeed upload <file...> Upload local file(s) → CDN URLs
136
+ wavespeed upload <file...> Upload local file(s) → CDN URLs (24h dedup cache)
137
137
  wavespeed download <url...> Save URLs to disk
138
138
 
139
139
  # account & history
140
140
  wavespeed balance Show your current credit balance
141
+ wavespeed usage [--since --until] Spend + request counts, per-model (last 7d)
142
+ wavespeed billings [--type --since …] Itemized charges and refunds
141
143
  wavespeed price <model> -i k=v Estimate the cost of a run (no charge)
142
144
  wavespeed top-up Open https://wavespeed.ai/top-up
143
145
  wavespeed history [--limit --status …] List recent predictions (last 24h)
package/dist/cli.js CHANGED
@@ -21,6 +21,8 @@ import { registerHistory } from './commands/history.js';
21
21
  import { registerShow } from './commands/show.js';
22
22
  import { registerDelete } from './commands/delete.js';
23
23
  import { registerOpen } from './commands/open.js';
24
+ import { registerBillings } from './commands/billings.js';
25
+ import { registerUsage } from './commands/usage.js';
24
26
  import { detectRunHelp, printDynamicRunHelp } from './lib/dynamic-help.js';
25
27
  // Read version from package.json at runtime so a bump in package.json is
26
28
  // reflected automatically (no second place to keep in sync).
@@ -64,6 +66,8 @@ registerHistory(program);
64
66
  registerShow(program);
65
67
  registerDelete(program);
66
68
  registerOpen(program);
69
+ registerBillings(program);
70
+ registerUsage(program);
67
71
  registerInit(program);
68
72
  registerSkill(program);
69
73
  program
@@ -0,0 +1,82 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { fetchBillings } from '../lib/api.js';
4
+ import { toIso } from '../lib/time.js';
5
+ function money(v) {
6
+ if (v === undefined)
7
+ return '';
8
+ return `$${v.toFixed(4)}`;
9
+ }
10
+ function formatTime(iso) {
11
+ try {
12
+ return new Date(iso).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
13
+ }
14
+ catch {
15
+ return iso;
16
+ }
17
+ }
18
+ export function registerBillings(program) {
19
+ program
20
+ .command('billings')
21
+ .description('List itemized charges and refunds (billing records)')
22
+ .option('--page <n>', 'Page number', '1')
23
+ .option('--limit <n>', 'Page size (1-100)', '20')
24
+ .option('--type <t>', 'Filter by type: deduct or refund')
25
+ .option('--model <uuid>', 'Filter by model')
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)')
28
+ .option('--until <time>', 'Records before this time (ISO or YYYY-MM-DD)')
29
+ .option('--json', 'Emit JSON: {page, has_more, items}')
30
+ .action(async (opts) => {
31
+ const spinner = !opts.json ? ora('Fetching billing records…').start() : null;
32
+ try {
33
+ const data = await fetchBillings({
34
+ page: parseInt(opts.page, 10),
35
+ pageSize: parseInt(opts.limit, 10),
36
+ type: opts.type,
37
+ model: opts.model,
38
+ accessKey: opts.key,
39
+ startTime: opts.since ? toIso(opts.since) : undefined,
40
+ endTime: opts.until ? toIso(opts.until, true) : undefined,
41
+ });
42
+ spinner?.stop();
43
+ if (opts.json) {
44
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
45
+ return;
46
+ }
47
+ const items = data.items ?? [];
48
+ console.log();
49
+ console.log(chalk.bold(`${items.length} record${items.length === 1 ? '' : 's'}`) +
50
+ chalk.gray(` · page ${data.page}${data.has_more ? ' · more available (--page)' : ''}`));
51
+ console.log();
52
+ if (items.length === 0) {
53
+ console.log(chalk.gray(' no billing records in this range.'));
54
+ console.log();
55
+ return;
56
+ }
57
+ let pageTotal = 0;
58
+ for (const b of items) {
59
+ const sign = b.billing_type === 'refund' ? chalk.green('+') : chalk.red('-');
60
+ pageTotal += (b.billing_type === 'refund' ? 1 : -1) * b.price;
61
+ const model = b.prediction?.model_uuid ?? '';
62
+ console.log(` ${sign}${money(b.price).padEnd(10)} ${chalk.gray(formatTime(b.created_at))} ` +
63
+ chalk.white(model.padEnd(40)) +
64
+ (b.access_key_name ? chalk.gray(` key:${b.access_key_name}`) : ''));
65
+ if (b.prediction?.uuid) {
66
+ console.log(' ' + chalk.gray('prediction: ') + chalk.cyan(b.prediction.uuid));
67
+ }
68
+ }
69
+ console.log();
70
+ console.log(chalk.gray(' page net: ') +
71
+ (pageTotal < 0 ? chalk.red(`-$${Math.abs(pageTotal).toFixed(4)}`) : chalk.green(`+$${pageTotal.toFixed(4)}`)));
72
+ console.log();
73
+ }
74
+ catch (err) {
75
+ spinner?.fail(err.message ?? String(err));
76
+ if (opts.json) {
77
+ process.stdout.write(JSON.stringify({ error: err.message ?? String(err) }, null, 2) + '\n');
78
+ }
79
+ process.exit(1);
80
+ }
81
+ });
82
+ }
@@ -7,10 +7,20 @@ export function registerDelete(program) {
7
7
  .command('delete')
8
8
  .description('Delete one or more predictions from your history')
9
9
  .argument('<id...>', 'Prediction ID(s) to delete')
10
- .option('-y, --yes', 'Skip the confirmation prompt')
10
+ .option('-y, --yes', 'Skip the confirmation prompt (required in scripts / non-interactive use)')
11
11
  .option('--json', 'Emit JSON: {deleted: [...ids]}')
12
12
  .action(async (ids, opts) => {
13
- if (!opts.yes && !opts.json) {
13
+ // --json controls output format only; it must never stand in for
14
+ // consent. Interactive terminals get the prompt, scripts must say -y.
15
+ if (!opts.yes) {
16
+ if (!process.stdin.isTTY) {
17
+ const msg = 'Refusing to delete without confirmation. Pass --yes in non-interactive use.';
18
+ if (opts.json)
19
+ process.stdout.write(JSON.stringify({ error: msg }, null, 2) + '\n');
20
+ else
21
+ console.error(chalk.red(msg));
22
+ process.exit(1);
23
+ }
14
24
  const go = await confirm({
15
25
  message: `Delete ${ids.length} prediction${ids.length === 1 ? '' : 's'} from your history? This cannot be undone.`,
16
26
  default: false,
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
- import { fetchHistory } from '../lib/api.js';
3
+ import { fetchHistory, PREDICTION_STATUSES } from '../lib/api.js';
4
+ import { toIso } from '../lib/time.js';
4
5
  function statusBadge(status) {
5
6
  const map = {
6
7
  completed: chalk.bgGreen.black,
@@ -34,11 +35,19 @@ export function registerHistory(program) {
34
35
  .option('--page <n>', 'Page number', '1')
35
36
  .option('--limit <n>', 'Page size (1-100)', '20')
36
37
  .option('--model <id>', 'Filter by model ID')
37
- .option('--status <s>', 'Filter by status (completed, failed, processing, pending)')
38
- .option('--since <iso>', 'Show predictions created after this ISO timestamp')
39
- .option('--until <iso>', 'Show predictions created before this ISO timestamp')
38
+ .option('--status <s>', 'Filter by status (created, processing, completed, failed, cancelled, timeout)')
39
+ .option('--since <time>', 'Predictions created after this time (ISO or YYYY-MM-DD)')
40
+ .option('--until <time>', 'Predictions created before this time (ISO or YYYY-MM-DD)')
40
41
  .option('--json', 'Emit JSON: {page, items: [...]}')
41
42
  .action(async (opts) => {
43
+ if (opts.status && !PREDICTION_STATUSES.includes(opts.status)) {
44
+ const msg = `Unknown status "${opts.status}". One of: ${PREDICTION_STATUSES.join(', ')}.`;
45
+ if (opts.json)
46
+ process.stdout.write(JSON.stringify({ error: msg }, null, 2) + '\n');
47
+ else
48
+ console.error(chalk.red('Error: ') + msg);
49
+ process.exit(1);
50
+ }
42
51
  const spinner = !opts.json ? ora('Fetching history…').start() : null;
43
52
  try {
44
53
  const data = await fetchHistory({
@@ -46,8 +55,8 @@ export function registerHistory(program) {
46
55
  pageSize: parseInt(opts.limit, 10),
47
56
  model: opts.model,
48
57
  status: opts.status,
49
- createdAfter: opts.since,
50
- createdBefore: opts.until,
58
+ createdAfter: opts.since ? toIso(opts.since) : undefined,
59
+ createdBefore: opts.until ? toIso(opts.until, true) : undefined,
51
60
  });
52
61
  spinner?.stop();
53
62
  if (opts.json) {
@@ -57,7 +66,10 @@ export function registerHistory(program) {
57
66
  const items = data.items ?? [];
58
67
  console.log();
59
68
  console.log(chalk.bold(`${items.length} prediction${items.length === 1 ? '' : 's'}`) +
60
- chalk.gray(` · page ${data.page}`));
69
+ chalk.gray(` · page ${data.page}`) +
70
+ // The silent 24h default made "where did yesterday's runs go?" a
71
+ // recurring surprise; name the window whenever it applies.
72
+ (opts.since || opts.until ? '' : chalk.gray(' · last 24h (widen with --since)')));
61
73
  console.log();
62
74
  if (items.length === 0) {
63
75
  console.log(chalk.gray(' no predictions in this range. Try --since "2026-01-01" for a wider window.'));
@@ -3,6 +3,9 @@ import ora from 'ora';
3
3
  import { fetchPricing } from '../lib/api.js';
4
4
  import { parseInputs } from '../lib/inputs.js';
5
5
  import { resolveModelToken } from '../lib/config.js';
6
+ import { collectLocalFiles, resolveLocalFiles } from '../lib/local-files.js';
7
+ import { uploadWithCache } from '../lib/upload-cache.js';
8
+ import { requireClient } from '../lib/client.js';
6
9
  export function registerPrice(program) {
7
10
  program
8
11
  .command('price')
@@ -10,6 +13,7 @@ export function registerPrice(program) {
10
13
  .argument('<model>', 'Model ID or alias')
11
14
  .option('-i, --input <pair...>', 'Inputs as key=value (same syntax as `wavespeed run`)', [])
12
15
  .option('-p, --prompt <text>', 'Shorthand for --input prompt=<text>')
16
+ .option('--upload', 'Upload @file inputs first so media-based pricing is exact (quota applies; identical bytes cached 24h)')
13
17
  .option('--json', 'Emit JSON')
14
18
  .action(async (modelArg, opts) => {
15
19
  const resolved = resolveModelToken(modelArg);
@@ -21,22 +25,74 @@ export function registerPrice(program) {
21
25
  console.error(chalk.red('Error: ') + msg);
22
26
  process.exit(1);
23
27
  }
24
- const inputs = { ...(resolved.defaultInput ?? {}) };
28
+ let inputs = { ...(resolved.defaultInput ?? {}) };
25
29
  Object.assign(inputs, parseInputs(opts.input ?? []));
26
30
  if (opts.prompt)
27
31
  inputs.prompt = opts.prompt;
32
+ // Pricing that depends on media (e.g. per-second video upscaling) is
33
+ // measured server-side from the URL, so a local path is useless to the
34
+ // pricing engine. Uploading is slow and quota-limited, so it is opt-in:
35
+ // without --upload we stop and say why, rather than silently sending a
36
+ // filesystem path and quoting from garbage.
37
+ const localRefs = collectLocalFiles(inputs);
38
+ let uploadedUrls = {};
39
+ if (localRefs.length > 0 && !opts.upload) {
40
+ const files = [...new Set(localRefs.map((r) => r.path))].join(', ');
41
+ const msg = `Input references local file(s): ${files}. ` +
42
+ 'Pricing for media-dependent models is measured from a hosted URL. ' +
43
+ 'Re-run with --upload to upload them for an exact quote (uses upload quota; identical bytes are cached 24h), or pass a URL directly.';
44
+ if (opts.json)
45
+ process.stdout.write(JSON.stringify({ error: msg }, null, 2) + '\n');
46
+ else
47
+ console.error(chalk.red('Error: ') + msg);
48
+ process.exit(1);
49
+ }
50
+ if (localRefs.length > 0 && opts.upload) {
51
+ const client = requireClient();
52
+ const upSpinner = !opts.json ? ora('Uploading for an exact quote…').start() : null;
53
+ try {
54
+ const resolvedFiles = await resolveLocalFiles(inputs, {
55
+ upload: async (file) => {
56
+ const { url, cached } = await uploadWithCache(file, (f) => client.upload(f));
57
+ uploadedUrls[file] = url;
58
+ if (cached)
59
+ upSpinner && (upSpinner.text = 'Using cached upload…');
60
+ return url;
61
+ },
62
+ });
63
+ inputs = resolvedFiles.input;
64
+ upSpinner?.succeed(resolvedFiles.uploaded > 0 ? 'Uploaded.' : 'All files already uploaded (cache).');
65
+ }
66
+ catch (err) {
67
+ upSpinner?.fail(err.message ?? String(err));
68
+ if (opts.json)
69
+ process.stdout.write(JSON.stringify({ error: err.message ?? String(err) }, null, 2) + '\n');
70
+ process.exit(1);
71
+ }
72
+ }
28
73
  const spinner = !opts.json ? ora('Calculating price…').start() : null;
29
74
  try {
30
75
  const data = await fetchPricing(resolved.model, inputs);
31
76
  spinner?.stop();
32
77
  if (opts.json) {
33
- process.stdout.write(JSON.stringify(data, null, 2) + '\n');
78
+ process.stdout.write(JSON.stringify({ ...data, uploaded: uploadedUrls }, null, 2) + '\n');
34
79
  return;
35
80
  }
36
81
  console.log();
82
+ const effective = data.discounted_price > 0 ? data.discounted_price : data.price;
37
83
  console.log(chalk.bold('Estimated cost'));
38
84
  console.log(' ' + chalk.gray('model: ') + chalk.cyan(data.model_id));
39
- console.log(' ' + chalk.gray('price: ') + chalk.green(`$${data.unit_price}`) + chalk.gray(` ${data.currency ?? 'USD'} per call`));
85
+ console.log(' ' + chalk.gray('price: ') + chalk.green(`$${effective}`) + chalk.gray(` ${data.currency ?? 'USD'} per call`) +
86
+ (data.discounted_price > 0 && data.discounted_price !== data.price
87
+ ? chalk.gray(` (list $${data.price})`)
88
+ : ''));
89
+ console.log();
90
+ for (const [file, url] of Object.entries(uploadedUrls)) {
91
+ console.log(' ' + chalk.gray('uploaded: ') + chalk.gray(file + ' → ') + chalk.cyan(url));
92
+ }
93
+ if (Object.keys(uploadedUrls).length > 0) {
94
+ console.log(' ' + chalk.gray('`wavespeed run` reuses these uploads automatically (content-hash cache).'));
95
+ }
40
96
  console.log();
41
97
  console.log(chalk.gray('Run it: ') + chalk.cyan(`wavespeed run ${modelArg}` + (opts.prompt ? ` -p "${opts.prompt}"` : '')));
42
98
  console.log();
@@ -5,6 +5,8 @@ import fs from 'node:fs';
5
5
  import { requireClient } from '../lib/client.js';
6
6
  import { parseInputs } from '../lib/inputs.js';
7
7
  import { resolveLocalFiles } from '../lib/local-files.js';
8
+ import { submitPrediction, waitForPrediction } from '../lib/api.js';
9
+ import { uploadWithCache } from '../lib/upload-cache.js';
8
10
  import { downloadOutputs, printOutputs } from '../lib/output.js';
9
11
  import { emitJson, emitJsonError, isJsonMode, log, setJsonMode } from '../lib/log.js';
10
12
  import { downloadOptsFromFlag } from '../lib/download-flag.js';
@@ -67,7 +69,7 @@ export function registerRun(program) {
67
69
  .command('run')
68
70
  .description('Run any Wavespeed model — generic invoke with --input key=value pairs')
69
71
  .argument('[model]', 'Model ID, alias name, or omit to use defaultModel.')
70
- .option('-i, --input <pair...>', 'Inputs as key=value (repeatable). Local media paths auto-upload; @path forces upload. Dotted keys nest.', [])
72
+ .option('-i, --input <pair...>', 'Inputs as key=value (repeatable). @path uploads a local file and passes its URL. Dotted keys nest.', [])
71
73
  .option('-p, --prompt <text>', 'Shorthand for --input prompt=<text>')
72
74
  .option('--input-file <path>', 'JSON file with full input object')
73
75
  .option('--download [path]', 'Save outputs locally (optional path template, e.g. "./out/{index}.{ext}")')
@@ -95,14 +97,13 @@ export function registerRun(program) {
95
97
  Object.assign(input, parseInputs(opts.input ?? []));
96
98
  if (opts.prompt)
97
99
  input.prompt = opts.prompt;
98
- // Local paths become hosted URLs before submission. Uploads go straight
99
- // to object storage via a presigned PUT, so this is one extra round trip
100
- // per distinct file rather than a reason to make the user run `upload`
101
- // first and paste the result back.
100
+ // `@path` inputs become hosted URLs before submission. Only the
101
+ // explicit @ marker uploads — the CLI never guesses that a bare value
102
+ // was meant to be a file.
102
103
  try {
103
104
  const uploadSpinner = !isJsonMode() ? ora({ color: 'magenta' }) : null;
104
105
  const resolved = await resolveLocalFiles(input, {
105
- upload: (file) => client.upload(file),
106
+ upload: async (file) => (await uploadWithCache(file, (f) => client.upload(f))).url,
106
107
  onUpload: (file, i, total) => {
107
108
  uploadSpinner?.start(total > 1
108
109
  ? `Uploading ${path.basename(file)} (${i + 1}/${total})…`
@@ -123,55 +124,86 @@ export function registerRun(program) {
123
124
  (aliasName ? chalk.gray(` (alias: ${aliasName})`) : ''));
124
125
  if (input.prompt)
125
126
  log(chalk.gray(' prompt: ') + chalk.white(String(input.prompt)));
127
+ // Submit and poll are deliberately separate calls. The moment the task
128
+ // exists we print its ID and arm a SIGINT handler — a paid generation
129
+ // must never be orphaned by a Ctrl+C or a dropped SSH session with the
130
+ // user left holding nothing to query.
126
131
  const spinner = ora({ text: 'Submitting…', color: 'magenta' });
127
132
  if (!isJsonMode())
128
133
  spinner.start();
129
134
  const startedAt = Date.now();
135
+ let submitted;
136
+ try {
137
+ submitted = await submitPrediction(model, input, { sync: !!opts.sync });
138
+ }
139
+ catch (err) {
140
+ const message = err.message ?? String(err);
141
+ spinner.fail(message);
142
+ if (isJsonMode())
143
+ emitJsonError(message);
144
+ process.exit(1);
145
+ }
146
+ const predictionId = submitted.id;
147
+ if (predictionId && !isJsonMode()) {
148
+ spinner.stopAndPersist({
149
+ symbol: chalk.gray('·'),
150
+ text: chalk.gray('prediction: ') + chalk.white(predictionId),
151
+ });
152
+ spinner.start('Generating…');
153
+ }
154
+ const abortNote = () => {
155
+ // Runs on Ctrl+C while the task is still going server-side. The task
156
+ // is NOT cancelled — it completes and is charged — so hand the user
157
+ // the way back to it.
158
+ process.stderr.write('\n' +
159
+ chalk.yellow('Interrupted — the task keeps running on the server.') +
160
+ '\n' +
161
+ (predictionId
162
+ ? chalk.gray(' resume: ') + chalk.cyan(`wavespeed show ${predictionId}`) + '\n'
163
+ : ''));
164
+ process.exit(130);
165
+ };
166
+ if (predictionId)
167
+ process.on('SIGINT', abortNote);
130
168
  let result;
131
169
  try {
132
- const iv = setInterval(() => {
133
- const s = Math.floor((Date.now() - startedAt) / 1000);
134
- spinner.text = `Generating… ${s}s`;
135
- }, 200);
136
- try {
137
- result = await client.run(model, input, { enableSyncMode: !!opts.sync });
170
+ if (submitted.status === 'completed') {
171
+ // Sync mode finished within the request.
172
+ result = submitted;
138
173
  }
139
- finally {
140
- clearInterval(iv);
174
+ else if (!predictionId) {
175
+ throw new Error(`No prediction ID in response: ${JSON.stringify(submitted)}`);
176
+ }
177
+ else {
178
+ const iv = setInterval(() => {
179
+ const s = Math.floor((Date.now() - startedAt) / 1000);
180
+ spinner.text = `Generating… ${s}s`;
181
+ }, 200);
182
+ try {
183
+ result = await waitForPrediction(predictionId, { intervalMs: 1000 });
184
+ }
185
+ finally {
186
+ clearInterval(iv);
187
+ }
141
188
  }
142
189
  spinner.succeed(`Done in ${Math.floor((Date.now() - startedAt) / 1000)}s.`);
143
190
  }
144
191
  catch (err) {
145
192
  const message = err.message ?? String(err);
146
- const syncTimeout = opts.sync ? extractSyncTimeoutDetails(message) : null;
147
- if (syncTimeout && !isJsonMode()) {
148
- spinner.fail('Sync mode timed out; the task is still processing asynchronously.');
149
- log(chalk.gray(' reason: ') + message);
150
- if (syncTimeout.predictionId) {
151
- log(chalk.gray(' prediction: ') + chalk.white(syncTimeout.predictionId));
152
- log(chalk.gray(' query: ') + chalk.cyan(`wavespeed show ${syncTimeout.predictionId}`));
153
- }
154
- if (syncTimeout.resultUrl) {
155
- log(chalk.gray(' result URL: ') + chalk.cyan(syncTimeout.resultUrl));
156
- }
157
- }
158
- else {
159
- spinner.fail(message);
193
+ spinner.fail(message);
194
+ if (predictionId && !isJsonMode()) {
195
+ log(chalk.gray(' prediction: ') + chalk.white(predictionId));
196
+ log(chalk.gray(' query: ') + chalk.cyan(`wavespeed show ${predictionId}`));
160
197
  }
161
198
  if (isJsonMode()) {
162
- emitJsonError(message, syncTimeout
163
- ? {
164
- sync_timeout: true,
165
- prediction_id: syncTimeout.predictionId,
166
- result_url: syncTimeout.resultUrl,
167
- query_command: syncTimeout.predictionId
168
- ? `wavespeed show ${syncTimeout.predictionId}`
169
- : undefined,
170
- }
171
- : {});
199
+ emitJsonError(message, predictionId ? { prediction_id: predictionId, query_command: `wavespeed show ${predictionId}` } : {});
172
200
  }
173
201
  process.exit(1);
174
202
  }
203
+ finally {
204
+ if (predictionId)
205
+ process.removeListener('SIGINT', abortNote);
206
+ }
175
207
  const urls = extractUrls(result);
176
208
  let saved = [];
177
209
  const dlOpts = downloadOptsFromFlag(opts.download, {
@@ -193,6 +225,7 @@ export function registerRun(program) {
193
225
  }
194
226
  if (isJsonMode()) {
195
227
  emitJson({
228
+ id: predictionId,
196
229
  model,
197
230
  prompt: typeof input.prompt === 'string' ? input.prompt : undefined,
198
231
  outputs: urls,
@@ -2,10 +2,11 @@ import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import fs from 'node:fs';
4
4
  import { requireClient } from '../lib/client.js';
5
+ import { uploadWithCache } from '../lib/upload-cache.js';
5
6
  export function registerUpload(program) {
6
7
  program
7
8
  .command('upload')
8
- .description('Upload one or more local files to Wavespeed and print their CDN URLs')
9
+ .description('Upload one or more local files to Wavespeed and print their CDN URLs (identical bytes reuse the same upload for 24h)')
9
10
  .argument('<file...>', 'Path(s) to file(s)')
10
11
  .option('--json', 'Emit a JSON object on stdout')
11
12
  .action(async (files, opts) => {
@@ -25,9 +26,9 @@ export function registerUpload(program) {
25
26
  for (const file of files) {
26
27
  const spinner = !opts.json ? ora(`Uploading ${file}…`).start() : null;
27
28
  try {
28
- const url = await client.upload(file);
29
+ const { url, cached } = await uploadWithCache(file, (f) => client.upload(f));
29
30
  urls.push(url);
30
- spinner?.succeed(`${file} → ${url}`);
31
+ spinner?.succeed(`${file} → ${url}` + (cached ? ' (cached)' : ''));
31
32
  }
32
33
  catch (err) {
33
34
  const message = err.message ?? String(err);
@@ -0,0 +1,62 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { fetchUsageStats } from '../lib/api.js';
4
+ import { toIso } from '../lib/time.js';
5
+ export function registerUsage(program) {
6
+ program
7
+ .command('usage')
8
+ .description('Usage statistics: spend, request counts, per-model breakdown (default: last 7 days)')
9
+ .option('--since <time>', 'Start of the window (ISO or YYYY-MM-DD)')
10
+ .option('--until <time>', 'End of the window (ISO or YYYY-MM-DD)')
11
+ .option('--model <uuid>', 'Restrict to one model')
12
+ .option('--json', 'Emit the raw stats as JSON')
13
+ .action(async (opts) => {
14
+ const spinner = !opts.json ? ora('Fetching usage…').start() : null;
15
+ try {
16
+ const now = new Date();
17
+ const start = opts.since
18
+ ? toIso(opts.since)
19
+ : new Date(now.getTime() - 7 * 24 * 3600 * 1000).toISOString();
20
+ const end = opts.until ? toIso(opts.until, true) : now.toISOString();
21
+ const stats = await fetchUsageStats(start, end, opts.model);
22
+ spinner?.stop();
23
+ if (opts.json) {
24
+ process.stdout.write(JSON.stringify(stats, null, 2) + '\n');
25
+ return;
26
+ }
27
+ const s = stats.summary;
28
+ const failed = s.total_requests - s.success_requests;
29
+ console.log();
30
+ console.log(chalk.bold('Usage ') +
31
+ chalk.gray(`${start.slice(0, 10)} → ${end.slice(0, 10)}`) +
32
+ (opts.since || opts.until ? '' : chalk.gray(' · last 7d (widen with --since)')));
33
+ console.log();
34
+ console.log(' ' + chalk.gray('total spend: ') + chalk.green(`$${s.total_cost.toFixed(4)}`));
35
+ console.log(' ' +
36
+ chalk.gray('requests: ') +
37
+ chalk.white(`${s.total_requests}`) +
38
+ chalk.gray(` (${s.success_requests} ok, ${failed} failed)`));
39
+ console.log();
40
+ const models = [...(stats.per_model_usage ?? [])].sort((a, b) => b.total_cost - a.total_cost);
41
+ if (models.length > 0) {
42
+ console.log(chalk.bold(' By model'));
43
+ for (const m of models.slice(0, 15)) {
44
+ console.log(' ' +
45
+ chalk.cyan(m.model_uuid.padEnd(44)) +
46
+ chalk.green(`$${m.total_cost.toFixed(4)}`.padStart(10)) +
47
+ (m.total_count ? chalk.gray(` ×${m.total_count}`) : ''));
48
+ }
49
+ if (models.length > 15)
50
+ console.log(chalk.gray(` … ${models.length - 15} more (use --json)`));
51
+ console.log();
52
+ }
53
+ }
54
+ catch (err) {
55
+ spinner?.fail(err.message ?? String(err));
56
+ if (opts.json) {
57
+ process.stdout.write(JSON.stringify({ error: err.message ?? String(err) }, null, 2) + '\n');
58
+ }
59
+ process.exit(1);
60
+ }
61
+ });
62
+ }
package/dist/lib/api.js CHANGED
@@ -117,7 +117,9 @@ export async function fetchBalance() {
117
117
  return apiGet('/api/v3/balance');
118
118
  }
119
119
  export async function fetchPricing(modelId, inputs) {
120
- return apiPost('/api/v3/model/pricing', { model_id: modelId, inputs });
120
+ // /model/price is the documented pricing endpoint; /model/pricing is a
121
+ // legacy contract kept for old clients and absent from the public docs.
122
+ return apiPost('/api/v3/model/price', { model_id: modelId, inputs });
121
123
  }
122
124
  export async function fetchHistory(query = {}) {
123
125
  const now = new Date();
@@ -127,7 +129,6 @@ export async function fetchHistory(query = {}) {
127
129
  page_size: query.pageSize ?? 20,
128
130
  created_after: query.createdAfter ?? oneDayAgo.toISOString(),
129
131
  created_before: query.createdBefore ?? now.toISOString(),
130
- include_inputs: true,
131
132
  };
132
133
  if (query.model)
133
134
  body.model = query.model;
@@ -141,3 +142,67 @@ export async function fetchPrediction(id) {
141
142
  export async function deletePredictions(ids) {
142
143
  await apiPost('/api/v3/predictions/delete', { ids });
143
144
  }
145
+ // Server-side prediction status enum (api-server internal/model/prediction.go).
146
+ // Validated client-side so `--status pending` fails with the real list instead
147
+ // of a server 400 or a silently empty page.
148
+ export const PREDICTION_STATUSES = [
149
+ 'created',
150
+ 'processing',
151
+ 'completed',
152
+ 'failed',
153
+ 'cancelled',
154
+ 'timeout',
155
+ ];
156
+ /**
157
+ * Submit a prediction WITHOUT waiting for it. Split from polling so the
158
+ * caller can print the prediction ID the moment it exists — a Ctrl+C or
159
+ * dropped connection mid-generation must never orphan a paid task.
160
+ */
161
+ export async function submitPrediction(model, input, opts = {}) {
162
+ const body = { ...input };
163
+ if (opts.sync)
164
+ body.enable_sync_mode = true;
165
+ return apiPost(`/api/v3/${model}`, body);
166
+ }
167
+ /**
168
+ * Poll a prediction until it reaches a terminal status. `onTick` fires each
169
+ * round so the caller can keep a spinner honest.
170
+ */
171
+ export async function waitForPrediction(id, opts = {}) {
172
+ const interval = opts.intervalMs ?? 1000;
173
+ for (;;) {
174
+ const item = await fetchPrediction(id);
175
+ opts.onTick?.(item.status);
176
+ if (item.status === 'completed')
177
+ return item;
178
+ if (item.status === 'failed' || item.status === 'cancelled' || item.status === 'timeout') {
179
+ throw new Error(`Prediction ${item.status}${item.error ? `: ${item.error}` : ''} (task_id: ${id})`);
180
+ }
181
+ if (opts.signal?.cancelled)
182
+ throw new Error(`cancelled (task_id: ${id})`);
183
+ await new Promise((r) => setTimeout(r, interval));
184
+ }
185
+ }
186
+ export async function fetchBillings(query = {}) {
187
+ const body = {
188
+ page: query.page ?? 1,
189
+ page_size: query.pageSize ?? 20,
190
+ };
191
+ if (query.type)
192
+ body.billing_type = query.type;
193
+ if (query.startTime)
194
+ body.start_time = query.startTime;
195
+ if (query.endTime)
196
+ body.end_time = query.endTime;
197
+ if (query.model)
198
+ body.model_uuid = query.model;
199
+ if (query.accessKey)
200
+ body.access_key_uuid = query.accessKey;
201
+ return apiPost('/api/v3/billings/search', body);
202
+ }
203
+ export async function fetchUsageStats(startTime, endTime, model) {
204
+ const body = { start_time: startTime, end_time: endTime };
205
+ if (model)
206
+ body.model_uuid = model;
207
+ return apiPost('/api/v3/user/usage_stats', body);
208
+ }
@@ -8,7 +8,19 @@ export const DEFAULTS = {
8
8
  export const userConfig = new Conf({
9
9
  projectName: 'wavespeed',
10
10
  defaults: {},
11
+ // The config file holds the API key in cleartext; owner-only, not the
12
+ // default 0664 that leaves it group-readable.
13
+ configFileMode: 0o600,
11
14
  });
15
+ // conf only applies configFileMode on write. Tighten a config that an older
16
+ // CLI version already left group-readable.
17
+ try {
18
+ if (fs.existsSync(userConfig.path))
19
+ fs.chmodSync(userConfig.path, 0o600);
20
+ }
21
+ catch {
22
+ /* best effort */
23
+ }
12
24
  export const PROJECT_FILE = 'wavespeed.json';
13
25
  export function loadProjectConfig(cwd = process.cwd()) {
14
26
  let dir = cwd;
@@ -32,7 +44,9 @@ export function getApiKey() {
32
44
  return process.env.WAVESPEED_API_KEY || userConfig.get('apiKey');
33
45
  }
34
46
  export function getBaseUrl() {
35
- return userConfig.get('baseUrl') || DEFAULTS.baseUrl;
47
+ // Env first, matching how the key resolves: CI pointing one command at the
48
+ // test environment must not require editing the user-level config.
49
+ return process.env.WAVESPEED_BASE_URL || userConfig.get('baseUrl') || DEFAULTS.baseUrl;
36
50
  }
37
51
  export function resolveOutputDir() {
38
52
  const project = loadProjectConfig();
@@ -1,36 +1,14 @@
1
- // Turn local file paths in a model's input into hosted URLs.
1
+ // Turn `@`-prefixed local file paths in a model's input into hosted URLs.
2
2
  //
3
- // Uploading is cheap now that the platform hands out a presigned PUT and the
4
- // bytes go straight to object storage, so making the user run `upload` first
5
- // and paste the URL back is pure friction. This module finds the paths and
6
- // swaps them for URLs; run.ts does it once, right before submitting.
7
- //
8
- // Two ways to point at a file:
9
- //
10
- // -i image=@./cat.png explicit — always uploaded, missing file is an error
11
- // -i image=./cat.png implicit — uploaded only if it looks like media AND
12
- // exists on disk; otherwise passed through untouched
13
- //
14
- // The implicit rule is deliberately narrow. A model input that happens to
15
- // match a filename in the working directory must not be silently replaced by
16
- // a URL, so a bare value has to clear both the extension check and the
17
- // existence check. Anything already a URL is left alone.
3
+ // The syntax is explicit on purpose — `-i image=@./cat.png`, borrowed from
4
+ // curl. An earlier draft also auto-detected bare paths (media extension +
5
+ // exists on disk), but that meant the CLI could upload a file the user never
6
+ // asked to share based on a heuristic; a value that merely looks like a
7
+ // filename must never leave the machine. No `@`, no upload: bare paths pass
8
+ // through to the API untouched and fail model validation there if they were
9
+ // meant to be files.
18
10
  import fs from 'node:fs';
19
11
  import path from 'node:path';
20
- // Extensions the platform's models actually accept as inputs. Kept explicit
21
- // rather than "any file that exists": a bare `-i seed=1.txt` should stay a
22
- // string even in a directory that happens to contain 1.txt.
23
- const MEDIA_EXTENSIONS = new Set([
24
- // images
25
- '.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif', '.heic', '.heif', '.avif',
26
- // video
27
- '.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v',
28
- // audio
29
- '.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg', '.opus',
30
- ]);
31
- // Free-text fields never auto-detect: `-p ./notes.txt` means that literal
32
- // string. An explicit `@` still works if someone genuinely wants it.
33
- const NO_IMPLICIT_DETECTION = new Set(['prompt', 'negative_prompt', 'system_prompt']);
34
12
  function isRegularFile(p) {
35
13
  try {
36
14
  return fs.statSync(p).isFile();
@@ -39,46 +17,28 @@ function isRegularFile(p) {
39
17
  return false;
40
18
  }
41
19
  }
42
- function isUrl(value) {
43
- return /^(https?:|data:|s3:|gs:)/i.test(value);
44
- }
45
20
  /**
46
- * Walk the input and list every value that should become an upload. Pure over
47
- * `exists`, so the decision rules can be tested without touching the disk.
21
+ * Walk the input and list every `@path` value. Pure over `exists`, so the
22
+ * rules can be tested without touching the disk.
48
23
  */
49
- export function collectLocalFiles(input, opts = {}) {
50
- const exists = opts.exists ?? isRegularFile;
24
+ export function collectLocalFiles(input, _opts = {}) {
51
25
  const found = [];
52
- const walk = (node, at, key) => {
26
+ const walk = (node, at) => {
53
27
  if (Array.isArray(node)) {
54
- // Arrays inherit the key of their container, so `image_urls.0` is still
55
- // judged as `image_urls` — element indices are not field names.
56
- node.forEach((v, i) => walk(v, at ? `${at}.${i}` : String(i), key));
28
+ node.forEach((v, i) => walk(v, at ? `${at}.${i}` : String(i)));
57
29
  return;
58
30
  }
59
31
  if (node && typeof node === 'object') {
60
32
  for (const [k, v] of Object.entries(node)) {
61
- walk(v, at ? `${at}.${k}` : k, k);
33
+ walk(v, at ? `${at}.${k}` : k);
62
34
  }
63
35
  return;
64
36
  }
65
- if (typeof node !== 'string' || node === '')
66
- return;
67
- if (node.startsWith('@')) {
68
- found.push({ path: node.slice(1), at, explicit: true });
69
- return;
70
- }
71
- if (isUrl(node))
72
- return;
73
- if (NO_IMPLICIT_DETECTION.has(key))
37
+ if (typeof node !== 'string' || !node.startsWith('@'))
74
38
  return;
75
- if (!MEDIA_EXTENSIONS.has(path.extname(node).toLowerCase()))
76
- return;
77
- if (!exists(node))
78
- return;
79
- found.push({ path: node, at, explicit: false });
39
+ found.push({ path: node.slice(1), at });
80
40
  };
81
- walk(input, '', '');
41
+ walk(input, '');
82
42
  return found;
83
43
  }
84
44
  function setAt(root, dotted, value) {
@@ -89,16 +49,18 @@ function setAt(root, dotted, value) {
89
49
  cur[parts[parts.length - 1]] = value;
90
50
  }
91
51
  /**
92
- * Replace local paths in `input` with uploaded URLs. Returns a new object;
93
- * the caller's input is not mutated. Distinct paths upload once even when
94
- * referenced several times — the same image passed as two fields is one
95
- * upload, not two.
52
+ * Replace `@path` values in `input` with uploaded URLs. Returns a new object;
53
+ * the caller's input is not mutated. A missing file is an error — the user
54
+ * explicitly asked for an upload, so a silent pass-through would submit the
55
+ * literal `@path` string to the model. Distinct paths upload once even when
56
+ * referenced several times.
96
57
  */
97
58
  export async function resolveLocalFiles(input, opts) {
98
59
  const refs = collectLocalFiles(input, opts);
99
60
  if (refs.length === 0)
100
61
  return { input, uploaded: 0 };
101
- const missing = refs.filter((r) => r.explicit && !(opts.exists ?? isRegularFile)(r.path));
62
+ const exists = opts.exists ?? isRegularFile;
63
+ const missing = refs.filter((r) => !exists(r.path));
102
64
  if (missing.length > 0) {
103
65
  throw new Error(`File not found: ${missing.map((m) => m.path).join(', ')}`);
104
66
  }
@@ -0,0 +1,16 @@
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
3
+ // there. Shared by history, billings, and usage so every time flag accepts
4
+ // the same two forms: full ISO 8601 or YYYY-MM-DD.
5
+ /** Accepts ISO 8601 or YYYY-MM-DD; returns RFC3339. Day-only values span the
6
+ * whole day: start-of-day normally, end-of-day when `endOfDay` is set. */
7
+ export function toIso(value, endOfDay = false) {
8
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
9
+ return value + (endOfDay ? 'T23:59:59Z' : 'T00:00:00Z');
10
+ }
11
+ const d = new Date(value);
12
+ if (isNaN(d.getTime())) {
13
+ throw new Error(`Invalid time "${value}". Use ISO 8601 or YYYY-MM-DD.`);
14
+ }
15
+ return d.toISOString();
16
+ }
@@ -0,0 +1,58 @@
1
+ // Content-addressed cache of uploaded files: sha256(file bytes) → hosted URL.
2
+ //
3
+ // Uploads are quota-limited per organization and slow for large videos, while
4
+ // the same file routinely goes up more than once — `price --upload` followed
5
+ // by `run`, or the same reference image across a batch of runs. Hashing the
6
+ // bytes (not the path) means a renamed or copied file still hits.
7
+ //
8
+ // TTL is 24h: hosted media does not expire that fast, but a bounded window
9
+ // keeps the cache from ever serving a URL the platform has since removed.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import os from 'node:os';
13
+ import crypto from 'node:crypto';
14
+ const TTL_MS = 24 * 60 * 60 * 1000;
15
+ function cacheFile() {
16
+ return path.join(os.homedir(), '.cache', 'wavespeed', 'uploads.json');
17
+ }
18
+ function readCache() {
19
+ try {
20
+ return JSON.parse(fs.readFileSync(cacheFile(), 'utf8'));
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ function writeCache(entries) {
27
+ try {
28
+ fs.mkdirSync(path.dirname(cacheFile()), { recursive: true });
29
+ fs.writeFileSync(cacheFile(), JSON.stringify(entries));
30
+ }
31
+ catch {
32
+ /* best-effort; a failed cache write must never fail the upload */
33
+ }
34
+ }
35
+ export function hashFile(filePath) {
36
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
37
+ }
38
+ /**
39
+ * Upload through `doUpload` unless the identical bytes already went up within
40
+ * the TTL. Returns the hosted URL either way.
41
+ */
42
+ export async function uploadWithCache(filePath, doUpload) {
43
+ const key = hashFile(filePath);
44
+ const entries = readCache();
45
+ const hit = entries[key];
46
+ if (hit && Date.now() - hit.uploaded_at < TTL_MS) {
47
+ return { url: hit.url, cached: true };
48
+ }
49
+ const url = await doUpload(filePath);
50
+ entries[key] = { url, uploaded_at: Date.now() };
51
+ // Drop expired entries while we are here so the file stays small.
52
+ for (const [k, v] of Object.entries(entries)) {
53
+ if (Date.now() - v.uploaded_at >= TTL_MS)
54
+ delete entries[k];
55
+ }
56
+ writeCache(entries);
57
+ return { url, cached: false };
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavespeed/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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
@@ -23,7 +23,7 @@ wavespeed run google/nano-banana-2/text-to-image \
23
23
  -i aspect_ratio="16:9" -i resolution="2k" --json
24
24
  ```
25
25
 
26
- `run --json` returns `{ model, prompt, outputs: [url, ...], saved: [path, ...], elapsed_ms, raw }`. Use the URL when the user wants a link. Add `--download` if they need bytes on disk.
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.
27
27
 
28
28
  ## Recommended defaults
29
29
 
@@ -39,17 +39,18 @@ These are good starting points. Browse alternatives with `wavespeed models <quer
39
39
  ## Common recipes
40
40
 
41
41
  ```bash
42
- # Edit an existing image — upload first, then pass the URL
43
- URL=$(wavespeed upload ./input.jpg --json | jq -r .url)
42
+ # Edit an existing image — @path uploads the file and passes its URL (one step)
44
43
  wavespeed run google/nano-banana-2/edit \
45
44
  -p "replace the background with a sunlit kitchen" \
46
- -i images="[\"$URL\"]" --json
45
+ -i images='["@./input.jpg"]' --json
47
46
 
48
- # Image-to-video — same pattern
49
- URL=$(wavespeed upload ./hero.jpg --json | jq -r .url)
47
+ # Image-to-video — same @ marker for single-URL fields
50
48
  wavespeed run bytedance/seedance-2.0/image-to-video \
51
49
  -p "subtle parallax, gentle wind" \
52
- -i image="$URL" -i duration=5 --json
50
+ -i image=@./hero.jpg -i duration=5 --json
51
+
52
+ # Or upload separately when you need the URL itself
53
+ URL=$(wavespeed upload ./hero.jpg --json | jq -r .url)
53
54
 
54
55
  # Save outputs locally with a template
55
56
  wavespeed run ... -p "..." --download "./out/{index}.{ext}"
@@ -62,7 +63,7 @@ If `wavespeed.json` exists (created by `wavespeed init`):
62
63
  - **`defaultModel`** — lets `wavespeed run -p "…"` (no model arg) work.
63
64
  - **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.
64
65
 
65
- The CLI never modifies the user's prompt or inputs. What you typed is what hits the API.
66
+ 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.
66
67
 
67
68
  ## Auth
68
69
 
@@ -70,6 +71,8 @@ The CLI never modifies the user's prompt or inputs. What you typed is what hits
70
71
 
71
72
  ## Pitfalls
72
73
 
73
- - Local file paths don't auto-upload — call `wavespeed upload` first to get a CDN URL.
74
+ - Local files: use `@./file.jpg` in `-i` values. Bare paths are NOT uploaded and the model will reject them.
74
75
  - Don't invent model IDs. Always confirm via `wavespeed models` or `wavespeed schema <id>` before running.
75
76
  - Use `--json` on every `run` so you can read `outputs[0]` programmatically.
77
+ - `wavespeed delete` requires `--yes` when run non-interactively (that includes you).
78
+ - Spend questions: `wavespeed usage` (totals, per-model) and `wavespeed billings` (per-charge records).