| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| from __future__ import annotations |
|
|
| import os |
| import warnings |
| warnings.filterwarnings("ignore") |
| os.environ["PYTHONWARNINGS"] = "ignore" |
| os.environ["LLAMA_NUMA"] = "1" |
|
|
| import logging |
| import threading |
| import queue as _queue_module |
| import time |
| from flask import Flask, request, jsonify |
|
|
| |
| logging.basicConfig( |
| level=logging.INFO, |
| format="%(asctime)s [%(name)s] %(levelname)s %(message)s", |
| datefmt="%Y-%m-%d %H:%M:%S", |
| ) |
| log = logging.getLogger("gen_llm") |
| logging.getLogger("werkzeug").setLevel(logging.ERROR) |
| logging.getLogger("httpx").setLevel(logging.WARNING) |
|
|
| |
| MODEL_REPO = os.getenv("GEN_MODEL_ID", "Jackrong/Qwen3.5-2B-Claude-4.6-Opus-Reasoning-Distilled-GGUF") |
| MODEL_FILE = os.getenv("GEN_MODEL_FILENAME", "Qwen3.5-2B.Q4_K_M.gguf") |
| HOST = os.getenv("GEN_HOST", "127.0.0.1") |
| PORT = int(os.getenv("GEN_PORT", "8002")) |
|
|
| |
| DEFAULT_CPU_THREADS = int(os.getenv("GEN_CPU_THREADS", "2")) |
|
|
| |
| _QUEUE_MAX_SIZE = int(os.getenv("GEN_QUEUE_MAX", "8")) |
|
|
| |
| _REQUEST_TIMEOUT_S = int(os.getenv("LLM_TIMEOUT", "600")) |
|
|
| |
| _gpu_available = False |
| _gpu_id = "cpu" |
|
|
| try: |
| import torch |
| if torch.cuda.is_available(): |
| _cuda_idx = int(os.getenv("GEN_CUDA_DEVICE", "0")) |
| _gpu_id = f"cuda:{_cuda_idx}" |
| _gpu_available = True |
| log.info("GPU DETECTED β %s available for on-demand inference", _gpu_id) |
| else: |
| log.info("No CUDA GPU detected β CPU-only inference available") |
| except ImportError: |
| log.info("torch not available β GPU detection skipped, CPU-only mode") |
|
|
| |
| |
|
|
| log.info("β" * 60) |
| log.info("gen_llm starting β gpu_available=%s model=%s (%s)", _gpu_available, MODEL_REPO, MODEL_FILE) |
| log.info("Default CPU threads=%d Queue: max_size=%d request_timeout=%ds", |
| DEFAULT_CPU_THREADS, _QUEUE_MAX_SIZE, _REQUEST_TIMEOUT_S) |
| log.info("β" * 60) |
|
|
|
|
| |
| |
| |
|
|
| _model_lock = threading.Lock() |
| _models: dict[str, object] = {} |
| _model_ready = threading.Event() |
| _model_path = None |
|
|
|
|
| def _load_model(use_gpu: bool = False): |
| """Load and cache a model instance. Returns the cached instance if already loaded.""" |
| mode_key = "gpu" if (use_gpu and _gpu_available) else "cpu" |
| |
| with _model_lock: |
| if mode_key in _models: |
| return _models[mode_key] |
| |
| global _model_path |
| if _model_path is None: |
| log.info("Downloading/Locating model from Hub: %s/%s", MODEL_REPO, MODEL_FILE) |
| from huggingface_hub import hf_hub_download |
| _model_path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE) |
| |
| n_gpu_layers = -1 if (use_gpu and _gpu_available) else 0 |
| cpu_threads = DEFAULT_CPU_THREADS |
| |
| log.info("Loading GGUF model β mode=%s n_gpu_layers=%d threads=%d", |
| mode_key, n_gpu_layers, cpu_threads) |
| |
| from llama_cpp import Llama |
| m = Llama( |
| model_path=_model_path, |
| n_ctx=8192, |
| n_batch=512, |
| n_threads=cpu_threads, |
| n_gpu_layers=n_gpu_layers, |
| use_mmap=True, |
| use_mlock=True, |
| numa=True, |
| flash_attn=True, |
| verbose=False, |
| ) |
| _models[mode_key] = m |
| log.info("Model instance [%s] ready!", mode_key) |
| return m |
|
|
|
|
| |
| try: |
| _load_model(use_gpu=False) |
| _model_ready.set() |
| log.info("CPU model pre-loaded and ready.") |
| except Exception as e: |
| log.error("Failed to load CPU model: %s", e) |
| raise |
|
|
|
|
| |
| app = Flask(__name__) |
|
|
| |
| _inference_queue: _queue_module.Queue = _queue_module.Queue(maxsize=_QUEUE_MAX_SIZE) |
|
|
|
|
| def _run_inference(data: dict) -> dict: |
| """Execute one completion request. Called only from the inference worker thread.""" |
| raw_prompt = data.get("prompt", "") |
| if not raw_prompt: |
| return {"error": "Field 'prompt' is required."} |
|
|
| prompts = raw_prompt if isinstance(raw_prompt, list) and (len(raw_prompt) == 0 or not isinstance(raw_prompt[0], dict)) else [raw_prompt] |
|
|
| use_gpu = bool(data.get("use_gpu", False)) |
| cpu_threads = int(data.get("cpu_threads", DEFAULT_CPU_THREADS)) |
|
|
| |
| model = _load_model(use_gpu=use_gpu) |
| active_device = _gpu_id if (use_gpu and _gpu_available) else "cpu" |
|
|
| |
| |
| if not (use_gpu and _gpu_available) and cpu_threads != DEFAULT_CPU_THREADS: |
| log.info("cpu_threads=%d requested (model loaded with %d β static per-instance)", |
| cpu_threads, DEFAULT_CPU_THREADS) |
|
|
| _default_max = 1024 |
| max_new_tokens = int( data.get("max_tokens", _default_max)) |
| |
| |
| |
| |
| if 100 < max_new_tokens < 2048: |
| max_new_tokens = 2048 |
| temperature = float(data.get("temperature", 0.7)) |
| top_p = float(data.get("top_p", 0.95)) |
| top_k = int( data.get("top_k", 40)) |
| repeat_penalty = float(data.get("repeat_penalty", 1.15)) |
| freq_penalty = float(data.get("frequency_penalty", 0.1)) |
|
|
| choices = [] |
| total_prompt_tokens = 0 |
| total_completion_tokens = 0 |
|
|
| |
| REP_WINDOW = 120 |
| REP_THRESHOLD = 2 |
|
|
| def _is_repeating(text: str) -> bool: |
| if len(text) < REP_WINDOW * (REP_THRESHOLD + 1): |
| return False |
| tail = text[-REP_WINDOW:] |
| preceding = text[: -REP_WINDOW] |
| count = 0 |
| start = 0 |
| while True: |
| idx = preceding.find(tail, start) |
| if idx == -1: |
| break |
| count += 1 |
| if count >= REP_THRESHOLD: |
| return True |
| start = idx + 1 |
| return False |
|
|
| for i, prompt in enumerate(prompts): |
| if isinstance(prompt, list): |
| messages = prompt |
| else: |
| messages = [ |
| {"role": "system", "content": "You are a helpful, respectful and honest assistant."}, |
| {"role": "user", "content": prompt} |
| ] |
|
|
| |
| stream = model.create_chat_completion( |
| messages=messages, |
| max_tokens=max_new_tokens, |
| temperature=temperature if temperature > 0.15 else 0.0, |
| top_p=top_p, |
| top_k=top_k, |
| repeat_penalty=repeat_penalty, |
| frequency_penalty=freq_penalty, |
| stream=True, |
| ) |
|
|
| full_output = "" |
| prompt_len = len(str(messages)) // 4 |
| completion_len = 0 |
| |
| print(f"\n[CONSOLE STREAM] Generating for: {MODEL_REPO}") |
| print("-" * 30) |
|
|
| for chunk in stream: |
| if "choices" in chunk and len(chunk["choices"]) > 0: |
| choice = chunk["choices"][0] |
| text_part = choice.get("delta", {}).get("content", "") |
| if not text_part: |
| text_part = choice.get("text", "") |
| |
| if text_part: |
| print(text_part, end="", flush=True) |
| full_output += text_part |
| completion_len += 1 |
|
|
| if _is_repeating(full_output): |
| print("\n[CONSOLE STREAM] Repetition detected β cutting off generation.") |
| full_output = full_output[:-REP_WINDOW].strip() |
| break |
|
|
| print("\n" + "-" * 30) |
|
|
| answer_text = full_output.strip() |
|
|
| total_prompt_tokens += prompt_len |
| total_completion_tokens += completion_len |
|
|
| |
| think_text = "" |
| think_end = answer_text.find("</think>") |
| think_start = answer_text.find("<think>") |
| |
| if think_end != -1: |
| |
| if think_start != -1 and think_start < think_end: |
| think_text = answer_text[think_start + len("<think>"):think_end].strip() |
| answer_text = (answer_text[:think_start] + "\n" + answer_text[think_end + len("</think>"):]).strip() |
| else: |
| |
| think_text = answer_text[:think_end].strip() |
| answer_text = answer_text[think_end + len("</think>"):].strip() |
| elif think_start != -1: |
| |
| think_text = answer_text[think_start + len("<think>"):].strip() |
| answer_text = answer_text[:think_start].strip() |
| |
| |
| if not answer_text or (not think_text and answer_text): |
| _THINK_PREFIXES = ( |
| "Thinking Process:", "Let me analyze", "Let me think", |
| "I need to analyze", "Let me break this down", |
| "Let me review", "Let me examine", "Let me consider", |
| "I'll analyze", "Step 1:", "1. **Analyze", |
| ) |
| stripped = answer_text.lstrip("\n ") |
| for prefix in _THINK_PREFIXES: |
| if stripped.startswith(prefix): |
| think_text = stripped |
| answer_text = "" |
| break |
|
|
| log.info("Prompt %d β %d new tokens (device=%s, gpu=%s, threads=%d)", |
| i, completion_len, active_device, use_gpu and _gpu_available, cpu_threads) |
|
|
| choices.append({ |
| "index": i, |
| "text": answer_text, |
| "thinking": think_text, |
| }) |
|
|
| return { |
| "model": MODEL_REPO, |
| "choices": choices, |
| "usage": { |
| "prompt_tokens": total_prompt_tokens, |
| "completion_tokens": total_completion_tokens, |
| }, |
| "device": active_device, |
| } |
|
|
|
|
| def _inference_worker() -> None: |
| log.info("Inference worker thread started (pid=%d)", os.getpid()) |
|
|
| while True: |
| try: |
| item = _inference_queue.get(timeout=1.0) |
| except _queue_module.Empty: |
| continue |
|
|
| req_data, result_holder, done_event = item |
| try: |
| result_holder[0] = _run_inference(req_data) |
| except Exception as exc: |
| log.error("Inference worker error: %s", exc) |
| result_holder[0] = {"error": f"Inference failed: {exc}"} |
| finally: |
| done_event.set() |
| _inference_queue.task_done() |
|
|
|
|
| _worker_thread = threading.Thread(target=_inference_worker, name="inference-worker", daemon=True) |
| _worker_thread.start() |
|
|
|
|
| |
|
|
| @app.route("/v1/kv_cache", methods=["POST"]) |
| def kv_cache(): |
| """KV cache is managed natively by llama.cpp. This is a no-op.""" |
| return jsonify({"status": "skipped", "reason": "llama.cpp manages KV cache natively"}) |
|
|
|
|
| @app.route("/health", methods=["GET"]) |
| def health(): |
| import psutil |
| mem = psutil.virtual_memory() |
| ram_used_gb = round((mem.total - mem.available) / 1024 ** 3, 2) |
| ram_total_gb = round(mem.total / 1024 ** 3, 2) |
|
|
| queue_depth = _inference_queue.qsize() |
| is_ready = _model_ready.is_set() |
| loaded_modes = list(_models.keys()) |
|
|
| return jsonify({ |
| "status": "ok" if is_ready else "loading", |
| "model": MODEL_REPO, |
| "gpu_available": _gpu_available, |
| "gpu_id": _gpu_id, |
| "loaded_modes": loaded_modes, |
| "default_threads": DEFAULT_CPU_THREADS, |
| "kv_cache_length": 0, |
| "kv_cache_enabled": False, |
| "torch_compile": False, |
| "vram_free_gib": 0.0, |
| "ram_used_gb": ram_used_gb, |
| "ram_total_gb": ram_total_gb, |
| "queue_depth": queue_depth, |
| "queue_max": _QUEUE_MAX_SIZE, |
| "model_ready": is_ready, |
| }) |
|
|
|
|
| @app.route("/v1/completions", methods=["POST"]) |
| def completions(): |
| if not _model_ready.is_set(): |
| return jsonify({ |
| "error": "Model is still loading β please try again in a few seconds.", |
| "retry_after": 5, |
| }), 503 |
|
|
| data: dict = request.get_json(force=True) or {} |
|
|
| current_depth = _inference_queue.qsize() |
| if current_depth >= _QUEUE_MAX_SIZE: |
| log.warning("Inference queue full (%d/%d) β rejecting request.", current_depth, _QUEUE_MAX_SIZE) |
| return jsonify({ |
| "error": "Server busy β all inference slots are occupied. Please try again shortly.", |
| "retry_after": max(5, current_depth * 3), |
| "queue_depth": current_depth, |
| "queue_max": _QUEUE_MAX_SIZE, |
| }), 503 |
|
|
| result_holder: list = [None] |
| done_event = threading.Event() |
|
|
| try: |
| _inference_queue.put_nowait((data, result_holder, done_event)) |
| except _queue_module.Full: |
| return jsonify({ |
| "error": "Server busy β inference queue full. Please try again shortly.", |
| "retry_after": 5, |
| }), 503 |
|
|
| completed = done_event.wait(timeout=_REQUEST_TIMEOUT_S) |
|
|
| if not completed: |
| return jsonify({ |
| "error": f"Request timed out after {_REQUEST_TIMEOUT_S}s. ", |
| "retry_after": 10, |
| }), 503 |
|
|
| result = result_holder[0] |
| if result is None: |
| return jsonify({"error": "Internal error: inference worker returned no result."}), 500 |
|
|
| if "error" in result: |
| return jsonify(result), 500 |
|
|
| return jsonify(result) |
|
|
|
|
| if __name__ == "__main__": |
| import signal, sys |
|
|
| def sigint_handler(sig, frame): |
| sys.exit(0) |
| signal.signal(signal.SIGINT, sigint_handler) |
|
|
| app.run(host=HOST, port=PORT, debug=False, threaded=True) |
|
|