@wavespeed/mcp 1.0.8 → 1.0.9

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/dist/lib/api.js CHANGED
@@ -64,9 +64,11 @@ async function httpError(res, method, apiPath) {
64
64
  catch {
65
65
  /* non-JSON body — fall back below */
66
66
  }
67
- if (detail)
68
- return new Error(detail);
69
- return new Error(`${method} ${apiPath} failed: ${res.status} ${res.statusText}`);
67
+ const err = detail
68
+ ? new Error(detail)
69
+ : new Error(`${method} ${apiPath} failed: ${res.status} ${res.statusText}`);
70
+ err.status = res.status;
71
+ return err;
70
72
  }
71
73
  function authHeaders() {
72
74
  const apiKey = getApiKey();
@@ -75,8 +77,14 @@ function authHeaders() {
75
77
  }
76
78
  return { Authorization: `Bearer ${apiKey}`, ...clientAttributionHeaders() };
77
79
  }
78
- async function apiGet(apiPath) {
79
- const res = await fetch(`${getBaseUrl()}${apiPath}`, { headers: authHeaders() });
80
+ // A hung socket would otherwise stall the tool call until the client gives
81
+ // up. 30s is far above a healthy read, so it only trips on a dead connection.
82
+ const GET_TIMEOUT_MS = 30_000;
83
+ async function apiGetOnce(apiPath) {
84
+ const res = await fetch(`${getBaseUrl()}${apiPath}`, {
85
+ headers: authHeaders(),
86
+ signal: AbortSignal.timeout(GET_TIMEOUT_MS),
87
+ });
80
88
  if (!res.ok)
81
89
  throw await httpError(res, 'GET', apiPath);
82
90
  const json = (await res.json());
@@ -84,6 +92,33 @@ async function apiGet(apiPath) {
84
92
  throw new Error(json.message || `API returned code ${json.code}`);
85
93
  return json.data;
86
94
  }
95
+ // Same set as wavespeed-python. An error with no HTTP status is a network
96
+ // failure or timeout; envelope/JSON errors are not transient.
97
+ const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
98
+ const MAX_GET_RETRIES = 5;
99
+ function isTransient(err) {
100
+ if (typeof err?.status === 'number')
101
+ return RETRYABLE_STATUS.has(err.status);
102
+ return err instanceof TypeError || err?.name === 'TimeoutError' || err?.name === 'AbortError';
103
+ }
104
+ // Read-only GETs only — submit (POST) is deliberately never retried.
105
+ async function apiGet(apiPath, opts = {}) {
106
+ const delay = opts.retryDelayMs ?? 1000;
107
+ for (let attempt = 0;; attempt++) {
108
+ try {
109
+ return await apiGetOnce(apiPath);
110
+ }
111
+ catch (err) {
112
+ const reason = err?.message ?? String(err);
113
+ if (!isTransient(err) && !opts.describe)
114
+ throw err;
115
+ if (!isTransient(err) || attempt >= MAX_GET_RETRIES) {
116
+ throw new Error(opts.describe ? opts.describe(reason, attempt + 1) : reason);
117
+ }
118
+ await new Promise((r) => setTimeout(r, delay * (attempt + 1)));
119
+ }
120
+ }
121
+ }
87
122
  async function apiPost(apiPath, body) {
88
123
  const res = await fetch(`${getBaseUrl()}${apiPath}`, {
89
124
  method: 'POST',
@@ -135,8 +170,15 @@ export async function fetchPricing(modelId, inputs) {
135
170
  export async function submitPrediction(model, input) {
136
171
  return apiPost(`/api/v3/${model}`, { ...input });
137
172
  }
138
- export async function fetchPrediction(id) {
139
- return apiGet(`/api/v3/predictions/${id}/result`);
173
+ // The message always carries the task id and says the task was not declared
174
+ // failed — a status read that dies after submit must not look like a lost
175
+ // (or failed) paid task.
176
+ export async function fetchPrediction(id, opts = {}) {
177
+ return apiGet(`/api/v3/predictions/${id}/result`, {
178
+ retryDelayMs: opts.retryDelayMs,
179
+ describe: (reason, attempts) => `Could not read the status of task ${id} after ${attempts} attempt${attempts > 1 ? 's' : ''}: ${reason}. ` +
180
+ `The task was created and may still be running — call get_prediction with id "${id}" to check it (task_id: ${id}).`,
181
+ });
140
182
  }
141
183
  export const TERMINAL_FAILURES = ['failed', 'cancelled', 'timeout', 'deleted'];
142
184
  /**
@@ -145,13 +187,13 @@ export const TERMINAL_FAILURES = ['failed', 'cancelled', 'timeout', 'deleted'];
145
187
  * budget runs out. Running out is NOT an error: generations can take hours,
146
188
  * so the caller gets the still-running prediction back (done=false) and
147
189
  * reports its id instead of losing it in a thrown message. `onTick` fires
148
- * after every non-terminal poll.
190
+ * after every non-terminal poll. Transient status-read failures are retried.
149
191
  */
150
192
  export async function waitForPrediction(id, opts = {}) {
151
193
  const interval = opts.intervalMs ?? 1000;
152
194
  const deadline = opts.timeoutMs !== undefined ? Date.now() + opts.timeoutMs : undefined;
153
195
  for (;;) {
154
- const item = await fetchPrediction(id);
196
+ const item = await fetchPrediction(id, { retryDelayMs: opts.retryDelayMs });
155
197
  if (item.status === 'completed' || TERMINAL_FAILURES.includes(item.status)) {
156
198
  return { item, done: true };
157
199
  }
@@ -16,6 +16,31 @@ export function requestSchemaProps(model) {
16
16
  const keys = Object.keys(props);
17
17
  return keys.length > 0 ? keys : null;
18
18
  }
19
+ /**
20
+ * Legacy field names a model still accepts, as `legacy -> canonical`.
21
+ *
22
+ * The API renames public fields without breaking old callers by declaring the
23
+ * old name in the property's `x-aliases` (e.g. `last_image` carries
24
+ * `["end_image"]`) and folding it into the canonical name before validation.
25
+ * A legacy name is therefore a valid input, not a typo — it just is not listed
26
+ * as an accepted input, so schema-driven UIs show only the current name.
27
+ */
28
+ export function requestSchemaAliases(model) {
29
+ const aliases = new Map();
30
+ const props = model?.api_schema?.api_schemas?.[0]?.request_schema?.properties;
31
+ if (!props)
32
+ return aliases;
33
+ for (const [name, def] of Object.entries(props)) {
34
+ const declared = def?.['x-aliases'];
35
+ if (!Array.isArray(declared))
36
+ continue;
37
+ for (const alias of declared) {
38
+ if (typeof alias === 'string' && alias !== name)
39
+ aliases.set(alias, name);
40
+ }
41
+ }
42
+ return aliases;
43
+ }
19
44
  // Small edit distance for typo hints — inputs are short snake_case tokens, so
20
45
  // a full Levenshtein over ≤40 keys is nothing.
21
46
  function editDistance(a, b) {
@@ -49,7 +74,8 @@ export function findUnknownInputs(input, model) {
49
74
  if (!known)
50
75
  return null;
51
76
  const knownSet = new Set(known);
52
- const unknown = Object.keys(input).filter((k) => !knownSet.has(k));
77
+ const aliases = requestSchemaAliases(model);
78
+ const unknown = Object.keys(input).filter((k) => !knownSet.has(k) && !aliases.has(k));
53
79
  if (unknown.length === 0)
54
80
  return null;
55
81
  const suggestions = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavespeed/mcp",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "WaveSpeed MCP server — run any model on the live WaveSpeed catalog (image, video, audio, 3D) with schema introspection, local-file upload, and price quotes.",
5
5
  "type": "module",
6
6
  "bin": {