parse-bench 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. parse_bench/__init__.py +3 -0
  2. parse_bench/analysis/__init__.py +6 -0
  3. parse_bench/analysis/aggregation_report.py +582 -0
  4. parse_bench/analysis/cli.py +472 -0
  5. parse_bench/analysis/comparison.py +382 -0
  6. parse_bench/analysis/comparison_core.py +357 -0
  7. parse_bench/analysis/comparison_report.py +2066 -0
  8. parse_bench/analysis/detailed_report.py +2254 -0
  9. parse_bench/analysis/leaderboard_report.py +852 -0
  10. parse_bench/analysis/metric_definitions.py +771 -0
  11. parse_bench/cli.py +267 -0
  12. parse_bench/data/__init__.py +1 -0
  13. parse_bench/data/cli.py +118 -0
  14. parse_bench/data/download.py +127 -0
  15. parse_bench/evaluation/__init__.py +11 -0
  16. parse_bench/evaluation/cli.py +435 -0
  17. parse_bench/evaluation/evaluators/__init__.py +17 -0
  18. parse_bench/evaluation/evaluators/base.py +34 -0
  19. parse_bench/evaluation/evaluators/extract.py +429 -0
  20. parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
  21. parse_bench/evaluation/evaluators/parse.py +1353 -0
  22. parse_bench/evaluation/evaluators/qa.py +199 -0
  23. parse_bench/evaluation/layout_adapters/__init__.py +21 -0
  24. parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
  25. parse_bench/evaluation/layout_adapters/base.py +105 -0
  26. parse_bench/evaluation/layout_adapters/registry.py +109 -0
  27. parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
  28. parse_bench/evaluation/layout_label_mappers/base.py +66 -0
  29. parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
  30. parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
  31. parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
  32. parse_bench/evaluation/metric_aggregation.py +56 -0
  33. parse_bench/evaluation/metrics/__init__.py +5 -0
  34. parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
  35. parse_bench/evaluation/metrics/attribution/constants.py +12 -0
  36. parse_bench/evaluation/metrics/attribution/core.py +1108 -0
  37. parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
  38. parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
  39. parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
  40. parse_bench/evaluation/metrics/base.py +33 -0
  41. parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
  42. parse_bench/evaluation/metrics/extract/__init__.py +29 -0
  43. parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
  44. parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
  45. parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
  46. parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
  47. parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
  48. parse_bench/evaluation/metrics/extract/test_types.py +11 -0
  49. parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
  50. parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
  51. parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
  52. parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
  53. parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
  54. parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
  55. parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
  56. parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
  57. parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
  58. parse_bench/evaluation/metrics/parse/__init__.py +5 -0
  59. parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
  60. parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
  61. parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
  62. parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
  63. parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
  64. parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
  65. parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
  66. parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
  67. parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
  68. parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
  69. parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
  70. parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
  71. parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
  72. parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
  73. parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
  74. parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
  75. parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
  76. parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
  77. parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
  78. parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
  79. parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
  80. parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
  81. parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
  82. parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
  83. parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
  84. parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
  85. parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
  86. parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
  87. parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
  88. parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
  89. parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
  90. parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
  91. parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
  92. parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
  93. parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
  94. parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
  95. parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
  96. parse_bench/evaluation/metrics/parse/test_types.py +103 -0
  97. parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
  98. parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
  99. parse_bench/evaluation/metrics/parse/utils.py +885 -0
  100. parse_bench/evaluation/metrics/qa/__init__.py +5 -0
  101. parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
  102. parse_bench/evaluation/qa/__init__.py +5 -0
  103. parse_bench/evaluation/qa/llm_service.py +335 -0
  104. parse_bench/evaluation/reports/__init__.py +8 -0
  105. parse_bench/evaluation/reports/csv.py +64 -0
  106. parse_bench/evaluation/reports/html.py +338 -0
  107. parse_bench/evaluation/reports/markdown.py +98 -0
  108. parse_bench/evaluation/reports/rule_csv.py +22 -0
  109. parse_bench/evaluation/runner.py +1864 -0
  110. parse_bench/evaluation/stats.py +104 -0
  111. parse_bench/extensions.py +72 -0
  112. parse_bench/inference/__init__.py +33 -0
  113. parse_bench/inference/chunkr_layout_extraction.py +160 -0
  114. parse_bench/inference/cli.py +484 -0
  115. parse_bench/inference/layout_extraction.py +422 -0
  116. parse_bench/inference/pipelines/__init__.py +59 -0
  117. parse_bench/inference/pipelines/extract.py +39 -0
  118. parse_bench/inference/pipelines/layout.py +142 -0
  119. parse_bench/inference/pipelines/parse.py +2603 -0
  120. parse_bench/inference/pipelines.py +0 -0
  121. parse_bench/inference/providers/__init__.py +28 -0
  122. parse_bench/inference/providers/base.py +196 -0
  123. parse_bench/inference/providers/cancellation.py +137 -0
  124. parse_bench/inference/providers/extract/__init__.py +22 -0
  125. parse_bench/inference/providers/extract/citations.py +549 -0
  126. parse_bench/inference/providers/extract/extend.py +851 -0
  127. parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
  128. parse_bench/inference/providers/layoutdet/__init__.py +25 -0
  129. parse_bench/inference/providers/layoutdet/adapters.py +946 -0
  130. parse_bench/inference/providers/layoutdet/base.py +203 -0
  131. parse_bench/inference/providers/layoutdet/chandra.py +449 -0
  132. parse_bench/inference/providers/layoutdet/docling.py +125 -0
  133. parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
  134. parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
  135. parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
  136. parse_bench/inference/providers/layoutdet/paddle.py +117 -0
  137. parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
  138. parse_bench/inference/providers/layoutdet/surya.py +250 -0
  139. parse_bench/inference/providers/layoutdet/yolo.py +109 -0
  140. parse_bench/inference/providers/parse/__init__.py +64 -0
  141. parse_bench/inference/providers/parse/_docling_common.py +233 -0
  142. parse_bench/inference/providers/parse/_layout_utils.py +611 -0
  143. parse_bench/inference/providers/parse/amazon_nova.py +515 -0
  144. parse_bench/inference/providers/parse/anthropic.py +882 -0
  145. parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
  146. parse_bench/inference/providers/parse/chandra2.py +633 -0
  147. parse_bench/inference/providers/parse/chunkr.py +268 -0
  148. parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
  149. parse_bench/inference/providers/parse/datalab.py +370 -0
  150. parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
  151. parse_bench/inference/providers/parse/docling.py +281 -0
  152. parse_bench/inference/providers/parse/docling_serve.py +289 -0
  153. parse_bench/inference/providers/parse/dots_ocr.py +574 -0
  154. parse_bench/inference/providers/parse/extend_parse.py +710 -0
  155. parse_bench/inference/providers/parse/falconocr.py +436 -0
  156. parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
  157. parse_bench/inference/providers/parse/gemma4.py +472 -0
  158. parse_bench/inference/providers/parse/glm_zai.py +229 -0
  159. parse_bench/inference/providers/parse/google.py +1125 -0
  160. parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
  161. parse_bench/inference/providers/parse/google_docai.py +776 -0
  162. parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
  163. parse_bench/inference/providers/parse/granite_vision.py +515 -0
  164. parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
  165. parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
  166. parse_bench/inference/providers/parse/landingai.py +452 -0
  167. parse_bench/inference/providers/parse/liteparse.py +350 -0
  168. parse_bench/inference/providers/parse/llamaparse.py +677 -0
  169. parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
  170. parse_bench/inference/providers/parse/markitdown.py +138 -0
  171. parse_bench/inference/providers/parse/mineru25.py +405 -0
  172. parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
  173. parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
  174. parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
  175. parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
  176. parse_bench/inference/providers/parse/oi_parser.py +222 -0
  177. parse_bench/inference/providers/parse/openai.py +740 -0
  178. parse_bench/inference/providers/parse/opendataloader.py +152 -0
  179. parse_bench/inference/providers/parse/paddleocr.py +624 -0
  180. parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
  181. parse_bench/inference/providers/parse/pulse.py +785 -0
  182. parse_bench/inference/providers/parse/pymupdf.py +207 -0
  183. parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
  184. parse_bench/inference/providers/parse/pypdf.py +179 -0
  185. parse_bench/inference/providers/parse/qwen.py +678 -0
  186. parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
  187. parse_bench/inference/providers/parse/reducto.py +546 -0
  188. parse_bench/inference/providers/parse/surya2.py +372 -0
  189. parse_bench/inference/providers/parse/tesseract.py +301 -0
  190. parse_bench/inference/providers/parse/textract.py +694 -0
  191. parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
  192. parse_bench/inference/providers/parse/unstructured.py +485 -0
  193. parse_bench/inference/providers/parse/warp_ingest.py +199 -0
  194. parse_bench/inference/providers/registry.py +49 -0
  195. parse_bench/inference/renormalize.py +170 -0
  196. parse_bench/inference/runner.py +2023 -0
  197. parse_bench/layout_label_mapping.py +424 -0
  198. parse_bench/layout_projection.py +179 -0
  199. parse_bench/pipeline/__init__.py +1 -0
  200. parse_bench/pipeline/cli.py +549 -0
  201. parse_bench/schemas/__init__.py +33 -0
  202. parse_bench/schemas/evaluation.py +93 -0
  203. parse_bench/schemas/extract_output.py +36 -0
  204. parse_bench/schemas/layout_detection_output.py +545 -0
  205. parse_bench/schemas/layout_ontology.py +315 -0
  206. parse_bench/schemas/metrics.py +69 -0
  207. parse_bench/schemas/parse_output.py +152 -0
  208. parse_bench/schemas/pipeline.py +22 -0
  209. parse_bench/schemas/pipeline_io.py +106 -0
  210. parse_bench/schemas/product.py +97 -0
  211. parse_bench/test_cases/__init__.py +25 -0
  212. parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
  213. parse_bench/test_cases/extract_field_paths.py +164 -0
  214. parse_bench/test_cases/layout_attribution_generation.py +287 -0
  215. parse_bench/test_cases/loader.py +652 -0
  216. parse_bench/test_cases/parse_rule_schemas.py +1071 -0
  217. parse_bench/test_cases/rule_filters.py +32 -0
  218. parse_bench/test_cases/rule_ids.py +107 -0
  219. parse_bench/test_cases/schema.py +427 -0
  220. parse_bench/utils/__init__.py +15 -0
  221. parse_bench/utils/gemini_layout_utils.py +670 -0
  222. parse_bench/utils/text_aggregation.py +100 -0
  223. parse_bench-1.0.0.dist-info/METADATA +476 -0
  224. parse_bench-1.0.0.dist-info/RECORD +227 -0
  225. parse_bench-1.0.0.dist-info/WHEEL +4 -0
  226. parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
  227. parse_bench-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,1125 @@
1
+ """Provider for Gemini 3 Flash vision-based PARSE."""
2
+
3
+ import io
4
+ import logging
5
+ import os
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from PIL import Image
11
+
12
+ from parse_bench.inference.providers.base import (
13
+ Provider,
14
+ ProviderConfigError,
15
+ ProviderPermanentError,
16
+ ProviderTransientError,
17
+ )
18
+ from parse_bench.inference.providers.parse._layout_utils import (
19
+ build_layout_pages,
20
+ items_to_markdown,
21
+ parse_layout_blocks,
22
+ resolve_layout_prompts,
23
+ split_pdf_to_pages,
24
+ swap_gemini_bbox,
25
+ )
26
+ from parse_bench.inference.providers.parse.google_agentic_vision import (
27
+ GoogleAgenticVisionRunner,
28
+ build_layout_pages_from_agentic_items,
29
+ )
30
+ from parse_bench.inference.providers.registry import register_provider
31
+ from parse_bench.schemas.parse_output import (
32
+ PageIR,
33
+ ParseLayoutPageIR,
34
+ ParseOutput,
35
+ )
36
+ from parse_bench.schemas.pipeline import PipelineSpec
37
+ from parse_bench.schemas.pipeline_io import (
38
+ InferenceRequest,
39
+ InferenceResult,
40
+ RawInferenceResult,
41
+ )
42
+ from parse_bench.schemas.product import ProductType
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ SYSTEM_PROMPT = (
47
+ "You are a document parser. Your task is to convert "
48
+ "document images to clean, well-structured markdown."
49
+ "\n\nGuidelines:\n"
50
+ "- Preserve the document structure "
51
+ "(headings, paragraphs, lists, tables)\n"
52
+ "- Convert tables to HTML format "
53
+ "(<table>, <tr>, <th>, <td>)\n"
54
+ "- For existing tables in the document: use colspan "
55
+ "and rowspan attributes to preserve merged cells "
56
+ "and hierarchical headers\n"
57
+ "- For charts/graphs being converted to tables: use "
58
+ "flat combined column headers (e.g., "
59
+ '"Primary 2015" not separate rows) so each data '
60
+ "cell's row contains all its labels\n"
61
+ "- Describe images/figures briefly in square brackets "
62
+ "like [Figure: description]\n"
63
+ "- Preserve any code blocks with appropriate syntax "
64
+ "highlighting\n"
65
+ "- Maintain reading order (left-to-right, "
66
+ "top-to-bottom for Western documents)\n"
67
+ "- Do not add commentary or explanations "
68
+ "- only output the parsed content"
69
+ )
70
+
71
+ USER_PROMPT = (
72
+ "Parse this document page and output its content as "
73
+ "clean markdown. Use HTML tables for any tabular "
74
+ "data. For charts/graphs, use flat combined column "
75
+ "headers. Output ONLY the parsed content, "
76
+ "no explanations."
77
+ )
78
+
79
+ # Gemini pricing: USD per million tokens (input, output)
80
+ # Thinking tokens are billed at the output token rate.
81
+ # Source: https://ai.google.dev/gemini-api/docs/pricing (2026-03-25)
82
+ # gemini-3.7-flash is introductory pricing through 2026-12-31; it rises to
83
+ # (1.50, 7.50) on 2027-01-01.
84
+ # gemini-3.8-flash (preview id "skimaki") launches 2026-09-02 with the same
85
+ # discounted structure as 3.7-flash (Google DeepMind partner note, 2026-08-31).
86
+ _GEMINI_PRICING_PER_M: dict[str, tuple[float, float]] = {
87
+ # model-prefix: (input_per_M, output_per_M)
88
+ "gemini-3.7-flash": (0.75, 3.75),
89
+ "gemini-3.8-flash": (0.75, 3.75),
90
+ "gemini-3.6-flash": (1.50, 7.50),
91
+ "gemini-3.5-flash": (1.50, 9.00),
92
+ "gemini-3.5-flash-lite": (0.30, 2.50),
93
+ "gemini-3-flash": (0.50, 3.00),
94
+ "gemini-3.1-flash-lite": (0.25, 1.50),
95
+ "gemini-2.5-flash": (0.30, 2.50),
96
+ "gemini-2.5-flash-lite": (0.10, 0.40),
97
+ "gemini-2.0-flash": (0.10, 0.40),
98
+ "gemini-2.0-flash-lite": (0.075, 0.30),
99
+ "gemini-2.5-pro": (1.25, 10.00),
100
+ "gemini-3.1-pro": (2.00, 12.00),
101
+ # Legacy 1.5 series (≤128k prompt tier; Gemini 1.5 prices were tiered above 128k)
102
+ "gemini-1.5-pro": (1.25, 5.00),
103
+ "gemini-1.5-flash": (0.075, 0.30),
104
+ "gemini-1.5-flash-8b": (0.0375, 0.15),
105
+ }
106
+
107
+ # Gemini context caching pricing: USD per million tokens / per million token-hours.
108
+ # Source: https://ai.google.dev/gemini-api/docs/pricing (2026-04-05)
109
+ _GEMINI_CONTEXT_CACHE_PRICING_PER_M: dict[str, tuple[float, float]] = {
110
+ # model-prefix: (cache_hit_per_M, storage_per_M_token_hour)
111
+ "gemini-3-flash": (0.05, 1.00),
112
+ "gemini-3.1-flash-lite": (0.025, 1.00),
113
+ "gemini-3.5-flash": (0.15, 1.00),
114
+ "gemini-3.5-flash-lite": (0.03, 1.00),
115
+ "gemini-3.6-flash": (0.15, 1.00),
116
+ "gemini-2.5-flash": (0.03, 1.00),
117
+ "gemini-2.5-flash-lite": (0.01, 1.00),
118
+ "gemini-2.5-pro": (0.125, 4.50),
119
+ "gemini-3.1-pro": (0.20, 4.50),
120
+ }
121
+
122
+ # Appended to the user turn when Gemini's recitation guard blocked the first
123
+ # attempt: document transcription is not a request to reproduce a known work.
124
+ _RECITATION_RETRY_PROMPT = (
125
+ "Retry mode: the previous attempt triggered Gemini's recitation guard. "
126
+ "Do not rely on memorized text, web recall, citations, URLs, or external sources. "
127
+ "Use only the attached document image as evidence. This is document transcription "
128
+ "into structured markdown/HTML, not a request to reproduce a known work. "
129
+ "Return only the requested output with no commentary."
130
+ )
131
+
132
+
133
+ def _request_timeout_ms(timeout_seconds: float | int | str | None) -> int | None:
134
+ if timeout_seconds is None:
135
+ return None
136
+ try:
137
+ value = float(timeout_seconds)
138
+ except (TypeError, ValueError):
139
+ return None
140
+ if value <= 0:
141
+ return None
142
+ return max(1000, int(round(value * 1000.0)))
143
+
144
+
145
+ def _is_retryable_empty_gemini_reason(reason: str) -> bool:
146
+ normalized = reason.lower()
147
+ return any(
148
+ marker in normalized
149
+ for marker in (
150
+ "finish_reason=",
151
+ "no candidates",
152
+ "prompt blocked",
153
+ "candidate has no content",
154
+ "candidate content has no parts",
155
+ "empty text",
156
+ "unusable table placeholder",
157
+ )
158
+ )
159
+
160
+
161
+ def _is_recitation_gemini_reason(reason: str) -> bool:
162
+ return "recitation" in reason.lower()
163
+
164
+
165
+ def _is_rate_limited_gemini_reason(reason: str) -> bool:
166
+ normalized = reason.lower()
167
+ return any(marker in normalized for marker in ("rate_limit", "rate limit", "429", "resource_exhausted"))
168
+
169
+
170
+ @register_provider("google")
171
+ class GoogleProvider(Provider):
172
+ """
173
+ Provider for Google Gemini vision-based document parsing.
174
+
175
+ Renders PDF pages to images and uses Gemini's vision
176
+ capabilities to parse document content to markdown.
177
+ """
178
+
179
+ def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
180
+ """
181
+ Initialize the provider.
182
+
183
+ :param provider_name: Name of the provider
184
+ :param base_config: Optional configuration with:
185
+ - `model`: Gemini model to use (default: "gemini-3-flash-preview")
186
+ - `dpi`: DPI for PDF to image conversion (default: 150)
187
+ - `max_tokens`: Max tokens per response (default: 8192)
188
+ - `timeout`: Request timeout in seconds (default: 120)
189
+ - `thinking_level`: Thinking level for Gemini 3 models
190
+ ("minimal", "low", "medium", "high"). If not set, uses
191
+ model default.
192
+ - `mode`: "image" (default) to send page screenshots, or "file" to send raw PDF
193
+ """
194
+ super().__init__(provider_name, base_config)
195
+
196
+ # Get API key from environment
197
+ self._api_key = os.environ.get("GOOGLE_GEMINI_API_KEY")
198
+ if not self._api_key:
199
+ raise ProviderConfigError("GOOGLE_GEMINI_API_KEY environment variable not set")
200
+
201
+ # Configuration
202
+ self._model = self.base_config.get("model", "gemini-3-flash-preview")
203
+ self._dpi = self.base_config.get("dpi", 150)
204
+ self._max_tokens = self.base_config.get("max_tokens", 8192)
205
+ self._timeout = self.base_config.get("timeout", 120)
206
+ self._thinking_level = self.base_config.get("thinking_level", None)
207
+ self._mode = self.base_config.get("mode", "image") # "image" or "file"
208
+ self._enable_explicit_context_cache = bool(self.base_config.get("enable_explicit_context_cache", False))
209
+ self._context_cache_ttl_seconds = int(self.base_config.get("context_cache_ttl_seconds", 900))
210
+ self._min_cacheable_tokens = int(self.base_config.get("min_cacheable_tokens", 1024))
211
+
212
+ if self._mode not in (
213
+ "image",
214
+ "file",
215
+ "parse_with_layout",
216
+ "parse_with_layout_file",
217
+ "parse_with_layout_agentic_vision",
218
+ ):
219
+ raise ProviderConfigError(
220
+ f"Invalid mode '{self._mode}'. "
221
+ "Must be 'image', 'file', 'parse_with_layout', 'parse_with_layout_file', "
222
+ "or 'parse_with_layout_agentic_vision'."
223
+ )
224
+
225
+ # Grid the layout-mode bboxes are on: 1000 (the 0-1000 prompt, the
226
+ # default) or None (absolute pixels of the sent image, for models
227
+ # that ground well but re-normalize unreliably).
228
+ self._bbox_scale = self.base_config.get("bbox_scale", 1000)
229
+ self._layout_system_prompt, self._layout_user_prompt = resolve_layout_prompts(
230
+ self._bbox_scale,
231
+ self._mode,
232
+ gemini=True,
233
+ pixel_disallowed_modes=("parse_with_layout_file", "parse_with_layout_agentic_vision"),
234
+ )
235
+
236
+ # Initialize Gemini client
237
+ try:
238
+ from google import genai
239
+ from google.genai import types
240
+
241
+ self._types = types
242
+ timeout_ms = _request_timeout_ms(self._timeout)
243
+ http_options = types.HttpOptions(timeout=timeout_ms) if timeout_ms is not None else None
244
+ self._client = genai.Client(api_key=self._api_key, http_options=http_options)
245
+ except ImportError as e:
246
+ raise ProviderConfigError("google-genai package not installed. Run: pip install google-genai") from e
247
+
248
+ # Gemini API limits
249
+ MAX_IMAGE_DIMENSION = 8000 # pixels
250
+ MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024 # 20 MB (raw bytes, no base64 overhead)
251
+
252
+ def _get_pricing(self) -> tuple[float, float]:
253
+ """Return (input_rate, output_rate) in USD per million tokens.
254
+
255
+ Uses longest-prefix matching to avoid ambiguity when one model
256
+ prefix is a substring of another (e.g. "gemini-2.5-flash" vs
257
+ "gemini-2.5-flash-lite").
258
+ """
259
+ matches = [(p, r) for p, r in _GEMINI_PRICING_PER_M.items() if self._model.startswith(p)]
260
+ return max(matches, key=lambda x: len(x[0]))[1] if matches else (0.0, 0.0)
261
+
262
+ def _get_context_cache_pricing(self) -> tuple[float, float]:
263
+ """Return (cache_hit_rate, storage_rate) in USD per million tokens."""
264
+ matches = [(p, r) for p, r in _GEMINI_CONTEXT_CACHE_PRICING_PER_M.items() if self._model.startswith(p)]
265
+ return max(matches, key=lambda x: len(x[0]))[1] if matches else (0.0, 0.0)
266
+
267
+ def _usage_cost_breakdown(self, usage: dict[str, Any]) -> dict[str, float]:
268
+ """Compute cost breakdown for one Gemini API call."""
269
+ input_rate, output_rate = self._get_pricing()
270
+ cache_hit_rate, _ = self._get_context_cache_pricing()
271
+
272
+ input_tokens = int(usage.get("input_tokens", 0) or 0)
273
+ cached_content_tokens = min(input_tokens, int(usage.get("cached_content_tokens", 0) or 0))
274
+ tool_use_prompt_tokens = int(usage.get("tool_use_prompt_tokens", 0) or 0)
275
+ output_tokens = int(usage.get("output_tokens", 0) or 0)
276
+ thinking_tokens = int(usage.get("thinking_tokens", 0) or 0)
277
+ # When the API already counts thoughts inside output_tokens, do not bill them twice.
278
+ thinking_tokens_in_output_tokens = bool(usage.get("thinking_tokens_in_output_tokens", False))
279
+
280
+ non_cached_input_tokens = max(input_tokens - cached_content_tokens - tool_use_prompt_tokens, 0)
281
+ input_cost_usd = non_cached_input_tokens * input_rate / 1_000_000
282
+ tool_use_prompt_cost_usd = tool_use_prompt_tokens * input_rate / 1_000_000
283
+ cached_input_cost_usd = cached_content_tokens * cache_hit_rate / 1_000_000
284
+ output_cost_usd = output_tokens * output_rate / 1_000_000
285
+ thinking_cost_usd = 0.0 if thinking_tokens_in_output_tokens else thinking_tokens * output_rate / 1_000_000
286
+ output_and_thinking_cost_usd = output_cost_usd + thinking_cost_usd
287
+ cost_usd = input_cost_usd + tool_use_prompt_cost_usd + cached_input_cost_usd + output_and_thinking_cost_usd
288
+
289
+ return {
290
+ "input_cost_usd": input_cost_usd,
291
+ "tool_use_prompt_cost_usd": tool_use_prompt_cost_usd,
292
+ "cached_input_cost_usd": cached_input_cost_usd,
293
+ "output_cost_usd": output_cost_usd,
294
+ "thinking_cost_usd": thinking_cost_usd,
295
+ "output_and_thinking_cost_usd": output_and_thinking_cost_usd,
296
+ "cost_usd": cost_usd,
297
+ }
298
+
299
+ def _compute_usage_cost_summary(
300
+ self,
301
+ usages: list[dict[str, int]],
302
+ *,
303
+ num_pages: int,
304
+ cache_storage_cost_usd: float = 0.0,
305
+ ) -> dict[str, float | int]:
306
+ """Aggregate token and cost accounting across all Gemini calls for one document."""
307
+ total_input = sum(int(usage.get("input_tokens", 0) or 0) for usage in usages)
308
+ total_tool_use_prompt = sum(int(usage.get("tool_use_prompt_tokens", 0) or 0) for usage in usages)
309
+ total_cached_content = sum(int(usage.get("cached_content_tokens", 0) or 0) for usage in usages)
310
+ total_output = sum(int(usage.get("output_tokens", 0) or 0) for usage in usages)
311
+ total_thinking = sum(int(usage.get("thinking_tokens", 0) or 0) for usage in usages)
312
+ total_tokens = sum(int(usage.get("total_tokens", 0) or 0) for usage in usages)
313
+
314
+ per_call_breakdowns = [self._usage_cost_breakdown(usage) for usage in usages]
315
+ input_cost_usd = sum(breakdown["input_cost_usd"] for breakdown in per_call_breakdowns)
316
+ tool_use_prompt_cost_usd = sum(breakdown["tool_use_prompt_cost_usd"] for breakdown in per_call_breakdowns)
317
+ cached_input_cost_usd = sum(breakdown["cached_input_cost_usd"] for breakdown in per_call_breakdowns)
318
+ output_and_thinking_cost_usd = sum(
319
+ breakdown["output_and_thinking_cost_usd"] for breakdown in per_call_breakdowns
320
+ )
321
+ cost_usd = (
322
+ input_cost_usd
323
+ + tool_use_prompt_cost_usd
324
+ + cached_input_cost_usd
325
+ + output_and_thinking_cost_usd
326
+ + cache_storage_cost_usd
327
+ )
328
+
329
+ return {
330
+ "input_tokens": total_input,
331
+ "tool_use_prompt_tokens": total_tool_use_prompt,
332
+ "cached_content_tokens": total_cached_content,
333
+ "output_tokens": total_output,
334
+ "thinking_tokens": total_thinking,
335
+ "total_tokens": total_tokens,
336
+ "num_api_calls": len(usages),
337
+ "cost_usd": cost_usd,
338
+ "cost_per_page_usd": cost_usd / num_pages if num_pages > 0 else 0.0,
339
+ "input_cost_usd": input_cost_usd,
340
+ "tool_use_prompt_cost_usd": tool_use_prompt_cost_usd,
341
+ "cached_input_cost_usd": cached_input_cost_usd,
342
+ "output_and_thinking_cost_usd": output_and_thinking_cost_usd,
343
+ "cache_storage_cost_usd": cache_storage_cost_usd,
344
+ "input_tokens_per_page": total_input / num_pages if num_pages > 0 else 0.0,
345
+ "tool_use_prompt_tokens_per_page": total_tool_use_prompt / num_pages if num_pages > 0 else 0.0,
346
+ "cached_content_tokens_per_page": total_cached_content / num_pages if num_pages > 0 else 0.0,
347
+ "output_tokens_per_page": total_output / num_pages if num_pages > 0 else 0.0,
348
+ }
349
+
350
+ def _annotate_api_calls_with_costs(self, api_calls: list[dict[str, Any]]) -> None:
351
+ """Populate cost fields for serialized Agentic Vision API calls."""
352
+ for call in api_calls:
353
+ if not isinstance(call, dict):
354
+ continue
355
+ usage = call.get("usage", {})
356
+ if not isinstance(usage, dict):
357
+ usage = {}
358
+ breakdown = self._usage_cost_breakdown(usage)
359
+ call["cost_usd"] = breakdown["cost_usd"]
360
+ call["cost_breakdown_usd"] = {
361
+ "input_cost_usd": breakdown["input_cost_usd"],
362
+ "tool_use_prompt_cost_usd": breakdown["tool_use_prompt_cost_usd"],
363
+ "cached_input_cost_usd": breakdown["cached_input_cost_usd"],
364
+ "output_and_thinking_cost_usd": breakdown["output_and_thinking_cost_usd"],
365
+ }
366
+
367
+ def _build_agentic_vision_runner(self, expected_page_calls: int) -> GoogleAgenticVisionRunner:
368
+ """Build the shared Agentic Vision runner for one document."""
369
+ input_rate, _ = self._get_pricing()
370
+ cache_hit_rate, storage_rate = self._get_context_cache_pricing()
371
+ return GoogleAgenticVisionRunner(
372
+ client=self._client,
373
+ types_module=self._types,
374
+ model=self._model,
375
+ max_output_tokens=self._max_tokens,
376
+ thinking_level=self._thinking_level,
377
+ enable_explicit_context_cache=self._enable_explicit_context_cache,
378
+ context_cache_ttl_seconds=self._context_cache_ttl_seconds,
379
+ min_cacheable_tokens=self._min_cacheable_tokens,
380
+ input_cost_per_million=input_rate,
381
+ cache_hit_cost_per_million=cache_hit_rate,
382
+ cache_storage_cost_per_million_token_hour=storage_rate,
383
+ expected_page_calls=expected_page_calls,
384
+ )
385
+
386
+ @staticmethod
387
+ def _convert_layout_items_to_agentic_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
388
+ """Convert x-first layout items to Gemini-native y-first bbox ordering."""
389
+ converted: list[dict[str, Any]] = []
390
+ for item in items:
391
+ bbox = item.get("bbox", [])
392
+ if not isinstance(bbox, list) or len(bbox) != 4:
393
+ continue
394
+ x1, y1, x2, y2 = [int(round(float(value))) for value in bbox]
395
+ converted.append(
396
+ {
397
+ "bbox_2d": [y1, x1, y2, x2],
398
+ "label": item.get("label", "Text"),
399
+ "text": item.get("text", ""),
400
+ }
401
+ )
402
+ return converted
403
+
404
+ @staticmethod
405
+ def _extract_usage(response) -> dict[str, int]: # type: ignore[no-untyped-def]
406
+ """Extract token counts from a Gemini API response."""
407
+ meta = getattr(response, "usage_metadata", None)
408
+ if meta is None:
409
+ return {"input_tokens": 0, "output_tokens": 0, "thinking_tokens": 0, "total_tokens": 0}
410
+ input_tok = getattr(meta, "prompt_token_count", 0) or 0
411
+ output_tok = getattr(meta, "candidates_token_count", 0) or 0
412
+ thinking_tok = getattr(meta, "thoughts_token_count", 0) or 0
413
+ total_tok = getattr(meta, "total_token_count", 0) or 0
414
+ return {
415
+ "input_tokens": input_tok,
416
+ "output_tokens": output_tok,
417
+ "thinking_tokens": thinking_tok,
418
+ "total_tokens": total_tok,
419
+ }
420
+
421
+ def _prepare_image_for_api(self, image: Image.Image) -> Image.Image:
422
+ """
423
+ Resize image if it exceeds Gemini API dimension limits.
424
+
425
+ :param image: PIL Image to prepare
426
+ :return: Resized image if needed, otherwise original
427
+ """
428
+ width, height = image.size
429
+ max_dim = max(width, height)
430
+
431
+ if max_dim <= self.MAX_IMAGE_DIMENSION:
432
+ return image
433
+
434
+ # Calculate scale factor to fit within limits
435
+ scale = self.MAX_IMAGE_DIMENSION / max_dim
436
+ new_width = int(width * scale)
437
+ new_height = int(height * scale)
438
+
439
+ return image.resize((new_width, new_height), Image.Resampling.LANCZOS)
440
+
441
+ def _image_to_bytes(self, image: Image.Image) -> bytes:
442
+ """
443
+ Convert PIL Image to JPEG bytes, respecting Gemini API limits.
444
+
445
+ Handles:
446
+ - Images with dimensions exceeding 8000 pixels (resizes proportionally)
447
+ - Images exceeding 20MB (reduces quality iteratively)
448
+ """
449
+ # Resize if dimensions exceed limit
450
+ image = self._prepare_image_for_api(image)
451
+
452
+ # Convert to RGB if necessary (e.g., RGBA images)
453
+ if image.mode in ("RGBA", "P"):
454
+ image = image.convert("RGB")
455
+
456
+ # Try encoding with decreasing quality until under size limit
457
+ quality = 85
458
+ min_quality = 20
459
+
460
+ while quality >= min_quality:
461
+ buffer = io.BytesIO()
462
+ image.save(buffer, format="JPEG", quality=quality)
463
+ data = buffer.getvalue()
464
+
465
+ if len(data) <= self.MAX_IMAGE_SIZE_BYTES:
466
+ return data
467
+
468
+ quality -= 10
469
+
470
+ # If still too large after quality reduction, resize the image
471
+ while True:
472
+ width, height = image.size
473
+ new_width = int(width * 0.8)
474
+ new_height = int(height * 0.8)
475
+
476
+ if new_width < 100 or new_height < 100:
477
+ # Give up - image is too complex to fit in limits
478
+ break
479
+
480
+ image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
481
+
482
+ buffer = io.BytesIO()
483
+ image.save(buffer, format="JPEG", quality=min_quality)
484
+ data = buffer.getvalue()
485
+
486
+ if len(data) <= self.MAX_IMAGE_SIZE_BYTES:
487
+ return data
488
+
489
+ # Final fallback - return what we have
490
+ buffer = io.BytesIO()
491
+ image.save(buffer, format="JPEG", quality=min_quality)
492
+ return buffer.getvalue()
493
+
494
+ def _pdf_to_images(self, pdf_path: str) -> list[Image.Image]:
495
+ """
496
+ Convert PDF pages to images.
497
+
498
+ :param pdf_path: Path to the PDF file
499
+ :return: List of PIL Images, one per page
500
+ """
501
+ try:
502
+ from pdf2image import convert_from_path
503
+ except ImportError as e:
504
+ raise ProviderConfigError("pdf2image package not installed. Run: pip install pdf2image") from e
505
+
506
+ try:
507
+ images = convert_from_path(pdf_path, dpi=self._dpi)
508
+ return images
509
+ except Exception as e:
510
+ raise ProviderPermanentError(f"Failed to convert PDF to images: {e}") from e
511
+
512
+ @staticmethod
513
+ def _extract_text(response) -> str | None: # type: ignore[no-untyped-def]
514
+ """Extract text from a Gemini response, or None if empty."""
515
+ if not response.candidates:
516
+ return None
517
+ content = response.candidates[0].content
518
+ if content is None or content.parts is None:
519
+ return None
520
+ text = content.parts[0].text
521
+ return text if text else None
522
+
523
+ @staticmethod
524
+ def _failure_reason(response) -> str: # type: ignore[no-untyped-def]
525
+ """Return a human-readable reason why a Gemini response had no text."""
526
+ if not response.candidates:
527
+ block_reason = getattr(getattr(response, "prompt_feedback", None), "block_reason", None)
528
+ if block_reason:
529
+ return f"no candidates (prompt blocked: {block_reason})"
530
+ return "no candidates returned"
531
+ candidate = response.candidates[0]
532
+ finish_reason = getattr(candidate, "finish_reason", None)
533
+ if finish_reason:
534
+ return f"finish_reason={finish_reason}"
535
+ content = getattr(candidate, "content", None)
536
+ if content is None:
537
+ return "candidate has no content"
538
+ if content.parts is None:
539
+ return "candidate content has no parts"
540
+ return "empty text in response"
541
+
542
+ def _generate_with_empty_retry(
543
+ self, contents: list[Any], gen_config: Any
544
+ ) -> tuple[str | None, dict[str, int], str]:
545
+ """Call ``generate_content`` and retry once when the response carries no text.
546
+
547
+ A RECITATION finish reason gets the anti-recitation prompt appended to
548
+ the user turn before the retry; other empty reasons retry unchanged.
549
+ Returns ``(text, usage, failure_summary)`` where ``text`` is None when
550
+ both attempts were empty.
551
+ """
552
+ response = self._client.models.generate_content(
553
+ model=self._model,
554
+ contents=contents,
555
+ config=gen_config,
556
+ )
557
+ usage = self._extract_usage(response)
558
+ text = self._extract_text(response)
559
+ if text is not None:
560
+ return text, usage, ""
561
+
562
+ reason1 = self._failure_reason(response)
563
+ if _is_recitation_gemini_reason(reason1):
564
+ contents[0].parts.append(self._types.Part.from_text(text=_RECITATION_RETRY_PROMPT))
565
+ response = self._client.models.generate_content(
566
+ model=self._model,
567
+ contents=contents,
568
+ config=gen_config,
569
+ )
570
+ usage = self._extract_usage(response)
571
+ text = self._extract_text(response)
572
+ if text is not None:
573
+ return text, usage, ""
574
+ reason2 = self._failure_reason(response)
575
+ return None, usage, f"1st={reason1}, 2nd={reason2}"
576
+
577
+ @staticmethod
578
+ def _raise_if_retryable_empty(summary: str) -> None:
579
+ """Escalate an empty Gemini response to the runner's retry loop.
580
+
581
+ Empty candidates, blocked prompts, RECITATION and rate-limit reasons are
582
+ transient in practice; returning a placeholder would score the page as
583
+ empty markdown instead of retrying it.
584
+ """
585
+ if _is_rate_limited_gemini_reason(summary):
586
+ raise ProviderTransientError(f"Gemini rate limited: {summary}")
587
+ if _is_recitation_gemini_reason(summary) or _is_retryable_empty_gemini_reason(summary):
588
+ raise ProviderTransientError(f"Gemini returned no usable output after 2 attempts: {summary}")
589
+
590
+ def _parse_image(self, image: Image.Image) -> tuple[str, dict[str, int]]:
591
+ """
592
+ Send image to Gemini Flash and get markdown response.
593
+
594
+ Retries once if the response is empty.
595
+
596
+ :param image: PIL Image to parse
597
+ :return: Tuple of (markdown content, usage dict)
598
+ """
599
+ img_bytes = self._image_to_bytes(image)
600
+ types = self._types
601
+
602
+ try:
603
+ image_part = types.Part.from_bytes(data=img_bytes, mime_type="image/jpeg")
604
+ text_part = types.Part.from_text(text=USER_PROMPT)
605
+
606
+ gen_config = types.GenerateContentConfig(
607
+ temperature=0,
608
+ max_output_tokens=self._max_tokens,
609
+ system_instruction=SYSTEM_PROMPT,
610
+ )
611
+ if self._thinking_level is not None:
612
+ gen_config.thinking_config = types.ThinkingConfig(
613
+ thinking_level=self._thinking_level,
614
+ )
615
+
616
+ contents = [
617
+ types.Content(
618
+ role="user",
619
+ parts=[image_part, text_part],
620
+ )
621
+ ]
622
+
623
+ text, usage, summary = self._generate_with_empty_retry(contents, gen_config)
624
+
625
+ if text is None:
626
+ self._raise_if_retryable_empty(summary)
627
+ return f"[No output after 2 attempts: {summary}]", usage
628
+ return text, usage
629
+
630
+ except (ProviderTransientError, ProviderPermanentError):
631
+ raise
632
+ except Exception as e:
633
+ error_str = str(e).lower()
634
+ if any(kw in error_str for kw in ["timeout", "connection", "network"]):
635
+ raise ProviderTransientError(f"Transient error calling Gemini API: {e}") from e
636
+ if any(kw in error_str for kw in ["rate_limit", "rate limit", "429", "resource_exhausted"]):
637
+ raise ProviderTransientError(f"Rate limited: {e}") from e
638
+ raise ProviderPermanentError(f"Error calling Gemini API: {e}") from e
639
+
640
+ def _parse_image_with_layout(self, image: Image.Image) -> tuple[list[dict[str, Any]], str, dict[str, int]]:
641
+ """Send image to Gemini with layout prompt and get annotated response.
642
+
643
+ :param image: PIL Image to parse
644
+ :return: Tuple of (parsed layout items, raw content, usage dict)
645
+ """
646
+ img_bytes = self._image_to_bytes(image)
647
+ types = self._types
648
+
649
+ try:
650
+ image_part = types.Part.from_bytes(data=img_bytes, mime_type="image/jpeg")
651
+ text_part = types.Part.from_text(text=self._layout_user_prompt)
652
+
653
+ gen_config = types.GenerateContentConfig(
654
+ temperature=0,
655
+ max_output_tokens=self._max_tokens,
656
+ system_instruction=self._layout_system_prompt,
657
+ )
658
+ if self._thinking_level is not None:
659
+ gen_config.thinking_config = types.ThinkingConfig(
660
+ thinking_level=self._thinking_level,
661
+ )
662
+
663
+ contents = [
664
+ types.Content(
665
+ role="user",
666
+ parts=[image_part, text_part],
667
+ )
668
+ ]
669
+
670
+ text, usage, summary = self._generate_with_empty_retry(contents, gen_config)
671
+
672
+ if text is None:
673
+ self._raise_if_retryable_empty(summary)
674
+ return [], f"[No output after 2 attempts: {summary}]", usage
675
+
676
+ items = swap_gemini_bbox(parse_layout_blocks(text))
677
+ return items, text, usage
678
+
679
+ except (ProviderTransientError, ProviderPermanentError):
680
+ raise
681
+ except Exception as e:
682
+ error_str = str(e).lower()
683
+ if any(kw in error_str for kw in ["timeout", "connection", "network"]):
684
+ raise ProviderTransientError(f"Transient error calling Gemini API: {e}") from e
685
+ if any(kw in error_str for kw in ["rate_limit", "rate limit", "429", "resource_exhausted"]):
686
+ raise ProviderTransientError(f"Rate limited: {e}") from e
687
+ raise ProviderPermanentError(f"Error calling Gemini API: {e}") from e
688
+
689
+ def _parse_pdf_file(self, pdf_path: str) -> tuple[str, dict[str, int]]:
690
+ """
691
+ Send raw PDF file to Gemini using inline data.
692
+
693
+ Uses Gemini's document understanding capability to process
694
+ the PDF directly without converting to images. Retries once
695
+ if the response is empty.
696
+
697
+ :param pdf_path: Path to the PDF file
698
+ :return: Tuple of (markdown content, usage dict)
699
+ """
700
+ types = self._types
701
+
702
+ try:
703
+ # Read PDF file
704
+ with open(pdf_path, "rb") as f:
705
+ pdf_data = f.read()
706
+
707
+ # Send PDF as inline data
708
+ pdf_part = types.Part.from_bytes(data=pdf_data, mime_type="application/pdf")
709
+ text_part = types.Part.from_text(text=USER_PROMPT)
710
+
711
+ gen_config = types.GenerateContentConfig(
712
+ temperature=0,
713
+ max_output_tokens=self._max_tokens,
714
+ system_instruction=SYSTEM_PROMPT,
715
+ )
716
+ if self._thinking_level is not None:
717
+ gen_config.thinking_config = types.ThinkingConfig(
718
+ thinking_level=self._thinking_level,
719
+ )
720
+
721
+ contents = [
722
+ types.Content(
723
+ role="user",
724
+ parts=[pdf_part, text_part],
725
+ )
726
+ ]
727
+
728
+ text, usage, summary = self._generate_with_empty_retry(contents, gen_config)
729
+
730
+ if text is None:
731
+ self._raise_if_retryable_empty(summary)
732
+ return f"[No output after 2 attempts: {summary}]", usage
733
+ return text, usage
734
+
735
+ except (ProviderTransientError, ProviderPermanentError):
736
+ raise
737
+ except Exception as e:
738
+ error_str = str(e).lower()
739
+ if any(kw in error_str for kw in ["timeout", "connection", "network"]):
740
+ raise ProviderTransientError(f"Transient error calling Gemini API: {e}") from e
741
+ if any(kw in error_str for kw in ["rate_limit", "rate limit", "429", "resource_exhausted"]):
742
+ raise ProviderTransientError(f"Rate limited: {e}") from e
743
+ raise ProviderPermanentError(f"Error calling Gemini API: {e}") from e
744
+
745
+ def _parse_pdf_page_with_layout(self, pdf_bytes: bytes) -> tuple[list[dict[str, Any]], str, dict[str, int]]:
746
+ """Send a single-page PDF to Gemini with layout prompt.
747
+
748
+ :param pdf_bytes: Raw bytes of a single-page PDF
749
+ :return: Tuple of (parsed layout items, raw content, usage dict)
750
+ """
751
+ types = self._types
752
+
753
+ try:
754
+ pdf_part = types.Part.from_bytes(data=pdf_bytes, mime_type="application/pdf")
755
+ text_part = types.Part.from_text(text=self._layout_user_prompt)
756
+
757
+ gen_config = types.GenerateContentConfig(
758
+ temperature=0,
759
+ max_output_tokens=self._max_tokens,
760
+ system_instruction=self._layout_system_prompt,
761
+ )
762
+ if self._thinking_level is not None:
763
+ gen_config.thinking_config = types.ThinkingConfig(
764
+ thinking_level=self._thinking_level,
765
+ )
766
+
767
+ contents = [
768
+ types.Content(
769
+ role="user",
770
+ parts=[pdf_part, text_part],
771
+ )
772
+ ]
773
+
774
+ text, usage, summary = self._generate_with_empty_retry(contents, gen_config)
775
+
776
+ if text is None:
777
+ self._raise_if_retryable_empty(summary)
778
+ text = f"[No output after 2 attempts: {summary}]"
779
+
780
+ items = swap_gemini_bbox(parse_layout_blocks(text))
781
+ return items, text, usage
782
+
783
+ except (ProviderTransientError, ProviderPermanentError):
784
+ raise
785
+ except Exception as e:
786
+ error_str = str(e).lower()
787
+ if any(kw in error_str for kw in ["timeout", "connection", "network"]):
788
+ raise ProviderTransientError(f"Transient error calling Gemini API: {e}") from e
789
+ if any(kw in error_str for kw in ["rate_limit", "rate limit", "429", "resource_exhausted"]):
790
+ raise ProviderTransientError(f"Rate limited: {e}") from e
791
+ raise ProviderPermanentError(f"Error calling Gemini API: {e}") from e
792
+
793
+ def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
794
+ """
795
+ Run inference and return raw results.
796
+
797
+ :param pipeline: Pipeline specification
798
+ :param request: Inference request
799
+ :return: Raw inference result
800
+ """
801
+ if request.product_type != ProductType.PARSE:
802
+ raise ProviderPermanentError(f"GoogleProvider only supports PARSE product type, got {request.product_type}")
803
+
804
+ source_path = Path(request.source_file_path)
805
+ if not source_path.exists():
806
+ raise ProviderPermanentError(f"Source file not found: {source_path}")
807
+
808
+ # Check file extension
809
+ supported_extensions = {".pdf", ".png", ".jpg", ".jpeg"}
810
+ if source_path.suffix.lower() not in supported_extensions:
811
+ raise ProviderPermanentError(f"GoogleProvider supports {supported_extensions}, got {source_path.suffix}")
812
+
813
+ started_at = datetime.now()
814
+
815
+ try:
816
+ page_usages: list[dict[str, int]] = []
817
+
818
+ if self._mode == "file":
819
+ if source_path.suffix.lower() == ".pdf":
820
+ # File mode: send raw PDF to API
821
+ markdown, usage = self._parse_pdf_file(str(source_path))
822
+ page_usages.append(usage)
823
+ # In file mode, we get one response for the entire document
824
+ # We don't have page-level info, so we treat it as a single "page"
825
+ pages = [
826
+ {
827
+ "page_index": 0,
828
+ "markdown": markdown,
829
+ "width": None,
830
+ "height": None,
831
+ }
832
+ ]
833
+ num_pages = 1 # We don't know actual page count in file mode
834
+ else:
835
+ # Non-PDF: fall back to image-based parsing
836
+ image = Image.open(source_path)
837
+ markdown, usage = self._parse_image(image)
838
+ page_usages.append(usage)
839
+ pages = [
840
+ {
841
+ "page_index": 0,
842
+ "markdown": markdown,
843
+ "width": image.width,
844
+ "height": image.height,
845
+ }
846
+ ]
847
+ num_pages = 1
848
+ elif self._mode == "parse_with_layout_file":
849
+ if source_path.suffix.lower() == ".pdf":
850
+ # Split PDF into single-page PDFs, send each with layout prompt
851
+ layout_pdf_pages = split_pdf_to_pages(str(source_path))
852
+ pages = []
853
+ for page_index, (pdf_bytes, w, h) in enumerate(layout_pdf_pages):
854
+ items, raw_content, usage = self._parse_pdf_page_with_layout(pdf_bytes)
855
+ page_usages.append(usage)
856
+ pages.append(
857
+ {
858
+ "page_index": page_index,
859
+ "items": items,
860
+ "raw_content": raw_content,
861
+ "width": w,
862
+ "height": h,
863
+ }
864
+ )
865
+ num_pages = len(layout_pdf_pages)
866
+ else:
867
+ # Non-PDF: fall back to image-based layout parsing
868
+ image = Image.open(source_path)
869
+ items, raw_content, usage = self._parse_image_with_layout(image)
870
+ page_usages.append(usage)
871
+ pages = [
872
+ {
873
+ "page_index": 0,
874
+ "items": items,
875
+ "raw_content": raw_content,
876
+ "width": image.width,
877
+ "height": image.height,
878
+ }
879
+ ]
880
+ num_pages = 1
881
+ elif self._mode == "parse_with_layout_agentic_vision":
882
+ if source_path.suffix.lower() == ".pdf":
883
+ images = self._pdf_to_images(str(source_path))
884
+ else:
885
+ images = [Image.open(source_path)]
886
+
887
+ num_pages = len(images)
888
+ runner = self._build_agentic_vision_runner(expected_page_calls=num_pages)
889
+ pages = []
890
+
891
+ for page_index, image in enumerate(images): # type: ignore[assignment]
892
+ img_bytes = self._image_to_bytes(image)
893
+
894
+ try:
895
+ page_result = runner.parse_page(
896
+ page_index=page_index,
897
+ image=image,
898
+ image_bytes=img_bytes,
899
+ image_mime_type="image/jpeg",
900
+ )
901
+ self._annotate_api_calls_with_costs(page_result.api_calls)
902
+ page_usages.extend(
903
+ call.get("usage", {}) for call in page_result.api_calls if isinstance(call, dict)
904
+ )
905
+ pages.append(
906
+ {
907
+ "page_index": page_result.page_index,
908
+ "items": page_result.items,
909
+ "markdown": page_result.markdown,
910
+ "raw_content": page_result.raw_content,
911
+ "width": page_result.width,
912
+ "height": page_result.height,
913
+ "image_mime_type": page_result.image_mime_type,
914
+ "thought_summaries": page_result.thought_summaries,
915
+ "thought_signatures": page_result.thought_signatures,
916
+ "generated_code": page_result.generated_code,
917
+ "code_execution_results": page_result.code_execution_results,
918
+ "api_calls": page_result.api_calls,
919
+ }
920
+ )
921
+ except (ProviderPermanentError, ProviderTransientError) as exc:
922
+ debug_payload = exc.debug_payload if isinstance(exc.debug_payload, dict) else None
923
+ if debug_payload is not None:
924
+ maybe_calls = debug_payload.get("api_calls", [])
925
+ if isinstance(maybe_calls, list):
926
+ failed_api_calls = [call for call in maybe_calls if isinstance(call, dict)]
927
+ self._annotate_api_calls_with_costs(failed_api_calls)
928
+ page_usages.extend(call.get("usage", {}) for call in failed_api_calls)
929
+ debug_payload["api_calls"] = failed_api_calls
930
+ raise
931
+ else:
932
+ # Image mode (both "image" and "parse_with_layout"):
933
+ # convert PDF to images and process each page
934
+ if source_path.suffix.lower() == ".pdf":
935
+ images = self._pdf_to_images(str(source_path))
936
+ else:
937
+ images = [Image.open(source_path)]
938
+
939
+ pages = []
940
+ for page_index, image in enumerate(images): # type: ignore[assignment]
941
+ if self._mode == "parse_with_layout":
942
+ items, raw_content, usage = self._parse_image_with_layout(image)
943
+ page_usages.append(usage)
944
+ pages.append(
945
+ {
946
+ "page_index": page_index,
947
+ "items": items,
948
+ "raw_content": raw_content,
949
+ "width": image.width,
950
+ "height": image.height,
951
+ }
952
+ )
953
+ else:
954
+ markdown, usage = self._parse_image(image)
955
+ page_usages.append(usage)
956
+ pages.append(
957
+ {
958
+ "page_index": page_index,
959
+ "markdown": markdown,
960
+ "width": image.width,
961
+ "height": image.height,
962
+ }
963
+ )
964
+ num_pages = len(images)
965
+
966
+ completed_at = datetime.now()
967
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
968
+
969
+ config_info: dict[str, Any] = {
970
+ "dpi": self._dpi,
971
+ "max_tokens": self._max_tokens,
972
+ "mode": self._mode,
973
+ }
974
+ if self._thinking_level is not None:
975
+ config_info["thinking_level"] = self._thinking_level
976
+ if self._mode == "parse_with_layout_agentic_vision":
977
+ config_info["enable_explicit_context_cache"] = self._enable_explicit_context_cache
978
+ config_info["context_cache_ttl_seconds"] = self._context_cache_ttl_seconds
979
+ config_info["min_cacheable_tokens"] = self._min_cacheable_tokens
980
+
981
+ if self._mode == "parse_with_layout_agentic_vision":
982
+ cache_info = runner.cache_info if "runner" in locals() else None
983
+ cache_storage_cost_usd = cache_info.storage_cost_usd if cache_info is not None else 0.0
984
+ usage_summary = self._compute_usage_cost_summary(
985
+ page_usages,
986
+ num_pages=num_pages,
987
+ cache_storage_cost_usd=cache_storage_cost_usd,
988
+ )
989
+ else:
990
+ total_input = sum(u["input_tokens"] for u in page_usages)
991
+ total_output = sum(u["output_tokens"] for u in page_usages)
992
+ total_thinking = sum(u["thinking_tokens"] for u in page_usages)
993
+ total_all = sum(u["total_tokens"] for u in page_usages)
994
+
995
+ input_rate, output_rate = self._get_pricing()
996
+ cost = (total_input * input_rate + (total_output + total_thinking) * output_rate) / 1_000_000
997
+ usage_summary = {
998
+ "input_tokens": total_input,
999
+ "output_tokens": total_output,
1000
+ "thinking_tokens": total_thinking,
1001
+ "total_tokens": total_all,
1002
+ "cost_usd": cost,
1003
+ "cost_per_page_usd": cost / num_pages if num_pages > 0 else 0.0,
1004
+ "input_tokens_per_page": total_input / num_pages if num_pages > 0 else 0.0,
1005
+ "output_tokens_per_page": total_output / num_pages if num_pages > 0 else 0.0,
1006
+ }
1007
+
1008
+ raw_output = {
1009
+ "pages": pages,
1010
+ "num_pages": num_pages,
1011
+ "model": self._model,
1012
+ "mode": self._mode,
1013
+ "bbox_scale": self._bbox_scale,
1014
+ "config": config_info,
1015
+ **usage_summary,
1016
+ }
1017
+ if self._mode == "parse_with_layout_agentic_vision":
1018
+ cache_info = runner.cache_info if "runner" in locals() else None
1019
+ raw_output["cache_error"] = runner.cache_error if "runner" in locals() else None
1020
+ raw_output["explicit_context_cache"] = (
1021
+ {
1022
+ "name": cache_info.name,
1023
+ "display_name": cache_info.display_name,
1024
+ "token_count": cache_info.token_count,
1025
+ "ttl_seconds": cache_info.ttl_seconds,
1026
+ "storage_cost_usd": cache_info.storage_cost_usd,
1027
+ "created": cache_info.created,
1028
+ }
1029
+ if cache_info is not None
1030
+ else None
1031
+ )
1032
+
1033
+ return RawInferenceResult(
1034
+ request=request,
1035
+ pipeline=pipeline,
1036
+ pipeline_name=pipeline.pipeline_name,
1037
+ product_type=request.product_type,
1038
+ raw_output=raw_output,
1039
+ started_at=started_at,
1040
+ completed_at=completed_at,
1041
+ latency_in_ms=latency_ms,
1042
+ )
1043
+
1044
+ except (ProviderPermanentError, ProviderTransientError, ProviderConfigError):
1045
+ raise
1046
+ except Exception as e:
1047
+ raise ProviderPermanentError(f"Unexpected error during inference: {e}") from e
1048
+
1049
+ def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
1050
+ """
1051
+ Normalize raw inference result to produce ParseOutput.
1052
+
1053
+ :param raw_result: Raw inference result from run_inference()
1054
+ :return: Inference result with both raw and normalized outputs
1055
+ """
1056
+ if raw_result.product_type != ProductType.PARSE:
1057
+ raise ProviderPermanentError(
1058
+ f"GoogleProvider only supports PARSE product type, got {raw_result.product_type}"
1059
+ )
1060
+
1061
+ mode = raw_result.raw_output.get("mode", "image")
1062
+
1063
+ # Build page-level output
1064
+ pages: list[PageIR] = []
1065
+ page_markdowns: list[str] = []
1066
+ layout_pages: list[ParseLayoutPageIR] = []
1067
+
1068
+ for page_data in raw_result.raw_output.get("pages", []):
1069
+ page_index = page_data.get("page_index", 0)
1070
+
1071
+ if mode in ("parse_with_layout", "parse_with_layout_file"):
1072
+ items = page_data.get("items", [])
1073
+ image_width = page_data.get("width", 0)
1074
+ image_height = page_data.get("height", 0)
1075
+ markdown = items_to_markdown(items)
1076
+ layout_pages.extend(
1077
+ build_layout_pages(
1078
+ items,
1079
+ image_width,
1080
+ image_height,
1081
+ markdown,
1082
+ page_number=page_index + 1,
1083
+ bbox_scale=raw_result.raw_output.get("bbox_scale", 1000),
1084
+ )
1085
+ )
1086
+ elif mode == "parse_with_layout_agentic_vision":
1087
+ items = page_data.get("items", [])
1088
+ image_width = page_data.get("width", 0)
1089
+ image_height = page_data.get("height", 0)
1090
+ markdown, page_layout_pages = build_layout_pages_from_agentic_items(
1091
+ items,
1092
+ image_width,
1093
+ image_height,
1094
+ page_number=page_index + 1,
1095
+ )
1096
+ layout_pages.extend(page_layout_pages)
1097
+ else:
1098
+ markdown = page_data.get("markdown", "")
1099
+
1100
+ pages.append(PageIR(page_index=page_index, markdown=markdown))
1101
+ page_markdowns.append(markdown)
1102
+
1103
+ # Sort by page index and concatenate
1104
+ pages.sort(key=lambda p: p.page_index)
1105
+ full_markdown = "\n\n".join(page_markdowns)
1106
+
1107
+ output = ParseOutput(
1108
+ task_type="parse",
1109
+ example_id=raw_result.request.example_id,
1110
+ pipeline_name=raw_result.pipeline_name,
1111
+ pages=pages,
1112
+ markdown=full_markdown,
1113
+ layout_pages=layout_pages,
1114
+ )
1115
+
1116
+ return InferenceResult(
1117
+ request=raw_result.request,
1118
+ pipeline_name=raw_result.pipeline_name,
1119
+ product_type=raw_result.product_type,
1120
+ raw_output=raw_result.raw_output,
1121
+ output=output,
1122
+ started_at=raw_result.started_at,
1123
+ completed_at=raw_result.completed_at,
1124
+ latency_in_ms=raw_result.latency_in_ms,
1125
+ )