cloudunity commited on
Commit
ecc643a
·
verified ·
1 Parent(s): 3207259

Create server.py

Browse files
Files changed (1) hide show
  1. server.py +457 -0
server.py ADDED
@@ -0,0 +1,457 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenAI-compatible Images API server for stabilityai/sd-turbo (or a
3
+ compatible drop-in model) running on CPU inside a Hugging Face Docker Space.
4
+
5
+ Endpoints:
6
+ GET /health
7
+ GET /v1/models
8
+ POST /v1/images/generations
9
+
10
+ The diffusion pipeline is loaded exactly once at process startup and
11
+ reused for every request. Because the underlying model is not
12
+ thread-safe for concurrent inference, a global lock serializes all
13
+ generation calls.
14
+
15
+ Note on ONNX: SD Turbo does not have a maintained, broadly compatible
16
+ ONNX Runtime export that works with AutoPipelineForText2Image out of
17
+ the box (optimum's ONNX SD pipelines require a separately exported
18
+ ONNX model directory with its own graph format, and no such export is
19
+ published/guaranteed for this model). Since none is available here,
20
+ this server falls back to the standard Diffusers PyTorch pipeline,
21
+ as instructed.
22
+ """
23
+
24
+ import base64
25
+ import io
26
+ import logging
27
+ import os
28
+ import threading
29
+ import time
30
+ import uuid
31
+
32
+ from flask import Flask, jsonify, request
33
+ from flask_cors import CORS
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Logging
37
+ # ---------------------------------------------------------------------------
38
+
39
+ logging.basicConfig(
40
+ level=logging.INFO,
41
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
42
+ )
43
+ logger = logging.getLogger("sd-turbo-server")
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Configuration (overridable via environment variables)
47
+ # ---------------------------------------------------------------------------
48
+
49
+ IMAGE_MODEL = os.environ.get("IMAGE_MODEL", "stabilityai/sd-turbo")
50
+
51
+ # If the model was pre-downloaded at build time to a local directory,
52
+ # prefer loading from there so no network access is required at runtime.
53
+ MODEL_LOCAL_DIR = os.environ.get("MODEL_LOCAL_DIR", "/app/models/sd-turbo")
54
+
55
+ DEFAULT_STEPS = int(os.environ.get("DEFAULT_STEPS", "2"))
56
+ DEFAULT_GUIDANCE = float(os.environ.get("DEFAULT_GUIDANCE", "0"))
57
+
58
+ # Model-facing short id used in the OpenAI-compatible API responses.
59
+ MODEL_SHORT_ID = "sd-turbo"
60
+ MODEL_OWNER = "stabilityai"
61
+
62
+ SUPPORTED_SIZES = {
63
+ "256x256": (256, 256),
64
+ "512x512": (512, 512),
65
+ "768x768": (768, 768),
66
+ }
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Flask app
70
+ # ---------------------------------------------------------------------------
71
+
72
+ app = Flask(__name__)
73
+ CORS(app)
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Global pipeline state
77
+ # ---------------------------------------------------------------------------
78
+
79
+ _pipeline = None
80
+ _pipeline_lock = threading.Lock() # serializes generation calls
81
+ _pipeline_load_error = None
82
+
83
+
84
+ def _resolve_model_source():
85
+ """
86
+ Decide whether to load from the local pre-downloaded directory
87
+ (populated at Docker build time) or fall back to the hub id.
88
+ """
89
+ if os.path.isdir(MODEL_LOCAL_DIR) and os.listdir(MODEL_LOCAL_DIR):
90
+ logger.info("Using pre-downloaded model directory: %s", MODEL_LOCAL_DIR)
91
+ return MODEL_LOCAL_DIR
92
+ logger.info(
93
+ "Local model directory not found or empty (%s). "
94
+ "Falling back to downloading '%s' from the Hugging Face Hub.",
95
+ MODEL_LOCAL_DIR,
96
+ IMAGE_MODEL,
97
+ )
98
+ return IMAGE_MODEL
99
+
100
+
101
+ def load_pipeline():
102
+ """
103
+ Load the diffusion pipeline exactly once at startup and cache it
104
+ globally. Uses AutoPipelineForText2Image with float32 weights,
105
+ since this deployment is CPU-only (no GPU / no fp16 support).
106
+ """
107
+ global _pipeline, _pipeline_load_error
108
+
109
+ import torch
110
+ from diffusers import AutoPipelineForText2Image
111
+
112
+ model_source = _resolve_model_source()
113
+
114
+ logger.info("Loading text-to-image pipeline from '%s' ...", model_source)
115
+ try:
116
+ pipeline = AutoPipelineForText2Image.from_pretrained(
117
+ model_source,
118
+ torch_dtype=torch.float32,
119
+ safety_checker=None,
120
+ )
121
+ pipeline.to("cpu")
122
+
123
+ # Disable the per-step progress bar (noisy in server logs / stdout).
124
+ pipeline.set_progress_bar_config(disable=True)
125
+
126
+ _pipeline = pipeline
127
+ logger.info("Pipeline loaded successfully.")
128
+ except Exception as exc: # noqa: BLE001 - we want to capture and report any load failure
129
+ _pipeline_load_error = str(exc)
130
+ logger.exception("Failed to load pipeline: %s", exc)
131
+ raise
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Helpers
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ def openai_error_response(message, err_type="invalid_request_error", param=None, code=None, status=400):
140
+ """
141
+ Build a Flask response matching the OpenAI API error envelope:
142
+ {"error": {"message": ..., "type": ..., "param": ..., "code": ...}}
143
+ """
144
+ body = {
145
+ "error": {
146
+ "message": message,
147
+ "type": err_type,
148
+ "param": param,
149
+ "code": code,
150
+ }
151
+ }
152
+ response = jsonify(body)
153
+ response.status_code = status
154
+ return response
155
+
156
+
157
+ def parse_size(size_str):
158
+ """
159
+ Validate and convert an OpenAI-style size string (e.g. "512x512")
160
+ into a (width, height) tuple. Returns None if unsupported.
161
+ """
162
+ if size_str is None:
163
+ return SUPPORTED_SIZES["512x512"]
164
+ return SUPPORTED_SIZES.get(size_str)
165
+
166
+
167
+ def image_to_b64(pil_image, image_format="PNG"):
168
+ """Encode a PIL image to a base64 string (no data URI prefix)."""
169
+ buffer = io.BytesIO()
170
+ pil_image.save(buffer, format=image_format)
171
+ raw_bytes = buffer.getvalue()
172
+ return base64.b64encode(raw_bytes).decode("utf-8")
173
+
174
+
175
+ def run_generation(prompt, width, height, steps, guidance_scale, n_images):
176
+ """
177
+ Run the diffusion pipeline under the global lock so only one
178
+ generation happens at a time (safe for a single CPU worker process).
179
+ Returns a list of PIL.Image objects.
180
+ """
181
+ if _pipeline is None:
182
+ raise RuntimeError(
183
+ _pipeline_load_error or "Image generation pipeline is not initialized."
184
+ )
185
+
186
+ images = []
187
+ with _pipeline_lock:
188
+ for _ in range(n_images):
189
+ result = _pipeline(
190
+ prompt=prompt,
191
+ num_inference_steps=steps,
192
+ guidance_scale=guidance_scale,
193
+ width=width,
194
+ height=height,
195
+ )
196
+ images.append(result.images[0])
197
+ return images
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Routes
202
+ # ---------------------------------------------------------------------------
203
+
204
+
205
+ @app.route("/health", methods=["GET"])
206
+ def health():
207
+ """Basic health/readiness check."""
208
+ status = "ok" if _pipeline is not None else "loading"
209
+ status_code = 200 if _pipeline is not None else 503
210
+ return jsonify(
211
+ {
212
+ "status": status,
213
+ "model": MODEL_SHORT_ID,
214
+ "error": _pipeline_load_error,
215
+ }
216
+ ), status_code
217
+
218
+
219
+ @app.route("/v1/models", methods=["GET"])
220
+ def list_models():
221
+ """OpenAI-compatible model listing endpoint."""
222
+ return jsonify(
223
+ {
224
+ "object": "list",
225
+ "data": [
226
+ {
227
+ "id": MODEL_SHORT_ID,
228
+ "object": "model",
229
+ "owned_by": MODEL_OWNER,
230
+ }
231
+ ],
232
+ }
233
+ )
234
+
235
+
236
+ @app.route("/v1/images/generations", methods=["POST"])
237
+ def images_generations():
238
+ """OpenAI-compatible image generation endpoint."""
239
+
240
+ # ---- Parse JSON body ----
241
+ if not request.is_json:
242
+ return openai_error_response(
243
+ "Request body must be valid JSON with Content-Type: application/json.",
244
+ param=None,
245
+ code="invalid_json",
246
+ status=400,
247
+ )
248
+
249
+ try:
250
+ payload = request.get_json(silent=False)
251
+ except Exception: # noqa: BLE001
252
+ return openai_error_response(
253
+ "Request body could not be parsed as JSON.",
254
+ code="invalid_json",
255
+ status=400,
256
+ )
257
+
258
+ if not isinstance(payload, dict):
259
+ return openai_error_response(
260
+ "Request body must be a JSON object.",
261
+ code="invalid_json",
262
+ status=400,
263
+ )
264
+
265
+ # ---- Validate prompt ----
266
+ prompt = payload.get("prompt")
267
+ if not prompt or not isinstance(prompt, str) or not prompt.strip():
268
+ return openai_error_response(
269
+ "You must provide a non-empty string in the 'prompt' field.",
270
+ param="prompt",
271
+ code="missing_prompt",
272
+ status=400,
273
+ )
274
+
275
+ # ---- Validate model (optional field, informational only) ----
276
+ requested_model = payload.get("model", MODEL_SHORT_ID)
277
+ if requested_model not in (MODEL_SHORT_ID, IMAGE_MODEL):
278
+ return openai_error_response(
279
+ f"The model '{requested_model}' does not exist or is not supported "
280
+ f"by this server. Available model: '{MODEL_SHORT_ID}'.",
281
+ param="model",
282
+ code="model_not_found",
283
+ status=404,
284
+ )
285
+
286
+ # ---- Validate size ----
287
+ size_str = payload.get("size", "512x512")
288
+ dimensions = parse_size(size_str)
289
+ if dimensions is None:
290
+ supported = ", ".join(sorted(SUPPORTED_SIZES.keys()))
291
+ return openai_error_response(
292
+ f"Unsupported size '{size_str}'. Supported sizes are: {supported}.",
293
+ param="size",
294
+ code="unsupported_size",
295
+ status=400,
296
+ )
297
+ width, height = dimensions
298
+
299
+ # ---- Validate n ----
300
+ n_images = payload.get("n", 1)
301
+ if not isinstance(n_images, int) or isinstance(n_images, bool) or n_images < 1:
302
+ return openai_error_response(
303
+ "'n' must be a positive integer.",
304
+ param="n",
305
+ code="invalid_n",
306
+ status=400,
307
+ )
308
+ if n_images > 4:
309
+ return openai_error_response(
310
+ "'n' must be less than or equal to 4 for this server.",
311
+ param="n",
312
+ code="invalid_n",
313
+ status=400,
314
+ )
315
+
316
+ # ---- Validate response_format ----
317
+ response_format = payload.get("response_format", "b64_json")
318
+ if response_format != "b64_json":
319
+ return openai_error_response(
320
+ "This server only supports response_format='b64_json'. "
321
+ "URL-based responses are not available on this CPU-only deployment.",
322
+ param="response_format",
323
+ code="unsupported_response_format",
324
+ status=400,
325
+ )
326
+
327
+ # ---- Optional generation overrides ----
328
+ steps = payload.get("num_inference_steps", DEFAULT_STEPS)
329
+ guidance_scale = payload.get("guidance_scale", DEFAULT_GUIDANCE)
330
+
331
+ try:
332
+ steps = int(steps)
333
+ guidance_scale = float(guidance_scale)
334
+ except (TypeError, ValueError):
335
+ return openai_error_response(
336
+ "'num_inference_steps' must be an integer and 'guidance_scale' must be a number.",
337
+ code="invalid_generation_params",
338
+ status=400,
339
+ )
340
+
341
+ if steps < 1 or steps > 50:
342
+ return openai_error_response(
343
+ "'num_inference_steps' must be between 1 and 50.",
344
+ param="num_inference_steps",
345
+ code="invalid_generation_params",
346
+ status=400,
347
+ )
348
+
349
+ if guidance_scale < 0 or guidance_scale > 20:
350
+ return openai_error_response(
351
+ "'guidance_scale' must be between 0 and 20.",
352
+ param="guidance_scale",
353
+ code="invalid_generation_params",
354
+ status=400,
355
+ )
356
+
357
+ # ---- Ensure pipeline is ready ----
358
+ if _pipeline is None:
359
+ return openai_error_response(
360
+ _pipeline_load_error
361
+ or "The image generation model is still loading. Please retry shortly.",
362
+ err_type="server_error",
363
+ code="model_not_ready",
364
+ status=503,
365
+ )
366
+
367
+ # ---- Run generation ----
368
+ request_id = uuid.uuid4().hex[:12]
369
+ logger.info(
370
+ "[%s] Generating %d image(s) | size=%dx%d steps=%d guidance=%.2f prompt=%r",
371
+ request_id,
372
+ n_images,
373
+ width,
374
+ height,
375
+ steps,
376
+ guidance_scale,
377
+ prompt[:200],
378
+ )
379
+
380
+ try:
381
+ images = run_generation(
382
+ prompt=prompt,
383
+ width=width,
384
+ height=height,
385
+ steps=steps,
386
+ guidance_scale=guidance_scale,
387
+ n_images=n_images,
388
+ )
389
+ except Exception as exc: # noqa: BLE001
390
+ logger.exception("[%s] Generation failed: %s", request_id, exc)
391
+ return openai_error_response(
392
+ f"Image generation failed: {exc}",
393
+ err_type="server_error",
394
+ code="generation_failed",
395
+ status=500,
396
+ )
397
+
398
+ # ---- Build OpenAI-compatible response ----
399
+ data = [{"b64_json": image_to_b64(img)} for img in images]
400
+
401
+ return jsonify(
402
+ {
403
+ "created": int(time.time()),
404
+ "data": data,
405
+ }
406
+ )
407
+
408
+
409
+ @app.errorhandler(404)
410
+ def not_found(_error):
411
+ return openai_error_response(
412
+ "The requested endpoint does not exist.",
413
+ err_type="invalid_request_error",
414
+ code="not_found",
415
+ status=404,
416
+ )
417
+
418
+
419
+ @app.errorhandler(405)
420
+ def method_not_allowed(_error):
421
+ return openai_error_response(
422
+ "This HTTP method is not allowed for the requested endpoint.",
423
+ err_type="invalid_request_error",
424
+ code="method_not_allowed",
425
+ status=405,
426
+ )
427
+
428
+
429
+ @app.errorhandler(500)
430
+ def internal_error(_error):
431
+ return openai_error_response(
432
+ "An internal server error occurred.",
433
+ err_type="server_error",
434
+ code="internal_error",
435
+ status=500,
436
+ )
437
+
438
+
439
+ # ---------------------------------------------------------------------------
440
+ # Entrypoint
441
+ # ---------------------------------------------------------------------------
442
+
443
+ if __name__ == "__main__":
444
+ # Load the model once, synchronously, before accepting traffic.
445
+ try:
446
+ load_pipeline()
447
+ except Exception: # noqa: BLE001
448
+ # We still start the Flask app so /health reports the failure
449
+ # instead of the container silently dying and HF Spaces retrying
450
+ # forever without diagnostics.
451
+ logger.error(
452
+ "Starting server in degraded mode: pipeline failed to load. "
453
+ "/health will report the error."
454
+ )
455
+
456
+ port = int(os.environ.get("PORT", "7860"))
457
+ app.run(host="0.0.0.0", port=port, threaded=True)