@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 +5 -3
- package/dist/cli.js +4 -0
- package/dist/commands/billings.js +82 -0
- package/dist/commands/delete.js +12 -2
- package/dist/commands/history.js +19 -7
- package/dist/commands/price.js +59 -3
- package/dist/commands/run.js +71 -38
- package/dist/commands/upload.js +4 -3
- package/dist/commands/usage.js +62 -0
- package/dist/lib/api.js +67 -2
- package/dist/lib/config.js +15 -1
- package/dist/lib/local-files.js +24 -62
- package/dist/lib/time.js +16 -0
- package/dist/lib/upload-cache.js +58 -0
- package/package.json +1 -1
- package/skills/SKILL.md +12 -9
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
|
|
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
|
|
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
|
+
}
|
package/dist/commands/delete.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/commands/history.js
CHANGED
|
@@ -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,
|
|
38
|
-
.option('--since <
|
|
39
|
-
.option('--until <
|
|
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.'));
|
package/dist/commands/price.js
CHANGED
|
@@ -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
|
-
|
|
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(`$${
|
|
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();
|
package/dist/commands/run.js
CHANGED
|
@@ -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).
|
|
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
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
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(
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
log(chalk.gray('
|
|
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,
|
|
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,
|
package/dist/commands/upload.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/lib/config.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/lib/local-files.js
CHANGED
|
@@ -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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
47
|
-
*
|
|
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,
|
|
50
|
-
const exists = opts.exists ?? isRegularFile;
|
|
24
|
+
export function collectLocalFiles(input, _opts = {}) {
|
|
51
25
|
const found = [];
|
|
52
|
-
const walk = (node, at
|
|
26
|
+
const walk = (node, at) => {
|
|
53
27
|
if (Array.isArray(node)) {
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
93
|
-
* the caller's input is not mutated.
|
|
94
|
-
*
|
|
95
|
-
* upload
|
|
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
|
|
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
|
}
|
package/dist/lib/time.js
ADDED
|
@@ -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
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 —
|
|
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=
|
|
45
|
+
-i images='["@./input.jpg"]' --json
|
|
47
46
|
|
|
48
|
-
# Image-to-video — same
|
|
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
|
|
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.
|
|
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
|
|
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).
|