| from contextlib import asynccontextmanager |
| from datetime import datetime |
| from json import dumps, loads |
| from logging import DEBUG, Formatter, INFO, StreamHandler, WARNING, getLogger |
| from os import environ |
| from pathlib import Path |
| from random import randint |
| from typing import AsyncGenerator |
|
|
| from apscheduler.schedulers.asyncio import AsyncIOScheduler |
| from fastapi import Depends, FastAPI, HTTPException, Header, Request |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse |
| from httpx import AsyncClient |
| from patchright.async_api import Page, async_playwright |
| from starlette.responses import Response |
|
|
| screenshot_path = Path(__file__).parent / 'screenshot.jpeg' |
| token_path = Path(__file__).parent / 'token.json' |
|
|
| AUTH_URL = 'https://chat.reka.ai/bff/auth/login' |
| GET_TOKEN_URL = 'https://chat.reka.ai/bff/auth/access_token' |
| EMAIL_INPUT = 'input#username' |
| PASSWRD_INPUT = 'input#password' |
| SUBMIT_BUTTON = 'button[type="submit"][name="action"][value="default"]:not([data-provider])' |
| REKA_CHAT_PAGE = 'https://chat.reka.ai/chat' |
| EMAIL = str(environ.get('EMAIL')).strip() |
| PASSWRD = str(environ.get('PASSWORD')).strip() |
| 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' |
| BROWSER_TIMEOUT_SECONDS = 15 |
|
|
| API_TOKEN = str(environ.get('API_TOKEN')).strip() |
| REKA_API_URL = 'https://chat.reka.ai/api/chat' |
|
|
| logger = getLogger('REKA_API') |
| logger.setLevel(DEBUG) |
| handler = StreamHandler() |
| handler.setLevel(INFO) |
| formatter = Formatter('%(asctime)s | %(levelname)s : %(message)s', datefmt='%d.%m.%Y %H:%M:%S') |
| handler.setFormatter(formatter) |
| logger.addHandler(handler) |
| getLogger('httpx').setLevel(WARNING) |
|
|
| logger.info('инициализация приложения...') |
|
|
|
|
| async def make_screenshot(page: Page): |
| await page.screenshot(type='jpeg', path=screenshot_path.resolve().as_posix(), quality=85, full_page=True) |
| logger.info('скриншот создан') |
|
|
|
|
| async def refresh_token(): |
| max_timeout = BROWSER_TIMEOUT_SECONDS * 1000 |
| json_data = {'accessToken': None} |
| async with async_playwright() as playwright: |
| logger.info('запуск браузера') |
| browser = await playwright.chromium.launch(headless=True, args=['--disable-blink-features=AutomationControlled']) |
| context = await browser.new_context( |
| color_scheme='dark', |
| ignore_https_errors=True, |
| locale='en-US', |
| user_agent=UA, |
| no_viewport=True, |
| ) |
| context.set_default_timeout(max_timeout) |
| context.set_default_navigation_timeout(max_timeout) |
| page = await context.new_page() |
| page.set_default_timeout(max_timeout) |
| page.set_default_navigation_timeout(max_timeout) |
| try: |
| logger.info('открытие страницы авторизации') |
| await page.goto(AUTH_URL, wait_until='networkidle') |
| await make_screenshot(page) |
| logger.info('заполнение формы авторизации') |
| await page.locator(EMAIL_INPUT).type(EMAIL) |
| await page.locator(PASSWRD_INPUT).type(PASSWRD) |
| await make_screenshot(page) |
| await page.locator(SUBMIT_BUTTON).click() |
| logger.info('переход на страницу чата') |
| await page.wait_for_url(REKA_CHAT_PAGE) |
| await make_screenshot(page) |
| logger.info('получение токена') |
| await page.goto(GET_TOKEN_URL, wait_until='domcontentloaded') |
| await make_screenshot(page) |
| json_text = await page.evaluate("document.querySelector('pre').textContent") |
| json_data = loads(json_text) |
| await make_screenshot(page) |
| finally: |
| await page.close() |
| await context.close() |
| await browser.close() |
| logger.info('работа браузера завершена') |
| return json_data |
|
|
|
|
| async def get_token(): |
| token_data = await refresh_token() |
| if token_data.get('accessToken'): |
| token_path.write_text(dumps(token_data)) |
| logger.info('токен reka получен') |
| else: |
| raise logger.error('токен reka не был получен') |
|
|
|
|
| def reka_headers() -> dict[str, str]: |
| token = None |
| if token_path.exists(): |
| token = loads(token_path.read_text()).get('accessToken') |
| return { |
| 'authorization': f'Bearer {token}', |
| 'content-type': 'application/json', |
| 'user-agent': UA |
| } |
|
|
|
|
| def convert_openai_to_reka(messages: list[dict]) -> list[dict]: |
| reka_messages = [] |
| skip_next = False |
| logger.debug('конвертация сообщений в формат reka') |
| for i, message in enumerate(messages): |
| if skip_next: |
| skip_next = False |
| continue |
| if message['role'] in ['user', 'assistant']: |
| content = message['content'] |
| if isinstance(content, list): |
| text_content = '' |
| image_url = None |
| for part in content: |
| if part['type'] == 'text': |
| text_content += part['text'] + ' ' |
| elif part['type'] == 'image_url': |
| image_url = part['image_url']['url'] |
| reka_message = { |
| 'type': 'human' if message['role'] == 'user' else 'model', |
| 'text': text_content.strip() |
| } |
| if image_url: |
| reka_message['image_url'] = image_url |
| reka_message['media_type'] = 'image' |
| reka_messages.append(reka_message) |
| else: |
| reka_messages.append({ |
| 'type': 'human' if message['role'] == 'user' else 'model', |
| 'text': content |
| }) |
| elif message['role'] == 'system': |
| if i + 1 < len(messages) and messages[i + 1]['role'] == 'user': |
| combined_text = '[SYSTEM: ' + message['content'] + '] ' + messages[i + 1]['content'] |
| reka_messages.append({ |
| 'type': 'human', |
| 'text': combined_text |
| }) |
| skip_next = True |
| else: |
| reka_messages.append({ |
| 'type': 'human', |
| 'text': '[SYSTEM: ' + message['content'] + ']' |
| }) |
|
|
| return reka_messages |
|
|
|
|
| def format_part(current_text: str, previous_text: str, finish_reason: str): |
| logger.debug(f'форматирование сообщения: {current_text}') |
| return f"data: {dumps({ |
| 'id': 'chatcmpl-0', |
| 'object': 'chat.completion.chunk', |
| 'created': int(datetime.now().timestamp()), |
| 'model': 'reka-core', |
| 'system_fingerprint': 'fp_67802d9a6d', |
| 'choices': [{ |
| 'index': 0, |
| 'delta': {'content': current_text[len(previous_text):]}, |
| 'finish_reason': finish_reason |
| }]}, ensure_ascii=False)}\n\n".encode('utf-8') |
|
|
|
|
| async def fetch_reka_stream(data: dict) -> AsyncGenerator[str, None]: |
| logger.info('запрос к reka и стриминг ответа') |
| async with AsyncClient() as client: |
| response = await client.post(REKA_API_URL, headers=reka_headers(), json=data, timeout=None) |
| previous_text = '' |
| async for line in response.aiter_lines(): |
| logger.debug(line) |
| if line.startswith('{"detail":'): |
| yield format_part('ОШИБКА: ' + loads(line).get('detail'), previous_text, 'error') |
| break |
| if line.startswith('data:'): |
| event_data = loads(line[5:]) |
| current_text = event_data['text'] |
| sep_index = current_text.find('<sep') |
| finish_reason = None |
| if sep_index != -1: |
| finish_reason = 'stop' |
| current_text = current_text[:sep_index].rstrip() |
| if current_text.endswith('\n\n<'): |
| current_text = current_text[:-3] |
| if current_text != previous_text: |
| yield format_part(current_text, previous_text, finish_reason) |
| previous_text = current_text |
| if finish_reason == 'stop': |
| break |
|
|
|
|
| async def periodic_get_token(scheduler: AsyncIOScheduler): |
| logger.info('запуск задачи периодического обновления токена') |
| scheduler.add_job( |
| get_token, |
| trigger='interval', |
| hours=24, |
| next_run_time=datetime.now(), |
| misfire_grace_time=3600 |
| ) |
|
|
|
|
| @asynccontextmanager |
| async def app_lifespan(_) -> AsyncGenerator: |
| logger.info('запуск приложения') |
| scheduler = AsyncIOScheduler() |
| await periodic_get_token(scheduler) |
| try: |
| logger.info('запуск переодических задач') |
| scheduler.start() |
| logger.info('старт API') |
| yield |
| finally: |
| scheduler.shutdown() |
| logger.info('приложение завершено') |
|
|
|
|
| app = FastAPI(lifespan=app_lifespan, title='Reka_API') |
|
|
| banned_endpoints = [ |
| '/openapi.json', |
| '/docs', |
| '/docs/oauth2-redirect', |
| 'swagger_ui_redirect', |
| '/redoc', |
| ] |
|
|
|
|
| @app.middleware('http') |
| async def block_banned_endpoints(request: Request, call_next): |
| logger.debug(f'получен запрос: {request.url.path}') |
| if request.url.path in banned_endpoints: |
| logger.warning(f'запрещенный endpoint: {request.url.path}') |
| return Response(status_code=403) |
| response = await call_next(request) |
| return response |
|
|
|
|
| def verify_token(authorization: str = Header(...)): |
| if authorization is None: |
| logger.warning('попытка доступа без заголовков авторизации') |
| raise HTTPException(status_code=401, detail='эм... нужен пролапс') |
| try: |
| scheme, token = authorization.split() |
| if scheme.lower() != 'bearer': |
| logger.warning('попытка доступа с неверным типом авторизации') |
| raise HTTPException(status_code=401, detail='пролапс не того вида...') |
| if token != API_TOKEN: |
| logger.warning('попытка доступа с неверным токеном') |
| raise HTTPException(status_code=401, detail='пролапс неверный...') |
| except ValueError: |
| logger.warning('попытка доступа с неверным типом авторизации') |
| raise HTTPException(status_code=401, detail='а где пролапс?') |
|
|
|
|
| @app.get('/') |
| async def root(): |
| return HTMLResponse('ну пролапс, ну и что', status_code=200) |
|
|
|
|
| @app.post('/api/chat/completions') |
| @app.post('/api/v1/chat/completions') |
| async def chat_completions(request: Request, token: str = Depends(verify_token)): |
| logger.debug('запрос `completions`') |
| data = await request.json() |
| messages = data.get('messages', []) |
| reka_messages = convert_openai_to_reka(messages) |
|
|
| reka_data = { |
| 'conversation_history': reka_messages, |
| 'stream': True, |
| 'use_search_engine': False, |
| 'use_code_interpreter': False, |
| 'model_name': 'reka-core', |
| 'random_seed': randint(0, 2 ** 32 - 1) |
| } |
|
|
| return StreamingResponse(fetch_reka_stream(reka_data), media_type='text/event-stream') |
|
|
|
|
| @app.get('/api/models') |
| @app.get('/api/v1/models') |
| async def models(): |
| logger.debug('запрос `models`') |
| return JSONResponse({ |
| 'object': 'list', |
| 'data': [{'id': 'reka-core', 'object': 'model', 'created': int(datetime.now().timestamp()), 'owned_by': 'reka.ai'}] |
| }, status_code=200, media_type='application/json') |
|
|
|
|
| @app.get('/api/update_token') |
| async def update_token(token: str = Depends(verify_token)): |
| logger.info('запрос `update_token`') |
| task = get_token() |
| return JSONResponse({'status': 'обновление токена запущено'}, status_code=200, media_type='application/json') |
|
|
|
|
| @app.get('/api/show_last_screen') |
| async def show_last_screen(token: str = Depends(verify_token)): |
| logger.info('запрос `show_last_screen`') |
| return FileResponse(screenshot_path.resolve().as_posix(), media_type='image/jpeg', status_code=200) |
|
|
| @app.get('/api') |
| @app.get('/api/info', response_class=HTMLResponse) |
| async def info(): |
| logger.debug('запрос `info`') |
| return HTMLResponse(content='''<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <title>Reka Reverse Proxy Endpoints</title> |
| <style> |
| body {font-family: monospace; background-color: #202020; color:#bfbcb9;} |
| .locked::before {content: '🔐'; margin-right: 5px;} |
| .unlocked::before {content: '🔓'; margin-right: 5px;} |
| </style> |
| <script> |
| document.addEventListener('DOMContentLoaded', () => { |
| const url = `${window.location.protocol}//${window.location.host}`; |
| const endpoints = [ |
| { type: 'locked', path: '/api/v1/chat/completions' }, |
| { type: 'locked', path: '/api/chat/completions' }, |
| { type: 'unlocked', path: '/api/v1/models' }, |
| { type: 'unlocked', path: '/api/models' }, |
| { type: 'locked', path: '/api/update_token' }, |
| { type: 'locked', path: '/api/show_last_screen' } |
| ]; |
| |
| const listContainer = document.getElementById('endpoints-list'); |
| endpoints.forEach(({ type, path }) => { |
| const listItem = document.createElement('li'); |
| listItem.className = type; |
| listItem.textContent = `${url}${path}`; |
| listContainer.appendChild(listItem); |
| }); |
| }); |
| </script> |
| </head> |
| <body> |
| <h2>эндпоинты:</h2> |
| <ul id="endpoints-list"></ul> |
| </body> |
| </html>''', status_code=200) |
|
|
|
|
| if __name__ == '__main__': |
| from uvicorn import run as uvicorn_run |
|
|
| logger.info('запуск сервера uvicorn') |
| uvicorn_run(app, host='0.0.0.0', port=7860) |
|
|