/** * route-prerequisites.ts — the ONE canonical declaration of what each provider * route needs before it can run, and which specialist lanes it belongs to. * * This is a LEAF module on purpose: it imports only `./types.js`, so anything * may depend on it without dragging in `provider-status.ts` → * `execution-runtime.ts` (and from there most of the domain). `provider-status.ts` * used to hold five parallel `Record` tables that a new route * had to be added to five times; they live here now and it reads them. * * The lane flags are DECLARED, not inferred. `batchQueue` and `voiceRefInjection` * are deliberate specialist subsets — a route being live does not make it a batch * route, and does not mean a voice clone should be injected into its payload — so * each one names its members here and the consumers derive their sets from these * flags. `capability-contract.test.ts` fails when a consumer drifts. */ import type { ProviderRouteId } from './types.js'; /** * Executables a route's native transport needs on PATH. Kept as a local literal * union rather than an import so this module stays a leaf; it is structurally * assignable to `provider-status.ts`'s `ExecutableName`. */ export type RouteDependencyName = 'python3' | 'bun' | 'ffmpeg'; /** Everything a route needs configured, plus the specialist lanes it belongs to. */ export interface RoutePrerequisites { /** Env vars the route's built-in transport needs, in report order. */ requiredEnvVars: string[]; /** * A route with more than one credential path declares the selector that * chooses between them and the env vars each choice then needs. The choice * is EXPLICIT (ADR 0001): two credentials are two bills, so the route never * infers a path from whichever key happens to be set. `requiredEnvVarsFor` * folds the chosen path's vars into `requiredEnvVars` for the consumers that * report readiness; an unset or unknown selector reports only the selector. */ credentialAlternatives?: { selectorEnvVar: string; choices: Record; }; /** Executables the route's native transport shells out to. */ requiredDependencies: RouteDependencyName[]; /** `scaffold` routes report as `degraded` and stay out of default routing. */ maturity: 'production' | 'scaffold'; /** Env var naming a full custom adapter binary for this route. */ adapterEnvVar: string; /** Env vars naming per-route command shims run through the built-in adapter. */ commandEnvVars: string[]; /** Declared membership of the specialist lanes that use a route subset. */ lanes: { /** * The overnight batch queue (`batch-queue.ts`) targets only the routes with * in-process native submit/poll transports that loop `payload.tasks`. */ batchQueue: boolean; /** * Routes that consume a blank-video-with-audio voice clone as a video * reference to lock a cloned voice (the black-frame video carries the voice; * a raw MP3 does NOT lock it as reliably — that is the whole point of the * trick): * • seedance-direct — the .mp4 rides into `reference_videos` * • runway-useapi — Seedance-2 via the UseAPI gateway → `videoAssetId` + audio:true * • dreamina-useapi — Seedance-2 via Dreamina Omni Reference → `omni_N_videoRef` * (the omni video ref is EXPECTED to drive the voice on its own; unverified * live — if a render is mute, VCLAW_DREAMINA_AUDIO=1 forces `audio:true`) * Other routes leave voice clones un-injected (byte-identical). */ voiceRefInjection: boolean; }; } /** * Prerequisites for every live provider route. The key set is asserted equal to * `PROVIDER_ROUTE_IDS` by `capability-contract.test.ts`, so a new route cannot * be added to the id tuple without landing here too. */ export const ROUTE_PREREQUISITES: Record = { 'veo-useapi': { requiredEnvVars: ['USEAPI_API_TOKEN', 'USEAPI_ACCOUNT_EMAIL'], requiredDependencies: ['python3', 'bun', 'ffmpeg'], maturity: 'production', adapterEnvVar: 'VCLAW_VEO_USEAPI_ADAPTER', commandEnvVars: ['VCLAW_VEO_USEAPI_SUBMIT_CMD'], lanes: { batchQueue: false, voiceRefInjection: false }, }, 'runway-useapi': { requiredEnvVars: ['USEAPI_API_TOKEN', 'USEAPI_ACCOUNT_EMAIL'], // runway-useapi native transport is pure Node (fetch + fs), no python/bun // shell-outs. ffmpeg is still useful for downstream stitching/post but is // not strictly required for the route itself to deliver mp4s. requiredDependencies: ['ffmpeg'], maturity: 'production', adapterEnvVar: 'VCLAW_RUNWAY_USEAPI_ADAPTER', commandEnvVars: ['VCLAW_RUNWAY_USEAPI_SUBMIT_CMD'], lanes: { batchQueue: true, voiceRefInjection: true }, }, 'dreamina-useapi': { requiredEnvVars: ['USEAPI_API_TOKEN', 'VCLAW_DREAMINA_ACCOUNT'], // dreamina-useapi native transport is pure Node (fetch + fs), no python/bun // shell-outs. ffmpeg is still useful for downstream stitching/post but is // not strictly required for the route itself to deliver mp4s. requiredDependencies: ['ffmpeg'], maturity: 'production', adapterEnvVar: 'VCLAW_DREAMINA_USEAPI_ADAPTER', commandEnvVars: ['VCLAW_DREAMINA_USEAPI_SUBMIT_CMD'], lanes: { batchQueue: true, voiceRefInjection: true }, }, 'seedance-direct': { requiredEnvVars: ['SUTUI_API_KEY'], requiredDependencies: ['python3', 'ffmpeg'], maturity: 'production', adapterEnvVar: 'VCLAW_SEEDANCE_DIRECT_ADAPTER', commandEnvVars: ['VCLAW_SEEDANCE_DIRECT_SUBMIT_CMD'], lanes: { batchQueue: true, voiceRefInjection: true }, }, 'magnific-rest': { requiredEnvVars: ['MAGNIFIC_API_KEY'], // magnific-rest native transport is pure Node (fetch + fs). ffmpeg only for // downstream stitching/post, not strictly required for the route itself. requiredDependencies: ['ffmpeg'], maturity: 'production', adapterEnvVar: 'VCLAW_MAGNIFIC_REST_ADAPTER', commandEnvVars: ['VCLAW_MAGNIFIC_REST_SUBMIT_CMD'], lanes: { batchQueue: false, voiceRefInjection: false }, }, 'seedance-modelark': { // BytePlus ModelArk (the official Seedance 2.5 / 2.0 API). Pure Node fetch; // ffmpeg only for the chain-seed last-frame extraction it shares with // seedance-direct. Its own key on purpose: SUTUI_API_KEY belongs to the // xskill aggregator, a different biller (ADR 0007). requiredEnvVars: ['ARK_API_KEY'], requiredDependencies: ['ffmpeg'], maturity: 'production', adapterEnvVar: 'VCLAW_SEEDANCE_MODELARK_ADAPTER', commandEnvVars: ['VCLAW_SEEDANCE_MODELARK_SUBMIT_CMD'], // Voice clips ride as omni `reference_video`. In the batch lane since // 2026-09-21 (#604 item 5): batch-submit enqueues exact-quote tasks, and the // route's native poll serves the historical monitor. lanes: { batchQueue: true, voiceRefInjection: true }, }, 'reapi-seedance': { // Seedance 2.5 "Less Restriction" (content_filter:false) served by reAPI. // Two credential paths, chosen explicitly: `treg` bills the treg team // balance and hosts reference files on treg's own media host; `direct` // bills a reAPI account and hosts references on Go Bananas R2. The // native transport is pure Node (fetch + fs); ffmpeg is only downstream. requiredEnvVars: ['VCLAW_REAPI_SEEDANCE_VIA'], credentialAlternatives: { selectorEnvVar: 'VCLAW_REAPI_SEEDANCE_VIA', choices: { treg: ['TREG_TOKEN'], direct: ['REAPI_API_KEY', 'GO_BANANAS_API_KEY'], }, }, requiredDependencies: ['ffmpeg'], maturity: 'production', adapterEnvVar: 'VCLAW_REAPI_SEEDANCE_ADAPTER', commandEnvVars: ['VCLAW_REAPI_SEEDANCE_SUBMIT_CMD'], // Voice clones ride `audio_urls` here (the transport extracts the audio // track of a black-frame voice video), so the lane flag is honest: a bound // voice clip IS consumed, as an audio reference rather than a video one. lanes: { batchQueue: true, voiceRefInjection: true }, }, }; /** Every route id whose declared `lanes[lane]` flag is true, in declaration order. */ export function routesInLane(lane: keyof RoutePrerequisites['lanes']): ProviderRouteId[] { return (Object.keys(ROUTE_PREREQUISITES) as ProviderRouteId[]).filter( (routeId) => ROUTE_PREREQUISITES[routeId].lanes[lane], ); } /** * The env vars a route needs in THIS environment: its declared * `requiredEnvVars`, plus — for a route with `credentialAlternatives` — the * vars of the path its selector names. An unset or unknown selector adds * nothing (the selector itself is already in `requiredEnvVars`, so the report * names it as the missing piece). Pure: reads only the `env` it is given. */ export function requiredEnvVarsFor(routeId: ProviderRouteId, env: NodeJS.ProcessEnv): string[] { const prerequisites = ROUTE_PREREQUISITES[routeId]; const alternatives = prerequisites.credentialAlternatives; if (!alternatives) return [...prerequisites.requiredEnvVars]; const choice = (env[alternatives.selectorEnvVar] ?? '').trim().toLowerCase(); const extra = alternatives.choices[choice] ?? []; return [...prerequisites.requiredEnvVars, ...extra.filter((name) => !prerequisites.requiredEnvVars.includes(name))]; } /** Every env var a route could ever need, across all of its credential paths. */ export function allDeclaredEnvVarsFor(routeId: ProviderRouteId): string[] { const prerequisites = ROUTE_PREREQUISITES[routeId]; const names = [...prerequisites.requiredEnvVars]; for (const vars of Object.values(prerequisites.credentialAlternatives?.choices ?? {})) { for (const name of vars) if (!names.includes(name)) names.push(name); } return names; }