Skip to content

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
Google 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-nano hosting gpt-image-2 at 1024x1024) showed usage.output_tokens holding only the host model's tokens — 224 at quality="low" and 267 at "medium", of which 128 and 192 were reasoning — so the mainline record, priced from usage, is not a double bill. The image model's usage arrives separately in a top-level response.tool_usage["image_gen"] block (undeclared on openai 3.16.2, so read from Response.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's input (and input_image) rate and its image output tokens at output_image, with cost_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.