You're looking at the live demo of ShipKit — a real deployment with seeded data.Get the template at shipkit.sh

AI image and video generation

The /generate page, the provider adapter in core/ai, the model catalog and its prices, how a run is held, finished and charged, and how to add a model or a provider.

ShipKit ships a working AI product: signed-in users type a prompt on /generate, pick a model, and get images or video back, paid for in credits. The models run on Replicate. You choose which ones are offered and what they cost in Admin → AI, with no deploy. If your product does not sell model calls, remove the whole module (see the end of this page).

Turning it on

Generation needs one thing: a Replicate API token. Either

  • paste it in Admin → AI → Models, under the provider keys (it is stored encrypted, like every secret operator setting), or
  • set REPLICATE_API_TOKEN in .dev.vars locally and with wrangler secret put REPLICATE_API_TOKEN in production.

The pasted one wins. Without either, the provider is off: its models are not offered, and /generate tells the user none are available yet. The /ai-models skill walks through the token, the presets and their prices.

What the user sees

/generate is one prompt box with Image and Video tabs, a model dropdown and a settings panel. The panel is drawn from the model's own input schema, so every model shows exactly the options it accepts: aspect ratio, quality, duration, seed and so on. Models that take reference images get an upload area; files can be dropped, pasted or picked, and named in the prompt with @.

The button shows the price in credits for the inputs as they stand. Below the box is the user's history, newest first, with a preview dialog, download and delete. A run in progress shows as a placeholder that fills in when it finishes; the page polls every 2.5 seconds while anything is pending.

Outputs live in your R2 bucket and are streamed to their owner only, through /api/generations/$id/$index. Deleting a run deletes its files.

How a run works

Every run is asynchronous. An image takes seconds and a video minutes, and a Worker request cannot wait for either. The whole life of a run is in src/features/generations/server/lifecycle.ts, the only file that changes a run's status:

StatusMeaning
queuedThe row exists and credits are held; the provider has not seen it yet
runningThe provider accepted it
savingThe provider finished; outputs are being copied into R2
succeededDone and charged
failedDone and free

Start. The server checks the input against the model's fields (a field the form never offered is refused), quotes the price, holds that many credits, writes the generation row, uploads any input images to the provider, and submits. If the balance cannot cover the quote, nothing is written and the user is asked to top up.

Finish. Three things can finish a run, and any two may arrive together:

  1. the provider's webhook, POST /api/webhooks/generations/$provider, which is only registered when APP_URL is https://;
  2. the page polling a run it is waiting on, which is the only path in local development, because Replicate cannot reach localhost;
  3. the generations.sweep job in the daily cron, for runs nobody was watching and whose webhook never came.

Whichever arrives first moves the row from running to saving in one conditional UPDATE. That claim is what lets exactly one of them copy the outputs and settle the charge; the others find nothing to claim and stop. D1 has no transactions, so each step is one guarded statement, ordered so that a crash between any two leaves a row the sweep can finish.

Outputs are copied, not linked. Provider URLs expire (Replicate's within an hour), so finishing a run streams every file into R2 under generations/<userId>/<runId>/. If the copy fails, the run goes back to running and the next poll, webhook retry or sweep tries again.

Failed runs cost nothing. A failure, or a "success" with no outputs (what a safety filter looks like), releases the hold.

What a run costs

A model's price is written the way the provider publishes it: US dollars per output, or per second for video (per: 'duration'), with optional tiers on inputs that change it.

pricing: {
  usd: 0.09,
  tiers: [
    { when: { size: '1K' }, usd: 0.045 },
    { when: { size: '2K' }, usd: 0.09 },
  ],
},

Credits follow from one operator setting, the markup (generations.markup, default 2, set in Admin → AI), over the peg of $1 = 100 credits from src/config/plans.ts. A $0.09 image at the default markup is 18 credits. A model can carry its own markup to price it apart from the rest.

Prices are whole credits. The unit price (per image, per second) is rounded up after the markup, and everything else is that times whole counts, so the label, the hold and the charge are the same integers. One pure function, quoteMicro in src/features/generations/pricing.ts, computes the price for both the button and the server's hold, over the same inputs (form defaults, typed values and locks). The price shown is the price charged.

A model that returns several images per run holds the price of the most it may return and charges per image actually returned. Three images out of four asked for cost three unit prices.

Check the prices before launch. Providers reprice and nothing in the app notices. bun run ai-models audit compares every preset with what Replicate publishes today and exits non-zero on any difference; --db local or --db remote also checks the models saved in Admin → AI.

The model catalog

The live catalog is two layers, read through src/features/generations/server/catalog.ts and never from the config file:

  • Presets in src/config/models.ts: the models the template ships with (Seedream 5.0 Pro and two GPT Image 2.5 variants).
  • Rows in ai_model, written by Admin → AI. A row with a preset's id overrides it; any other id is a model the operator added.

As with operator settings, an untouched preset has no row, so a preset you change in code reaches every deployment that never edited it, and Reset just deletes the row. Changes are live everywhere within a minute.

A preset entry, with every optional field:

{
  id: 'gpt-image-2.5-flare',      // stored on every run; never rename one in use
  provider: 'replicate',
  ref: 'openai/gpt-image-2.5-flare', // owner/name on Replicate
  category: 'text-to-image',      // src/core/ai/categories.ts
  label: 'GPT Image 2.5 Flare',
  pricing: { usd: 0.25, tiers: [/* by quality */] },
  markup: 3,                      // this model only; omit to follow the global one
  count: { field: 'number_of_images', max: 4 },
  hide: ['openai_api_key', 'user_id'], // never shown; provider default applies
  lock: { moderation: 'auto' },        // pinned and never shown
}

Everything else about a model's form comes from the provider at runtime: its inputs from the model's schema, its cover image and description from its listing (hotlinked, cached for an hour). hide and lock are for inputs a paying user should not touch. Hide anything that lets the caller bring their own key or loosen moderation on your account.

Adding a model in the console

Admin → AI → Models (permission generations.write to change anything, generations.read to look) lists the catalog. Add model browses Replicate's collection for a category or searches everything it hosts. After picking one you set:

  • each input as shown, hidden or locked to a value;
  • the price. Read from Replicate fills it in from the model's published price, mapped onto its inputs; check it, since a price the page does not state cannot be read;
  • an optional markup of its own and the multi-output input.

Edits, switches and resets are written to the audit log (generations.model_saved, generations.model_enabled, …).

What a model can take

Models are grouped by src/core/ai/categories.ts, which follows Replicate's task collections narrowed to images and video: text-to-image, image editing, upscaling, text-to-video, image-to-video and more. Text and image inputs are supported. A category that needs a video or audio input is listed but cannot be added to yet, and a model whose schema requires such an input is refused in any category.

Input images go to R2 first (POST /api/generations/inputs, PNG, JPEG, WebP or GIF up to 10 MB, into the user's own folder). A run submits only upload ids. At submit time the server copies each file to the provider's own file storage, because the provider cannot reach a private bucket or a dev machine, and should not be handed a public link to one.

Adding a provider

src/core/ai/ is a provider-neutral layer shaped like core/payment/. Nothing above it knows which provider it is talking to. A provider implements AiProvider from src/core/ai/types.ts:

MethodDoes
describeA model's listing and its inputs as form fields
browseA category's curated list, or a search
pricingThe published price, when there is one to read
stageInputUpload an input file and return a URL the provider accepts
submitStart a run, with a webhook URL when there is one
getA run's current state, for polling
parseWebhookVerify the signature and return the run, or null

A new provider is a file beside replicate.ts, a line in providers.ts, an id in AiProviderId and AI_PROVIDERS (src/features/generations/config.ts), and its token in src/core/ai/settings.ts and src/core/env.ts.

Charging through the meter

The generations feature never imports the credits feature. It charges through src/core/meter/events.ts (holdUsage, settleUsage, releaseUsage), and the credits feature registers the ledger behind it. The protocol is the hold-then-settle one described in Credits; every charge has the ledger reason generation and the ref generation:<runId>.

Use the same three calls for any metered work of your own. If you delete the credits module, no meter is registered, every hold succeeds and generation is free.

Admin and account data

  • Admin → AI (/admin/models) holds the provider keys, the markup and the catalog. It is one console section; see Admin console.
  • A user's runs cascade with their account. Their files in R2, outputs and uploaded inputs alike, are deleted by an account-deletion hook, and the data export includes every run's model, input, status and cost.

Not using it

Run /delete-feature generations. It removes the feature, /generate, /admin/models, the routes under /api/generations and the webhook, and src/config/models.ts. src/core/ai/ and the provider token setting stay, for the next feature that calls a model; the token moves to Admin → Settings. See Deleting features.