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_TOKENin.dev.varslocally and withwrangler secret put REPLICATE_API_TOKENin 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:
| Status | Meaning |
|---|---|
queued | The row exists and credits are held; the provider has not seen it yet |
running | The provider accepted it |
saving | The provider finished; outputs are being copied into R2 |
succeeded | Done and charged |
failed | Done 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:
- the provider's webhook,
POST /api/webhooks/generations/$provider, which is only registered whenAPP_URLishttps://; - the page polling a run it is waiting on, which is the only path in local
development, because Replicate cannot reach
localhost; - the
generations.sweepjob 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:
| Method | Does |
|---|---|
describe | A model's listing and its inputs as form fields |
browse | A category's curated list, or a search |
pricing | The published price, when there is one to read |
stageInput | Upload an input file and return a URL the provider accepts |
submit | Start a run, with a webhook URL when there is one |
get | A run's current state, for polling |
parseWebhook | Verify 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.
ShipKit