/** * Google Flow account price table — the ONE authoritative source for what a * Flow render costs on THIS account. * * `GET /v1/google-flow/accounts/{email}` (useapi.net) returns Google's own * per-model `creditCost` table plus the live balance. Live-verified 2026-09-01: * the table lists e.g. `veo_3_1_t2v` at 100, `veo_3_1_t2v_fast_ultra` at 10, * `veo_3_1_t2v_lite` at 5, `veo_3_1_t2v_lite_low_priority` at 0, and the omni * (`abra_*`) rows priced by duration × resolution. Nothing here hardcodes a * price: a model the account does not list is refused, and a query that * matches rows with different prices is refused as ambiguous. This module is * pure apart from `fetchFlowAccount`; the quote adapter and the veo-useapi * submit both price through {@link matchFlowPrice}, so the number the queue * authorizes and the number the transport records at completion come from the * same table. */ import { readFile } from 'node:fs/promises'; import type { VideoExecutionPayload } from './types.js'; export const FLOW_ACCOUNT_BASE = 'https://api.useapi.net/v1/google-flow'; export const FLOW_ACCOUNT_FIXTURE_ENV = 'VCLAW_FLOW_ACCOUNT_FIXTURE'; export const FLOW_CREDIT_CURRENCY = 'credits'; export interface FlowAccountVideoModel { key: string; displayName?: string; resolution?: string; videoLengthSeconds?: number; creditCost?: number; } export interface FlowAccount { credits: { credits: number; userPaygateTier?: string; sku?: string; serviceTier?: string; topUpCredits?: number; subscriptionCredits?: number; }; models: { videoModels: FlowAccountVideoModel[] }; } export type FlowAccountSource = 'live' | 'fixture'; export class FlowAccountError extends Error {} function finiteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } /** Pure: validate the parts of the account payload this module reads. */ export function parseFlowAccount(raw: unknown): FlowAccount { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { throw new FlowAccountError('Flow account payload is not an object'); } const record = raw as Record; const credits = record.credits as Record | undefined; if (!credits || typeof credits !== 'object' || !finiteNumber(credits.credits)) { throw new FlowAccountError('Flow account payload has no numeric credits.credits'); } const models = (record.models as Record | undefined)?.videoModels; if (!Array.isArray(models) || models.length === 0) { throw new FlowAccountError('Flow account payload lists no models.videoModels'); } const videoModels: FlowAccountVideoModel[] = models.map((entry, index) => { const model = entry as Record; if (!model || typeof model.key !== 'string' || !model.key) { throw new FlowAccountError(`Flow account models.videoModels[${index}] has no key`); } return { key: model.key, ...(typeof model.displayName === 'string' ? { displayName: model.displayName } : {}), ...(typeof model.resolution === 'string' ? { resolution: model.resolution } : {}), ...(finiteNumber(model.videoLengthSeconds) ? { videoLengthSeconds: model.videoLengthSeconds } : {}), ...(finiteNumber(model.creditCost) ? { creditCost: model.creditCost } : {}), }; }); return { credits: { credits: credits.credits, ...(typeof credits.userPaygateTier === 'string' ? { userPaygateTier: credits.userPaygateTier } : {}), ...(typeof credits.sku === 'string' ? { sku: credits.sku } : {}), ...(typeof credits.serviceTier === 'string' ? { serviceTier: credits.serviceTier } : {}), ...(finiteNumber(credits.topUpCredits) ? { topUpCredits: credits.topUpCredits } : {}), ...(finiteNumber(credits.subscriptionCredits) ? { subscriptionCredits: credits.subscriptionCredits } : {}), }, models: { videoModels }, }; } export async function fetchFlowAccount(options: { token: string; email: string; fetchImpl?: typeof fetch; base?: string; }): Promise { const doFetch = options.fetchImpl ?? fetch; const url = `${options.base ?? FLOW_ACCOUNT_BASE}/accounts/${encodeURIComponent(options.email)}`; const response = await doFetch(url, { headers: { Authorization: `Bearer ${options.token}` } }); if (!response.ok) { throw new FlowAccountError(`Flow account lookup failed: HTTP ${response.status} from ${url}`); } return parseFlowAccount(await response.json()); } export async function readFlowAccountFixture(path: string): Promise { return parseFlowAccount(JSON.parse(await readFile(path, 'utf-8'))); } /** * Resolve the account table from the environment: an explicit fixture file * (`VCLAW_FLOW_ACCOUNT_FIXTURE`, a test seam — the caller decides whether a * fixture may stand in for the live table) or the live endpoint via * `USEAPI_API_TOKEN` + `USEAPI_ACCOUNT_EMAIL`. Returns `null` when neither is * configured so the caller can decide whether that is fatal. */ export async function loadFlowAccount( env: NodeJS.ProcessEnv, options: { fetchImpl?: typeof fetch } = {}, ): Promise<{ account: FlowAccount; source: FlowAccountSource } | null> { const fixture = env[FLOW_ACCOUNT_FIXTURE_ENV]?.trim(); if (fixture) { return { account: await readFlowAccountFixture(fixture), source: 'fixture' }; } const token = env.USEAPI_API_TOKEN?.trim(); const email = env.USEAPI_ACCOUNT_EMAIL?.trim(); if (!token || !email) return null; return { account: await fetchFlowAccount({ token, email, fetchImpl: options.fetchImpl }), source: 'live' }; } // --------------------------------------------------------------------------- // Pricing // --------------------------------------------------------------------------- /** The durations Flow renders; the transport drops any other value and Flow renders 8 s. */ export const FLOW_DURATIONS: ReadonlySet = new Set([4, 6, 8, 10]); export type FlowVeoModel = NonNullable; export type FlowOperation = 't2v' | 'i2v' | 'i2v-fl' | 'r2v' | 'edit'; export interface FlowPriceQuery { veoModel: FlowVeoModel; operation: FlowOperation; aspect: 'landscape' | 'portrait'; durationSeconds: number; /** omni-flash only; Veo rows are 720p and carry no resolution marker. */ resolution?: '360p' | '720p'; } export interface FlowPriceMatch { creditCost: number; /** The first matched row — the record of which table row priced this. */ modelKey: string; matchedKeys: string[]; } const VEO_NEVER = /extend|extension|interpolation|upsampler/; function veoTierMatches(key: string, veoModel: FlowVeoModel): boolean { const lowPriority = key.includes('low_priority'); const lite = key.includes('_lite'); const fast = key.includes('_fast'); switch (veoModel) { case 'free': return lite && lowPriority; case 'lite': return lite && !lowPriority; case 'fast': return fast; case 'quality': return !lite && !fast; default: return false; } } function veoCandidates(keys: string[], query: FlowPriceQuery): string[] { const op = query.operation === 'i2v-fl' ? 'i2v' : query.operation; let candidates = keys.filter((key) => key.startsWith(`veo_3_1_${op}`) && !VEO_NEVER.test(key) && veoTierMatches(key, query.veoModel), ); // First↔last-frame rows carry `_fl`; every other query excludes them. candidates = candidates.filter((key) => (query.operation === 'i2v-fl') === /_fl(_|$)/.test(key)); // Aspect: portrait rows say so; rows without a marker are aspect-neutral // (the lite tiers). Prefer an explicit match, fall back to neutral rows. const portraitRows = candidates.filter((key) => key.includes('portrait')); const neutralRows = candidates.filter((key) => !key.includes('portrait') && !key.includes('landscape')); const landscapeRows = candidates.filter((key) => key.includes('landscape')); if (query.aspect === 'portrait') { candidates = portraitRows.length > 0 ? portraitRows : neutralRows; } else { candidates = landscapeRows.length > 0 ? [...landscapeRows, ...neutralRows] : neutralRows; } // Duration: 4 s / 6 s rows carry `_4s` / `_6s`; 8 s rows carry nothing. const wanted = query.durationSeconds === 8 ? null : `_${query.durationSeconds}s`; candidates = candidates.filter((key) => { const marked = /_(4|6|10)s(_|$)/.test(key); return wanted === null ? !marked : key.includes(wanted); }); return candidates; } function omniCandidates(keys: string[], query: FlowPriceQuery): string[] { const suffix = query.resolution === '360p' ? '_360p' : ''; if (query.operation === 'edit') return keys.filter((key) => key === `abra_edit${suffix}`); const duration = `${query.durationSeconds}s`; if (query.operation === 'i2v-fl') { return keys.filter((key) => key === `omni_flash_i2v_${duration}_first_last${suffix}`); } return keys.filter((key) => key === `abra_${query.operation}_${duration}${suffix}`); } /** * Pure: price one render against the account's table. Refuses (throws) when * the account lists no row for the combination, or when the matching rows * disagree on price — a quote is never a guess. */ export function matchFlowPrice(account: FlowAccount, query: FlowPriceQuery): FlowPriceMatch { const rows = account.models.videoModels; const keys = rows.map((row) => row.key); const matched = query.veoModel === 'omni-flash' ? omniCandidates(keys, query) : veoCandidates(keys, query); const describe = `${query.veoModel} ${query.operation} ${query.durationSeconds}s ${query.aspect}${query.resolution ? ` ${query.resolution}` : ''}`; if (matched.length === 0) { throw new FlowAccountError(`Flow account lists no model row for ${describe}; refusing to price it`); } const priced = matched.map((key) => ({ key, cost: rows.find((row) => row.key === key)?.creditCost })); const unpriced = priced.filter((row) => row.cost === undefined).map((row) => row.key); if (unpriced.length > 0) { throw new FlowAccountError(`Flow account rows ${unpriced.join(', ')} carry no creditCost; refusing to price ${describe}`); } const costs = [...new Set(priced.map((row) => row.cost as number))]; if (costs.length !== 1) { throw new FlowAccountError( `Flow account rows for ${describe} disagree on price (${priced.map((row) => `${row.key}=${row.cost}`).join(', ')}); refusing to price it`, ); } return { creditCost: costs[0], modelKey: matched[0], matchedKeys: matched }; } export function flowAspectForRatio(aspectRatio: string): 'landscape' | 'portrait' { return aspectRatio === '9:16' ? 'portrait' : 'landscape'; } /** * Pure: derive the price query for one execution task, mirroring how the * veo-useapi transport builds its request (`native-veo.ts` buildPrompt): a V2V * reference is an edit; saved Flow characters or omni ingredients are R2V; an * image reference on Veo is the I2V start frame; otherwise T2V. */ export function flowPriceQueryForTask( payload: VideoExecutionPayload, task: VideoExecutionPayload['tasks'][number], ): FlowPriceQuery { const veoModel: FlowVeoModel = payload.executionProfile.veoModel ?? payload.executionProfile.quality; const images = task.inputKind === 'image' ? task.referencePaths.filter(Boolean) : []; const isOmni = veoModel === 'omni-flash'; let operation: FlowOperation = 't2v'; if (task.referenceVideoMediaId) operation = 'edit'; else if (task.characterRefs && task.characterRefs.length > 0) operation = 'r2v'; else if (images.length > 0) operation = isOmni ? (task.firstFrame ? (task.endKeyframePath ? 'i2v-fl' : 'i2v') : 'r2v') : 'i2v'; return { veoModel, operation, aspect: flowAspectForRatio(payload.executionProfile.aspectRatio), durationSeconds: task.durationSeconds !== undefined && FLOW_DURATIONS.has(task.durationSeconds) ? task.durationSeconds : 8, ...(isOmni ? { resolution: payload.executionProfile.flowResolution ?? '720p' } : {}), }; } export interface FlowPayloadQuote { unitCost: number; perTask: Array<{ sceneIndex: number; query: FlowPriceQuery; match: FlowPriceMatch; clips: number; cost: number }>; } /** Pure: price a whole payload — every task, every clip it asks for. */ export function quoteFlowPayload(account: FlowAccount, payload: VideoExecutionPayload): FlowPayloadQuote { const clips = Math.max(1, payload.executionProfile.outputCount ?? 1); const perTask = payload.tasks.map((task) => { const query = flowPriceQueryForTask(payload, task); const match = matchFlowPrice(account, query); return { sceneIndex: task.sceneIndex, query, match, clips, cost: match.creditCost * clips }; }); return { unitCost: perTask.reduce((sum, entry) => sum + entry.cost, 0), perTask }; }