| import os |
| import json |
| import gc |
| import asyncio |
| from fastapi import FastAPI, Request, HTTPException |
| from fastapi.responses import StreamingResponse, JSONResponse |
| from duckduckgo_search import DDGS |
| from llama_cpp import Llama |
|
|
| app = FastAPI(title="Optimized LLM Server") |
|
|
| AVAILABLE_MODELS = { |
| "gemma-4-e2b-it": { |
| "repo_id": "google/gemma-4-E2B-it-qat-q4_0-gguf", |
| "filename": "*q4_0-it.gguf" |
| }, |
| "jan-3.5-4b": { |
| "repo_id": "janhq/Jan-v3.5-4B-gguf", |
| "filename": "*Q4_K_M.gguf" |
| } |
| } |
| DEFAULT_MODEL = "gemma-4-e2b-it" |
|
|
| active_model_id = None |
| llm = None |
| HF_TOKEN = os.environ.get("HF_TOKEN") |
|
|
| |
| |
| model_semaphore = asyncio.Semaphore(1) |
|
|
| def search_web_sync(query): |
| """Función bloqueante original de búsqueda""" |
| try: |
| results = DDGS().text(query, max_results=3) |
| if not results: |
| return "" |
| context = "CONTEXTO DE INTERNET ACTUALIZADO:\n" |
| for r in results: |
| context += f"- {r.get('title')}: {r.get('body')}\n" |
| return context |
| except Exception: |
| return "" |
|
|
| async def search_web_async(query): |
| """Ejecuta la búsqueda en un hilo separado para no bloquear el servidor""" |
| return await asyncio.to_thread(search_web_sync, query) |
|
|
| def load_model_into_memory(model_id): |
| global active_model_id, llm |
| |
| if model_id not in AVAILABLE_MODELS: |
| model_id = DEFAULT_MODEL |
| |
| if active_model_id == model_id: |
| return |
| |
| if llm is not None: |
| del llm |
| gc.collect() |
| |
| model_info = AVAILABLE_MODELS[model_id] |
| llm = Llama.from_pretrained( |
| repo_id=model_info["repo_id"], |
| filename=model_info["filename"], |
| n_ctx=4096, |
| n_threads=2, |
| verbose=False |
| ) |
| active_model_id = model_id |
|
|
| @app.get("/") |
| async def health_check(): |
| return {"status": "online", "role": "mirror_worker", "backend": "FastAPI + llama.cpp"} |
|
|
| @app.get("/v1/models") |
| async def list_models(): |
| data = [{"id": m, "object": "model"} for m in AVAILABLE_MODELS.keys()] |
| return {"object": "list", "data": data} |
|
|
| @app.post("/v1/chat/completions") |
| async def chat_completions(request: Request): |
| data = await request.json() |
| if not data or 'messages' not in data: |
| raise HTTPException(status_code=400, detail="Petición inválida") |
|
|
| messages = data.get('messages', []) |
| requested_model = data.get('model', DEFAULT_MODEL) |
| temperature = data.get('temperature', 0.6) |
| max_tokens = min(data.get('max_tokens', 1024), 2048) |
| stream = data.get('stream', False) |
| use_web_search = data.get('web_search', False) |
|
|
| |
| if use_web_search and messages and messages[-1]['role'] == 'user': |
| user_query = messages[-1]['content'] |
| web_context = await search_web_async(user_query) |
| if web_context: |
| messages[-1]['content'] = f"Responde usando esta info de internet si es útil:\n{web_context}\n\nPregunta: {user_query}" |
|
|
| |
| async def generate_response(): |
| async with model_semaphore: |
| |
| await asyncio.to_thread(load_model_into_memory, requested_model) |
| |
| |
| response = await asyncio.to_thread( |
| llm.create_chat_completion, |
| messages=messages, |
| max_tokens=max_tokens, |
| temperature=temperature, |
| stream=stream |
| ) |
| return response |
|
|
| try: |
| response_data = await generate_response() |
|
|
| if stream: |
| async def event_generator(): |
| |
| def get_next_chunk(): |
| try: |
| return next(response_data) |
| except StopIteration: |
| return None |
| |
| while True: |
| chunk = await asyncio.to_thread(get_next_chunk) |
| if chunk is None: |
| break |
| yield f"data: {json.dumps(chunk)}\n\n" |
| yield "data: [DONE]\n\n" |
|
|
| return StreamingResponse(event_generator(), media_type="text/event-stream") |
| else: |
| return JSONResponse(content=response_data) |
| |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=str(e)) |