@wavespeed/cli 0.2.5 → 0.4.0

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,7 +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 mutates the user's prompt or inputs** — what you pass is what hits the API.
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
+
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.
94
96
 
95
97
  ## Recommended starting models
96
98
 
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,81 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { fetchBillings } from '../lib/api.js';
4
+ function money(v) {
5
+ if (v === undefined)
6
+ return '';
7
+ return `$${v.toFixed(4)}`;
8
+ }
9
+ function formatTime(iso) {
10
+ try {
11
+ return new Date(iso).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
12
+ }
13
+ catch {
14
+ return iso;
15
+ }
16
+ }
17
+ export function registerBillings(program) {
18
+ program
19
+ .command('billings')
20
+ .description('List itemized charges and refunds (billing records)')
21
+ .option('--page <n>', 'Page number', '1')
22
+ .option('--limit <n>', 'Page size (1-100)', '20')
23
+ .option('--type <t>', 'Filter by type: deduct or refund')
24
+ .option('--model <uuid>', 'Filter by model')
25
+ .option('--key <uuid>', 'Filter by the API key that generated the charge')
26
+ .option('--since <time>', 'Records after this time (ISO or YYYY-MM-DD)')
27
+ .option('--until <time>', 'Records before this time (ISO or YYYY-MM-DD)')
28
+ .option('--json', 'Emit JSON: {page, has_more, items}')
29
+ .action(async (opts) => {
30
+ const spinner = !opts.json ? ora('Fetching billing records…').start() : null;
31
+ try {
32
+ const data = await fetchBillings({
33
+ page: parseInt(opts.page, 10),
34
+ pageSize: parseInt(opts.limit, 10),
35
+ type: opts.type,
36
+ model: opts.model,
37
+ accessKey: opts.key,
38
+ startTime: opts.since,
39
+ endTime: opts.until,
40
+ });
41
+ spinner?.stop();
42
+ if (opts.json) {
43
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
44
+ return;
45
+ }
46
+ const items = data.items ?? [];
47
+ console.log();
48
+ console.log(chalk.bold(`${items.length} record${items.length === 1 ? '' : 's'}`) +
49
+ chalk.gray(` · page ${data.page}${data.has_more ? ' · more available (--page)' : ''}`));
50
+ console.log();
51
+ if (items.length === 0) {
52
+ console.log(chalk.gray(' no billing records in this range.'));
53
+ console.log();
54
+ return;
55
+ }
56
+ let pageTotal = 0;
57
+ for (const b of items) {
58
+ const sign = b.billing_type === 'refund' ? chalk.green('+') : chalk.red('-');
59
+ pageTotal += (b.billing_type === 'refund' ? 1 : -1) * b.price;
60
+ const model = b.prediction?.model_uuid ?? '';
61
+ console.log(` ${sign}${money(b.price).padEnd(10)} ${chalk.gray(formatTime(b.created_at))} ` +
62
+ chalk.white(model.padEnd(40)) +
63
+ (b.access_key_name ? chalk.gray(` key:${b.access_key_name}`) : ''));
64
+ if (b.prediction?.uuid) {
65
+ console.log(' ' + chalk.gray('prediction: ') + chalk.cyan(b.prediction.uuid));
66
+ }
67
+ }
68
+ console.log();
69
+ console.log(chalk.gray(' page net: ') +
70
+ (pageTotal < 0 ? chalk.red(`-$${Math.abs(pageTotal).toFixed(4)}`) : chalk.green(`+$${pageTotal.toFixed(4)}`)));
71
+ console.log();
72
+ }
73
+ catch (err) {
74
+ spinner?.fail(err.message ?? String(err));
75
+ if (opts.json) {
76
+ process.stdout.write(JSON.stringify({ error: err.message ?? String(err) }, null, 2) + '\n');
77
+ }
78
+ process.exit(1);
79
+ }
80
+ });
81
+ }
@@ -10,7 +10,17 @@ export function registerDelete(program) {
10
10
  .option('-y, --yes', 'Skip the confirmation prompt')
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,6 @@
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
4
  function statusBadge(status) {
5
5
  const map = {
6
6
  completed: chalk.bgGreen.black,
@@ -34,11 +34,19 @@ export function registerHistory(program) {
34
34
  .option('--page <n>', 'Page number', '1')
35
35
  .option('--limit <n>', 'Page size (1-100)', '20')
36
36
  .option('--model <id>', 'Filter by model ID')
37
- .option('--status <s>', 'Filter by status (completed, failed, processing, pending)')
37
+ .option('--status <s>', 'Filter by status (created, processing, completed, failed, cancelled, timeout)')
38
38
  .option('--since <iso>', 'Show predictions created after this ISO timestamp')
39
39
  .option('--until <iso>', 'Show predictions created before this ISO timestamp')
40
40
  .option('--json', 'Emit JSON: {page, items: [...]}')
41
41
  .action(async (opts) => {
42
+ if (opts.status && !PREDICTION_STATUSES.includes(opts.status)) {
43
+ const msg = `Unknown status "${opts.status}". One of: ${PREDICTION_STATUSES.join(', ')}.`;
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
+ }
42
50
  const spinner = !opts.json ? ora('Fetching history…').start() : null;
43
51
  try {
44
52
  const data = await fetchHistory({
@@ -57,7 +65,10 @@ export function registerHistory(program) {
57
65
  const items = data.items ?? [];
58
66
  console.log();
59
67
  console.log(chalk.bold(`${items.length} prediction${items.length === 1 ? '' : 's'}`) +
60
- chalk.gray(` · page ${data.page}`));
68
+ chalk.gray(` · page ${data.page}`) +
69
+ // The silent 24h default made "where did yesterday's runs go?" a
70
+ // recurring surprise; name the window whenever it applies.
71
+ (opts.since || opts.until ? '' : chalk.gray(' · last 24h (widen with --since)')));
61
72
  console.log();
62
73
  if (items.length === 0) {
63
74
  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 local file inputs first so media-based pricing is exact (quota applies; content-hash 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();
@@ -4,6 +4,9 @@ import path from 'node:path';
4
4
  import fs from 'node:fs';
5
5
  import { requireClient } from '../lib/client.js';
6
6
  import { parseInputs } from '../lib/inputs.js';
7
+ import { resolveLocalFiles } from '../lib/local-files.js';
8
+ import { submitPrediction, waitForPrediction } from '../lib/api.js';
9
+ import { uploadWithCache } from '../lib/upload-cache.js';
7
10
  import { downloadOutputs, printOutputs } from '../lib/output.js';
8
11
  import { emitJson, emitJsonError, isJsonMode, log, setJsonMode } from '../lib/log.js';
9
12
  import { downloadOptsFromFlag } from '../lib/download-flag.js';
@@ -43,6 +46,13 @@ function failMissingModel() {
43
46
  }
44
47
  process.exit(1);
45
48
  }
49
+ function failWith(msg) {
50
+ if (isJsonMode())
51
+ emitJsonError(msg);
52
+ else
53
+ console.error(chalk.red('Error: ') + msg);
54
+ process.exit(1);
55
+ }
46
56
  function failUnknownAlias(token) {
47
57
  const msg = `"${token}" is neither a model ID (no \`/\`) nor a known alias.`;
48
58
  if (isJsonMode())
@@ -59,7 +69,7 @@ export function registerRun(program) {
59
69
  .command('run')
60
70
  .description('Run any Wavespeed model — generic invoke with --input key=value pairs')
61
71
  .argument('[model]', 'Model ID, alias name, or omit to use defaultModel.')
62
- .option('-i, --input <pair...>', 'Inputs as key=value (repeatable). Use dotted keys for nested.', [])
72
+ .option('-i, --input <pair...>', 'Inputs as key=value (repeatable). @path uploads a local file and passes its URL. Dotted keys nest.', [])
63
73
  .option('-p, --prompt <text>', 'Shorthand for --input prompt=<text>')
64
74
  .option('--input-file <path>', 'JSON file with full input object')
65
75
  .option('--download [path]', 'Save outputs locally (optional path template, e.g. "./out/{index}.{ext}")')
@@ -87,61 +97,113 @@ export function registerRun(program) {
87
97
  Object.assign(input, parseInputs(opts.input ?? []));
88
98
  if (opts.prompt)
89
99
  input.prompt = opts.prompt;
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.
103
+ try {
104
+ const uploadSpinner = !isJsonMode() ? ora({ color: 'magenta' }) : null;
105
+ const resolved = await resolveLocalFiles(input, {
106
+ upload: async (file) => (await uploadWithCache(file, (f) => client.upload(f))).url,
107
+ onUpload: (file, i, total) => {
108
+ uploadSpinner?.start(total > 1
109
+ ? `Uploading ${path.basename(file)} (${i + 1}/${total})…`
110
+ : `Uploading ${path.basename(file)}…`);
111
+ },
112
+ });
113
+ if (resolved.uploaded > 0) {
114
+ uploadSpinner?.succeed(`Uploaded ${resolved.uploaded} file${resolved.uploaded === 1 ? '' : 's'}.`);
115
+ }
116
+ input = resolved.input;
117
+ }
118
+ catch (err) {
119
+ failWith(err.message ?? String(err));
120
+ }
90
121
  log('');
91
122
  log(chalk.bold('▶ ') +
92
123
  chalk.cyan(model) +
93
124
  (aliasName ? chalk.gray(` (alias: ${aliasName})`) : ''));
94
125
  if (input.prompt)
95
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.
96
131
  const spinner = ora({ text: 'Submitting…', color: 'magenta' });
97
132
  if (!isJsonMode())
98
133
  spinner.start();
99
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);
100
168
  let result;
101
169
  try {
102
- const iv = setInterval(() => {
103
- const s = Math.floor((Date.now() - startedAt) / 1000);
104
- spinner.text = `Generating… ${s}s`;
105
- }, 200);
106
- try {
107
- result = await client.run(model, input, { enableSyncMode: !!opts.sync });
170
+ if (submitted.status === 'completed') {
171
+ // Sync mode finished within the request.
172
+ result = submitted;
173
+ }
174
+ else if (!predictionId) {
175
+ throw new Error(`No prediction ID in response: ${JSON.stringify(submitted)}`);
108
176
  }
109
- finally {
110
- clearInterval(iv);
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
+ }
111
188
  }
112
189
  spinner.succeed(`Done in ${Math.floor((Date.now() - startedAt) / 1000)}s.`);
113
190
  }
114
191
  catch (err) {
115
192
  const message = err.message ?? String(err);
116
- const syncTimeout = opts.sync ? extractSyncTimeoutDetails(message) : null;
117
- if (syncTimeout && !isJsonMode()) {
118
- spinner.fail('Sync mode timed out; the task is still processing asynchronously.');
119
- log(chalk.gray(' reason: ') + message);
120
- if (syncTimeout.predictionId) {
121
- log(chalk.gray(' prediction: ') + chalk.white(syncTimeout.predictionId));
122
- log(chalk.gray(' query: ') + chalk.cyan(`wavespeed show ${syncTimeout.predictionId}`));
123
- }
124
- if (syncTimeout.resultUrl) {
125
- log(chalk.gray(' result URL: ') + chalk.cyan(syncTimeout.resultUrl));
126
- }
127
- }
128
- else {
129
- 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}`));
130
197
  }
131
198
  if (isJsonMode()) {
132
- emitJsonError(message, syncTimeout
133
- ? {
134
- sync_timeout: true,
135
- prediction_id: syncTimeout.predictionId,
136
- result_url: syncTimeout.resultUrl,
137
- query_command: syncTimeout.predictionId
138
- ? `wavespeed show ${syncTimeout.predictionId}`
139
- : undefined,
140
- }
141
- : {});
199
+ emitJsonError(message, predictionId ? { prediction_id: predictionId, query_command: `wavespeed show ${predictionId}` } : {});
142
200
  }
143
201
  process.exit(1);
144
202
  }
203
+ finally {
204
+ if (predictionId)
205
+ process.removeListener('SIGINT', abortNote);
206
+ }
145
207
  const urls = extractUrls(result);
146
208
  let saved = [];
147
209
  const dlOpts = downloadOptsFromFlag(opts.download, {
@@ -163,6 +225,7 @@ export function registerRun(program) {
163
225
  }
164
226
  if (isJsonMode()) {
165
227
  emitJson({
228
+ id: predictionId,
166
229
  model,
167
230
  prompt: typeof input.prompt === 'string' ? input.prompt : undefined,
168
231
  outputs: urls,
@@ -2,6 +2,7 @@ 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')
@@ -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,71 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { fetchUsageStats } from '../lib/api.js';
4
+ /** Accepts ISO or YYYY-MM-DD; returns full RFC3339 the API requires. */
5
+ function toIso(value, endOfDay = false) {
6
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
7
+ return value + (endOfDay ? 'T23:59:59Z' : 'T00:00:00Z');
8
+ }
9
+ const d = new Date(value);
10
+ if (isNaN(d.getTime()))
11
+ throw new Error(`Invalid time "${value}". Use ISO 8601 or YYYY-MM-DD.`);
12
+ return d.toISOString();
13
+ }
14
+ export function registerUsage(program) {
15
+ program
16
+ .command('usage')
17
+ .description('Usage statistics: spend, request counts, per-model breakdown (default: last 7 days)')
18
+ .option('--since <time>', 'Start of the window (ISO or YYYY-MM-DD)')
19
+ .option('--until <time>', 'End of the window (ISO or YYYY-MM-DD)')
20
+ .option('--model <uuid>', 'Restrict to one model')
21
+ .option('--json', 'Emit the raw stats as JSON')
22
+ .action(async (opts) => {
23
+ const spinner = !opts.json ? ora('Fetching usage…').start() : null;
24
+ try {
25
+ const now = new Date();
26
+ const start = opts.since
27
+ ? toIso(opts.since)
28
+ : new Date(now.getTime() - 7 * 24 * 3600 * 1000).toISOString();
29
+ const end = opts.until ? toIso(opts.until, true) : now.toISOString();
30
+ const stats = await fetchUsageStats(start, end, opts.model);
31
+ spinner?.stop();
32
+ if (opts.json) {
33
+ process.stdout.write(JSON.stringify(stats, null, 2) + '\n');
34
+ return;
35
+ }
36
+ const s = stats.summary;
37
+ const failed = s.total_requests - s.success_requests;
38
+ console.log();
39
+ console.log(chalk.bold('Usage ') +
40
+ chalk.gray(`${start.slice(0, 10)} → ${end.slice(0, 10)}`) +
41
+ (opts.since || opts.until ? '' : chalk.gray(' · last 7d (widen with --since)')));
42
+ console.log();
43
+ console.log(' ' + chalk.gray('total spend: ') + chalk.green(`$${s.total_cost.toFixed(4)}`));
44
+ console.log(' ' +
45
+ chalk.gray('requests: ') +
46
+ chalk.white(`${s.total_requests}`) +
47
+ chalk.gray(` (${s.success_requests} ok, ${failed} failed)`));
48
+ console.log();
49
+ const models = [...(stats.per_model_usage ?? [])].sort((a, b) => b.total_cost - a.total_cost);
50
+ if (models.length > 0) {
51
+ console.log(chalk.bold(' By model'));
52
+ for (const m of models.slice(0, 15)) {
53
+ console.log(' ' +
54
+ chalk.cyan(m.model_uuid.padEnd(44)) +
55
+ chalk.green(`$${m.total_cost.toFixed(4)}`.padStart(10)) +
56
+ (m.total_count ? chalk.gray(` ×${m.total_count}`) : ''));
57
+ }
58
+ if (models.length > 15)
59
+ console.log(chalk.gray(` … ${models.length - 15} more (use --json)`));
60
+ console.log();
61
+ }
62
+ }
63
+ catch (err) {
64
+ spinner?.fail(err.message ?? String(err));
65
+ if (opts.json) {
66
+ process.stdout.write(JSON.stringify({ error: err.message ?? String(err) }, null, 2) + '\n');
67
+ }
68
+ process.exit(1);
69
+ }
70
+ });
71
+ }
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();
@@ -0,0 +1,78 @@
1
+ // Turn `@`-prefixed local file paths in a model's input into hosted URLs.
2
+ //
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.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ function isRegularFile(p) {
13
+ try {
14
+ return fs.statSync(p).isFile();
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ /**
21
+ * Walk the input and list every `@path` value. Pure over `exists`, so the
22
+ * rules can be tested without touching the disk.
23
+ */
24
+ export function collectLocalFiles(input, _opts = {}) {
25
+ const found = [];
26
+ const walk = (node, at) => {
27
+ if (Array.isArray(node)) {
28
+ node.forEach((v, i) => walk(v, at ? `${at}.${i}` : String(i)));
29
+ return;
30
+ }
31
+ if (node && typeof node === 'object') {
32
+ for (const [k, v] of Object.entries(node)) {
33
+ walk(v, at ? `${at}.${k}` : k);
34
+ }
35
+ return;
36
+ }
37
+ if (typeof node !== 'string' || !node.startsWith('@'))
38
+ return;
39
+ found.push({ path: node.slice(1), at });
40
+ };
41
+ walk(input, '');
42
+ return found;
43
+ }
44
+ function setAt(root, dotted, value) {
45
+ const parts = dotted.split('.');
46
+ let cur = root;
47
+ for (let i = 0; i < parts.length - 1; i++)
48
+ cur = cur[parts[i]];
49
+ cur[parts[parts.length - 1]] = value;
50
+ }
51
+ /**
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.
57
+ */
58
+ export async function resolveLocalFiles(input, opts) {
59
+ const refs = collectLocalFiles(input, opts);
60
+ if (refs.length === 0)
61
+ return { input, uploaded: 0 };
62
+ const exists = opts.exists ?? isRegularFile;
63
+ const missing = refs.filter((r) => !exists(r.path));
64
+ if (missing.length > 0) {
65
+ throw new Error(`File not found: ${missing.map((m) => m.path).join(', ')}`);
66
+ }
67
+ const distinct = [...new Set(refs.map((r) => path.resolve(r.path)))];
68
+ const urls = new Map();
69
+ for (const [i, abs] of distinct.entries()) {
70
+ opts.onUpload?.(abs, i, distinct.length);
71
+ urls.set(abs, await opts.upload(abs));
72
+ }
73
+ const out = JSON.parse(JSON.stringify(input));
74
+ for (const ref of refs) {
75
+ setAt(out, ref.at, urls.get(path.resolve(ref.path)));
76
+ }
77
+ return { input: out, uploaded: distinct.length };
78
+ }
@@ -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.2.5",
3
+ "version": "0.4.0",
4
4
  "description": "WaveSpeed CLI — one command to run any WaveSpeed AI model from your terminal.",
5
5
  "publishConfig": {
6
6
  "access": "public"