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.
- marketmyna/__init__.py +14 -0
- marketmyna/agent/__init__.py +8 -0
- marketmyna/agent/agents.py +543 -0
- marketmyna/agent/context.py +69 -0
- marketmyna/agent/dashboard.py +55 -0
- marketmyna/agent/orchestrator.py +153 -0
- marketmyna/agent/sentiment.py +150 -0
- marketmyna/agent/skills.py +119 -0
- marketmyna/ai/__init__.py +1 -0
- marketmyna/ai/portfolio_optimizer.py +330 -0
- marketmyna/analysis/__init__.py +6 -0
- marketmyna/analysis/fundamental.py +329 -0
- marketmyna/analysis/sip_optimizer.py +81 -0
- marketmyna/analysis/technical.py +321 -0
- marketmyna/api/__init__.py +1 -0
- marketmyna/api/app.py +209 -0
- marketmyna/api/deps.py +63 -0
- marketmyna/api/middlewares/__init__.py +0 -0
- marketmyna/api/middlewares/auth.py +116 -0
- marketmyna/api/middlewares/csrf.py +66 -0
- marketmyna/api/middlewares/error_handler.py +38 -0
- marketmyna/api/middlewares/slashes.py +27 -0
- marketmyna/api/v1/__init__.py +0 -0
- marketmyna/api/v1/endpoints/__init__.py +0 -0
- marketmyna/api/v1/endpoints/account.py +166 -0
- marketmyna/api/v1/endpoints/agent.py +114 -0
- marketmyna/api/v1/endpoints/bot.py +38 -0
- marketmyna/api/v1/endpoints/calendar.py +269 -0
- marketmyna/api/v1/endpoints/data_sources.py +174 -0
- marketmyna/api/v1/endpoints/health.py +10 -0
- marketmyna/api/v1/endpoints/listings.py +564 -0
- marketmyna/api/v1/endpoints/macro.py +163 -0
- marketmyna/api/v1/endpoints/portfolio.py +530 -0
- marketmyna/api/v1/endpoints/push.py +119 -0
- marketmyna/api/v1/endpoints/reports.py +49 -0
- marketmyna/api/v1/endpoints/scheduler.py +219 -0
- marketmyna/api/v1/endpoints/stocks.py +400 -0
- marketmyna/api/v1/endpoints/system_config.py +157 -0
- marketmyna/api/v1/endpoints/thesis.py +112 -0
- marketmyna/api/v1/endpoints/trade.py +709 -0
- marketmyna/api/v1/endpoints/updates.py +394 -0
- marketmyna/api/v1/endpoints/watchlists.py +179 -0
- marketmyna/api/v1/router.py +43 -0
- marketmyna/bot/__init__.py +14 -0
- marketmyna/bot/commands/__init__.py +0 -0
- marketmyna/bot/commands/analyze.py +40 -0
- marketmyna/bot/commands/ask.py +51 -0
- marketmyna/bot/commands/base.py +35 -0
- marketmyna/bot/commands/batch.py +53 -0
- marketmyna/bot/commands/common.py +84 -0
- marketmyna/bot/commands/help.py +43 -0
- marketmyna/bot/commands/markets.py +153 -0
- marketmyna/bot/commands/portfolio.py +145 -0
- marketmyna/bot/commands/research.py +323 -0
- marketmyna/bot/commands/status.py +48 -0
- marketmyna/bot/commands/trade.py +176 -0
- marketmyna/bot/dispatcher.py +95 -0
- marketmyna/bot/formatting.py +25 -0
- marketmyna/bot/models.py +27 -0
- marketmyna/bot/registry.py +50 -0
- marketmyna/bot/telegram_listener.py +227 -0
- marketmyna/cli/__init__.py +1 -0
- marketmyna/cli/scheduled_run.py +292 -0
- marketmyna/core/__init__.py +6 -0
- marketmyna/core/compliance.py +53 -0
- marketmyna/core/config.py +377 -0
- marketmyna/core/constants.py +69 -0
- marketmyna/core/home.py +22 -0
- marketmyna/core/numbers.py +28 -0
- marketmyna/core/secret_store.py +104 -0
- marketmyna/core/tenant.py +130 -0
- marketmyna/data/__init__.py +8 -0
- marketmyna/data/base.py +36 -0
- marketmyna/data/categories.py +272 -0
- marketmyna/data/china.py +360 -0
- marketmyna/data/crypto.py +200 -0
- marketmyna/data/ecb_fx.py +131 -0
- marketmyna/data/extra_sources.py +569 -0
- marketmyna/data/india.py +241 -0
- marketmyna/data/india_filings.py +343 -0
- marketmyna/data/listings.py +1314 -0
- marketmyna/data/mutual_funds.py +34 -0
- marketmyna/data/news.py +98 -0
- marketmyna/data/news_search.py +346 -0
- marketmyna/data/nse_deals.py +138 -0
- marketmyna/data/resilience.py +173 -0
- marketmyna/data/sec_facts.py +252 -0
- marketmyna/data/shariah.py +283 -0
- marketmyna/data/social/__init__.py +9 -0
- marketmyna/data/social/base.py +100 -0
- marketmyna/data/social/browser.py +92 -0
- marketmyna/data/social/hackernews.py +53 -0
- marketmyna/data/social/reddit.py +397 -0
- marketmyna/data/social/stocktwits.py +63 -0
- marketmyna/data/social/valuepickr.py +53 -0
- marketmyna/data/stocks.py +197 -0
- marketmyna/data/yahoo_price.py +48 -0
- marketmyna/launcher.py +71 -0
- marketmyna/llm/__init__.py +33 -0
- marketmyna/llm/backend_factory.py +51 -0
- marketmyna/llm/backend_registry.py +19 -0
- marketmyna/llm/channels.py +447 -0
- marketmyna/llm/json_reply.py +63 -0
- marketmyna/llm/litellm_backend.py +197 -0
- marketmyna/llm/model_catalog.py +166 -0
- marketmyna/llm/model_health.py +193 -0
- marketmyna/llm/protocol.py +111 -0
- marketmyna/llm/providers.py +128 -0
- marketmyna/llm/store.py +128 -0
- marketmyna/notifiers/__init__.py +35 -0
- marketmyna/notifiers/apprise_sender.py +43 -0
- marketmyna/notifiers/astrbot.py +47 -0
- marketmyna/notifiers/bark.py +24 -0
- marketmyna/notifiers/base.py +80 -0
- marketmyna/notifiers/dingtalk.py +54 -0
- marketmyna/notifiers/discord.py +39 -0
- marketmyna/notifiers/dispatcher.py +153 -0
- marketmyna/notifiers/email.py +99 -0
- marketmyna/notifiers/feishu.py +57 -0
- marketmyna/notifiers/googlechat.py +24 -0
- marketmyna/notifiers/gotify.py +36 -0
- marketmyna/notifiers/matrix.py +28 -0
- marketmyna/notifiers/ntfy.py +36 -0
- marketmyna/notifiers/pushbullet.py +24 -0
- marketmyna/notifiers/pushover.py +41 -0
- marketmyna/notifiers/pushplus.py +41 -0
- marketmyna/notifiers/serverchan3.py +40 -0
- marketmyna/notifiers/settings_schema.py +119 -0
- marketmyna/notifiers/signal_messenger.py +25 -0
- marketmyna/notifiers/slack.py +36 -0
- marketmyna/notifiers/teams.py +37 -0
- marketmyna/notifiers/telegram.py +81 -0
- marketmyna/notifiers/twilio.py +33 -0
- marketmyna/notifiers/webhook.py +44 -0
- marketmyna/notifiers/webpush.py +222 -0
- marketmyna/notifiers/wechat.py +40 -0
- marketmyna/notifiers/whatsapp.py +27 -0
- marketmyna/portfolio/__init__.py +6 -0
- marketmyna/portfolio/analytics.py +336 -0
- marketmyna/portfolio/manager.py +383 -0
- marketmyna/portfolio/models.py +155 -0
- marketmyna/portfolio/optimizers.py +88 -0
- marketmyna/portfolio/review.py +411 -0
- marketmyna/services/__init__.py +1 -0
- marketmyna/services/accounts.py +278 -0
- marketmyna/services/ai_access.py +94 -0
- marketmyna/services/analysis_run.py +491 -0
- marketmyna/services/big_trades.py +335 -0
- marketmyna/services/brokers/__init__.py +0 -0
- marketmyna/services/brokers/angel.py +271 -0
- marketmyna/services/brokers/finnhub.py +209 -0
- marketmyna/services/byok_detect.py +196 -0
- marketmyna/services/calendar.py +748 -0
- marketmyna/services/central_banks.py +202 -0
- marketmyna/services/commodities.py +134 -0
- marketmyna/services/compare.py +307 -0
- marketmyna/services/cot.py +148 -0
- marketmyna/services/country_macro.py +135 -0
- marketmyna/services/crude_sensitivity.py +239 -0
- marketmyna/services/crypto_mood.py +135 -0
- marketmyna/services/crypto_screens.py +226 -0
- marketmyna/services/data_export.py +91 -0
- marketmyna/services/dcf.py +220 -0
- marketmyna/services/dcf_model.py +194 -0
- marketmyna/services/delivery.py +142 -0
- marketmyna/services/discovery.py +146 -0
- marketmyna/services/dividends.py +95 -0
- marketmyna/services/earnings.py +305 -0
- marketmyna/services/econ_calendar.py +495 -0
- marketmyna/services/eia_stocks.py +115 -0
- marketmyna/services/estimates.py +109 -0
- marketmyna/services/etf.py +236 -0
- marketmyna/services/etf_rotation.py +193 -0
- marketmyna/services/etf_screens.py +196 -0
- marketmyna/services/financials.py +639 -0
- marketmyna/services/fund_ai.py +181 -0
- marketmyna/services/fund_analytics.py +371 -0
- marketmyna/services/fund_cost.py +147 -0
- marketmyna/services/fund_holdings.py +664 -0
- marketmyna/services/fund_peers.py +247 -0
- marketmyna/services/fund_rotation.py +206 -0
- marketmyna/services/fund_style.py +179 -0
- marketmyna/services/funds.py +70 -0
- marketmyna/services/hot_stocks.py +358 -0
- marketmyna/services/india_flows.py +263 -0
- marketmyna/services/india_rates.py +258 -0
- marketmyna/services/investor_lenses.py +271 -0
- marketmyna/services/leadership.py +165 -0
- marketmyna/services/live_market.py +497 -0
- marketmyna/services/live_quotes.py +295 -0
- marketmyna/services/macro.py +182 -0
- marketmyna/services/market_calendar.py +399 -0
- marketmyna/services/market_mood.py +168 -0
- marketmyna/services/market_valuation.py +231 -0
- marketmyna/services/mf_holdings.py +199 -0
- marketmyna/services/mf_insights.py +132 -0
- marketmyna/services/mf_lumpsum.py +587 -0
- marketmyna/services/mf_portfolio_ai.py +195 -0
- marketmyna/services/nse_events.py +98 -0
- marketmyna/services/overview.py +366 -0
- marketmyna/services/ownership.py +143 -0
- marketmyna/services/quality_scores.py +199 -0
- marketmyna/services/rates.py +335 -0
- marketmyna/services/reconcile.py +343 -0
- marketmyna/services/report.py +584 -0
- marketmyna/services/report_i18n.py +678 -0
- marketmyna/services/rotation.py +224 -0
- marketmyna/services/runtime_scheduler.py +548 -0
- marketmyna/services/schedule_config.py +251 -0
- marketmyna/services/schedule_time.py +39 -0
- marketmyna/services/screens.py +398 -0
- marketmyna/services/sec_mdna.py +173 -0
- marketmyna/services/shariah.py +246 -0
- marketmyna/services/sip.py +148 -0
- marketmyna/services/sip_days.py +362 -0
- marketmyna/services/smart_file.py +595 -0
- marketmyna/services/smart_pdf.py +425 -0
- marketmyna/services/smart_report.py +370 -0
- marketmyna/services/social_sentiment.py +511 -0
- marketmyna/services/thesis.py +328 -0
- marketmyna/services/translate.py +146 -0
- marketmyna/services/valuation.py +173 -0
- marketmyna/services/worker_pool.py +149 -0
- marketmyna/services/world_markets.py +208 -0
- marketmyna/storage.py +722 -0
- marketmyna/strategies/README.md +51 -0
- marketmyna/strategies/bottom_volume.yaml +49 -0
- marketmyna/strategies/box_oscillation.yaml +69 -0
- marketmyna/strategies/bull_trend.yaml +49 -0
- marketmyna/strategies/chan_theory.yaml +58 -0
- marketmyna/strategies/dragon_head.yaml +44 -0
- marketmyna/strategies/emotion_cycle.yaml +79 -0
- marketmyna/strategies/event_driven.yaml +54 -0
- marketmyna/strategies/expectation_repricing.yaml +54 -0
- marketmyna/strategies/growth_quality.yaml +54 -0
- marketmyna/strategies/hot_theme.yaml +54 -0
- marketmyna/strategies/ma_golden_cross.yaml +46 -0
- marketmyna/strategies/one_yang_three_yin.yaml +37 -0
- marketmyna/strategies/shrink_pullback.yaml +46 -0
- marketmyna/strategies/volume_breakout.yaml +47 -0
- marketmyna/strategies/wave_theory.yaml +64 -0
- marketmyna/trading/__init__.py +2 -0
- marketmyna/trading/alerts.py +356 -0
- marketmyna/trading/backtest.py +317 -0
- marketmyna/trading/charges.py +82 -0
- marketmyna/trading/chart.py +197 -0
- marketmyna/trading/indicators.py +303 -0
- marketmyna/trading/journal.py +74 -0
- marketmyna/trading/knn.py +164 -0
- marketmyna/trading/lots.py +109 -0
- marketmyna/trading/market_hours.py +66 -0
- marketmyna/trading/options.py +554 -0
- marketmyna/trading/pairs.py +94 -0
- marketmyna/trading/risk.py +170 -0
- marketmyna/trading/scanner.py +500 -0
- marketmyna/trading/volatility.py +163 -0
- marketmyna/utils/__init__.py +1 -0
- marketmyna/utils/deadline.py +36 -0
- marketmyna/utils/log_redact.py +52 -0
- marketmyna/utils/yahoo_gate.py +183 -0
- marketmyna/utils/yf_lock.py +12 -0
- marketmyna/web/apple-touch-icon.png +0 -0
- marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
- marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
- marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
- marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
- marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
- marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
- marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
- marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
- marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
- marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
- marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
- marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
- marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
- marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
- marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
- marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
- marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
- marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
- marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
- marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
- marketmyna/web/assets/app-BP1o7WZS.js +1 -0
- marketmyna/web/assets/app-BkIUKdld.js +1 -0
- marketmyna/web/assets/app-CKeSYhEt.js +1 -0
- marketmyna/web/assets/app-CN1OERYX.js +1 -0
- marketmyna/web/assets/app-DIQllha5.js +1 -0
- marketmyna/web/assets/app-DeiAW0tX.js +1 -0
- marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
- marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
- marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
- marketmyna/web/assets/common-0wvpAVpd.js +1 -0
- marketmyna/web/assets/common-3ghy53Us.js +1 -0
- marketmyna/web/assets/common-Bib2unjX.js +1 -0
- marketmyna/web/assets/common-BvSJP0w_.js +1 -0
- marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
- marketmyna/web/assets/common-C_t-iZPf.js +1 -0
- marketmyna/web/assets/compare-DMcsx70H.js +1 -0
- marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
- marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
- marketmyna/web/assets/files-CpgiZRbN.js +1 -0
- marketmyna/web/assets/format-d8InM0Zw.js +9 -0
- marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
- marketmyna/web/assets/index-CKAnq-Or.js +12 -0
- marketmyna/web/assets/index-D4U8E6LH.css +2 -0
- marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
- marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
- marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
- marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
- marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
- marketmyna/web/assets/listings-BefgXpb1.js +1 -0
- marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
- marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
- marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
- marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
- marketmyna/web/assets/markets-DPO40TTe.js +1 -0
- marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
- marketmyna/web/assets/markets-ff1e792u.js +1 -0
- marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
- marketmyna/web/assets/options-BYAZnwzW.js +1 -0
- marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
- marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
- marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
- marketmyna/web/assets/toast-DDradTZw.js +1 -0
- marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
- marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
- marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
- marketmyna/web/brand/logo-mark.webp +0 -0
- marketmyna/web/favicon.png +0 -0
- marketmyna/web/icon-192.png +0 -0
- marketmyna/web/icon-512.png +0 -0
- marketmyna/web/index.html +34 -0
- marketmyna/web/manifest.webmanifest +28 -0
- marketmyna/web/robots.txt +2 -0
- marketmyna/web/sw.js +40 -0
- marketmyna/web/theme-init.js +37 -0
- marketmyna-0.2.0.dist-info/METADATA +345 -0
- marketmyna-0.2.0.dist-info/RECORD +344 -0
- marketmyna-0.2.0.dist-info/WHEEL +5 -0
- marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
- marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
- marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
- 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
|