File size: 8,527 Bytes
04c4194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
"""
DocuLens - Google Gemini Vision Extraction Provider.

Uses the Gemini REST API (Google AI Studio free tier) for document
extraction.  Designed as a drop-in alternative to the HF Inference
pipeline β€” accepts the same prompt and returns raw text that
_parse_vlm_json / _vlm_data_to_result can process.

Environment variables:
  GEMINI_API_KEY   β€” Google AI Studio API key (required for Gemini models)
  GEMINI_MODEL     β€” Model name override (default: gemini-3.6-flash)
  GEMINI_TIMEOUT   β€” Request timeout in seconds (default: 120)
  GEMINI_MAX_RETRIES β€” Retry count on transient errors (default: 2)

Usage:
    from gemini_client import extract_with_gemini

    result_text = extract_with_gemini(image_b64, prompt, model="gemini-3.6-flash")
"""

import os
import time
import json
import logging
from typing import Optional

import requests

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
GEMINI_DEFAULT_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.5-flash-lite")
GEMINI_TIMEOUT = int(os.environ.get("GEMINI_TIMEOUT", "120"))
GEMINI_MAX_RETRIES = int(os.environ.get("GEMINI_MAX_RETRIES", "2"))
GEMINI_RETRY_BASE_DELAY = float(os.environ.get("GEMINI_RETRY_BASE_DELAY", "1.0"))

# Google AI Studio REST endpoint
_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models"

# Transient HTTP status codes worth retrying
_TRANSIENT_CODES = {429, 500, 502, 503}


# ---------------------------------------------------------------------------
# Low-level API call with retry
# ---------------------------------------------------------------------------

def _call_gemini(
    model: str,
    prompt: str,
    image_b64: str,
    *,
    max_tokens: int = 4096,
    temperature: float = 0.1,
    timeout: Optional[int] = None,
    max_retries: Optional[int] = None,
) -> str:
    """Call the Gemini generateContent API and return the text response.

    Uses header-based auth (x-goog-api-key) per Google's recommended
    approach. For Gemini 3.x thinking models, sets thinkingConfig to
    minimal to reduce latency for structured extraction tasks.

    Raises on non-transient errors or after exhausting retries.
    """
    api_key = GEMINI_API_KEY
    if not api_key:
        raise RuntimeError("GEMINI_API_KEY environment variable is not set")

    url = f"{_BASE_URL}/{model}:generateContent"
    headers = {
        "Content-Type": "application/json",
        "x-goog-api-key": api_key,
    }

    # Build generationConfig β€” adapt thinkingConfig to model generation:
    #   Gemini 3.x uses thinkingLevel: "minimal" | "low" | "medium" | "high"
    #   Gemini 2.5  uses thinkingBudget: 0..24576
    #   Omitting thinkingConfig entirely also works (uses model default).
    generation_config: dict = {
        "temperature": temperature,
        "maxOutputTokens": max_tokens,
    }
    # Use minimal thinking for extraction β€” we want deterministic JSON, not
    # chain-of-thought reasoning, and it reduces latency + token usage.
    if model.startswith("gemini-3"):
        generation_config["thinkingConfig"] = {"thinkingLevel": "minimal"}
    elif model.startswith("gemini-2.5"):
        generation_config["thinkingConfig"] = {"thinkingBudget": 0}

    payload = {
        "contents": [
            {
                "parts": [
                    {
                        "inline_data": {
                            "mime_type": "image/jpeg",
                            "data": image_b64,
                        }
                    },
                    {"text": prompt},
                ],
            }
        ],
        "generationConfig": generation_config,
    }

    retries = max_retries if max_retries is not None else GEMINI_MAX_RETRIES
    delay = GEMINI_RETRY_BASE_DELAY
    t = timeout or GEMINI_TIMEOUT

    # Log payload size for latency diagnostics
    payload_json = json.dumps(payload)
    payload_kb = len(payload_json) / 1024
    logger.info(
        "Gemini %s payload: %.0f KB (image b64: %.0f KB, prompt: %d chars)",
        model, payload_kb, len(image_b64) / 1024, len(prompt),
    )

    last_exc: Optional[Exception] = None
    for attempt in range(1 + retries):
        try:
            logger.info(
                "Gemini %s attempt %d/%d",
                model, attempt + 1, 1 + retries,
            )
            t0 = time.monotonic()
            resp = requests.post(url, json=payload, headers=headers, timeout=t)
            elapsed_s = time.monotonic() - t0
            logger.info(
                "Gemini %s HTTP %d in %.1fs (%.0f KB response)",
                model, resp.status_code, elapsed_s, len(resp.content) / 1024,
            )

            if resp.status_code in _TRANSIENT_CODES and attempt < retries:
                wait = delay * (2 ** attempt)
                logger.warning(
                    "Gemini %s transient %d (attempt %d/%d). Retrying in %.1fs... Body: %s",
                    model, resp.status_code, attempt + 1, 1 + retries, wait,
                    resp.text[:300],
                )
                time.sleep(wait)
                continue

            if not resp.ok:
                body = resp.text[:500]
                logger.error(
                    "Gemini %s HTTP %d: %s",
                    model, resp.status_code, body,
                )
            resp.raise_for_status()
            data = resp.json()

            # Extract text from response β€” thinking models may include
            # "thought" parts alongside the actual text output.
            candidates = data.get("candidates", [])
            if not candidates:
                raise RuntimeError(f"Gemini returned no candidates: {json.dumps(data)[:300]}")

            parts = candidates[0].get("content", {}).get("parts", [])
            # Filter out "thought" parts β€” only keep regular text output
            text_parts = [
                p["text"] for p in parts
                if "text" in p and not p.get("thought")
            ]
            if not text_parts:
                # Fallback: include thought parts if no regular text found
                text_parts = [p["text"] for p in parts if "text" in p]
            if not text_parts:
                raise RuntimeError(f"Gemini returned no text parts: {json.dumps(data)[:300]}")

            return "\n".join(text_parts)

        except requests.exceptions.Timeout as e:
            last_exc = e
            if attempt < retries:
                wait = delay * (2 ** attempt)
                logger.warning(
                    "Gemini %s timeout (attempt %d/%d). Retrying in %.1fs...",
                    model, attempt + 1, 1 + retries, wait,
                )
                time.sleep(wait)
            else:
                raise
        except requests.exceptions.HTTPError as e:
            # Non-transient HTTP error β€” don't retry
            raise
        except Exception as e:
            last_exc = e
            if attempt < retries:
                wait = delay * (2 ** attempt)
                logger.warning(
                    "Gemini %s error (attempt %d/%d): %s. Retrying in %.1fs...",
                    model, attempt + 1, 1 + retries, str(e)[:120], wait,
                )
                time.sleep(wait)
            else:
                raise

    if last_exc:
        raise last_exc
    raise RuntimeError("Gemini call failed silently")


# ---------------------------------------------------------------------------
# Public extraction function
# ---------------------------------------------------------------------------

def extract_with_gemini(
    image_b64: str,
    prompt: str,
    model: str = "",
) -> str:
    """Extract document data using Google Gemini Vision API.

    Args:
        image_b64: Base64-encoded JPEG image data (no data-URI prefix).
        prompt: The extraction prompt text.
        model: Gemini model id (e.g. "gemini-3.6-flash"). Falls back to
               GEMINI_DEFAULT_MODEL env var.

    Returns:
        Raw text response from Gemini (JSON string to be parsed by caller).

    Raises:
        RuntimeError: If GEMINI_API_KEY is not configured.
        requests.exceptions.HTTPError: On non-transient API errors.
    """
    model_id = model or GEMINI_DEFAULT_MODEL
    logger.info("Gemini extraction using model=%s", model_id)
    return _call_gemini(model_id, prompt, image_b64)