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" }]
}
promptis required. Without it the answer isinvalid_request.nis 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.sizemaps onto aspect ratios:1792x1024and1536x1024are 16:9,1024x1792and1024x1536are 9:16, anything else is square.modelis 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_promptis 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:
| Route | What it does |
POST /api/images | Generate, from prompt, negative and aspect_ratio. Answers with the stored generation. |
GET /api/images | The 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 byGET /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
| Code | HTTP | Meaning |
invalid_request | 400 | No prompt. |
account_required | 403 | Image generation needs an account. |
plan_required | 403 | The plan does not include images. |
scope_required | 403 | The key is scoped to chat. |
weekly_allowance_reached | 402 | The plan's images for this week are used. |
refused | 422 | The one content constraint. |
provider_refused | 422 | The provider refused, and the refusal is theirs. |
generation_failed | 502 | The image could not be generated. |
provider_unavailable | 503 | Generation is down on our side. Nothing was charged. |