gnilets commited on
Commit
de8f3db
·
verified ·
1 Parent(s): 444376f

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +356 -0
app.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+ from datetime import datetime
3
+ from json import dumps, loads
4
+ from logging import Formatter, INFO, StreamHandler, WARNING, getLogger
5
+ from os import environ
6
+ from pathlib import Path
7
+ from random import randint
8
+ from typing import AsyncGenerator
9
+
10
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
11
+ from fastapi import Depends, FastAPI, HTTPException, Header, Request
12
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
13
+ from httpx import AsyncClient
14
+ from playwright.async_api import Page, async_playwright
15
+ from starlette.responses import Response
16
+
17
+ screenshot_path = Path(__file__).parent / 'screenshot.jpeg'
18
+ token_path = Path(__file__).parent / 'token.json'
19
+
20
+ AUTH_URL = 'https://chat.reka.ai/bff/auth/login'
21
+ GET_TOKEN_URL = 'https://chat.reka.ai/bff/auth/access_token'
22
+ EMAIL_INPUT = 'input#username'
23
+ PASSWRD_INPUT = 'input#password'
24
+ SUBMIT_BUTTON = 'button[type="submit"][name="action"][value="default"]:not([data-provider])'
25
+ REKA_CHAT_PAGE = 'https://chat.reka.ai/chat'
26
+ EMAIL = str(environ.get('EMAIL')).strip()
27
+ PASSWRD = str(environ.get('PASSWORD')).strip()
28
+ UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0'
29
+ BROWSER_TIMEOUT_SECONDS = 15
30
+
31
+ API_TOKEN = str(environ.get('API_TOKEN')).strip()
32
+ REKA_API_URL = 'https://chat.reka.ai/api/chat'
33
+
34
+ logger = getLogger('REKA_API')
35
+ logger.setLevel(INFO)
36
+ handler = StreamHandler()
37
+ handler.setLevel(INFO)
38
+ formatter = Formatter('%(asctime)s | %(levelname)s : %(message)s', datefmt='%d.%m.%Y %H:%M:%S')
39
+ handler.setFormatter(formatter)
40
+ logger.addHandler(handler)
41
+ getLogger('httpx').setLevel(WARNING)
42
+
43
+ logger.info('инициализация приложения...')
44
+
45
+
46
+ async def make_screenshot(page: Page):
47
+ await page.screenshot(type='jpeg', path=screenshot_path.resolve().as_posix(), quality=85, full_page=True)
48
+ logger.info('скриншот создан')
49
+
50
+
51
+ async def refresh_token():
52
+ max_timeout = BROWSER_TIMEOUT_SECONDS * 1000
53
+ json_data = {'accessToken': None}
54
+ async with async_playwright() as playwright:
55
+ logger.info('запуск браузера')
56
+ browser = await playwright.chromium.launch(headless=True, args=['--disable-blink-features=AutomationControlled'])
57
+ context = await browser.new_context(color_scheme='dark', ignore_https_errors=True, locale='en-US', user_agent=UA)
58
+ context.set_default_timeout(max_timeout)
59
+ context.set_default_navigation_timeout(max_timeout)
60
+ page = await context.new_page()
61
+ page.set_default_timeout(max_timeout)
62
+ page.set_default_navigation_timeout(max_timeout)
63
+ try:
64
+ logger.info('открытие страницы авторизации')
65
+ await page.goto(AUTH_URL, wait_until='networkidle')
66
+ await make_screenshot(page)
67
+ logger.info('заполнение формы авторизации')
68
+ await page.locator(EMAIL_INPUT).type(EMAIL)
69
+ await page.locator(PASSWRD_INPUT).type(PASSWRD)
70
+ await make_screenshot(page)
71
+ await page.locator(SUBMIT_BUTTON).click()
72
+ logger.info('переход на страницу чата')
73
+ await page.wait_for_url(REKA_CHAT_PAGE)
74
+ await make_screenshot(page)
75
+ logger.info('получение токена')
76
+ await page.goto(GET_TOKEN_URL, wait_until='domcontentloaded')
77
+ await make_screenshot(page)
78
+ json_text = await page.evaluate("document.querySelector('pre').textContent")
79
+ json_data = loads(json_text)
80
+ await make_screenshot(page)
81
+ finally:
82
+ await page.close()
83
+ await context.close()
84
+ await browser.close()
85
+ logger.info('работа браузера завершена')
86
+ return json_data
87
+
88
+
89
+ async def get_token():
90
+ token_data = await refresh_token()
91
+ if token_data.get('accessToken'):
92
+ token_path.write_text(dumps(token_data))
93
+ logger.info('токен reka получен')
94
+ else:
95
+ raise logger.error('токен reka не был получен')
96
+
97
+
98
+ def reka_headers() -> dict[str, str]:
99
+ token = None
100
+ if token_path.exists():
101
+ token = loads(token_path.read_text()).get('accessToken')
102
+ return {
103
+ 'authorization': f'Bearer {token}',
104
+ 'content-type': 'application/json',
105
+ 'user-agent': UA
106
+ }
107
+
108
+
109
+ def convert_openai_to_reka(messages: list[dict]) -> list[dict]:
110
+ reka_messages = []
111
+ skip_next = False
112
+ logger.debug('конвертация сообщений в формат reka')
113
+ for i, message in enumerate(messages):
114
+ if skip_next:
115
+ skip_next = False
116
+ continue
117
+ if message['role'] in ['user', 'assistant']:
118
+ content = message['content']
119
+ if isinstance(content, list):
120
+ text_content = ''
121
+ image_url = None
122
+ for part in content:
123
+ if part['type'] == 'text':
124
+ text_content += part['text'] + ' '
125
+ elif part['type'] == 'image_url':
126
+ image_url = part['image_url']['url']
127
+ reka_message = {
128
+ 'type': 'human' if message['role'] == 'user' else 'model',
129
+ 'text': text_content.strip()
130
+ }
131
+ if image_url:
132
+ reka_message['image_url'] = image_url
133
+ reka_message['media_type'] = 'image'
134
+ reka_messages.append(reka_message)
135
+ else:
136
+ reka_messages.append({
137
+ 'type': 'human' if message['role'] == 'user' else 'model',
138
+ 'text': content
139
+ })
140
+ elif message['role'] == 'system':
141
+ if i + 1 < len(messages) and messages[i + 1]['role'] == 'user':
142
+ combined_text = '[SYSTEM: ' + message['content'] + '] ' + messages[i + 1]['content']
143
+ reka_messages.append({
144
+ 'type': 'human',
145
+ 'text': combined_text
146
+ })
147
+ skip_next = True
148
+ else:
149
+ reka_messages.append({
150
+ 'type': 'human',
151
+ 'text': '[SYSTEM: ' + message['content'] + ']'
152
+ })
153
+
154
+ return reka_messages
155
+
156
+
157
+ def format_part(current_text: str, previous_text: str, finish_reason: str):
158
+ logger.debug(f'форматирование сообщения: {current_text}')
159
+ return f"data: {dumps({
160
+ 'id': 'chatcmpl-0',
161
+ 'object': 'chat.completion.chunk',
162
+ 'created': int(datetime.now().timestamp()),
163
+ 'model': 'reka-core',
164
+ 'system_fingerprint': 'fp_67802d9a6d',
165
+ 'choices': [{
166
+ 'index': 0,
167
+ 'delta': {'content': current_text[len(previous_text):]},
168
+ 'finish_reason': finish_reason
169
+ }]}, ensure_ascii=False)}\n\n"
170
+
171
+
172
+ async def fetch_reka_stream(data: dict) -> AsyncGenerator[str, None]:
173
+ logger.info('запрос к reka и стриминг ответа')
174
+ async with AsyncClient() as client:
175
+ response = await client.post(REKA_API_URL, headers=reka_headers(), json=data, timeout=None)
176
+ previous_text = ''
177
+ async for line in response.aiter_lines():
178
+ logger.debug(line)
179
+ if line.startswith('{"detail":'):
180
+ yield format_part('ОШИБКА: ' + loads(line).get('detail'), previous_text, 'error')
181
+ break
182
+ if line.startswith('data:'):
183
+ event_data = loads(line[5:])
184
+ current_text = event_data['text']
185
+ sep_index = current_text.find('<sep')
186
+ finish_reason = None
187
+ if sep_index != -1:
188
+ finish_reason = 'stop'
189
+ current_text = current_text[:sep_index].rstrip()
190
+ if current_text.endswith('\n\n<'):
191
+ current_text = current_text[:-3]
192
+ if current_text != previous_text:
193
+ yield format_part(current_text, previous_text, finish_reason)
194
+ previous_text = current_text
195
+ if finish_reason == 'stop':
196
+ break
197
+
198
+
199
+ async def periodic_get_token(scheduler: AsyncIOScheduler):
200
+ logger.info('запуск задачи периодического обновления токена')
201
+ scheduler.add_job(
202
+ get_token,
203
+ trigger='interval',
204
+ hours=24,
205
+ next_run_time=datetime.now(),
206
+ misfire_grace_time=3600
207
+ )
208
+
209
+
210
+ @asynccontextmanager
211
+ async def app_lifespan(_) -> AsyncGenerator:
212
+ logger.info('запуск приложения')
213
+ scheduler = AsyncIOScheduler()
214
+ await periodic_get_token(scheduler)
215
+ try:
216
+ logger.info('запуск переодических задач')
217
+ scheduler.start()
218
+ logger.info('старт API')
219
+ yield
220
+ finally:
221
+ scheduler.shutdown()
222
+ logger.info('приложение завершено')
223
+
224
+
225
+ app = FastAPI(lifespan=app_lifespan, title='Reka_API')
226
+
227
+ banned_endpoints = [
228
+ '/openapi.json',
229
+ '/docs',
230
+ '/docs/oauth2-redirect',
231
+ 'swagger_ui_redirect',
232
+ '/redoc',
233
+ ]
234
+
235
+
236
+ @app.middleware('http')
237
+ async def block_banned_endpoints(request: Request, call_next):
238
+ if request.url.path in banned_endpoints:
239
+ logger.warning(f'запрещенный endpoint: {request.url.path}')
240
+ return Response(status_code=403)
241
+ response = await call_next(request)
242
+ return response
243
+
244
+
245
+ def verify_token(authorization: str = Header(...)):
246
+ if authorization is None:
247
+ logger.warning('попытка доступа без заголовков авторизации')
248
+ raise HTTPException(status_code=401, detail='эм... нужен пролапс')
249
+ try:
250
+ scheme, token = authorization.split()
251
+ if scheme.lower() != 'bearer':
252
+ logger.warning('попытка доступа с неверным типом авторизации')
253
+ raise HTTPException(status_code=401, detail='пролапс не того вида...')
254
+ if token != API_TOKEN:
255
+ logger.warning('попытка доступа с неверным токеном')
256
+ raise HTTPException(status_code=401, detail='пролапс неверный...')
257
+ except ValueError:
258
+ logger.warning('попытка доступа с неверным типом авторизации')
259
+ raise HTTPException(status_code=401, detail='а где пролапс?')
260
+
261
+
262
+ @app.get('/')
263
+ async def root():
264
+ return HTMLResponse('ну пролапс, ну и что', status_code=200)
265
+
266
+
267
+ @app.post('/v1/chat/completions')
268
+ @app.post('/chat/completions')
269
+ async def chat_completions(request: Request, token: str = Depends(verify_token)):
270
+ logger.debug('запрос `completions`')
271
+ data = await request.json()
272
+ messages = data.get('messages', [])
273
+ reka_messages = convert_openai_to_reka(messages)
274
+
275
+ reka_data = {
276
+ 'conversation_history': reka_messages,
277
+ 'stream': True,
278
+ 'use_search_engine': False,
279
+ 'use_code_interpreter': False,
280
+ 'model_name': 'reka-core',
281
+ 'random_seed': randint(0, 2 ** 32 - 1)
282
+ }
283
+
284
+ return StreamingResponse(fetch_reka_stream(reka_data), media_type='text/event-stream')
285
+
286
+
287
+ @app.get('/v1/models')
288
+ @app.get('/models')
289
+ async def models():
290
+ logger.debug('запрос `models`')
291
+ return JSONResponse({
292
+ 'object': 'list',
293
+ 'data': [{'id': 'reka-core', 'object': 'model', 'created': int(datetime.now().timestamp()), 'owned_by': 'reka.ai'}]
294
+ }, status_code=200, media_type='application/json')
295
+
296
+
297
+ @app.get('/update_token')
298
+ async def update_token(token: str = Depends(verify_token)):
299
+ logger.info('запрос `update_token`')
300
+ task = get_token()
301
+ return JSONResponse({'status': 'обновление токена запущено'}, status_code=200, media_type='application/json')
302
+
303
+
304
+ @app.get('/show_last_screen')
305
+ async def show_last_screen(token: str = Depends(verify_token)):
306
+ logger.info('запрос `show_last_screen`')
307
+ return FileResponse(screenshot_path.resolve().as_posix(), media_type='image/jpeg', status_code=200)
308
+
309
+
310
+ @app.get('/info', response_class=HTMLResponse)
311
+ async def info():
312
+ logger.debug('запрос `info`')
313
+ return HTMLResponse(content='''<!DOCTYPE html>
314
+ <html lang="en">
315
+ <head>
316
+ <meta charset="UTF-8">
317
+ <title>Reka Reverse Proxy Endpoints</title>
318
+ <style>
319
+ body {font-family: monospace; background-color: #202020; color:#bfbcb9;}
320
+ .locked::before {content: '🔐'; margin-right: 5px;}
321
+ .unlocked::before {content: '🔓'; margin-right: 5px;}
322
+ </style>
323
+ <script>
324
+ document.addEventListener('DOMContentLoaded', () => {
325
+ const url = `${window.location.protocol}//${window.location.host}`;
326
+ const endpoints = [
327
+ { type: 'locked', path: '/v1/chat/completions' },
328
+ { type: 'locked', path: '/chat/completions' },
329
+ { type: 'unlocked', path: '/v1/models' },
330
+ { type: 'unlocked', path: '/models' },
331
+ { type: 'locked', path: '/update_token' },
332
+ { type: 'locked', path: '/show_last_screen' }
333
+ ];
334
+
335
+ const listContainer = document.getElementById('endpoints-list');
336
+ endpoints.forEach(({ type, path }) => {
337
+ const listItem = document.createElement('li');
338
+ listItem.className = type;
339
+ listItem.textContent = `${url}${path}`;
340
+ listContainer.appendChild(listItem);
341
+ });
342
+ });
343
+ </script>
344
+ </head>
345
+ <body>
346
+ <h2>эндпоинты:</h2>
347
+ <ul id="endpoints-list"></ul>
348
+ </body>
349
+ </html>''', status_code=200)
350
+
351
+
352
+ if __name__ == '__main__':
353
+ from uvicorn import run as uvicorn_run
354
+
355
+ logger.info('запуск сервера uvicorn')
356
+ uvicorn_run(app, host='0.0.0.0', port=7860)