Как проверить ответ Nano Banana 2.1, если изображение не появилось

Nano Banana 2.1 не вернула изображение: проверьте части ответа, причины завершения, HTTP-ошибки и тайм-аут до повтора. Код локальной диагностики.

Чёрный рисунок: шестерёнка на светлой карточке, геометрический декор и заголовок Nano Banana 2.1.

Успешный HTTP-запрос — ещё не гарантия, что в ответе есть изображение. При генерации изображений Gemini оно передаётся в candidate внутри content.parts[].inlineData (или inline_data) вместе с возможными текстовыми частями. Если приложение проверяет только response.text, оно может сообщить «изображения нет», даже когда ответ его содержит.

Это руководство посвящено идентификатору модели Ofox google/gemini-nano-banana-2.1. Ofox публикует эту статью и управляет шлюзом, через который проводился описанный ниже тест. В инструкциях отдельно указано, что именно вернул шлюз в этом тесте, а что Google описывает в своей документации; мы не предполагаем, что у всех неудачных запросов одна и та же причина.

Для начала проверьте, есть ли изображение в ответе

В запросе к нативному Gemini проверяйте части каждого candidate и ищите объект inlineData с MIME-типом изображения и непустым полем data. Пустое или отсутствующее текстовое поле не доказывает, что генерация не удалась. Прежде чем решать, что делать дальше, также сохраните HTTP-статус, finishReason кандидата и promptFeedback.

Ниже приведён редактированный структурный фрагмент, а не копия байтов изображения, из успешного теста шлюза Ofox от 13 октября 2026 года:

{
  "candidates": [
    {
      "content": {
        "parts": [
          {
            "inlineData": {
              "mimeType": "image/png",
              "data": "[base64 image bytes omitted]"
            }
          }
        ]
      },
      "finishReason": "STOP"
    }
  ]
}

В тесте запрос вернул HTTP 200 и PNG размером 1024 × 1024. В этом фрагменте опущены другие данные ответа; согласно отчёту теста, использование составило 54 входных токена, 80 учтённых токенов текстового вывода, 1 120 токенов вывода изображения и 639 токенов рассуждений. Эти значения относятся к данному запросу и не задают обязательную структуру ответа или фиксированную стоимость. В документации Google по генерации изображений описаны части ответа с изображениями, а в документации модели — её возможности.

Отправьте один запрос, ответ на который можно проверить

Задайте переменную OFOX_API_KEY в окружении, затем отправьте точный запрос для постера, использованный в тесте. Важен идентификатор модели: google/gemini-nano-banana-2.1 — это не старая google/gemini-3.1-flash-image и не вариант «Lite».

cat > request.json <<'JSON'
{
  "contents": [
    {
      "role": "user",
      "parts": [
        {
          "text": "Create a square minimalist event poster on warm cream paper. Large exact headline: ORCHARD MARKET. Smaller exact line: SATURDAY 10 AM. Draw three red apples in a blue ceramic bowl below the text. No other text, no logos, no border."
        }
      ]
    }
  ],
  "generationConfig": {
    "responseModalities": ["TEXT", "IMAGE"],
    "imageConfig": {
      "aspectRatio": "1:1",
      "imageSize": "1K"
    }
  }
}
JSON

curl --silent --show-error \
  --output response.json \
  --write-out 'HTTP %{http_code}\n' \
  -H "x-goog-api-key: ${OFOX_API_KEY:?Set OFOX_API_KEY first}" \
  -H 'Content-Type: application/json' \
  --data-binary @request.json \
  'https://api.ofox.ai/gemini/v1beta/models/google/gemini-nano-banana-2.1:generateContent'

Код HTTP-статуса, который выводит curl, важен: сохраните его вместе с ответом и не считайте, что наличие файла response.json означает успешный вызов. Не помещайте файлы запросов и ответов с конфиденциальными промптами или данными изображений в открытые журналы и репозитории. Настройку эндпоинта за пределами этого примера описывает руководство по API Nano Banana 2.1.

Прежде чем менять промпт, запустите локальную диагностику

Сохраните следующий код в файл diagnose_gemini_image.py. Он читает сохранённый ответ в формате JSON и не вызывает API. Передайте HTTP-статус, записанный вашим клиентом, с помощью --http-status. Если клиент завершился по таймауту и пригодного ответа нет, используйте --timeout. Вывод намеренно ограничен диагностическими полями: в нём не появляются ключ, исходное сообщение об ошибке, промпт или данные изображения в формате base64.

#!/usr/bin/env python3
import argparse
import json
import sys
from pathlib import Path

def field(obj, camel, snake):
    if not isinstance(obj, dict):
        return None
    return obj.get(camel, obj.get(snake))

def safe_feedback(obj):
    if not isinstance(obj, dict):
        return None
    ratings = field(obj, "safetyRatings", "safety_ratings")
    result = {"blockReason": field(obj, "blockReason", "block_reason")}
    if isinstance(ratings, list):
        result["safetyRatings"] = [
            {
                "category": r.get("category"),
                "probability": r.get("probability"),
            }
            for r in ratings if isinstance(r, dict)
        ]
    return result

def diagnose(body, http_status=None, timed_out=False):
    # Accept either a native response or {"body": <native response>}.
    if isinstance(body, dict) and isinstance(body.get("body"), dict):
        if http_status is None:
            http_status = body.get("status_code", body.get("httpStatus"))
        body = body["body"]

    result = {
        "status": "no_image",
        "httpStatus": http_status,
        "imagesCount": 0,
        "finishReasons": [],
        "promptFeedback": safe_feedback(
            field(body, "promptFeedback", "prompt_feedback")
        ),
    }

    if timed_out:
        result["status"] = "transport_uncertain"
        return result

    if http_status is not None and not 200 <= http_status < 300:
        result["status"] = (
            "http_error_check_retryability"
            if http_status == 429 or 500 <= http_status <= 599
            else "http_error"
        )
        return result

    candidates = body.get("candidates", []) if isinstance(body, dict) else []
    if not isinstance(candidates, list):
        candidates = []

    for candidate in candidates:
        if not isinstance(candidate, dict):
            continue
        reason = field(candidate, "finishReason", "finish_reason")
        if reason is not None:
            result["finishReasons"].append(reason)
        content = candidate.get("content")
        parts = content.get("parts", []) if isinstance(content, dict) else []
        if not isinstance(parts, list):
            continue
        for part in parts:
            inline = field(part, "inlineData", "inline_data")
            if not isinstance(inline, dict):
                continue
            mime = field(inline, "mimeType", "mime_type")
            if (isinstance(mime, str) and mime.startswith("image/")
                    and isinstance(inline.get("data"), str)
                    and inline["data"]):
                result["imagesCount"] += 1

    if result["imagesCount"]:
        result["status"] = "image_present"
    elif (result["promptFeedback"] or {}).get("blockReason"):
        result["status"] = "blocked"
    elif any(
        r in {"SAFETY", "PROHIBITED_CONTENT", "SPII", "RECITATION"}
        for r in result["finishReasons"]
    ):
        result["status"] = "blocked"
    elif "MAX_TOKENS" in result["finishReasons"]:
        result["status"] = "max_tokens_no_image"
    return result

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("response_json", type=Path, nargs="?")
    parser.add_argument("--http-status", type=int)
    parser.add_argument("--timeout", action="store_true")
    args = parser.parse_args()
    if args.timeout:
        print(json.dumps(diagnose({}, args.http_status, timed_out=True)))
        return 0
    if args.response_json is None:
        parser.error("Provide response_json unless using --timeout")
    try:
        body = json.loads(args.response_json.read_text(encoding="utf-8"))
    except (OSError, UnicodeError, json.JSONDecodeError):
        print("Cannot read a valid JSON response", file=sys.stderr)
        return 2
    print(json.dumps(
        diagnose(body, args.http_status, args.timeout),
        separators=(",", ":")
    ))
    return 0

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

Например, команда python3 diagnose_gemini_image.py response.json --http-status 200 сообщит, нашлись ли части с изображениями. imagesCount подсчитывает объявленные непустые части изображения; она не декодирует данные и не подтверждает, что файл изображения исправен. Если извлечение не удаётся, сохраните исходный ответ для более подробной локальной проверки.

Ветвления диагностики можно проверить без расхода средств на API. Это синтетические тестовые данные, а не имитация тестов модели:

printf '%s\n' \
  '{"candidates":[{"content":{"parts":[{"inline_data":{"mime_type":"image/png","data":"aGVsbG8="}}]},"finish_reason":"STOP"}]}' \
  > fixture-image.json
python3 diagnose_gemini_image.py fixture-image.json --http-status 200
# {"status":"image_present","httpStatus":200,"imagesCount":1,"finishReasons":["STOP"],"promptFeedback":null}

printf '%s\n' \
  '{"candidates":[{"finishReason":"MAX_TOKENS","content":{"parts":[]}}]}' \
  > fixture-limit.json
python3 diagnose_gemini_image.py fixture-limit.json --http-status 200
# {"status":"max_tokens_no_image","httpStatus":200,"imagesCount":0,"finishReasons":["MAX_TOKENS"],"promptFeedback":null}

Значение aGVsbG8= в тестовых данных — это просто непустой текст в формате base64, а не PNG. Это полезное различие: сначала нужно обнаружить часть с изображением, затем декодировать и проверить файл.

Если диагностика обнаружила изображение, извлеките его, а не запускайте генерацию повторно. Этот небольшой локальный скрипт сохраняет все части изображения и не выводит их содержимое:

import base64
import json
from pathlib import Path

response = json.loads(Path("response.json").read_text(encoding="utf-8"))
saved = 0

for candidate in response.get("candidates", []):
    for part in candidate.get("content", {}).get("parts", []):
        inline = part.get("inlineData", part.get("inline_data", {}))
        mime = inline.get("mimeType", inline.get("mime_type", ""))
        data = inline.get("data")
        if not (mime.startswith("image/") and data):
            continue
        extension = {"image/png": ".png", "image/jpeg": ".jpg"}.get(mime)
        if extension is None:
            raise ValueError(f"Inspect unsupported image MIME type: {mime}")
        image = base64.b64decode(data, validate=True)
        saved += 1
        Path(f"image-{saved}{extension}").write_bytes(image)

print(f"Saved {saved} image(s)")

В этом примере явно поддерживаются PNG и JPEG: расширение файла не угадывается для каждого MIME-типа. Если декодирование не удалось, сохраните HTTP-статус и метаданные ответа, но не вставляйте base64 в запрос в службу поддержки или открытое сообщение об ошибке.

Как разбираться с ответом без изображения

Дальнейшее действие зависит от того, на каком этапе произошёл сбой. В руководстве Google по устранению неполадок API приведены общие рекомендации по ошибкам; в таблице ниже они применены как практическая последовательность проверки этого вызова через шлюз. Здесь не предполагается, что любую ошибку можно исправить повторной отправкой запроса.

НаблюдениеЧто проверить в первую очередьЧто делать
400JSON запроса, responseModalities, imageConfig и названия полейИсправьте неверный параметр или содержимое запроса. Повторять тот же запрос без изменений, скорее всего, бесполезно.
401Передан ли ключ шлюза в x-goog-api-key и действителен ли онИсправьте аутентификацию, не раскрывая ключ в журналах.
403Права аккаунта или доступ к модели, а также классификацию ошибки, если она возвращенаРазберитесь с ограничениями доступа или политиками; не называйте такую ошибку временным сбоем генерации изображения.
404Точный эндпоинт и идентификатор моделиПроверьте, что в пути указан google/gemini-nano-banana-2.1. Старое сообщение об ошибке 404 для другого идентификатора или на другую дату не подтверждает текущую доступность.
429Информацию в ошибке об ограничении частоты запросов, кредитах или квоте, а также состояние аккаунтаДелайте паузу и повторяйте запрос только в случае временного ограничения частоты запросов, допускающего повтор. Дополнительные попытки не помогут, если кредит исчерпан или требуется действие для снятия лимита аккаунта.
5xxСтатус шлюза или сервера и наличие записи о запросе либо результатаМожно попробовать ограниченное число повторов, но сначала проверьте, не был ли запрос уже обработан.
Таймаут клиента или потеря соединенияЛокальный идентификатор запроса, запись о запросе на шлюзе, если она доступна, и сохранённый ответРезультат неизвестен. Не считайте, что «изображение не сгенерировано» или «счёт не выставлен» только потому, что клиент не получил ответ.

Если получен ответ 200 с STOP, но без части с изображением, проверьте всех кандидатов и все части, запрошенные модальности и сохранённый JSON. Ответ 200 с MAX_TOKENS и без изображения указывает на другое: вывод мог завершиться до того, как изображение было передано. В сообщении пользователя на форуме разработчиков Google описан такой симптом; это неподтверждённое сообщение пользователя, а не сбой, который мы воспроизвели в шести успешных запросах, описанных здесь. Указанное число токенов ниже настроенного лимита само по себе не доказывает наличие ошибки модели; прежде чем делать такой вывод, нужно проверить лимиты, учёт данных ответа и значение конкретного счётчика.

Если в promptFeedback указана причина блокировки или кандидат завершился по причине, связанной с безопасностью, считайте это результатом применения политики. Проверьте, соответствует ли запрос применимым условиям Gemini API; не пытайтесь обойти ограничение повторными запросами или переформулировкой промпта. Сведения о настройке приведены в руководстве по API.

Повторяйте запросы только при необходимости и сохраняйте контекст для продолжения

Не запускайте автоматическую повторную отправку при каждом ответе без изображения. Сначала устраните причины ошибок 400, 401, 403 и 404. Если установлено, что ошибка 429 или 5xx допускает повтор, сделайте небольшое ограниченное число попыток с увеличивающимися интервалами и случайным разбросом; учитывайте переданные сервером рекомендации по повторам, если ваш клиент их поддерживает. После таймаута, по возможности, проверьте запись о запросе, прежде чем отправлять его снова: потеря ответа оставляет неопределёнными и результат генерации, и статус оплаты. В руководстве по ценам объясняется, почему дополнительное сгенерированное изображение не становится бесплатным лишь оттого, что первое вам не подошло.

При редактировании «повтор» и «следующий ход» — разные операции. Для следующего хода нужно передать содержимое предыдущего диалога, включая возвращённые моделью части ответа. Не собирайте предыдущее сообщение модели заново только по видимому тексту: так можно потерять изображение и подписи рассуждений. В протестированной цепочке из трёх шагов перед каждым новым сообщением пользователя без изменений передавалось предыдущее candidates[0].content. Следующие промпты были такими:

Change only the headline from ORCHARD MARKET to ORCHARD FAIR. Keep SATURDAY 10 AM exactly unchanged. Preserve the three red apples, blue bowl, cream background, composition and all other pixels as closely as possible.

Now change only the bowl from blue to dark green. Preserve ORCHARD FAIR, SATURDAY 10 AM, the three red apples and the layout.

В Python ключевая операция с историей выглядит так:

# previous_response is the complete parsed response from the preceding turn.
model_content = previous_response["candidates"][0]["content"]
contents.append(model_content)  # Keep its parts and signatures intact.
contents.append({
    "role": "user",
    "parts": [{"text": next_prompt}]
})

Сохраняйте обновлённый ответ, прежде чем добавлять следующий ход. Если предыдущий ответ был заблокирован, неполон или не содержит данных, необходимых для редактирования, не представляйте его как полноценное продолжение. Ожидания от редактирования, включая то, что именно гарантирует и чего не гарантирует слово «сохранить», описаны в инструкции по редактированию постера и более раннем руководстве по сохранению товара на изображении. У рабочих процессов с несколькими референсами есть свои компромиссы, описанные в материале один или два референса товара.

Что показывает успешный тест — и чего он не доказывает

13 октября 2026 года нативный маршрут Gemini через Ofox вернул HTTP 200, STOP и PNG размером 1024 × 1024 для каждого из шести фактических запросов: постера, редактирования заголовка и следующего хода с изменением цвета миски на Nano Banana 2.1, а также тех же трёх промптов на более старой Nano Banana 2. В результатах 2.1 запрошенные английские надписи и объекты визуально сохранялись при редактировании. Это полезные примеры успешных ответов, а не воспроизведение сбоя, показатель надёжности или оценка качества для производственного использования: повторных испытаний не было, а модели могут использовать разные настройки по умолчанию. Ограниченное сравнение приведено в статье Nano Banana 2.1 и Nano Banana 2.

На публичной странице модели Ofox Nano Banana 2.1 модель отображалась на момент проверки. Публичная страница служит подтверждением доступности, но не доказывает, что Playground, требующий входа в аккаунт, сгенерировал результат; выбор модели в авторизованном Agent не тестировался. И наоборот, историческая ошибка 404 для маршрута 2.1 до появления модели не опровергает полученные ответы 2.1. Проверяйте текущий идентификатор модели и доступ именно для вашего аккаунта, а не подменяйте одно наблюдение другим.

При отладке важно учитывать и стоимость: в протестированном постере 2.1 с разрешением 1K было указано 1 120 токенов вывода изображения. При наблюдавшемся тарифе каталога $30 за миллион токенов вывода изображения эта составляющая изображения составила бы $0,0336, но это не подтверждённая сумма в счёте; к ней могут добавиться расходы на входные данные, текст, рассуждения и другие применимые начисления. Ни таймаут, ни ответ, который приложение не смогло обработать, сами по себе не доказывают, что оплачиваемой работы не было. Сначала сохраните статус и диагностические поля, а затем решайте, оправдан ли новый запрос.