fomext commited on
Commit
19449c6
Β·
verified Β·
1 Parent(s): a926f14

Upload main.py

Browse files
Files changed (1) hide show
  1. main.py +572 -0
main.py ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ HuggingFace Spaces β†’ OpenAI-compatible API Proxy
4
+ =================================================
5
+ Exposes /v1/models and /v1/chat/completions (streaming + non-streaming).
6
+ Balances across multiple HF Spaces, queuing requests when all are busy.
7
+
8
+ Each space has a "type" that controls how the proxy talks to it:
9
+
10
+ "openai" β€” spaces that expose a real HTTP OpenAI-compatible API
11
+ Health: GET /health β†’ {"ready": true/false, "status": "..."}
12
+ Chat: POST /v1/chat/completions (streaming supported)
13
+ Example: (none currently β€” all spaces use gradio type)
14
+
15
+ "gradio" β€” spaces built with Gradio, called via the gradio_client library
16
+ so that requests are routed through the HF Pro GPU quota.
17
+ Health: GET /health β†’ {"status": "ok", "model": "..."}
18
+ (no "ready" field β€” if it responds at all, it's ready)
19
+ Chat: gradio_client.Client(space_id, token=HF_TOKEN)
20
+ .predict(messages_json=..., api_name="/chat_completions")
21
+ Token: read from the HF_TOKEN environment variable / secret
22
+ Example: qwen3-14b (fallback_module_trial spaces)
23
+ qwen3-30b-a3b (intelect_module spaces)
24
+ qwen3-coder-30b (coder_v2 spaces)
25
+ """
26
+
27
+ import asyncio
28
+ import json
29
+ import logging
30
+ import os
31
+ import time
32
+ import uuid
33
+ import httpx
34
+ from gradio_client import Client as GradioClient
35
+
36
+ from fastapi import FastAPI, HTTPException, Request
37
+ from fastapi.responses import StreamingResponse, JSONResponse
38
+ from fastapi.middleware.cors import CORSMiddleware
39
+ from typing import Optional
40
+
41
+ # ─────────────────────────────────────────────────────────────────────────────
42
+ # CONFIGURE YOUR SPACES HERE
43
+ #
44
+ # Required fields for every space:
45
+ # url β€” base URL of the HF Space
46
+ # model_id β€” model name exposed to clients (e.g. Paperclip)
47
+ # name β€” human-readable label used in logs
48
+ # type β€” "openai" or "gradio" (controls how the proxy talks to it)
49
+ #
50
+ # Required for gradio spaces:
51
+ # space_id β€” HF repo id, e.g. "fomext/intelect_module_trial"
52
+ # used by gradio_client so requests hit your Pro GPU quota
53
+ #
54
+ # Optional:
55
+ # hf_token β€” per-space HF token override (falls back to HF_TOKEN secret)
56
+ # ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ SPACES = [
59
+
60
+ # ── qwen3-14b (gradio type β€” called via gradio_client) ─────────────────
61
+ {
62
+ "url": "https://fomext-intelect-module-v3.hf.space",
63
+ "space_id": "fomext/intelect_module_v3",
64
+ "model_id": "qwen3-14b",
65
+ "name": "14b Reasoning (Space 5)",
66
+ "type": "gradio",
67
+ "supports_thinking": False,
68
+ },
69
+ {
70
+ "url": "https://fomext-intelect-module-v3-1.hf.space",
71
+ "space_id": "fomext/intelect_module_v3_1",
72
+ "model_id": "qwen3-14b",
73
+ "name": "14b Reasoning (Space 4)",
74
+ "type": "gradio",
75
+ "supports_thinking": False,
76
+ },
77
+ {
78
+ "url": "https://fomext-intelect-module-v3-2.hf.space",
79
+ "space_id": "fomext/intelect_module_v3_2",
80
+ "model_id": "qwen3-14b",
81
+ "name": "14b Reasoning (Space 3)",
82
+ "type": "gradio",
83
+ "supports_thinking": False,
84
+ },
85
+ {
86
+ "url": "https://fomext-intelect-module-v3-3.hf.space",
87
+ "space_id": "fomext/intelect_module_v3_3",
88
+ "model_id": "qwen3-14b",
89
+ "name": "14b Reasoning (Space 2)",
90
+ "type": "gradio",
91
+ "supports_thinking": False,
92
+ },
93
+ {
94
+ "url": "https://fomext-intelect-module-v3-4.hf.space",
95
+ "space_id": "fomext/intelect_module_v3_4",
96
+ "model_id": "qwen3-14b",
97
+ "name": "14b Reasoning (Space 1)",
98
+ "type": "gradio",
99
+ "supports_thinking": False,
100
+ },
101
+
102
+ # ── qwen3-coder-30b (gradio type β€” called via gradio_client) ───────────
103
+ # NOTE: coder spaces do NOT accept the enable_thinking parameter
104
+ {
105
+ "url": "https://fomext-coder-v2-trial.hf.space",
106
+ "space_id": "fomext/coder_v2_trial",
107
+ "model_id": "qwen3-coder-30b-a3b-instruct-fp8",
108
+ "name": "Coder 30b (Space 1)",
109
+ "type": "gradio",
110
+ "supports_thinking": False,
111
+ },
112
+ {
113
+ "url": "https://fomext-coder-v2-trial2.hf.space",
114
+ "space_id": "fomext/coder_v2_trial2",
115
+ "model_id": "qwen3-coder-30b-a3b-instruct-fp8",
116
+ "name": "Coder 30b (Space 2)",
117
+ "type": "gradio",
118
+ "supports_thinking": False,
119
+ },
120
+ {
121
+ "url": "https://fomext-coder-v2-trial3.hf.space",
122
+ "space_id": "fomext/coder_v2_trial3",
123
+ "model_id": "qwen3-coder-30b-a3b-instruct-fp8",
124
+ "name": "Coder 30b (Space 3)",
125
+ "type": "gradio",
126
+ "supports_thinking": False,
127
+ },
128
+
129
+ # ── qwen3-30b-a3b (gradio type β€” called via gradio_client) ─────────────
130
+ {
131
+ "url": "https://fomext-intelect_module_trial.hf.space",
132
+ "space_id": "fomext/intelect_module_trial",
133
+ "model_id": "qwen3-30b-a3b",
134
+ "name": "30b Reasoning (Space 1)",
135
+ "type": "gradio",
136
+ "supports_thinking": True,
137
+ },
138
+ {
139
+ "url": "https://fomext-intelect_module_trial2.hf.space",
140
+ "space_id": "fomext/intelect_module_trial2",
141
+ "model_id": "qwen3-30b-a3b",
142
+ "name": "30b Reasoning (Space 2)",
143
+ "type": "gradio",
144
+ "supports_thinking": True,
145
+ },
146
+ {
147
+ "url": "https://fomext-intelect_module_trial3.hf.space",
148
+ "space_id": "fomext/intelect_module_trial3",
149
+ "model_id": "qwen3-30b-a3b",
150
+ "name": "30b Reasoning (Space 3)",
151
+ "type": "gradio",
152
+ "supports_thinking": True,
153
+ },
154
+
155
+ ]
156
+
157
+ # ── Model aliases ────────────────────────────────────────────────────────────
158
+ # Maps external model names (e.g. OpenAI names sent by Paperclip/OpenCode)
159
+ # to the actual model IDs configured in SPACES above.
160
+ # Add any new aliases here β€” no other code needs to change.
161
+ MODEL_ALIASES: dict[str, str] = {
162
+ # OpenAI codex / GPT names β†’ coder model
163
+ "gpt-5.1-codex-mini": "qwen3-coder-30b-a3b-instruct-fp8",
164
+ "gpt-5.1-codex": "qwen3-coder-30b-a3b-instruct-fp8",
165
+ "code-davinci-002": "qwen3-coder-30b-a3b-instruct-fp8",
166
+ # GPT-4-class names β†’ 30b reasoning model
167
+ "gpt-4o": "qwen3-30b-a3b",
168
+ "gpt-4o-mini": "qwen3-14b",
169
+ "gpt-4": "qwen3-30b-a3b",
170
+ "gpt-4-turbo": "qwen3-14b",
171
+ "gpt-4-turbo-preview": "qwen3-14b",
172
+ # GPT-3.5 names β†’ 14b model
173
+ "gpt-3.5-turbo": "qwen3-14b",
174
+ "gpt-3.5-turbo-16k": "qwen3-14b",
175
+ }
176
+
177
+ # Fallback model when the requested name isn't in SPACES or MODEL_ALIASES
178
+ DEFAULT_MODEL = "qwen3-14b"
179
+
180
+ # HF token for Gradio spaces β€” set this as a secret called HF_TOKEN
181
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
182
+
183
+
184
+ SPACE_READY_TIMEOUT = 600
185
+ # Seconds between health polls
186
+ POLL_INTERVAL = 10
187
+ # Upstream request timeout
188
+ REQUEST_TIMEOUT = 300
189
+
190
+ # ─────────────────────────────────────────────────────────────────────────────
191
+
192
+ logging.basicConfig(
193
+ level=logging.INFO,
194
+ format="%(asctime)s %(levelname)-8s %(message)s",
195
+ datefmt="%H:%M:%S",
196
+ )
197
+ log = logging.getLogger("hf-proxy")
198
+
199
+ app = FastAPI(title="HF Spaces OpenAI Proxy", version="2.0.0")
200
+ app.add_middleware(
201
+ CORSMiddleware,
202
+ allow_origins=["*"],
203
+ allow_methods=["*"],
204
+ allow_headers=["*"],
205
+ )
206
+
207
+
208
+ # ── Space state ───────────────────────────────────────────────────────────────
209
+
210
+ class SpaceState:
211
+ def __init__(self, cfg: dict):
212
+ self.url: str = cfg["url"].rstrip("/")
213
+ self.space_id: str = cfg.get("space_id", "") # e.g. "fomext/intelect_module_trial"
214
+ self.model_id: str = cfg["model_id"]
215
+ self.name: str = cfg["name"]
216
+ self.type: str = cfg["type"] # "openai" | "gradio"
217
+ self.hf_token: str = cfg.get("hf_token", "")
218
+ self.supports_thinking: bool = cfg.get("supports_thinking", True)
219
+ self.busy: bool = False
220
+ self.ready: bool = False
221
+ self.lock: asyncio.Lock = asyncio.Lock()
222
+ self._ready_event: asyncio.Event = asyncio.Event()
223
+
224
+ def __repr__(self):
225
+ s = "ready" if self.ready else "loading"
226
+ b = "busy" if self.busy else "free"
227
+ return f"<{self.name} [{self.type}] {s}/{b}>"
228
+
229
+
230
+ spaces: list[SpaceState] = [SpaceState(cfg) for cfg in SPACES]
231
+
232
+
233
+ # ── Health checks (type-aware) ────────────────────────────────────────────────
234
+
235
+ async def check_health_openai(space: SpaceState) -> bool:
236
+ """openai spaces: GET /health must return {"ready": true}"""
237
+ try:
238
+ async with httpx.AsyncClient(timeout=10) as client:
239
+ r = await client.get(f"{space.url}/health")
240
+ if r.status_code != 200:
241
+ return False
242
+ data = r.json()
243
+ return bool(data.get("ready", False))
244
+ except Exception:
245
+ return False
246
+
247
+
248
+ async def check_health_gradio(space: SpaceState) -> bool:
249
+ """
250
+ Gradio spaces: GET /health returns {"status": "ok", "model": "..."}
251
+ No "ready" field β€” if it responds with status=ok it IS ready.
252
+ We also try the Gradio queue info endpoint as a fallback.
253
+ """
254
+ try:
255
+ async with httpx.AsyncClient(timeout=10) as client:
256
+ r = await client.get(f"{space.url}/health")
257
+ if r.status_code == 200:
258
+ data = r.json()
259
+ if data.get("status") == "ok":
260
+ return True
261
+ # Fallback: Gradio exposes /info when the app is up
262
+ r2 = await client.get(f"{space.url}/info")
263
+ return r2.status_code == 200
264
+ except Exception:
265
+ return False
266
+
267
+
268
+ async def check_space_health(space: SpaceState) -> bool:
269
+ if space.type == "openai":
270
+ return await check_health_openai(space)
271
+ else:
272
+ return await check_health_gradio(space)
273
+
274
+
275
+ async def wait_until_ready(space: SpaceState):
276
+ deadline = time.time() + SPACE_READY_TIMEOUT
277
+ while time.time() < deadline:
278
+ if await check_space_health(space):
279
+ space.ready = True
280
+ space._ready_event.set()
281
+ log.info(f"Ready: {space}")
282
+ return
283
+ log.debug(f"Not ready yet: {space.name}")
284
+ await asyncio.sleep(POLL_INTERVAL)
285
+ log.warning(f"Timed out waiting for: {space.name}")
286
+
287
+
288
+ @app.on_event("startup")
289
+ async def startup():
290
+ for space in spaces:
291
+ asyncio.create_task(wait_until_ready(space))
292
+ log.info(f"Proxy started β€” {len(spaces)} space(s) across "
293
+ f"{len(set(s.model_id for s in spaces))} model(s)")
294
+
295
+
296
+ # ── Load balancer ─────────────────────────────────────────────────────────────
297
+
298
+ async def acquire_space(model_id: str) -> SpaceState:
299
+ candidates = [s for s in spaces if s.model_id == model_id]
300
+ if not candidates:
301
+ raise HTTPException(404, detail=f"No space configured for model '{model_id}'")
302
+
303
+ # Wait for at least one candidate to be ready
304
+ ready_tasks = [asyncio.create_task(s._ready_event.wait()) for s in candidates]
305
+ done, pending = await asyncio.wait(ready_tasks, return_when=asyncio.FIRST_COMPLETED)
306
+ for t in pending:
307
+ t.cancel()
308
+
309
+ while True:
310
+ for space in candidates:
311
+ if space.ready and not space.busy:
312
+ async with space.lock:
313
+ if not space.busy:
314
+ space.busy = True
315
+ log.info(f"Acquired {space.name}")
316
+ return space
317
+ await asyncio.sleep(0.5)
318
+
319
+
320
+ def release_space(space: SpaceState):
321
+ space.busy = False
322
+ log.info(f"Released {space.name}")
323
+
324
+
325
+ # ── Chat adapters ─────────────────────────────────────────────────────────────
326
+ #
327
+ # openai spaces β†’ forward body unchanged to /v1/chat/completions
328
+ # gradio spaces β†’ call /run/chat_completions with messages serialised as JSON
329
+ # string; get back a plain text / JSON response and wrap it
330
+ # into an OpenAI-shaped reply for Paperclip.
331
+
332
+ async def call_openai_space(space: SpaceState, body: dict) -> dict:
333
+ async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
334
+ r = await client.post(
335
+ f"{space.url}/v1/chat/completions",
336
+ json=body,
337
+ headers={"Content-Type": "application/json"},
338
+ )
339
+ r.raise_for_status()
340
+ return r.json()
341
+
342
+
343
+ async def stream_openai_space(space: SpaceState, body: dict):
344
+ async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
345
+ async with client.stream(
346
+ "POST",
347
+ f"{space.url}/v1/chat/completions",
348
+ json=body,
349
+ headers={"Content-Type": "application/json"},
350
+ ) as r:
351
+ async for chunk in r.aiter_bytes():
352
+ yield chunk
353
+
354
+
355
+ async def call_gradio_space(space: SpaceState, body: dict) -> dict:
356
+ """
357
+ Call a Gradio space via the gradio_client library so the request is
358
+ routed through the caller's HF Pro GPU quota.
359
+
360
+ gradio_client.Client.predict() is synchronous, so we run it in a
361
+ thread-pool to avoid blocking the event loop.
362
+ """
363
+ messages = body.get("messages", [])
364
+ max_tokens = body.get("max_tokens", 512)
365
+ temperature = body.get("temperature", 0.7)
366
+ top_p = body.get("top_p", 0.9)
367
+ enable_thinking = body.get("enable_thinking", False)
368
+ messages_json = json.dumps(messages)
369
+
370
+ # The upstream vLLM/transformers backend rejects temperature=0 with a
371
+ # ValueError. Clamp it to the smallest positive value that works.
372
+ if temperature == 0:
373
+ temperature = 0.01
374
+
375
+ # Prefer per-space token, fall back to the global HF_TOKEN secret
376
+ token = space.hf_token or HF_TOKEN or None
377
+
378
+ # Use space_id (e.g. "fomext/intelect_module_trial") if set,
379
+ # otherwise fall back to the bare URL.
380
+ src = space.space_id if space.space_id else space.url
381
+
382
+ def _call_sync() -> str:
383
+ client = GradioClient(src, token=token)
384
+ kwargs = dict(
385
+ messages_json=messages_json,
386
+ max_tokens=max_tokens,
387
+ temperature=temperature,
388
+ top_p=top_p,
389
+ api_name="/chat_completions",
390
+ )
391
+ # Only pass enable_thinking to spaces that support it (e.g. reasoning
392
+ # models). Coder spaces reject it with a keyword-argument error.
393
+ if space.supports_thinking:
394
+ kwargs["enable_thinking"] = enable_thinking
395
+ return client.predict(**kwargs)
396
+
397
+ loop = asyncio.get_event_loop()
398
+ raw = await loop.run_in_executor(None, _call_sync)
399
+
400
+ # raw is a JSON string returned by the Gradio endpoint
401
+ if isinstance(raw, str):
402
+ parsed = json.loads(raw)
403
+ else:
404
+ parsed = raw
405
+
406
+ # If the space returned an error dict, surface it as a 502 rather than
407
+ # silently wrapping the error string as model content.
408
+ if "error" in parsed and "choices" not in parsed:
409
+ raise HTTPException(502, detail=f"Upstream error: {parsed['error']}")
410
+
411
+ if "choices" in parsed:
412
+ return parsed
413
+
414
+ content = parsed.get("content") or parsed.get("text") or str(parsed)
415
+ return _wrap_as_openai(content, body.get("model", space.model_id))
416
+
417
+
418
+ def _wrap_as_openai(content: str, model_id: str) -> dict:
419
+ """Wrap a plain text response into an OpenAI chat.completion shape."""
420
+ return {
421
+ "id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
422
+ "object": "chat.completion",
423
+ "created": int(time.time()),
424
+ "model": model_id,
425
+ "choices": [{
426
+ "index": 0,
427
+ "message": {"role": "assistant", "content": content},
428
+ "finish_reason": "stop",
429
+ }],
430
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
431
+ }
432
+
433
+
434
+ def _gradio_response_as_sse(openai_response: dict) -> bytes:
435
+ """Convert a full OpenAI response dict into a single SSE event + DONE."""
436
+ # Emit one delta chunk then [DONE]
437
+ content = openai_response["choices"][0]["message"]["content"]
438
+ chunk = {
439
+ "id": openai_response["id"],
440
+ "object": "chat.completion.chunk",
441
+ "created": openai_response["created"],
442
+ "model": openai_response["model"],
443
+ "choices": [{
444
+ "index": 0,
445
+ "delta": {"role": "assistant", "content": content},
446
+ "finish_reason": "stop",
447
+ }],
448
+ }
449
+ data = f"data: {json.dumps(chunk)}\n\n".encode()
450
+ done = b"data: [DONE]\n\n"
451
+ return data + done
452
+
453
+
454
+ # ── Routes ────────────────────────────────────────────────────────────────────
455
+
456
+ @app.get("/")
457
+ async def root():
458
+ return {"status": "ok", "spaces": len(spaces)}
459
+
460
+
461
+ @app.get("/health")
462
+ async def health():
463
+ statuses = [
464
+ {
465
+ "name": s.name,
466
+ "model": s.model_id,
467
+ "type": s.type,
468
+ "ready": s.ready,
469
+ "busy": s.busy,
470
+ }
471
+ for s in spaces
472
+ ]
473
+ return {
474
+ "ready": any(s.ready for s in spaces),
475
+ "spaces": statuses,
476
+ }
477
+
478
+
479
+ @app.get("/v1/models")
480
+ async def list_models():
481
+ seen, models = set(), []
482
+ for s in spaces:
483
+ if s.model_id not in seen:
484
+ seen.add(s.model_id)
485
+ models.append({
486
+ "id": s.model_id,
487
+ "object": "model",
488
+ "created": 0,
489
+ "owned_by": "huggingface-spaces",
490
+ })
491
+ return {"object": "list", "data": models}
492
+
493
+
494
+ @app.post("/v1/chat/completions")
495
+ async def chat_completions(request: Request):
496
+ body = await request.json()
497
+ model_id = body.get("model", "")
498
+ is_stream = body.get("stream", False)
499
+
500
+ # Resolve any alias (e.g. "gpt-5.1-codex-mini" β†’ "qwen3-coder-30b-a3b-instruct-fp8")
501
+ # then fall back to DEFAULT_MODEL if the name is still unknown.
502
+ resolved_id = MODEL_ALIASES.get(model_id, model_id) or DEFAULT_MODEL
503
+ if resolved_id != model_id:
504
+ log.info(f"Model alias: '{model_id}' β†’ '{resolved_id}'")
505
+ model_id = resolved_id
506
+ if not any(s.model_id == model_id for s in spaces):
507
+ log.warning(f"Unknown model '{model_id}', falling back to '{DEFAULT_MODEL}'")
508
+ model_id = DEFAULT_MODEL
509
+ body["model"] = model_id # keep body in sync so upstream sees the real name
510
+
511
+ space = await acquire_space(model_id)
512
+
513
+ try:
514
+ # ── openai-type space ─────────────────────────────────────────────
515
+ if space.type == "openai":
516
+ if is_stream:
517
+ return StreamingResponse(
518
+ _stream_openai(space, body),
519
+ media_type="text/event-stream",
520
+ )
521
+ else:
522
+ return await _non_stream_openai(space, body)
523
+
524
+ # ── gradio-type space ─────────────────────────────────────────────
525
+ else:
526
+ # Gradio spaces don't support true streaming from this proxy.
527
+ # We call the endpoint, get the full response, then either
528
+ # return it directly or wrap it as a single SSE event.
529
+ try:
530
+ response = await call_gradio_space(space, body)
531
+ release_space(space)
532
+ except Exception as e:
533
+ release_space(space)
534
+ log.error(f"Gradio error ({space.name}): {e}")
535
+ raise HTTPException(502, detail=f"Upstream error: {e}")
536
+
537
+ if is_stream:
538
+ # Paperclip asked for streaming β€” fake it with one big chunk
539
+ sse_bytes = _gradio_response_as_sse(response)
540
+ async def _single_chunk():
541
+ yield sse_bytes
542
+ return StreamingResponse(_single_chunk(), media_type="text/event-stream")
543
+ else:
544
+ return JSONResponse(content=response)
545
+
546
+ except HTTPException:
547
+ raise
548
+ except Exception:
549
+ release_space(space)
550
+ raise
551
+
552
+
553
+ async def _non_stream_openai(space: SpaceState, body: dict):
554
+ try:
555
+ result = await call_openai_space(space, body)
556
+ release_space(space)
557
+ return JSONResponse(content=result)
558
+ except Exception as e:
559
+ release_space(space)
560
+ log.error(f"Upstream error ({space.name}): {e}")
561
+ raise HTTPException(502, detail=f"Upstream error: {e}")
562
+
563
+
564
+ async def _stream_openai(space: SpaceState, body: dict):
565
+ try:
566
+ async for chunk in stream_openai_space(space, body):
567
+ yield chunk
568
+ except Exception as e:
569
+ log.error(f"Stream error ({space.name}): {e}")
570
+ yield b"data: [DONE]\n\n"
571
+ finally:
572
+ release_space(space)