ai-lcr

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's duration (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:

  1. Dimensions are explicitly named — never report a bare count. seconds and outputs can't be confused, so a flat per-call price can never be multiplied by a clip's duration (the 8× overcharge the old untyped units field invited).
  2. Throw errors with an HTTP status property (see the bundled FalMediaError) 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 idModalityProviders that serve it
google/nano-bananaimageKunavo
google/nano-banana-2imageKunavo · Runware · fal
google/nano-banana-proimageKunavo · fal
openai/gpt-image-2imageKunavo · Runware
openai/gpt-image-2-highimagefal
bfl/flux-schnellimageRunware · fal
bfl/flux-kontext-devimageRunware · fal
google/veo-3videoKunavo · fal
google/veo-3-litevideoKunavo · fal
google/veo-3-qualityvideoKunavo

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.

On this page