diff --git a/.gitignore b/.gitignore index 64790cf..869d558 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ wheels/ # Virtual environments .venv -models \ No newline at end of file +models +model_choice diff --git a/README.md b/README.md index 94fa127..49e8690 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ python main.py --help ``` - `--model-path` / `-m`: Ruta al archivo del modelo GGUF (requerido) +- `--mmproj-path`: Ruta al proyector multimodal (mmproj) si el modelo lo requiere (LLaVA, Qwen-VL, etc.) - `--host`: Host del servidor (default: 0.0.0.0) - `--port`: Puerto del servidor (default: 8000) - `--n-ctx`: Tama�o del contexto (default: 4096) @@ -87,6 +88,31 @@ uv run python main.py --model-path ./models/Qwen3-4B-Instruct-2507-Q4_K_M.gguf - uv run python main.py --model-path ./models/Qwen3-4B-Instruct-2507-Q4_K_M.gguf --port 8000 --n-ctx 4096 --n-gpu-layers -1 --main-gpu 0 --split-mode 1 ``` +### Modelos multimodales (visi�n) + +- Si el modelo requiere proyector externo (mmproj), coloca el archivo en disco y pasa `--mmproj-path /ruta/proyector.mmproj` o config�ralo en `api_cuda.conf` como `MM_PROJ_PATH`. +- Solo se aceptan im�genes inline (data URI o base64 puro). Si env�as base64 sin prefijo, el servidor lo convierte en `data:image/png;base64,...`. +- Ejemplo de request multimodal: + +```bash +curl -X POST http://localhost:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "llama", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe lo que ves"}, + {"type": "image_url", "image_url": "data:image/png;base64,AAA..."} + ] + } + ], + "max_tokens": 120, + "temperature": 0.2 + }' +``` + ## API Endpoints ### GET `/v1/models` @@ -188,4 +214,4 @@ Una vez que el servidor est� ejecut�ndose, puedes acceder a la documentaci - **Memoria GPU insuficiente**: Reduce `--n-gpu-layers` a un n�mero menor (ej: 20, 10) - **GPU no detectada**: Verifica que `nvidia-smi` funcione y muestre tu GPU - **Rendimiento lento con GPU**: Aseg�rate de usar `--n-gpu-layers -1` para cargar todas las capas -- **Error de compatibilidad**: Verifica que tu GPU tenga compute capability >= 3.5 \ No newline at end of file +- **Error de compatibilidad**: Verifica que tu GPU tenga compute capability >= 3.5 diff --git a/api_cuda.conf b/api_cuda.conf new file mode 100644 index 0000000..39e5f86 --- /dev/null +++ b/api_cuda.conf @@ -0,0 +1,35 @@ +# Configuracion editable para run_api_cuda.sh +# Si MODEL_PATH queda vacio, el script usara el primer .gguf en model_choice/ + +# Ruta al modelo GGUF +MODEL_PATH="" +# Ruta al proyector multimodal (mmproj) cuando el modelo lo necesita (LLaVA, Qwen-VL, etc.) +MM_PROJ_PATH="" + +# Red +HOST="0.0.0.0" +PORT=8000 + +# Parametros del modelo +N_CTX=4096 +N_BATCH=512 +# Dejar vacio para que se use el valor automatico de llama.cpp +N_THREADS="" +N_GPU_LAYERS=-1 # -1 usa todas las capas en GPU +MAIN_GPU=0 +SPLIT_MODE=1 +ROPE_FREQ_BASE=10000 +ROPE_FREQ_SCALE=1.0 +OFFLOAD_KV_CACHE=true +KEEP_MODEL_IN_MEMORY=true # usa mlock para fijar en RAM +TRY_MMAP=true +SEED=0 +FLASH_ATTN=true + +# Parametros de generacion por defecto (pueden sobreescribirse en cada request) +DEFAULT_MAX_TOKENS=2048 +DEFAULT_TEMPERATURE=0.8 +DEFAULT_TOP_K=40 +DEFAULT_REPEAT_PENALTY=1.1 +DEFAULT_MIN_P=0.05 +DEFAULT_TOP_P=0.95 diff --git a/main.py b/main.py index 5661e5f..c177260 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,10 @@ import sys import time import uuid -from typing import List, Optional +from typing import List, Optional, Union from dataclasses import dataclass from pathlib import Path +from typing import Literal from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware @@ -12,21 +13,63 @@ import uvicorn from llama_cpp import Llama +def str_to_bool(value: str) -> bool: + """Parsea valores booleanos desde la CLI.""" + if isinstance(value, bool): + return value + lowered = value.strip().lower() + if lowered in {"true", "1", "yes", "y", "t"}: + return True + if lowered in {"false", "0", "no", "n", "f"}: + return False + raise ValueError(f"Valor booleano invalido: {value}") + + # Modelos de datos para la API compatible con OpenAI class ChatCompletionMessage(BaseModel): role: str = Field(..., description="El rol del mensaje: 'system', 'user', o 'assistant'") - content: str = Field(..., description="El contenido del mensaje") + content: Union[str, List["MessageContentPart"]] = Field(..., description="Contenido del mensaje (texto o partes multimodales)") class ChatCompletionRequest(BaseModel): model: str = Field(default="llama", description="Nombre del modelo") messages: List[ChatCompletionMessage] = Field(..., description="Lista de mensajes") - max_tokens: Optional[int] = Field(default=2048, description="Máximo número de tokens a generar") - temperature: Optional[float] = Field(default=0.7, description="Temperatura para el muestreo") - top_p: Optional[float] = Field(default=0.9, description="Top-p para el muestreo") + max_tokens: Optional[int] = Field(default=None, description="Máximo número de tokens a generar") + temperature: Optional[float] = Field(default=None, description="Temperatura para el muestreo") + top_p: Optional[float] = Field(default=None, description="Top-p para el muestreo") + top_k: Optional[int] = Field(default=None, description="Top-k para el muestreo") + repeat_penalty: Optional[float] = Field(default=None, description="Penalización por repetición") + min_p: Optional[float] = Field(default=None, description="Umbral mínimo de probabilidad (min_p sampling)") stream: Optional[bool] = Field(default=False, description="Si devolver respuesta en streaming") +class MessageContentPart(BaseModel): + type: Literal["text", "image_url"] + text: Optional[str] = None + image_url: Optional[str] = None + + +# Resolver forward refs +ChatCompletionMessage.model_rebuild() + + +def normalize_image_input(raw: str) -> str: + """Normaliza imágenes a data URI; si llega base64 simple, se envuelve como data:image/png;base64.""" + if raw.startswith("data:"): + return raw + if "://" in raw: + # En este entorno no se hace fetch remoto + raise HTTPException(status_code=400, detail="Solo se aceptan imágenes en data URI o base64 inline") + # Asumimos base64 puro + return f"data:image/png;base64,{raw}" + + +def likely_vision_model(model_path: str) -> bool: + """Heurística simple para detectar modelos multimodales.""" + lowered = Path(model_path).name.lower() + return any(key in lowered for key in ("vl", "vision", "llava", "mmproj")) + + class ChatCompletionChoice(BaseModel): index: int message: ChatCompletionMessage @@ -64,9 +107,13 @@ class ModelsResponse(BaseModel): class LlamaAPI: """Clase para manejar la API de Llama""" - def __init__(self, model_path: str, **kwargs): + def __init__(self, model_path: str, mmproj_path: Optional[str] = None, **kwargs): if not Path(model_path).exists(): raise FileNotFoundError(f"El archivo del modelo no existe: {model_path}") + if mmproj_path: + if not Path(mmproj_path).exists(): + raise FileNotFoundError(f"El archivo mmproj no existe: {mmproj_path}") + kwargs["mmproj_path"] = mmproj_path print(f"Cargando modelo desde: {model_path}") @@ -79,6 +126,13 @@ class LlamaAPI: "n_gpu_layers": -1, # Usar todas las capas en GPU (-1 = auto) "main_gpu": 0, # GPU principal a usar "split_mode": 1, # Modo de división entre GPUs + "rope_freq_base": 10000.0, + "rope_freq_scale": 1.0, + "offload_kv": True, + "use_mmap": True, + "use_mlock": False, + "seed": 0, + "flash_attn": False, } # Actualizar con parámetros personalizados @@ -90,6 +144,12 @@ class LlamaAPI: print(f"GPU layers: {default_params.get('n_gpu_layers', 'N/A')}") print(f"Main GPU: {default_params.get('main_gpu', 'N/A')}") except Exception as e: + vision_hint = likely_vision_model(model_path) + if vision_hint and not mmproj_path: + raise RuntimeError( + "Fallo al cargar modelo multimodal. Se requiere un archivo mmproj. " + "Define --mmproj-path o coloca un .mmproj en model_choice/." + ) from e print(f"Error al cargar el modelo: {e}") print("Nota: Si tienes problemas con CUDA, intenta instalar: pip install llama-cpp-python[cublas]") raise @@ -97,11 +157,15 @@ class LlamaAPI: def generate_chat_completion(self, messages: List[ChatCompletionMessage], max_tokens: int = 2048, temperature: float = 0.7, - top_p: float = 0.9) -> str: + top_p: float = 0.9, + top_k: int = 40, + repeat_penalty: float = 1.1, + min_p: float = 0.05) -> str: """Genera una respuesta de chat usando el modelo""" # Formatear los mensajes en un prompt prompt = self._format_messages_to_prompt(messages) + images = self._collect_images(messages) try: # Generar respuesta usando llama.cpp @@ -110,7 +174,11 @@ class LlamaAPI: max_tokens=max_tokens, temperature=temperature, top_p=top_p, + top_k=top_k, + repeat_penalty=repeat_penalty, + min_p=min_p, echo=False, + images=images if images else None, stop=["", "<|im_end|>", "<|endoftext|>"] ) @@ -126,19 +194,52 @@ class LlamaAPI: prompt_parts = [] for message in messages: + text_content = self._extract_text_from_content(message.content) if message.role == "system": - prompt_parts.append(f"Sistema: {message.content}") + prompt_parts.append(f"Sistema: {text_content}") elif message.role == "user": - prompt_parts.append(f"Usuario: {message.content}") + prompt_parts.append(f"Usuario: {text_content}") elif message.role == "assistant": - prompt_parts.append(f"Asistente: {message.content}") + prompt_parts.append(f"Asistente: {text_content}") prompt_parts.append("Asistente:") return "\n".join(prompt_parts) + def _extract_text_from_content(self, content: Union[str, List["MessageContentPart"]]) -> str: + """Extrae el texto de un contenido que puede ser string o lista multimodal.""" + if isinstance(content, str): + return content + + texts = [] + for part in content: + if part.type == "text" and part.text: + texts.append(part.text) + elif part.type == "image_url": + texts.append("[imagen]") + return " ".join(texts).strip() + + def _collect_images(self, messages: List[ChatCompletionMessage]) -> List[str]: + """Recolecta imágenes (data URI) de los mensajes.""" + images: List[str] = [] + for message in messages: + if isinstance(message.content, str): + continue + for part in message.content: + if part.type == "image_url" and part.image_url: + images.append(normalize_image_input(part.image_url)) + return images + # Instancia global de la API llama_api = None +generation_defaults = { + "max_tokens": 2048, + "temperature": 0.7, + "top_p": 0.9, + "top_k": 40, + "repeat_penalty": 1.1, + "min_p": 0.05, +} # Crear la aplicación FastAPI app = FastAPI( @@ -189,11 +290,21 @@ async def create_chat_completion(request: ChatCompletionRequest): try: # Generar respuesta + temperature = request.temperature if request.temperature is not None else generation_defaults["temperature"] + top_p = request.top_p if request.top_p is not None else generation_defaults["top_p"] + top_k = request.top_k if request.top_k is not None else generation_defaults["top_k"] + repeat_penalty = request.repeat_penalty if request.repeat_penalty is not None else generation_defaults["repeat_penalty"] + min_p = request.min_p if request.min_p is not None else generation_defaults["min_p"] + max_tokens = request.max_tokens if request.max_tokens is not None else generation_defaults["max_tokens"] + response_text = llama_api.generate_chat_completion( messages=request.messages, - max_tokens=request.max_tokens or 2048, - temperature=request.temperature or 0.7, - top_p=request.top_p or 0.9 + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + top_k=top_k, + repeat_penalty=repeat_penalty, + min_p=min_p ) # Crear respuesta en formato OpenAI @@ -241,6 +352,8 @@ def main(): help="Tamaño del contexto (default: 4096)") parser.add_argument("--n-batch", type=int, default=512, help="Tamaño del batch (default: 512)") + parser.add_argument("--eval-batch-size", type=int, default=None, + help="Tamaño del batch de evaluación (sobrescribe n-batch si se setea)") parser.add_argument("--n-threads", type=int, default=None, help="Número de threads (default: auto)") parser.add_argument("--n-gpu-layers", type=int, default=-1, @@ -249,6 +362,35 @@ def main(): help="GPU principal a usar (default: 0)") parser.add_argument("--split-mode", type=int, default=1, help="Modo de división entre GPUs (default: 1)") + parser.add_argument("--rope-freq-base", type=float, default=10000.0, + help="Base de frecuencia ROPE (default: 10000)") + parser.add_argument("--rope-freq-scale", type=float, default=1.0, + help="Escala de frecuencia ROPE (default: 1.0)") + parser.add_argument("--offload-kv-cache", type=str_to_bool, default=True, + help="Offload del KV cache a GPU (default: true)") + parser.add_argument("--keep-model-in-memory", type=str_to_bool, default=False, + help="Usa mlock para mantener el modelo en RAM (default: false)") + parser.add_argument("--try-mmap", type=str_to_bool, default=True, + help="Usar mmap para mapear el modelo (default: true)") + parser.add_argument("--seed", type=int, default=0, + help="Seed para la generación (0 = aleatorio)") + parser.add_argument("--flash-attn", type=str_to_bool, default=False, + help="Habilitar Flash Attention si está disponible") + # Defaults de generación + parser.add_argument("--default-max-tokens", type=int, default=2048, + help="Límite de tokens de respuesta por defecto") + parser.add_argument("--default-temperature", type=float, default=0.7, + help="Temperatura por defecto") + parser.add_argument("--default-top-k", type=int, default=40, + help="Top-k por defecto") + parser.add_argument("--default-repeat-penalty", type=float, default=1.1, + help="Penalización de repetición por defecto") + parser.add_argument("--default-min-p", type=float, default=0.05, + help="Min-p por defecto") + parser.add_argument("--default-top-p", type=float, default=0.9, + help="Top-p por defecto") + parser.add_argument("--mmproj-path", type=str, default=None, + help="Ruta al proyector multimodal (mmproj) si el modelo lo requiere") args = parser.parse_args() @@ -260,25 +402,48 @@ def main(): # Configurar parámetros del modelo model_params = { "n_ctx": args.n_ctx, - "n_batch": args.n_batch, + "n_batch": args.eval_batch_size or args.n_batch, "n_gpu_layers": args.n_gpu_layers, "main_gpu": args.main_gpu, "split_mode": args.split_mode, + "rope_freq_base": args.rope_freq_base, + "rope_freq_scale": args.rope_freq_scale, + "offload_kv": args.offload_kv_cache, + "use_mmap": args.try_mmap, + "use_mlock": args.keep_model_in_memory, + "seed": args.seed, + "flash_attn": args.flash_attn, } if args.n_threads: model_params["n_threads"] = args.n_threads + # Defaults de generación para usar si la request no los especifica + global generation_defaults + generation_defaults = { + "max_tokens": args.default_max_tokens, + "temperature": args.default_temperature, + "top_p": args.default_top_p, + "top_k": args.default_top_k, + "repeat_penalty": args.default_repeat_penalty, + "min_p": args.default_min_p, + } + try: # Inicializar la API de Llama global llama_api - llama_api = LlamaAPI(args.model_path, **model_params) + llama_api = LlamaAPI(args.model_path, mmproj_path=args.mmproj_path, **model_params) print(f"\n🚀 Servidor iniciado en http://{args.host}:{args.port}") print(f"📚 Documentación disponible en http://{args.host}:{args.port}/docs") print(f"🤖 Modelo cargado desde: {args.model_path}") print(f"🎮 GPU layers: {args.n_gpu_layers} (usa -1 para todas las capas)") print(f"🎯 Main GPU: {args.main_gpu}") + print(f"🧠 ROPE base/scale: {args.rope_freq_base} / {args.rope_freq_scale}") + print(f"🧮 Offload KV cache: {args.offload_kv_cache} | mmap: {args.try_mmap} | mlock: {args.keep_model_in_memory}") + print(f"⚡ Flash Attn: {args.flash_attn} | Seed: {args.seed}") + print(f"🖼️ mmproj: {args.mmproj_path or ''}") + print(f"🎛 Defaults -> max_tokens: {generation_defaults['max_tokens']}, temp: {generation_defaults['temperature']}, top_k: {generation_defaults['top_k']}, repeat_penalty: {generation_defaults['repeat_penalty']}, min_p: {generation_defaults['min_p']}, top_p: {generation_defaults['top_p']}") print("\n💡 Ejemplo de uso con curl:") print(f""" curl -X POST http://{args.host}:{args.port}/v1/chat/completions \\ diff --git a/run_api_cuda.sh b/run_api_cuda.sh new file mode 100755 index 0000000..3dd83df --- /dev/null +++ b/run_api_cuda.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Ejecuta la API usando CUDA tomando el modelo GGUF de model_choice + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONF_FILE="${SCRIPT_DIR}/api_cuda.conf" + +if [[ -f "${CONF_FILE}" ]]; then + # shellcheck source=/dev/null + source "${CONF_FILE}" +else + echo "Archivo de configuracion no encontrado en ${CONF_FILE}. Usando valores por defecto." +fi + +# Valores por defecto si no vienen del .conf o del entorno +HOST="${HOST:-0.0.0.0}" +PORT="${PORT:-8000}" +N_CTX="${N_CTX:-4096}" +N_BATCH="${N_BATCH:-512}" +N_THREADS="${N_THREADS:-}" +N_GPU_LAYERS="${N_GPU_LAYERS:--1}" +MAIN_GPU="${MAIN_GPU:-0}" +SPLIT_MODE="${SPLIT_MODE:-1}" +ROPE_FREQ_BASE="${ROPE_FREQ_BASE:-10000}" +ROPE_FREQ_SCALE="${ROPE_FREQ_SCALE:-1.0}" +OFFLOAD_KV_CACHE="${OFFLOAD_KV_CACHE:-true}" +KEEP_MODEL_IN_MEMORY="${KEEP_MODEL_IN_MEMORY:-false}" +TRY_MMAP="${TRY_MMAP:-true}" +SEED="${SEED:-0}" +FLASH_ATTN="${FLASH_ATTN:-false}" +MM_PROJ_PATH="${MM_PROJ_PATH:-}" +# Defaults for sampling (se pueden sobreescribir en la peticion) +DEFAULT_MAX_TOKENS="${DEFAULT_MAX_TOKENS:-2048}" +DEFAULT_TEMPERATURE="${DEFAULT_TEMPERATURE:-0.7}" +DEFAULT_TOP_K="${DEFAULT_TOP_K:-40}" +DEFAULT_REPEAT_PENALTY="${DEFAULT_REPEAT_PENALTY:-1.1}" +DEFAULT_MIN_P="${DEFAULT_MIN_P:-0.05}" +DEFAULT_TOP_P="${DEFAULT_TOP_P:-0.9}" + +# Determinar el modelo: usa MODEL_PATH si esta definido, si no toma el primer .gguf en model_choice +MODEL_PATH_VALUE="${MODEL_PATH:-}" +if [[ -z "${MODEL_PATH_VALUE}" ]]; then + MODEL_PATH_VALUE="$(find "${SCRIPT_DIR}/model_choice" -maxdepth 1 -type f -name '*.gguf' | head -n 1 || true)" +fi + +if [[ -z "${MODEL_PATH_VALUE}" || ! -f "${MODEL_PATH_VALUE}" ]]; then + echo "No se encontro un modelo .gguf. Define MODEL_PATH en ${CONF_FILE} o agrega un archivo a model_choice/." + exit 1 +fi + +# Autodetectar mmproj si no se configuro (acepta .mmproj o archivos .gguf que contengan 'mmproj') +if [[ -z "${MM_PROJ_PATH}" ]]; then + MODEL_DIR="$(dirname "${MODEL_PATH_VALUE}")" + MM_PROJ_PATH="$(find "${MODEL_DIR}" -maxdepth 1 -type f -name '*.mmproj' -o -name '*mmproj*.gguf' | head -n 1 || true)" +fi +if [[ -z "${MM_PROJ_PATH}" ]]; then + MM_PROJ_PATH="$(find "${SCRIPT_DIR}/model_choice" -maxdepth 1 -type f -name '*.mmproj' -o -name '*mmproj*.gguf' | head -n 1 || true)" +fi + +# Elegir ejecutor: preferimos uv run para respetar uv.lock; si no, se usa python directo +if command -v uv >/dev/null 2>&1; then + export UV_CACHE_DIR="${SCRIPT_DIR}/.uv_cache" + PY_RUNNER=(uv run python) +else + PY_RUNNER=(python) +fi + +CMD=( + "${PY_RUNNER[@]}" + "${SCRIPT_DIR}/main.py" + --model-path "${MODEL_PATH_VALUE}" + --host "${HOST}" + --port "${PORT}" + --n-ctx "${N_CTX}" + --n-batch "${N_BATCH}" + --n-gpu-layers "${N_GPU_LAYERS}" + --main-gpu "${MAIN_GPU}" + --split-mode "${SPLIT_MODE}" + --rope-freq-base "${ROPE_FREQ_BASE}" + --rope-freq-scale "${ROPE_FREQ_SCALE}" + --offload-kv-cache "${OFFLOAD_KV_CACHE}" + --keep-model-in-memory "${KEEP_MODEL_IN_MEMORY}" + --try-mmap "${TRY_MMAP}" + --seed "${SEED}" + --flash-attn "${FLASH_ATTN}" + --default-max-tokens "${DEFAULT_MAX_TOKENS}" + --default-temperature "${DEFAULT_TEMPERATURE}" + --default-top-k "${DEFAULT_TOP_K}" + --default-repeat-penalty "${DEFAULT_REPEAT_PENALTY}" + --default-min-p "${DEFAULT_MIN_P}" + --default-top-p "${DEFAULT_TOP_P}" +) + +if [[ -n "${N_THREADS}" ]]; then + CMD+=(--n-threads "${N_THREADS}") +fi +if [[ -n "${MM_PROJ_PATH}" ]]; then + CMD+=(--mmproj-path "${MM_PROJ_PATH}") +fi + +echo "Iniciando API con CUDA" +echo "Modelo: ${MODEL_PATH_VALUE}" +echo "Host/Port: ${HOST}:${PORT}" +echo "n_ctx=${N_CTX}, n_batch=${N_BATCH}, n_gpu_layers=${N_GPU_LAYERS}, main_gpu=${MAIN_GPU}, split_mode=${SPLIT_MODE}, n_threads=${N_THREADS:-auto}" +echo "rope_base=${ROPE_FREQ_BASE}, rope_scale=${ROPE_FREQ_SCALE}, offload_kv=${OFFLOAD_KV_CACHE}, mmap=${TRY_MMAP}, mlock=${KEEP_MODEL_IN_MEMORY}, seed=${SEED}, flash_attn=${FLASH_ATTN}" +echo "mmproj_path=${MM_PROJ_PATH:-}" +echo "defaults -> max_tokens=${DEFAULT_MAX_TOKENS}, temp=${DEFAULT_TEMPERATURE}, top_k=${DEFAULT_TOP_K}, repeat_penalty=${DEFAULT_REPEAT_PENALTY}, min_p=${DEFAULT_MIN_P}, top_p=${DEFAULT_TOP_P}" + +# Exportar bandera de compilacion por si se re-instala llama-cpp con cublas; no afecta si ya esta instalado +export CMAKE_ARGS="-DLLAMA_CUBLAS=on" + +exec "${CMD[@]}"