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,78 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ import httpx
7
+
8
+ TimeoutProfile = Literal["fetch", "search"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class HttpTimeoutBudget:
13
+ overall_s: float
14
+ connect_s: float
15
+ read_s: float
16
+ write_s: float
17
+ pool_s: float
18
+
19
+ def as_httpx_timeout(self) -> httpx.Timeout:
20
+ return httpx.Timeout(
21
+ connect=self.connect_s,
22
+ read=self.read_s,
23
+ write=self.write_s,
24
+ pool=self.pool_s,
25
+ )
26
+
27
+
28
+ def build_http_timeout_budget(overall_s: float, *, profile: TimeoutProfile) -> HttpTimeoutBudget:
29
+ total = float(overall_s)
30
+ if total <= 0:
31
+ raise ValueError("overall timeout must be > 0")
32
+
33
+ if profile == "fetch":
34
+ connect_cap = 5.0
35
+ write_cap = 10.0
36
+ pool_cap = 5.0
37
+ else:
38
+ connect_cap = 10.0
39
+ write_cap = 15.0
40
+ pool_cap = 10.0
41
+
42
+ return HttpTimeoutBudget(
43
+ overall_s=total,
44
+ connect_s=min(total, connect_cap),
45
+ read_s=total,
46
+ write_s=min(total, write_cap),
47
+ pool_s=min(total, pool_cap),
48
+ )
49
+
50
+
51
+ def format_http_timeout_error(
52
+ *,
53
+ operation: str,
54
+ budget: HttpTimeoutBudget,
55
+ error: httpx.TimeoutException,
56
+ ) -> str:
57
+ if isinstance(error, httpx.ConnectTimeout):
58
+ phase = "connection setup"
59
+ elif isinstance(error, httpx.ReadTimeout):
60
+ phase = "response read"
61
+ elif isinstance(error, httpx.WriteTimeout):
62
+ phase = "request write"
63
+ elif isinstance(error, httpx.PoolTimeout):
64
+ phase = "connection-pool wait"
65
+ else:
66
+ phase = "network I/O"
67
+
68
+ budget_text = (
69
+ f"connect={budget.connect_s:g}s read={budget.read_s:g}s "
70
+ f"write={budget.write_s:g}s pool={budget.pool_s:g}s"
71
+ )
72
+ detail = str(error).strip()
73
+ if detail:
74
+ return (
75
+ f"{operation} timed out during {phase} "
76
+ f"({budget_text}; overall={budget.overall_s:g}s): {detail}"
77
+ )
78
+ return f"{operation} timed out during {phase} ({budget_text}; overall={budget.overall_s:g}s)."
@@ -0,0 +1,552 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import hashlib
6
+ import io
7
+ import ipaddress
8
+ import os
9
+ import socket
10
+ import tempfile
11
+ import time
12
+ from contextlib import suppress
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.parse import urlsplit
16
+
17
+ import httpx
18
+ from PIL import Image, UnidentifiedImageError
19
+
20
+ from ..branding import env_get
21
+ from ..config import AppConfig
22
+ from ..error_text import sanitize_error_summary
23
+ from ..profiles import get_active_profile, resolve_effective_base_url
24
+
25
+ _OUTPUT_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".webp"})
26
+ _SIZES = frozenset({"auto", "1024x1024", "1536x1024", "1024x1536"})
27
+ _QUALITIES = frozenset({"auto", "low", "medium", "high"})
28
+ _BACKGROUNDS = frozenset({"auto", "opaque", "transparent"})
29
+
30
+
31
+ class ImageGenerationError(RuntimeError):
32
+ pass
33
+
34
+
35
+ def plan_image_output_paths(
36
+ *,
37
+ root: Path,
38
+ output_path: str,
39
+ count: int,
40
+ ) -> tuple[tuple[Path, str], ...]:
41
+ root = root.resolve()
42
+ raw = str(output_path or "").strip()
43
+ if not raw:
44
+ raise ImageGenerationError("output_path is required")
45
+ requested = Path(raw)
46
+ if requested.is_absolute():
47
+ raise ImageGenerationError("output_path must be relative to the workspace root")
48
+ if requested.suffix.lower() not in _OUTPUT_SUFFIXES:
49
+ raise ImageGenerationError("output_path must end in .png, .jpg, .jpeg, or .webp")
50
+ if isinstance(count, bool) or not isinstance(count, int) or count < 1 or count > 4:
51
+ raise ImageGenerationError("count must be an integer from 1 to 4")
52
+
53
+ candidates: list[Path] = []
54
+ if count == 1:
55
+ candidates.append(requested)
56
+ else:
57
+ candidates.extend(
58
+ requested.with_name(f"{requested.stem}-{index}{requested.suffix}")
59
+ for index in range(1, count + 1)
60
+ )
61
+
62
+ planned: list[tuple[Path, str]] = []
63
+ for candidate in candidates:
64
+ resolved = (root / candidate).resolve()
65
+ try:
66
+ relative = resolved.relative_to(root)
67
+ except ValueError as exc:
68
+ raise ImageGenerationError("output_path escapes the workspace root") from exc
69
+ planned.append((resolved, relative.as_posix()))
70
+ return tuple(planned)
71
+
72
+
73
+ def generate_images(
74
+ *,
75
+ root: Path,
76
+ cfg: AppConfig,
77
+ fallback_api_key: str | None,
78
+ prompt: str,
79
+ output_path: str,
80
+ count: int = 1,
81
+ size: str = "auto",
82
+ quality: str = "auto",
83
+ background: str = "auto",
84
+ timeout_s: float | None = None,
85
+ transport: httpx.BaseTransport | None = None,
86
+ ) -> dict[str, Any]:
87
+ settings = cfg.image_generation
88
+ if not settings.enabled:
89
+ raise ImageGenerationError(
90
+ "Image generation is disabled. Enable image_generation.enabled first."
91
+ )
92
+ clean_prompt = str(prompt or "").strip()
93
+ if not clean_prompt:
94
+ raise ImageGenerationError("prompt is required")
95
+ if len(clean_prompt) > 32_000:
96
+ raise ImageGenerationError("prompt exceeds the 32,000 character limit")
97
+ if count > settings.max_images_per_call:
98
+ raise ImageGenerationError(
99
+ f"count exceeds configured max_images_per_call={settings.max_images_per_call}"
100
+ )
101
+
102
+ normalized_size = _enum_value(size, allowed=_SIZES, field="size")
103
+ normalized_quality = _enum_value(quality, allowed=_QUALITIES, field="quality")
104
+ normalized_background = _enum_value(
105
+ background,
106
+ allowed=_BACKGROUNDS,
107
+ field="background",
108
+ )
109
+ planned = plan_image_output_paths(root=root, output_path=output_path, count=count)
110
+ if normalized_background == "transparent" and any(
111
+ target.suffix.lower() in {".jpg", ".jpeg"} for target, _ in planned
112
+ ):
113
+ raise ImageGenerationError("transparent background requires PNG or WebP output")
114
+ existing = [relative for target, relative in planned if target.exists()]
115
+ if existing:
116
+ raise ImageGenerationError(
117
+ "Refusing to overwrite existing image file(s): " + ", ".join(existing)
118
+ )
119
+
120
+ base_url, api_key, extra_headers = _resolve_runtime(
121
+ cfg=cfg,
122
+ fallback_api_key=fallback_api_key,
123
+ )
124
+ endpoint = _generation_endpoint(base_url)
125
+ payload: dict[str, Any] = {
126
+ "model": settings.model,
127
+ "prompt": clean_prompt,
128
+ "n": count,
129
+ }
130
+ if normalized_size != "auto":
131
+ payload["size"] = normalized_size
132
+ if normalized_quality != "auto":
133
+ payload["quality"] = normalized_quality
134
+ if normalized_background != "auto":
135
+ payload["background"] = normalized_background
136
+ if "gpt-image" in settings.model.casefold():
137
+ payload["output_format"] = _provider_output_format(planned[0][0].suffix)
138
+
139
+ safe_extra_headers = {
140
+ str(name): str(value)
141
+ for name, value in extra_headers.items()
142
+ if str(name).strip().casefold()
143
+ not in {"authorization", "content-length", "content-type", "host"}
144
+ }
145
+ headers = {
146
+ "Accept": "application/json",
147
+ "Authorization": f"Bearer {api_key}",
148
+ "Content-Type": "application/json",
149
+ "User-Agent": "alysis-code/image-generation",
150
+ **safe_extra_headers,
151
+ }
152
+ effective_timeout = float(timeout_s or settings.timeout_s)
153
+ max_response_bytes = min(
154
+ count * (((settings.max_image_bytes + 2) // 3) * 4) + 1_000_000,
155
+ 160_000_000,
156
+ )
157
+ started = time.perf_counter()
158
+ try:
159
+ with httpx.Client(
160
+ timeout=httpx.Timeout(effective_timeout),
161
+ follow_redirects=False,
162
+ transport=transport,
163
+ ) as client:
164
+ with client.stream("POST", endpoint, headers=headers, json=payload) as streamed:
165
+ response = _bounded_response(
166
+ streamed,
167
+ max_bytes=max_response_bytes,
168
+ label="Image provider response",
169
+ )
170
+ except (httpx.HTTPError, OSError) as exc:
171
+ raise ImageGenerationError(
172
+ "Image provider request failed: " + sanitize_error_summary(str(exc))
173
+ ) from exc
174
+ response_payload = _response_json(response)
175
+ if response.status_code < 200 or response.status_code >= 300:
176
+ detail = _provider_error_detail(response_payload, response.text)
177
+ raise ImageGenerationError(f"Image provider returned HTTP {response.status_code}: {detail}")
178
+
179
+ data = response_payload.get("data")
180
+ if not isinstance(data, list) or len(data) < count:
181
+ raise ImageGenerationError(
182
+ f"Image provider returned {len(data) if isinstance(data, list) else 0} "
183
+ f"image(s); expected {count}"
184
+ )
185
+
186
+ endpoint_host = str(urlsplit(endpoint).hostname or "").casefold()
187
+ prepared: list[tuple[Path, str, bytes, dict[str, Any]]] = []
188
+ revised_prompts: list[str] = []
189
+ for index, ((target, relative), item) in enumerate(zip(planned, data, strict=False), start=1):
190
+ if not isinstance(item, dict):
191
+ raise ImageGenerationError(f"Image provider result {index} is not an object")
192
+ raw_bytes = _image_bytes_from_result(
193
+ item=item,
194
+ max_bytes=settings.max_image_bytes,
195
+ endpoint_host=endpoint_host,
196
+ timeout_s=effective_timeout,
197
+ transport=transport,
198
+ )
199
+ normalized_bytes, metadata = _normalize_and_validate_image(
200
+ raw_bytes=raw_bytes,
201
+ output_suffix=target.suffix,
202
+ max_bytes=settings.max_image_bytes,
203
+ max_pixels=settings.max_pixels,
204
+ )
205
+ prepared.append((target, relative, normalized_bytes, metadata))
206
+ revised_prompt = str(item.get("revised_prompt") or "").strip()
207
+ if revised_prompt:
208
+ revised_prompts.append(revised_prompt)
209
+
210
+ committed: list[Path] = []
211
+ try:
212
+ for target, _relative, image_bytes, _metadata in prepared:
213
+ _atomic_create_bytes(target, image_bytes)
214
+ committed.append(target)
215
+ except Exception:
216
+ for target in committed:
217
+ with suppress(OSError):
218
+ target.unlink()
219
+ raise
220
+
221
+ images: list[dict[str, Any]] = []
222
+ for _target, relative, image_bytes, metadata in prepared:
223
+ images.append(
224
+ {
225
+ "path": relative,
226
+ "bytes": len(image_bytes),
227
+ "sha256": hashlib.sha256(image_bytes).hexdigest(),
228
+ **metadata,
229
+ }
230
+ )
231
+ raw_usage = response_payload.get("usage")
232
+ return {
233
+ "status": "succeeded",
234
+ "model": settings.model,
235
+ "count": len(images),
236
+ "images": images,
237
+ "output_paths": [item["path"] for item in images],
238
+ "size": normalized_size,
239
+ "quality": normalized_quality,
240
+ "background": normalized_background,
241
+ "prompt_sha256": hashlib.sha256(clean_prompt.encode("utf-8")).hexdigest(),
242
+ "revised_prompts": revised_prompts,
243
+ "provider_request_id": str(response.headers.get("x-request-id") or "") or None,
244
+ "provider_usage": raw_usage if isinstance(raw_usage, dict) else None,
245
+ "elapsed_ms": max(0, int((time.perf_counter() - started) * 1000)),
246
+ "technical_validation": "decoded, dimension-bounded, format-normalized, atomically written",
247
+ }
248
+
249
+
250
+ def _resolve_runtime(
251
+ *,
252
+ cfg: AppConfig,
253
+ fallback_api_key: str | None,
254
+ ) -> tuple[str, str, dict[str, str]]:
255
+ settings = cfg.image_generation
256
+ explicit_base_url = str(settings.base_url or "").strip()
257
+ if explicit_base_url:
258
+ base_url = explicit_base_url
259
+ extra_headers: dict[str, str] = {}
260
+ else:
261
+ profile = get_active_profile(cfg)
262
+ base_url = resolve_effective_base_url(cfg=cfg, profile=profile)
263
+ extra_headers = dict(profile.extra_headers)
264
+
265
+ configured_env = str(settings.api_key_env or "").strip()
266
+ if configured_env:
267
+ api_key = str(env_get(configured_env) or "").strip()
268
+ if not api_key:
269
+ raise ImageGenerationError(
270
+ f"Image API key environment variable {configured_env!r} is not set"
271
+ )
272
+ else:
273
+ api_key = str(env_get("ALYSIS_IMAGE_API_KEY") or fallback_api_key or "").strip()
274
+ if not api_key:
275
+ raise ImageGenerationError(
276
+ "No image API key is available. Set ALYSIS_IMAGE_API_KEY or "
277
+ "image_generation.api_key_env."
278
+ )
279
+ return base_url, api_key, extra_headers
280
+
281
+
282
+ def _generation_endpoint(base_url: str) -> str:
283
+ clean = str(base_url or "").strip().rstrip("/")
284
+ parts = urlsplit(clean)
285
+ if parts.scheme not in {"http", "https"} or not parts.netloc:
286
+ raise ImageGenerationError("Image generation base URL must be HTTP(S)")
287
+ if parts.username or parts.password or parts.query or parts.fragment:
288
+ raise ImageGenerationError("Image generation base URL cannot contain credentials or query")
289
+ if parts.path.rstrip("/").endswith("/images/generations"):
290
+ return clean
291
+ return clean + "/images/generations"
292
+
293
+
294
+ def _response_json(response: httpx.Response) -> dict[str, Any]:
295
+ try:
296
+ payload = response.json()
297
+ except ValueError as exc:
298
+ raise ImageGenerationError(
299
+ f"Image provider returned invalid JSON (HTTP {response.status_code})"
300
+ ) from exc
301
+ if not isinstance(payload, dict):
302
+ raise ImageGenerationError("Image provider response must be a JSON object")
303
+ return payload
304
+
305
+
306
+ def _bounded_response(
307
+ response: httpx.Response,
308
+ *,
309
+ max_bytes: int,
310
+ label: str,
311
+ ) -> httpx.Response:
312
+ content_length = str(response.headers.get("content-length") or "").strip()
313
+ if content_length:
314
+ try:
315
+ declared_length = int(content_length)
316
+ except ValueError:
317
+ declared_length = 0
318
+ if declared_length > max_bytes:
319
+ raise ImageGenerationError(f"{label} exceeds the configured byte limit")
320
+ body = bytearray()
321
+ for chunk in response.iter_bytes():
322
+ if len(body) + len(chunk) > max_bytes:
323
+ raise ImageGenerationError(f"{label} exceeds the configured byte limit")
324
+ body.extend(chunk)
325
+ return httpx.Response(
326
+ status_code=response.status_code,
327
+ headers=response.headers,
328
+ content=bytes(body),
329
+ request=response.request,
330
+ extensions=dict(response.extensions),
331
+ )
332
+
333
+
334
+ def _provider_error_detail(payload: dict[str, Any], fallback: str) -> str:
335
+ error = payload.get("error")
336
+ if isinstance(error, dict):
337
+ candidate = error.get("message") or error.get("detail") or error.get("code")
338
+ else:
339
+ candidate = error
340
+ return sanitize_error_summary(str(candidate or fallback or "provider error"))
341
+
342
+
343
+ def _image_bytes_from_result(
344
+ *,
345
+ item: dict[str, Any],
346
+ max_bytes: int,
347
+ endpoint_host: str,
348
+ timeout_s: float,
349
+ transport: httpx.BaseTransport | None,
350
+ ) -> bytes:
351
+ encoded = item.get("b64_json")
352
+ if isinstance(encoded, str) and encoded.strip():
353
+ try:
354
+ payload = base64.b64decode(encoded, validate=True)
355
+ except (binascii.Error, ValueError) as exc:
356
+ raise ImageGenerationError("Image provider returned invalid base64 image data") from exc
357
+ if len(payload) > max_bytes:
358
+ raise ImageGenerationError("Generated image exceeds configured max_image_bytes")
359
+ return payload
360
+
361
+ url = str(item.get("url") or "").strip()
362
+ if not url:
363
+ raise ImageGenerationError("Image provider result has neither b64_json nor url")
364
+ return _download_image(
365
+ url=url,
366
+ endpoint_host=endpoint_host,
367
+ max_bytes=max_bytes,
368
+ timeout_s=timeout_s,
369
+ transport=transport,
370
+ )
371
+
372
+
373
+ def _download_image(
374
+ *,
375
+ url: str,
376
+ endpoint_host: str,
377
+ max_bytes: int,
378
+ timeout_s: float,
379
+ transport: httpx.BaseTransport | None,
380
+ ) -> bytes:
381
+ parts = urlsplit(url)
382
+ host = str(parts.hostname or "").casefold()
383
+ if parts.scheme != "https" or not host or parts.username or parts.password:
384
+ raise ImageGenerationError("Provider image URL must be credential-free HTTPS")
385
+ if host != endpoint_host and not _public_network_host(host):
386
+ raise ImageGenerationError("Provider image URL resolves to a non-public network address")
387
+ try:
388
+ with httpx.Client(
389
+ timeout=httpx.Timeout(timeout_s),
390
+ follow_redirects=False,
391
+ transport=transport,
392
+ ) as client:
393
+ with client.stream("GET", url, headers={"Accept": "image/*"}) as response:
394
+ if response.status_code < 200 or response.status_code >= 300:
395
+ raise ImageGenerationError(
396
+ f"Provider image download returned HTTP {response.status_code}"
397
+ )
398
+ content_length = response.headers.get("content-length")
399
+ if content_length and int(content_length) > max_bytes:
400
+ raise ImageGenerationError(
401
+ "Generated image download exceeds configured max_image_bytes"
402
+ )
403
+ chunks: list[bytes] = []
404
+ total = 0
405
+ for chunk in response.iter_bytes():
406
+ total += len(chunk)
407
+ if total > max_bytes:
408
+ raise ImageGenerationError(
409
+ "Generated image download exceeds configured max_image_bytes"
410
+ )
411
+ chunks.append(chunk)
412
+ except ImageGenerationError:
413
+ raise
414
+ except (httpx.HTTPError, OSError, ValueError) as exc:
415
+ raise ImageGenerationError(
416
+ "Generated image download failed: " + sanitize_error_summary(str(exc))
417
+ ) from exc
418
+ return b"".join(chunks)
419
+
420
+
421
+ def _public_network_host(host: str) -> bool:
422
+ if host in {"localhost", "localhost.localdomain"} or host.endswith(".local"):
423
+ return False
424
+ try:
425
+ literal = ipaddress.ip_address(host)
426
+ except ValueError:
427
+ literal = None
428
+ if literal is not None:
429
+ return literal.is_global
430
+ try:
431
+ addresses = {
432
+ ipaddress.ip_address(sockaddr[0])
433
+ for _family, _type, _proto, _canonname, sockaddr in socket.getaddrinfo(
434
+ host,
435
+ 443,
436
+ type=socket.SOCK_STREAM,
437
+ )
438
+ }
439
+ except (OSError, ValueError):
440
+ return False
441
+ return bool(addresses) and all(address.is_global for address in addresses)
442
+
443
+
444
+ def _normalize_and_validate_image(
445
+ *,
446
+ raw_bytes: bytes,
447
+ output_suffix: str,
448
+ max_bytes: int,
449
+ max_pixels: int,
450
+ ) -> tuple[bytes, dict[str, Any]]:
451
+ try:
452
+ with Image.open(io.BytesIO(raw_bytes)) as opened:
453
+ width, height = opened.size
454
+ if width < 1 or height < 1 or width * height > max_pixels:
455
+ raise ImageGenerationError(
456
+ f"Generated image dimensions {width}x{height} exceed configured max_pixels"
457
+ )
458
+ opened.load()
459
+ image = opened.copy()
460
+ except ImageGenerationError:
461
+ raise
462
+ except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as exc:
463
+ raise ImageGenerationError("Provider returned invalid or unsupported image bytes") from exc
464
+
465
+ desired_format = _pillow_output_format(output_suffix)
466
+ output = io.BytesIO()
467
+ if desired_format == "JPEG":
468
+ if image.mode in {"RGBA", "LA"} or (image.mode == "P" and "transparency" in image.info):
469
+ rgba = image.convert("RGBA")
470
+ background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
471
+ background.alpha_composite(rgba)
472
+ image = background.convert("RGB")
473
+ elif image.mode != "RGB":
474
+ image = image.convert("RGB")
475
+ image.save(output, format="JPEG", quality=95, optimize=True)
476
+ elif desired_format == "WEBP":
477
+ image.save(output, format="WEBP", quality=95, method=6)
478
+ else:
479
+ image.save(output, format="PNG", optimize=True)
480
+ normalized = output.getvalue()
481
+ if len(normalized) > max_bytes:
482
+ raise ImageGenerationError("Normalized image exceeds configured max_image_bytes")
483
+ has_alpha = image.mode in {"RGBA", "LA"} or (image.mode == "P" and "transparency" in image.info)
484
+ return normalized, {
485
+ "width": width,
486
+ "height": height,
487
+ "format": desired_format.lower(),
488
+ "mode": image.mode,
489
+ "has_alpha": bool(has_alpha),
490
+ }
491
+
492
+
493
+ def _atomic_create_bytes(path: Path, payload: bytes) -> None:
494
+ path.parent.mkdir(parents=True, exist_ok=True)
495
+ fd, temp_name = tempfile.mkstemp(
496
+ dir=path.parent,
497
+ prefix=f".{path.name}.",
498
+ suffix=".tmp",
499
+ )
500
+ temp_path = Path(temp_name)
501
+ try:
502
+ with os.fdopen(fd, "wb") as handle:
503
+ handle.write(payload)
504
+ handle.flush()
505
+ os.fsync(handle.fileno())
506
+ try:
507
+ os.link(temp_path, path)
508
+ except FileExistsError as exc:
509
+ raise ImageGenerationError(
510
+ f"Refusing to overwrite existing image file: {path.name}"
511
+ ) from exc
512
+ except OSError:
513
+ # Filesystems without hard-link support still get an exclusive,
514
+ # fully-written target. The fallback never overwrites an existing path.
515
+ try:
516
+ target_fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
517
+ except FileExistsError as exc:
518
+ raise ImageGenerationError(
519
+ f"Refusing to overwrite existing image file: {path.name}"
520
+ ) from exc
521
+ try:
522
+ with os.fdopen(target_fd, "wb") as target:
523
+ target.write(payload)
524
+ target.flush()
525
+ os.fsync(target.fileno())
526
+ except Exception:
527
+ with suppress(OSError):
528
+ path.unlink()
529
+ raise
530
+ finally:
531
+ with suppress(OSError):
532
+ temp_path.unlink()
533
+
534
+
535
+ def _enum_value(value: str, *, allowed: frozenset[str], field: str) -> str:
536
+ normalized = str(value or "auto").strip().lower() or "auto"
537
+ if normalized not in allowed:
538
+ raise ImageGenerationError(f"{field} must be one of: {', '.join(sorted(allowed))}")
539
+ return normalized
540
+
541
+
542
+ def _provider_output_format(suffix: str) -> str:
543
+ return "jpeg" if suffix.lower() in {".jpg", ".jpeg"} else suffix.lower().lstrip(".")
544
+
545
+
546
+ def _pillow_output_format(suffix: str) -> str:
547
+ return {
548
+ ".jpg": "JPEG",
549
+ ".jpeg": "JPEG",
550
+ ".webp": "WEBP",
551
+ ".png": "PNG",
552
+ }[suffix.lower()]