@wavespeed/mcp 1.0.8 → 1.0.10
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 +66 -11
- package/dist/lib/validate-inputs.js +27 -1
- package/package.json +2 -2
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,17 @@ function authHeaders() {
|
|
|
75
77
|
}
|
|
76
78
|
return { Authorization: `Bearer ${apiKey}`, ...clientAttributionHeaders() };
|
|
77
79
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
// Per-request timeouts (no overall cap). A hung socket would otherwise stall
|
|
81
|
+
// the tool call. Read-only GETs are far below 30s in practice (result p99.9
|
|
82
|
+
// < 1s); the async submit gets more headroom because a timeout there is an
|
|
83
|
+
// ambiguous outcome, not a clean failure.
|
|
84
|
+
export const GET_TIMEOUT_MS = 30_000;
|
|
85
|
+
export const SUBMIT_TIMEOUT_MS = 60_000;
|
|
86
|
+
async function apiGetOnce(apiPath) {
|
|
87
|
+
const res = await fetch(`${getBaseUrl()}${apiPath}`, {
|
|
88
|
+
headers: authHeaders(),
|
|
89
|
+
signal: AbortSignal.timeout(GET_TIMEOUT_MS),
|
|
90
|
+
});
|
|
80
91
|
if (!res.ok)
|
|
81
92
|
throw await httpError(res, 'GET', apiPath);
|
|
82
93
|
const json = (await res.json());
|
|
@@ -84,11 +95,39 @@ async function apiGet(apiPath) {
|
|
|
84
95
|
throw new Error(json.message || `API returned code ${json.code}`);
|
|
85
96
|
return json.data;
|
|
86
97
|
}
|
|
87
|
-
|
|
98
|
+
// Same set as wavespeed-python. An error with no HTTP status is a network
|
|
99
|
+
// failure or timeout; envelope/JSON errors are not transient.
|
|
100
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
101
|
+
const MAX_GET_RETRIES = 5;
|
|
102
|
+
function isTransient(err) {
|
|
103
|
+
if (typeof err?.status === 'number')
|
|
104
|
+
return RETRYABLE_STATUS.has(err.status);
|
|
105
|
+
return err instanceof TypeError || err?.name === 'TimeoutError' || err?.name === 'AbortError';
|
|
106
|
+
}
|
|
107
|
+
// Read-only GETs only — submit (POST) is deliberately never retried.
|
|
108
|
+
async function apiGet(apiPath, opts = {}) {
|
|
109
|
+
const delay = opts.retryDelayMs ?? 1000;
|
|
110
|
+
for (let attempt = 0;; attempt++) {
|
|
111
|
+
try {
|
|
112
|
+
return await apiGetOnce(apiPath);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
const reason = err?.message ?? String(err);
|
|
116
|
+
if (!isTransient(err) && !opts.describe)
|
|
117
|
+
throw err;
|
|
118
|
+
if (!isTransient(err) || attempt >= MAX_GET_RETRIES) {
|
|
119
|
+
throw new Error(opts.describe ? opts.describe(reason, attempt + 1) : reason);
|
|
120
|
+
}
|
|
121
|
+
await new Promise((r) => setTimeout(r, delay * (attempt + 1)));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function apiPost(apiPath, body, opts = {}) {
|
|
88
126
|
const res = await fetch(`${getBaseUrl()}${apiPath}`, {
|
|
89
127
|
method: 'POST',
|
|
90
128
|
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
|
91
129
|
body: JSON.stringify(body),
|
|
130
|
+
signal: opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined,
|
|
92
131
|
});
|
|
93
132
|
if (!res.ok)
|
|
94
133
|
throw await httpError(res, 'POST', apiPath);
|
|
@@ -133,10 +172,26 @@ export async function fetchPricing(modelId, inputs) {
|
|
|
133
172
|
* mid-generation must never orphan a paid task.
|
|
134
173
|
*/
|
|
135
174
|
export async function submitPrediction(model, input) {
|
|
136
|
-
|
|
175
|
+
try {
|
|
176
|
+
return await apiPost(`/api/v3/${model}`, { ...input }, { timeoutMs: SUBMIT_TIMEOUT_MS });
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
// Never retried: the server may already have created (and billed) the task.
|
|
180
|
+
if (err?.name === 'TimeoutError') {
|
|
181
|
+
throw new Error(`Submission outcome unknown: no response within ${SUBMIT_TIMEOUT_MS / 1000}s. The task may have been created and billed — do not resubmit blindly; if you have a prediction id, check it with get_prediction.`);
|
|
182
|
+
}
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
137
185
|
}
|
|
138
|
-
|
|
139
|
-
|
|
186
|
+
// The message always carries the task id and says the task was not declared
|
|
187
|
+
// failed — a status read that dies after submit must not look like a lost
|
|
188
|
+
// (or failed) paid task.
|
|
189
|
+
export async function fetchPrediction(id, opts = {}) {
|
|
190
|
+
return apiGet(`/api/v3/predictions/${id}/result`, {
|
|
191
|
+
retryDelayMs: opts.retryDelayMs,
|
|
192
|
+
describe: (reason, attempts) => `Could not read the status of task ${id} after ${attempts} attempt${attempts > 1 ? 's' : ''}: ${reason}. ` +
|
|
193
|
+
`The task was created and may still be running — call get_prediction with id "${id}" to check it (task_id: ${id}).`,
|
|
194
|
+
});
|
|
140
195
|
}
|
|
141
196
|
export const TERMINAL_FAILURES = ['failed', 'cancelled', 'timeout', 'deleted'];
|
|
142
197
|
/**
|
|
@@ -145,13 +200,13 @@ export const TERMINAL_FAILURES = ['failed', 'cancelled', 'timeout', 'deleted'];
|
|
|
145
200
|
* budget runs out. Running out is NOT an error: generations can take hours,
|
|
146
201
|
* so the caller gets the still-running prediction back (done=false) and
|
|
147
202
|
* reports its id instead of losing it in a thrown message. `onTick` fires
|
|
148
|
-
* after every non-terminal poll.
|
|
203
|
+
* after every non-terminal poll. Transient status-read failures are retried.
|
|
149
204
|
*/
|
|
150
205
|
export async function waitForPrediction(id, opts = {}) {
|
|
151
206
|
const interval = opts.intervalMs ?? 1000;
|
|
152
207
|
const deadline = opts.timeoutMs !== undefined ? Date.now() + opts.timeoutMs : undefined;
|
|
153
208
|
for (;;) {
|
|
154
|
-
const item = await fetchPrediction(id);
|
|
209
|
+
const item = await fetchPrediction(id, { retryDelayMs: opts.retryDelayMs });
|
|
155
210
|
if (item.status === 'completed' || TERMINAL_FAILURES.includes(item.status)) {
|
|
156
211
|
return { item, done: true };
|
|
157
212
|
}
|
|
@@ -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.10",
|
|
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": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
],
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
42
|
-
"wavespeed": "^0.3.
|
|
42
|
+
"wavespeed": "^0.3.4",
|
|
43
43
|
"zod": "^3.24.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|