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,197 @@
1
+ """LiteLLM-backed GenerationBackend.
2
+
3
+ litellm itself is imported lazily, inside generate(), not at module
4
+ scope - this backend's class definition, backend_id and capabilities
5
+ must stay importable even when litellm isn't installed (e.g. this
6
+ sandbox), so callers that never actually invoke generate() (config
7
+ listing, backend_registry resolution, tests) don't crash on import.
8
+
9
+ litellm reaches every provider - OpenAI, Anthropic, Gemini, and local
10
+ Ollama alike - through one model-string convention (e.g. "gpt-4o-mini",
11
+ "claude-3-5-sonnet-20241022", "ollama/qwen3:8b"), which is why there is
12
+ no separate "Ollama backend" class here, matching daily_stock_analysis's
13
+ own design: Ollama is just a litellm model string plus llm_api_base
14
+ pointed at the local server.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Callable, Optional
20
+
21
+ from .protocol import (
22
+ GenerationCapabilities,
23
+ GenerationConfig,
24
+ GenerationError,
25
+ GenerationErrorCode,
26
+ GenerationResult,
27
+ )
28
+
29
+ # How providers say a model is gone (retired, renamed, or not offered to this
30
+ # key): Gemini "no longer available to new users" / "is not found for API
31
+ # version", Groq / OpenAI "model_not_found", "decommissioned", ...
32
+ _MODEL_GONE = (
33
+ "no longer available", "model_not_found", "model not found", "does not exist", "decommissioned",
34
+ "has been deprecated", "is deprecated", "is not found for api version", "not supported for generatecontent",
35
+ "unknown model", "invalid model", "no endpoints found", "is not a valid model",
36
+ )
37
+
38
+
39
+ def model_unavailable(exception_name: str, message: str) -> bool:
40
+ """True when an error says the model itself is unusable, not the key,
41
+ a quota or the network."""
42
+ name, message = exception_name.lower(), message.lower()
43
+ if any(phrase in message for phrase in _MODEL_GONE):
44
+ return True
45
+ return "notfound" in name and "model" in message
46
+
47
+
48
+ class LiteLLMGenerationBackend:
49
+ backend_id = "litellm"
50
+ capabilities = GenerationCapabilities(
51
+ supports_json=True,
52
+ supports_tools=True,
53
+ supports_stream=False, # not implemented in this phase, see protocol.py
54
+ supports_vision=False,
55
+ supports_health_check=False,
56
+ supports_smoke_test=False,
57
+ )
58
+
59
+ def __init__(self, config):
60
+ self._model = getattr(config, "llm_model", None)
61
+ self._api_base = getattr(config, "llm_api_base", None)
62
+ self._api_key = getattr(config, "llm_api_key", None)
63
+ self._default_timeout = getattr(config, "llm_timeout_seconds", 60.0)
64
+ self._default_max_tokens = getattr(config, "llm_max_tokens", 2048)
65
+ self._default_temperature = getattr(config, "llm_temperature", 0.7)
66
+
67
+ def generate(
68
+ self,
69
+ prompt: str,
70
+ generation_config: GenerationConfig,
71
+ *,
72
+ system_prompt: Optional[str] = None,
73
+ stream: bool = False,
74
+ stream_progress_callback: Optional[Callable[[str], None]] = None,
75
+ ) -> GenerationResult:
76
+ if stream:
77
+ raise GenerationError(
78
+ GenerationErrorCode.INVALID_RESPONSE,
79
+ "LiteLLMGenerationBackend does not implement streaming yet",
80
+ stage="generate",
81
+ retryable=False,
82
+ fallbackable=False,
83
+ backend=self.backend_id,
84
+ )
85
+
86
+ model = generation_config.model or self._model
87
+ if not model:
88
+ raise GenerationError(
89
+ GenerationErrorCode.BACKEND_NOT_CONFIGURED,
90
+ "No llm_model configured",
91
+ stage="setup",
92
+ retryable=False,
93
+ fallbackable=False,
94
+ backend=self.backend_id,
95
+ )
96
+
97
+ messages = []
98
+ if system_prompt:
99
+ messages.append({"role": "system", "content": system_prompt})
100
+ messages.append({"role": "user", "content": prompt})
101
+
102
+ try:
103
+ import litellm
104
+ except ImportError as exc:
105
+ raise GenerationError(
106
+ GenerationErrorCode.BACKEND_NOT_CONFIGURED,
107
+ "litellm is not installed",
108
+ stage="setup",
109
+ retryable=False,
110
+ fallbackable=False,
111
+ backend=self.backend_id,
112
+ details={"import_error": str(exc)},
113
+ ) from exc
114
+
115
+ try:
116
+ response = litellm.completion(
117
+ model=model,
118
+ messages=messages,
119
+ api_base=self._api_base,
120
+ api_key=self._api_key,
121
+ timeout=generation_config.timeout or self._default_timeout,
122
+ max_tokens=generation_config.max_tokens or self._default_max_tokens,
123
+ temperature=(
124
+ generation_config.temperature
125
+ if generation_config.temperature is not None
126
+ else self._default_temperature
127
+ ),
128
+ )
129
+ except Exception as exc: # noqa: BLE001 - normalized into GenerationError below
130
+ raise self._wrap_exception(exc, model)
131
+
132
+ return self._to_result(response, model)
133
+
134
+ def _wrap_exception(self, exc: Exception, model: str) -> GenerationError:
135
+ name = type(exc).__name__.lower()
136
+ message = str(exc).lower()
137
+
138
+ if "timeout" in name or "timeout" in message:
139
+ code, retryable = GenerationErrorCode.TIMEOUT, True
140
+ elif "rate" in name or "rate limit" in message or "429" in message:
141
+ code, retryable = GenerationErrorCode.RATE_LIMITED, True
142
+ elif model_unavailable(name, message):
143
+ code, retryable = GenerationErrorCode.MODEL_UNAVAILABLE, False
144
+ elif "auth" in name or "api key" in message or "401" in message or "403" in message:
145
+ code, retryable = GenerationErrorCode.AUTH_FAILED, False
146
+ elif "connection" in name or "connect" in message:
147
+ code, retryable = GenerationErrorCode.UNKNOWN, True
148
+ else:
149
+ code, retryable = GenerationErrorCode.UNKNOWN, False
150
+
151
+ return GenerationError(
152
+ code,
153
+ str(exc),
154
+ stage="call",
155
+ retryable=retryable,
156
+ fallbackable=True,
157
+ backend=self.backend_id,
158
+ provider=model.split("/")[0] if "/" in model else None,
159
+ details={"exception_type": type(exc).__name__},
160
+ )
161
+
162
+ def _to_result(self, response, model: str) -> GenerationResult:
163
+ try:
164
+ choice = response.choices[0]
165
+ text = choice.message.content or ""
166
+ except (AttributeError, IndexError) as exc:
167
+ raise GenerationError(
168
+ GenerationErrorCode.INVALID_RESPONSE,
169
+ "litellm response missing choices[0].message.content",
170
+ stage="parse",
171
+ retryable=False,
172
+ fallbackable=True,
173
+ backend=self.backend_id,
174
+ details={"raw_type": type(response).__name__},
175
+ ) from exc
176
+
177
+ usage = {}
178
+ raw_usage = getattr(response, "usage", None)
179
+ if raw_usage is not None:
180
+ usage = {
181
+ "prompt_tokens": getattr(raw_usage, "prompt_tokens", None),
182
+ "completion_tokens": getattr(raw_usage, "completion_tokens", None),
183
+ "total_tokens": getattr(raw_usage, "total_tokens", None),
184
+ }
185
+
186
+ provider = model.split("/")[0] if "/" in model else "unknown"
187
+ response_model = getattr(response, "model", None) or model
188
+
189
+ return GenerationResult(
190
+ text=text,
191
+ model=response_model,
192
+ provider=provider,
193
+ backend=self.backend_id,
194
+ usage=usage,
195
+ raw=response,
196
+ diagnostics={},
197
+ )
@@ -0,0 +1,166 @@
1
+ """Picks models from a provider's live model list instead of hard-coded
2
+ names that go stale when providers retire models.
3
+
4
+ - recommend(provider, ids): the model to suggest for a new key or a fresh
5
+ setup - a fast, cheap, capable one (e.g. the newest stable Gemini Flash).
6
+ - suggest_replacement(model, ids): when `model` has been retired, the closest
7
+ current model of the same family and tier (Flash -> newer Flash, never Pro,
8
+ so costs don't jump), else recommend().
9
+
10
+ Preferences below are name *patterns*, not versions, so a new release
11
+ (gemini-4-flash, gpt-6-mini) is picked up without a code change. Ids are
12
+ bare provider ids ("gemini-3-flash"); callers add the litellm prefix.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Iterable, Optional
19
+
20
+ # Per provider, best first. Within a pattern the newest stable version wins.
21
+ # Aliases the provider keeps pointing at its current model come first where
22
+ # they exist: a listed but closed model (Gemini lists 2.5 Flash, yet refuses it
23
+ # to new keys) can't be told apart from a working one by the list alone.
24
+ PREFERRED_PATTERNS: dict[str, list[str]] = {
25
+ "gemini": [r"gemini-flash-latest", r"gemini-[\d.]+-flash", r"gemini-[\d.]+-flash-preview(-[\w-]+)?",
26
+ r"gemini-flash-lite-latest", r"gemini-[\d.]+-flash-lite"],
27
+ "groq": [r"openai/gpt-oss-120b", r"llama-[\d.]+-\d+b-versatile", r"meta-llama/llama-[\d.]+-.*instruct",
28
+ r"openai/gpt-oss-20b", r"llama-[\d.]+-\d+b-instant"],
29
+ "openai": [r"gpt-[\d.]+-mini", r"gpt-4o-mini"],
30
+ "anthropic": [r"claude-haiku-[\d-]+", r"claude-[\d-]+-haiku(-\d+)?", r"claude-sonnet-[\d-]+"],
31
+ "mistral": [r"mistral-small-latest", r"mistral-medium-latest", r"mistral-large-latest"],
32
+ "deepseek": [r"deepseek-chat"],
33
+ "cerebras": [r"gpt-oss-120b", r"llama-[\d.]+-70b", r"qwen-[\d.]+-\d+b(-[\w-]+)?", r"llama[\d.]+-\d+b"],
34
+ "nvidia_nim": [r"openai/gpt-oss-120b", r"meta/llama-[\d.]+-70b-instruct", r"meta/llama-[\d.]+-\d+b-instruct"],
35
+ "openrouter": [r"openai/gpt-[\d.]+-mini", r"google/gemini-[\d.]+-flash", r"anthropic/claude-haiku-[\d.-]+"],
36
+ }
37
+
38
+ # Words that tell a model's size/price tier; a replacement keeps the same set.
39
+ TIER_WORDS = {
40
+ "lite", "mini", "nano", "flash", "pro", "ultra", "haiku", "sonnet", "opus", "small", "medium", "large",
41
+ "instant", "versatile", "chat", "reasoner", "turbo", "max", "plus",
42
+ }
43
+ UNSTABLE_WORDS = {"preview", "exp", "experimental", "beta", "alpha"}
44
+ # Not for text generation, even if a provider's list says otherwise.
45
+ NON_TEXT_WORDS = {
46
+ "tts", "image", "imagen", "embedding", "embed", "live", "audio", "vision", "native", "transcribe",
47
+ "whisper", "guard", "moderation", "veo", "robotics", "computer", "search", "realtime", "dall",
48
+ }
49
+
50
+
51
+ def bare(model: str) -> str:
52
+ """'gemini/gemini-3-flash' -> 'gemini-3-flash'; OpenRouter / Groq ids keep
53
+ their own vendor part ('groq/openai/gpt-oss-120b' -> 'openai/gpt-oss-120b')."""
54
+ return model.split("/", 1)[1] if "/" in model else model
55
+
56
+
57
+ def _words(model_id: str) -> list[str]:
58
+ return [w for w in re.split(r"[^a-z]+", model_id.lower()) if w]
59
+
60
+
61
+ def _version(model_id: str) -> tuple[int, ...]:
62
+ """Numbers in the name, sizes like '70b' and dates left out:
63
+ 'gemini-2.5-flash' -> (2, 5); 'claude-haiku-4-5-20251001' -> (4, 5)."""
64
+ name = model_id.lower().rsplit("/", 1)[-1]
65
+ # Numbers after "preview" / "exp" are release dates ("-preview-09-2025").
66
+ name = re.split(r"[-_.](?:%s)(?:[-_.]|$)" % "|".join(UNSTABLE_WORDS), name)[0]
67
+ nums = re.findall(r"(?<![a-z])(\d+(?:\.\d+)*)(?![a-z\d])", name)
68
+ out: list[int] = []
69
+ for n in nums:
70
+ if len(n) >= 4: # a year or date stamp, not a version
71
+ continue
72
+ out.extend(int(p) for p in n.split(".") if p)
73
+ return tuple(out)
74
+
75
+
76
+ def _unstable(model_id: str) -> bool:
77
+ return bool(set(_words(model_id)) & UNSTABLE_WORDS)
78
+
79
+
80
+ def is_text_model(model_id: str) -> bool:
81
+ return not (set(_words(model_id)) & NON_TEXT_WORDS)
82
+
83
+
84
+ def _rank_key(model_id: str) -> tuple:
85
+ # Stable before preview; newer version first; shorter (plain) names first.
86
+ return (_unstable(model_id), tuple(-v for v in _version(model_id)), len(model_id), model_id)
87
+
88
+
89
+ def recommend(provider: str, ids: Iterable[str], exclude: Iterable[str] = ()) -> Optional[str]:
90
+ """Best model id for `provider` among `ids` (bare ids), or None."""
91
+ skip = {bare(x) if "/" in x and x.split("/", 1)[0] == provider else x for x in exclude}
92
+ pool = [i for i in dict.fromkeys(ids) if i and i not in skip and is_text_model(i)]
93
+ for pattern in PREFERRED_PATTERNS.get(provider, []):
94
+ rx = re.compile(rf"^{pattern}$", re.I)
95
+ hits = [i for i in pool if rx.match(i)]
96
+ if hits:
97
+ return sorted(hits, key=_rank_key)[0]
98
+ return None
99
+
100
+
101
+ def suggest_replacement(model: str, ids: Iterable[str], exclude: Iterable[str] = ()) -> Optional[str]:
102
+ """Closest current model to a retired `model` (litellm id), as a litellm id.
103
+
104
+ Same family (first word) and the same tier words, never an older version;
105
+ a concrete newer stable release beats the provider's '-latest' alias,
106
+ which beats a newer preview. Nothing like it -> recommend() for the
107
+ provider. None when the list offers nothing usable."""
108
+ provider = model.split("/", 1)[0] if "/" in model else ""
109
+ old = bare(model)
110
+ skip = {old} | {bare(x) for x in exclude}
111
+ pool = [i for i in dict.fromkeys(ids) if i and i not in skip and is_text_model(i)]
112
+ words = _words(old)
113
+ family = words[0] if words else ""
114
+ tiers = set(words) & TIER_WORDS
115
+ old_version = _version(old)
116
+
117
+ def same_kind(i: str) -> bool:
118
+ w = _words(i)
119
+ return bool(w) and w[0] == family and (set(w) & TIER_WORDS) == tiers
120
+
121
+ alike = [i for i in pool if same_kind(i)]
122
+ newer = [i for i in alike if _version(i) and _version(i) > old_version]
123
+ stable = sorted([i for i in newer if not _unstable(i)], key=_rank_key)
124
+ alias = sorted([i for i in alike if "latest" in _words(i)], key=len)
125
+ preview = sorted([i for i in newer if _unstable(i)], key=_rank_key)
126
+ pick = (stable or alias or preview or [None])[0]
127
+ if pick is None:
128
+ pick = recommend(provider, pool) if provider else None
129
+ if pick is None:
130
+ return None
131
+ return f"{provider}/{pick}" if provider else pick
132
+
133
+
134
+ def tier_of(model_id: str) -> tuple:
135
+ """Family and tier: ('gemini', {'flash', 'lite'}); sizes ('70b') count too."""
136
+ w = _words(model_id.rsplit("/", 1)[-1])
137
+ return (w[0] if w else "", frozenset(set(w) & TIER_WORDS), _size(model_id))
138
+
139
+
140
+ def _size(model_id: str) -> tuple[str, ...]:
141
+ return tuple(re.findall(r"(?<![a-z\d])(\d+b)(?![a-z])", model_id.lower()))
142
+
143
+
144
+ def superseded(ids: Iterable[str]) -> set[str]:
145
+ """Ids a newer stable release of the same family, tier and size replaces
146
+ (gemini-2.0-flash when gemini-2.5-flash is listed; a preview once its
147
+ version is out as stable). Aliases and unversioned ids are never hidden."""
148
+ pool = [i for i in dict.fromkeys(ids) if i]
149
+ kinds: dict[str, tuple] = {}
150
+ for i in pool:
151
+ w = _words(i)
152
+ kinds[i] = (w[0] if w else "", frozenset(set(w) & TIER_WORDS), _size(i))
153
+ out = set()
154
+ for i in pool:
155
+ v = _version(i)
156
+ if not v or "latest" in _words(i):
157
+ continue
158
+ for j in pool:
159
+ if j == i or kinds[j] != kinds[i] or _unstable(j) or not _version(j):
160
+ continue
161
+ vj = _version(j)
162
+ if vj > v or (vj == v and _unstable(i)):
163
+ out.add(i)
164
+ break
165
+ return out
166
+
@@ -0,0 +1,193 @@
1
+ """Notices when an AI provider retires a configured model, and keeps AI
2
+ working until someone fixes the setting.
3
+
4
+ Runtime healing (FailoverGenerationBackend): a call failing with "model
5
+ not found / no longer available" makes the backend ask the provider which
6
+ models the key can use, pick the closest current one
7
+ (model_catalog.suggest_replacement: Flash -> newer Flash, never Pro) and
8
+ retry with it. A working replacement is remembered, so later calls go
9
+ straight to it.
10
+ - Platform models (Settings -> AI): the saved setting is NOT changed;
11
+ the owner is told once (log + system-error channels) to set it.
12
+ - A user's own key (BYOK): the key's saved model is updated directly.
13
+
14
+ State is kept in <app_dir>/ai_models.json.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import os
22
+ import threading
23
+ import time
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+ from typing import Optional
27
+
28
+ from marketmyna.core.home import app_home
29
+
30
+ from .model_catalog import bare, suggest_replacement
31
+ from .store import clean_error, error_kind
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ STATE_NAME = "ai_models.json"
36
+ LIST_TTL_SECONDS = 3600.0
37
+ # Replacements tried for one retired model before giving up.
38
+ MAX_TRIES = 3
39
+
40
+ _lock = threading.Lock()
41
+ _cache: dict[str, tuple[float, dict]] = {}
42
+ _lists: dict[tuple[str, str, Optional[str]], tuple[float, list[str]]] = {}
43
+
44
+
45
+ # ---------------------------------------------------------------- state
46
+ def _state_file(config) -> Path:
47
+ return Path(getattr(config, "app_dir", None) or app_home()) / STATE_NAME
48
+
49
+
50
+ def load(config) -> dict:
51
+ path = _state_file(config)
52
+ try:
53
+ mtime = path.stat().st_mtime
54
+ except OSError:
55
+ return {"checked_at": None, "models": {}}
56
+ hit = _cache.get(str(path))
57
+ if hit and hit[0] == mtime:
58
+ return json.loads(json.dumps(hit[1]))
59
+ try:
60
+ state = json.loads(path.read_text(encoding="utf-8"))
61
+ except (OSError, ValueError):
62
+ state = {}
63
+ state = {"checked_at": state.get("checked_at"), "models": state.get("models") or {}}
64
+ _cache[str(path)] = (mtime, state)
65
+ return json.loads(json.dumps(state))
66
+
67
+
68
+ def _save(config, state: dict) -> None:
69
+ path = _state_file(config)
70
+ path.parent.mkdir(parents=True, exist_ok=True)
71
+ tmp = path.with_suffix(".tmp")
72
+ tmp.write_text(json.dumps(state, indent=1, sort_keys=True), encoding="utf-8")
73
+ os.replace(tmp, path)
74
+ _cache.pop(str(path), None)
75
+
76
+
77
+ def _now() -> str:
78
+ return datetime.now().isoformat(timespec="seconds")
79
+
80
+
81
+ def substitute(config, model: str) -> Optional[str]:
82
+ """The working replacement for a retired platform model, if one was found."""
83
+ entry = load(config)["models"].get(model) or {}
84
+ if entry.get("status") == "unavailable" and entry.get("replacement") and entry.get("replacement_ok"):
85
+ return entry["replacement"]
86
+ return None
87
+
88
+
89
+ # ---------------------------------------------------------------- provider lists
90
+ def available_ids(provider: Optional[str], key: Optional[str], api_base: Optional[str] = None,
91
+ fresh: bool = False) -> Optional[list[str]]:
92
+ """Bare model ids the key can use, or None when the list can't be had."""
93
+ from .providers import PROVIDER_IDS, list_models
94
+
95
+ if not provider or provider not in PROVIDER_IDS:
96
+ return None
97
+ if not key and provider not in ("openrouter", "ollama"):
98
+ return None
99
+ cache_key = (provider, key or "", api_base)
100
+ hit = _lists.get(cache_key)
101
+ if hit and not fresh and time.monotonic() - hit[0] < LIST_TTL_SECONDS:
102
+ return hit[1]
103
+ try:
104
+ ids = [bare(m["id"]) for m in list_models(provider, key, api_base)]
105
+ except Exception as exc:
106
+ logger.info("Could not list %s models: %s", provider, clean_error(exc, 200))
107
+ return None
108
+ _lists[cache_key] = (time.monotonic(), ids)
109
+ return ids
110
+
111
+
112
+ def find_replacement(model: str, key: Optional[str], api_base: Optional[str], tried: set[str]) -> Optional[str]:
113
+ from .channels import provider_of
114
+
115
+ ids = available_ids(provider_of(model), key, api_base)
116
+ return suggest_replacement(model, ids, exclude=tried) if ids else None
117
+
118
+
119
+ # ---------------------------------------------------------------- runtime healing
120
+ def replaced(config, route, new_model: str, error: Exception) -> None:
121
+ """A retired route answered with `new_model`: remember it (platform) or
122
+ save it on the user's key (BYOK)."""
123
+ if getattr(route, "source", "") == "byok":
124
+ key_id = getattr(route, "key_id", None)
125
+ if key_id is not None:
126
+ try:
127
+ from ..services.ai_access import update_byok_model
128
+
129
+ update_byok_model(config, key_id, new_model)
130
+ except Exception:
131
+ logger.exception("Could not save the new model for key %s", key_id)
132
+ logger.warning("Own-key model %s retired; switched to %s", route.model, new_model)
133
+ return
134
+ newly = _mark(config, route.model, status="unavailable", kind=error_kind(error), message=clean_error(error),
135
+ replacement=new_model, replacement_ok=True)
136
+ logger.warning("AI model %s unavailable (%s); using %s until the setting is changed",
137
+ route.model, clean_error(error, 120), new_model)
138
+ if newly:
139
+ _announce_async(config, route.model, clean_error(error), new_model)
140
+
141
+
142
+ def unavailable(config, route, error: Exception) -> None:
143
+ """A platform route is retired and nothing could replace it."""
144
+ if getattr(route, "source", "") == "byok":
145
+ return
146
+ if _mark(config, route.model, status="unavailable", kind=error_kind(error), message=clean_error(error),
147
+ replacement=None, replacement_ok=False):
148
+ _announce_async(config, route.model, clean_error(error), None)
149
+
150
+
151
+ def _mark(config, model: str, **fields) -> bool:
152
+ """Updates one model's entry; True when it just became unavailable."""
153
+ with _lock:
154
+ state = load(config)
155
+ entry = state["models"].get(model) or {}
156
+ was = entry.get("status")
157
+ if fields.get("status") == "unavailable" and was == "unavailable" and not fields.get("replacement"):
158
+ fields.pop("replacement", None) # keep a replacement found earlier
159
+ fields.pop("replacement_ok", None)
160
+ entry.update(fields)
161
+ entry["checked_at"] = _now()
162
+ if entry.get("status") == "unavailable":
163
+ entry.setdefault("since", _now())
164
+ state["models"][model] = entry
165
+ _save(config, state)
166
+ newly = entry.get("status") == "unavailable" and not entry.get("notified")
167
+ if newly:
168
+ entry["notified"] = _now()
169
+ _save(config, state)
170
+ return newly
171
+
172
+
173
+ def _announce_async(config, model: str, message: str, replacement: Optional[str]) -> None:
174
+ threading.Thread(target=announce, args=(config, model, message, replacement),
175
+ name="ai-model-notice", daemon=True).start()
176
+
177
+
178
+ def announce(config, model: str, message: str, replacement: Optional[str]) -> None:
179
+ """Logged and sent to the system-error channels, once per model."""
180
+ if replacement:
181
+ text = (f"The AI model {model} is no longer available ({message[:160]}). MarketMyna is using "
182
+ f"{replacement} instead for now. Set LLM_MODEL to keep it, "
183
+ "or pick another model.")
184
+ else:
185
+ text = (f"The AI model {model} is no longer available ({message[:160]}) and no replacement was found. "
186
+ "AI features may fail until you add an AI key in Settings → AI keys or set another LLM_MODEL.")
187
+ logger.warning("%s", text)
188
+ try:
189
+ from ..notifiers.dispatcher import NotificationDispatcher
190
+
191
+ NotificationDispatcher(config).send_all(text, title="⚠️ MarketMyna AI model retired", purpose="system_error")
192
+ except Exception:
193
+ logger.exception("Could not send the retired-model notice")
@@ -0,0 +1,111 @@
1
+ """GenerationBackend protocol: the swappable interface every LLM backend
2
+ implements, and the result/error shapes they exchange with callers.
3
+
4
+ Simplified from daily_stock_analysis's own marketmyna/llm/generation_backend.py:
5
+ `response_validator` (schema-validation-with-retry) and `audit_context`
6
+ (HMAC-hashed prompt audit telemetry) are dropped here - both are real
7
+ features but belong to a later hardening pass, not a first LLM backend
8
+ for MarketMyna. `stream`/`stream_progress_callback` are kept in the
9
+ signature (Phase 3b's agent chat endpoint will want streaming) but no
10
+ backend in this phase actually implements streaming yet - passing
11
+ stream=True raises before any call is made, rather than silently
12
+ falling back to non-streaming and surprising a caller expecting chunks.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from enum import Enum
19
+ from typing import Any, Callable, Optional, Protocol
20
+
21
+
22
+ class GenerationErrorCode(Enum):
23
+ BACKEND_NOT_CONFIGURED = "backend_not_configured"
24
+ TIMEOUT = "timeout"
25
+ RATE_LIMITED = "rate_limited"
26
+ INVALID_RESPONSE = "invalid_response"
27
+ AUTH_FAILED = "auth_failed" # the provider rejected the key
28
+ MODEL_UNAVAILABLE = "model_unavailable" # retired, renamed, or closed to this key
29
+ UNKNOWN = "unknown"
30
+
31
+
32
+ class GenerationError(Exception):
33
+ """Structured error every backend raises on failure, so callers (the
34
+ future agent orchestrator) can decide retryable/fallbackable behavior
35
+ without parsing exception messages."""
36
+
37
+ def __init__(
38
+ self,
39
+ error_code: GenerationErrorCode,
40
+ message: str = "",
41
+ *,
42
+ stage: str = "generate",
43
+ retryable: bool = False,
44
+ fallbackable: bool = True,
45
+ backend: Optional[str] = None,
46
+ provider: Optional[str] = None,
47
+ details: Optional[dict] = None,
48
+ ):
49
+ super().__init__(message or error_code.value)
50
+ self.error_code = error_code
51
+ self.stage = stage
52
+ self.retryable = retryable
53
+ self.fallbackable = fallbackable
54
+ self.backend = backend
55
+ self.provider = provider
56
+ self.details = details or {}
57
+
58
+
59
+ @dataclass
60
+ class GenerationCapabilities:
61
+ supports_json: bool = False
62
+ supports_tools: bool = False
63
+ supports_stream: bool = False
64
+ supports_vision: bool = False
65
+ supports_health_check: bool = False
66
+ supports_smoke_test: bool = False
67
+
68
+
69
+ @dataclass
70
+ class GenerationResult:
71
+ text: str
72
+ model: str
73
+ provider: str
74
+ backend: str
75
+ usage: dict = field(default_factory=dict)
76
+ raw: Any = None
77
+ diagnostics: dict = field(default_factory=dict)
78
+
79
+
80
+ @dataclass
81
+ class GenerationConfig:
82
+ """Per-call generation parameters. A backend applies its own Config-
83
+ derived defaults (model/timeout/max_tokens/temperature) for any field
84
+ left None here."""
85
+
86
+ model: Optional[str] = None
87
+ timeout: Optional[float] = None
88
+ max_tokens: Optional[int] = None
89
+ temperature: Optional[float] = None
90
+ # Reuse key in place of the prompt text: answers for the same scope (e.g. one
91
+ # stock's stage) are shared for the cache's lifetime even as live prices move.
92
+ cache_scope: Optional[str] = None
93
+
94
+
95
+ class GenerationBackend(Protocol):
96
+ """Every concrete backend (LiteLLM today; a local-CLI subprocess
97
+ backend later) implements this."""
98
+
99
+ backend_id: str
100
+ capabilities: GenerationCapabilities
101
+
102
+ def generate(
103
+ self,
104
+ prompt: str,
105
+ generation_config: GenerationConfig,
106
+ *,
107
+ system_prompt: Optional[str] = None,
108
+ stream: bool = False,
109
+ stream_progress_callback: Optional[Callable[[str], None]] = None,
110
+ ) -> GenerationResult:
111
+ ...