@wavespeed/cli 0.4.3 → 0.4.5

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
@@ -195,3 +195,8 @@ Stack: TypeScript, Commander, Inquirer, Chalk, Ora, the official `wavespeed` SDK
195
195
  ## License
196
196
 
197
197
  MIT — see [LICENSE](LICENSE).
198
+
199
+ ---
200
+
201
+ **[WaveSpeed AI](https://wavespeed.ai/)** — AI image & video generation platform.
202
+ Try it in the browser: **[Image generator](https://wavespeed.ai/image-generator)** · **[Video generator](https://wavespeed.ai/video-generator)**
@@ -3,9 +3,10 @@ import ora from 'ora';
3
3
  import path from 'node:path';
4
4
  import fs from 'node:fs';
5
5
  import { requireClient } from '../lib/client.js';
6
- import { parseInputs } from '../lib/inputs.js';
6
+ import { parseInputs, withInputSyntaxHint } from '../lib/inputs.js';
7
7
  import { resolveLocalFiles } from '../lib/local-files.js';
8
- import { submitPrediction, waitForPrediction } from '../lib/api.js';
8
+ import { fetchModels, submitPrediction, waitForPrediction } from '../lib/api.js';
9
+ import { findUnknownInputs } from '../lib/validate-inputs.js';
9
10
  import { uploadWithCache } from '../lib/upload-cache.js';
10
11
  import { downloadOutputs, printOutputs } from '../lib/output.js';
11
12
  import { emitJson, emitJsonError, isJsonMode, log, setJsonMode } from '../lib/log.js';
@@ -74,6 +75,7 @@ export function registerRun(program) {
74
75
  .option('--input-file <path>', 'JSON file with full input object')
75
76
  .option('--download [path]', 'Save outputs locally (optional path template, e.g. "./out/{index}.{ext}")')
76
77
  .option('--sync', 'Attempt sync mode; timed-out tasks can still be queried later')
78
+ .option('--no-validate', 'Skip pre-submit input validation against the model schema')
77
79
  .option('--json', 'Emit a single JSON object on stdout (progress goes to stderr)')
78
80
  .option('--output-dir <dir>', 'Directory for --download when no path is given')
79
81
  .action(async (modelArg, opts) => {
@@ -97,6 +99,50 @@ export function registerRun(program) {
97
99
  Object.assign(input, parseInputs(opts.input ?? []));
98
100
  if (opts.prompt)
99
101
  input.prompt = opts.prompt;
102
+ // Reject inputs the model's schema does not declare BEFORE anything
103
+ // costs money. The API's entry whitelist silently drops unknown keys,
104
+ // so a typo doesn't fail — it bills a generation that ignored the
105
+ // parameter. Validation fails open (no schema → no check) and, to
106
+ // avoid stale-cache false positives, re-fetches the catalog once
107
+ // before rejecting.
108
+ if (opts.validate !== false) {
109
+ try {
110
+ let { models } = await fetchModels();
111
+ let found = models.find((m) => m.model_id === model);
112
+ let report = findUnknownInputs(input, found);
113
+ if (report) {
114
+ ({ models } = await fetchModels({ refresh: true }));
115
+ found = models.find((m) => m.model_id === model);
116
+ report = findUnknownInputs(input, found);
117
+ }
118
+ if (report) {
119
+ const lines = report.unknown.map((k) => {
120
+ const hint = report.suggestions.get(k);
121
+ return ` ${k}` + (hint ? ` (did you mean \`${hint}\`?)` : '');
122
+ });
123
+ const msg = `Model ${model} does not accept: ${report.unknown.join(', ')}. ` +
124
+ `The API silently drops unknown inputs, so this would bill a run that ignores them.`;
125
+ if (isJsonMode()) {
126
+ emitJsonError(msg, { unknown_inputs: report.unknown, accepted_inputs: report.known });
127
+ }
128
+ else {
129
+ console.error(chalk.red('Error: ') + `unknown input${report.unknown.length > 1 ? 's' : ''} for ` + chalk.cyan(model) + ':');
130
+ for (const l of lines)
131
+ console.error(chalk.yellow(l));
132
+ console.error(chalk.gray(' The API silently drops unknown inputs — the run would be billed with them ignored.'));
133
+ console.error(chalk.gray(' Accepted inputs: ') + report.known.join(', '));
134
+ console.error(chalk.gray(' Full schema: ') + chalk.cyan(`wavespeed schema ${model}`));
135
+ console.error(chalk.gray(' Bypass check: ') + chalk.cyan('--no-validate'));
136
+ }
137
+ process.exit(1);
138
+ }
139
+ }
140
+ catch {
141
+ // Catalog unreachable — validation is best-effort, never a blocker.
142
+ // (The unknown-input rejection above exits the process directly and
143
+ // is not routed through here.)
144
+ }
145
+ }
100
146
  // `@path` inputs become hosted URLs before submission. Only the
101
147
  // explicit @ marker uploads — the CLI never guesses that a bare value
102
148
  // was meant to be a file.
@@ -137,7 +183,7 @@ export function registerRun(program) {
137
183
  submitted = await submitPrediction(model, input, { sync: !!opts.sync });
138
184
  }
139
185
  catch (err) {
140
- const message = err.message ?? String(err);
186
+ const message = withInputSyntaxHint(err.message ?? String(err), opts.input);
141
187
  spinner.fail(message);
142
188
  if (isJsonMode())
143
189
  emitJsonError(message);
@@ -2,8 +2,9 @@
2
2
  // - bare strings stay strings
3
3
  // - "123" / "1.5" parsed as number
4
4
  // - "true" / "false" parsed as boolean
5
- // - "[a,b,c]" parsed as array of strings
6
- // - JSON values (starting with { or [) parsed as JSON
5
+ // - values starting with { or [ are parsed as JSON, and stay strings if that
6
+ // fails — so an array has to be written as valid JSON: '["a","b"]'.
7
+ // A bare "[a,b,c]" is not JSON and reaches the API as a string.
7
8
  // - keys with dots like "size.width" become nested
8
9
  function coerce(raw) {
9
10
  if (raw === 'true')
@@ -50,3 +51,16 @@ function setPath(obj, dotted, value) {
50
51
  }
51
52
  cur[parts[parts.length - 1]] = value;
52
53
  }
54
+ /**
55
+ * The API rejects a scalar sent to an array field. The message says what is
56
+ * wrong but not how to write it, and `-i key=value` gives no hint that an
57
+ * array needs JSON — so point at the fix rather than mutating the input.
58
+ */
59
+ export function withInputSyntaxHint(message, pairs = []) {
60
+ const field = /field "([^"]+)" must be an array/.exec(message)?.[1];
61
+ if (!field)
62
+ return message;
63
+ const given = pairs.find((p) => p.slice(0, p.indexOf('=')).trim() === field);
64
+ const sample = given ? given.slice(given.indexOf('=') + 1) : 'value';
65
+ return `${message}\nWrite arrays as JSON: -i '${field}=["${sample}"]'`;
66
+ }
@@ -0,0 +1,62 @@
1
+ // Pre-submit input validation against a model's published request schema.
2
+ //
3
+ // The API's entry whitelist silently DROPS any input key that is not in the
4
+ // model's schema — a typo like `size=2560*1440` against a model whose real
5
+ // knobs are `aspect_ratio`/`resolution` doesn't fail, it just quietly bills a
6
+ // generation with defaults. Catching that before submission is the only
7
+ // moment the mistake is still free.
8
+ //
9
+ // Validation fails OPEN: no schema (brand-new model, catalog lag, offline
10
+ // cache miss) means no check. It must never block a submission the API would
11
+ // have accepted.
12
+ export function requestSchemaProps(model) {
13
+ const props = model?.api_schema?.api_schemas?.[0]?.request_schema?.properties;
14
+ if (!props)
15
+ return null;
16
+ const keys = Object.keys(props);
17
+ return keys.length > 0 ? keys : null;
18
+ }
19
+ // Small edit distance for typo hints — inputs are short snake_case tokens, so
20
+ // a full Levenshtein over ≤40 keys is nothing.
21
+ function editDistance(a, b) {
22
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
23
+ for (let j = 0; j <= b.length; j++)
24
+ dp[0][j] = j;
25
+ for (let i = 1; i <= a.length; i++)
26
+ for (let j = 1; j <= b.length; j++)
27
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
28
+ return dp[a.length][b.length];
29
+ }
30
+ function closest(key, known) {
31
+ let best;
32
+ let bestD = Infinity;
33
+ for (const k of known) {
34
+ const d = editDistance(key.toLowerCase(), k.toLowerCase());
35
+ if (d < bestD) {
36
+ bestD = d;
37
+ best = k;
38
+ }
39
+ }
40
+ // A hint is only a hint when it's plausibly the same word.
41
+ return bestD <= Math.max(2, Math.floor(key.length / 3)) ? best : undefined;
42
+ }
43
+ /**
44
+ * Compare top-level input keys with the schema's properties.
45
+ * Returns null when everything checks out or no schema is available.
46
+ */
47
+ export function findUnknownInputs(input, model) {
48
+ const known = requestSchemaProps(model);
49
+ if (!known)
50
+ return null;
51
+ const knownSet = new Set(known);
52
+ const unknown = Object.keys(input).filter((k) => !knownSet.has(k));
53
+ if (unknown.length === 0)
54
+ return null;
55
+ const suggestions = new Map();
56
+ for (const k of unknown) {
57
+ const hit = closest(k, known);
58
+ if (hit)
59
+ suggestions.set(k, hit);
60
+ }
61
+ return { unknown, known, suggestions };
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavespeed/cli",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "WaveSpeed CLI \u2014 one command to run any WaveSpeed AI model from your terminal.",
5
5
  "publishConfig": {
6
6
  "access": "public"