evalrx 0.1.2__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 (339) hide show
  1. evalrx/__init__.py +139 -0
  2. evalrx/agent_assets/__init__.py +2 -0
  3. evalrx/agent_assets/skills/README.md +28 -0
  4. evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
  5. evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
  6. evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
  7. evalrx/agent_assets/skills/nature-figure/README.md +412 -0
  8. evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
  9. evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
  10. evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
  11. evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
  12. evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
  13. evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
  14. evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
  15. evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
  16. evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
  17. evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
  18. evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
  19. evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
  20. evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
  21. evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
  22. evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
  23. evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
  24. evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
  25. evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
  26. evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
  27. evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
  28. evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
  29. evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
  30. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
  31. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
  32. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
  33. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
  34. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
  35. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
  36. evalrx/agent_assets/skills.py +27 -0
  37. evalrx/agent_runtime/__init__.py +78 -0
  38. evalrx/agent_runtime/_docker_runner.py +89 -0
  39. evalrx/agent_runtime/cli_runtime.py +103 -0
  40. evalrx/agent_runtime/cli_transcript.py +138 -0
  41. evalrx/agent_runtime/cli_types.py +68 -0
  42. evalrx/agent_runtime/codegen/__init__.py +5 -0
  43. evalrx/agent_runtime/codegen/runner.py +94 -0
  44. evalrx/agent_runtime/experiment_harness.py +117 -0
  45. evalrx/agent_runtime/factory.py +102 -0
  46. evalrx/agent_runtime/json_shape.py +44 -0
  47. evalrx/agent_runtime/judges/__init__.py +28 -0
  48. evalrx/agent_runtime/judges/agy.py +179 -0
  49. evalrx/agent_runtime/judges/autodetect.py +135 -0
  50. evalrx/agent_runtime/judges/claude.py +159 -0
  51. evalrx/agent_runtime/judges/codex.py +120 -0
  52. evalrx/agent_runtime/providers/__init__.py +21 -0
  53. evalrx/agent_runtime/providers/antigravity.py +31 -0
  54. evalrx/agent_runtime/providers/base.py +145 -0
  55. evalrx/agent_runtime/providers/claude_code.py +49 -0
  56. evalrx/agent_runtime/providers/codex.py +37 -0
  57. evalrx/agent_runtime/providers/gemini_cli.py +26 -0
  58. evalrx/agent_runtime/providers/kimi_cli.py +27 -0
  59. evalrx/agent_runtime/providers/opencode.py +27 -0
  60. evalrx/agent_runtime/providers/registry.py +58 -0
  61. evalrx/agent_runtime/sandbox.py +517 -0
  62. evalrx/agent_runtime/skill_audit.py +143 -0
  63. evalrx/agent_runtime/skills/__init__.py +19 -0
  64. evalrx/agent_runtime/skills/installer.py +68 -0
  65. evalrx/agent_runtime/skills/prompt_policy.py +86 -0
  66. evalrx/agent_runtime/skills/resolver.py +19 -0
  67. evalrx/analysis/__init__.py +132 -0
  68. evalrx/analysis/adjudicate.py +154 -0
  69. evalrx/analysis/analysis_module.py +361 -0
  70. evalrx/analysis/api.py +171 -0
  71. evalrx/analysis/case_studio.py +651 -0
  72. evalrx/analysis/cli.py +114 -0
  73. evalrx/analysis/dashboard.py +350 -0
  74. evalrx/analysis/eval_case_matrix.py +118 -0
  75. evalrx/analysis/eval_viz_theme.py +833 -0
  76. evalrx/analysis/explore_run.py +333 -0
  77. evalrx/analysis/explorer.py +1276 -0
  78. evalrx/analysis/failure_modes.py +607 -0
  79. evalrx/analysis/fused_pipeline.py +489 -0
  80. evalrx/analysis/holdout.py +300 -0
  81. evalrx/analysis/hypothesis_agent.py +230 -0
  82. evalrx/analysis/narration.py +177 -0
  83. evalrx/analysis/operationalize.py +442 -0
  84. evalrx/analysis/plain_language.py +42 -0
  85. evalrx/analysis/planner.py +283 -0
  86. evalrx/analysis/probe_search.py +203 -0
  87. evalrx/analysis/profile.py +268 -0
  88. evalrx/analysis/prompts/__init__.py +0 -0
  89. evalrx/analysis/prompts/explorer.py +417 -0
  90. evalrx/analysis/prompts/failure_modes.py +33 -0
  91. evalrx/analysis/prompts/holdout.py +27 -0
  92. evalrx/analysis/prompts/hypothesis_agent.py +78 -0
  93. evalrx/analysis/prompts/run_codebase.py +47 -0
  94. evalrx/analysis/prompts/stats_agent.py +72 -0
  95. evalrx/analysis/prompts/stats_tool_generator.py +43 -0
  96. evalrx/analysis/result_marker.py +47 -0
  97. evalrx/analysis/run_codebase.py +242 -0
  98. evalrx/analysis/run_view.py +205 -0
  99. evalrx/analysis/stage_views.py +93 -0
  100. evalrx/analysis/stats_agent.py +944 -0
  101. evalrx/analysis/stats_tool_agent.py +261 -0
  102. evalrx/analysis/stats_tool_generator.py +415 -0
  103. evalrx/analysis/stats_tools.py +1153 -0
  104. evalrx/analysis/trajectory_records.py +193 -0
  105. evalrx/analysis/workbench.py +431 -0
  106. evalrx/analyzers/__init__.py +42 -0
  107. evalrx/analyzers/agent/__init__.py +25 -0
  108. evalrx/analyzers/agent/counterfactual.py +84 -0
  109. evalrx/analyzers/agent/first_error_judge.py +96 -0
  110. evalrx/analyzers/agent/ignored_obs.py +81 -0
  111. evalrx/analyzers/agent/loop_detect.py +79 -0
  112. evalrx/analyzers/agent/reliability.py +165 -0
  113. evalrx/analyzers/agent/tool_shap.py +225 -0
  114. evalrx/analyzers/agent/trajectory_rubric.py +168 -0
  115. evalrx/analyzers/attention/__init__.py +19 -0
  116. evalrx/analyzers/attention/relative_attn.py +610 -0
  117. evalrx/analyzers/attention/rollout.py +73 -0
  118. evalrx/analyzers/attention/sink.py +56 -0
  119. evalrx/analyzers/attention/summary.py +190 -0
  120. evalrx/analyzers/attribution/__init__.py +6 -0
  121. evalrx/analyzers/attribution/generic_attn.py +31 -0
  122. evalrx/analyzers/attribution/gradcam.py +30 -0
  123. evalrx/analyzers/base.py +12 -0
  124. evalrx/analyzers/geometry/__init__.py +6 -0
  125. evalrx/analyzers/geometry/cka.py +70 -0
  126. evalrx/analyzers/geometry/linear_probe.py +157 -0
  127. evalrx/analyzers/hallucination/__init__.py +9 -0
  128. evalrx/analyzers/hallucination/chair.py +78 -0
  129. evalrx/analyzers/hallucination/opera.py +29 -0
  130. evalrx/analyzers/hallucination/pope.py +119 -0
  131. evalrx/analyzers/hallucination/selfcheck.py +155 -0
  132. evalrx/analyzers/hallucination/vcd.py +29 -0
  133. evalrx/analyzers/lens/__init__.py +7 -0
  134. evalrx/analyzers/lens/layer_contrast.py +133 -0
  135. evalrx/analyzers/lens/logit_lens.py +138 -0
  136. evalrx/analyzers/lens/tuned_lens.py +30 -0
  137. evalrx/analyzers/patching/__init__.py +5 -0
  138. evalrx/analyzers/patching/causal_trace.py +30 -0
  139. evalrx/analyzers/perturbation/__init__.py +23 -0
  140. evalrx/analyzers/perturbation/_shapley.py +54 -0
  141. evalrx/analyzers/perturbation/context_shap.py +174 -0
  142. evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
  143. evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
  144. evalrx/analyzers/perturbation/mm_shap.py +146 -0
  145. evalrx/analyzers/perturbation/modality_ablation.py +196 -0
  146. evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
  147. evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
  148. evalrx/analyzers/perturbation/rise.py +94 -0
  149. evalrx/analyzers/perturbation/vl_shap.py +102 -0
  150. evalrx/analyzers/reasoning/__init__.py +33 -0
  151. evalrx/analyzers/reasoning/_text.py +328 -0
  152. evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
  153. evalrx/analyzers/reasoning/arith_audit.py +226 -0
  154. evalrx/analyzers/reasoning/contamination.py +214 -0
  155. evalrx/analyzers/reasoning/knowledge_split.py +253 -0
  156. evalrx/analyzers/reasoning/self_repair.py +246 -0
  157. evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
  158. evalrx/analyzers/reasoning/termination_audit.py +258 -0
  159. evalrx/analyzers/uncertainty/__init__.py +18 -0
  160. evalrx/analyzers/uncertainty/calibration.py +174 -0
  161. evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
  162. evalrx/analyzers/uncertainty/entropy.py +90 -0
  163. evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
  164. evalrx/analyzers/uncertainty/self_consistency.py +204 -0
  165. evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
  166. evalrx/cli.py +411 -0
  167. evalrx/config.py +77 -0
  168. evalrx/contract/__init__.py +179 -0
  169. evalrx/contract/common.py +452 -0
  170. evalrx/contract/emit.py +948 -0
  171. evalrx/contract/export.py +237 -0
  172. evalrx/contract/m1.py +325 -0
  173. evalrx/contract/m2.py +317 -0
  174. evalrx/contract/m3.py +165 -0
  175. evalrx/contract/m4.py +130 -0
  176. evalrx/contract/m5.py +292 -0
  177. evalrx/contract/methodology.py +76 -0
  178. evalrx/contract/pre_m1.py +58 -0
  179. evalrx/contract/typescript.py +140 -0
  180. evalrx/core/__init__.py +85 -0
  181. evalrx/core/analyzer.py +174 -0
  182. evalrx/core/capability.py +54 -0
  183. evalrx/core/case.py +443 -0
  184. evalrx/core/experiment.py +106 -0
  185. evalrx/core/model.py +198 -0
  186. evalrx/core/pipeline.py +42 -0
  187. evalrx/core/registry.py +142 -0
  188. evalrx/core/result.py +64 -0
  189. evalrx/core/spec.py +173 -0
  190. evalrx/core/tokentype.py +165 -0
  191. evalrx/core/tool.py +92 -0
  192. evalrx/datasets/__init__.py +41 -0
  193. evalrx/datasets/base.py +68 -0
  194. evalrx/datasets/gui_os.py +52 -0
  195. evalrx/datasets/llm_qa.py +57 -0
  196. evalrx/datasets/pure_qa.py +12 -0
  197. evalrx/datasets/vlm_qa.py +695 -0
  198. evalrx/datasets/web_search_qa.py +52 -0
  199. evalrx/eval_agent/__init__.py +341 -0
  200. evalrx/eval_agent/_tools.py +81 -0
  201. evalrx/eval_agent/ab_runner.py +50 -0
  202. evalrx/eval_agent/agentic/__init__.py +43 -0
  203. evalrx/eval_agent/agentic/actions.py +216 -0
  204. evalrx/eval_agent/agentic/board.py +107 -0
  205. evalrx/eval_agent/agentic/loop.py +190 -0
  206. evalrx/eval_agent/agentic/tools.py +538 -0
  207. evalrx/eval_agent/checkpoint.py +57 -0
  208. evalrx/eval_agent/cli_agent.py +59 -0
  209. evalrx/eval_agent/cli_skills.py +5 -0
  210. evalrx/eval_agent/evolution.py +396 -0
  211. evalrx/eval_agent/git_manager.py +215 -0
  212. evalrx/eval_agent/hypothesis.py +172 -0
  213. evalrx/eval_agent/label_quarantine.py +209 -0
  214. evalrx/eval_agent/legacy.py +530 -0
  215. evalrx/eval_agent/log_schema.py +497 -0
  216. evalrx/eval_agent/loop.py +2159 -0
  217. evalrx/eval_agent/loop_reports.py +116 -0
  218. evalrx/eval_agent/model_instrumentation.py +282 -0
  219. evalrx/eval_agent/narration.py +193 -0
  220. evalrx/eval_agent/nl_runner.py +460 -0
  221. evalrx/eval_agent/orchestrator.py +61 -0
  222. evalrx/eval_agent/preregister.py +93 -0
  223. evalrx/eval_agent/prompts/__init__.py +1 -0
  224. evalrx/eval_agent/prompts/agentic.py +46 -0
  225. evalrx/eval_agent/prompts/case_discovery.py +25 -0
  226. evalrx/eval_agent/prompts/diagnosis.py +125 -0
  227. evalrx/eval_agent/prompts/experiment_writer.py +265 -0
  228. evalrx/eval_agent/prompts/explore_step.py +37 -0
  229. evalrx/eval_agent/prompts/fix_agent.py +257 -0
  230. evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
  231. evalrx/eval_agent/prompts/nl_runner.py +38 -0
  232. evalrx/eval_agent/prompts/probe_agent.py +25 -0
  233. evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
  234. evalrx/eval_agent/prompts/probe_generator.py +35 -0
  235. evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
  236. evalrx/eval_agent/report.py +58 -0
  237. evalrx/eval_agent/run_context.py +354 -0
  238. evalrx/eval_agent/run_log.schema.json +1215 -0
  239. evalrx/eval_agent/run_logger_v2.py +1764 -0
  240. evalrx/eval_agent/run_metadata.py +208 -0
  241. evalrx/eval_agent/stages/__init__.py +56 -0
  242. evalrx/eval_agent/stages/case_discovery.py +293 -0
  243. evalrx/eval_agent/stages/diagnosis.py +1017 -0
  244. evalrx/eval_agent/stages/experiment_writer.py +1634 -0
  245. evalrx/eval_agent/stages/fix_agent.py +3916 -0
  246. evalrx/eval_agent/stages/fix_internals.py +499 -0
  247. evalrx/eval_agent/stages/fix_pipeline.py +725 -0
  248. evalrx/eval_agent/stages/fix_tiers.py +187 -0
  249. evalrx/eval_agent/stages/fix_tools.py +1034 -0
  250. evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
  251. evalrx/eval_agent/stages/probe.py +439 -0
  252. evalrx/eval_agent/stages/probe_agent.py +1079 -0
  253. evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
  254. evalrx/eval_agent/stages/probe_generator.py +326 -0
  255. evalrx/eval_agent/stages/probe_search_agent.py +106 -0
  256. evalrx/eval_agent/stages/protocol.py +112 -0
  257. evalrx/eval_agent/stages/repair_catalog.py +273 -0
  258. evalrx/eval_agent/stages/surgery.py +524 -0
  259. evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
  260. evalrx/eval_agent/store.py +231 -0
  261. evalrx/logging_utils.py +112 -0
  262. evalrx/models/__init__.py +161 -0
  263. evalrx/models/_discover.py +101 -0
  264. evalrx/models/agent.py +380 -0
  265. evalrx/models/backends/__init__.py +58 -0
  266. evalrx/models/backends/api.py +169 -0
  267. evalrx/models/backends/base.py +57 -0
  268. evalrx/models/backends/gemini_compat.py +579 -0
  269. evalrx/models/backends/hf_local.py +2074 -0
  270. evalrx/models/backends/openai_compat.py +301 -0
  271. evalrx/models/backends/vllm_offline.py +116 -0
  272. evalrx/models/base.py +24 -0
  273. evalrx/models/blackbox/__init__.py +4 -0
  274. evalrx/models/blackbox/agent.py +31 -0
  275. evalrx/models/blackbox/base.py +29 -0
  276. evalrx/models/blackbox/gemini.py +279 -0
  277. evalrx/models/blackbox/llm_api.py +17 -0
  278. evalrx/models/blackbox/vlm_api.py +17 -0
  279. evalrx/models/compose.py +66 -0
  280. evalrx/models/inference.py +88 -0
  281. evalrx/models/paper_methods/__init__.py +8 -0
  282. evalrx/models/paper_methods/aad.py +53 -0
  283. evalrx/models/paper_methods/ifcd.py +204 -0
  284. evalrx/models/paper_methods/pai.py +164 -0
  285. evalrx/models/paper_methods/tcd.py +202 -0
  286. evalrx/models/paper_methods/vcd.py +45 -0
  287. evalrx/models/paper_methods/vicrop.py +137 -0
  288. evalrx/models/toolcodec.py +143 -0
  289. evalrx/models/tools/__init__.py +20 -0
  290. evalrx/models/tools/perception.py +300 -0
  291. evalrx/models/tools/visual.py +174 -0
  292. evalrx/models/whitebox/__init__.py +26 -0
  293. evalrx/models/whitebox/agent.py +31 -0
  294. evalrx/models/whitebox/base.py +24 -0
  295. evalrx/models/whitebox/qwen.py +61 -0
  296. evalrx/models/whitebox/qwen2_5_omni.py +29 -0
  297. evalrx/models/whitebox/qwen2_audio.py +25 -0
  298. evalrx/models/whitebox/qwen_omni.py +53 -0
  299. evalrx/models/whitebox/qwen_vl.py +62 -0
  300. evalrx/observability/__init__.py +21 -0
  301. evalrx/observability/envelope.py +122 -0
  302. evalrx/observability/outbox.py +111 -0
  303. evalrx/observability/tracer.py +882 -0
  304. evalrx/reporting/__init__.py +28 -0
  305. evalrx/reporting/case_study.py +947 -0
  306. evalrx/reporting/compiler.py +587 -0
  307. evalrx/reporting/dynamic.py +1882 -0
  308. evalrx/reporting/html_report.py +2225 -0
  309. evalrx/reporting/langfuse_exporter.py +38 -0
  310. evalrx/reporting/langfuse_source.py +155 -0
  311. evalrx/reporting/model.py +151 -0
  312. evalrx/reporting/run_events.py +184 -0
  313. evalrx/reporting/server.py +557 -0
  314. evalrx/reporting/stages.py +58 -0
  315. evalrx/reporting/static_export.py +142 -0
  316. evalrx/reporting/web_dist/index.html +146 -0
  317. evalrx/specs.py +727 -0
  318. evalrx/stats/__init__.py +47 -0
  319. evalrx/stats/api.py +192 -0
  320. evalrx/stats/bootstrap.py +86 -0
  321. evalrx/stats/ebh.py +27 -0
  322. evalrx/stats/evalue.py +98 -0
  323. evalrx/stats/friedman.py +138 -0
  324. evalrx/stats/mcnemar.py +40 -0
  325. evalrx/stats/multiplicity.py +159 -0
  326. evalrx/stats/subset_sampling.py +55 -0
  327. evalrx/term_links.py +43 -0
  328. evalrx/viz/__init__.py +7 -0
  329. evalrx/viz/labels.py +77 -0
  330. evalrx/viz/prompts.py +39 -0
  331. evalrx/viz/renderer.py +590 -0
  332. evalrx/viz/schema.py +36 -0
  333. evalrx/viz/style.py +134 -0
  334. evalrx-0.1.2.dist-info/METADATA +532 -0
  335. evalrx-0.1.2.dist-info/RECORD +339 -0
  336. evalrx-0.1.2.dist-info/WHEEL +5 -0
  337. evalrx-0.1.2.dist-info/entry_points.txt +3 -0
  338. evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
  339. evalrx-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,579 @@
1
+ """Built-in Gemini client — chat_fn / generate_fn / logprobs_fn factories for APIModel.
2
+
3
+ The mirror of :mod:`openai_compat` on Google's official ``google-genai`` SDK,
4
+ so a closed-weight Gemini model rides the same ``api`` backend as a served
5
+ open model::
6
+
7
+ from evalrx import compose
8
+ from evalrx.models.backends.gemini_compat import gemini_runtime
9
+
10
+ rt = gemini_runtime(temperature=0.6, max_output_tokens=2048) # key: GEMINI_API_KEY
11
+ llm = compose("gemini-3.6-flash", "api", runtime=rt)
12
+
13
+ What the wrapper adds over the raw SDK, in the order it matters for a
14
+ diagnosis run:
15
+
16
+ * **Thinking floor.** The benchmark runs every model with thinking as far OFF
17
+ as the model allows. 3.x models take ``thinking_level`` (3.7-flash bottoms
18
+ out at ``low``, the others at ``minimal``); 2.5 models take
19
+ ``thinking_budget`` (``0`` on flash / flash-lite; 2.5-pro cannot switch it
20
+ off, floor 128). :func:`thinking_config` resolves the floor per model id,
21
+ ``ThinkingPolicy(level=..., budget=...)`` overrides it, and a model that
22
+ rejects the config falls back to the API default ONCE per model, logged.
23
+ * **Thought parts are not answer text.** The answer is rebuilt from the
24
+ non-``thought`` parts only (``response.text`` would fold summaries in).
25
+ * **OpenAI-named sampling kwargs are translated** (``max_tokens`` →
26
+ ``max_output_tokens``, ``n`` → ``candidate_count``, ``stop`` →
27
+ ``stop_sequences``): the fix stage's L0 candidates re-issue the baseline
28
+ decoding controls under the names the endpoint path uses.
29
+ * **Retry with backoff** on 429 / 5xx (``RESOURCE_EXHAUSTED`` / ``UNAVAILABLE``)
30
+ — the discovery pass fires ``--concurrency`` requests at once.
31
+ * **Media goes inline**: image → PNG part, audio → its own format (or 16-bit
32
+ WAV for a waveform); the API takes up to 20 MB per request inline.
33
+ * **Logprobs are OFF by default.** Gemini returns none for 3.x models
34
+ ("working as intended", Google forum 2026-08-05) and withdrew them on 2.5;
35
+ pass ``with_logprobs=True`` only against a model that actually returns a
36
+ ``logprobs_result`` — claiming :attr:`Capability.LOGPROBS` otherwise turns a
37
+ skipped analyzer into a failing one.
38
+
39
+ Torch-free; ``google-genai`` is imported lazily (``pip install 'evalrx[gemini]'``).
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import logging
46
+ import os
47
+ import random
48
+ import threading
49
+ import time
50
+ from dataclasses import dataclass
51
+ from typing import Any, Callable, Optional
52
+
53
+ from evalrx.core.model import TokenLogprob
54
+ from evalrx.core.tool import ChatTurn
55
+ from evalrx.models.backends.base import RuntimeConfig
56
+ from evalrx.models.blackbox.gemini import (
57
+ _audio_bytes,
58
+ _png_bytes,
59
+ _to_genai_contents,
60
+ _to_genai_tools,
61
+ )
62
+
63
+ logger = logging.getLogger(__name__)
64
+
65
+ API_KEY_ENVS = ("GEMINI_API_KEY", "GOOGLE_API_KEY")
66
+ THINKING_LEVELS = ("minimal", "low", "medium", "high")
67
+
68
+ #: Lowest ``thinking_level`` each 3.x model accepts (model cards + the thinking
69
+ #: guide, read 2026-08-25). Keys match as prefixes, so a dated variant
70
+ #: (``gemini-3.5-flash-lite-preview-…``) resolves to its base model.
71
+ LEVEL_FLOOR: dict[str, str] = {
72
+ "gemini-3.7-flash": "low", # minimal is not offered on 3.7-flash
73
+ "gemini-3.6-flash": "minimal",
74
+ "gemini-3.5-flash": "minimal",
75
+ "gemini-3.5-flash-lite": "minimal",
76
+ "gemini-3.1-flash-lite": "minimal",
77
+ }
78
+ #: Lowest ``thinking_budget`` each 2.5 model accepts (0 = thinking off).
79
+ BUDGET_FLOOR: dict[str, int] = {
80
+ "gemini-2.5-flash": 0,
81
+ "gemini-2.5-flash-lite": 0,
82
+ "gemini-2.5-pro": 128,
83
+ }
84
+ #: A ``--thinking-level`` asked of a budget model: rough token equivalents.
85
+ LEVEL_TO_BUDGET: dict[str, int] = {"minimal": 0, "low": 1024, "medium": 8192, "high": 24576}
86
+
87
+ #: Thought tokens count against ``max_output_tokens`` (measured 2026-08-25 on
88
+ #: 3.6/3.7-flash: at a 16-token cap with thinking_level=low the answer is
89
+ #: truncated or empty, finish_reason=MAX_TOKENS). Whenever the effective
90
+ #: thinking config is not "off" (level above minimal, or a budget above 0),
91
+ #: the request cap is raised by this headroom so a short-answer task's 64-token
92
+ #: budget measures the answer, not the thoughts. 3.7-flash cannot go below
93
+ #: ``low``, so this is its normal operating mode.
94
+ THINKING_OUTPUT_HEADROOM = 1024
95
+
96
+
97
+ def thinking_spends_output_tokens(think: "dict | None") -> bool:
98
+ """True when *think* lets the model emit thought tokens (they share the cap)."""
99
+ if not think:
100
+ return False
101
+ if think.get("thinking_budget") is not None:
102
+ return int(think["thinking_budget"]) > 0
103
+ return str(think.get("thinking_level", "")).lower() != "minimal"
104
+
105
+ #: google-genai logs an "automatic function calling (AFC) … not recommended"
106
+ #: notice from ``Models.generate_content`` at WARNING; its once-per-process
107
+ #: guard races under concurrent discovery and the notice floods stderr (seen
108
+ #: 2026-08-25, SDK 2.20.0, concurrency 8). AFC is never enabled by this runtime
109
+ #: (tools go in as declarations, not callables), so the notice — and the "AFC is
110
+ #: enabled"/"AFC remote call" INFO chatter — carries no signal here. Drop exactly
111
+ #: those records; every other SDK warning stays visible.
112
+ _SDK_NOISE_MARKERS = ("automatic function calling", "AFC is enabled", "AFC remote call")
113
+
114
+
115
+ class _DropSdkNoise(logging.Filter):
116
+ def filter(self, record: logging.LogRecord) -> bool:
117
+ msg = record.getMessage()
118
+ return not any(marker in msg for marker in _SDK_NOISE_MARKERS)
119
+
120
+
121
+ _SDK_NOISE_FILTER = _DropSdkNoise()
122
+
123
+
124
+ def silence_sdk_chatter() -> None:
125
+ """Keep google-genai AFC chatter off stderr (idempotent, per-logger)."""
126
+ sdk_logger = logging.getLogger("google_genai.models")
127
+ if _SDK_NOISE_FILTER not in sdk_logger.filters:
128
+ sdk_logger.addFilter(_SDK_NOISE_FILTER)
129
+
130
+
131
+ _RETRY_CODES = frozenset({408, 409, 429, 500, 502, 503, 504})
132
+ _RETRY_MARKERS = ("resource_exhausted", "unavailable", "deadline_exceeded", "overloaded", "rate limit")
133
+
134
+ # OpenAI / transformers names the harness uses -> GenerateContentConfig names.
135
+ _RENAME = {"max_tokens": "max_output_tokens", "max_new_tokens": "max_output_tokens",
136
+ "n": "candidate_count", "stop": "stop_sequences"}
137
+ _PASS = frozenset({
138
+ "temperature", "top_p", "top_k", "seed", "max_output_tokens", "candidate_count",
139
+ "stop_sequences", "response_logprobs", "logprobs", "safety_settings",
140
+ "presence_penalty", "frequency_penalty",
141
+ })
142
+ _DROP = frozenset({"do_sample", "extra_body", "chat_template_kwargs", "top_logprobs", "timeout", "stream"})
143
+
144
+
145
+ # ----------------------------------------------------------------------
146
+ # Thinking policy
147
+ # ----------------------------------------------------------------------
148
+ @dataclass(frozen=True)
149
+ class ThinkingPolicy:
150
+ """How much the model may think.
151
+
152
+ ``level`` / ``budget`` are explicit overrides (``--thinking-level`` /
153
+ ``--thinking-budget``); with neither, ``floor=True`` sends the lowest
154
+ setting the model accepts and ``floor=False`` leaves the API default
155
+ (``--enable-thinking``).
156
+ """
157
+
158
+ level: Optional[str] = None
159
+ budget: Optional[int] = None
160
+ floor: bool = True
161
+
162
+
163
+ def _base_model(model: str) -> str:
164
+ return model.split("/", 1)[1] if model.startswith("models/") else model
165
+
166
+
167
+ def _lookup(table: dict, model: str) -> Optional[str]:
168
+ """Longest key that is *model* or a dashed prefix of it."""
169
+ name = _base_model(model)
170
+ best = None
171
+ for key in table:
172
+ if name == key or name.startswith(key + "-"):
173
+ if best is None or len(key) > len(best):
174
+ best = key
175
+ return best
176
+
177
+
178
+ def thinking_config(model: str, policy: ThinkingPolicy = ThinkingPolicy()) -> Optional[dict]:
179
+ """``ThinkingConfig`` kwargs for *model* under *policy*; None = the API default."""
180
+ if policy.budget is not None:
181
+ return {"thinking_budget": int(policy.budget)}
182
+ level_key = _lookup(LEVEL_FLOOR, model)
183
+ budget_key = _lookup(BUDGET_FLOOR, model)
184
+ if policy.level is not None:
185
+ level = str(policy.level).lower()
186
+ if level not in THINKING_LEVELS:
187
+ raise ValueError(f"unknown thinking level {policy.level!r}; one of {THINKING_LEVELS}")
188
+ if budget_key is not None:
189
+ return {"thinking_budget": max(BUDGET_FLOOR[budget_key], LEVEL_TO_BUDGET[level])}
190
+ if level_key is not None:
191
+ floor = LEVEL_FLOOR[level_key]
192
+ if THINKING_LEVELS.index(level) < THINKING_LEVELS.index(floor):
193
+ logger.warning("%s does not go below thinking_level=%s; sending it instead of %s",
194
+ model, floor, level)
195
+ level = floor
196
+ return {"thinking_level": level}
197
+ if not policy.floor:
198
+ return None
199
+ if level_key is not None:
200
+ return {"thinking_level": LEVEL_FLOOR[level_key]}
201
+ if budget_key is not None:
202
+ return {"thinking_budget": BUDGET_FLOOR[budget_key]}
203
+ logger.warning("%s: no thinking floor on record; thinking stays at the API default", model)
204
+ return None
205
+
206
+
207
+ # ----------------------------------------------------------------------
208
+ # Request shaping
209
+ # ----------------------------------------------------------------------
210
+ def translate_sampling(kwargs: dict) -> dict:
211
+ """OpenAI / transformers sampling names -> ``GenerateContentConfig`` fields.
212
+
213
+ Unknown names are dropped (logged at debug) rather than sent: the SDK
214
+ raises on them, and the harness passes decoding controls under the
215
+ endpoint path's names (``max_tokens``, ``do_sample``, ``extra_body``).
216
+ """
217
+ out: dict[str, Any] = {}
218
+ unknown = []
219
+ for key, value in kwargs.items():
220
+ name = _RENAME.get(key, key)
221
+ if name == "logprobs" and isinstance(value, bool):
222
+ continue # OpenAI's flag; Gemini's `logprobs` is the top-k count (set by logprobs_fn)
223
+ if name in _PASS:
224
+ if name == "stop_sequences" and isinstance(value, str):
225
+ value = [value]
226
+ if name in ("max_output_tokens", "candidate_count", "top_k", "logprobs", "seed"):
227
+ value = int(value)
228
+ out[name] = value
229
+ elif name in _DROP:
230
+ continue
231
+ else:
232
+ unknown.append(key)
233
+ if unknown:
234
+ logger.debug("gemini: ignoring unsupported sampling kwargs %s", unknown)
235
+ return out
236
+
237
+
238
+ def _parts(types: Any, prompt: str, image: Any = None, audio: Any = None) -> list:
239
+ """One user turn as genai parts: media first, then the prompt."""
240
+ parts = []
241
+ if audio is not None:
242
+ data, mime = _audio_bytes(audio)
243
+ parts.append(types.Part.from_bytes(data=data, mime_type=mime))
244
+ if image is not None:
245
+ parts.append(types.Part.from_bytes(data=_png_bytes(image), mime_type="image/png"))
246
+ parts.append(types.Part(text=prompt))
247
+ return parts
248
+
249
+
250
+ def _retryable(exc: Exception) -> bool:
251
+ code = getattr(exc, "code", None)
252
+ if isinstance(code, int) and code in _RETRY_CODES:
253
+ return True
254
+ text = str(exc).lower()
255
+ return any(marker in text for marker in _RETRY_MARKERS)
256
+
257
+
258
+ class _Runtime:
259
+ """One client + the per-model decisions the three factories share."""
260
+
261
+ def __init__(self, *, api_key: Optional[str], client: Any, timeout: float, retries: int,
262
+ policy: ThinkingPolicy, top_logprobs: int, sampling: dict) -> None:
263
+ self.api_key = api_key
264
+ self._client = client
265
+ self.timeout = float(timeout)
266
+ self.retries = max(0, int(retries))
267
+ self.policy = policy
268
+ self.top_logprobs = int(top_logprobs)
269
+ self.sampling = dict(sampling)
270
+ #: shared, readable through ``fn.state`` on every factory output
271
+ self.state: dict[str, Any] = {
272
+ "model_version": None, "calls": 0, "retries": 0, "thinking_fallback": [],
273
+ "headroom_for": [],
274
+ }
275
+ self._lock = threading.Lock()
276
+ silence_sdk_chatter()
277
+
278
+ # -- plumbing ------------------------------------------------------
279
+ def genai(self):
280
+ try:
281
+ from google import genai
282
+ from google.genai import types
283
+ except ImportError as exc: # pragma: no cover - depends on the install
284
+ raise ImportError(
285
+ "gemini_runtime needs the google-genai package: pip install 'evalrx[gemini]'"
286
+ ) from exc
287
+ with self._lock:
288
+ if self._client is None:
289
+ key = self.api_key or next((os.environ[e] for e in API_KEY_ENVS if os.environ.get(e)), None)
290
+ if not key:
291
+ raise ValueError("No Gemini API key: pass api_key= or set GEMINI_API_KEY")
292
+ self._client = genai.Client(
293
+ api_key=key, http_options=types.HttpOptions(timeout=int(self.timeout * 1000))
294
+ )
295
+ return self._client, types
296
+
297
+ def thinking(self, model: str) -> Optional[dict]:
298
+ if model in self.state["thinking_fallback"]:
299
+ return None
300
+ return thinking_config(model, self.policy)
301
+
302
+ def config(self, types: Any, model: str, extra: dict, *, system: Any = None,
303
+ tools: Any = None, logprobs: bool = False):
304
+ kw = translate_sampling({**self.sampling, **extra})
305
+ think = self.thinking(model)
306
+ if think:
307
+ kw["thinking_config"] = types.ThinkingConfig(**think)
308
+ if thinking_spends_output_tokens(think) and kw.get("max_output_tokens"):
309
+ # thought tokens share max_output_tokens: give them their own room
310
+ kw["max_output_tokens"] = int(kw["max_output_tokens"]) + THINKING_OUTPUT_HEADROOM
311
+ if model not in self.state["headroom_for"]:
312
+ self.state["headroom_for"].append(model)
313
+ logger.info("%s thinks at %s: max_output_tokens raised by %d for the thought tokens",
314
+ model, think, THINKING_OUTPUT_HEADROOM)
315
+ if system is not None:
316
+ kw["system_instruction"] = system
317
+ if tools:
318
+ kw["tools"] = tools
319
+ if logprobs:
320
+ kw["response_logprobs"] = True
321
+ kw["logprobs"] = self.top_logprobs
322
+ return types.GenerateContentConfig(**kw)
323
+
324
+ def request(self, model: str, contents: Any, extra: dict, *, system: Any = None,
325
+ tools: Any = None, logprobs: bool = False):
326
+ client, types = self.genai()
327
+ attempt = 0
328
+ while True:
329
+ config = self.config(types, model, extra, system=system, tools=tools, logprobs=logprobs)
330
+ try:
331
+ response = self._generate_with_deadline(
332
+ client, model=model, contents=contents, config=config,
333
+ )
334
+ except Exception as exc:
335
+ text = str(exc)
336
+ if (getattr(exc, "code", None) == 400 and "thinking" in text.lower()
337
+ and self.thinking(model) is not None):
338
+ logger.warning("%s rejected thinking config %s (%s); using the API default from now on",
339
+ model, self.thinking(model), text[:160])
340
+ self.state["thinking_fallback"].append(model)
341
+ continue
342
+ if attempt < self.retries and _retryable(exc):
343
+ delay = min(30.0, 1.5 * (2 ** attempt)) + random.uniform(0.0, 0.5)
344
+ self.state["retries"] += 1
345
+ logger.warning("gemini %s: %s; retry %d/%d in %.1fs",
346
+ model, text[:120], attempt + 1, self.retries, delay)
347
+ time.sleep(delay)
348
+ attempt += 1
349
+ continue
350
+ raise
351
+ self.state["calls"] += 1
352
+ version = getattr(response, "model_version", None)
353
+ if version:
354
+ self.state["model_version"] = version
355
+ return response
356
+
357
+ def _generate_with_deadline(self, client: Any, *, model: str, contents: Any, config: Any):
358
+ """Enforce a wall-clock deadline around a synchronous SDK request.
359
+
360
+ ``google-genai``'s HTTP timeout is normally sufficient, but an audio
361
+ upload can occasionally leave its synchronous call blocked beyond that
362
+ setting. Benchmark M1 fans those calls out, so one such request used
363
+ to keep an entire diagnosis run alive indefinitely. A daemon helper
364
+ lets the caller recover at ``self.timeout``; it deliberately cannot
365
+ join a wedged transport thread, so it never delays process shutdown.
366
+ """
367
+ done = threading.Event()
368
+ result: dict[str, Any] = {}
369
+
370
+ def _call() -> None:
371
+ try:
372
+ result["response"] = client.models.generate_content(
373
+ model=model, contents=contents, config=config,
374
+ )
375
+ except BaseException as exc: # re-raised on the caller thread
376
+ result["error"] = exc
377
+ finally:
378
+ done.set()
379
+
380
+ worker = threading.Thread(target=_call, name="gemini-request", daemon=True)
381
+ worker.start()
382
+ if not done.wait(self.timeout):
383
+ raise TimeoutError(f"Gemini request exceeded wall-clock timeout of {self.timeout:.1f}s")
384
+ if "error" in result:
385
+ raise result["error"]
386
+ return result["response"]
387
+
388
+
389
+ # ----------------------------------------------------------------------
390
+ # Response reading
391
+ # ----------------------------------------------------------------------
392
+ def _candidate(response: Any):
393
+ candidates = getattr(response, "candidates", None) or []
394
+ if not candidates:
395
+ feedback = getattr(response, "prompt_feedback", None)
396
+ reason = getattr(feedback, "block_reason", None) if feedback is not None else None
397
+ if reason:
398
+ logger.warning("gemini returned no candidate (block_reason=%s)", reason)
399
+ return None, []
400
+ cand = candidates[0]
401
+ content = getattr(cand, "content", None)
402
+ return cand, (getattr(content, "parts", None) or [])
403
+
404
+
405
+ def answer_text(response: Any) -> str:
406
+ """The model's answer: text parts that are not ``thought`` parts."""
407
+ _cand, parts = _candidate(response)
408
+ return "".join(p.text for p in parts
409
+ if getattr(p, "text", None) and not getattr(p, "thought", False))
410
+
411
+
412
+ def _finish_reason(cand: Any) -> Optional[str]:
413
+ reason = getattr(cand, "finish_reason", None) if cand is not None else None
414
+ if reason is None:
415
+ return None
416
+ name = (getattr(reason, "name", None) or str(reason)).rsplit(".", 1)[-1].upper()
417
+ return {"STOP": "stop", "MAX_TOKENS": "length"}.get(name, name.lower())
418
+
419
+
420
+ def _usage(response: Any) -> Optional[dict]:
421
+ um = getattr(response, "usage_metadata", None)
422
+ if um is None:
423
+ return None
424
+ return {
425
+ "prompt_tokens": int(getattr(um, "prompt_token_count", 0) or 0),
426
+ "completion_tokens": int(getattr(um, "candidates_token_count", 0) or 0),
427
+ "thought_tokens": int(getattr(um, "thoughts_token_count", 0) or 0),
428
+ }
429
+
430
+
431
+ def parse_logprobs_result(result: Any) -> list[TokenLogprob]:
432
+ """``candidates[0].logprobs_result`` -> ``list[TokenLogprob]``.
433
+
434
+ ``chosen_candidates`` are the generated tokens; ``top_candidates[i]`` the
435
+ top-k alternatives at step *i*. A missing result (the model returns none)
436
+ is an empty list, which the caller must read as "no logprobs", not as
437
+ "an empty answer".
438
+ """
439
+ if result is None:
440
+ return []
441
+ chosen = getattr(result, "chosen_candidates", None) or []
442
+ tops = getattr(result, "top_candidates", None) or []
443
+ out: list[TokenLogprob] = []
444
+ for i, c in enumerate(chosen):
445
+ top: dict[str, float] = {}
446
+ if i < len(tops):
447
+ for t in getattr(tops[i], "candidates", None) or []:
448
+ top[getattr(t, "token", "") or ""] = float(getattr(t, "log_probability", 0.0) or 0.0)
449
+ out.append(TokenLogprob(token=getattr(c, "token", "") or "",
450
+ logprob=float(getattr(c, "log_probability", 0.0) or 0.0), top=top))
451
+ return out
452
+
453
+
454
+ # ----------------------------------------------------------------------
455
+ # Factories
456
+ # ----------------------------------------------------------------------
457
+ def _generate_fn(rt: _Runtime) -> Callable[..., str]:
458
+ def _fn(prompt: str, model: str = "", *, image: Any = None, audio: Any = None, **kw) -> str:
459
+ _client, types = rt.genai()
460
+ return answer_text(rt.request(model, _parts(types, prompt, image, audio), kw))
461
+
462
+ _fn.state = rt.state # type: ignore[attr-defined]
463
+ return _fn
464
+
465
+
466
+ def _chat_fn(rt: _Runtime) -> Callable[..., ChatTurn]:
467
+ def _fn(messages: list, tools=None, model: str = "") -> ChatTurn:
468
+ _client, types = rt.genai()
469
+ system, contents = _to_genai_contents(messages, types)
470
+ response = rt.request(model, contents, {}, system=system, tools=_to_genai_tools(tools, types))
471
+ cand, parts = _candidate(response)
472
+ texts: list[str] = []
473
+ calls: list[dict] = []
474
+ for part in parts:
475
+ if getattr(part, "text", None) and not getattr(part, "thought", False):
476
+ texts.append(part.text)
477
+ fc = getattr(part, "function_call", None)
478
+ if fc is not None:
479
+ calls.append({"id": getattr(fc, "id", None),
480
+ "function": {"name": fc.name, "arguments": json.dumps(dict(fc.args or {}))}})
481
+ return ChatTurn(text="".join(texts), raw_tool_calls=calls or None,
482
+ finish_reason=_finish_reason(cand), usage=_usage(response))
483
+
484
+ _fn.state = rt.state # type: ignore[attr-defined]
485
+ return _fn
486
+
487
+
488
+ def _logprobs_fn(rt: _Runtime) -> Callable[..., list]:
489
+ def _fn(prompt: str, model: str = "", *, image: Any = None, audio: Any = None, **kw) -> list:
490
+ _client, types = rt.genai()
491
+ response = rt.request(model, _parts(types, prompt, image, audio), kw, logprobs=True)
492
+ cand, _parts_ = _candidate(response)
493
+ return parse_logprobs_result(getattr(cand, "logprobs_result", None) if cand is not None else None)
494
+
495
+ _fn.state = rt.state # type: ignore[attr-defined]
496
+ return _fn
497
+
498
+
499
+ def _runtime(*, api_key, client, timeout, retries, thinking, top_logprobs, sampling) -> _Runtime:
500
+ sampling = dict(sampling)
501
+ sampling.setdefault("temperature", 0.0)
502
+ return _Runtime(api_key=api_key, client=client, timeout=timeout, retries=retries,
503
+ policy=thinking or ThinkingPolicy(), top_logprobs=top_logprobs, sampling=sampling)
504
+
505
+
506
+ def gemini_generate_fn(
507
+ *,
508
+ api_key: Optional[str] = None,
509
+ client: Any = None,
510
+ timeout: float = 300.0,
511
+ retries: int = 5,
512
+ thinking: Optional[ThinkingPolicy] = None,
513
+ **sampling: Any,
514
+ ) -> Callable[..., str]:
515
+ """Build ``generate_fn(prompt, model=..., image=, audio=) -> str``."""
516
+ return _generate_fn(_runtime(api_key=api_key, client=client, timeout=timeout, retries=retries,
517
+ thinking=thinking, top_logprobs=5, sampling=sampling))
518
+
519
+
520
+ def gemini_chat_fn(
521
+ *,
522
+ api_key: Optional[str] = None,
523
+ client: Any = None,
524
+ timeout: float = 300.0,
525
+ retries: int = 5,
526
+ thinking: Optional[ThinkingPolicy] = None,
527
+ **sampling: Any,
528
+ ) -> Callable[..., ChatTurn]:
529
+ """Build a tool-aware ``chat_fn(messages, tools, model) -> ChatTurn`` (native function calling)."""
530
+ return _chat_fn(_runtime(api_key=api_key, client=client, timeout=timeout, retries=retries,
531
+ thinking=thinking, top_logprobs=5, sampling=sampling))
532
+
533
+
534
+ def gemini_logprobs_fn(
535
+ *,
536
+ api_key: Optional[str] = None,
537
+ client: Any = None,
538
+ timeout: float = 300.0,
539
+ retries: int = 5,
540
+ thinking: Optional[ThinkingPolicy] = None,
541
+ top_logprobs: int = 5,
542
+ **sampling: Any,
543
+ ) -> Callable[..., list]:
544
+ """Build ``logprobs_fn(prompt, model=...) -> list[TokenLogprob]`` (``response_logprobs``)."""
545
+ return _logprobs_fn(_runtime(api_key=api_key, client=client, timeout=timeout, retries=retries,
546
+ thinking=thinking, top_logprobs=top_logprobs, sampling=sampling))
547
+
548
+
549
+ def gemini_runtime(
550
+ *,
551
+ api_key: Optional[str] = None,
552
+ client: Any = None,
553
+ timeout: float = 300.0,
554
+ retries: int = 5,
555
+ thinking: Optional[ThinkingPolicy] = None,
556
+ with_logprobs: bool = False,
557
+ top_logprobs: int = 5,
558
+ **sampling: Any,
559
+ ) -> RuntimeConfig:
560
+ """A ready :class:`RuntimeConfig` for ``compose(key, "api", runtime=...)``.
561
+
562
+ The three functions share one client and one ``state`` dict
563
+ (``model_version`` of the last reply, call / retry counts, models that
564
+ fell back to the default thinking config). ``with_logprobs`` is False by
565
+ default because Gemini does not return logprobs for the 3.x models.
566
+ """
567
+ rt = _runtime(api_key=api_key, client=client, timeout=timeout, retries=retries,
568
+ thinking=thinking, top_logprobs=top_logprobs, sampling=sampling)
569
+ return RuntimeConfig(
570
+ chat_fn=_chat_fn(rt),
571
+ generate_fn=_generate_fn(rt),
572
+ logprobs_fn=_logprobs_fn(rt) if with_logprobs else None,
573
+ )
574
+
575
+
576
+ def runtime_state(model: Any) -> dict:
577
+ """The shared ``state`` behind an APIModel built by :func:`gemini_runtime` (else ``{}``)."""
578
+ fn = getattr(getattr(model, "runtime", None), "generate_fn", None)
579
+ return dict(getattr(fn, "state", None) or {})