Files
Anush e455f2a924 fix: image-to-image mode not returning generated images
Fix response parsing in GeminiClient that caused i2i to fail with
  "No images generated" while t2i worked correctly.

  - Remove aggressive chunk filtering that skipped chunks without
    /gg-dl/ markers when status < 3, which missed valid i2i image data
  - Add fallback candidate search at alternate indices [3, 4, 5] when
    index [4] is empty, handling different i2i response structures
  - Expand find_image_container search range and improve handling of
    dict-based candidates from gemini-3-pro model
2026-03-18 19:11:47 +05:30

1478 lines
69 KiB
Python

import asyncio
import re
from pathlib import Path
from typing import Any, Optional
import orjson as json
from httpx import AsyncClient, ReadTimeout, Response
from .components import GemMixin
from .constants import Endpoint, ErrorCode, Headers, ImageStatus, Model
from .exceptions import (
APIError,
AuthError,
GeminiError,
ModelInvalid,
TemporarilyBlocked,
TimeoutError,
UsageLimitExceeded,
)
from .types import (
Candidate,
Gem,
GeneratedImage,
ModelOutput,
RPCData,
WebImage,
)
from .utils import (
extract_json_from_response,
get_access_token,
get_nested_value,
logger,
parse_file_name,
rotate_1psidts,
rotate_tasks,
running,
upload_file,
)
class GeminiClient(GemMixin):
"""
Async httpx client interface for gemini.google.com.
`secure_1psid` must be provided unless the optional dependency `browser-cookie3` is installed, and
you have logged in to google.com in your local browser.
Parameters
----------
secure_1psid: `str`, optional
__Secure-1PSID cookie value.
secure_1psidts: `str`, optional
__Secure-1PSIDTS cookie value, some google accounts don't require this value, provide only if it's in the cookie list.
proxy: `str`, optional
Proxy URL.
kwargs: `dict`, optional
Additional arguments which will be passed to the http client.
Refer to `httpx.AsyncClient` for more information.
Raises
------
`ValueError`
If `browser-cookie3` is installed but cookies for google.com are not found in your local browser storage.
"""
__slots__ = [
"cookies",
"proxy",
"_running",
"client",
"access_token",
"timeout",
"auto_close",
"close_delay",
"close_task",
"auto_refresh",
"refresh_interval",
"_gems", # From GemMixin
"kwargs",
]
def __init__(
self,
secure_1psid: str | None = None,
secure_1psidts: str | None = None,
proxy: str | None = None,
**kwargs,
):
super().__init__()
self.cookies = {}
self.proxy = proxy
self._running: bool = False
self.client: AsyncClient | None = None
self.access_token: str | None = None
self.timeout: float = 300
self.auto_close: bool = False
self.close_delay: float = 300
self.close_task: Task | None = None
self.auto_refresh: bool = True
self.refresh_interval: float = 540
self.kwargs = kwargs
if secure_1psid:
self.cookies["__Secure-1PSID"] = secure_1psid
if secure_1psidts:
self.cookies["__Secure-1PSIDTS"] = secure_1psidts
async def init(
self,
timeout: float = 300,
auto_close: bool = False,
close_delay: float = 300,
auto_refresh: bool = True,
refresh_interval: float = 540,
verbose: bool = True,
) -> None:
"""
Get SNlM0e value as access token. Without this token posting will fail with 400 bad request.
Parameters
----------
timeout: `float`, optional
Request timeout of the client in seconds. Used to limit the max waiting time when sending a request.
auto_close: `bool`, optional
If `True`, the client will close connections and clear resource usage after a certain period
of inactivity. Useful for always-on services.
close_delay: `float`, optional
Time to wait before auto-closing the client in seconds. Effective only if `auto_close` is `True`.
auto_refresh: `bool`, optional
If `True`, will schedule a task to automatically refresh cookies in the background.
refresh_interval: `float`, optional
Time interval for background cookie refresh in seconds. Effective only if `auto_refresh` is `True`.
verbose: `bool`, optional
If `True`, will print more infomation in logs.
"""
try:
# Determine auth_method: if cookies were provided at init, use "manual" (strict)
# Otherwise use "auto_cookies" to try all sources
auth_method = "manual" if self.cookies else "auto_cookies"
access_token, valid_cookies = await get_access_token(
base_cookies=self.cookies, proxy=self.proxy, verbose=verbose,
auth_method=auth_method
)
self.client = AsyncClient(
timeout=timeout,
proxy=self.proxy,
follow_redirects=True,
http2=True, # Enable HTTP/2 for faster multiplexed requests
headers={**Headers.GEMINI.value, **Headers.BROWSER.value},
cookies=valid_cookies,
**self.kwargs,
)
self.access_token = access_token
self.cookies = valid_cookies
self._running = True
self.timeout = timeout
self.auto_close = auto_close
self.close_delay = close_delay
if self.auto_close:
await self.reset_close_task()
self.auto_refresh = auto_refresh
self.refresh_interval = refresh_interval
if task := rotate_tasks.get(self.cookies["__Secure-1PSID"]):
task.cancel()
if self.auto_refresh:
rotate_tasks[self.cookies["__Secure-1PSID"]] = asyncio.create_task(
self.start_auto_refresh()
)
if verbose:
logger.success("Gemini client initialized successfully.")
except Exception:
await self.close()
raise
async def close(self, delay: float = 0) -> None:
"""
Close the client after a certain period of inactivity, or call manually to close immediately.
Parameters
----------
delay: `float`, optional
Time to wait before closing the client in seconds.
"""
if delay:
await asyncio.sleep(delay)
self._running = False
if self.close_task:
self.close_task.cancel()
self.close_task = None
if self.client:
await self.client.aclose()
async def reset_close_task(self) -> None:
"""
Reset the timer for closing the client when a new request is made.
"""
if self.close_task:
self.close_task.cancel()
self.close_task = None
self.close_task = asyncio.create_task(self.close(self.close_delay))
async def start_auto_refresh(self) -> None:
"""
Start the background task to automatically refresh cookies.
"""
while True:
new_1psidts: str | None = None
try:
new_1psidts = await rotate_1psidts(self.cookies, self.proxy)
except AuthError:
if task := rotate_tasks.get(self.cookies.get("__Secure-1PSID", "")):
task.cancel()
logger.warning(
"AuthError: Failed to refresh cookies. Auto refresh task canceled."
)
return
except Exception as exc:
logger.warning(f"Unexpected error while refreshing cookies: {exc}")
if new_1psidts:
self.cookies["__Secure-1PSIDTS"] = new_1psidts
if self._running:
self.client.cookies.set("__Secure-1PSIDTS", new_1psidts)
logger.debug("Cookies refreshed. New __Secure-1PSIDTS applied.")
await asyncio.sleep(self.refresh_interval)
@running(retry=2)
async def generate_content(
self,
prompt: str,
files: list[str | Path] | None = None,
model: Model | str | dict = Model.UNSPECIFIED,
gem: Gem | str | None = None,
chat: Optional["ChatSession"] = None,
image_mode: bool = False,
debug_mode: bool = False,
**kwargs,
) -> ModelOutput:
"""
Generates contents with prompt.
Parameters
----------
prompt: `str`
Prompt provided by user.
files: `list[str | Path]`, optional
List of file paths to be attached.
model: `Model | str | dict`, optional
Specify the model to use for generation.
Pass either a `gemini_webapi.constants.Model` enum or a model name string to use predefined models.
Pass a dictionary to use custom model header strings ("model_name" and "model_header" keys must be provided).
gem: `Gem | str`, optional
Specify a gem to use as system prompt for the chat session.
Pass either a `gemini_webapi.types.Gem` object or a gem id string.
chat: `ChatSession`, optional
Chat data to retrieve conversation history. If None, will automatically generate a new chat id when sending post request.
kwargs: `dict`, optional
Additional arguments which will be passed to the post request.
Refer to `httpx.AsyncClient.request` for more information.
Returns
-------
:class:`ModelOutput`
Output data from gemini.google.com, use `ModelOutput.text` to get the default text reply, `ModelOutput.images` to get a list
of images in the default reply, `ModelOutput.candidates` to get a list of all answer candidates in the output.
Raises
------
`AssertionError`
If prompt is empty.
`gemini_webapi.TimeoutError`
If request timed out.
`gemini_webapi.GeminiError`
If no reply candidate found in response.
`gemini_webapi.APIError`
- If request failed with status code other than 200.
- If response structure is invalid and failed to parse.
"""
assert prompt, "Prompt cannot be empty."
if isinstance(model, str):
model = Model.from_name(model)
elif isinstance(model, dict):
model = Model.from_dict(model)
elif not isinstance(model, Model):
raise TypeError(
f"'model' must be a `gemini_webapi.constants.Model` instance, "
f"string, or dictionary; got `{type(model).__name__}`"
)
if isinstance(gem, Gem):
gem_id = gem.id
else:
gem_id = gem
if self.auto_close:
await self.reset_close_task()
# Build final headers
final_headers = {}
# Setup image mode if requested
if image_mode:
import uuid
session_uuid = str(uuid.uuid4()).upper()
# Both t2i and i2i use the SAME extended headers structure
# Browser analysis confirmed: identical headers, identical 67-element payload
# The only difference is file data embedded in prompt_data[3] for i2i
# Extract model ID and trailing flag from model header or use defaults
model_header_str = model.model_header.get("x-goog-ext-525001261-jspb", "") if model.model_header else ""
if '"' in model_header_str:
# Extract model ID from existing header
model_id = model_header_str.split('"')[1]
# Extract trailing flag (last number before closing bracket)
# Model header format: [1,null,null,null,"model_id",null,null,0,[4],null,null,X]
# where X is the trailing flag (1 for regular, 2 for thinking models)
try:
# Parse the last number before the closing bracket
trailing_flag = int(model_header_str.rstrip(']').split(',')[-1])
except (ValueError, IndexError):
trailing_flag = 1 # Default
else:
# Default to gemini-3-flash model ID for image mode
model_id = "9ec249fc9ad08861" # G_2_5_FLASH model ID
trailing_flag = 1
# Use extended headers for ALL image mode requests
# IMPORTANT: Preserve the trailing flag from the model definition
final_headers["x-goog-ext-525001261-jspb"] = f'[1,null,null,null,"{model_id}",null,null,0,[4],null,null,{trailing_flag}]'
final_headers["x-goog-ext-525005358-jspb"] = f'["{session_uuid}",1]'
# Note: x-goog-ext-73010989-jspb is now included in base GEMINI headers
mode_type = "i2i" if files else "t2i"
logger.debug(f"Image mode ({mode_type}): model_name='{model.model_name}', model_id='{model_id}', trailing_flag={trailing_flag}")
else:
# Non-image mode: use model headers if specified
session_uuid = None # No session UUID needed for text mode
if model.model_header:
final_headers = {**model.model_header}
try:
# Helper function to build payload array with specific values at specific positions
def build_payload(size: int, values: dict) -> list:
"""Build an array of given size with values at specific positions."""
payload = [None] * size
for pos, val in values.items():
payload[pos] = val
return payload
# Build prompt data for position 0
if files:
# Upload files using browser-matching resumable protocol
uploaded_files = []
for file in files:
file_id = await upload_file(
file=file,
cookies=self.cookies,
proxy=self.proxy,
debug=debug_mode,
)
uploaded_files.append([
[file_id],
parse_file_name(file),
])
prompt_data = [
prompt,
0,
None,
uploaded_files,
None,
None,
0,
]
else:
prompt_data = [prompt, 0, None, None, None, None, 0]
# Chat metadata for position 2
chat_metadata = chat.metadata if chat else ["", "", "", None, None, None, None, None, None, ""]
if image_mode:
# Both t2i and i2i use the SAME 67-element payload structure
import time
current_time = int(time.time())
timestamp_ns = int((time.time() % 1) * 1000000000)
mode_type = "i2i" if files else "t2i"
logger.debug(f"Image mode ({mode_type}): Using 67-element payload with {len(files) if files else 0} file(s)")
# Payload construction (same for all models)
image_mode_values = {
0: prompt_data,
1: ["en"],
2: chat_metadata,
7: 1,
10: 1,
17: [[0]],
27: 1,
30: [4],
41: [1],
49: 14,
59: session_uuid,
66: [current_time, timestamp_ns],
}
inner_payload = build_payload(67, image_mode_values)
else:
# Text mode: Simpler payload structure
text_mode_values = {
0: prompt_data,
1: ["en"],
2: chat_metadata,
}
inner_payload = build_payload(20, text_mode_values)
# Append gem_id if provided
if gem_id:
inner_payload.extend([None] * 16 + [gem_id])
response = await self.client.post(
Endpoint.GENERATE.value,
headers=final_headers,
data={
"at": self.access_token,
"f.req": json.dumps(
[
None,
json.dumps(inner_payload).decode(),
]
).decode(),
},
**kwargs,
)
except ReadTimeout:
raise TimeoutError(
"Generate content request timed out, please try again. If the problem persists, "
"consider setting a higher `timeout` value when initializing GeminiClient."
)
if response.status_code != 200:
await self.close()
raise APIError(
f"Failed to generate contents. Request failed with status code {response.status_code}"
)
else:
# Debug: Save request and response to files if debug_mode is enabled
if debug_mode:
import os
debug_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
try:
request_data = {
"prompt": prompt,
"files": [str(f) for f in files] if files else None,
"image_mode": image_mode,
"model": model.model_name if hasattr(model, 'model_name') else str(model),
}
with open(os.path.join(debug_dir, "debug_request.txt"), "w", encoding="utf-8") as f:
f.write(json.dumps(request_data, option=json.OPT_INDENT_2).decode())
print(f"[Gemini Debug] Saved request to {debug_dir}/debug_request.txt")
except Exception as e:
logger.debug(f"Failed to save request: {e}")
try:
# Sanitize response to remove sensitive location/Google Maps data
def sanitize_response(text):
"""Remove sensitive location data from response for safe sharing."""
import re
sanitized = text
# Remove Google Maps URLs
sanitized = re.sub(
r'//www\.google\.com/maps/vt/data=[^"\']+',
'[MAPS_DATA_REDACTED]',
sanitized
)
# Remove location descriptions (city, state, country patterns)
# Matches: "City Name, Area, City, State, Country"
sanitized = re.sub(
r'"[A-Z][a-zA-Z\s]+,\s*[A-Z][a-zA-Z\s]+,\s*[A-Z][a-zA-Z\s]+,\s*[A-Z][a-zA-Z\s]+,\s*[A-Z][a-zA-Z\s]+"',
'"[LOCATION_REDACTED]"',
sanitized
)
# Remove SWML location markers
sanitized = re.sub(
r'"SWML_DESCRIPTION_FROM_YOUR_PLACES_[A-Z]+"',
'"[PLACES_MARKER_REDACTED]"',
sanitized
)
# Remove coordinates if present (lat, lng patterns)
sanitized = re.sub(
r'(\d+\.\d{4,}),\s*(\d+\.\d{4,})',
'[COORDS_REDACTED]',
sanitized
)
return sanitized
def parse_to_clean_json(raw_text):
"""Parse Google XSSI-protected response into clean JSON."""
import re
# Extract JSON chunks from XSSI-protected response
parsed_chunks = []
try:
# Use the existing extract_json_from_response function
chunks = extract_json_from_response(raw_text)
for i, chunk in enumerate(chunks):
chunk_data = {"chunk_index": i, "data": None}
try:
# Try to get the inner body and parse nested JSON strings
if isinstance(chunk, list) and len(chunk) > 2 and chunk[2]:
inner_json_str = chunk[2]
if isinstance(inner_json_str, str):
inner_data = json.loads(inner_json_str)
chunk_data["data"] = inner_data
else:
chunk_data["data"] = chunk
else:
chunk_data["data"] = chunk
except (json.JSONDecodeError, TypeError):
chunk_data["data"] = chunk
parsed_chunks.append(chunk_data)
except Exception as e:
# Fallback: return raw text info
parsed_chunks = [{"error": str(e), "raw_length": len(raw_text)}]
return parsed_chunks
# Parse to clean JSON structure
clean_json = parse_to_clean_json(response.text)
# Apply sanitization to the formatted output
formatted_output = json.dumps(clean_json, option=json.OPT_INDENT_2).decode()
sanitized_output = sanitize_response(formatted_output)
with open(os.path.join(debug_dir, "debug_response.json"), "w", encoding="utf-8") as f:
f.write(sanitized_output)
print(f"[Gemini Debug] Saved parsed response to {debug_dir}/debug_response.json (location data redacted)")
# Also save raw response for complete debugging (NOT sanitized for full analysis)
with open(os.path.join(debug_dir, "debug_response_raw.txt"), "w", encoding="utf-8") as f:
f.write(response.text)
print(f"[Gemini Debug] Saved raw response to {debug_dir}/debug_response_raw.txt")
except Exception as e:
logger.debug(f"Failed to save response: {e}")
response_json: list[Any] = []
body: list[Any] = []
body_index = 0
try:
# extract_json_from_response now returns a list of chunks
# Each chunk is itself a list like [["wrb.fr", ...]]
response_chunks = extract_json_from_response(response.text)
# Flatten all chunks into a single list for processing
# Each chunk contains parts like [["wrb.fr", null, "..."]]
response_json = []
for chunk in response_chunks:
if isinstance(chunk, list):
response_json.extend(chunk)
# Find the best body - look for the chunk with actual text content
# Streaming responses have multiple chunks; early ones may have empty text
best_body = None
best_body_index = 0
for part_index, part in enumerate(response_json):
try:
part_body = get_nested_value(part, [2])
if not part_body:
continue
part_json = json.loads(part_body)
candidates = get_nested_value(part_json, [4])
if candidates:
# Check if this chunk has actual text content
text_content = get_nested_value(candidates, [0, 1, 0], "")
if debug_mode:
logger.debug(f"Part {part_index}: text_content = '{text_content[:50] if text_content else 'None'}...'")
# Prefer chunks with text, but keep any valid candidate as fallback
# This is important for image-to-image where text may be empty
if text_content and text_content.strip():
best_body = part_json
best_body_index = part_index
elif best_body is None:
best_body = part_json
best_body_index = part_index
else:
# For i2i responses, candidates might be structured differently
# Check if this chunk has any image-related data before skipping
if best_body is None and isinstance(part_json, list) and len(part_json) >= 5:
# Look for candidate-like structures at various indices
for check_idx in [4, 3, 5]:
alt_candidates = get_nested_value(part_json, [check_idx])
if alt_candidates and isinstance(alt_candidates, list) and len(alt_candidates) > 0:
if debug_mode:
logger.debug(f"Part {part_index}: Found candidates at alternate index [{check_idx}]")
best_body = part_json
best_body_index = part_index
break
except json.JSONDecodeError:
continue
if debug_mode:
final_text = get_nested_value(best_body, [4, 0, 1, 0], "") if best_body else "None"
logger.debug(f"Selected body index {best_body_index}, text: '{final_text[:100] if final_text else 'None'}...'")
body = best_body if best_body else []
body_index = best_body_index
if not body:
raise Exception
except Exception:
await self.close()
try:
# First check for batchexecute safety filter responses
# Safety filter returns [3] or [4] at index [0][5]
safety_code = get_nested_value(response_json, [0, 5], None)
if safety_code == [3] or safety_code == [4] or safety_code == 3 or safety_code == 4:
raise GeminiError(
"Failed to generate contents. Your prompt was rejected by Gemini's content safety filters. "
"Please try a different prompt that doesn't trigger content moderation."
)
# Check for BardErrorInfo response (code [5,...] at index [0][5])
# This indicates various API errors including image session issues (1115)
if isinstance(safety_code, list) and len(safety_code) >= 1 and safety_code[0] == 5:
# Extract error code from BardErrorInfo if present
bard_error_code = get_nested_value(response_json, [0, 5, 2, 0, 1, 0], -1)
if bard_error_code == 1115:
raise APIError(
"Failed to generate image. Image generation session initialization failed (Error 1115). "
"This may be a temporary issue - please try again or check your cookies."
)
else:
logger.debug(f"BardErrorInfo with code: {bard_error_code}")
raise APIError(
f"Failed to generate contents. Gemini returned error code {bard_error_code}. "
"Please try again or check your authentication."
)
# Then check for standard error codes
error_code = get_nested_value(response_json, [0, 5, 2, 0, 1, 0], -1)
match error_code:
case ErrorCode.CONTENT_SAFETY_3 | ErrorCode.CONTENT_SAFETY_4:
raise GeminiError(
"Failed to generate contents. Your prompt was rejected by Gemini's content safety filters. "
"Please try a different prompt that doesn't trigger content moderation."
)
case ErrorCode.USAGE_LIMIT_EXCEEDED:
raise UsageLimitExceeded(
f"Failed to generate contents. Usage limit of {model.model_name} model has exceeded. Please try switching to another model."
)
case ErrorCode.MODEL_INCONSISTENT:
raise ModelInvalid(
"Failed to generate contents. The specified model is inconsistent with the chat history. Please make sure to pass the same "
"`model` parameter when starting a chat session with previous metadata."
)
case ErrorCode.MODEL_HEADER_INVALID:
raise ModelInvalid(
"Failed to generate contents. The specified model is not available. Please update gemini_webapi to the latest version. "
"If the error persists and is caused by the package, please report it on GitHub."
)
case ErrorCode.IP_TEMPORARILY_BLOCKED:
raise TemporarilyBlocked(
"Failed to generate contents. Your IP address is temporarily blocked by Google. Please try using a proxy or waiting for a while."
)
case _:
raise Exception
except GeminiError:
raise
except Exception:
logger.debug(f"Invalid response: {response.text}")
raise APIError(
"Failed to generate contents. Invalid response data received. Client will try to re-initialize on next request."
)
try:
candidate_list: list[Any] = get_nested_value(body, [4], [])
output_candidates: list[Candidate] = []
for candidate_index, candidate in enumerate(candidate_list):
rcid = get_nested_value(candidate, [0])
if not rcid:
continue # Skip candidate if it has no rcid
# Text output and thoughts
text = get_nested_value(candidate, [1, 0], "")
if re.match(
r"^http://googleusercontent\.com/card_content/\d+", text
):
text = get_nested_value(candidate, [22, 0]) or text
thoughts = get_nested_value(candidate, [37, 0, 0])
# Web images
web_images = []
for web_img_data in get_nested_value(candidate, [12, 1], []):
url = get_nested_value(web_img_data, [0, 0, 0])
if not url:
continue
web_images.append(
WebImage(
url=url,
title=get_nested_value(web_img_data, [7, 0], ""),
alt=get_nested_value(web_img_data, [0, 4], ""),
proxy=self.proxy,
)
)
# =====================================================
# ROBUST IMAGE EXTRACTION (v2 - Anti-Fragility Design)
# =====================================================
# Implements: Score-based chunk selection, recursive blob finder,
# hunt-and-peck index verification, priority-based extraction,
# MIME-based watermark detection, safety handling, early exit
# =====================================================
generated_images = []
# Helper: Normalize object-based responses to list format
# gemini-3-pro returns: {"7": [3], "8": [[[[blob]]]]}
# gemini-3-flash returns: [null,..., [3], [[[[blob]]]]]
def normalize_image_container(container):
"""
Convert object-based image container to list format.
Some models (gemini-3-pro) return dict with string keys like '7', '8'
instead of a list with numeric indices.
"""
if isinstance(container, dict):
# Find the maximum key to determine list size
max_key = 0
for key in container.keys():
try:
max_key = max(max_key, int(key))
except (ValueError, TypeError):
pass
# Convert to list
result = [None] * (max_key + 1)
for key, value in container.items():
try:
result[int(key)] = value
except (ValueError, TypeError):
pass
if debug_mode:
logger.debug(f"Normalized dict container with keys {list(container.keys())} to list of len {len(result)}")
return result
return container
# Helper: Recursive image blob finder
def find_image_blobs(node, depth=0, max_depth=12, input_urls=None, parent_index=None):
"""
Recursively find all image blob arrays in a nested structure.
Image blob pattern: [null/any, 1, "filename", "https://...googleusercontent...", ...]
Watermark detection is based on parent array position:
- Position [3] = watermarked (first image)
- Position [6] = no watermark (second image)
"""
if input_urls is None:
input_urls = set()
results = []
if depth > max_depth or not isinstance(node, list):
return results
# Check if this node is an image blob
if len(node) >= 4:
url = node[3] if len(node) > 3 else None
if isinstance(url, str) and "googleusercontent.com" in url:
filename = node[2] if len(node) > 2 and isinstance(node[2], str) else ""
mime_type = node[9] if len(node) > 9 and isinstance(node[9], str) else ""
# Classify as generated or input
is_generated = False
is_input = False
# Primary: URL pattern check
if "/gg-dl/" in url:
is_generated = True
elif "googleusercontent.com/gg/" in url and "/gg-dl/" not in url:
is_input = True
# Secondary: Filename entropy (generated have long numeric names)
if not is_generated and not is_input:
if filename and len(filename) > 20 and filename[0].isdigit():
is_generated = True
# Tertiary: URL prefix patterns
if not is_generated and not is_input:
if "ABS2G" in url:
is_generated = True
elif "AIJ2g" in url:
is_input = True
# Final safety: Exclude exact input URLs
if url in input_urls:
is_input = True
is_generated = False
if is_generated:
# Determine watermark status from parent array position
# Position [3] = watermarked, Position [6] = no watermark
# This is based on Gemini's response structure where images are at:
# [[...[BLOB_AT_3], null, null, [BLOB_AT_6]...]]
is_watermarked = parent_index == 3 if parent_index is not None else True
results.append({
"url": url,
"filename": filename,
"mime_type": mime_type,
"is_watermarked": is_watermarked,
"parent_index": parent_index,
"depth": depth,
})
# Recurse into children, tracking their index in the parent array
for idx, child in enumerate(node):
if isinstance(child, list):
results.extend(find_image_blobs(child, depth + 1, max_depth, input_urls, parent_index=idx))
return results
# Helper: Find image container with hunt-and-peck verification
def find_image_container(cand, expected_index=12):
"""
Find the image data container, using hunt-and-peck if the expected index is wrong.
Returns the container (normalized to list) and its actual index.
Handles both:
- List format: cand[12] = [null, null, null, null, null, null, [3], [[[[blob]]]]]
- Dict format: cand[12] = {"7": [3], "8": [[[[blob]]]]} (gemini-3-pro)
"""
# Special case: If cand itself is a dict with image keys like '7', '8'
# This happens with gemini-3-pro where the candidate structure is object-based
if isinstance(cand, dict):
# Check if this dict has image-related keys
if '7' in cand or '8' in cand or 7 in cand or 8 in cand:
normalized = normalize_image_container(cand)
if verify_image_container(normalized):
if debug_mode:
logger.debug(f"Candidate is dict-based with image keys, normalized to list")
return normalized, 0 # Return 0 as pseudo-index
# Try expected index first
container = get_nested_value(cand, [expected_index])
# Unwrap single-element list containing a dict (gemini-3-pro format)
# candidate[12] = [{"7": [3], "8": [[[[blob]]]]}] instead of just the dict
if isinstance(container, list) and len(container) == 1 and isinstance(container[0], dict):
if debug_mode:
logger.debug(f"Unwrapping single-element list at [{expected_index}]")
container = container[0]
# If container is a dict, normalize it
if isinstance(container, dict):
container = normalize_image_container(container)
if verify_image_container(container):
return container, expected_index
# Hunt in neighboring indices
# For dict-based candidates, use a wider search range since we can't determine length
if isinstance(cand, list):
search_end = min(len(cand), expected_index + 6)
else:
# For dicts or unknown types, search a reasonable range
search_end = expected_index + 8
search_range = range(max(0, expected_index - 4), search_end)
for i in search_range:
if i == expected_index:
continue
container = get_nested_value(cand, [i])
# Unwrap single-element list containing a dict
if isinstance(container, list) and len(container) == 1 and isinstance(container[0], dict):
if debug_mode:
logger.debug(f"Unwrapping single-element list at [{i}]")
container = container[0]
# Normalize dict containers
if isinstance(container, dict):
container = normalize_image_container(container)
if verify_image_container(container):
if debug_mode:
logger.debug(f"Image container found at [{i}] instead of [{expected_index}]")
return container, i
return None, -1
def verify_image_container(container):
"""Verify this is an image container by checking for expected structure."""
if container is None:
return False
# Must be a list after normalization
if not isinstance(container, list):
return False
if len(container) < 6:
return False
# Check for image mode indicators at [6] or [7] or [8]
has_status = isinstance(get_nested_value(container, [6]), list) or isinstance(get_nested_value(container, [7]), list)
has_img_data = get_nested_value(container, [7]) is not None or get_nested_value(container, [8]) is not None
return has_status or has_img_data
# Helper: Score a chunk for image content
def score_chunk(part_json, cand_index, input_urls):
"""
Score a response chunk for image content quality.
Higher score = better candidate for image extraction.
"""
score = 0
part_cand = get_nested_value(part_json, [4, cand_index])
if not part_cand:
return score, None
# Find image container
img_container, _ = find_image_container(part_cand)
if img_container:
score += 10
# Check status at [6] or [7] (different model formats)
status = -1
for status_idx in [6, 7]:
status_arr = get_nested_value(img_container, [status_idx]) if img_container else None
if isinstance(status_arr, list) and status_arr and isinstance(status_arr[0], int):
status = status_arr[0]
break
if isinstance(status, int):
if status >= ImageStatus.IN_PROGRESS:
score += 5
if status == ImageStatus.COMPLETE:
score += 2
# Safety/blocked check
if status in [ImageStatus.SAFETY_BLOCKED, ImageStatus.ERROR]:
return -100, part_cand # Blocked - negative score
# Check for image data at primary paths [7] or [8]
primary_data = None
for data_idx in [7, 8]:
primary_data = get_nested_value(img_container, [data_idx, 0]) if img_container else None
if primary_data:
score += 20
break
# Check for /gg-dl/ URLs (strongest signal)
blobs = find_image_blobs(img_container, input_urls=input_urls) if img_container else []
if blobs:
score += 30
return score, part_cand
# Check if image mode is active
if debug_mode:
# Log candidate structure for debugging
cand_type = type(candidate).__name__
cand_len = len(candidate) if isinstance(candidate, (list, dict)) else "N/A"
logger.debug(f"Candidate type={cand_type}, len={cand_len}")
if isinstance(candidate, list):
for i in range(min(15, len(candidate))):
val = candidate[i]
if val is not None:
val_type = type(val).__name__
val_preview = str(val)[:80] if not isinstance(val, (list, dict)) else f"{val_type}(len={len(val) if hasattr(val, '__len__') else '?'})"
logger.debug(f" candidate[{i}] = {val_preview}")
img_container, img_container_idx = find_image_container(candidate)
is_image_mode = img_container is not None
if debug_mode:
if is_image_mode:
logger.debug(f"Image mode: ENABLED (container at [{img_container_idx}])")
else:
logger.debug(f"Image mode: DISABLED (no container found)")
# Helper: Get image status from container (handles different positions)
def get_image_status(container):
"""Extract image status from container, checking [6] then [7]."""
if not container:
return None, None
# Try [6] first (gemini-3-flash format)
status_arr = get_nested_value(container, [6])
if isinstance(status_arr, list) and status_arr and isinstance(status_arr[0], int):
return status_arr, status_arr[0]
# Try [7] (gemini-3-pro format)
status_arr = get_nested_value(container, [7])
if isinstance(status_arr, list) and status_arr and isinstance(status_arr[0], int):
return status_arr, status_arr[0]
return None, None
if is_image_mode:
status_arr, img_mode_status = get_image_status(img_container)
if debug_mode:
logger.debug(f"Image mode detected at [{img_container_idx}], status_arr = {status_arr}, status={img_mode_status}")
# Check for safety/blocked status
if isinstance(img_mode_status, int) and img_mode_status in [ImageStatus.SAFETY_BLOCKED, ImageStatus.ERROR]:
if debug_mode:
logger.debug(f"Image generation BLOCKED (status={img_mode_status})")
# Don't raise - just set empty images and continue
output_candidates.append(
Candidate(
rcid=rcid,
text="Image generation was blocked by safety filters.",
thoughts=thoughts,
web_images=web_images,
generated_images=[],
)
)
continue
# Collect input image URLs for exclusion
input_urls = set()
# Input images are often at [12][7][0] with /gg/ pattern in earlier chunks
# We'll detect them by /gg/ without /gg-dl/
# SCORE-BASED CHUNK SELECTION with early exit
best_score = -1
best_candidate = None
best_chunk_idx = -1
if debug_mode:
logger.debug(f"Starting chunk scoring: {len(response_json)} total chunks, initial img_mode_status={img_mode_status}")
for part_idx, part in enumerate(response_json):
try:
# EARLY EXIT: Skip chunks with status < 2 (JSON peek optimization)
raw_body = get_nested_value(part, [2])
if not raw_body:
if debug_mode:
logger.debug(f"Chunk {part_idx}: Skipped (no raw_body)")
continue
has_gg_dl = "gg-dl" in raw_body
# Note: We used to skip chunks without /gg-dl/ markers when status < 3,
# but this caused image-to-image to fail because:
# 1. The initial img_mode_status may not reflect the actual state
# 2. i2i responses may have images in chunks without /gg-dl/ markers
# 3. The status progression may differ between t2i and i2i
# So we now process all chunks regardless of markers.
if debug_mode:
logger.debug(f"Chunk {part_idx}: Processing (has_gg_dl={has_gg_dl}, raw_body_len={len(raw_body)})")
part_json = json.loads(raw_body)
score, part_cand = score_chunk(part_json, candidate_index, input_urls)
if debug_mode:
logger.debug(f"Chunk {part_idx}: score={score}")
# EARLY EXIT on status=3 with good score
if score > best_score:
best_score = score
best_candidate = part_cand
best_chunk_idx = part_idx
# Check if this is a final chunk (status=3) with images
cand_container, _ = find_image_container(part_cand)
# Check status at [6] or [7]
cand_status = -1
for status_idx in [6, 7]:
cand_status_arr = get_nested_value(cand_container, [status_idx]) if cand_container else None
if isinstance(cand_status_arr, list) and cand_status_arr and isinstance(cand_status_arr[0], int):
cand_status = cand_status_arr[0]
break
if cand_status == 3 and score >= 30:
if debug_mode:
logger.debug(f"EARLY EXIT: status=3 with score={score}")
break
except json.JSONDecodeError as e:
if debug_mode:
logger.debug(f"Chunk {part_idx}: JSON decode error: {e}")
continue
if debug_mode:
logger.debug(f"Selected chunk {best_chunk_idx} with score={best_score}")
if best_candidate is not None:
img_cand_container, container_idx = find_image_container(best_candidate)
if debug_mode:
# Log container structure for debugging
if img_cand_container:
logger.debug(f"Image container found at [{container_idx}], type={type(img_cand_container).__name__}, len={len(img_cand_container) if isinstance(img_cand_container, list) else 'N/A'}")
# Show first few non-null entries
if isinstance(img_cand_container, list):
for i, val in enumerate(img_cand_container[:15]):
if val is not None:
logger.debug(f" Container[{i}] = {type(val).__name__}, preview={str(val)[:100]}...")
else:
logger.debug(f"No image container found in candidate")
if img_cand_container:
# Update text if available
if finished_text := get_nested_value(best_candidate, [1, 0]):
# Flexible pattern: handles http/https and potential subdomain variations
processed_text = re.sub(
r"https?://(?:\w+\.)?googleusercontent\.com/image_generation_content/\d+",
"",
finished_text,
).rstrip()
if processed_text and processed_text.strip():
text = processed_text
# PRIORITY-BASED EXTRACTION
# Priority 1: Primary path [7] - JPEG (no watermark) first
# Priority 2: Primary path [7] - PNG (watermark)
# Priority 3: Fallback path [6]
blobs = find_image_blobs(img_cand_container, input_urls=input_urls)
if debug_mode:
logger.debug(f"Found {len(blobs)} image blobs in selected chunk")
# Sort blobs: no watermark first (position [6]), then watermarked (position [3])
no_watermark_blobs = [b for b in blobs if not b["is_watermarked"]]
watermarked_blobs = [b for b in blobs if b["is_watermarked"]]
# Add non-watermarked images first (position [6])
for blob in no_watermark_blobs:
if not any(img.url == blob["url"] for img in generated_images):
if debug_mode:
logger.debug(f"Adding image (no watermark, pos={blob.get('parent_index', '?')}): {blob['filename']}")
generated_images.append(
GeneratedImage(
url=blob["url"],
title=f"[NO_WATERMARK] {blob['filename']}" if blob['filename'] else "[NO_WATERMARK] Image",
alt="",
proxy=self.proxy,
cookies=self.cookies,
)
)
# Then add watermarked images (position [3])
for blob in watermarked_blobs:
if not any(img.url == blob["url"] for img in generated_images):
if debug_mode:
logger.debug(f"Adding image (watermark, pos={blob.get('parent_index', '?')}): {blob['filename']}")
generated_images.append(
GeneratedImage(
url=blob["url"],
title=f"[WATERMARK] {blob['filename']}" if blob['filename'] else "[WATERMARK] Image",
alt="",
proxy=self.proxy,
cookies=self.cookies,
)
)
if debug_mode:
logger.debug(f"Total extracted images: {len(generated_images)}")
else:
if debug_mode:
logger.debug("Image mode detected but no suitable chunk found")
output_candidates.append(
Candidate(
rcid=rcid,
text=text,
thoughts=thoughts,
web_images=web_images,
generated_images=generated_images,
)
)
if not output_candidates:
raise GeminiError(
"Failed to generate contents. No output data found in response."
)
output = ModelOutput(
metadata=get_nested_value(body, [1], []),
candidates=output_candidates,
)
except (TypeError, IndexError) as e:
logger.debug(
f"{type(e).__name__}: {e}; Invalid response structure: {response.text}"
)
raise APIError(
"Failed to parse response body. Data structure is invalid."
)
if isinstance(chat, ChatSession):
chat.last_output = output
return output
def start_chat(self, **kwargs) -> "ChatSession":
"""
Returns a `ChatSession` object attached to this client.
Parameters
----------
kwargs: `dict`, optional
Additional arguments which will be passed to the chat session.
Refer to `gemini_webapi.ChatSession` for more information.
Returns
-------
:class:`ChatSession`
Empty chat session object for retrieving conversation history.
"""
return ChatSession(geminiclient=self, **kwargs)
async def _batch_execute(self, payloads: list[RPCData], **kwargs) -> Response:
"""
Execute a batch of requests to Gemini API.
Parameters
----------
payloads: `list[GRPC]`
List of `gemini_webapi.types.GRPC` objects to be executed.
kwargs: `dict`, optional
Additional arguments which will be passed to the post request.
Refer to `httpx.AsyncClient.request` for more information.
Returns
-------
:class:`httpx.Response`
Response object containing the result of the batch execution.
"""
try:
response = await self.client.post(
Endpoint.BATCH_EXEC,
data={
"at": self.access_token,
"f.req": json.dumps(
[[payload.serialize() for payload in payloads]]
).decode(),
},
**kwargs,
)
except ReadTimeout:
raise TimeoutError(
"Batch execute request timed out, please try again. If the problem persists, "
"consider setting a higher `timeout` value when initializing GeminiClient."
)
# ? Seems like batch execution will immediately invalidate the current access token,
# ? causing the next request to fail with 401 Unauthorized.
if response.status_code != 200:
await self.close()
raise APIError(
f"Batch execution failed with status code {response.status_code}"
)
return response
class ChatSession:
"""
Chat data to retrieve conversation history. Only if all 3 ids are provided will the conversation history be retrieved.
Parameters
----------
geminiclient: `GeminiClient`
Async httpx client interface for gemini.google.com.
metadata: `list[str]`, optional
List of chat metadata `[cid, rid, rcid]`, can be shorter than 3 elements, like `[cid, rid]` or `[cid]` only.
cid: `str`, optional
Chat id, if provided together with metadata, will override the first value in it.
rid: `str`, optional
Reply id, if provided together with metadata, will override the second value in it.
rcid: `str`, optional
Reply candidate id, if provided together with metadata, will override the third value in it.
model: `Model | str | dict`, optional
Specify the model to use for generation.
Pass either a `gemini_webapi.constants.Model` enum or a model name string to use predefined models.
Pass a dictionary to use custom model header strings ("model_name" and "model_header" keys must be provided).
gem: `Gem | str`, optional
Specify a gem to use as system prompt for the chat session.
Pass either a `gemini_webapi.types.Gem` object or a gem id string.
"""
__slots__ = [
"__metadata",
"geminiclient",
"last_output",
"model",
"gem",
]
def __init__(
self,
geminiclient: GeminiClient,
metadata: list[str | None] | None = None,
cid: str | None = None, # chat id
rid: str | None = None, # reply id
rcid: str | None = None, # reply candidate id
model: Model | str | dict = Model.UNSPECIFIED,
gem: Gem | str | None = None,
):
self.__metadata: list[str | None] = [None, None, None]
self.geminiclient: GeminiClient = geminiclient
self.last_output: ModelOutput | None = None
self.model: Model | str | dict = model
self.gem: Gem | str | None = gem
if metadata:
self.metadata = metadata
if cid:
self.cid = cid
if rid:
self.rid = rid
if rcid:
self.rcid = rcid
def __str__(self):
return f"ChatSession(cid='{self.cid}', rid='{self.rid}', rcid='{self.rcid}')"
__repr__ = __str__
def __setattr__(self, name: str, value: Any) -> None:
super().__setattr__(name, value)
# update conversation history when last output is updated
if name == "last_output" and isinstance(value, ModelOutput):
self.metadata = value.metadata
self.rcid = value.rcid
async def send_message(
self,
prompt: str,
files: list[str | Path] | None = None,
**kwargs,
) -> ModelOutput:
"""
Generates contents with prompt.
Use as a shortcut for `GeminiClient.generate_content(prompt, image, self)`.
Parameters
----------
prompt: `str`
Prompt provided by user.
files: `list[str | Path]`, optional
List of file paths to be attached.
kwargs: `dict`, optional
Additional arguments which will be passed to the post request.
Refer to `httpx.AsyncClient.request` for more information.
Returns
-------
:class:`ModelOutput`
Output data from gemini.google.com, use `ModelOutput.text` to get the default text reply, `ModelOutput.images` to get a list
of images in the default reply, `ModelOutput.candidates` to get a list of all answer candidates in the output.
Raises
------
`AssertionError`
If prompt is empty.
`gemini_webapi.TimeoutError`
If request timed out.
`gemini_webapi.GeminiError`
If no reply candidate found in response.
`gemini_webapi.APIError`
- If request failed with status code other than 200.
- If response structure is invalid and failed to parse.
"""
return await self.geminiclient.generate_content(
prompt=prompt,
files=files,
model=self.model,
gem=self.gem,
chat=self,
**kwargs,
)
def choose_candidate(self, index: int) -> ModelOutput:
"""
Choose a candidate from the last `ModelOutput` to control the ongoing conversation flow.
Parameters
----------
index: `int`
Index of the candidate to choose, starting from 0.
Returns
-------
:class:`ModelOutput`
Output data of the chosen candidate.
Raises
------
`ValueError`
If no previous output data found in this chat session, or if index exceeds the number of candidates in last model output.
"""
if not self.last_output:
raise ValueError("No previous output data found in this chat session.")
if index >= len(self.last_output.candidates):
raise ValueError(
f"Index {index} exceeds the number of candidates in last model output."
)
self.last_output.chosen = index
self.rcid = self.last_output.rcid
return self.last_output
@property
def metadata(self):
return self.__metadata
@metadata.setter
def metadata(self, value: list[str]):
if len(value) > 3:
raise ValueError("metadata cannot exceed 3 elements")
self.__metadata[: len(value)] = value
@property
def cid(self):
return self.__metadata[0]
@cid.setter
def cid(self, value: str):
self.__metadata[0] = value
@property
def rid(self):
return self.__metadata[1]
@rid.setter
def rid(self, value: str):
self.__metadata[1] = value
@property
def rcid(self):
return self.__metadata[2]
@rcid.setter
def rcid(self, value: str):
self.__metadata[2] = value