Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| HuggingFace Spaces β OpenAI-compatible API Proxy | |
| ================================================= | |
| Exposes /v1/models and /v1/chat/completions (streaming + non-streaming). | |
| Balances across multiple HF Spaces, queuing requests when all are busy. | |
| Each space has a "type" that controls how the proxy talks to it: | |
| "openai" β spaces that expose a real HTTP OpenAI-compatible API | |
| Health: GET /health β {"ready": true/false, "status": "..."} | |
| Chat: POST /v1/chat/completions (streaming supported) | |
| Example: (none currently β all spaces use gradio type) | |
| "gradio" β spaces built with Gradio, called via the gradio_client library | |
| so that requests are routed through the HF Pro GPU quota. | |
| Health: GET /health β {"status": "ok", "model": "..."} | |
| (no "ready" field β if it responds at all, it's ready) | |
| Chat: gradio_client.Client(space_id, token=HF_TOKEN) | |
| .predict(messages_json=..., api_name="/chat_completions") | |
| Token: read from the HF_TOKEN environment variable / secret | |
| Example: qwen3-14b (fallback_module_trial spaces) | |
| qwen3-30b-a3b (intelect_module spaces) | |
| qwen3-coder-30b (coder_v2 spaces) | |
| """ | |
| import asyncio | |
| import json | |
| import logging | |
| import os | |
| import time | |
| import uuid | |
| import httpx | |
| from gradio_client import Client as GradioClient | |
| from fastapi import FastAPI, HTTPException, Request | |
| from fastapi.responses import StreamingResponse, JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from typing import Optional | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CONFIGURE YOUR SPACES HERE | |
| # | |
| # Required fields for every space: | |
| # url β base URL of the HF Space | |
| # model_id β model name exposed to clients (e.g. Paperclip) | |
| # name β human-readable label used in logs | |
| # type β "openai" or "gradio" (controls how the proxy talks to it) | |
| # | |
| # Required for gradio spaces: | |
| # space_id β HF repo id, e.g. "fomext/intelect_module_trial" | |
| # used by gradio_client so requests hit your Pro GPU quota | |
| # | |
| # Optional: | |
| # hf_token β per-space HF token override (falls back to HF_TOKEN secret) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| SPACES = [ | |
| # ββ qwen3-14b (gradio type β called via gradio_client) βββββββββββββββββ | |
| { | |
| "url": "https://fomext-intelect-module-v3.hf.space", | |
| "space_id": "fomext/intelect_module_v3", | |
| "model_id": "qwen3-14b", | |
| "name": "14b Reasoning (Space 5)", | |
| "type": "gradio", | |
| "supports_thinking": False, | |
| }, | |
| { | |
| "url": "https://fomext-intelect-module-v3-1.hf.space", | |
| "space_id": "fomext/intelect_module_v3_1", | |
| "model_id": "qwen3-14b", | |
| "name": "14b Reasoning (Space 4)", | |
| "type": "gradio", | |
| "supports_thinking": False, | |
| }, | |
| { | |
| "url": "https://fomext-intelect-module-v3-2.hf.space", | |
| "space_id": "fomext/intelect_module_v3_2", | |
| "model_id": "qwen3-14b", | |
| "name": "14b Reasoning (Space 3)", | |
| "type": "gradio", | |
| "supports_thinking": False, | |
| }, | |
| { | |
| "url": "https://fomext-intelect-module-v3-3.hf.space", | |
| "space_id": "fomext/intelect_module_v3_3", | |
| "model_id": "qwen3-14b", | |
| "name": "14b Reasoning (Space 2)", | |
| "type": "gradio", | |
| "supports_thinking": False, | |
| }, | |
| { | |
| "url": "https://fomext-intelect-module-v3-4.hf.space", | |
| "space_id": "fomext/intelect_module_v3_4", | |
| "model_id": "qwen3-14b", | |
| "name": "14b Reasoning (Space 1)", | |
| "type": "gradio", | |
| "supports_thinking": False, | |
| }, | |
| # ββ qwen3-coder-30b (gradio type β called via gradio_client) βββββββββββ | |
| # NOTE: coder spaces do NOT accept the enable_thinking parameter | |
| { | |
| "url": "https://fomext-coder-v2-trial.hf.space", | |
| "space_id": "fomext/coder_v2_trial", | |
| "model_id": "qwen3-coder-30b-a3b-instruct-fp8", | |
| "name": "Coder 30b (Space 1)", | |
| "type": "gradio", | |
| "supports_thinking": False, | |
| }, | |
| { | |
| "url": "https://fomext-coder-v2-trial2.hf.space", | |
| "space_id": "fomext/coder_v2_trial2", | |
| "model_id": "qwen3-coder-30b-a3b-instruct-fp8", | |
| "name": "Coder 30b (Space 2)", | |
| "type": "gradio", | |
| "supports_thinking": False, | |
| }, | |
| { | |
| "url": "https://fomext-coder-v2-trial3.hf.space", | |
| "space_id": "fomext/coder_v2_trial3", | |
| "model_id": "qwen3-coder-30b-a3b-instruct-fp8", | |
| "name": "Coder 30b (Space 3)", | |
| "type": "gradio", | |
| "supports_thinking": False, | |
| }, | |
| # ββ qwen3-30b-a3b (gradio type β called via gradio_client) βββββββββββββ | |
| { | |
| "url": "https://fomext-intelect_module_trial.hf.space", | |
| "space_id": "fomext/intelect_module_trial", | |
| "model_id": "qwen3-30b-a3b", | |
| "name": "30b Reasoning (Space 1)", | |
| "type": "gradio", | |
| "supports_thinking": True, | |
| }, | |
| { | |
| "url": "https://fomext-intelect_module_trial2.hf.space", | |
| "space_id": "fomext/intelect_module_trial2", | |
| "model_id": "qwen3-30b-a3b", | |
| "name": "30b Reasoning (Space 2)", | |
| "type": "gradio", | |
| "supports_thinking": True, | |
| }, | |
| { | |
| "url": "https://fomext-intelect_module_trial3.hf.space", | |
| "space_id": "fomext/intelect_module_trial3", | |
| "model_id": "qwen3-30b-a3b", | |
| "name": "30b Reasoning (Space 3)", | |
| "type": "gradio", | |
| "supports_thinking": True, | |
| }, | |
| ] | |
| # ββ Model aliases ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Maps external model names (e.g. OpenAI names sent by Paperclip/OpenCode) | |
| # to the actual model IDs configured in SPACES above. | |
| # Add any new aliases here β no other code needs to change. | |
| MODEL_ALIASES: dict[str, str] = { | |
| # OpenAI codex / GPT names β coder model | |
| "gpt-5.1-codex-mini": "qwen3-coder-30b-a3b-instruct-fp8", | |
| "gpt-5.1-codex": "qwen3-coder-30b-a3b-instruct-fp8", | |
| "code-davinci-002": "qwen3-coder-30b-a3b-instruct-fp8", | |
| # GPT-4-class names β 30b reasoning model | |
| "gpt-4o": "qwen3-30b-a3b", | |
| "gpt-4o-mini": "qwen3-14b", | |
| "gpt-4": "qwen3-30b-a3b", | |
| "gpt-4-turbo": "qwen3-14b", | |
| "gpt-4-turbo-preview": "qwen3-14b", | |
| # GPT-3.5 names β 14b model | |
| "gpt-3.5-turbo": "qwen3-14b", | |
| "gpt-3.5-turbo-16k": "qwen3-14b", | |
| } | |
| # Fallback model when the requested name isn't in SPACES or MODEL_ALIASES | |
| DEFAULT_MODEL = "qwen3-14b" | |
| # HF token for Gradio spaces β set this as a secret called HF_TOKEN | |
| HF_TOKEN = os.environ.get("HF_TOKEN", "") | |
| SPACE_READY_TIMEOUT = 600 | |
| # Seconds between health polls | |
| POLL_INTERVAL = 10 | |
| # Upstream request timeout | |
| REQUEST_TIMEOUT = 300 | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s %(levelname)-8s %(message)s", | |
| datefmt="%H:%M:%S", | |
| ) | |
| log = logging.getLogger("hf-proxy") | |
| app = FastAPI(title="HF Spaces OpenAI Proxy", version="2.0.0") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ββ Space state βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SpaceState: | |
| def __init__(self, cfg: dict): | |
| self.url: str = cfg["url"].rstrip("/") | |
| self.space_id: str = cfg.get("space_id", "") # e.g. "fomext/intelect_module_trial" | |
| self.model_id: str = cfg["model_id"] | |
| self.name: str = cfg["name"] | |
| self.type: str = cfg["type"] # "openai" | "gradio" | |
| self.hf_token: str = cfg.get("hf_token", "") | |
| self.supports_thinking: bool = cfg.get("supports_thinking", True) | |
| self.busy: bool = False | |
| self.ready: bool = False | |
| self.lock: asyncio.Lock = asyncio.Lock() | |
| self._ready_event: asyncio.Event = asyncio.Event() | |
| def __repr__(self): | |
| s = "ready" if self.ready else "loading" | |
| b = "busy" if self.busy else "free" | |
| return f"<{self.name} [{self.type}] {s}/{b}>" | |
| spaces: list[SpaceState] = [SpaceState(cfg) for cfg in SPACES] | |
| # ββ Health checks (type-aware) ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def check_health_openai(space: SpaceState) -> bool: | |
| """openai spaces: GET /health must return {"ready": true}""" | |
| try: | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| r = await client.get(f"{space.url}/health") | |
| if r.status_code != 200: | |
| return False | |
| data = r.json() | |
| return bool(data.get("ready", False)) | |
| except Exception: | |
| return False | |
| async def check_health_gradio(space: SpaceState) -> bool: | |
| """ | |
| Gradio spaces: GET /health returns {"status": "ok", "model": "..."} | |
| No "ready" field β if it responds with status=ok it IS ready. | |
| We also try the Gradio queue info endpoint as a fallback. | |
| """ | |
| try: | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| r = await client.get(f"{space.url}/health") | |
| if r.status_code == 200: | |
| data = r.json() | |
| if data.get("status") == "ok": | |
| return True | |
| # Fallback: Gradio exposes /info when the app is up | |
| r2 = await client.get(f"{space.url}/info") | |
| return r2.status_code == 200 | |
| except Exception: | |
| return False | |
| async def check_space_health(space: SpaceState) -> bool: | |
| if space.type == "openai": | |
| return await check_health_openai(space) | |
| else: | |
| return await check_health_gradio(space) | |
| async def wait_until_ready(space: SpaceState): | |
| deadline = time.time() + SPACE_READY_TIMEOUT | |
| while time.time() < deadline: | |
| if await check_space_health(space): | |
| space.ready = True | |
| space._ready_event.set() | |
| log.info(f"Ready: {space}") | |
| return | |
| log.debug(f"Not ready yet: {space.name}") | |
| await asyncio.sleep(POLL_INTERVAL) | |
| log.warning(f"Timed out waiting for: {space.name}") | |
| async def startup(): | |
| for space in spaces: | |
| asyncio.create_task(wait_until_ready(space)) | |
| log.info(f"Proxy started β {len(spaces)} space(s) across " | |
| f"{len(set(s.model_id for s in spaces))} model(s)") | |
| # ββ Load balancer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def acquire_space(model_id: str) -> SpaceState: | |
| candidates = [s for s in spaces if s.model_id == model_id] | |
| if not candidates: | |
| raise HTTPException(404, detail=f"No space configured for model '{model_id}'") | |
| # Wait for at least one candidate to be ready | |
| ready_tasks = [asyncio.create_task(s._ready_event.wait()) for s in candidates] | |
| done, pending = await asyncio.wait(ready_tasks, return_when=asyncio.FIRST_COMPLETED) | |
| for t in pending: | |
| t.cancel() | |
| while True: | |
| for space in candidates: | |
| if space.ready and not space.busy: | |
| async with space.lock: | |
| if not space.busy: | |
| space.busy = True | |
| log.info(f"Acquired {space.name}") | |
| return space | |
| await asyncio.sleep(0.5) | |
| def release_space(space: SpaceState): | |
| space.busy = False | |
| log.info(f"Released {space.name}") | |
| # ββ Chat adapters βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # | |
| # openai spaces β forward body unchanged to /v1/chat/completions | |
| # gradio spaces β call /run/chat_completions with messages serialised as JSON | |
| # string; get back a plain text / JSON response and wrap it | |
| # into an OpenAI-shaped reply for Paperclip. | |
| async def call_openai_space(space: SpaceState, body: dict) -> dict: | |
| async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: | |
| r = await client.post( | |
| f"{space.url}/v1/chat/completions", | |
| json=body, | |
| headers={"Content-Type": "application/json"}, | |
| ) | |
| r.raise_for_status() | |
| return r.json() | |
| async def stream_openai_space(space: SpaceState, body: dict): | |
| async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: | |
| async with client.stream( | |
| "POST", | |
| f"{space.url}/v1/chat/completions", | |
| json=body, | |
| headers={"Content-Type": "application/json"}, | |
| ) as r: | |
| async for chunk in r.aiter_bytes(): | |
| yield chunk | |
| async def call_gradio_space(space: SpaceState, body: dict) -> dict: | |
| """ | |
| Call a Gradio space via the gradio_client library so the request is | |
| routed through the caller's HF Pro GPU quota. | |
| gradio_client.Client.predict() is synchronous, so we run it in a | |
| thread-pool to avoid blocking the event loop. | |
| """ | |
| messages = body.get("messages", []) | |
| max_tokens = body.get("max_tokens", 512) | |
| temperature = body.get("temperature", 0.7) | |
| top_p = body.get("top_p", 0.9) | |
| enable_thinking = body.get("enable_thinking", False) | |
| messages_json = json.dumps(messages) | |
| # The upstream vLLM/transformers backend rejects temperature=0 with a | |
| # ValueError. Clamp it to the smallest positive value that works. | |
| if temperature == 0: | |
| temperature = 0.01 | |
| # Prefer per-space token, fall back to the global HF_TOKEN secret | |
| token = space.hf_token or HF_TOKEN or None | |
| # Use space_id (e.g. "fomext/intelect_module_trial") if set, | |
| # otherwise fall back to the bare URL. | |
| src = space.space_id if space.space_id else space.url | |
| def _call_sync() -> str: | |
| client = GradioClient(src, token=token) | |
| kwargs = dict( | |
| messages_json=messages_json, | |
| max_tokens=max_tokens, | |
| temperature=temperature, | |
| top_p=top_p, | |
| api_name="/chat_completions", | |
| ) | |
| # Only pass enable_thinking to spaces that support it (e.g. reasoning | |
| # models). Coder spaces reject it with a keyword-argument error. | |
| if space.supports_thinking: | |
| kwargs["enable_thinking"] = enable_thinking | |
| return client.predict(**kwargs) | |
| loop = asyncio.get_event_loop() | |
| raw = await loop.run_in_executor(None, _call_sync) | |
| # raw is a JSON string returned by the Gradio endpoint | |
| if isinstance(raw, str): | |
| parsed = json.loads(raw) | |
| else: | |
| parsed = raw | |
| # If the space returned an error dict, surface it as a 502 rather than | |
| # silently wrapping the error string as model content. | |
| if "error" in parsed and "choices" not in parsed: | |
| raise HTTPException(502, detail=f"Upstream error: {parsed['error']}") | |
| if "choices" in parsed: | |
| return parsed | |
| content = parsed.get("content") or parsed.get("text") or str(parsed) | |
| return _wrap_as_openai(content, body.get("model", space.model_id)) | |
| def _wrap_as_openai(content: str, model_id: str) -> dict: | |
| """Wrap a plain text response into an OpenAI chat.completion shape.""" | |
| return { | |
| "id": f"chatcmpl-{uuid.uuid4().hex[:12]}", | |
| "object": "chat.completion", | |
| "created": int(time.time()), | |
| "model": model_id, | |
| "choices": [{ | |
| "index": 0, | |
| "message": {"role": "assistant", "content": content}, | |
| "finish_reason": "stop", | |
| }], | |
| "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, | |
| } | |
| def _gradio_response_as_sse(openai_response: dict) -> bytes: | |
| """Convert a full OpenAI response dict into a single SSE event + DONE.""" | |
| # Emit one delta chunk then [DONE] | |
| content = openai_response["choices"][0]["message"]["content"] | |
| chunk = { | |
| "id": openai_response["id"], | |
| "object": "chat.completion.chunk", | |
| "created": openai_response["created"], | |
| "model": openai_response["model"], | |
| "choices": [{ | |
| "index": 0, | |
| "delta": {"role": "assistant", "content": content}, | |
| "finish_reason": "stop", | |
| }], | |
| } | |
| data = f"data: {json.dumps(chunk)}\n\n".encode() | |
| done = b"data: [DONE]\n\n" | |
| return data + done | |
| # ββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def root(): | |
| return {"status": "ok", "spaces": len(spaces)} | |
| async def health(): | |
| statuses = [ | |
| { | |
| "name": s.name, | |
| "model": s.model_id, | |
| "type": s.type, | |
| "ready": s.ready, | |
| "busy": s.busy, | |
| } | |
| for s in spaces | |
| ] | |
| return { | |
| "ready": any(s.ready for s in spaces), | |
| "spaces": statuses, | |
| } | |
| async def list_models(): | |
| seen, models = set(), [] | |
| for s in spaces: | |
| if s.model_id not in seen: | |
| seen.add(s.model_id) | |
| models.append({ | |
| "id": s.model_id, | |
| "object": "model", | |
| "created": 0, | |
| "owned_by": "huggingface-spaces", | |
| }) | |
| return {"object": "list", "data": models} | |
| async def chat_completions(request: Request): | |
| body = await request.json() | |
| model_id = body.get("model", "") | |
| is_stream = body.get("stream", False) | |
| # Resolve any alias (e.g. "gpt-5.1-codex-mini" β "qwen3-coder-30b-a3b-instruct-fp8") | |
| # then fall back to DEFAULT_MODEL if the name is still unknown. | |
| resolved_id = MODEL_ALIASES.get(model_id, model_id) or DEFAULT_MODEL | |
| if resolved_id != model_id: | |
| log.info(f"Model alias: '{model_id}' β '{resolved_id}'") | |
| model_id = resolved_id | |
| if not any(s.model_id == model_id for s in spaces): | |
| log.warning(f"Unknown model '{model_id}', falling back to '{DEFAULT_MODEL}'") | |
| model_id = DEFAULT_MODEL | |
| body["model"] = model_id # keep body in sync so upstream sees the real name | |
| space = await acquire_space(model_id) | |
| try: | |
| # ββ openai-type space βββββββββββββββββββββββββββββββββββββββββββββ | |
| if space.type == "openai": | |
| if is_stream: | |
| return StreamingResponse( | |
| _stream_openai(space, body), | |
| media_type="text/event-stream", | |
| ) | |
| else: | |
| return await _non_stream_openai(space, body) | |
| # ββ gradio-type space βββββββββββββββββββββββββββββββββββββββββββββ | |
| else: | |
| # Gradio spaces don't support true streaming from this proxy. | |
| # We call the endpoint, get the full response, then either | |
| # return it directly or wrap it as a single SSE event. | |
| try: | |
| response = await call_gradio_space(space, body) | |
| release_space(space) | |
| except Exception as e: | |
| release_space(space) | |
| log.error(f"Gradio error ({space.name}): {e}") | |
| raise HTTPException(502, detail=f"Upstream error: {e}") | |
| if is_stream: | |
| # Paperclip asked for streaming β fake it with one big chunk | |
| sse_bytes = _gradio_response_as_sse(response) | |
| async def _single_chunk(): | |
| yield sse_bytes | |
| return StreamingResponse(_single_chunk(), media_type="text/event-stream") | |
| else: | |
| return JSONResponse(content=response) | |
| except HTTPException: | |
| raise | |
| except Exception: | |
| release_space(space) | |
| raise | |
| async def _non_stream_openai(space: SpaceState, body: dict): | |
| try: | |
| result = await call_openai_space(space, body) | |
| release_space(space) | |
| return JSONResponse(content=result) | |
| except Exception as e: | |
| release_space(space) | |
| log.error(f"Upstream error ({space.name}): {e}") | |
| raise HTTPException(502, detail=f"Upstream error: {e}") | |
| async def _stream_openai(space: SpaceState, body: dict): | |
| try: | |
| async for chunk in stream_openai_space(space, body): | |
| yield chunk | |
| except Exception as e: | |
| log.error(f"Stream error ({space.name}): {e}") | |
| yield b"data: [DONE]\n\n" | |
| finally: | |
| release_space(space) | |