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.
- parse_bench/__init__.py +3 -0
- parse_bench/analysis/__init__.py +6 -0
- parse_bench/analysis/aggregation_report.py +582 -0
- parse_bench/analysis/cli.py +472 -0
- parse_bench/analysis/comparison.py +382 -0
- parse_bench/analysis/comparison_core.py +357 -0
- parse_bench/analysis/comparison_report.py +2066 -0
- parse_bench/analysis/detailed_report.py +2254 -0
- parse_bench/analysis/leaderboard_report.py +852 -0
- parse_bench/analysis/metric_definitions.py +771 -0
- parse_bench/cli.py +267 -0
- parse_bench/data/__init__.py +1 -0
- parse_bench/data/cli.py +118 -0
- parse_bench/data/download.py +127 -0
- parse_bench/evaluation/__init__.py +11 -0
- parse_bench/evaluation/cli.py +435 -0
- parse_bench/evaluation/evaluators/__init__.py +17 -0
- parse_bench/evaluation/evaluators/base.py +34 -0
- parse_bench/evaluation/evaluators/extract.py +429 -0
- parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
- parse_bench/evaluation/evaluators/parse.py +1353 -0
- parse_bench/evaluation/evaluators/qa.py +199 -0
- parse_bench/evaluation/layout_adapters/__init__.py +21 -0
- parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
- parse_bench/evaluation/layout_adapters/base.py +105 -0
- parse_bench/evaluation/layout_adapters/registry.py +109 -0
- parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
- parse_bench/evaluation/layout_label_mappers/base.py +66 -0
- parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
- parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
- parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
- parse_bench/evaluation/metric_aggregation.py +56 -0
- parse_bench/evaluation/metrics/__init__.py +5 -0
- parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
- parse_bench/evaluation/metrics/attribution/constants.py +12 -0
- parse_bench/evaluation/metrics/attribution/core.py +1108 -0
- parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
- parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
- parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
- parse_bench/evaluation/metrics/base.py +33 -0
- parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
- parse_bench/evaluation/metrics/extract/__init__.py +29 -0
- parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
- parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
- parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
- parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
- parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
- parse_bench/evaluation/metrics/extract/test_types.py +11 -0
- parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
- parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
- parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
- parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
- parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
- parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
- parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
- parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
- parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
- parse_bench/evaluation/metrics/parse/__init__.py +5 -0
- parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
- parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
- parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
- parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
- parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
- parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
- parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
- parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
- parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
- parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
- parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
- parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
- parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
- parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
- parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
- parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
- parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
- parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
- parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
- parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
- parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
- parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
- parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
- parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
- parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
- parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
- parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
- parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
- parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
- parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
- parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
- parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
- parse_bench/evaluation/metrics/parse/test_types.py +103 -0
- parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
- parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
- parse_bench/evaluation/metrics/parse/utils.py +885 -0
- parse_bench/evaluation/metrics/qa/__init__.py +5 -0
- parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
- parse_bench/evaluation/qa/__init__.py +5 -0
- parse_bench/evaluation/qa/llm_service.py +335 -0
- parse_bench/evaluation/reports/__init__.py +8 -0
- parse_bench/evaluation/reports/csv.py +64 -0
- parse_bench/evaluation/reports/html.py +338 -0
- parse_bench/evaluation/reports/markdown.py +98 -0
- parse_bench/evaluation/reports/rule_csv.py +22 -0
- parse_bench/evaluation/runner.py +1864 -0
- parse_bench/evaluation/stats.py +104 -0
- parse_bench/extensions.py +72 -0
- parse_bench/inference/__init__.py +33 -0
- parse_bench/inference/chunkr_layout_extraction.py +160 -0
- parse_bench/inference/cli.py +484 -0
- parse_bench/inference/layout_extraction.py +422 -0
- parse_bench/inference/pipelines/__init__.py +59 -0
- parse_bench/inference/pipelines/extract.py +39 -0
- parse_bench/inference/pipelines/layout.py +142 -0
- parse_bench/inference/pipelines/parse.py +2603 -0
- parse_bench/inference/pipelines.py +0 -0
- parse_bench/inference/providers/__init__.py +28 -0
- parse_bench/inference/providers/base.py +196 -0
- parse_bench/inference/providers/cancellation.py +137 -0
- parse_bench/inference/providers/extract/__init__.py +22 -0
- parse_bench/inference/providers/extract/citations.py +549 -0
- parse_bench/inference/providers/extract/extend.py +851 -0
- parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
- parse_bench/inference/providers/layoutdet/__init__.py +25 -0
- parse_bench/inference/providers/layoutdet/adapters.py +946 -0
- parse_bench/inference/providers/layoutdet/base.py +203 -0
- parse_bench/inference/providers/layoutdet/chandra.py +449 -0
- parse_bench/inference/providers/layoutdet/docling.py +125 -0
- parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
- parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
- parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
- parse_bench/inference/providers/layoutdet/paddle.py +117 -0
- parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
- parse_bench/inference/providers/layoutdet/surya.py +250 -0
- parse_bench/inference/providers/layoutdet/yolo.py +109 -0
- parse_bench/inference/providers/parse/__init__.py +64 -0
- parse_bench/inference/providers/parse/_docling_common.py +233 -0
- parse_bench/inference/providers/parse/_layout_utils.py +611 -0
- parse_bench/inference/providers/parse/amazon_nova.py +515 -0
- parse_bench/inference/providers/parse/anthropic.py +882 -0
- parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
- parse_bench/inference/providers/parse/chandra2.py +633 -0
- parse_bench/inference/providers/parse/chunkr.py +268 -0
- parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
- parse_bench/inference/providers/parse/datalab.py +370 -0
- parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
- parse_bench/inference/providers/parse/docling.py +281 -0
- parse_bench/inference/providers/parse/docling_serve.py +289 -0
- parse_bench/inference/providers/parse/dots_ocr.py +574 -0
- parse_bench/inference/providers/parse/extend_parse.py +710 -0
- parse_bench/inference/providers/parse/falconocr.py +436 -0
- parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
- parse_bench/inference/providers/parse/gemma4.py +472 -0
- parse_bench/inference/providers/parse/glm_zai.py +229 -0
- parse_bench/inference/providers/parse/google.py +1125 -0
- parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
- parse_bench/inference/providers/parse/google_docai.py +776 -0
- parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
- parse_bench/inference/providers/parse/granite_vision.py +515 -0
- parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
- parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
- parse_bench/inference/providers/parse/landingai.py +452 -0
- parse_bench/inference/providers/parse/liteparse.py +350 -0
- parse_bench/inference/providers/parse/llamaparse.py +677 -0
- parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
- parse_bench/inference/providers/parse/markitdown.py +138 -0
- parse_bench/inference/providers/parse/mineru25.py +405 -0
- parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
- parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
- parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
- parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
- parse_bench/inference/providers/parse/oi_parser.py +222 -0
- parse_bench/inference/providers/parse/openai.py +740 -0
- parse_bench/inference/providers/parse/opendataloader.py +152 -0
- parse_bench/inference/providers/parse/paddleocr.py +624 -0
- parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
- parse_bench/inference/providers/parse/pulse.py +785 -0
- parse_bench/inference/providers/parse/pymupdf.py +207 -0
- parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
- parse_bench/inference/providers/parse/pypdf.py +179 -0
- parse_bench/inference/providers/parse/qwen.py +678 -0
- parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
- parse_bench/inference/providers/parse/reducto.py +546 -0
- parse_bench/inference/providers/parse/surya2.py +372 -0
- parse_bench/inference/providers/parse/tesseract.py +301 -0
- parse_bench/inference/providers/parse/textract.py +694 -0
- parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
- parse_bench/inference/providers/parse/unstructured.py +485 -0
- parse_bench/inference/providers/parse/warp_ingest.py +199 -0
- parse_bench/inference/providers/registry.py +49 -0
- parse_bench/inference/renormalize.py +170 -0
- parse_bench/inference/runner.py +2023 -0
- parse_bench/layout_label_mapping.py +424 -0
- parse_bench/layout_projection.py +179 -0
- parse_bench/pipeline/__init__.py +1 -0
- parse_bench/pipeline/cli.py +549 -0
- parse_bench/schemas/__init__.py +33 -0
- parse_bench/schemas/evaluation.py +93 -0
- parse_bench/schemas/extract_output.py +36 -0
- parse_bench/schemas/layout_detection_output.py +545 -0
- parse_bench/schemas/layout_ontology.py +315 -0
- parse_bench/schemas/metrics.py +69 -0
- parse_bench/schemas/parse_output.py +152 -0
- parse_bench/schemas/pipeline.py +22 -0
- parse_bench/schemas/pipeline_io.py +106 -0
- parse_bench/schemas/product.py +97 -0
- parse_bench/test_cases/__init__.py +25 -0
- parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
- parse_bench/test_cases/extract_field_paths.py +164 -0
- parse_bench/test_cases/layout_attribution_generation.py +287 -0
- parse_bench/test_cases/loader.py +652 -0
- parse_bench/test_cases/parse_rule_schemas.py +1071 -0
- parse_bench/test_cases/rule_filters.py +32 -0
- parse_bench/test_cases/rule_ids.py +107 -0
- parse_bench/test_cases/schema.py +427 -0
- parse_bench/utils/__init__.py +15 -0
- parse_bench/utils/gemini_layout_utils.py +670 -0
- parse_bench/utils/text_aggregation.py +100 -0
- parse_bench-1.0.0.dist-info/METADATA +476 -0
- parse_bench-1.0.0.dist-info/RECORD +227 -0
- parse_bench-1.0.0.dist-info/WHEEL +4 -0
- parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|