Spaces:
Running on Zero
Running on Zero
| import json | |
| import os | |
| import subprocess | |
| import time | |
| import uuid | |
| from pathlib import Path | |
| from threading import Lock, Thread | |
| from typing import Any, Dict, Iterable, List | |
| os.environ.setdefault("HF_HOME", "/tmp/hf_home") | |
| os.environ.setdefault("HF_MODULES_CACHE", "/tmp/hf_modules") | |
| os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib") | |
| os.environ.setdefault("GRADIO_SSR_MODE", "false") | |
| for _path in (os.environ["HF_HOME"], os.environ["HF_MODULES_CACHE"], os.environ["MPLCONFIGDIR"]): | |
| os.makedirs(_path, exist_ok=True) | |
| import spaces # noqa: E402 | |
| import httpx # noqa: E402 | |
| import gradio as gr # noqa: E402 | |
| from fastapi import Request # noqa: E402 | |
| from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse # noqa: E402 | |
| from starlette.background import BackgroundTask # noqa: E402 | |
| from huggingface_hub import snapshot_download # noqa: E402 | |
| from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer # noqa: E402 | |
| import torch # noqa: E402 | |
| MODEL_ID = "WeiboAI/VibeThinker-3B" | |
| MODEL_REVISION = "main" | |
| DEFAULT_MAX_NEW_TOKENS = 4096 | |
| MAX_NEW_TOKENS = 4096 | |
| ROOT_DIR = Path(__file__).resolve().parent | |
| CHAT_UI_DIR = ROOT_DIR / "chat-ui" | |
| CHAT_UI_PORT = int(os.environ.get("CHAT_UI_PORT", "3000")) | |
| CHAT_UI_URL = f"http://127.0.0.1:{CHAT_UI_PORT}" | |
| CHAT_UI_BUILD = CHAT_UI_DIR / "build" / "index.js" | |
| CHAT_UI_DB = Path(os.environ.get("CHAT_UI_DB", "/tmp/vibethinker-chat-ui-db")) | |
| _model_lock = Lock() | |
| _model = None | |
| _tokenizer = None | |
| _model_device = "cpu" | |
| _chat_ui_lock = Lock() | |
| _chat_ui_process: subprocess.Popen | None = None | |
| def _download_model() -> None: | |
| print(f"Downloading {MODEL_ID} to the local Hub cache...", flush=True) | |
| snapshot_download( | |
| repo_id=MODEL_ID, | |
| revision=MODEL_REVISION, | |
| ignore_patterns=["*.msgpack", "*.h5", "*.ot", "*.onnx"], | |
| ) | |
| print("Model files are present in the local Hub cache.", flush=True) | |
| def _load_model_cpu() -> None: | |
| global _model, _tokenizer | |
| if _model is not None and _tokenizer is not None: | |
| return | |
| with _model_lock: | |
| if _model is not None and _tokenizer is not None: | |
| return | |
| _download_model() | |
| print("Loading tokenizer...", flush=True) | |
| _tokenizer = AutoTokenizer.from_pretrained( | |
| MODEL_ID, | |
| revision=MODEL_REVISION, | |
| trust_remote_code=True, | |
| ) | |
| if _tokenizer.pad_token_id is None and _tokenizer.eos_token_id is not None: | |
| _tokenizer.pad_token = _tokenizer.eos_token | |
| print("Loading model on CPU...", flush=True) | |
| _model = AutoModelForCausalLM.from_pretrained( | |
| MODEL_ID, | |
| revision=MODEL_REVISION, | |
| torch_dtype=torch.bfloat16, | |
| low_cpu_mem_usage=True, | |
| trust_remote_code=True, | |
| attn_implementation="sdpa", | |
| ).eval() | |
| print("Model loaded on CPU.", flush=True) | |
| def _ensure_model_on_cuda() -> None: | |
| global _model_device | |
| _load_model_cpu() | |
| if _model_device == "cuda": | |
| return | |
| if not torch.cuda.is_available(): | |
| raise RuntimeError("CUDA is not available. Confirm the Space is running on zero-a10g.") | |
| print("Moving model to CUDA...", flush=True) | |
| _model.to("cuda") | |
| _model_device = "cuda" | |
| print("Model is ready on CUDA.", flush=True) | |
| def _clean_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, str]]: | |
| cleaned: List[Dict[str, str]] = [] | |
| for item in messages: | |
| if not isinstance(item, dict): | |
| continue | |
| role = str(item.get("role", "")).strip() | |
| content = str(item.get("content", "")).strip() | |
| if role in {"system", "user", "assistant"} and content: | |
| cleaned.append({"role": role, "content": content}) | |
| return cleaned | |
| def _messages_from_json(history_json: str) -> List[Dict[str, str]]: | |
| try: | |
| raw = json.loads(history_json) | |
| except json.JSONDecodeError as exc: | |
| raise ValueError("history_json must be valid JSON") from exc | |
| if not isinstance(raw, list): | |
| raise ValueError("history_json must encode a list of chat messages") | |
| messages = _clean_messages(raw) | |
| if not messages or messages[-1]["role"] != "user": | |
| raise ValueError("The final message must be a user message") | |
| return messages | |
| def _coerce_generation_args( | |
| max_new_tokens: int, | |
| temperature: float, | |
| top_p: float, | |
| repetition_penalty: float, | |
| ) -> Dict[str, Any]: | |
| max_new_tokens = max(1, min(int(max_new_tokens or DEFAULT_MAX_NEW_TOKENS), MAX_NEW_TOKENS)) | |
| temperature = max(0.0, min(float(temperature), 2.0)) | |
| top_p = max(0.05, min(float(top_p), 1.0)) | |
| repetition_penalty = max(0.8, min(float(repetition_penalty), 1.5)) | |
| return { | |
| "max_new_tokens": max_new_tokens, | |
| "do_sample": temperature > 0, | |
| "temperature": max(temperature, 1e-5), | |
| "top_p": top_p, | |
| "repetition_penalty": repetition_penalty, | |
| "pad_token_id": _tokenizer.pad_token_id, | |
| "eos_token_id": _tokenizer.eos_token_id, | |
| } | |
| def _format_prompt(messages: List[Dict[str, str]]) -> str: | |
| return _tokenizer.apply_chat_template( | |
| messages, | |
| tokenize=False, | |
| add_generation_prompt=True, | |
| ) | |
| def _estimate_duration( | |
| history_json: str, | |
| max_new_tokens: int, | |
| temperature: float, | |
| top_p: float, | |
| repetition_penalty: float, | |
| *args: Any, | |
| **kwargs: Any, | |
| ) -> int: | |
| del history_json, temperature, top_p, repetition_penalty, args, kwargs | |
| return min(240, max(60, 40 + int(max_new_tokens or DEFAULT_MAX_NEW_TOKENS) // 12)) | |
| def _zerogpu_probe() -> str: | |
| return "ready" | |
| def _generate_stream( | |
| messages: List[Dict[str, str]], | |
| max_new_tokens: int, | |
| temperature: float, | |
| top_p: float, | |
| repetition_penalty: float, | |
| ) -> Iterable[str]: | |
| _ensure_model_on_cuda() | |
| prompt = _format_prompt(messages) | |
| inputs = _tokenizer([prompt], return_tensors="pt").to(_model.device) | |
| generation_args = _coerce_generation_args( | |
| max_new_tokens=max_new_tokens, | |
| temperature=temperature, | |
| top_p=top_p, | |
| repetition_penalty=repetition_penalty, | |
| ) | |
| streamer = TextIteratorStreamer( | |
| _tokenizer, | |
| skip_prompt=True, | |
| skip_special_tokens=True, | |
| timeout=180, | |
| ) | |
| generation_kwargs = { | |
| **inputs, | |
| **generation_args, | |
| "streamer": streamer, | |
| } | |
| worker = Thread(target=_model.generate, kwargs=generation_kwargs, daemon=True) | |
| worker.start() | |
| partial = "" | |
| for token in streamer: | |
| partial += token | |
| yield partial | |
| worker.join(timeout=1) | |
| def _gpu_chat_stream( | |
| history_json: str, | |
| max_new_tokens: int, | |
| temperature: float, | |
| top_p: float, | |
| repetition_penalty: float, | |
| ) -> Iterable[str]: | |
| messages = _messages_from_json(history_json) | |
| yield from _generate_stream( | |
| messages=messages, | |
| max_new_tokens=max_new_tokens, | |
| temperature=temperature, | |
| top_p=top_p, | |
| repetition_penalty=repetition_penalty, | |
| ) | |
| app = gr.Server() | |
| def _chat_ui_model_config() -> str: | |
| return json.dumps( | |
| [ | |
| { | |
| "id": MODEL_ID, | |
| "name": MODEL_ID, | |
| "displayName": "VibeThinker-3B", | |
| "description": "Reasoning-focused 3B model hosted on ZeroGPU.", | |
| "modelUrl": f"https://huggingface.co/{MODEL_ID}", | |
| "parameters": { | |
| "temperature": 1.0, | |
| "top_p": 0.95, | |
| "max_tokens": DEFAULT_MAX_NEW_TOKENS, | |
| }, | |
| "supportsReasoning": True, | |
| } | |
| ], | |
| separators=(",", ":"), | |
| ) | |
| def _chat_ui_env() -> Dict[str, str]: | |
| env = os.environ.copy() | |
| env.update( | |
| { | |
| "OPENAI_BASE_URL": "http://127.0.0.1:7860/v1", | |
| "OPENAI_API_KEY": "sk-local", | |
| "USE_USER_TOKEN": "false", | |
| "AUTOMATIC_LOGIN": "false", | |
| "ALLOW_IFRAME": "true", | |
| "PUBLIC_APP_NAME": "VibeThinker", | |
| "PUBLIC_APP_ASSETS": "chatui", | |
| "PUBLIC_APP_DESCRIPTION": "VibeThinker-3B hosted on ZeroGPU.", | |
| "PUBLIC_ORIGIN": os.environ.get( | |
| "PUBLIC_ORIGIN", | |
| "https://mike0021-vibethinker-3b-zerogpu.hf.space", | |
| ), | |
| "MONGO_STORAGE_PATH": str(CHAT_UI_DB), | |
| "MONGODB_DB_NAME": "chat-ui", | |
| "MONGODB_DIRECT_CONNECTION": "false", | |
| "COOKIE_NAME": "vibethinker-chat-session", | |
| "HUSKY": "0", | |
| "ENABLE_CONFIG_MANAGER": "false", | |
| "LLM_SUMMARIZATION": "false", | |
| "TASK_MODEL": MODEL_ID, | |
| "MODELS": _chat_ui_model_config(), | |
| "PORT": str(CHAT_UI_PORT), | |
| "HOST": "127.0.0.1", | |
| "BODY_SIZE_LIMIT": os.environ.get("BODY_SIZE_LIMIT", "15728640"), | |
| } | |
| ) | |
| return env | |
| def _run_chat_ui_command(command: List[str], env: Dict[str, str]) -> None: | |
| print(f"[chat-ui] running: {' '.join(command)}", flush=True) | |
| subprocess.run(command, cwd=CHAT_UI_DIR, env=env, check=True) | |
| def _ensure_chat_ui_build(env: Dict[str, str]) -> None: | |
| if not CHAT_UI_DIR.exists(): | |
| raise RuntimeError("chat-ui source directory is missing from the Space.") | |
| node_modules = CHAT_UI_DIR / "node_modules" | |
| if not node_modules.exists(): | |
| _run_chat_ui_command(["npm", "ci"], env) | |
| if not CHAT_UI_BUILD.exists(): | |
| _run_chat_ui_command(["npm", "run", "build"], env) | |
| def _chat_ui_is_ready() -> bool: | |
| try: | |
| with httpx.Client(timeout=2.0) as client: | |
| response = client.get(f"{CHAT_UI_URL}/healthcheck") | |
| return response.status_code < 500 | |
| except Exception: | |
| return False | |
| def _start_chat_ui() -> None: | |
| global _chat_ui_process | |
| with _chat_ui_lock: | |
| if _chat_ui_process is not None and _chat_ui_process.poll() is None: | |
| return | |
| env = _chat_ui_env() | |
| _ensure_chat_ui_build(env) | |
| print(f"[chat-ui] starting on {CHAT_UI_URL}", flush=True) | |
| _chat_ui_process = subprocess.Popen( | |
| [ | |
| "node", | |
| "--dns-result-order=ipv4first", | |
| str(CHAT_UI_BUILD), | |
| "--", | |
| "--host", | |
| "127.0.0.1", | |
| "--port", | |
| str(CHAT_UI_PORT), | |
| ], | |
| cwd=CHAT_UI_DIR, | |
| env=env, | |
| ) | |
| def _start_chat_ui_background() -> None: | |
| try: | |
| time.sleep(3) | |
| _start_chat_ui() | |
| except Exception as exc: | |
| print(f"[chat-ui] failed to start: {exc}", flush=True) | |
| async def _wait_for_chat_ui(timeout: float = 240.0) -> None: | |
| if _chat_ui_process is None or _chat_ui_process.poll() is not None: | |
| Thread(target=_start_chat_ui_background, daemon=True).start() | |
| deadline = time.time() + timeout | |
| while time.time() < deadline: | |
| if _chat_ui_is_ready(): | |
| return | |
| await anyio_sleep(1.0) | |
| raise RuntimeError("chat-ui did not become ready before the timeout.") | |
| async def anyio_sleep(seconds: float) -> None: | |
| import anyio | |
| await anyio.sleep(seconds) | |
| async def start_chat_ui_on_startup() -> None: | |
| Thread(target=_start_chat_ui_background, daemon=True).start() | |
| def zerogpu_probe() -> str: | |
| return _zerogpu_probe() | |
| def chat( | |
| history_json: str, | |
| max_new_tokens: int = DEFAULT_MAX_NEW_TOKENS, | |
| temperature: float = 1.0, | |
| top_p: float = 0.95, | |
| repetition_penalty: float = 1.0, | |
| ) -> Iterable[str]: | |
| yield from _gpu_chat_stream( | |
| history_json, | |
| max_new_tokens, | |
| temperature, | |
| top_p, | |
| repetition_penalty, | |
| ) | |
| async def health() -> Dict[str, str]: | |
| return {"status": "ok", "model": MODEL_ID} | |
| async def openai_models() -> Dict[str, Any]: | |
| created = int(time.time()) | |
| return { | |
| "object": "list", | |
| "data": [ | |
| { | |
| "id": MODEL_ID, | |
| "object": "model", | |
| "created": created, | |
| "owned_by": "WeiboAI", | |
| } | |
| ], | |
| } | |
| def _messages_for_openai(payload: Dict[str, Any]) -> List[Dict[str, str]]: | |
| raw_messages = payload.get("messages") | |
| if not isinstance(raw_messages, list): | |
| raise ValueError("messages must be a list") | |
| return _clean_messages(raw_messages) | |
| async def openai_chat_completions(request: Request): | |
| try: | |
| payload = await request.json() | |
| messages = _messages_for_openai(payload) | |
| if not messages: | |
| raise ValueError("messages cannot be empty") | |
| max_new_tokens = int(payload.get("max_tokens") or payload.get("max_new_tokens") or DEFAULT_MAX_NEW_TOKENS) | |
| temperature = float(payload.get("temperature", 1.0)) | |
| top_p = float(payload.get("top_p", 0.95)) | |
| repetition_penalty = float(payload.get("repetition_penalty", 1.0)) | |
| stream = bool(payload.get("stream", False)) | |
| except Exception as exc: | |
| return JSONResponse({"error": {"message": str(exc), "type": "invalid_request_error"}}, status_code=400) | |
| completion_id = f"chatcmpl-{uuid.uuid4().hex}" | |
| created = int(time.time()) | |
| if stream: | |
| def events() -> Iterable[bytes]: | |
| last_text = "" | |
| for text in _gpu_chat_stream( | |
| json.dumps(messages), | |
| max_new_tokens, | |
| temperature, | |
| top_p, | |
| repetition_penalty, | |
| ): | |
| delta = text[len(last_text) :] | |
| last_text = text | |
| chunk = { | |
| "id": completion_id, | |
| "object": "chat.completion.chunk", | |
| "created": created, | |
| "model": MODEL_ID, | |
| "choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}], | |
| } | |
| yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode("utf-8") | |
| final = { | |
| "id": completion_id, | |
| "object": "chat.completion.chunk", | |
| "created": created, | |
| "model": MODEL_ID, | |
| "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], | |
| } | |
| yield f"data: {json.dumps(final, ensure_ascii=False)}\n\n".encode("utf-8") | |
| yield b"data: [DONE]\n\n" | |
| return StreamingResponse(events(), media_type="text/event-stream") | |
| final_text = "" | |
| for final_text in _gpu_chat_stream( | |
| json.dumps(messages), | |
| max_new_tokens, | |
| temperature, | |
| top_p, | |
| repetition_penalty, | |
| ): | |
| pass | |
| return JSONResponse( | |
| { | |
| "id": completion_id, | |
| "object": "chat.completion", | |
| "created": created, | |
| "model": MODEL_ID, | |
| "choices": [ | |
| { | |
| "index": 0, | |
| "message": {"role": "assistant", "content": final_text}, | |
| "finish_reason": "stop", | |
| } | |
| ], | |
| } | |
| ) | |
| async def gradio_startup_events() -> Dict[str, str]: | |
| return {"status": "ok"} | |
| HOP_BY_HOP_HEADERS = { | |
| "connection", | |
| "keep-alive", | |
| "proxy-authenticate", | |
| "proxy-authorization", | |
| "te", | |
| "trailers", | |
| "transfer-encoding", | |
| "upgrade", | |
| } | |
| async def _close_upstream(response: httpx.Response, client: httpx.AsyncClient) -> None: | |
| await response.aclose() | |
| await client.aclose() | |
| async def proxy_chat_ui(path: str, request: Request): | |
| host = request.headers.get("host", "") | |
| if path == "" and host.startswith(("localhost:", "127.0.0.1:", "0.0.0.0:")) and not _chat_ui_is_ready(): | |
| return PlainTextResponse("chat-ui is starting", status_code=200) | |
| try: | |
| await _wait_for_chat_ui() | |
| except Exception as exc: | |
| return PlainTextResponse(f"chat-ui is starting or failed to start: {exc}", status_code=503) | |
| target_url = f"{CHAT_UI_URL}/{path}" | |
| if request.url.query: | |
| target_url = f"{target_url}?{request.url.query}" | |
| excluded_request_headers = HOP_BY_HOP_HEADERS | {"host", "content-length"} | |
| headers = { | |
| key: value | |
| for key, value in request.headers.items() | |
| if key.lower() not in excluded_request_headers | |
| } | |
| headers["x-forwarded-host"] = request.headers.get("host", "") | |
| headers["x-forwarded-proto"] = request.url.scheme | |
| client = httpx.AsyncClient(timeout=None, follow_redirects=False) | |
| try: | |
| upstream_request = client.build_request( | |
| request.method, | |
| target_url, | |
| headers=headers, | |
| content=await request.body(), | |
| ) | |
| upstream_response = await client.send(upstream_request, stream=True) | |
| except Exception as exc: | |
| await client.aclose() | |
| return PlainTextResponse(f"chat-ui proxy error: {exc}", status_code=502) | |
| excluded_response_headers = HOP_BY_HOP_HEADERS | {"content-length"} | |
| response_headers = { | |
| key: value | |
| for key, value in upstream_response.headers.items() | |
| if key.lower() not in excluded_response_headers | |
| } | |
| return StreamingResponse( | |
| upstream_response.aiter_raw(), | |
| status_code=upstream_response.status_code, | |
| headers=response_headers, | |
| background=BackgroundTask(_close_upstream, upstream_response, client), | |
| ) | |
| demo = app | |
| if __name__ == "__main__": | |
| app.launch() | |