Nano Banana Pro and 2 API: Choose a Tokenhot Route and Save Images in Python
This tutorial uses Tokenhot's API gateway routes for Nano Banana Pro and Nano Banana 2. The documented route IDs are nano-banana-pro and nano-banana-2; both use a JSON generateContent request and return an image URL in the documentation's success example. The Python script below saves the API response first, then downloads the image.
These are Tokenhot route names, not direct Google Gemini API IDs. Google's current Gemini 3 Pro Image model page uses gemini-3-pro-image for Nano Banana Pro; its Gemini 3.1 Flash Image model page uses gemini-3.1-flash-image for Nano Banana 2. Use the exact ID for the API you are calling; the names do not establish that Tokenhot's discounted trial routes have identical model behavior, features, pricing, or service conditions. See Tokenhot's Nano Banana Pro route docs and Nano Banana 2 route docs.
Pick the route by name, then check the trial condition
| Tokenhot API route | Tokenhot model ID for the URL | Google's current native model ID | What the source supports |
|---|---|---|---|
| Nano Banana Pro | nano-banana-pro |
gemini-3-pro-image |
Google's page describes Pro for complex visual work. Tokenhot separately labels its nano-banana-pro route a discounted trial route. |
| Nano Banana 2 | nano-banana-2 |
gemini-3.1-flash-image |
Google describes 2 as a more general high-volume model. Tokenhot separately labels its nano-banana-2 route a discounted trial route. |
The native descriptions in the last two columns come from Google's own model documentation. They are a guide to Google's product naming only; they are not a comparative test or feature guarantee for Tokenhot's alternate route IDs. Tokenhot's current Google model list says both Nano Banana trial routes may take longer and have “roughly a 10% chance of failing.” That is Tokenhot's listing copy, not a failure-rate measurement from this article or an assurance about refunds or charges. If queue delays or occasional failed requests are unacceptable, check the current Tokenhot model list and its regular-route options before choosing.
Tokenhot's route docs use the exact URL pattern below, with the model ID embedded in the path:
- Pro:
https://api.tokenhot.ai/v1beta/models/nano-banana-pro:generateContent - 2:
https://api.tokenhot.ai/v1beta/models/nano-banana-2:generateContent
Each page shows Bearer Token authentication and a required JSON body. This route-specific /v1beta/ endpoint is the one documented for Nano Banana; do not replace it with Google's endpoint or assume the general Tokenhot Quick Start base path is interchangeable.
Set up Python and your API key
Download nano_banana_api.py.
The example uses only Python's standard library, so it needs no package installation. Save the script as nano_banana_api.py.
Set your Tokenhot API key in the environment before running it. The program reads only the TOKENHOT_API_KEY environment variable; it does not read a .env file or print the key.
On macOS or Linux:
export TOKENHOT_API_KEY="your_tokenhot_key"
In PowerShell:
$env:TOKENHOT_API_KEY = "your_tokenhot_key"
Use an existing key or follow Tokenhot's Quick Start and console token page for the current account setup steps. The links are setup entry points; they do not guarantee that an account has access to either route.
Generate and save one image
The request body below follows the two Tokenhot route examples: a user text part, responseModalities, and imageConfig with aspectRatio and imageSize. The sample uses 2K with an uppercase K, as shown on those route pages.
The script makes one POST attempt only. Before sending it, the program refuses to use an image path or response path that already exists, so a new run cannot replace an older image or recovery record. It stores the raw response before attempting the image download and never blindly retries a generation request.
"""Generate and download one image through a documented Tokenhot Nano Banana route.
Each new generation needs unused output paths. The raw response is persisted
before any image GET; repeated POSTs are never triggered automatically.
"""
from __future__ import annotations
import argparse
import http.client
import json
import os
import socket
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
API_BASE = "https://api.tokenhot.ai/v1beta/models"
MODEL_IDS = ("nano-banana-pro", "nano-banana-2")
MIME_EXTENSIONS = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
NETWORK_READ_ERRORS = (
TimeoutError,
socket.timeout,
urllib.error.URLError,
OSError,
http.client.HTTPException,
)
class StageFailure(Exception):
"""A sanitized, reader-facing network or file stage failure."""
def save_new_bytes(path: Path, data: bytes) -> None:
"""Create a new artifact without replacing an existing file."""
created = False
try:
with path.open("xb") as file:
created = True
file.write(data)
except OSError as error:
if created:
try:
path.unlink(missing_ok=True)
except OSError:
pass
raise StageFailure("could not save a new output artifact") from error
def response_path_for(output_path: Path) -> Path:
return output_path.with_name(output_path.name + ".response.json")
def require_fresh_generation_paths(output_path: Path, response_path: Path) -> None:
occupied = [path for path in (output_path, response_path) if path.exists()]
if occupied:
targets = ", ".join(str(path) for path in occupied)
recovery_tip = (
"Choose a fresh --output path for a new generation, or use --resume-response "
"for GET-only recovery if that saved response is the one you need."
if response_path in occupied
else "No saved response exists at the derived path; choose a fresh --output path."
)
raise StageFailure(
f"refusing a new POST because this output path already exists: {targets}. "
+ recovery_tip
)
def response_image_url(document: object) -> str:
"""Find the HTTPS URL in the Tokenhot docs' documented text response."""
if not isinstance(document, dict):
raise ValueError("response JSON is not an object")
candidates = document.get("candidates")
if not isinstance(candidates, list):
raise ValueError("response has no candidates array")
for candidate in candidates:
if not isinstance(candidate, dict):
continue
content = candidate.get("content")
if not isinstance(content, dict):
continue
parts = content.get("parts")
if not isinstance(parts, list):
continue
for part in parts:
if not isinstance(part, dict):
continue
text = part.get("text")
if isinstance(text, str) and text.startswith("https://"):
parsed = urllib.parse.urlsplit(text)
if parsed.scheme == "https" and parsed.netloc:
return text
raise ValueError("no documented HTTPS image URL found in candidate text parts")
def post_once(
url: str, token: str, payload: dict[str, Any], response_path: Path
) -> bytes:
"""Make exactly one POST and save its response body without echoing it."""
request = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
method="POST",
)
try:
response_context = urllib.request.urlopen(request, timeout=180)
except urllib.error.HTTPError as error:
status = error.code
try:
raw = error.read()
except NETWORK_READ_ERRORS as read_error:
partial = getattr(read_error, "partial", b"")
saved = False
if isinstance(partial, bytes):
try:
save_new_bytes(response_path, partial)
saved = True
except StageFailure:
pass
artifact_note = (
f" A partial response body is saved at {response_path}; inspect it "
"locally and redact it before sharing."
if saved
else " No response body artifact was saved."
)
raise StageFailure(
f"POST received HTTP {status}, but reading the error body failed. "
"The request outcome and charge are unknown; do not blindly retry."
+ artifact_note
) from read_error
try:
save_new_bytes(response_path, raw)
except StageFailure as save_error:
raise StageFailure(
f"POST received HTTP {status}; its error body could not be saved. "
"The request outcome and charge are unknown; do not blindly retry."
) from save_error
raise StageFailure(
f"POST received HTTP {status}. Its response body is saved at "
f"{response_path}; inspect it locally and redact it before sharing. "
"The request outcome and charge are unknown; do not blindly retry."
) from error
except NETWORK_READ_ERRORS as error:
raise StageFailure(
"POST transport failed before a usable HTTP response body was received. "
"The request outcome and charge are unknown; no response artifact was saved "
"and no automatic retry was attempted."
) from error
status = None
try:
with response_context as response:
status = getattr(response, "status", None)
raw = response.read()
except NETWORK_READ_ERRORS as error:
partial = getattr(error, "partial", b"")
saved = False
if isinstance(partial, bytes) and partial:
try:
save_new_bytes(response_path, partial)
saved = True
except StageFailure:
pass
status_text = f"HTTP {status}" if status is not None else "an HTTP response"
artifact_note = (
f" A partial body is saved at {response_path}; inspect it locally and "
"redact it before sharing."
if saved
else " No response body artifact was saved."
)
raise StageFailure(
f"POST received {status_text}, but reading its response body failed. "
"The generation and charge outcome are unknown; do not blindly retry."
+ artifact_note
) from error
try:
save_new_bytes(response_path, raw)
except StageFailure as error:
status_text = f"HTTP {status}" if status is not None else "an HTTP response"
raise StageFailure(
f"POST received {status_text}, but the response body could not be saved. "
"The generation and charge outcome are unknown; no image download was attempted."
) from error
return raw
def download_image(image_url: str, output_path: Path) -> None:
parsed = urllib.parse.urlsplit(image_url)
if parsed.scheme != "https" or not parsed.netloc:
raise StageFailure("refusing a malformed or non-HTTPS image URL")
# The image GET deliberately carries no Tokenhot bearer token.
request = urllib.request.Request(image_url, method="GET")
try:
response_context = urllib.request.urlopen(request, timeout=60)
except urllib.error.HTTPError as error:
raise StageFailure(
f"image GET received HTTP {error.code}; no image file was saved"
) from error
except NETWORK_READ_ERRORS as error:
raise StageFailure(
"image GET transport failed before a usable response; no image file was saved"
) from error
try:
with response_context as response:
image_bytes = response.read()
mime_type = response.headers.get_content_type().lower()
except NETWORK_READ_ERRORS as error:
raise StageFailure(
"image GET response body could not be read; no image file was saved"
) from error
extension = MIME_EXTENSIONS.get(mime_type)
if extension is None:
raise StageFailure(
f"image GET returned unsupported content type {mime_type}; no image file was saved"
)
allowed_suffixes = {extension}
if extension == ".jpg":
allowed_suffixes.add(".jpeg")
if output_path.suffix.lower() not in allowed_suffixes:
raise StageFailure(
f"image GET returned {mime_type}; choose an output path ending in {extension}"
)
save_new_bytes(output_path, image_bytes)
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate one image with a documented Tokenhot Nano Banana route."
)
parser.add_argument(
"--model",
choices=MODEL_IDS,
default="nano-banana-2",
help="Exact Tokenhot route ID (default: nano-banana-2)",
)
parser.add_argument(
"--prompt",
default="A small red cabin beside a lake at sunrise, editorial illustration",
help="Image prompt text",
)
parser.add_argument(
"--output",
type=Path,
default=Path("nano-banana-output.jpg"),
help="Unused destination for a downloaded JPEG, PNG, or WebP",
)
parser.add_argument(
"--resume-response",
type=Path,
help="Reuse saved response JSON and make only its image GET; skips the POST",
)
args = parser.parse_args()
try:
args.output.parent.mkdir(parents=True, exist_ok=True)
except OSError:
print("Cannot create the output directory.", file=sys.stderr)
return 1
if args.resume_response:
response_path = args.resume_response
if not response_path.is_file():
print(
f"Saved response file does not exist: {response_path}; no request was sent.",
file=sys.stderr,
)
return 1
if args.output.exists():
print(
f"Refusing to overwrite existing image path {args.output}; "
"choose a fresh --output path for GET-only recovery.",
file=sys.stderr,
)
return 1
try:
raw = response_path.read_bytes()
except OSError:
print(
f"Could not read saved response file {response_path}; no request was sent.",
file=sys.stderr,
)
return 1
print(f"Reusing saved response JSON; skipping POST: {response_path}")
else:
response_path = response_path_for(args.output)
try:
require_fresh_generation_paths(args.output, response_path)
except StageFailure as error:
print(str(error), file=sys.stderr)
return 1
token = os.environ.get("TOKENHOT_API_KEY")
if not token:
parser.error(
"set TOKENHOT_API_KEY in your shell; the script does not read a .env file"
)
endpoint = f"{API_BASE}/{args.model}:generateContent"
payload = {
"contents": [
{
"role": "user",
"parts": [{"text": args.prompt}],
}
],
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"],
"imageConfig": {
"aspectRatio": "16:9",
"imageSize": "2K",
},
},
}
try:
raw = post_once(endpoint, token, payload, response_path)
except StageFailure as error:
print(str(error), file=sys.stderr)
return 1
try:
document = json.loads(raw)
image_url = response_image_url(document)
except (json.JSONDecodeError, ValueError):
print(
f"No documented image URL could be read. The response body is preserved at "
f"{response_path}; inspect it locally and redact it before sharing. "
"No POST retry was made.",
file=sys.stderr,
)
return 1
try:
download_image(image_url, args.output)
except StageFailure as error:
print(
f"{error}. The API response remains at {response_path}; no generation POST "
"was repeated. Use --resume-response to retry only the GET if its URL still works.",
file=sys.stderr,
)
return 1
print(f"Response JSON: {response_path}")
print(f"Image saved: {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Run Nano Banana 2:
python nano_banana_api.py --model nano-banana-2 --output koala.jpg --prompt "A koala reading beneath a eucalyptus tree, warm editorial illustration"
Or select Pro by changing the route ID:
python nano_banana_api.py --model nano-banana-pro --output koala-pro.jpg --prompt "A koala reading beneath a eucalyptus tree, warm editorial illustration"
For a successful new run, the script creates a neighboring response file such as koala.jpg.response.json and saves the downloaded image at the requested path. The Tokenhot docs' sample response places the URL in candidates[0].content.parts[0].text. The example URL on the documentation page is a placeholder; the script extracts the HTTPS URL from your response. It sends no Tokenhot authorization header with the separate image-download GET. If either target path already exists, the script stops before the POST; use a fresh output name such as koala-02.jpg for another generation.
Recover a download without generating again
If the API request succeeds but downloading the image fails, the response JSON is already saved and remains paired with its original output name. The program does not overwrite an existing image during recovery. After checking the saved URL locally, retry only the download to a fresh output path:
python nano_banana_api.py --resume-response koala.jpg.response.json --output koala-recovered.jpg
This path skips the generation POST and does not require the API key. It retries only the GET for the saved URL and refuses an output path that already exists. A temporary returned URL may stop working; the Tokenhot docs' sample does not state how long image URLs remain available, so download outputs promptly and keep your own copy.
If an HTTP error response arrives, the script reports its status and saves the response body locally without printing it. Treat that file as sensitive: inspect and redact it before sharing. The client timeout is 180 seconds for the POST and 60 seconds for the image GET. If the POST times out or its response body cannot be read, the script identifies that stage, reports any status actually received, and says whether a partial response file was saved. A timeout does not confirm that generation stopped or reveal its billing outcome. Check account usage or support terms before deciding whether to submit a new POST; no universal refund or failed-request charge rule was verified. No POST is retried automatically.
Keep image editing on a verified route
This runnable example covers text-to-image generation and saving the URL result. The Tokenhot Nano Banana Pro and Nano Banana 2 pages captured for this guide show generation examples, but no image-input or editing request example. Tokenhot has separate preview-ID Gemini routes with editing pages, but their path examples conflict or contain malformed-looking model strings; I have not used those pages as a working editing recipe. Google's legacy Generate Content guide describes reference-image input and says to return image-generation thoughtSignature values in multi-turn histories. That is Google-native API behavior and does not establish that Tokenhot's separate nano-banana-* trial IDs accept the same edit payload or history fields.
If editing is required, start with the docs for the exact route you plan to call and verify its endpoint, image-input shape, and response before adapting this generation script. Don't change the route ID to a Google model name or add a guessed image payload to the Nano trial request.
Next step
For a route-specific payload, keep the Tokenhot Nano Banana Pro docs or Nano Banana 2 docs beside the script. Before you rely on either route, check the current model list for its current route description and account availability.
Compare Tokenhot's nano-banana-pro and nano-banana-2 route IDs, send a documented JSON request, and download the returned image in Python.

