Spaces:
Sleeping
Sleeping
Commit ·
9ca1dd6
1
Parent(s): aa607c1
Switch to GGUF Q4_K_M inference with llama-cpp-python
Browse filesReplace PyTorch FP32 (~10GB) + PEFT LoRA loading with quantized
GGUF model (~1.5GB) served via llama-cpp-python. This drops
first-token latency from >25s to ~1-2s on CPU and boosts
throughput to ~10-20 tok/s.
- inference.py: swap torch/peft/transformers for llama-cpp-python
- server.py: remove threading, use sync stream_recipe() iterator
- Dockerfile: python:3.11-slim + llama-cpp-python (no CUDA image)
- Add convert_to_gguf.py for reproducible model conversion
- GGUF model hosted at ClaireLee2429/gemma-2b-recipes-gguf
Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Dockerfile +6 -9
- README.md +1 -0
- convert_to_gguf.py +41 -0
- inference.py +58 -78
- server.py +30 -55
Dockerfile
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
-
FROM
|
| 2 |
|
| 3 |
ENV DEBIAN_FRONTEND=noninteractive
|
| 4 |
|
|
|
|
| 5 |
RUN apt-get update && \
|
| 6 |
-
apt-get install -y --no-install-recommends git && \
|
| 7 |
rm -rf /var/lib/apt/lists/*
|
| 8 |
|
| 9 |
# Non-root user required by HuggingFace Spaces
|
|
@@ -17,11 +18,9 @@ ENV MKL_NUM_THREADS=8
|
|
| 17 |
|
| 18 |
WORKDIR /home/user/app
|
| 19 |
|
| 20 |
-
# Install
|
| 21 |
RUN pip install --no-cache-dir \
|
| 22 |
-
|
| 23 |
-
peft \
|
| 24 |
-
accelerate \
|
| 25 |
fastapi \
|
| 26 |
"uvicorn[standard]" \
|
| 27 |
sse-starlette \
|
|
@@ -33,6 +32,4 @@ COPY --chown=user:user inference.py .
|
|
| 33 |
|
| 34 |
EXPOSE 7860
|
| 35 |
|
| 36 |
-
CMD ["python", "server.py",
|
| 37 |
-
"--adapter", "ClaireLee2429/gemma-2b-recipes-lora", \
|
| 38 |
-
"--port", "7860"]
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
|
| 3 |
ENV DEBIAN_FRONTEND=noninteractive
|
| 4 |
|
| 5 |
+
# Build tools for llama-cpp-python compilation + git for HF Spaces
|
| 6 |
RUN apt-get update && \
|
| 7 |
+
apt-get install -y --no-install-recommends build-essential cmake git && \
|
| 8 |
rm -rf /var/lib/apt/lists/*
|
| 9 |
|
| 10 |
# Non-root user required by HuggingFace Spaces
|
|
|
|
| 18 |
|
| 19 |
WORKDIR /home/user/app
|
| 20 |
|
| 21 |
+
# Install Python dependencies (no torch needed!)
|
| 22 |
RUN pip install --no-cache-dir \
|
| 23 |
+
llama-cpp-python \
|
|
|
|
|
|
|
| 24 |
fastapi \
|
| 25 |
"uvicorn[standard]" \
|
| 26 |
sse-starlette \
|
|
|
|
| 32 |
|
| 33 |
EXPOSE 7860
|
| 34 |
|
| 35 |
+
CMD ["python", "server.py", "--port", "7860"]
|
|
|
|
|
|
README.md
CHANGED
|
@@ -5,6 +5,7 @@ colorFrom: yellow
|
|
| 5 |
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
# recipe-lm
|
|
|
|
| 5 |
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
startup_duration_timeout: 300
|
| 9 |
---
|
| 10 |
|
| 11 |
# recipe-lm
|
convert_to_gguf.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
One-time script: merge LoRA adapter into base Gemma-2B and save the
|
| 3 |
+
merged model so it can be converted to GGUF with llama.cpp tooling.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
pip install torch transformers peft
|
| 7 |
+
python convert_to_gguf.py
|
| 8 |
+
|
| 9 |
+
Then, with llama.cpp built locally:
|
| 10 |
+
python llama.cpp/convert_hf_to_gguf.py ./merged_model --outtype f16 --outfile model.f16.gguf
|
| 11 |
+
./llama.cpp/build/bin/llama-quantize model.f16.gguf model.q4_k_m.gguf Q4_K_M
|
| 12 |
+
|
| 13 |
+
Finally, upload:
|
| 14 |
+
huggingface-cli upload ClaireLee2429/gemma-2b-recipes-gguf model.q4_k_m.gguf
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import torch
|
| 18 |
+
from peft import PeftModel
|
| 19 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 20 |
+
|
| 21 |
+
BASE_MODEL = "google/gemma-2b"
|
| 22 |
+
ADAPTER = "ClaireLee2429/gemma-2b-recipes-lora"
|
| 23 |
+
OUTPUT_DIR = "./merged_model"
|
| 24 |
+
|
| 25 |
+
print("Loading base model...")
|
| 26 |
+
base = AutoModelForCausalLM.from_pretrained(BASE_MODEL, torch_dtype=torch.float16)
|
| 27 |
+
|
| 28 |
+
print(f"Loading LoRA adapter from {ADAPTER}...")
|
| 29 |
+
model = PeftModel.from_pretrained(base, ADAPTER)
|
| 30 |
+
|
| 31 |
+
print("Merging adapter weights into base model...")
|
| 32 |
+
model = model.merge_and_unload()
|
| 33 |
+
|
| 34 |
+
print(f"Saving merged model to {OUTPUT_DIR}...")
|
| 35 |
+
model.save_pretrained(OUTPUT_DIR)
|
| 36 |
+
|
| 37 |
+
tokenizer = AutoTokenizer.from_pretrained(ADAPTER)
|
| 38 |
+
tokenizer.save_pretrained(OUTPUT_DIR)
|
| 39 |
+
|
| 40 |
+
print(f"Done. Merged model saved to {OUTPUT_DIR}/")
|
| 41 |
+
print("Next steps: see docstring for llama.cpp conversion commands.")
|
inference.py
CHANGED
|
@@ -1,21 +1,21 @@
|
|
| 1 |
"""
|
| 2 |
-
Standalone inference script for the fine-tuned recipe generation model.
|
| 3 |
|
| 4 |
Usage:
|
| 5 |
python inference.py --prompt "Recipe for chocolate chip cookies:"
|
| 6 |
python inference.py --prompt "Recipe for pasta carbonara:" --save output.txt
|
| 7 |
python inference.py --prompt "Recipe for banana bread:" --raw
|
| 8 |
-
python inference.py --adapter ClaireLee2429/gemma-2b-recipes-lora --prompt "Recipe for soup:"
|
| 9 |
-
python inference.py --no-adapter --prompt "Recipe for Thai green curry:"
|
| 10 |
"""
|
| 11 |
|
| 12 |
import argparse
|
| 13 |
import os
|
| 14 |
import re
|
| 15 |
|
| 16 |
-
import
|
| 17 |
-
from
|
| 18 |
-
|
|
|
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
def clean_recipe(text: str) -> str:
|
|
@@ -157,69 +157,62 @@ def parse_ingredients(text: str) -> list[dict]:
|
|
| 157 |
return ingredients
|
| 158 |
|
| 159 |
|
| 160 |
-
def load_model(
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
if
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
torch.set_num_interop_threads(num_threads)
|
| 176 |
-
|
| 177 |
-
device_name = "CUDA" if use_cuda else ("MPS" if use_mps else "CPU")
|
| 178 |
-
print(f"Loading base model ({device_name})...")
|
| 179 |
-
|
| 180 |
-
base_model = AutoModelForCausalLM.from_pretrained(
|
| 181 |
-
model_name, torch_dtype=dtype, device_map=device_map
|
| 182 |
)
|
|
|
|
|
|
|
| 183 |
|
| 184 |
-
if no_adapter:
|
| 185 |
-
print("Running base model without adapter")
|
| 186 |
-
model = base_model
|
| 187 |
-
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 188 |
-
else:
|
| 189 |
-
print(f"Loading LoRA adapter from {adapter_path}...")
|
| 190 |
-
model = PeftModel.from_pretrained(base_model, adapter_path)
|
| 191 |
-
tokenizer = AutoTokenizer.from_pretrained(adapter_path)
|
| 192 |
-
|
| 193 |
-
model.eval()
|
| 194 |
|
| 195 |
-
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
|
| 199 |
def generate_recipe(
|
| 200 |
-
|
| 201 |
-
tokenizer,
|
| 202 |
-
device: str,
|
| 203 |
prompt: str,
|
| 204 |
-
|
| 205 |
temperature: float = 0.7,
|
| 206 |
raw: bool = False,
|
| 207 |
) -> str:
|
| 208 |
-
"""Generate a recipe
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
do_sample=True,
|
| 218 |
-
repetition_penalty=1.2,
|
| 219 |
-
)
|
| 220 |
-
|
| 221 |
-
text = tokenizer.decode(outputs[0], skip_special_tokens=True)
|
| 222 |
-
|
| 223 |
if raw:
|
| 224 |
return text
|
| 225 |
return clean_recipe(text)
|
|
@@ -234,16 +227,10 @@ def main():
|
|
| 234 |
help="Prompt for recipe generation",
|
| 235 |
)
|
| 236 |
parser.add_argument(
|
| 237 |
-
"--
|
| 238 |
-
type=str,
|
| 239 |
-
default="./processed_data/lora_adapter",
|
| 240 |
-
help="Path to LoRA adapter (local or HuggingFace Hub ID)",
|
| 241 |
-
)
|
| 242 |
-
parser.add_argument(
|
| 243 |
-
"--model",
|
| 244 |
type=str,
|
| 245 |
-
default=
|
| 246 |
-
help="
|
| 247 |
)
|
| 248 |
parser.add_argument(
|
| 249 |
"--max-tokens",
|
|
@@ -257,11 +244,6 @@ def main():
|
|
| 257 |
default=0.7,
|
| 258 |
help="Sampling temperature",
|
| 259 |
)
|
| 260 |
-
parser.add_argument(
|
| 261 |
-
"--no-adapter",
|
| 262 |
-
action="store_true",
|
| 263 |
-
help="Run the base model without a LoRA adapter",
|
| 264 |
-
)
|
| 265 |
parser.add_argument(
|
| 266 |
"--raw",
|
| 267 |
action="store_true",
|
|
@@ -279,17 +261,15 @@ def main():
|
|
| 279 |
# Ensure prompt ends with newline
|
| 280 |
prompt = args.prompt if args.prompt.endswith("\n") else args.prompt + "\n"
|
| 281 |
|
| 282 |
-
|
| 283 |
|
| 284 |
print(f"\nPrompt: {prompt.strip()}")
|
| 285 |
print("-" * 40)
|
| 286 |
|
| 287 |
result = generate_recipe(
|
| 288 |
-
|
| 289 |
-
tokenizer,
|
| 290 |
-
device,
|
| 291 |
prompt,
|
| 292 |
-
|
| 293 |
temperature=args.temperature,
|
| 294 |
raw=args.raw,
|
| 295 |
)
|
|
|
|
| 1 |
"""
|
| 2 |
+
Standalone inference script for the fine-tuned recipe generation model (GGUF).
|
| 3 |
|
| 4 |
Usage:
|
| 5 |
python inference.py --prompt "Recipe for chocolate chip cookies:"
|
| 6 |
python inference.py --prompt "Recipe for pasta carbonara:" --save output.txt
|
| 7 |
python inference.py --prompt "Recipe for banana bread:" --raw
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import argparse
|
| 11 |
import os
|
| 12 |
import re
|
| 13 |
|
| 14 |
+
from huggingface_hub import hf_hub_download
|
| 15 |
+
from llama_cpp import Llama
|
| 16 |
+
|
| 17 |
+
GGUF_REPO = os.environ.get("GGUF_REPO", "ClaireLee2429/gemma-2b-recipes-gguf")
|
| 18 |
+
GGUF_FILE = os.environ.get("GGUF_FILE", "model.q4_k_m.gguf")
|
| 19 |
|
| 20 |
|
| 21 |
def clean_recipe(text: str) -> str:
|
|
|
|
| 157 |
return ingredients
|
| 158 |
|
| 159 |
|
| 160 |
+
def load_model(
|
| 161 |
+
n_threads: int = 8,
|
| 162 |
+
n_ctx: int = 2048,
|
| 163 |
+
model_path: str | None = None,
|
| 164 |
+
) -> Llama:
|
| 165 |
+
"""Download GGUF model from HuggingFace Hub and load with llama-cpp-python."""
|
| 166 |
+
if model_path is None:
|
| 167 |
+
print(f"Downloading {GGUF_REPO}/{GGUF_FILE}...")
|
| 168 |
+
model_path = hf_hub_download(repo_id=GGUF_REPO, filename=GGUF_FILE)
|
| 169 |
+
print(f"Loading GGUF model from {model_path}...")
|
| 170 |
+
llm = Llama(
|
| 171 |
+
model_path=model_path,
|
| 172 |
+
n_threads=n_threads,
|
| 173 |
+
n_ctx=n_ctx,
|
| 174 |
+
verbose=False,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
)
|
| 176 |
+
print(f"Model loaded ({n_threads} threads, {n_ctx} ctx).")
|
| 177 |
+
return llm
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
+
def stream_recipe(
|
| 181 |
+
llm: Llama,
|
| 182 |
+
prompt: str,
|
| 183 |
+
max_tokens: int = 256,
|
| 184 |
+
temperature: float = 0.7,
|
| 185 |
+
):
|
| 186 |
+
"""Yield token strings as they are generated."""
|
| 187 |
+
for chunk in llm.create_completion(
|
| 188 |
+
prompt,
|
| 189 |
+
max_tokens=max_tokens,
|
| 190 |
+
temperature=temperature,
|
| 191 |
+
top_p=0.9,
|
| 192 |
+
repeat_penalty=1.2,
|
| 193 |
+
stream=True,
|
| 194 |
+
):
|
| 195 |
+
token_text = chunk["choices"][0]["text"]
|
| 196 |
+
if token_text:
|
| 197 |
+
yield token_text
|
| 198 |
|
| 199 |
|
| 200 |
def generate_recipe(
|
| 201 |
+
llm: Llama,
|
|
|
|
|
|
|
| 202 |
prompt: str,
|
| 203 |
+
max_tokens: int = 256,
|
| 204 |
temperature: float = 0.7,
|
| 205 |
raw: bool = False,
|
| 206 |
) -> str:
|
| 207 |
+
"""Generate a complete recipe (non-streaming, for CLI use)."""
|
| 208 |
+
output = llm.create_completion(
|
| 209 |
+
prompt,
|
| 210 |
+
max_tokens=max_tokens,
|
| 211 |
+
temperature=temperature,
|
| 212 |
+
top_p=0.9,
|
| 213 |
+
repeat_penalty=1.2,
|
| 214 |
+
)
|
| 215 |
+
text = prompt + output["choices"][0]["text"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
if raw:
|
| 217 |
return text
|
| 218 |
return clean_recipe(text)
|
|
|
|
| 227 |
help="Prompt for recipe generation",
|
| 228 |
)
|
| 229 |
parser.add_argument(
|
| 230 |
+
"--model-path",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
type=str,
|
| 232 |
+
default=None,
|
| 233 |
+
help="Path to a local GGUF file (skips HF Hub download)",
|
| 234 |
)
|
| 235 |
parser.add_argument(
|
| 236 |
"--max-tokens",
|
|
|
|
| 244 |
default=0.7,
|
| 245 |
help="Sampling temperature",
|
| 246 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
parser.add_argument(
|
| 248 |
"--raw",
|
| 249 |
action="store_true",
|
|
|
|
| 261 |
# Ensure prompt ends with newline
|
| 262 |
prompt = args.prompt if args.prompt.endswith("\n") else args.prompt + "\n"
|
| 263 |
|
| 264 |
+
llm = load_model(model_path=args.model_path)
|
| 265 |
|
| 266 |
print(f"\nPrompt: {prompt.strip()}")
|
| 267 |
print("-" * 40)
|
| 268 |
|
| 269 |
result = generate_recipe(
|
| 270 |
+
llm,
|
|
|
|
|
|
|
| 271 |
prompt,
|
| 272 |
+
max_tokens=args.max_tokens,
|
| 273 |
temperature=args.temperature,
|
| 274 |
raw=args.raw,
|
| 275 |
)
|
server.py
CHANGED
|
@@ -1,37 +1,37 @@
|
|
| 1 |
"""
|
| 2 |
-
FastAPI server for recipe generation with streaming output.
|
| 3 |
|
| 4 |
Usage:
|
| 5 |
-
pip install -
|
| 6 |
python server.py
|
| 7 |
-
python server.py --adapter ClaireLee2429/gemma-2b-recipes-lora
|
| 8 |
python server.py --port 8080
|
| 9 |
"""
|
| 10 |
|
| 11 |
import argparse
|
|
|
|
| 12 |
import base64
|
| 13 |
import json
|
| 14 |
import os
|
| 15 |
-
import threading
|
| 16 |
import time
|
| 17 |
from contextlib import asynccontextmanager
|
| 18 |
|
| 19 |
import httpx
|
| 20 |
-
import torch
|
| 21 |
import uvicorn
|
| 22 |
from fastapi import FastAPI
|
| 23 |
from fastapi.middleware.cors import CORSMiddleware
|
| 24 |
from pydantic import BaseModel, Field
|
| 25 |
from sse_starlette.sse import EventSourceResponse
|
| 26 |
-
from transformers import TextIteratorStreamer
|
| 27 |
|
| 28 |
-
from inference import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
| 31 |
-
_model = None
|
| 32 |
-
_tokenizer = None
|
| 33 |
-
_device = None
|
| 34 |
-
_model_name = None
|
| 35 |
|
| 36 |
|
| 37 |
class GenerateRequest(BaseModel):
|
|
@@ -56,25 +56,14 @@ _kroger_token_expiry: float = 0
|
|
| 56 |
|
| 57 |
def _parse_args():
|
| 58 |
parser = argparse.ArgumentParser(description="Recipe generation API server")
|
|
|
|
|
|
|
| 59 |
parser.add_argument(
|
| 60 |
-
"--
|
| 61 |
-
type=str,
|
| 62 |
-
default="./processed_data/lora_adapter",
|
| 63 |
-
help="Path to LoRA adapter (local or HuggingFace Hub ID)",
|
| 64 |
-
)
|
| 65 |
-
parser.add_argument(
|
| 66 |
-
"--model",
|
| 67 |
type=str,
|
| 68 |
-
default=
|
| 69 |
-
help="
|
| 70 |
-
)
|
| 71 |
-
parser.add_argument(
|
| 72 |
-
"--no-adapter",
|
| 73 |
-
action="store_true",
|
| 74 |
-
help="Run the base model without a LoRA adapter",
|
| 75 |
)
|
| 76 |
-
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to bind to")
|
| 77 |
-
parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
|
| 78 |
return parser.parse_args()
|
| 79 |
|
| 80 |
|
|
@@ -83,10 +72,10 @@ args = _parse_args()
|
|
| 83 |
|
| 84 |
@asynccontextmanager
|
| 85 |
async def lifespan(app: FastAPI):
|
| 86 |
-
global
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
print(f"Server ready —
|
| 90 |
yield
|
| 91 |
|
| 92 |
|
|
@@ -105,40 +94,26 @@ app.add_middleware(
|
|
| 105 |
def health():
|
| 106 |
return {
|
| 107 |
"status": "ok",
|
| 108 |
-
"model":
|
| 109 |
-
"device":
|
| 110 |
}
|
| 111 |
|
| 112 |
|
| 113 |
@app.post("/generate")
|
| 114 |
async def generate(req: GenerateRequest):
|
| 115 |
prompt = req.prompt if req.prompt.endswith("\n") else req.prompt + "\n"
|
| 116 |
-
inputs = _tokenizer(prompt, return_tensors="pt").to(_device)
|
| 117 |
-
|
| 118 |
-
streamer = TextIteratorStreamer(
|
| 119 |
-
_tokenizer, skip_prompt=True, skip_special_tokens=True
|
| 120 |
-
)
|
| 121 |
-
|
| 122 |
-
generate_kwargs = dict(
|
| 123 |
-
**inputs,
|
| 124 |
-
max_new_tokens=req.max_tokens,
|
| 125 |
-
temperature=req.temperature,
|
| 126 |
-
top_p=0.9,
|
| 127 |
-
do_sample=True,
|
| 128 |
-
repetition_penalty=1.2,
|
| 129 |
-
streamer=streamer,
|
| 130 |
-
)
|
| 131 |
-
|
| 132 |
-
thread = threading.Thread(target=_model.generate, kwargs=generate_kwargs)
|
| 133 |
-
thread.start()
|
| 134 |
|
| 135 |
async def event_stream():
|
| 136 |
full_text = prompt
|
| 137 |
-
for token in
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
| 140 |
full_text += token
|
| 141 |
yield {"data": json.dumps({"token": token})}
|
|
|
|
| 142 |
cleaned = clean_recipe(full_text)
|
| 143 |
yield {"data": json.dumps({"done": True, "full_text": cleaned})}
|
| 144 |
|
|
|
|
| 1 |
"""
|
| 2 |
+
FastAPI server for recipe generation with streaming output (GGUF).
|
| 3 |
|
| 4 |
Usage:
|
| 5 |
+
pip install llama-cpp-python fastapi "uvicorn[standard]" sse-starlette httpx
|
| 6 |
python server.py
|
|
|
|
| 7 |
python server.py --port 8080
|
| 8 |
"""
|
| 9 |
|
| 10 |
import argparse
|
| 11 |
+
import asyncio
|
| 12 |
import base64
|
| 13 |
import json
|
| 14 |
import os
|
|
|
|
| 15 |
import time
|
| 16 |
from contextlib import asynccontextmanager
|
| 17 |
|
| 18 |
import httpx
|
|
|
|
| 19 |
import uvicorn
|
| 20 |
from fastapi import FastAPI
|
| 21 |
from fastapi.middleware.cors import CORSMiddleware
|
| 22 |
from pydantic import BaseModel, Field
|
| 23 |
from sse_starlette.sse import EventSourceResponse
|
|
|
|
| 24 |
|
| 25 |
+
from inference import (
|
| 26 |
+
GGUF_FILE,
|
| 27 |
+
GGUF_REPO,
|
| 28 |
+
clean_recipe,
|
| 29 |
+
load_model,
|
| 30 |
+
parse_ingredients,
|
| 31 |
+
stream_recipe,
|
| 32 |
+
)
|
| 33 |
|
| 34 |
+
_llm = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
class GenerateRequest(BaseModel):
|
|
|
|
| 56 |
|
| 57 |
def _parse_args():
|
| 58 |
parser = argparse.ArgumentParser(description="Recipe generation API server")
|
| 59 |
+
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to bind to")
|
| 60 |
+
parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
|
| 61 |
parser.add_argument(
|
| 62 |
+
"--model-path",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
type=str,
|
| 64 |
+
default=None,
|
| 65 |
+
help="Path to a local GGUF file (skips HF Hub download)",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
)
|
|
|
|
|
|
|
| 67 |
return parser.parse_args()
|
| 68 |
|
| 69 |
|
|
|
|
| 72 |
|
| 73 |
@asynccontextmanager
|
| 74 |
async def lifespan(app: FastAPI):
|
| 75 |
+
global _llm
|
| 76 |
+
n_threads = int(os.environ.get("OMP_NUM_THREADS", "8"))
|
| 77 |
+
_llm = load_model(n_threads=n_threads, n_ctx=2048, model_path=args.model_path)
|
| 78 |
+
print(f"Server ready — GGUF model loaded, {n_threads} threads")
|
| 79 |
yield
|
| 80 |
|
| 81 |
|
|
|
|
| 94 |
def health():
|
| 95 |
return {
|
| 96 |
"status": "ok",
|
| 97 |
+
"model": f"{GGUF_REPO}/{GGUF_FILE}",
|
| 98 |
+
"device": "cpu (GGUF Q4_K_M)",
|
| 99 |
}
|
| 100 |
|
| 101 |
|
| 102 |
@app.post("/generate")
|
| 103 |
async def generate(req: GenerateRequest):
|
| 104 |
prompt = req.prompt if req.prompt.endswith("\n") else req.prompt + "\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
async def event_stream():
|
| 107 |
full_text = prompt
|
| 108 |
+
for token in stream_recipe(
|
| 109 |
+
_llm,
|
| 110 |
+
prompt,
|
| 111 |
+
max_tokens=req.max_tokens,
|
| 112 |
+
temperature=req.temperature,
|
| 113 |
+
):
|
| 114 |
full_text += token
|
| 115 |
yield {"data": json.dumps({"token": token})}
|
| 116 |
+
await asyncio.sleep(0) # yield control to event loop
|
| 117 |
cleaned = clean_recipe(full_text)
|
| 118 |
yield {"data": json.dumps({"done": True, "full_text": cleaned})}
|
| 119 |
|