polyrob 0.5.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 (745) hide show
  1. agents/README.md +453 -0
  2. agents/__init__.py +164 -0
  3. agents/base_agent.py +743 -0
  4. agents/personality/character.py +119 -0
  5. agents/personality/character_manager.py +309 -0
  6. agents/personality/characters/rob.character.json +56 -0
  7. agents/personality/characters/trump.character.json +63 -0
  8. agents/personality/persona_render.py +78 -0
  9. agents/personality/persona_resolver.py +115 -0
  10. agents/prompt/__init__.py +58 -0
  11. agents/prompt/base_prompt.py +92 -0
  12. agents/prompt/system.py +417 -0
  13. agents/task/README_PATH_MANAGEMENT.md +141 -0
  14. agents/task/__init__.py +162 -0
  15. agents/task/agent/__init__.py +70 -0
  16. agents/task/agent/agent_state.py +408 -0
  17. agents/task/agent/async_delegation.py +274 -0
  18. agents/task/agent/autonomy_state.py +275 -0
  19. agents/task/agent/conversation.py +75 -0
  20. agents/task/agent/core/__init__.py +6 -0
  21. agents/task/agent/core/aux_metering.py +61 -0
  22. agents/task/agent/core/background_review.py +157 -0
  23. agents/task/agent/core/construction.py +1179 -0
  24. agents/task/agent/core/conversational_exit.py +49 -0
  25. agents/task/agent/core/correspondent_gate.py +227 -0
  26. agents/task/agent/core/curator.py +246 -0
  27. agents/task/agent/core/episodic_digest.py +103 -0
  28. agents/task/agent/core/error_recovery.py +480 -0
  29. agents/task/agent/core/history_io.py +257 -0
  30. agents/task/agent/core/llm_provisioning.py +287 -0
  31. agents/task/agent/core/llm_runner.py +547 -0
  32. agents/task/agent/core/logging_io.py +276 -0
  33. agents/task/agent/core/loop_detection.py +262 -0
  34. agents/task/agent/core/memory_prefetch.py +325 -0
  35. agents/task/agent/core/memory_writer.py +667 -0
  36. agents/task/agent/core/model_introspection.py +82 -0
  37. agents/task/agent/core/model_swap.py +177 -0
  38. agents/task/agent/core/next_action_internal.py +1331 -0
  39. agents/task/agent/core/output_validation.py +303 -0
  40. agents/task/agent/core/project_context.py +272 -0
  41. agents/task/agent/core/resources.py +93 -0
  42. agents/task/agent/core/result_processing.py +389 -0
  43. agents/task/agent/core/run_loop.py +661 -0
  44. agents/task/agent/core/safety_lifecycle.py +228 -0
  45. agents/task/agent/core/secret_guard.py +293 -0
  46. agents/task/agent/core/self_wake.py +280 -0
  47. agents/task/agent/core/session_metadata.py +104 -0
  48. agents/task/agent/core/step.py +781 -0
  49. agents/task/agent/core/step_execution.py +398 -0
  50. agents/task/agent/core/step_telemetry.py +222 -0
  51. agents/task/agent/core/test_aux_metering.py +50 -0
  52. agents/task/agent/core/test_billing_failover.py +68 -0
  53. agents/task/agent/core/test_fatal_error_classifier.py +38 -0
  54. agents/task/agent/core/test_judge_validation.py +275 -0
  55. agents/task/agent/core/test_model_swap.py +190 -0
  56. agents/task/agent/core/test_reflection_offload.py +54 -0
  57. agents/task/agent/core/turn_input.py +8 -0
  58. agents/task/agent/core/untrusted_wrap.py +103 -0
  59. agents/task/agent/core/user_ingress.py +228 -0
  60. agents/task/agent/hitl_manager.py +396 -0
  61. agents/task/agent/log_sanitize.py +36 -0
  62. agents/task/agent/message_manager/config.py +97 -0
  63. agents/task/agent/message_manager/service.py +758 -0
  64. agents/task/agent/message_manager/tests.py +305 -0
  65. agents/task/agent/message_manager/tool_call_builder.py +539 -0
  66. agents/task/agent/message_manager/tool_message_repair.py +364 -0
  67. agents/task/agent/message_manager/views.py +143 -0
  68. agents/task/agent/messages/__init__.py +8 -0
  69. agents/task/agent/messages/builders.py +309 -0
  70. agents/task/agent/messages/compactor.py +611 -0
  71. agents/task/agent/messages/context_references.py +271 -0
  72. agents/task/agent/messages/filters.py +333 -0
  73. agents/task/agent/messages/guidance.py +328 -0
  74. agents/task/agent/messages/persistence.py +448 -0
  75. agents/task/agent/messages/retrieval.py +506 -0
  76. agents/task/agent/messages/sqlite_persistence.py +85 -0
  77. agents/task/agent/messages/test_compaction_upgrade.py +349 -0
  78. agents/task/agent/messages/test_persistence_subdir_fix.py +28 -0
  79. agents/task/agent/messages/test_sqlite_persistence.py +54 -0
  80. agents/task/agent/messages/token_counter.py +532 -0
  81. agents/task/agent/orchestrator.py +776 -0
  82. agents/task/agent/profile_manager.py +105 -0
  83. agents/task/agent/profile_registry.py +173 -0
  84. agents/task/agent/prompts.py +1090 -0
  85. agents/task/agent/scenario_registry.py +226 -0
  86. agents/task/agent/service.py +509 -0
  87. agents/task/agent/session.py +984 -0
  88. agents/task/agent/skill_discovery.py +110 -0
  89. agents/task/agent/skill_frontmatter.py +94 -0
  90. agents/task/agent/skill_manager.py +1201 -0
  91. agents/task/agent/skill_store.py +627 -0
  92. agents/task/agent/skill_validation.py +56 -0
  93. agents/task/agent/skill_writer.py +492 -0
  94. agents/task/agent/sub_agent_manager.py +1137 -0
  95. agents/task/agent/test_session_disk_recovery.py +34 -0
  96. agents/task/agent/tests.py +513 -0
  97. agents/task/agent/tool_call_tracker.py +511 -0
  98. agents/task/agent/views.py +469 -0
  99. agents/task/config.py +432 -0
  100. agents/task/constants.py +1384 -0
  101. agents/task/flag_defaults.py +63 -0
  102. agents/task/goals/__init__.py +1 -0
  103. agents/task/goals/autonomy_marker.py +28 -0
  104. agents/task/goals/board.py +690 -0
  105. agents/task/goals/completion_judge.py +247 -0
  106. agents/task/goals/context.py +62 -0
  107. agents/task/goals/dispatcher.py +848 -0
  108. agents/task/goals/escalation.py +102 -0
  109. agents/task/goals/planner.py +114 -0
  110. agents/task/logging_config.py +155 -0
  111. agents/task/path.py +991 -0
  112. agents/task/robust_parse_config.py +400 -0
  113. agents/task/runtime/__init__.py +0 -0
  114. agents/task/runtime/run_as_session.py +130 -0
  115. agents/task/runtime_safety.py +308 -0
  116. agents/task/session/__init__.py +5 -0
  117. agents/task/session/browser_pool.py +130 -0
  118. agents/task/session/cleanup.py +612 -0
  119. agents/task/session/execution.py +397 -0
  120. agents/task/session/feed.py +200 -0
  121. agents/task/session/hitl_ingress.py +201 -0
  122. agents/task/session/hooks.py +83 -0
  123. agents/task/session/multi_agent.py +243 -0
  124. agents/task/session/workspace.py +91 -0
  125. agents/task/session_registry.py +91 -0
  126. agents/task/session_route.py +35 -0
  127. agents/task/sqlite_session_registry.py +261 -0
  128. agents/task/surface_config.py +185 -0
  129. agents/task/telemetry/__init__.py +58 -0
  130. agents/task/telemetry/event_log.py +180 -0
  131. agents/task/telemetry/formatters.py +642 -0
  132. agents/task/telemetry/manager.py +571 -0
  133. agents/task/telemetry/memory_events.py +66 -0
  134. agents/task/telemetry/self_events.py +53 -0
  135. agents/task/telemetry/sequence.py +160 -0
  136. agents/task/telemetry/service.py +1265 -0
  137. agents/task/telemetry/views.py +882 -0
  138. agents/task/templates.py +98 -0
  139. agents/task/test_aux_chain.py +151 -0
  140. agents/task/test_aux_router.py +41 -0
  141. agents/task/tests/test_parse_robustness.py +170 -0
  142. agents/task/tool_defaults.py +110 -0
  143. agents/task/utils.py +762 -0
  144. agents/task/utils_json.py +768 -0
  145. agents/task/utils_webview.py +86 -0
  146. agents/task/workspace_context.py +229 -0
  147. agents/task_agent_lite.py +2398 -0
  148. api/README.md +683 -0
  149. api/TASK_API_DOCS.md +151 -0
  150. api/__init__.py +0 -0
  151. api/a2a/__init__.py +37 -0
  152. api/a2a/agent_card.py +337 -0
  153. api/a2a/client.py +657 -0
  154. api/a2a/endpoints.py +375 -0
  155. api/a2a/models.py +268 -0
  156. api/a2a/streaming.py +419 -0
  157. api/a2a/task_handler.py +929 -0
  158. api/admin_endpoints.py +1584 -0
  159. api/app.py +923 -0
  160. api/auth_constants.py +28 -0
  161. api/auth_endpoints.py +402 -0
  162. api/auth_state.py +36 -0
  163. api/chat_via_task.py +42 -0
  164. api/conversation_manager.py +231 -0
  165. api/dependencies.py +186 -0
  166. api/eip8004_endpoints.py +452 -0
  167. api/hyperliquid_models.py +174 -0
  168. api/hyperliquid_routes.py +450 -0
  169. api/interfaces.py +207 -0
  170. api/jwt_middleware.py +83 -0
  171. api/kb/__init__.py +1 -0
  172. api/kb/endpoints.py +314 -0
  173. api/mcp_models.py +279 -0
  174. api/mcp_routes.py +438 -0
  175. api/middleware.py +526 -0
  176. api/models.py +176 -0
  177. api/openai_compat/__init__.py +0 -0
  178. api/openai_compat/model_map.py +25 -0
  179. api/openai_compat/models.py +59 -0
  180. api/openai_compat/router.py +140 -0
  181. api/payment_endpoints.py +444 -0
  182. api/payment_verification.py +138 -0
  183. api/polymarket_models.py +155 -0
  184. api/polymarket_routes.py +501 -0
  185. api/pricing_endpoints.py +89 -0
  186. api/session_routing.py +54 -0
  187. api/skill_endpoints.py +453 -0
  188. api/task_http_api.py +1856 -0
  189. api/webhooks.py +52 -0
  190. api/x402_endpoints.py +280 -0
  191. avatar/README.md +44 -0
  192. avatar/__init__.py +1 -0
  193. avatar/config/rob.json +7 -0
  194. avatar/mindprint.js +395 -0
  195. avatar/renders/rob.meta.json +20 -0
  196. avatar/renders/rob.png +0 -0
  197. avatar/studio.html +373 -0
  198. avatar/webview/avatar-live.js +44 -0
  199. cli/README.md +89 -0
  200. cli/__init__.py +0 -0
  201. cli/cli_surface.py +67 -0
  202. cli/commands/__init__.py +0 -0
  203. cli/commands/_bootstrap.py +49 -0
  204. cli/commands/_errors.py +53 -0
  205. cli/commands/chat.py +973 -0
  206. cli/commands/config.py +100 -0
  207. cli/commands/dashboard.py +103 -0
  208. cli/commands/doctor.py +222 -0
  209. cli/commands/email.py +174 -0
  210. cli/commands/gateway.py +346 -0
  211. cli/commands/goals.py +509 -0
  212. cli/commands/init.py +276 -0
  213. cli/commands/journey.py +30 -0
  214. cli/commands/kb.py +280 -0
  215. cli/commands/model.py +107 -0
  216. cli/commands/owner.py +431 -0
  217. cli/commands/pfp.py +232 -0
  218. cli/commands/run.py +414 -0
  219. cli/commands/serve.py +41 -0
  220. cli/commands/session.py +684 -0
  221. cli/commands/skill_install.py +786 -0
  222. cli/commands/skills.py +128 -0
  223. cli/commands/subagents.py +100 -0
  224. cli/commands/surface.py +81 -0
  225. cli/commands/telegram.py +185 -0
  226. cli/commands/todos.py +209 -0
  227. cli/commands/tools.py +102 -0
  228. cli/commands/update.py +316 -0
  229. cli/commands/whatsapp.py +175 -0
  230. cli/config_store.py +276 -0
  231. cli/gitignore.py +43 -0
  232. cli/inventory.py +46 -0
  233. cli/keys.py +101 -0
  234. cli/persona.py +17 -0
  235. cli/polyrob.py +137 -0
  236. cli/ui/__init__.py +64 -0
  237. cli/ui/activity.py +141 -0
  238. cli/ui/app.py +538 -0
  239. cli/ui/banner.py +233 -0
  240. cli/ui/blocks.py +534 -0
  241. cli/ui/bootstrap_notice.py +47 -0
  242. cli/ui/commands/__init__.py +41 -0
  243. cli/ui/commands/h_cron.py +64 -0
  244. cli/ui/commands/h_journey.py +164 -0
  245. cli/ui/commands/h_kb.py +121 -0
  246. cli/ui/commands/h_learn.py +100 -0
  247. cli/ui/commands/h_mcp.py +215 -0
  248. cli/ui/commands/h_self.py +113 -0
  249. cli/ui/commands/h_skills.py +314 -0
  250. cli/ui/commands/handlers.py +1672 -0
  251. cli/ui/commands/registry.py +426 -0
  252. cli/ui/dialog.py +425 -0
  253. cli/ui/event_registry.py +110 -0
  254. cli/ui/event_specs.py +45 -0
  255. cli/ui/events.py +433 -0
  256. cli/ui/identity.py +15 -0
  257. cli/ui/lifecycle.py +159 -0
  258. cli/ui/live_hooks.py +43 -0
  259. cli/ui/model_selector.py +532 -0
  260. cli/ui/persistent_loop.py +181 -0
  261. cli/ui/pick.py +113 -0
  262. cli/ui/plain_renderer.py +351 -0
  263. cli/ui/renderer.py +384 -0
  264. cli/ui/rich_renderer.py +533 -0
  265. cli/ui/secrets.py +115 -0
  266. cli/ui/state.py +376 -0
  267. cli/ui/statusbar.py +231 -0
  268. cli/ui/streaming.py +254 -0
  269. cli/ui/terminal_render.py +105 -0
  270. cli/ui/theme.py +132 -0
  271. cli/update/__init__.py +6 -0
  272. cli/update/context.py +121 -0
  273. cli/update/detect.py +176 -0
  274. cli/update/engine.py +95 -0
  275. cli/update/migrate_guarded.py +55 -0
  276. cli/update/process_guard.py +215 -0
  277. cli/update/runners.py +78 -0
  278. cli/update/snapshot.py +270 -0
  279. cli/update/versions.py +205 -0
  280. core/README.md +377 -0
  281. core/__init__.py +174 -0
  282. core/assets.py +87 -0
  283. core/async_bridge.py +76 -0
  284. core/autonomy_runtime.py +259 -0
  285. core/base_component.py +302 -0
  286. core/bootstrap.py +681 -0
  287. core/bot.py +310 -0
  288. core/config.py +833 -0
  289. core/constants.py +126 -0
  290. core/container.py +462 -0
  291. core/db_manifest.py +101 -0
  292. core/embedding.py +57 -0
  293. core/env.py +39 -0
  294. core/exceptions.py +287 -0
  295. core/flags.py +144 -0
  296. core/flags_catalog.py +345 -0
  297. core/home_migration.py +62 -0
  298. core/identity.py +118 -0
  299. core/initialization.py +1004 -0
  300. core/instance.py +537 -0
  301. core/interactive_gate.py +96 -0
  302. core/logging.py +436 -0
  303. core/owner_doc_writer.py +176 -0
  304. core/pairing.py +193 -0
  305. core/path_safety.py +20 -0
  306. core/paths.py +27 -0
  307. core/payment_config.py +21 -0
  308. core/permissions.py +322 -0
  309. core/runtime_config.py +96 -0
  310. core/runtime_paths.py +135 -0
  311. core/seams.py +35 -0
  312. core/secret_scan.py +25 -0
  313. core/secret_scrub.py +71 -0
  314. core/secrets.py +35 -0
  315. core/security_logging_filter.py +148 -0
  316. core/self_context_writer.py +333 -0
  317. core/self_evolution.py +294 -0
  318. core/session_context.py +47 -0
  319. core/sqlite_util.py +59 -0
  320. core/surfaces/__init__.py +16 -0
  321. core/surfaces/access.py +118 -0
  322. core/surfaces/binding.py +88 -0
  323. core/surfaces/bootstrap.py +78 -0
  324. core/surfaces/circuit.py +168 -0
  325. core/surfaces/continuity.py +77 -0
  326. core/surfaces/correspondents.py +228 -0
  327. core/surfaces/dispatcher.py +216 -0
  328. core/surfaces/envelopes.py +75 -0
  329. core/surfaces/gc.py +30 -0
  330. core/surfaces/idempotency.py +57 -0
  331. core/surfaces/inbound_webhook.py +124 -0
  332. core/surfaces/media.py +32 -0
  333. core/surfaces/message_router.py +91 -0
  334. core/surfaces/outbound_allowlist.py +80 -0
  335. core/surfaces/outbound_dispatcher.py +110 -0
  336. core/surfaces/outbound_mirror.py +37 -0
  337. core/surfaces/outbound_queue.py +107 -0
  338. core/surfaces/outbound_target.py +12 -0
  339. core/surfaces/owner_admin.py +29 -0
  340. core/surfaces/proactive.py +78 -0
  341. core/surfaces/progress.py +108 -0
  342. core/surfaces/rate_bucket.py +21 -0
  343. core/surfaces/registry.py +51 -0
  344. core/surfaces/rendering.py +53 -0
  345. core/surfaces/send_policy.py +14 -0
  346. core/surfaces/serialize.py +24 -0
  347. core/surfaces/session_chat_registry.py +112 -0
  348. core/surfaces/session_policy.py +60 -0
  349. core/surfaces/surface.py +288 -0
  350. core/surfaces/transcription.py +88 -0
  351. core/surfaces/voice_echo.py +26 -0
  352. core/surfaces/voice_guard.py +23 -0
  353. core/tickers.py +174 -0
  354. core/tool_catalog.py +192 -0
  355. core/version.py +75 -0
  356. core/wallet/__init__.py +5 -0
  357. core/wallet/agent_wallet.py +59 -0
  358. core/wallet/audit_sink.py +61 -0
  359. core/wallet/config.py +70 -0
  360. core/wallet/factory.py +87 -0
  361. core/wallet/policy.py +104 -0
  362. core/wallet/signer.py +51 -0
  363. cron/README.md +65 -0
  364. cron/__init__.py +0 -0
  365. cron/delivery.py +277 -0
  366. cron/digest.py +124 -0
  367. cron/jobs.py +207 -0
  368. cron/runner.py +297 -0
  369. cron/schedule.py +194 -0
  370. cron/scheduler.py +201 -0
  371. cron/service.py +57 -0
  372. cron/wake_gate.py +223 -0
  373. data/__init__.py +1 -0
  374. data/prompts/autov2_prompts.json +87 -0
  375. data/prompts/skills/browser-automation/SKILL.md +43 -0
  376. data/prompts/skills/coding-workflow/SKILL.md +36 -0
  377. data/prompts/skills/crypto-trading-safety/SKILL.md +53 -0
  378. data/prompts/skills/document-writing/SKILL.md +33 -0
  379. data/prompts/skills/email-comms/SKILL.md +33 -0
  380. data/prompts/skills/file-data-ops/SKILL.md +35 -0
  381. data/prompts/skills/hyperliquid-account-review/SKILL.md +48 -0
  382. data/prompts/skills/hyperliquid-market-data/SKILL.md +52 -0
  383. data/prompts/skills/hyperliquid-trading/SKILL.md +59 -0
  384. data/prompts/skills/lead-research/SKILL.md +86 -0
  385. data/prompts/skills/market-research-brief/SKILL.md +104 -0
  386. data/prompts/skills/person-analyzer/SKILL.md +124 -0
  387. data/prompts/skills/polymarket-market-research/SKILL.md +54 -0
  388. data/prompts/skills/polymarket-portfolio-review/SKILL.md +50 -0
  389. data/prompts/skills/polymarket-trading/SKILL.md +55 -0
  390. data/prompts/skills/presentation-creator/SKILL.md +36 -0
  391. data/prompts/skills/project-analyzer/SKILL.md +169 -0
  392. data/prompts/skills/rules.json +268 -0
  393. data/prompts/skills/secret-handling/SKILL.md +20 -0
  394. data/prompts/skills/skill-authoring/SKILL.md +31 -0
  395. data/prompts/skills/skill-security-review/SKILL.md +27 -0
  396. data/prompts/skills/social-discovery/SKILL.md +57 -0
  397. data/prompts/skills/task-planning/SKILL.md +33 -0
  398. data/prompts/skills/web-research/SKILL.md +39 -0
  399. data/prompts/skills/web-scraping/SKILL.md +73 -0
  400. data/prompts/skills/x-engagement/SKILL.md +69 -0
  401. data/prompts/system_prompts.json +4 -0
  402. data/subscription_channels.json +5 -0
  403. modules/README.md +503 -0
  404. modules/__init__.py +124 -0
  405. modules/auth/__init__.py +13 -0
  406. modules/auth/api_key_manager.py +157 -0
  407. modules/auth/identity_mapper.py +380 -0
  408. modules/auth/siwe_auth.py +273 -0
  409. modules/auth/tier_manager.py +173 -0
  410. modules/base_module.py +190 -0
  411. modules/credits/__init__.py +29 -0
  412. modules/credits/balance_manager.py +237 -0
  413. modules/credits/cost_utils.py +129 -0
  414. modules/credits/pricing.py +139 -0
  415. modules/credits/unified_ledger.py +148 -0
  416. modules/credits/usage_meter.py +310 -0
  417. modules/credits/usage_tracker.py +674 -0
  418. modules/database/__init__.py +33 -0
  419. modules/database/audit_log.py +322 -0
  420. modules/database/auth_tables.py +276 -0
  421. modules/database/connection.py +644 -0
  422. modules/database/connection_pool.py +305 -0
  423. modules/database/conversation_contexts.py +446 -0
  424. modules/database/database_manager.py +237 -0
  425. modules/database/hyperliquid.py +375 -0
  426. modules/database/polymarket.py +574 -0
  427. modules/database/user_mcp_servers.py +728 -0
  428. modules/database/user_profiles.py +562 -0
  429. modules/database/utils.py +107 -0
  430. modules/database/x402_tables.py +92 -0
  431. modules/eip8004/README.md +1069 -0
  432. modules/eip8004/__init__.py +65 -0
  433. modules/eip8004/contracts.py +498 -0
  434. modules/eip8004/models.py +271 -0
  435. modules/eip8004/registration.py +148 -0
  436. modules/eip8004/reputation.py +367 -0
  437. modules/eip8004/validation.py +277 -0
  438. modules/llm/__init__.py +97 -0
  439. modules/llm/adapters.py +963 -0
  440. modules/llm/anthropic_client.py +831 -0
  441. modules/llm/available_models.py +106 -0
  442. modules/llm/brain_scrubber.py +137 -0
  443. modules/llm/cache_hints.py +162 -0
  444. modules/llm/deepseek_client.py +562 -0
  445. modules/llm/gemini_client.py +1367 -0
  446. modules/llm/llm_client.py +1139 -0
  447. modules/llm/llm_client_registry.py +188 -0
  448. modules/llm/llm_factory.py +237 -0
  449. modules/llm/llm_manager.py +1036 -0
  450. modules/llm/messages.py +374 -0
  451. modules/llm/model_registry.py +1935 -0
  452. modules/llm/nvidia_client.py +125 -0
  453. modules/llm/openai_client.py +800 -0
  454. modules/llm/openrouter_client.py +969 -0
  455. modules/llm/profiles.py +178 -0
  456. modules/llm/test_anthropic_cache_capture.py +35 -0
  457. modules/llm/test_cached_pricing_derivation.py +24 -0
  458. modules/llm/think_scrubber.py +444 -0
  459. modules/llm/token_counter.py +268 -0
  460. modules/memory/__init__.py +35 -0
  461. modules/memory/backend_factory.py +83 -0
  462. modules/memory/cache_manager.py +186 -0
  463. modules/memory/episodic.py +108 -0
  464. modules/memory/local_vector_memory_provider.py +552 -0
  465. modules/memory/memory_manager.py +225 -0
  466. modules/memory/models.py +312 -0
  467. modules/memory/provider.py +210 -0
  468. modules/memory/registry.py +205 -0
  469. modules/memory/sqlite_memory_provider.py +767 -0
  470. modules/memory/task/__init__.py +52 -0
  471. modules/memory/task/compaction_manager.py +157 -0
  472. modules/memory/task/context_retriever.py +809 -0
  473. modules/memory/task/hierarchical_memory.py +1002 -0
  474. modules/memory/task/lexical_retriever.py +71 -0
  475. modules/memory/task/null_context_manager.py +213 -0
  476. modules/memory/task/phase_manager.py +625 -0
  477. modules/memory/task/reflection_service.py +157 -0
  478. modules/memory/task/semantic_retriever.py +218 -0
  479. modules/memory/task/task_context_manager.py +1165 -0
  480. modules/memory/task/test_forgetting_engages.py +24 -0
  481. modules/memory/task/test_reflection_llm.py +39 -0
  482. modules/memory/task/test_threat_scan.py +53 -0
  483. modules/memory/task/threat_scan.py +90 -0
  484. modules/memory/test_sqlite_memory_provider.py +106 -0
  485. modules/memory/user_profile_manager.py +527 -0
  486. modules/payments/__init__.py +11 -0
  487. modules/payments/deposit_monitor.py +478 -0
  488. modules/payments/price_oracle.py +70 -0
  489. modules/payments/treasury_sweeper.py +401 -0
  490. modules/payments/wallet_generator.py +145 -0
  491. modules/pfp/__init__.py +1 -0
  492. modules/pfp/config.py +92 -0
  493. modules/pfp/mesh.py +565 -0
  494. modules/pfp/push.py +69 -0
  495. modules/pfp/renderer.py +85 -0
  496. modules/pfp/store.py +82 -0
  497. modules/skills/__init__.py +1 -0
  498. modules/skills/skill_usage.py +248 -0
  499. modules/transcription/__init__.py +24 -0
  500. modules/transcription/base.py +20 -0
  501. modules/transcription/faster_whisper_transcriber.py +55 -0
  502. modules/x402/README.md +249 -0
  503. modules/x402/__init__.py +22 -0
  504. modules/x402/invoicing.py +416 -0
  505. modules/x402/middleware.py +364 -0
  506. modules/x402/settlement_watcher.py +145 -0
  507. modules/x402/x402_integration.py +344 -0
  508. polyrob-0.5.0.dist-info/METADATA +482 -0
  509. polyrob-0.5.0.dist-info/RECORD +745 -0
  510. polyrob-0.5.0.dist-info/WHEEL +5 -0
  511. polyrob-0.5.0.dist-info/entry_points.txt +2 -0
  512. polyrob-0.5.0.dist-info/licenses/LICENSE +21 -0
  513. polyrob-0.5.0.dist-info/top_level.txt +12 -0
  514. surfaces/README.md +70 -0
  515. surfaces/__init__.py +0 -0
  516. surfaces/email/__init__.py +0 -0
  517. surfaces/email/dedup.py +44 -0
  518. surfaces/email/harness.py +187 -0
  519. surfaces/email/inbound.py +159 -0
  520. surfaces/email/seed.py +60 -0
  521. surfaces/email/surface.py +105 -0
  522. surfaces/telegram/__init__.py +0 -0
  523. surfaces/telegram/dedup.py +83 -0
  524. surfaces/telegram/harness.py +886 -0
  525. surfaces/telegram/inbound.py +145 -0
  526. surfaces/telegram/interactive_tools.py +49 -0
  527. surfaces/telegram/markdown.py +347 -0
  528. surfaces/telegram/rate_limit.py +167 -0
  529. surfaces/telegram/surface.py +187 -0
  530. surfaces/telegram/voice.py +52 -0
  531. surfaces/whatsapp/__init__.py +0 -0
  532. surfaces/whatsapp/client.py +51 -0
  533. surfaces/whatsapp/harness.py +62 -0
  534. surfaces/whatsapp/inbound.py +132 -0
  535. surfaces/whatsapp/surface.py +64 -0
  536. surfaces/whatsapp/window.py +34 -0
  537. tools/README.md +578 -0
  538. tools/__init__.py +424 -0
  539. tools/alchemy/__init__.py +5 -0
  540. tools/alchemy/alchemy_tool.py +389 -0
  541. tools/anysite/__init__.py +17 -0
  542. tools/anysite/client.py +88 -0
  543. tools/anysite/tool.py +89 -0
  544. tools/base_tool.py +468 -0
  545. tools/browser/__init__.py +22 -0
  546. tools/browser/actions.py +118 -0
  547. tools/browser/browser.py +1457 -0
  548. tools/browser/browser_manager.py +690 -0
  549. tools/browser/context.py +1748 -0
  550. tools/browser/playwright_utils.py +137 -0
  551. tools/browser/views.py +56 -0
  552. tools/code_exec/SANDBOX_SECURITY.md +144 -0
  553. tools/code_exec/__init__.py +178 -0
  554. tools/code_exec/backend.py +73 -0
  555. tools/code_exec/backends/__init__.py +1 -0
  556. tools/code_exec/backends/docker.py +779 -0
  557. tools/code_exec/backends/local_subprocess.py +154 -0
  558. tools/code_exec/env_policy.py +45 -0
  559. tools/code_exec/result.py +42 -0
  560. tools/code_exec/sandbox_guard.py +59 -0
  561. tools/code_exec/tool.py +227 -0
  562. tools/coding/__init__.py +50 -0
  563. tools/coding/edit.py +107 -0
  564. tools/coding/search.py +113 -0
  565. tools/coding/tool.py +353 -0
  566. tools/collabland/__init__.py +5 -0
  567. tools/collabland/collabland_tool.py +471 -0
  568. tools/controller/__init__.py +0 -0
  569. tools/controller/_helpers.py +165 -0
  570. tools/controller/action_registration.py +1765 -0
  571. tools/controller/approval.py +244 -0
  572. tools/controller/approval_interactive.py +90 -0
  573. tools/controller/delegation.py +195 -0
  574. tools/controller/execution.py +797 -0
  575. tools/controller/execution_context.py +141 -0
  576. tools/controller/hooks.py +135 -0
  577. tools/controller/introspection.py +344 -0
  578. tools/controller/mcp_registrar.py +190 -0
  579. tools/controller/message_send.py +31 -0
  580. tools/controller/registry/__init__.py +4 -0
  581. tools/controller/registry/schema_generators.py +395 -0
  582. tools/controller/registry/schema_sanitizer.py +406 -0
  583. tools/controller/registry/service.py +1266 -0
  584. tools/controller/registry/views.py +151 -0
  585. tools/controller/service.py +409 -0
  586. tools/controller/tool_management.py +334 -0
  587. tools/controller/types.py +28 -0
  588. tools/controller/views.py +450 -0
  589. tools/cronjob_tools.py +166 -0
  590. tools/crypto_trade_gate.py +59 -0
  591. tools/descriptors.py +452 -0
  592. tools/dom/__init__.py +0 -0
  593. tools/dom/history_tree_processor/service.py +244 -0
  594. tools/dom/history_tree_processor/view.py +99 -0
  595. tools/dom/service.py +471 -0
  596. tools/dom/views.py +346 -0
  597. tools/email_tool.py +403 -0
  598. tools/exceptions.py +169 -0
  599. tools/filesystem.py +1038 -0
  600. tools/filesystem_docproc.py +331 -0
  601. tools/filesystem_pdf.py +755 -0
  602. tools/git/__init__.py +42 -0
  603. tools/git/tool.py +258 -0
  604. tools/github/__init__.py +33 -0
  605. tools/github/client.py +94 -0
  606. tools/github/tool.py +237 -0
  607. tools/goal_tools.py +309 -0
  608. tools/hyperliquid/__init__.py +56 -0
  609. tools/hyperliquid/models.py +233 -0
  610. tools/hyperliquid/service.py +1680 -0
  611. tools/knowledge_ingest.py +950 -0
  612. tools/mcp/README.md +535 -0
  613. tools/mcp/__init__.py +52 -0
  614. tools/mcp/catalog.py +115 -0
  615. tools/mcp/config.py +337 -0
  616. tools/mcp/mcp_tool.py +1415 -0
  617. tools/mcp/param_coercion.py +289 -0
  618. tools/mcp/protocol.py +1520 -0
  619. tools/mcp/rate_limit.py +50 -0
  620. tools/mcp/security.py +424 -0
  621. tools/mcp/self_install.py +111 -0
  622. tools/mcp/server_manager.py +829 -0
  623. tools/mcp/subscriptions.py +72 -0
  624. tools/mcp/user_mcp_service.py +899 -0
  625. tools/mcp/validation_tracker.py +89 -0
  626. tools/mcp/views.py +346 -0
  627. tools/oauth/__init__.py +17 -0
  628. tools/oauth/manager.py +75 -0
  629. tools/oauth/provider.py +69 -0
  630. tools/oauth/providers/__init__.py +1 -0
  631. tools/oauth/providers/generic_oauth2.py +96 -0
  632. tools/perplexity_tool.py +280 -0
  633. tools/polymarket/__init__.py +41 -0
  634. tools/polymarket/clob_adapter.py +76 -0
  635. tools/polymarket/models.py +182 -0
  636. tools/polymarket/service.py +2082 -0
  637. tools/self_env/__init__.py +61 -0
  638. tools/self_env/tool.py +274 -0
  639. tools/shell/__init__.py +80 -0
  640. tools/shell/backend_pool.py +118 -0
  641. tools/shell/discipline.py +62 -0
  642. tools/shell/executor.py +157 -0
  643. tools/shell/loopback_allow.py +87 -0
  644. tools/shell/process_registry.py +113 -0
  645. tools/shell/process_tool.py +131 -0
  646. tools/shell/state.py +129 -0
  647. tools/shell/tool.py +151 -0
  648. tools/task_tool.py +637 -0
  649. tools/twitter_tool.py +2086 -0
  650. tools/user_directory.py +179 -0
  651. tools/web_fetch/__init__.py +3 -0
  652. tools/web_fetch/fetcher.py +144 -0
  653. tools/web_fetch/render.py +44 -0
  654. tools/web_fetch/tool.py +60 -0
  655. tools/x402/__init__.py +60 -0
  656. tools/x402/client.py +47 -0
  657. tools/x402/invoice_tool.py +143 -0
  658. tools/x402/real_client.py +250 -0
  659. tools/x402/service.py +131 -0
  660. utils/README.md +498 -0
  661. utils/__init__.py +46 -0
  662. utils/auth_utils.py +114 -0
  663. utils/bounded_collections.py +162 -0
  664. utils/circuit_breaker.py +389 -0
  665. utils/gif_utils.py +435 -0
  666. utils/markdown_utils.py +291 -0
  667. utils/message_utils.py +147 -0
  668. utils/metrics.py +36 -0
  669. utils/path_validator.py +227 -0
  670. utils/rate_limit_manager.py +399 -0
  671. utils/result_size.py +296 -0
  672. utils/time_utils.py +206 -0
  673. utils/user_utils.py +188 -0
  674. webview/README.md +111 -0
  675. webview/RENAME_AND_ALIGN_HANDOFF.md +200 -0
  676. webview/__init__.py +8 -0
  677. webview/activity.py +636 -0
  678. webview/owner_auth.py +108 -0
  679. webview/pages.py +448 -0
  680. webview/repair_sessions.py +399 -0
  681. webview/server.py +4323 -0
  682. webview/server_launcher.py +112 -0
  683. webview/static/css/activity.css +89 -0
  684. webview/static/css/chat.css +1772 -0
  685. webview/static/css/components.css +1190 -0
  686. webview/static/css/config-panel.css +651 -0
  687. webview/static/css/pages/admin.css +766 -0
  688. webview/static/css/pages/profile.css +231 -0
  689. webview/static/css/pages/settings.css +297 -0
  690. webview/static/css/pages/signin.css +316 -0
  691. webview/static/css/style.css +4250 -0
  692. webview/static/css/variables.css +147 -0
  693. webview/static/css/workspace-fullwidth-fix.css +109 -0
  694. webview/static/img/favicon.ico +0 -0
  695. webview/static/js/activity.js +394 -0
  696. webview/static/js/admin/activity.js +186 -0
  697. webview/static/js/admin/dashboard.js +163 -0
  698. webview/static/js/admin/user_detail.js +400 -0
  699. webview/static/js/admin/users.js +174 -0
  700. webview/static/js/admin/utils.js +214 -0
  701. webview/static/js/chat.js +3178 -0
  702. webview/static/js/config-panel.js +522 -0
  703. webview/static/js/constants.js +20 -0
  704. webview/static/js/error-handler.js +226 -0
  705. webview/static/js/ethers.min.js +1 -0
  706. webview/static/js/event-filter.js +137 -0
  707. webview/static/js/event-store.js +351 -0
  708. webview/static/js/file-attachments.js +374 -0
  709. webview/static/js/file-loader.js +163 -0
  710. webview/static/js/index.js +103 -0
  711. webview/static/js/performance-utils.js +196 -0
  712. webview/static/js/profile.js +396 -0
  713. webview/static/js/screenshot.js +386 -0
  714. webview/static/js/session.js +1527 -0
  715. webview/static/js/settings.js +1288 -0
  716. webview/static/js/sidebar-data.js +766 -0
  717. webview/static/js/sidebar-toggle.js +296 -0
  718. webview/static/js/socket.io.min.js +7 -0
  719. webview/static/js/stats.js +355 -0
  720. webview/static/js/ui-utils.js +420 -0
  721. webview/static/js/workspace.js +966 -0
  722. webview/stats_service.py +613 -0
  723. webview/templates/__init__.py +1 -0
  724. webview/templates/__pycache__/__init__.cpython-311.pyc +0 -0
  725. webview/templates/activity.html +36 -0
  726. webview/templates/admin/activity.html +122 -0
  727. webview/templates/admin/dashboard.html +112 -0
  728. webview/templates/admin/user_detail.html +228 -0
  729. webview/templates/admin/users.html +105 -0
  730. webview/templates/autonomy.html +60 -0
  731. webview/templates/error.html +133 -0
  732. webview/templates/finance.html +90 -0
  733. webview/templates/identity.html +73 -0
  734. webview/templates/index.html +42 -0
  735. webview/templates/layout.html +125 -0
  736. webview/templates/memory.html +59 -0
  737. webview/templates/owner_login.html +15 -0
  738. webview/templates/profile.html +121 -0
  739. webview/templates/session.html +312 -0
  740. webview/templates/settings.html +534 -0
  741. webview/templates/sidebar.html +112 -0
  742. webview/templates/signin.html +213 -0
  743. webview/templates/status.html +14 -0
  744. webview/templates/system.html +45 -0
  745. webview/webgate.py +196 -0
api/admin_endpoints.py ADDED
@@ -0,0 +1,1584 @@
1
+ """Admin management endpoints for user and credit management.
2
+
3
+ Phase 1 MVP endpoints:
4
+ - User search: GET /api/admin/users/search
5
+ - Token verification: POST /api/admin/users/{user_id}/verify-token
6
+ - User audit trail: GET /api/admin/users/{user_id}/audit
7
+ - User sessions: GET /api/admin/users/{user_id}/sessions
8
+ - Block/unblock: POST /api/admin/users/{user_id}/block, /unblock
9
+ - Enhanced stats: GET /api/admin/stats/dashboard
10
+ - Activity feed: GET /api/admin/activity
11
+ """
12
+
13
+ from fastapi import APIRouter, HTTPException, Request, Depends, Query
14
+ from pydantic import BaseModel, Field
15
+ from typing import List, Optional, Dict, Any, Literal
16
+ import logging
17
+ from datetime import datetime, timedelta
18
+ from api.auth_constants import (
19
+ VALID_ROLES,
20
+ VALID_TIERS,
21
+ validate_role,
22
+ validate_assignable_role,
23
+ validate_tier,
24
+ extract_admin_info,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def get_client_ip(request: Request) -> str:
31
+ """Get client IP from request, handling proxies."""
32
+ # Check X-Forwarded-For header (set by nginx/proxies)
33
+ forwarded_for = request.headers.get("X-Forwarded-For")
34
+ if forwarded_for:
35
+ # Take the first IP in the chain (original client)
36
+ return forwarded_for.split(",")[0].strip()
37
+ # Fall back to direct client IP
38
+ return request.client.host if request.client else "unknown"
39
+
40
+
41
+ async def get_audit_logger():
42
+ """Get audit logger instance."""
43
+ try:
44
+ from core.container import DependencyContainer
45
+ container = DependencyContainer.get_instance()
46
+ db = container.get_service('database_manager')
47
+ if db:
48
+ from modules.database.audit_log import AuditLogger
49
+ return AuditLogger(db)
50
+ except Exception as e:
51
+ logger.warning(f"Could not get audit logger: {e}")
52
+ return None
53
+
54
+ router = APIRouter(prefix="/admin", tags=["admin"])
55
+
56
+
57
+ # Request/Response Models
58
+
59
+ class AddCreditsRequest(BaseModel):
60
+ """Request to add credits to user."""
61
+ amount: int
62
+ reason: str
63
+ transaction_type: str = "admin_grant"
64
+
65
+
66
+ class SetRoleRequest(BaseModel):
67
+ """Request to set user role."""
68
+ role: str
69
+
70
+
71
+ class SetTierRequest(BaseModel):
72
+ """Request to set user tier."""
73
+ tier: str
74
+
75
+
76
+ class UserInfo(BaseModel):
77
+ """User information response."""
78
+ user_id: str
79
+ wallet_address: Optional[str]
80
+ email: Optional[str]
81
+ tier: Optional[str]
82
+ role: str
83
+ den_token_count: int
84
+ balance: int
85
+ lifetime_earned: int
86
+ lifetime_spent: int
87
+ created_at: str
88
+
89
+
90
+ class CreditTransaction(BaseModel):
91
+ """Credit transaction info."""
92
+ amount: int
93
+ transaction_type: str
94
+ reason: str
95
+ balance_before: int
96
+ balance_after: int
97
+ timestamp: str
98
+
99
+
100
+ class BlockUserRequest(BaseModel):
101
+ """Request to block a user."""
102
+ reason: str = Field(..., min_length=1, description="Reason for blocking")
103
+
104
+
105
+ class UserSearchResult(BaseModel):
106
+ """User search result."""
107
+ user_id: str
108
+ wallet_address: Optional[str]
109
+ email: Optional[str]
110
+ tier: Optional[str]
111
+ role: str
112
+ den_token_count: int
113
+ balance: int
114
+ created_at: str
115
+ is_blocked: bool = False
116
+
117
+
118
+ class TokenVerificationResult(BaseModel):
119
+ """Token verification result."""
120
+ previous_count: int
121
+ new_count: int
122
+ previous_tier: str
123
+ new_tier: str
124
+ tier_changed: bool
125
+ token_ids: List[str]
126
+ verified_at: str
127
+ bonuses_granted: int = 0
128
+ bonuses_already_claimed: int = 0
129
+
130
+
131
+ class AuditEvent(BaseModel):
132
+ """Audit event."""
133
+ id: int
134
+ timestamp: str
135
+ event_type: str
136
+ actor_id: Optional[str]
137
+ actor_wallet: Optional[str]
138
+ actor_ip: Optional[str]
139
+ target_id: Optional[str]
140
+ action: str
141
+ old_value: Optional[str]
142
+ new_value: Optional[str]
143
+ success: bool
144
+
145
+
146
+ class SessionUsageSummary(BaseModel):
147
+ """Session usage summary."""
148
+ session_id: str
149
+ total_cost: int
150
+ total_calls: int
151
+ timestamp: str
152
+
153
+
154
+ class UserSessionsResponse(BaseModel):
155
+ """User sessions response."""
156
+ total_sessions: int
157
+ total_spent: int
158
+ sessions: List[SessionUsageSummary]
159
+
160
+
161
+ class DashboardStats(BaseModel):
162
+ """Enhanced dashboard statistics."""
163
+ users: Dict[str, Any]
164
+ credits: Dict[str, Any]
165
+ sessions: Dict[str, Any]
166
+ revenue: Dict[str, Any]
167
+ alerts: List[Dict[str, Any]]
168
+
169
+
170
+ class BlockStatusResponse(BaseModel):
171
+ """Block status response."""
172
+ is_blocked: bool
173
+ blocked_reason: Optional[str]
174
+ blocked_at: Optional[str]
175
+ blocked_by: Optional[str]
176
+
177
+
178
+ class BillingFailure(BaseModel):
179
+ """Billing failure record."""
180
+ id: int
181
+ user_id: str
182
+ session_id: str
183
+ request_id: str
184
+ credits_owed: int
185
+ api_cost_usd: float
186
+ model: Optional[str]
187
+ created_at: str
188
+ resolved_at: Optional[str]
189
+ status: str
190
+ resolution_notes: Optional[str]
191
+ current_balance: Optional[int] = None
192
+ wallet_address: Optional[str] = None
193
+
194
+
195
+ class BillingFailuresResponse(BaseModel):
196
+ """Billing failures list response."""
197
+ failures: List[BillingFailure]
198
+ total: int
199
+ status_filter: str
200
+ total_credits_owed: int
201
+ total_api_cost_usd: float
202
+
203
+
204
+ class ResolveBillingFailureRequest(BaseModel):
205
+ """Request to resolve a billing failure."""
206
+ resolution: Literal["charged", "written_off", "refunded"]
207
+ notes: str = ""
208
+
209
+
210
+ # Dependency: Check admin access
211
+
212
+ async def require_admin(request: Request):
213
+ """Require admin access for endpoint.
214
+
215
+ Reads the SINGLE canonical admin-truth source: `role` (+ `wallet_address`)
216
+ on request.state, via the same extract_admin_info()/is_admin() helper
217
+ verify_payment_for_request uses. Deliberately does NOT read
218
+ request.state.is_admin — that was a second, independently-set truth
219
+ source (some middlewares set it, some didn't) that could diverge from
220
+ `role` (H1 fix: an owner-login session had role="owner", tier="admin" but
221
+ was denied here pre-fix because "owner" wasn't recognized as admin).
222
+
223
+ Admin access is granted via:
224
+ 1. role in ADMIN_ROLES (currently 'admin' or 'owner')
225
+ 2. wallet address in ADMIN_WALLETS env var
226
+ """
227
+ admin_status, _role, _wallet = extract_admin_info(request.state)
228
+ if not admin_status:
229
+ raise HTTPException(
230
+ status_code=403,
231
+ detail="Admin access required"
232
+ )
233
+ return True
234
+
235
+
236
+ # Endpoints
237
+
238
+ @router.get("/users", dependencies=[Depends(require_admin)])
239
+ async def list_users(
240
+ request: Request,
241
+ limit: int = 50,
242
+ offset: int = 0,
243
+ tier: Optional[str] = None,
244
+ role: Optional[str] = None
245
+ ) -> List[UserInfo]:
246
+ """List all users (admin only).
247
+
248
+ Query Parameters:
249
+ limit: Max results (default 50)
250
+ offset: Pagination offset (default 0)
251
+ tier: Filter by tier (optional)
252
+ role: Filter by role (optional)
253
+ """
254
+ from core.container import DependencyContainer
255
+ container = DependencyContainer.get_instance()
256
+ db = container.get_service('database_manager')
257
+
258
+ if not db:
259
+ raise HTTPException(status_code=503, detail="Database unavailable")
260
+
261
+ # Build query with filters
262
+ where_clauses = []
263
+ params = []
264
+
265
+ if tier:
266
+ where_clauses.append("u.tier = ?")
267
+ params.append(tier)
268
+
269
+ if role:
270
+ where_clauses.append("u.role = ?")
271
+ params.append(role)
272
+
273
+ where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
274
+
275
+ params.extend([limit, offset])
276
+
277
+ users = await db.fetch_all(f"""
278
+ SELECT
279
+ u.user_id,
280
+ u.wallet_address,
281
+ u.email,
282
+ u.tier,
283
+ u.role,
284
+ u.den_token_count,
285
+ u.created_at,
286
+ c.balance,
287
+ c.lifetime_earned,
288
+ c.lifetime_spent
289
+ FROM user_profiles u
290
+ LEFT JOIN user_credits c ON u.user_id = c.user_id
291
+ {where_sql}
292
+ ORDER BY u.created_at DESC
293
+ LIMIT ? OFFSET ?
294
+ """, tuple(params))
295
+
296
+ return [
297
+ UserInfo(
298
+ user_id=u['user_id'],
299
+ wallet_address=u['wallet_address'],
300
+ email=u['email'],
301
+ tier=u['tier'],
302
+ role=u['role'] or 'user',
303
+ den_token_count=u['den_token_count'] or 0,
304
+ balance=u['balance'] or 0,
305
+ lifetime_earned=u['lifetime_earned'] or 0,
306
+ lifetime_spent=u['lifetime_spent'] or 0,
307
+ created_at=u['created_at']
308
+ )
309
+ for u in users
310
+ ]
311
+
312
+
313
+ @router.get("/users/{user_id}", dependencies=[Depends(require_admin)])
314
+ async def get_user(request: Request, user_id: str) -> UserInfo:
315
+ """Get user details (admin only)."""
316
+ from core.container import DependencyContainer
317
+ container = DependencyContainer.get_instance()
318
+ db = container.get_service('database_manager')
319
+
320
+ if not db:
321
+ raise HTTPException(status_code=503, detail="Database unavailable")
322
+
323
+ user = await db.fetch_one("""
324
+ SELECT
325
+ u.user_id,
326
+ u.wallet_address,
327
+ u.email,
328
+ u.tier,
329
+ u.role,
330
+ u.den_token_count,
331
+ u.created_at,
332
+ c.balance,
333
+ c.lifetime_earned,
334
+ c.lifetime_spent
335
+ FROM user_profiles u
336
+ LEFT JOIN user_credits c ON u.user_id = c.user_id
337
+ WHERE u.user_id = ?
338
+ """, (user_id,))
339
+
340
+ if not user:
341
+ raise HTTPException(status_code=404, detail="User not found")
342
+
343
+ return UserInfo(
344
+ user_id=user['user_id'],
345
+ wallet_address=user['wallet_address'],
346
+ email=user['email'],
347
+ tier=user['tier'],
348
+ role=user['role'] or 'user',
349
+ den_token_count=user['den_token_count'] or 0,
350
+ balance=user['balance'] or 0,
351
+ lifetime_earned=user['lifetime_earned'] or 0,
352
+ lifetime_spent=user['lifetime_spent'] or 0,
353
+ created_at=user['created_at']
354
+ )
355
+
356
+
357
+ @router.post("/users/{user_id}/credits/add", dependencies=[Depends(require_admin)])
358
+ async def add_user_credits(
359
+ request: Request,
360
+ user_id: str,
361
+ credits: AddCreditsRequest
362
+ ):
363
+ """Add credits to user account (admin only)."""
364
+ from core.container import DependencyContainer
365
+ container = DependencyContainer.get_instance()
366
+ balance_mgr = container.get_service('balance_manager')
367
+
368
+ if not balance_mgr:
369
+ raise HTTPException(status_code=503, detail="Balance manager unavailable")
370
+
371
+ admin_id = getattr(request.state, 'user_id', 'unknown')
372
+ client_ip = get_client_ip(request)
373
+
374
+ # Get balance before for audit
375
+ balance_before_info = await balance_mgr.get_balance(user_id)
376
+ balance_before = balance_before_info.get('balance', 0) if balance_before_info else 0
377
+
378
+ success = await balance_mgr.add_credits(
379
+ user_id=user_id,
380
+ amount=credits.amount,
381
+ reason=f"{credits.reason} (granted by admin {admin_id})",
382
+ transaction_type=credits.transaction_type
383
+ )
384
+
385
+ if not success:
386
+ raise HTTPException(status_code=500, detail="Failed to add credits")
387
+
388
+ # Get updated balance
389
+ balance_info = await balance_mgr.get_balance(user_id)
390
+ balance_after = balance_info.get('balance', 0)
391
+
392
+ # SECURITY: Audit log credit addition
393
+ audit = await get_audit_logger()
394
+ if audit:
395
+ await audit.log_credit_change(
396
+ admin_id=admin_id,
397
+ target_user_id=user_id,
398
+ amount=credits.amount,
399
+ is_addition=True,
400
+ reason=credits.reason,
401
+ balance_before=balance_before,
402
+ balance_after=balance_after,
403
+ ip_address=client_ip
404
+ )
405
+
406
+ return {
407
+ "success": True,
408
+ "user_id": user_id,
409
+ "added_amount": credits.amount,
410
+ "new_balance": balance_after,
411
+ "transaction_type": credits.transaction_type,
412
+ "reason": credits.reason
413
+ }
414
+
415
+
416
+ @router.post("/users/{user_id}/credits/deduct", dependencies=[Depends(require_admin)])
417
+ async def deduct_user_credits(
418
+ request: Request,
419
+ user_id: str,
420
+ credits: AddCreditsRequest
421
+ ):
422
+ """Deduct credits from user account (admin only)."""
423
+ from core.container import DependencyContainer
424
+ container = DependencyContainer.get_instance()
425
+ balance_mgr = container.get_service('balance_manager')
426
+
427
+ if not balance_mgr:
428
+ raise HTTPException(status_code=503, detail="Balance manager unavailable")
429
+
430
+ admin_id = getattr(request.state, 'user_id', 'unknown')
431
+ client_ip = get_client_ip(request)
432
+
433
+ # Get balance before for audit
434
+ balance_before_info = await balance_mgr.get_balance(user_id)
435
+ balance_before = balance_before_info.get('balance', 0) if balance_before_info else 0
436
+
437
+ success = await balance_mgr.deduct_credits(
438
+ user_id=user_id,
439
+ amount=credits.amount,
440
+ reason=f"{credits.reason} (deducted by admin {admin_id})"
441
+ )
442
+
443
+ if not success:
444
+ raise HTTPException(status_code=400, detail="Insufficient balance or deduction failed")
445
+
446
+ # Get updated balance
447
+ balance_info = await balance_mgr.get_balance(user_id)
448
+ balance_after = balance_info.get('balance', 0)
449
+
450
+ # SECURITY: Audit log credit deduction
451
+ audit = await get_audit_logger()
452
+ if audit:
453
+ await audit.log_credit_change(
454
+ admin_id=admin_id,
455
+ target_user_id=user_id,
456
+ amount=credits.amount,
457
+ is_addition=False,
458
+ reason=credits.reason,
459
+ balance_before=balance_before,
460
+ balance_after=balance_after,
461
+ ip_address=client_ip
462
+ )
463
+
464
+ return {
465
+ "success": True,
466
+ "user_id": user_id,
467
+ "deducted_amount": credits.amount,
468
+ "new_balance": balance_after
469
+ }
470
+
471
+
472
+ @router.get("/users/{user_id}/credits", dependencies=[Depends(require_admin)])
473
+ async def get_user_credits(request: Request, user_id: str):
474
+ """Get user's credit balance and history (admin only)."""
475
+ from core.container import DependencyContainer
476
+ container = DependencyContainer.get_instance()
477
+ balance_mgr = container.get_service('balance_manager')
478
+
479
+ if not balance_mgr:
480
+ raise HTTPException(status_code=503, detail="Balance manager unavailable")
481
+
482
+ balance_info = await balance_mgr.get_balance(user_id)
483
+ transactions = await balance_mgr.get_transaction_history(user_id, limit=50)
484
+ monthly_stats = await balance_mgr.get_monthly_stats(user_id)
485
+
486
+ return {
487
+ "user_id": user_id,
488
+ "balance": balance_info['balance'],
489
+ "lifetime_earned": balance_info['lifetime_earned'],
490
+ "lifetime_spent": balance_info['lifetime_spent'],
491
+ "month_spent": monthly_stats['month_spent'],
492
+ "month_earned": monthly_stats['month_earned'],
493
+ "recent_transactions": [
494
+ CreditTransaction(
495
+ amount=t['amount'],
496
+ transaction_type=t['transaction_type'],
497
+ reason=t['reason'],
498
+ balance_before=t['balance_before'],
499
+ balance_after=t['balance_after'],
500
+ timestamp=t['timestamp']
501
+ )
502
+ for t in transactions
503
+ ]
504
+ }
505
+
506
+
507
+ @router.post("/users/{user_id}/role", dependencies=[Depends(require_admin)])
508
+ async def set_user_role(
509
+ request: Request,
510
+ user_id: str,
511
+ role_request: SetRoleRequest
512
+ ):
513
+ """Set user role (admin only).
514
+
515
+ Assignable roles: user, admin.
516
+ "owner" is NOT assignable here — it is reserved for the password-gated
517
+ owner-login (webview/owner_auth.py::issue_owner_session_cookie) and must
518
+ never be mintable via admin action.
519
+ """
520
+ from core.container import DependencyContainer
521
+ container = DependencyContainer.get_instance()
522
+ db = container.get_service('database_manager')
523
+
524
+ if not db:
525
+ raise HTTPException(status_code=503, detail="Database unavailable")
526
+
527
+ # Validate role using centralized ASSIGNMENT-boundary validation. This is
528
+ # stricter than validate_role(): "owner" is a VALID_ROLE (read-side admin
529
+ # checks need it) but must never be admin-assignable.
530
+ try:
531
+ validate_assignable_role(role_request.role)
532
+ except ValueError as e:
533
+ raise HTTPException(status_code=400, detail=str(e))
534
+
535
+ # Get current role for audit log and verify user exists
536
+ current_user = await db.fetch_one(
537
+ "SELECT role FROM user_profiles WHERE user_id = ?", (user_id,)
538
+ )
539
+ if not current_user:
540
+ raise HTTPException(status_code=404, detail=f"User {user_id} not found")
541
+
542
+ old_role = current_user['role']
543
+
544
+ # Update role
545
+ await db.execute("""
546
+ UPDATE user_profiles
547
+ SET role = ?, updated_at = CURRENT_TIMESTAMP
548
+ WHERE user_id = ?
549
+ """, (role_request.role, user_id))
550
+
551
+ admin_id = getattr(request.state, 'user_id', 'unknown')
552
+ client_ip = get_client_ip(request)
553
+
554
+ # SECURITY: Audit log role change
555
+ audit = await get_audit_logger()
556
+ if audit:
557
+ await audit.log_role_change(
558
+ admin_id=admin_id,
559
+ target_user_id=user_id,
560
+ old_role=old_role,
561
+ new_role=role_request.role,
562
+ ip_address=client_ip
563
+ )
564
+
565
+ logger.info(f"User {user_id} role changed from {old_role} to {role_request.role} by admin {admin_id}")
566
+
567
+ return {
568
+ "success": True,
569
+ "user_id": user_id,
570
+ "new_role": role_request.role
571
+ }
572
+
573
+
574
+ @router.post("/users/{user_id}/tier", dependencies=[Depends(require_admin)])
575
+ async def set_user_tier(
576
+ request: Request,
577
+ user_id: str,
578
+ tier_request: SetTierRequest
579
+ ):
580
+ """Manually set user tier (admin only).
581
+
582
+ Tiers:
583
+ - free: No access (blocked from sessions)
584
+ - free_access: Admin-granted access without DEN token (uses credits)
585
+ - holder: DEN token holder (automatic via token verification)
586
+ - x402: Pay-per-request
587
+ - admin: Unlimited access
588
+
589
+ Use 'free_access' to grant someone access for demo/showcase purposes.
590
+ """
591
+ from core.container import DependencyContainer
592
+ container = DependencyContainer.get_instance()
593
+ db = container.get_service('database_manager')
594
+
595
+ if not db:
596
+ raise HTTPException(status_code=503, detail="Database unavailable")
597
+
598
+ # Validate tier using centralized validation
599
+ try:
600
+ validate_tier(tier_request.tier)
601
+ except ValueError as e:
602
+ raise HTTPException(status_code=400, detail=str(e))
603
+
604
+ # Get current tier for audit log and verify user exists
605
+ current_user = await db.fetch_one(
606
+ "SELECT tier FROM user_profiles WHERE user_id = ?", (user_id,)
607
+ )
608
+ if not current_user:
609
+ raise HTTPException(status_code=404, detail=f"User {user_id} not found")
610
+
611
+ old_tier = current_user['tier']
612
+
613
+ # Update tier
614
+ await db.execute("""
615
+ UPDATE user_profiles
616
+ SET tier = ?, updated_at = CURRENT_TIMESTAMP
617
+ WHERE user_id = ?
618
+ """, (tier_request.tier, user_id))
619
+
620
+ admin_id = getattr(request.state, 'user_id', 'unknown')
621
+ client_ip = get_client_ip(request)
622
+
623
+ # SECURITY: Audit log tier change
624
+ audit = await get_audit_logger()
625
+ if audit:
626
+ await audit.log_tier_change(
627
+ admin_id=admin_id,
628
+ target_user_id=user_id,
629
+ old_tier=old_tier,
630
+ new_tier=tier_request.tier,
631
+ ip_address=client_ip
632
+ )
633
+
634
+ logger.info(f"User {user_id} tier changed from {old_tier} to {tier_request.tier} by admin {admin_id}")
635
+
636
+ return {
637
+ "success": True,
638
+ "user_id": user_id,
639
+ "new_tier": tier_request.tier
640
+ }
641
+
642
+
643
+ @router.get("/stats", dependencies=[Depends(require_admin)])
644
+ async def get_system_stats(request: Request):
645
+ """Get system statistics (admin only)."""
646
+ from core.container import DependencyContainer
647
+ container = DependencyContainer.get_instance()
648
+ db = container.get_service('database_manager')
649
+
650
+ if not db:
651
+ raise HTTPException(status_code=503, detail="Database unavailable")
652
+
653
+ # User stats
654
+ total_users = await db.fetch_one("SELECT COUNT(*) as count FROM user_profiles")
655
+ users_by_tier = await db.fetch_all("""
656
+ SELECT tier, COUNT(*) as count
657
+ FROM user_profiles
658
+ GROUP BY tier
659
+ """)
660
+ users_by_role = await db.fetch_all("""
661
+ SELECT role, COUNT(*) as count
662
+ FROM user_profiles
663
+ GROUP BY role
664
+ """)
665
+
666
+ # Credit stats
667
+ total_credits_issued = await db.fetch_one("""
668
+ SELECT COALESCE(SUM(balance), 0) as total
669
+ FROM user_credits
670
+ """)
671
+ total_credits_spent = await db.fetch_one("""
672
+ SELECT COALESCE(SUM(ABS(amount)), 0) as total
673
+ FROM credit_transactions
674
+ WHERE transaction_type = 'usage'
675
+ """)
676
+
677
+ # x402 stats (if enabled)
678
+ x402_payments = await db.fetch_one("""
679
+ SELECT
680
+ COUNT(*) as total_requests,
681
+ COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) as completed,
682
+ COALESCE(SUM(CASE WHEN status = 'completed' THEN amount_usd ELSE 0 END), 0) as total_revenue
683
+ FROM x402_payment_requests
684
+ """)
685
+
686
+ return {
687
+ "users": {
688
+ "total": total_users['count'] if total_users else 0,
689
+ "by_tier": {row['tier']: row['count'] for row in users_by_tier},
690
+ "by_role": {row['role'] or 'user': row['count'] for row in users_by_role}
691
+ },
692
+ "credits": {
693
+ "total_issued": total_credits_issued['total'] if total_credits_issued else 0,
694
+ "total_spent": total_credits_spent['total'] if total_credits_spent else 0
695
+ },
696
+ "x402": {
697
+ "total_requests": x402_payments['total_requests'] if x402_payments else 0,
698
+ "completed_payments": x402_payments['completed'] if x402_payments else 0,
699
+ "total_revenue_usd": x402_payments['total_revenue'] if x402_payments else 0
700
+ }
701
+ }
702
+
703
+
704
+ @router.get("/users/search", dependencies=[Depends(require_admin)])
705
+ async def search_users(
706
+ request: Request,
707
+ q: str = Query(..., min_length=1, description="Search query"),
708
+ field: Literal["wallet", "email", "user_id", "all"] = Query("all", description="Field to search"),
709
+ limit: int = Query(50, ge=1, le=200),
710
+ offset: int = Query(0, ge=0)
711
+ ) -> List[UserSearchResult]:
712
+ """Search users by wallet address, email, or user_id.
713
+
714
+ - Wallet search is case-insensitive and supports partial match
715
+ - Email search is case-insensitive and supports partial match
716
+ - User ID search is exact match prefix
717
+ """
718
+ from core.container import DependencyContainer
719
+ container = DependencyContainer.get_instance()
720
+ db = container.get_service('database_manager')
721
+
722
+ if not db:
723
+ raise HTTPException(status_code=503, detail="Database unavailable")
724
+
725
+ # Build search query based on field
726
+ search_term = q.lower().strip()
727
+ where_clauses = []
728
+ params = []
729
+
730
+ if field == "wallet" or field == "all":
731
+ where_clauses.append("LOWER(u.wallet_address) LIKE ?")
732
+ params.append(f"%{search_term}%")
733
+
734
+ if field == "email" or field == "all":
735
+ where_clauses.append("LOWER(u.email) LIKE ?")
736
+ params.append(f"%{search_term}%")
737
+
738
+ if field == "user_id" or field == "all":
739
+ where_clauses.append("u.user_id LIKE ?")
740
+ params.append(f"{search_term}%")
741
+
742
+ where_sql = f"WHERE ({' OR '.join(where_clauses)})"
743
+ params.extend([limit, offset])
744
+
745
+ users = await db.fetch_all(f"""
746
+ SELECT
747
+ u.user_id,
748
+ u.wallet_address,
749
+ u.email,
750
+ u.tier,
751
+ u.role,
752
+ u.den_token_count,
753
+ u.created_at,
754
+ COALESCE(c.balance, 0) as balance,
755
+ COALESCE(b.is_blocked, 0) as is_blocked
756
+ FROM user_profiles u
757
+ LEFT JOIN user_credits c ON u.user_id = c.user_id
758
+ LEFT JOIN blocked_users b ON u.user_id = b.user_id
759
+ {where_sql}
760
+ ORDER BY u.created_at DESC
761
+ LIMIT ? OFFSET ?
762
+ """, tuple(params))
763
+
764
+ return [
765
+ UserSearchResult(
766
+ user_id=u['user_id'],
767
+ wallet_address=u['wallet_address'],
768
+ email=u['email'],
769
+ tier=u['tier'],
770
+ role=u['role'] or 'user',
771
+ den_token_count=u['den_token_count'] or 0,
772
+ balance=u['balance'] or 0,
773
+ created_at=str(u['created_at']),
774
+ is_blocked=bool(u['is_blocked'])
775
+ )
776
+ for u in users
777
+ ]
778
+
779
+
780
+ @router.post("/users/{user_id}/verify-token", dependencies=[Depends(require_admin)])
781
+ async def verify_user_token(
782
+ request: Request,
783
+ user_id: str
784
+ ) -> TokenVerificationResult:
785
+ """Re-verify DEN token ownership for a user and update tier if needed.
786
+
787
+ Uses AlchemyTool to check current token ownership and:
788
+ 1. Updates den_token_count and den_token_verified_at
789
+ 2. Upgrades tier to 'holder' if user has tokens and is currently 'free'
790
+ 3. Grants DEN Sign Up Allowance for any unclaimed token IDs
791
+ """
792
+ from core.container import DependencyContainer
793
+ container = DependencyContainer.get_instance()
794
+ db = container.get_service('database_manager')
795
+
796
+ if not db:
797
+ raise HTTPException(status_code=503, detail="Database unavailable")
798
+
799
+ # Get user info
800
+ user = await db.fetch_one("""
801
+ SELECT user_id, wallet_address, tier, den_token_count
802
+ FROM user_profiles WHERE user_id = ?
803
+ """, (user_id,))
804
+
805
+ if not user:
806
+ raise HTTPException(status_code=404, detail="User not found")
807
+
808
+ wallet_address = user['wallet_address']
809
+ previous_tier = user['tier']
810
+ previous_count = user['den_token_count'] or 0
811
+
812
+ # Try to get AlchemyTool from container services
813
+ alchemy_tool = container.get_service('alchemy_tool')
814
+ if not alchemy_tool:
815
+ # Try to get 'alchemy' service (the registered name)
816
+ alchemy_tool = container.get_service('alchemy')
817
+
818
+ if not alchemy_tool:
819
+ # Try to create one on the fly as last resort
820
+ try:
821
+ from tools.alchemy.alchemy_tool import AlchemyTool
822
+ from core.config import BotConfig
823
+ config = BotConfig() # BaseSettings auto-loads from env
824
+ alchemy_tool = AlchemyTool(name="alchemy", config=config, container=container)
825
+ await alchemy_tool.initialize()
826
+ except Exception as e:
827
+ logger.error(f"Could not initialize AlchemyTool: {e}")
828
+ raise HTTPException(
829
+ status_code=503,
830
+ detail=f"Token verification service unavailable: {e}"
831
+ )
832
+
833
+ # Call Alchemy to verify token
834
+ try:
835
+ from tools.alchemy.alchemy_tool import CheckTokenParams
836
+ result = await alchemy_tool.alchemy_check_token(
837
+ CheckTokenParams(address=wallet_address)
838
+ )
839
+ except Exception as e:
840
+ logger.error(f"Token verification failed: {e}")
841
+ raise HTTPException(status_code=500, detail=f"Token verification failed: {e}")
842
+
843
+ if result.get('status') != 'success':
844
+ raise HTTPException(
845
+ status_code=500,
846
+ detail=result.get('message', 'Token verification failed')
847
+ )
848
+
849
+ # Extract results
850
+ new_count = result.get('token_count', 0)
851
+ token_ids = result.get('token_ids', [])
852
+ verified_at = datetime.utcnow().isoformat() + "Z"
853
+
854
+ # Determine new tier
855
+ new_tier = previous_tier
856
+ tier_changed = False
857
+
858
+ # Only auto-update tier between 'free' and 'holder'
859
+ # Preserve admin-granted tiers: free_access, x402, admin
860
+ if new_count > 0 and previous_tier == 'free':
861
+ new_tier = 'holder'
862
+ tier_changed = True
863
+ elif new_count == 0 and previous_tier == 'holder':
864
+ # Only downgrade from holder to free
865
+ # (Don't touch free_access, x402, or admin)
866
+ new_tier = 'free'
867
+ tier_changed = True
868
+
869
+ # Update user profile
870
+ await db.execute("""
871
+ UPDATE user_profiles
872
+ SET den_token_count = ?,
873
+ den_token_verified_at = CURRENT_TIMESTAMP,
874
+ tier = ?,
875
+ updated_at = CURRENT_TIMESTAMP
876
+ WHERE user_id = ?
877
+ """, (new_count, new_tier, user_id))
878
+
879
+ # Check and grant bonuses for new token IDs
880
+ bonuses_granted = 0
881
+ bonuses_already_claimed = 0
882
+
883
+ if token_ids:
884
+ # Get contract address from config
885
+ from core.config import BotConfig
886
+ config = BotConfig() # BaseSettings auto-loads from env
887
+ contract_address = getattr(config, 'den_token_contract_address', '')
888
+
889
+ if contract_address:
890
+ for token_id in token_ids:
891
+ # Check if bonus already granted for this token
892
+ existing = await db.fetch_one("""
893
+ SELECT 1 FROM den_token_bonuses
894
+ WHERE token_id = ? AND contract_address = ?
895
+ """, (token_id, contract_address))
896
+
897
+ if existing:
898
+ bonuses_already_claimed += 1
899
+ else:
900
+ # Grant bonus
901
+ from modules.credits.pricing import DEN_SIGNUP_ALLOWANCE
902
+ balance_mgr = container.get_service('balance_manager')
903
+ if balance_mgr:
904
+ await balance_mgr.add_credits(
905
+ user_id=user_id,
906
+ amount=DEN_SIGNUP_ALLOWANCE,
907
+ reason=f"DEN Sign Up Allowance for token #{token_id}",
908
+ transaction_type="den_bonus"
909
+ )
910
+ # Record bonus granted
911
+ await db.execute("""
912
+ INSERT INTO den_token_bonuses (token_id, contract_address)
913
+ VALUES (?, ?)
914
+ """, (token_id, contract_address))
915
+ bonuses_granted += 1
916
+
917
+ # Audit log
918
+ admin_id = getattr(request.state, 'user_id', 'unknown')
919
+ client_ip = get_client_ip(request)
920
+ audit = await get_audit_logger()
921
+
922
+ if audit and tier_changed:
923
+ await audit.log_tier_change(
924
+ admin_id=admin_id,
925
+ target_user_id=user_id,
926
+ old_tier=previous_tier,
927
+ new_tier=new_tier,
928
+ ip_address=client_ip
929
+ )
930
+
931
+ return TokenVerificationResult(
932
+ previous_count=previous_count,
933
+ new_count=new_count,
934
+ previous_tier=previous_tier,
935
+ new_tier=new_tier,
936
+ tier_changed=tier_changed,
937
+ token_ids=token_ids,
938
+ verified_at=verified_at,
939
+ bonuses_granted=bonuses_granted,
940
+ bonuses_already_claimed=bonuses_already_claimed
941
+ )
942
+
943
+
944
+ @router.get("/users/{user_id}/audit", dependencies=[Depends(require_admin)])
945
+ async def get_user_audit_trail(
946
+ request: Request,
947
+ user_id: str,
948
+ limit: int = Query(50, ge=1, le=500),
949
+ offset: int = Query(0, ge=0)
950
+ ) -> List[AuditEvent]:
951
+ """Get audit trail for a specific user."""
952
+ from core.container import DependencyContainer
953
+ container = DependencyContainer.get_instance()
954
+ db = container.get_service('database_manager')
955
+
956
+ if not db:
957
+ raise HTTPException(status_code=503, detail="Database unavailable")
958
+
959
+ # Check user exists
960
+ user = await db.fetch_one(
961
+ "SELECT 1 FROM user_profiles WHERE user_id = ?", (user_id,)
962
+ )
963
+ if not user:
964
+ raise HTTPException(status_code=404, detail="User not found")
965
+
966
+ # Get audit events where user is either actor or target
967
+ events = await db.fetch_all("""
968
+ SELECT
969
+ id, timestamp, event_type, actor_id, actor_wallet, actor_ip,
970
+ target_id, action, old_value, new_value, success
971
+ FROM audit_log
972
+ WHERE actor_id = ? OR target_id = ?
973
+ ORDER BY timestamp DESC
974
+ LIMIT ? OFFSET ?
975
+ """, (user_id, user_id, limit, offset))
976
+
977
+ return [
978
+ AuditEvent(
979
+ id=e['id'],
980
+ timestamp=str(e['timestamp']),
981
+ event_type=e['event_type'],
982
+ actor_id=e['actor_id'],
983
+ actor_wallet=e['actor_wallet'],
984
+ actor_ip=e['actor_ip'],
985
+ target_id=e['target_id'],
986
+ action=e['action'],
987
+ old_value=e['old_value'],
988
+ new_value=e['new_value'],
989
+ success=bool(e['success'])
990
+ )
991
+ for e in events
992
+ ]
993
+
994
+
995
+ @router.get("/users/{user_id}/sessions", dependencies=[Depends(require_admin)])
996
+ async def get_user_sessions(
997
+ request: Request,
998
+ user_id: str,
999
+ limit: int = Query(50, ge=1, le=200)
1000
+ ) -> UserSessionsResponse:
1001
+ """Get user's session usage summary."""
1002
+ from core.container import DependencyContainer
1003
+ container = DependencyContainer.get_instance()
1004
+ db = container.get_service('database_manager')
1005
+
1006
+ if not db:
1007
+ raise HTTPException(status_code=503, detail="Database unavailable")
1008
+
1009
+ # Check user exists
1010
+ user = await db.fetch_one(
1011
+ "SELECT 1 FROM user_profiles WHERE user_id = ?", (user_id,)
1012
+ )
1013
+ if not user:
1014
+ raise HTTPException(status_code=404, detail="User not found")
1015
+
1016
+ # Get session summaries from usage_records
1017
+ sessions = await db.fetch_all("""
1018
+ SELECT
1019
+ session_id,
1020
+ SUM(cost) as total_cost,
1021
+ COUNT(*) as total_calls,
1022
+ MAX(timestamp) as last_timestamp
1023
+ FROM usage_records
1024
+ WHERE user_id = ?
1025
+ GROUP BY session_id
1026
+ ORDER BY last_timestamp DESC
1027
+ LIMIT ?
1028
+ """, (user_id, limit))
1029
+
1030
+ # Get total stats
1031
+ totals = await db.fetch_one("""
1032
+ SELECT
1033
+ COUNT(DISTINCT session_id) as total_sessions,
1034
+ COALESCE(SUM(cost), 0) as total_spent
1035
+ FROM usage_records
1036
+ WHERE user_id = ?
1037
+ """, (user_id,))
1038
+
1039
+ return UserSessionsResponse(
1040
+ total_sessions=totals['total_sessions'] if totals else 0,
1041
+ total_spent=totals['total_spent'] if totals else 0,
1042
+ sessions=[
1043
+ SessionUsageSummary(
1044
+ session_id=s['session_id'],
1045
+ total_cost=s['total_cost'] or 0,
1046
+ total_calls=s['total_calls'] or 0,
1047
+ timestamp=str(s['last_timestamp'])
1048
+ )
1049
+ for s in sessions
1050
+ ]
1051
+ )
1052
+
1053
+
1054
+ @router.post("/users/{user_id}/block", dependencies=[Depends(require_admin)])
1055
+ async def block_user(
1056
+ request: Request,
1057
+ user_id: str,
1058
+ block_request: BlockUserRequest
1059
+ ):
1060
+ """Block a user."""
1061
+ from core.container import DependencyContainer
1062
+ container = DependencyContainer.get_instance()
1063
+ db = container.get_service('database_manager')
1064
+
1065
+ if not db:
1066
+ raise HTTPException(status_code=503, detail="Database unavailable")
1067
+
1068
+ # Check user exists
1069
+ user = await db.fetch_one(
1070
+ "SELECT 1 FROM user_profiles WHERE user_id = ?", (user_id,)
1071
+ )
1072
+ if not user:
1073
+ raise HTTPException(status_code=404, detail="User not found")
1074
+
1075
+ admin_id = getattr(request.state, 'user_id', 'unknown')
1076
+
1077
+ # Insert or update blocked_users record
1078
+ await db.execute("""
1079
+ INSERT INTO blocked_users (user_id, is_blocked, blocked_reason, blocked_at, blocked_by)
1080
+ VALUES (?, 1, ?, CURRENT_TIMESTAMP, ?)
1081
+ ON CONFLICT(user_id) DO UPDATE SET
1082
+ is_blocked = 1,
1083
+ blocked_reason = excluded.blocked_reason,
1084
+ blocked_at = CURRENT_TIMESTAMP,
1085
+ blocked_by = excluded.blocked_by,
1086
+ unblocked_at = NULL
1087
+ """, (user_id, block_request.reason, admin_id))
1088
+
1089
+ # Audit log
1090
+ client_ip = get_client_ip(request)
1091
+ audit = await get_audit_logger()
1092
+ if audit:
1093
+ await audit.log(
1094
+ event_type="user_blocked",
1095
+ action=f"User blocked: {block_request.reason}",
1096
+ actor_id=admin_id,
1097
+ actor_ip=client_ip,
1098
+ target_id=user_id,
1099
+ target_type="user",
1100
+ metadata={"reason": block_request.reason}
1101
+ )
1102
+
1103
+ return {
1104
+ "success": True,
1105
+ "user_id": user_id,
1106
+ "blocked_at": datetime.utcnow().isoformat() + "Z",
1107
+ "blocked_by": admin_id
1108
+ }
1109
+
1110
+
1111
+ @router.post("/users/{user_id}/unblock", dependencies=[Depends(require_admin)])
1112
+ async def unblock_user(
1113
+ request: Request,
1114
+ user_id: str
1115
+ ):
1116
+ """Unblock a user."""
1117
+ from core.container import DependencyContainer
1118
+ container = DependencyContainer.get_instance()
1119
+ db = container.get_service('database_manager')
1120
+
1121
+ if not db:
1122
+ raise HTTPException(status_code=503, detail="Database unavailable")
1123
+
1124
+ # Check user exists and is blocked
1125
+ blocked = await db.fetch_one("""
1126
+ SELECT is_blocked FROM blocked_users WHERE user_id = ?
1127
+ """, (user_id,))
1128
+
1129
+ if not blocked or not blocked['is_blocked']:
1130
+ raise HTTPException(status_code=400, detail="User is not blocked")
1131
+
1132
+ admin_id = getattr(request.state, 'user_id', 'unknown')
1133
+
1134
+ # Update blocked status
1135
+ await db.execute("""
1136
+ UPDATE blocked_users
1137
+ SET is_blocked = 0, unblocked_at = CURRENT_TIMESTAMP
1138
+ WHERE user_id = ?
1139
+ """, (user_id,))
1140
+
1141
+ # Audit log
1142
+ client_ip = get_client_ip(request)
1143
+ audit = await get_audit_logger()
1144
+ if audit:
1145
+ await audit.log(
1146
+ event_type="user_unblocked",
1147
+ action="User unblocked",
1148
+ actor_id=admin_id,
1149
+ actor_ip=client_ip,
1150
+ target_id=user_id,
1151
+ target_type="user"
1152
+ )
1153
+
1154
+ return {
1155
+ "success": True,
1156
+ "user_id": user_id,
1157
+ "unblocked_at": datetime.utcnow().isoformat() + "Z"
1158
+ }
1159
+
1160
+
1161
+ @router.get("/users/{user_id}/block-status", dependencies=[Depends(require_admin)])
1162
+ async def get_block_status(
1163
+ request: Request,
1164
+ user_id: str
1165
+ ) -> BlockStatusResponse:
1166
+ """Get user's block status."""
1167
+ from core.container import DependencyContainer
1168
+ container = DependencyContainer.get_instance()
1169
+ db = container.get_service('database_manager')
1170
+
1171
+ if not db:
1172
+ raise HTTPException(status_code=503, detail="Database unavailable")
1173
+
1174
+ # Get block status
1175
+ blocked = await db.fetch_one("""
1176
+ SELECT is_blocked, blocked_reason, blocked_at, blocked_by
1177
+ FROM blocked_users WHERE user_id = ?
1178
+ """, (user_id,))
1179
+
1180
+ if not blocked:
1181
+ return BlockStatusResponse(
1182
+ is_blocked=False,
1183
+ blocked_reason=None,
1184
+ blocked_at=None,
1185
+ blocked_by=None
1186
+ )
1187
+
1188
+ return BlockStatusResponse(
1189
+ is_blocked=bool(blocked['is_blocked']),
1190
+ blocked_reason=blocked['blocked_reason'],
1191
+ blocked_at=str(blocked['blocked_at']) if blocked['blocked_at'] else None,
1192
+ blocked_by=blocked['blocked_by']
1193
+ )
1194
+
1195
+
1196
+ @router.get("/stats/dashboard", dependencies=[Depends(require_admin)])
1197
+ async def get_dashboard_stats(request: Request) -> DashboardStats:
1198
+ """Get enhanced dashboard statistics with alerts."""
1199
+ from core.container import DependencyContainer
1200
+ container = DependencyContainer.get_instance()
1201
+ db = container.get_service('database_manager')
1202
+
1203
+ if not db:
1204
+ raise HTTPException(status_code=503, detail="Database unavailable")
1205
+
1206
+ # User stats
1207
+ total_users = await db.fetch_one("SELECT COUNT(*) as count FROM user_profiles")
1208
+ new_today = await db.fetch_one("""
1209
+ SELECT COUNT(*) as count FROM user_profiles
1210
+ WHERE DATE(created_at) = DATE('now')
1211
+ """)
1212
+ users_by_tier = await db.fetch_all("""
1213
+ SELECT tier, COUNT(*) as count FROM user_profiles GROUP BY tier
1214
+ """)
1215
+ users_by_role = await db.fetch_all("""
1216
+ SELECT role, COUNT(*) as count FROM user_profiles GROUP BY role
1217
+ """)
1218
+
1219
+ # Credit stats
1220
+ credit_totals = await db.fetch_one("""
1221
+ SELECT
1222
+ COALESCE(SUM(balance), 0) as total_balance,
1223
+ COALESCE(SUM(lifetime_earned), 0) as total_earned,
1224
+ COALESCE(SUM(lifetime_spent), 0) as total_spent
1225
+ FROM user_credits
1226
+ """)
1227
+
1228
+ # Session stats (today)
1229
+ sessions_today = await db.fetch_one("""
1230
+ SELECT COUNT(DISTINCT session_id) as count
1231
+ FROM usage_records
1232
+ WHERE DATE(timestamp) = DATE('now')
1233
+ """)
1234
+
1235
+ # Revenue stats - ALL TIME totals
1236
+ x402_total = await db.fetch_one("""
1237
+ SELECT COALESCE(SUM(amount_usd), 0) as total
1238
+ FROM x402_payment_requests
1239
+ WHERE status = 'completed'
1240
+ """)
1241
+ crypto_total = await db.fetch_one("""
1242
+ SELECT COALESCE(SUM(amount_usd), 0) as total
1243
+ FROM crypto_payments
1244
+ WHERE status = 'confirmed'
1245
+ """)
1246
+
1247
+ # Revenue stats - MTD (Month-to-Date)
1248
+ x402_mtd = await db.fetch_one("""
1249
+ SELECT COALESCE(SUM(amount_usd), 0) as total
1250
+ FROM x402_payment_requests
1251
+ WHERE status = 'completed'
1252
+ AND strftime('%Y-%m', completed_at) = strftime('%Y-%m', 'now')
1253
+ """)
1254
+ crypto_mtd = await db.fetch_one("""
1255
+ SELECT COALESCE(SUM(amount_usd), 0) as total
1256
+ FROM crypto_payments
1257
+ WHERE status = 'confirmed'
1258
+ AND strftime('%Y-%m', detected_at) = strftime('%Y-%m', 'now')
1259
+ """)
1260
+
1261
+ # Alerts
1262
+ alerts = []
1263
+
1264
+ # Check for outdated token verifications (>24h)
1265
+ outdated_verifications = await db.fetch_one("""
1266
+ SELECT COUNT(*) as count FROM user_profiles
1267
+ WHERE den_token_count > 0
1268
+ AND (den_token_verified_at IS NULL
1269
+ OR den_token_verified_at < datetime('now', '-24 hours'))
1270
+ """)
1271
+ if outdated_verifications and outdated_verifications['count'] > 0:
1272
+ alerts.append({
1273
+ "type": "outdated_verification",
1274
+ "severity": "warning",
1275
+ "count": outdated_verifications['count'],
1276
+ "message": f"{outdated_verifications['count']} users with outdated token verification (>24h)"
1277
+ })
1278
+
1279
+ # Check for pending sweeps (>1h)
1280
+ pending_sweeps = await db.fetch_one("""
1281
+ SELECT COUNT(*) as count FROM crypto_payments
1282
+ WHERE swept_at IS NULL
1283
+ AND detected_at < datetime('now', '-1 hour')
1284
+ """)
1285
+ if pending_sweeps and pending_sweeps['count'] > 0:
1286
+ alerts.append({
1287
+ "type": "pending_sweep",
1288
+ "severity": "warning",
1289
+ "count": pending_sweeps['count'],
1290
+ "message": f"{pending_sweeps['count']} pending deposits not swept (>1h old)"
1291
+ })
1292
+
1293
+ # Check for recent auth failures
1294
+ auth_failures = await db.fetch_one("""
1295
+ SELECT COUNT(*) as count FROM audit_log
1296
+ WHERE event_type = 'auth_failure'
1297
+ AND timestamp > datetime('now', '-1 hour')
1298
+ """)
1299
+ if auth_failures and auth_failures['count'] > 5:
1300
+ alerts.append({
1301
+ "type": "auth_failures",
1302
+ "severity": "high",
1303
+ "count": auth_failures['count'],
1304
+ "message": f"{auth_failures['count']} authentication failures in the last hour"
1305
+ })
1306
+
1307
+ return DashboardStats(
1308
+ users={
1309
+ "total": total_users['count'] if total_users else 0,
1310
+ "new_today": new_today['count'] if new_today else 0,
1311
+ "by_tier": {row['tier'] or 'free': row['count'] for row in users_by_tier},
1312
+ "by_role": {row['role'] or 'user': row['count'] for row in users_by_role}
1313
+ },
1314
+ credits={
1315
+ "total_balance": credit_totals['total_balance'] if credit_totals else 0,
1316
+ "total_issued": credit_totals['total_earned'] if credit_totals else 0,
1317
+ "total_spent": credit_totals['total_spent'] if credit_totals else 0
1318
+ },
1319
+ sessions={
1320
+ "total_today": sessions_today['count'] if sessions_today else 0
1321
+ },
1322
+ revenue={
1323
+ # MTD values (shown in breakdown)
1324
+ "x402_mtd_usd": float(x402_mtd['total']) if x402_mtd else 0.0,
1325
+ "crypto_mtd_usd": float(crypto_mtd['total']) if crypto_mtd else 0.0,
1326
+ "mtd_usd": (float(x402_mtd['total']) if x402_mtd else 0.0) + (float(crypto_mtd['total']) if crypto_mtd else 0.0),
1327
+ # All-time totals
1328
+ "x402_total_usd": float(x402_total['total']) if x402_total else 0.0,
1329
+ "crypto_total_usd": float(crypto_total['total']) if crypto_total else 0.0,
1330
+ "total_usd": (float(x402_total['total']) if x402_total else 0.0) + (float(crypto_total['total']) if crypto_total else 0.0)
1331
+ },
1332
+ alerts=alerts
1333
+ )
1334
+
1335
+
1336
+ @router.get("/activity", dependencies=[Depends(require_admin)])
1337
+ async def get_activity_feed(
1338
+ request: Request,
1339
+ limit: int = Query(50, ge=1, le=200),
1340
+ offset: int = Query(0, ge=0),
1341
+ event_type: Optional[str] = Query(None, description="Filter by event type"),
1342
+ actor_id: Optional[str] = Query(None, description="Filter by actor user ID"),
1343
+ target_id: Optional[str] = Query(None, description="Filter by target user ID")
1344
+ ) -> List[AuditEvent]:
1345
+ """Get recent activity feed from audit log."""
1346
+ audit = await get_audit_logger()
1347
+
1348
+ if not audit:
1349
+ raise HTTPException(status_code=503, detail="Audit service unavailable")
1350
+
1351
+ events = await audit.get_recent_events(
1352
+ limit=limit,
1353
+ offset=offset,
1354
+ event_type=event_type,
1355
+ actor_id=actor_id,
1356
+ target_id=target_id
1357
+ )
1358
+
1359
+ return [
1360
+ AuditEvent(
1361
+ id=e['id'],
1362
+ timestamp=str(e['timestamp']),
1363
+ event_type=e['event_type'],
1364
+ actor_id=e['actor_id'],
1365
+ actor_wallet=e['actor_wallet'],
1366
+ actor_ip=e['actor_ip'],
1367
+ target_id=e['target_id'],
1368
+ action=e['action'],
1369
+ old_value=e['old_value'],
1370
+ new_value=e['new_value'],
1371
+ success=bool(e['success'])
1372
+ )
1373
+ for e in events
1374
+ ]
1375
+
1376
+
1377
+ @router.get("/billing-failures", dependencies=[Depends(require_admin)])
1378
+ async def get_billing_failures(
1379
+ request: Request,
1380
+ status: str = Query("pending", description="Filter by status: pending, resolved, written_off"),
1381
+ limit: int = Query(50, ge=1, le=200),
1382
+ offset: int = Query(0, ge=0)
1383
+ ) -> BillingFailuresResponse:
1384
+ """Get billing failures for admin review.
1385
+
1386
+ Returns list of billing failures where credits could not be deducted.
1387
+ Admins can review and resolve these issues.
1388
+ """
1389
+ from core.container import DependencyContainer
1390
+ container = DependencyContainer.get_instance()
1391
+ db = container.get_service('database_manager')
1392
+
1393
+ if not db:
1394
+ raise HTTPException(status_code=503, detail="Database unavailable")
1395
+
1396
+ # Get failures with user info
1397
+ failures = await db.fetch_all("""
1398
+ SELECT
1399
+ bf.id, bf.user_id, bf.session_id, bf.request_id,
1400
+ bf.credits_owed, bf.api_cost_usd, bf.model,
1401
+ bf.created_at, bf.resolved_at, bf.status, bf.resolution_notes,
1402
+ COALESCE(uc.balance, 0) as current_balance,
1403
+ u.wallet_address
1404
+ FROM billing_failures bf
1405
+ LEFT JOIN user_credits uc ON bf.user_id = uc.user_id
1406
+ LEFT JOIN user_profiles u ON bf.user_id = u.user_id
1407
+ WHERE bf.status = ?
1408
+ ORDER BY bf.created_at DESC
1409
+ LIMIT ? OFFSET ?
1410
+ """, (status, limit, offset))
1411
+
1412
+ # Get totals
1413
+ totals = await db.fetch_one("""
1414
+ SELECT
1415
+ COUNT(*) as total,
1416
+ COALESCE(SUM(credits_owed), 0) as total_credits,
1417
+ COALESCE(SUM(api_cost_usd), 0) as total_api_cost
1418
+ FROM billing_failures
1419
+ WHERE status = ?
1420
+ """, (status,))
1421
+
1422
+ return BillingFailuresResponse(
1423
+ failures=[
1424
+ BillingFailure(
1425
+ id=f['id'],
1426
+ user_id=f['user_id'],
1427
+ session_id=f['session_id'],
1428
+ request_id=f['request_id'],
1429
+ credits_owed=f['credits_owed'],
1430
+ api_cost_usd=f['api_cost_usd'],
1431
+ model=f['model'],
1432
+ created_at=str(f['created_at']),
1433
+ resolved_at=str(f['resolved_at']) if f['resolved_at'] else None,
1434
+ status=f['status'],
1435
+ resolution_notes=f['resolution_notes'],
1436
+ current_balance=f['current_balance'],
1437
+ wallet_address=f['wallet_address']
1438
+ )
1439
+ for f in failures
1440
+ ],
1441
+ total=totals['total'] if totals else 0,
1442
+ status_filter=status,
1443
+ total_credits_owed=totals['total_credits'] if totals else 0,
1444
+ total_api_cost_usd=float(totals['total_api_cost']) if totals else 0.0
1445
+ )
1446
+
1447
+
1448
+ @router.post("/billing-failures/{failure_id}/resolve", dependencies=[Depends(require_admin)])
1449
+ async def resolve_billing_failure(
1450
+ request: Request,
1451
+ failure_id: int,
1452
+ resolution_request: ResolveBillingFailureRequest
1453
+ ):
1454
+ """Resolve a billing failure.
1455
+
1456
+ Resolution options:
1457
+ - charged: Successfully deducted credits from user
1458
+ - written_off: Loss absorbed, user not charged
1459
+ - refunded: Credits returned to user (if already charged)
1460
+ """
1461
+ from core.container import DependencyContainer
1462
+ container = DependencyContainer.get_instance()
1463
+ db = container.get_service('database_manager')
1464
+
1465
+ if not db:
1466
+ raise HTTPException(status_code=503, detail="Database unavailable")
1467
+
1468
+ # Verify failure exists and is pending
1469
+ failure = await db.fetch_one("""
1470
+ SELECT id, user_id, credits_owed, status
1471
+ FROM billing_failures WHERE id = ?
1472
+ """, (failure_id,))
1473
+
1474
+ if not failure:
1475
+ raise HTTPException(status_code=404, detail="Billing failure not found")
1476
+
1477
+ if failure['status'] != 'pending':
1478
+ raise HTTPException(
1479
+ status_code=400,
1480
+ detail=f"Failure already resolved with status: {failure['status']}"
1481
+ )
1482
+
1483
+ admin_id = getattr(request.state, 'user_id', 'unknown')
1484
+ resolution = resolution_request.resolution
1485
+ notes = resolution_request.notes
1486
+
1487
+ # If resolution is "charged", try to actually deduct credits
1488
+ if resolution == "charged":
1489
+ balance_mgr = container.get_service('balance_manager')
1490
+ if balance_mgr:
1491
+ success = await balance_mgr.deduct_credits(
1492
+ user_id=failure['user_id'],
1493
+ amount=failure['credits_owed'],
1494
+ reason=f"Billing reconciliation (resolved by admin {admin_id})"
1495
+ )
1496
+ if not success:
1497
+ raise HTTPException(
1498
+ status_code=400,
1499
+ detail="Failed to deduct credits - user may still have insufficient balance"
1500
+ )
1501
+
1502
+ # Update failure record
1503
+ await db.execute("""
1504
+ UPDATE billing_failures
1505
+ SET status = 'resolved',
1506
+ resolved_at = CURRENT_TIMESTAMP,
1507
+ resolution_notes = ?
1508
+ WHERE id = ?
1509
+ """, (f"{resolution}: {notes} (by {admin_id})", failure_id))
1510
+
1511
+ # Audit log
1512
+ audit = await get_audit_logger()
1513
+ if audit:
1514
+ client_ip = get_client_ip(request)
1515
+ await audit.log(
1516
+ event_type="billing_failure_resolved",
1517
+ action=f"Resolved billing failure #{failure_id}: {resolution}",
1518
+ actor_id=admin_id,
1519
+ actor_ip=client_ip,
1520
+ target_id=failure['user_id'],
1521
+ target_type="billing_failure",
1522
+ old_value="pending",
1523
+ new_value=resolution,
1524
+ metadata={
1525
+ "failure_id": failure_id,
1526
+ "credits_owed": failure['credits_owed'],
1527
+ "resolution": resolution,
1528
+ "notes": notes
1529
+ }
1530
+ )
1531
+
1532
+ return {
1533
+ "success": True,
1534
+ "failure_id": failure_id,
1535
+ "resolution": resolution,
1536
+ "resolved_by": admin_id
1537
+ }
1538
+
1539
+
1540
+ @router.get("/billing-failures/summary", dependencies=[Depends(require_admin)])
1541
+ async def get_billing_failures_summary(request: Request):
1542
+ """Get summary of billing failures by status."""
1543
+ from core.container import DependencyContainer
1544
+ container = DependencyContainer.get_instance()
1545
+ db = container.get_service('database_manager')
1546
+
1547
+ if not db:
1548
+ raise HTTPException(status_code=503, detail="Database unavailable")
1549
+
1550
+ summary = await db.fetch_all("""
1551
+ SELECT
1552
+ status,
1553
+ COUNT(*) as count,
1554
+ COALESCE(SUM(credits_owed), 0) as total_credits,
1555
+ COALESCE(SUM(api_cost_usd), 0) as total_api_cost
1556
+ FROM billing_failures
1557
+ GROUP BY status
1558
+ """)
1559
+
1560
+ # Get recent failures (last 24h)
1561
+ recent = await db.fetch_one("""
1562
+ SELECT COUNT(*) as count
1563
+ FROM billing_failures
1564
+ WHERE created_at > datetime('now', '-24 hours')
1565
+ """)
1566
+
1567
+ result = {
1568
+ "by_status": {
1569
+ row['status']: {
1570
+ "count": row['count'],
1571
+ "total_credits_owed": row['total_credits'],
1572
+ "total_api_cost_usd": float(row['total_api_cost'])
1573
+ }
1574
+ for row in summary
1575
+ },
1576
+ "recent_24h": recent['count'] if recent else 0
1577
+ }
1578
+
1579
+ # Calculate totals
1580
+ result["total_pending"] = result["by_status"].get("pending", {}).get("count", 0)
1581
+ result["total_pending_credits"] = result["by_status"].get("pending", {}).get("total_credits_owed", 0)
1582
+ result["total_pending_api_cost"] = result["by_status"].get("pending", {}).get("total_api_cost_usd", 0.0)
1583
+
1584
+ return result