Image Sessions¶
ImageSession keeps an image conversation going across turns — generate, then refine, then refine
again — and survives the process that started it. to_dict() returns a JSON-serialisable
envelope that from_dict() restores, so a web backend can hold a session in object storage between
two HTTP requests instead of keeping it in memory.
It lives in doc_processing_toolkit.image_session and is also reachable as
multimedia.ImageSession, beside the one-shot generate_image / edit_image
facades.
from doc_processing_toolkit.image_session import ImageSession
session = ImageSession("gpt-image-2", quality="low", size="1024x1024")
first = session.start("A red circle on a white background")
second = session.step("Make the circle blue and add a thin border")
second.image_bytes # the provider's original bytes, never a re-encode
session.latest_image() # the same, from the session
session.total_cost # the sum of every request's own cost record
envelope = session.to_dict() # store this anywhere JSON goes
later = ImageSession.from_dict(envelope)
later.step("Now put it on a dark background")
Async servers: astep¶
await session.astep(prompt, image=...) is the async twin of step(): same arguments, same
ImageTurn, and the same session state and envelope afterwards. The provider SDK calls are
synchronous, so the step runs on the event loop's default executor (the pool asyncio.to_thread
uses) and a 30-second render no longer blocks the loop. On a session with no turns, astep is the
async form of start().
Steps on one session are sequential, and a concurrent one is refused rather than queued: a second
astep() or step() while one is running raises MultimediaValidationError ("already running a
step") and sends nothing, because each step edits the image the previous one produced. Cancelling
the awaiting task does not stop a render that was already sent: the worker thread finishes, the turn
(and the cost the provider billed) is recorded, and the session accepts the next step once it has.
Two providers, and only two¶
Anthropic and Fireworks do not generate images at all. Claude's own documentation states that it
"cannot generate, produce, edit, manipulate, or create images", and Fireworks publishes no
image-generation endpoint. A model from either raises MultimediaValidationError naming the two
providers that do, before anything is sent.
| Provider | Surface used | persistence="server" |
|---|---|---|
| OpenAI | The stateless Images API — the only OpenAI surface that reports an exact image/text token split | Available, opt-in |
generateContent, replaying the provider's own Content history |
Raises — it would need the Interactions API, which the toolkit does not use yet |
References and thinking (0.5.4)¶
start(image=...) and step(image=...) take the references edit_image
takes: images on every row, and on a Google row
whose registry input_types list "video" or "document" (gemini-nano-banana-2.1) a video clip
or a PDF as well. Two things follow from the session owning the history. The reference lives in the
replayed Content, so every later turn re-sends it and is billed for it again at the row's input
rate (a 30-second clip is roughly 9k tokens a turn; a ten-page PDF about 2.6k), and it counts against
the row's input window on every turn; the Gemini image rows support no context caching, so nothing
softens that. And the signature-loss rebuild (below) restates the prompts with the last image only,
so a turn with signatures_lost=True has dropped the references, and the Files API uploads the
rebuilt history no longer names (below) leave the media table and Google with them. The envelope
keeps a reference under its own MIME type, inline or externalised, exactly as it keeps an image.
thinking_level= is a session setting on the Google rows (validated against the row's
supported_thinking_levels, refused elsewhere), stored in the envelope's settings and sent on every
turn; a turn's output_tokens include the thinking tokens, billed at the row's text rate.
A reference over 20 MB goes through the Files API, and the session keeps the upload. Under
GOOGLE_INLINE_REFERENCE_MAX_BYTES the bytes travel inline; over it they are uploaded once and the
history names the upload as a file_data part (the same bytes on a later turn reuse the live upload);
an upload Google fails to process, or that is still processing at the deadline, is deleted before
the error is raised, here and on edit_image. Because the replayed history names it, the upload is
not deleted after the call — unlike
edit_image — and the session records it in a media table, one entry per upload with its uri,
name, mime_type, size, sha256, uploaded_at, expiration_time and the bytes. The envelope
carries that table as provider_state.media (schema version 2; a version-1 envelope still loads),
with the bytes inline or, under to_dict(externalize=True), handed to the image_store and kept as a
ref the image_loader resolves. A file_data part adds nothing to history_bytes. After every
step the table holds only the uploads the history names: a failed step's own upload, and the
references a rebuild dropped, are deleted on Google and leave the table. A session restored with an
image_loader from an externalised envelope carries its media bytes again, so a later plain
to_dict() is self-contained. A transport failure on an upload or a refresh is a failed turn with
an error_code and retryable, like one on the request; media Google could not process, and a
dead upload whose bytes were not kept, raise MultimediaValidationError instead.
Google keeps an upload for 48 hours. Before every step the session checks each upload the history
names — the recorded expiry decides without a network call, an upload within a minute of it counting
as expired, and an upload with no recorded expiry is checked with files.get — and re-uploads a dead one from its kept
bytes, rewriting the part's file_uri in place (the model's thought signatures bind its own parts, not
the user's, so the replay stays valid). A request Google rejects because a named file is gone triggers
the same pass with every upload re-checked, and one retry; that turn reports
recovered_from="media_refreshed". Either way the turn's media_refreshed counts the uploads
refreshed, and signatures_lost stays False. A dead upload whose bytes are not available — an
externalised envelope restored without an image_loader — stops the step with an error naming the
upload and its position in the history, because an image edit cannot proceed on a placeholder. The
9 Oct 2026 check uploaded a 25 MB clip, deleted it behind the session's back and stepped twice: the
first step re-uploaded after the rejection and the second re-uploaded on a past expiry, with the
signed history intact.
Persistence modes¶
persistence takes "auto", "replay" or "server" (image_session.PERSISTENCE_MODES).
"auto" resolves to "replay" on both providers. Replay holds the whole conversation inside the
session: it is portable, self-contained and exactly costed, and it is what makes the envelope worth
having.
"server" is OpenAI-only and an explicit opt-in. The Responses API holds the chain through
previous_response_id and a mainline response_model hosts the image_generation tool, so:
- both models are billed — each step emits two cost records, the mainline model and the image model;
- each record is priced from its own model's reported usage. A live probe (2026-09-23,
gpt-5-nanohostinggpt-image-2at 1024x1024) showedusage.output_tokensholding only the host model's tokens — 224 atquality="low"and 267 at"medium", of which 128 and 192 were reasoning — so the mainline record, priced fromusage, is not a double bill. The image model's usage arrives separately in a top-levelresponse.tool_usage["image_gen"]block (undeclared on openai 3.16.2, so read fromResponse.model_extra): 23 text input / 196 image output tokens at low, 25 / 1,756 at medium. The image record prices its input tokens at the row'sinput(andinput_image) rate and its image output tokens atoutput_image, withcost_estimated=False; - only when that block is absent does the image record fall back to OpenAI's published
per-quality table (272 tokens for a low 1024x1024 render, 1,056 for medium), flagged
cost_estimated=True. The probe measured that table 39% high at low and 40% low at medium, with no charge for the image model's text input — which is what earlier releases billed on every server step; - stored responses expire after 30 days;
- it is not cheaper — OpenAI re-bills the chained history as input either way.
response_model is warned about, not gated
OpenAI's two documentation pages publish non-nested lists of the models that may host the
image_generation tool, so the toolkit validates response_model only as "an OpenAI model the
registry knows" and warns when it falls outside the documented intersection
(image_session.OPENAI_IMAGE_TOOL_HOSTS_DOCUMENTED). Baking an unverified allow-list into the
registry would reject models that work; the constant moves into the registry once the real list
is settled.
What the envelope guarantees¶
to_dict() produces a versioned mapping — schema_version, provider, model, response_model,
persistence, settings, limits, sdk, turns, provider_state — that round-trips thought
signatures and inline image bytes byte for byte. It is produced by the SDK's own
model_dump(mode="json"), and model_validate is the only supported reader: hand-rolled base64 is
not a substitute. from_dict() verifies every artifact's SHA-256 on the way back in, and refuses an
envelope written by a newer IMAGE_SESSION_SCHEMA_VERSION rather than guessing at its shape.
A session the toolkit downgraded to "replay" after an expired OpenAI chain restores in that mode:
from_dict() restores response_model and partial_images only when the saved persistence is
"server", because only that mode accepts them. Such an envelope used to raise on load and strand
every later turn; envelopes written by earlier releases load as before.
A base64 dump is roughly 1.33x the raw image size, so an envelope with several turns belongs in
object storage rather than a database column. to_dict(externalize=True) is the alternative: image
payloads go to a caller-supplied image_store and the envelope keeps references plus the hashes,
with image_loader resolving them on the way back.
The Google signature rule¶
Every model part carrying a thought_signature — the first part after the thoughts, and every
inline_data part — must be returned unchanged or the API answers 400. The session therefore
replays the provider's original bytes and never a converted, resized or re-saved copy: a signature is
bound to the exact bytes the model returned. That is also why the session, not you, owns the history.
Recovery is bounded and visible¶
A step makes at most three provider attempts, and whatever happened is recorded on the returned turn.
| Situation | What the session does | recovered_from |
|---|---|---|
A missing a thought_signature 400 |
Re-sends the identical payload once — the single documented exception to "never retry a 400" | spurious_signature_400 |
| A persistent signature 400 | Rebuilds from the last image with the accumulated prompt history restated, so earlier edits are not silently dropped, and sets signatures_lost=True once the rebuild has produced an image |
signature_rebuild |
| The replayed history is rejected | The same rebuild | history_rejected |
An OpenAI previous_response_id that no longer resolves (the error names previous_response_id, or is a 404 naming the chained response id — any other 404, such as model_not_found, is a plain failed turn) |
Falls back once to a stateless request carrying the last image and downgrades the session to "replay" |
expired_chain |
A rebuild replaces the replayed history only once it has produced an image. A rebuild that comes back blocked is a failed turn that leaves the history untouched, so the next step rebuilds again instead of silently generating from scratch — which is what it used to do.
When signatures_lost flips. The flag describes the session's history, not the attempt: it is
True only on the turn whose rebuild produced an image and replaced the signed Google history.
From that turn on, the thought signatures of the earlier turns are gone for good — later steps
replay the rebuilt, signature-free request plus the new model turn — so a caller that needs the
signed chain (an audit trail, or a byte-exact replay of the original conversation) should keep the
envelope saved before that turn. A rebuild that raised or came back blocked reports
signatures_lost=False with recovered_from still naming the rung that ran: the signed history
survived, and the next step() replays it (or rebuilds again). Earlier releases reported True on
that blocked turn although nothing had been lost.
A rebuild re-sends the image as billable input, so a recovered step can cost more than a clean
one. size_downgraded is set when the rebuild also had to drop to the next smaller entry in the
model's supported_sizes because the error blamed the resolution; it never costs an extra request.
A failed turn is still recorded — its observed usage and cost are real — but it does not advance
the conversation, so the next step() continues from the last good image.
Bounds raise, they never truncate¶
max_turns (default 16) and max_history_bytes (default 32 MiB) raise a
MultimediaValidationError naming the ceiling and the knob it came from. Pass None to either to
disable it. Silent truncation was rejected deliberately: dropping earlier turns would break the
Google signature rule and change the image being edited without telling the caller.
history_bytes measures what the next request actually re-sends. On OpenAI that is only the latest
image, because neither persistence mode re-sends earlier turns (the server mode sends a chain id);
it used to sum every turn, so a 4K session hit the 32 MiB default at turn 5.
Cost¶
Every turn carries cost_records, one per API request, and turn.cost is their sum; the
session's total_cost is the sum over turns. The toolkit never prices a session by summing tokens
and costing the sum. An OpenAI persistence="server" step produces two records, each priced from its
own model's reported usage; the image one is flagged cost_estimated=True only when the response
carried no tool_usage["image_gen"] block and the per-quality table was used. session.cost_estimated is True when any component of any turn was
derived rather than reported. session.cost_breakdown gives the same named lines
generate_image reports.
See Cost Tracking for why per-request summing is the rule rather than a detail.
Related pages¶
- Multi-turn Image Sessions recipe — create, step, serialise and restore a session on both providers, and read its cost
- Multimedia — one-shot
generate_image/edit_image, the size solver, and the image cost breakdown - Cost Tracking — per-request records and what
cost_estimatedmarks - Client Integration — holding session state in an application