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,951 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import copy
5
+ import hashlib
6
+ import hmac
7
+ import html
8
+ import json
9
+ import secrets
10
+ import threading
11
+ import time
12
+ from collections.abc import Callable, Mapping
13
+ from http.server import BaseHTTPRequestHandler, HTTPServer
14
+ from typing import Any
15
+ from urllib.parse import parse_qs, urlencode, urlsplit
16
+
17
+ import httpx
18
+
19
+ from ..branding import env_get
20
+ from ..chatgpt_codex_static_provider import resolve_chatgpt_codex_static_model
21
+ from ..host_browser import open_url
22
+ from .base import (
23
+ ProviderAccountStatus,
24
+ ProviderAuthError,
25
+ ProviderLoginRequiredError,
26
+ ProviderModel,
27
+ ProviderReasoningEffort,
28
+ )
29
+ from .store import (
30
+ ProviderTokenRecord,
31
+ delete_provider_token,
32
+ load_provider_token,
33
+ save_provider_token,
34
+ )
35
+
36
+ SESSION_EXPIRED_DETAIL = "session expired"
37
+ """Stable ``detail`` value meaning the stored session must be re-authorized."""
38
+
39
+ _ISSUER = "https://auth.openai.com"
40
+ _CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
41
+ _RESPONSES_URL = f"{_CODEX_BASE_URL}/responses"
42
+ _MODELS_URL = f"{_CODEX_BASE_URL}/models"
43
+ _DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
44
+ _DEFAULT_CODEX_COMPAT_VERSION = "0.144.6"
45
+ _CALLBACK_PORTS = (1455, 1457)
46
+ _BROWSER_TIMEOUT_SECONDS = 300.0
47
+ _DEVICE_TIMEOUT_SECONDS = 15.0 * 60.0
48
+ _REFRESH_SKEW_SECONDS = 300.0
49
+ _DEFAULT_TOKEN_LIFETIME_SECONDS = 3600.0
50
+ _ALLOWED_REQUESTS = frozenset({_RESPONSES_URL, _MODELS_URL})
51
+ _REFRESH_LOCKS: dict[str, threading.Lock] = {}
52
+ _REFRESH_LOCKS_GUARD = threading.Lock()
53
+ _LOGIN_LOCK = threading.Lock()
54
+
55
+
56
+ def _client_id() -> str:
57
+ return str(env_get("ALYSIS_OPENAI_OAUTH_CLIENT_ID") or "").strip() or _DEFAULT_CLIENT_ID
58
+
59
+
60
+ def _codex_compat_version() -> str:
61
+ """Codex catalog compatibility version, intentionally not Alysis Code's version."""
62
+
63
+ return (
64
+ str(env_get("ALYSIS_OPENAI_CODEX_COMPAT_VERSION") or "").strip()
65
+ or _DEFAULT_CODEX_COMPAT_VERSION
66
+ )
67
+
68
+
69
+ class OpenAICodexSubscriptionAuth:
70
+ """ChatGPT-backed Codex Responses auth for Alysis Code's native agent loop.
71
+
72
+ The model transport intentionally lives behind this adapter because the
73
+ ChatGPT Codex endpoint is a compatibility surface, not the ordinary OpenAI
74
+ API. Credentials are attached only to two exact, HTTPS allowlisted URLs.
75
+ """
76
+
77
+ provider_id = "openai-codex"
78
+ display_name = "ChatGPT Codex subscription"
79
+ description = "Use Codex models from ChatGPT while keeping Alysis Code's native agent."
80
+ profile_name = "chatgpt-codex"
81
+ auth_hint = "Browser or device-code sign-in; credentials use Alysis Code's encrypted vault."
82
+ base_url = _CODEX_BASE_URL
83
+ protocol = "openai_responses"
84
+ supports_previous_response_id = False
85
+ supports_temperature = False
86
+ requires_streaming = True
87
+
88
+ def __init__(self, *, transport: httpx.BaseTransport | None = None) -> None:
89
+ self._transport = transport
90
+ self._models_cache: tuple[ProviderModel, ...] | None = None
91
+
92
+ def account_status(self) -> ProviderAccountStatus:
93
+ """Report account state without ever raising.
94
+
95
+ A supervising app polls this to decide what to show the user, so every
96
+ failure has to come back as data. Reading the credential store can fail
97
+ on its own — a spawned process may not reach the OS keyring holding the
98
+ store's master key — so the load is guarded too, not just the refresh.
99
+ """
100
+
101
+ try:
102
+ record = load_provider_token(self.provider_id)
103
+ except ProviderAuthError as exc:
104
+ return ProviderAccountStatus(
105
+ connected=False,
106
+ verified=False,
107
+ detail=str(exc),
108
+ )
109
+ except Exception as exc: # noqa: BLE001 - a status probe must not crash its caller
110
+ return ProviderAccountStatus(
111
+ connected=False,
112
+ verified=False,
113
+ detail=f"Could not read stored ChatGPT credentials ({exc.__class__.__name__}).",
114
+ )
115
+ if record is None:
116
+ return ProviderAccountStatus(
117
+ connected=False,
118
+ detail="No ChatGPT subscription account is connected.",
119
+ )
120
+ try:
121
+ record = self._valid_record(record)
122
+ except ProviderLoginRequiredError:
123
+ # A stable marker, not the thrown message: this is the one state a
124
+ # supervising app must be able to branch on to prompt a re-login.
125
+ return ProviderAccountStatus(
126
+ connected=False,
127
+ verified=True,
128
+ account_label=record.account_label,
129
+ detail=SESSION_EXPIRED_DETAIL,
130
+ )
131
+ except ProviderAuthError as exc:
132
+ return ProviderAccountStatus(
133
+ connected=False,
134
+ verified=False,
135
+ account_label=record.account_label,
136
+ detail=str(exc),
137
+ )
138
+ except Exception as exc: # noqa: BLE001 - a status probe must not crash its caller
139
+ return ProviderAccountStatus(
140
+ connected=False,
141
+ verified=False,
142
+ account_label=record.account_label,
143
+ detail=f"Could not verify ChatGPT credentials ({exc.__class__.__name__}).",
144
+ )
145
+ return ProviderAccountStatus(
146
+ connected=True,
147
+ account_label=record.account_label,
148
+ detail="Connected with ChatGPT.",
149
+ )
150
+
151
+ def route_credential_scope(self) -> str:
152
+ """Return a non-secret stable account scope for continuation isolation."""
153
+
154
+ record = load_provider_token(self.provider_id)
155
+ if record is None:
156
+ return ""
157
+ material = record.account_id or record.account_label or record.access_token
158
+ if not material:
159
+ return ""
160
+ return hashlib.sha256(f"alysis-route-scope:v1:{material}".encode()).hexdigest()
161
+
162
+ def login(
163
+ self,
164
+ method: str = "browser",
165
+ *,
166
+ output_write: Callable[[str], None] | None = None,
167
+ ) -> ProviderAccountStatus:
168
+ normalized = str(method or "browser").strip().lower()
169
+ write = output_write or print
170
+ if normalized not in {"browser", "device-code"}:
171
+ raise ProviderAuthError(f"Unsupported ChatGPT sign-in method: {method!r}.")
172
+ if not _LOGIN_LOCK.acquire(blocking=False):
173
+ raise ProviderAuthError("Another ChatGPT sign-in is already in progress.")
174
+ try:
175
+ record = (
176
+ self._browser_login(write) if normalized == "browser" else self._device_login(write)
177
+ )
178
+ save_provider_token(self.provider_id, record)
179
+ self._models_cache = None
180
+ finally:
181
+ _LOGIN_LOCK.release()
182
+ return ProviderAccountStatus(
183
+ connected=True,
184
+ account_label=record.account_label,
185
+ detail="Connected with ChatGPT.",
186
+ )
187
+
188
+ def logout(self) -> ProviderAccountStatus:
189
+ record = load_provider_token(self.provider_id)
190
+ revoke_error: str | None = None
191
+ if record is not None:
192
+ try:
193
+ with self._client(timeout=10.0) as client:
194
+ response = client.post(
195
+ f"{_ISSUER}/oauth/revoke",
196
+ json={
197
+ "token": record.refresh_token,
198
+ "token_type_hint": "refresh_token",
199
+ "client_id": _client_id(),
200
+ },
201
+ )
202
+ if response.status_code >= 400:
203
+ revoke_error = f"remote revocation returned status {response.status_code}"
204
+ except Exception as exc: # noqa: BLE001
205
+ # Local credential removal is still deterministic. Remote
206
+ # revocation is best effort because auth services can be offline.
207
+ revoke_error = f"remote revocation failed: {type(exc).__name__}"
208
+ delete_provider_token(self.provider_id)
209
+ self._models_cache = None
210
+ return ProviderAccountStatus(
211
+ connected=False,
212
+ verified=revoke_error is None,
213
+ detail=(
214
+ "Disconnected locally."
215
+ if revoke_error is None
216
+ else f"Disconnected locally; {revoke_error}."
217
+ ),
218
+ )
219
+
220
+ def authorization_headers(
221
+ self,
222
+ url: str,
223
+ *,
224
+ force_refresh: bool = False,
225
+ session_id: str | None = None,
226
+ ) -> Mapping[str, str]:
227
+ normalized_url = _canonical_request_url(url)
228
+ if normalized_url not in _ALLOWED_REQUESTS:
229
+ raise ProviderAuthError(
230
+ "Refusing to attach ChatGPT credentials to a non-Codex destination."
231
+ )
232
+ record = load_provider_token(self.provider_id)
233
+ if record is None:
234
+ raise ProviderLoginRequiredError(
235
+ "ChatGPT is not connected. Run `alysis auth login openai-codex`."
236
+ )
237
+ record = self._valid_record(record, force_refresh=force_refresh)
238
+ headers: dict[str, str] = {
239
+ "Authorization": f"Bearer {record.access_token}",
240
+ "ChatGPT-Account-Id": str(record.account_id or ""),
241
+ # The ChatGPT Codex backend gates model availability on the native
242
+ # Codex client identity as well as the account entitlement. Keep
243
+ # these headers paired with the compatibility version used for model
244
+ # discovery; an Alysis Code-specific identity can make catalog-listed
245
+ # models fail at inference time with a misleading 404.
246
+ "originator": "codex_cli_rs",
247
+ "User-Agent": f"codex_cli_rs/{_codex_compat_version()}",
248
+ }
249
+ if not record.account_id:
250
+ headers.pop("ChatGPT-Account-Id", None)
251
+ normalized_session = str(session_id or "").strip()
252
+ if normalized_session:
253
+ headers.update(
254
+ {
255
+ "session-id": normalized_session,
256
+ "x-session-affinity": normalized_session,
257
+ "X-Session-Id": normalized_session,
258
+ }
259
+ )
260
+ return headers
261
+
262
+ def adapt_responses_payload(self, payload: Mapping[str, Any]) -> dict[str, Any]:
263
+ adapted = copy.deepcopy(dict(payload))
264
+ input_items = adapted.get("input")
265
+ if isinstance(input_items, list):
266
+ instructions: list[str] = []
267
+ retained: list[Any] = []
268
+ for item in input_items:
269
+ if isinstance(item, dict) and str(item.get("role") or "") in {
270
+ "system",
271
+ "developer",
272
+ }:
273
+ text = _message_text(item.get("content"))
274
+ if text:
275
+ instructions.append(text)
276
+ continue
277
+ if isinstance(item, dict):
278
+ item = copy.deepcopy(item)
279
+ item.pop("id", None)
280
+ item.pop("status", None)
281
+ retained.append(item)
282
+ adapted["input"] = retained
283
+ if instructions:
284
+ existing = str(adapted.get("instructions") or "").strip()
285
+ combined = "\n\n".join(instructions)
286
+ adapted["instructions"] = f"{existing}\n\n{combined}" if existing else combined
287
+
288
+ adapted["store"] = False
289
+ adapted.pop("temperature", None)
290
+ adapted.pop("max_output_tokens", None)
291
+ adapted.pop("prompt_cache_retention", None)
292
+ adapted.pop("previous_response_id", None)
293
+
294
+ include = adapted.get("include")
295
+ include_values = list(include) if isinstance(include, list) else []
296
+ if "reasoning.encrypted_content" not in include_values:
297
+ include_values.append("reasoning.encrypted_content")
298
+ adapted["include"] = include_values
299
+
300
+ text_config = adapted.get("text")
301
+ if isinstance(text_config, dict):
302
+ text_config.setdefault("verbosity", "low")
303
+ else:
304
+ adapted["text"] = {"verbosity": "low"}
305
+
306
+ tools = adapted.get("tools")
307
+ if isinstance(tools, list):
308
+ for tool in tools:
309
+ if isinstance(tool, dict) and str(tool.get("type") or "") == "function":
310
+ tool["strict"] = False
311
+ tool["parameters"] = _sanitize_openai_tool_schema(
312
+ tool.get("parameters", {"type": "object"})
313
+ )
314
+ return adapted
315
+
316
+ def list_models(self, *, refresh: bool = False) -> tuple[ProviderModel, ...]:
317
+ if self._models_cache is not None and not refresh:
318
+ return self._models_cache
319
+ url = f"{_MODELS_URL}?{urlencode({'client_version': _codex_compat_version()})}"
320
+ headers = dict(self.authorization_headers(_MODELS_URL))
321
+ try:
322
+ with self._client(timeout=20.0) as client:
323
+ response = client.get(url, headers=headers)
324
+ except httpx.HTTPError as exc:
325
+ raise ProviderAuthError("Could not load models for this ChatGPT account.") from exc
326
+ if response.status_code == 401:
327
+ headers = dict(self.authorization_headers(_MODELS_URL, force_refresh=True))
328
+ try:
329
+ with self._client(timeout=20.0) as client:
330
+ response = client.get(url, headers=headers)
331
+ except httpx.HTTPError as exc:
332
+ raise ProviderAuthError(
333
+ "Could not load models for this ChatGPT account after refreshing credentials."
334
+ ) from exc
335
+ if response.status_code >= 400:
336
+ raise ProviderAuthError(
337
+ f"ChatGPT model discovery failed with status {response.status_code}."
338
+ )
339
+ try:
340
+ data = response.json()
341
+ except ValueError as exc:
342
+ raise ProviderAuthError("ChatGPT returned an invalid model catalog.") from exc
343
+ raw_models = data.get("models") if isinstance(data, dict) else None
344
+ if not isinstance(raw_models, list):
345
+ raise ProviderAuthError("ChatGPT returned an invalid model catalog.")
346
+
347
+ parsed: list[tuple[int, ProviderModel]] = []
348
+ for raw in raw_models:
349
+ if not isinstance(raw, dict):
350
+ continue
351
+ model_id = str(raw.get("slug") or raw.get("id") or "").strip()
352
+ visibility = str(raw.get("visibility") or "list").strip().lower()
353
+ if not model_id or visibility in {"hide", "hidden"}:
354
+ continue
355
+ efforts: list[ProviderReasoningEffort] = []
356
+ raw_efforts = raw.get("supported_reasoning_levels")
357
+ if isinstance(raw_efforts, list):
358
+ for entry in raw_efforts:
359
+ if not isinstance(entry, dict):
360
+ continue
361
+ effort_id = str(entry.get("effort") or entry.get("id") or "").strip()
362
+ if not effort_id:
363
+ continue
364
+ efforts.append(
365
+ ProviderReasoningEffort(
366
+ id=effort_id,
367
+ label=effort_id.replace("_", " ").title(),
368
+ description=str(entry.get("description") or "").strip(),
369
+ )
370
+ )
371
+ static_model = resolve_chatgpt_codex_static_model(model_id)
372
+ if not efforts and static_model is not None:
373
+ efforts = [
374
+ ProviderReasoningEffort(
375
+ id=effort_id,
376
+ label=effort_id.replace("_", " ").title(),
377
+ description=description,
378
+ )
379
+ for effort_id, description in static_model.reasoning_efforts
380
+ ]
381
+ try:
382
+ priority = int(raw.get("priority") or 9999)
383
+ except (TypeError, ValueError):
384
+ priority = static_model.priority if static_model is not None else 9999
385
+ input_modalities = _input_modalities(raw)
386
+ if (
387
+ static_model is not None
388
+ and "input_modalities" not in raw
389
+ and "supports_image_input" not in raw
390
+ ):
391
+ input_modalities = static_model.input_modalities
392
+ parsed.append(
393
+ (
394
+ priority,
395
+ ProviderModel(
396
+ id=model_id,
397
+ label=str(raw.get("display_name") or model_id).strip(),
398
+ description=str(raw.get("description") or "").strip(),
399
+ reasoning_efforts=tuple(efforts),
400
+ default_reasoning_effort=(
401
+ str(raw.get("default_reasoning_level") or "").strip()
402
+ or (
403
+ static_model.default_reasoning_effort
404
+ if static_model is not None
405
+ else None
406
+ )
407
+ ),
408
+ input_modalities=input_modalities,
409
+ context_window_tokens=(
410
+ _positive_int(raw.get("context_window"))
411
+ or (
412
+ static_model.context_window_tokens
413
+ if static_model is not None
414
+ else None
415
+ )
416
+ ),
417
+ max_output_tokens=(
418
+ _positive_int(raw.get("max_output_tokens"))
419
+ or (
420
+ static_model.max_output_tokens if static_model is not None else None
421
+ )
422
+ ),
423
+ ),
424
+ )
425
+ )
426
+ parsed.sort(key=lambda pair: (pair[0], pair[1].label.casefold(), pair[1].id))
427
+ models = [model for _priority, model in parsed]
428
+ if models:
429
+ first = models[0]
430
+ models[0] = ProviderModel(
431
+ id=first.id,
432
+ label=first.label,
433
+ description=first.description,
434
+ is_default=True,
435
+ reasoning_efforts=first.reasoning_efforts,
436
+ default_reasoning_effort=first.default_reasoning_effort,
437
+ input_modalities=first.input_modalities,
438
+ context_window_tokens=first.context_window_tokens,
439
+ max_output_tokens=first.max_output_tokens,
440
+ )
441
+ self._models_cache = tuple(models)
442
+ return self._models_cache
443
+
444
+ def _valid_record(
445
+ self,
446
+ record: ProviderTokenRecord,
447
+ *,
448
+ force_refresh: bool = False,
449
+ ) -> ProviderTokenRecord:
450
+ if not force_refresh and record.expires_at > time.time() + _REFRESH_SKEW_SECONDS:
451
+ return record
452
+ lock = _refresh_lock(self.provider_id)
453
+ with lock:
454
+ current = load_provider_token(self.provider_id)
455
+ if current is None:
456
+ raise ProviderLoginRequiredError("ChatGPT credentials were removed; connect again.")
457
+ if force_refresh and current.access_token != record.access_token:
458
+ return current
459
+ if not force_refresh and current.expires_at > time.time() + _REFRESH_SKEW_SECONDS:
460
+ return current
461
+ return self._refresh(
462
+ current,
463
+ allow_unexpired_fallback=not force_refresh,
464
+ )
465
+
466
+ def _refresh(
467
+ self,
468
+ record: ProviderTokenRecord,
469
+ *,
470
+ allow_unexpired_fallback: bool,
471
+ ) -> ProviderTokenRecord:
472
+ try:
473
+ with self._client(timeout=30.0) as client:
474
+ response = client.post(
475
+ f"{_ISSUER}/oauth/token",
476
+ data={
477
+ "grant_type": "refresh_token",
478
+ "refresh_token": record.refresh_token,
479
+ "client_id": _client_id(),
480
+ },
481
+ )
482
+ except httpx.HTTPError as exc:
483
+ if allow_unexpired_fallback and record.expires_at > time.time():
484
+ return record
485
+ raise ProviderAuthError(
486
+ "ChatGPT credential refresh is temporarily unavailable; try again shortly."
487
+ ) from exc
488
+ if response.status_code >= 400:
489
+ error_code = _oauth_error_code(response)
490
+ if response.status_code in {400, 401} or error_code in {
491
+ "invalid_grant",
492
+ "invalid_token",
493
+ }:
494
+ raise ProviderLoginRequiredError(
495
+ "ChatGPT session expired; connect your account again."
496
+ )
497
+ if allow_unexpired_fallback and record.expires_at > time.time():
498
+ return record
499
+ raise ProviderAuthError(
500
+ "ChatGPT credential refresh is temporarily unavailable; try again shortly."
501
+ )
502
+ data = _json_object(response, "ChatGPT returned an invalid refresh response.")
503
+ access_token = str(data.get("access_token") or "").strip()
504
+ refresh_token = str(data.get("refresh_token") or record.refresh_token).strip()
505
+ if not access_token or not refresh_token:
506
+ raise ProviderLoginRequiredError("ChatGPT session expired; connect your account again.")
507
+ id_token = str(data.get("id_token") or "").strip()
508
+ claims = _jwt_claims(id_token) if id_token else _jwt_claims(access_token)
509
+ refreshed = ProviderTokenRecord(
510
+ access_token=access_token,
511
+ refresh_token=refresh_token,
512
+ expires_at=_token_expiry(data, access_token),
513
+ account_id=_account_id(claims) or record.account_id,
514
+ account_label=_account_label(claims) or record.account_label,
515
+ )
516
+ save_provider_token(self.provider_id, refreshed)
517
+ return refreshed
518
+
519
+ def _browser_login(self, write: Callable[[str], None]) -> ProviderTokenRecord:
520
+ verifier = _pkce_verifier()
521
+ challenge = _pkce_challenge(verifier)
522
+ expected_state = secrets.token_urlsafe(32)
523
+ server = _bind_callback_server()
524
+ actual_port = int(server.server_address[1])
525
+ redirect_uri = f"http://localhost:{actual_port}/auth/callback"
526
+ params = {
527
+ "response_type": "code",
528
+ "client_id": _client_id(),
529
+ "redirect_uri": redirect_uri,
530
+ "scope": "openid profile email offline_access",
531
+ "code_challenge": challenge,
532
+ "code_challenge_method": "S256",
533
+ "id_token_add_organizations": "true",
534
+ "codex_cli_simplified_flow": "true",
535
+ "state": expected_state,
536
+ "originator": "alysis",
537
+ }
538
+ auth_url = f"{_ISSUER}/oauth/authorize?{urlencode(params)}"
539
+ callback: dict[str, str] = {}
540
+ server.RequestHandlerClass = _callback_handler(callback, expected_state)
541
+ server.timeout = 0.5
542
+ write("Opening ChatGPT sign-in in your browser…")
543
+ if not open_url(auth_url):
544
+ write(f"Open this URL to continue: {auth_url}")
545
+ deadline = time.monotonic() + _BROWSER_TIMEOUT_SECONDS
546
+ try:
547
+ while time.monotonic() < deadline and "done" not in callback:
548
+ server.handle_request()
549
+ finally:
550
+ server.server_close()
551
+ if "done" not in callback:
552
+ raise ProviderAuthError("ChatGPT browser sign-in timed out after five minutes.")
553
+ if callback.get("error"):
554
+ raise ProviderAuthError(f"ChatGPT sign-in failed: {callback['error']}")
555
+ code = callback.get("code") or ""
556
+ if not code:
557
+ raise ProviderAuthError("ChatGPT sign-in did not return an authorization code.")
558
+ return self._exchange_code(code=code, verifier=verifier, redirect_uri=redirect_uri)
559
+
560
+ def _device_login(self, write: Callable[[str], None]) -> ProviderTokenRecord:
561
+ try:
562
+ with self._client(timeout=30.0) as client:
563
+ response = client.post(
564
+ f"{_ISSUER}/api/accounts/deviceauth/usercode",
565
+ json={"client_id": _client_id()},
566
+ )
567
+ except httpx.HTTPError as exc:
568
+ raise ProviderAuthError("Could not start ChatGPT device-code sign-in.") from exc
569
+ if response.status_code >= 400:
570
+ raise ProviderAuthError(
571
+ f"ChatGPT device-code sign-in is unavailable (status {response.status_code})."
572
+ )
573
+ data = _json_object(response, "ChatGPT returned an invalid device-code response.")
574
+ device_auth_id = str(data.get("device_auth_id") or "").strip()
575
+ user_code = str(data.get("user_code") or data.get("usercode") or "").strip()
576
+ try:
577
+ interval = max(1.0, float(data.get("interval") or 5.0))
578
+ except (TypeError, ValueError):
579
+ interval = 5.0
580
+ if not device_auth_id or not user_code:
581
+ raise ProviderAuthError("ChatGPT returned an invalid device-code response.")
582
+ verification_url = f"{_ISSUER}/codex/device"
583
+ write(f"Open {verification_url}")
584
+ write(f"Enter this one-time code: {user_code}")
585
+ deadline = time.monotonic() + _DEVICE_TIMEOUT_SECONDS
586
+ code_data: dict[str, Any] | None = None
587
+ while time.monotonic() < deadline:
588
+ try:
589
+ with self._client(timeout=30.0) as client:
590
+ poll = client.post(
591
+ f"{_ISSUER}/api/accounts/deviceauth/token",
592
+ json={"device_auth_id": device_auth_id, "user_code": user_code},
593
+ )
594
+ except httpx.HTTPError as exc:
595
+ raise ProviderAuthError("ChatGPT device-code sign-in was interrupted.") from exc
596
+ if poll.status_code < 400:
597
+ code_data = _json_object(poll, "ChatGPT returned an invalid device token.")
598
+ break
599
+ if poll.status_code not in {403, 404}:
600
+ raise ProviderAuthError(
601
+ f"ChatGPT device-code sign-in failed with status {poll.status_code}."
602
+ )
603
+ time.sleep(min(interval, max(0.1, deadline - time.monotonic())))
604
+ if code_data is None:
605
+ raise ProviderAuthError("ChatGPT device-code sign-in timed out after 15 minutes.")
606
+ code = str(code_data.get("authorization_code") or "").strip()
607
+ verifier = str(code_data.get("code_verifier") or "").strip()
608
+ if not code or not verifier:
609
+ raise ProviderAuthError("ChatGPT returned an invalid device authorization.")
610
+ return self._exchange_code(
611
+ code=code,
612
+ verifier=verifier,
613
+ redirect_uri=f"{_ISSUER}/deviceauth/callback",
614
+ )
615
+
616
+ def _exchange_code(
617
+ self,
618
+ *,
619
+ code: str,
620
+ verifier: str,
621
+ redirect_uri: str,
622
+ ) -> ProviderTokenRecord:
623
+ try:
624
+ with self._client(timeout=30.0) as client:
625
+ response = client.post(
626
+ f"{_ISSUER}/oauth/token",
627
+ data={
628
+ "grant_type": "authorization_code",
629
+ "code": code,
630
+ "redirect_uri": redirect_uri,
631
+ "client_id": _client_id(),
632
+ "code_verifier": verifier,
633
+ },
634
+ )
635
+ except httpx.HTTPError as exc:
636
+ raise ProviderAuthError("Could not complete ChatGPT token exchange.") from exc
637
+ if response.status_code >= 400:
638
+ raise ProviderAuthError(
639
+ f"ChatGPT token exchange failed with status {response.status_code}."
640
+ )
641
+ data = _json_object(response, "ChatGPT returned an invalid token response.")
642
+ access_token = str(data.get("access_token") or "").strip()
643
+ refresh_token = str(data.get("refresh_token") or "").strip()
644
+ id_token = str(data.get("id_token") or "").strip()
645
+ if not access_token or not refresh_token:
646
+ raise ProviderAuthError("ChatGPT token response was incomplete.")
647
+ claims = _jwt_claims(id_token) if id_token else _jwt_claims(access_token)
648
+ return ProviderTokenRecord(
649
+ access_token=access_token,
650
+ refresh_token=refresh_token,
651
+ expires_at=_token_expiry(data, access_token),
652
+ account_id=_account_id(claims),
653
+ account_label=_account_label(claims),
654
+ )
655
+
656
+ def _client(self, *, timeout: float) -> httpx.Client:
657
+ return httpx.Client(
658
+ timeout=timeout,
659
+ transport=self._transport,
660
+ follow_redirects=False,
661
+ )
662
+
663
+
664
+ def _refresh_lock(provider_id: str) -> threading.Lock:
665
+ with _REFRESH_LOCKS_GUARD:
666
+ return _REFRESH_LOCKS.setdefault(provider_id, threading.Lock())
667
+
668
+
669
+ def _canonical_request_url(url: str) -> str:
670
+ split = urlsplit(str(url or ""))
671
+ if split.scheme != "https" or split.hostname != "chatgpt.com" or split.query or split.fragment:
672
+ return ""
673
+ port = split.port
674
+ if port not in {None, 443}:
675
+ return ""
676
+ return f"https://chatgpt.com{split.path.rstrip('/')}"
677
+
678
+
679
+ def _pkce_verifier() -> str:
680
+ return base64.urlsafe_b64encode(secrets.token_bytes(64)).decode("ascii").rstrip("=")
681
+
682
+
683
+ def _pkce_challenge(verifier: str) -> str:
684
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
685
+ return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
686
+
687
+
688
+ def _bind_callback_server() -> HTTPServer:
689
+ last_error: OSError | None = None
690
+ for port in _CALLBACK_PORTS:
691
+ try:
692
+ return HTTPServer(("127.0.0.1", port), BaseHTTPRequestHandler)
693
+ except OSError as exc:
694
+ last_error = exc
695
+ raise ProviderAuthError("Could not open the local ChatGPT login callback port.") from last_error
696
+
697
+
698
+ def _callback_handler(
699
+ result: dict[str, str],
700
+ expected_state: str,
701
+ ) -> type[BaseHTTPRequestHandler]:
702
+ class CallbackHandler(BaseHTTPRequestHandler):
703
+ def do_GET(self) -> None: # noqa: N802 - stdlib callback name
704
+ split = urlsplit(self.path)
705
+ if split.path != "/auth/callback":
706
+ self._respond(404, "Not found")
707
+ return
708
+ query = parse_qs(split.query, keep_blank_values=True)
709
+ state = str((query.get("state") or [""])[0])
710
+ if not hmac.compare_digest(state, expected_state):
711
+ self._respond(400, "State mismatch. Return to Alysis Code and try again.")
712
+ return
713
+ error = str((query.get("error_description") or query.get("error") or [""])[0])
714
+ if error:
715
+ result.update({"done": "1", "error": error})
716
+ self._respond(400, error)
717
+ return
718
+ code = str((query.get("code") or [""])[0])
719
+ if not code:
720
+ result.update({"done": "1", "error": "Missing authorization code."})
721
+ self._respond(400, "Missing authorization code.")
722
+ return
723
+ result.update({"done": "1", "code": code})
724
+ self._respond(200, "Connected. You can close this tab and return to Alysis Code.")
725
+
726
+ def log_message(self, _format: str, *_args: Any) -> None:
727
+ return
728
+
729
+ def _respond(self, status: int, message: str) -> None:
730
+ safe = html.escape(message, quote=True)
731
+ body = (
732
+ "<!doctype html><meta charset='utf-8'><title>Alysis Code sign-in</title>"
733
+ "<style>body{font-family:system-ui;max-width:42rem;margin:4rem auto;"
734
+ "padding:0 1.5rem;line-height:1.5}</style>"
735
+ f"<h1>Alysis Code</h1><p>{safe}</p>"
736
+ ).encode()
737
+ self.send_response(status)
738
+ self.send_header("Content-Type", "text/html; charset=utf-8")
739
+ self.send_header("Content-Length", str(len(body)))
740
+ self.send_header("Cache-Control", "no-store")
741
+ self.end_headers()
742
+ self.wfile.write(body)
743
+
744
+ return CallbackHandler
745
+
746
+
747
+ def _json_object(response: httpx.Response, message: str) -> dict[str, Any]:
748
+ try:
749
+ data = response.json()
750
+ except ValueError as exc:
751
+ raise ProviderAuthError(message) from exc
752
+ if not isinstance(data, dict):
753
+ raise ProviderAuthError(message)
754
+ return data
755
+
756
+
757
+ def _oauth_error_code(response: httpx.Response) -> str:
758
+ try:
759
+ data = response.json()
760
+ except ValueError:
761
+ return ""
762
+ if not isinstance(data, dict):
763
+ return ""
764
+ error = data.get("error")
765
+ if isinstance(error, dict):
766
+ return str(error.get("code") or error.get("type") or "").strip().lower()
767
+ return str(error or "").strip().lower()
768
+
769
+
770
+ def _jwt_claims(token: str) -> dict[str, Any]:
771
+ parts = str(token or "").split(".")
772
+ if len(parts) < 2 or not parts[1]:
773
+ return {}
774
+ payload = parts[1]
775
+ payload += "=" * (-len(payload) % 4)
776
+ try:
777
+ decoded = base64.urlsafe_b64decode(payload.encode("ascii"))
778
+ data = json.loads(decoded.decode("utf-8"))
779
+ except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
780
+ return {}
781
+ return data if isinstance(data, dict) else {}
782
+
783
+
784
+ def _auth_claims(claims: Mapping[str, Any]) -> Mapping[str, Any]:
785
+ nested = claims.get("https://api.openai.com/auth")
786
+ return nested if isinstance(nested, dict) else {}
787
+
788
+
789
+ def _account_id(claims: Mapping[str, Any]) -> str | None:
790
+ for value in (
791
+ claims.get("chatgpt_account_id"),
792
+ _auth_claims(claims).get("chatgpt_account_id"),
793
+ ):
794
+ normalized = str(value or "").strip()
795
+ if normalized:
796
+ return normalized
797
+ organizations = claims.get("organizations")
798
+ if isinstance(organizations, list) and organizations:
799
+ first = organizations[0]
800
+ if isinstance(first, dict):
801
+ normalized = str(first.get("id") or "").strip()
802
+ if normalized:
803
+ return normalized
804
+ return None
805
+
806
+
807
+ def _account_label(claims: Mapping[str, Any]) -> str | None:
808
+ email = str(claims.get("email") or "").strip()
809
+ profile = claims.get("https://api.openai.com/profile")
810
+ if not email and isinstance(profile, dict):
811
+ email = str(profile.get("email") or "").strip()
812
+ plan = str(_auth_claims(claims).get("chatgpt_plan_type") or "").strip()
813
+ if email and plan:
814
+ return f"{email} ({plan})"
815
+ return email or (f"ChatGPT {plan}" if plan else None)
816
+
817
+
818
+ def _token_expiry(data: Mapping[str, Any], access_token: str) -> float:
819
+ try:
820
+ expires_in = float(data.get("expires_in") or 0)
821
+ except (TypeError, ValueError):
822
+ expires_in = 0
823
+ if expires_in > 0:
824
+ return time.time() + expires_in
825
+ claims = _jwt_claims(access_token)
826
+ try:
827
+ expiration = float(claims.get("exp") or 0)
828
+ except (TypeError, ValueError):
829
+ expiration = 0
830
+ return expiration if expiration > time.time() else time.time() + _DEFAULT_TOKEN_LIFETIME_SECONDS
831
+
832
+
833
+ def _message_text(content: Any) -> str:
834
+ if isinstance(content, str):
835
+ return content.strip()
836
+ if not isinstance(content, list):
837
+ return str(content or "").strip()
838
+ parts: list[str] = []
839
+ for item in content:
840
+ if isinstance(item, str):
841
+ parts.append(item)
842
+ elif isinstance(item, dict):
843
+ value = item.get("text") or item.get("content")
844
+ if isinstance(value, str):
845
+ parts.append(value)
846
+ return "\n".join(part for part in parts if part).strip()
847
+
848
+
849
+ def _positive_int(value: Any) -> int | None:
850
+ try:
851
+ parsed = int(value)
852
+ except (TypeError, ValueError):
853
+ return None
854
+ return parsed if parsed > 0 else None
855
+
856
+
857
+ def _input_modalities(raw: Mapping[str, Any]) -> tuple[str, ...]:
858
+ advertised = raw.get("input_modalities")
859
+ if isinstance(advertised, list):
860
+ values = tuple(
861
+ dict.fromkeys(
862
+ str(item or "").strip().lower() for item in advertised if str(item or "").strip()
863
+ )
864
+ )
865
+ if values:
866
+ return values
867
+ if raw.get("supports_image_input") is True:
868
+ return ("text", "image")
869
+ return ("text",)
870
+
871
+
872
+ def _sanitize_openai_tool_schema(value: Any) -> Any:
873
+ """Lower broad JSON Schema/MCP shapes to Codex-compatible tool schemas."""
874
+
875
+ supported_types = {"string", "number", "boolean", "integer", "object", "array", "null"}
876
+ composition_keys = ("anyOf", "oneOf", "allOf")
877
+ if isinstance(value, bool):
878
+ return {"type": "string"}
879
+ if isinstance(value, list):
880
+ return [_sanitize_openai_tool_schema(item) for item in value]
881
+ if not isinstance(value, dict):
882
+ return value
883
+
884
+ result: dict[str, Any] = {}
885
+ if isinstance(value.get("$ref"), str):
886
+ result["$ref"] = value["$ref"]
887
+ if isinstance(value.get("description"), str):
888
+ result["description"] = value["description"]
889
+ if "const" in value:
890
+ result["enum"] = [value["const"]]
891
+ elif isinstance(value.get("enum"), list):
892
+ result["enum"] = copy.deepcopy(value["enum"])
893
+ properties = value.get("properties")
894
+ if isinstance(properties, dict):
895
+ result["properties"] = {
896
+ str(key): _sanitize_openai_tool_schema(item) for key, item in properties.items()
897
+ }
898
+ required = value.get("required")
899
+ if isinstance(required, list):
900
+ result["required"] = [item for item in required if isinstance(item, str)]
901
+ if "items" in value:
902
+ result["items"] = _sanitize_openai_tool_schema(value["items"])
903
+ if "additionalProperties" in value:
904
+ additional = value["additionalProperties"]
905
+ result["additionalProperties"] = (
906
+ additional if isinstance(additional, bool) else _sanitize_openai_tool_schema(additional)
907
+ )
908
+ for key in composition_keys:
909
+ if isinstance(value.get(key), list):
910
+ result[key] = [_sanitize_openai_tool_schema(item) for item in value[key]]
911
+ for key in ("$defs", "definitions"):
912
+ definitions = value.get(key)
913
+ if isinstance(definitions, dict):
914
+ result[key] = {
915
+ str(name): _sanitize_openai_tool_schema(item) for name, item in definitions.items()
916
+ }
917
+
918
+ raw_type = value.get("type")
919
+ if isinstance(raw_type, str):
920
+ schema_types = [raw_type] if raw_type in supported_types else []
921
+ elif isinstance(raw_type, list):
922
+ schema_types = [
923
+ item for item in raw_type if isinstance(item, str) and item in supported_types
924
+ ]
925
+ else:
926
+ schema_types = []
927
+ if not schema_types and ("$ref" in result or any(key in result for key in composition_keys)):
928
+ return result
929
+ if not schema_types:
930
+ if any(key in value for key in ("properties", "required", "additionalProperties")):
931
+ schema_types = ["object"]
932
+ elif any(key in value for key in ("items", "prefixItems")):
933
+ schema_types = ["array"]
934
+ elif "enum" in result or "format" in value:
935
+ schema_types = ["string"]
936
+ elif any(
937
+ key in value
938
+ for key in ("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf")
939
+ ):
940
+ schema_types = ["number"]
941
+ if not schema_types:
942
+ return {}
943
+ result["type"] = schema_types[0] if len(schema_types) == 1 else schema_types
944
+ if "object" in schema_types and "properties" not in result:
945
+ result["properties"] = {}
946
+ if "array" in schema_types and "items" not in result:
947
+ result["items"] = {"type": "string"}
948
+ return result
949
+
950
+
951
+ __all__ = ["SESSION_EXPIRED_DETAIL", "OpenAICodexSubscriptionAuth"]