anon4ik commited on
Commit
410d3bc
·
verified ·
1 Parent(s): 2c20f40

Upload 2 files

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