@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 +3 -1
- package/dist/cli.js +4 -0
- package/dist/commands/billings.js +81 -0
- package/dist/commands/delete.js +11 -1
- package/dist/commands/history.js +14 -3
- package/dist/commands/price.js +59 -3
- package/dist/commands/run.js +96 -33
- package/dist/commands/upload.js +3 -2
- package/dist/commands/usage.js +71 -0
- package/dist/lib/api.js +67 -2
- package/dist/lib/config.js +15 -1
- package/dist/lib/local-files.js +78 -0
- package/dist/lib/upload-cache.js +58 -0
- package/package.json +1 -1
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
|
|
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
|
+
}
|
package/dist/commands/delete.js
CHANGED
|
@@ -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
|
-
|
|
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,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,
|
|
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.'));
|
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 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
|
-
|
|
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
|
@@ -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).
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
log(chalk.gray('
|
|
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,
|
|
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,
|
package/dist/commands/upload.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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();
|
|
@@ -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
|
+
}
|