Source code for rapidtools.models.gemini

# Copyright (c) 2025 The University of Washington
#
# This file is part of rapidtools.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# You should have received a copy of the BSD 3-Clause License along with
# rapidtools. If not, see <http://www.opensource.org/licenses/>.
#
# Contributors:
# Barbaros Cetiner
#
# Last updated:
# 09-22-2026

"""
Google Gemini API wrapper (raw HTTP, ``generateContent``).

Example:
    >>> from rapidtools.models import GeminiInference
    >>> model = GeminiInference(api_key='AIza...', model_id='gemini-3.8-flash')
    >>> out = model.run_inference('roof.jpg', 'Describe the roof damage.')
    >>> print(out.text)
"""

from __future__ import annotations

import logging
from pathlib import Path
from typing import Any

from requests.exceptions import HTTPError, RetryError

from rapidtools.config import get_configured_session

from .api_base import (
    API_REQUEST_TIMEOUT,
    BaseAPIInferenceModel,
    api_session,
    catalog_ids,
    is_retryable_status,
    resolve_api_key,
)
from .base import GenerationConfig, ModelOutput
from .catalogs import (
    GEMINI_DEFAULT_MODEL,
    GEMINI_MODEL_CATALOG,
    PROVIDERS,
)

logger = logging.getLogger(__name__)

GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'


[docs] class GeminiInference(BaseAPIInferenceModel): """ Google Gemini ``generateContent`` client for image + text prompts. The key is read from ``api_key`` (string or key-file path) or the ``GOOGLE_API_KEY`` environment variable. Safety filters are set to ``BLOCK_NONE`` because damage imagery is frequently mis-flagged. Example: >>> from rapidtools.models import GeminiInference >>> GeminiInference.list_known_models()[0] 'gemini-3.8-flash' >>> model = GeminiInference(api_key='AIza...') >>> out = model.run_inference( ... 'house.jpg', 'Return JSON with a chs_level key (0-5).', json_mode=True ... ) >>> out.text '{"chs_level": 3}' """ INFO = PROVIDERS['gemini'] PROVIDER_NAME = 'Gemini' API_KEY_ENV = ('GOOGLE_API_KEY', 'GEMINI_API_KEY') MODEL_CATALOG = GEMINI_MODEL_CATALOG MIME_MAP = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.heic': 'image/heic', '.heif': 'image/heif', '.tif': 'image/tiff', '.tiff': 'image/tiff', '.bmp': 'image/bmp', } SAFETY_SETTINGS = [ {'category': cat, 'threshold': 'BLOCK_NONE'} for cat in [ 'HARM_CATEGORY_HARASSMENT', 'HARM_CATEGORY_HATE_SPEECH', 'HARM_CATEGORY_SEXUALLY_EXPLICIT', 'HARM_CATEGORY_DANGEROUS_CONTENT', ] ] def __init__( self, api_key: str | Path | None = None, model_id: str = GEMINI_DEFAULT_MODEL, max_workers: int = 10, max_retries: int = 3, system_instruction: str | None = None, temperature: float = 0.4, max_tokens: int = 2048, timeout: float = API_REQUEST_TIMEOUT, ): """ Initialise the client and validate the chosen model. Args: api_key: Key string, key-file path, or ``None`` for ``GOOGLE_API_KEY`` / ``GEMINI_API_KEY``. model_id: Gemini model ID (with or without the ``models/`` prefix). Defaults to ``'gemini-3.8-flash'``. max_workers: Threads used by :meth:`run_batch`. max_retries: Retries for transient HTTP failures. system_instruction: Optional system prompt. temperature: Sampling temperature. max_tokens: Maximum output tokens per response. timeout: Seconds to wait for each response (default 60). Raises: ValueError: If no API key can be resolved. FileNotFoundError: If ``api_key`` is a ``Path`` that does not exist. """ super().__init__( max_retries=max_retries, max_workers=max_workers, timeout=timeout ) self.api_key = self._resolve_api_key(api_key) self.model_id = model_id.replace('models/', '') self.system_instruction = system_instruction self.temperature = temperature self.max_tokens = max_tokens self.session.headers.update({'x-goog-api-key': self.api_key}) self._validate_model() def _validate_model(self) -> None: """Warn (never fail) when the model is not in Google's list.""" if not self.api_key or not self.model_id: return try: available_models = self.list_available_models(self.api_key) available_ids = {m.replace('models/', '') for m in available_models} if available_ids and self.model_id not in available_ids: logger.warning( f"Model '{self.model_id}' is not in the available models list." ) except Exception as e: # noqa: BLE001 - validation is best effort logger.debug(f'Transient error during model validation: {e}')
[docs] @classmethod def list_available_models(cls, api_key: str | Path | None = None) -> list[str]: """ List Gemini models that support ``generateContent``. Without a key (or when the API cannot be reached) the curated catalogue is returned instead. Args: api_key: Key string, key-file path, or ``None`` for the env var. Returns: list[str]: Sorted model IDs without the ``models/`` prefix. Example: >>> from rapidtools.models import GeminiInference >>> ids = GeminiInference.list_available_models('AIza...') >>> 'gemini-3.8-flash' in ids True """ try: resolved_key = resolve_api_key(api_key, cls.API_KEY_ENV, cls.PROVIDER_NAME) except (ValueError, FileNotFoundError): return catalog_ids(GEMINI_MODEL_CATALOG) url = f'{GEMINI_BASE_URL}/models' try: session = get_configured_session() response = session.get( url, headers={'x-goog-api-key': resolved_key}, timeout=10 ) if response.status_code != 200: return catalog_ids(GEMINI_MODEL_CATALOG) data = response.json() models = [ m['name'].replace('models/', '') for m in data.get('models', []) if 'generateContent' in m.get('supportedGenerationMethods', []) ] return sorted(models) or catalog_ids(GEMINI_MODEL_CATALOG) except Exception: # noqa: BLE001 - network failures degrade gracefully return catalog_ids(GEMINI_MODEL_CATALOG)
def _build_payload( self, contents_parts: list[dict[str, Any]], temperature: float | None, max_tokens: int | None, json_mode: bool, system_instruction: str | None = None, ) -> dict[str, Any]: """Construct the ``generateContent`` request body.""" generation_config: dict[str, Any] = {} if temperature is not None: generation_config['temperature'] = temperature if max_tokens is not None: generation_config['maxOutputTokens'] = max_tokens generation_config['responseMimeType'] = ( 'application/json' if json_mode else 'text/plain' ) payload: dict[str, Any] = { 'contents': [{'parts': contents_parts}], 'safetySettings': self.SAFETY_SETTINGS, 'generationConfig': generation_config, } system = system_instruction or self.system_instruction if system: payload['systemInstruction'] = {'parts': [{'text': system}]} return payload @staticmethod def _extract_text(result_json: dict[str, Any]) -> str | None: """Join the text parts of the first candidate, if any.""" try: parts = result_json['candidates'][0]['content']['parts'] except (KeyError, IndexError, TypeError): return None texts = [p['text'] for p in parts if isinstance(p, dict) and 'text' in p] return ''.join(texts) if texts else None
[docs] def run_inference( self, image_inputs: str | Path | list[str | Path], prompt: str | Path, json_mode: bool = False, max_retries: int | None = None, temperature: float | None = None, max_tokens: int | None = None, config: GenerationConfig | None = None, **kwargs: Any, ) -> ModelOutput | None: """ Send images and a prompt to ``generateContent``. Args: image_inputs: One path/URL or a list of them. Unreadable images are skipped with a logged error; text-only prompts are allowed by passing ``[]``. prompt: Prompt text or path to a prompt file. json_mode: Request ``application/json`` output. max_retries: Per-call override of the session retry count. temperature: Per-call temperature override. max_tokens: Per-call output-token limit override. config: A :class:`~rapidtools.models.GenerationConfig`; explicit keyword arguments take precedence over it. **kwargs: Ignored; accepted for interface compatibility. Returns: ModelOutput | None: The response text and raw JSON, or ``None`` when the request failed, was blocked by safety filters, or nothing could be sent. Example: >>> from rapidtools.models import GeminiInference >>> model = GeminiInference(api_key='AIza...') >>> model.run_inference(['a.jpg', 'b.jpg'], 'Which roof is worse?').text 'The roof in the second image shows more severe damage.' """ prompt_str = self._resolve_prompt(prompt) log_ctx = f"[Prompt snippet: '{prompt_str[:30]}...']" if image_inputs is None: image_inputs = [] elif not isinstance(image_inputs, list): image_inputs = [image_inputs] contents_parts: list[dict[str, Any]] = [] for img_input in image_inputs: img_data = self._fetch_and_encode_image(img_input) if img_data: contents_parts.append({'inline_data': img_data}) if image_inputs and not contents_parts: logger.error(f'{log_ctx} No valid images were loaded.') return None if not prompt_str and not contents_parts: logger.error(f'{log_ctx} No valid text or images were loaded.') return None contents_parts.append({'text': prompt_str}) gen = self._resolve_generation( config, temperature=temperature, max_tokens=max_tokens, json_mode=json_mode ) payload = self._build_payload( contents_parts, gen.temperature, gen.max_tokens, gen.json_mode, gen.system_instruction, ) url = f'{GEMINI_BASE_URL}/models/{self.model_id}:generateContent' headers = {'x-goog-api-key': self.api_key} session_to_use = self.session should_close_session = False if max_retries is not None: session_to_use = api_session(max_retries) should_close_session = True try: response = session_to_use.post( url, json=payload, headers=headers, timeout=self.timeout ) response.raise_for_status() result_json = response.json() extracted_text = self._extract_text(result_json) if extracted_text is not None: return ModelOutput(text=extracted_text, raw_response=result_json) block_reason = result_json.get('promptFeedback', {}).get('blockReason') if block_reason: reason = f'Blocked by safety filters. Reason: {block_reason}' logger.warning(f'{log_ctx} {reason}') return ModelOutput.failure(reason, raw_response=result_json) candidates = result_json.get('candidates', []) if candidates: finish_reason = candidates[0].get('finishReason') if finish_reason != 'STOP': reason = ( 'Generation halted unexpectedly. ' f'Finish Reason: {finish_reason}' ) logger.warning(f'{log_ctx} {reason}') return ModelOutput.failure(reason, raw_response=result_json) logger.error(f'{log_ctx} Unexpected response format: {result_json}') return None except RetryError: logger.error(f'{log_ctx} Max retries exceeded.') except HTTPError as e: body = e.response.text if e.response is not None else '' logger.error(f'{log_ctx} HTTP Error: {e} | Response: {body}') status = e.response.status_code if e.response is not None else None if status is not None and not is_retryable_status(status): return ModelOutput.failure(f'HTTP {status}', retryable=False) except Exception as e: # noqa: BLE001 - surfaced as a failed asset logger.error(f'{log_ctx} Unexpected error: {e}') finally: if should_close_session: session_to_use.close() return None