""" chatPDB inference API — HuggingFace Space (Gradio SDK, ZeroGPU) Exposes a Gradio API endpoint (api_name="generate") consumed by the Flask PTY app on the droplet via Gradio's own REST protocol (POST /call/generate -> event_id, then GET /call/generate/ for an SSE stream) -- NOT a hand-rolled custom FastAPI route. Real fix history (found + fixed 2026-07-23, in order): 1. First attempt added a custom route via `@demo.app.post("/generate")` -- confirmed live via curl that this silently doesn't take effect (405, `allow: GET`); Gradio's own catch-all SPA route claims the path instead. 2. Second attempt used `gr.mount_gradio_app()` to mount Gradio under a FastAPI app that owns the real routes -- this DID fix routing, but broke ZeroGPU entirely: the Space failed to start with "No @spaces.GPU function detected during startup". Confirmed via web research (HF forum threads on this exact error) that ZeroGPU's detection hook requires Gradio's own `demo.launch()` to be the actual serving entrypoint -- a custom-FastAPI-primary architecture is not supported, regardless of whether spaces.GPU is imported/used. 3. This version: `demo.launch()` is the real entrypoint again (restoring ZeroGPU detection), and the /generate contract is exposed via Gradio's own native API mechanism instead of a custom route -- a hidden Textbox pair wired to a `.click()` handler with `api_name="generate"`, which Gradio automatically exposes as `POST /call/generate` + `GET /call/generate/` (SSE). This stays entirely within Gradio's own request-handling pipeline, which is what ZeroGPU's detection actually requires. Cold-start note: the 19.8GB GGUF is downloaded once at container boot (module import, below), NOT inside the @spaces.GPU-decorated function. Real bug found + fixed 2026-07-23: the download was originally triggered lazily from inside _generate_tokens(), which meant it ran *inside* the ZeroGPU lease window and burned GPU-seconds on a plain network transfer -- confirmed via real Space logs showing a live download stuck at 81%/16.0GB after 5m24s wall-clock, well past the `@spaces.GPU(duration=180)` cap, with no further log lines ever written (the lease was silently reclaimed mid-download). Downloading eagerly at import time means the file is already local on the Space's persistent container disk by the time any real request arrives, so the GPU lease only has to cover model load + generation. """ from __future__ import annotations import json import gradio as gr import spaces from huggingface_hub import hf_hub_download REPO_ID = "Dellboy/chatpdb_32b_v1-GGUF" FILENAME = "chatpdb_32b_v1_q4km.gguf" N_CTX = 1536 # matches chatPDB's real training max_seq_length (config/train_config.yaml) N_GPU_LAYERS = -1 # offload all layers to GPU print(f"[chatPDB] downloading {FILENAME} from {REPO_ID} (one-time, outside GPU lease)...") _MODEL_PATH = hf_hub_download(repo_id=REPO_ID, filename=FILENAME) print(f"[chatPDB] model ready at {_MODEL_PATH}") @spaces.GPU(duration=180) def _generate_tokens( prompt: str, max_tokens: int, temperature: float, repeat_penalty: float, ) -> str: """Run inside the ZeroGPU lease; collect all tokens and return as one joined string. ZeroGPU-decorated functions run in a bounded lease and can't hold a live generator open across the function boundary, so tokens are collected here rather than streamed token-by-token -- the client sees compute-then-deliver rather than true live latency, a real trade-off of the ZeroGPU model. Real fix (found + fixed 2026-07-23): the CUDA-built llama-cpp-python wheel failed to load on the real ZeroGPU worker with `OSError: libcudart.so.12: cannot open shared object file` -- confirmed via the Space's actual runtime logs (fetch_space_logs), not guessed. The `nvidia-cuda-runtime-cu12`/`nvidia-cublas-cu12` pip packages bundle the needed .so files (real fix pattern confirmed via github.com/abetlen/llama-cpp-python#1460), but llama-cpp-python loads its library via a raw `ctypes.CDLL()` call that only searches the system's standard library path / LD_LIBRARY_PATH -- it doesn't know to look inside a pip package's install directory (unlike PyTorch's own wheels, which have special-cased loader logic for exactly this). The real subpath is `.__path__[0]/lib/*.so*` (a `lib/` subdirectory, not a Python submodule). Pre-loading every nvidia-*-cu12 package's .so files explicitly via ctypes here, before llama_cpp's own import triggers its CDLL() call, makes it resolve to the already-loaded libraries instead of searching (and failing) on its own -- done generically across every installed `nvidia.*` package rather than hardcoding exact library names, since llama.cpp's CUDA backend may need more than just cudart+cublas depending on the exact build. """ import ctypes import glob import importlib import pkgutil try: import nvidia for _finder, _pkg_name, _ in pkgutil.iter_modules(nvidia.__path__): try: _pkg = importlib.import_module(f"nvidia.{_pkg_name}") for _so in glob.glob(f"{_pkg.__path__[0]}/lib/*.so*"): ctypes.CDLL(_so, mode=ctypes.RTLD_GLOBAL) except Exception: continue except Exception: pass # fall through -- if this fails, llama_cpp's own import error will surface as before from llama_cpp import Llama llm = Llama( model_path=_MODEL_PATH, n_ctx=N_CTX, n_gpu_layers=N_GPU_LAYERS, verbose=False, ) parts: list[str] = [] for chunk in llm( prompt, max_tokens=max_tokens, temperature=temperature, repeat_penalty=repeat_penalty, stream=True, ): tok = chunk["choices"][0]["text"] if tok: parts.append(tok) return "".join(parts) def _generate_api(prompt: str, max_tokens: int, temperature: float, repeat_penalty: float) -> str: """Thin wrapper: chat_remote.py sends max_tokens/temperature/repeat_penalty as a JSON string packed into one field isn't needed -- Gradio's API takes positional args directly, matching the .click() inputs list below.""" return _generate_tokens(prompt, int(max_tokens), float(temperature), float(repeat_penalty)) with gr.Blocks(title="chatPDB API") as demo: gr.Markdown( "## 🧬 chatPDB Inference API\n\n" "Internal endpoint for [chatpdb.mdeller.com](https://chatpdb.mdeller.com). " "Consumed via Gradio's own API: `POST /gradio_api/call/generate` then " "`GET /gradio_api/call/generate/` (SSE).\n\n" "**Cold start:** model download happens once at container boot, outside the GPU " "lease. First real request per GPU lease takes ~10-30 s to load the GGUF onto the GPU." ) # Hidden inputs/outputs purely to register a real Gradio event with api_name="generate" -- # ZeroGPU's startup detection scans Gradio's own event/dependency graph, so the GPU-decorated # function must be reachable through a real .click()/.submit() binding, not just referenced # from a custom route. with gr.Row(visible=False): prompt_in = gr.Textbox() max_tokens_in = gr.Number(value=512) temperature_in = gr.Number(value=0.15) repeat_penalty_in = gr.Number(value=1.15) text_out = gr.Textbox() trigger = gr.Button() trigger.click( fn=_generate_api, inputs=[prompt_in, max_tokens_in, temperature_in, repeat_penalty_in], outputs=text_out, api_name="generate", ) if __name__ == "__main__": demo.launch()