# Nano Banana no genera imagen en la API: diagnóstico y arreglo

> Si la API de Nano Banana responde sin imagen, el JSON explica el motivo: solo texto, un bloqueo, candidates vacío o un ID retirado. Cada caso tiene su arreglo.

- URL: https://blog.laozhang.ai/es/posts/nano-banana-2-200-ok-no-image
- Published: 2026-03-09
- Updated: 2026-09-24
- Author: LaoZhang AI Team (https://blog.laozhang.ai/es/about)
- Topic: Resolución de problemas
- Tags: Nano Banana, Gemini API, finishReason, IMAGE_SAFETY, Solución de errores API

---
Cuando la API de Nano Banana contesta con HTTP 200 y no trae ninguna imagen, la llamada no ha fallado por red: la respuesta llegó y el motivo está dentro del JSON. Hay cuatro explicaciones posibles: el modelo respondió solo con texto, un filtro detuvo la imagen (`finishReason` como `IMAGE_SAFETY`, o `candidates` vacío con `promptFeedback.blockReason`), la salida se cortó antes de tiempo, o tu código no encontró una imagen que sí venía. Aparte están los ID de modelo retirados, que ya no generan nada: `gemini-3.1-flash-image-preview` y `gemini-3-pro-image-preview` se apagaron el 25 de junio de 2026, y `gemini-2.5-flash-image` se apaga el 2 de octubre de 2026.

Reintentar la misma petición solo tiene sentido con `NO_IMAGE`, `IMAGE_OTHER`, un 429 o errores 5xx, y en los dos últimos casos tras una espera. Ante un bloqueo por políticas o un ID retirado, repetirla da el mismo resultado: hay que cambiar el contenido o el modelo.

Si usas la app o la web de Gemini sin escribir código, tu caso está en [Nano Banana no funciona en Gemini: 12 arreglos para errores de imagen](https://blog.laozhang.ai/es/posts/nano-banana-not-working-gemini-image-errors). Lo que sigue es para quien llama a la Gemini API directamente, desde AI Studio, con un SDK oficial o con una herramienta montada sobre la API. A 24 de septiembre de 2026, los ID vigentes son `gemini-3.1-flash-image` (Nano Banana 2), `gemini-3.1-flash-lite-image` (Nano Banana 2 Lite), `gemini-3-pro-image` (Nano Banana Pro) y `gemini-2.5-flash-image` (el Nano Banana original, hasta su retirada).

## Guarda la respuesta cruda antes de cambiar nada

El diagnóstico sale de la respuesta completa, no del mensaje que muestre tu aplicación. Hay dos API con estructuras distintas y conviene no mezclarlas. La [Interactions API](https://ai.google.dev/gemini-api/docs/interactions-overview) está disponible de forma general (GA) desde junio de 2026 y Google la recomienda para proyectos nuevos; `generateContent` pasa a considerarse heredada, pero sigue plenamente admitida.

| Qué buscas | `generateContent` | Interactions API |
|---|---|---|
| ¿Terminó bien? | `candidates[0].finishReason` y, si existe, `finishMessage` | `status` |
| ¿Se bloqueó el prompt? | `candidates` vacío y `promptFeedback.blockReason` | `status: "failed"` y la lista `errors` |
| La imagen final | `parts` con `inlineData` y `mimeType` de imagen, sin `thought: true` | bloques `type: "image"` dentro de pasos `model_output` |
| Imágenes de razonamiento | `parts` con `thought: true` | pasos `type: "thought"`, dentro de `summary` |
| Consumo de tokens | `usageMetadata` | `usage` |

Con REST basta con escribir el cuerpo en un archivo:

```bash
curl -s -X POST \
  "https://generativelanguage.googleapis.com/v1/models/gemini-3.1-flash-image:generateContent" \
  -H "x-goog-api-key: $GEMINI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"contents": [{"parts": [{"text": "Genera una imagen de un faro en la costa cantábrica al amanecer"}]}]}' \
  -o respuesta.json
```

Con el SDK de Python (`google-genai`), los objetos de respuesta se pueden volcar a JSON:

```python
with open("respuesta.json", "w", encoding="utf-8") as f:
    f.write(response.model_dump_json(exclude_none=True))      # generateContent
    # f.write(interaction.model_dump_json(exclude_none=True)) # Interactions API
```

El volcado del SDK usa nombres en snake_case (`finish_reason`, `inline_data`, `mime_type`), mientras que REST devuelve camelCase (`finishReason`, `inlineData`, `mimeType`). Un parser que busque `inlineData` en un volcado del SDK no encontrará ninguna imagen aunque la haya; el script del final acepta las dos formas.

## De la forma de la respuesta a la acción

Cada fila de esta tabla corresponde a un caso distinto. La última columna es la que más dinero ahorra: indica si merece la pena repetir exactamente la misma petición.

| Lo que ves | Qué significa | Qué hacer | ¿Repetir igual? |
|---|---|---|---|
| `finishReason: "STOP"` con solo texto, o `status: "completed"` sin bloque `image` | El modelo decidió contestar con texto | Pide la imagen de forma explícita y limita la salida a imagen | No; cambia la petición |
| `NO_IMAGE` | Se esperaba una imagen y no se generó | Pide la imagen explícitamente, simplifica el prompt | Una vez |
| `IMAGE_SAFETY`, `IMAGE_PROHIBITED_CONTENT`, `PROHIBITED_CONTENT`, `BLOCKLIST`, `SPII` | El contenido chocó con las políticas | Cambia lo que pides o las imágenes que envías | No |
| `SAFETY` | Lo cortó uno de los filtros ajustables | Revisa `safetyRatings` y tus `safetySettings` | Solo si habías endurecido el umbral |
| `candidates` vacío y `promptFeedback.blockReason` | Se bloqueó el prompt antes de generar | Actúa según `blockReason` | No |
| `IMAGE_RECITATION` o `RECITATION` | La salida se parecía a material existente | Haz el prompt más original | No |
| `IMAGE_OTHER` u `OTHER` | Causa no especificada | Reintenta una vez; si se repite, simplifica | Una vez |
| `MAX_TOKENS` o `status: "incomplete"` | Se alcanzó el límite de salida | Sube o quita `maxOutputTokens` / `max_output_tokens` | Tras cambiarlo |
| `MISSING_THOUGHT_SIGNATURE` | Falta una firma de razonamiento en una edición multiturno | Devuelve las firmas tal como llegaron | Tras corregirlo |
| `status: "failed"` | La interaction falló | Lee `errors[].message` | Depende del mensaje |
| `PUP_LIMITED_DISABLED` | Cuenta limitada por la Política de uso prohibido | Se resuelve en la cuenta, no en el código | No |
| Error HTTP con un ID `-preview` de imagen | El modelo ya no existe | Cambia al ID GA | No |
| HTTP 429, 408 o 5xx | Cuota o fallo transitorio | Espera con backoff exponencial | Sí, con espera |

Los valores de `finishReason` salen de la [referencia de `generateContent`](https://ai.google.dev/api/generate-content#FinishReason). Ninguna de sus descripciones dice qué parte concreta del prompt provocó un bloqueo; `finishMessage`, cuando viene, puede añadir algo de detalle.

## Solo texto: pide la imagen y limita la salida

La documentación de Google lo dice sin rodeos: [el modelo devuelve por defecto texto e imagen](https://ai.google.dev/gemini-api/docs/image-generation#optional-configurations). Si nada en la petición deja claro que quieres una imagen, la respuesta puede quedarse en texto. El arreglo tiene dos partes: que el prompt diga claramente que quieres una imagen y que la petición restrinja la salida.

```python
from google import genai
from google.genai import types

client = genai.Client()
prompt = "Genera una imagen: un faro en la costa cantábrica al amanecer, estilo acuarela."

# generateContent
response = client.models.generate_content(
    model="gemini-3.1-flash-image",
    contents=prompt,
    config=types.GenerateContentConfig(response_modalities=["IMAGE"]),
)

# Interactions API
interaction = client.interactions.create(
    model="gemini-3.1-flash-image",
    input=prompt,
    response_format={"type": "image"},
)
```

`responseModalities` exige que la respuesta coincida exactamente con las modalidades pedidas, y si pides una combinación que el modelo no admite, la API devuelve un error en lugar de una respuesta vacía. Restringir la salida no garantiza la imagen: la llamada puede seguir terminando en `NO_IMAGE` o en un bloqueo, y entonces toca la fila correspondiente de la tabla. Tampoco cuentes con el número exacto de imágenes: según las [limitaciones publicadas](https://ai.google.dev/gemini-api/docs/image-generation#limitations), el modelo no siempre respeta cuántas pides.

Sobre el idioma: la lista de idiomas con mejor rendimiento incluye `es-MX` y no menciona `es-ES`, lo que no indica ningún problema con el español de España. Si un prompt en español vuelve una y otra vez solo con texto, la prueba barata es dejar muy explícita la petición de imagen al principio y, si sigue igual, comparar con la misma petición en inglés.

## Bloqueos: qué cambiar y qué no sirve

Los bloqueos por políticas (`IMAGE_SAFETY`, `IMAGE_PROHIBITED_CONTENT`, `PROHIBITED_CONTENT`, `BLOCKLIST`, `SPII`) no se arreglan repitiendo la llamada. Como el enum no señala el elemento que lo disparó, lo práctico es quitar de uno en uno lo que tenga más papeletas: personas reales identificables, contenido sexual o violento, marcas, obras ajenas o datos personales. Con imágenes de referencia pasa lo mismo; en r/Bard hay usuarios que cuentan que Gemini deja de generar en cuanto una de las fotos de entrada es de un famoso.

Los ajustes de seguridad no son una vía para saltarse estos bloqueos. Según la [guía de ajustes de seguridad](https://ai.google.dev/gemini-api/docs/safety-settings):

- Los umbrales ajustables cubren cuatro categorías: acoso (`HARASSMENT`), incitación al odio (`HATE_SPEECH`), contenido sexual explícito (`SEXUALLY_EXPLICIT`) y contenido peligroso (`DANGEROUS_CONTENT`). `IMAGE_SAFETY`, `PROHIBITED_CONTENT` y el resto no están entre ellas, así que no hay ningún ajuste documentado que los desactive.
- Las protecciones frente a daños graves, como los que ponen en peligro a menores, bloquean siempre y no se pueden ajustar.
- Si no fijas ningún umbral, en los modelos Gemini 2.5 y 3 el valor por defecto es `OFF`. De ahí se deduce que poner `BLOCK_NONE` no afloja nada si nunca tocaste esos ajustes, y que `finishReason: "SAFETY"` solo se corrige desde `safetySettings` cuando antes los habías endurecido tú; en ese caso, `safetyRatings` indica qué categoría saltó. En AI Studio están en Run settings, dentro de Advanced settings.
- La Interactions API no admite ajustes de seguridad personalizados.
- Google avisa de que las aplicaciones con ajustes menos restrictivos pueden someterse a revisión.

Si el prompt se bloquea entero, `candidates` llega vacío y `promptFeedback.blockReason` trae `SAFETY`, `OTHER`, `BLOCKLIST`, `PROHIBITED_CONTENT` o `IMAGE_SAFETY`. El caso `OTHER` tiene su propia guía: [Nano Banana 2 blockReason OTHER: qué significa y qué revisar](https://blog.laozhang.ai/es/posts/nano-banana-2-blockreason-other).

Con `RECITATION` o `IMAGE_RECITATION`, la [guía de solución de problemas](https://ai.google.dev/gemini-api/docs/troubleshooting) recomienda que el prompt y el contexto sean lo más originales posible: fuera títulos de obras, personajes con nombre y peticiones de «igual que».

Cambiar de plataforma tampoco resuelve un bloqueo: si otra herramienta o proveedor acaba llamando al mismo modelo de Google, la decisión sobre el contenido la vuelve a tomar ese modelo.

## Cuando la imagen está ahí y tu código no la ve

Los modelos de imagen de Gemini 3 razonan siempre, sin opción de desactivarlo, y durante ese proceso generan hasta dos imágenes provisionales para probar la composición. Según la [documentación del proceso de razonamiento](https://ai.google.dev/gemini-api/docs/image-generation#thinking-process), la última imagen del razonamiento coincide con la imagen final. En `generateContent`, esas partes llevan `thought: true` y aparecen cuando activas `includeThoughts`; en la Interactions API van en pasos `type: "thought"`. Los ejemplos oficiales se saltan esas partes antes de guardar la imagen.

De ahí salen dos fallos típicos del parser (la documentación no los describe así; se deducen de la estructura de la respuesta):

- Con `includeThoughts` activado, guardar la primera parte con `inlineData` puede darte un borrador de razonamiento en lugar de la imagen final.
- Un filtro mal escrito puede informar de «sin imagen» con la imagen dentro de la respuesta: por ejemplo, quedarse solo con `parts[0]` o descartar toda parte que lleve `thoughtSignature`, cuando la documentación indica que las partes de imagen de la respuesta final también llevan esa firma.

La salida intercalada de texto e imagen tiene su propia trampa. En la Interactions API, `interaction.output_image` devuelve solo el último bloque de imagen; si pides, por ejemplo, una historia ilustrada, hay que recorrer `steps` y guardar cada bloque `image` de los pasos `model_output`. En `generateContent` hay que recorrer todas las `parts`, no solo la primera. Antes de concluir que no hay imagen, guarda todas las partes.

![Estructura de la respuesta en generateContent y en la Interactions API: dónde van los borradores del razonamiento y dónde la imagen final](https://blog.laozhang.ai/posts/es/nano-banana-2-200-ok-no-image/img/respuesta-imagen-final.webp)

Si tu código de la Interactions API lee `interaction.outputs`, está escrito para el esquema anterior. Según el [registro de cambios](https://ai.google.dev/gemini-api/docs/changelog), la respuesta pasó de `outputs` a `steps`: el esquema nuevo es el predeterminado desde el 26 de mayo de 2026 y el antiguo se retiró el 8 de junio. Con `google-genai` 1.65.0, por ejemplo, el objeto `Interaction` solo define `outputs`; ante una respuesta con `steps`, ese campo queda en `None` y tu código informa de que no hay imagen aunque la haya. Actualiza el SDK y recorre `steps`. El script de más abajo lee los dos esquemas y avisa cuando encuentra el antiguo.

Si el problema aparece en la segunda vuelta de una edición conversacional y la respuesta termina en `MISSING_THOUGHT_SIGNATURE`, falta devolver las firmas de razonamiento. El chat de los SDK oficiales lo hace solo; si montas el historial a mano, consulta [Error de Thought Signature en Nano Banana 2: Guía completa de solución (2026)](https://blog.laozhang.ai/es/posts/nano-banana-2-thought-signature-error).

## ID retirados y código que funcionaba hace unos meses

Si tu código funcionaba en primavera y ahora falla, mira primero el ID del modelo. Estas son las fechas de la [página de retiradas de Google](https://ai.google.dev/gemini-api/docs/deprecations):

| ID antiguo | Estado a 24 de septiembre de 2026 | Qué usar |
|---|---|---|
| `gemini-3.1-flash-image-preview` | Apagado el 25 de junio de 2026 | `gemini-3.1-flash-image` |
| `gemini-3-pro-image-preview` | Apagado el 25 de junio de 2026 | `gemini-3-pro-image` |
| `gemini-2.5-flash-image-preview` | Apagado el 15 de enero de 2026 | `gemini-3.1-flash-image` o `gemini-3.1-flash-lite-image` |
| `gemini-2.0-flash-preview-image-generation` | Apagado el 14 de noviembre de 2025 | `gemini-3.1-flash-image` o `gemini-3.1-flash-lite-image` |
| `gemini-2.5-flash-image` | Se apaga el 2 de octubre de 2026 | `gemini-3.1-flash-image` o `gemini-3.1-flash-lite-image` |

`gemini-3.1-flash-image` y `gemini-3-pro-image` son GA desde el 28 de mayo de 2026 y no tienen fecha de retirada anunciada. Ojo con la columna de reemplazo de esa misma página: para `gemini-2.5-flash-image` todavía señala `gemini-3.1-flash-image-preview`, que ya está apagado. La advertencia de la [página de precios](https://ai.google.dev/gemini-api/docs/pricing) indica migrar a Gemini 3.1 Flash Image o a Gemini 3.1 Flash Lite Image, y la guía de imagen recomienda Nano Banana 2 Lite a quien viene del modelo original. Para elegir entre ellos por calidad y precio, tienes [Nano Banana 2 Lite vs 2 vs 2.1 vs Pro: elige por tarea](https://blog.laozhang.ai/es/posts/nano-banana-pro-vs-nano-banana-2).

Una llamada con un ID apagado devuelve un error en lugar de una respuesta. Reintentar no cambia nada: hay que cambiar el ID. Si además tu código importa `google.generativeai` o `@google/generative-ai`, aprovecha la migración: los ejemplos oficiales actuales usan `from google import genai` en Python y `@google/genai` en JavaScript.

Los 429 no son respuestas sin imagen, sino límites de cuota o de tasa; se tratan en [Límites de Nano Banana 2: cuota en Gemini, AI Mode y la API](https://blog.laozhang.ai/es/posts/nano-banana-2-limits-daily-quotas-guide). Para 429, 408 y 5xx, la guía de Google recomienda backoff exponencial con jitter y un tope de reintentos. Los errores 400 y 403 no se reintentan.

## Script para diagnosticar una respuesta guardada

`nb_inspect.py` lee el JSON guardado (de REST o volcado desde el SDK de Python), detecta de qué API es, clasifica el caso con la tabla anterior e imprime la acción. También cuenta imágenes finales y de razonamiento, avisa si el ID está retirado, resume los tokens por modalidad y, para los tres modelos GA, estima el coste con la tarifa Standard publicada. Solo usa la biblioteca estándar de Python 3.9 o posterior.

- Entrada: un archivo JSON; opcionalmente `--model ID` si la respuesta no trae el modelo (en los errores HTTP no viene) y `--save carpeta` para escribir las imágenes finales.
- Salida: líneas `CASO`, `ACCIÓN`, `IMÁGENES`, `TOKENS` y, si procede, `ESTIMACIÓN` y `MODELO RETIRADO`.
- Código de salida: `0` si hay imagen final, `1` si no la hay y `2` si el archivo no es válido. Sirve para cortar un pipeline antes de guardar un resultado vacío.

```python
#!/usr/bin/env python3
"""Diagnostica una respuesta de Nano Banana guardada en JSON (generateContent o Interactions API).

Uso: python3 nb_inspect.py respuesta.json [--model ID] [--save carpeta]
Salida: una línea CASO, la acción, recuento de imágenes y tokens por modalidad.
Código de salida: 0 = hay imagen final, 1 = no hay imagen, 2 = entrada no válida.
Solo biblioteca estándar. Acepta camelCase (REST) y snake_case (volcado de SDK).
"""
import argparse, base64, json, pathlib, sys

RETIRED = {  # ID -> (fecha de retirada, sustituto recomendado)
    "gemini-3.1-flash-image-preview": ("2026-06-25", "gemini-3.1-flash-image"),
    "gemini-3-pro-image-preview": ("2026-06-25", "gemini-3-pro-image"),
    "gemini-2.5-flash-image-preview": ("2026-01-15", "gemini-3.1-flash-lite-image"),
    "gemini-2.0-flash-preview-image-generation": ("2025-11-14", "gemini-3.1-flash-lite-image"),
}
SUNSET = {"gemini-2.5-flash-image": "2026-10-02"}
# Tarifa Standard USD por 1M tokens: (entrada, salida texto+razonamiento, salida imagen)
RATES = {
    "gemini-3.1-flash-image": (0.50, 3.00, 60.00),
    "gemini-3-pro-image": (2.00, 12.00, 120.00),
    "gemini-3.1-flash-lite-image": (0.25, 1.50, 30.00),
}
POLICY = {"IMAGE_SAFETY", "PROHIBITED_CONTENT", "IMAGE_PROHIBITED_CONTENT", "BLOCKLIST", "SPII"}
ACTIONS = {
    "TEXT_ONLY": "Pide la imagen de forma explícita y limita la salida a imagen "
                 "(responseModalities ['IMAGE'] o response_format {'type': 'image'}).",
    "NO_IMAGE": "El modelo debía generar una imagen y no lo hizo: pide la imagen explícitamente, "
                "simplifica el prompt y reintenta una vez.",
    "POLICY": "Bloqueo por políticas: no repitas la misma petición. Cambia el contenido "
              "(p. ej., personas reales, contenido sexual o violento, marcas u obras ajenas); "
              "los ajustes de seguridad no lo desbloquean.",
    "SAFETY": "Lo cortó un filtro ajustable: si configuraste safetySettings, mira safetyRatings y tu umbral "
              "(sin configurar están en OFF en Gemini 2.5 y 3); si no los tocaste, cambia el contenido.",
    "PROMPT_OTHER": "Prompt bloqueado sin categoría (blockReason OTHER): puede infringir los términos o no estar admitido; "
                    "cambia el contenido en vez de reintentar.",
    "RECITATION": "Salida parecida a material existente: haz el prompt más original y quita referencias a obras concretas.",
    "OTHER": "Causa no especificada: reintenta una vez; si se repite, simplifica el prompt o las imágenes de entrada.",
    "MAX_TOKENS": "Se alcanzó el límite de salida: sube o quita maxOutputTokens / max_output_tokens.",
    "MISSING_THOUGHT_SIGNATURE": "En edición multiturno devuelve las thought signatures tal cual (o usa el chat del SDK).",
    "FAILED": "Lee errors[]: si el mensaje habla de contenido o políticas, trátalo como bloqueo; "
              "si es un fallo de plataforma, reintenta con backoff.",
    "NOT_DONE": "La interaction no ha terminado o se canceló: vuelve a consultarla con su id o atiende la acción pendiente.",
    "PUP_LIMITED_DISABLED": "Cuenta limitada por la Política de uso prohibido: no se arregla desde el código.",
}


def g(d, *names, default=None):
    """Lee un campo aceptando camelCase o snake_case."""
    for n in names:
        if isinstance(d, dict) and n in d:
            return d[n]
    return default


def check_model(model):
    notes = []
    m = (model or "").removeprefix("models/")
    if m in RETIRED:
        day, new = RETIRED[m]
        notes.append(f"MODELO RETIRADO: {m} se apagó el {day}; cambia a {new}. Reintentar no sirve.")
    elif m in SUNSET:
        notes.append(f"AVISO: {m} se apaga el {SUNSET[m]}; migra a gemini-3.1-flash-image o gemini-3.1-flash-lite-image.")
    return notes


def estimate(model, tin, ttext, timg):
    r = RATES.get((model or "").removeprefix("models/"))
    if not r:
        return None
    return (tin * r[0] + ttext * r[1] + timg * r[2]) / 1_000_000


def inspect_generate_content(d):
    cands = g(d, "candidates", default=[]) or []
    fb = g(d, "promptFeedback", "prompt_feedback", default={}) or {}
    um = g(d, "usageMetadata", "usage_metadata", default={}) or {}
    img_tok = sum(g(x, "tokenCount", "token_count", default=0) for x in
                  g(um, "candidatesTokensDetails", "candidates_tokens_details", default=[]) or []
                  if str(g(x, "modality")).upper() == "IMAGE")
    cand_tok = g(um, "candidatesTokenCount", "candidates_token_count", default=0) or 0
    usage = {"entrada": g(um, "promptTokenCount", "prompt_token_count", default=0) or 0,
             "razonamiento": g(um, "thoughtsTokenCount", "thoughts_token_count", default=0) or 0,
             "salida_texto": cand_tok - img_tok, "salida_imagen": img_tok}
    finals, thoughts, texts = [], [], []
    if not cands:
        reason = g(fb, "blockReason", "block_reason", default="SIN_CANDIDATOS")
        case = {"OTHER": "PROMPT_OTHER", "SAFETY": "SAFETY", "SIN_CANDIDATOS": "OTHER"}.get(reason, "POLICY")
        return f"PROMPT_BLOQUEADO:{reason}", case, finals, thoughts, texts, usage, None
    c = cands[0]
    for p in g(g(c, "content", default={}), "parts", default=[]) or []:
        blob = g(p, "inlineData", "inline_data")
        if blob and str(g(blob, "mimeType", "mime_type", default="")).startswith("image/"):
            (thoughts if p.get("thought") else finals).append(blob)
        elif p.get("text") and not p.get("thought"):
            texts.append(p["text"])
    fr = str(g(c, "finishReason", "finish_reason", default="SIN_FINISHREASON")).removeprefix("FinishReason.")
    msg = g(c, "finishMessage", "finish_message")
    if finals:
        return f"IMAGEN_OK:{fr}", None, finals, thoughts, texts, usage, msg
    if fr in POLICY:
        case = "POLICY"
    elif fr == "SAFETY":
        case = "SAFETY"
    elif fr in ("RECITATION", "IMAGE_RECITATION"):
        case = "RECITATION"
    elif fr in ("OTHER", "IMAGE_OTHER"):
        case = "OTHER"
    elif fr == "STOP":
        case = "TEXT_ONLY"
    else:
        case = fr if fr in ACTIONS else None
    return f"SIN_IMAGEN:{fr}", case, finals, thoughts, texts, usage, msg


def inspect_interaction(d):
    status = d.get("status")
    finals, thoughts, texts = [], [], []
    steps = d.get("steps")
    if steps is None and isinstance(d.get("outputs"), list):  # esquema antiguo (antes de mayo de 2026)
        steps = [{"type": "model_output", "content": [b for b in d["outputs"] if b.get("type") != "thought"]}]
    for step in steps or []:
        blocks = step.get("content") or [] if step.get("type") == "model_output" else step.get("summary") or []
        for b in blocks:
            if b.get("type") == "image" and b.get("data"):
                (finals if step.get("type") == "model_output" else thoughts).append(b)
            elif b.get("type") == "text" and step.get("type") == "model_output":
                texts.append(b.get("text", ""))
    u = d.get("usage") or {}
    img_tok = sum(x.get("tokens", 0) for x in u.get("output_tokens_by_modality") or [] if x.get("modality") == "image")
    usage = {"entrada": u.get("total_input_tokens", 0), "razonamiento": u.get("total_thought_tokens", 0),
             "salida_texto": u.get("total_output_tokens", 0) - img_tok, "salida_imagen": img_tok}
    msg = "; ".join(f"{e.get('code')}: {e.get('message')}" for e in d.get("errors") or []) or None
    if finals and status == "completed":
        return "IMAGEN_OK:completed", None, finals, thoughts, texts, usage, msg
    case = {"completed": "TEXT_ONLY", "incomplete": "MAX_TOKENS", "failed": "FAILED"}.get(status, "NOT_DONE")
    return f"SIN_IMAGEN:{status}", case, finals, thoughts, texts, usage, msg


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("file")
    ap.add_argument("--model", help="ID usado en la petición (si la respuesta no lo trae)")
    ap.add_argument("--save", help="carpeta donde guardar las imágenes finales")
    a = ap.parse_args()
    try:
        d = json.loads(pathlib.Path(a.file).read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as e:
        print(f"ENTRADA_NO_VALIDA: {e}")
        return 2
    model = a.model or g(d, "modelVersion", "model_version") or d.get("model")
    notes = check_model(model)
    if "error" in d and isinstance(d["error"], dict):  # error HTTP: la petición ni siquiera generó
        code = d["error"].get("code")
        print(f"CASO: ERROR_HTTP:{code}  {d['error'].get('message', '')}")
        if code == 429:
            print("ACCIÓN: cuota o límite de tasa; espera con backoff exponencial o revisa tu nivel.")
        elif isinstance(code, int) and (code >= 500 or code == 408):
            print("ACCIÓN: fallo transitorio; reintenta con backoff exponencial y jitter.")
        else:
            print("ACCIÓN: error de la petición (clave, parámetros o ID de modelo); no reintentes sin cambiarla.")
        for n in notes:
            print(n)
        return 1
    if "candidates" in d or g(d, "promptFeedback", "prompt_feedback") is not None:
        api, res = "generateContent", inspect_generate_content(d)
    elif "status" in d:
        api, res = "Interactions", inspect_interaction(d)
    else:
        print("ENTRADA_NO_VALIDA: no parece generateContent (candidates) ni Interactions (status/steps)")
        return 2
    label, case, finals, thoughts, texts, usage, msg = res
    print(f"API: {api}   MODELO: {model or 'desconocido'}")
    print(f"CASO: {label}")
    if msg:
        print(f"DETALLE: {msg}")
    print(f"IMÁGENES: finales={len(finals)} razonamiento={len(thoughts)}   TEXTO: {len(texts)} bloque(s)")
    if case:
        print(f"ACCIÓN: {ACTIONS[case]}")
    elif not finals:
        print("ACCIÓN: valor no reconocido; guarda el JSON completo y revisa status/errors o finishReason.")
    if thoughts and not finals:
        print("NOTA: solo hay imágenes de razonamiento (thought) y ninguna final; revisa el caso antes de usarlas.")
    if api == "Interactions" and "outputs" in d and "steps" not in d:
        print("NOTA: esquema antiguo 'outputs'; la API actual devuelve 'steps'. Actualiza el SDK y recorre steps.")
    if len(finals) > 1:
        print("NOTA: hay varias imágenes finales; output_image o parts[0] no las recogen todas.")
    for n in notes:
        print(n)
    if not any(usage.values()):
        print("TOKENS: la respuesta no trae campos de uso; consulta el consumo en tu panel de facturación.")
        cost = None
    else:
        print("TOKENS: " + "  ".join(f"{k}={v}" for k, v in usage.items()))
        cost = estimate(model, usage["entrada"], usage["razonamiento"] + usage["salida_texto"], usage["salida_imagen"])
    if cost is not None:
        print(f"ESTIMACIÓN (tarifa Standard publicada, no es la factura): {cost:.6f} USD")
    if a.save and finals:
        out = pathlib.Path(a.save)
        out.mkdir(parents=True, exist_ok=True)
        for i, b in enumerate(finals, 1):
            ext = str(g(b, "mimeType", "mime_type", default="image/png")).split("/")[-1]
            (out / f"imagen_{i}.{ext}").write_bytes(base64.b64decode(b["data"]))
        print(f"GUARDADAS: {len(finals)} en {out}")
    return 0 if finals else 1


if __name__ == "__main__":
    sys.exit(main())
```

Esta es la salida con una respuesta de ejemplo de Nano Banana Pro bloqueada por `IMAGE_SAFETY`:

```text
$ python3 nb_inspect.py gc_image_safety.json
API: generateContent   MODELO: gemini-3-pro-image
CASO: SIN_IMAGEN:IMAGE_SAFETY
DETALLE: (texto de ejemplo)
IMÁGENES: finales=0 razonamiento=0   TEXTO: 0 bloque(s)
ACCIÓN: Bloqueo por políticas: no repitas la misma petición. Cambia el contenido (p. ej., personas reales, contenido sexual o violento, marcas u obras ajenas); los ajustes de seguridad no lo desbloquean.
TOKENS: entrada=20  razonamiento=210  salida_texto=0  salida_imagen=0
ESTIMACIÓN (tarifa Standard publicada, no es la factura): 0.002560 USD
```

Y esta, con una historia ilustrada de la Interactions API con dos imágenes intercaladas y una imagen de razonamiento:

```text
$ python3 nb_inspect.py int_interleaved.json --save out
API: Interactions   MODELO: gemini-3-pro-image
CASO: IMAGEN_OK:completed
IMÁGENES: finales=2 razonamiento=1   TEXTO: 2 bloque(s)
NOTA: hay varias imágenes finales; output_image o parts[0] no las recogen todas.
TOKENS: entrada=20  razonamiento=300  salida_texto=60  salida_imagen=2240
ESTIMACIÓN (tarifa Standard publicada, no es la factura): 0.273160 USD
GUARDADAS: 2 en out
```

El script se probó sin conexión el 24 de septiembre de 2026 con Python 3.12 sobre 25 archivos: 18 JSON de ejemplo escritos a mano (los casos de la tabla, el esquema antiguo `outputs` y dos entradas no válidas) y 7 volcados generados al pasar varios de esos ejemplos por los tipos del SDK `google-genai` en sus versiones 1.65.0 y 2.25.0. Todos dieron el caso y el código de salida esperados. No se ha ejecutado contra llamadas reales a Google, así que los tokens y los textos de error de los ejemplos son inventados. En la Interactions API, la documentación no muestra cómo se representa un bloqueo de imagen: con `status: "failed"`, el script se limita a enseñarte `errors[]` sin inventar campos.

## Cuánto te costó la llamada que no trajo imagen

Google cobra la Gemini API por tokens y por modalidad, y no publica ninguna regla sobre si una respuesta bloqueada se factura entera o sale gratis. La forma de saberlo es leer el consumo de esa llamada: `usageMetadata` en `generateContent` (`promptTokenCount`, `thoughtsTokenCount`, `candidatesTokenCount` y su desglose `candidatesTokensDetails` por modalidad) y `usage` en la Interactions API (`total_input_tokens`, `total_thought_tokens`, `total_output_tokens` y `output_tokens_by_modality`).

Tarifas Standard de pago publicadas a 24 de septiembre de 2026, en USD por millón de tokens. Ninguno de estos modelos tiene nivel gratuito para salida de imagen:

| Modelo | Entrada | Salida de texto y razonamiento | Salida de imagen | Equivalencia por imagen |
|---|---|---|---|---|
| Nano Banana 2 (`gemini-3.1-flash-image`) | USD 0,50 | USD 3 | USD 60 | 1K = 1.120 tokens ≈ USD 0,067; 4K ≈ USD 0,151 |
| Nano Banana Pro (`gemini-3-pro-image`) | USD 2 | USD 12 | USD 120 | 1K/2K ≈ USD 0,134; 4K ≈ USD 0,24 |
| Nano Banana 2 Lite (`gemini-3.1-flash-lite-image`) | USD 0,25 | USD 1,50 | USD 30 | Solo 1K: 1.120 tokens ≈ USD 0,0336 |

Dos detalles de la documentación afectan a las llamadas fallidas: los tokens de razonamiento se facturan los veas o no, y las imágenes provisionales del razonamiento no se cobran. Con eso, la estimación es:

coste ≈ (entrada × tarifa de entrada + (razonamiento + texto) × tarifa de texto + tokens de imagen × tarifa de imagen) ÷ 1.000.000

En el ejemplo inventado de Nano Banana Pro bloqueado por `IMAGE_SAFETY` (20 tokens de entrada, 210 de razonamiento, 0 de imagen), el resultado es (20 × 2 + 210 × 12) ÷ 1.000.000 ≈ USD 0,0026, frente a unos USD 0,134 de una imagen 1K. Si en tu respuesta real el desglose trae tokens `IMAGE` aunque no haya imagen, ese es el dato que conviene llevar al panel de facturación, que es el que manda. Al reintentar a ciegas un bloqueo por políticas, en cambio, cada intento vuelve a consumir entrada y razonamiento para el mismo resultado.

![Desglose del coste de una llamada de Nano Banana Pro bloqueada por IMAGE_SAFETY, unos USD 0,0026, frente a unos USD 0,134 de una imagen 1K](https://blog.laozhang.ai/posts/es/nano-banana-2-200-ok-no-image/img/coste-llamada-bloqueada.webp)

## Preguntas frecuentes

### ¿Por qué la API devuelve 200 si no hay imagen?

Porque la petición se procesó: el servidor recibió el prompt, lo evaluó y contestó. Que no haya imagen es un resultado de la generación y se indica dentro del cuerpo (`finishReason`, `promptFeedback.blockReason` o `status`), no con el código HTTP. Comprueba siempre el cuerpo antes de dar la llamada por buena.

### ¿Poner BLOCK_NONE o OFF en todas las categorías arregla IMAGE_SAFETY?

No. Los umbrales ajustables solo cubren acoso, incitación al odio, contenido sexual explícito y contenido peligroso, y en Gemini 2.5 y 3 ya están en `OFF` si no los tocas. `IMAGE_SAFETY` y `PROHIBITED_CONTENT` no forman parte de esos ajustes: toca cambiar el contenido.

### Uso AI Studio y solo recibo texto, ¿es otro problema?

Son los mismos modelos y los mismos casos. Pide la imagen de forma explícita, revisa en Run settings si has endurecido los ajustes de seguridad y, si necesitas ver `finishReason`, repite la petición por la API y guarda el JSON.

### ¿Me cobran igual una respuesta bloqueada que una imagen?

Google no publica una regla específica para ese caso. Mira `usageMetadata` o `usage` de esa llamada: si el desglose por modalidad no trae tokens de imagen, la parte cara (USD 60 o USD 120 por millón de tokens de imagen) no aparece en el consumo, aunque la entrada y el razonamiento sí cuentan.

## Documentación oficial

Los parámetros de la solicitud, los precios y las reglas de cobro están en la documentación para desarrolladores de LaoZhang API: [facturación y fallos en la generación de imágenes](https://docs.laozhang.ai/en/api-capabilities/image-billing-and-failures).

*Aviso: [laozhang.ai es un servicio propio](https://blog.laozhang.ai/es/about#laozhang). Sus precios son nuestra tarifa, no una comparación independiente.*

## Fuentes

Páginas externas que cita esta guía, en el orden en que aparecen. Última actualización: 2026-09-24.

- [Interactions API](https://ai.google.dev/gemini-api/docs/interactions-overview) (ai.google.dev)
- [referencia de generateContent](https://ai.google.dev/api/generate-content) (ai.google.dev)
- [el modelo devuelve por defecto texto e imagen](https://ai.google.dev/gemini-api/docs/image-generation) (ai.google.dev)
- [guía de ajustes de seguridad](https://ai.google.dev/gemini-api/docs/safety-settings) (ai.google.dev)
- [guía de solución de problemas](https://ai.google.dev/gemini-api/docs/troubleshooting) (ai.google.dev)
- [registro de cambios](https://ai.google.dev/gemini-api/docs/changelog) (ai.google.dev)
- [página de retiradas de Google](https://ai.google.dev/gemini-api/docs/deprecations) (ai.google.dev)
- [página de precios](https://ai.google.dev/gemini-api/docs/pricing) (ai.google.dev)
