LevArtesa commited on
Commit
bc3b40f
·
verified ·
1 Parent(s): b841ca2

Stage_V_3 serve: upload training/serve.py

Browse files
Files changed (1) hide show
  1. training/serve.py +250 -0
training/serve.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal HTTP inference server for Qwen3-4B (non-thinking mode).
2
+
3
+ Serves a single POST /generate endpoint that takes a prompt and returns a
4
+ completion. Designed to run on the same HF Space as training/evaluation.
5
+
6
+ Auto-pauses the Space after IDLE_SECONDS of no requests to avoid billing
7
+ surprises.
8
+
9
+ Environment variables:
10
+ HF_TOKEN: HuggingFace token for auto-pausing the Space on idle.
11
+ IDLE_SECONDS: Seconds of inactivity before auto-pause (default 900 = 15 min).
12
+
13
+ Endpoints:
14
+ GET / → "ok"
15
+ POST /generate → body {"prompt_text": str, "system_prompt"?: str,
16
+ "max_new_tokens"?: int, "temperature"?: float,
17
+ "top_p"?: float, "enable_thinking"?: bool}
18
+ → {"completion": str, "gen_time_s": float}
19
+ POST /shutdown → pauses the Space immediately
20
+ """
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import sys
26
+ import threading
27
+ import time
28
+ from http.server import BaseHTTPRequestHandler, HTTPServer
29
+
30
+ import torch
31
+ from peft import PeftModel
32
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
33
+
34
+ BASE_MODEL = os.environ.get("MODEL_REPO", "Qwen/Qwen3-4B")
35
+ ADAPTER_REPO = os.environ.get("ADAPTER_REPO", "LevArtesa/grpo-humanizer-de-lora")
36
+ # Subfolder of a specific checkpoint, or "" for the top-level adapter
37
+ ADAPTER_SUBFOLDER = os.environ.get("ADAPTER_SUBFOLDER", "")
38
+ LOAD_ADAPTER = os.environ.get("LOAD_ADAPTER", "1").lower() in ("1", "true", "yes")
39
+ IDLE_SECONDS = int(os.environ.get("IDLE_SECONDS", "900"))
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ # Track last activity timestamp for idle-based auto-pause
44
+ _last_request_ts = time.time()
45
+ _lock = threading.Lock()
46
+
47
+
48
+ def _setup_logging() -> None:
49
+ logging.basicConfig(
50
+ level=logging.INFO,
51
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
52
+ datefmt="%Y-%m-%d %H:%M:%S",
53
+ handlers=[logging.StreamHandler(sys.stdout)],
54
+ )
55
+
56
+
57
+ def _load_model():
58
+ logger.info("Loading %s (4-bit)", BASE_MODEL)
59
+ bnb = BitsAndBytesConfig(
60
+ load_in_4bit=True,
61
+ bnb_4bit_quant_type="nf4",
62
+ bnb_4bit_compute_dtype=torch.bfloat16,
63
+ bnb_4bit_use_double_quant=True,
64
+ )
65
+ tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL)
66
+ if tokenizer.pad_token is None:
67
+ tokenizer.pad_token = tokenizer.eos_token
68
+ model = AutoModelForCausalLM.from_pretrained(
69
+ BASE_MODEL,
70
+ quantization_config=bnb,
71
+ device_map="auto",
72
+ )
73
+
74
+ adapter_loaded = None
75
+ if LOAD_ADAPTER:
76
+ from huggingface_hub import snapshot_download
77
+ hf_token = os.environ.get("HF_TOKEN")
78
+ try:
79
+ if ADAPTER_SUBFOLDER:
80
+ logger.info("Downloading adapter %s/%s", ADAPTER_REPO, ADAPTER_SUBFOLDER)
81
+ local_dir = snapshot_download(
82
+ repo_id=ADAPTER_REPO,
83
+ allow_patterns=[f"{ADAPTER_SUBFOLDER}/*"],
84
+ token=hf_token,
85
+ )
86
+ adapter_path = str(__import__("pathlib").Path(local_dir) / ADAPTER_SUBFOLDER)
87
+ else:
88
+ logger.info("Downloading top-level adapter from %s", ADAPTER_REPO)
89
+ adapter_path = snapshot_download(
90
+ repo_id=ADAPTER_REPO,
91
+ allow_patterns=["adapter_*", "*.json", "*.txt", "tokenizer*", "chat_template*"],
92
+ token=hf_token,
93
+ )
94
+ logger.info("Applying LoRA adapter from %s", adapter_path)
95
+ model = PeftModel.from_pretrained(model, adapter_path)
96
+ adapter_loaded = adapter_path
97
+ except Exception as exc: # noqa: BLE001
98
+ logger.exception("Failed to load adapter; falling back to base model")
99
+
100
+ model.eval()
101
+ logger.info(
102
+ "Model loaded. Adapter=%s",
103
+ adapter_loaded if adapter_loaded else "none (base only)",
104
+ )
105
+ return model, tokenizer
106
+
107
+
108
+ def _pause_space() -> None:
109
+ space_id = os.environ.get("SPACE_ID")
110
+ hf_token = os.environ.get("HF_TOKEN")
111
+ if not space_id or not hf_token:
112
+ logger.warning("No SPACE_ID/HF_TOKEN — cannot auto-pause")
113
+ return
114
+ try:
115
+ from huggingface_hub import HfApi
116
+ logger.info("Pausing Space %s", space_id)
117
+ HfApi(token=hf_token).pause_space(space_id)
118
+ except Exception as exc: # noqa: BLE001
119
+ logger.error("Failed to pause: %s", exc)
120
+
121
+
122
+ def _idle_watchdog():
123
+ while True:
124
+ time.sleep(60)
125
+ with _lock:
126
+ idle = time.time() - _last_request_ts
127
+ if idle > IDLE_SECONDS:
128
+ logger.warning("Idle %.0fs > %d — auto-pausing Space", idle, IDLE_SECONDS)
129
+ _pause_space()
130
+ os._exit(0)
131
+
132
+
133
+ def _generate(model, tokenizer, body: dict) -> dict:
134
+ prompt_text = body.get("prompt_text") or ""
135
+ system_prompt = body.get("system_prompt")
136
+ max_new_tokens = int(body.get("max_new_tokens", 1024))
137
+ temperature = float(body.get("temperature", 0.9))
138
+ top_p = float(body.get("top_p", 0.95))
139
+ enable_thinking = bool(body.get("enable_thinking", False))
140
+
141
+ messages: list[dict] = []
142
+ if system_prompt:
143
+ messages.append({"role": "system", "content": system_prompt})
144
+ messages.append({"role": "user", "content": prompt_text})
145
+
146
+ # v4/v5/v51 patched: enable_thinking is Qwen-specific
147
+ try:
148
+ inputs = tokenizer.apply_chat_template(
149
+ messages,
150
+ add_generation_prompt=True,
151
+ return_tensors="pt",
152
+ enable_thinking=enable_thinking,
153
+ ).to(model.device)
154
+ except Exception: # noqa: BLE001
155
+ inputs = tokenizer.apply_chat_template(
156
+ messages,
157
+ add_generation_prompt=True,
158
+ return_tensors="pt",
159
+ ).to(model.device)
160
+
161
+ t0 = time.time()
162
+ with torch.no_grad():
163
+ output = model.generate(
164
+ inputs,
165
+ max_new_tokens=max_new_tokens,
166
+ do_sample=True,
167
+ temperature=temperature,
168
+ top_p=top_p,
169
+ pad_token_id=tokenizer.pad_token_id,
170
+ )
171
+ gen_time = time.time() - t0
172
+
173
+ new_tokens = output[0][inputs.shape[1]:]
174
+ completion = tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
175
+ return {"completion": completion, "gen_time_s": round(gen_time, 2)}
176
+
177
+
178
+ def _make_handler(model, tokenizer):
179
+ class Handler(BaseHTTPRequestHandler):
180
+ def log_message(self, format, *args): # noqa: A002
181
+ pass
182
+
183
+ def do_GET(self):
184
+ global _last_request_ts
185
+ with _lock:
186
+ _last_request_ts = time.time()
187
+ if self.path in ("/", "/health"):
188
+ self.send_response(200)
189
+ self.send_header("Content-Type", "text/plain")
190
+ self.end_headers()
191
+ self.wfile.write(b"ok\n")
192
+ else:
193
+ self.send_response(404)
194
+ self.end_headers()
195
+
196
+ def do_POST(self):
197
+ global _last_request_ts
198
+ with _lock:
199
+ _last_request_ts = time.time()
200
+
201
+ if self.path == "/shutdown":
202
+ self.send_response(200)
203
+ self.end_headers()
204
+ self.wfile.write(b'{"status":"pausing"}')
205
+ threading.Thread(target=lambda: (time.sleep(1), _pause_space(), os._exit(0)), daemon=True).start()
206
+ return
207
+
208
+ if self.path != "/generate":
209
+ self.send_response(404)
210
+ self.end_headers()
211
+ return
212
+
213
+ length = int(self.headers.get("Content-Length", 0))
214
+ raw = self.rfile.read(length) if length else b"{}"
215
+ try:
216
+ body = json.loads(raw)
217
+ result = _generate(model, tokenizer, body)
218
+ payload = json.dumps(result, ensure_ascii=False).encode("utf-8")
219
+ self.send_response(200)
220
+ self.send_header("Content-Type", "application/json; charset=utf-8")
221
+ self.send_header("Content-Length", str(len(payload)))
222
+ self.end_headers()
223
+ self.wfile.write(payload)
224
+ except Exception as exc: # noqa: BLE001
225
+ logger.exception("generate error")
226
+ err = json.dumps({"error": str(exc)}).encode("utf-8")
227
+ self.send_response(500)
228
+ self.send_header("Content-Type", "application/json")
229
+ self.end_headers()
230
+ self.wfile.write(err)
231
+
232
+ return Handler
233
+
234
+
235
+ def main() -> None:
236
+ _setup_logging()
237
+ logger.info("=== Inference server — starting ===")
238
+ model, tokenizer = _load_model()
239
+
240
+ threading.Thread(target=_idle_watchdog, daemon=True).start()
241
+ logger.info("Idle watchdog armed: %d seconds", IDLE_SECONDS)
242
+
243
+ handler = _make_handler(model, tokenizer)
244
+ server = HTTPServer(("0.0.0.0", 7860), handler)
245
+ logger.info("Listening on :7860")
246
+ server.serve_forever()
247
+
248
+
249
+ if __name__ == "__main__":
250
+ main()