patchi 0.7.5__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 (496) hide show
  1. patchi/__init__.py +1 -0
  2. patchi/__main__.py +13 -0
  3. patchi/cli/__init__.py +0 -0
  4. patchi/cli/command_families.py +235 -0
  5. patchi/cli/commands/__init__.py +0 -0
  6. patchi/cli/commands/access_cmd.py +65 -0
  7. patchi/cli/commands/agent_stats_cmd.py +273 -0
  8. patchi/cli/commands/agents_cmd.py +187 -0
  9. patchi/cli/commands/ai_cmd.py +308 -0
  10. patchi/cli/commands/assure_cmd.py +538 -0
  11. patchi/cli/commands/audit_cmd.py +286 -0
  12. patchi/cli/commands/auto_cmd.py +110 -0
  13. patchi/cli/commands/blame_cmd.py +53 -0
  14. patchi/cli/commands/chains_cmd.py +327 -0
  15. patchi/cli/commands/charter_cmd.py +173 -0
  16. patchi/cli/commands/chat_cmd.py +597 -0
  17. patchi/cli/commands/check_cmd.py +232 -0
  18. patchi/cli/commands/cleanup_cmd.py +328 -0
  19. patchi/cli/commands/cockpit_cmd.py +208 -0
  20. patchi/cli/commands/commands_cmd.py +160 -0
  21. patchi/cli/commands/cross_repo_cmd.py +113 -0
  22. patchi/cli/commands/deps_cmd.py +110 -0
  23. patchi/cli/commands/dev_check_cmd.py +342 -0
  24. patchi/cli/commands/dev_cmd.py +445 -0
  25. patchi/cli/commands/doctor_cmd.py +457 -0
  26. patchi/cli/commands/findings_cmd.py +187 -0
  27. patchi/cli/commands/fix_cmd.py +648 -0
  28. patchi/cli/commands/fix_review_cmd.py +273 -0
  29. patchi/cli/commands/heatmap_cmd.py +62 -0
  30. patchi/cli/commands/help_cmd.py +159 -0
  31. patchi/cli/commands/hosted_cmd.py +980 -0
  32. patchi/cli/commands/init.py +456 -0
  33. patchi/cli/commands/key_cmd.py +571 -0
  34. patchi/cli/commands/learn_cmd.py +140 -0
  35. patchi/cli/commands/link_cmd.py +104 -0
  36. patchi/cli/commands/log_cmd.py +57 -0
  37. patchi/cli/commands/memory_cmd.py +215 -0
  38. patchi/cli/commands/model_cmd.py +207 -0
  39. patchi/cli/commands/notify_cmd.py +213 -0
  40. patchi/cli/commands/notify_prompts.py +29 -0
  41. patchi/cli/commands/patch_cmd.py +231 -0
  42. patchi/cli/commands/plan_cmd.py +128 -0
  43. patchi/cli/commands/plugins_cmd.py +307 -0
  44. patchi/cli/commands/queue_cmd.py +152 -0
  45. patchi/cli/commands/quick_cmd.py +159 -0
  46. patchi/cli/commands/ready_cmd.py +292 -0
  47. patchi/cli/commands/report_cmd.py +416 -0
  48. patchi/cli/commands/restrict_cmd.py +127 -0
  49. patchi/cli/commands/review_cmd.py +225 -0
  50. patchi/cli/commands/rules_cmd.py +233 -0
  51. patchi/cli/commands/scan_cmd.py +1908 -0
  52. patchi/cli/commands/settings_cmd.py +45 -0
  53. patchi/cli/commands/status_cmd.py +679 -0
  54. patchi/cli/commands/test_cmd.py +1052 -0
  55. patchi/cli/commands/trend_cmd.py +296 -0
  56. patchi/cli/commands/undo_cmd.py +235 -0
  57. patchi/cli/commands/update_cmd.py +345 -0
  58. patchi/cli/commands/verify_cmd.py +56 -0
  59. patchi/cli/commands/vr_cmd.py +440 -0
  60. patchi/cli/commands/watch_cmd.py +304 -0
  61. patchi/cli/commands/web_cmd.py +114 -0
  62. patchi/cli/console.py +101 -0
  63. patchi/cli/display/__init__.py +1 -0
  64. patchi/cli/display/live_audit.py +269 -0
  65. patchi/cli/display/live_progress.py +169 -0
  66. patchi/cli/framework.py +241 -0
  67. patchi/cli/logo.py +128 -0
  68. patchi/cli/main.py +222 -0
  69. patchi/cli/registry.py +1171 -0
  70. patchi/cli/style.py +68 -0
  71. patchi/cli/themes.py +187 -0
  72. patchi/cli/ux.py +444 -0
  73. patchi/core/__init__.py +0 -0
  74. patchi/core/agents/__init__.py +0 -0
  75. patchi/core/agents/api_fuzzer_agent.py +141 -0
  76. patchi/core/agents/attack_agent.py +242 -0
  77. patchi/core/agents/base.py +1012 -0
  78. patchi/core/agents/bug_predictor_agent.py +58 -0
  79. patchi/core/agents/build_tool_validator.py +150 -0
  80. patchi/core/agents/cache.py +192 -0
  81. patchi/core/agents/chaos_agent.py +133 -0
  82. patchi/core/agents/cicd_generator.py +240 -0
  83. patchi/core/agents/comment_scanner.py +280 -0
  84. patchi/core/agents/console_logging_agent.py +156 -0
  85. patchi/core/agents/coordinator.py +858 -0
  86. patchi/core/agents/core_scanner.py +269 -0
  87. patchi/core/agents/coverage_prioritizer.py +181 -0
  88. patchi/core/agents/dead_code_hygiene.py +296 -0
  89. patchi/core/agents/dead_code_scanner.py +692 -0
  90. patchi/core/agents/dependency_scanner.py +725 -0
  91. patchi/core/agents/doc_claim_agent.py +573 -0
  92. patchi/core/agents/duplicate_scanner.py +568 -0
  93. patchi/core/agents/env_scanner.py +410 -0
  94. patchi/core/agents/feature_flag_agent.py +89 -0
  95. patchi/core/agents/frontend_framework_agent.py +133 -0
  96. patchi/core/agents/goroutine_agent.py +82 -0
  97. patchi/core/agents/governor.py +1972 -0
  98. patchi/core/agents/i18n_agent.py +95 -0
  99. patchi/core/agents/license_compliance.py +247 -0
  100. patchi/core/agents/linking_agent.py +251 -0
  101. patchi/core/agents/memory_profiler_agent.py +102 -0
  102. patchi/core/agents/modernization_agent.py +84 -0
  103. patchi/core/agents/mutation_agent.py +174 -0
  104. patchi/core/agents/nl_query_agent.py +74 -0
  105. patchi/core/agents/promise_rejection_agent.py +100 -0
  106. patchi/core/agents/refactoring_agent.py +286 -0
  107. patchi/core/agents/resource_leak_agent.py +91 -0
  108. patchi/core/agents/route_graph_scanner.py +951 -0
  109. patchi/core/agents/run_agent.py +106 -0
  110. patchi/core/agents/rust_unwrap_agent.py +100 -0
  111. patchi/core/agents/sbom_generator.py +284 -0
  112. patchi/core/agents/scanners.py +67 -0
  113. patchi/core/agents/side/__init__.py +1 -0
  114. patchi/core/agents/side/build_agent.py +65 -0
  115. patchi/core/agents/side/format_agent.py +50 -0
  116. patchi/core/agents/side/install_agent.py +63 -0
  117. patchi/core/agents/side_file_scanner.py +549 -0
  118. patchi/core/agents/snapshot_drift_detector.py +146 -0
  119. patchi/core/agents/spa_route_inventory.py +203 -0
  120. patchi/core/agents/test_scanner.py +442 -0
  121. patchi/core/agents/tool_health.py +109 -0
  122. patchi/core/agents/tool_runner.py +162 -0
  123. patchi/core/agents/type_scanner.py +168 -0
  124. patchi/core/agents/ui_scanner.py +558 -0
  125. patchi/core/ai/__init__.py +20 -0
  126. patchi/core/ai/agent_audit.py +153 -0
  127. patchi/core/ai/agent_profiler.py +277 -0
  128. patchi/core/ai/client.py +499 -0
  129. patchi/core/ai/cost_tracker.py +107 -0
  130. patchi/core/ai/model_router.py +318 -0
  131. patchi/core/ai/orchestrator.py +625 -0
  132. patchi/core/ai/prompts.py +681 -0
  133. patchi/core/ai/smart.py +302 -0
  134. patchi/core/ai/tool_executor.py +24 -0
  135. patchi/core/ai/tools/__init__.py +10 -0
  136. patchi/core/ai/tools/executor.py +621 -0
  137. patchi/core/ai/tools/realize.py +1160 -0
  138. patchi/core/ai/tools/registry.py +1577 -0
  139. patchi/core/assurance/__init__.py +33 -0
  140. patchi/core/assurance/builder.py +211 -0
  141. patchi/core/assurance/graph.py +342 -0
  142. patchi/core/assurance/invariants.py +168 -0
  143. patchi/core/atomic.py +61 -0
  144. patchi/core/attackers/__init__.py +17 -0
  145. patchi/core/attackers/api_abuse.py +89 -0
  146. patchi/core/attackers/auth_bypass.py +73 -0
  147. patchi/core/attackers/base.py +91 -0
  148. patchi/core/attackers/business_logic.py +88 -0
  149. patchi/core/attackers/planner.py +108 -0
  150. patchi/core/attackers/priv_escalation.py +88 -0
  151. patchi/core/attackers/recon.py +87 -0
  152. patchi/core/brain/__init__.py +0 -0
  153. patchi/core/brain/ast_utils/__init__.py +81 -0
  154. patchi/core/brain/ast_utils/assignments.py +219 -0
  155. patchi/core/brain/ast_utils/calls.py +139 -0
  156. patchi/core/brain/ast_utils/config.py +132 -0
  157. patchi/core/brain/ast_utils/control_flow.py +74 -0
  158. patchi/core/brain/ast_utils/dead_symbols.py +205 -0
  159. patchi/core/brain/ast_utils/decorators.py +108 -0
  160. patchi/core/brain/ast_utils/helpers.py +144 -0
  161. patchi/core/brain/ast_utils/imports.py +169 -0
  162. patchi/core/brain/ast_utils/scan.py +143 -0
  163. patchi/core/brain/ast_utils/taint.py +310 -0
  164. patchi/core/brain/audit.py +379 -0
  165. patchi/core/brain/baseline.py +195 -0
  166. patchi/core/brain/blast_radius.py +312 -0
  167. patchi/core/brain/body_tags.py +144 -0
  168. patchi/core/brain/brain.py +1161 -0
  169. patchi/core/brain/brain_context.py +393 -0
  170. patchi/core/brain/brain_watcher.py +340 -0
  171. patchi/core/brain/charter.py +473 -0
  172. patchi/core/brain/classifier.py +214 -0
  173. patchi/core/brain/cockpit.py +350 -0
  174. patchi/core/brain/code_query.py +710 -0
  175. patchi/core/brain/contract.py +889 -0
  176. patchi/core/brain/contract_diff.py +155 -0
  177. patchi/core/brain/council.py +800 -0
  178. patchi/core/brain/cross_repo.py +104 -0
  179. patchi/core/brain/dead_code_tools.py +157 -0
  180. patchi/core/brain/doc_validator.py +197 -0
  181. patchi/core/brain/domain_activator.py +2183 -0
  182. patchi/core/brain/enriched_context.py +220 -0
  183. patchi/core/brain/file_corpus.py +220 -0
  184. patchi/core/brain/framework.py +953 -0
  185. patchi/core/brain/freshness.py +273 -0
  186. patchi/core/brain/git_aware.py +206 -0
  187. patchi/core/brain/heatmap.py +104 -0
  188. patchi/core/brain/ignore_parser.py +153 -0
  189. patchi/core/brain/import_graph.py +486 -0
  190. patchi/core/brain/languages.py +397 -0
  191. patchi/core/brain/layered_brain.py +419 -0
  192. patchi/core/brain/learning.py +229 -0
  193. patchi/core/brain/orphaned_endpoints.py +313 -0
  194. patchi/core/brain/personas/__init__.py +648 -0
  195. patchi/core/brain/personas/bad_user.py +165 -0
  196. patchi/core/brain/personas/base.py +349 -0
  197. patchi/core/brain/proactive.py +807 -0
  198. patchi/core/brain/project_context.py +121 -0
  199. patchi/core/brain/project_reader.py +521 -0
  200. patchi/core/brain/reasoning.py +269 -0
  201. patchi/core/brain/route_detector/__init__.py +15 -0
  202. patchi/core/brain/route_detector/base.py +31 -0
  203. patchi/core/brain/route_detector/csharp.py +209 -0
  204. patchi/core/brain/route_detector/file_based.py +81 -0
  205. patchi/core/brain/route_detector/go.py +152 -0
  206. patchi/core/brain/route_detector/java.py +195 -0
  207. patchi/core/brain/route_detector/javascript.py +197 -0
  208. patchi/core/brain/route_detector/php.py +158 -0
  209. patchi/core/brain/route_detector/python.py +193 -0
  210. patchi/core/brain/route_detector/registry.py +25 -0
  211. patchi/core/brain/route_detector/ruby.py +134 -0
  212. patchi/core/brain/route_detector/rust.py +216 -0
  213. patchi/core/brain/route_detector/swift.py +118 -0
  214. patchi/core/brain/route_mapper.py +265 -0
  215. patchi/core/brain/scanner.py +2136 -0
  216. patchi/core/brain/secrets.py +280 -0
  217. patchi/core/brain/symbol_graph.py +1955 -0
  218. patchi/core/brain/team.py +28 -0
  219. patchi/core/brain/trace_log.py +132 -0
  220. patchi/core/brain/type_checker/__init__.py +65 -0
  221. patchi/core/brain/type_checker/base.py +60 -0
  222. patchi/core/brain/type_checker/csharp.py +59 -0
  223. patchi/core/brain/type_checker/dart.py +59 -0
  224. patchi/core/brain/type_checker/go.py +81 -0
  225. patchi/core/brain/type_checker/java.py +75 -0
  226. patchi/core/brain/type_checker/kotlin.py +75 -0
  227. patchi/core/brain/type_checker/php.py +88 -0
  228. patchi/core/brain/type_checker/python.py +87 -0
  229. patchi/core/brain/type_checker/rust.py +81 -0
  230. patchi/core/brain/type_checker/swift.py +59 -0
  231. patchi/core/brain/type_checker/typescript.py +298 -0
  232. patchi/core/brain/understander.py +130 -0
  233. patchi/core/brain/verify.py +379 -0
  234. patchi/core/campaigns/__init__.py +14 -0
  235. patchi/core/campaigns/base.py +62 -0
  236. patchi/core/campaigns/data_flow_abuse.py +153 -0
  237. patchi/core/campaigns/orchestrator.py +140 -0
  238. patchi/core/campaigns/state_transitions.py +178 -0
  239. patchi/core/ci_bundle.py +104 -0
  240. patchi/core/config.py +311 -0
  241. patchi/core/constants.py +245 -0
  242. patchi/core/debug/__init__.py +25 -0
  243. patchi/core/debug/adapters/__init__.py +11 -0
  244. patchi/core/debug/adapters/codelldb.py +379 -0
  245. patchi/core/debug/adapters/node.py +222 -0
  246. patchi/core/debug/adapters/powershell.py +210 -0
  247. patchi/core/debug/adapters/python.py +244 -0
  248. patchi/core/debug/capture.py +210 -0
  249. patchi/core/debug/dap_client.py +268 -0
  250. patchi/core/detector/__init__.py +0 -0
  251. patchi/core/detector/audit.py +194 -0
  252. patchi/core/detector/bus.py +226 -0
  253. patchi/core/detector/dispatcher.py +275 -0
  254. patchi/core/detector/event.py +254 -0
  255. patchi/core/detector/sigma_engine.py +378 -0
  256. patchi/core/detector/triage.py +289 -0
  257. patchi/core/export/sarif.py +113 -0
  258. patchi/core/fix/__init__.py +17 -0
  259. patchi/core/fix/applier.py +574 -0
  260. patchi/core/fix/base.py +510 -0
  261. patchi/core/fix/code_fixer.py +169 -0
  262. patchi/core/fix/dead_code_remover.py +131 -0
  263. patchi/core/fix/fix_agents.py +775 -0
  264. patchi/core/fix/patch.py +444 -0
  265. patchi/core/fix/risk_gate.py +383 -0
  266. patchi/core/fix/security_fixer.py +93 -0
  267. patchi/core/fix/verify_loop.py +204 -0
  268. patchi/core/fuzz/__init__.py +8 -0
  269. patchi/core/fuzz/corpus.py +137 -0
  270. patchi/core/fuzz/input_fuzzer.py +256 -0
  271. patchi/core/fuzz/sequence_fuzzer.py +162 -0
  272. patchi/core/fuzz/state_fuzzer.py +236 -0
  273. patchi/core/health.py +407 -0
  274. patchi/core/hosted/__init__.py +1 -0
  275. patchi/core/hosted/anomaly.py +298 -0
  276. patchi/core/hosted/audit_log.py +102 -0
  277. patchi/core/hosted/compliance_report.py +148 -0
  278. patchi/core/hosted/ip_reputation.py +169 -0
  279. patchi/core/hosted/log_parsers.py +251 -0
  280. patchi/core/hosted/tokens.py +127 -0
  281. patchi/core/hosted/watchlist.py +164 -0
  282. patchi/core/hosted/webhooks.py +156 -0
  283. patchi/core/memory.py +437 -0
  284. patchi/core/notifications/__init__.py +18 -0
  285. patchi/core/notifications/channels.py +192 -0
  286. patchi/core/notifications/digest.py +141 -0
  287. patchi/core/notifications/escalation.py +158 -0
  288. patchi/core/notifications/notifier.py +182 -0
  289. patchi/core/notifications/quiet_hours.py +69 -0
  290. patchi/core/plugins/__init__.py +48 -0
  291. patchi/core/plugins/analyzer.py +326 -0
  292. patchi/core/plugins/analyzers/__init__.py +6 -0
  293. patchi/core/plugins/analyzers/dead_code_analyzer.py +120 -0
  294. patchi/core/plugins/analyzers/secret_scanner.py +158 -0
  295. patchi/core/plugins/analyzers/todo_scanner.py +115 -0
  296. patchi/core/plugins/registry.py +335 -0
  297. patchi/core/queue.py +375 -0
  298. patchi/core/reliability/__init__.py +13 -0
  299. patchi/core/reliability/chaos.py +163 -0
  300. patchi/core/reliability/fault_injector.py +172 -0
  301. patchi/core/reliability/idempotency.py +128 -0
  302. patchi/core/reliability/recovery.py +147 -0
  303. patchi/core/runtime/__init__.py +5 -0
  304. patchi/core/runtime/tracer.py +230 -0
  305. patchi/core/scan_bus.py +156 -0
  306. patchi/core/security/__init__.py +1 -0
  307. patchi/core/security/ai_validator.py +462 -0
  308. patchi/core/security/app_mapper.py +216 -0
  309. patchi/core/security/app_profile.py +464 -0
  310. patchi/core/security/attack_feedback.py +141 -0
  311. patchi/core/security/attack_scenarios/__init__.py +1 -0
  312. patchi/core/security/attack_tree.py +176 -0
  313. patchi/core/security/auth_audit_agent.py +344 -0
  314. patchi/core/security/authz_agent.py +602 -0
  315. patchi/core/security/auto_fix_proactive.py +620 -0
  316. patchi/core/security/auto_fixer.py +660 -0
  317. patchi/core/security/auto_ticket.py +53 -0
  318. patchi/core/security/bandit_agent.py +152 -0
  319. patchi/core/security/blast_radius.py +226 -0
  320. patchi/core/security/browser_tester.py +333 -0
  321. patchi/core/security/business_logic_agent.py +263 -0
  322. patchi/core/security/catch_block_auditor.py +395 -0
  323. patchi/core/security/cdn_cache_agent.py +357 -0
  324. patchi/core/security/chain_analyzer.py +321 -0
  325. patchi/core/security/chain_to_assurance.py +168 -0
  326. patchi/core/security/charter.py +510 -0
  327. patchi/core/security/cloud_waf_detector.py +295 -0
  328. patchi/core/security/codeql_agent.py +233 -0
  329. patchi/core/security/compliance_agent.py +348 -0
  330. patchi/core/security/confidence_gate.py +423 -0
  331. patchi/core/security/container_scanner.py +282 -0
  332. patchi/core/security/crypto_agent.py +378 -0
  333. patchi/core/security/cve_monitor.py +262 -0
  334. patchi/core/security/dast_agent.py +865 -0
  335. patchi/core/security/defectdojo.py +238 -0
  336. patchi/core/security/defenders/__init__.py +75 -0
  337. patchi/core/security/defenders/auth_middleware.py +115 -0
  338. patchi/core/security/defenders/base.py +97 -0
  339. patchi/core/security/defenders/block_ip.py +71 -0
  340. patchi/core/security/defenders/block_ws_origin.py +36 -0
  341. patchi/core/security/defenders/crypto_fix.py +118 -0
  342. patchi/core/security/defenders/enforce_rate_limit.py +108 -0
  343. patchi/core/security/defenders/fix_code.py +93 -0
  344. patchi/core/security/defenders/invalidate_session.py +40 -0
  345. patchi/core/security/defenders/patch_config.py +70 -0
  346. patchi/core/security/defenders/rotate_secret.py +54 -0
  347. patchi/core/security/defenders/suspend_account.py +41 -0
  348. patchi/core/security/defenders/update_dependency.py +160 -0
  349. patchi/core/security/defense_layer.py +217 -0
  350. patchi/core/security/dependency_vulnerability_agent.py +616 -0
  351. patchi/core/security/detection_pipeline.py +289 -0
  352. patchi/core/security/dns_security_agent.py +497 -0
  353. patchi/core/security/domain_activator_v2.py +913 -0
  354. patchi/core/security/domain_loader.py +1004 -0
  355. patchi/core/security/domains/__init__.py +0 -0
  356. patchi/core/security/email_authentication_agent.py +417 -0
  357. patchi/core/security/env_var_validator.py +304 -0
  358. patchi/core/security/evidence.py +100 -0
  359. patchi/core/security/falco_runtime_agent.py +387 -0
  360. patchi/core/security/fix-playbooks/__init__.py +0 -0
  361. patchi/core/security/gated_finding.py +85 -0
  362. patchi/core/security/git_diff_activator.py +367 -0
  363. patchi/core/security/governance.py +208 -0
  364. patchi/core/security/gradual_gate.py +34 -0
  365. patchi/core/security/history.py +315 -0
  366. patchi/core/security/iac_scanner.py +250 -0
  367. patchi/core/security/ignore_expiry.py +111 -0
  368. patchi/core/security/ignore_learner.py +416 -0
  369. patchi/core/security/injection_agent.py +319 -0
  370. patchi/core/security/insecure_randomness_agent.py +288 -0
  371. patchi/core/security/intent_analyzer.py +326 -0
  372. patchi/core/security/jwt_agent.py +453 -0
  373. patchi/core/security/kubernetes_agent.py +638 -0
  374. patchi/core/security/layer2_orchestrator.py +341 -0
  375. patchi/core/security/llm_security_agent.py +252 -0
  376. patchi/core/security/misconfig_agent.py +600 -0
  377. patchi/core/security/mobile_security_agent.py +474 -0
  378. patchi/core/security/network_agent.py +376 -0
  379. patchi/core/security/noise_filter.py +274 -0
  380. patchi/core/security/orchestrator.py +313 -0
  381. patchi/core/security/pattern_context.py +125 -0
  382. patchi/core/security/pattern_loader.py +101 -0
  383. patchi/core/security/pentest/__init__.py +4 -0
  384. patchi/core/security/pentest/base.py +58 -0
  385. patchi/core/security/pentest/dalfox_adapter.py +40 -0
  386. patchi/core/security/pentest/ffuf_adapter.py +48 -0
  387. patchi/core/security/pentest/nuclei_adapter.py +50 -0
  388. patchi/core/security/pentest/registry.py +80 -0
  389. patchi/core/security/pentest/shannon_adapter.py +107 -0
  390. patchi/core/security/pentest/sqlmap_adapter.py +56 -0
  391. patchi/core/security/pentest/zap_adapter.py +52 -0
  392. patchi/core/security/plan_auditor.py +276 -0
  393. patchi/core/security/policy_engine.py +272 -0
  394. patchi/core/security/prechecks.py +172 -0
  395. patchi/core/security/precommit_hook.py +247 -0
  396. patchi/core/security/privacy_agent.py +361 -0
  397. patchi/core/security/push_notification_agent.py +417 -0
  398. patchi/core/security/pysa_agent.py +143 -0
  399. patchi/core/security/reasoning.py +442 -0
  400. patchi/core/security/red_team_agent.py +184 -0
  401. patchi/core/security/red_team_engine.py +1142 -0
  402. patchi/core/security/remediation.py +360 -0
  403. patchi/core/security/request_interceptor.py +294 -0
  404. patchi/core/security/runtime_validator.py +245 -0
  405. patchi/core/security/saml_sso_agent.py +401 -0
  406. patchi/core/security/sast_agent.py +213 -0
  407. patchi/core/security/sast_gate.py +95 -0
  408. patchi/core/security/scheduler.py +287 -0
  409. patchi/core/security/secrets_runtime_agent.py +476 -0
  410. patchi/core/security/security_agents.py +175 -0
  411. patchi/core/security/security_config.py +551 -0
  412. patchi/core/security/security_probe.py +762 -0
  413. patchi/core/security/security_taint.py +1005 -0
  414. patchi/core/security/sensitive_data_agent.py +271 -0
  415. patchi/core/security/service_mesh_agent.py +412 -0
  416. patchi/core/security/session_management_agent.py +245 -0
  417. patchi/core/security/ssrf_agent.py +422 -0
  418. patchi/core/security/supply_chain.py +449 -0
  419. patchi/core/security/threat_model_generator.py +378 -0
  420. patchi/core/security/threat_model_updater.py +196 -0
  421. patchi/core/security/tool_adapters.py +135 -0
  422. patchi/core/security/tool_verify.py +173 -0
  423. patchi/core/security/websocket_security_agent.py +188 -0
  424. patchi/core/snapshot.py +242 -0
  425. patchi/core/tenant.py +386 -0
  426. patchi/core/testing/__init__.py +17 -0
  427. patchi/core/testing/_browser.py +147 -0
  428. patchi/core/testing/ads_agent.py +141 -0
  429. patchi/core/testing/api_contract_agent.py +450 -0
  430. patchi/core/testing/app_discovery_agent.py +641 -0
  431. patchi/core/testing/app_launcher.py +184 -0
  432. patchi/core/testing/browser_test_agent.py +516 -0
  433. patchi/core/testing/e2e_flow_agent.py +354 -0
  434. patchi/core/testing/flake_detector_agent.py +292 -0
  435. patchi/core/testing/gate.py +31 -0
  436. patchi/core/testing/live_test_runner.py +304 -0
  437. patchi/core/testing/live_v2/__init__.py +1 -0
  438. patchi/core/testing/live_v2/browser_pool.py +349 -0
  439. patchi/core/testing/live_v2/browser_test_runner.py +313 -0
  440. patchi/core/testing/live_v2/runner.py +626 -0
  441. patchi/core/testing/live_v2/screenshot_manager.py +474 -0
  442. patchi/core/testing/live_v2/stress_orchestrator.py +523 -0
  443. patchi/core/testing/live_v2/video_recorder.py +303 -0
  444. patchi/core/testing/regression_agent.py +302 -0
  445. patchi/core/testing/security_test_agent.py +253 -0
  446. patchi/core/testing/stress_test_agent.py +203 -0
  447. patchi/core/testing/test_agents.py +50 -0
  448. patchi/core/testing/test_config.py +194 -0
  449. patchi/core/testing/ui_accessibility_agent.py +324 -0
  450. patchi/core/testing/ui_button_agent.py +294 -0
  451. patchi/core/testing/ui_layout_agent.py +330 -0
  452. patchi/core/testing/unit_test_agent.py +1149 -0
  453. patchi/core/testing/visual_regression_agent.py +260 -0
  454. patchi/web/__init__.py +1 -0
  455. patchi/web/api/__init__.py +1 -0
  456. patchi/web/api/brain_map.py +114 -0
  457. patchi/web/api/charts.py +47 -0
  458. patchi/web/api/chat.py +224 -0
  459. patchi/web/api/cicd.py +576 -0
  460. patchi/web/api/dast_evidence.py +49 -0
  461. patchi/web/api/dev_check.py +320 -0
  462. patchi/web/api/fix.py +134 -0
  463. patchi/web/api/guard.py +65 -0
  464. patchi/web/api/hosted.py +128 -0
  465. patchi/web/api/hosted_v2.py +234 -0
  466. patchi/web/api/live_testing.py +699 -0
  467. patchi/web/api/scan.py +582 -0
  468. patchi/web/api/smart.py +111 -0
  469. patchi/web/api/tenant.py +117 -0
  470. patchi/web/api/tools.py +85 -0
  471. patchi/web/api_legacy.py +2401 -0
  472. patchi/web/app.py +310 -0
  473. patchi/web/events.py +483 -0
  474. patchi/web/routes/__init__.py +1 -0
  475. patchi/web/routes/assurance.py +541 -0
  476. patchi/web/routes/brain.py +36 -0
  477. patchi/web/routes/charter.py +227 -0
  478. patchi/web/routes/chat.py +23 -0
  479. patchi/web/routes/dashboard.py +558 -0
  480. patchi/web/routes/dashboard_v2.py +402 -0
  481. patchi/web/routes/findings.py +330 -0
  482. patchi/web/routes/guard.py +35 -0
  483. patchi/web/routes/history.py +126 -0
  484. patchi/web/routes/landing.py +52 -0
  485. patchi/web/routes/live_testing.py +24 -0
  486. patchi/web/routes/review.py +57 -0
  487. patchi/web/routes/self_improvement.py +110 -0
  488. patchi/web/routes/settings.py +49 -0
  489. patchi/web/routes/tokens.py +29 -0
  490. patchi/web/spawn.py +139 -0
  491. patchi/web/ws.py +141 -0
  492. patchi-0.7.5.dist-info/METADATA +315 -0
  493. patchi-0.7.5.dist-info/RECORD +496 -0
  494. patchi-0.7.5.dist-info/WHEEL +5 -0
  495. patchi-0.7.5.dist-info/licenses/LICENSE +60 -0
  496. patchi-0.7.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,499 @@
1
+ """
2
+ Unified AI client for Patchi agents.
3
+
4
+ Supports:
5
+ - System message + user message (two-role prompting)
6
+ - Local Ollama, OpenAI-compatible APIs, Anthropic
7
+ - Structured JSON output (with fallback parsing)
8
+ - Automatic key rotation across configured providers
9
+ - Offline fallback (returns None, never crashes)
10
+ - Retry with exponential backoff for transient failures
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import random
17
+
18
+ # ── Public API ─────────────────────────────────────────────────────────────────
19
+ import logging
20
+ import os
21
+ import re
22
+ import time
23
+ import urllib.error
24
+ import urllib.request
25
+ from pathlib import Path
26
+ from typing import Callable, TypeVar
27
+
28
+ from loguru import logger
29
+
30
+ from patchi.core.ai.cost_tracker import estimate_tokens, track
31
+ from patchi.core.constants import (
32
+ ANTHROPIC_API_VERSION,
33
+ DEFAULT_MAX_TOKENS,
34
+ DEFAULT_TEMPERATURE,
35
+ HTTP_REQUEST_TIMEOUT,
36
+ OLLAMA_GENERATE_URL,
37
+ )
38
+
39
+ _log = logging.getLogger("patchi.core.client")
40
+
41
+ # ── Retry configuration ─────────────────────────────────────────────────────────
42
+ DEFAULT_MAX_RETRIES = 3
43
+ DEFAULT_BASE_DELAY = 1.0 # seconds
44
+ DEFAULT_MAX_DELAY = 30.0 # seconds
45
+ DEFAULT_BACKOFF_MULTIPLIER = 2.0
46
+ DEFAULT_JITTER = 0.1 # 10% jitter
47
+
48
+ T = TypeVar("T")
49
+
50
+
51
+ def _is_retryable_error(e: Exception) -> bool:
52
+ """Determine if an error is retryable (transient)."""
53
+ if isinstance(e, urllib.error.HTTPError):
54
+ # Retry on 429 (rate limit), 5xx (server errors)
55
+ return e.code in (429, 500, 502, 503, 504)
56
+ if isinstance(e, urllib.error.URLError):
57
+ # Retry on connection errors, timeouts
58
+ return True
59
+ if isinstance(e, TimeoutError):
60
+ return True
61
+ if isinstance(e, ConnectionError):
62
+ return True
63
+ return False
64
+
65
+
66
+ def _retry_with_backoff(
67
+ func: Callable[[], T],
68
+ max_retries: int = DEFAULT_MAX_RETRIES,
69
+ base_delay: float = DEFAULT_BASE_DELAY,
70
+ max_delay: float = DEFAULT_MAX_DELAY,
71
+ backoff_multiplier: float = DEFAULT_BACKOFF_MULTIPLIER,
72
+ jitter: float = DEFAULT_JITTER,
73
+ timeout_remaining: Callable[[], float | None] | None = None,
74
+ progress_callback: Callable[[str], None] | None = None,
75
+ ) -> T | None:
76
+ """
77
+ Execute a function with exponential backoff retry logic.
78
+
79
+ Args:
80
+ func: Function to execute (should return result or raise exception)
81
+ max_retries: Maximum number of retry attempts
82
+ base_delay: Initial delay between retries (seconds)
83
+ max_delay: Maximum delay between retries (seconds)
84
+ backoff_multiplier: Multiplier for exponential backoff
85
+ jitter: Random jitter factor (0.0-1.0) to prevent thundering herd
86
+ timeout_remaining: Optional callable returning remaining time budget
87
+ progress_callback: Optional callback for progress messages
88
+
89
+ Returns:
90
+ Result of func() on success, None on all retries exhausted
91
+ """
92
+ def _progress(msg: str) -> None:
93
+ if progress_callback:
94
+ try:
95
+ progress_callback(msg)
96
+ except Exception:
97
+ pass
98
+
99
+ delay = base_delay
100
+
101
+ for attempt in range(max_retries + 1):
102
+ # Check timeout budget
103
+ if timeout_remaining is not None:
104
+ remaining = timeout_remaining()
105
+ if remaining is not None and remaining <= 0:
106
+ logger.debug("Retry budget exhausted")
107
+ return None
108
+
109
+ try:
110
+ return func()
111
+ except Exception as e:
112
+ if attempt < max_retries and _is_retryable_error(e):
113
+ # Calculate delay with jitter
114
+ jitter_amount = delay * jitter * (2 * random.random() - 1)
115
+ actual_delay = min(delay + jitter_amount, max_delay)
116
+
117
+ _progress(f"Retrying in {actual_delay:.1f}s... (attempt {attempt + 2}/{max_retries + 1})")
118
+ logger.debug(
119
+ f"Retryable error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
120
+ f"Retrying in {actual_delay:.1f}s..."
121
+ )
122
+
123
+ time.sleep(actual_delay)
124
+ delay *= backoff_multiplier
125
+ else:
126
+ # Non-retryable error or max retries reached
127
+ logger.debug(f"Non-retryable error or max retries reached: {e}")
128
+ break
129
+
130
+ return None
131
+
132
+
133
+ def _ensure_env_loaded(root: str | None = None) -> None:
134
+ """Load .patchi/.env into os.environ if not already loaded."""
135
+ _marker = "_PATCHI_ENV_LOADED"
136
+ if os.environ.get(_marker):
137
+ return
138
+ try:
139
+ if root is None:
140
+ from patchi.core.config import find_project_root
141
+
142
+ r = find_project_root()
143
+ else:
144
+ from pathlib import Path
145
+
146
+ r = Path(root)
147
+ if r is None:
148
+ return
149
+ env_file = r / ".patchi" / ".env"
150
+ if env_file.exists():
151
+ raw = env_file.read_text(encoding="utf-8")
152
+ try:
153
+ data = json.loads(raw)
154
+ except json.JSONDecodeError:
155
+ data = {}
156
+ for line in raw.strip().splitlines():
157
+ if "=" in line and not line.startswith("#"):
158
+ k, v = line.split("=", 1)
159
+ data[k.strip()] = v.strip()
160
+ for key, value in data.items():
161
+ if key and key not in os.environ:
162
+ os.environ[key] = str(value)
163
+ os.environ[_marker] = "1"
164
+ except Exception as e:
165
+ _log.warning("_ensure_env_loaded failed: %s", e)
166
+
167
+
168
+ def call_ai(
169
+ config: dict,
170
+ system_prompt: str,
171
+ user_prompt: str,
172
+ max_tokens: int = DEFAULT_MAX_TOKENS,
173
+ temperature: float = DEFAULT_TEMPERATURE,
174
+ timeout: float | None = None,
175
+ root: Path | None = None,
176
+ progress_callback: Callable[[str], None] | None = None,
177
+ ) -> str | None:
178
+ """
179
+ Send a system+user prompt to the configured AI.
180
+ Returns the response text, or None if no AI is available / call fails.
181
+
182
+ Priority: local Ollama → configured API keys → None.
183
+
184
+ Honors PATCHI_OFFLINE=1 (set by `p scan --offline`): returns None without
185
+ making ANY network call — the flag's documented "static analysis only, zero
186
+ token cost" contract.
187
+
188
+ `timeout` bounds the ENTIRE call (all providers tried, poll loops included),
189
+ so a hung provider can never freeze a scan or leave a worker thread alive
190
+ indefinitely. None = no overall deadline (per-request socket timeouts only).
191
+
192
+ `progress_callback`: Optional callback(message) invoked during long-running
193
+ operations (e.g., "Contacting provider...", "Retrying in 2s...").
194
+ """
195
+ if os.environ.get("PATCHI_OFFLINE"):
196
+ return None
197
+
198
+ deadline = time.monotonic() + timeout if timeout is not None else None
199
+
200
+ def _remaining() -> float | None:
201
+ if deadline is None:
202
+ return None
203
+ return max(0.0, deadline - time.monotonic())
204
+
205
+ _ensure_env_loaded()
206
+ ai_config = config.get("ai", {})
207
+
208
+ def _progress(msg: str) -> None:
209
+ if progress_callback:
210
+ try:
211
+ progress_callback(msg)
212
+ except Exception:
213
+ pass
214
+
215
+ # ── Resolve root from tenant context if not provided ─────────────────
216
+ if root is None:
217
+ try:
218
+ from patchi.core.tenant import get_current_tenant_root
219
+
220
+ root = get_current_tenant_root()
221
+ except Exception as _exc:
222
+ _log.warning('call_ai failed: %s', _exc)
223
+
224
+ # ── Cost-aware model routing ───────────────────────────────────────────
225
+ # Use ModelRouter to select optimal model based on prompt complexity
226
+ try:
227
+ from patchi.core.ai.model_router import TaskComplexity, get_model_router
228
+
229
+ _router = get_model_router(config, root=root)
230
+ # Estimate complexity from prompt length
231
+ total_len = len(system_prompt) + len(user_prompt)
232
+ if total_len < 500:
233
+ complexity = TaskComplexity.SIMPLE
234
+ elif total_len < 2000:
235
+ complexity = TaskComplexity.MODERATE
236
+ elif total_len < 5000:
237
+ complexity = TaskComplexity.COMPLEX
238
+ else:
239
+ complexity = TaskComplexity.CRITICAL
240
+ routed_model = _router.select_model(complexity=complexity)
241
+ # Override the model in config for this call
242
+ ai_config = dict(ai_config)
243
+ ai_config["_routed_model"] = routed_model
244
+ except Exception as _exc:
245
+ _log.debug("model routing skipped: %s", _exc)
246
+
247
+ # Try local Ollama first
248
+ local_model = ai_config.get("local_model_name")
249
+ if local_model:
250
+ result = _call_ollama(
251
+ local_model, system_prompt, user_prompt, max_tokens, temperature, _remaining(), _progress
252
+ )
253
+ if result:
254
+ return result
255
+
256
+ # Try configured API keys in order
257
+ keys = ai_config.get("keys", [])
258
+ for key_cfg in keys:
259
+ if key_cfg.get("status") == "error":
260
+ continue
261
+ env_var = key_cfg.get("env_var", "")
262
+ api_key = os.environ.get(env_var, "")
263
+ if not api_key:
264
+ continue
265
+
266
+ fmt = key_cfg.get("format", "openai")
267
+ base_url = key_cfg.get("base_url", "https://api.openai.com/v1")
268
+ # Use routed model if available, else fall back to configured model
269
+ model = ai_config.get("_routed_model") or key_cfg.get("model", "gpt-4o-mini")
270
+
271
+ if fmt == "anthropic":
272
+ result = _call_anthropic(
273
+ api_key, base_url, model, system_prompt, user_prompt, max_tokens, _remaining(), _progress
274
+ )
275
+ else:
276
+ result = _call_openai_compat(
277
+ api_key,
278
+ base_url,
279
+ model,
280
+ system_prompt,
281
+ user_prompt,
282
+ max_tokens,
283
+ temperature,
284
+ _remaining(),
285
+ _progress,
286
+ )
287
+
288
+ if result:
289
+ logger.debug(f"AI call succeeded via {key_cfg.get('name', 'unknown')} ({model})")
290
+ return result
291
+
292
+ return None
293
+
294
+
295
+ def call_ai_structured(
296
+ config: dict,
297
+ system_prompt: str,
298
+ user_prompt: str,
299
+ max_tokens: int = DEFAULT_MAX_TOKENS,
300
+ temperature: float = 0.2,
301
+ progress_callback: Callable[[str], None] | None = None,
302
+ ) -> dict | list | None:
303
+ """
304
+ Call AI and parse the response as JSON.
305
+ Returns parsed JSON or None.
306
+ """
307
+ raw = call_ai(config, system_prompt, user_prompt, max_tokens, temperature, progress_callback=progress_callback)
308
+ if not raw:
309
+ return None
310
+ return _parse_json_response(raw)
311
+
312
+
313
+ # ── Provider implementations ──────────────────────────────────────────────────
314
+
315
+
316
+ def _call_ollama(
317
+ model: str,
318
+ system: str,
319
+ user: str,
320
+ max_tokens: int,
321
+ temperature: float = DEFAULT_TEMPERATURE,
322
+ timeout: float | None = None,
323
+ progress_callback: Callable[[str], None] | None = None,
324
+ ) -> str | None:
325
+ def _do_call() -> str | None:
326
+ if timeout is not None and timeout <= 0:
327
+ return None
328
+ if progress_callback:
329
+ progress_callback(f"Contacting Ollama ({model})...")
330
+ payload = json.dumps(
331
+ {
332
+ "model": model,
333
+ "system": system,
334
+ "prompt": user,
335
+ "stream": False,
336
+ "options": {"num_predict": max_tokens, "temperature": temperature},
337
+ }
338
+ ).encode()
339
+ req = urllib.request.Request(
340
+ OLLAMA_GENERATE_URL,
341
+ data=payload,
342
+ headers={"Content-Type": "application/json"},
343
+ method="POST",
344
+ )
345
+ with urllib.request.urlopen(
346
+ req, timeout=timeout if timeout is not None else HTTP_REQUEST_TIMEOUT
347
+ ) as resp:
348
+ data = json.loads(resp.read())
349
+ pt = estimate_tokens(system + user)
350
+ ct = estimate_tokens(data.get("response", ""))
351
+ track(model, pt, ct)
352
+ return data.get("response", "")
353
+
354
+ return _retry_with_backoff(
355
+ _do_call,
356
+ timeout_remaining=lambda: timeout,
357
+ progress_callback=progress_callback
358
+ )
359
+
360
+
361
+ def _call_openai_compat(
362
+ api_key: str,
363
+ base_url: str,
364
+ model: str,
365
+ system: str,
366
+ user: str,
367
+ max_tokens: int,
368
+ temperature: float,
369
+ timeout: float | None = None,
370
+ progress_callback: Callable[[str], None] | None = None,
371
+ ) -> str | None:
372
+ def _do_call() -> str | None:
373
+ if timeout is not None and timeout <= 0:
374
+ return None
375
+ if progress_callback:
376
+ progress_callback(f"Contacting API ({model})...")
377
+ payload = json.dumps(
378
+ {
379
+ "model": model,
380
+ "max_tokens": max_tokens,
381
+ "temperature": temperature,
382
+ "messages": [
383
+ {"role": "system", "content": system},
384
+ {"role": "user", "content": user},
385
+ ],
386
+ }
387
+ ).encode()
388
+ headers = {
389
+ "Content-Type": "application/json",
390
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
391
+ }
392
+ if api_key:
393
+ headers["Authorization"] = f"Bearer {api_key}"
394
+ req = urllib.request.Request(
395
+ f"{base_url}/chat/completions",
396
+ data=payload,
397
+ headers=headers,
398
+ method="POST",
399
+ )
400
+ with urllib.request.urlopen(
401
+ req, timeout=timeout if timeout is not None else HTTP_REQUEST_TIMEOUT
402
+ ) as resp:
403
+ data = json.loads(resp.read())
404
+ usage = data.get("usage", {})
405
+ pt = usage.get("prompt_tokens", 0) or estimate_tokens(system + user)
406
+ ct = usage.get("completion_tokens", 0) or estimate_tokens(
407
+ data.get("choices", [{}])[0].get("message", {}).get("content", "")
408
+ )
409
+ track(model, pt, ct)
410
+ return data.get("choices", [{}])[0].get("message", {}).get("content", "")
411
+
412
+ return _retry_with_backoff(
413
+ _do_call,
414
+ timeout_remaining=lambda: timeout,
415
+ progress_callback=progress_callback
416
+ )
417
+
418
+
419
+ def _call_anthropic(
420
+ api_key: str,
421
+ base_url: str,
422
+ model: str,
423
+ system: str,
424
+ user: str,
425
+ max_tokens: int,
426
+ timeout: float | None = None,
427
+ progress_callback: Callable[[str], None] | None = None,
428
+ ) -> str | None:
429
+ def _do_call() -> str | None:
430
+ if timeout is not None and timeout <= 0:
431
+ return None
432
+ if progress_callback:
433
+ progress_callback(f"Contacting Anthropic ({model})...")
434
+ payload = json.dumps(
435
+ {
436
+ "model": model,
437
+ "max_tokens": max_tokens,
438
+ "system": system,
439
+ "messages": [{"role": "user", "content": user}],
440
+ }
441
+ ).encode()
442
+ headers = {
443
+ "x-api-key": api_key,
444
+ "anthropic-version": ANTHROPIC_API_VERSION,
445
+ "Content-Type": "application/json",
446
+ }
447
+ req = urllib.request.Request(
448
+ f"{base_url}/messages",
449
+ data=payload,
450
+ headers=headers,
451
+ method="POST",
452
+ )
453
+ with urllib.request.urlopen(
454
+ req, timeout=timeout if timeout is not None else HTTP_REQUEST_TIMEOUT
455
+ ) as resp:
456
+ data = json.loads(resp.read())
457
+ usage = data.get("usage", {})
458
+ pt = usage.get("input_tokens", 0) or estimate_tokens(system + user)
459
+ ct = usage.get("output_tokens", 0) or estimate_tokens(
460
+ data.get("content", [{}])[0].get("text", "")
461
+ )
462
+ track(model, pt, ct)
463
+ return data.get("content", [{}])[0].get("text", "")
464
+
465
+ return _retry_with_backoff(
466
+ _do_call,
467
+ timeout_remaining=lambda: timeout,
468
+ progress_callback=progress_callback
469
+ )
470
+
471
+
472
+ # ── JSON parsing ──────────────────────────────────────────────────────────────
473
+
474
+ def _parse_json_response(text: str) -> dict | list | None:
475
+ """Extract and parse JSON from AI response text."""
476
+ # Direct parse
477
+ try:
478
+ return json.loads(text)
479
+ except json.JSONDecodeError:
480
+ pass
481
+
482
+ # Extract from code block
483
+ m = re.search(r"```(?:json)?\s*\n(.*?)```", text, re.DOTALL)
484
+ if m:
485
+ try:
486
+ return json.loads(m.group(1).strip())
487
+ except json.JSONDecodeError:
488
+ pass
489
+
490
+ # Find first JSON object or array
491
+ for pattern in [r"\{.*\}", r"\[.*\]"]:
492
+ m = re.search(pattern, text, re.DOTALL)
493
+ if m:
494
+ try:
495
+ return json.loads(m.group())
496
+ except json.JSONDecodeError:
497
+ continue
498
+
499
+ return None
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from loguru import logger
8
+
9
+ _COST_PER_1K_TOKENS = {
10
+ "gpt-4o-mini": (0.00015, 0.00060),
11
+ "gpt-4o": (0.00250, 0.01000),
12
+ "claude-haiku": (0.00025, 0.00125),
13
+ "claude-sonnet": (0.00300, 0.01500),
14
+ "gemini-1.5-flash": (0.000075, 0.00030),
15
+ "llama3-8b": (0.00010, 0.00040),
16
+ "mistral-small": (0.00020, 0.00060),
17
+ "mistralai/Mistral-7B-Instruct-v0.2": (0.0, 0.0),
18
+ }
19
+
20
+ _ACCUMULATED: dict[str, Any] = {
21
+ "total_tokens": 0,
22
+ "calls": 0,
23
+ "cost_estimate": 0.0,
24
+ "by_model": {},
25
+ }
26
+
27
+ _TRACKER_FILE = ""
28
+
29
+
30
+ def init(root: Path) -> None:
31
+ global _TRACKER_FILE
32
+ tracker_dir = root / ".patchi"
33
+ tracker_dir.mkdir(parents=True, exist_ok=True)
34
+ _TRACKER_FILE = str(tracker_dir / "ai_usage.json")
35
+ try:
36
+ with open(_TRACKER_FILE, encoding="utf-8") as f:
37
+ data = json.load(f)
38
+ _ACCUMULATED.update(data)
39
+ except (FileNotFoundError, json.JSONDecodeError):
40
+ pass
41
+
42
+
43
+ # Alias expected by the web app (patchi/web/app.py).
44
+ init_cost = init
45
+
46
+
47
+ def _persist() -> None:
48
+ if _TRACKER_FILE:
49
+ try:
50
+ with open(_TRACKER_FILE, "w", encoding="utf-8") as f:
51
+ json.dump(_ACCUMULATED, f, indent=2)
52
+ except Exception as e:
53
+ logger.debug(f"Failed to persist AI usage: {e}")
54
+
55
+
56
+ def track(model: str, prompt_tokens: int, completion_tokens: int) -> None:
57
+ total = prompt_tokens + completion_tokens
58
+ _ACCUMULATED["total_tokens"] += total
59
+ _ACCUMULATED["calls"] += 1
60
+
61
+ model_key = model
62
+ for key in _COST_PER_1K_TOKENS:
63
+ if key in model:
64
+ model_key = key
65
+ break
66
+ inp_rate, out_rate = _COST_PER_1K_TOKENS.get(model_key, (0.0, 0.0))
67
+ cost = (prompt_tokens / 1000) * inp_rate + (completion_tokens / 1000) * out_rate
68
+ _ACCUMULATED["cost_estimate"] += cost
69
+
70
+ by_model = _ACCUMULATED["by_model"]
71
+ mstats = by_model.setdefault(model, {"calls": 0, "tokens": 0, "cost": 0.0})
72
+ mstats["calls"] += 1
73
+ mstats["tokens"] += total
74
+ mstats["cost"] += cost
75
+
76
+ _persist()
77
+
78
+
79
+ def get_stats() -> dict[str, Any]:
80
+ return dict(_ACCUMULATED)
81
+
82
+
83
+ def reset() -> None:
84
+ _ACCUMULATED.clear()
85
+ _ACCUMULATED.update({"total_tokens": 0, "calls": 0, "cost_estimate": 0.0, "by_model": {}})
86
+
87
+
88
+ def check_budget(config: dict) -> str | None:
89
+ limit_cfg = config.get("ai", {})
90
+ if not limit_cfg.get("cost_limit_enabled"):
91
+ return None
92
+ limit = limit_cfg.get("cost_limit", 0)
93
+ if limit <= 0:
94
+ return None
95
+ spent = _ACCUMULATED["cost_estimate"]
96
+ if spent >= limit:
97
+ return f"AI budget exhausted (${spent:.4f} / ${limit:.4f})"
98
+ pct = (spent / limit) * 100
99
+ if pct >= 90:
100
+ return f"AI budget nearly exhausted ({pct:.0f}% used — ${spent:.4f} / ${limit:.4f})"
101
+ if pct >= 75:
102
+ logger.info(f"AI budget at {pct:.0f}% (${spent:.4f} / ${limit:.4f})")
103
+ return None
104
+
105
+
106
+ def estimate_tokens(text: str) -> int:
107
+ return int(len(text) * 1.3)