MB-IDK commited on
Commit
ccf84ba
Β·
verified Β·
1 Parent(s): 6e8fbba

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1329 -0
app.py ADDED
@@ -0,0 +1,1329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ ╔══════════════════════════════════════════════════════════════════════════════╗
4
+ β•‘ ChatGPTGratuit API β€” Hugging Face Spaces Edition β•‘
5
+ β•‘ Based on Ultimate Edition v5.0 β•‘
6
+ β•‘ Deployed as a Docker Space on Hugging Face (Free Tier) β•‘
7
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import os
14
+ import sys
15
+ import json
16
+ import uuid
17
+ import time
18
+ import random
19
+ import string
20
+ import logging
21
+ import threading
22
+ from abc import ABC, abstractmethod
23
+ from collections import deque
24
+ from contextlib import contextmanager
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime, timezone
27
+ from enum import Enum, auto
28
+ from pathlib import Path
29
+ from typing import (
30
+ Any, Deque, Dict, Generator, List, Optional, Tuple, Union
31
+ )
32
+ from urllib.parse import urlencode
33
+
34
+ import requests
35
+ from bs4 import BeautifulSoup
36
+
37
+ try:
38
+ import yaml
39
+ HAS_YAML = True
40
+ except ImportError:
41
+ HAS_YAML = False
42
+
43
+ # ══════════════════════════════════════════════════════════════
44
+ # CONSTANTS & ENUMS
45
+ # ══════════════════════════════════════════════════════════════
46
+
47
+ VERSION = "5.0.0-hf"
48
+ APP_NAME = "ChatGPTGratuit-API"
49
+ BASE_URL = "https://chatgptgratuit.org"
50
+
51
+
52
+ class SessionState(Enum):
53
+ UNINITIALIZED = auto()
54
+ READY = auto()
55
+ DEGRADED = auto()
56
+ EXPIRED = auto()
57
+ FAILED = auto()
58
+
59
+
60
+ class ErrorCode(Enum):
61
+ INIT_FAILED = "INIT_FAILED"
62
+ NONCE_EXPIRED = "NONCE_EXPIRED"
63
+ CACHE_FAILED = "CACHE_FAILED"
64
+ STREAM_FAILED = "STREAM_FAILED"
65
+ STREAM_EMPTY = "STREAM_EMPTY"
66
+ RATE_LIMITED = "RATE_LIMITED"
67
+ TIMEOUT = "TIMEOUT"
68
+ PARSE_ERROR = "PARSE_ERROR"
69
+ INVALID_INPUT = "INVALID_INPUT"
70
+ CIRCUIT_OPEN = "CIRCUIT_OPEN"
71
+ POOL_EXHAUSTED = "POOL_EXHAUSTED"
72
+ UNKNOWN = "UNKNOWN"
73
+
74
+
75
+ class CircuitState(Enum):
76
+ CLOSED = "closed"
77
+ OPEN = "open"
78
+ HALF_OPEN = "half_open"
79
+
80
+
81
+ USER_AGENTS: List[str] = [
82
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
83
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
84
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
85
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0",
86
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
87
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
88
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0",
89
+ ]
90
+
91
+ ACCEPT_LANGUAGES: List[str] = [
92
+ "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
93
+ "fr-FR,fr;q=0.9,en;q=0.8",
94
+ "en-US,en;q=0.9,fr;q=0.8",
95
+ ]
96
+
97
+ CONTROL_EVENTS: frozenset = frozenset({
98
+ "done", "message_start", "openai_response_id",
99
+ "error", "ping", "heartbeat", "keep-alive",
100
+ })
101
+
102
+
103
+ # ══════════════════════════════════════════════════════════════
104
+ # LOGGING
105
+ # ══════════════════════════════════════════════════════════════
106
+
107
+ class PrettyFormatter(logging.Formatter):
108
+ COLORS = {
109
+ "DEBUG": "\033[36m", "INFO": "\033[32m",
110
+ "WARNING": "\033[33m", "ERROR": "\033[31m",
111
+ "CRITICAL": "\033[35m",
112
+ }
113
+ RESET = "\033[0m"
114
+ ICONS = {
115
+ "DEBUG": "πŸ”", "INFO": "βœ…", "WARNING": "⚠️ ",
116
+ "ERROR": "❌", "CRITICAL": "πŸ’€",
117
+ }
118
+
119
+ def format(self, record: logging.LogRecord) -> str:
120
+ color = self.COLORS.get(record.levelname, "")
121
+ icon = self.ICONS.get(record.levelname, "")
122
+ ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
123
+ msg = record.getMessage()
124
+ extra_parts = []
125
+ for key in ("action", "duration_ms", "status_code", "cache_key"):
126
+ val = getattr(record, key, None)
127
+ if val is not None:
128
+ extra_parts.append(f"{key}={val}")
129
+ extra_str = f" [{', '.join(extra_parts)}]" if extra_parts else ""
130
+ return f"{color}{ts} {icon} {msg}{extra_str}{self.RESET}"
131
+
132
+
133
+ def setup_logging(level: str = "INFO") -> logging.Logger:
134
+ logger = logging.getLogger(APP_NAME)
135
+ logger.setLevel(getattr(logging, level.upper(), logging.INFO))
136
+ logger.handlers.clear()
137
+ handler = logging.StreamHandler(sys.stderr)
138
+ handler.setFormatter(PrettyFormatter())
139
+ logger.addHandler(handler)
140
+ return logger
141
+
142
+
143
+ log = setup_logging(os.environ.get("CGPT_LOG_LEVEL", "INFO"))
144
+
145
+
146
+ # ══════════════════════════════════════════════════════════════
147
+ # CONFIGURATION
148
+ # ══════════════════════════════════════════════════════════════
149
+
150
+ @dataclass
151
+ class Config:
152
+ base_url: str = BASE_URL
153
+ timeout_init: int = 20
154
+ timeout_cache: int = 30
155
+ timeout_stream: int = 120
156
+ max_retries: int = 3
157
+ retry_backoff_base: float = 1.5
158
+ retry_jitter: float = 0.5
159
+ rate_limit_rpm: int = 15
160
+ rate_limit_burst: int = 5
161
+ pool_size: int = 2
162
+ session_ttl: int = 1800
163
+ max_history_messages: int = 50
164
+ max_message_length: int = 10000
165
+ cb_failure_threshold: int = 5
166
+ cb_recovery_timeout: int = 60
167
+ cb_half_open_max: int = 2
168
+ host: str = "0.0.0.0"
169
+ port: int = 7860
170
+ debug: bool = False
171
+ log_level: str = "INFO"
172
+ json_logs: bool = False
173
+ log_sse_raw: bool = False
174
+ enable_metrics: bool = True
175
+ enable_openai_compat: bool = True
176
+ enable_cors: bool = True
177
+
178
+ @classmethod
179
+ def from_env(cls) -> "Config":
180
+ cfg = cls()
181
+ env_map = {
182
+ "CGPT_BASE_URL": ("base_url", str),
183
+ "CGPT_TIMEOUT": ("timeout_stream", int),
184
+ "CGPT_MAX_RETRIES": ("max_retries", int),
185
+ "CGPT_RATE_LIMIT": ("rate_limit_rpm", int),
186
+ "CGPT_POOL_SIZE": ("pool_size", int),
187
+ "CGPT_MAX_HISTORY": ("max_history_messages", int),
188
+ "CGPT_HOST": ("host", str),
189
+ "CGPT_PORT": ("port", int),
190
+ "CGPT_DEBUG": ("debug", lambda x: x.lower() in ("1", "true", "yes")),
191
+ "CGPT_LOG_LEVEL": ("log_level", str),
192
+ "CGPT_JSON_LOGS": ("json_logs", lambda x: x.lower() in ("1", "true", "yes")),
193
+ }
194
+ for env_key, (attr, converter) in env_map.items():
195
+ val = os.environ.get(env_key)
196
+ if val is not None:
197
+ try:
198
+ setattr(cfg, attr, converter(val))
199
+ except (ValueError, TypeError):
200
+ log.warning(f"Invalid env var {env_key}={val}, using default")
201
+ return cfg
202
+
203
+ def validate(self) -> List[str]:
204
+ warnings = []
205
+ if self.pool_size < 1:
206
+ self.pool_size = 1
207
+ warnings.append("pool_size adjusted to minimum 1")
208
+ if self.max_retries < 0:
209
+ self.max_retries = 0
210
+ warnings.append("max_retries adjusted to 0")
211
+ if self.rate_limit_rpm < 1:
212
+ self.rate_limit_rpm = 1
213
+ warnings.append("rate_limit_rpm adjusted to minimum 1")
214
+ if self.timeout_stream < 10:
215
+ self.timeout_stream = 10
216
+ warnings.append("timeout_stream adjusted to minimum 10s")
217
+ return warnings
218
+
219
+
220
+ # ══════════════════════════════════════════════════════════════
221
+ # EXCEPTIONS
222
+ # ══════════════════════════════════════════════════════════════
223
+
224
+ class ChatGPTGratuitError(Exception):
225
+ def __init__(self, message: str, code: ErrorCode = ErrorCode.UNKNOWN,
226
+ details: Optional[Dict] = None):
227
+ super().__init__(message)
228
+ self.code = code
229
+ self.details = details or {}
230
+
231
+ def to_dict(self) -> Dict[str, Any]:
232
+ return {"error": str(self), "code": self.code.value, "details": self.details}
233
+
234
+
235
+ class InitError(ChatGPTGratuitError):
236
+ def __init__(self, msg="Session initialization failed", **kw):
237
+ super().__init__(msg, ErrorCode.INIT_FAILED, **kw)
238
+
239
+
240
+ class NonceExpiredError(ChatGPTGratuitError):
241
+ def __init__(self, msg="Nonce expired", **kw):
242
+ super().__init__(msg, ErrorCode.NONCE_EXPIRED, **kw)
243
+
244
+
245
+ class CacheError(ChatGPTGratuitError):
246
+ def __init__(self, msg="Cache operation failed", **kw):
247
+ super().__init__(msg, ErrorCode.CACHE_FAILED, **kw)
248
+
249
+
250
+ class StreamError(ChatGPTGratuitError):
251
+ def __init__(self, msg="Stream error", **kw):
252
+ super().__init__(msg, ErrorCode.STREAM_FAILED, **kw)
253
+
254
+
255
+ class EmptyResponseError(ChatGPTGratuitError):
256
+ def __init__(self, msg="Empty response from stream", **kw):
257
+ super().__init__(msg, ErrorCode.STREAM_EMPTY, **kw)
258
+
259
+
260
+ class RateLimitError(ChatGPTGratuitError):
261
+ def __init__(self, msg="Rate limit exceeded", **kw):
262
+ super().__init__(msg, ErrorCode.RATE_LIMITED, **kw)
263
+
264
+
265
+ class CircuitOpenError(ChatGPTGratuitError):
266
+ def __init__(self, msg="Circuit breaker is open", **kw):
267
+ super().__init__(msg, ErrorCode.CIRCUIT_OPEN, **kw)
268
+
269
+
270
+ class InputValidationError(ChatGPTGratuitError):
271
+ def __init__(self, msg="Invalid input", **kw):
272
+ super().__init__(msg, ErrorCode.INVALID_INPUT, **kw)
273
+
274
+
275
+ # ══════════════════════════════════════════════════════════════
276
+ # DATA MODELS
277
+ # ══════════════════════════════════════════════════════════════
278
+
279
+ @dataclass
280
+ class Message:
281
+ role: str
282
+ content: str
283
+ timestamp: float = field(default_factory=time.time)
284
+ message_id: str = field(default_factory=lambda: str(uuid.uuid4()))
285
+ tokens_estimate: int = 0
286
+
287
+ def __post_init__(self):
288
+ self.tokens_estimate = max(1, len(self.content) // 4)
289
+
290
+
291
+ @dataclass
292
+ class Conversation:
293
+ conversation_id: str = field(default_factory=lambda: str(uuid.uuid4()))
294
+ messages: List[Message] = field(default_factory=list)
295
+ created_at: float = field(default_factory=time.time)
296
+ updated_at: float = field(default_factory=time.time)
297
+ title: Optional[str] = None
298
+ metadata: Dict[str, Any] = field(default_factory=dict)
299
+
300
+ def add_message(self, role: str, content: str, max_messages: int = 50) -> Message:
301
+ msg = Message(role=role, content=content)
302
+ self.messages.append(msg)
303
+ self.updated_at = time.time()
304
+ if self.title is None and role == "user":
305
+ self.title = content[:80] + ("..." if len(content) > 80 else "")
306
+ if len(self.messages) > max_messages:
307
+ system_msgs = [m for m in self.messages if m.role == "system"]
308
+ other_msgs = [m for m in self.messages if m.role != "system"]
309
+ keep = max_messages - len(system_msgs)
310
+ self.messages = system_msgs + other_msgs[-keep:]
311
+ return msg
312
+
313
+ @property
314
+ def total_tokens(self) -> int:
315
+ return sum(m.tokens_estimate for m in self.messages)
316
+
317
+ def to_dict(self) -> Dict:
318
+ return {
319
+ "conversation_id": self.conversation_id,
320
+ "title": self.title,
321
+ "message_count": len(self.messages),
322
+ "total_tokens": self.total_tokens,
323
+ "created_at": self.created_at,
324
+ "updated_at": self.updated_at,
325
+ }
326
+
327
+
328
+ @dataclass
329
+ class SessionInfo:
330
+ nonce: Optional[str] = None
331
+ bot_id: Optional[str] = None
332
+ post_id: Optional[str] = None
333
+ created_at: float = field(default_factory=time.time)
334
+ last_used: float = field(default_factory=time.time)
335
+ request_count: int = 0
336
+ error_count: int = 0
337
+
338
+ @property
339
+ def is_valid(self) -> bool:
340
+ return bool(self.nonce and self.bot_id)
341
+
342
+ @property
343
+ def age_seconds(self) -> float:
344
+ return time.time() - self.created_at
345
+
346
+ def mark_used(self):
347
+ self.last_used = time.time()
348
+ self.request_count += 1
349
+
350
+ def mark_error(self):
351
+ self.error_count += 1
352
+
353
+
354
+ # ══════════════════════════════════════════════════════════════
355
+ # METRICS
356
+ # ══════════════════════════════════════════════════════════════
357
+
358
+ @dataclass
359
+ class Metrics:
360
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
361
+ total_requests: int = 0
362
+ successful_requests: int = 0
363
+ failed_requests: int = 0
364
+ total_retries: int = 0
365
+ nonce_refreshes: int = 0
366
+ total_chars_received: int = 0
367
+ total_response_time_ms: float = 0.0
368
+ active_streams: int = 0
369
+ circuit_breaker_trips: int = 0
370
+ _latencies: Deque[float] = field(default_factory=lambda: deque(maxlen=1000), repr=False)
371
+ started_at: float = field(default_factory=time.time)
372
+
373
+ def record_request(self, success: bool, duration_ms: float, chars: int = 0):
374
+ with self._lock:
375
+ self.total_requests += 1
376
+ if success:
377
+ self.successful_requests += 1
378
+ self.total_chars_received += chars
379
+ else:
380
+ self.failed_requests += 1
381
+ self.total_response_time_ms += duration_ms
382
+ self._latencies.append(duration_ms)
383
+
384
+ def record_retry(self):
385
+ with self._lock:
386
+ self.total_retries += 1
387
+
388
+ def record_nonce_refresh(self):
389
+ with self._lock:
390
+ self.nonce_refreshes += 1
391
+
392
+ def record_circuit_trip(self):
393
+ with self._lock:
394
+ self.circuit_breaker_trips += 1
395
+
396
+ @property
397
+ def avg_latency_ms(self) -> float:
398
+ with self._lock:
399
+ return sum(self._latencies) / len(self._latencies) if self._latencies else 0.0
400
+
401
+ @property
402
+ def p95_latency_ms(self) -> float:
403
+ with self._lock:
404
+ if not self._latencies:
405
+ return 0.0
406
+ s = sorted(self._latencies)
407
+ return s[min(int(len(s) * 0.95), len(s) - 1)]
408
+
409
+ @property
410
+ def success_rate(self) -> float:
411
+ with self._lock:
412
+ return self.successful_requests / self.total_requests if self.total_requests else 1.0
413
+
414
+ def to_dict(self) -> Dict[str, Any]:
415
+ with self._lock:
416
+ return {
417
+ "total_requests": self.total_requests,
418
+ "successful_requests": self.successful_requests,
419
+ "failed_requests": self.failed_requests,
420
+ "success_rate": round(self.success_rate, 4),
421
+ "total_retries": self.total_retries,
422
+ "nonce_refreshes": self.nonce_refreshes,
423
+ "total_chars_received": self.total_chars_received,
424
+ "avg_latency_ms": round(self.avg_latency_ms, 1),
425
+ "p95_latency_ms": round(self.p95_latency_ms, 1),
426
+ "active_streams": self.active_streams,
427
+ "circuit_breaker_trips": self.circuit_breaker_trips,
428
+ "uptime_seconds": round(time.time() - self.started_at, 1),
429
+ }
430
+
431
+
432
+ metrics = Metrics()
433
+
434
+
435
+ # ══════════════════════════════════════════════════════════════
436
+ # RATE LIMITER
437
+ # ══════════════════════════════════════════════════════════════
438
+
439
+ class RateLimiter:
440
+ def __init__(self, rpm: int = 10, burst: int = 3):
441
+ self.rate = rpm / 60.0
442
+ self.max_tokens = float(burst)
443
+ self.tokens = float(burst)
444
+ self.last_refill = time.monotonic()
445
+ self._lock = threading.Lock()
446
+
447
+ def _refill(self):
448
+ now = time.monotonic()
449
+ self.tokens = min(self.max_tokens, self.tokens + (now - self.last_refill) * self.rate)
450
+ self.last_refill = now
451
+
452
+ def acquire(self, timeout: float = 30.0) -> bool:
453
+ deadline = time.monotonic() + timeout
454
+ while True:
455
+ with self._lock:
456
+ self._refill()
457
+ if self.tokens >= 1.0:
458
+ self.tokens -= 1.0
459
+ return True
460
+ if time.monotonic() >= deadline:
461
+ return False
462
+ time.sleep(min(1.0 / max(self.rate, 0.01), 0.5))
463
+
464
+ @property
465
+ def available_tokens(self) -> float:
466
+ with self._lock:
467
+ self._refill()
468
+ return self.tokens
469
+
470
+
471
+ # ══════════════════════════════════════════════════════════════
472
+ # CIRCUIT BREAKER
473
+ # ══════════════════════════════════════════════════════════════
474
+
475
+ class CircuitBreaker:
476
+ def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 60,
477
+ half_open_max: int = 2):
478
+ self.failure_threshold = failure_threshold
479
+ self.recovery_timeout = recovery_timeout
480
+ self.half_open_max = half_open_max
481
+ self.state = CircuitState.CLOSED
482
+ self.failure_count = 0
483
+ self.success_count = 0
484
+ self.last_failure_time = 0.0
485
+ self.half_open_attempts = 0
486
+ self._lock = threading.Lock()
487
+
488
+ def can_execute(self) -> bool:
489
+ with self._lock:
490
+ if self.state == CircuitState.CLOSED:
491
+ return True
492
+ if self.state == CircuitState.OPEN:
493
+ if time.time() - self.last_failure_time >= self.recovery_timeout:
494
+ self.state = CircuitState.HALF_OPEN
495
+ self.half_open_attempts = 0
496
+ log.info("Circuit breaker β†’ HALF_OPEN")
497
+ return True
498
+ return False
499
+ return self.half_open_attempts < self.half_open_max
500
+
501
+ def record_success(self):
502
+ with self._lock:
503
+ if self.state == CircuitState.HALF_OPEN:
504
+ self.success_count += 1
505
+ if self.success_count >= self.half_open_max:
506
+ self.state = CircuitState.CLOSED
507
+ self.failure_count = 0
508
+ self.success_count = 0
509
+ log.info("Circuit breaker β†’ CLOSED (recovered)")
510
+ else:
511
+ self.failure_count = max(0, self.failure_count - 1)
512
+
513
+ def record_failure(self):
514
+ with self._lock:
515
+ self.failure_count += 1
516
+ self.last_failure_time = time.time()
517
+ if self.state == CircuitState.HALF_OPEN:
518
+ self.state = CircuitState.OPEN
519
+ metrics.record_circuit_trip()
520
+ log.warning("Circuit breaker β†’ OPEN (recovery failed)")
521
+ elif self.failure_count >= self.failure_threshold:
522
+ self.state = CircuitState.OPEN
523
+ metrics.record_circuit_trip()
524
+ log.warning(f"Circuit breaker β†’ OPEN (failures={self.failure_count})")
525
+
526
+
527
+ # ══════════════════════════════════════════════════════════════
528
+ # PLUGIN SYSTEM
529
+ # ══════════════════════════════════════════════════════════════
530
+
531
+ class ResponsePlugin(ABC):
532
+ @property
533
+ @abstractmethod
534
+ def name(self) -> str: ...
535
+
536
+ @abstractmethod
537
+ def process(self, response: str, conversation: Conversation) -> str: ...
538
+
539
+
540
+ class StripWhitespacePlugin(ResponsePlugin):
541
+ @property
542
+ def name(self) -> str:
543
+ return "strip_whitespace"
544
+
545
+ def process(self, response: str, conversation: Conversation) -> str:
546
+ return re.sub(r'\n{3,}', '\n\n', response).strip()
547
+
548
+
549
+ class PluginManager:
550
+ def __init__(self):
551
+ self._plugins: List[ResponsePlugin] = []
552
+
553
+ def register(self, plugin: ResponsePlugin):
554
+ self._plugins.append(plugin)
555
+
556
+ def apply_all(self, response: str, conversation: Conversation) -> str:
557
+ for plugin in self._plugins:
558
+ try:
559
+ response = plugin.process(response, conversation)
560
+ except Exception as e:
561
+ log.warning(f"Plugin {plugin.name} failed: {e}")
562
+ return response
563
+
564
+
565
+ # ══════════════════════════════════════════════════════════════
566
+ # WEBSITE PARSER
567
+ # ══════════════════════════════════════════════════════════════
568
+
569
+ class WebsiteParser:
570
+ @staticmethod
571
+ def parse(html: str) -> SessionInfo:
572
+ info = SessionInfo()
573
+ soup = BeautifulSoup(html, "html.parser")
574
+
575
+ container = soup.find(class_="aipkit_chat_container")
576
+ if container:
577
+ config_str = container.get("data-config", "")
578
+ if config_str:
579
+ try:
580
+ config = json.loads(config_str)
581
+ info.nonce = config.get("nonce", "")
582
+ info.bot_id = str(config.get("botId", ""))
583
+ info.post_id = str(config.get("postId", ""))
584
+ if info.is_valid:
585
+ return info
586
+ except json.JSONDecodeError:
587
+ pass
588
+
589
+ if not info.bot_id:
590
+ textarea = soup.find("textarea", id=re.compile(r"aipkit_chat_input_field_\d+"))
591
+ if textarea:
592
+ match = re.search(r"_(\d+)$", textarea.get("id", ""))
593
+ if match:
594
+ info.bot_id = match.group(1)
595
+
596
+ if not info.nonce:
597
+ for pattern in [
598
+ r'"nonce"\s*:\s*"([a-f0-9]{8,})"',
599
+ r"'nonce'\s*:\s*'([a-f0-9]{8,})'",
600
+ r'nonce["\s:=]+["\']([a-f0-9]{8,})',
601
+ ]:
602
+ match = re.search(pattern, html)
603
+ if match:
604
+ info.nonce = match.group(1)
605
+ break
606
+
607
+ if not info.bot_id:
608
+ for pattern in [r'"botId"\s*:\s*"?(\d+)', r'"bot_id"\s*:\s*"?(\d+)']:
609
+ match = re.search(pattern, html)
610
+ if match:
611
+ info.bot_id = match.group(1)
612
+ break
613
+
614
+ if not info.post_id:
615
+ match = re.search(r'"postId"\s*:\s*"?(\d+)', html)
616
+ info.post_id = match.group(1) if match else "7"
617
+
618
+ return info
619
+
620
+
621
+ # ══════════════════════════════════════════════════════════════
622
+ # SSE PARSER
623
+ # ══════════════════════════════════════════════════════════════
624
+
625
+ class SSEParser:
626
+ @staticmethod
627
+ def iter_events(response: requests.Response,
628
+ log_raw: bool = False) -> Generator[Tuple[str, str], None, None]:
629
+ current_event = "message"
630
+ data_buffer: List[str] = []
631
+
632
+ for line in response.iter_lines(decode_unicode=True):
633
+ if log_raw:
634
+ log.debug(f"SSE RAW: {repr(line)}")
635
+ if line is None:
636
+ continue
637
+ if not line:
638
+ if data_buffer:
639
+ yield current_event, "\n".join(data_buffer)
640
+ data_buffer.clear()
641
+ current_event = "message"
642
+ continue
643
+ if line.startswith("event:"):
644
+ current_event = line[6:].strip()
645
+ elif line.startswith("data:"):
646
+ data_str = line[5:]
647
+ if data_str.startswith(" "):
648
+ data_str = data_str[1:]
649
+ data_buffer.append(data_str)
650
+ elif line.startswith("id:") or line.startswith(":"):
651
+ continue
652
+
653
+ if data_buffer:
654
+ yield current_event, "\n".join(data_buffer)
655
+
656
+ @staticmethod
657
+ def extract_text(data_str: str) -> str:
658
+ try:
659
+ data = json.loads(data_str)
660
+ if isinstance(data, dict):
661
+ if "delta" in data and isinstance(data["delta"], str):
662
+ return data["delta"]
663
+ if "choices" in data and data["choices"]:
664
+ choice = data["choices"][0]
665
+ if "delta" in choice:
666
+ return choice["delta"].get("content", "")
667
+ if "message" in choice:
668
+ return choice["message"].get("content", "")
669
+ for key in ("content", "text", "message", "data", "chunk", "response"):
670
+ if key in data and isinstance(data[key], str):
671
+ return data[key]
672
+ if isinstance(data, str):
673
+ return data
674
+ return ""
675
+ except (json.JSONDecodeError, TypeError):
676
+ return data_str
677
+
678
+
679
+ # ══════════════════════════════════════════════════════════════
680
+ # CORE CLIENT
681
+ # ══════════════════════════════════════════════════════════════
682
+
683
+ class ChatGPTGratuitClient:
684
+ def __init__(self, config: Optional[Config] = None):
685
+ self.config = config or Config.from_env()
686
+ self._session = requests.Session()
687
+ self._session_info = SessionInfo()
688
+ self._state = SessionState.UNINITIALIZED
689
+ self._guest_uuid = str(uuid.uuid4())
690
+ self._lock = threading.Lock()
691
+
692
+ self.rate_limiter = RateLimiter(
693
+ rpm=self.config.rate_limit_rpm, burst=self.config.rate_limit_burst,
694
+ )
695
+ self.circuit_breaker = CircuitBreaker(
696
+ failure_threshold=self.config.cb_failure_threshold,
697
+ recovery_timeout=self.config.cb_recovery_timeout,
698
+ half_open_max=self.config.cb_half_open_max,
699
+ )
700
+ self.plugin_manager = PluginManager()
701
+ self.plugin_manager.register(StripWhitespacePlugin())
702
+
703
+ self._conversations: Dict[str, Conversation] = {}
704
+ self._active_conversation_id: Optional[str] = None
705
+ self._rotate_identity()
706
+
707
+ @property
708
+ def state(self) -> SessionState:
709
+ return self._state
710
+
711
+ @property
712
+ def is_ready(self) -> bool:
713
+ return self._state in (SessionState.READY, SessionState.DEGRADED)
714
+
715
+ @property
716
+ def active_conversation(self) -> Conversation:
717
+ if self._active_conversation_id not in self._conversations:
718
+ conv = Conversation()
719
+ self._conversations[conv.conversation_id] = conv
720
+ self._active_conversation_id = conv.conversation_id
721
+ return self._conversations[self._active_conversation_id]
722
+
723
+ @property
724
+ def session_info(self) -> SessionInfo:
725
+ return self._session_info
726
+
727
+ def _rotate_identity(self):
728
+ ua = random.choice(USER_AGENTS)
729
+ lang = random.choice(ACCEPT_LANGUAGES)
730
+ self._session.headers.update({
731
+ "User-Agent": ua,
732
+ "Accept-Language": lang,
733
+ "Referer": self.config.base_url + "/",
734
+ "Origin": self.config.base_url,
735
+ "DNT": "1",
736
+ "Sec-Fetch-Dest": "empty",
737
+ "Sec-Fetch-Mode": "cors",
738
+ "Sec-Fetch-Site": "same-origin",
739
+ })
740
+
741
+ def init_session(self, force: bool = False) -> bool:
742
+ with self._lock:
743
+ if self.is_ready and not force:
744
+ if self._session_info.age_seconds < self.config.session_ttl:
745
+ return True
746
+
747
+ log.info("Initializing session...", extra={"action": "init_session"})
748
+ self._rotate_identity()
749
+
750
+ try:
751
+ resp = self._session.get(self.config.base_url, timeout=self.config.timeout_init)
752
+ resp.raise_for_status()
753
+ except requests.RequestException as e:
754
+ self._state = SessionState.FAILED
755
+ log.error(f"Failed to load page: {e}")
756
+ return False
757
+
758
+ self._session_info = WebsiteParser.parse(resp.text)
759
+
760
+ if self._session_info.is_valid:
761
+ self._state = SessionState.READY
762
+ log.info(
763
+ f"Session ready: bot_id={self._session_info.bot_id}, "
764
+ f"nonce={self._session_info.nonce[:8]}...",
765
+ extra={"action": "init_session", "bot_id": self._session_info.bot_id},
766
+ )
767
+ else:
768
+ self._state = SessionState.DEGRADED
769
+ log.warning(
770
+ f"Partial init: nonce={'βœ“' if self._session_info.nonce else 'βœ—'}, "
771
+ f"bot_id={'βœ“' if self._session_info.bot_id else 'βœ—'}"
772
+ )
773
+
774
+ return self._session_info.is_valid
775
+
776
+ def refresh_nonce(self) -> bool:
777
+ log.info("Refreshing nonce...")
778
+ metrics.record_nonce_refresh()
779
+ return self.init_session(force=True)
780
+
781
+ def _ensure_session(self):
782
+ if not self.is_ready:
783
+ if not self.init_session():
784
+ raise InitError()
785
+ if self._session_info.age_seconds > self.config.session_ttl:
786
+ self.refresh_nonce()
787
+
788
+ def new_conversation(self, system_prompt: Optional[str] = None) -> Conversation:
789
+ conv = Conversation()
790
+ if system_prompt:
791
+ conv.add_message("system", system_prompt, self.config.max_history_messages)
792
+ self._conversations[conv.conversation_id] = conv
793
+ self._active_conversation_id = conv.conversation_id
794
+ return conv
795
+
796
+ def get_conversation(self, conversation_id: str) -> Optional[Conversation]:
797
+ return self._conversations.get(conversation_id)
798
+
799
+ def list_conversations(self) -> List[Dict]:
800
+ return [c.to_dict() for c in self._conversations.values()]
801
+
802
+ def _generate_client_msg_id(self) -> str:
803
+ ts = int(time.time() * 1000)
804
+ rand = ''.join(random.choices(string.ascii_lowercase + string.digits, k=5))
805
+ return f"aipkit-client-msg-{self._session_info.bot_id}-{ts}-{rand}"
806
+
807
+ def _calculate_backoff(self, attempt: int) -> float:
808
+ return self.config.retry_backoff_base ** attempt + random.uniform(0, self.config.retry_jitter)
809
+
810
+ def send_message(
811
+ self, message: str, *, stream: bool = False,
812
+ conversation_id: Optional[str] = None,
813
+ system_prompt: Optional[str] = None,
814
+ ) -> Union[str, Generator[str, None, None]]:
815
+
816
+ message = message.strip()
817
+ if not message:
818
+ raise InputValidationError("Message cannot be empty")
819
+ if len(message) > self.config.max_message_length:
820
+ raise InputValidationError(f"Message too long ({len(message)} chars)")
821
+
822
+ if not self.circuit_breaker.can_execute():
823
+ raise CircuitOpenError()
824
+
825
+ if not self.rate_limiter.acquire(timeout=10.0):
826
+ raise RateLimitError()
827
+
828
+ self._ensure_session()
829
+
830
+ if conversation_id and conversation_id in self._conversations:
831
+ conv = self._conversations[conversation_id]
832
+ else:
833
+ conv = self.active_conversation
834
+
835
+ if system_prompt and not any(m.role == "system" for m in conv.messages):
836
+ conv.add_message("system", system_prompt, self.config.max_history_messages)
837
+
838
+ conv.add_message("user", message, self.config.max_history_messages)
839
+
840
+ last_error: Optional[Exception] = None
841
+ start_time = time.monotonic()
842
+
843
+ for attempt in range(self.config.max_retries + 1):
844
+ try:
845
+ if attempt > 0:
846
+ delay = self._calculate_backoff(attempt)
847
+ log.info(f"Retry {attempt}/{self.config.max_retries} after {delay:.1f}s")
848
+ metrics.record_retry()
849
+ time.sleep(delay)
850
+
851
+ if stream:
852
+ gen = self._execute_stream(message, conv)
853
+ return self._wrap_stream_generator(gen, conv, start_time)
854
+ else:
855
+ result = self._execute_blocking(message, conv)
856
+ duration = (time.monotonic() - start_time) * 1000
857
+ result = self.plugin_manager.apply_all(result, conv)
858
+ conv.add_message("assistant", result, self.config.max_history_messages)
859
+ metrics.record_request(True, duration, len(result))
860
+ self.circuit_breaker.record_success()
861
+ self._session_info.mark_used()
862
+ log.info(
863
+ f"Response received ({len(result)} chars)",
864
+ extra={"action": "send_message", "duration_ms": round(duration)},
865
+ )
866
+ return result
867
+
868
+ except NonceExpiredError:
869
+ log.warning("Nonce expired, refreshing...")
870
+ self.refresh_nonce()
871
+ last_error = NonceExpiredError()
872
+ except (StreamError, EmptyResponseError, CacheError) as e:
873
+ last_error = e
874
+ self.circuit_breaker.record_failure()
875
+ self._session_info.mark_error()
876
+ log.warning(f"Attempt {attempt + 1} failed: {e}")
877
+ if attempt < self.config.max_retries:
878
+ self.refresh_nonce()
879
+ except requests.Timeout:
880
+ last_error = ChatGPTGratuitError("Request timeout", ErrorCode.TIMEOUT)
881
+ self.circuit_breaker.record_failure()
882
+ except requests.RequestException as e:
883
+ last_error = ChatGPTGratuitError(str(e), ErrorCode.UNKNOWN)
884
+ self.circuit_breaker.record_failure()
885
+
886
+ duration = (time.monotonic() - start_time) * 1000
887
+ metrics.record_request(False, duration)
888
+ if isinstance(last_error, ChatGPTGratuitError):
889
+ raise last_error
890
+ raise ChatGPTGratuitError(f"All attempts failed: {last_error}", ErrorCode.UNKNOWN)
891
+
892
+ def _wrap_stream_generator(self, gen, conv, start_time):
893
+ full_text = ""
894
+ try:
895
+ for chunk in gen:
896
+ full_text += chunk
897
+ yield chunk
898
+ full_text = self.plugin_manager.apply_all(full_text, conv)
899
+ conv.add_message("assistant", full_text, self.config.max_history_messages)
900
+ duration = (time.monotonic() - start_time) * 1000
901
+ metrics.record_request(True, duration, len(full_text))
902
+ self.circuit_breaker.record_success()
903
+ self._session_info.mark_used()
904
+ except Exception:
905
+ duration = (time.monotonic() - start_time) * 1000
906
+ metrics.record_request(False, duration)
907
+ self.circuit_breaker.record_failure()
908
+ raise
909
+
910
+ def _cache_message(self, message: str) -> str:
911
+ info = self._session_info
912
+ payload = {
913
+ "action": "aipkit_cache_sse_message",
914
+ "message": message,
915
+ "_ajax_nonce": info.nonce,
916
+ "bot_id": info.bot_id,
917
+ "user_client_message_id": self._generate_client_msg_id(),
918
+ }
919
+ ajax_url = f"{self.config.base_url}/wp-admin/admin-ajax.php"
920
+ resp = self._session.post(
921
+ ajax_url, data=payload,
922
+ headers={"X-Requested-With": "XMLHttpRequest", "Accept": "*/*"},
923
+ timeout=self.config.timeout_cache,
924
+ )
925
+ if resp.status_code == 403 or (resp.status_code == 400 and "nonce" in resp.text.lower()):
926
+ raise NonceExpiredError()
927
+ try:
928
+ data = resp.json()
929
+ except json.JSONDecodeError:
930
+ raise CacheError(f"Invalid JSON (HTTP {resp.status_code}): {resp.text[:200]}")
931
+ if not data.get("success"):
932
+ err_data = data.get("data", {})
933
+ err_msg = err_data.get("message", str(err_data)) if isinstance(err_data, dict) else str(err_data)
934
+ if "nonce" in err_msg.lower():
935
+ raise NonceExpiredError()
936
+ raise CacheError(f"Cache rejected: {err_msg}")
937
+ return data["data"]["cache_key"]
938
+
939
+ def _open_stream(self, cache_key: str, conv: Conversation) -> requests.Response:
940
+ info = self._session_info
941
+ ajax_url = f"{self.config.base_url}/wp-admin/admin-ajax.php"
942
+ params = {
943
+ "action": "aipkit_frontend_chat_stream",
944
+ "cache_key": cache_key,
945
+ "bot_id": info.bot_id,
946
+ "session_id": self._guest_uuid,
947
+ "conversation_uuid": conv.conversation_id,
948
+ "post_id": info.post_id,
949
+ "_ts": str(int(time.time() * 1000)),
950
+ "_ajax_nonce": info.nonce,
951
+ }
952
+ resp = self._session.get(
953
+ f"{ajax_url}?{urlencode(params)}",
954
+ headers={"Accept": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive"},
955
+ timeout=self.config.timeout_stream,
956
+ stream=True,
957
+ )
958
+ if resp.status_code == 403:
959
+ raise NonceExpiredError()
960
+ if resp.status_code != 200:
961
+ raise StreamError(f"Stream HTTP {resp.status_code}")
962
+ return resp
963
+
964
+ def _execute_blocking(self, message: str, conv: Conversation) -> str:
965
+ cache_key = self._cache_message(message)
966
+ resp = self._open_stream(cache_key, conv)
967
+ full_text = ""
968
+ got_data = False
969
+ for event_type, data_str in SSEParser.iter_events(resp, log_raw=self.config.log_sse_raw):
970
+ got_data = True
971
+ if data_str.strip() == "[DONE]":
972
+ break
973
+ if event_type == "error":
974
+ raise StreamError(f"SSE error: {data_str}")
975
+ if event_type in CONTROL_EVENTS:
976
+ continue
977
+ chunk = SSEParser.extract_text(data_str)
978
+ if chunk:
979
+ full_text += chunk
980
+ if not got_data:
981
+ raise EmptyResponseError()
982
+ if not full_text.strip():
983
+ raise EmptyResponseError("Stream completed but empty")
984
+ return full_text
985
+
986
+ def _execute_stream(self, message: str, conv: Conversation) -> Generator[str, None, None]:
987
+ cache_key = self._cache_message(message)
988
+ resp = self._open_stream(cache_key, conv)
989
+ metrics.active_streams += 1
990
+ try:
991
+ for event_type, data_str in SSEParser.iter_events(resp, log_raw=self.config.log_sse_raw):
992
+ if data_str.strip() == "[DONE]":
993
+ break
994
+ if event_type == "error":
995
+ raise StreamError(f"SSE error: {data_str}")
996
+ if event_type in CONTROL_EVENTS:
997
+ continue
998
+ chunk = SSEParser.extract_text(data_str)
999
+ if chunk:
1000
+ yield chunk
1001
+ finally:
1002
+ metrics.active_streams = max(0, metrics.active_streams - 1)
1003
+
1004
+ def get_status(self) -> Dict[str, Any]:
1005
+ return {
1006
+ "version": VERSION,
1007
+ "state": self._state.name,
1008
+ "session": {
1009
+ "bot_id": self._session_info.bot_id,
1010
+ "has_nonce": bool(self._session_info.nonce),
1011
+ "post_id": self._session_info.post_id,
1012
+ "age_seconds": round(self._session_info.age_seconds, 1),
1013
+ "request_count": self._session_info.request_count,
1014
+ "error_count": self._session_info.error_count,
1015
+ },
1016
+ "circuit_breaker": self.circuit_breaker.state.value,
1017
+ "rate_limiter_tokens": round(self.rate_limiter.available_tokens, 2),
1018
+ "metrics": metrics.to_dict(),
1019
+ }
1020
+
1021
+
1022
+ # ══════════════════════════════════════════════════════════════
1023
+ # SESSION POOL
1024
+ # ══════════════════════════════════════════════════════════════
1025
+
1026
+ class SessionPool:
1027
+ def __init__(self, config: Config):
1028
+ self.config = config
1029
+ self._clients: List[ChatGPTGratuitClient] = []
1030
+ self._index = 0
1031
+ self._lock = threading.Lock()
1032
+ for i in range(config.pool_size):
1033
+ self._clients.append(ChatGPTGratuitClient(config))
1034
+
1035
+ def initialize_all(self) -> int:
1036
+ success = 0
1037
+ for i, client in enumerate(self._clients):
1038
+ try:
1039
+ if client.init_session():
1040
+ success += 1
1041
+ log.info(f"Pool client {i+1} initialized")
1042
+ except Exception as e:
1043
+ log.error(f"Pool client {i+1} init error: {e}")
1044
+ return success
1045
+
1046
+ @contextmanager
1047
+ def acquire(self) -> Generator[ChatGPTGratuitClient, None, None]:
1048
+ with self._lock:
1049
+ attempts = len(self._clients)
1050
+ for _ in range(attempts):
1051
+ client = self._clients[self._index % len(self._clients)]
1052
+ self._index += 1
1053
+ if client.is_ready or client.state == SessionState.UNINITIALIZED:
1054
+ break
1055
+ else:
1056
+ client = self._clients[0]
1057
+ yield client
1058
+
1059
+ def get_status(self) -> Dict:
1060
+ return {
1061
+ "pool_size": len(self._clients),
1062
+ "clients": [
1063
+ {"index": i, "state": c.state.name, "requests": c.session_info.request_count}
1064
+ for i, c in enumerate(self._clients)
1065
+ ],
1066
+ }
1067
+
1068
+
1069
+ # ══════════════════════════════════════════════════════════════
1070
+ # FLASK APP FACTORY
1071
+ # ══════════════════════════════════════════════════════════════
1072
+
1073
+ def create_app(config: Optional[Config] = None) -> Tuple:
1074
+ from flask import Flask, request as freq, jsonify, Response, stream_with_context
1075
+
1076
+ config = config or Config.from_env()
1077
+ app = Flask(APP_NAME)
1078
+
1079
+ pool = SessionPool(config)
1080
+ ready_count = pool.initialize_all()
1081
+ if ready_count == 0:
1082
+ log.warning("No pool clients initialized! Will retry on first request.")
1083
+
1084
+ if config.enable_cors:
1085
+ @app.after_request
1086
+ def add_cors(response):
1087
+ response.headers["Access-Control-Allow-Origin"] = "*"
1088
+ response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
1089
+ response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
1090
+ return response
1091
+
1092
+ @app.errorhandler(ChatGPTGratuitError)
1093
+ def handle_api_error(e: ChatGPTGratuitError):
1094
+ status_map = {
1095
+ ErrorCode.RATE_LIMITED: 429,
1096
+ ErrorCode.INVALID_INPUT: 400,
1097
+ ErrorCode.CIRCUIT_OPEN: 503,
1098
+ }
1099
+ return jsonify({"ok": False, **e.to_dict()}), status_map.get(e.code, 500)
1100
+
1101
+ @app.errorhandler(404)
1102
+ def not_found(e):
1103
+ return jsonify({
1104
+ "ok": False, "error": "Endpoint not found",
1105
+ "endpoints": [
1106
+ "POST /chat", "POST /chat/stream", "POST /v1/chat/completions",
1107
+ "POST /new", "POST /refresh", "GET /health", "GET /metrics",
1108
+ ],
1109
+ }), 404
1110
+
1111
+ # ── Landing page ──
1112
+ @app.route("/", methods=["GET"])
1113
+ def index():
1114
+ return jsonify({
1115
+ "name": APP_NAME,
1116
+ "version": VERSION,
1117
+ "status": "running",
1118
+ "endpoints": {
1119
+ "chat": "POST /chat",
1120
+ "stream": "POST /chat/stream",
1121
+ "openai_compat": "POST /v1/chat/completions",
1122
+ "health": "GET /health",
1123
+ "metrics": "GET /metrics",
1124
+ "new_conversation": "POST /new",
1125
+ "refresh_session": "POST /refresh",
1126
+ "conversations": "GET /conversations",
1127
+ },
1128
+ "usage_example": {
1129
+ "curl": 'curl -X POST /chat -H "Content-Type: application/json" -d \'{"message": "Hello!"}\'',
1130
+ },
1131
+ })
1132
+
1133
+ # ── POST /chat ──
1134
+ @app.route("/chat", methods=["POST"])
1135
+ def chat():
1136
+ data = freq.get_json(force=True, silent=True) or {}
1137
+ message = data.get("message", "").strip()
1138
+ if not message:
1139
+ return jsonify({"ok": False, "error": "Field 'message' is required"}), 400
1140
+
1141
+ with pool.acquire() as client:
1142
+ if data.get("new_conversation"):
1143
+ client.new_conversation(data.get("system_prompt"))
1144
+ response_text = client.send_message(message, system_prompt=data.get("system_prompt"))
1145
+ return jsonify({
1146
+ "ok": True,
1147
+ "response": response_text,
1148
+ "conversation_id": client.active_conversation.conversation_id,
1149
+ "usage": {
1150
+ "message_count": len(client.active_conversation.messages),
1151
+ "estimated_tokens": client.active_conversation.total_tokens,
1152
+ },
1153
+ })
1154
+
1155
+ # ── POST /chat/stream ──
1156
+ @app.route("/chat/stream", methods=["POST"])
1157
+ def chat_stream():
1158
+ data = freq.get_json(force=True, silent=True) or {}
1159
+ message = data.get("message", "").strip()
1160
+ if not message:
1161
+ return jsonify({"ok": False, "error": "Field 'message' is required"}), 400
1162
+
1163
+ with pool.acquire() as client:
1164
+ if data.get("new_conversation"):
1165
+ client.new_conversation(data.get("system_prompt"))
1166
+
1167
+ def generate():
1168
+ try:
1169
+ for chunk in client.send_message(message, stream=True):
1170
+ yield f"data: {json.dumps({'chunk': chunk}, ensure_ascii=False)}\n\n"
1171
+ yield "data: [DONE]\n\n"
1172
+ except ChatGPTGratuitError as e:
1173
+ yield f"data: {json.dumps(e.to_dict())}\n\n"
1174
+ except Exception as e:
1175
+ yield f"data: {json.dumps({'error': str(e)})}\n\n"
1176
+
1177
+ return Response(
1178
+ stream_with_context(generate()),
1179
+ content_type="text/event-stream",
1180
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
1181
+ )
1182
+
1183
+ # ── POST /v1/chat/completions (OpenAI-compatible) ──
1184
+ if config.enable_openai_compat:
1185
+ @app.route("/v1/chat/completions", methods=["POST"])
1186
+ def openai_compat():
1187
+ data = freq.get_json(force=True, silent=True) or {}
1188
+ messages = data.get("messages", [])
1189
+ do_stream = data.get("stream", False)
1190
+
1191
+ if not messages:
1192
+ return jsonify({"error": {"message": "messages required"}}), 400
1193
+
1194
+ user_msg = None
1195
+ system_prompt = None
1196
+ for msg in messages:
1197
+ if msg.get("role") == "user":
1198
+ user_msg = msg.get("content", "")
1199
+ if msg.get("role") == "system":
1200
+ system_prompt = msg.get("content")
1201
+
1202
+ if not user_msg:
1203
+ return jsonify({"error": {"message": "No user message found"}}), 400
1204
+
1205
+ response_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
1206
+ created = int(time.time())
1207
+
1208
+ with pool.acquire() as client:
1209
+ if do_stream:
1210
+ def generate():
1211
+ try:
1212
+ for chunk in client.send_message(
1213
+ user_msg, stream=True, system_prompt=system_prompt
1214
+ ):
1215
+ yield f"data: {json.dumps({'id': response_id, 'object': 'chat.completion.chunk', 'created': created, 'model': 'chatgpt-gratuit', 'choices': [{'index': 0, 'delta': {'content': chunk}, 'finish_reason': None}]})}\n\n"
1216
+ yield f"data: {json.dumps({'id': response_id, 'object': 'chat.completion.chunk', 'created': created, 'model': 'chatgpt-gratuit', 'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]})}\n\n"
1217
+ yield "data: [DONE]\n\n"
1218
+ except Exception as e:
1219
+ yield f"data: {json.dumps({'error': {'message': str(e)}})}\n\n"
1220
+
1221
+ return Response(
1222
+ stream_with_context(generate()),
1223
+ content_type="text/event-stream",
1224
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
1225
+ )
1226
+
1227
+ response_text = client.send_message(user_msg, system_prompt=system_prompt)
1228
+ return jsonify({
1229
+ "id": response_id,
1230
+ "object": "chat.completion",
1231
+ "created": created,
1232
+ "model": "chatgpt-gratuit",
1233
+ "choices": [{
1234
+ "index": 0,
1235
+ "message": {"role": "assistant", "content": response_text},
1236
+ "finish_reason": "stop",
1237
+ }],
1238
+ "usage": {
1239
+ "prompt_tokens": len(user_msg) // 4,
1240
+ "completion_tokens": len(response_text) // 4,
1241
+ "total_tokens": (len(user_msg) + len(response_text)) // 4,
1242
+ },
1243
+ })
1244
+
1245
+ # ── POST /new ──
1246
+ @app.route("/new", methods=["POST"])
1247
+ def new_conv():
1248
+ data = freq.get_json(force=True, silent=True) or {}
1249
+ with pool.acquire() as client:
1250
+ conv = client.new_conversation(data.get("system_prompt"))
1251
+ return jsonify({"ok": True, "conversation_id": conv.conversation_id})
1252
+
1253
+ # ── POST /refresh ──
1254
+ @app.route("/refresh", methods=["POST"])
1255
+ def refresh():
1256
+ with pool.acquire() as client:
1257
+ result = client.refresh_nonce()
1258
+ return jsonify({
1259
+ "ok": result,
1260
+ "nonce_prefix": client.session_info.nonce[:8] + "..." if client.session_info.nonce else None,
1261
+ })
1262
+
1263
+ # ── GET /health ──
1264
+ @app.route("/health", methods=["GET"])
1265
+ def health():
1266
+ with pool.acquire() as client:
1267
+ status = client.get_status()
1268
+ status["pool"] = pool.get_status()
1269
+ return jsonify(status), 200 if client.is_ready else 503
1270
+
1271
+ # ── GET /metrics ──
1272
+ if config.enable_metrics:
1273
+ @app.route("/metrics", methods=["GET"])
1274
+ def metrics_endpoint():
1275
+ return jsonify(metrics.to_dict())
1276
+
1277
+ # ── GET /conversations ──
1278
+ @app.route("/conversations", methods=["GET"])
1279
+ def conversations():
1280
+ with pool.acquire() as client:
1281
+ return jsonify({
1282
+ "ok": True,
1283
+ "conversations": client.list_conversations(),
1284
+ "active": client._active_conversation_id,
1285
+ })
1286
+
1287
+ # ── GET /conversation/<id>/messages ──
1288
+ @app.route("/conversation/<conv_id>/messages", methods=["GET"])
1289
+ def conversation_messages(conv_id: str):
1290
+ with pool.acquire() as client:
1291
+ conv = client.get_conversation(conv_id)
1292
+ if not conv:
1293
+ return jsonify({"ok": False, "error": "Conversation not found"}), 404
1294
+ return jsonify({
1295
+ "ok": True,
1296
+ "conversation": conv.to_dict(),
1297
+ "messages": [
1298
+ {"role": m.role, "content": m.content, "timestamp": m.timestamp, "message_id": m.message_id}
1299
+ for m in conv.messages
1300
+ ],
1301
+ })
1302
+
1303
+ return app, pool
1304
+
1305
+
1306
+ # ══════════════════════════════════════════════════════════════
1307
+ # GUNICORN ENTRY POINT + DIRECT RUN
1308
+ # ══════════════════════════════════════════════════════════════
1309
+
1310
+ config = Config.from_env()
1311
+ config.validate()
1312
+
1313
+ # Gunicorn looks for 'application'
1314
+ application, _pool = create_app(config)
1315
+
1316
+ if __name__ == "__main__":
1317
+ print(f"""
1318
+ ╔══════════════════════════════════════════════════════════════╗
1319
+ β•‘ πŸš€ ChatGPT Gratuit API v{VERSION:<38s}β•‘
1320
+ β•‘ Server: http://{config.host}:{config.port:<43d}β•‘
1321
+ β•‘ β•‘
1322
+ β•‘ POST /chat β†’ Complete response β•‘
1323
+ β•‘ POST /chat/stream β†’ Streaming SSE β•‘
1324
+ β•‘ POST /v1/chat/completions β†’ OpenAI-compatible β•‘
1325
+ β•‘ GET /health β†’ Health check β•‘
1326
+ β•‘ GET /metrics β†’ Metrics β•‘
1327
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
1328
+ """)
1329
+ application.run(host=config.host, port=config.port, debug=config.debug, threaded=True)