FreshPixels commited on
Commit
03a535a
·
verified ·
1 Parent(s): fc6a459

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +352 -0
app.py CHANGED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Application entry point for Telegram AI SaaS.
3
+
4
+ Performs the following startup sequence:
5
+ 1. Load Settings from environment variables (lazy)
6
+ 2. Initialize structured logging
7
+ 3. Validate required configuration
8
+ 4. Create LLMManager
9
+ 5. Register OpenAI provider (if API key available)
10
+ 6. Start HTTP health check server (with /health and /ready)
11
+ 7. Log successful start
12
+
13
+ Shutdown signals (SIGTERM, SIGINT) are handled gracefully.
14
+ Windows-compatible signal handling is supported.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import logging
21
+ import signal
22
+ import sys
23
+ from contextlib import AsyncExitStack
24
+ from typing import NoReturn
25
+
26
+ from aiohttp import web
27
+
28
+ from core.config.settings import get_settings
29
+ from core.logging.logger import get_logger, setup_logging
30
+ from core.security.validators import validate_admin_id, validate_bot_token
31
+ from llm.manager import (
32
+ AllProvidersFailedError,
33
+ FallbackPolicy,
34
+ LLMManager,
35
+ LLMManagerError,
36
+ ProviderNotFoundError,
37
+ )
38
+ from llm.providers.base_provider import LLMProviderError
39
+ from llm.providers.openai_provider import OpenAIProvider
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Application-level exception
44
+ # ---------------------------------------------------------------------------
45
+
46
+ class ConfigurationError(Exception):
47
+ """Raised when required configuration is missing or invalid."""
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Health states
52
+ # ---------------------------------------------------------------------------
53
+
54
+ class AppHealth:
55
+ """Tracks application readiness state for health endpoints.
56
+
57
+ Separates infrastructure liveness (process alive) from business
58
+ capability readiness (providers registered, config valid).
59
+ """
60
+
61
+ def __init__(self) -> None:
62
+ self._config_valid: bool = False
63
+ self._llm_managers_ready: bool = False
64
+
65
+ def mark_config_valid(self) -> None:
66
+ self._config_valid = True
67
+
68
+ def mark_llm_ready(self) -> None:
69
+ self._llm_managers_ready = True
70
+
71
+ @property
72
+ def is_healthy(self) -> bool:
73
+ """Liveness: process is alive and basic infrastructure works."""
74
+ return True
75
+
76
+ @property
77
+ def is_ready(self) -> bool:
78
+ """Readiness: can serve LLM requests."""
79
+ return self._config_valid and self._llm_managers_ready
80
+
81
+ @property
82
+ def status_detail(self) -> dict[str, str]:
83
+ """Return a dict with readiness details."""
84
+ details: dict[str, str] = {}
85
+ if not self._config_valid:
86
+ details["config"] = "invalid"
87
+ if not self._llm_managers_ready:
88
+ details["llm_providers"] = "none_registered"
89
+ return details
90
+
91
+
92
+ # Global health state — set during startup
93
+ _app_health: AppHealth = AppHealth()
94
+ _llm_manager: LLMManager | None = None
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Configuration validation
99
+ # ---------------------------------------------------------------------------
100
+
101
+ def validate_configuration() -> None:
102
+ """Validate all required configuration fields.
103
+
104
+ Checks that required environment variables are present and
105
+ that their values pass format validation.
106
+
107
+ Raises:
108
+ ConfigurationError: If any required variable is missing or invalid.
109
+ """
110
+ log = get_logger("config_validator")
111
+ settings = get_settings()
112
+
113
+ required = settings.required_fields
114
+ missing = [name for name, value in required.items() if not value]
115
+
116
+ if missing:
117
+ log.error(
118
+ "missing_required_environment_variables",
119
+ missing=missing,
120
+ )
121
+ raise ConfigurationError(
122
+ f"Missing required environment variables: {missing}"
123
+ )
124
+
125
+ try:
126
+ validate_bot_token(settings.BOT_TOKEN)
127
+ except ValueError as exc:
128
+ log.error("configuration_validation_failed", field="BOT_TOKEN", error=str(exc))
129
+ raise ConfigurationError(str(exc)) from exc
130
+
131
+ try:
132
+ validate_admin_id(settings.ADMIN_ID)
133
+ except ValueError as exc:
134
+ log.error("configuration_validation_failed", field="ADMIN_ID", error=str(exc))
135
+ raise ConfigurationError(str(exc)) from exc
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # HTTP handlers
140
+ # ---------------------------------------------------------------------------
141
+
142
+ async def _health_handler(request: web.Request) -> web.Response:
143
+ """Liveness probe — process is alive and responsive.
144
+
145
+ Always returns 200 while the process is running.
146
+ """
147
+ return web.json_response({"status": "ok"})
148
+
149
+
150
+ async def _ready_handler(request: web.Request) -> web.Response:
151
+ """Readiness probe — can serve LLM requests.
152
+
153
+ Returns 200 if providers are registered and config is valid.
154
+ Returns 503 with details if not ready.
155
+ """
156
+ if _app_health.is_ready:
157
+ return web.json_response({"status": "ok"})
158
+
159
+ details = _app_health.status_detail
160
+ return web.json_response(
161
+ {"status": "degraded", "reasons": details},
162
+ status=503,
163
+ )
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # HTTP server
168
+ # ---------------------------------------------------------------------------
169
+
170
+ async def _start_health_server(port: int) -> web.AppRunner:
171
+ """Start a minimal HTTP server for health checks.
172
+
173
+ Registers /health (liveness) and /ready (readiness) endpoints.
174
+
175
+ Args:
176
+ port: Port number to listen on.
177
+
178
+ Returns:
179
+ The aiohttp AppRunner (caller is responsible for cleanup).
180
+ """
181
+ app = web.Application()
182
+ app.router.add_get("/health", _health_handler)
183
+ app.router.add_get("/ready", _ready_handler)
184
+ runner = web.AppRunner(app)
185
+ await runner.setup()
186
+ site = web.TCPSite(runner, "0.0.0.0", port)
187
+ await site.start()
188
+ return runner
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Async main
193
+ # ---------------------------------------------------------------------------
194
+
195
+ async def _async_main() -> None:
196
+ """Async application entry point with graceful shutdown support."""
197
+ log = get_logger("app")
198
+
199
+ # 1. Setup logging (idempotent, thread-safe)
200
+ settings = get_settings()
201
+ setup_logging(log_level=settings.LOG_LEVEL, log_format=settings.LOG_FORMAT)
202
+
203
+ log.info(
204
+ "settings_loaded",
205
+ default_provider=settings.DEFAULT_PROVIDER,
206
+ port=settings.PORT,
207
+ log_level=settings.LOG_LEVEL,
208
+ log_format=settings.LOG_FORMAT,
209
+ )
210
+
211
+ log.info(
212
+ "logging_initialized",
213
+ level=settings.LOG_LEVEL,
214
+ format=settings.LOG_FORMAT,
215
+ )
216
+
217
+ # 2. Validate configuration
218
+ validate_configuration()
219
+ log.info("configuration_validated")
220
+ _app_health.mark_config_valid()
221
+
222
+ # 3. Create LLMManager with fallback policy
223
+ fallback_policy = FallbackPolicy(
224
+ providers=["openai", "claude", "gemini", "glm", "qwen"],
225
+ max_retries_per_provider=2,
226
+ base_delay_seconds=1.0,
227
+ max_delay_seconds=30.0,
228
+ )
229
+
230
+ global _llm_manager
231
+ _llm_manager = LLMManager(
232
+ default_provider=settings.DEFAULT_PROVIDER,
233
+ fallback_policy=fallback_policy,
234
+ )
235
+
236
+ log.info(
237
+ "llm_manager_initialized",
238
+ default_provider=_llm_manager.default_provider,
239
+ registered_providers=_llm_manager.list_providers(),
240
+ )
241
+
242
+ # 4. Register OpenAI provider if API key is available
243
+ if settings.OPENAI_API_KEY:
244
+ try:
245
+ openai_provider = OpenAIProvider(
246
+ api_key=settings.OPENAI_API_KEY,
247
+ )
248
+ await _llm_manager.register_provider("openai", openai_provider)
249
+ _app_health.mark_llm_ready()
250
+ log.info("openai_provider_registered")
251
+ except Exception as exc:
252
+ log.warning(
253
+ "openai_provider_registration_failed",
254
+ error=str(exc),
255
+ )
256
+ else:
257
+ log.warning(
258
+ "no_openai_api_key",
259
+ message="OPENAI_API_KEY not set — no LLM provider available",
260
+ )
261
+
262
+ if not _llm_manager.has_providers():
263
+ log.warning(
264
+ "no_providers_registered",
265
+ message="LLMManager has no providers. "
266
+ "Register providers before making LLM requests. "
267
+ "The /ready endpoint will return 503.",
268
+ )
269
+
270
+ # 5. Start health server
271
+ runner = await _start_health_server(settings.PORT)
272
+ log.info("health_server_started", port=settings.PORT)
273
+
274
+ log.info(
275
+ "application_started",
276
+ port=settings.PORT,
277
+ default_provider=settings.DEFAULT_PROVIDER,
278
+ telegram_api_server=settings.TELEGRAM_API_SERVER,
279
+ providers_count=len(_llm_manager.list_providers()),
280
+ )
281
+
282
+ # 6. Wait for shutdown signal (cross-platform)
283
+ shutdown_event = asyncio.Event()
284
+
285
+ def _set_shutdown() -> None:
286
+ log.info("shutdown_signal_received")
287
+ shutdown_event.set()
288
+
289
+ loop = asyncio.get_running_loop()
290
+
291
+ try:
292
+ for sig in (signal.SIGTERM, signal.SIGINT):
293
+ loop.add_signal_handler(sig, _set_shutdown)
294
+ except NotImplementedError:
295
+ # Windows fallback — signal.signal works with SIGINT on Windows
296
+ signal.signal(signal.SIGINT, lambda *_: _set_shutdown())
297
+ try:
298
+ signal.signal(signal.SIGTERM, lambda *_: _set_shutdown())
299
+ except (OSError, ValueError):
300
+ # SIGTERM may not be signal-able on Windows
301
+ pass
302
+
303
+ # 7. Graceful shutdown
304
+ try:
305
+ await shutdown_event.wait()
306
+ finally:
307
+ log.info("application_shutting_down")
308
+ await runner.cleanup()
309
+ logging.shutdown()
310
+ log.info("application_stopped")
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # Synchronous entry point
315
+ # ---------------------------------------------------------------------------
316
+
317
+ def main() -> None:
318
+ """Application entry point — uses asyncio.run() for proper lifecycle."""
319
+ try:
320
+ asyncio.run(_async_main())
321
+ except ConfigurationError as exc:
322
+ try:
323
+ # Logging may not be configured yet — try structlog, fallback to print
324
+ get_logger("app").error(
325
+ "configuration_error",
326
+ error=str(exc),
327
+ )
328
+ except Exception:
329
+ print(f"FATAL: Configuration error: {exc}", file=sys.stderr)
330
+ logging.shutdown()
331
+ sys.exit(1)
332
+
333
+ except KeyboardInterrupt:
334
+ # asyncio.run() handles cleanup — just exit cleanly
335
+ pass
336
+
337
+ except Exception as exc:
338
+ try:
339
+ get_logger("app").error(
340
+ "unexpected_startup_error",
341
+ error=str(exc),
342
+ exc_info=True,
343
+ )
344
+ except Exception:
345
+ print(f"FATAL: Unexpected error: {exc}", file=sys.stderr)
346
+ logging.shutdown()
347
+ sys.exit(1)
348
+
349
+
350
+ if __name__ == "__main__":
351
+ main()
352
+