@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 +51 -9
- package/dist/lib/validate-inputs.js +27 -1
- package/package.json +1 -1
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
|
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.
|
|
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": {
|