Spaces:
Sleeping
Sleeping
| """ | |
| OpenAI-compatible FastAPI wrapper for Qwen3-14B (GGUF / llama-cpp-python) | |
| Endpoints: GET /v1/models, POST /v1/chat/completions | |
| Supports streaming (SSE) and non-streaming responses. | |
| """ | |
| import os | |
| import time | |
| import uuid | |
| import json | |
| import asyncio | |
| import logging | |
| from typing import AsyncIterator, List, Optional | |
| from fastapi import FastAPI, HTTPException, Request | |
| from fastapi.responses import StreamingResponse, JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel, Field | |
| from llama_cpp import Llama | |
| # --------------------------------------------------------------------------- | |
| # Logging | |
| # --------------------------------------------------------------------------- | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") | |
| logger = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # Config (override via environment variables) | |
| # --------------------------------------------------------------------------- | |
| MODEL_PATH = os.environ.get("MODEL_PATH", "/models/qwen3-14b-q4_k_m.gguf") | |
| MODEL_ID = os.environ.get("MODEL_ID", "qwen3-14b") | |
| N_CTX = int(os.environ.get("N_CTX", "4096")) | |
| N_THREADS = int(os.environ.get("N_THREADS", str(os.cpu_count() or 4))) | |
| N_BATCH = int(os.environ.get("N_BATCH", "512")) | |
| VERBOSE = os.environ.get("VERBOSE", "false").lower() == "true" | |
| # --------------------------------------------------------------------------- | |
| # Load model at startup | |
| # --------------------------------------------------------------------------- | |
| logger.info(f"Loading model from {MODEL_PATH} — this may take a few minutes on CPU …") | |
| llm = Llama( | |
| model_path=MODEL_PATH, | |
| n_ctx=N_CTX, | |
| n_threads=N_THREADS, | |
| n_batch=N_BATCH, | |
| n_gpu_layers=0, # CPU only | |
| verbose=VERBOSE, | |
| chat_format="chatml", # Qwen3 uses ChatML | |
| ) | |
| logger.info("Model loaded ✓") | |
| # --------------------------------------------------------------------------- | |
| # FastAPI app | |
| # --------------------------------------------------------------------------- | |
| app = FastAPI(title="Qwen3-14B OpenAI-compatible API", version="1.0.0") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Pydantic schemas (OpenAI-compatible subset) | |
| # --------------------------------------------------------------------------- | |
| class Message(BaseModel): | |
| role: str | |
| content: str | |
| class ChatCompletionRequest(BaseModel): | |
| model: str = MODEL_ID | |
| messages: List[Message] | |
| max_tokens: Optional[int] = Field(default=1024, ge=1, le=8192) | |
| temperature: Optional[float] = Field(default=0.7, ge=0.0, le=2.0) | |
| top_p: Optional[float] = Field(default=0.9, ge=0.0, le=1.0) | |
| stream: Optional[bool] = False | |
| stop: Optional[List[str]] = None | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def _make_chunk(delta_content: str, finish_reason: Optional[str], request_id: str) -> str: | |
| chunk = { | |
| "id": request_id, | |
| "object": "chat.completion.chunk", | |
| "created": int(time.time()), | |
| "model": MODEL_ID, | |
| "choices": [ | |
| { | |
| "index": 0, | |
| "delta": {"content": delta_content} if delta_content else {}, | |
| "finish_reason": finish_reason, | |
| } | |
| ], | |
| } | |
| return f"data: {json.dumps(chunk)}\n\n" | |
| async def _stream_response(request: ChatCompletionRequest, request_id: str) -> AsyncIterator[str]: | |
| """Run llama-cpp in a thread pool and yield SSE chunks.""" | |
| messages = [{"role": m.role, "content": m.content} for m in request.messages] | |
| loop = asyncio.get_event_loop() | |
| def _run(): | |
| return llm.create_chat_completion( | |
| messages=messages, | |
| max_tokens=request.max_tokens, | |
| temperature=request.temperature, | |
| top_p=request.top_p, | |
| stop=request.stop or [], | |
| stream=True, | |
| ) | |
| # llama-cpp streaming returns a generator; run initial call in thread pool | |
| gen = await loop.run_in_executor(None, _run) | |
| # Yield first role delta | |
| yield _make_chunk("", None, request_id) | |
| for chunk in gen: | |
| choice = chunk["choices"][0] | |
| delta = choice.get("delta", {}) | |
| content = delta.get("content", "") | |
| finish = choice.get("finish_reason") | |
| if content: | |
| yield _make_chunk(content, None, request_id) | |
| if finish: | |
| yield _make_chunk("", finish, request_id) | |
| break | |
| yield "data: [DONE]\n\n" | |
| # --------------------------------------------------------------------------- | |
| # Routes | |
| # --------------------------------------------------------------------------- | |
| async def root(): | |
| return {"status": "ok", "model": MODEL_ID} | |
| async def list_models(): | |
| return { | |
| "object": "list", | |
| "data": [ | |
| { | |
| "id": MODEL_ID, | |
| "object": "model", | |
| "created": 1700000000, | |
| "owned_by": "local", | |
| } | |
| ], | |
| } | |
| async def chat_completions(request: ChatCompletionRequest): | |
| messages = [{"role": m.role, "content": m.content} for m in request.messages] | |
| if request.stream: | |
| request_id = f"chatcmpl-{uuid.uuid4().hex}" | |
| return StreamingResponse( | |
| _stream_response(request, request_id), | |
| media_type="text/event-stream", | |
| headers={ | |
| "Cache-Control": "no-cache", | |
| "X-Accel-Buffering": "no", | |
| }, | |
| ) | |
| # Non-streaming | |
| loop = asyncio.get_event_loop() | |
| def _run(): | |
| return llm.create_chat_completion( | |
| messages=messages, | |
| max_tokens=request.max_tokens, | |
| temperature=request.temperature, | |
| top_p=request.top_p, | |
| stop=request.stop or [], | |
| stream=False, | |
| ) | |
| result = await loop.run_in_executor(None, _run) | |
| choice = result["choices"][0] | |
| usage = result.get("usage", {}) | |
| return { | |
| "id": f"chatcmpl-{uuid.uuid4().hex}", | |
| "object": "chat.completion", | |
| "created": int(time.time()), | |
| "model": MODEL_ID, | |
| "choices": [ | |
| { | |
| "index": 0, | |
| "message": { | |
| "role": "assistant", | |
| "content": choice["message"]["content"], | |
| }, | |
| "finish_reason": choice.get("finish_reason", "stop"), | |
| } | |
| ], | |
| "usage": { | |
| "prompt_tokens": usage.get("prompt_tokens", 0), | |
| "completion_tokens": usage.get("completion_tokens", 0), | |
| "total_tokens": usage.get("total_tokens", 0), | |
| }, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Health check (useful for Docker HEALTHCHECK) | |
| # --------------------------------------------------------------------------- | |
| async def health(): | |
| return {"status": "healthy", "model": MODEL_ID} | |