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