marketmyna 0.2.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 (344) hide show
  1. marketmyna/__init__.py +14 -0
  2. marketmyna/agent/__init__.py +8 -0
  3. marketmyna/agent/agents.py +543 -0
  4. marketmyna/agent/context.py +69 -0
  5. marketmyna/agent/dashboard.py +55 -0
  6. marketmyna/agent/orchestrator.py +153 -0
  7. marketmyna/agent/sentiment.py +150 -0
  8. marketmyna/agent/skills.py +119 -0
  9. marketmyna/ai/__init__.py +1 -0
  10. marketmyna/ai/portfolio_optimizer.py +330 -0
  11. marketmyna/analysis/__init__.py +6 -0
  12. marketmyna/analysis/fundamental.py +329 -0
  13. marketmyna/analysis/sip_optimizer.py +81 -0
  14. marketmyna/analysis/technical.py +321 -0
  15. marketmyna/api/__init__.py +1 -0
  16. marketmyna/api/app.py +209 -0
  17. marketmyna/api/deps.py +63 -0
  18. marketmyna/api/middlewares/__init__.py +0 -0
  19. marketmyna/api/middlewares/auth.py +116 -0
  20. marketmyna/api/middlewares/csrf.py +66 -0
  21. marketmyna/api/middlewares/error_handler.py +38 -0
  22. marketmyna/api/middlewares/slashes.py +27 -0
  23. marketmyna/api/v1/__init__.py +0 -0
  24. marketmyna/api/v1/endpoints/__init__.py +0 -0
  25. marketmyna/api/v1/endpoints/account.py +166 -0
  26. marketmyna/api/v1/endpoints/agent.py +114 -0
  27. marketmyna/api/v1/endpoints/bot.py +38 -0
  28. marketmyna/api/v1/endpoints/calendar.py +269 -0
  29. marketmyna/api/v1/endpoints/data_sources.py +174 -0
  30. marketmyna/api/v1/endpoints/health.py +10 -0
  31. marketmyna/api/v1/endpoints/listings.py +564 -0
  32. marketmyna/api/v1/endpoints/macro.py +163 -0
  33. marketmyna/api/v1/endpoints/portfolio.py +530 -0
  34. marketmyna/api/v1/endpoints/push.py +119 -0
  35. marketmyna/api/v1/endpoints/reports.py +49 -0
  36. marketmyna/api/v1/endpoints/scheduler.py +219 -0
  37. marketmyna/api/v1/endpoints/stocks.py +400 -0
  38. marketmyna/api/v1/endpoints/system_config.py +157 -0
  39. marketmyna/api/v1/endpoints/thesis.py +112 -0
  40. marketmyna/api/v1/endpoints/trade.py +709 -0
  41. marketmyna/api/v1/endpoints/updates.py +394 -0
  42. marketmyna/api/v1/endpoints/watchlists.py +179 -0
  43. marketmyna/api/v1/router.py +43 -0
  44. marketmyna/bot/__init__.py +14 -0
  45. marketmyna/bot/commands/__init__.py +0 -0
  46. marketmyna/bot/commands/analyze.py +40 -0
  47. marketmyna/bot/commands/ask.py +51 -0
  48. marketmyna/bot/commands/base.py +35 -0
  49. marketmyna/bot/commands/batch.py +53 -0
  50. marketmyna/bot/commands/common.py +84 -0
  51. marketmyna/bot/commands/help.py +43 -0
  52. marketmyna/bot/commands/markets.py +153 -0
  53. marketmyna/bot/commands/portfolio.py +145 -0
  54. marketmyna/bot/commands/research.py +323 -0
  55. marketmyna/bot/commands/status.py +48 -0
  56. marketmyna/bot/commands/trade.py +176 -0
  57. marketmyna/bot/dispatcher.py +95 -0
  58. marketmyna/bot/formatting.py +25 -0
  59. marketmyna/bot/models.py +27 -0
  60. marketmyna/bot/registry.py +50 -0
  61. marketmyna/bot/telegram_listener.py +227 -0
  62. marketmyna/cli/__init__.py +1 -0
  63. marketmyna/cli/scheduled_run.py +292 -0
  64. marketmyna/core/__init__.py +6 -0
  65. marketmyna/core/compliance.py +53 -0
  66. marketmyna/core/config.py +377 -0
  67. marketmyna/core/constants.py +69 -0
  68. marketmyna/core/home.py +22 -0
  69. marketmyna/core/numbers.py +28 -0
  70. marketmyna/core/secret_store.py +104 -0
  71. marketmyna/core/tenant.py +130 -0
  72. marketmyna/data/__init__.py +8 -0
  73. marketmyna/data/base.py +36 -0
  74. marketmyna/data/categories.py +272 -0
  75. marketmyna/data/china.py +360 -0
  76. marketmyna/data/crypto.py +200 -0
  77. marketmyna/data/ecb_fx.py +131 -0
  78. marketmyna/data/extra_sources.py +569 -0
  79. marketmyna/data/india.py +241 -0
  80. marketmyna/data/india_filings.py +343 -0
  81. marketmyna/data/listings.py +1314 -0
  82. marketmyna/data/mutual_funds.py +34 -0
  83. marketmyna/data/news.py +98 -0
  84. marketmyna/data/news_search.py +346 -0
  85. marketmyna/data/nse_deals.py +138 -0
  86. marketmyna/data/resilience.py +173 -0
  87. marketmyna/data/sec_facts.py +252 -0
  88. marketmyna/data/shariah.py +283 -0
  89. marketmyna/data/social/__init__.py +9 -0
  90. marketmyna/data/social/base.py +100 -0
  91. marketmyna/data/social/browser.py +92 -0
  92. marketmyna/data/social/hackernews.py +53 -0
  93. marketmyna/data/social/reddit.py +397 -0
  94. marketmyna/data/social/stocktwits.py +63 -0
  95. marketmyna/data/social/valuepickr.py +53 -0
  96. marketmyna/data/stocks.py +197 -0
  97. marketmyna/data/yahoo_price.py +48 -0
  98. marketmyna/launcher.py +71 -0
  99. marketmyna/llm/__init__.py +33 -0
  100. marketmyna/llm/backend_factory.py +51 -0
  101. marketmyna/llm/backend_registry.py +19 -0
  102. marketmyna/llm/channels.py +447 -0
  103. marketmyna/llm/json_reply.py +63 -0
  104. marketmyna/llm/litellm_backend.py +197 -0
  105. marketmyna/llm/model_catalog.py +166 -0
  106. marketmyna/llm/model_health.py +193 -0
  107. marketmyna/llm/protocol.py +111 -0
  108. marketmyna/llm/providers.py +128 -0
  109. marketmyna/llm/store.py +128 -0
  110. marketmyna/notifiers/__init__.py +35 -0
  111. marketmyna/notifiers/apprise_sender.py +43 -0
  112. marketmyna/notifiers/astrbot.py +47 -0
  113. marketmyna/notifiers/bark.py +24 -0
  114. marketmyna/notifiers/base.py +80 -0
  115. marketmyna/notifiers/dingtalk.py +54 -0
  116. marketmyna/notifiers/discord.py +39 -0
  117. marketmyna/notifiers/dispatcher.py +153 -0
  118. marketmyna/notifiers/email.py +99 -0
  119. marketmyna/notifiers/feishu.py +57 -0
  120. marketmyna/notifiers/googlechat.py +24 -0
  121. marketmyna/notifiers/gotify.py +36 -0
  122. marketmyna/notifiers/matrix.py +28 -0
  123. marketmyna/notifiers/ntfy.py +36 -0
  124. marketmyna/notifiers/pushbullet.py +24 -0
  125. marketmyna/notifiers/pushover.py +41 -0
  126. marketmyna/notifiers/pushplus.py +41 -0
  127. marketmyna/notifiers/serverchan3.py +40 -0
  128. marketmyna/notifiers/settings_schema.py +119 -0
  129. marketmyna/notifiers/signal_messenger.py +25 -0
  130. marketmyna/notifiers/slack.py +36 -0
  131. marketmyna/notifiers/teams.py +37 -0
  132. marketmyna/notifiers/telegram.py +81 -0
  133. marketmyna/notifiers/twilio.py +33 -0
  134. marketmyna/notifiers/webhook.py +44 -0
  135. marketmyna/notifiers/webpush.py +222 -0
  136. marketmyna/notifiers/wechat.py +40 -0
  137. marketmyna/notifiers/whatsapp.py +27 -0
  138. marketmyna/portfolio/__init__.py +6 -0
  139. marketmyna/portfolio/analytics.py +336 -0
  140. marketmyna/portfolio/manager.py +383 -0
  141. marketmyna/portfolio/models.py +155 -0
  142. marketmyna/portfolio/optimizers.py +88 -0
  143. marketmyna/portfolio/review.py +411 -0
  144. marketmyna/services/__init__.py +1 -0
  145. marketmyna/services/accounts.py +278 -0
  146. marketmyna/services/ai_access.py +94 -0
  147. marketmyna/services/analysis_run.py +491 -0
  148. marketmyna/services/big_trades.py +335 -0
  149. marketmyna/services/brokers/__init__.py +0 -0
  150. marketmyna/services/brokers/angel.py +271 -0
  151. marketmyna/services/brokers/finnhub.py +209 -0
  152. marketmyna/services/byok_detect.py +196 -0
  153. marketmyna/services/calendar.py +748 -0
  154. marketmyna/services/central_banks.py +202 -0
  155. marketmyna/services/commodities.py +134 -0
  156. marketmyna/services/compare.py +307 -0
  157. marketmyna/services/cot.py +148 -0
  158. marketmyna/services/country_macro.py +135 -0
  159. marketmyna/services/crude_sensitivity.py +239 -0
  160. marketmyna/services/crypto_mood.py +135 -0
  161. marketmyna/services/crypto_screens.py +226 -0
  162. marketmyna/services/data_export.py +91 -0
  163. marketmyna/services/dcf.py +220 -0
  164. marketmyna/services/dcf_model.py +194 -0
  165. marketmyna/services/delivery.py +142 -0
  166. marketmyna/services/discovery.py +146 -0
  167. marketmyna/services/dividends.py +95 -0
  168. marketmyna/services/earnings.py +305 -0
  169. marketmyna/services/econ_calendar.py +495 -0
  170. marketmyna/services/eia_stocks.py +115 -0
  171. marketmyna/services/estimates.py +109 -0
  172. marketmyna/services/etf.py +236 -0
  173. marketmyna/services/etf_rotation.py +193 -0
  174. marketmyna/services/etf_screens.py +196 -0
  175. marketmyna/services/financials.py +639 -0
  176. marketmyna/services/fund_ai.py +181 -0
  177. marketmyna/services/fund_analytics.py +371 -0
  178. marketmyna/services/fund_cost.py +147 -0
  179. marketmyna/services/fund_holdings.py +664 -0
  180. marketmyna/services/fund_peers.py +247 -0
  181. marketmyna/services/fund_rotation.py +206 -0
  182. marketmyna/services/fund_style.py +179 -0
  183. marketmyna/services/funds.py +70 -0
  184. marketmyna/services/hot_stocks.py +358 -0
  185. marketmyna/services/india_flows.py +263 -0
  186. marketmyna/services/india_rates.py +258 -0
  187. marketmyna/services/investor_lenses.py +271 -0
  188. marketmyna/services/leadership.py +165 -0
  189. marketmyna/services/live_market.py +497 -0
  190. marketmyna/services/live_quotes.py +295 -0
  191. marketmyna/services/macro.py +182 -0
  192. marketmyna/services/market_calendar.py +399 -0
  193. marketmyna/services/market_mood.py +168 -0
  194. marketmyna/services/market_valuation.py +231 -0
  195. marketmyna/services/mf_holdings.py +199 -0
  196. marketmyna/services/mf_insights.py +132 -0
  197. marketmyna/services/mf_lumpsum.py +587 -0
  198. marketmyna/services/mf_portfolio_ai.py +195 -0
  199. marketmyna/services/nse_events.py +98 -0
  200. marketmyna/services/overview.py +366 -0
  201. marketmyna/services/ownership.py +143 -0
  202. marketmyna/services/quality_scores.py +199 -0
  203. marketmyna/services/rates.py +335 -0
  204. marketmyna/services/reconcile.py +343 -0
  205. marketmyna/services/report.py +584 -0
  206. marketmyna/services/report_i18n.py +678 -0
  207. marketmyna/services/rotation.py +224 -0
  208. marketmyna/services/runtime_scheduler.py +548 -0
  209. marketmyna/services/schedule_config.py +251 -0
  210. marketmyna/services/schedule_time.py +39 -0
  211. marketmyna/services/screens.py +398 -0
  212. marketmyna/services/sec_mdna.py +173 -0
  213. marketmyna/services/shariah.py +246 -0
  214. marketmyna/services/sip.py +148 -0
  215. marketmyna/services/sip_days.py +362 -0
  216. marketmyna/services/smart_file.py +595 -0
  217. marketmyna/services/smart_pdf.py +425 -0
  218. marketmyna/services/smart_report.py +370 -0
  219. marketmyna/services/social_sentiment.py +511 -0
  220. marketmyna/services/thesis.py +328 -0
  221. marketmyna/services/translate.py +146 -0
  222. marketmyna/services/valuation.py +173 -0
  223. marketmyna/services/worker_pool.py +149 -0
  224. marketmyna/services/world_markets.py +208 -0
  225. marketmyna/storage.py +722 -0
  226. marketmyna/strategies/README.md +51 -0
  227. marketmyna/strategies/bottom_volume.yaml +49 -0
  228. marketmyna/strategies/box_oscillation.yaml +69 -0
  229. marketmyna/strategies/bull_trend.yaml +49 -0
  230. marketmyna/strategies/chan_theory.yaml +58 -0
  231. marketmyna/strategies/dragon_head.yaml +44 -0
  232. marketmyna/strategies/emotion_cycle.yaml +79 -0
  233. marketmyna/strategies/event_driven.yaml +54 -0
  234. marketmyna/strategies/expectation_repricing.yaml +54 -0
  235. marketmyna/strategies/growth_quality.yaml +54 -0
  236. marketmyna/strategies/hot_theme.yaml +54 -0
  237. marketmyna/strategies/ma_golden_cross.yaml +46 -0
  238. marketmyna/strategies/one_yang_three_yin.yaml +37 -0
  239. marketmyna/strategies/shrink_pullback.yaml +46 -0
  240. marketmyna/strategies/volume_breakout.yaml +47 -0
  241. marketmyna/strategies/wave_theory.yaml +64 -0
  242. marketmyna/trading/__init__.py +2 -0
  243. marketmyna/trading/alerts.py +356 -0
  244. marketmyna/trading/backtest.py +317 -0
  245. marketmyna/trading/charges.py +82 -0
  246. marketmyna/trading/chart.py +197 -0
  247. marketmyna/trading/indicators.py +303 -0
  248. marketmyna/trading/journal.py +74 -0
  249. marketmyna/trading/knn.py +164 -0
  250. marketmyna/trading/lots.py +109 -0
  251. marketmyna/trading/market_hours.py +66 -0
  252. marketmyna/trading/options.py +554 -0
  253. marketmyna/trading/pairs.py +94 -0
  254. marketmyna/trading/risk.py +170 -0
  255. marketmyna/trading/scanner.py +500 -0
  256. marketmyna/trading/volatility.py +163 -0
  257. marketmyna/utils/__init__.py +1 -0
  258. marketmyna/utils/deadline.py +36 -0
  259. marketmyna/utils/log_redact.py +52 -0
  260. marketmyna/utils/yahoo_gate.py +183 -0
  261. marketmyna/utils/yf_lock.py +12 -0
  262. marketmyna/web/apple-touch-icon.png +0 -0
  263. marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
  264. marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
  265. marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
  266. marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
  267. marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
  268. marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
  269. marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
  270. marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
  271. marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
  272. marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
  273. marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
  274. marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
  275. marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
  276. marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
  277. marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
  278. marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
  279. marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
  280. marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
  281. marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
  282. marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
  283. marketmyna/web/assets/app-BP1o7WZS.js +1 -0
  284. marketmyna/web/assets/app-BkIUKdld.js +1 -0
  285. marketmyna/web/assets/app-CKeSYhEt.js +1 -0
  286. marketmyna/web/assets/app-CN1OERYX.js +1 -0
  287. marketmyna/web/assets/app-DIQllha5.js +1 -0
  288. marketmyna/web/assets/app-DeiAW0tX.js +1 -0
  289. marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
  290. marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
  291. marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
  292. marketmyna/web/assets/common-0wvpAVpd.js +1 -0
  293. marketmyna/web/assets/common-3ghy53Us.js +1 -0
  294. marketmyna/web/assets/common-Bib2unjX.js +1 -0
  295. marketmyna/web/assets/common-BvSJP0w_.js +1 -0
  296. marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
  297. marketmyna/web/assets/common-C_t-iZPf.js +1 -0
  298. marketmyna/web/assets/compare-DMcsx70H.js +1 -0
  299. marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
  300. marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
  301. marketmyna/web/assets/files-CpgiZRbN.js +1 -0
  302. marketmyna/web/assets/format-d8InM0Zw.js +9 -0
  303. marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
  304. marketmyna/web/assets/index-CKAnq-Or.js +12 -0
  305. marketmyna/web/assets/index-D4U8E6LH.css +2 -0
  306. marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
  307. marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
  308. marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
  309. marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
  310. marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
  311. marketmyna/web/assets/listings-BefgXpb1.js +1 -0
  312. marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
  313. marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
  314. marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
  315. marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
  316. marketmyna/web/assets/markets-DPO40TTe.js +1 -0
  317. marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
  318. marketmyna/web/assets/markets-ff1e792u.js +1 -0
  319. marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
  320. marketmyna/web/assets/options-BYAZnwzW.js +1 -0
  321. marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
  322. marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
  323. marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  324. marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
  325. marketmyna/web/assets/toast-DDradTZw.js +1 -0
  326. marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
  327. marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
  328. marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
  329. marketmyna/web/brand/logo-mark.webp +0 -0
  330. marketmyna/web/favicon.png +0 -0
  331. marketmyna/web/icon-192.png +0 -0
  332. marketmyna/web/icon-512.png +0 -0
  333. marketmyna/web/index.html +34 -0
  334. marketmyna/web/manifest.webmanifest +28 -0
  335. marketmyna/web/robots.txt +2 -0
  336. marketmyna/web/sw.js +40 -0
  337. marketmyna/web/theme-init.js +37 -0
  338. marketmyna-0.2.0.dist-info/METADATA +345 -0
  339. marketmyna-0.2.0.dist-info/RECORD +344 -0
  340. marketmyna-0.2.0.dist-info/WHEEL +5 -0
  341. marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
  342. marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
  343. marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
  344. marketmyna-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,278 @@
1
+ """The local owner account and helpers around it. Single-user build: no
2
+ sign-up or login; every request runs as the owner (marketmyna/api/middlewares/auth.py),
3
+ an admin account made on first use. Saved secrets (channel webhooks, AI keys)
4
+ are encrypted with AES-GCM using SECRET_KEY or a generated key file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import logging
11
+ import os
12
+ import re
13
+ import secrets
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
19
+ from sqlalchemy.exc import IntegrityError
20
+ from sqlalchemy.orm import Session
21
+
22
+ from marketmyna.core.home import app_home
23
+
24
+ from ..core import tenant
25
+ from ..storage import UserApiKeyRecord, UserRecord
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class AccountError(Exception):
31
+ def __init__(self, message: str, status: int = 400):
32
+ super().__init__(message)
33
+ self.message = message
34
+ self.status = status
35
+
36
+
37
+ BYOK_PROVIDERS = ("gemini", "groq", "openrouter", "openai", "anthropic", "deepseek", "mistral")
38
+
39
+
40
+ LOCAL_OWNER_EMAIL = "owner@localhost"
41
+
42
+
43
+ def ensure_local_owner(session: Session) -> UserRecord:
44
+ """Single-user build: the one account every request runs as. The first
45
+ active admin if there is one, otherwise a fresh admin with no password
46
+ (nobody logs in; the app is reachable only by whoever runs it)."""
47
+ user = (session.query(UserRecord).filter_by(role="admin", status="active")
48
+ .order_by(UserRecord.id).first())
49
+ if user is not None:
50
+ return user
51
+ user = UserRecord(email=LOCAL_OWNER_EMAIL, display_name="Owner", role="admin", status="active",
52
+ email_verified_at=datetime.now())
53
+ session.add(user)
54
+ session.flush()
55
+ adopt_unowned(session, user.id)
56
+ logger.info("Created the local owner account")
57
+ return user
58
+
59
+
60
+ def adopt_unowned(session: Session, user_id: int) -> int:
61
+ """Gives rows saved before accounts existed (user_id NULL: portfolios,
62
+ watchlists, alerts, ...) to the first admin, so switching a local
63
+ install to log-in keeps its data. Returns the number of rows moved."""
64
+ moved = 0
65
+ with tenant.system_scope():
66
+ for model in tenant.owned_models():
67
+ moved += session.query(model).filter(model.user_id.is_(None)).update(
68
+ {model.user_id: user_id}, synchronize_session=False)
69
+ return moved
70
+
71
+
72
+ # ---------- email ----------
73
+
74
+
75
+ def report_sender(config, purpose: Optional[str]) -> Optional[str]:
76
+ """From address for `purpose`: the reports address for scheduled reports
77
+ and alerts, else None (the main sender)."""
78
+ return (getattr(config, "email_reports_sender", None) or None) if purpose in ("report", "alert") else None
79
+
80
+
81
+ # ---------- saved secrets ----------
82
+
83
+
84
+ def _aes_key(config) -> bytes:
85
+ secret = getattr(config, "secret_key", None)
86
+ if not secret:
87
+ path = Path(getattr(config, "app_dir", app_home())) / "secret.key"
88
+ if not path.exists():
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ path.write_text(secrets.token_urlsafe(48), encoding="utf-8")
91
+ path.chmod(0o600)
92
+ secret = path.read_text(encoding="utf-8").strip()
93
+ return hashlib.sha256(b"marketmyna-byok-v1:" + secret.encode()).digest()
94
+
95
+
96
+ def encrypt_secret(config, plaintext: str) -> bytes:
97
+ nonce = os.urandom(12)
98
+ return nonce + AESGCM(_aes_key(config)).encrypt(nonce, plaintext.encode(), b"byok")
99
+
100
+
101
+ def decrypt_secret(config, blob: bytes) -> str:
102
+ return AESGCM(_aes_key(config)).decrypt(blob[:12], blob[12:], b"byok").decode()
103
+
104
+
105
+ # ---------- AI keys ----------
106
+
107
+
108
+ MAX_API_KEYS = 10
109
+
110
+
111
+ def set_api_key(session: Session, config, user: UserRecord, provider: Optional[str], key: str) -> UserApiKeyRecord:
112
+ """Saves a key. With no provider, works out the provider and model from the
113
+ key itself (byok_detect), checking the provider accepts it. The same key
114
+ saved again only refreshes what was detected."""
115
+ provider = (provider or "").strip().lower()
116
+ key = (key or "").strip()
117
+ if len(key) < 8 or len(key) > 512 or any(c.isspace() for c in key):
118
+ raise AccountError("That doesn't look like an API key.")
119
+ if provider and provider not in BYOK_PROVIDERS:
120
+ raise AccountError(f"Unsupported provider. Choose one of: {', '.join(BYOK_PROVIDERS)}.")
121
+ rows = session.query(UserApiKeyRecord).filter_by(user_id=user.id).all()
122
+ row = next((r for r in rows if r.key_last4 == key[-4:] and _same_secret(config, r, key)), None)
123
+ if row is None and len(rows) >= MAX_API_KEYS:
124
+ raise AccountError(f"You can keep up to {MAX_API_KEYS} keys. Remove one first.")
125
+ detected = None
126
+ if not provider:
127
+ from .byok_detect import KeyDetectError, detect
128
+
129
+ try:
130
+ detected = detect(key)
131
+ except KeyDetectError as exc:
132
+ raise AccountError(str(exc)) from None
133
+ provider = detected.provider
134
+ if row is None:
135
+ row = UserApiKeyRecord(user_id=user.id, key_ciphertext=encrypt_secret(config, key), key_last4=key[-4:],
136
+ created_at=datetime.now())
137
+ row.provider = provider
138
+ row.model = detected.model if detected else None
139
+ row.free = detected.free if detected else None
140
+ row.note = detected.note if detected else None
141
+ try:
142
+ with session.begin_nested():
143
+ session.add(row)
144
+ except IntegrityError:
145
+ raise AccountError(f"This database keeps one {provider} key. Remove the old one first.", status=409) from None
146
+ return row
147
+
148
+
149
+ def _same_secret(config, row: UserApiKeyRecord, key: str) -> bool:
150
+ try:
151
+ return decrypt_secret(config, row.key_ciphertext) == key
152
+ except Exception:
153
+ return False
154
+
155
+
156
+ def split_keys(raw: str) -> list[str]:
157
+ """Several keys pasted at once: one per line, or separated by spaces or commas."""
158
+ seen: list[str] = []
159
+ for part in re.split(r"[\s,;]+", raw or ""):
160
+ if part and part not in seen:
161
+ seen.append(part)
162
+ return seen
163
+
164
+
165
+ def set_api_keys(session: Session, config, user: UserRecord, provider: Optional[str], raw: str) -> list[str]:
166
+ """Saves every key in `raw` (split_keys). Returns one message per key that
167
+ failed; raises when none could be saved."""
168
+ keys = split_keys(raw)
169
+ if not keys:
170
+ raise AccountError("Paste an API key.")
171
+ errors = []
172
+ for key in keys:
173
+ try:
174
+ set_api_key(session, config, user, provider, key)
175
+ except AccountError as exc:
176
+ if len(keys) == 1:
177
+ raise
178
+ errors.append(f"Key ending {key[-4:]}: {exc.message}")
179
+ if len(errors) == len(keys):
180
+ raise AccountError(" ".join(errors))
181
+ return errors
182
+
183
+
184
+ def delete_api_key(session: Session, user: UserRecord, key_id: str) -> bool:
185
+ """key_id: the key's id, or a provider name to remove all of its keys."""
186
+ query = session.query(UserApiKeyRecord).filter_by(user_id=user.id)
187
+ ref = (key_id or "").strip().lower()
188
+ rows = query.filter_by(id=int(ref)).all() if ref.isdigit() else query.filter_by(provider=ref).all()
189
+ for row in rows:
190
+ session.delete(row)
191
+ session.flush()
192
+ return bool(rows)
193
+
194
+
195
+ def api_keys(session: Session, user: UserRecord) -> list[dict]:
196
+ """In the order AI tries them (oldest first)."""
197
+ return [
198
+ {
199
+ "id": r.id,
200
+ "provider": r.provider,
201
+ "last4": r.key_last4,
202
+ "model": r.model,
203
+ "free": r.free,
204
+ "note": r.note,
205
+ "added_at": r.created_at.isoformat() if r.created_at else None,
206
+ }
207
+ for r in session.query(UserApiKeyRecord).filter_by(user_id=user.id).order_by(UserApiKeyRecord.id)
208
+ ]
209
+
210
+
211
+ # ---------- background work in accounts mode ----------
212
+
213
+ _owner_cache: dict[str, tuple[float, Optional[int]]] = {}
214
+ OWNER_CACHE_SECONDS = 60
215
+
216
+
217
+ def owner_user_id(config) -> Optional[int]:
218
+ """The platform owner: the first admin account. Its scope gets the
219
+ owner's channels (config.json) and the platform's scheduled report."""
220
+ import time
221
+
222
+ from ..storage import DatabaseManager, get_db_url
223
+
224
+ url = get_db_url(config=config)
225
+ hit = _owner_cache.get(url)
226
+ if hit and time.time() - hit[0] < OWNER_CACHE_SECONDS:
227
+ return hit[1]
228
+ with DatabaseManager.instance(url).get_session() as session:
229
+ row = (session.query(UserRecord.id).filter_by(role="admin", status="active")
230
+ .order_by(UserRecord.id).first())
231
+ owner = row[0] if row else None
232
+ _owner_cache[url] = (time.time(), owner)
233
+ return owner
234
+
235
+
236
+ def is_owner_scope(config) -> bool:
237
+ """Whether the current data scope may use the owner's channels: always
238
+ outside accounts mode; the system scope (errors) or the owner otherwise."""
239
+ if not tenant.enabled():
240
+ return True
241
+ scope = tenant.current()
242
+ if scope is tenant.SYSTEM:
243
+ return True
244
+ return scope is not None and scope == owner_user_id(config)
245
+
246
+
247
+ def owner_scope(config):
248
+ """Runs the block as the platform owner in accounts mode (fails closed
249
+ when there is no admin yet); a no-op otherwise."""
250
+ import contextlib
251
+
252
+ if not tenant.enabled():
253
+ return contextlib.nullcontext()
254
+ return tenant.user_scope(owner_user_id(config))
255
+
256
+
257
+ def active_user_ids(config) -> list[int]:
258
+ from ..storage import DatabaseManager, get_db_url
259
+
260
+ with DatabaseManager.instance(get_db_url(config=config)).get_session() as session:
261
+ rows = (session.query(UserRecord.id)
262
+ .filter(UserRecord.status == "active", UserRecord.email_verified_at.isnot(None))
263
+ .order_by(UserRecord.id).all())
264
+ return [r[0] for r in rows]
265
+
266
+
267
+ def for_each_user(config, fn, label: str) -> None:
268
+ """Runs fn() once per active user in that user's scope (accounts mode),
269
+ or once unscoped otherwise. One user's failure doesn't stop the rest."""
270
+ if not tenant.enabled():
271
+ fn()
272
+ return
273
+ for user_id in active_user_ids(config):
274
+ try:
275
+ with tenant.user_scope(user_id):
276
+ fn()
277
+ except Exception:
278
+ logger.exception("%s failed for user %s", label, user_id)
@@ -0,0 +1,94 @@
1
+ """Which AI the app uses.
2
+
3
+ The owner's own keys come first (Settings -> AI keys: paste a key, the
4
+ provider and model are worked out from it, marketmyna/services/byok_detect.py),
5
+ each tried in turn. With no saved key, the AI setup in config.json and the
6
+ environment (LLM_MODEL, OLLAMA_BASE_URL, ...) is used unchanged; with
7
+ neither, AI features say no model is configured.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ from typing import Optional
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Model used with a key when none was picked from the provider's list when
19
+ # the key was saved (marketmyna/services/byok_detect.py). Aliases the provider keeps
20
+ # current where they exist; a retired one is replaced at the first failing
21
+ # call (marketmyna/llm/model_health.py). Override with BYOK_MODEL_<PROVIDER>, e.g.
22
+ # BYOK_MODEL_GEMINI=gemini/gemini-pro-latest.
23
+ BYOK_DEFAULT_MODELS = {
24
+ "gemini": "gemini/gemini-flash-latest",
25
+ "groq": "groq/openai/gpt-oss-120b",
26
+ "openrouter": "openrouter/openai/gpt-5-mini",
27
+ "openai": "openai/gpt-5-mini",
28
+ "anthropic": "anthropic/claude-haiku-4-5",
29
+ "deepseek": "deepseek/deepseek-chat",
30
+ "mistral": "mistral/mistral-small-latest",
31
+ }
32
+
33
+
34
+ def byok_model(provider: str, detected: Optional[str] = None) -> str:
35
+ """Env override, else the model picked when the key was saved, else the default."""
36
+ return os.environ.get(f"BYOK_MODEL_{provider.upper()}") or detected or BYOK_DEFAULT_MODELS[provider]
37
+
38
+
39
+ def _db(config):
40
+ from ..storage import DatabaseManager, get_db_url
41
+
42
+ return DatabaseManager.instance(get_db_url(config=config))
43
+
44
+
45
+ def own_keys(config) -> list[tuple[str, str, Optional[str], int]]:
46
+ """Every usable saved key as (provider, key, model, key row id), oldest first."""
47
+ from datetime import datetime
48
+
49
+ from ..core import tenant
50
+ from ..storage import UserApiKeyRecord
51
+ from .accounts import BYOK_PROVIDERS, decrypt_secret, owner_user_id
52
+
53
+ owner = owner_user_id(config) if tenant.enabled() else None
54
+ out = []
55
+ try:
56
+ with _db(config).get_session() as session:
57
+ rows = session.query(UserApiKeyRecord)
58
+ if owner is not None:
59
+ rows = rows.filter_by(user_id=owner)
60
+ for row in rows.order_by(UserApiKeyRecord.id):
61
+ if row.provider not in BYOK_PROVIDERS:
62
+ continue
63
+ try:
64
+ out.append((row.provider, decrypt_secret(config, row.key_ciphertext), row.model, row.id))
65
+ except Exception:
66
+ logger.warning("Could not decrypt the saved %s key %s", row.provider, row.id)
67
+ continue
68
+ row.last_used_at = datetime.now()
69
+ except Exception:
70
+ logger.exception("Could not read the saved AI keys")
71
+ return out
72
+
73
+
74
+ def wrap_backend(config, build, tier: str):
75
+ """The saved keys if there are any, else `build(config, tier)` (config.json / env)."""
76
+ keys = own_keys(config)
77
+ if not keys:
78
+ return build(config, tier)
79
+ from ..llm.channels import FailoverGenerationBackend, LLMRoute
80
+
81
+ routes = [LLMRoute(byok_model(provider, model), [key], None, source="byok", key_id=key_id)
82
+ for provider, key, model, key_id in keys]
83
+ return FailoverGenerationBackend(config, routes, store=None, tier=tier)
84
+
85
+
86
+ def update_byok_model(config, key_id: int, model: str) -> None:
87
+ """The model saved with a key was retired: keep the one that answered
88
+ instead (marketmyna/llm/model_health.py)."""
89
+ from ..storage import UserApiKeyRecord
90
+
91
+ with _db(config).get_session() as session:
92
+ row = session.get(UserApiKeyRecord, key_id)
93
+ if row is not None:
94
+ row.model = model