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,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
|
+
...
|