Nano Banana 2.1が画像を返さないときは、再試行する前にレスポンスを確認する
Nano Banana 2.1の応答に画像がないときの調査手順。画像パート、終了理由、HTTPエラー、タイムアウトをオフライン診断コードで切り分けます。
HTTPリクエストが成功しても、画像を含むレスポンスが返ったとは限りません。Geminiの画像生成では、画像は候補のcontent.parts[].inlineData(またはinline_data)に格納され、テキストパートと一緒に返されることがあります。response.textだけを読み取るアプリケーションでは、実際にはレスポンスに画像が含まれているのに「画像なし」と報告される可能性があります。
この記事では、OfoxのモデルID google/gemini-nano-banana-2.1を扱います。この記事を公開しているOfoxは、以下のテストで使ったゲートウェイも運用しています。ここでは、そのテストでゲートウェイが返した内容と、Googleが一般に説明している内容を区別します。リクエストが失敗する原因はすべて同じだとは限りません。
まず、レスポンスに画像が含まれているか確認する
Geminiネイティブのリクエストでは、各候補のパートを調べ、画像MIMEタイプと空でないdataを持つinlineDataオブジェクトを探します。テキストフィールドが空、または存在しないことを、生成失敗の証拠として扱わないでください。次の対応を決める前に、HTTPステータス、候補のfinishReason、promptFeedbackも記録します。
以下は、2026年10月13日にOfoxゲートウェイで成功したテストから抜粋した構造を示す伏字入りの例です。画像データそのもののコピーではありません。
{
"candidates": [
{
"content": {
"parts": [
{
"inlineData": {
"mimeType": "image/png",
"data": "[base64 image bytes omitted]"
}
}
]
},
"finishReason": "STOP"
}
]
}
テストのリクエストはHTTP 200を返し、1024 × 1024のPNG画像が含まれていました。この抜粋にはレスポンスの他の情報は含まれていません。テストで報告された使用量は、入力54トークン、報告されたテキスト出力80トークン、画像出力1,120トークン、推論トークン639でした。これらの数値はこのリクエストについてのものであり、必須のレスポンス形式や固定料金を示すものではありません。Googleの画像生成ドキュメントでは画像を含むパートについて、モデルのドキュメントではモデルの機能について説明されています。
確認できるリクエストを1回送る
環境変数にOFOX_API_KEYを設定し、テストで使った正確なポスター用プロンプトを送信します。モデルIDにも注意してください。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'
curlが出力するHTTPコードは重要です。response.jsonが存在するからといってリクエストが成功したと決めつけず、レスポンスと一緒に保存してください。機密性のあるプロンプトや画像データを含むリクエスト・レスポンスのファイルは、公開ログやリポジトリに置かないでください。この例以外のエンドポイント設定については、Nano Banana 2.1 APIガイドを参照してください。
プロンプトを変える前に、オフラインで診断する
以下を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トラブルシューティングガイドでは一般的なエラー対応が説明されています。以下の表では、それをこのゲートウェイ呼び出しの実用的な切り分け手順としてまとめています。すべてのエラーが再試行可能だと決めつけてはいません。
| 状況 | 最初に確認する項目 | 適切な対応 |
|---|---|---|
| 400 | リクエストJSON、responseModalities、imageConfig、フィールド名 | 無効なパラメーターやペイロードを修正します。同じリクエストをそのまま繰り返しても、解決する可能性は低いでしょう。 |
| 401 | ゲートウェイのキーがx-goog-api-keyに渡されているか、有効か | ログにキーを露出させず、認証を修正します。 |
| 403 | アカウントまたはモデルの権限、返されたエラー分類 | アクセス制限やポリシー上の制限を解決します。一時的な画像生成エラーとして扱い直さないでください。 |
| 404 | 正確なエンドポイントとモデルID | パスにgoogle/gemini-nano-banana-2.1が使われているか確認します。別のIDや日付に関する過去の404報告だけでは、現在の提供状況は判断できません。 |
| 429 | エラーとアカウント状態に示されたレート制限、クレジット、クォータの情報 | 再試行可能なレート制限の場合に限り、間隔を空けます。クレジットの枯渇や対応が必要なアカウント制限は、再試行を増やしても解消しません。 |
| 5xx | ゲートウェイ/サーバーの状態、リクエスト記録や結果が存在するか | 上限を設けた再試行が妥当な場合もありますが、まずリクエストがすでに処理された可能性を考慮してください。 |
| クライアントのタイムアウトまたは接続切断 | ローカルのリクエストID、利用できる場合はゲートウェイのリクエスト記録、保存済みレスポンス | 結果は不確かです。クライアントが受け取れなかったというだけで、「画像は生成されなかった」「課金されていない」と決めつけないでください。 |
画像パートがないままSTOPを伴う200が返った場合は、すべての候補とパート、要求したモダリティ、保存済みJSONを確認します。画像がないMAX_TOKENSを伴う200は別の手がかりです。画像が返される前に出力が終了した可能性があります。Googleの開発者フォーラムへのユーザー報告では、この症状が説明されています。ただし、これは未検証のユーザー報告であり、ここで成功した6件のリクエストで再現した失敗ではありません。報告されたトークン数が設定上限を下回っているというだけでは、モデルのバグを証明できません。結論を出す前に、上限、レスポンスでの使用量の計上方法、該当するカウンターの意味を確認してください。
promptFeedbackにブロック理由が含まれている場合や、候補が安全性に関する理由で終了した場合は、ポリシー上の判定として扱います。リクエストが適用されるGemini API利用規約に照らして適切か確認し、制限を回避する目的で再試行したり、プロンプトを言い換えたりしないでください。設定の詳細はAPIガイドを参照してください。
再試行は範囲を絞り、後続ターンには前の内容を引き継ぐ
画像を含まない結果をすべて自動再試行ループに入れないでください。まず400、401、403、404の原因を修正します。再試行可能と判断できた429または5xxの場合は、回数を少なく制限し、間隔を徐々に延ばしてランダムな揺らぎを加え、クライアントが対応していればサーバー指定の再試行ガイダンスに従います。タイムアウト後は、可能であれば再送信する前にリクエスト記録を調べてください。レスポンスが失われた場合、生成と課金の状況はどちらも不確かです。料金ガイドでは、最初の画像が不要だったからといって、追加で生成した画像が無料になるわけではない理由を説明しています。
編集では、「再試行」と「後続ターン」も別の操作です。後続ターンには、モデルが返したコンテンツパートを含め、前の会話内容を渡す必要があります。前のモデルメッセージを画面で見えるテキストだけから作り直さないでください。画像や思考署名が失われる可能性があります。テストした3段階のやり取りでは、新しいユーザーターンの前に、直前の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}]
})
次のターンを追加する前に、更新後のレスポンスを保存してください。前のレスポンスがブロックされている、不完全である、または編集に必要な内容が欠けている場合、同等の続きとして扱ってはいけません。編集時の「保持」が保証することと保証しないことについては、ポスター編集の手順と、既存の商品画像を保持するためのガイドを参照してください。複数の参照画像を使う方法には、商品参照画像を1枚にする場合と2枚にする場合で扱う別のトレードオフがあります。
成功したテストで分かること、分からないこと
2026年10月13日、Ofox経由のGeminiネイティブルートは、実際に送信した6件すべてでHTTP 200、STOP、1024 × 1024のPNGを返しました。6件の内訳は、Nano Banana 2.1でのポスター生成、見出しの編集、ボウルの色を変える後続ターンと、旧モデルNano Banana 2での同じ3つのプロンプトです。2.1の出力では、編集を通じて指定した英語の文言とオブジェクトが視覚的に維持されていました。これらは成功レスポンスの例として参考になりますが、失敗の再現でも、成功率でも、本番品質の順位付けでもありません。繰り返し試験は行っておらず、モデルごとにデフォルト設定が異なる可能性があります。この限定的な比較については、2.1と2の比較を参照してください。
公開されているOfox Nano Banana 2.1モデルページは、確認時点でモデルを掲載していました。公開インターフェースがあることは利用可能性を示す情報ですが、ログインが必要なPlaygroundで結果が生成された証拠ではありません。また、認証済みのAgentでモデル選択を試したわけでもありません。一方、2.1が利用可能になる前に2.1ルートで発生した過去の404は、観測された2.1のレスポンスを否定するものではありません。片方の観測をもう片方の代わりに使うのではなく、現在のモデルIDと自分のアクセス権を確認してください。
デバッグ時の最後の注意として、費用にも気をつけてください。テストした1Kの2.1ポスターでは、画像出力が1,120トークンと報告されました。観測されたカタログ料金である画像出力100万トークンあたり$30を使って計算すると、画像部分の料金は$0.0336になります。ただし、これは請求書で確認された合計額ではありません。入力、テキスト、思考、その他該当する料金が加算される場合があります。タイムアウトした呼び出しでも、アプリケーションが解析できなかったレスポンスでも、課金対象の処理がゼロだったとは証明できません。まずステータスと診断フィールドを保存し、そのうえで新しいリクエストが必要か判断してください。


