@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.
@@ -0,0 +1,1194 @@
1
+ /**
2
+ * Run a generation, price one first, and read one back.
3
+ *
4
+ * `generate` is the only tool in this package that spends anything. Everything
5
+ * around it is read-only.
6
+ */
7
+ import { z } from 'zod';
8
+ import { log } from '../log.js';
9
+ import { request, contentIdempotencyKey, sleep, VidofyError } from '../backend.js';
10
+ import { buildModelSchema } from '../schema.js';
11
+ import { mapStatus, mapResult, readCostCredits, stripProviderCost } from '../map/b2c.js';
12
+ /** coins on the account door, credits on the key door — the wallets differ. */
13
+ const unitFor = (cfg) => (cfg.mode === 'account' ? 'coins' : 'credits');
14
+ /**
15
+ * Turn the agent's clean-named input into what the wire wants.
16
+ *
17
+ * Shared by estimate_cost and generate deliberately: if the two mapped inputs
18
+ * differently, the quote the user approved would not be the job they paid for.
19
+ *
20
+ * An unrecognised key is an ERROR, not something to drop. Dropping it prices —
21
+ * and then generates — the model's DEFAULTS while the agent believes it asked
22
+ * for something else, which on a model whose price swings 20× with its settings
23
+ * is both the wrong output and the wrong bill, with nothing in either response
24
+ * to say so.
25
+ */
26
+ function toWire(schema, input,
27
+ /* Slot indices already claimed by a reuse reference, keyed by the slot's
28
+ * clean name. A numbered multi-upload slot can hold a MIX — one file from
29
+ * disk and one chained from an earlier generation — and both halves are
30
+ * numbered into the same m_multi_file_<n> namespace. Without this the
31
+ * uploads restart at 0 and overwrite the reuse entries the server was told
32
+ * about, so the job silently runs on the wrong inputs. */
33
+ takenIndices = {}) {
34
+ const form = {
35
+ m_model_key: schema.model_key,
36
+ m_slug: schema.slug,
37
+ /* Sent for effect models, omitted for the rest.
38
+ *
39
+ * Without it the price handler falls back to a rate that does not match
40
+ * the effect, and the worker dispatches an empty scene, so the user is
41
+ * billed the wrong amount for the wrong output.
42
+ * It rides here rather than in `wire` because it is not a choice the
43
+ * agent makes: it belongs to the model it already picked. */
44
+ ...(schema.effect_key !== '' ? { m_effect_key: schema.effect_key } : {}),
45
+ m_mode: schema.mode_wire,
46
+ };
47
+ const files = [];
48
+ const slots = new Map(schema.files.map((f) => [f.name, f]));
49
+ const unknown = [];
50
+ for (const [clean, value] of Object.entries(input ?? {})) {
51
+ if (value === undefined || value === null)
52
+ continue;
53
+ const slot = slots.get(clean);
54
+ if (slot) {
55
+ /* A reuse reference is resolved BEFORE toWire runs (generate does
56
+ it, because it needs a round trip), and estimate_cost now drops
57
+ every file slot before calling here, so no ordinary path reaches
58
+ this branch any more. It stays as a guard: a future caller that
59
+ forgets one of those two steps gets a sentence naming the input
60
+ instead of "[object Object]" arriving at the filesystem. */
61
+ if (isReuseRef(value)) {
62
+ throw new VidofyError('REUSE_NOT_SUPPORTED_HERE', `"${clean}" was given a from_generation reference, which only generate can resolve. ` +
63
+ 'estimate_cost prices the settings, not the file — omit file inputs when pricing.');
64
+ }
65
+ /* A multi-upload slot's wire name ends in a LITERAL "_N", because
66
+ the server numbers the fields itself: it matches
67
+ ^m_multi_file_\d+$ / ^m_multi_<type>_file_\d+$
68
+ as a pattern. Sent verbatim, the file
69
+ arrives under a field name nothing reads, the count comes to
70
+ zero, and the submit 422s below min_media — every one of the 56
71
+ models with a required multi slot was unusable until 2026-09-07.
72
+ These slots also take SEVERAL files, so the value may be a list. */
73
+ const isNumbered = slot.wire.endsWith('_N');
74
+ /* Check every ELEMENT, not just the value. `String()` on an object
75
+ * yields "[object Object]", which used to travel all the way to
76
+ * the filesystem and surface as
77
+ * FILE_UNREADABLE: ENOENT ... lstat '[object Object]'
78
+ * — an error that names nothing the caller wrote and leaks an
79
+ * internal call. A reuse reference inside a list is the case that
80
+ * hit it, and generate now lifts those out before this runs, so
81
+ * anything left here is genuinely not a path. */
82
+ const raw = Array.isArray(value) ? value : [value];
83
+ const paths = raw.map((el, i) => {
84
+ if (typeof el === 'string')
85
+ return el;
86
+ const where = Array.isArray(value) ? `${clean}[${i}]` : clean;
87
+ throw new VidofyError('INVALID_FILE_INPUT', `${where} must be a path to a file on this machine, or ` +
88
+ '{"from_generation": "<id>"} to reuse an earlier generation. ' +
89
+ `Received ${el === null ? 'null' : typeof el}.`);
90
+ });
91
+ if (!isNumbered && paths.length > 1) {
92
+ throw new VidofyError('TOO_MANY_FILES', `${clean} takes a single file, but ${paths.length} paths were given.`);
93
+ }
94
+ /* Number around whatever reuse already claimed, so a mixed list
95
+ * stays collision-free and keeps its order. */
96
+ const taken = takenIndices[clean] ?? new Set();
97
+ let next = 0;
98
+ paths.forEach((p) => {
99
+ while (taken.has(next))
100
+ next++;
101
+ const index = next++;
102
+ files.push({
103
+ field: isNumbered ? slot.wire.replace(/_N$/, `_${index}`) : slot.wire,
104
+ path: p,
105
+ // The limits travel with the file so the transport can
106
+ // refuse a wrong type or an oversized one before reading it.
107
+ accept: slot.accepts,
108
+ ...(slot.maxSizeMb !== null ? { maxSizeMb: slot.maxSizeMb } : {}),
109
+ // The length cap travels with the file for the same reason
110
+ // the size and type do: so the transport can refuse before
111
+ // the upload rather than after the server's 422.
112
+ ...(slot.maxDurationSec !== null ? { maxDurationSec: slot.maxDurationSec } : {}),
113
+ });
114
+ });
115
+ continue;
116
+ }
117
+ const wire = schema.wire[clean];
118
+ if (wire === undefined) {
119
+ unknown.push(clean);
120
+ continue;
121
+ }
122
+ /* A SETTING must be a single scalar, and the check has to be here.
123
+ *
124
+ * This line used to be `String(value)` with no test, and the input
125
+ * schema is `z.record(z.unknown())` — any shape parses — while the
126
+ * required-field check above asks only whether a key is PRESENT. So
127
+ * {"prompt": {"text": "a cat"}} passed every gate and reached the
128
+ * server as m_prompt=[object Object]. Coins are deducted when the
129
+ * submit is accepted, not when it succeeds, so the user paid for a
130
+ * generation on a literal that means nothing. A list is the same
131
+ * failure with a comma: String(["a","b"]) is "a,b".
132
+ *
133
+ * Refused rather than unwrapped. Guessing that {"text": …} meant its
134
+ * .text is how you charge someone for a generation on a value they
135
+ * never wrote — and the file slots above already refuse a non-string
136
+ * for exactly this reason, so this is the same rule applied to the
137
+ * other half of the input.
138
+ *
139
+ * Scalar is the whole legitimate set, measured across the live
140
+ * catalogue rather than assumed: every dynamic field in it is
141
+ * radio_group, select, slider or toggle, and none is multi-valued.
142
+ *
143
+ * Non-finite numbers go with them. NaN is typeof "number" and would
144
+ * slip through a plain typeof test as the string "NaN", which PHP
145
+ * casts to 0 — a slider silently priced and generated at zero is the
146
+ * same defect wearing a different type. */
147
+ const scalar = typeof value === 'string' ||
148
+ typeof value === 'boolean' ||
149
+ (typeof value === 'number' && Number.isFinite(value));
150
+ if (!scalar) {
151
+ const got = Array.isArray(value)
152
+ ? 'a list'
153
+ : typeof value === 'object'
154
+ ? 'an object'
155
+ : typeof value === 'number'
156
+ ? `${value}`
157
+ : typeof value;
158
+ throw new VidofyError('INVALID_INPUT_VALUE', `"${clean}" takes one text, number or true/false value — received ${got}. ` +
159
+ 'Pass the value itself, not a wrapper around it: ' +
160
+ `{"${clean}": "…"}, not {"${clean}": {"…": "…"}}. ` +
161
+ 'Call get_model for this input\'s type.');
162
+ }
163
+ form[wire] = typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value);
164
+ }
165
+ if (unknown.length) {
166
+ const known = [...Object.keys(schema.wire), ...slots.keys()].sort();
167
+ throw new VidofyError('UNKNOWN_INPUT_FIELD', `${schema.model_key} has no input called ${unknown.map((u) => `"${u}"`).join(', ')}. ` +
168
+ `Call get_model and use the names in its schema: ${known.join(', ')}.`);
169
+ }
170
+ return { form, files };
171
+ }
172
+ /* ── estimate_cost ───────────────────────────────────────────────────────── */
173
+ export const estimateCostInput = z.object({
174
+ model: z.string().min(1).describe('Model slug, as given to get_model.'),
175
+ input: z
176
+ .record(z.unknown())
177
+ .optional()
178
+ .describe('The same input object you would pass to generate. Price varies enormously with ' +
179
+ 'it — on some models by 20× between the cheapest and dearest settings — so pass ' +
180
+ 'the real values, not an empty object.'),
181
+ });
182
+ /**
183
+ * Ask the server what a generation would cost, before running it.
184
+ *
185
+ * The price is computed by the same calculator the submit path uses, so this
186
+ * is an answer rather than an estimate — provided the input matches what
187
+ * generate will be given.
188
+ */
189
+ export async function estimateCost(cfg,
190
+ // `| undefined` spelled out because exactOptionalPropertyTypes distinguishes
191
+ // "absent" from "present and undefined", and zod's .optional() produces the
192
+ // latter.
193
+ args) {
194
+ // The wire names live in the model's schema; sending clean names would
195
+ // price the model's defaults instead of what the agent actually chose, and
196
+ // do it silently.
197
+ const info = await request(cfg, {
198
+ method: 'GET',
199
+ path: `info/model-info/${encodeURIComponent(args.model)}`,
200
+ });
201
+ const schema = buildModelSchema(info);
202
+ /* Same mapping generate will use — see toWire — but with the file slots
203
+ * stripped out first.
204
+ *
205
+ * Pricing is computed from the settings (duration, resolution, quality,
206
+ * count), never from the bytes; the endpoint receives no upload and would
207
+ * drop one anyway. Passing them through meant a caller who priced the exact
208
+ * input they were about to generate got REUSE_NOT_SUPPORTED_HERE and the
209
+ * whole call failed, which forced them to build two different input objects
210
+ * for one operation — an easy way to price A and then generate B.
211
+ *
212
+ * Dropped silently and by SLOT NAME, so a path is discarded on the same
213
+ * footing as a reuse reference and neither can reach toWire's file branch. */
214
+ const pricingInput = { ...(args.input ?? {}) };
215
+ for (const slot of schema.files)
216
+ delete pricingInput[slot.name];
217
+ const { form } = toWire(schema, pricingInput);
218
+ const payload = await request(cfg, { method: 'POST', path: 'info/model-credits', form });
219
+ const cost = readCostCredits(payload, unitFor(cfg));
220
+ /* On an upload-billed model this figure is a FLOOR, and saying so is the
221
+ * whole point of the field.
222
+ *
223
+ * The block above drops every file slot before pricing, and the pricing
224
+ * endpoint receives no upload and would discard one anyway — but a minority
225
+ * of models are charged by the DURATION of the media the
226
+ * user uploads (the submit path measures it with ffprobe). For those, a
227
+ * settings-only number returned under the name `cost` is a quote that is
228
+ * always too low, told to the user as a price and then contradicted by the
229
+ * charge. The number is still worth returning — it is the settings
230
+ * component, and the agent has nothing else — but not under a name that
231
+ * claims to be the total. */
232
+ const upload = schema.billedByUploadDuration;
233
+ return {
234
+ model: schema.model_key,
235
+ cost: cost,
236
+ unit: unitFor(cfg),
237
+ /* Present either way, and false rather than absent: an agent that has
238
+ * to infer "no news is good news" from a missing key will eventually
239
+ * infer it from a key that went missing for a different reason. */
240
+ cost_is_final: !upload,
241
+ ...(upload ? { depends_on: 'the duration of the media you upload' } : {}),
242
+ note: (upload
243
+ ? 'THIS IS A FLOOR, NOT THE PRICE. This model is billed by the duration of ' +
244
+ 'the media you upload, which a quote never sends — the figure above covers ' +
245
+ 'the settings only and the real charge will be higher, in proportion to how ' +
246
+ 'long your file is. Tell the user that before generating. '
247
+ : '') +
248
+ `Charged when the generation is submitted, not when it finishes. ` +
249
+ `Re-check after changing any input — the price depends on all of them.`,
250
+ };
251
+ }
252
+ const isReuseRef = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) &&
253
+ typeof v['from_generation'] === 'string';
254
+ /**
255
+ * Turn `{from_generation: id}` into the R2 object that generation produced.
256
+ *
257
+ * The raw key is fetched here and never shown to the agent: it asks with an id
258
+ * it already has from get_result, and the key stays inside this package.
259
+ *
260
+ * `bucket` matters. A private first-party output lives in the PRIVATE bucket
261
+ * and comes back as a presigned URL, which is why matching on the URL would
262
+ * fail — the resolver's CDN-prefix test only knows the public domain. The key
263
+ * plus `bucket:'private'` sidesteps that entirely, and the worker resolves it
264
+ * with its own private-URL builder. Nothing is downloaded.
265
+ */
266
+ async function resolveReuse(cfg, ref, want, slotName, budgetMs) {
267
+ const payload = await request(cfg, {
268
+ method: 'GET',
269
+ path: `generate/result/${encodeURIComponent(ref.from_generation)}`,
270
+ allowEnvelopeError: true,
271
+ // Bounded by the caller's remaining budget — see the deadline in
272
+ // generate(). One attempt: a read that is retried three times inside a
273
+ // tool call the client has already abandoned helps nobody.
274
+ timeoutMs: budgetMs,
275
+ maxAttempts: 1,
276
+ });
277
+ const root = (payload ?? {});
278
+ const data = (root['data'] ?? {});
279
+ const result = (data['result'] ?? {});
280
+ const column = want === 'video' ? 'm_output_video' : want === 'audio' ? 'm_output_audio' : 'm_output_image';
281
+ const key = typeof result[column] === 'string' ? result[column].trim() : '';
282
+ if (key === '') {
283
+ const status = typeof data['m_status'] === 'string' ? data['m_status'] : 'unknown';
284
+ throw new VidofyError('REUSE_SOURCE_UNAVAILABLE', `Generation ${ref.from_generation} has no ${want} output to reuse for "${slotName}" ` +
285
+ `(its status is "${status}"). Only a finished generation of the right media type can be reused.`);
286
+ }
287
+ /* Always the PRIVATE bucket, and the reason is not the one this used to
288
+ give. It read `data['m_public']` and branched on it — but the result
289
+ endpoint emits m_public in NEITHER response shape, so that expression was always
290
+ '' and the branch always took this same side. A test that cannot fail is
291
+ worse than no test: it reads as a rule being applied.
292
+
293
+ Private is nonetheless the correct answer for everything an MCP caller
294
+ can reuse. The worker uploads every B2C output to the private bucket
295
+ FIRST and unconditionally, and only then COPIES a watermarked version to
296
+ the public one under the same key,
297
+ so the private object exists for every first-party row whatever m_public
298
+ says. Measured 2026-09-09 against live R2, ranged GET: private present
299
+ for 8/8 rows in each of image/video/audio x public/not-public.
300
+
301
+ The one shape with no private copy is a B2B row (m_origin='public_api'),
302
+ which is uploaded public-only. Reusing one would resolve to a key the
303
+ worker cannot fetch. It is reachable in principle — the result endpoint
304
+ scopes by user, not by origin, so an account that uses both the partner API and
305
+ this server could name such an id — and is left as a known gap rather
306
+ than guessed at, because closing it needs the origin in the payload. */
307
+ return {
308
+ key,
309
+ kind: want,
310
+ bucket: 'private',
311
+ file_name: key.split('/').pop() || key,
312
+ };
313
+ }
314
+ /* ── generate ────────────────────────────────────────────────────────────── */
315
+ export const generateInput = z.object({
316
+ model: z.string().min(1).describe('Model slug, as given to list_models / get_model.'),
317
+ input: z
318
+ .record(z.unknown())
319
+ .describe('The model\'s inputs, using the names from get_model. A file input takes EITHER ' +
320
+ 'a path to a file on this machine (pass the user\'s own file, never a copy you ' +
321
+ 'made), OR — to reuse something Vidofy already made — an object ' +
322
+ '{"from_generation": "<id of an earlier generation>"}. Reuse is the right way to ' +
323
+ 'chain: it costs no upload and no download, because the file is already in ' +
324
+ 'Vidofy\'s storage. Example: {"image": {"from_generation": "97803476830366548"}}.'),
325
+ /* The description says "on a subscriber account" because on a free one this
326
+ flag decides nothing: the submit handler forces m_public='on' below
327
+ u_pro 2 so that a watermarked copy exists, under the site's own B2C
328
+ watermark policy. Saying "the output stays private" to every caller
329
+ was a promise the server had stopped keeping. */
330
+ public: z
331
+ .boolean()
332
+ .optional()
333
+ .describe('Publish the result to the public Vidofy CDN, permanently. Default false. On a ' +
334
+ 'subscriber account false keeps the output private, served through a link that ' +
335
+ 'expires. On a free account the result is published and watermarked either way, ' +
336
+ 'and this flag changes nothing. Only pass true if the user asked for a public, ' +
337
+ 'permanent link.'),
338
+ });
339
+ /**
340
+ * Submit a generation. THIS SPENDS THE USER'S BALANCE.
341
+ *
342
+ * Charged at submit, not on success — so a job that
343
+ * fails later has still cost coins, refunded idempotently by the server's own
344
+ * failure path, not by anything here. Call estimate_cost first and show the
345
+ * user the number.
346
+ *
347
+ * Returns as soon as the job is queued, with an id to follow. It does not wait:
348
+ * a generation runs from ~30 seconds to several minutes, far past what a tool
349
+ * call should hold open, and the client would time out somewhere unpredictable.
350
+ * The agent polls get_status instead.
351
+ */
352
+ /**
353
+ * Has the caller gone away?
354
+ *
355
+ * ⚠ THE ONE RULE ABOUT THIS SIGNAL: check it BEFORE the submit, never wire it
356
+ * INTO the submit.
357
+ *
358
+ * Charging happens server-side the instant PHP accepts the submit. So aborting a
359
+ * submit that is already in flight is the worst available outcome, strictly worse
360
+ * than doing nothing: the charge may already have committed, and by abandoning the
361
+ * response we throw away the media_id — the user pays and we cannot even tell them
362
+ * what was created. Passing `signal` to that request() call would look like the
363
+ * careful thing and would be the harmful thing.
364
+ *
365
+ * Before the submit, nothing has been spent, so stopping is free and saves the
366
+ * whole charge. After it, there is nothing to undo and the generation is not lost:
367
+ * it lands in the user's account and shows up in get_usage and in the studio. It
368
+ * simply does not appear in the chat they walked away from. Do not "fix" that with
369
+ * a refund — the work was done and delivered.
370
+ */
371
+ function abandoned(ctx) {
372
+ return ctx?.signal?.aborted === true;
373
+ }
374
+ export async function generate(cfg, args, ctx) {
375
+ /* Checkpoint one, before any work at all.
376
+ *
377
+ * Cheap and rarely useful on its own — a client that has already gone by the
378
+ * time the handler starts is unusual. It is here because the expensive
379
+ * preparation begins on the next line, and the cost of checking is a property
380
+ * read. */
381
+ if (abandoned(ctx)) {
382
+ throw new VidofyError('CALL_ABANDONED', 'The caller disconnected before this generation started, so it was NOT submitted '
383
+ + 'and nothing was charged.');
384
+ }
385
+ /* ONE budget for the whole tool call, because the client has one.
386
+ *
387
+ * The submit was already capped at 50s/1 attempt against the SDK's 60s
388
+ * DEFAULT_REQUEST_TIMEOUT_MSEC, but the reasoning stopped there and the
389
+ * preparation before it kept the transport defaults: 60s × 4 attempts,
390
+ * plus backoff. So a single slow model-info could run 264s — and with a
391
+ * reuse reference, another 264s on top — inside a call the client
392
+ * abandoned at 60s. Measured against the report of a generate that hung
393
+ * "more than 4 minutes" and ended with the server not responding: that is
394
+ * exactly 4 × 60s + backoff, and the arithmetic is the whole explanation.
395
+ *
396
+ * A deadline fixes it where per-request timeouts cannot: they compose by
397
+ * ADDING, so every new preflight step makes the ceiling worse, while a
398
+ * deadline is the same ceiling no matter how many steps run under it.
399
+ * Each step gets what is left, so the last one to start is the one that
400
+ * fails — and it fails before the client gives up, which is the point:
401
+ * a timeout the client sees is a timeout, and a timeout it does not see
402
+ * is a charge with no receipt. */
403
+ const deadline = Date.now() + 55_000;
404
+ const remainingMs = () => Math.max(2_000, deadline - Date.now());
405
+ const info = await request(cfg, {
406
+ method: 'GET',
407
+ path: `info/model-info/${encodeURIComponent(args.model)}`,
408
+ // Cached server-side for 7 days, so 10s is already generous; capped by
409
+ // the deadline as well, for the case where several reuse refs ran first.
410
+ timeoutMs: Math.min(10_000, remainingMs()),
411
+ maxAttempts: 1,
412
+ });
413
+ const schema = buildModelSchema(info);
414
+ /* Pull out the file slots the agent asked to REUSE rather than upload.
415
+ *
416
+ * The natural agent workflow is a chain — make an image, then animate it —
417
+ * and re-uploading is the wrong way to do it: the bytes are already in
418
+ * Vidofy's own R2. `regen_source_map` points the new job at the existing
419
+ * object, so nothing is downloaded, nothing is uploaded, and the server
420
+ * checks ownership before using it.
421
+ *
422
+ * Resolution happens here, not in toWire, because it needs a round trip
423
+ * per reference; what toWire receives afterwards is only local paths. */
424
+ const reuse = {};
425
+ const takenIndices = {};
426
+ const inputForWire = { ...(args.input ?? {}) };
427
+ for (const slot of schema.files) {
428
+ const v = inputForWire[slot.name];
429
+ // The slot decides which of the source's outputs to take: an image
430
+ // slot wants its image, a lipsync video slot wants its video.
431
+ const want = /video/i.test(slot.wire) || /video/i.test(slot.name) ? 'video'
432
+ : /audio/i.test(slot.wire) || /audio/i.test(slot.name) ? 'audio'
433
+ : 'image';
434
+ /* Refuse a chain the SERVER will throw away, before spending on it.
435
+ *
436
+ * supportsReuse was computed and reported by get_model and then never
437
+ * acted on here. The server-side filter that decides matches
438
+ * /^m_multi_file_\d+$/ and an exact allow-list, so
439
+ * a per-type slot — m_multi_image_file_0 and its kin — matches neither
440
+ * and is dropped with a bare `continue`. Nothing is said. The
441
+ * generation then runs without the input the caller asked for, or
442
+ * 422s below min_media after a wasted round trip resolving a reference
443
+ * that was never going to be used.
444
+ *
445
+ * Measured 2026-09-10 over the live catalogue: of the 46 active listed
446
+ * models with multi-upload, 39 use the legacy m_multi_file_N and chain
447
+ * fine; 7 use per-type slots and cannot. It is a server gap — the
448
+ * studio cannot chain those either — so this refuses rather than
449
+ * pretending, and says which slot and what to do instead. */
450
+ if ((isReuseRef(v) || (Array.isArray(v) && v.some(isReuseRef))) && !slot.supportsReuse) {
451
+ throw new VidofyError('REUSE_NOT_SUPPORTED_FOR_SLOT', `"${slot.name}" cannot reuse an earlier generation on ${schema.model_key}: the ` +
452
+ `server discards a chained input for this slot (${slot.wire}), so the job ` +
453
+ 'would run without it. Download the file and pass its path instead, or pick ' +
454
+ 'a model whose get_model shows supportsReuse on this slot.');
455
+ }
456
+ if (isReuseRef(v)) {
457
+ reuse[slot.wire] = await resolveReuse(cfg, v, want, slot.name, Math.min(10_000, remainingMs()));
458
+ delete inputForWire[slot.name];
459
+ continue;
460
+ }
461
+ /* A multi-upload slot takes a LIST, and a chain is the natural way to
462
+ * fill one — "edit the image you just made, plus this photo of mine".
463
+ * Only a bare object was recognised before, so a reference inside the
464
+ * list fell through to the upload path and died on
465
+ * lstat('[object Object]'). Each reference is numbered at the position
466
+ * the caller wrote it, and toWire numbers the remaining files around
467
+ * those, so order survives and nothing collides.
468
+ *
469
+ * Restricted to numbered slots because the wire name is what carries
470
+ * the index; a single-file slot given a list is still an error, and
471
+ * toWire raises it. */
472
+ if (Array.isArray(v) && slot.wire.endsWith('_N') && v.some(isReuseRef)) {
473
+ const taken = new Set();
474
+ const rest = [];
475
+ for (let i = 0; i < v.length; i++) {
476
+ const el = v[i];
477
+ if (isReuseRef(el)) {
478
+ reuse[slot.wire.replace(/_N$/, `_${i}`)] =
479
+ await resolveReuse(cfg, el, want, `${slot.name}[${i}]`, Math.min(10_000, remainingMs()));
480
+ taken.add(i);
481
+ }
482
+ else {
483
+ rest.push(el);
484
+ }
485
+ }
486
+ takenIndices[slot.name] = taken;
487
+ if (rest.length)
488
+ inputForWire[slot.name] = rest;
489
+ else
490
+ delete inputForWire[slot.name];
491
+ }
492
+ }
493
+ const { form, files } = toWire(schema, inputForWire, takenIndices);
494
+ /* The map is keyed by the WIRE field name, which is exactly what the
495
+ server's own whitelist accepts — m_image / m_video / m_audio /
496
+ m_first_frame / m_last_frame / a dynamic field's own name / and
497
+ m_multi_file_N.
498
+
499
+ Known limit, and it is the SERVER's not ours: the per-type multi slots
500
+ (m_multi_<type>_file_N, 7 models) are absent from that whitelist, so
501
+ reuse is silently dropped for them — for the studio too, not just here. */
502
+ if (Object.keys(reuse).length) {
503
+ form['regen_source_map'] = JSON.stringify(reuse);
504
+ }
505
+ /* Refuse locally what the server would refuse anyway.
506
+ *
507
+ * Not a duplicate of the server's validator — it is the same check moved
508
+ * to where the error is readable. The 422 says INVALID_REQUEST with a
509
+ * field list; this says which named input is missing, in the vocabulary
510
+ * get_model just handed the agent, without a round trip. */
511
+ const missing = [
512
+ ...schema.inputSchema.required.filter((r) => !(r in (args.input ?? {}))),
513
+ ...schema.files.filter((f) => f.required && !(f.name in (args.input ?? {}))).map((f) => f.name),
514
+ ];
515
+ if (missing.length) {
516
+ throw new VidofyError('MISSING_REQUIRED_INPUT', `${schema.model_key} needs ${missing.map((m) => `"${m}"`).join(', ')}. ` +
517
+ 'Call get_model for the full input schema.');
518
+ }
519
+ /* Private by default. The server reads m_public as the literal 'on' — an
520
+ unchecked HTML checkbox sends nothing at all, which is exactly what
521
+ "private" means here, so the key is OMITTED rather than set to a falsy
522
+ value the handler would still see as present. */
523
+ if (args.public === true)
524
+ form['m_public'] = 'on';
525
+ /* Refuse to submit on a budget too small to hear the answer.
526
+ *
527
+ * Charging happens server-side the moment the submit is accepted, so a
528
+ * POST that starts with two seconds left is the worst possible outcome:
529
+ * the coins go, the answer arrives after the client has stopped listening,
530
+ * and the agent is told the call failed. Better to stop here, where
531
+ * nothing has been spent and the message can say so plainly. */
532
+ if (remainingMs() < 15_000) {
533
+ throw new VidofyError('PREFLIGHT_BUDGET_EXHAUSTED', 'Preparing this generation (reading the model, resolving the reused inputs) used the ' +
534
+ 'whole time budget, so it was NOT submitted and nothing was charged. Retry, or ' +
535
+ 'pass fewer from_generation references in one call.');
536
+ }
537
+ /* Checkpoint two — the one that matters, and it belongs exactly here.
538
+ *
539
+ * This is the last instant at which stopping is free: one line below, the POST
540
+ * goes out and the coins are gone. The guard above says the same thing about a
541
+ * spent time budget — "stop here, where nothing has been spent and the message
542
+ * can say so plainly" — and an abandoned call has the identical shape. The
543
+ * preparation that just ran (model-info, resolving every from_generation
544
+ * reference, hashing the payload) takes seconds and is where a user actually
545
+ * walks away, which is what makes this checkpoint the valuable one rather than
546
+ * the first.
547
+ *
548
+ * Measured 2026-09-13: a disconnect reaches this signal 5 ms after the socket
549
+ * dies, so by the time a multi-second preparation finishes, the flag is set. */
550
+ if (abandoned(ctx)) {
551
+ throw new VidofyError('CALL_ABANDONED', 'The caller disconnected while this generation was being prepared, so it was NOT '
552
+ + 'submitted and nothing was charged.');
553
+ }
554
+ const payload = await request(cfg, {
555
+ method: 'POST',
556
+ path: 'generate/submit',
557
+ form,
558
+ files,
559
+ /* One key per logical generation — and "logical" has to mean the
560
+ * REQUEST, not the invocation.
561
+ *
562
+ * The server dedupes on it — it reads it, checks it, and keys the coin
563
+ * ledger with it — which makes
564
+ * the transport's retries safe. But this was newIdempotencyKey(), a
565
+ * randomUUID() minted fresh on every call, so the tuple the server
566
+ * dedupes on could never repeat and the dedupe could never fire: two
567
+ * identical generate calls were two generations and two charges. Both
568
+ * halves of the mechanism were built; the value joining them
569
+ * guaranteed they would miss.
570
+ *
571
+ * Derived from the model, the resolved inputs and the public flag,
572
+ * inside a 60-second window — see contentIdempotencyKey for why the
573
+ * window is part of the key rather than a nicety (the server's lookup
574
+ * has no time bound of its own).
575
+ *
576
+ * `form` is the right thing to hash rather than args.input: it is what
577
+ * actually goes on the wire, after toWire has resolved reuse
578
+ * references and normalised names, so two spellings of one request
579
+ * agree and two different requests cannot collide. The file PATHS ride
580
+ * separately because their bytes are not in `form`. */
581
+ idempotencyKey: contentIdempotencyKey(form, files.map((f) => f.path)),
582
+ /* Deliberately UNDER the client's own budget, not over it.
583
+ *
584
+ * The SDK gives a tool call 60s by default
585
+ * (shared/protocol.js DEFAULT_REQUEST_TIMEOUT_MSEC = 60000). This
586
+ * used to be 90s, and 300s with files, reasoning that an upload needs
587
+ * room — which had it exactly backwards: the client gives up at 60s
588
+ * while the POST keeps running, the server charges, and the agent is
589
+ * told the call failed and retries with a FRESH idempotency key. That
590
+ * is a double charge produced by the timeout meant to prevent
591
+ * trouble. Whatever budget we take has to end before the client's, so
592
+ * a timeout here is a real timeout and not a lost receipt. */
593
+ // Whatever the preparation left, never more than the original 50s.
594
+ // Guarded above to be at least 15s, so this is never a token amount.
595
+ timeoutMs: Math.min(50_000, remainingMs()),
596
+ // And ONE attempt, for the same reason: 4 × 50s would outlive the
597
+ // client's 60s budget several times over. The server-side dedupe makes
598
+ // a retry harmless; the client giving up mid-charge is what does not.
599
+ maxAttempts: 1,
600
+ /* ⚠ NO `signal` HERE, AND THAT IS THE POINT — see abandoned() above.
601
+ Aborting this request would not stop the charge (PHP commits the moment
602
+ it accepts) and would lose the media_id, so the user would pay for a
603
+ generation nobody can name. The signal is checked BEFORE this line and
604
+ never during it. */
605
+ });
606
+ /* Charged, and the caller is gone. Nothing to undo — the generation is real and
607
+ lands in their account — but it is worth a line in the log, because "a user
608
+ was charged for something they never saw" is the kind of thing that should be
609
+ countable rather than invisible if it turns out to be common. */
610
+ if (abandoned(ctx)) {
611
+ log('generate: the caller disconnected after the submit was accepted — '
612
+ + 'the generation is charged and will complete; it appears in get_usage and the studio.');
613
+ }
614
+ const root = stripProviderCost(payload);
615
+ const data = (root['data'] ?? root);
616
+ const pick = (...keys) => {
617
+ for (const k of keys) {
618
+ if (data[k] !== undefined)
619
+ return data[k];
620
+ if (root[k] !== undefined)
621
+ return root[k];
622
+ }
623
+ return null;
624
+ };
625
+ const id = pick('media_id', 'id', 'm_id');
626
+ /* No id means we cannot tell the user what they just paid for.
627
+ *
628
+ * The submit answered 200 and the coins are gone, but the response carried
629
+ * no identifier — a shape change, a proxy, a truncated body. Returning
630
+ * `id: null` beside "call get_status with this id" (which is what this did
631
+ * until 2026-09-07) sends the agent to poll `null` and tells the user
632
+ * nothing went wrong. Fail loudly and point at the one place the job can
633
+ * still be found. */
634
+ if (id === null || id === undefined || String(id) === '') {
635
+ throw new VidofyError('SUBMIT_ID_MISSING', 'Vidofy accepted the generation but returned no id, so it cannot be tracked from here. ' +
636
+ 'The balance may already have been charged — call get_usage to find the job before ' +
637
+ 'trying again, or a retry will pay for a second one.');
638
+ }
639
+ /* `request_status` is the JOB. `status` is the ENVELOPE, and reading it
640
+ * here was a real bug: a submit answers `status: 'success'` meaning "the
641
+ * request was accepted" while the job it
642
+ * just queued is `request_status: 'processing'` and has not
643
+ * started. Neither shape carries `m_status` on submit — measured — so the
644
+ * old `pick('m_status', 'status', …)` fell through to the envelope every
645
+ * single time and told the agent its generation had SUCCEEDED, one field
646
+ * away from get_status saying 'processing'. An agent that believed it went
647
+ * straight to get_result on an unfinished job.
648
+ *
649
+ * Still read rather than asserted, because 'processing' is not the only
650
+ * honest answer: a duplicate Idempotency-Key replays the original row
651
+ * and reports that row's real state, which may have finished or
652
+ * failed long ago. Hardcoding 'queued' would send the agent to poll
653
+ * something that will never change again. */
654
+ const reported = pick('request_status');
655
+ const status = typeof reported === 'string' && reported !== '' ? reported : 'queued';
656
+ return {
657
+ id,
658
+ status,
659
+ spent: pick('coins_charged', 'credits_charged'),
660
+ unit: unitFor(cfg),
661
+ estimated_seconds: pick('m_sec', 'estimated_seconds'),
662
+ // Also read back, not echoed: the server is what decides, and for a
663
+ // replayed duplicate the answer belongs to the ORIGINAL submit.
664
+ visibility: String(pick('m_public') ?? '') === 'on' || args.public === true ? 'public' : 'private',
665
+ model: schema.model_key,
666
+ /* For the card, not for the agent — which is why they are separate
667
+ * fields rather than a nicer `model`.
668
+ *
669
+ * model_key is what chaining and every later call need, so it stays as
670
+ * `model` untouched. But "Flux_schnell_t2i" is a database key wearing a
671
+ * label's clothes, and the card showed it for the whole wait and then
672
+ * swapped to "Flux Schnell" on completion — the same generation
673
+ * appearing to change model. model_name is the name a person reads, and
674
+ * model_icon is the provider's mark, so the card can carry the maker's
675
+ * logo instead of a coloured square. Both come from the schema already
676
+ * in hand; neither costs a request. */
677
+ model_name: schema.name !== '' ? schema.name : schema.model_key,
678
+ model_icon: schema.icon,
679
+ /* 'processing' is what a fresh submit reports, and it has to be in this
680
+ * list: leaving it out sent every new generation down the "already
681
+ * finished" branch. 'queued'/'pending' stay for the fallback above and
682
+ * for any older shape. */
683
+ next: status === 'processing' || status === 'queued' || status === 'pending'
684
+ ? 'Charged now, not on success. Call get_status with this id — it is not finished ' +
685
+ 'yet. When done:true, call get_result.'
686
+ : `This id was already submitted before and Vidofy replayed that job, which is ` +
687
+ `"${status}". Nothing was charged twice. Call get_status to confirm, then get_result.`,
688
+ };
689
+ }
690
+ /* ── get_status ──────────────────────────────────────────────────────────── */
691
+ export const generationIdInput = z.object({
692
+ id: z.string().min(1).describe('The id returned by generate.'),
693
+ });
694
+ /** get_result takes the same id, plus a way to decline the inline image. */
695
+ export const generationResultInput = generationIdInput.extend({
696
+ include_preview: z
697
+ .boolean()
698
+ .optional()
699
+ .describe('Return the media itself as an image, so it appears in the conversation and you can ' +
700
+ 'see it. Default true. An image comes back as itself; a video comes back as its ' +
701
+ 'poster frame, with the video at url. Pass false when you only need the link — it ' +
702
+ 'saves roughly 1,400 tokens.'),
703
+ });
704
+ /**
705
+ * How long get_status may hold the call open before answering.
706
+ *
707
+ * A model has no way to wait. Told to "poll", with a tool it can call
708
+ * instantly and nothing saying how often, it calls as fast as the loop
709
+ * allows — measured on a real audio run, roughly once a second, each one a
710
+ * paid round trip. The pacing the card uses (poll_after_ms, 3s then 6s) lives
711
+ * on generation_card_state, which is visibility:['app'] and so invisible to
712
+ * the model by design.
713
+ *
714
+ * Holding the call is the half that does not depend on the model's goodwill:
715
+ * one call now covers ten seconds of real time whatever the model intends.
716
+ *
717
+ * Ten seconds, not more, because the host's own tool timeout is variable and
718
+ * outside our control (owner, 2026-09-11) — so this has to be short enough to
719
+ * be safe under the worst of it rather than tuned to any measured value.
720
+ */
721
+ const STATUS_HOLD_MS = 10_000;
722
+ /** Gap between internal re-reads while holding. */
723
+ const STATUS_RECHECK_MS = 2_000;
724
+ /**
725
+ * How long the agent should wait before asking again — the advisory half.
726
+ *
727
+ * Built on ELAPSED, deliberately, never on estimated_seconds. The estimate is
728
+ * not reliable enough to schedule against: measured 2026-09-11 over all
729
+ * successful rows, real time runs several times the estimate for images and
730
+ * about double for video, while audio is close. It is not stale data either —
731
+ * the stored estimate is only refreshed for a model with enough recent
732
+ * successes, and almost none qualify. Advising from it would send the agent
733
+ * back early, every time, on exactly the media type that is slowest.
734
+ *
735
+ * The steps come from the real distribution: audio finishes soonest, images
736
+ * next, video last.
737
+ */
738
+ function nextCheckIn(elapsedSeconds) {
739
+ const e = elapsedSeconds ?? 0;
740
+ if (e < 60)
741
+ return 5;
742
+ if (e < 180)
743
+ return 15;
744
+ return 30;
745
+ }
746
+ export async function getStatus(cfg, args) {
747
+ const read = async () => mapStatus(await request(cfg, {
748
+ method: 'GET',
749
+ path: `generate/status/${encodeURIComponent(args.id)}`,
750
+ // "Your generation failed" is an ANSWER, not a failed call — the server
751
+ // sends it as 200 + success:false. Let the mapper report it as a
752
+ // terminal status instead of throwing a transport-shaped error.
753
+ allowEnvelopeError: true,
754
+ }));
755
+ /* Hold until it finishes or the budget runs out, whichever comes first.
756
+ *
757
+ * Nothing here can finish sooner than this hold anyway: across the
758
+ * successful rows measured, the fastest generation ever recorded took about
759
+ * ten seconds (audio and image; video's floor is far higher), and with the NSFW
760
+ * pre-check enabled the request goes to a moderation model BEFORE the
761
+ * provider is called at all, which only pushes that floor later. So the
762
+ * first answer costs the agent nothing in responsiveness. */
763
+ const until = Date.now() + STATUS_HOLD_MS;
764
+ let status = await read();
765
+ while (!status.done && Date.now() + STATUS_RECHECK_MS <= until) {
766
+ await sleep(STATUS_RECHECK_MS);
767
+ status = await read();
768
+ }
769
+ if (status.done)
770
+ return status;
771
+ return { ...status, check_again_in_seconds: nextCheckIn(status.elapsed_seconds) };
772
+ }
773
+ /* ── get_result ──────────────────────────────────────────────────────────── */
774
+ /**
775
+ * The ceiling on a WHOLE tool result, text and image together.
776
+ *
777
+ * There are two different limits and picking the wrong one broke this: 5 MB is
778
+ * the cap on a single IMAGE at the API, but the client refuses a TOOL RESULT
779
+ * over 1 MB — "Tool result is too large. Maximum size is 1MB" — and that is the
780
+ * one that applies. It is enforced by the host, not by the SDK, so nothing in
781
+ * this package's types would have caught it.
782
+ *
783
+ * The failure is total, which is what makes it serious: the result is rejected
784
+ * whole, so the caller loses the URL, the id and the status as well as the
785
+ * preview. get_result stops being a way to read anything at all. Measured on
786
+ * two real generations: 553 KB of JPEG came back (721 KB encoded), 1,188 KB of
787
+ * PNG was refused (1,547 KB encoded).
788
+ *
789
+ * 1,000,000 rather than 1,048,576 — the message says "1MB" without saying
790
+ * which, and the few kilobytes are not worth being wrong about.
791
+ */
792
+ const MAX_TOOL_RESULT_BYTES = 1_000_000;
793
+ /**
794
+ * The biggest SOURCE file worth fetching for an inline preview.
795
+ *
796
+ * base64 inflates by 4/3, so this is the ceiling above less the room the JSON
797
+ * text needs. Checked before the download so an oversized file costs nothing,
798
+ * and checked again exactly after it — an estimate here, arithmetic there.
799
+ */
800
+ const MAX_INLINE_BYTES = Math.floor((MAX_TOOL_RESULT_BYTES - 20_000) * 3 / 4);
801
+ /**
802
+ * Fetch the bytes for an inline preview.
803
+ *
804
+ * Never throws. A preview is a nicety on top of a result the caller already
805
+ * has; failing the whole tool because a thumbnail would not download turns a
806
+ * successful, PAID generation into an error. Every failure path returns null
807
+ * and the caller reports the reason in words.
808
+ */
809
+ async function fetchPreview(url, knownBytes) {
810
+ // Refuse before spending anything, when the row told us the size.
811
+ if (knownBytes !== null && knownBytes > MAX_INLINE_BYTES)
812
+ return null;
813
+ try {
814
+ const res = await fetch(url, {
815
+ // Short and single: the media is already reachable by URL, so a
816
+ // slow preview is worth abandoning, never retrying. Same budget
817
+ // discipline as generate() — see the deadline there.
818
+ signal: AbortSignal.timeout(15_000),
819
+ redirect: 'error',
820
+ });
821
+ if (!res.ok)
822
+ return null;
823
+ /* Second guard, on the header. The row's size can be 0 (never
824
+ * recorded) or stale, and a video POSTER has no stored size at all —
825
+ * measured 7-98 KB, but measured is not guaranteed. Content-Length
826
+ * arrives before the body, so this still costs no transfer. */
827
+ const declared = Number(res.headers.get('content-length') ?? '');
828
+ if (Number.isFinite(declared) && declared > MAX_INLINE_BYTES)
829
+ return null;
830
+ const buf = Buffer.from(await res.arrayBuffer());
831
+ // Third guard, for a response that declared nothing.
832
+ if (buf.byteLength > MAX_INLINE_BYTES)
833
+ return null;
834
+ /* Normalise, do not pass through.
835
+ *
836
+ * R2 serves the posters as `image/jpg`, which is NOT a real media type
837
+ * — the registered one is `image/jpeg`, and `jpg` only ever existed as
838
+ * a filename extension. It comes from the upload path naming the type
839
+ * after the extension. Trusting the header verbatim put an invalid
840
+ * type on the wire, which a client is entitled to reject outright, and
841
+ * it is exactly the kind of thing that fails on someone else's client
842
+ * and not on the one you tested with.
843
+ *
844
+ * An allow-list rather than a rewrite of whatever arrives: only these
845
+ * four are safe to claim, so anything unrecognised falls back to the
846
+ * extension and then to JPEG. */
847
+ const raw = (res.headers.get('content-type')?.split(';')[0] ?? '').trim().toLowerCase();
848
+ const CANON = {
849
+ 'image/jpeg': 'image/jpeg',
850
+ 'image/jpg': 'image/jpeg',
851
+ 'image/png': 'image/png',
852
+ 'image/webp': 'image/webp',
853
+ 'image/gif': 'image/gif',
854
+ };
855
+ const mimeType = CANON[raw] ??
856
+ (/\.png(\?|$)/i.test(url) ? 'image/png'
857
+ : /\.webp(\?|$)/i.test(url) ? 'image/webp'
858
+ : /\.gif(\?|$)/i.test(url) ? 'image/gif'
859
+ : 'image/jpeg');
860
+ return { data: buf.toString('base64'), mimeType };
861
+ }
862
+ catch {
863
+ return null;
864
+ }
865
+ }
866
+ export async function getResult(cfg, args) {
867
+ const payload = await request(cfg, {
868
+ method: 'GET',
869
+ path: `generate/result/${encodeURIComponent(args.id)}`,
870
+ // Same as get_status — the result endpoint reports a failed job the
871
+ // same way, as a 200 whose envelope says success:false.
872
+ allowEnvelopeError: true,
873
+ });
874
+ // No mode argument: whether the URL expires is a property of the row, and
875
+ // the mapper reads it off the URL itself. See the note in mapResult.
876
+ const mapped = mapResult(payload);
877
+ if (!mapped.done) {
878
+ return {
879
+ ...mapped,
880
+ hint: 'Still running. Call get_status to check, and get_result once it reports done.',
881
+ };
882
+ }
883
+ /* Put the media IN the conversation, not just a link to it.
884
+ *
885
+ * A URL is invisible to the model — it is a string. Without this the agent
886
+ * hands over an address it has never seen, cannot say whether the light is
887
+ * right or which of two takes is better, and reaches for some other tool to
888
+ * look. MCP's image content block is the mechanism, and its `data` field
889
+ * takes base64 bytes only; a URL placed there decodes to noise.
890
+ *
891
+ * The link is NOT replaced. The block is a view; the URL is the file the
892
+ * user keeps, the full resolution, the video itself, and the only thing
893
+ * left when a preview is skipped. */
894
+ if (args.include_preview === false)
895
+ return mapped;
896
+ const isImage = mapped.media_type === 'image';
897
+ const isVideo = mapped.media_type === 'video';
898
+ /* Video is a link, always: the protocol has no video block. Its POSTER is
899
+ * a separate JPEG of a few tens of KB, so the frame goes inline
900
+ * and the video stays a URL. Audio has neither a poster nor a size that
901
+ * would fit — several MB typical — so it is a link and says so. */
902
+ /* An image now has a poster of its own too (2026-09-11).
903
+ *
904
+ * Until then m_thumbnail WAS the output key for images — identical on every
905
+ * row — so sending "the thumbnail" sent the full file and
906
+ * the block was refused for exceeding the 1 MB tool-result limit on most
907
+ * images. The pipeline now writes a real 768px WebP beside the output;
908
+ * measured on the first real generation through it, 14 KB against 1,514 KB.
909
+ *
910
+ * Compared against the url rather than merely checked for existence,
911
+ * because on every row made BEFORE this the two are still the same string
912
+ * — no backfill was done (owner, 2026-09-11) — and those must keep the old
913
+ * behaviour exactly, size guard included. A row with a real thumbnail
914
+ * skips that guard: output_size describes the full file, which is not
915
+ * what we are fetching. */
916
+ const imageThumb = isImage && mapped.thumbnail_url !== null && mapped.thumbnail_url !== mapped.url
917
+ ? mapped.thumbnail_url
918
+ : null;
919
+ const src = isImage ? (imageThumb ?? mapped.url) : isVideo ? mapped.thumbnail_url : null;
920
+ const known = isImage && imageThumb === null ? mapped.output_size : null;
921
+ if (src === null) {
922
+ return {
923
+ ...mapped,
924
+ preview: `No inline preview for ${mapped.media_type ?? 'this media type'} — open the url.`,
925
+ };
926
+ }
927
+ const preview = await fetchPreview(src, known);
928
+ if (preview === null) {
929
+ const tooBig = known !== null && known > MAX_INLINE_BYTES;
930
+ return {
931
+ ...mapped,
932
+ preview: tooBig
933
+ ? `Not shown inline: the file is ${(known / 1_048_576).toFixed(1)} MB, and a tool ` +
934
+ `result may carry only 1 MB in total once the image is base64-encoded. ` +
935
+ `Open the url for it — everything else in this result is complete.`
936
+ : 'The inline preview could not be fetched. The result itself is fine — open the url.',
937
+ };
938
+ }
939
+ /* Last guard, and the only exact one: measure the finished result.
940
+ *
941
+ * Everything above is an estimate — a size the row reported, a header the
942
+ * origin declared, a byte count before encoding. This weighs what will
943
+ * actually be sent. It matters because being wrong here is not "no
944
+ * preview", it is the host rejecting the entire result, so the caller
945
+ * loses the URL and the status too and has no idea why. */
946
+ const textBytes = Buffer.byteLength(JSON.stringify(mapped, null, 2), 'utf8');
947
+ if (textBytes + preview.data.length > MAX_TOOL_RESULT_BYTES) {
948
+ return {
949
+ ...mapped,
950
+ preview: `Not shown inline: encoded it comes to ${(preview.data.length / 1_048_576).toFixed(1)} MB, ` +
951
+ `over the 1 MB a tool result may carry. The file itself is fine — open the url.`,
952
+ };
953
+ }
954
+ const out = {
955
+ ...mapped,
956
+ preview: isVideo
957
+ ? 'The image shown is the video POSTER. The video itself is at url.'
958
+ : 'Shown above. url is the same file at full resolution.',
959
+ };
960
+ /* Held beside the result, never on it — see setPreview. The dispatcher
961
+ * reads it back and emits a separate image block. */
962
+ setPreview(out, preview);
963
+ return out;
964
+ }
965
+ /**
966
+ * Media a tool wants shown, held BESIDE the result rather than on it.
967
+ *
968
+ * This was a Symbol key on the result object, chosen because JSON.stringify
969
+ * skips symbol keys — structural protection instead of a rule to remember. It
970
+ * was also, exactly, a bug: the SDK validates every tool result against
971
+ * CallToolResultSchema, structuredContent is a z.record(z.string(), …), and Zod
972
+ * walks own SYMBOL keys too. So the response was rejected before it left, as
973
+ * `-32602 Invalid tools/call result: expected string, received symbol`, and the
974
+ * host showed "Failed to call tool". Deterministic: every get_result carrying a
975
+ * preview failed and include_preview:false always worked, which is what made it
976
+ * look like a size limit when size had nothing to do with it.
977
+ *
978
+ * A WeakMap has no key on the object at all — nothing to serialise, nothing to
979
+ * validate, nothing to iterate over. It is the primitive this needed from the
980
+ * start. Entries die with the result they belong to.
981
+ */
982
+ const previews = new WeakMap();
983
+ /** Attach media to a result. @see takePreview */
984
+ export function setPreview(result, preview) {
985
+ previews.set(result, preview);
986
+ }
987
+ /** The media attached to a result, if any. Read once by the dispatcher. */
988
+ export function takePreview(result) {
989
+ return result !== null && typeof result === 'object' ? previews.get(result) ?? null : null;
990
+ }
991
+ /* ── the generation card's state (MCP Apps) ──────────────────────────────── */
992
+ /**
993
+ * Turn the stored dimension into something a person reads.
994
+ *
995
+ * The column holds JSON — `{"width":1344,"height":768}` — and the card was
996
+ * printing it verbatim, so a finished image was labelled with a fragment of
997
+ * database. Anything unparseable is returned as-is rather than dropped: a
998
+ * value we did not expect is still better shown than hidden.
999
+ */
1000
+ function readableDimensions(raw) {
1001
+ if (raw === null || raw === '')
1002
+ return null;
1003
+ try {
1004
+ const d = JSON.parse(raw);
1005
+ const w = Number(d.width);
1006
+ const h = Number(d.height);
1007
+ if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
1008
+ // × (U+00D7), not the letter x — this is a dimension, not a variable.
1009
+ return `${Math.round(w)} × ${Math.round(h)}`;
1010
+ }
1011
+ }
1012
+ catch {
1013
+ /* not JSON — some rows store "1920x1080" already */
1014
+ }
1015
+ return raw;
1016
+ }
1017
+ /**
1018
+ * Seconds as a person reads them: 47s, 3m 54s.
1019
+ *
1020
+ * One implementation, because the card shows a duration twice — counting up
1021
+ * while the job runs, and settled in the footer once it finishes — and two
1022
+ * spellings of the same quantity on one card ("190s elapsed" above, "234s"
1023
+ * below) look like two different kinds of number.
1024
+ */
1025
+ function prettySeconds(secs) {
1026
+ if (secs === null || !Number.isFinite(secs))
1027
+ return null;
1028
+ const s = Math.max(0, Math.round(secs));
1029
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
1030
+ }
1031
+ /**
1032
+ * One reading of a generation, shaped for the card view.
1033
+ *
1034
+ * Called by the view through the host, never by the model — the tool is
1035
+ * registered app-only. So it may be called every few seconds without costing
1036
+ * tokens or filling the transcript, and it must stay cheap: one request while
1037
+ * the job runs, two once it finishes.
1038
+ *
1039
+ * Status carries the progress; only a finished job needs the result, and only
1040
+ * then does the second call happen.
1041
+ */
1042
+ export async function cardState(cfg, args) {
1043
+ const status = mapStatus(await request(cfg, {
1044
+ method: 'GET',
1045
+ path: `generate/status/${encodeURIComponent(args.id)}`,
1046
+ allowEnvelopeError: true,
1047
+ timeoutMs: 12_000,
1048
+ maxAttempts: 1,
1049
+ }));
1050
+ if (!status.done) {
1051
+ /* Only what is TRUE while a job runs: how long it has been going.
1052
+ *
1053
+ * The estimate (m_sec) is an average over past runs of this model, not
1054
+ * a forecast of this one, and it is wrong in both directions routinely.
1055
+ * Two things used to be built on it and both misled:
1056
+ *
1057
+ * "about 170s" — read as a promise. Measured 2026-09-10 on
1058
+ * veo-3-1-fast-i2v: estimate 170s, real 234s.
1059
+ * a percentage — elapsed/estimate, clamped to 95. Not "no percentage
1060
+ * invented" as the old comment claimed: it IS the
1061
+ * estimate wearing a different hat, so the same run
1062
+ * sat at 95% for the final minute. A bar that parks
1063
+ * just short of full is the classic lying bar.
1064
+ *
1065
+ * Removing the estimate and keeping the bar would have kept the lie and
1066
+ * dropped the disclaimer, so both go. The card shows a turning ring and
1067
+ * an indeterminate sweep instead — motion says "running" without
1068
+ * claiming to know when it ends.
1069
+ *
1070
+ * A "taking longer than usual" note was kept for a day as a THRESHOLD
1071
+ * use of the estimate — the one use that a passing second cannot
1072
+ * falsify — and then removed on measurement: it appeared on a large
1073
+ * fraction of perfectly successful runs of every media type.
1074
+ * Backfilling the estimate from real generations did not
1075
+ * rescue it — the rates barely moved — because the fault is
1076
+ * definitional, not data. m_sec is a MEAN, and roughly half of all
1077
+ * perfectly normal runs exceed their own mean; "unusual" would need a
1078
+ * high percentile, which this column is not and never was. A note
1079
+ * that fires on the majority of healthy runs is decoration.
1080
+ *
1081
+ * So the estimate now drives nothing on this card. Elapsed is a fact;
1082
+ * it is all the card claims. */
1083
+ const elapsed = status.elapsed_seconds;
1084
+ const secs = elapsed === null ? null : Math.max(0, Math.round(elapsed));
1085
+ const pretty = prettySeconds(secs);
1086
+ return {
1087
+ id: status.id,
1088
+ status: status.status,
1089
+ done: false,
1090
+ model: status.model,
1091
+ // Always null: see above. Kept in the payload because the card's
1092
+ // setWaiting still accepts a number, so a real per-stage progress
1093
+ // signal — if the pipeline ever reports one — needs no card change.
1094
+ progress: null,
1095
+ wait_label: pretty !== null ? `${pretty} elapsed` : 'working…',
1096
+ // Slower once a job is clearly long, so a four-minute video is not
1097
+ // eighty round trips.
1098
+ poll_after_ms: (elapsed ?? 0) > 60 ? 6_000 : 3_000,
1099
+ };
1100
+ }
1101
+ if (status.status !== 'success') {
1102
+ return {
1103
+ id: status.id,
1104
+ status: status.status,
1105
+ done: true,
1106
+ model: status.model,
1107
+ error: status.error ?? 'The generation did not finish.',
1108
+ /* The server refunds a failed job on its own, idempotently; saying
1109
+ * so in the card is the difference between a user who is annoyed
1110
+ * and one who thinks they were charged for nothing.
1111
+ *
1112
+ * But NOT for deleted_media, which is not a failure: that
1113
+ * generation delivered, and an operator removed its files
1114
+ * afterwards. No coins come back, and telling someone
1115
+ * their money was returned when it was not is the one error here
1116
+ * that costs trust rather than time.
1117
+ *
1118
+ * Measured against the coin ledger on first-party rows: over a
1119
+ * recent window EVERY blocked, error and failed row carries a
1120
+ * refund — so `true` is right for those — while NO deleted_media
1121
+ * row ever has. (Older rows show far lower rates because refunds
1122
+ * were not ledger-recorded then. Those predate the ledger, not the
1123
+ * refund, and a card only ever shows a generation the user just
1124
+ * made.) */
1125
+ refunded: status.status !== 'deleted_media',
1126
+ };
1127
+ }
1128
+ const result = mapResult(await request(cfg, {
1129
+ method: 'GET',
1130
+ path: `generate/result/${encodeURIComponent(args.id)}`,
1131
+ allowEnvelopeError: true,
1132
+ timeoutMs: 12_000,
1133
+ maxAttempts: 1,
1134
+ }));
1135
+ return {
1136
+ id: result.id,
1137
+ status: 'success',
1138
+ done: true,
1139
+ model: result.model,
1140
+ media_type: result.media_type,
1141
+ url: result.url,
1142
+ /* The generation's page on the site — where it can be renamed, shared,
1143
+ * published to the feed, or used as the start of another one.
1144
+ *
1145
+ * Built from the configured base rather than written down, so it points
1146
+ * at whichever deployment this server talks to. Reachable by the owner
1147
+ * even when the output is private: the view page admits a row that is
1148
+ * either public or the caller's own, so a signed-in owner sees their own
1149
+ * work and nobody else's. */
1150
+ vidofy_url: result.id !== null
1151
+ ? `${new URL(cfg.baseUrl).origin}/en/view/${encodeURIComponent(result.id)}`
1152
+ : null,
1153
+ // For a video this is the poster; for an image the mapper reports the
1154
+ // same key as url, which the card simply does not use.
1155
+ poster_url: result.thumbnail_url,
1156
+ download_url: result.download_url,
1157
+ /* The watermark, and where to go to be rid of it.
1158
+ *
1159
+ * A free account's output is published and watermarked — the submit
1160
+ * handler forces m_public='on' below u_pro 2 precisely so a watermarked
1161
+ * copy exists, under the site's own B2C watermark policy. A
1162
+ * subscriber's stays private and clean.
1163
+ *
1164
+ * The card offers "Remove watermark" only when the file it is showing
1165
+ * actually carries one, so the button never appears over a clean image.
1166
+ * pricing_url is null in that case rather than always present: a button
1167
+ * cannot be rendered by mistake if there is nowhere for it to go. */
1168
+ watermarked: result.watermarked,
1169
+ pricing_url: result.watermarked
1170
+ ? `${new URL(cfg.baseUrl).origin}/en/pricing`
1171
+ : null,
1172
+ credits: result.credits_charged,
1173
+ dimensions: readableDimensions(result.dimensions),
1174
+ /* How long it TOOK to make, not how long the file plays.
1175
+ *
1176
+ * It is result.elapsed_seconds — submit to final state. The footer
1177
+ * printed it bare, as "234s", immediately after "1920 × 1080": one
1178
+ * property of the file followed by a number that is not one. An
1179
+ * 8-second clip that took 234 seconds therefore advertised itself as
1180
+ * nearly four minutes long. Reformatting alone would have deepened
1181
+ * that — "3m 54s" reads even more like a running time — so the word
1182
+ * comes with it.
1183
+ *
1184
+ * Sent ready to print: the counter above it is worded on this side
1185
+ * too, and a second copy of the formatter in the card would be a
1186
+ * second place for the two to drift apart. */
1187
+ duration_label: result.elapsed_seconds === null
1188
+ ? null
1189
+ : `took ${prettySeconds(result.elapsed_seconds)}`,
1190
+ };
1191
+ }
1192
+ /** Exported for the gate, which asserts nothing costed ever escapes. */
1193
+ export { stripProviderCost };
1194
+ //# sourceMappingURL=generation.js.map