@wavespeed/mcp 1.2.4 → 1.2.6
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 +27 -17
- package/dist/lib/config.js +11 -0
- package/dist/lib/delete-predictions.js +5 -6
- package/dist/lib/ids.js +16 -0
- package/dist/lib/media-plan.js +84 -9
- package/dist/lib/upload-cache.js +16 -12
- package/dist/lib/validate-inputs.js +8 -1
- package/dist/server.js +40 -16
- package/package.json +1 -1
package/dist/lib/api.js
CHANGED
|
@@ -7,7 +7,7 @@ import fs from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
10
|
-
import { getApiKey, getBaseUrl } from './config.js';
|
|
10
|
+
import { callerCacheId, getApiKey, getBaseUrl } from './config.js';
|
|
11
11
|
const require = createRequire(import.meta.url);
|
|
12
12
|
const { version: PKG_VERSION } = require('../../package.json');
|
|
13
13
|
// Channel-attribution headers (X-Client-Name / X-Client-Version / X-Client-OS),
|
|
@@ -24,12 +24,13 @@ export function clientAttributionHeaders() {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// One file per base URL + API key: the catalog differs per caller.
|
|
28
|
+
function cachePath(cacheId) {
|
|
29
|
+
return path.join(os.homedir(), '.cache', 'wavespeed', `models-${cacheId}.json`);
|
|
29
30
|
}
|
|
30
|
-
function readCache(baseUrl) {
|
|
31
|
+
function readCache(baseUrl, cacheId) {
|
|
31
32
|
try {
|
|
32
|
-
const parsed = JSON.parse(fs.readFileSync(cachePath(), 'utf8'));
|
|
33
|
+
const parsed = JSON.parse(fs.readFileSync(cachePath(cacheId), 'utf8'));
|
|
33
34
|
if (parsed.base_url !== baseUrl)
|
|
34
35
|
return null;
|
|
35
36
|
if (Date.now() - parsed.fetched_at > CACHE_TTL_MS)
|
|
@@ -40,11 +41,11 @@ function readCache(baseUrl) {
|
|
|
40
41
|
return null;
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
|
-
function writeCache(baseUrl, models) {
|
|
44
|
+
function writeCache(baseUrl, cacheId, models) {
|
|
44
45
|
try {
|
|
45
|
-
fs.mkdirSync(path.dirname(cachePath()), { recursive: true });
|
|
46
|
+
fs.mkdirSync(path.dirname(cachePath(cacheId)), { recursive: true });
|
|
46
47
|
const body = { fetched_at: Date.now(), base_url: baseUrl, models };
|
|
47
|
-
fs.writeFileSync(cachePath(), JSON.stringify(body));
|
|
48
|
+
fs.writeFileSync(cachePath(cacheId), JSON.stringify(body));
|
|
48
49
|
}
|
|
49
50
|
catch {
|
|
50
51
|
/* best-effort cache; ignore failures */
|
|
@@ -142,21 +143,22 @@ async function apiPost(apiPath, body, opts = {}) {
|
|
|
142
143
|
let memCache = null;
|
|
143
144
|
export async function fetchModels(opts = {}) {
|
|
144
145
|
const baseUrl = getBaseUrl();
|
|
146
|
+
const cacheId = callerCacheId(baseUrl, getApiKey());
|
|
145
147
|
if (!opts.refresh) {
|
|
146
148
|
if (memCache &&
|
|
147
|
-
memCache.
|
|
149
|
+
memCache.cacheId === cacheId &&
|
|
148
150
|
Date.now() - memCache.fetchedAt <= CACHE_TTL_MS) {
|
|
149
151
|
return memCache.models;
|
|
150
152
|
}
|
|
151
|
-
const cached = readCache(baseUrl);
|
|
153
|
+
const cached = readCache(baseUrl, cacheId);
|
|
152
154
|
if (cached) {
|
|
153
|
-
memCache = {
|
|
155
|
+
memCache = { cacheId, fetchedAt: cached.fetched_at, models: cached.models };
|
|
154
156
|
return cached.models;
|
|
155
157
|
}
|
|
156
158
|
}
|
|
157
159
|
const models = await apiGet('/api/v3/models');
|
|
158
|
-
writeCache(baseUrl, models);
|
|
159
|
-
memCache = {
|
|
160
|
+
writeCache(baseUrl, cacheId, models);
|
|
161
|
+
memCache = { cacheId, fetchedAt: Date.now(), models };
|
|
160
162
|
return models;
|
|
161
163
|
}
|
|
162
164
|
export async function fetchBalance() {
|
|
@@ -236,9 +238,18 @@ export async function fetchPrediction(id, opts = {}) {
|
|
|
236
238
|
});
|
|
237
239
|
}
|
|
238
240
|
export const TERMINAL_FAILURES = ['failed', 'cancelled', 'timeout', 'deleted'];
|
|
241
|
+
// The only statuses of a run that is still going. Anything else is final:
|
|
242
|
+
// completed, a failure, or a status this client does not know — including the
|
|
243
|
+
// empty status of a row whose outputs were archived (the API then sends empty
|
|
244
|
+
// outputs and an error saying they expired). Waiting on those would never end.
|
|
245
|
+
export const PENDING_STATUSES = ['created', 'processing'];
|
|
246
|
+
export function isPending(status) {
|
|
247
|
+
return PENDING_STATUSES.includes(status ?? '');
|
|
248
|
+
}
|
|
239
249
|
/**
|
|
240
|
-
* Poll a prediction until it
|
|
241
|
-
* completed or
|
|
250
|
+
* Poll a prediction until it is no longer pending (done=true, whether
|
|
251
|
+
* completed, failed or an unknown/empty status — the caller decides what it
|
|
252
|
+
* means, and gets the API's error with it) or the wait
|
|
242
253
|
* budget runs out. Running out is NOT an error: generations can take hours,
|
|
243
254
|
* so the caller gets the still-running prediction back (done=false) and
|
|
244
255
|
* reports its id instead of losing it in a thrown message. `onTick` fires
|
|
@@ -249,9 +260,8 @@ export async function waitForPrediction(id, opts = {}) {
|
|
|
249
260
|
const deadline = opts.timeoutMs !== undefined ? Date.now() + opts.timeoutMs : undefined;
|
|
250
261
|
for (;;) {
|
|
251
262
|
const item = await fetchPrediction(id, { retryDelayMs: opts.retryDelayMs });
|
|
252
|
-
if (
|
|
263
|
+
if (!isPending(item.status))
|
|
253
264
|
return { item, done: true };
|
|
254
|
-
}
|
|
255
265
|
await opts.onTick?.(item);
|
|
256
266
|
if (opts.signal?.aborted)
|
|
257
267
|
return { item, done: false };
|
package/dist/lib/config.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import os from 'node:os';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
10
11
|
export const DEFAULT_BASE_URL = 'https://api.wavespeed.ai';
|
|
11
12
|
// Mirrors conf's env-paths resolution (projectName "wavespeed", suffix
|
|
12
13
|
// "nodejs"), which is per-platform — a Linux-only path would miss every
|
|
@@ -45,3 +46,13 @@ export function requireApiKey() {
|
|
|
45
46
|
}
|
|
46
47
|
return key;
|
|
47
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Names the caller's own cache files (catalog, uploads): the first 16 hex of
|
|
51
|
+
* sha256(base_url + "\n" + api_key). The model catalog and the uploads
|
|
52
|
+
* differ per account, so two keys on one machine must not share a cache. Only
|
|
53
|
+
* the hash is ever written; the same scheme as the wavespeed CLI, which shares
|
|
54
|
+
* ~/.cache/wavespeed.
|
|
55
|
+
*/
|
|
56
|
+
export function callerCacheId(baseUrl, apiKey) {
|
|
57
|
+
return crypto.createHash('sha256').update(`${baseUrl}\n${apiKey ?? ''}`).digest('hex').slice(0, 16);
|
|
58
|
+
}
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
// delete_predictions: the checks and the report around POST /api/v3/predictions/delete.
|
|
2
|
-
// The API only touches the key's own organization and returns the ids it deleted.
|
|
2
|
+
// The API only touches the key's own organization and returns the ids it deleted. Deleting is a soft
|
|
3
|
+
// delete: the record stays, with status "deleted" and no outputs.
|
|
4
|
+
import { checkPredictionId } from './ids.js';
|
|
3
5
|
export const MAX_DELETE_IDS = 100;
|
|
4
|
-
const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
5
6
|
/** The ids to send: trimmed, unique, valid, and only once the user agreed (confirm). */
|
|
6
7
|
export function prepareDelete(ids, confirm) {
|
|
7
8
|
const unique = [];
|
|
8
9
|
for (const raw of ids) {
|
|
9
|
-
const id = raw
|
|
10
|
-
if (!ID_PATTERN.test(id))
|
|
11
|
-
throw new Error(`Invalid prediction id "${raw}".`);
|
|
10
|
+
const id = checkPredictionId(raw);
|
|
12
11
|
if (!unique.includes(id))
|
|
13
12
|
unique.push(id);
|
|
14
13
|
}
|
|
@@ -16,7 +15,7 @@ export function prepareDelete(ids, confirm) {
|
|
|
16
15
|
throw new Error(`Pass between 1 and ${MAX_DELETE_IDS} prediction ids.`);
|
|
17
16
|
}
|
|
18
17
|
if (!confirm) {
|
|
19
|
-
throw new Error(`Nothing was deleted. This
|
|
18
|
+
throw new Error(`Nothing was deleted. This marks ${unique.length} prediction(s) as deleted (their outputs are no longer returned) and cannot be undone here: ask the user, then call again with confirm: true.`);
|
|
20
19
|
}
|
|
21
20
|
return unique;
|
|
22
21
|
}
|
package/dist/lib/ids.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Ids that go into an API path, checked before the request is built: the same
|
|
2
|
+
// patterns as the remote MCP (api-server handler/mcp_tools.go mcpModelIDPattern
|
|
3
|
+
// and mcpPredictionIDPattern), so a stray "../" or "?" never reaches the URL.
|
|
4
|
+
export const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*){1,3}$/;
|
|
5
|
+
export const PREDICTION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
6
|
+
export function checkModelId(id) {
|
|
7
|
+
if (!MODEL_ID_PATTERN.test(id))
|
|
8
|
+
throw new Error(`Invalid model id "${id}". Use an id returned by list_models.`);
|
|
9
|
+
return id;
|
|
10
|
+
}
|
|
11
|
+
export function checkPredictionId(raw) {
|
|
12
|
+
const id = raw.trim();
|
|
13
|
+
if (!PREDICTION_ID_PATTERN.test(id))
|
|
14
|
+
throw new Error(`Invalid prediction id "${raw}".`);
|
|
15
|
+
return id;
|
|
16
|
+
}
|
package/dist/lib/media-plan.js
CHANGED
|
@@ -5,16 +5,73 @@
|
|
|
5
5
|
// the same words give the same model, endpoint and input on both.
|
|
6
6
|
import { DEFAULT_IMAGE_MODEL, DEFAULT_VIDEO_MODEL, IMAGE_MODELS, IMAGE_MODEL_KEYS, VIDEO_MODELS, VIDEO_MODEL_KEYS, referenceFields, videoEndpoints, } from './media-models.js';
|
|
7
7
|
import { renameRefs } from './prompt-refs.js';
|
|
8
|
-
|
|
8
|
+
// Size tiers: 480p, 720p, 1080p (rank = N) and 0.5k, 1k, 2k, 4k (rank = X*1000),
|
|
9
|
+
// so 480p < 720p < 768p < 1080p < 2k < 4k on one scale.
|
|
10
|
+
function tierRank(v) {
|
|
11
|
+
if (typeof v !== 'string')
|
|
12
|
+
return undefined;
|
|
13
|
+
const m = /^(\d+(?:\.\d+)?)([pk])$/i.exec(v.trim());
|
|
14
|
+
if (!m)
|
|
15
|
+
return undefined;
|
|
16
|
+
const n = Number(m[1]);
|
|
17
|
+
return m[2].toLowerCase() === 'k' ? n * 1000 : n;
|
|
18
|
+
}
|
|
19
|
+
// ln(W/H) of a W:H ratio, so 2:1 and 1:2 are equally far from 1:1.
|
|
20
|
+
function ratioLog(v) {
|
|
21
|
+
if (typeof v !== 'string')
|
|
22
|
+
return undefined;
|
|
23
|
+
const m = /^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/.exec(v.trim());
|
|
24
|
+
if (!m)
|
|
25
|
+
return undefined;
|
|
26
|
+
const w = Number(m[1]);
|
|
27
|
+
const h = Number(m[2]);
|
|
28
|
+
return w > 0 && h > 0 ? Math.log(w / h) : undefined;
|
|
29
|
+
}
|
|
30
|
+
// Distances within this count as a tie (4:3 and 3:4 around 1:1 differ only in the last bit).
|
|
31
|
+
const TIE_EPSILON = 1e-9;
|
|
32
|
+
/** The member whose score is closest to target; on a tie, the one `better` prefers (default: the first). */
|
|
33
|
+
function closestMember(members, score, target, better = () => false) {
|
|
34
|
+
let best;
|
|
35
|
+
for (const v of members) {
|
|
36
|
+
const s = score(v);
|
|
37
|
+
if (s === undefined)
|
|
38
|
+
continue;
|
|
39
|
+
const d = Math.abs(s - target);
|
|
40
|
+
if (!best || d < best.d - TIE_EPSILON || (Math.abs(d - best.d) <= TIE_EPSILON && better({ v, s }, best)))
|
|
41
|
+
best = { v, s, d };
|
|
42
|
+
}
|
|
43
|
+
return best?.v;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Keep only fields the model accepts and fit each value to its schema. The
|
|
47
|
+
* rule is shared with the remote MCP (api-server) and the dashboard /chat:
|
|
48
|
+
* - a size tier (resolution, or an enum of Np / Nk values) the model does not
|
|
49
|
+
* offer becomes the closest tier it does; a tie goes to the lower one;
|
|
50
|
+
* - a W:H aspect ratio becomes the closest ratio (in ln(W/H)) among the
|
|
51
|
+
* members that are W:H; a tie goes to the first in enum order;
|
|
52
|
+
* - any other value outside an enum is left out, so the model default applies;
|
|
53
|
+
* - numbers go to the nearest numeric enum value, or are rounded (integers)
|
|
54
|
+
* and clamped to the range.
|
|
55
|
+
* Every value not sent as requested gets a note.
|
|
56
|
+
*/
|
|
9
57
|
export async function fitInput(modelId, wanted, propsOf, showDefaults = []) {
|
|
10
58
|
const properties = await propsOf(modelId);
|
|
11
59
|
if (!properties)
|
|
12
60
|
throw new Error(`No schema for ${modelId} in the model catalog.`);
|
|
13
61
|
const input = {};
|
|
62
|
+
const notes = [];
|
|
63
|
+
// Defaults filled in only to show the price are not the caller's request: no note for them.
|
|
64
|
+
const fromDefault = new Set();
|
|
65
|
+
const used = (key, from, to) => {
|
|
66
|
+
input[key] = to;
|
|
67
|
+
if (to !== from && !fromDefault.has(key))
|
|
68
|
+
notes.push(`${key} ${String(from)} is not offered by ${modelId}; used ${String(to)}.`);
|
|
69
|
+
};
|
|
14
70
|
// Spell out the defaults that set the price, so the estimate shows them.
|
|
15
71
|
for (const key of showDefaults) {
|
|
16
72
|
if (properties[key]?.default !== undefined && (wanted[key] === undefined || wanted[key] === null || wanted[key] === '')) {
|
|
17
73
|
wanted = { ...wanted, [key]: properties[key].default };
|
|
74
|
+
fromDefault.add(key);
|
|
18
75
|
}
|
|
19
76
|
}
|
|
20
77
|
for (const [key, value] of Object.entries(wanted)) {
|
|
@@ -28,11 +85,28 @@ export async function fitInput(modelId, wanted, propsOf, showDefaults = []) {
|
|
|
28
85
|
continue;
|
|
29
86
|
}
|
|
30
87
|
if (prop.enum?.length) {
|
|
31
|
-
|
|
88
|
+
const members = prop.enum;
|
|
89
|
+
if (members.includes(value)) {
|
|
32
90
|
input[key] = value;
|
|
33
|
-
|
|
34
|
-
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (typeof value === 'number' && members.every((v) => typeof v === 'number')) {
|
|
94
|
+
used(key, value, members.reduce((a, b) => (Math.abs(b - value) < Math.abs(a - value) ? b : a)));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const rank = tierRank(value);
|
|
98
|
+
const tierMembers = members.filter((v) => tierRank(v) !== undefined);
|
|
99
|
+
if (rank !== undefined && tierMembers.length && (key === 'resolution' || tierMembers.length === members.length)) {
|
|
100
|
+
used(key, value, closestMember(tierMembers, tierRank, rank, (a, b) => a.s < b.s));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const ratio = ratioLog(value);
|
|
104
|
+
if (ratio !== undefined && members.some((v) => ratioLog(v) !== undefined)) {
|
|
105
|
+
used(key, value, closestMember(members, ratioLog, ratio));
|
|
106
|
+
continue;
|
|
35
107
|
}
|
|
108
|
+
if (!fromDefault.has(key))
|
|
109
|
+
notes.push(`${key} ${String(value)} is not accepted by ${modelId}; the model default is used.`);
|
|
36
110
|
continue;
|
|
37
111
|
}
|
|
38
112
|
if (typeof value === 'number' && (prop.type === 'integer' || prop.type === 'number')) {
|
|
@@ -41,12 +115,12 @@ export async function fitInput(modelId, wanted, propsOf, showDefaults = []) {
|
|
|
41
115
|
v = Math.max(prop.minimum, v);
|
|
42
116
|
if (typeof prop.maximum === 'number')
|
|
43
117
|
v = Math.min(prop.maximum, v);
|
|
44
|
-
|
|
118
|
+
used(key, value, v);
|
|
45
119
|
continue;
|
|
46
120
|
}
|
|
47
121
|
input[key] = value;
|
|
48
122
|
}
|
|
49
|
-
return input;
|
|
123
|
+
return { input, notes };
|
|
50
124
|
}
|
|
51
125
|
function imageModelKey(named) {
|
|
52
126
|
if (named === undefined || named === '')
|
|
@@ -64,10 +138,9 @@ export async function planImage(args, propsOf) {
|
|
|
64
138
|
const wanted = { prompt: args.prompt, aspect_ratio: args.aspect_ratio, resolution: args.resolution };
|
|
65
139
|
if (edit)
|
|
66
140
|
wanted.images = images;
|
|
67
|
-
const input = await fitInput(modelId, wanted, propsOf);
|
|
141
|
+
const { input, notes } = await fitInput(modelId, wanted, propsOf);
|
|
68
142
|
if (typeof input.prompt === 'string')
|
|
69
143
|
input.prompt = renameRefs(input.prompt, input, modelId);
|
|
70
|
-
const notes = [];
|
|
71
144
|
if (edit && Array.isArray(input.images) && input.images.length < images.length) {
|
|
72
145
|
notes.push(`${model.label} takes at most ${input.images.length} images, so only the first ${input.images.length} were used.`);
|
|
73
146
|
}
|
|
@@ -167,7 +240,8 @@ export async function planVideo(args, action, propsOf) {
|
|
|
167
240
|
const model = VIDEO_MODELS[chosen.key];
|
|
168
241
|
const modelId = endpoints(chosen.key)[chosen.mode];
|
|
169
242
|
// Image-to-video follows the first frame's shape: no aspect_ratio, not even the schema default.
|
|
170
|
-
const
|
|
243
|
+
const fitted = await fitInput(modelId, chosen.wanted, propsOf, chosen.mode === 'image' ? ['duration', 'resolution'] : ['duration', 'resolution', 'aspect_ratio']);
|
|
244
|
+
const input = fitted.input;
|
|
171
245
|
if (chosen.mode === 'image')
|
|
172
246
|
delete input.aspect_ratio;
|
|
173
247
|
if (typeof input.prompt === 'string')
|
|
@@ -184,5 +258,6 @@ export async function planVideo(args, action, propsOf) {
|
|
|
184
258
|
? `${model.label} does not take ${dropped.join(', ')} in any mode, so those were left out; the model you named was not switched. ${await whoTakes(dropped, propsOf)}`
|
|
185
259
|
: `No available video model takes ${dropped.join(', ')} for this request, so those were left out.`);
|
|
186
260
|
}
|
|
261
|
+
notes.push(...fitted.notes);
|
|
187
262
|
return { modelId, label: model.label, mode: MODE_LABEL[chosen.mode], input, notes };
|
|
188
263
|
}
|
package/dist/lib/upload-cache.js
CHANGED
|
@@ -12,21 +12,24 @@ import path from 'node:path';
|
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
import crypto from 'node:crypto';
|
|
14
14
|
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// One file per caller (callerCacheId: a hash of base URL + API key, never the
|
|
16
|
+
// key itself): an upload belongs to the account that made it, so another key
|
|
17
|
+
// on the same machine must not reuse it.
|
|
18
|
+
export function uploadCacheFile(cacheId) {
|
|
19
|
+
return path.join(os.homedir(), '.cache', 'wavespeed', `uploads-${cacheId}.json`);
|
|
17
20
|
}
|
|
18
|
-
function readCache() {
|
|
21
|
+
function readCache(cacheId) {
|
|
19
22
|
try {
|
|
20
|
-
return JSON.parse(fs.readFileSync(
|
|
23
|
+
return JSON.parse(fs.readFileSync(uploadCacheFile(cacheId), 'utf8'));
|
|
21
24
|
}
|
|
22
25
|
catch {
|
|
23
26
|
return {};
|
|
24
27
|
}
|
|
25
28
|
}
|
|
26
|
-
function writeCache(entries) {
|
|
29
|
+
function writeCache(cacheId, entries) {
|
|
27
30
|
try {
|
|
28
|
-
fs.mkdirSync(path.dirname(
|
|
29
|
-
fs.writeFileSync(
|
|
31
|
+
fs.mkdirSync(path.dirname(uploadCacheFile(cacheId)), { recursive: true });
|
|
32
|
+
fs.writeFileSync(uploadCacheFile(cacheId), JSON.stringify(entries));
|
|
30
33
|
}
|
|
31
34
|
catch {
|
|
32
35
|
/* best-effort; a failed cache write must never fail the upload */
|
|
@@ -36,12 +39,13 @@ export function hashFile(filePath) {
|
|
|
36
39
|
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
|
37
40
|
}
|
|
38
41
|
/**
|
|
39
|
-
* Upload through `doUpload` unless the identical bytes already went up
|
|
40
|
-
* the TTL. Returns the
|
|
42
|
+
* Upload through `doUpload` unless the identical bytes already went up for
|
|
43
|
+
* this caller (`cacheId`, see callerCacheId) within the TTL. Returns the
|
|
44
|
+
* hosted URL either way.
|
|
41
45
|
*/
|
|
42
|
-
export async function uploadWithCache(filePath, doUpload) {
|
|
46
|
+
export async function uploadWithCache(filePath, cacheId, doUpload) {
|
|
43
47
|
const key = hashFile(filePath);
|
|
44
|
-
const entries = readCache();
|
|
48
|
+
const entries = readCache(cacheId);
|
|
45
49
|
const hit = entries[key];
|
|
46
50
|
if (hit && Date.now() - hit.uploaded_at < TTL_MS) {
|
|
47
51
|
return { url: hit.url, cached: true };
|
|
@@ -53,6 +57,6 @@ export async function uploadWithCache(filePath, doUpload) {
|
|
|
53
57
|
if (Date.now() - v.uploaded_at >= TTL_MS)
|
|
54
58
|
delete entries[k];
|
|
55
59
|
}
|
|
56
|
-
writeCache(entries);
|
|
60
|
+
writeCache(cacheId, entries);
|
|
57
61
|
return { url, cached: false };
|
|
58
62
|
}
|
|
@@ -65,6 +65,13 @@ function closest(key, known) {
|
|
|
65
65
|
// A hint is only a hint when it's plausibly the same word.
|
|
66
66
|
return bestD <= Math.max(2, Math.floor(key.length / 3)) ? best : undefined;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Request controls the API accepts for every model without any schema
|
|
70
|
+
* declaring them (api-server pkg/openapi/validate.go publicInputControlProperties
|
|
71
|
+
* and the enable_content_filter opt-in in handler/model_run.go). They are never
|
|
72
|
+
* unknown inputs.
|
|
73
|
+
*/
|
|
74
|
+
export const GLOBAL_INPUT_CONTROLS = ['enable_sync_mode', 'enable_base64_output', 'enable_content_filter'];
|
|
68
75
|
/**
|
|
69
76
|
* Compare top-level input keys with the schema's properties.
|
|
70
77
|
* Returns null when everything checks out or no schema is available.
|
|
@@ -75,7 +82,7 @@ export function findUnknownInputs(input, model) {
|
|
|
75
82
|
return null;
|
|
76
83
|
const knownSet = new Set(known);
|
|
77
84
|
const aliases = requestSchemaAliases(model);
|
|
78
|
-
const unknown = Object.keys(input).filter((k) => !knownSet.has(k) && !aliases.has(k));
|
|
85
|
+
const unknown = Object.keys(input).filter((k) => !knownSet.has(k) && !aliases.has(k) && !GLOBAL_INPUT_CONTROLS.includes(k));
|
|
79
86
|
if (unknown.length === 0)
|
|
80
87
|
return null;
|
|
81
88
|
const suggestions = new Map();
|
package/dist/server.js
CHANGED
|
@@ -19,7 +19,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
19
19
|
import { z } from 'zod';
|
|
20
20
|
import { Client } from 'wavespeed';
|
|
21
21
|
import { fetchModels, fetchBalance, fetchHistory, fetchBillings, fetchUsageStats, deletePredictions, PREDICTION_STATUSES, fetchPricing, modelPlaygroundUrl, submitPrediction, waitForPrediction, MCP_CLIENT_NAME, TERMINAL_FAILURES, } from './lib/api.js';
|
|
22
|
-
import { getApiKey, getBaseUrl } from './lib/config.js';
|
|
22
|
+
import { callerCacheId, getApiKey, getBaseUrl } from './lib/config.js';
|
|
23
23
|
import { MAX_DELETE_IDS, prepareDelete, summarizeDelete } from './lib/delete-predictions.js';
|
|
24
24
|
// Where to add credit; handed to the assistant next to the balance.
|
|
25
25
|
const TOP_UP_URL = 'https://wavespeed.ai/top-up';
|
|
@@ -27,6 +27,7 @@ import { resolveLocalFiles } from './lib/local-files.js';
|
|
|
27
27
|
import { APP_MIME_TYPE, RESULT_VIEW_URI, toolMeta, VIEW_RESOURCE_DOMAINS, insufficientCreditsResult, isInsufficientCredits, loadView, } from './lib/app.js';
|
|
28
28
|
import { uploadWithCache } from './lib/upload-cache.js';
|
|
29
29
|
import { findUnknownInputs } from './lib/validate-inputs.js';
|
|
30
|
+
import { checkModelId, checkPredictionId } from './lib/ids.js';
|
|
30
31
|
import { IMAGE_MODEL_KEYS, VIDEO_MODEL_KEYS } from './lib/media-models.js';
|
|
31
32
|
import { planImage, planVideo } from './lib/media-plan.js';
|
|
32
33
|
const require = createRequire(import.meta.url);
|
|
@@ -56,8 +57,9 @@ async function uploadFile(filePath) {
|
|
|
56
57
|
const apiKey = getApiKey();
|
|
57
58
|
if (!apiKey)
|
|
58
59
|
throw new Error('No API key configured (WAVESPEED_API_KEY or `wavespeed login`).');
|
|
59
|
-
const
|
|
60
|
-
|
|
60
|
+
const baseUrl = getBaseUrl();
|
|
61
|
+
const client = new Client(apiKey, { baseUrl, clientName: MCP_CLIENT_NAME });
|
|
62
|
+
return uploadWithCache(filePath, callerCacheId(baseUrl, apiKey), (p) => client.upload(p));
|
|
61
63
|
}
|
|
62
64
|
// run_model submits and returns the prediction id right away (same as the
|
|
63
65
|
// hosted server); the assistant then waits with get_prediction. Many MCP
|
|
@@ -144,6 +146,7 @@ more = {}) {
|
|
|
144
146
|
status: item.status,
|
|
145
147
|
done,
|
|
146
148
|
outputs: item.outputs ?? [],
|
|
149
|
+
...(item.error ? { error: item.error } : {}),
|
|
147
150
|
elapsed_ms: Date.now() - started,
|
|
148
151
|
...more.waited,
|
|
149
152
|
...more.always,
|
|
@@ -283,6 +286,7 @@ export function createServer() {
|
|
|
283
286
|
.default(DEFAULT_WAIT_SECONDS)
|
|
284
287
|
.describe(`Max seconds to wait for the result in this call (default ${DEFAULT_WAIT_SECONDS}: return the prediction id right away)`),
|
|
285
288
|
}, { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ model, input, wait_seconds }, extra) => {
|
|
289
|
+
checkModelId(model);
|
|
286
290
|
const resolved = await resolveLocalFiles(input, {
|
|
287
291
|
props: (await catalogProps(model).catch(() => null)) ?? undefined,
|
|
288
292
|
upload: async (p) => (await uploadFile(p)).url,
|
|
@@ -352,11 +356,16 @@ export function createServer() {
|
|
|
352
356
|
const resolved = await resolveLocalFiles(media, { allFields: true, upload: async (p) => (await uploadFile(p)).url });
|
|
353
357
|
return { args: { ...args, ...resolved.input }, uploaded: resolved.uploaded };
|
|
354
358
|
}
|
|
359
|
+
// Every size any /chat image model offers; each request is fitted to its model's own list.
|
|
360
|
+
const IMAGE_RESOLUTIONS = ['0.5k', '1k', '1.5k', '2k', '4k'];
|
|
355
361
|
const imageShape = {
|
|
356
362
|
prompt: z.string().min(1).describe('Complete visual prompt: subject, composition, style, lighting, mood.'),
|
|
357
363
|
model: z.enum(IMAGE_MODEL_KEYS).optional().describe('Only when the user names a model; default is the first (Seedream 5 Pro).'),
|
|
358
|
-
aspect_ratio: z.string().optional().describe('e.g. 1:1, 16:9, 9:16, 4:3, 3:4
|
|
359
|
-
resolution: z
|
|
364
|
+
aspect_ratio: z.string().optional().describe('e.g. 1:1, 16:9, 9:16, 4:3, 3:4, 21:9, 9:21. The closest ratio the model supports is used; a note says when. If omitted: text-to-image is 1:1; with input images it is determined automatically from the input image.'),
|
|
365
|
+
resolution: z
|
|
366
|
+
.enum(IMAGE_RESOLUTIONS)
|
|
367
|
+
.optional()
|
|
368
|
+
.describe('Only when the user asks for a size. The closest size the model supports is used (a tie goes to the smaller one); a note says when.'),
|
|
360
369
|
wait_seconds: waitSchema,
|
|
361
370
|
estimate_only: estimateSchema,
|
|
362
371
|
};
|
|
@@ -375,11 +384,18 @@ export function createServer() {
|
|
|
375
384
|
.enum(VIDEO_MODEL_KEYS)
|
|
376
385
|
.optional()
|
|
377
386
|
.describe(`Only when the user names a model (${VIDEO_MODEL_KEYS.join(', ')}). Without it the default (${VIDEO_MODEL_KEYS[0]}) is used, and another model only when it cannot take the inputs.`);
|
|
387
|
+
// The union of the resolution enums of every /chat video endpoint (live catalog, 2026-11-03);
|
|
388
|
+
// each request is fitted to its endpoint's own list.
|
|
389
|
+
const VIDEO_RESOLUTIONS = ['480p', '540p', '720p', '768p', '1080p', '4k'];
|
|
378
390
|
const resolutionSchema = z
|
|
379
|
-
.enum(
|
|
391
|
+
.enum(VIDEO_RESOLUTIONS)
|
|
392
|
+
.optional()
|
|
393
|
+
.describe('Only when the user asks for a resolution, or 480p for a cheaper draft; otherwise omit. The closest resolution the model supports is used (a tie goes to the lower one); a note says when. MiniMax H3 defaults to 768p (its equivalent of 720p).');
|
|
394
|
+
const durationSchema = z
|
|
395
|
+
.number()
|
|
396
|
+
.int()
|
|
380
397
|
.optional()
|
|
381
|
-
.describe(
|
|
382
|
-
const durationSchema = z.number().int().optional().describe('Seconds, only when the user asks for a length; omit to use the model default.');
|
|
398
|
+
.describe("Seconds, only when the user asks for a length; omit to use the model default. A length outside the model's range is moved into it; a note says when.");
|
|
383
399
|
const VIDEO_KEYS = ['first_frame', 'last_frame', 'reference_images', 'reference_videos', 'reference_audios', 'video'];
|
|
384
400
|
server.tool('generate_video', `Create a video with the same models as the WaveSpeed /chat page (default ${VIDEO_MODEL_KEYS[0]}). Give first_frame to animate an image; give reference_images / reference_videos / reference_audios to keep characters, products, motion, voice or music. The right mode (text, image or reference to video) is picked from what you give. Videos can cost dollars: use estimate_only first when unsure. ${REFS}`, {
|
|
385
401
|
prompt: z.string().min(1).describe('Shot description: subject, action, camera movement, style, sound.'),
|
|
@@ -391,7 +407,10 @@ export function createServer() {
|
|
|
391
407
|
reference_audios: mediaList('Optional audio for voice, music or sound to use').optional(),
|
|
392
408
|
duration: durationSchema,
|
|
393
409
|
resolution: resolutionSchema,
|
|
394
|
-
aspect_ratio: z
|
|
410
|
+
aspect_ratio: z
|
|
411
|
+
.string()
|
|
412
|
+
.optional()
|
|
413
|
+
.describe("e.g. 16:9, 9:16, 1:1, 4:3, 3:4, 21:9, 9:21. The closest ratio the model supports is used; a note says when. If omitted, the model's default ratio is used; with first_frame the video follows that image's shape and this is ignored."),
|
|
395
414
|
wait_seconds: waitSchema,
|
|
396
415
|
estimate_only: estimateSchema,
|
|
397
416
|
}, shortcutAnnotations, async (raw, extra) => {
|
|
@@ -417,7 +436,7 @@ export function createServer() {
|
|
|
417
436
|
prompt: z.string().min(1).describe('What happens next.'),
|
|
418
437
|
video: z.string().describe('The video to continue (URL or "@./path").'),
|
|
419
438
|
last_frame: z.string().optional().describe('Optional image the continuation should end on.'),
|
|
420
|
-
duration: durationSchema.describe(
|
|
439
|
+
duration: durationSchema.describe("Seconds of new footage; omit to use the model default. A length outside the model's range is moved into it; a note says when."),
|
|
421
440
|
model: videoModelSchema,
|
|
422
441
|
resolution: resolutionSchema,
|
|
423
442
|
wait_seconds: waitSchema,
|
|
@@ -430,7 +449,11 @@ export function createServer() {
|
|
|
430
449
|
server.tool('get_price', 'Estimate the cost of a run before executing it (no charge). Provide the same input you would pass to run_model — pricing often depends on inputs like duration or resolution, and a quote made without them can be lower than the real charge.', {
|
|
431
450
|
model: z.string().describe('Model ID from list_models'),
|
|
432
451
|
input: z.record(z.unknown()).default({}).describe('Inputs the quote should account for'),
|
|
433
|
-
},
|
|
452
|
+
},
|
|
453
|
+
// Not read-only, unlike the remote server's get_price: here "@./path" inputs upload local files
|
|
454
|
+
// (a write to the account's uploads) before the quote.
|
|
455
|
+
{ readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ model, input }) => {
|
|
456
|
+
checkModelId(model);
|
|
434
457
|
// Resolve @path markers exactly like run_model: some prices are measured
|
|
435
458
|
// from the file itself, so quoting with the literal "@./a.mp3" string
|
|
436
459
|
// would be wrong. The 24h content-hash cache means the later run reuses
|
|
@@ -467,7 +490,7 @@ export function createServer() {
|
|
|
467
490
|
page: z.number().int().min(1).default(1),
|
|
468
491
|
limit: z.number().int().min(1).max(100).default(20).describe('Page size'),
|
|
469
492
|
type: z.enum(['deduct', 'refund']).optional(),
|
|
470
|
-
model: z.string().optional().describe('Filter by model
|
|
493
|
+
model: z.string().optional().describe('Filter by model ID as list_models returns it, e.g. bytedance/seedream-v5.0-pro'),
|
|
471
494
|
key: z.string().optional().describe('Filter by the API key uuid that generated the charge'),
|
|
472
495
|
since: z.string().optional().describe('Records after this time (ISO or YYYY-MM-DD)'),
|
|
473
496
|
until: z.string().optional().describe('Records before this time (ISO or YYYY-MM-DD)'),
|
|
@@ -485,13 +508,13 @@ export function createServer() {
|
|
|
485
508
|
server.tool('get_usage', 'Usage statistics: spend, request counts, daily series and per-model breakdown. Defaults to the last 7 days. Requires an API key with Billing access.', {
|
|
486
509
|
since: z.string().optional().describe('Start (ISO or YYYY-MM-DD); default 7 days ago'),
|
|
487
510
|
until: z.string().optional().describe('End (ISO or YYYY-MM-DD); default now'),
|
|
488
|
-
model: z.string().optional().describe('Filter by model
|
|
511
|
+
model: z.string().optional().describe('Filter by model ID as list_models returns it, e.g. bytedance/seedream-v5.0-pro'),
|
|
489
512
|
}, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ since, until, model }) => {
|
|
490
513
|
const end = toIso(until, 'until') ?? new Date().toISOString();
|
|
491
514
|
const start = toIso(since, 'since') ?? new Date(Date.parse(end) - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
492
515
|
return ok(await fetchUsageStats(start, end, model));
|
|
493
516
|
});
|
|
494
|
-
server.tool('delete_predictions', `Delete finished predictions from this account's run history, up to ${MAX_DELETE_IDS} at a time.
|
|
517
|
+
server.tool('delete_predictions', `Delete finished predictions from this account's run history, up to ${MAX_DELETE_IDS} at a time. This is a soft delete: each record stays, with status "deleted" and without its outputs, and list_predictions and get_prediction still return it that way; the output links are revoked (best effort). This tool cannot undo it. Runs that are still going are left alone, and only the key's own organization's records are deleted. Ask the user first, and pass confirm: true only after they agreed to delete these ids.`, {
|
|
495
518
|
ids: z.array(z.string()).min(1).max(MAX_DELETE_IDS).describe('Prediction ids to delete'),
|
|
496
519
|
confirm: z.boolean().describe('Must be true: the user has agreed to delete these predictions'),
|
|
497
520
|
}, { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, async ({ ids, confirm }) => {
|
|
@@ -511,7 +534,8 @@ export function createServer() {
|
|
|
511
534
|
.max(1800)
|
|
512
535
|
.default(0)
|
|
513
536
|
.describe('Max seconds to wait for a terminal status; 0 = return the current status now'),
|
|
514
|
-
}, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ id, wait_seconds }, extra) => {
|
|
537
|
+
}, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ id: rawId, wait_seconds }, extra) => {
|
|
538
|
+
const id = checkPredictionId(rawId);
|
|
515
539
|
const started = Date.now();
|
|
516
540
|
const { item, done } = await waitForPrediction(id, {
|
|
517
541
|
intervalMs: POLL_INTERVAL_MS,
|
|
@@ -520,7 +544,7 @@ export function createServer() {
|
|
|
520
544
|
signal: extra.signal,
|
|
521
545
|
});
|
|
522
546
|
return ok({
|
|
523
|
-
id: item.id,
|
|
547
|
+
id: item.id || id,
|
|
524
548
|
model: item.model,
|
|
525
549
|
status: item.status,
|
|
526
550
|
done,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wavespeed/mcp",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.6",
|
|
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": {
|