Image & video
ai-lcr routes image and video generation too — but through a separate API, because media outputs are files with mixed pricing units, not token-billed model objects.
ai-lcr routes image and video generation across providers the same way it
routes text — cheapest-first, with fallback. But media is a separate API,
not the text router.
Why a separate API
The text router returns a standard AI SDK model (lcr("...")) because LLM calls
are token-billed and share one interface. Media is a different world:
- outputs are files (URLs), not streamed tokens;
- pricing units differ per provider — flat per-image, per-megapixel of compute, per-second of video, or a flat per-clip charge;
- video is often an async job (submit, then poll).
So media has its own entry point, createMediaLCR,
which normalizes those mixed units to a common reference output (16:9 1080p
image, 5-second clip) to compare prices apples-to-apples.
Shape of it
You give it a registry (logical models → provider routes with pricing) and
adapters (one per provider). It returns a generate(modelId, input)
function.
import {
createMediaLCR,
createRunwareMediaAdapter,
createFalMediaAdapter,
createKunavoMediaAdapter,
} from "ai-lcr";
const generate = createMediaLCR({
// one adapter per provider
adapters: {
runware: createRunwareMediaAdapter({ apiKey: process.env.RUNWARE_API_KEY! }),
fal: createFalMediaAdapter({ apiKey: process.env.FAL_KEY! }),
kunavo: createKunavoMediaAdapter({ apiKey: process.env.KUNAVO_API_KEY! }),
},
// logical model → the providers that serve it, each with its native id + price
registry: {
"black-forest-labs/flux-schnell": {
id: "black-forest-labs/flux-schnell",
modality: "image",
routes: [
{ provider: "runware", externalId: "runware:100@1", pricing: { unit: "image", cents: 0.13 } },
{ provider: "fal", externalId: "fal-ai/flux/schnell", pricing: { unit: "image", cents: 0.3 } },
],
},
},
onCost: ({ modelId, provider, costCents }) =>
console.log(`${modelId} · ${provider}: $${(costCents / 100).toFixed(4)}`),
});
const result = await generate("black-forest-labs/flux-schnell", {
prompt: "a red panda coding at night, neon",
});
// result.outputs[].url — the generated file(s); result.provider / result.costCents
console.log(result.provider, result.outputs[0].url);pricing.unit is one of "image" (flat per image), "megapixel" (compute —
scales with resolution), "second" (per second of video), or "call" (flat
per clip).
Two prices, two jobs: ranking vs. billing (0.6+)
The reference normalization answers ONE question — which route is cheapest? — by pricing every route for the same imaginary output (a 16:9 1080p image, a 5-second clip). Billing is a different question and is computed from what was actually produced:
- a per-second SKU bills the actual clip length — from the adapter's
reported
usage.seconds, else the input'sduration(numbers or"8s"-style strings), else the 5s reference as a last resort; - per-image / per-call SKUs bill the output count;
- per-megapixel SKUs bill measured megapixels.
When the provider reports its own bill (costCents), that wins. Each settled
CallRecord also carries estCostUsd — what the price table predicted — so
on provider-reported calls, costUsd − estCostUsd is price-table drift (a
stale registry price, or the classic USD-vs-cents slip at exactly 100×). A bill
≥25× off the prediction additionally raises onError.
The savings baseline follows the same rule: a model's official first-party
price is computed for the call's actual usage (an 8s clip → 8s of the
official rate), and baselineKind on the record says whether the baseline was
"official" or just the "priciest-route" you configured — see
Cost tracking.
Writing your own adapter
Adding a provider = implementing MediaAdapter: run for sync, optional
submit/checkStatus for async. The one contract that matters is how a settled
result reports what was produced:
{
outputs: [{ url, type: "image" | "video" }],
costCents?: number, // the provider's OWN bill, in US cents — ×100 if its API returns dollars!
usage?: { // typed actual usage — what billing is based on
seconds?: number, // video length actually produced
outputs?: number, // output count (images or clips)
megapixels?: number // total output megapixels
}
}Two rules keep billing honest:
- Dimensions are explicitly named — never report a bare count.
secondsandoutputscan't be confused, so a flat per-call price can never be multiplied by a clip's duration (the 8× overcharge the old untypedunitsfield invited). - Throw errors with an HTTP
statusproperty (see the bundledFalMediaError) so the router can classify them and fail over.
Video
Video works the same — declare modality: "video" routes. Both fal and
Kunavo are live, verified video paths. fal uses its async queue API; Kunavo
defaults to its async API (POST /v1/videos → poll GET /v1/videos/{id}) with
a blocking sync fallback (videoMode: "sync"). The adapter submits and polls
for you, and a poll timeout fails over to the next route.
registry: {
"bytedance/seedance-lite": {
id: "bytedance/seedance-lite",
modality: "video",
routes: [
{ provider: "fal", externalId: "fal-ai/bytedance/seedance/v1/lite", pricing: { unit: "second", cents: 3.6 } },
],
},
}
const clip = await generate("bytedance/seedance-lite", {
prompt: "drone shot over a misty forest",
// input also carries image_url, duration, aspect_ratio, … per the provider
});Bundled model catalog
You don't have to hand-write the registry. ai-lcr ships MEDIA_PRICING — a
cross-provider price table for the models where more than one provider competes,
so the router actually has a cheapest-first choice. Feed it straight to
createMediaLCR({ registry: MEDIA_PRICING, ... }).
import { createMediaLCR, MEDIA_PRICING } from "ai-lcr";| Logical id | Modality | Providers that serve it |
|---|---|---|
google/nano-banana | image | Kunavo |
google/nano-banana-2 | image | Kunavo · Runware · fal |
google/nano-banana-pro | image | Kunavo · fal |
openai/gpt-image-2 | image | Kunavo · Runware |
openai/gpt-image-2-high | image | fal |
bfl/flux-schnell | image | Runware · fal |
bfl/flux-kontext-dev | image | Runware · fal |
google/veo-3 | video | Kunavo · fal |
google/veo-3-lite | video | Kunavo · fal |
google/veo-3-quality | video | Kunavo |
This table is the models that 2+ providers carry — that's where routing has a decision to make. The long tail of fal/runware-only models (the rest of Flux, Seedream, Kling, WAN, Sora, Hunyuan, …) isn't bundled here; add those as your own registry entries with a single route. Some prices in the bundle are hand-entered pending a provider-page audit (Veo per-call clips especially) — see the notes in the source and verify before trusting a gap.
Just want to compare prices?
comparePrices(registry) returns, per model, the cheapest provider and each
route's normalized cost — the same data behind the /prices page — without
running any generation.