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,447 @@
1
+ """LLM routes: every model the app may call, in priority order, and the
2
+ failover backend that walks them.
3
+
4
+ Sources, in order:
5
+ 1. The main model: Config.llm_model with llm_api_key (+ llm_api_keys for
6
+ rotation) and llm_api_base.
7
+ 2. Named channels, configured the same way as daily_stock_analysis so the
8
+ same environment variables / GitHub secrets work in both projects:
9
+
10
+ LLM_CHANNELS=groq,openrouter
11
+ LLM_GROQ_API_KEYS=key1,key2 (or LLM_GROQ_API_KEY=key)
12
+ LLM_GROQ_MODELS=llama-3.3-70b-versatile
13
+ LLM_GROQ_PROTOCOL=openai (optional; inferred from the name)
14
+ LLM_GROQ_BASE_URL=https://api.groq.com/openai/v1 (optional)
15
+ LLM_GROQ_ENABLED=false (optional kill switch)
16
+
17
+ 3. Config.llm_fallback_models (env LLM_FALLBACK_MODELS or the
18
+ daily_stock_analysis name LITELLM_FALLBACK_MODELS), which use each
19
+ provider's own key env var (GEMINI_API_KEY, GROQ_API_KEY, ...) via litellm.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import os
26
+ import threading
27
+ import time
28
+ from dataclasses import dataclass, field, replace
29
+ from types import SimpleNamespace
30
+ from typing import Callable, Mapping, Optional
31
+
32
+ from .litellm_backend import LiteLLMGenerationBackend
33
+ from .protocol import (
34
+ GenerationConfig,
35
+ GenerationError,
36
+ GenerationErrorCode,
37
+ GenerationResult,
38
+ )
39
+ from .store import clean_error
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ # Provider prefixes litellm understands that a channel name can imply.
44
+ KNOWN_PROVIDERS = {
45
+ "openai", "anthropic", "gemini", "vertex_ai", "deepseek", "ollama", "groq", "openrouter", "mistral",
46
+ "together_ai", "fireworks_ai", "perplexity", "xai", "cohere", "azure", "bedrock", "huggingface",
47
+ "cerebras", "sambanova", "nvidia_nim", "dashscope", "moonshot", "zhipuai", "volcengine",
48
+ }
49
+ PROTOCOL_ALIASES = {"openai_compatible": "openai", "openai_compat": "openai", "claude": "anthropic", "google": "gemini"}
50
+
51
+ # Bring-your-own key per provider: Config attribute holding that provider's key.
52
+ PROVIDER_KEY_FIELDS = {
53
+ "gemini": "gemini_api_key",
54
+ "groq": "groq_api_key",
55
+ "openrouter": "openrouter_api_key",
56
+ "openai": "openai_api_key",
57
+ "anthropic": "anthropic_api_key",
58
+ "deepseek": "deepseek_api_key",
59
+ "mistral": "mistral_api_key",
60
+ "cerebras": "cerebras_api_key",
61
+ "nvidia_nim": "nvidia_api_key",
62
+ }
63
+
64
+
65
+ def provider_of(model: str) -> Optional[str]:
66
+ """litellm provider for a model id ("groq/llama..." -> "groq"; bare
67
+ "gpt-4o-mini" -> "openai", "claude-..." -> "anthropic")."""
68
+ model = (model or "").strip()
69
+ if "/" in model:
70
+ prefix = model.split("/", 1)[0].lower()
71
+ return PROTOCOL_ALIASES.get(prefix, prefix)
72
+ lowered = model.lower()
73
+ if lowered.startswith(("gpt-", "o1", "o3", "o4", "chatgpt")):
74
+ return "openai"
75
+ if lowered.startswith("claude"):
76
+ return "anthropic"
77
+ if lowered.startswith("gemini"):
78
+ return "gemini"
79
+ return None
80
+
81
+
82
+ # How long a rate-limited / failing key is skipped before being retried. Each
83
+ # failure in a row doubles it (a dead free model stops costing a call a minute),
84
+ # up to MAX_COOLDOWN_SECONDS; one success resets it.
85
+ COOLDOWN_SECONDS = 60.0
86
+ MAX_COOLDOWN_SECONDS = 3600.0
87
+
88
+
89
+ @dataclass
90
+ class LLMRoute:
91
+ model: str
92
+ api_keys: list[str] = field(default_factory=list)
93
+ api_base: Optional[str] = None
94
+ source: str = "main"
95
+ # BYOK: the user's saved key row, so a retired model can be updated on it.
96
+ key_id: Optional[int] = None
97
+
98
+ def label(self) -> str:
99
+ return f"{self.source}:{self.model}"
100
+
101
+
102
+ def _split(raw: Optional[str]) -> list[str]:
103
+ return [x.strip() for x in (raw or "").split(",") if x.strip()]
104
+
105
+
106
+ def _protocol(name: str, explicit: str, base_url: Optional[str]) -> Optional[str]:
107
+ explicit = PROTOCOL_ALIASES.get(explicit.lower(), explicit.lower()) if explicit else ""
108
+ if explicit:
109
+ return explicit
110
+ lowered = name.lower()
111
+ if lowered in KNOWN_PROVIDERS:
112
+ return lowered
113
+ return "openai" if base_url else None # any custom endpoint is assumed OpenAI-compatible
114
+
115
+
116
+ def normalize_model(model: str, protocol: Optional[str]) -> str:
117
+ """Give a bare model id the provider prefix litellm routes on."""
118
+ model = model.strip()
119
+ if not model or not protocol:
120
+ return model
121
+ if "/" in model and model.split("/", 1)[0].lower() in KNOWN_PROVIDERS:
122
+ return model
123
+ return f"{protocol}/{model}"
124
+
125
+
126
+ def channel_routes(environ: Mapping[str, str], names: list[str]) -> list[LLMRoute]:
127
+ routes = []
128
+ for name in names:
129
+ up = name.upper()
130
+ enabled = environ.get(f"LLM_{up}_ENABLED", "").strip().lower()
131
+ if enabled in ("0", "false", "no", "off"):
132
+ logger.info("LLM channel %s disabled", name)
133
+ continue
134
+ base_url = environ.get(f"LLM_{up}_BASE_URL", "").strip() or None
135
+ keys = _split(environ.get(f"LLM_{up}_API_KEYS")) or _split(environ.get(f"LLM_{up}_API_KEY"))
136
+ models = _split(environ.get(f"LLM_{up}_MODELS"))
137
+ protocol = _protocol(name, environ.get(f"LLM_{up}_PROTOCOL", "").strip(), base_url)
138
+ if not models:
139
+ logger.warning("LLM channel %s has no LLM_%s_MODELS; skipped", name, up)
140
+ continue
141
+ if not keys and protocol != "ollama":
142
+ logger.warning("LLM channel %s has no API key (LLM_%s_API_KEY[S]); skipped", name, up)
143
+ continue
144
+ for model in models:
145
+ routes.append(LLMRoute(normalize_model(model, protocol), keys, base_url, source=name.lower()))
146
+ return routes
147
+
148
+
149
+ def build_routes(config, environ: Optional[Mapping[str, str]] = None, tier: str = "deep") -> list[LLMRoute]:
150
+ """Every configured model, highest priority first, without duplicates.
151
+
152
+ tier="fast" puts Config.llm_fast_model (the cheap/fast "level 1" model)
153
+ in front; the rest of the chain stays behind it as a fallback.
154
+ """
155
+ environ = os.environ if environ is None else environ
156
+ routes: list[LLMRoute] = []
157
+
158
+ fast_model = getattr(config, "llm_fast_model", None)
159
+ if tier == "fast" and fast_model:
160
+ routes.append(LLMRoute(fast_model.strip(), source="fast"))
161
+
162
+ main_model = getattr(config, "llm_model", None)
163
+ if main_model:
164
+ keys = [k for k in [getattr(config, "llm_api_key", None), *(getattr(config, "llm_api_keys", None) or [])] if k]
165
+ routes.append(LLMRoute(main_model.strip(), list(dict.fromkeys(keys)), getattr(config, "llm_api_base", None)))
166
+
167
+ names = list(getattr(config, "llm_channels", None) or []) or _split(environ.get("LLM_CHANNELS"))
168
+ routes += channel_routes(environ, names)
169
+
170
+ fallbacks = list(getattr(config, "llm_fallback_models", None) or []) or _split(
171
+ environ.get("LLM_FALLBACK_MODELS") or environ.get("LITELLM_FALLBACK_MODELS")
172
+ )
173
+ routes += [LLMRoute(m, source="fallback") for m in fallbacks]
174
+
175
+ # Only a fast model set: the deep tier uses it rather than reporting
176
+ # "no AI model configured". With any main chain it stays fast-only.
177
+ if tier != "fast" and fast_model and not routes:
178
+ routes.append(LLMRoute(fast_model.strip(), source="fast"))
179
+
180
+ seen, unique = set(), []
181
+ for route in routes:
182
+ key = (route.model, route.api_base)
183
+ if route.model and key not in seen:
184
+ seen.add(key)
185
+ unique.append(route)
186
+
187
+ # Routes without their own key use the provider's saved key (BYOK);
188
+ # litellm still falls back to provider env vars when neither is set.
189
+ for route in unique:
190
+ provider = provider_of(route.model)
191
+ if not route.api_keys and provider in PROVIDER_KEY_FIELDS:
192
+ key = getattr(config, PROVIDER_KEY_FIELDS[provider], None)
193
+ if key:
194
+ route.api_keys = [key]
195
+ if provider == "ollama" and not route.api_base:
196
+ route.api_base = getattr(config, "ollama_base_url", None) or None
197
+ # openai/* models can live on any OpenAI-compatible server (LM Studio,
198
+ # vLLM, a gateway); OPENAI_BASE_URL points them there.
199
+ if provider == "openai" and not route.api_base:
200
+ route.api_base = getattr(config, "openai_base_url", None) or None
201
+ return unique
202
+
203
+
204
+ def llm_configured(config) -> bool:
205
+ """A saved AI key (Settings -> AI keys) or a model set in config.json / env."""
206
+ if build_routes(config):
207
+ return True
208
+ from ..services.ai_access import own_keys
209
+
210
+ return bool(own_keys(config))
211
+
212
+
213
+ class _Cooldowns:
214
+ """Process-wide memory of which (model, key) pairs just failed, so a
215
+ batch of calls (one per stock) doesn't hit a rate-limited key again
216
+ and again. Keyed by a key index, never the key itself."""
217
+
218
+ def __init__(self):
219
+ self._until: dict[tuple[str, Optional[str], int], float] = {}
220
+ self._strikes: dict[tuple[str, Optional[str], int], int] = {}
221
+ self._lock = threading.Lock()
222
+
223
+ def cooling(self, key) -> bool:
224
+ with self._lock:
225
+ return self._until.get(key, 0.0) > time.monotonic()
226
+
227
+ def mark(self, key, seconds: float) -> None:
228
+ with self._lock:
229
+ self._until[key] = time.monotonic() + seconds
230
+
231
+ def fail(self, key) -> float:
232
+ """Another failure in a row: skip the key twice as long as last time."""
233
+ with self._lock:
234
+ strikes = self._strikes.get(key, 0) + 1
235
+ self._strikes[key] = strikes
236
+ seconds = min(MAX_COOLDOWN_SECONDS, COOLDOWN_SECONDS * 2 ** (strikes - 1))
237
+ self._until[key] = time.monotonic() + seconds
238
+ return seconds
239
+
240
+ def ok(self, key) -> None:
241
+ with self._lock:
242
+ self._strikes.pop(key, None)
243
+ self._until.pop(key, None)
244
+
245
+
246
+ _COOLDOWNS = _Cooldowns()
247
+
248
+
249
+ class FailoverGenerationBackend:
250
+ """Tries each route (and each of its keys) until one answers.
251
+
252
+ Non-fallbackable errors (e.g. streaming requested) stop immediately;
253
+ everything else - rate limits, timeouts, auth failures, provider
254
+ outages - moves on to the next key, then the next model.
255
+ """
256
+
257
+ backend_id = "litellm"
258
+ capabilities = LiteLLMGenerationBackend.capabilities
259
+
260
+ def __init__(
261
+ self,
262
+ config,
263
+ routes: list[LLMRoute],
264
+ backend_factory: Callable[[object], object] = LiteLLMGenerationBackend,
265
+ cooldowns: _Cooldowns = _COOLDOWNS,
266
+ store=None,
267
+ tier: str = "deep",
268
+ heal: bool = True,
269
+ ):
270
+ self._config = config
271
+ self._routes = routes
272
+ self._factory = backend_factory
273
+ self._cooldowns = cooldowns
274
+ self._rotation: dict[str, int] = {}
275
+ # Optional LLMStore: response cache + usage counters.
276
+ self._store = store
277
+ self.tier = tier
278
+ # Replace a retired model with the provider's closest current one
279
+ # (marketmyna/llm/model_health.py).
280
+ self._heal = heal
281
+ # Level-1 backend for simple jobs; set by create_generation_backend.
282
+ self.fast = self
283
+
284
+ def _backend_for(self, route: LLMRoute, api_key: Optional[str]):
285
+ cfg = SimpleNamespace(
286
+ llm_model=route.model,
287
+ llm_api_key=api_key,
288
+ llm_api_base=route.api_base,
289
+ llm_timeout_seconds=getattr(self._config, "llm_timeout_seconds", 60.0),
290
+ llm_max_tokens=getattr(self._config, "llm_max_tokens", 2048),
291
+ llm_temperature=getattr(self._config, "llm_temperature", 0.7),
292
+ )
293
+ return self._factory(cfg)
294
+
295
+ def _effective(self, route: LLMRoute) -> LLMRoute:
296
+ """A platform model already known to be retired goes straight to its
297
+ working replacement (until the admin accepts or changes it)."""
298
+ if not self._heal or route.source == "byok":
299
+ return route
300
+ try:
301
+ from . import model_health
302
+
303
+ new = model_health.substitute(self._config, route.model)
304
+ except Exception:
305
+ logger.debug("model health state unreadable", exc_info=True)
306
+ return route
307
+ return replace(route, model=new) if new else route
308
+
309
+ def _replace_retired(self, route, key, error, attempts, prompt, generation_config, system_prompt, cache_key):
310
+ """The route's model is gone: try the closest current model with the
311
+ same key. Returns the result, or None to carry on down the chain."""
312
+ from . import model_health
313
+
314
+ tried = {route.model}
315
+ for _ in range(model_health.MAX_TRIES):
316
+ try:
317
+ new = model_health.find_replacement(route.model, key, route.api_base, tried)
318
+ except Exception:
319
+ logger.exception("Looking for a replacement for %s failed", route.model)
320
+ new = None
321
+ if not new:
322
+ break
323
+ tried.add(new)
324
+ stand_in = replace(route, model=new)
325
+ try:
326
+ result = self._backend_for(stand_in, key).generate(
327
+ prompt, generation_config, system_prompt=system_prompt)
328
+ except GenerationError as exc:
329
+ attempts.append(f"{stand_in.label()}: {exc.error_code.value}")
330
+ if self._store is not None:
331
+ self._store.record(f"{self.tier}:{new}", ok=False, error=exc)
332
+ if exc.error_code != GenerationErrorCode.MODEL_UNAVAILABLE:
333
+ break
334
+ continue
335
+ model_health.replaced(self._config, route, new, error)
336
+ old = route.model
337
+ attempts.append(f"{route.label()}: retired, answered by {new}")
338
+ if route.source == "byok":
339
+ route.model = new # this backend's later calls too
340
+ result.diagnostics = {**(result.diagnostics or {}), "route": stand_in.label(), "attempts": attempts,
341
+ "replaced": {old: new}}
342
+ if self._store is not None:
343
+ self._store.record(f"{self.tier}:{new}", usage=result.usage)
344
+ if cache_key:
345
+ self._store.put(cache_key, result.text, new)
346
+ return result
347
+ model_health.unavailable(self._config, route, error)
348
+ return None
349
+
350
+ def _key_order(self, route: LLMRoute) -> list[tuple[int, Optional[str]]]:
351
+ if not route.api_keys:
352
+ return [(0, None)]
353
+ # Round-robin the starting key so load spreads across keys.
354
+ start = self._rotation.get(route.label(), 0) % len(route.api_keys)
355
+ self._rotation[route.label()] = start + 1
356
+ indexed = list(enumerate(route.api_keys))
357
+ return indexed[start:] + indexed[:start]
358
+
359
+ def generate(
360
+ self,
361
+ prompt: str,
362
+ generation_config: GenerationConfig,
363
+ *,
364
+ system_prompt: Optional[str] = None,
365
+ stream: bool = False,
366
+ stream_progress_callback=None,
367
+ ) -> GenerationResult:
368
+ attempts: list[str] = []
369
+ last_error: Optional[GenerationError] = None
370
+
371
+ cache_key = None
372
+ if self._store is not None and not stream:
373
+ scope = generation_config.cache_scope
374
+ cache_key = self._store.key(self.tier, system_prompt, f"scope:{scope}" if scope else prompt,
375
+ generation_config.max_tokens, generation_config.temperature)
376
+ hit = self._store.get(cache_key)
377
+ if hit:
378
+ text, model = hit
379
+ self._store.record(f"{self.tier}:{model or 'cache'}", cached=True)
380
+ return GenerationResult(text=text, model=model, provider="cache", backend=self.backend_id,
381
+ diagnostics={"route": "cache", "cached": True})
382
+ # An explicit per-call model only makes sense for the route that owns it.
383
+ routes = self._routes
384
+ if generation_config.model:
385
+ routes = [r for r in routes if r.model == generation_config.model] or [LLMRoute(generation_config.model)]
386
+ generation_config = GenerationConfig(
387
+ model=None,
388
+ timeout=generation_config.timeout,
389
+ max_tokens=generation_config.max_tokens,
390
+ temperature=generation_config.temperature,
391
+ )
392
+
393
+ routes = [self._effective(route) for route in routes]
394
+ candidates = [(route, index, key) for route in routes for index, key in self._key_order(route)]
395
+ # If every candidate is cooling down, try them anyway rather than
396
+ # failing without a single call - a short rate limit may be over.
397
+ respect_cooldown = not all(
398
+ self._cooldowns.cooling((route.model, route.api_base, index)) for route, index, _ in candidates
399
+ )
400
+ for route, index, key in candidates:
401
+ cool_key = (route.model, route.api_base, index)
402
+ if respect_cooldown and self._cooldowns.cooling(cool_key):
403
+ attempts.append(f"{route.label()}#{index}: cooling down")
404
+ continue
405
+ try:
406
+ result = self._backend_for(route, key).generate(
407
+ prompt, generation_config, system_prompt=system_prompt, stream=stream,
408
+ stream_progress_callback=stream_progress_callback,
409
+ )
410
+ except GenerationError as exc:
411
+ last_error = exc
412
+ attempts.append(f"{route.label()}#{index}: {exc.error_code.value}")
413
+ if self._store is not None:
414
+ self._store.record(f"{self.tier}:{route.model}", ok=False, error=exc)
415
+ if not exc.fallbackable:
416
+ raise
417
+ logger.warning("LLM route %s key #%d failed (%s): %s", route.label(), index,
418
+ exc.error_code.value, clean_error(exc, 200))
419
+ if exc.error_code == GenerationErrorCode.MODEL_UNAVAILABLE and self._heal:
420
+ healed = self._replace_retired(route, key, exc, attempts, prompt, generation_config,
421
+ system_prompt, cache_key)
422
+ if healed is not None:
423
+ return healed
424
+ continue
425
+ if exc.error_code in (GenerationErrorCode.RATE_LIMITED, GenerationErrorCode.INVALID_RESPONSE,
426
+ GenerationErrorCode.AUTH_FAILED, GenerationErrorCode.TIMEOUT):
427
+ self._cooldowns.fail(cool_key)
428
+ continue
429
+ self._cooldowns.ok(cool_key)
430
+ result.diagnostics = {**(result.diagnostics or {}), "route": route.label(), "attempts": attempts}
431
+ if self._store is not None:
432
+ self._store.record(f"{self.tier}:{route.model}", usage=result.usage)
433
+ if cache_key:
434
+ self._store.put(cache_key, result.text, route.model)
435
+ if attempts:
436
+ logger.info("LLM answered via %s after: %s", route.label(), "; ".join(attempts))
437
+ return result
438
+
439
+ raise GenerationError(
440
+ last_error.error_code if last_error else GenerationErrorCode.BACKEND_NOT_CONFIGURED,
441
+ f"All LLM routes failed ({'; '.join(attempts) or 'no routes'})",
442
+ stage="failover",
443
+ retryable=bool(last_error and last_error.retryable),
444
+ fallbackable=False,
445
+ backend=self.backend_id,
446
+ details={"attempts": attempts},
447
+ )
@@ -0,0 +1,63 @@
1
+ """Read a JSON object out of a model's reply, tolerating the usual damage:
2
+ code fences, prose around the object, and a reply cut off at the token
3
+ limit (reasoning models spend part of the budget thinking). A cut-off
4
+ object is closed after its last complete value, so finished fields
5
+ survive and the half-written one is dropped."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Optional
11
+
12
+
13
+ def parse_object(text: str) -> Optional[dict]:
14
+ start = (text or "").find("{")
15
+ if start < 0:
16
+ return None
17
+ body = text[start:]
18
+ end = body.rfind("}")
19
+ if end >= 0:
20
+ try:
21
+ data = json.loads(body[: end + 1])
22
+ return data if isinstance(data, dict) else None
23
+ except ValueError:
24
+ pass
25
+ return _close_truncated(body)
26
+
27
+
28
+ def _close_truncated(body: str) -> Optional[dict]:
29
+ """Cut at the last point where a value had just finished (a ',' outside
30
+ any string), then add the closing brackets that are still open."""
31
+ stack: list[str] = []
32
+ in_string = escaped = False
33
+ cut: Optional[tuple[int, list[str]]] = None
34
+ for i, ch in enumerate(body):
35
+ if in_string:
36
+ if escaped:
37
+ escaped = False
38
+ elif ch == "\\":
39
+ escaped = True
40
+ elif ch == '"':
41
+ in_string = False
42
+ continue
43
+ if ch == '"':
44
+ in_string = True
45
+ elif ch in "{[":
46
+ stack.append("}" if ch == "{" else "]")
47
+ elif ch in "}]":
48
+ if not stack:
49
+ break
50
+ stack.pop()
51
+ if not stack:
52
+ return None # a complete object that still failed to parse: not truncation
53
+ cut = (i + 1, list(stack))
54
+ elif ch == ",":
55
+ cut = (i, list(stack))
56
+ if cut is None:
57
+ return None
58
+ pos, open_ = cut
59
+ try:
60
+ data = json.loads(body[:pos] + "".join(reversed(open_)))
61
+ except ValueError:
62
+ return None
63
+ return data if isinstance(data, dict) else None