@vidofy/mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/backend.d.ts +133 -0
- package/dist/backend.js +455 -0
- package/dist/config.d.ts +85 -0
- package/dist/config.js +207 -0
- package/dist/heartbeat.d.ts +11 -0
- package/dist/heartbeat.js +70 -0
- package/dist/http.d.ts +44 -0
- package/dist/http.js +885 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +661 -0
- package/dist/log.d.ts +19 -0
- package/dist/log.js +22 -0
- package/dist/map/b2c.d.ts +139 -0
- package/dist/map/b2c.js +236 -0
- package/dist/media-duration.d.ts +33 -0
- package/dist/media-duration.js +137 -0
- package/dist/oauth/authorize.d.ts +61 -0
- package/dist/oauth/authorize.js +275 -0
- package/dist/oauth/clients.d.ts +137 -0
- package/dist/oauth/clients.js +621 -0
- package/dist/oauth/ratelimit.d.ts +92 -0
- package/dist/oauth/ratelimit.js +116 -0
- package/dist/oauth/store.d.ts +135 -0
- package/dist/oauth/store.js +277 -0
- package/dist/oauth/token.d.ts +29 -0
- package/dist/oauth/token.js +165 -0
- package/dist/schema.d.ts +147 -0
- package/dist/schema.js +629 -0
- package/dist/tools/account.d.ts +28 -0
- package/dist/tools/account.js +130 -0
- package/dist/tools/generation.d.ts +102 -0
- package/dist/tools/generation.js +1194 -0
- package/dist/tools/info.d.ts +50 -0
- package/dist/tools/info.js +95 -0
- package/dist/ui/generation-card.html +869 -0
- package/package.json +79 -0
- package/server.json +36 -0
package/dist/backend.js
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that talks to Vidofy over HTTP.
|
|
3
|
+
*
|
|
4
|
+
* Everything else in this package builds a request description and hands it
|
|
5
|
+
* here, so door selection, authentication, retries, timeouts and error shape
|
|
6
|
+
* are decided once instead of in nine tools.
|
|
7
|
+
*
|
|
8
|
+
* WHAT THIS FILE IS RESPONSIBLE FOR
|
|
9
|
+
* · picking /app/v1 or /api/v1 from the configured mode
|
|
10
|
+
* · attaching the credential and the User-Agent on every call
|
|
11
|
+
* · multipart bodies — one form field per m_* key, exactly as the studio posts
|
|
12
|
+
* · carrying an Idempotency-Key when the caller supplies one
|
|
13
|
+
* · retrying the things that are worth retrying, and nothing else
|
|
14
|
+
* · turning both transport failures and API error envelopes into one error type
|
|
15
|
+
*/
|
|
16
|
+
import { readFile, lstat } from 'node:fs/promises';
|
|
17
|
+
import { statSync } from 'node:fs';
|
|
18
|
+
import { basename, extname } from 'node:path';
|
|
19
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
20
|
+
import { apiPrefix, authHeaders } from './config.js';
|
|
21
|
+
// map/ imports nothing, so this direction cannot cycle.
|
|
22
|
+
import { stripProviderCost } from './map/b2c.js';
|
|
23
|
+
import { durationSecFromBuffer } from './media-duration.js';
|
|
24
|
+
/* ── errors ──────────────────────────────────────────────────────────────── */
|
|
25
|
+
/**
|
|
26
|
+
* Anything that went wrong, from either side of the wire.
|
|
27
|
+
*
|
|
28
|
+
* `code` is the machine-readable one — the API's own `error` field where there
|
|
29
|
+
* was a response, or a transport pseudo-code where there was not. `message` is
|
|
30
|
+
* what a person (or a model) should read.
|
|
31
|
+
*/
|
|
32
|
+
export class VidofyError extends Error {
|
|
33
|
+
code;
|
|
34
|
+
httpStatus;
|
|
35
|
+
details;
|
|
36
|
+
constructor(code, message, httpStatus = null,
|
|
37
|
+
/** Extra fields the API returned, e.g. `allowed` on INVALID_MODE. */
|
|
38
|
+
details = {}) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.code = code;
|
|
41
|
+
this.httpStatus = httpStatus;
|
|
42
|
+
this.details = details;
|
|
43
|
+
this.name = 'VidofyError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/* ── retry policy ────────────────────────────────────────────────────────── */
|
|
47
|
+
/**
|
|
48
|
+
* Retry a rate limit, a gateway hiccup and a dropped connection. Nothing else.
|
|
49
|
+
*
|
|
50
|
+
* Every other 4xx is deterministic — a missing field, an unknown model, a
|
|
51
|
+
* revoked token, an empty balance. Retrying those burns the user's time to
|
|
52
|
+
* arrive at the same answer, and on a 402 it would look like the server is
|
|
53
|
+
* being asked to charge repeatedly.
|
|
54
|
+
*/
|
|
55
|
+
function isRetryableStatus(status) {
|
|
56
|
+
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
57
|
+
}
|
|
58
|
+
const MAX_ATTEMPTS = 4;
|
|
59
|
+
const BASE_DELAY_MS = 500;
|
|
60
|
+
/**
|
|
61
|
+
* Exponential backoff with full jitter, capped.
|
|
62
|
+
*
|
|
63
|
+
* Jitter matters even for one client: an agent fires several tools at once, and
|
|
64
|
+
* without it their retries line up and hit the same rate limit together.
|
|
65
|
+
* Retry-After wins when the server sends one. /api/v1 now does, on 429, and the
|
|
66
|
+
* value is exact rather than a guess because the limiter uses a fixed window
|
|
67
|
+
* (per-minute and per-day) so it knows precisely when the window turns over.
|
|
68
|
+
* /app/v1's own 60/min limiter
|
|
69
|
+
* sends none, and that door is the one this server actually speaks to, so the
|
|
70
|
+
* jitter below is still the live path.
|
|
71
|
+
*/
|
|
72
|
+
function backoffDelayMs(attempt, retryAfterHeader) {
|
|
73
|
+
if (retryAfterHeader) {
|
|
74
|
+
const seconds = Number(retryAfterHeader);
|
|
75
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
76
|
+
return Math.min(seconds * 1000, 30_000);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const ceiling = Math.min(BASE_DELAY_MS * 2 ** attempt, 8_000);
|
|
80
|
+
return Math.random() * ceiling;
|
|
81
|
+
}
|
|
82
|
+
/** Exported for get_status, which holds its call open rather than letting the
|
|
83
|
+
* agent poll in a tight loop — one definition, not a second one that drifts. */
|
|
84
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
85
|
+
/**
|
|
86
|
+
* A ceiling that applies even when the model declares none.
|
|
87
|
+
*
|
|
88
|
+
* It is not a policy about what Vidofy accepts — the server decides that. It is
|
|
89
|
+
* a guard on THIS process: readFile() buffers the whole file and Blob copies it
|
|
90
|
+
* again, so a path pointing at a 4 GB file takes the MCP server down with an
|
|
91
|
+
* OOM, and the AI client reports only that the connection died.
|
|
92
|
+
*/
|
|
93
|
+
const HARD_MAX_BYTES = 512 * 1024 * 1024;
|
|
94
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
95
|
+
/* ONE ceiling for a whole call, retries and backoff included.
|
|
96
|
+
*
|
|
97
|
+
* DEFAULT_TIMEOUT_MS is PER ATTEMPT, and attempts compose by ADDING: four of
|
|
98
|
+
* them plus backoff is 264s. The MCP SDK's DEFAULT_REQUEST_TIMEOUT_MSEC is
|
|
99
|
+
* 60s, so every one of those calls was still trying, alone, minutes after the
|
|
100
|
+
* client had given up and told the user it failed — and get_status/get_result,
|
|
101
|
+
* the two that are called over and over while a generation runs, were among
|
|
102
|
+
* them. `generate` was given a deadline for exactly this reason; it is the
|
|
103
|
+
* transport that should carry it, not one of the nine tools.
|
|
104
|
+
*
|
|
105
|
+
* 55s leaves the client a margin to hear the answer. A per-attempt timeout can
|
|
106
|
+
* still be shorter (generate's submit sets 50s) — this is the ceiling, not the
|
|
107
|
+
* allowance. */
|
|
108
|
+
const CALL_BUDGET_MS = 55_000;
|
|
109
|
+
/* Below this there is no point starting another attempt: the sleep plus a
|
|
110
|
+
* one-second window buys a near-certain second failure and spends the last of
|
|
111
|
+
* the time the caller could have used to hear about the first. */
|
|
112
|
+
const MIN_ATTEMPT_MS = 2_000;
|
|
113
|
+
/** Content types this package will attach, keyed by extension. */
|
|
114
|
+
const MIME = {
|
|
115
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
|
116
|
+
'.webp': 'image/webp', '.gif': 'image/gif',
|
|
117
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
|
|
118
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg',
|
|
119
|
+
};
|
|
120
|
+
function guessMime(path) {
|
|
121
|
+
return MIME[extname(path).toLowerCase()] ?? 'application/octet-stream';
|
|
122
|
+
}
|
|
123
|
+
/** A fresh idempotency key. Callers make ONE per logical operation. */
|
|
124
|
+
export function newIdempotencyKey() {
|
|
125
|
+
return randomUUID();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* An idempotency key derived from WHAT IS BEING ASKED FOR, inside a window.
|
|
129
|
+
*
|
|
130
|
+
* The server dedupes on (user, origin, m_client_request_id) and this package
|
|
131
|
+
* sent a randomUUID() per invocation, so that tuple could never repeat and
|
|
132
|
+
* the dedupe could never fire: two identical generate calls were two
|
|
133
|
+
* generations and two charges. The mechanism existed on both sides and was
|
|
134
|
+
* joined by a value guaranteed to differ.
|
|
135
|
+
*
|
|
136
|
+
* NO TIME COMPONENT IN THE KEY, deliberately — that was tried and is wrong.
|
|
137
|
+
* The expiry a derived key needs (the same prompt next week must generate
|
|
138
|
+
* again, not replay) is a property of the LOOKUP, and it now lives there:
|
|
139
|
+
* The server's idempotent-lookup takes a max age and the submit
|
|
140
|
+
* handler passes 60 seconds for an MCP caller. Hashing a time bucket into the
|
|
141
|
+
* key instead makes a tumbling grid, so two calls seconds apart miss each
|
|
142
|
+
* other whenever a boundary falls between them — measured on the first live
|
|
143
|
+
* pair tried: 11 seconds apart, two jobs, two charges. A bound on the query
|
|
144
|
+
* is a real "within 60s of each other" at any alignment.
|
|
145
|
+
*
|
|
146
|
+
* Files are identified by path AND by size+mtime, so editing an image and
|
|
147
|
+
* re-running inside the window is a different request rather than a cached
|
|
148
|
+
* answer. A path that cannot be stat'd contributes its raw path and the real
|
|
149
|
+
* complaint arrives later, from the code whose job that is.
|
|
150
|
+
*/
|
|
151
|
+
export function contentIdempotencyKey(parts, filePaths = []) {
|
|
152
|
+
const fileTags = [...filePaths].sort().map((p) => {
|
|
153
|
+
try {
|
|
154
|
+
const s = statSync(p);
|
|
155
|
+
return `${p}:${s.size}:${Math.floor(s.mtimeMs)}`;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return p;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
// Sorted keys: {a,b} and {b,a} are the same request and must not become
|
|
162
|
+
// two. JSON.stringify preserves insertion order, which the caller's object
|
|
163
|
+
// literal does not control.
|
|
164
|
+
const canonical = JSON.stringify(parts, Object.keys(parts).sort());
|
|
165
|
+
return createHash('sha256')
|
|
166
|
+
.update(`${canonical}\n${fileTags.join('\n')}`)
|
|
167
|
+
.digest('hex');
|
|
168
|
+
}
|
|
169
|
+
async function buildBody(opts) {
|
|
170
|
+
if (opts.method === 'GET')
|
|
171
|
+
return { body: undefined, uploadBytes: 0 };
|
|
172
|
+
const entries = Object.entries(opts.form ?? {}).filter((e) => e[1] !== undefined);
|
|
173
|
+
if (!opts.files || opts.files.length === 0) {
|
|
174
|
+
const params = new URLSearchParams();
|
|
175
|
+
for (const [k, v] of entries)
|
|
176
|
+
params.append(k, String(v));
|
|
177
|
+
return { body: params, uploadBytes: 0 };
|
|
178
|
+
}
|
|
179
|
+
let uploadBytes = 0;
|
|
180
|
+
const fd = new FormData();
|
|
181
|
+
for (const [k, v] of entries)
|
|
182
|
+
fd.append(k, String(v));
|
|
183
|
+
for (const f of opts.files) {
|
|
184
|
+
let bytes;
|
|
185
|
+
try {
|
|
186
|
+
/* lstat, NOT stat: stat follows symlinks, so a file the agent was
|
|
187
|
+
told to send as "photo.png" could be a link to ~/.ssh/id_rsa and
|
|
188
|
+
every check below would inspect the harmless name while readFile
|
|
189
|
+
returned the key. The link itself is refused instead. */
|
|
190
|
+
const info = await lstat(f.path);
|
|
191
|
+
if (info.isSymbolicLink()) {
|
|
192
|
+
throw new VidofyError('FILE_IS_SYMLINK', `Refusing to upload a symbolic link: ${f.path}. Pass the real file.`);
|
|
193
|
+
}
|
|
194
|
+
if (!info.isFile()) {
|
|
195
|
+
throw new VidofyError('FILE_NOT_A_FILE', `Not a file: ${f.path}`);
|
|
196
|
+
}
|
|
197
|
+
/* Checked BEFORE the read, against the model's own limits, for two
|
|
198
|
+
separate reasons. The size one is ours: a 40 MB file sent to a
|
|
199
|
+
10 MB slot costs the user the whole upload to arrive at a 422 it
|
|
200
|
+
cannot diagnose. The extension one is the user's: the path comes
|
|
201
|
+
from the conversation, so anything that talks the model into
|
|
202
|
+
naming a private file gets those bytes off the machine. This does
|
|
203
|
+
not make that safe — a real .png anywhere on disk still passes —
|
|
204
|
+
which is why `generate` is not readOnly and the client shows the
|
|
205
|
+
path to the user before it runs. */
|
|
206
|
+
/* Both sides normalised to a bare, lower-case extension. The model
|
|
207
|
+
declares them WITHOUT a dot ("jpg", "png" — straight out of
|
|
208
|
+
m_upload_*_settings.allowed_extensions) while extname() returns
|
|
209
|
+
one (".png"), so comparing them raw refuses every legitimate
|
|
210
|
+
upload. Accepting either form here means a caller cannot get it
|
|
211
|
+
subtly wrong. */
|
|
212
|
+
const bare = (e) => e.trim().toLowerCase().replace(/^\./, '');
|
|
213
|
+
const ext = bare(extname(f.path));
|
|
214
|
+
const accepted = (f.accept ?? []).map(bare).filter((e) => e !== '');
|
|
215
|
+
/* No allowlist means REFUSE, not "allow anything".
|
|
216
|
+
*
|
|
217
|
+
* This read `accepted.length > 0 && …` until 2026-09-07, so a slot
|
|
218
|
+
* that declared no extensions switched the check off entirely — and
|
|
219
|
+
* 10+ active models declare none. The caller now always resolves
|
|
220
|
+
* the server's own defaults first (schema.ts resolveUploadRules),
|
|
221
|
+
* so an empty list here means the limits are genuinely unknown, and
|
|
222
|
+
* a file whose type we cannot vouch for is exactly the one not to
|
|
223
|
+
* read off the user's disk. */
|
|
224
|
+
if (accepted.length === 0) {
|
|
225
|
+
throw new VidofyError('UPLOAD_LIMITS_UNKNOWN', `No accepted file types are known for ${f.field}; refusing to upload ` +
|
|
226
|
+
`${basename(f.path)}. Call get_model to see the slot's limits.`);
|
|
227
|
+
}
|
|
228
|
+
if (!accepted.includes(ext)) {
|
|
229
|
+
throw new VidofyError('FILE_TYPE_REJECTED', `${basename(f.path)} is ${ext ? '.' + ext : 'extensionless'}; ` +
|
|
230
|
+
`${f.field} accepts ${accepted.map((e) => '.' + e).join(', ')}.`);
|
|
231
|
+
}
|
|
232
|
+
const limitBytes = Math.min(f.maxSizeMb !== undefined ? f.maxSizeMb * 1024 * 1024 : HARD_MAX_BYTES, HARD_MAX_BYTES);
|
|
233
|
+
if (info.size > limitBytes) {
|
|
234
|
+
throw new VidofyError('FILE_TOO_LARGE', `${basename(f.path)} is ${(info.size / 1048576).toFixed(1)} MB; ` +
|
|
235
|
+
`${f.field} accepts up to ${(limitBytes / 1048576).toFixed(0)} MB.`);
|
|
236
|
+
}
|
|
237
|
+
bytes = await readFile(f.path);
|
|
238
|
+
/* Length, checked HERE because here it is free.
|
|
239
|
+
*
|
|
240
|
+
* The bytes are already in memory and nothing has gone on the wire
|
|
241
|
+
* yet, so reading the container costs no I/O and still saves the
|
|
242
|
+
* whole upload — which is the point: nine models cap what you send
|
|
243
|
+
* (30s of video on lipsync and motion-control, 30-600s of audio),
|
|
244
|
+
* and a 200 MB clip otherwise travels in full to be told it is
|
|
245
|
+
* fifteen seconds too long.
|
|
246
|
+
*
|
|
247
|
+
* null means the format does not state its length exactly, and
|
|
248
|
+
* then nothing is refused — the server checks authoritatively and
|
|
249
|
+
* says so clearly, before charging. Refusing on an inferred number
|
|
250
|
+
* would trade a saved upload for a rejected valid file. */
|
|
251
|
+
const cap = f.maxDurationSec ?? 0;
|
|
252
|
+
if (cap > 0) {
|
|
253
|
+
const seconds = durationSecFromBuffer(bytes, extname(f.path));
|
|
254
|
+
// Ceil to match the server, which compares ceil(duration)
|
|
255
|
+
// against the same cap.
|
|
256
|
+
if (seconds !== null && Math.ceil(seconds) > cap) {
|
|
257
|
+
throw new VidofyError('FILE_TOO_LONG', `${basename(f.path)} is ${Math.ceil(seconds)}s; ` +
|
|
258
|
+
`${f.field} accepts up to ${cap}s. Trim it and try again — ` +
|
|
259
|
+
'nothing was uploaded and nothing was charged.');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
if (err instanceof VidofyError)
|
|
265
|
+
throw err;
|
|
266
|
+
throw new VidofyError('FILE_UNREADABLE', `Could not read ${f.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
267
|
+
}
|
|
268
|
+
// One form field per m_* key — the same shape the studio's FormData
|
|
269
|
+
// produces, which is why the server needs no special case for us.
|
|
270
|
+
fd.append(f.field, new Blob([bytes], { type: guessMime(f.path) }), basename(f.path));
|
|
271
|
+
uploadBytes += bytes.length;
|
|
272
|
+
}
|
|
273
|
+
return { body: fd, uploadBytes };
|
|
274
|
+
}
|
|
275
|
+
/** Strip anything that could echo the credential back into a log or a tool result. */
|
|
276
|
+
function redact(text, cfg) {
|
|
277
|
+
return cfg.credential ? text.split(cfg.credential).join('«credential»') : text;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Perform one request, with retries.
|
|
281
|
+
*
|
|
282
|
+
* Returns the parsed JSON body on success. Throws VidofyError on anything else,
|
|
283
|
+
* including a 200 whose envelope says `success: false`.
|
|
284
|
+
*/
|
|
285
|
+
export async function request(cfg, opts) {
|
|
286
|
+
const url = new URL(cfg.baseUrl + apiPrefix(cfg) + '/' + opts.path.replace(/^\/+/, ''));
|
|
287
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
288
|
+
if (v !== undefined)
|
|
289
|
+
url.searchParams.set(k, String(v));
|
|
290
|
+
}
|
|
291
|
+
const headers = {
|
|
292
|
+
...authHeaders(cfg),
|
|
293
|
+
'User-Agent': cfg.userAgent,
|
|
294
|
+
Accept: 'application/json',
|
|
295
|
+
};
|
|
296
|
+
if (opts.idempotencyKey)
|
|
297
|
+
headers['Idempotency-Key'] = opts.idempotencyKey;
|
|
298
|
+
let lastError = null;
|
|
299
|
+
/* Built ONCE, before the loop.
|
|
300
|
+
*
|
|
301
|
+
* It used to be rebuilt per attempt, on the theory that a consumed FormData
|
|
302
|
+
* is not replayable. It is: undici re-reads the Blob on each send. What the
|
|
303
|
+
* old placement actually did was re-read every file from disk on every
|
|
304
|
+
* retry — four disk reads and four full uploads for one 20 MB image — and,
|
|
305
|
+
* worse, a file edited between attempts went out as different bytes under
|
|
306
|
+
* the SAME Idempotency-Key, which is precisely the case that key exists to
|
|
307
|
+
* make identical. */
|
|
308
|
+
const { body, uploadBytes } = await buildBody(opts);
|
|
309
|
+
// Per-call cap; see RequestOptions.maxAttempts.
|
|
310
|
+
const attempts = Math.max(1, Math.min(opts.maxAttempts ?? MAX_ATTEMPTS, MAX_ATTEMPTS));
|
|
311
|
+
/* The wall clock for the whole call — see CALL_BUDGET_MS. Fixed here,
|
|
312
|
+
* before the first attempt, so it bounds the attempts AND the backoff
|
|
313
|
+
* between them rather than restarting with each one. */
|
|
314
|
+
const deadline = Date.now() + CALL_BUDGET_MS;
|
|
315
|
+
const remainingMs = () => deadline - Date.now();
|
|
316
|
+
/* Is there time for another attempt, and if so wait out the backoff.
|
|
317
|
+
*
|
|
318
|
+
* Returns false when the attempts are spent OR the budget is — and the
|
|
319
|
+
* second half is the point: without it, a fourth attempt was still being
|
|
320
|
+
* started long after the client had stopped listening. Only sleeps when it
|
|
321
|
+
* is going to say yes, so a caller writing
|
|
322
|
+
* `if (isRetryable(...) && await canRetry(...)) continue;`
|
|
323
|
+
* pays nothing when the status is not retryable. */
|
|
324
|
+
const canRetry = async (attempt, retryAfter) => {
|
|
325
|
+
if (attempt >= attempts - 1)
|
|
326
|
+
return false;
|
|
327
|
+
const delay = backoffDelayMs(attempt, retryAfter);
|
|
328
|
+
if (remainingMs() - delay < MIN_ATTEMPT_MS)
|
|
329
|
+
return false;
|
|
330
|
+
await sleep(delay);
|
|
331
|
+
return true;
|
|
332
|
+
};
|
|
333
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
334
|
+
/* What THIS attempt gets: its own timeout, or whatever is left of the
|
|
335
|
+
* call's budget, whichever is smaller. The floor keeps
|
|
336
|
+
* AbortSignal.timeout out of the range where it aborts instantly — a
|
|
337
|
+
* request that never left is reported as a timeout it did not have. */
|
|
338
|
+
const attemptTimeoutMs = Math.max(1_000, Math.min(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, remainingMs()));
|
|
339
|
+
/* `body` is OMITTED rather than passed as undefined: with
|
|
340
|
+
exactOptionalPropertyTypes a GET carrying `body: undefined` is not
|
|
341
|
+
the same thing as a GET with no body, and undici rejects the former. */
|
|
342
|
+
const init = {
|
|
343
|
+
method: opts.method,
|
|
344
|
+
headers,
|
|
345
|
+
signal: AbortSignal.timeout(attemptTimeoutMs),
|
|
346
|
+
/* Never follow a redirect. undici strips Authorization across
|
|
347
|
+
origins but forwards X-API-Key, so a redirect to another host
|
|
348
|
+
would hand that credential over. Nothing on /app/v1 or /api/v1
|
|
349
|
+
redirects; if one starts, failing is the right answer. */
|
|
350
|
+
redirect: 'error',
|
|
351
|
+
};
|
|
352
|
+
if (body !== undefined)
|
|
353
|
+
init.body = body;
|
|
354
|
+
let res;
|
|
355
|
+
try {
|
|
356
|
+
res = await fetch(url, init);
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
const name = err instanceof Error ? err.name : '';
|
|
360
|
+
const isTimeout = name === 'TimeoutError' || name === 'AbortError';
|
|
361
|
+
lastError = new VidofyError(isTimeout ? 'TIMEOUT' : 'NETWORK_ERROR', isTimeout
|
|
362
|
+
// The EFFECTIVE timeout, which is not always opts.timeoutMs:
|
|
363
|
+
// a later attempt gets whatever is left of the call budget,
|
|
364
|
+
// and reporting the requested figure would name a wait that
|
|
365
|
+
// did not happen.
|
|
366
|
+
? `Vidofy did not answer within ${Math.round(attemptTimeoutMs / 1000)}s.`
|
|
367
|
+
: `Could not reach ${url.origin}: ${redact(err instanceof Error ? err.message : String(err), cfg)}`);
|
|
368
|
+
if (await canRetry(attempt, null))
|
|
369
|
+
continue;
|
|
370
|
+
throw lastError;
|
|
371
|
+
}
|
|
372
|
+
/* Reading the body can fail on its own — the headers arrived, then the
|
|
373
|
+
connection dropped or the per-attempt timeout fired mid-stream. That
|
|
374
|
+
used to throw a raw TypeError/DOMException straight out of this
|
|
375
|
+
function: not a VidofyError, and NOT retried, even though the very
|
|
376
|
+
same failure one line earlier (during fetch) is retried. Same class
|
|
377
|
+
of failure, same treatment. */
|
|
378
|
+
let text;
|
|
379
|
+
try {
|
|
380
|
+
text = await res.text();
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
const name = err instanceof Error ? err.name : '';
|
|
384
|
+
const isTimeout = name === 'TimeoutError' || name === 'AbortError';
|
|
385
|
+
lastError = new VidofyError(isTimeout ? 'TIMEOUT' : 'NETWORK_ERROR', `Vidofy answered ${res.status} but the response body did not arrive: ` +
|
|
386
|
+
redact(err instanceof Error ? err.message : String(err), cfg));
|
|
387
|
+
if (await canRetry(attempt, res.headers.get('retry-after')))
|
|
388
|
+
continue;
|
|
389
|
+
throw lastError;
|
|
390
|
+
}
|
|
391
|
+
let parsed = null;
|
|
392
|
+
try {
|
|
393
|
+
parsed = text === '' ? null : JSON.parse(text);
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
/* Not JSON. An admin account gets HTML back from some endpoints,
|
|
397
|
+
and a proxy error page is HTML too — either way, saying "the
|
|
398
|
+
server did not return JSON" beats a JSON.parse stack trace. */
|
|
399
|
+
if (isRetryableStatus(res.status) && await canRetry(attempt, res.headers.get('retry-after'))) {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
/* 413 is the one status that reliably arrives as HTML, because
|
|
403
|
+
nginx/PHP refuse the body before any Vidofy code runs — so
|
|
404
|
+
there is no JSON envelope to read and no message to relay.
|
|
405
|
+
Saying "non-JSON body" there would hide the only fact that
|
|
406
|
+
matters, which is that the upload was too big for the server
|
|
407
|
+
and not for the model. */
|
|
408
|
+
if (res.status === 413) {
|
|
409
|
+
throw new VidofyError('UPLOAD_TOO_LARGE', `Vidofy refused the upload before reading it: ${(uploadBytes / 1048576).toFixed(1)} MB ` +
|
|
410
|
+
'exceeded the server\'s total request limit. Send fewer or smaller files — ' +
|
|
411
|
+
'the per-file limits from get_model are separate and were satisfied.', 413);
|
|
412
|
+
}
|
|
413
|
+
throw new VidofyError('NON_JSON_RESPONSE', `Vidofy returned ${res.status} with a non-JSON body (${text.length} bytes).`, res.status);
|
|
414
|
+
}
|
|
415
|
+
const envelope = (parsed ?? {});
|
|
416
|
+
const apiCode = typeof envelope['error'] === 'string' ? envelope['error'] : null;
|
|
417
|
+
const apiMessage = typeof envelope['message'] === 'string' ? envelope['message'] : null;
|
|
418
|
+
if (res.ok && envelope['success'] !== false) {
|
|
419
|
+
return parsed;
|
|
420
|
+
}
|
|
421
|
+
/* A generation that FAILED is not a failed call.
|
|
422
|
+
*
|
|
423
|
+
* The status endpoint answers HTTP 200 with success:false and
|
|
424
|
+
* error:'FAILED'|'ERROR'|'BLOCKED' for a job that reached a terminal
|
|
425
|
+
* failure — the full data object is right there beside it. Treating
|
|
426
|
+
* that as a transport error threw before the mapper ever ran, so
|
|
427
|
+
* get_status could never report {done:true, status:'failed'} and the
|
|
428
|
+
* agent could not tell "your video was blocked" from "Vidofy is
|
|
429
|
+
* unreachable". It also made three of the five entries in the mapper's
|
|
430
|
+
* TERMINAL set dead code, covering thousands of real rows.
|
|
431
|
+
*
|
|
432
|
+
* Only the callers that read a job set this, and only on a 200 — every
|
|
433
|
+
* other envelope error still throws. */
|
|
434
|
+
if (res.ok && opts.allowEnvelopeError) {
|
|
435
|
+
return parsed;
|
|
436
|
+
}
|
|
437
|
+
// An error, from either the status line or the envelope.
|
|
438
|
+
const { success: _s, error: _e, message: _m, ...rest } = envelope;
|
|
439
|
+
lastError = new VidofyError(apiCode ?? `HTTP_${res.status}`, redact(apiMessage ?? `Vidofy answered ${res.status}.`, cfg), res.status,
|
|
440
|
+
/* Redacted AND cost-stripped, same as the message beside it.
|
|
441
|
+
index.ts serialises `details` into the tool result, so anything
|
|
442
|
+
left here reaches the model — including the provider cost the
|
|
443
|
+
rest of this package works to keep in-house. */
|
|
444
|
+
stripProviderCost(JSON.parse(redact(JSON.stringify(rest), cfg))));
|
|
445
|
+
if (isRetryableStatus(res.status) && await canRetry(attempt, res.headers.get('retry-after'))) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
throw lastError;
|
|
449
|
+
}
|
|
450
|
+
/* Unreachable: every path above either returns or throws on the last
|
|
451
|
+
attempt. Present so the function is total rather than relying on that
|
|
452
|
+
reasoning staying true. */
|
|
453
|
+
throw lastError ?? new VidofyError('UNKNOWN', 'Request failed for an unknown reason.');
|
|
454
|
+
}
|
|
455
|
+
//# sourceMappingURL=backend.js.map
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the server points, and which credential it carries.
|
|
3
|
+
*
|
|
4
|
+
* ONE credential, ONE door — never both. The two modes bill different wallets:
|
|
5
|
+
*
|
|
6
|
+
* VIDOFY_TOKEN (vmt_…) → the user's own coins (account mode — served)
|
|
7
|
+
* VIDOFY_API_KEY (vky_…) → a different balance (key mode — refused)
|
|
8
|
+
*
|
|
9
|
+
* Mixing them would mean a caller could not tell which balance a generation
|
|
10
|
+
* was going to spend until after it spent it, so a request carrying both is
|
|
11
|
+
* refused at startup rather than resolved by precedence.
|
|
12
|
+
*
|
|
13
|
+
* Key mode is DETECTED but NOT SERVED, and that is the settled shape of the
|
|
14
|
+
* product rather than a gap (owner decision, 2026-09-11): this server is for
|
|
15
|
+
* personal Vidofy accounts and spends their own coins. It is detected only so
|
|
16
|
+
* that a key can be refused with an explanation instead of failing later as an
|
|
17
|
+
* unexplained 401.
|
|
18
|
+
*
|
|
19
|
+
* Detected rather than ignored so that someone who sets VIDOFY_API_KEY is told
|
|
20
|
+
* where to go, instead of watching the server start and then fail on every
|
|
21
|
+
* call. See the refusal in index.ts.
|
|
22
|
+
*/
|
|
23
|
+
/** Which wallet this process will spend from. */
|
|
24
|
+
export type Mode = 'account' | 'key';
|
|
25
|
+
export interface Config {
|
|
26
|
+
mode: Mode;
|
|
27
|
+
/** The raw credential. Never logged, never echoed in a tool result. */
|
|
28
|
+
credential: string;
|
|
29
|
+
/** Origin only, no trailing slash — e.g. https://vidofy.ai */
|
|
30
|
+
baseUrl: string;
|
|
31
|
+
/** Sent on every request so usage can be attributed. */
|
|
32
|
+
userAgent: string;
|
|
33
|
+
version: string;
|
|
34
|
+
/**
|
|
35
|
+
* This connector's own canonical resource (RFC 8707), when the credential
|
|
36
|
+
* came from an OAuth flow.
|
|
37
|
+
*
|
|
38
|
+
* Declared to /app/v1 on every call so the server can check the token's
|
|
39
|
+
* audience. Undefined for the stdio package and for a
|
|
40
|
+
* hand-made token, and that absence is meaningful rather than missing: it
|
|
41
|
+
* pairs with a token that declares no resource, and a hand-made token is refused at a
|
|
42
|
+
* connector precisely because the connector always declares one.
|
|
43
|
+
*/
|
|
44
|
+
resource?: string;
|
|
45
|
+
}
|
|
46
|
+
export declare class ConfigError extends Error {
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Read the environment into a Config, or throw a message a human can act on.
|
|
50
|
+
*
|
|
51
|
+
* @param env Defaults to process.env; injectable so this is testable without
|
|
52
|
+
* mutating the real environment.
|
|
53
|
+
*/
|
|
54
|
+
export declare function loadConfig(env?: NodeJS.ProcessEnv, version?: string): Config;
|
|
55
|
+
/**
|
|
56
|
+
* A Config for ONE `vmt_` token, with the origin still taken from the process
|
|
57
|
+
* environment.
|
|
58
|
+
*
|
|
59
|
+
* This exists for the remote transport, where the two halves of a Config arrive
|
|
60
|
+
* from different places and at different times: the origin is server
|
|
61
|
+
* configuration, fixed at boot, while the credential belongs to whoever is
|
|
62
|
+
* making this particular request. loadConfig cannot serve that case — it reads
|
|
63
|
+
* the credential from the environment, which in a multi-user process would mean
|
|
64
|
+
* every caller spending one account's coins.
|
|
65
|
+
*
|
|
66
|
+
* The prefix check is deliberately the SAME one loadConfig applies. A remote
|
|
67
|
+
* caller pasting `vky_` into an Authorization header deserves the answer the
|
|
68
|
+
* local user gets, not a 401 that says nothing.
|
|
69
|
+
*
|
|
70
|
+
* @throws ConfigError — the caller turns it into a 401 with a readable message.
|
|
71
|
+
*/
|
|
72
|
+
export declare function configForToken(token: string, env?: NodeJS.ProcessEnv, version?: string, resource?: string): Config;
|
|
73
|
+
/**
|
|
74
|
+
* Validate and normalise VIDOFY_API_BASE.
|
|
75
|
+
*
|
|
76
|
+
* Factored out of loadConfig so the remote transport cannot end up with a
|
|
77
|
+
* weaker check than the local one — the checks below are the whole reason a
|
|
78
|
+
* hostile value in claude_desktop_config.json cannot redirect a credential, and
|
|
79
|
+
* a second copy of them would be a second chance to get one wrong.
|
|
80
|
+
*/
|
|
81
|
+
export declare function resolveBaseUrl(env?: NodeJS.ProcessEnv): string;
|
|
82
|
+
/** The credential header for this mode. Kept next to loadConfig so the two cannot drift. */
|
|
83
|
+
export declare function authHeaders(cfg: Config): Record<string, string>;
|
|
84
|
+
/** `/app/v1` or `/api/v1` — the door this mode speaks to. */
|
|
85
|
+
export declare function apiPrefix(cfg: Config): string;
|