@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/dist/schema.js ADDED
@@ -0,0 +1,629 @@
1
+ /**
2
+ * Turn one model's admin-authored `m_options` into a JSON Schema an agent can
3
+ * fill in, plus the wire names needed to actually submit it.
4
+ *
5
+ * This is the widest surface in the package. A human wrote the options for every
6
+ * model in the catalogue, so the rule here is: describe only what the data
7
+ * actually contains, and stay silent rather than invent. A property this file
8
+ * omits costs the agent a feature; a property it invents costs the user a
9
+ * rejected generation they already waited for.
10
+ *
11
+ * SHAPES, MEASURED ACROSS THE WHOLE ACTIVE CATALOGUE — not read off the docs:
12
+ * m_aspect_ratio ARRAY all (often empty)
13
+ * m_resolution_quality ARRAY all (often empty)
14
+ * m_duration OBJECT some KEYS are the durations; each value is
15
+ * the list of resolutions that duration
16
+ * allows ({"5": [], "10": []} = any)
17
+ * m_output_number INTEGER all
18
+ * m_dynamic_fields ARRAY all a long tail of field names
19
+ * m_multi_upload OBJECT all 8 key sets, TWO families (see below)
20
+ * m_negative_prompt / m_seed / m_generate_audio /
21
+ * m_camera_fixed / m_enhance_prompt all BOOLEAN
22
+ *
23
+ * DYNAMIC FIELD TYPES IN USE — only five, though the docs list nine:
24
+ * radio_group · select · toggle · slider · file_upload_image
25
+ * Unknown types degrade to a string rather than throwing: a model added
26
+ * tomorrow must not break the whole catalogue.
27
+ */
28
+ /* ── helpers ─────────────────────────────────────────────────────────────── */
29
+ const asRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) ? v : {};
30
+ const asArray = (v) => (Array.isArray(v) ? v : []);
31
+ const isTrue = (v) => v === true || v === 1 || v === '1' || v === 'true' || v === 'on';
32
+ const num = (v) => {
33
+ const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
34
+ return Number.isFinite(n) ? n : null;
35
+ };
36
+ /**
37
+ * Clean names for wire fields whose obvious de-prefixing would be wrong or
38
+ * inconsistent.
39
+ *
40
+ * EMPTY, and that is the finished state rather than a stub.
41
+ *
42
+ * It held one entry — `m_resolution_quality: 'resolution'` — placed there for a
43
+ * good reason: that field reaches the schema by TWO routes (the base
44
+ * `m_resolution_quality` array, and on other models a dynamic field of the same
45
+ * name), and without the table the same input would have been called
46
+ * `resolution` on one model and `resolution_quality` on the next. One name for
47
+ * one input was right.
48
+ *
49
+ * The name it chose was not. `m_resolution` is a DIFFERENT wire field, carried
50
+ * by video models as a per-duration list, and it also claimed `resolution` — so
51
+ * two distinct fields shared one clean name, and `wire[]` kept only whichever
52
+ * registered last. Harmless while no model needed both, which was measured and
53
+ * true; `test-i2i` then carried both and the agent lost the ability to send a
54
+ * field the validator demands (gate: SERVER_REQUIRES_MISSING_FIELD, 2026-09-12).
55
+ *
56
+ * Nothing aliases the two for us. The server DOES alias them — but only on a
57
+ * JSON body, and account mode submits multipart, where the only
58
+ * canonicalisation is stripping a "p" suffix. The partner reference says it
59
+ * outright:
60
+ * "a multipart submit that sends only m_resolution to a model requiring it gets
61
+ * a 422 listing m_resolution_quality as missing."
62
+ *
63
+ * So each field now keeps its own de-prefixed name — `resolution_quality` and
64
+ * `resolution` — which is also the pair /api/v1 documents, so MCP and the
65
+ * Partners API say the same words. Collision is gone by construction, and the
66
+ * both-routes consistency the table existed for still holds, because plain
67
+ * de-prefixing gives both routes the same answer.
68
+ *
69
+ * Add an entry here only for a field whose de-prefixed name would be WRONG,
70
+ * never to merge two fields into one name.
71
+ */
72
+ const CLEAN_NAME = {};
73
+ const cleanNameFor = (wireName) => CLEAN_NAME[wireName] ?? wireName.replace(/^m_/, '');
74
+ /**
75
+ * The server's own fallback when a model declares no upload limits.
76
+ *
77
+ * Mirrors the server's own hardcoded upload defaults, whose rule is that an
78
+ * empty declared list falls back to them — an empty list means "use the
79
+ * defaults", NEVER "allow anything".
80
+ *
81
+ * This package used to read the declared list and, when it was empty, emit
82
+ * `accepts: []`, which the transport read as "no allowlist to enforce". That is
83
+ * fail-OPEN where the server fails closed, and it was not hypothetical: 10+
84
+ * active, popular models (nano-banana-i2i, gpt-image-1-5-edit-i2i,
85
+ * seedream-4-5-edit-i2i, wan-2-6-r2v …) ship a multi-upload slot with no
86
+ * extensions declared. On those, an agent talked into passing a path to an SSH
87
+ * key or a .env would have had it read and uploaded — measured 2026-09-07,
88
+ * `wallet.pem` went through untouched.
89
+ */
90
+ const UPLOAD_DEFAULTS = {
91
+ image: { accepts: ['jpg', 'jpeg', 'png', 'webp'], maxSizeMb: 10 },
92
+ video: { accepts: ['mp4', 'mov'], maxSizeMb: 50 },
93
+ audio: { accepts: ['mp3', 'wav'], maxSizeMb: 25 },
94
+ };
95
+ /**
96
+ * Resolve one slot's real limits, the way the server resolves them.
97
+ *
98
+ * `kind` is image/video/audio; anything else falls back to image, matching the
99
+ * server's own defaults table and its image fallback.
100
+ */
101
+ function resolveUploadRules(declaredExts, declaredMax, kind) {
102
+ const d = UPLOAD_DEFAULTS[kind] ?? UPLOAD_DEFAULTS['image'];
103
+ const exts = asArray(declaredExts)
104
+ // CSV-in-array is real legacy data — the server's own normaliser
105
+ // handles 'jpg,png' and ['jpg,png'] as well as a proper array.
106
+ .flatMap((v) => String(v).split(','))
107
+ .map((s) => s.trim().toLowerCase().replace(/^\./, ''))
108
+ .filter((s) => s !== '');
109
+ const max = num(declaredMax);
110
+ return {
111
+ accepts: exts.length ? exts : d.accepts,
112
+ maxSizeMb: max !== null && max > 0 ? max : d.maxSizeMb,
113
+ };
114
+ }
115
+ /** Descriptions are admin-authored and contain markup like `<br>`. */
116
+ const plain = (v) => String(v ?? '')
117
+ .replace(/<br\s*\/?>/gi, ' ')
118
+ .replace(/<[^>]*>/g, '')
119
+ .replace(/\s+/g, ' ')
120
+ .trim();
121
+ /* ── the builder ─────────────────────────────────────────────────────────── */
122
+ /**
123
+ * @param payload The whole `/info/model-info/{slug}` body. The B2C door wraps
124
+ * the model under `.model` with m_-prefixed keys; the B2B door returns the
125
+ * options flat and unprefixed. Both are accepted, and which one arrived is
126
+ * decided by the PAYLOAD, never by the configured mode — a status lookup can
127
+ * legitimately return the other shape.
128
+ */
129
+ export function buildModelSchema(payload) {
130
+ const root = asRecord(payload);
131
+ const wrapped = asRecord(root['model']);
132
+ const isWrapped = Object.keys(wrapped).length > 0;
133
+ const model = isWrapped ? wrapped : root;
134
+ /** Read a model field under either naming. */
135
+ const mf = (key) => model[`m_${key}`] ?? model[key];
136
+ const opts = asRecord(mf('options'));
137
+ /** Read an option under either naming. */
138
+ const opt = (key) => opts[`m_${key}`] ?? opts[key];
139
+ const properties = {};
140
+ const required = [];
141
+ const wire = {};
142
+ /* supportsReuse is derived from the finished list, not set at each of the
143
+ * five push sites — see the block just above the return. */
144
+ const files = [];
145
+ const notes = [];
146
+ /* Admin defaults — the reason a "required" field often is not.
147
+ *
148
+ * The server applies model defaults BEFORE
149
+ * every required check, filling any listed field that arrives absent or
150
+ * empty from `m_options.m_defaults`. So a
151
+ * field with a non-empty default can never be missing by the time the check
152
+ * runs — marking it required in the schema is stricter than the server and
153
+ * makes the agent supply something it did not need to.
154
+ *
155
+ * 72 model/field pairs carry one today (60 resolution, 4 output_format,
156
+ * 4 style, 4 movement_amplitude), and this was missed when those four
157
+ * fields were made required. It also inflated the figure quoted that day:
158
+ * the count of models missing one of those four fields included many that
159
+ * carried a default the server would have filled, so the number that could
160
+ * actually never generate was far smaller. */
161
+ const modelDefaults = asRecord(opt('defaults'));
162
+ const defaultFor = (wireName) => {
163
+ const v = modelDefaults[wireName];
164
+ return v === undefined || v === null ? '' : String(v);
165
+ };
166
+ const add = (name, wireName, prop, isRequired = false) => {
167
+ /* Two wire fields may never share one clean name.
168
+ *
169
+ * `wire[name] = wireName` below is a plain assignment, so a second
170
+ * registration under the same name used to overwrite the first and take
171
+ * the losing field out of the map entirely. Nothing failed at that
172
+ * moment: the schema still validated, the tool still answered, and the
173
+ * only symptom arrived later as a 422 from the server naming a field the
174
+ * agent was never told about.
175
+ *
176
+ * It happened. `resolution` was issued to both m_resolution_quality and
177
+ * m_resolution, safe only because no model populated both — measured,
178
+ * documented in a comment, and then falsified by a single row.
179
+ *
180
+ * So the invariant is enforced here instead of being asserted in prose.
181
+ * Throwing is the right answer rather than renaming silently: a
182
+ * duplicate means two inputs were given one identity, and which of them
183
+ * the agent should send is a decision for whoever added the second, not
184
+ * a default this helper can guess. get_model surfaces the throw as an
185
+ * error on that model alone, which is loud, local, and impossible to
186
+ * ship unnoticed — the gate builds a schema for every active model. */
187
+ if (wire[name] !== undefined && wire[name] !== wireName) {
188
+ throw new Error(`schema: clean name "${name}" is already mapped to "${wire[name]}" and cannot ` +
189
+ `also carry "${wireName}" — give one of them its own name (see CLEAN_NAME).`);
190
+ }
191
+ const dflt = defaultFor(wireName);
192
+ // Surface the default so the agent can send it deliberately, and drop
193
+ // the required flag when one exists — the server will fill it anyway.
194
+ // Only a string default is copied in, and only when the property has
195
+ // none of its own. exactOptionalPropertyTypes forbids spreading a
196
+ // possibly-undefined `default` back over the object.
197
+ properties[name] =
198
+ dflt !== '' && prop.default === undefined && prop.type === 'string'
199
+ ? { ...prop, default: dflt }
200
+ : prop;
201
+ wire[name] = wireName;
202
+ if (isRequired && dflt === '')
203
+ required.push(name);
204
+ };
205
+ /* ── prompt ──────────────────────────────────────────────────────────
206
+ Required exactly when the shared validator requires it:
207
+ m_active_prompt is on AND m_prompt_settings.required is not false.
208
+ Mirrored from the server's shared validator rather than guessed — this is
209
+ the single most common reason a submit is refused. */
210
+ const promptSettings = asRecord(opt('prompt_settings'));
211
+ if (isTrue(opt('active_prompt'))) {
212
+ const maxChars = num(promptSettings['max_chars']);
213
+ add('prompt', 'm_prompt', {
214
+ type: 'string',
215
+ description: plain(promptSettings['description']) ||
216
+ plain(promptSettings['placeholder']) ||
217
+ 'What to generate.',
218
+ }, promptSettings['required'] !== false);
219
+ // Carried as a note, not a schema keyword: `maximum` constrains numbers,
220
+ // and the length keyword this subset does not model is `maxLength`. A
221
+ // note the agent reads beats a keyword that would silently do nothing.
222
+ if (maxChars)
223
+ notes.push(`prompt is limited to ${maxChars} characters`);
224
+ }
225
+ if (isTrue(opt('negative_prompt'))) {
226
+ add('negative_prompt', 'm_negative_prompt', {
227
+ type: 'string',
228
+ description: 'What to avoid in the output.',
229
+ });
230
+ }
231
+ /* ── choice lists ────────────────────────────────────────────────────
232
+ Emitted only when non-empty. An empty array means the model does not
233
+ offer the choice at all, and advertising it would invite a rejected
234
+ value.
235
+
236
+ AND A NON-EMPTY LIST MEANS THE FIELD IS REQUIRED. That is not an
237
+ inference — the validator tests "set, an array, and not empty" and then
238
+ rejects the request when the
239
+ value is absent, and the pricing endpoint runs the same validator. An
240
+ earlier version of this file emitted these as optional, which would have
241
+ made every generate fail on the large majority of models, which carry at
242
+ least one enum; the gate caught it as MISSING_REQUIRED_FIELDS. */
243
+ const aspect = asArray(opt('aspect_ratio')).map(String).filter((s) => s !== '');
244
+ if (aspect.length) {
245
+ add('aspect_ratio', 'm_aspect_ratio', { type: 'string', enum: aspect, description: 'Output shape.' }, true);
246
+ }
247
+ /* IMAGE resolution. Measured 2026-09-12: 24 active listed models carry a
248
+ non-empty list here and every one of them is an image — values read
249
+ "1K"/"2K"/"4K", not "720"/"1080". The video counterpart is m_resolution
250
+ below, a separate wire field; see the CLEAN_NAME note for why they must
251
+ not share a clean name. */
252
+ const resolution = asArray(opt('resolution_quality')).map(String).filter((s) => s !== '');
253
+ if (resolution.length) {
254
+ add('resolution_quality', 'm_resolution_quality', {
255
+ type: 'string',
256
+ enum: resolution,
257
+ description: 'Output resolution.',
258
+ }, true);
259
+ }
260
+ /* Duration is an OBJECT whose KEYS are the allowed durations; each value
261
+ lists the resolutions that duration permits (empty = all). The pairing
262
+ is enforced server-side, so the schema offers the durations and says so
263
+ rather than trying to express a cross-field rule JSON Schema cannot. */
264
+ const durations = Object.keys(asRecord(opt('duration')));
265
+ if (durations.length) {
266
+ // Required for the same reason as the two above: a populated list is a
267
+ // choice the validator insists on.
268
+ add('duration', 'm_duration', {
269
+ type: 'string',
270
+ enum: durations,
271
+ description: 'Length in seconds.',
272
+ }, true);
273
+ /* Collapse when every duration allows the same resolutions, which is the
274
+ common case. Spelling out "3s → 720/1080, 4s → 720/1080, …" eight
275
+ times says the same thing eight times and buries the models where the
276
+ pairing genuinely differs. */
277
+ const pairs = Object.entries(asRecord(opt('duration')))
278
+ .map(([d, v]) => [d, asArray(v).map(String).filter((s) => s !== '')])
279
+ .filter(([, v]) => v.length > 0);
280
+ if (pairs.length) {
281
+ /* m_resolution is REQUIRED here, and it is a separate wire field
282
+ from m_resolution_quality above.
283
+ The validator looks up the chosen
284
+ duration's list and rejects the submit when the list is
285
+ non-empty and m_resolution is absent.
286
+
287
+ This block used to emit the note alone. That produced a schema
288
+ that contradicted itself — the note told the agent resolution
289
+ had to be 720 or 1080, while additionalProperties:false forbade
290
+ sending it — and every generate on such a model 422'd. It
291
+ affected roughly half the catalogue. Nothing caught it because
292
+ schema.test.mjs only checks the schema against itself, never
293
+ against what the server requires.
294
+
295
+ The enum is the union across durations; which subset applies to
296
+ the chosen duration is a cross-field rule JSON Schema cannot
297
+ express, so the note carries it.
298
+
299
+ This block used to say reusing the clean name `resolution` was
300
+ "safe: measured, zero models carry both a populated
301
+ m_resolution_quality and a populated per-duration list". The
302
+ measurement was true and the conclusion was still wrong — one row
303
+ is all it takes to end it, and `test-i2i` was that row. The name
304
+ is no longer shared (see CLEAN_NAME), and add() now refuses a
305
+ duplicate outright, so this no longer rests on a count. */
306
+ const union = [...new Set(pairs.flatMap(([, v]) => v))];
307
+ const distinct = new Set(pairs.map(([, v]) => v.join('/')));
308
+ const uniform = distinct.size === 1 && pairs.length === durations.length;
309
+ /* The enum is the UNION across durations, which on a non-uniform
310
+ model offers a value the chosen duration forbids. JSON Schema
311
+ draft-07 cannot express the dependency in a way clients reliably
312
+ honour, so the pairing goes in the property's OWN description —
313
+ the text the agent reads while filling this exact field —
314
+ instead of only in `notes`, which it may never reach.
315
+ Measured 2026-09-07: 55 models pair differently per duration. */
316
+ const pairing = pairs.map(([d, v]) => `${d}s → ${v.join('/')}`).join(', ');
317
+ add('resolution', 'm_resolution', {
318
+ type: 'string',
319
+ enum: union,
320
+ description: uniform
321
+ ? 'Output resolution.'
322
+ : `Output resolution. NOT every value is valid at every duration — ${pairing}. ` +
323
+ 'Pick the one that matches the duration you chose, or the submit is refused.',
324
+ }, true);
325
+ notes.push(uniform
326
+ ? `resolution must be one of ${[...distinct][0]} at any duration`
327
+ : `resolution depends on the duration you pick: ${pairing}`);
328
+ }
329
+ }
330
+ /* The remaining three fixed lists, each enforced by the same
331
+ `isset && is_array && count > 0` → required rule as aspect_ratio:
332
+ m_output_format 68 models
333
+ m_movement_amplitude 13 models
334
+ m_style 9 models
335
+ All three were missing until 2026-09-07. */
336
+ const formats = [
337
+ ...new Set(
338
+ // Keyed by media type ("image": ["jpeg","png"]); the validator
339
+ // flattens across every type and de-duplicates, so the
340
+ // agent is offered the same flat set the server will compare against.
341
+ Object.values(asRecord(opt('output_format')))
342
+ .flatMap((v) => asArray(v).map(String))
343
+ .filter((s) => s.trim() !== '')),
344
+ ];
345
+ if (formats.length) {
346
+ add('output_format', 'm_output_format', {
347
+ type: 'string',
348
+ enum: formats,
349
+ description: 'File format of the output.',
350
+ }, true);
351
+ }
352
+ const amplitude = asArray(opt('movement_amplitude')).map(String).filter((s) => s !== '');
353
+ if (amplitude.length) {
354
+ add('movement_amplitude', 'm_movement_amplitude', {
355
+ type: 'string',
356
+ enum: amplitude,
357
+ description: 'How much motion to apply.',
358
+ }, true);
359
+ }
360
+ const style = asArray(opt('style')).map(String).filter((s) => s !== '');
361
+ if (style.length) {
362
+ add('style', 'm_style', {
363
+ type: 'string',
364
+ enum: style,
365
+ description: 'Visual style of the output.',
366
+ }, true);
367
+ }
368
+ const outputNumber = num(opt('output_number')) ?? 1;
369
+ if (outputNumber > 1) {
370
+ add('output_number', 'm_output_number', {
371
+ type: 'integer',
372
+ minimum: 1,
373
+ maximum: outputNumber,
374
+ default: 1,
375
+ description: `How many outputs to generate (up to ${outputNumber}). Each one is charged.`,
376
+ });
377
+ }
378
+ /* ── the boolean switches ────────────────────────────────────────────
379
+ The option is a CAPABILITY flag ("this model can do it"), and the input
380
+ it unlocks is what goes in the schema. m_seed is the odd one: the flag
381
+ is boolean but the value the server wants is a number. */
382
+ for (const [flag, name, wireName, description] of [
383
+ ['generate_audio', 'generate_audio', 'm_generate_audio', 'Generate an audio track with the video.'],
384
+ ['generate_sound_effect', 'generate_sound_effect', 'm_generate_sound_effect', 'Add sound effects.'],
385
+ ['camera_fixed', 'camera_fixed', 'm_camera_fixed', 'Keep the camera still.'],
386
+ ['enhance_prompt', 'enhance_prompt', 'm_enhance_prompt', 'Let the provider rewrite the prompt for better results.'],
387
+ ]) {
388
+ if (isTrue(opt(flag))) {
389
+ add(name, wireName, { type: 'boolean', description });
390
+ }
391
+ }
392
+ if (isTrue(opt('seed'))) {
393
+ add('seed', 'm_seed', {
394
+ type: 'integer',
395
+ description: 'Seed for a reproducible result. Omit for a random one.',
396
+ });
397
+ }
398
+ /* ── dynamic fields ──────────────────────────────────────────────────
399
+ Per-model, admin-defined, open-ended by design. Their `name` is already
400
+ the wire name (it must match ^m_[A-Za-z0-9_]{1,64}$ server-side), so the
401
+ clean name is that with the m_ removed. */
402
+ for (const raw of asArray(opt('dynamic_fields'))) {
403
+ const f = asRecord(raw);
404
+ const wireName = String(f['name'] ?? '');
405
+ if (!/^m_[A-Za-z0-9_]{1,64}$/.test(wireName))
406
+ continue; // not a field the server would accept
407
+ const cleanName = cleanNameFor(wireName);
408
+ if (properties[cleanName])
409
+ continue; // a base field already claimed it
410
+ const type = String(f['type'] ?? '');
411
+ const label = plain(f['label']) || cleanName;
412
+ const description = [label, plain(f['description'])].filter(Boolean).join(' — ');
413
+ const isRequired = f['required'] === true;
414
+ /* is_label options are GROUP HEADERS in the UI, not values. 19 of them
415
+ exist in the catalogue; letting one into an enum would offer the
416
+ agent a choice the server refuses. */
417
+ const choices = asArray(f['options'])
418
+ .map(asRecord)
419
+ .filter((o) => !isTrue(o['is_label']))
420
+ .map((o) => String(o['value'] ?? ''))
421
+ .filter((s) => s !== '');
422
+ if (type === 'file_upload_image' || type === 'file_upload_video' || type === 'file_upload_audio') {
423
+ files.push({
424
+ name: cleanName,
425
+ wire: wireName,
426
+ required: isRequired,
427
+ ...resolveUploadRules(f['value'], f['max_size'], type.replace('file_upload_', '')),
428
+ maxDurationSec: null,
429
+ description,
430
+ });
431
+ continue;
432
+ }
433
+ if (type === 'toggle' || type === 'checkbox') {
434
+ const prop = { type: 'boolean', description };
435
+ if (f['default'] !== undefined && f['default'] !== '')
436
+ prop.default = isTrue(f['default']);
437
+ add(cleanName, wireName, prop, isRequired);
438
+ continue;
439
+ }
440
+ if (type === 'slider') {
441
+ const prop = { type: 'number', description };
442
+ const min = num(f['min']);
443
+ const max = num(f['max']);
444
+ const step = num(f['step']);
445
+ if (min !== null)
446
+ prop.minimum = min;
447
+ if (max !== null)
448
+ prop.maximum = max;
449
+ if (step !== null && step > 0)
450
+ prop.multipleOf = step;
451
+ const dflt = num(f['default']);
452
+ if (dflt !== null)
453
+ prop.default = dflt;
454
+ add(cleanName, wireName, prop, isRequired);
455
+ continue;
456
+ }
457
+ // select · radio_group · text · textarea · and anything added tomorrow.
458
+ const prop = { type: 'string', description };
459
+ if (choices.length)
460
+ prop.enum = choices;
461
+ const dflt = f['default'];
462
+ if (dflt !== undefined && dflt !== '' && (!choices.length || choices.includes(String(dflt)))) {
463
+ prop.default = String(dflt);
464
+ }
465
+ add(cleanName, wireName, prop, isRequired);
466
+ }
467
+ /* ── single-file inputs ──────────────────────────────────────────────── */
468
+ for (const [flag, name, wireName, settingsKey] of [
469
+ ['upload_image', 'image', 'm_image', 'upload_image_settings'],
470
+ ['upload_video', 'video', 'm_video', 'upload_video_settings'],
471
+ ['upload_audio', 'audio', 'm_audio', 'upload_audio_settings'],
472
+ ]) {
473
+ if (!isTrue(opt(flag)))
474
+ continue;
475
+ const s = asRecord(opt(settingsKey));
476
+ files.push({
477
+ name,
478
+ wire: wireName,
479
+ required: true, // the model asked for this input; it is not optional
480
+ ...resolveUploadRules(s['allowed_extensions'], s['max_size'], name),
481
+ maxDurationSec: num(opt(`max_duration_${name}`)),
482
+ description: plain(s['label']) || `The ${name} to work from.`,
483
+ });
484
+ }
485
+ if (isTrue(opt('upload_frames'))) {
486
+ const fs = asRecord(opt('upload_frames_settings'));
487
+ for (const slot of ['first_frame', 'last_frame']) {
488
+ const s = asRecord(fs[slot]);
489
+ files.push({
490
+ name: slot,
491
+ wire: `m_${slot}`,
492
+ required: true,
493
+ // A frame slot is always an image.
494
+ ...resolveUploadRules(s['allowed_extensions'], s['max_size'], 'image'),
495
+ maxDurationSec: null,
496
+ description: plain(s['label']) || `The ${slot.replace('_', ' ')}.`,
497
+ });
498
+ }
499
+ }
500
+ /* ── multi-upload: two families, DIFFERENT field names ───────────────
501
+ A `slots` model REJECTS the legacy name outright, so getting this wrong is not a
502
+ degraded experience, it is a hard refusal. */
503
+ const multi = asRecord(opt('multi_upload'));
504
+ if (isTrue(multi['active'])) {
505
+ const slots = asRecord(multi['slots']);
506
+ if (Object.keys(slots).length > 0) {
507
+ for (const [slotType, rawSlot] of Object.entries(slots)) {
508
+ const s = asRecord(rawSlot);
509
+ const count = num(s['number']) ?? 0;
510
+ const minMedia = num(s['min_media']) ?? 0;
511
+ if (count <= 0)
512
+ continue;
513
+ files.push({
514
+ name: `${slotType}_files`,
515
+ wire: `m_multi_${slotType}_file_N`,
516
+ required: minMedia > 0,
517
+ ...resolveUploadRules(s['allowed_extensions'], s['max_size'], slotType),
518
+ maxDurationSec: num(s['max_duration']),
519
+ description: `${plain(s['label']) || `${slotType} files`} — up to ${count}` +
520
+ (minMedia > 0 ? `, at least ${minMedia} required` : '') +
521
+ `. Numbered from 0: m_multi_${slotType}_file_0, _1, …`,
522
+ });
523
+ }
524
+ notes.push('this model uses PER-TYPE upload fields (m_multi_<type>_file_N); the plain m_multi_file_N is refused');
525
+ }
526
+ else {
527
+ const slotType = String(multi['type'] ?? 'image');
528
+ const count = num(multi['number']) ?? 0;
529
+ const minMedia = num(multi['min_media']) ?? 0;
530
+ if (count > 0) {
531
+ files.push({
532
+ name: `${slotType}_files`,
533
+ wire: 'm_multi_file_N',
534
+ required: minMedia > 0,
535
+ ...resolveUploadRules(multi['allowed_extensions'], multi['max_size'], slotType),
536
+ maxDurationSec: num(multi['max_duration']),
537
+ description: `Up to ${count} ${slotType} files` +
538
+ (minMedia > 0 ? `, at least ${minMedia} required` : '') +
539
+ '. Numbered from 0: m_multi_file_0, _1, …',
540
+ });
541
+ }
542
+ }
543
+ if (isTrue(multi['paid_uploads'])) {
544
+ const free = num(multi['free_count']) ?? 0;
545
+ notes.push(`files beyond the first ${free} are charged extra — call estimate_cost with the real count`);
546
+ }
547
+ }
548
+ /* See ModelSchema.billedByUploadDuration for where this value comes from
549
+ * and why the served field is the right source rather than the column. */
550
+ const billedByUploadDuration = String(opt('pricing_mode') ?? '') === 'per_second';
551
+ if (billedByUploadDuration) {
552
+ notes.push('billed by the DURATION of the media you upload — estimate_cost prices the ' +
553
+ 'settings only, so the real charge is higher than its figure');
554
+ }
555
+ /* Mark which slots can be chained, derived once from the wire name rather
556
+ * than repeated at each of the five construction sites above.
557
+ *
558
+ * The list is the server's, copied from the allow-list the submit path
559
+ * applies to a reuse map: the fixed
560
+ * image/video/audio slots under either spelling, the two frame slots,
561
+ * m_multi_file_<n>, and every dynamic file field by its own name. A field
562
+ * outside it is dropped from regen_source_map without a word, so the job
563
+ * runs with that input missing.
564
+ *
565
+ * m_multi_<type>_file_N is the notable exclusion — a server-side gap, not a
566
+ * client one; the studio cannot chain those slots either. Saying so here
567
+ * turns a wasted submit into a readable capability flag. */
568
+ const REUSABLE_FIXED = new Set([
569
+ 'm_image', 'm_input_image',
570
+ 'm_video', 'm_input_video',
571
+ 'm_audio', 'm_input_audio',
572
+ 'm_first_frame', 'm_last_frame',
573
+ ]);
574
+ const filesOut = files.map((f) => ({
575
+ ...f,
576
+ /* Say the length cap out loud.
577
+ *
578
+ * Nine models cap what you may upload, and the admin's label is all
579
+ * the agent used to get — "Video:" on kling-lipsync, which caps video
580
+ * at 30 seconds. Worse on exactly those models: they are the
581
+ * upload-billed ones, so the agent is told "you are billed by the
582
+ * duration of the media you upload" and never told where the ceiling
583
+ * is. Now it can pick a clip that fits, and tell the user the limit
584
+ * before asking them for a file.
585
+ *
586
+ * Appended here rather than at each of the four places a slot is
587
+ * built, so a fifth cannot be added without it. */
588
+ description: f.maxDurationSec !== null && f.maxDurationSec > 0
589
+ ? `${f.description} Up to ${f.maxDurationSec} seconds.`
590
+ : f.description,
591
+ supportsReuse: REUSABLE_FIXED.has(f.wire) ||
592
+ f.wire === 'm_multi_file_N' ||
593
+ // A dynamic file field is whitelisted under its own name, and its
594
+ // wire IS that name — any m_* field that is neither a fixed slot
595
+ // nor one of the numbered multi families.
596
+ (/^m_[a-z0-9_]+$/i.test(f.wire) && !/^m_multi_/.test(f.wire)),
597
+ }));
598
+ return {
599
+ model_key: String(mf('model_key') ?? ''),
600
+ effect_key: String(mf('effect_key') ?? ''),
601
+ slug: String(mf('slug') ?? root['slug'] ?? ''),
602
+ name: String(mf('name') ?? ''),
603
+ icon: String(mf('icon') ?? ''),
604
+ /* TWO mode values, because the agent and the wire want different ones
605
+ * and conflating them breaks one of the two.
606
+ *
607
+ * `mode` — the SHORT code (t2v), which is what the agent sees and what
608
+ * list_models accepts. Measured 2026-09-07: /info/models-flat/t2v
609
+ * answers, /info/models-flat/text-to-video is INVALID_MODE. This field
610
+ * used to carry the long key, so an agent that took `mode` out of
611
+ * get_model and passed it to list_models got an error, while the same
612
+ * field name coming from list_modes worked.
613
+ *
614
+ * `mode_wire` — the LONG key (text-to-video), which is what m_mode
615
+ * means on submit and model-credits, and what the media row stores.
616
+ * Never show this to the agent; never post the other one. */
617
+ mode: String(root['mode_code'] ?? mf('mode_code') ?? ''),
618
+ mode_wire: String(mf('mode') ?? root['mode'] ?? ''),
619
+ media_type: String(mf('media_type') ?? ''),
620
+ credits_from: num(mf('coins')) ?? num(mf('credits')),
621
+ estimated_seconds: num(mf('sec')) ?? num(mf('estimated_seconds')),
622
+ inputSchema: { type: 'object', properties, required, additionalProperties: false },
623
+ wire,
624
+ files: filesOut,
625
+ billedByUploadDuration,
626
+ notes,
627
+ };
628
+ }
629
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Account tools — what the balance is, and where it went.
3
+ *
4
+ * Both read-only. Neither can move money: the endpoints that could (checkout,
5
+ * auto-topup, purchases) are deliberately not exposed by this package at all,
6
+ * so an agent cannot buy coins on the user's behalf however it is prompted.
7
+ */
8
+ import { z } from 'zod';
9
+ import type { Config } from '../config.js';
10
+ export declare function getBalance(cfg: Config): Promise<unknown>;
11
+ export declare const getUsageInput: z.ZodObject<{
12
+ days: z.ZodOptional<z.ZodNumber>;
13
+ limit: z.ZodOptional<z.ZodNumber>;
14
+ offset: z.ZodOptional<z.ZodNumber>;
15
+ }, "strip", z.ZodTypeAny, {
16
+ days?: number | undefined;
17
+ limit?: number | undefined;
18
+ offset?: number | undefined;
19
+ }, {
20
+ days?: number | undefined;
21
+ limit?: number | undefined;
22
+ offset?: number | undefined;
23
+ }>;
24
+ export declare function getUsage(cfg: Config, args: {
25
+ days?: number | undefined;
26
+ limit?: number | undefined;
27
+ offset?: number | undefined;
28
+ }): Promise<unknown>;