On the morning of August 17 I ran the generation script for my wallpaper assets. The argument rewrite had been finished the day before, so I expected nothing more than a clean run.
It stopped on the save.
AttributeError: 'GenerateContentResponse' object has no attribute 'generated_images'I had spent all my attention on the mapping table for arguments and never touched the other end. My assumption was that migration work lives at the entrance. In practice, the thing that stopped me was the exit.
If you are stuck at the same spot, here are the three branches that open up on the response side.
The old API was the one carrying your files
In the previous call, each generated image object owned a save() method. Writing bytes to disk was the SDK's job.
# Before: the SDK handled saving
result = client.models.generate_images(
model="imagen-4.0-generate-001",
prompt=prompt,
config=types.GenerateImagesConfig(number_of_images=4),
)
for i, gen in enumerate(result.generated_images):
gen.image.save(f"out_{i}.png")After the migration, image generation travels the same path as text. What comes back is a list of candidates, and inside each one is a list of parts. An image arrives as one of those parts, hanging off inline_data.
# After: you get parts, and saving is now your responsibility
response = client.models.generate_content(
model="gemini-3.1-flash-image",
contents=prompt,
config=types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
),
)Reaching straight for response.candidates[0].content.parts[0] and treating it as the image will bite you sooner or later. The model often returns a short description first, and in that case part zero is text.
Walk the parts and keep only the ones that carry inline_data. That is the first step on the exit side.
def image_blobs(response):
"""Return only the image parts. Returns an empty list when there are none."""
blobs = []
for candidate in response.candidates or []:
content = getattr(candidate, "content", None)
if content is None:
continue
for part in content.parts or []:
blob = getattr(part, "inline_data", None)
if blob is not None:
blobs.append(blob)
return blobsThe getattr around content is deliberate. When a safety check stops the generation, the candidate can still come back while its content is empty. Walking that path with plain dot access turns the real reason into an AttributeError and hides it from you.
Branch 1 - no image part at all
This is the one that fails most quietly.
No exception is raised. The loop runs zero times, zero files are written, and the script exits with status 0. The first time it happened to me, I believed the run had succeeded until I opened the log.
Now I stop the run right there, with the reason attached.
blobs = image_blobs(response)
if not blobs:
feedback = getattr(response, "prompt_feedback", None)
reasons = [c.finish_reason for c in (response.candidates or [])]
raise RuntimeError(
"zero image parts "
f"/ block_reason={getattr(feedback, 'block_reason', None)} "
f"/ finish_reason={reasons}"
)block_reason is populated when the prompt itself was refused. finish_reason tells you why generation stopped partway through. The fix differs depending on which one fired, so printing both on the same line saves a round trip later.
Refused at the prompt means rewriting the wording. Stopped during generation means a retry or a fallback model.
Branch 2 - when the count moves
The old API took number_of_images, so the number of results was decided on the calling side. After the migration, the count leans on the prompt, and a request for four can come back with three.
Here is why that matters in practice for an indie developer.
My wallpapers are produced in sets, one per device resolution, and the generation order maps one to one onto the rows of the category classification that runs afterward. If a single item is missing from a set and the run continues, every classification result shifts by one row and attaches itself to the wrong image. Crashing would be the kinder outcome. When it slides silently all the way through to App Store assets, the discovery happens by eye.
So the count check sits before the save.
EXPECTED = 4
if len(blobs) != EXPECTED:
raise RuntimeError(f"expected {EXPECTED} images, received {len(blobs)}")Three lines, but they change how much you can trust everything downstream. If your design cannot fix the count in advance, at minimum log the actual number and rebind the following steps by filename rather than by index.
Branch 3 - the extension comes from mime_type
Hardcoding .png is the other easy miss. What comes back is not guaranteed to be image/png.
| mime_type | Extension | Notes |
|---|---|---|
| image/png | .png | Use for assets that need transparency |
| image/jpeg | .jpg | Shows up on photographic output |
| image/webp | .webp | A candidate when you serve the file directly |
It is tempting to hand this to mimetypes.guess_extension() from the standard library, but its answer for image/jpeg has shifted between Python versions. I would rather not have my asset naming depend on which interpreter the job happens to run under, so I keep the mapping explicit.
EXT_BY_MIME = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
}One more thing: inline_data.data is already decoded bytes. If you have looked at the raw REST response you will picture a base64 string, but by the time the SDK hands it to you the decoding is done.
with open(path, "wb") as f:
f.write(blob.data) # no base64 decoding neededAdding base64.b64decode() here gets you an exception on a good day and a silently corrupted file on a bad one. If you are touching the save path during migration anyway, this is the single line worth checking first.
The three branches in one receiver
Collecting all three into one place looks like this. You can replace the saving section of an existing script with a single call.
from pathlib import Path
EXT_BY_MIME = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
}
def save_images(response, out_dir: Path, stem: str, expected: int) -> list[Path]:
"""Extract images from a response, write them out, and return the paths."""
blobs = []
for candidate in response.candidates or []:
content = getattr(candidate, "content", None)
if content is None:
continue
for part in content.parts or []:
blob = getattr(part, "inline_data", None)
if blob is not None:
blobs.append(blob)
if not blobs:
feedback = getattr(response, "prompt_feedback", None)
reasons = [c.finish_reason for c in (response.candidates or [])]
raise RuntimeError(
"zero image parts "
f"/ block_reason={getattr(feedback, 'block_reason', None)} "
f"/ finish_reason={reasons}"
)
if len(blobs) != expected:
raise RuntimeError(f"expected {expected} images, received {len(blobs)}")
out_dir.mkdir(parents=True, exist_ok=True)
saved = []
for i, blob in enumerate(blobs):
mime = blob.mime_type or ""
if mime not in EXT_BY_MIME:
raise RuntimeError(f"unknown mime_type: {mime!r}")
path = out_dir / f"{stem}_{i}{EXT_BY_MIME[mime]}"
path.write_bytes(blob.data)
saved.append(path)
return savedThe call site stays short.
paths = save_images(
response,
out_dir=Path("build/wallpapers"),
stem="sunrise",
expected=4,
)
print(f"saved {len(paths)} images")expected is a parameter rather than a constant because set sizes differ by asset type. Letting the caller decide felt more honest than pretending the function knows.
For the entrance side of the same migration, the argument mapping is written up in The Arguments Do Not Move Cleanly from generate_images to generate_content. If you are fixing both ends in one sitting, that one comes first.
The one line worth adding today
Of the three branches, the count check is the one to add if you only add one.
Zero parts and a wrong extension eventually announce themselves, through an exception or through the file itself. A missing item in a set is the only one that travels quietly into everything downstream. Open your script and put a len(blobs) check immediately before the save loop.
If you have not started the migration yet and are unsure which of your code paths still call a retired model, the inventory steps in Finding the August 17 Retirements in Your Own Code are a calmer place to begin.
And if a model name is already baked into a shipped app and you are weighing whether a rollback across the retirement date is still safe, that decision is worked through in The Model Name Your Shipped App Still Remembers.
I hope this helps if you are stuck on the last step of the migration. Thank you for reading.