We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
From the blog
OpenAI Image Generation in Elixir: Build an AI Image Generator in Phoenix LiveView
By Liam Killingback ·
OpenAI Image Generation in Elixir: Build an AI Image Generator in Phoenix LiveView
Text generation gets all the tutorials. Image generation is the feature customers actually screenshot and share, and it is surprisingly easy to get wrong in a web app: a single request can take thirty seconds or more, the response is a few megabytes of base64, and every image costs real money. If you call the API from a LiveView handle_event/3 and hope for the best, you will block the process, lose the result on a refresh, and have no idea what each customer spent.
This guide does OpenAI image generation in Elixir properly. We will call the Images API with Req, store the result, run the slow part in an Oban job, push progress to the browser over PubSub, and cap how many images each user can make. It is part of our series on building AI apps with Elixir and Phoenix, and everything here works on Phoenix 1.7 and 1.8.
What we are building
A LiveView page with a prompt box, a size and quality picker, and a grid of the user’s images. When the user submits a prompt:
-
A
generationsrow is inserted with statuspending, and a placeholder card appears instantly. - An Oban job calls the OpenAI Images API, decodes the base64 image and writes it to storage.
-
The job marks the row
done(orfailedwith a reason) and broadcasts on PubSub. - The LiveView receives the broadcast and swaps the placeholder for the finished image.
The user can close the tab and come back later. The image is still there, because nothing about the work depends on the LiveView process staying alive.
The Images API in one request
The endpoint is POST https://api.openai.com/v1/images/generations. The request body is small:
{
"model": "gpt-image-1",
"prompt": "A watercolor fox reading a newspaper in a Paris cafe",
"size": "1024x1024",
"quality": "medium",
"n": 1
}
With the GPT image models the response carries the image itself, base64 encoded, rather than a URL:
{
"created": 1759740000,
"data": [{ "b64_json": "iVBORw0KGgoAAAANSUhEUgAA..." }],
"usage": { "input_tokens": 18, "output_tokens": 1056, "total_tokens": 1074 }
}
A few things to know before writing any code:
- Model names move. OpenAI ships new image models regularly. Keep the model in config so you can switch without a deploy, and check the models page for the current one.
-
Sizes are fixed. The GPT image models accept
1024x1024,1536x1024(landscape),1024x1536(portrait) andauto. Anything else is a 400. -
Quality drives cost and latency.
low,mediumandhighdiffer a lot in both. Default tomediumand let paying users pickhigh. - Older DALL-E models return URLs that expire after about an hour. If you use one, download the file immediately. Never store an OpenAI URL in your database.
- Prompts are moderated. A rejected prompt comes back as a 400 with an error body. That is a normal outcome you have to show the user, not an exception.
Setup
Add Req and Oban if you do not have them already:
# mix.exs
defp deps do
[
{:req, "~> 0.5"},
{:oban, "~> 2.18"},
# ...
]
end
Put the key and the defaults in runtime config, never in source:
# config/runtime.exs
config :my_app, :openai,
api_key: System.fetch_env!("OPENAI_API_KEY"),
image_model: System.get_env("OPENAI_IMAGE_MODEL", "gpt-image-1")
A small Images client
Keep the HTTP call in one module. It returns plain tuples so the caller never has to know about Req.
defmodule MyApp.AI.Images do
@moduledoc "Thin client for the OpenAI Images API."
@sizes ~w(1024x1024 1536x1024 1024x1536)
@qualities ~w(low medium high)
def sizes, do: @sizes
def qualities, do: @qualities
@spec generate(String.t(), keyword()) ::
{:ok, %{binary: binary(), usage: map()}} | {:error, term()}
def generate(prompt, opts \\ []) do
config = Application.fetch_env!(:my_app, :openai)
body = %{
model: Keyword.get(opts, :model, config[:image_model]),
prompt: prompt,
size: Keyword.get(opts, :size, "1024x1024"),
quality: Keyword.get(opts, :quality, "medium"),
n: 1
}
req =
Req.new(
base_url: "https://api.openai.com/v1",
auth: {:bearer, config[:api_key]},
# image generation is slow: give it room
receive_timeout: 120_000,
retry: :transient
)
|> Req.merge(Application.get_env(:my_app, :images_req_options, []))
case Req.post(req, url: "/images/generations", json: body) do
{:ok, %{status: 200, body: %{"data" => [%{"b64_json" => b64} | _]} = resp}} ->
{:ok, %{binary: Base.decode64!(b64), usage: resp["usage"] || %{}}}
{:ok, %{status: 400, body: %{"error" => %{"message" => message} = error}}} ->
{:error, {:rejected, error["code"], message}}
{:ok, %{status: 429}} ->
{:error, :rate_limited}
{:ok, %{status: status, body: body}} ->
{:error, {:http, status, body}}
{:error, exception} ->
{:error, exception}
end
end
end
Three details matter here. receive_timeout: 120_000 overrides Req’s default, which is far too short for a high quality image. retry: :transient retries network errors and 5xx responses but not a 400, so a rejected prompt is not billed twice. And the :images_req_options merge is a hook for tests, which we will use at the end.
If you are already handling 429s across several AI calls, the patterns in OpenAI rate limits in Elixir apply unchanged: image endpoints have their own, lower limits.
The schema
One table records every request, whether it succeeded or not. That row is your audit trail, your history page and your cost report.
defmodule MyApp.Repo.Migrations.CreateGenerations do
use Ecto.Migration
def change do
create table(:generations) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :prompt, :text, null: false
add :size, :string, null: false
add :quality, :string, null: false
add :status, :string, null: false, default: "pending"
add :path, :string
add :error, :text
add :usage, :map, default: %{}
timestamps()
end
create index(:generations, [:user_id, :inserted_at])
end
end
defmodule MyApp.AI.Generation do
use Ecto.Schema
import Ecto.Changeset
schema "generations" do
belongs_to :user, MyApp.Accounts.User
field :prompt, :string
field :size, :string, default: "1024x1024"
field :quality, :string, default: "medium"
field :status, Ecto.Enum, values: [:pending, :done, :failed], default: :pending
field :path, :string
field :error, :string
field :usage, :map, default: %{}
timestamps()
end
def create_changeset(generation, attrs) do
generation
|> cast(attrs, [:prompt, :size, :quality])
|> validate_required([:prompt])
|> validate_length(:prompt, min: 3, max: 1_000)
|> validate_inclusion(:size, MyApp.AI.Images.sizes())
|> validate_inclusion(:quality, MyApp.AI.Images.qualities())
end
end
Storing the image
The API hands you bytes, so you choose where they live. For a real app that is object storage. The function below writes to S3 through ExAws, and the comment shows the local disk version for development.
defmodule MyApp.AI.ImageStore do
@bucket "my-app-generations"
@spec put(integer(), binary()) :: {:ok, String.t()} | {:error, term()}
def put(generation_id, binary) do
key = "generations/#{generation_id}-#{System.unique_integer([:positive])}.png"
@bucket
|> ExAws.S3.put_object(key, binary, content_type: "image/png")
|> ExAws.request()
|> case do
{:ok, _} -> {:ok, key}
{:error, reason} -> {:error, reason}
end
# In development, write under priv/static instead:
# path = Path.join([:code.priv_dir(:my_app), "static", "generated", key])
# File.mkdir_p!(Path.dirname(path))
# File.write!(path, binary)
end
def url(key), do: "https://#{@bucket}.s3.amazonaws.com/#{key}"
end
If you serve these to other users, use presigned URLs or a CDN in front of a private bucket instead of a public one. The direct-to-S3 upload guide covers the bucket policy side.
The context: create the row, enqueue the job
defmodule MyApp.AI do
import Ecto.Query
alias MyApp.Repo
alias MyApp.AI.{Generation, GenerateImageWorker}
@daily_limit 20
def topic(user_id), do: "generations:#{user_id}"
def list_generations(user) do
Repo.all(
from g in Generation,
where: g.user_id == ^user.id,
order_by: [desc: g.inserted_at],
limit: 60
)
end
def change_generation(attrs \\ %{}),
do: Generation.create_changeset(%Generation{}, attrs)
def request_image(user, attrs) do
if used_today(user) >= @daily_limit do
{:error, :daily_limit}
else
Ecto.Multi.new()
|> Ecto.Multi.insert(:generation, fn _ ->
%Generation{user_id: user.id} |> Generation.create_changeset(attrs)
end)
|> Oban.insert(:job, fn %{generation: g} ->
GenerateImageWorker.new(%{generation_id: g.id})
end)
|> Repo.transaction()
|> case do
{:ok, %{generation: generation}} -> {:ok, generation}
{:error, :generation, changeset, _} -> {:error, changeset}
{:error, _step, reason, _} -> {:error, reason}
end
end
end
defp used_today(user) do
since = DateTime.utc_now() |> DateTime.add(-1, :day)
Repo.aggregate(
from(g in Generation,
where: g.user_id == ^user.id and g.inserted_at > ^since and g.status != :failed
),
:count
)
end
end
The Ecto.Multi matters: the row and the job commit together or not at all, so you never have a pending row with no job behind it. If Oban is new to you, the Oban background jobs guide explains queues, retries and uniqueness.
The @daily_limit count query is fine for a side project. It is not fine once images are a paid feature: it reads Postgres on every request, two quick submits can both pass the check, and it says nothing about billing. That is the problem Aurora Meter exists for. It keeps the counter in ETS, and AuroraMeter.with_quota(user, :images, fn -> ... end) reserves a unit atomically before the work runs, so two concurrent requests cannot both take the last image of a plan.
The worker
defmodule MyApp.AI.GenerateImageWorker do
use Oban.Worker, queue: :images, max_attempts: 3
alias MyApp.{Repo, AI}
alias MyApp.AI.{Generation, Images, ImageStore}
@impl Oban.Worker
def timeout(_job), do: :timer.minutes(3)
@impl Oban.Worker
def perform(%Oban.Job{args: %{"generation_id" => id}, attempt: attempt, max_attempts: max}) do
generation = Repo.get!(Generation, id)
with {:ok, %{binary: png, usage: usage}} <-
Images.generate(generation.prompt, size: generation.size, quality: generation.quality),
{:ok, key} <- ImageStore.put(generation.id, png) do
generation
|> Ecto.Changeset.change(status: :done, path: key, usage: usage)
|> Repo.update!()
|> broadcast()
:ok
else
# the prompt was refused: retrying will not help
{:error, {:rejected, _code, message}} ->
fail(generation, message)
{:cancel, :rejected}
{:error, reason} when attempt >= max ->
fail(generation, "Generation failed, please try again.")
{:error, reason}
{:error, reason} ->
{:error, reason}
end
end
defp fail(generation, message) do
generation
|> Ecto.Changeset.change(status: :failed, error: message)
|> Repo.update!()
|> broadcast()
end
defp broadcast(generation) do
Phoenix.PubSub.broadcast(MyApp.PubSub, AI.topic(generation.user_id), {:generation, generation})
end
end
Give images their own queue with a low concurrency limit, for example queues: [default: 10, images: 3]. That keeps a burst of image jobs from starving your other work and keeps you under OpenAI’s per-minute image limit without any extra code.
Returning {:cancel, :rejected} for a moderation refusal is deliberate. A refused prompt will be refused again, and each retry would cost an API call.
The LiveView
defmodule MyAppWeb.ImageLive do
use MyAppWeb, :live_view
alias MyApp.AI
alias MyApp.AI.{Images, ImageStore}
def mount(_params, _session, socket) do
user = socket.assigns.current_user
if connected?(socket), do: Phoenix.PubSub.subscribe(MyApp.PubSub, AI.topic(user.id))
{:ok,
socket
|> assign(form: to_form(AI.change_generation()))
|> stream(:generations, AI.list_generations(user))}
end
def handle_event("generate", %{"generation" => params}, socket) do
case AI.request_image(socket.assigns.current_user, params) do
{:ok, generation} ->
{:noreply,
socket
|> stream_insert(:generations, generation, at: 0)
|> assign(form: to_form(AI.change_generation()))}
{:error, :daily_limit} ->
{:noreply, put_flash(socket, :error, "You have used today's images. Upgrade for more.")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Could not start the generation.")}
end
end
def handle_info({:generation, generation}, socket) do
{:noreply, stream_insert(socket, :generations, generation)}
end
end
stream_insert/3 with an existing id replaces the card in place, so the same call handles both the new placeholder and the finished image. The template:
<.simple_form for={@form} phx-submit="generate">
<.input field={@form[:prompt]} type="textarea" label="Describe the image" />
<.input field={@form[:size]} type="select" options={Images.sizes()} label="Size" />
<.input field={@form[:quality]} type="select" options={Images.qualities()} label="Quality" />
<:actions><.button phx-disable-with="Queuing...">Generate</.button></:actions>
</.simple_form>
<div id="generations" phx-update="stream" class="grid grid-cols-2 md:grid-cols-3 gap-4 mt-8">
<div :for={{id, g} <- @streams.generations} id={id} class="rounded-lg border p-2">
<%= case g.status do %>
<% :pending -> %>
<div class="aspect-square animate-pulse bg-zinc-100 rounded"></div>
<% :done -> %>
<img src={ImageStore.url(g.path)} alt={g.prompt} class="rounded" loading="lazy" />
<% :failed -> %>
<p class="text-sm text-red-600"><%= g.error %></p>
<% end %>
<p class="mt-2 text-xs text-zinc-500 line-clamp-2"><%= g.prompt %></p>
</div>
</div>
The alt attribute uses the prompt, which is free accessibility for every image.
When Oban is more than you need
For an internal tool where losing an in-flight image on a deploy is acceptable, LiveView’s start_async/3 (LiveView 0.20 and later) runs the call in a task linked to the socket and is less code:
def handle_event("generate", %{"prompt" => prompt}, socket) do
{:noreply,
socket
|> assign(:loading, true)
|> start_async(:image, fn -> Images.generate(prompt) end)}
end
def handle_async(:image, {:ok, {:ok, %{binary: png}}}, socket) do
{:noreply, assign(socket, loading: false, data_url: "data:image/png;base64," <> Base.encode64(png))}
end
def handle_async(:image, {:ok, {:error, reason}}, socket) do
{:noreply, socket |> assign(loading: false) |> put_flash(:error, inspect(reason))}
end
The tradeoff is honest: the task dies with the LiveView. Close the tab and you paid for an image nobody will see. For anything customer facing, the job version wins.
Costs, limits and abuse
Image generation is one of the more expensive API calls you can make per click, so plan for it on day one:
-
Cap per user per period. The daily limit above is the minimum. Tie it to the plan: free users get a handful, paid users get more,
highquality counts for more thanlow. -
Record usage. The
usagemap from the response is stored on every row, so you can work out exactly what each user cost you last month with oneSUMquery. - Limit concurrency. The Oban queue limit is your global brake. One user should not be able to hold all three slots, so add a per-user check for pending rows.
- Keep prompts. Storing every prompt lets you answer abuse reports and tune moderation. Mention it in your privacy policy.
Testing without spending money
Req ships a test adapter, so the client can be exercised without touching the network. Point the client at a stub in the test config:
# config/test.exs
config :my_app, :images_req_options, plug: {Req.Test, MyApp.AI.Images}
config :my_app, :openai, api_key: "test", image_model: "gpt-image-1"
defmodule MyApp.AI.ImagesTest do
use ExUnit.Case, async: true
alias MyApp.AI.Images
@png <<137, 80, 78, 71, 13, 10, 26, 10>>
test "decodes the base64 image" do
Req.Test.stub(Images, fn conn ->
Req.Test.json(conn, %{"data" => [%{"b64_json" => Base.encode64(@png)}], "usage" => %{}})
end)
assert {:ok, %{binary: @png}} = Images.generate("a fox")
end
test "a refused prompt is an error, not a crash" do
Req.Test.stub(Images, fn conn ->
conn
|> Plug.Conn.put_status(400)
|> Req.Test.json(%{"error" => %{"code" => "moderation_blocked", "message" => "Refused"}})
end)
assert {:error, {:rejected, "moderation_blocked", "Refused"}} = Images.generate("bad")
end
end
For the worker, Oban.Testing with testing: :manual lets you assert the job was enqueued, then perform_job/2 runs it against the same stub.
Summary
OpenAI image generation in Elixir comes down to four decisions:
-
Treat the call as slow and expensive. A long
receive_timeout, retries only on transient errors, and no retries on a refused prompt. -
Persist before you call. A
pendingrow plus an Oban job in one transaction means a refresh or a deploy never loses an image the user paid for. -
Push, do not poll. PubSub plus
stream_insert/3updates the grid the moment the job finishes. - Meter it. Store the usage on every row and cap images per plan, because this is the feature that will run up your bill.
If you would rather start from a working AI app than wire the API client, job queue and LiveView streaming yourself, phx_ai ships an OpenAI-backed Phoenix app with auth, streaming responses and a clean place to add an image generator like this one. And if you plan to build more than one product, the Builder Pass gives you every PhxTemplates starter, current and future, for a single lifetime price.