| """ |
| Qwen2.5-Coder-7B on Hugging Face ZeroGPU |
| ----------------------------------------- |
| - Serves an OpenAI-compatible /v1/chat/completions endpoint (+ /v1/models) |
| - Protects the API with a Bearer API key (via HF Space Secret, not hardcoded) |
| - Rate-limits to N requests/minute per API key (default 5) |
| - Ships a small Gradio admin panel (also gated by the same API key) to |
| swap the active model without redeploying |
| |
| Architecture note: ZeroGPU only schedules GPU time for functions that run |
| *inside the Gradio process* (functions decorated with @spaces.GPU). So the |
| FastAPI layer below does NOT do inference itself -- it validates the |
| request, then calls into a plain Python function that is decorated with |
| @spaces.GPU. That's what actually grabs the GPU for the few seconds it |
| needs, then releases it. This is required by ZeroGPU, not a stylistic |
| choice. |
| """ |
|
|
| import os |
| import time |
| import threading |
| import uuid |
| from collections import defaultdict, deque |
| from typing import List, Optional |
|
|
| import gradio as gr |
| import spaces |
| import torch |
| from fastapi import FastAPI, Request, HTTPException |
| from fastapi.responses import JSONResponse |
| from pydantic import BaseModel |
| from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer |
|
|
| |
| |
| |
|
|
| |
| |
| API_KEY = os.environ.get("API_KEY") |
|
|
| |
| RATE_LIMIT_PER_MINUTE = int(os.environ.get("RATE_LIMIT_PER_MINUTE", "5")) |
|
|
| |
| |
| DEFAULT_MODEL_ID = os.environ.get("MODEL_ID", "Qwen/Qwen2.5-Coder-7B-Instruct") |
|
|
| |
| |
| GPU_DURATION_SECONDS = int(os.environ.get("GPU_DURATION_SECONDS", "90")) |
|
|
| if not API_KEY: |
| |
| raise RuntimeError( |
| "API_KEY environment variable is not set. " |
| "Go to your Space's Settings -> Variables and secrets and add a " |
| "secret named API_KEY before this Space will start." |
| ) |
|
|
| |
| |
| |
|
|
| class ModelState: |
| """Holds whatever model/tokenizer is currently loaded, plus a lock so a |
| hot-swap from the admin panel can't race with an in-flight request.""" |
|
|
| def __init__(self, model_id: str): |
| self.lock = threading.Lock() |
| self.model_id = None |
| self.model = None |
| self.tokenizer = None |
| self.load(model_id) |
|
|
| def load(self, model_id: str): |
| with self.lock: |
| print(f"[model] loading {model_id} ...") |
| tokenizer = AutoTokenizer.from_pretrained(model_id) |
| model = AutoModelForCausalLM.from_pretrained( |
| model_id, |
| torch_dtype=torch.bfloat16, |
| device_map=None, |
| ) |
| |
| |
| |
| |
| |
| |
| |
| self.model_id = model_id |
| self.tokenizer = tokenizer |
| self.model = model |
| print(f"[model] loaded {model_id}") |
|
|
| def swap(self, new_model_id: str) -> str: |
| if new_model_id == self.model_id: |
| return f"'{new_model_id}' is already the active model." |
| try: |
| self.load(new_model_id) |
| return f"Switched active model to '{new_model_id}'." |
| except Exception as e: |
| return f"Failed to load '{new_model_id}': {e}" |
|
|
|
|
| state = ModelState(DEFAULT_MODEL_ID) |
|
|
| |
| |
| |
| |
| |
|
|
| _rate_lock = threading.Lock() |
| _request_log: dict[str, deque] = defaultdict(deque) |
|
|
|
|
| def check_rate_limit(key: str): |
| now = time.monotonic() |
| window = 60.0 |
| with _rate_lock: |
| dq = _request_log[key] |
| while dq and now - dq[0] > window: |
| dq.popleft() |
| if len(dq) >= RATE_LIMIT_PER_MINUTE: |
| retry_after = max(0, window - (now - dq[0])) |
| raise HTTPException( |
| status_code=429, |
| detail=f"Rate limit exceeded: {RATE_LIMIT_PER_MINUTE} requests/minute. " |
| f"Retry after {retry_after:.1f}s.", |
| headers={"Retry-After": str(int(retry_after) + 1)}, |
| ) |
| dq.append(now) |
|
|
|
|
| def require_api_key(request: Request): |
| auth = request.headers.get("authorization", "") |
| if not auth.startswith("Bearer "): |
| raise HTTPException(status_code=401, detail="Missing bearer token.") |
| token = auth.removeprefix("Bearer ").strip() |
| if token != API_KEY: |
| raise HTTPException(status_code=401, detail="Invalid API key.") |
| check_rate_limit(token) |
| return token |
|
|
|
|
| |
| |
| |
| |
|
|
| @spaces.GPU(duration=GPU_DURATION_SECONDS) |
| def generate(messages: List[dict], max_new_tokens: int, temperature: float, top_p: float) -> str: |
| model = state.model |
| tokenizer = state.tokenizer |
|
|
| if not model.parameters().__next__().is_cuda: |
| model.to("cuda") |
|
|
| prompt = tokenizer.apply_chat_template( |
| messages, tokenize=False, add_generation_prompt=True |
| ) |
| inputs = tokenizer(prompt, return_tensors="pt").to("cuda") |
|
|
| with torch.no_grad(): |
| output_ids = model.generate( |
| **inputs, |
| max_new_tokens=max_new_tokens, |
| do_sample=temperature > 0, |
| temperature=max(temperature, 1e-5), |
| top_p=top_p, |
| pad_token_id=tokenizer.eos_token_id, |
| ) |
|
|
| new_tokens = output_ids[0][inputs["input_ids"].shape[1]:] |
| text = tokenizer.decode(new_tokens, skip_special_tokens=True) |
|
|
| |
| |
| return text |
|
|
|
|
| |
| |
| |
|
|
| class ChatMessage(BaseModel): |
| role: str |
| content: str |
|
|
|
|
| class ChatCompletionRequest(BaseModel): |
| model: Optional[str] = None |
| messages: List[ChatMessage] |
| max_tokens: Optional[int] = 512 |
| temperature: Optional[float] = 0.7 |
| top_p: Optional[float] = 0.9 |
| stream: Optional[bool] = False |
|
|
|
|
| |
| |
| |
|
|
| api = FastAPI(title="Qwen2.5-Coder ZeroGPU API") |
|
|
|
|
| @api.get("/v1/models") |
| def list_models(_=None): |
| return { |
| "object": "list", |
| "data": [{"id": state.model_id, "object": "model", "owned_by": "you"}], |
| } |
|
|
|
|
| @api.post("/v1/chat/completions") |
| def chat_completions(payload: ChatCompletionRequest, request: Request): |
| require_api_key(request) |
|
|
| if payload.stream: |
| raise HTTPException( |
| status_code=400, |
| detail="stream=true is not implemented on this endpoint yet. " |
| "Set stream=false in your OpenAI SDK call.", |
| ) |
|
|
| messages = [m.model_dump() for m in payload.messages] |
|
|
| try: |
| text = generate( |
| messages=messages, |
| max_new_tokens=payload.max_tokens or 512, |
| temperature=payload.temperature if payload.temperature is not None else 0.7, |
| top_p=payload.top_p or 0.9, |
| ) |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=f"Generation failed: {e}") |
|
|
| now = int(time.time()) |
| return JSONResponse({ |
| "id": f"chatcmpl-{uuid.uuid4().hex[:24]}", |
| "object": "chat.completion", |
| "created": now, |
| "model": state.model_id, |
| "choices": [{ |
| "index": 0, |
| "message": {"role": "assistant", "content": text}, |
| "finish_reason": "stop", |
| }], |
| |
| |
| |
| "usage": {"prompt_tokens": -1, "completion_tokens": -1, "total_tokens": -1}, |
| }) |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| CANDIDATE_MODELS = [ |
| "Qwen/Qwen2.5-Coder-7B-Instruct", |
| "Qwen/Qwen2.5-Coder-1.5B-Instruct", |
| "Qwen/Qwen2.5-Coder-3B-Instruct", |
| "Qwen/Qwen2.5-Coder-14B-Instruct", |
| ] |
|
|
|
|
| def try_unlock(key: str): |
| if key == API_KEY: |
| return ( |
| gr.update(visible=True), |
| gr.update(visible=False), |
| gr.update(value=f"Current model: {state.model_id}"), |
| ) |
| return ( |
| gr.update(visible=False), |
| gr.update(visible=True), |
| gr.update(), |
| ) |
|
|
|
|
| def do_swap(model_choice: str, custom_model_id: str): |
| target = custom_model_id.strip() or model_choice |
| msg = state.swap(target) |
| return msg, f"Current model: {state.model_id}" |
|
|
|
|
| with gr.Blocks(title="Qwen2.5-Coder ZeroGPU API") as demo: |
| gr.Markdown( |
| "# Qwen2.5-Coder — ZeroGPU API\n" |
| "This Space exposes an OpenAI-compatible API at `/v1/chat/completions`.\n" |
| "It requires a Bearer API key (set as the `API_KEY` Space secret).\n\n" |
| "The panel below is for the Space owner only, to switch the active model." |
| ) |
|
|
| with gr.Group(visible=True) as login_panel: |
| gr.Markdown("### Admin login") |
| key_box = gr.Textbox(label="API key", type="password") |
| login_btn = gr.Button("Unlock") |
|
|
| with gr.Group(visible=False) as admin_panel: |
| gr.Markdown("### Admin panel") |
| status_box = gr.Markdown() |
| model_dropdown = gr.Dropdown( |
| choices=CANDIDATE_MODELS, label="Switch to a known model", value=None |
| ) |
| custom_model_box = gr.Textbox( |
| label="...or enter any Hugging Face model ID (overrides dropdown)", |
| placeholder="e.g. Qwen/Qwen2.5-Coder-32B-Instruct", |
| ) |
| swap_btn = gr.Button("Load this model", variant="primary") |
| swap_result = gr.Markdown() |
|
|
| login_btn.click( |
| try_unlock, |
| inputs=[key_box], |
| outputs=[admin_panel, login_panel, status_box], |
| ) |
| swap_btn.click( |
| do_swap, |
| inputs=[model_dropdown, custom_model_box], |
| outputs=[swap_result, status_box], |
| ) |
|
|
| |
| |
| |
| |
| |
| app = gr.mount_gradio_app(api, demo, path="/admin") |
|
|
| if __name__ == "__main__": |
| import uvicorn |
| uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860))) |
|
|