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.
- patchi/__init__.py +1 -0
- patchi/__main__.py +13 -0
- patchi/cli/__init__.py +0 -0
- patchi/cli/command_families.py +235 -0
- patchi/cli/commands/__init__.py +0 -0
- patchi/cli/commands/access_cmd.py +65 -0
- patchi/cli/commands/agent_stats_cmd.py +273 -0
- patchi/cli/commands/agents_cmd.py +187 -0
- patchi/cli/commands/ai_cmd.py +308 -0
- patchi/cli/commands/assure_cmd.py +538 -0
- patchi/cli/commands/audit_cmd.py +286 -0
- patchi/cli/commands/auto_cmd.py +110 -0
- patchi/cli/commands/blame_cmd.py +53 -0
- patchi/cli/commands/chains_cmd.py +327 -0
- patchi/cli/commands/charter_cmd.py +173 -0
- patchi/cli/commands/chat_cmd.py +597 -0
- patchi/cli/commands/check_cmd.py +232 -0
- patchi/cli/commands/cleanup_cmd.py +328 -0
- patchi/cli/commands/cockpit_cmd.py +208 -0
- patchi/cli/commands/commands_cmd.py +160 -0
- patchi/cli/commands/cross_repo_cmd.py +113 -0
- patchi/cli/commands/deps_cmd.py +110 -0
- patchi/cli/commands/dev_check_cmd.py +342 -0
- patchi/cli/commands/dev_cmd.py +445 -0
- patchi/cli/commands/doctor_cmd.py +457 -0
- patchi/cli/commands/findings_cmd.py +187 -0
- patchi/cli/commands/fix_cmd.py +648 -0
- patchi/cli/commands/fix_review_cmd.py +273 -0
- patchi/cli/commands/heatmap_cmd.py +62 -0
- patchi/cli/commands/help_cmd.py +159 -0
- patchi/cli/commands/hosted_cmd.py +980 -0
- patchi/cli/commands/init.py +456 -0
- patchi/cli/commands/key_cmd.py +571 -0
- patchi/cli/commands/learn_cmd.py +140 -0
- patchi/cli/commands/link_cmd.py +104 -0
- patchi/cli/commands/log_cmd.py +57 -0
- patchi/cli/commands/memory_cmd.py +215 -0
- patchi/cli/commands/model_cmd.py +207 -0
- patchi/cli/commands/notify_cmd.py +213 -0
- patchi/cli/commands/notify_prompts.py +29 -0
- patchi/cli/commands/patch_cmd.py +231 -0
- patchi/cli/commands/plan_cmd.py +128 -0
- patchi/cli/commands/plugins_cmd.py +307 -0
- patchi/cli/commands/queue_cmd.py +152 -0
- patchi/cli/commands/quick_cmd.py +159 -0
- patchi/cli/commands/ready_cmd.py +292 -0
- patchi/cli/commands/report_cmd.py +416 -0
- patchi/cli/commands/restrict_cmd.py +127 -0
- patchi/cli/commands/review_cmd.py +225 -0
- patchi/cli/commands/rules_cmd.py +233 -0
- patchi/cli/commands/scan_cmd.py +1908 -0
- patchi/cli/commands/settings_cmd.py +45 -0
- patchi/cli/commands/status_cmd.py +679 -0
- patchi/cli/commands/test_cmd.py +1052 -0
- patchi/cli/commands/trend_cmd.py +296 -0
- patchi/cli/commands/undo_cmd.py +235 -0
- patchi/cli/commands/update_cmd.py +345 -0
- patchi/cli/commands/verify_cmd.py +56 -0
- patchi/cli/commands/vr_cmd.py +440 -0
- patchi/cli/commands/watch_cmd.py +304 -0
- patchi/cli/commands/web_cmd.py +114 -0
- patchi/cli/console.py +101 -0
- patchi/cli/display/__init__.py +1 -0
- patchi/cli/display/live_audit.py +269 -0
- patchi/cli/display/live_progress.py +169 -0
- patchi/cli/framework.py +241 -0
- patchi/cli/logo.py +128 -0
- patchi/cli/main.py +222 -0
- patchi/cli/registry.py +1171 -0
- patchi/cli/style.py +68 -0
- patchi/cli/themes.py +187 -0
- patchi/cli/ux.py +444 -0
- patchi/core/__init__.py +0 -0
- patchi/core/agents/__init__.py +0 -0
- patchi/core/agents/api_fuzzer_agent.py +141 -0
- patchi/core/agents/attack_agent.py +242 -0
- patchi/core/agents/base.py +1012 -0
- patchi/core/agents/bug_predictor_agent.py +58 -0
- patchi/core/agents/build_tool_validator.py +150 -0
- patchi/core/agents/cache.py +192 -0
- patchi/core/agents/chaos_agent.py +133 -0
- patchi/core/agents/cicd_generator.py +240 -0
- patchi/core/agents/comment_scanner.py +280 -0
- patchi/core/agents/console_logging_agent.py +156 -0
- patchi/core/agents/coordinator.py +858 -0
- patchi/core/agents/core_scanner.py +269 -0
- patchi/core/agents/coverage_prioritizer.py +181 -0
- patchi/core/agents/dead_code_hygiene.py +296 -0
- patchi/core/agents/dead_code_scanner.py +692 -0
- patchi/core/agents/dependency_scanner.py +725 -0
- patchi/core/agents/doc_claim_agent.py +573 -0
- patchi/core/agents/duplicate_scanner.py +568 -0
- patchi/core/agents/env_scanner.py +410 -0
- patchi/core/agents/feature_flag_agent.py +89 -0
- patchi/core/agents/frontend_framework_agent.py +133 -0
- patchi/core/agents/goroutine_agent.py +82 -0
- patchi/core/agents/governor.py +1972 -0
- patchi/core/agents/i18n_agent.py +95 -0
- patchi/core/agents/license_compliance.py +247 -0
- patchi/core/agents/linking_agent.py +251 -0
- patchi/core/agents/memory_profiler_agent.py +102 -0
- patchi/core/agents/modernization_agent.py +84 -0
- patchi/core/agents/mutation_agent.py +174 -0
- patchi/core/agents/nl_query_agent.py +74 -0
- patchi/core/agents/promise_rejection_agent.py +100 -0
- patchi/core/agents/refactoring_agent.py +286 -0
- patchi/core/agents/resource_leak_agent.py +91 -0
- patchi/core/agents/route_graph_scanner.py +951 -0
- patchi/core/agents/run_agent.py +106 -0
- patchi/core/agents/rust_unwrap_agent.py +100 -0
- patchi/core/agents/sbom_generator.py +284 -0
- patchi/core/agents/scanners.py +67 -0
- patchi/core/agents/side/__init__.py +1 -0
- patchi/core/agents/side/build_agent.py +65 -0
- patchi/core/agents/side/format_agent.py +50 -0
- patchi/core/agents/side/install_agent.py +63 -0
- patchi/core/agents/side_file_scanner.py +549 -0
- patchi/core/agents/snapshot_drift_detector.py +146 -0
- patchi/core/agents/spa_route_inventory.py +203 -0
- patchi/core/agents/test_scanner.py +442 -0
- patchi/core/agents/tool_health.py +109 -0
- patchi/core/agents/tool_runner.py +162 -0
- patchi/core/agents/type_scanner.py +168 -0
- patchi/core/agents/ui_scanner.py +558 -0
- patchi/core/ai/__init__.py +20 -0
- patchi/core/ai/agent_audit.py +153 -0
- patchi/core/ai/agent_profiler.py +277 -0
- patchi/core/ai/client.py +499 -0
- patchi/core/ai/cost_tracker.py +107 -0
- patchi/core/ai/model_router.py +318 -0
- patchi/core/ai/orchestrator.py +625 -0
- patchi/core/ai/prompts.py +681 -0
- patchi/core/ai/smart.py +302 -0
- patchi/core/ai/tool_executor.py +24 -0
- patchi/core/ai/tools/__init__.py +10 -0
- patchi/core/ai/tools/executor.py +621 -0
- patchi/core/ai/tools/realize.py +1160 -0
- patchi/core/ai/tools/registry.py +1577 -0
- patchi/core/assurance/__init__.py +33 -0
- patchi/core/assurance/builder.py +211 -0
- patchi/core/assurance/graph.py +342 -0
- patchi/core/assurance/invariants.py +168 -0
- patchi/core/atomic.py +61 -0
- patchi/core/attackers/__init__.py +17 -0
- patchi/core/attackers/api_abuse.py +89 -0
- patchi/core/attackers/auth_bypass.py +73 -0
- patchi/core/attackers/base.py +91 -0
- patchi/core/attackers/business_logic.py +88 -0
- patchi/core/attackers/planner.py +108 -0
- patchi/core/attackers/priv_escalation.py +88 -0
- patchi/core/attackers/recon.py +87 -0
- patchi/core/brain/__init__.py +0 -0
- patchi/core/brain/ast_utils/__init__.py +81 -0
- patchi/core/brain/ast_utils/assignments.py +219 -0
- patchi/core/brain/ast_utils/calls.py +139 -0
- patchi/core/brain/ast_utils/config.py +132 -0
- patchi/core/brain/ast_utils/control_flow.py +74 -0
- patchi/core/brain/ast_utils/dead_symbols.py +205 -0
- patchi/core/brain/ast_utils/decorators.py +108 -0
- patchi/core/brain/ast_utils/helpers.py +144 -0
- patchi/core/brain/ast_utils/imports.py +169 -0
- patchi/core/brain/ast_utils/scan.py +143 -0
- patchi/core/brain/ast_utils/taint.py +310 -0
- patchi/core/brain/audit.py +379 -0
- patchi/core/brain/baseline.py +195 -0
- patchi/core/brain/blast_radius.py +312 -0
- patchi/core/brain/body_tags.py +144 -0
- patchi/core/brain/brain.py +1161 -0
- patchi/core/brain/brain_context.py +393 -0
- patchi/core/brain/brain_watcher.py +340 -0
- patchi/core/brain/charter.py +473 -0
- patchi/core/brain/classifier.py +214 -0
- patchi/core/brain/cockpit.py +350 -0
- patchi/core/brain/code_query.py +710 -0
- patchi/core/brain/contract.py +889 -0
- patchi/core/brain/contract_diff.py +155 -0
- patchi/core/brain/council.py +800 -0
- patchi/core/brain/cross_repo.py +104 -0
- patchi/core/brain/dead_code_tools.py +157 -0
- patchi/core/brain/doc_validator.py +197 -0
- patchi/core/brain/domain_activator.py +2183 -0
- patchi/core/brain/enriched_context.py +220 -0
- patchi/core/brain/file_corpus.py +220 -0
- patchi/core/brain/framework.py +953 -0
- patchi/core/brain/freshness.py +273 -0
- patchi/core/brain/git_aware.py +206 -0
- patchi/core/brain/heatmap.py +104 -0
- patchi/core/brain/ignore_parser.py +153 -0
- patchi/core/brain/import_graph.py +486 -0
- patchi/core/brain/languages.py +397 -0
- patchi/core/brain/layered_brain.py +419 -0
- patchi/core/brain/learning.py +229 -0
- patchi/core/brain/orphaned_endpoints.py +313 -0
- patchi/core/brain/personas/__init__.py +648 -0
- patchi/core/brain/personas/bad_user.py +165 -0
- patchi/core/brain/personas/base.py +349 -0
- patchi/core/brain/proactive.py +807 -0
- patchi/core/brain/project_context.py +121 -0
- patchi/core/brain/project_reader.py +521 -0
- patchi/core/brain/reasoning.py +269 -0
- patchi/core/brain/route_detector/__init__.py +15 -0
- patchi/core/brain/route_detector/base.py +31 -0
- patchi/core/brain/route_detector/csharp.py +209 -0
- patchi/core/brain/route_detector/file_based.py +81 -0
- patchi/core/brain/route_detector/go.py +152 -0
- patchi/core/brain/route_detector/java.py +195 -0
- patchi/core/brain/route_detector/javascript.py +197 -0
- patchi/core/brain/route_detector/php.py +158 -0
- patchi/core/brain/route_detector/python.py +193 -0
- patchi/core/brain/route_detector/registry.py +25 -0
- patchi/core/brain/route_detector/ruby.py +134 -0
- patchi/core/brain/route_detector/rust.py +216 -0
- patchi/core/brain/route_detector/swift.py +118 -0
- patchi/core/brain/route_mapper.py +265 -0
- patchi/core/brain/scanner.py +2136 -0
- patchi/core/brain/secrets.py +280 -0
- patchi/core/brain/symbol_graph.py +1955 -0
- patchi/core/brain/team.py +28 -0
- patchi/core/brain/trace_log.py +132 -0
- patchi/core/brain/type_checker/__init__.py +65 -0
- patchi/core/brain/type_checker/base.py +60 -0
- patchi/core/brain/type_checker/csharp.py +59 -0
- patchi/core/brain/type_checker/dart.py +59 -0
- patchi/core/brain/type_checker/go.py +81 -0
- patchi/core/brain/type_checker/java.py +75 -0
- patchi/core/brain/type_checker/kotlin.py +75 -0
- patchi/core/brain/type_checker/php.py +88 -0
- patchi/core/brain/type_checker/python.py +87 -0
- patchi/core/brain/type_checker/rust.py +81 -0
- patchi/core/brain/type_checker/swift.py +59 -0
- patchi/core/brain/type_checker/typescript.py +298 -0
- patchi/core/brain/understander.py +130 -0
- patchi/core/brain/verify.py +379 -0
- patchi/core/campaigns/__init__.py +14 -0
- patchi/core/campaigns/base.py +62 -0
- patchi/core/campaigns/data_flow_abuse.py +153 -0
- patchi/core/campaigns/orchestrator.py +140 -0
- patchi/core/campaigns/state_transitions.py +178 -0
- patchi/core/ci_bundle.py +104 -0
- patchi/core/config.py +311 -0
- patchi/core/constants.py +245 -0
- patchi/core/debug/__init__.py +25 -0
- patchi/core/debug/adapters/__init__.py +11 -0
- patchi/core/debug/adapters/codelldb.py +379 -0
- patchi/core/debug/adapters/node.py +222 -0
- patchi/core/debug/adapters/powershell.py +210 -0
- patchi/core/debug/adapters/python.py +244 -0
- patchi/core/debug/capture.py +210 -0
- patchi/core/debug/dap_client.py +268 -0
- patchi/core/detector/__init__.py +0 -0
- patchi/core/detector/audit.py +194 -0
- patchi/core/detector/bus.py +226 -0
- patchi/core/detector/dispatcher.py +275 -0
- patchi/core/detector/event.py +254 -0
- patchi/core/detector/sigma_engine.py +378 -0
- patchi/core/detector/triage.py +289 -0
- patchi/core/export/sarif.py +113 -0
- patchi/core/fix/__init__.py +17 -0
- patchi/core/fix/applier.py +574 -0
- patchi/core/fix/base.py +510 -0
- patchi/core/fix/code_fixer.py +169 -0
- patchi/core/fix/dead_code_remover.py +131 -0
- patchi/core/fix/fix_agents.py +775 -0
- patchi/core/fix/patch.py +444 -0
- patchi/core/fix/risk_gate.py +383 -0
- patchi/core/fix/security_fixer.py +93 -0
- patchi/core/fix/verify_loop.py +204 -0
- patchi/core/fuzz/__init__.py +8 -0
- patchi/core/fuzz/corpus.py +137 -0
- patchi/core/fuzz/input_fuzzer.py +256 -0
- patchi/core/fuzz/sequence_fuzzer.py +162 -0
- patchi/core/fuzz/state_fuzzer.py +236 -0
- patchi/core/health.py +407 -0
- patchi/core/hosted/__init__.py +1 -0
- patchi/core/hosted/anomaly.py +298 -0
- patchi/core/hosted/audit_log.py +102 -0
- patchi/core/hosted/compliance_report.py +148 -0
- patchi/core/hosted/ip_reputation.py +169 -0
- patchi/core/hosted/log_parsers.py +251 -0
- patchi/core/hosted/tokens.py +127 -0
- patchi/core/hosted/watchlist.py +164 -0
- patchi/core/hosted/webhooks.py +156 -0
- patchi/core/memory.py +437 -0
- patchi/core/notifications/__init__.py +18 -0
- patchi/core/notifications/channels.py +192 -0
- patchi/core/notifications/digest.py +141 -0
- patchi/core/notifications/escalation.py +158 -0
- patchi/core/notifications/notifier.py +182 -0
- patchi/core/notifications/quiet_hours.py +69 -0
- patchi/core/plugins/__init__.py +48 -0
- patchi/core/plugins/analyzer.py +326 -0
- patchi/core/plugins/analyzers/__init__.py +6 -0
- patchi/core/plugins/analyzers/dead_code_analyzer.py +120 -0
- patchi/core/plugins/analyzers/secret_scanner.py +158 -0
- patchi/core/plugins/analyzers/todo_scanner.py +115 -0
- patchi/core/plugins/registry.py +335 -0
- patchi/core/queue.py +375 -0
- patchi/core/reliability/__init__.py +13 -0
- patchi/core/reliability/chaos.py +163 -0
- patchi/core/reliability/fault_injector.py +172 -0
- patchi/core/reliability/idempotency.py +128 -0
- patchi/core/reliability/recovery.py +147 -0
- patchi/core/runtime/__init__.py +5 -0
- patchi/core/runtime/tracer.py +230 -0
- patchi/core/scan_bus.py +156 -0
- patchi/core/security/__init__.py +1 -0
- patchi/core/security/ai_validator.py +462 -0
- patchi/core/security/app_mapper.py +216 -0
- patchi/core/security/app_profile.py +464 -0
- patchi/core/security/attack_feedback.py +141 -0
- patchi/core/security/attack_scenarios/__init__.py +1 -0
- patchi/core/security/attack_tree.py +176 -0
- patchi/core/security/auth_audit_agent.py +344 -0
- patchi/core/security/authz_agent.py +602 -0
- patchi/core/security/auto_fix_proactive.py +620 -0
- patchi/core/security/auto_fixer.py +660 -0
- patchi/core/security/auto_ticket.py +53 -0
- patchi/core/security/bandit_agent.py +152 -0
- patchi/core/security/blast_radius.py +226 -0
- patchi/core/security/browser_tester.py +333 -0
- patchi/core/security/business_logic_agent.py +263 -0
- patchi/core/security/catch_block_auditor.py +395 -0
- patchi/core/security/cdn_cache_agent.py +357 -0
- patchi/core/security/chain_analyzer.py +321 -0
- patchi/core/security/chain_to_assurance.py +168 -0
- patchi/core/security/charter.py +510 -0
- patchi/core/security/cloud_waf_detector.py +295 -0
- patchi/core/security/codeql_agent.py +233 -0
- patchi/core/security/compliance_agent.py +348 -0
- patchi/core/security/confidence_gate.py +423 -0
- patchi/core/security/container_scanner.py +282 -0
- patchi/core/security/crypto_agent.py +378 -0
- patchi/core/security/cve_monitor.py +262 -0
- patchi/core/security/dast_agent.py +865 -0
- patchi/core/security/defectdojo.py +238 -0
- patchi/core/security/defenders/__init__.py +75 -0
- patchi/core/security/defenders/auth_middleware.py +115 -0
- patchi/core/security/defenders/base.py +97 -0
- patchi/core/security/defenders/block_ip.py +71 -0
- patchi/core/security/defenders/block_ws_origin.py +36 -0
- patchi/core/security/defenders/crypto_fix.py +118 -0
- patchi/core/security/defenders/enforce_rate_limit.py +108 -0
- patchi/core/security/defenders/fix_code.py +93 -0
- patchi/core/security/defenders/invalidate_session.py +40 -0
- patchi/core/security/defenders/patch_config.py +70 -0
- patchi/core/security/defenders/rotate_secret.py +54 -0
- patchi/core/security/defenders/suspend_account.py +41 -0
- patchi/core/security/defenders/update_dependency.py +160 -0
- patchi/core/security/defense_layer.py +217 -0
- patchi/core/security/dependency_vulnerability_agent.py +616 -0
- patchi/core/security/detection_pipeline.py +289 -0
- patchi/core/security/dns_security_agent.py +497 -0
- patchi/core/security/domain_activator_v2.py +913 -0
- patchi/core/security/domain_loader.py +1004 -0
- patchi/core/security/domains/__init__.py +0 -0
- patchi/core/security/email_authentication_agent.py +417 -0
- patchi/core/security/env_var_validator.py +304 -0
- patchi/core/security/evidence.py +100 -0
- patchi/core/security/falco_runtime_agent.py +387 -0
- patchi/core/security/fix-playbooks/__init__.py +0 -0
- patchi/core/security/gated_finding.py +85 -0
- patchi/core/security/git_diff_activator.py +367 -0
- patchi/core/security/governance.py +208 -0
- patchi/core/security/gradual_gate.py +34 -0
- patchi/core/security/history.py +315 -0
- patchi/core/security/iac_scanner.py +250 -0
- patchi/core/security/ignore_expiry.py +111 -0
- patchi/core/security/ignore_learner.py +416 -0
- patchi/core/security/injection_agent.py +319 -0
- patchi/core/security/insecure_randomness_agent.py +288 -0
- patchi/core/security/intent_analyzer.py +326 -0
- patchi/core/security/jwt_agent.py +453 -0
- patchi/core/security/kubernetes_agent.py +638 -0
- patchi/core/security/layer2_orchestrator.py +341 -0
- patchi/core/security/llm_security_agent.py +252 -0
- patchi/core/security/misconfig_agent.py +600 -0
- patchi/core/security/mobile_security_agent.py +474 -0
- patchi/core/security/network_agent.py +376 -0
- patchi/core/security/noise_filter.py +274 -0
- patchi/core/security/orchestrator.py +313 -0
- patchi/core/security/pattern_context.py +125 -0
- patchi/core/security/pattern_loader.py +101 -0
- patchi/core/security/pentest/__init__.py +4 -0
- patchi/core/security/pentest/base.py +58 -0
- patchi/core/security/pentest/dalfox_adapter.py +40 -0
- patchi/core/security/pentest/ffuf_adapter.py +48 -0
- patchi/core/security/pentest/nuclei_adapter.py +50 -0
- patchi/core/security/pentest/registry.py +80 -0
- patchi/core/security/pentest/shannon_adapter.py +107 -0
- patchi/core/security/pentest/sqlmap_adapter.py +56 -0
- patchi/core/security/pentest/zap_adapter.py +52 -0
- patchi/core/security/plan_auditor.py +276 -0
- patchi/core/security/policy_engine.py +272 -0
- patchi/core/security/prechecks.py +172 -0
- patchi/core/security/precommit_hook.py +247 -0
- patchi/core/security/privacy_agent.py +361 -0
- patchi/core/security/push_notification_agent.py +417 -0
- patchi/core/security/pysa_agent.py +143 -0
- patchi/core/security/reasoning.py +442 -0
- patchi/core/security/red_team_agent.py +184 -0
- patchi/core/security/red_team_engine.py +1142 -0
- patchi/core/security/remediation.py +360 -0
- patchi/core/security/request_interceptor.py +294 -0
- patchi/core/security/runtime_validator.py +245 -0
- patchi/core/security/saml_sso_agent.py +401 -0
- patchi/core/security/sast_agent.py +213 -0
- patchi/core/security/sast_gate.py +95 -0
- patchi/core/security/scheduler.py +287 -0
- patchi/core/security/secrets_runtime_agent.py +476 -0
- patchi/core/security/security_agents.py +175 -0
- patchi/core/security/security_config.py +551 -0
- patchi/core/security/security_probe.py +762 -0
- patchi/core/security/security_taint.py +1005 -0
- patchi/core/security/sensitive_data_agent.py +271 -0
- patchi/core/security/service_mesh_agent.py +412 -0
- patchi/core/security/session_management_agent.py +245 -0
- patchi/core/security/ssrf_agent.py +422 -0
- patchi/core/security/supply_chain.py +449 -0
- patchi/core/security/threat_model_generator.py +378 -0
- patchi/core/security/threat_model_updater.py +196 -0
- patchi/core/security/tool_adapters.py +135 -0
- patchi/core/security/tool_verify.py +173 -0
- patchi/core/security/websocket_security_agent.py +188 -0
- patchi/core/snapshot.py +242 -0
- patchi/core/tenant.py +386 -0
- patchi/core/testing/__init__.py +17 -0
- patchi/core/testing/_browser.py +147 -0
- patchi/core/testing/ads_agent.py +141 -0
- patchi/core/testing/api_contract_agent.py +450 -0
- patchi/core/testing/app_discovery_agent.py +641 -0
- patchi/core/testing/app_launcher.py +184 -0
- patchi/core/testing/browser_test_agent.py +516 -0
- patchi/core/testing/e2e_flow_agent.py +354 -0
- patchi/core/testing/flake_detector_agent.py +292 -0
- patchi/core/testing/gate.py +31 -0
- patchi/core/testing/live_test_runner.py +304 -0
- patchi/core/testing/live_v2/__init__.py +1 -0
- patchi/core/testing/live_v2/browser_pool.py +349 -0
- patchi/core/testing/live_v2/browser_test_runner.py +313 -0
- patchi/core/testing/live_v2/runner.py +626 -0
- patchi/core/testing/live_v2/screenshot_manager.py +474 -0
- patchi/core/testing/live_v2/stress_orchestrator.py +523 -0
- patchi/core/testing/live_v2/video_recorder.py +303 -0
- patchi/core/testing/regression_agent.py +302 -0
- patchi/core/testing/security_test_agent.py +253 -0
- patchi/core/testing/stress_test_agent.py +203 -0
- patchi/core/testing/test_agents.py +50 -0
- patchi/core/testing/test_config.py +194 -0
- patchi/core/testing/ui_accessibility_agent.py +324 -0
- patchi/core/testing/ui_button_agent.py +294 -0
- patchi/core/testing/ui_layout_agent.py +330 -0
- patchi/core/testing/unit_test_agent.py +1149 -0
- patchi/core/testing/visual_regression_agent.py +260 -0
- patchi/web/__init__.py +1 -0
- patchi/web/api/__init__.py +1 -0
- patchi/web/api/brain_map.py +114 -0
- patchi/web/api/charts.py +47 -0
- patchi/web/api/chat.py +224 -0
- patchi/web/api/cicd.py +576 -0
- patchi/web/api/dast_evidence.py +49 -0
- patchi/web/api/dev_check.py +320 -0
- patchi/web/api/fix.py +134 -0
- patchi/web/api/guard.py +65 -0
- patchi/web/api/hosted.py +128 -0
- patchi/web/api/hosted_v2.py +234 -0
- patchi/web/api/live_testing.py +699 -0
- patchi/web/api/scan.py +582 -0
- patchi/web/api/smart.py +111 -0
- patchi/web/api/tenant.py +117 -0
- patchi/web/api/tools.py +85 -0
- patchi/web/api_legacy.py +2401 -0
- patchi/web/app.py +310 -0
- patchi/web/events.py +483 -0
- patchi/web/routes/__init__.py +1 -0
- patchi/web/routes/assurance.py +541 -0
- patchi/web/routes/brain.py +36 -0
- patchi/web/routes/charter.py +227 -0
- patchi/web/routes/chat.py +23 -0
- patchi/web/routes/dashboard.py +558 -0
- patchi/web/routes/dashboard_v2.py +402 -0
- patchi/web/routes/findings.py +330 -0
- patchi/web/routes/guard.py +35 -0
- patchi/web/routes/history.py +126 -0
- patchi/web/routes/landing.py +52 -0
- patchi/web/routes/live_testing.py +24 -0
- patchi/web/routes/review.py +57 -0
- patchi/web/routes/self_improvement.py +110 -0
- patchi/web/routes/settings.py +49 -0
- patchi/web/routes/tokens.py +29 -0
- patchi/web/spawn.py +139 -0
- patchi/web/ws.py +141 -0
- patchi-0.7.5.dist-info/METADATA +315 -0
- patchi-0.7.5.dist-info/RECORD +496 -0
- patchi-0.7.5.dist-info/WHEEL +5 -0
- patchi-0.7.5.dist-info/licenses/LICENSE +60 -0
- patchi-0.7.5.dist-info/top_level.txt +1 -0
patchi/core/ai/client.py
ADDED
|
@@ -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)
|