alysis-code 0.13.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 (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,1699 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ import logging
6
+ import re
7
+ import threading
8
+ import time
9
+ import warnings
10
+ from collections import deque
11
+ from collections.abc import Callable, Mapping
12
+ from contextlib import contextmanager
13
+ from contextvars import ContextVar
14
+ from pathlib import Path
15
+ from typing import Any
16
+ from urllib.parse import urlsplit
17
+
18
+ from .failure_category import is_provider_throttling_error, is_provider_unavailable_error
19
+ from .llm.types import LLMResponse, LLMUsage
20
+ from .logging_redaction import redact_log_text
21
+ from .run_provenance import (
22
+ extract_system_fingerprint,
23
+ fingerprint_drift_payload,
24
+ resolve_response_header_allowlist,
25
+ response_fingerprint_payload,
26
+ select_response_headers,
27
+ )
28
+
29
+ _LOGGER = logging.getLogger(__name__)
30
+ _MAX_HISTORY = 50
31
+ _HISTORY_LOCK = threading.Lock()
32
+ _PROVIDER_CALL_HISTORY: deque[dict[str, Any]] = deque(maxlen=_MAX_HISTORY)
33
+ _WEB_SEARCH_HISTORY: deque[dict[str, Any]] = deque(maxlen=_MAX_HISTORY)
34
+
35
+ # Durable, process-wide JSONL sink for provider/web-search telemetry. The in-memory
36
+ # deques above evaporate on process exit -- exactly when a crashed run is being
37
+ # investigated -- so a registered sink persists each already-redacted summary to disk
38
+ # for post-mortem reconstruction. Guarded by its own lock so concurrent provider calls
39
+ # append complete lines.
40
+ _SINK_LOCK = threading.Lock()
41
+ _TELEMETRY_SINK_PATH: Path | None = None
42
+ _SINK_WRITE_FAILURES = 0
43
+ # Resolved once per process: the allowlist is environment-driven and constant
44
+ # for a run, and re-parsing it on every provider call would be pure overhead.
45
+ _RESPONSE_HEADER_LOCK = threading.Lock()
46
+ _RESPONSE_HEADER_ALLOWLIST: Any = None
47
+ _ALYSIS_WEB_SEARCH_TOOL_NAME = "web_search"
48
+ _PROVIDER_OPERATION_OVERRIDE: ContextVar[str | None] = ContextVar(
49
+ "provider_operation_override",
50
+ default=None,
51
+ )
52
+ _PROVIDER_RECORD_COUNT: ContextVar[int] = ContextVar(
53
+ "provider_record_count",
54
+ default=0,
55
+ )
56
+ _CACHE_USAGE_TOTAL_FIELDS = (
57
+ "prompt_tokens",
58
+ "completion_tokens",
59
+ "total_tokens",
60
+ "cached_prompt_tokens",
61
+ "input_tokens_uncached",
62
+ "cache_read_input_tokens",
63
+ "cache_creation_input_tokens",
64
+ "cache_creation_5m_input_tokens",
65
+ "cache_creation_1h_input_tokens",
66
+ )
67
+ _SENSITIVE_EXACT_KEYS = {
68
+ "api_key",
69
+ "apikey",
70
+ "authorization",
71
+ "auth_token",
72
+ "bearer_token",
73
+ "credential",
74
+ "id_token",
75
+ "password",
76
+ "refresh_token",
77
+ "secret",
78
+ "token",
79
+ "x_api_key",
80
+ "x_goog_api_key",
81
+ }
82
+ _SENSITIVE_KEY_FRAGMENTS = ("client_secret", "private_key")
83
+ _SAFE_REDACTION_KEYS = {"api_key_present", "api_key_source"}
84
+ _HIDDEN_PAYLOAD_KEYS = {
85
+ "arguments",
86
+ "body",
87
+ "content",
88
+ "contents",
89
+ "headers",
90
+ "input",
91
+ "messages",
92
+ "parameters",
93
+ "provider_metadata",
94
+ "raw",
95
+ "tool_calls",
96
+ "tools",
97
+ }
98
+ _SECRET_VALUE_RE = re.compile(
99
+ r"(?i)\b(?:sk|tvly|ghp|github_pat|xoxb|xapp|ya29|AIza|key|token)[-_A-Za-z0-9]{10,}\b"
100
+ )
101
+
102
+
103
+ def telemetry_clock_ms() -> float:
104
+ return time.monotonic() * 1000.0
105
+
106
+
107
+ @contextmanager
108
+ def provider_telemetry_operation(operation: str):
109
+ """Label provider calls made by a non-turn control-plane operation."""
110
+ token = _PROVIDER_OPERATION_OVERRIDE.set(str(operation or "").strip() or None)
111
+ try:
112
+ yield
113
+ finally:
114
+ _PROVIDER_OPERATION_OVERRIDE.reset(token)
115
+
116
+
117
+ def provider_telemetry_record_count() -> int:
118
+ """Return this execution context's provider-record count."""
119
+ return _PROVIDER_RECORD_COUNT.get()
120
+
121
+
122
+ def base_url_host(base_url: str | None) -> str:
123
+ raw = str(base_url or "").strip()
124
+ if not raw:
125
+ return ""
126
+ try:
127
+ parsed = urlsplit(raw)
128
+ except Exception:
129
+ return ""
130
+ host = (parsed.hostname or "").rstrip(".").casefold()
131
+ return host
132
+
133
+
134
+ def reset_provider_telemetry_for_tests() -> None:
135
+ global _TELEMETRY_SINK_PATH, _SINK_WRITE_FAILURES, _RESPONSE_HEADER_ALLOWLIST
136
+ with _HISTORY_LOCK:
137
+ _PROVIDER_CALL_HISTORY.clear()
138
+ _WEB_SEARCH_HISTORY.clear()
139
+ with _SINK_LOCK:
140
+ _TELEMETRY_SINK_PATH = None
141
+ _SINK_WRITE_FAILURES = 0
142
+ with _RESPONSE_HEADER_LOCK:
143
+ _RESPONSE_HEADER_ALLOWLIST = None
144
+
145
+
146
+ def response_header_allowlist():
147
+ """The process's response-header allowlist, resolved once from the environment."""
148
+ global _RESPONSE_HEADER_ALLOWLIST
149
+ with _RESPONSE_HEADER_LOCK:
150
+ if _RESPONSE_HEADER_ALLOWLIST is None:
151
+ _RESPONSE_HEADER_ALLOWLIST = resolve_response_header_allowlist()
152
+ return _RESPONSE_HEADER_ALLOWLIST
153
+
154
+
155
+ def capture_response_headers(headers: Any) -> dict[str, str]:
156
+ """Select the allowlisted response headers and redact each value.
157
+
158
+ Redaction is belt-and-braces: the allowlist is already bounded by a deny
159
+ list that keeps credential-bearing headers out, and both sinks redact the
160
+ serialized line on write. This third pass covers the in-memory history and
161
+ the structured log record, which are not write-path sinks and so are not
162
+ otherwise covered by PR1's boundary.
163
+ """
164
+ selected = select_response_headers(headers, allowlist=response_header_allowlist())
165
+ return {name: redact_log_text(value) for name, value in selected.items()}
166
+
167
+
168
+ def set_provider_telemetry_sink(path: str | Path | None) -> None:
169
+ """Route each recorded provider/web-search summary to a durable JSONL file.
170
+
171
+ Process-wide and idempotent (last writer wins); pass ``None`` to disable. The
172
+ persisted payload is already secret-redacted. This is what lets an autonomous
173
+ fix-loop recover the retry/throttle/latency history of a run whose process has
174
+ already exited.
175
+ """
176
+ global _TELEMETRY_SINK_PATH
177
+ with _SINK_LOCK:
178
+ _TELEMETRY_SINK_PATH = Path(path) if path else None
179
+
180
+
181
+ def provider_telemetry_sink_path() -> Path | None:
182
+ with _SINK_LOCK:
183
+ return _TELEMETRY_SINK_PATH
184
+
185
+
186
+ def _append_to_sink(payload: Mapping[str, Any]) -> None:
187
+ global _SINK_WRITE_FAILURES
188
+ with _SINK_LOCK:
189
+ path = _TELEMETRY_SINK_PATH
190
+ if path is None:
191
+ return
192
+ record = {"recorded_at_epoch": round(time.time(), 3), **dict(payload)}
193
+ line = redact_log_text(
194
+ json.dumps(record, ensure_ascii=True, sort_keys=True, default=str) + "\n"
195
+ )
196
+ with _SINK_LOCK:
197
+ try:
198
+ path.parent.mkdir(parents=True, exist_ok=True)
199
+ with path.open("a", encoding="utf-8") as fh:
200
+ fh.write(line)
201
+ except Exception as exc: # noqa: BLE001 - telemetry must not crash a provider call
202
+ # Non-silent: a broken telemetry sink during an autonomous run must be
203
+ # observable rather than quietly capturing nothing.
204
+ _SINK_WRITE_FAILURES += 1
205
+ if _SINK_WRITE_FAILURES == 1:
206
+ warnings.warn(
207
+ f"provider telemetry sink write to {path} failed "
208
+ f"({type(exc).__name__}: {exc}); subsequent failures are counted but "
209
+ "not re-warned.",
210
+ RuntimeWarning,
211
+ stacklevel=2,
212
+ )
213
+
214
+
215
+ def provider_call_history_snapshot(*, limit: int | None = None) -> list[dict[str, Any]]:
216
+ with _HISTORY_LOCK:
217
+ items = list(_PROVIDER_CALL_HISTORY)
218
+ if limit is not None:
219
+ items = items[-max(0, int(limit)) :]
220
+ return [copy.deepcopy(item) for item in items]
221
+
222
+
223
+ def web_search_history_snapshot(*, limit: int | None = None) -> list[dict[str, Any]]:
224
+ with _HISTORY_LOCK:
225
+ items = list(_WEB_SEARCH_HISTORY)
226
+ if limit is not None:
227
+ items = items[-max(0, int(limit)) :]
228
+ return [copy.deepcopy(item) for item in items]
229
+
230
+
231
+ def last_provider_call_summary() -> dict[str, Any] | None:
232
+ with _HISTORY_LOCK:
233
+ item = _PROVIDER_CALL_HISTORY[-1] if _PROVIDER_CALL_HISTORY else None
234
+ return copy.deepcopy(item) if item is not None else None
235
+
236
+
237
+ def last_web_search_summary() -> dict[str, Any] | None:
238
+ with _HISTORY_LOCK:
239
+ item = _WEB_SEARCH_HISTORY[-1] if _WEB_SEARCH_HISTORY else None
240
+ return copy.deepcopy(item) if item is not None else None
241
+
242
+
243
+ def provider_cache_effectiveness_snapshot(*, limit: int | None = None) -> dict[str, Any]:
244
+ return _cache_effectiveness_payload(provider_call_history_snapshot(limit=limit))
245
+
246
+
247
+ def provider_cache_diagnostics_snapshot(*, limit: int | None = None) -> dict[str, Any]:
248
+ return _cache_diagnostics_payload(provider_call_history_snapshot(limit=limit))
249
+
250
+
251
+ def provider_token_reconciliation_snapshot(*, limit: int | None = None) -> dict[str, Any]:
252
+ return _token_reconciliation_aggregate_payload(provider_call_history_snapshot(limit=limit))
253
+
254
+
255
+ def provider_fingerprint_drift_snapshot(*, limit: int | None = None) -> dict[str, Any]:
256
+ """Did one requested model come back as more than one thing this run?"""
257
+ return fingerprint_drift_payload(provider_call_history_snapshot(limit=limit))
258
+
259
+
260
+ class ProviderCallTelemetryRecorder:
261
+ """Accumulates one safe provider-call summary.
262
+
263
+ The recorder stores only derived fields: counts, booleans, host names, latency,
264
+ and token totals. It never stores request bodies, tool arguments, raw provider
265
+ payloads, or hidden provider metadata.
266
+ """
267
+
268
+ def __init__(
269
+ self,
270
+ *,
271
+ provider_key: str | None,
272
+ protocol: str,
273
+ model: str,
274
+ base_url: str,
275
+ stream: bool,
276
+ tools: list[dict[str, Any]] | None,
277
+ web_search_mode: str | None = None,
278
+ web_search_adapter: str | None = None,
279
+ native_web_search: bool = False,
280
+ cache_policy: Mapping[str, Any] | None = None,
281
+ request_plan: Mapping[str, Any] | None = None,
282
+ request_shape: Mapping[str, Any] | None = None,
283
+ token_reconciliation: Mapping[str, Any] | None = None,
284
+ operation: str = "chat",
285
+ sampling: Any | None = None,
286
+ ) -> None:
287
+ self.provider_key = _safe_label(provider_key)
288
+ self.protocol = _safe_label(protocol)
289
+ self.model = _safe_label(model)
290
+ self.base_url_host = base_url_host(base_url)
291
+ self.stream = bool(stream)
292
+ self.operation = _safe_label(_PROVIDER_OPERATION_OVERRIDE.get() or operation)
293
+ self.tool_count = len([tool for tool in tools or [] if isinstance(tool, dict)])
294
+ self.web_search_exposed = tools_expose_web_search(tools) or bool(native_web_search)
295
+ self.web_search_mode = _safe_label(web_search_mode or "off")
296
+ self.web_search_adapter = _safe_label(web_search_adapter or "")
297
+ self.provider_hosted_search = bool(native_web_search)
298
+ self.cache_policy = _safe_cache_policy(cache_policy)
299
+ self.request_plan = _safe_request_plan(request_plan)
300
+ self.request_shape = _safe_request_shape(request_shape)
301
+ self.token_reconciliation = _safe_token_reconciliation(token_reconciliation)
302
+ self.external_search_provider = _external_search_provider(
303
+ exposed=self.web_search_exposed,
304
+ native=native_web_search,
305
+ adapter=web_search_adapter,
306
+ )
307
+ self.web_search_backend_kind = _chat_web_search_backend_kind(
308
+ exposed=self.web_search_exposed,
309
+ native=native_web_search,
310
+ mode=web_search_mode,
311
+ )
312
+ self.sampling = _safe_sampling(sampling)
313
+ self._started_ms = telemetry_clock_ms()
314
+ self._first_text_delta_ms: float | None = None
315
+ self._text_delta_count = 0
316
+ self._first_reasoning_delta_ms: float | None = None
317
+ self._reasoning_delta_count = 0
318
+ self._retry_count = 0
319
+ self._retry_reasons: list[str] = []
320
+ # Captured from the live HTTP response, so it survives into the error
321
+ # payload too: the request id on a 429 is often the only handle a
322
+ # provider will accept when asked what happened.
323
+ self._response_headers: dict[str, str] = {}
324
+
325
+ def wrap_text_delta(
326
+ self,
327
+ callback: Callable[[str], None] | None,
328
+ ) -> Callable[[str], None] | None:
329
+ if not self.stream:
330
+ return callback
331
+
332
+ def _wrapped(delta: str) -> None:
333
+ if delta:
334
+ self._text_delta_count += 1
335
+ if self._first_text_delta_ms is None:
336
+ self._first_text_delta_ms = telemetry_clock_ms()
337
+ if callback is not None:
338
+ callback(delta)
339
+
340
+ return _wrapped
341
+
342
+ def wrap_reasoning_delta(
343
+ self,
344
+ callback: Callable[[str], None] | None,
345
+ ) -> Callable[[str], None] | None:
346
+ if not self.stream:
347
+ return callback
348
+
349
+ def _wrapped(delta: str) -> None:
350
+ if delta:
351
+ self._reasoning_delta_count += 1
352
+ if self._first_reasoning_delta_ms is None:
353
+ self._first_reasoning_delta_ms = telemetry_clock_ms()
354
+ if callback is not None:
355
+ callback(delta)
356
+
357
+ return _wrapped
358
+
359
+ def set_cache_policy(self, cache_policy: Mapping[str, Any] | None) -> None:
360
+ self.cache_policy = _safe_cache_policy(cache_policy)
361
+
362
+ def set_request_plan(self, request_plan: Mapping[str, Any] | None) -> None:
363
+ self.request_plan = _safe_request_plan(request_plan)
364
+
365
+ def set_request_shape(self, request_shape: Mapping[str, Any] | None) -> None:
366
+ self.request_shape = _safe_request_shape(request_shape)
367
+
368
+ def set_token_reconciliation(self, token_reconciliation: Mapping[str, Any] | None) -> None:
369
+ self.token_reconciliation = _safe_token_reconciliation(token_reconciliation)
370
+
371
+ def set_response_headers(self, headers: Any) -> None:
372
+ """Record the allowlisted provenance headers from a live HTTP response.
373
+
374
+ Never raises: an unreadable header mapping yields an empty capture, and
375
+ a provider call must not fail because provenance could not be collected.
376
+ """
377
+ try:
378
+ self._response_headers = capture_response_headers(headers)
379
+ except Exception: # noqa: BLE001 - provenance must not break a call
380
+ self._response_headers = {}
381
+
382
+ def on_retry(self, attempt: int, reason: str, _wait_seconds: float) -> None:
383
+ try:
384
+ self._retry_count = max(self._retry_count, int(attempt))
385
+ except (TypeError, ValueError):
386
+ self._retry_count += 1
387
+ normalized_reason = _safe_label(reason)
388
+ if normalized_reason and normalized_reason not in self._retry_reasons:
389
+ self._retry_reasons.append(normalized_reason)
390
+
391
+ def run(self, call: Callable[[], LLMResponse]) -> LLMResponse:
392
+ try:
393
+ response = call()
394
+ except Exception as exc:
395
+ self.record_error(exc)
396
+ raise
397
+ self.record_success(response)
398
+ return response
399
+
400
+ def record_success(self, response: LLMResponse) -> None:
401
+ latency_ms = _duration_ms(self._started_ms)
402
+ payload = self._base_payload(
403
+ latency_ms=latency_ms,
404
+ status_category="success",
405
+ )
406
+ payload["usage"] = _usage_payload(response.usage)
407
+ payload["token_reconciliation"] = _token_reconciliation_payload(
408
+ self.token_reconciliation,
409
+ response.usage,
410
+ )
411
+ payload["provider_metadata_present"] = bool(response.provider_metadata)
412
+ payload["tool_call_provider_metadata_count"] = sum(
413
+ 1 for tool_call in response.tool_calls if tool_call.provider_metadata
414
+ )
415
+ payload["streaming"] = self._streaming_payload(
416
+ raw=response.raw,
417
+ final_latency_ms=latency_ms,
418
+ )
419
+ payload["response_fingerprint"] = self._response_fingerprint(response)
420
+ payload["web_search"].update(_provider_metadata_web_search_counts(response))
421
+ record_provider_call(payload)
422
+
423
+ def record_error(self, exc: Exception) -> None:
424
+ latency_ms = _duration_ms(self._started_ms)
425
+ failure_category = _status_category(exc)
426
+ payload = self._base_payload(
427
+ latency_ms=latency_ms,
428
+ status_category=("failed" if self.operation == "cache_keepalive" else failure_category),
429
+ )
430
+ if self.operation == "cache_keepalive":
431
+ payload["failure_category"] = failure_category
432
+ payload["error_type"] = type(exc).__name__
433
+ payload["usage"] = _usage_payload(None)
434
+ payload["token_reconciliation"] = _token_reconciliation_payload(
435
+ self.token_reconciliation,
436
+ None,
437
+ )
438
+ payload["provider_metadata_present"] = False
439
+ payload["tool_call_provider_metadata_count"] = 0
440
+ payload["streaming"] = self._streaming_payload(
441
+ raw=None,
442
+ final_latency_ms=latency_ms,
443
+ error_type=type(exc).__name__ if self.stream else None,
444
+ )
445
+ record_provider_call(payload)
446
+
447
+ def _base_payload(self, *, latency_ms: int, status_category: str) -> dict[str, Any]:
448
+ return {
449
+ "kind": "provider_call",
450
+ "operation": self.operation,
451
+ "provider_key": self.provider_key,
452
+ "protocol": self.protocol,
453
+ "model": self.model,
454
+ "base_url_host": self.base_url_host,
455
+ "stream": self.stream,
456
+ "tool_count": self.tool_count,
457
+ "web_search_exposed": self.web_search_exposed,
458
+ "web_search": {
459
+ "web_search_mode": self.web_search_mode,
460
+ "web_search_adapter": self.web_search_adapter,
461
+ "backend_kind": self.web_search_backend_kind,
462
+ "provider_hosted_search": self.provider_hosted_search,
463
+ "external_provider_name": self.external_search_provider,
464
+ "source_count": 0,
465
+ "citation_count": 0,
466
+ "query_count": 0,
467
+ "fallback_occurred": False,
468
+ },
469
+ "cache_policy": copy.deepcopy(self.cache_policy),
470
+ "request_plan": copy.deepcopy(self.request_plan),
471
+ "request_shape": copy.deepcopy(self.request_shape),
472
+ "token_reconciliation": _token_reconciliation_payload(
473
+ self.token_reconciliation,
474
+ None,
475
+ ),
476
+ "retry_count": self._retry_count,
477
+ "retry_reasons": list(self._retry_reasons),
478
+ "status_category": status_category,
479
+ "latency_ms": latency_ms,
480
+ "sampling": copy.deepcopy(self.sampling),
481
+ "response_fingerprint": self._response_fingerprint(),
482
+ }
483
+
484
+ def _response_fingerprint(self, response: LLMResponse | None = None) -> dict[str, Any]:
485
+ """Which model actually answered, as far as the wire can tell.
486
+
487
+ Recorded on every call, successful or not, and with every field always
488
+ present: a run where ``system_fingerprint`` was never sent is a
489
+ different finding from one where it changed mid-run, and only an
490
+ always-present field distinguishes them.
491
+ """
492
+ response_model = None
493
+ system_fingerprint = None
494
+ if response is not None:
495
+ response_model = getattr(response, "response_model", None)
496
+ system_fingerprint = extract_system_fingerprint(response.raw)
497
+ return response_fingerprint_payload(
498
+ response_model=_safe_label(response_model) or None,
499
+ system_fingerprint=_safe_label(system_fingerprint) or None,
500
+ headers=dict(self._response_headers),
501
+ )
502
+
503
+ def _streaming_payload(
504
+ self,
505
+ *,
506
+ raw: Mapping[str, Any] | None,
507
+ final_latency_ms: int,
508
+ error_type: str | None = None,
509
+ ) -> dict[str, Any]:
510
+ if not self.stream:
511
+ return {
512
+ "enabled": False,
513
+ "event_count": 0,
514
+ "text_delta_count": 0,
515
+ "reasoning_delta_count": 0,
516
+ "first_token_latency_ms": None,
517
+ "first_reasoning_latency_ms": None,
518
+ "final_latency_ms": final_latency_ms,
519
+ "stream_error_type": None,
520
+ "unknown_event_count": 0,
521
+ "stream_restart_count": 0,
522
+ "stream_restart_reason": "",
523
+ }
524
+ return {
525
+ "enabled": True,
526
+ "event_count": _stream_event_count(raw),
527
+ "text_delta_count": self._text_delta_count,
528
+ "reasoning_delta_count": self._reasoning_delta_count,
529
+ "first_token_latency_ms": _first_token_latency_ms(
530
+ started_ms=self._started_ms,
531
+ first_delta_ms=self._first_text_delta_ms,
532
+ ),
533
+ "first_reasoning_latency_ms": _first_token_latency_ms(
534
+ started_ms=self._started_ms,
535
+ first_delta_ms=self._first_reasoning_delta_ms,
536
+ ),
537
+ "final_latency_ms": final_latency_ms,
538
+ "stream_error_type": error_type,
539
+ "unknown_event_count": _stream_unknown_event_count(raw),
540
+ "stream_restart_count": _stream_restart_count(raw),
541
+ "stream_restart_reason": _stream_restart_reason(raw),
542
+ }
543
+
544
+
545
+ def record_provider_call(payload: Mapping[str, Any]) -> None:
546
+ safe_payload = redact_telemetry_payload(payload)
547
+ _PROVIDER_RECORD_COUNT.set(_PROVIDER_RECORD_COUNT.get() + 1)
548
+ with _HISTORY_LOCK:
549
+ _PROVIDER_CALL_HISTORY.append(safe_payload)
550
+ _LOGGER.info("provider_call", extra={"alysis_provider_call": safe_payload})
551
+ _append_to_sink(safe_payload)
552
+
553
+
554
+ def record_web_search_call(
555
+ *,
556
+ protocol: str | None,
557
+ provider_key: str | None,
558
+ model: str | None,
559
+ web_search_mode: str,
560
+ web_search_adapter: str,
561
+ provider_hosted_search: bool,
562
+ external_provider_name: str | None,
563
+ source_count: int,
564
+ citation_count: int,
565
+ query_count: int,
566
+ fallback_occurred: bool,
567
+ status_category: str = "success",
568
+ ) -> None:
569
+ payload = redact_telemetry_payload(
570
+ {
571
+ "kind": "web_search",
572
+ "protocol": _safe_label(protocol),
573
+ "provider_key": _safe_label(provider_key),
574
+ "model": _safe_label(model),
575
+ "web_search_mode": _safe_label(web_search_mode),
576
+ "web_search_adapter": _safe_label(web_search_adapter),
577
+ "provider_hosted_search": bool(provider_hosted_search),
578
+ "external_provider_name": _safe_label(external_provider_name),
579
+ "source_count": max(0, int(source_count)),
580
+ "citation_count": max(0, int(citation_count)),
581
+ "query_count": max(0, int(query_count)),
582
+ "fallback_occurred": bool(fallback_occurred),
583
+ "status_category": _safe_label(status_category),
584
+ }
585
+ )
586
+ with _HISTORY_LOCK:
587
+ _WEB_SEARCH_HISTORY.append(payload)
588
+ _LOGGER.info("web_search_call", extra={"alysis_web_search": payload})
589
+ _append_to_sink(payload)
590
+
591
+
592
+ def redact_telemetry_payload(payload: Any) -> Any:
593
+ return _redact_value(payload)
594
+
595
+
596
+ def tools_expose_web_search(tools: list[dict[str, Any]] | None) -> bool:
597
+ for tool in tools or []:
598
+ if not isinstance(tool, dict):
599
+ continue
600
+ if _tool_name(tool) == _ALYSIS_WEB_SEARCH_TOOL_NAME:
601
+ return True
602
+ return False
603
+
604
+
605
+ def diagnostic_bundle_payload(*, provider_diagnostics: Mapping[str, Any]) -> dict[str, Any]:
606
+ return redact_telemetry_payload(
607
+ {
608
+ "redacted": True,
609
+ "provider_diagnostics": dict(provider_diagnostics),
610
+ "last_provider_call": last_provider_call_summary(),
611
+ "recent_provider_calls": provider_call_history_snapshot(limit=10),
612
+ "cache_effectiveness": provider_cache_effectiveness_snapshot(limit=_MAX_HISTORY),
613
+ "cache_diagnostics": provider_cache_diagnostics_snapshot(limit=_MAX_HISTORY),
614
+ "token_reconciliation": provider_token_reconciliation_snapshot(limit=_MAX_HISTORY),
615
+ "fingerprint_drift": provider_fingerprint_drift_snapshot(limit=_MAX_HISTORY),
616
+ "last_web_search": last_web_search_summary(),
617
+ "recent_web_search_calls": web_search_history_snapshot(limit=10),
618
+ "notes": [
619
+ "Request bodies, tool arguments, secrets, raw provider payloads, and hidden provider metadata are excluded.",
620
+ "Provider call history is process-local and may be empty in a fresh CLI process.",
621
+ ],
622
+ }
623
+ )
624
+
625
+
626
+ def _tool_name(tool: Mapping[str, Any]) -> str:
627
+ function = tool.get("function")
628
+ if isinstance(function, Mapping):
629
+ return str(function.get("name") or "").strip()
630
+ return str(tool.get("name") or "").strip()
631
+
632
+
633
+ def _chat_web_search_backend_kind(
634
+ *,
635
+ exposed: bool,
636
+ native: bool,
637
+ mode: str | None,
638
+ ) -> str:
639
+ if native:
640
+ return "native/provider-hosted"
641
+ if not exposed:
642
+ return "off"
643
+ normalized_mode = str(mode or "").strip().lower()
644
+ if normalized_mode == "off":
645
+ return "off"
646
+ return "external"
647
+
648
+
649
+ def _external_search_provider(
650
+ *,
651
+ exposed: bool,
652
+ native: bool,
653
+ adapter: str | None,
654
+ ) -> str | None:
655
+ if native or not exposed:
656
+ return None
657
+ normalized_adapter = str(adapter or "").strip().lower()
658
+ if normalized_adapter and normalized_adapter != "auto":
659
+ return normalized_adapter
660
+ return "alysis_web_search_tool"
661
+
662
+
663
+ def _duration_ms(started_ms: float) -> int:
664
+ return max(0, int(round(telemetry_clock_ms() - started_ms)))
665
+
666
+
667
+ def _provider_route_group_key(call: Mapping[str, Any]) -> tuple[str, str, str, str, str]:
668
+ return (
669
+ _safe_label(call.get("provider_key")),
670
+ _safe_label(call.get("protocol")),
671
+ _safe_label(call.get("model")),
672
+ _safe_label(call.get("base_url_host")),
673
+ _safe_label(call.get("operation")),
674
+ )
675
+
676
+
677
+ def _cache_diagnostics_payload(calls: list[dict[str, Any]]) -> dict[str, Any]:
678
+ total = _empty_cache_diagnostics_bucket()
679
+ groups: dict[tuple[str, str, str, str, str], dict[str, Any]] = {}
680
+ for call in calls:
681
+ if not isinstance(call, Mapping):
682
+ continue
683
+ group_key = _provider_route_group_key(call)
684
+ group = groups.setdefault(
685
+ group_key,
686
+ {
687
+ "provider_key": group_key[0],
688
+ "protocol": group_key[1],
689
+ "model": group_key[2],
690
+ "base_url_host": group_key[3],
691
+ "operation": group_key[4],
692
+ **_empty_cache_diagnostics_bucket(),
693
+ },
694
+ )
695
+ _accumulate_cache_diagnostics(total, call)
696
+ _accumulate_cache_diagnostics(group, call)
697
+ return {
698
+ "window_call_count": len(calls),
699
+ "totals": _finalize_cache_diagnostics_bucket(total),
700
+ "by_route": [
701
+ _finalize_cache_diagnostics_bucket(group) for _key, group in sorted(groups.items())
702
+ ],
703
+ }
704
+
705
+
706
+ def _empty_cache_diagnostics_bucket() -> dict[str, Any]:
707
+ return {
708
+ "provider_call_count": 0,
709
+ "cache_policy_call_count": 0,
710
+ "cache_enabled_call_count": 0,
711
+ "cache_field_emitted_call_count": 0,
712
+ "cache_read_call_count": 0,
713
+ "cache_write_call_count": 0,
714
+ "cache_fallback_call_count": 0,
715
+ "provider_rejection_or_downgrade_call_count": 0,
716
+ "strategy_counts": {},
717
+ "status_counts": {},
718
+ "fallback_counts": {},
719
+ "provider_rejection_reason_counts": {},
720
+ "cache_risk_reason_counts": {},
721
+ "compaction_trigger_reason_counts": {},
722
+ "tool_schema_share_sample_count": 0,
723
+ "tool_schema_share_total": 0.0,
724
+ "tool_schema_share_max": 0.0,
725
+ "inline_tool_transcript_share_sample_count": 0,
726
+ "inline_tool_transcript_share_total": 0.0,
727
+ "inline_tool_transcript_share_max": 0.0,
728
+ "token_estimate_error_sample_count": 0,
729
+ "sent_token_estimate_error_sample_count": 0,
730
+ "input_estimate_abs_error_tokens_total": 0,
731
+ "input_estimate_error_tokens_total": 0,
732
+ "sent_input_estimate_abs_error_tokens_total": 0,
733
+ "max_input_estimate_abs_error_tokens": 0,
734
+ "cache_field_emitted_rate": None,
735
+ "cache_read_rate": None,
736
+ "cache_write_rate": None,
737
+ "cache_fallback_rate": None,
738
+ "provider_rejection_or_downgrade_rate": None,
739
+ "tool_schema_share_average": None,
740
+ "inline_tool_transcript_share_average": None,
741
+ "mean_input_estimate_abs_error_tokens": None,
742
+ "mean_sent_input_estimate_abs_error_tokens": None,
743
+ }
744
+
745
+
746
+ def _accumulate_cache_diagnostics(bucket: dict[str, Any], call: Mapping[str, Any]) -> None:
747
+ bucket["provider_call_count"] += 1
748
+ cache_policy = call.get("cache_policy")
749
+ request_shape = call.get("request_shape")
750
+ request_plan = call.get("request_plan")
751
+ usage = call.get("usage") if isinstance(call.get("usage"), Mapping) else {}
752
+ reconciliation = (
753
+ call.get("token_reconciliation")
754
+ if isinstance(call.get("token_reconciliation"), Mapping)
755
+ else {}
756
+ )
757
+
758
+ if isinstance(cache_policy, Mapping):
759
+ bucket["cache_policy_call_count"] += 1
760
+ if bool(cache_policy.get("enabled")):
761
+ bucket["cache_enabled_call_count"] += 1
762
+ strategy = _safe_label(cache_policy.get("strategy"))
763
+ status = _safe_label(cache_policy.get("status"))
764
+ fallback = _safe_label(cache_policy.get("fallback"))
765
+ if strategy:
766
+ _increment_count(bucket["strategy_counts"], strategy)
767
+ if status:
768
+ _increment_count(bucket["status_counts"], status)
769
+ if fallback:
770
+ bucket["cache_fallback_call_count"] += 1
771
+ _increment_count(bucket["fallback_counts"], fallback)
772
+ if _cache_policy_provider_rejection_or_downgrade(cache_policy):
773
+ bucket["provider_rejection_or_downgrade_call_count"] += 1
774
+ for reason in _cache_policy_rejection_reasons(cache_policy):
775
+ _increment_count(bucket["provider_rejection_reason_counts"], reason)
776
+
777
+ if _cache_field_emitted(cache_policy, request_shape):
778
+ bucket["cache_field_emitted_call_count"] += 1
779
+ if _effective_cache_read_tokens(usage) > 0:
780
+ bucket["cache_read_call_count"] += 1
781
+ if _effective_cache_write_tokens(usage) > 0:
782
+ bucket["cache_write_call_count"] += 1
783
+
784
+ if isinstance(request_shape, Mapping):
785
+ for reason in _safe_label_list(request_shape.get("risk_reasons")):
786
+ _increment_count(bucket["cache_risk_reason_counts"], reason)
787
+ for reason in _safe_label_list(request_shape.get("compaction_trigger_reasons")):
788
+ _increment_count(bucket["compaction_trigger_reason_counts"], reason)
789
+ trigger_reason = _safe_label(request_shape.get("compaction_trigger_reason"))
790
+ if trigger_reason:
791
+ _increment_count(bucket["compaction_trigger_reason_counts"], trigger_reason)
792
+ _accumulate_share(
793
+ bucket,
794
+ value=request_shape.get("tool_schema_share"),
795
+ sample_key="tool_schema_share_sample_count",
796
+ total_key="tool_schema_share_total",
797
+ max_key="tool_schema_share_max",
798
+ )
799
+ _accumulate_share(
800
+ bucket,
801
+ value=request_shape.get("inline_tool_transcript_share"),
802
+ sample_key="inline_tool_transcript_share_sample_count",
803
+ total_key="inline_tool_transcript_share_total",
804
+ max_key="inline_tool_transcript_share_max",
805
+ )
806
+
807
+ if isinstance(request_plan, Mapping):
808
+ for reason in _safe_label_list(request_plan.get("compaction_trigger_reasons")):
809
+ _increment_count(bucket["compaction_trigger_reason_counts"], reason)
810
+ trigger_reason = _safe_label(request_plan.get("compaction_trigger_reason"))
811
+ if trigger_reason:
812
+ _increment_count(bucket["compaction_trigger_reason_counts"], trigger_reason)
813
+
814
+ if isinstance(reconciliation, Mapping):
815
+ abs_error = _optional_non_negative_int(
816
+ reconciliation.get("input_estimate_abs_error_tokens")
817
+ )
818
+ sent_abs_error = _optional_non_negative_int(
819
+ reconciliation.get("sent_input_estimate_abs_error_tokens")
820
+ )
821
+ if abs_error is not None:
822
+ bucket["token_estimate_error_sample_count"] += 1
823
+ bucket["input_estimate_abs_error_tokens_total"] += abs_error
824
+ bucket["input_estimate_error_tokens_total"] += _int_value(
825
+ reconciliation.get("input_estimate_error_tokens")
826
+ )
827
+ bucket["max_input_estimate_abs_error_tokens"] = max(
828
+ bucket["max_input_estimate_abs_error_tokens"],
829
+ abs_error,
830
+ )
831
+ if sent_abs_error is not None:
832
+ bucket["sent_token_estimate_error_sample_count"] += 1
833
+ bucket["sent_input_estimate_abs_error_tokens_total"] += sent_abs_error
834
+
835
+
836
+ def _finalize_cache_diagnostics_bucket(bucket: dict[str, Any]) -> dict[str, Any]:
837
+ finalized = copy.deepcopy(bucket)
838
+ provider_calls = _non_negative_int_value(finalized.get("provider_call_count"))
839
+ finalized["cache_field_emitted_rate"] = _rate(
840
+ finalized.get("cache_field_emitted_call_count"),
841
+ provider_calls,
842
+ )
843
+ finalized["cache_read_rate"] = _rate(finalized.get("cache_read_call_count"), provider_calls)
844
+ finalized["cache_write_rate"] = _rate(finalized.get("cache_write_call_count"), provider_calls)
845
+ finalized["cache_fallback_rate"] = _rate(
846
+ finalized.get("cache_fallback_call_count"),
847
+ provider_calls,
848
+ )
849
+ finalized["provider_rejection_or_downgrade_rate"] = _rate(
850
+ finalized.get("provider_rejection_or_downgrade_call_count"),
851
+ provider_calls,
852
+ )
853
+ finalized["tool_schema_share_average"] = _average(
854
+ finalized.get("tool_schema_share_total"),
855
+ finalized.get("tool_schema_share_sample_count"),
856
+ )
857
+ finalized["inline_tool_transcript_share_average"] = _average(
858
+ finalized.get("inline_tool_transcript_share_total"),
859
+ finalized.get("inline_tool_transcript_share_sample_count"),
860
+ )
861
+ estimate_samples = _non_negative_int_value(finalized.get("token_estimate_error_sample_count"))
862
+ finalized["mean_input_estimate_abs_error_tokens"] = _average(
863
+ finalized.get("input_estimate_abs_error_tokens_total"),
864
+ estimate_samples,
865
+ digits=2,
866
+ )
867
+ finalized["mean_sent_input_estimate_abs_error_tokens"] = _average(
868
+ finalized.get("sent_input_estimate_abs_error_tokens_total"),
869
+ finalized.get("sent_token_estimate_error_sample_count"),
870
+ digits=2,
871
+ )
872
+ for key in (
873
+ "strategy_counts",
874
+ "status_counts",
875
+ "fallback_counts",
876
+ "provider_rejection_reason_counts",
877
+ "cache_risk_reason_counts",
878
+ "compaction_trigger_reason_counts",
879
+ ):
880
+ finalized[key] = dict(sorted(finalized[key].items()))
881
+ return finalized
882
+
883
+
884
+ def _cache_field_emitted(
885
+ cache_policy: Any,
886
+ request_shape: Any,
887
+ ) -> bool:
888
+ if isinstance(request_shape, Mapping) and bool(request_shape.get("cache_fields_emitted")):
889
+ return True
890
+ if not isinstance(cache_policy, Mapping):
891
+ return False
892
+ emitted = cache_policy.get("emitted_fields")
893
+ return isinstance(emitted, (list, tuple)) and bool(emitted)
894
+
895
+
896
+ def _cache_policy_provider_rejection_or_downgrade(cache_policy: Mapping[str, Any]) -> bool:
897
+ if _safe_label(cache_policy.get("capability_downgrade")):
898
+ return True
899
+ return bool(_cache_policy_rejection_reasons(cache_policy))
900
+
901
+
902
+ def _cache_policy_rejection_reasons(cache_policy: Mapping[str, Any]) -> list[str]:
903
+ reasons: list[str] = []
904
+ for key in ("fallback", "status"):
905
+ value = _safe_label(cache_policy.get(key))
906
+ if not value:
907
+ continue
908
+ lowered = value.casefold()
909
+ if any(
910
+ marker in lowered
911
+ for marker in (
912
+ "reject",
913
+ "unsupported",
914
+ "stripped",
915
+ "runtime_disabled",
916
+ "provider_disabled",
917
+ "not_supported",
918
+ "failed",
919
+ )
920
+ ):
921
+ reasons.append(value)
922
+ downgrade = _safe_label(cache_policy.get("capability_downgrade"))
923
+ if downgrade:
924
+ reasons.append(downgrade)
925
+ return list(dict.fromkeys(reasons))
926
+
927
+
928
+ def _accumulate_share(
929
+ bucket: dict[str, Any],
930
+ *,
931
+ value: Any,
932
+ sample_key: str,
933
+ total_key: str,
934
+ max_key: str,
935
+ ) -> None:
936
+ parsed = _optional_non_negative_float(value)
937
+ if parsed is None:
938
+ return
939
+ bucket[sample_key] += 1
940
+ bucket[total_key] += parsed
941
+ bucket[max_key] = max(bucket[max_key], parsed)
942
+
943
+
944
+ def _rate(numerator: Any, denominator: Any) -> float | None:
945
+ denominator_int = _non_negative_int_value(denominator)
946
+ if denominator_int <= 0:
947
+ return None
948
+ return round(_non_negative_int_value(numerator) / denominator_int, 4)
949
+
950
+
951
+ def _average(total: Any, count: Any, *, digits: int = 4) -> float | None:
952
+ count_int = _non_negative_int_value(count)
953
+ if count_int <= 0:
954
+ return None
955
+ try:
956
+ total_float = float(total)
957
+ except (TypeError, ValueError):
958
+ return None
959
+ return round(total_float / count_int, digits)
960
+
961
+
962
+ def _int_value(value: Any) -> int:
963
+ try:
964
+ return int(value)
965
+ except (TypeError, ValueError):
966
+ return 0
967
+
968
+
969
+ def _safe_label_list(value: Any) -> list[str]:
970
+ if not isinstance(value, (list, tuple)):
971
+ return []
972
+ return [_safe_label(item) for item in value if _safe_label(item)]
973
+
974
+
975
+ def _cache_effectiveness_payload(calls: list[dict[str, Any]]) -> dict[str, Any]:
976
+ total = _empty_cache_effectiveness_bucket()
977
+ groups: dict[tuple[str, str, str, str], dict[str, Any]] = {}
978
+ for call in calls:
979
+ if not isinstance(call, Mapping):
980
+ continue
981
+ group_key = (
982
+ _safe_label(call.get("provider_key")),
983
+ _safe_label(call.get("protocol")),
984
+ _safe_label(call.get("model")),
985
+ _safe_label(call.get("base_url_host")),
986
+ )
987
+ group = groups.setdefault(
988
+ group_key,
989
+ {
990
+ "provider_key": group_key[0],
991
+ "protocol": group_key[1],
992
+ "model": group_key[2],
993
+ "base_url_host": group_key[3],
994
+ **_empty_cache_effectiveness_bucket(),
995
+ },
996
+ )
997
+ _accumulate_cache_effectiveness(total, call)
998
+ _accumulate_cache_effectiveness(group, call)
999
+ return {
1000
+ "window_call_count": len(calls),
1001
+ "totals": _finalize_cache_effectiveness_bucket(total),
1002
+ "by_provider_model": [
1003
+ _finalize_cache_effectiveness_bucket(group) for _key, group in sorted(groups.items())
1004
+ ],
1005
+ }
1006
+
1007
+
1008
+ def _empty_cache_effectiveness_bucket() -> dict[str, Any]:
1009
+ return {
1010
+ "provider_call_count": 0,
1011
+ "cache_policy_call_count": 0,
1012
+ "cache_enabled_call_count": 0,
1013
+ "cache_eligible_call_count": 0,
1014
+ "cache_used_call_count": 0,
1015
+ "cache_read_call_count": 0,
1016
+ "cache_write_call_count": 0,
1017
+ "cache_fallback_call_count": 0,
1018
+ "cache_miss_call_count": 0,
1019
+ "strategy_counts": {},
1020
+ "status_counts": {},
1021
+ "fallback_counts": {},
1022
+ "token_totals": {
1023
+ **{field: 0 for field in _CACHE_USAGE_TOTAL_FIELDS},
1024
+ "effective_cache_read_input_tokens": 0,
1025
+ "effective_cache_write_input_tokens": 0,
1026
+ },
1027
+ "cache_read_ratio": None,
1028
+ }
1029
+
1030
+
1031
+ def _accumulate_cache_effectiveness(bucket: dict[str, Any], call: Mapping[str, Any]) -> None:
1032
+ bucket["provider_call_count"] += 1
1033
+ cache_policy = call.get("cache_policy")
1034
+ usage = call.get("usage") if isinstance(call.get("usage"), Mapping) else {}
1035
+ effective_read = _effective_cache_read_tokens(usage)
1036
+ effective_write = _effective_cache_write_tokens(usage)
1037
+
1038
+ if isinstance(cache_policy, Mapping):
1039
+ bucket["cache_policy_call_count"] += 1
1040
+ enabled = bool(cache_policy.get("enabled"))
1041
+ eligible = bool(cache_policy.get("eligible"))
1042
+ used = bool(cache_policy.get("used"))
1043
+ fallback = _safe_label(cache_policy.get("fallback"))
1044
+ strategy = _safe_label(cache_policy.get("strategy"))
1045
+ status = _safe_label(cache_policy.get("status"))
1046
+ if enabled:
1047
+ bucket["cache_enabled_call_count"] += 1
1048
+ if eligible:
1049
+ bucket["cache_eligible_call_count"] += 1
1050
+ if used:
1051
+ bucket["cache_used_call_count"] += 1
1052
+ if fallback:
1053
+ bucket["cache_fallback_call_count"] += 1
1054
+ _increment_count(bucket["fallback_counts"], fallback)
1055
+ if strategy:
1056
+ _increment_count(bucket["strategy_counts"], strategy)
1057
+ if status:
1058
+ _increment_count(bucket["status_counts"], status)
1059
+ if enabled and not used and effective_read == 0 and effective_write == 0:
1060
+ bucket["cache_miss_call_count"] += 1
1061
+
1062
+ if effective_read > 0:
1063
+ bucket["cache_read_call_count"] += 1
1064
+ if effective_write > 0:
1065
+ bucket["cache_write_call_count"] += 1
1066
+
1067
+ token_totals = bucket["token_totals"]
1068
+ for field in _CACHE_USAGE_TOTAL_FIELDS:
1069
+ token_totals[field] += _non_negative_int_value(usage.get(field))
1070
+ token_totals["effective_cache_read_input_tokens"] += effective_read
1071
+ token_totals["effective_cache_write_input_tokens"] += effective_write
1072
+
1073
+
1074
+ def _finalize_cache_effectiveness_bucket(bucket: dict[str, Any]) -> dict[str, Any]:
1075
+ finalized = copy.deepcopy(bucket)
1076
+ token_totals = finalized.get("token_totals")
1077
+ if isinstance(token_totals, Mapping):
1078
+ prompt_tokens = _non_negative_int_value(token_totals.get("prompt_tokens"))
1079
+ cached_tokens = _non_negative_int_value(
1080
+ token_totals.get("effective_cache_read_input_tokens")
1081
+ )
1082
+ finalized["cache_read_ratio"] = (
1083
+ round(cached_tokens / prompt_tokens, 4) if prompt_tokens > 0 else None
1084
+ )
1085
+ finalized["strategy_counts"] = dict(sorted(finalized["strategy_counts"].items()))
1086
+ finalized["status_counts"] = dict(sorted(finalized["status_counts"].items()))
1087
+ finalized["fallback_counts"] = dict(sorted(finalized["fallback_counts"].items()))
1088
+ return finalized
1089
+
1090
+
1091
+ def _effective_cache_read_tokens(usage: Mapping[str, Any]) -> int:
1092
+ cache_read = usage.get("cache_read_input_tokens")
1093
+ if cache_read is not None:
1094
+ return _non_negative_int_value(cache_read)
1095
+ return _non_negative_int_value(usage.get("cached_prompt_tokens"))
1096
+
1097
+
1098
+ def _effective_cache_write_tokens(usage: Mapping[str, Any]) -> int:
1099
+ cache_creation = usage.get("cache_creation_input_tokens")
1100
+ if cache_creation is not None:
1101
+ return _non_negative_int_value(cache_creation)
1102
+ return _non_negative_int_value(usage.get("cache_creation_5m_input_tokens")) + (
1103
+ _non_negative_int_value(usage.get("cache_creation_1h_input_tokens"))
1104
+ )
1105
+
1106
+
1107
+ def _non_negative_int_value(value: Any) -> int:
1108
+ try:
1109
+ number = int(value)
1110
+ except (TypeError, ValueError):
1111
+ return 0
1112
+ return max(0, number)
1113
+
1114
+
1115
+ def _increment_count(counts: dict[str, int], key: str) -> None:
1116
+ counts[key] = counts.get(key, 0) + 1
1117
+
1118
+
1119
+ def _token_reconciliation_aggregate_payload(calls: list[dict[str, Any]]) -> dict[str, Any]:
1120
+ total = _empty_token_reconciliation_bucket()
1121
+ groups: dict[tuple[str, str, str, str], dict[str, Any]] = {}
1122
+ for call in calls:
1123
+ if not isinstance(call, Mapping):
1124
+ continue
1125
+ group_key = (
1126
+ _safe_label(call.get("provider_key")),
1127
+ _safe_label(call.get("protocol")),
1128
+ _safe_label(call.get("model")),
1129
+ _safe_label(call.get("base_url_host")),
1130
+ )
1131
+ group = groups.setdefault(
1132
+ group_key,
1133
+ {
1134
+ "provider_key": group_key[0],
1135
+ "protocol": group_key[1],
1136
+ "model": group_key[2],
1137
+ "base_url_host": group_key[3],
1138
+ **_empty_token_reconciliation_bucket(),
1139
+ },
1140
+ )
1141
+ reconciliation = call.get("token_reconciliation")
1142
+ if not isinstance(reconciliation, Mapping):
1143
+ continue
1144
+ _accumulate_token_reconciliation(total, reconciliation)
1145
+ _accumulate_token_reconciliation(group, reconciliation)
1146
+ return {
1147
+ "window_call_count": len(calls),
1148
+ "totals": _finalize_token_reconciliation_bucket(total),
1149
+ "by_provider_model": [
1150
+ _finalize_token_reconciliation_bucket(group) for _key, group in sorted(groups.items())
1151
+ ],
1152
+ }
1153
+
1154
+
1155
+ def _empty_token_reconciliation_bucket() -> dict[str, Any]:
1156
+ return {
1157
+ "reconciliation_call_count": 0,
1158
+ "reported_prompt_call_count": 0,
1159
+ "undercount_call_count": 0,
1160
+ "overcount_call_count": 0,
1161
+ "exact_count_call_count": 0,
1162
+ "input_estimate_tokens_total": 0,
1163
+ "sent_input_estimate_tokens_total": 0,
1164
+ "reported_input_estimate_tokens_total": 0,
1165
+ "reported_prompt_tokens_total": 0,
1166
+ "cached_prompt_tokens_total": 0,
1167
+ "input_tokens_uncached_total": 0,
1168
+ "input_estimate_error_tokens_total": 0,
1169
+ "input_estimate_abs_error_tokens_total": 0,
1170
+ "max_abs_error_tokens": 0,
1171
+ "mean_abs_error_tokens": None,
1172
+ "reported_to_estimate_ratio": None,
1173
+ "estimator_counts": {},
1174
+ "estimate_basis_counts": {},
1175
+ }
1176
+
1177
+
1178
+ def _accumulate_token_reconciliation(
1179
+ bucket: dict[str, Any],
1180
+ reconciliation: Mapping[str, Any],
1181
+ ) -> None:
1182
+ input_estimate = _optional_non_negative_int(reconciliation.get("input_estimate_tokens"))
1183
+ if input_estimate is None:
1184
+ return
1185
+ bucket["reconciliation_call_count"] += 1
1186
+ bucket["input_estimate_tokens_total"] += input_estimate
1187
+ bucket["sent_input_estimate_tokens_total"] += _non_negative_int_value(
1188
+ reconciliation.get("sent_input_estimate_tokens")
1189
+ )
1190
+ estimator = _safe_label(reconciliation.get("estimator"))
1191
+ if estimator:
1192
+ _increment_count(bucket["estimator_counts"], estimator)
1193
+ estimate_basis = _safe_label(reconciliation.get("estimate_basis"))
1194
+ if estimate_basis:
1195
+ _increment_count(bucket["estimate_basis_counts"], estimate_basis)
1196
+
1197
+ reported = _optional_non_negative_int(reconciliation.get("reported_prompt_tokens"))
1198
+ if reported is None:
1199
+ return
1200
+ bucket["reported_prompt_call_count"] += 1
1201
+ bucket["reported_input_estimate_tokens_total"] += input_estimate
1202
+ bucket["reported_prompt_tokens_total"] += reported
1203
+ bucket["cached_prompt_tokens_total"] += _non_negative_int_value(
1204
+ reconciliation.get("cached_prompt_tokens")
1205
+ )
1206
+ bucket["input_tokens_uncached_total"] += _non_negative_int_value(
1207
+ reconciliation.get("input_tokens_uncached")
1208
+ )
1209
+ error = reported - input_estimate
1210
+ abs_error = abs(error)
1211
+ bucket["input_estimate_error_tokens_total"] += error
1212
+ bucket["input_estimate_abs_error_tokens_total"] += abs_error
1213
+ bucket["max_abs_error_tokens"] = max(bucket["max_abs_error_tokens"], abs_error)
1214
+ if error > 0:
1215
+ bucket["undercount_call_count"] += 1
1216
+ elif error < 0:
1217
+ bucket["overcount_call_count"] += 1
1218
+ else:
1219
+ bucket["exact_count_call_count"] += 1
1220
+
1221
+
1222
+ def _finalize_token_reconciliation_bucket(bucket: dict[str, Any]) -> dict[str, Any]:
1223
+ finalized = copy.deepcopy(bucket)
1224
+ reported_calls = _non_negative_int_value(finalized.get("reported_prompt_call_count"))
1225
+ reported_estimate_total = _non_negative_int_value(
1226
+ finalized.get("reported_input_estimate_tokens_total")
1227
+ )
1228
+ reported_total = _non_negative_int_value(finalized.get("reported_prompt_tokens_total"))
1229
+ abs_error_total = _non_negative_int_value(
1230
+ finalized.get("input_estimate_abs_error_tokens_total")
1231
+ )
1232
+ finalized["mean_abs_error_tokens"] = (
1233
+ round(abs_error_total / reported_calls, 2) if reported_calls > 0 else None
1234
+ )
1235
+ finalized["reported_to_estimate_ratio"] = (
1236
+ round(reported_total / reported_estimate_total, 4) if reported_estimate_total > 0 else None
1237
+ )
1238
+ finalized["estimator_counts"] = dict(sorted(finalized["estimator_counts"].items()))
1239
+ finalized["estimate_basis_counts"] = dict(sorted(finalized["estimate_basis_counts"].items()))
1240
+ return finalized
1241
+
1242
+
1243
+ def _token_reconciliation_payload(
1244
+ reconciliation: Mapping[str, Any] | None,
1245
+ usage: LLMUsage | None,
1246
+ ) -> dict[str, Any] | None:
1247
+ safe = _safe_token_reconciliation(reconciliation)
1248
+ if safe is None and usage is None:
1249
+ return None
1250
+ payload = copy.deepcopy(safe or {})
1251
+ reported_prompt_tokens = usage.prompt_tokens if usage is not None else None
1252
+ cached_prompt_tokens = usage.cached_prompt_tokens if usage is not None else None
1253
+ input_tokens_uncached = usage.input_tokens_uncached if usage is not None else None
1254
+ cache_read_input_tokens = usage.cache_read_input_tokens if usage is not None else None
1255
+ payload["reported_prompt_tokens"] = reported_prompt_tokens
1256
+ payload["cached_prompt_tokens"] = cached_prompt_tokens
1257
+ payload["input_tokens_uncached"] = input_tokens_uncached
1258
+ payload["cache_read_input_tokens"] = cache_read_input_tokens
1259
+ input_estimate = _optional_non_negative_int(payload.get("input_estimate_tokens"))
1260
+ sent_input_estimate = _optional_non_negative_int(payload.get("sent_input_estimate_tokens"))
1261
+ reported = _optional_non_negative_int(reported_prompt_tokens)
1262
+ if input_estimate is not None and reported is not None:
1263
+ error = reported - input_estimate
1264
+ payload["input_estimate_error_tokens"] = error
1265
+ payload["input_estimate_abs_error_tokens"] = abs(error)
1266
+ payload["input_estimate_error_ratio"] = (
1267
+ round(reported / input_estimate, 4) if input_estimate > 0 else None
1268
+ )
1269
+ if sent_input_estimate is not None and reported is not None:
1270
+ error = reported - sent_input_estimate
1271
+ payload["sent_input_estimate_error_tokens"] = error
1272
+ payload["sent_input_estimate_abs_error_tokens"] = abs(error)
1273
+ payload["sent_input_estimate_error_ratio"] = (
1274
+ round(reported / sent_input_estimate, 4) if sent_input_estimate > 0 else None
1275
+ )
1276
+ return payload or None
1277
+
1278
+
1279
+ def _safe_token_reconciliation(payload: Mapping[str, Any] | None) -> dict[str, Any] | None:
1280
+ if not isinstance(payload, Mapping):
1281
+ return None
1282
+ safe: dict[str, Any] = {}
1283
+ for key in ("estimator", "estimate_basis", "input_mode"):
1284
+ value = payload.get(key)
1285
+ if value is not None:
1286
+ safe[key] = _safe_label(str(value))
1287
+ for key in ("input_estimate_tokens", "sent_input_estimate_tokens"):
1288
+ value = _optional_non_negative_int(payload.get(key))
1289
+ if value is not None:
1290
+ safe[key] = value
1291
+ return safe or None
1292
+
1293
+
1294
+ def _optional_non_negative_int(value: Any) -> int | None:
1295
+ try:
1296
+ number = int(value)
1297
+ except (TypeError, ValueError):
1298
+ return None
1299
+ if number < 0:
1300
+ return None
1301
+ return number
1302
+
1303
+
1304
+ def _optional_non_negative_float(value: Any) -> float | None:
1305
+ try:
1306
+ number = float(value)
1307
+ except (TypeError, ValueError):
1308
+ return None
1309
+ if number < 0:
1310
+ return None
1311
+ return number
1312
+
1313
+
1314
+ def _safe_sampling(sampling: Any) -> dict[str, Any]:
1315
+ """Normalize a ``SamplingSettings`` (or a mapping, or nothing) into a record.
1316
+
1317
+ Unlike the other ``_safe_*`` helpers this never returns ``None``. "No
1318
+ sampling controls were configured" is precisely the finding when two runs
1319
+ of one build diverge, so it belongs in every provider-call row as an
1320
+ explicit ``configured: false`` rather than as an absent key that a later
1321
+ reader has to interpret.
1322
+ """
1323
+ payload: Any = None
1324
+ if sampling is not None:
1325
+ getter = getattr(sampling, "telemetry_payload", None)
1326
+ if callable(getter):
1327
+ try:
1328
+ payload = getter()
1329
+ except Exception: # noqa: BLE001 - telemetry must not break a call
1330
+ payload = None
1331
+ elif isinstance(sampling, Mapping):
1332
+ payload = dict(sampling)
1333
+ if not isinstance(payload, Mapping):
1334
+ payload = {
1335
+ "configured": False,
1336
+ "temperature": None,
1337
+ "top_p": None,
1338
+ "seed": None,
1339
+ "sources": {},
1340
+ }
1341
+ return dict(payload)
1342
+
1343
+
1344
+ def _safe_cache_policy(policy: Mapping[str, Any] | None) -> dict[str, Any] | None:
1345
+ if not isinstance(policy, Mapping):
1346
+ return None
1347
+ safe: dict[str, Any] = {}
1348
+ for key in (
1349
+ "strategy",
1350
+ "mode",
1351
+ "ttl",
1352
+ "retention",
1353
+ "status",
1354
+ "fallback",
1355
+ "capability_downgrade",
1356
+ "source",
1357
+ "capability_source",
1358
+ "usage_schema",
1359
+ "refresh_reason",
1360
+ "delete_status",
1361
+ "prompt_cache_key_hash",
1362
+ ):
1363
+ value = policy.get(key)
1364
+ if value is not None:
1365
+ safe[key] = _safe_label(str(value))
1366
+ for key in (
1367
+ "allowed_fields",
1368
+ "emitted_fields",
1369
+ "trusted_usage_fields",
1370
+ "warnings",
1371
+ "disabled_fields",
1372
+ "runtime_disabled_fields",
1373
+ "eviction_reasons",
1374
+ ):
1375
+ value = policy.get(key)
1376
+ if isinstance(value, (list, tuple)):
1377
+ safe[key] = [_safe_label(str(item)) for item in value if str(item).strip()]
1378
+ for key in (
1379
+ "enabled",
1380
+ "eligible",
1381
+ "used",
1382
+ "emits_request_fields",
1383
+ "explicit_block_used",
1384
+ "top_level_cache_control_used",
1385
+ ):
1386
+ if key in policy:
1387
+ safe[key] = bool(policy.get(key))
1388
+ for key in (
1389
+ "min_tokens",
1390
+ "cacheable_prefix_estimated_tokens",
1391
+ "explicit_block_count",
1392
+ "entry_count",
1393
+ "max_entries",
1394
+ "ttl_seconds",
1395
+ "refresh_margin_seconds",
1396
+ "refresh_in_seconds",
1397
+ "expires_in_seconds",
1398
+ "cache_age_seconds",
1399
+ "cached_content_estimated_tokens",
1400
+ "created_entry_count",
1401
+ "reused_entry_count",
1402
+ "evicted_entry_count",
1403
+ "delete_attempt_count",
1404
+ "delete_success_count",
1405
+ "delete_failure_count",
1406
+ ):
1407
+ value = policy.get(key)
1408
+ try:
1409
+ number = int(value)
1410
+ except (TypeError, ValueError):
1411
+ continue
1412
+ if number >= 0:
1413
+ safe[key] = number
1414
+ return safe or None
1415
+
1416
+
1417
+ def _safe_request_shape(shape: Mapping[str, Any] | None) -> dict[str, Any] | None:
1418
+ if not isinstance(shape, Mapping):
1419
+ return None
1420
+ safe: dict[str, Any] = {}
1421
+ for key in ("input_mode", "cache_strategy", "cache_status", "compaction_trigger_reason"):
1422
+ value = shape.get(key)
1423
+ if value is not None:
1424
+ safe[key] = _safe_label(str(value))
1425
+ for key in (
1426
+ "cache_enabled",
1427
+ "cache_eligible",
1428
+ "cache_used",
1429
+ "cache_fields_emitted",
1430
+ "top_level_cache_control_present",
1431
+ "cached_content_attached",
1432
+ "affinity_field_emitted",
1433
+ "cacheable_prefix_present",
1434
+ ):
1435
+ if key in shape:
1436
+ safe[key] = bool(shape.get(key))
1437
+ for key in (
1438
+ "schema_version",
1439
+ "message_count",
1440
+ "tool_count",
1441
+ "system_message_count",
1442
+ "developer_message_count",
1443
+ "user_message_count",
1444
+ "assistant_message_count",
1445
+ "tool_message_count",
1446
+ "tool_call_message_count",
1447
+ "content_block_count",
1448
+ "cache_control_block_count",
1449
+ "explicit_cache_control_block_count",
1450
+ "cacheable_prefix_message_count",
1451
+ "cacheable_prefix_estimated_tokens",
1452
+ "cacheable_surface_estimated_tokens",
1453
+ "min_cacheable_tokens",
1454
+ "total_estimated_tokens",
1455
+ ):
1456
+ value = _optional_non_negative_int(shape.get(key))
1457
+ if value is not None:
1458
+ safe[key] = value
1459
+ for key in ("tool_schema_share", "inline_tool_transcript_share"):
1460
+ value = _optional_non_negative_float(shape.get(key))
1461
+ if value is not None:
1462
+ safe[key] = round(value, 4)
1463
+ for key in ("emitted_cache_fields", "risk_reasons", "compaction_trigger_reasons"):
1464
+ value = shape.get(key)
1465
+ if isinstance(value, (list, tuple)):
1466
+ safe[key] = [_safe_label(str(item)) for item in value if str(item).strip()]
1467
+ breakdown = shape.get("token_breakdown")
1468
+ if isinstance(breakdown, Mapping):
1469
+ safe_breakdown: dict[str, int] = {}
1470
+ for key in (
1471
+ "bootstrap_prompt_tokens",
1472
+ "tool_schema_tokens",
1473
+ "live_conversation_history_tokens",
1474
+ "inline_tool_transcript_tokens",
1475
+ "memory_summary_tokens",
1476
+ "pins_tokens",
1477
+ "total_tokens",
1478
+ ):
1479
+ value = _optional_non_negative_int(breakdown.get(key))
1480
+ if value is not None:
1481
+ safe_breakdown[key] = value
1482
+ if safe_breakdown:
1483
+ safe["token_breakdown"] = safe_breakdown
1484
+ return safe or None
1485
+
1486
+
1487
+ def _safe_request_plan(plan: Mapping[str, Any] | None) -> dict[str, Any] | None:
1488
+ if not isinstance(plan, Mapping):
1489
+ return None
1490
+ safe: dict[str, Any] = {}
1491
+ for key in (
1492
+ "input_mode",
1493
+ "status",
1494
+ "fallback",
1495
+ "continuation_strategy",
1496
+ "cache_strategy",
1497
+ "cache_mode",
1498
+ "cacheable_prefix_hash",
1499
+ "request_messages_signature",
1500
+ "tool_schema_hash",
1501
+ "compaction_trigger_reason",
1502
+ ):
1503
+ value = plan.get(key)
1504
+ if value is not None:
1505
+ safe[key] = _safe_label(str(value))
1506
+ for key in ("compaction_trigger_reasons",):
1507
+ value = plan.get(key)
1508
+ if isinstance(value, (list, tuple)):
1509
+ safe[key] = [_safe_label(str(item)) for item in value if str(item).strip()]
1510
+ for key in ("previous_response_id_used", "fallback_used", "stream"):
1511
+ if key in plan:
1512
+ safe[key] = bool(plan.get(key))
1513
+ for key in (
1514
+ "schema_version",
1515
+ "message_count",
1516
+ "request_message_count",
1517
+ "tool_count",
1518
+ "stable_prefix_message_count",
1519
+ "dynamic_suffix_message_count",
1520
+ "provider_metadata_message_count",
1521
+ "stable_prefix_estimated_tokens",
1522
+ "dynamic_suffix_estimated_tokens",
1523
+ "tool_schema_tokens",
1524
+ "total_estimated_tokens",
1525
+ "serialized_request_estimate_tokens",
1526
+ "sent_serialized_request_estimate_tokens",
1527
+ "full_input_item_count",
1528
+ "sent_input_item_count",
1529
+ "continuation_anchor_index",
1530
+ "resent_stable_instruction_count",
1531
+ ):
1532
+ value = plan.get(key)
1533
+ try:
1534
+ number = int(value)
1535
+ except (TypeError, ValueError):
1536
+ continue
1537
+ if number >= 0:
1538
+ safe[key] = number
1539
+ return safe or None
1540
+
1541
+
1542
+ def _first_token_latency_ms(
1543
+ *,
1544
+ started_ms: float,
1545
+ first_delta_ms: float | None,
1546
+ ) -> int | None:
1547
+ if first_delta_ms is None:
1548
+ return None
1549
+ return max(0, int(round(first_delta_ms - started_ms)))
1550
+
1551
+
1552
+ def _usage_payload(usage: LLMUsage | None) -> dict[str, int | None]:
1553
+ return {
1554
+ "prompt_tokens": usage.prompt_tokens if usage is not None else None,
1555
+ "completion_tokens": usage.completion_tokens if usage is not None else None,
1556
+ "total_tokens": usage.total_tokens if usage is not None else None,
1557
+ "cached_prompt_tokens": usage.cached_prompt_tokens if usage is not None else None,
1558
+ "input_tokens_uncached": usage.input_tokens_uncached if usage is not None else None,
1559
+ "cache_read_input_tokens": usage.cache_read_input_tokens if usage is not None else None,
1560
+ "cache_creation_input_tokens": (
1561
+ usage.cache_creation_input_tokens if usage is not None else None
1562
+ ),
1563
+ "cache_creation_5m_input_tokens": (
1564
+ usage.cache_creation_5m_input_tokens if usage is not None else None
1565
+ ),
1566
+ "cache_creation_1h_input_tokens": (
1567
+ usage.cache_creation_1h_input_tokens if usage is not None else None
1568
+ ),
1569
+ "reasoning_tokens": usage.reasoning_tokens if usage is not None else None,
1570
+ }
1571
+
1572
+
1573
+ def _status_category(exc: Exception) -> str:
1574
+ if is_provider_throttling_error(exc):
1575
+ return "rate_limited"
1576
+ if is_provider_unavailable_error(exc):
1577
+ return "provider_unavailable"
1578
+ lowered = str(exc).lower()
1579
+ if any(token in lowered for token in ("timeout", "connect", "network", "dns")):
1580
+ return "network_error"
1581
+ return "provider_error"
1582
+
1583
+
1584
+ def _stream_event_count(raw: Mapping[str, Any] | None) -> int:
1585
+ if not isinstance(raw, Mapping):
1586
+ return 0
1587
+ direct = raw.get("events")
1588
+ if isinstance(direct, int):
1589
+ return max(0, direct)
1590
+ stream_metadata = raw.get("stream_metadata")
1591
+ if isinstance(stream_metadata, Mapping):
1592
+ events = stream_metadata.get("events")
1593
+ if isinstance(events, int):
1594
+ return max(0, events)
1595
+ gemini_metadata = raw.get("streamMetadata")
1596
+ if isinstance(gemini_metadata, Mapping):
1597
+ chunks = gemini_metadata.get("chunks")
1598
+ if isinstance(chunks, int):
1599
+ return max(0, chunks)
1600
+ return 0
1601
+
1602
+
1603
+ def _stream_unknown_event_count(raw: Mapping[str, Any] | None) -> int:
1604
+ if not isinstance(raw, Mapping):
1605
+ return 0
1606
+ stream_metadata = raw.get("stream_metadata")
1607
+ if isinstance(stream_metadata, Mapping):
1608
+ unknown = stream_metadata.get("unknown_events")
1609
+ if isinstance(unknown, list):
1610
+ return len(unknown)
1611
+ gemini_metadata = raw.get("streamMetadata")
1612
+ if isinstance(gemini_metadata, Mapping):
1613
+ unknown_chunks = gemini_metadata.get("unknown_chunks")
1614
+ if isinstance(unknown_chunks, list):
1615
+ return len(unknown_chunks)
1616
+ return 0
1617
+
1618
+
1619
+ def _stream_restart_count(raw: Mapping[str, Any] | None) -> int:
1620
+ if not isinstance(raw, Mapping):
1621
+ return 0
1622
+ try:
1623
+ return max(0, int(raw.get("stream_restart_count") or 0))
1624
+ except (TypeError, ValueError):
1625
+ return 0
1626
+
1627
+
1628
+ def _stream_restart_reason(raw: Mapping[str, Any] | None) -> str:
1629
+ if not isinstance(raw, Mapping):
1630
+ return ""
1631
+ return _safe_label(raw.get("stream_restart_reason"))
1632
+
1633
+
1634
+ def _provider_metadata_web_search_counts(response: LLMResponse) -> dict[str, int]:
1635
+ metadata = response.provider_metadata if isinstance(response.provider_metadata, Mapping) else {}
1636
+ return {
1637
+ "source_count": _count_list_keys(metadata, {"sources", "groundingChunks"}),
1638
+ "citation_count": _count_list_keys(
1639
+ metadata,
1640
+ {"citations", "groundingSupports", "citationMetadata"},
1641
+ ),
1642
+ "query_count": _count_list_keys(metadata, {"queries", "webSearchQueries"}),
1643
+ }
1644
+
1645
+
1646
+ def _count_list_keys(value: Any, keys: set[str]) -> int:
1647
+ if isinstance(value, Mapping):
1648
+ count = 0
1649
+ for key, item in value.items():
1650
+ if str(key) in keys and isinstance(item, list):
1651
+ count += len(item)
1652
+ else:
1653
+ count += _count_list_keys(item, keys)
1654
+ return count
1655
+ if isinstance(value, list):
1656
+ return sum(_count_list_keys(item, keys) for item in value)
1657
+ return 0
1658
+
1659
+
1660
+ def _safe_label(value: Any) -> str:
1661
+ text = str(value or "").strip()
1662
+ if not text:
1663
+ return ""
1664
+ return _redact_string(text)
1665
+
1666
+
1667
+ def _redact_value(value: Any, *, key: str | None = None) -> Any:
1668
+ normalized_key = str(key or "").strip().casefold().replace("-", "_")
1669
+ if normalized_key:
1670
+ if normalized_key not in _SAFE_REDACTION_KEYS and (
1671
+ normalized_key in _SENSITIVE_EXACT_KEYS
1672
+ or normalized_key.endswith("_api_key")
1673
+ or any(fragment in normalized_key for fragment in _SENSITIVE_KEY_FRAGMENTS)
1674
+ ):
1675
+ return "[redacted]"
1676
+ if normalized_key in _HIDDEN_PAYLOAD_KEYS:
1677
+ return "[omitted]"
1678
+ if isinstance(value, Mapping):
1679
+ return {str(k): _redact_value(v, key=str(k)) for k, v in value.items()}
1680
+ if isinstance(value, list):
1681
+ return [_redact_value(item) for item in value]
1682
+ if isinstance(value, tuple):
1683
+ return [_redact_value(item) for item in value]
1684
+ if isinstance(value, str):
1685
+ return _redact_string(value)
1686
+ if isinstance(value, (bool, int, float)) or value is None:
1687
+ return value
1688
+ try:
1689
+ json.dumps(value)
1690
+ except TypeError:
1691
+ return _redact_string(repr(value))
1692
+ return value
1693
+
1694
+
1695
+ def _redact_string(value: str) -> str:
1696
+ text = str(value)
1697
+ if not text:
1698
+ return text
1699
+ return _SECRET_VALUE_RE.sub("[redacted]", text)