GEMINI LABJP
SUNSET — The image generation models shut down today, August 17: imagen-4.0-generate-001, ultra, fast, and the Gemini 3 Image family, and calls after that fail with a hard errorSCALE — Gemini crossed one billion monthly active users on August 11ASSISTANT — Starting September 4, Gemini replaces Google Assistant on Android and Wear OS, a rollout expected to take several weeks and one you cannot reverse on a deviceDEVICES — The change covers Android phones and tablets, Wear OS watches, Assistant-enabled headphones, and phone-projected Android Auto; cars with Google built-in keep workingSPARK — Since August 13, Gemini 3.7 Flash has powered Gemini Spark for AI Pro and Ultra subscribers across more than 160 countriesPRICE — Gemini 3.7 Flash carries introductory pricing of $0.75 per million input tokens and $3.75 output through December 31, moving to $1.50 and $7.50 after thatSUNSET — The image generation models shut down today, August 17: imagen-4.0-generate-001, ultra, fast, and the Gemini 3 Image family, and calls after that fail with a hard errorSCALE — Gemini crossed one billion monthly active users on August 11ASSISTANT — Starting September 4, Gemini replaces Google Assistant on Android and Wear OS, a rollout expected to take several weeks and one you cannot reverse on a deviceDEVICES — The change covers Android phones and tablets, Wear OS watches, Assistant-enabled headphones, and phone-projected Android Auto; cars with Google built-in keep workingSPARK — Since August 13, Gemini 3.7 Flash has powered Gemini Spark for AI Pro and Ultra subscribers across more than 160 countriesPRICE — Gemini 3.7 Flash carries introductory pricing of $0.75 per million input tokens and $3.75 output through December 31, moving to $1.50 and $7.50 after that
Articles/API / SDK
API / SDK/2026-08-17Intermediate

After generated_images Disappeared: Three Branches Between the Response and a Saved File

Once you switch to generate_content, the line that breaks is usually the save. Here are the three branches on the response side - zero image parts, a shifting image count, and picking the extension - plus a receiver function you can drop in.

Gemini API213Image Generation5Model Migration6Python41Troubleshooting6

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 blobs

The 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_typeExtensionNotes
image/png.pngUse for assets that need transparency
image/jpeg.jpgShows up on photographic output
image/webp.webpA 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 needed

Adding 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 saved

The 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.

Share

Thank You for Reading

Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

API / SDK2026-08-14
Rewriting generate_images as generate_content, where the arguments actually go
Ahead of the August 17 Imagen shutdown, I checked the SDK directly: of the 17 arguments in GenerateImagesConfig, only 5 move across unchanged. Here is the mapping, a compatibility layer that keeps callers working, and a way to verify the request shape without an API key.
API / SDK2026-05-24
Why Your Gemini File API Uploads Vanish After 48 Hours — and How to Code Around It
Gemini File API resources are auto-deleted 48 hours after upload. Here is how to recognize the failure, why it happens, and concrete patterns for re-uploading, falling back to inline data, and managing expiration safely.
API / SDK2026-04-28
Gemini API Won't Connect Through Corporate Proxy or SSL Verification — A Troubleshooting Walkthrough
Your Gemini API script worked on your personal laptop, but the corporate Windows machine just hangs. Isolate proxy, SSL, and certificate issues layer by layer with working Python and Node.js examples.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →