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.
- evalrx/__init__.py +139 -0
- evalrx/agent_assets/__init__.py +2 -0
- evalrx/agent_assets/skills/README.md +28 -0
- evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
- evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
- evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
- evalrx/agent_assets/skills/nature-figure/README.md +412 -0
- evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
- evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
- evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
- evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
- evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
- evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
- evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
- evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
- evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
- evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
- evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
- evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
- evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
- evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
- evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
- evalrx/agent_assets/skills.py +27 -0
- evalrx/agent_runtime/__init__.py +78 -0
- evalrx/agent_runtime/_docker_runner.py +89 -0
- evalrx/agent_runtime/cli_runtime.py +103 -0
- evalrx/agent_runtime/cli_transcript.py +138 -0
- evalrx/agent_runtime/cli_types.py +68 -0
- evalrx/agent_runtime/codegen/__init__.py +5 -0
- evalrx/agent_runtime/codegen/runner.py +94 -0
- evalrx/agent_runtime/experiment_harness.py +117 -0
- evalrx/agent_runtime/factory.py +102 -0
- evalrx/agent_runtime/json_shape.py +44 -0
- evalrx/agent_runtime/judges/__init__.py +28 -0
- evalrx/agent_runtime/judges/agy.py +179 -0
- evalrx/agent_runtime/judges/autodetect.py +135 -0
- evalrx/agent_runtime/judges/claude.py +159 -0
- evalrx/agent_runtime/judges/codex.py +120 -0
- evalrx/agent_runtime/providers/__init__.py +21 -0
- evalrx/agent_runtime/providers/antigravity.py +31 -0
- evalrx/agent_runtime/providers/base.py +145 -0
- evalrx/agent_runtime/providers/claude_code.py +49 -0
- evalrx/agent_runtime/providers/codex.py +37 -0
- evalrx/agent_runtime/providers/gemini_cli.py +26 -0
- evalrx/agent_runtime/providers/kimi_cli.py +27 -0
- evalrx/agent_runtime/providers/opencode.py +27 -0
- evalrx/agent_runtime/providers/registry.py +58 -0
- evalrx/agent_runtime/sandbox.py +517 -0
- evalrx/agent_runtime/skill_audit.py +143 -0
- evalrx/agent_runtime/skills/__init__.py +19 -0
- evalrx/agent_runtime/skills/installer.py +68 -0
- evalrx/agent_runtime/skills/prompt_policy.py +86 -0
- evalrx/agent_runtime/skills/resolver.py +19 -0
- evalrx/analysis/__init__.py +132 -0
- evalrx/analysis/adjudicate.py +154 -0
- evalrx/analysis/analysis_module.py +361 -0
- evalrx/analysis/api.py +171 -0
- evalrx/analysis/case_studio.py +651 -0
- evalrx/analysis/cli.py +114 -0
- evalrx/analysis/dashboard.py +350 -0
- evalrx/analysis/eval_case_matrix.py +118 -0
- evalrx/analysis/eval_viz_theme.py +833 -0
- evalrx/analysis/explore_run.py +333 -0
- evalrx/analysis/explorer.py +1276 -0
- evalrx/analysis/failure_modes.py +607 -0
- evalrx/analysis/fused_pipeline.py +489 -0
- evalrx/analysis/holdout.py +300 -0
- evalrx/analysis/hypothesis_agent.py +230 -0
- evalrx/analysis/narration.py +177 -0
- evalrx/analysis/operationalize.py +442 -0
- evalrx/analysis/plain_language.py +42 -0
- evalrx/analysis/planner.py +283 -0
- evalrx/analysis/probe_search.py +203 -0
- evalrx/analysis/profile.py +268 -0
- evalrx/analysis/prompts/__init__.py +0 -0
- evalrx/analysis/prompts/explorer.py +417 -0
- evalrx/analysis/prompts/failure_modes.py +33 -0
- evalrx/analysis/prompts/holdout.py +27 -0
- evalrx/analysis/prompts/hypothesis_agent.py +78 -0
- evalrx/analysis/prompts/run_codebase.py +47 -0
- evalrx/analysis/prompts/stats_agent.py +72 -0
- evalrx/analysis/prompts/stats_tool_generator.py +43 -0
- evalrx/analysis/result_marker.py +47 -0
- evalrx/analysis/run_codebase.py +242 -0
- evalrx/analysis/run_view.py +205 -0
- evalrx/analysis/stage_views.py +93 -0
- evalrx/analysis/stats_agent.py +944 -0
- evalrx/analysis/stats_tool_agent.py +261 -0
- evalrx/analysis/stats_tool_generator.py +415 -0
- evalrx/analysis/stats_tools.py +1153 -0
- evalrx/analysis/trajectory_records.py +193 -0
- evalrx/analysis/workbench.py +431 -0
- evalrx/analyzers/__init__.py +42 -0
- evalrx/analyzers/agent/__init__.py +25 -0
- evalrx/analyzers/agent/counterfactual.py +84 -0
- evalrx/analyzers/agent/first_error_judge.py +96 -0
- evalrx/analyzers/agent/ignored_obs.py +81 -0
- evalrx/analyzers/agent/loop_detect.py +79 -0
- evalrx/analyzers/agent/reliability.py +165 -0
- evalrx/analyzers/agent/tool_shap.py +225 -0
- evalrx/analyzers/agent/trajectory_rubric.py +168 -0
- evalrx/analyzers/attention/__init__.py +19 -0
- evalrx/analyzers/attention/relative_attn.py +610 -0
- evalrx/analyzers/attention/rollout.py +73 -0
- evalrx/analyzers/attention/sink.py +56 -0
- evalrx/analyzers/attention/summary.py +190 -0
- evalrx/analyzers/attribution/__init__.py +6 -0
- evalrx/analyzers/attribution/generic_attn.py +31 -0
- evalrx/analyzers/attribution/gradcam.py +30 -0
- evalrx/analyzers/base.py +12 -0
- evalrx/analyzers/geometry/__init__.py +6 -0
- evalrx/analyzers/geometry/cka.py +70 -0
- evalrx/analyzers/geometry/linear_probe.py +157 -0
- evalrx/analyzers/hallucination/__init__.py +9 -0
- evalrx/analyzers/hallucination/chair.py +78 -0
- evalrx/analyzers/hallucination/opera.py +29 -0
- evalrx/analyzers/hallucination/pope.py +119 -0
- evalrx/analyzers/hallucination/selfcheck.py +155 -0
- evalrx/analyzers/hallucination/vcd.py +29 -0
- evalrx/analyzers/lens/__init__.py +7 -0
- evalrx/analyzers/lens/layer_contrast.py +133 -0
- evalrx/analyzers/lens/logit_lens.py +138 -0
- evalrx/analyzers/lens/tuned_lens.py +30 -0
- evalrx/analyzers/patching/__init__.py +5 -0
- evalrx/analyzers/patching/causal_trace.py +30 -0
- evalrx/analyzers/perturbation/__init__.py +23 -0
- evalrx/analyzers/perturbation/_shapley.py +54 -0
- evalrx/analyzers/perturbation/context_shap.py +174 -0
- evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
- evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
- evalrx/analyzers/perturbation/mm_shap.py +146 -0
- evalrx/analyzers/perturbation/modality_ablation.py +196 -0
- evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
- evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
- evalrx/analyzers/perturbation/rise.py +94 -0
- evalrx/analyzers/perturbation/vl_shap.py +102 -0
- evalrx/analyzers/reasoning/__init__.py +33 -0
- evalrx/analyzers/reasoning/_text.py +328 -0
- evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
- evalrx/analyzers/reasoning/arith_audit.py +226 -0
- evalrx/analyzers/reasoning/contamination.py +214 -0
- evalrx/analyzers/reasoning/knowledge_split.py +253 -0
- evalrx/analyzers/reasoning/self_repair.py +246 -0
- evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
- evalrx/analyzers/reasoning/termination_audit.py +258 -0
- evalrx/analyzers/uncertainty/__init__.py +18 -0
- evalrx/analyzers/uncertainty/calibration.py +174 -0
- evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
- evalrx/analyzers/uncertainty/entropy.py +90 -0
- evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
- evalrx/analyzers/uncertainty/self_consistency.py +204 -0
- evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
- evalrx/cli.py +411 -0
- evalrx/config.py +77 -0
- evalrx/contract/__init__.py +179 -0
- evalrx/contract/common.py +452 -0
- evalrx/contract/emit.py +948 -0
- evalrx/contract/export.py +237 -0
- evalrx/contract/m1.py +325 -0
- evalrx/contract/m2.py +317 -0
- evalrx/contract/m3.py +165 -0
- evalrx/contract/m4.py +130 -0
- evalrx/contract/m5.py +292 -0
- evalrx/contract/methodology.py +76 -0
- evalrx/contract/pre_m1.py +58 -0
- evalrx/contract/typescript.py +140 -0
- evalrx/core/__init__.py +85 -0
- evalrx/core/analyzer.py +174 -0
- evalrx/core/capability.py +54 -0
- evalrx/core/case.py +443 -0
- evalrx/core/experiment.py +106 -0
- evalrx/core/model.py +198 -0
- evalrx/core/pipeline.py +42 -0
- evalrx/core/registry.py +142 -0
- evalrx/core/result.py +64 -0
- evalrx/core/spec.py +173 -0
- evalrx/core/tokentype.py +165 -0
- evalrx/core/tool.py +92 -0
- evalrx/datasets/__init__.py +41 -0
- evalrx/datasets/base.py +68 -0
- evalrx/datasets/gui_os.py +52 -0
- evalrx/datasets/llm_qa.py +57 -0
- evalrx/datasets/pure_qa.py +12 -0
- evalrx/datasets/vlm_qa.py +695 -0
- evalrx/datasets/web_search_qa.py +52 -0
- evalrx/eval_agent/__init__.py +341 -0
- evalrx/eval_agent/_tools.py +81 -0
- evalrx/eval_agent/ab_runner.py +50 -0
- evalrx/eval_agent/agentic/__init__.py +43 -0
- evalrx/eval_agent/agentic/actions.py +216 -0
- evalrx/eval_agent/agentic/board.py +107 -0
- evalrx/eval_agent/agentic/loop.py +190 -0
- evalrx/eval_agent/agentic/tools.py +538 -0
- evalrx/eval_agent/checkpoint.py +57 -0
- evalrx/eval_agent/cli_agent.py +59 -0
- evalrx/eval_agent/cli_skills.py +5 -0
- evalrx/eval_agent/evolution.py +396 -0
- evalrx/eval_agent/git_manager.py +215 -0
- evalrx/eval_agent/hypothesis.py +172 -0
- evalrx/eval_agent/label_quarantine.py +209 -0
- evalrx/eval_agent/legacy.py +530 -0
- evalrx/eval_agent/log_schema.py +497 -0
- evalrx/eval_agent/loop.py +2159 -0
- evalrx/eval_agent/loop_reports.py +116 -0
- evalrx/eval_agent/model_instrumentation.py +282 -0
- evalrx/eval_agent/narration.py +193 -0
- evalrx/eval_agent/nl_runner.py +460 -0
- evalrx/eval_agent/orchestrator.py +61 -0
- evalrx/eval_agent/preregister.py +93 -0
- evalrx/eval_agent/prompts/__init__.py +1 -0
- evalrx/eval_agent/prompts/agentic.py +46 -0
- evalrx/eval_agent/prompts/case_discovery.py +25 -0
- evalrx/eval_agent/prompts/diagnosis.py +125 -0
- evalrx/eval_agent/prompts/experiment_writer.py +265 -0
- evalrx/eval_agent/prompts/explore_step.py +37 -0
- evalrx/eval_agent/prompts/fix_agent.py +257 -0
- evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
- evalrx/eval_agent/prompts/nl_runner.py +38 -0
- evalrx/eval_agent/prompts/probe_agent.py +25 -0
- evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
- evalrx/eval_agent/prompts/probe_generator.py +35 -0
- evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
- evalrx/eval_agent/report.py +58 -0
- evalrx/eval_agent/run_context.py +354 -0
- evalrx/eval_agent/run_log.schema.json +1215 -0
- evalrx/eval_agent/run_logger_v2.py +1764 -0
- evalrx/eval_agent/run_metadata.py +208 -0
- evalrx/eval_agent/stages/__init__.py +56 -0
- evalrx/eval_agent/stages/case_discovery.py +293 -0
- evalrx/eval_agent/stages/diagnosis.py +1017 -0
- evalrx/eval_agent/stages/experiment_writer.py +1634 -0
- evalrx/eval_agent/stages/fix_agent.py +3916 -0
- evalrx/eval_agent/stages/fix_internals.py +499 -0
- evalrx/eval_agent/stages/fix_pipeline.py +725 -0
- evalrx/eval_agent/stages/fix_tiers.py +187 -0
- evalrx/eval_agent/stages/fix_tools.py +1034 -0
- evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
- evalrx/eval_agent/stages/probe.py +439 -0
- evalrx/eval_agent/stages/probe_agent.py +1079 -0
- evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
- evalrx/eval_agent/stages/probe_generator.py +326 -0
- evalrx/eval_agent/stages/probe_search_agent.py +106 -0
- evalrx/eval_agent/stages/protocol.py +112 -0
- evalrx/eval_agent/stages/repair_catalog.py +273 -0
- evalrx/eval_agent/stages/surgery.py +524 -0
- evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
- evalrx/eval_agent/store.py +231 -0
- evalrx/logging_utils.py +112 -0
- evalrx/models/__init__.py +161 -0
- evalrx/models/_discover.py +101 -0
- evalrx/models/agent.py +380 -0
- evalrx/models/backends/__init__.py +58 -0
- evalrx/models/backends/api.py +169 -0
- evalrx/models/backends/base.py +57 -0
- evalrx/models/backends/gemini_compat.py +579 -0
- evalrx/models/backends/hf_local.py +2074 -0
- evalrx/models/backends/openai_compat.py +301 -0
- evalrx/models/backends/vllm_offline.py +116 -0
- evalrx/models/base.py +24 -0
- evalrx/models/blackbox/__init__.py +4 -0
- evalrx/models/blackbox/agent.py +31 -0
- evalrx/models/blackbox/base.py +29 -0
- evalrx/models/blackbox/gemini.py +279 -0
- evalrx/models/blackbox/llm_api.py +17 -0
- evalrx/models/blackbox/vlm_api.py +17 -0
- evalrx/models/compose.py +66 -0
- evalrx/models/inference.py +88 -0
- evalrx/models/paper_methods/__init__.py +8 -0
- evalrx/models/paper_methods/aad.py +53 -0
- evalrx/models/paper_methods/ifcd.py +204 -0
- evalrx/models/paper_methods/pai.py +164 -0
- evalrx/models/paper_methods/tcd.py +202 -0
- evalrx/models/paper_methods/vcd.py +45 -0
- evalrx/models/paper_methods/vicrop.py +137 -0
- evalrx/models/toolcodec.py +143 -0
- evalrx/models/tools/__init__.py +20 -0
- evalrx/models/tools/perception.py +300 -0
- evalrx/models/tools/visual.py +174 -0
- evalrx/models/whitebox/__init__.py +26 -0
- evalrx/models/whitebox/agent.py +31 -0
- evalrx/models/whitebox/base.py +24 -0
- evalrx/models/whitebox/qwen.py +61 -0
- evalrx/models/whitebox/qwen2_5_omni.py +29 -0
- evalrx/models/whitebox/qwen2_audio.py +25 -0
- evalrx/models/whitebox/qwen_omni.py +53 -0
- evalrx/models/whitebox/qwen_vl.py +62 -0
- evalrx/observability/__init__.py +21 -0
- evalrx/observability/envelope.py +122 -0
- evalrx/observability/outbox.py +111 -0
- evalrx/observability/tracer.py +882 -0
- evalrx/reporting/__init__.py +28 -0
- evalrx/reporting/case_study.py +947 -0
- evalrx/reporting/compiler.py +587 -0
- evalrx/reporting/dynamic.py +1882 -0
- evalrx/reporting/html_report.py +2225 -0
- evalrx/reporting/langfuse_exporter.py +38 -0
- evalrx/reporting/langfuse_source.py +155 -0
- evalrx/reporting/model.py +151 -0
- evalrx/reporting/run_events.py +184 -0
- evalrx/reporting/server.py +557 -0
- evalrx/reporting/stages.py +58 -0
- evalrx/reporting/static_export.py +142 -0
- evalrx/reporting/web_dist/index.html +146 -0
- evalrx/specs.py +727 -0
- evalrx/stats/__init__.py +47 -0
- evalrx/stats/api.py +192 -0
- evalrx/stats/bootstrap.py +86 -0
- evalrx/stats/ebh.py +27 -0
- evalrx/stats/evalue.py +98 -0
- evalrx/stats/friedman.py +138 -0
- evalrx/stats/mcnemar.py +40 -0
- evalrx/stats/multiplicity.py +159 -0
- evalrx/stats/subset_sampling.py +55 -0
- evalrx/term_links.py +43 -0
- evalrx/viz/__init__.py +7 -0
- evalrx/viz/labels.py +77 -0
- evalrx/viz/prompts.py +39 -0
- evalrx/viz/renderer.py +590 -0
- evalrx/viz/schema.py +36 -0
- evalrx/viz/style.py +134 -0
- evalrx-0.1.2.dist-info/METADATA +532 -0
- evalrx-0.1.2.dist-info/RECORD +339 -0
- evalrx-0.1.2.dist-info/WHEEL +5 -0
- evalrx-0.1.2.dist-info/entry_points.txt +3 -0
- evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
- 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 {})
|