shopseenow / main.py
fomext's picture
Upload main.py
19449c6 verified
Raw
History Blame Contribute Delete
22.7 kB
#!/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}")
@app.on_event("startup")
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 ────────────────────────────────────────────────────────────────────
@app.get("/")
async def root():
return {"status": "ok", "spaces": len(spaces)}
@app.get("/health")
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,
}
@app.get("/v1/models")
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}
@app.post("/v1/chat/completions")
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)