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,851 @@
1
+ """Provider for Extend AI EXTRACT using the official Python SDK.
2
+
3
+ Based on Extend AI documentation: https://docs.extend.ai/developers/sd-ks
4
+ SDK: pip install extend-ai
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import threading
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Any, cast
14
+
15
+ from parse_bench.inference.providers.base import (
16
+ Provider,
17
+ ProviderConfigError,
18
+ ProviderError,
19
+ ProviderPermanentError,
20
+ ProviderRateLimitError,
21
+ ProviderTransientError,
22
+ )
23
+ from parse_bench.inference.providers.extract.citations import extract_extend_field_citations
24
+ from parse_bench.inference.providers.registry import register_provider
25
+ from parse_bench.schemas.pipeline import PipelineSpec
26
+ from parse_bench.schemas.pipeline_io import (
27
+ InferenceRequest,
28
+ InferenceResult,
29
+ RawInferenceResult,
30
+ )
31
+ from parse_bench.schemas.product import ProductType
32
+
33
+ _Extend: Any = None
34
+ _ApiError: Any = Exception
35
+ try:
36
+ from extend_ai import Extend as _ImportedExtend
37
+ from extend_ai.core.api_error import ApiError as _ImportedApiError
38
+
39
+ _Extend = _ImportedExtend
40
+ _ApiError = _ImportedApiError
41
+ _HAS_EXTEND_AI = True
42
+ except ImportError:
43
+ _HAS_EXTEND_AI = False
44
+
45
+ Extend: Any = _Extend
46
+ ApiError: Any = _ApiError
47
+
48
+ # Extend bills file parsing separately from document processing for older
49
+ # response payloads where processor_run.usage.credits covers extraction only.
50
+ # Newer payloads can include usage.totalCredits, which is the authoritative
51
+ # roll-up for work triggered by the run. Override the parse estimate per
52
+ # pipeline with `parse_credits_per_page` when totalCredits is absent.
53
+ # Source: https://docs.extend.ai/general/how-credits-work
54
+ DEFAULT_PARSE_CREDITS_PER_PAGE = 2.0
55
+
56
+ # USD per Extend credit at Pay-As-You-Go list pricing. Extend doesn't publish a
57
+ # direct per-credit rate, so this is derived from their published parse pricing:
58
+ # $0.025/page at 2 credits/page = $0.0125/credit. Hardcoded (like llamaextract's
59
+ # _USD_PER_CREDIT) so cost_usd / cost_per_page_usd always populate and the
60
+ # dashboard cost columns render. Update here if Extend's list pricing changes.
61
+ # Source: https://www.extend.ai/pricing
62
+ USD_PER_CREDIT = 0.0125
63
+
64
+ # JSON Schema properties not supported by Extend AI
65
+ UNSUPPORTED_SCHEMA_PROPERTIES = {
66
+ "$defs",
67
+ "$schema",
68
+ "pattern",
69
+ "not",
70
+ "allOf",
71
+ "anyOf",
72
+ "oneOf",
73
+ "if",
74
+ "then",
75
+ "else",
76
+ "minLength",
77
+ "maxLength",
78
+ "minimum",
79
+ "maximum",
80
+ "exclusiveMinimum",
81
+ "exclusiveMaximum",
82
+ "multipleOf",
83
+ "minItems",
84
+ "maxItems",
85
+ "uniqueItems",
86
+ "minProperties",
87
+ "maxProperties",
88
+ "patternProperties",
89
+ "format",
90
+ "const",
91
+ "contentMediaType",
92
+ "contentEncoding",
93
+ "default",
94
+ "definitions",
95
+ "title",
96
+ }
97
+
98
+ # Property names Extend AI reserves for internal use and rejects at processor
99
+ # creation ("Field key '<name>' is reserved for internal use"). These are legal
100
+ # JSON Schema keys accepted by every other provider, so we rename them to a
101
+ # collision-free alias only in the schema submitted to Extend and restore the
102
+ # original name in the result -- the shared dataset schema/GT are never touched.
103
+ RESERVED_PROPERTY_NAMES = frozenset({"id"})
104
+
105
+
106
+ def _is_extract_product_type(value: Any) -> bool:
107
+ extract_type = getattr(ProductType, "EXTRACT", None)
108
+ if extract_type is not None and value == extract_type:
109
+ return True
110
+ return bool(getattr(value, "value", value) == "extract")
111
+
112
+
113
+ def _extract_output_cls() -> type[Any]:
114
+ from parse_bench.schemas.extract_output import ExtractOutput
115
+
116
+ return ExtractOutput
117
+
118
+
119
+ def _adapt_schema_for_extend(
120
+ schema: dict[str, Any],
121
+ ) -> tuple[dict[str, Any], dict[str, list[str]], dict[str, dict[str, str]]]:
122
+ """
123
+ Adapt a JSON schema for Extend AI compatibility.
124
+
125
+ Extend AI has limited JSON Schema support:
126
+ 1. Array items must have type "object" (no primitive arrays like string[])
127
+ 2. Object schemas must declare at least one property (no empty-object items)
128
+ 3. A handful of property names ("id", ...) are reserved
129
+ 4. Many advanced keywords (pattern, not, allOf, etc.) are not supported
130
+
131
+ This adapter:
132
+ - Wraps primitive array items in objects with a "value" property
133
+ - Treats an array whose items are an empty object ({} == "return an empty
134
+ array") as a primitive string array, so Extend accepts it
135
+ - Renames reserved property names to a collision-free alias
136
+ - Strips unsupported schema properties
137
+
138
+ Returns:
139
+ tuple: (adapted_schema, primitive_array_paths, renamed_props) where
140
+ primitive_array_paths maps array JSON paths to the primitive
141
+ types that were wrapped, and renamed_props maps each parent
142
+ object's JSON path to an {alias: original_name} table.
143
+ """
144
+ primitive_array_paths: dict[str, list[str]] = {}
145
+ renamed_props: dict[str, dict[str, str]] = {}
146
+
147
+ def alias_for(name: str, siblings: dict[str, Any]) -> str:
148
+ """Pick a non-reserved alias that does not collide with a sibling key."""
149
+ candidate = f"{name}_field"
150
+ while candidate in siblings or candidate in RESERVED_PROPERTY_NAMES:
151
+ candidate = f"{candidate}_"
152
+ return candidate
153
+
154
+ def resolve_json_pointer(ref: str) -> dict[str, Any] | None:
155
+ if not ref.startswith("#/"):
156
+ return None
157
+
158
+ current: Any = schema
159
+ for raw_part in ref[2:].split("/"):
160
+ part = raw_part.replace("~1", "/").replace("~0", "~")
161
+ if not isinstance(current, dict):
162
+ return None
163
+ current = current.get(part)
164
+
165
+ return current if isinstance(current, dict) else None
166
+
167
+ def is_null_schema(node: Any) -> bool:
168
+ return isinstance(node, dict) and node.get("type") == "null"
169
+
170
+ def normalize_nullable_schema(node: dict[str, Any]) -> dict[str, Any]:
171
+ """Collapse nullable unions to the non-null schema shape Extend accepts."""
172
+ node_type = node.get("type")
173
+ if isinstance(node_type, list):
174
+ non_null_types = [value for value in node_type if value != "null"]
175
+ if len(non_null_types) == 1:
176
+ return {**node, "type": non_null_types[0]}
177
+
178
+ for union_key in ("anyOf", "oneOf"):
179
+ options = node.get(union_key)
180
+ if not isinstance(options, list):
181
+ continue
182
+ non_null_options = [option for option in options if not is_null_schema(option)]
183
+ if len(non_null_options) != 1 or len(non_null_options) == len(options):
184
+ continue
185
+
186
+ result = dict(non_null_options[0])
187
+ for key, value in node.items():
188
+ if key != union_key and key not in result:
189
+ result[key] = value
190
+ return result
191
+
192
+ return node
193
+
194
+ def resolve_ref_node(node: dict[str, Any]) -> dict[str, Any]:
195
+ ref = node.get("$ref")
196
+ if isinstance(ref, str):
197
+ resolved = resolve_json_pointer(ref)
198
+ if resolved is not None:
199
+ merged = dict(resolved)
200
+ for key, value in node.items():
201
+ if key != "$ref":
202
+ merged[key] = value
203
+ return merged
204
+ return node
205
+
206
+ def adapt_node(node: dict[str, Any], path: str = "") -> dict[str, Any]:
207
+ if not isinstance(node, dict):
208
+ return node
209
+
210
+ node = resolve_ref_node(node)
211
+ node = normalize_nullable_schema(node)
212
+ node = resolve_ref_node(node)
213
+ result: dict[str, Any] = {}
214
+ node_type = node.get("type")
215
+
216
+ for key, value in node.items():
217
+ # Skip unsupported properties
218
+ if key in UNSUPPORTED_SCHEMA_PROPERTIES:
219
+ continue
220
+
221
+ if key == "properties" and isinstance(value, dict):
222
+ # Recurse into properties, renaming any reserved key. The child
223
+ # path keeps the ORIGINAL name so primitive_array_paths and the
224
+ # result adapter stay aligned; only the emitted key is aliased.
225
+ adapted_props: dict[str, Any] = {}
226
+ for prop_name, prop_schema in value.items():
227
+ child_path = f"{path}.{prop_name}" if path else prop_name
228
+ out_name = prop_name
229
+ if prop_name in RESERVED_PROPERTY_NAMES:
230
+ out_name = alias_for(prop_name, value)
231
+ renamed_props.setdefault(path, {})[out_name] = prop_name
232
+ adapted_props[out_name] = adapt_node(prop_schema, child_path)
233
+ result["properties"] = adapted_props
234
+ elif key == "items" and node_type == "array":
235
+ # Handle array items
236
+ if isinstance(value, dict):
237
+ resolved_items = resolve_ref_node(value)
238
+ items_type = resolved_items.get("type")
239
+ is_empty_object = items_type == "object" and not resolved_items.get("properties")
240
+ # A primitive array, or an empty-object array (a field whose
241
+ # values are never populated -- GT is always []). Extend
242
+ # rejects both, so wrap them in an object with a "value" key.
243
+ if items_type in ("string", "number", "integer", "boolean") or is_empty_object:
244
+ wrapped_type: str = "string" if is_empty_object else str(items_type)
245
+ primitive_array_paths[path] = [wrapped_type]
246
+ result["items"] = {
247
+ "type": "object",
248
+ "properties": {"value": {"type": wrapped_type}},
249
+ }
250
+ else:
251
+ # Recurse into object items
252
+ result["items"] = adapt_node(value, f"{path}[items]")
253
+ else:
254
+ result["items"] = value
255
+ elif isinstance(value, dict):
256
+ result[key] = adapt_node(value, path)
257
+ elif isinstance(value, list):
258
+ result[key] = [adapt_node(item, path) if isinstance(item, dict) else item for item in value]
259
+ else:
260
+ result[key] = value
261
+
262
+ return result
263
+
264
+ adapted = adapt_node(schema)
265
+ return adapted, primitive_array_paths, renamed_props
266
+
267
+
268
+ def _adapt_result_from_extend(
269
+ data: Any,
270
+ primitive_array_paths: dict[str, list[str]],
271
+ renamed_props: dict[str, dict[str, str]] | None = None,
272
+ path: str = "",
273
+ ) -> Any:
274
+ """
275
+ Adapt extraction results back to match the original schema.
276
+
277
+ Unwraps primitive values that were wrapped in objects for Extend AI
278
+ compatibility, and restores any reserved property name that was aliased in
279
+ the submitted schema.
280
+ """
281
+ renamed_props = renamed_props or {}
282
+
283
+ if data is None:
284
+ return None
285
+
286
+ if isinstance(data, dict):
287
+ result = {}
288
+ rename_here = renamed_props.get(path, {})
289
+ for key, value in data.items():
290
+ original_key = rename_here.get(key, key)
291
+ current_path = f"{path}.{original_key}" if path else f"{original_key}"
292
+ result[original_key] = _adapt_result_from_extend(value, primitive_array_paths, renamed_props, current_path)
293
+ return result
294
+
295
+ if isinstance(data, list):
296
+ # Check if this array path had primitive items that were wrapped
297
+ if path in primitive_array_paths:
298
+ # Unwrap the "value" from each object
299
+ return [item.get("value") if isinstance(item, dict) else item for item in data]
300
+ else:
301
+ # Recurse into array items
302
+ return [
303
+ _adapt_result_from_extend(item, primitive_array_paths, renamed_props, f"{path}[items]") for item in data
304
+ ]
305
+
306
+ return data
307
+
308
+
309
+ def _coerce_nonnegative_float(value: Any) -> float | None:
310
+ if isinstance(value, (int, float)) and value >= 0:
311
+ return float(value)
312
+ return None
313
+
314
+
315
+ def _attach_cost_stats(raw_output: dict[str, Any], pipeline_config: dict[str, Any]) -> None:
316
+ """Surface Extend billing data as top-level raw_output keys.
317
+
318
+ build_operational_stats picks up `credits_used`, `parse_credits`,
319
+ `total_credits`, `cost_usd` and `cost_per_page_usd` from raw_output, and
320
+ `num_pages` additionally enables per-page latency. Called from both
321
+ run_inference (so .raw.json is self-describing) and normalize (so
322
+ `bench inference renormalize` backfills stats on older runs).
323
+ """
324
+ processor_run = raw_output.get("processor_run") or {}
325
+
326
+ num_pages = 0
327
+ for file_info in processor_run.get("files") or []:
328
+ page_count = ((file_info or {}).get("metadata") or {}).get("page_count")
329
+ if isinstance(page_count, (int, float)):
330
+ num_pages += int(page_count)
331
+ if num_pages:
332
+ raw_output["num_pages"] = num_pages
333
+
334
+ # Doc-processing (extraction) credits, measured by the API. Absent for
335
+ # runs created before 2025-10-07 or on legacy billing.
336
+ usage = processor_run.get("usage") or {}
337
+ credits_used = _coerce_nonnegative_float(usage.get("credits"))
338
+ total_credits_from_usage = _coerce_nonnegative_float(usage.get("totalCredits"))
339
+ if total_credits_from_usage is None:
340
+ total_credits_from_usage = _coerce_nonnegative_float(usage.get("total_credits"))
341
+ if credits_used is None and total_credits_from_usage is None:
342
+ return
343
+ if credits_used is not None:
344
+ raw_output["credits_used"] = credits_used
345
+
346
+ if total_credits_from_usage is not None:
347
+ total_credits = total_credits_from_usage
348
+ if credits_used is not None:
349
+ raw_output["parse_credits"] = max(0.0, total_credits - credits_used)
350
+ else:
351
+ parse_credits_per_page = float(pipeline_config.get("parse_credits_per_page", DEFAULT_PARSE_CREDITS_PER_PAGE))
352
+ parse_credits = parse_credits_per_page * num_pages
353
+ raw_output["parse_credits"] = parse_credits
354
+ total_credits = float(credits_used or 0.0) + parse_credits
355
+ raw_output["total_credits"] = total_credits
356
+
357
+ # USD conversion at PAYG list pricing so the dashboard cost columns populate.
358
+ cost_usd = total_credits * USD_PER_CREDIT
359
+ raw_output["cost_usd"] = cost_usd
360
+ if num_pages:
361
+ raw_output["credits_per_page"] = total_credits / num_pages
362
+ raw_output["cost_per_page_usd"] = cost_usd / num_pages
363
+
364
+
365
+ @register_provider("extend")
366
+ class ExtendProvider(Provider):
367
+ """
368
+ Provider for Extend AI document extraction using the official SDK.
369
+
370
+ This provider uses the extend-ai Python SDK for extraction tasks.
371
+ SDK Documentation: https://docs.extend.ai/developers/sd-ks
372
+
373
+ Workflow:
374
+ 1. Upload file via client.file.upload()
375
+ 2. Create processor with schema via client.processor.create() (cached per schema hash)
376
+ 3. Run processor via client.processor_run.create() with sync=True
377
+
378
+ Note: This provider adapts schemas to handle Extend AI's limited JSON Schema support:
379
+ - Primitive arrays (string[], number[]) are wrapped in objects
380
+ - Unsupported properties (pattern, not, allOf, etc.) are stripped
381
+ """
382
+
383
+ def __init__(
384
+ self,
385
+ provider_name: str,
386
+ base_config: dict[str, Any] | None = None,
387
+ ):
388
+ """
389
+ Initialize the provider.
390
+
391
+ :param provider_name: Name of the provider
392
+ :param base_config: Optional configuration with:
393
+ - `api_key`: Extend AI API key (defaults to EXTEND_API_KEY env var)
394
+ - `base_url`: Optional base URL for different deployments
395
+ (default: https://api.extend.ai, alternatives: https://api.us2.extend.app,
396
+ https://api.eu1.extend.ai)
397
+ - `processor_name_prefix`: Prefix for processor names (default: "bench_")
398
+ - `timeout`: Request timeout in seconds (default: 300)
399
+ """
400
+ super().__init__(provider_name, base_config)
401
+
402
+ if not _HAS_EXTEND_AI or Extend is None:
403
+ raise ProviderConfigError("ExtendProvider requires extend-ai. Install it with: pip install extend-ai")
404
+
405
+ # Get API key
406
+ api_key = self.base_config.get("api_key") or os.getenv("EXTEND_API_KEY")
407
+ if not api_key:
408
+ raise ProviderConfigError(
409
+ "Extend AI API key is required. Set EXTEND_API_KEY environment variable or pass api_key in base_config."
410
+ )
411
+
412
+ # Configuration
413
+ self._processor_name_prefix = self.base_config.get("processor_name_prefix", "bench_")
414
+ timeout = self.base_config.get("timeout", 300)
415
+
416
+ # Initialize the Extend client
417
+ client_kwargs: dict[str, Any] = {
418
+ "token": api_key,
419
+ "timeout": float(timeout),
420
+ }
421
+
422
+ # Optional base URL for different deployments (US2, EU1, etc.)
423
+ base_url = self.base_config.get("base_url")
424
+ if base_url:
425
+ client_kwargs["base_url"] = base_url
426
+
427
+ self._client = Extend(**client_kwargs)
428
+
429
+ # Cache for processor IDs by schema hash (thread-safe)
430
+ self._processor_cache: dict[str, str] = {}
431
+ self._processor_cache_lock = threading.Lock()
432
+
433
+ def _get_config_hash(self, config: dict[str, Any]) -> str:
434
+ """Get a deterministic hash of a config for caching processors."""
435
+ config_str = json.dumps(config, sort_keys=True)
436
+ return hashlib.sha256(config_str.encode()).hexdigest()[:16]
437
+
438
+ def _handle_api_error(self, e: ApiError, context: str) -> None:
439
+ """Convert SDK ApiError to appropriate ProviderError."""
440
+ status_code = getattr(e, "status_code", None)
441
+ error_body = getattr(e, "body", str(e))
442
+
443
+ if status_code == 429:
444
+ raise ProviderRateLimitError(f"Rate limit exceeded during {context}: {error_body}")
445
+ elif status_code in (502, 503, 504):
446
+ raise ProviderTransientError(f"Transient error during {context}: {status_code} - {error_body}")
447
+ elif status_code and status_code >= 400:
448
+ raise ProviderPermanentError(f"Error during {context}: {status_code} - {error_body}")
449
+ else:
450
+ raise ProviderPermanentError(f"API error during {context}: {error_body}")
451
+
452
+ def _upload_file(self, file_path: str) -> str:
453
+ """
454
+ Upload a file to Extend AI.
455
+
456
+ :param file_path: Path to the file to upload
457
+ :return: File ID from Extend AI
458
+ :raises ProviderError: For any upload errors
459
+ """
460
+ try:
461
+ with open(file_path, "rb") as f:
462
+ upload_response = self._client.files.upload(file=f)
463
+
464
+ # Extract file ID from response
465
+ if hasattr(upload_response, "id"):
466
+ return str(upload_response.id)
467
+ elif hasattr(upload_response, "file") and hasattr(upload_response.file, "id"):
468
+ return str(upload_response.file.id)
469
+ elif isinstance(upload_response, dict):
470
+ file_data = upload_response.get("file", upload_response)
471
+ file_id = file_data.get("id") or file_data.get("fileId")
472
+ if file_id:
473
+ return str(file_id)
474
+
475
+ raise ProviderPermanentError(f"No file ID in upload response: {upload_response}")
476
+
477
+ except ApiError as e:
478
+ self._handle_api_error(e, "file upload")
479
+ raise # Should not reach here, but satisfies type checker
480
+ except Exception as e:
481
+ error_str = str(e).lower()
482
+ if any(kw in error_str for kw in ["timeout", "timed out", "connection", "network", "readtimeout"]):
483
+ raise ProviderTransientError(f"Transient error during file upload: {e}") from e
484
+ raise ProviderPermanentError(f"Unexpected error during file upload: {e}") from e
485
+
486
+ def _build_processor_config(self, schema: dict[str, Any], pipeline_config: dict[str, Any]) -> dict[str, Any]:
487
+ """
488
+ Build the processor config by merging schema with pipeline config options.
489
+
490
+ :param schema: JSON schema for extraction
491
+ :param pipeline_config: Pipeline configuration options
492
+ :return: Complete processor config
493
+ """
494
+ config: dict[str, Any] = {
495
+ "type": "EXTRACT",
496
+ "schema": schema,
497
+ }
498
+
499
+ # Add baseProcessor if specified (e.g., "extraction_performance")
500
+ if "baseProcessor" in pipeline_config:
501
+ config["baseProcessor"] = pipeline_config["baseProcessor"]
502
+
503
+ # Add baseVersion if specified (e.g., "4.1.1")
504
+ if "baseVersion" in pipeline_config:
505
+ config["baseVersion"] = pipeline_config["baseVersion"]
506
+
507
+ # Add advancedOptions if specified
508
+ if "advancedOptions" in pipeline_config:
509
+ config["advancedOptions"] = pipeline_config["advancedOptions"]
510
+
511
+ return config
512
+
513
+ def _find_processor_by_name(self, name: str) -> str | None:
514
+ """
515
+ Find an existing processor by name.
516
+
517
+ Handles pagination to search through all processors.
518
+
519
+ :param name: Name of the processor to find
520
+ :return: Processor ID if found, None otherwise
521
+ """
522
+ try:
523
+ next_page_token: str | None = None
524
+
525
+ while True:
526
+ # List processors with pagination
527
+ if next_page_token:
528
+ list_response = self._client.processor.list(next_page_token=next_page_token)
529
+ else:
530
+ list_response = self._client.processor.list()
531
+
532
+ # Extract processors from response
533
+ processors: list[Any] = []
534
+ if hasattr(list_response, "processors"):
535
+ processors = list_response.processors or []
536
+ elif hasattr(list_response, "data"):
537
+ processors = list_response.data or []
538
+ elif isinstance(list_response, list):
539
+ processors = list_response
540
+
541
+ # Search for processor by name
542
+ for processor in processors:
543
+ proc_name = getattr(processor, "name", None)
544
+ if proc_name == name:
545
+ proc_id = getattr(processor, "id", None)
546
+ if proc_id:
547
+ return str(proc_id)
548
+
549
+ # Check for next page
550
+ next_page_token = getattr(list_response, "next_page_token", None)
551
+ if not next_page_token:
552
+ break
553
+
554
+ return None
555
+
556
+ except Exception:
557
+ # If listing fails, return None and let creation handle it
558
+ return None
559
+
560
+ def _create_processor(self, processor_config: dict[str, Any], config_hash: str) -> str:
561
+ """
562
+ Create an extraction processor with the given config.
563
+
564
+ :param processor_config: Full processor configuration including schema
565
+ :param config_hash: Hash of the config for naming
566
+ :return: Processor ID
567
+ :raises ProviderError: For any creation errors
568
+ """
569
+ processor_name = f"{self._processor_name_prefix}{config_hash}"
570
+
571
+ try:
572
+ processor_response = self._client.processor.create(
573
+ name=processor_name,
574
+ type="EXTRACT", # type: ignore[arg-type]
575
+ config=processor_config, # type: ignore[arg-type]
576
+ )
577
+
578
+ # Extract processor ID from response
579
+ # Response is ProcessorCreateResponse with a 'processor' attribute
580
+ if hasattr(processor_response, "processor"):
581
+ processor = processor_response.processor
582
+ if hasattr(processor, "id"):
583
+ return str(processor.id)
584
+ elif hasattr(processor_response, "id"):
585
+ return str(processor_response.id)
586
+ elif isinstance(processor_response, dict):
587
+ # Handle dict response
588
+ if "processor" in processor_response:
589
+ processor_id = processor_response["processor"].get("id")
590
+ else:
591
+ processor_id = processor_response.get("id") or processor_response.get("processorId")
592
+ if processor_id:
593
+ return str(processor_id)
594
+
595
+ raise ProviderPermanentError(f"No processor ID in creation response: {processor_response}")
596
+
597
+ except ApiError as e:
598
+ # Check if processor already exists
599
+ error_body = getattr(e, "body", {})
600
+ error_msg = ""
601
+ if isinstance(error_body, dict):
602
+ error_msg = error_body.get("error", "")
603
+ else:
604
+ error_msg = str(error_body)
605
+
606
+ if "already exists" in error_msg.lower():
607
+ # Try to find the existing processor
608
+ existing_id = self._find_processor_by_name(processor_name)
609
+ if existing_id:
610
+ return existing_id
611
+
612
+ self._handle_api_error(e, "processor creation")
613
+ raise # Should not reach here, but satisfies type checker
614
+ except Exception as e:
615
+ error_str = str(e).lower()
616
+ if any(kw in error_str for kw in ["timeout", "timed out", "connection", "network", "readtimeout"]):
617
+ raise ProviderTransientError(f"Transient error during processor creation: {e}") from e
618
+ raise ProviderPermanentError(f"Unexpected error during processor creation: {e}") from e
619
+
620
+ def _get_or_create_processor(self, processor_config: dict[str, Any]) -> str:
621
+ """
622
+ Get existing processor ID or create a new one for the given config.
623
+
624
+ Thread-safe: uses locking to prevent concurrent creation of same processor.
625
+
626
+ :param processor_config: Full processor configuration including schema
627
+ :return: Processor ID
628
+ """
629
+ config_hash = self._get_config_hash(processor_config)
630
+ processor_name = f"{self._processor_name_prefix}{config_hash}"
631
+
632
+ # Fast path: check cache without lock
633
+ if config_hash in self._processor_cache:
634
+ return self._processor_cache[config_hash]
635
+
636
+ # Slow path: acquire lock to prevent concurrent creation
637
+ with self._processor_cache_lock:
638
+ # Double-check after acquiring lock
639
+ if config_hash in self._processor_cache:
640
+ return self._processor_cache[config_hash]
641
+
642
+ # Check if processor already exists in Extend before creating
643
+ existing_id = self._find_processor_by_name(processor_name)
644
+ if existing_id:
645
+ self._processor_cache[config_hash] = existing_id
646
+ return existing_id
647
+
648
+ # Create new processor
649
+ processor_id = self._create_processor(processor_config, config_hash)
650
+ self._processor_cache[config_hash] = processor_id
651
+ return processor_id
652
+
653
+ def _run_processor(self, processor_id: str, file_id: str) -> dict[str, Any]:
654
+ """
655
+ Run a processor on a file synchronously.
656
+
657
+ :param processor_id: ID of the processor to run
658
+ :param file_id: ID of the uploaded file
659
+ :return: Raw response from the processor run
660
+ :raises ProviderError: For any run errors
661
+ """
662
+ try:
663
+ run_response = self._client.processor_run.create(
664
+ processor_id=processor_id,
665
+ file={"fileId": file_id}, # type: ignore[arg-type]
666
+ sync=True, # Synchronous processing - waits for completion
667
+ )
668
+
669
+ # Convert response to dict for storage
670
+ if hasattr(run_response, "model_dump"):
671
+ return cast(dict[str, Any], run_response.model_dump())
672
+ elif hasattr(run_response, "dict"):
673
+ return cast(dict[str, Any], run_response.dict())
674
+ elif isinstance(run_response, dict):
675
+ return run_response
676
+ else:
677
+ # Try to extract attributes manually
678
+ result: dict[str, Any] = {}
679
+ for attr in [
680
+ "id",
681
+ "status",
682
+ "output",
683
+ "extracted_data",
684
+ "extractedData",
685
+ "data",
686
+ "result",
687
+ "error",
688
+ "processorId",
689
+ "fileId",
690
+ ]:
691
+ if hasattr(run_response, attr):
692
+ value = getattr(run_response, attr)
693
+ if not callable(value):
694
+ result[attr] = value
695
+ return result
696
+
697
+ except ApiError as e:
698
+ self._handle_api_error(e, "processor run")
699
+ raise # Should not reach here, but satisfies type checker
700
+ except Exception as e:
701
+ error_str = str(e).lower()
702
+ if any(kw in error_str for kw in ["timeout", "timed out", "connection", "network", "readtimeout"]):
703
+ raise ProviderTransientError(f"Transient error during processor run: {e}") from e
704
+ raise ProviderPermanentError(f"Unexpected error during processor run: {e}") from e
705
+
706
+ def _extract_document(
707
+ self,
708
+ file_path: str,
709
+ schema: dict[str, Any],
710
+ pipeline_config: dict[str, Any],
711
+ ) -> dict[str, Any]:
712
+ """
713
+ Extract data from a document using Extend AI.
714
+
715
+ :param file_path: Path to the document file
716
+ :param schema: JSON schema for extraction
717
+ :param pipeline_config: Pipeline configuration options
718
+ :return: Raw API response with extracted data
719
+ :raises ProviderError: For any extraction errors
720
+ """
721
+ # Step 0: Adapt schema for Extend AI compatibility
722
+ adapted_schema, primitive_array_paths, renamed_props = _adapt_schema_for_extend(schema)
723
+
724
+ # Step 1: Upload file
725
+ file_id = self._upload_file(file_path)
726
+
727
+ # Step 2: Build processor config with adapted schema and pipeline options
728
+ processor_config = self._build_processor_config(adapted_schema, pipeline_config)
729
+
730
+ # Step 3: Get or create processor for this config
731
+ processor_id = self._get_or_create_processor(processor_config)
732
+
733
+ # Step 4: Run processor synchronously
734
+ result = self._run_processor(processor_id, file_id)
735
+
736
+ # Add metadata (including schema adaptation info for normalization)
737
+ result["_extend_metadata"] = {
738
+ "file_id": file_id,
739
+ "processor_id": processor_id,
740
+ "primitive_array_paths": primitive_array_paths,
741
+ "renamed_props": renamed_props,
742
+ }
743
+
744
+ return result
745
+
746
+ def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
747
+ """
748
+ Run inference and return raw results.
749
+
750
+ :param pipeline: Pipeline specification
751
+ :param request: Inference request (must include schema_override for EXTRACT)
752
+ :return: Raw inference result
753
+ :raises ProviderError: For any provider-related failures
754
+ """
755
+ if not _is_extract_product_type(request.product_type):
756
+ raise ProviderPermanentError(
757
+ f"ExtendProvider only supports EXTRACT product type, got {request.product_type}"
758
+ )
759
+
760
+ # Schema is required for extraction
761
+ if not request.schema_override:
762
+ raise ProviderPermanentError(
763
+ "schema_override is required for EXTRACT product type. "
764
+ "Provide a JSON schema in InferenceRequest.schema_override"
765
+ )
766
+
767
+ started_at = datetime.now()
768
+
769
+ # Check if file exists
770
+ file_path = Path(request.source_file_path)
771
+ if not file_path.exists():
772
+ raise ProviderPermanentError(f"File not found: {file_path}")
773
+
774
+ try:
775
+ # Run extraction with pipeline config options
776
+ raw_output = self._extract_document(
777
+ file_path=str(file_path),
778
+ schema=request.schema_override,
779
+ pipeline_config=pipeline.config,
780
+ )
781
+
782
+ completed_at = datetime.now()
783
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
784
+
785
+ _attach_cost_stats(raw_output, pipeline.config)
786
+
787
+ return RawInferenceResult(
788
+ request=request,
789
+ pipeline=pipeline,
790
+ pipeline_name=pipeline.pipeline_name,
791
+ product_type=request.product_type,
792
+ raw_output=raw_output,
793
+ started_at=started_at,
794
+ completed_at=completed_at,
795
+ latency_in_ms=latency_ms,
796
+ )
797
+
798
+ except ProviderError:
799
+ # Preserve transient/rate-limit classification so the harness
800
+ # retries — wrapping these as permanent disabled all retry and
801
+ # turned vendor 429s into hard per-doc failures.
802
+ raise
803
+ except Exception as e:
804
+ raise ProviderPermanentError(f"Unexpected error during inference: {e}") from e
805
+
806
+ def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
807
+ """
808
+ Normalize raw inference result to produce ExtractOutput.
809
+
810
+ :param raw_result: Raw inference result from run_inference()
811
+ :return: Inference result with both raw and normalized outputs
812
+ :raises ProviderError: For any normalization failures
813
+ """
814
+ if not _is_extract_product_type(raw_result.product_type):
815
+ raise ProviderPermanentError(
816
+ f"ExtendProvider only supports EXTRACT product type, got {raw_result.product_type}"
817
+ )
818
+
819
+ # Backfill cost stats for raw results recorded before cost tracking
820
+ # existed (renormalize path); no-op when run_inference already set them.
821
+ _attach_cost_stats(raw_result.raw_output, raw_result.pipeline.config)
822
+
823
+ # Extract the structured data from processor_run.output.value
824
+ extracted_data = raw_result.raw_output.get("processor_run", {}).get("output", {}).get("value", {})
825
+
826
+ # Adapt the result back to match the original schema
827
+ # (unwrap primitive arrays that were wrapped for Extend AI)
828
+ extend_metadata = raw_result.raw_output.get("_extend_metadata", {})
829
+ primitive_array_paths = extend_metadata.get("primitive_array_paths", {})
830
+ renamed_props = extend_metadata.get("renamed_props", {})
831
+ if primitive_array_paths or renamed_props:
832
+ extracted_data = _adapt_result_from_extend(extracted_data, primitive_array_paths, renamed_props)
833
+
834
+ output = _extract_output_cls()(
835
+ task_type="extract",
836
+ example_id=raw_result.request.example_id,
837
+ pipeline_name=raw_result.pipeline_name,
838
+ extracted_data=extracted_data,
839
+ field_citations=extract_extend_field_citations(raw_result.raw_output),
840
+ )
841
+
842
+ return InferenceResult(
843
+ request=raw_result.request,
844
+ pipeline_name=raw_result.pipeline_name,
845
+ product_type=raw_result.product_type,
846
+ raw_output=raw_result.raw_output,
847
+ output=output,
848
+ started_at=raw_result.started_at,
849
+ completed_at=raw_result.completed_at,
850
+ latency_in_ms=raw_result.latency_in_ms,
851
+ )