Images

Two ways in, one path underneath: the same generation, the same billing, the same record. A second route must never be a second set of rules.

The OpenAI Images drop-in

POST /v1/images/generations, in OpenAI's request and response shape. A script written against their image API changes one base URL and stops getting refusals.

curl https://YOUR-HOST/v1/images/generations \
  -H "Authorization: Bearer dk-YOUR-KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"drael-v1","prompt":"a lighthouse in a storm","n":1,"size":"1792x1024"}'
{
  "created": 1755590400,
  "data": [{ "url": "/api/images/9f2c...", "revised_prompt": "a lighthouse in a storm" }]
}
  • prompt is required. Without it the answer is invalid_request.
  • n is honoured up to 4 per request. Anything higher is served as 4 rather than refused. The images are generated one at a time, so a failure part way through answers with the error and charges only the calls that completed.
  • size maps onto aspect ratios: 1792x1024 and 1536x1024 are 16:9, 1024x1792 and 1024x1536 are 9:16, anything else is square.
  • model is accepted and ignored. The chat and API paths always use the default model; the picker belongs in the studio, where it costs no tokens.
  • The response carries data[].url. revised_prompt is the prompt as it was sent.

This route needs a key with the images scope. It is the one bearer route that spends cash at a provider, so it is the one a key can be kept away from: a key in an editor's configuration never generates an image, and scoped to chat it cannot spend money if it leaks. A key created without a scopes list carries both. See authentication.

The URL is the capability

A generated image is served from /api/images/<id>, with no credential, because the id is 128 bits of randomness and anyone holding the URL can fetch it. It is served as PNG and cached immutably: the bytes behind an id never change.

/api/images/<id>/thumb is the same picture at 640px on its long edge, as JPEG. It is what a wall of generations reads: the library draws forty at once, and forty full generations is tens of megabytes for tiles a few hundred pixels wide. Every record answers with both addresses, url and thumb_url, and the thumbnail route falls back to the picture itself where there is no small copy, so an id made before thumbnails existed still serves.

Deleting a generation deletes the file as well as the row. A deletion that leaves the pixels behind is not a deletion.

In chat

The generate_image tool takes prompt, negative and aspect_ratio, which is one of 1:1, 3:2, 2:3, 16:9 or 9:16. It has no model parameter, for the reason above.

The tool is served only on an account whose plan includes images, and its result carries the URL, which the answer then shows.

In the product

The studio drives the same service directly, on the account's own session rather than a key:

RouteWhat it does
POST /api/imagesGenerate, from prompt, negative and aspect_ratio. Answers with the stored generation.
GET /api/imagesThe account's generations, newest first, with limit and offset.
DELETE /api/images/{id}Delete the generation and its bytes.

Prompts are encrypted before they reach the database. Image prompts are the most revealing thing this product stores.

What the plan decides

  • A guest, who has typed but never signed up, gets account_required. It is the one wall a guest meets: chat, code and search do not have it.
  • An account whose plan carries no images gets plan_required. That is a different answer from the guest wall on purpose: the next step is a plan, not a second signup.
  • A plan whose images for the week are used gets weekly_allowance_reached, and extra usage carries on past it when it is turned on with a daily ceiling. The included counts are published by GET /api/billing/plans.

An image costs cash at the provider per attempt, so credit is held before the call and released when nothing came back. A failed generation is not charged.

The one refusal

There is exactly one content constraint in this product and it is on this path: it will not generate sexual imagery of minors. That is strict-liability criminal law naming the operator, not moderation of the user, and it is tuned so it never fires on adult, security or non-sexual content. Everything else generates.

It reads the prompt and deliberately not the negative prompt. A negative prompt says what must not appear, so a term there is an instruction to leave that thing out: matching it would fire on the most responsible way to use the tool, which is exactly the false positive that must never happen.

The refusal runs on our side, before the prompt reaches the model, and answers refused. It is the only prompt this product declines. The model can refuse one of its own, and that comes back under its own code (provider_refused) rather than dressed up as a rule of ours.

Errors

CodeHTTPMeaning
invalid_request400No prompt.
account_required403Image generation needs an account.
plan_required403The plan does not include images.
scope_required403The key is scoped to chat.
weekly_allowance_reached402The plan's images for this week are used.
refused422The one content constraint.
provider_refused422The provider refused, and the refusal is theirs.
generation_failed502The image could not be generated.
provider_unavailable503Generation is down on our side. Nothing was charged.