Spaces:
Running
Running
Upload 5 files
Browse files- README.md +5 -5
- _shared_logic.py +3 -1
- app.py +303 -57
- requirements.txt +5 -1
README.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
| 1 |
---
|
| 2 |
-
pinned: false
|
| 3 |
title: sphinx-ai-assistant proxy
|
| 4 |
emoji: 🔁
|
| 5 |
colorFrom: blue
|
| 6 |
colorTo: green
|
| 7 |
-
|
| 8 |
-
|
|
|
|
| 9 |
hf_oauth: true
|
| 10 |
hf_oauth_scopes:
|
| 11 |
- inference-api
|
| 12 |
-
|
| 13 |
-
|
| 14 |
---
|
| 15 |
|
| 16 |
# sphinx-ai-assistant proxy
|
|
|
|
| 1 |
---
|
|
|
|
| 2 |
title: sphinx-ai-assistant proxy
|
| 3 |
emoji: 🔁
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
hf_oauth: true
|
| 10 |
hf_oauth_scopes:
|
| 11 |
- inference-api
|
| 12 |
+
license: bsd-3-clause
|
| 13 |
+
short_description: ai
|
| 14 |
---
|
| 15 |
|
| 16 |
# sphinx-ai-assistant proxy
|
_shared_logic.py
CHANGED
|
@@ -482,7 +482,9 @@ def _resolve_upstream_url(
|
|
| 482 |
hf_base: str = DEFAULT_HF_BASE,
|
| 483 |
default_model: str = DEFAULT_MODEL,
|
| 484 |
hf_spaces_model_url: str = DEFAULT_HF_SPACES_MODEL_URL,
|
| 485 |
-
hf_spaces_model_namespaces:
|
|
|
|
|
|
|
| 486 |
proxy_timeout: float = float(DEFAULT_PROXY_TIMEOUT),
|
| 487 |
path2_read_timeout: float = DEFAULT_PATH2_READ_TIMEOUT,
|
| 488 |
path3_read_timeout: float = DEFAULT_PATH3_READ_TIMEOUT,
|
|
|
|
| 482 |
hf_base: str = DEFAULT_HF_BASE,
|
| 483 |
default_model: str = DEFAULT_MODEL,
|
| 484 |
hf_spaces_model_url: str = DEFAULT_HF_SPACES_MODEL_URL,
|
| 485 |
+
hf_spaces_model_namespaces: (
|
| 486 |
+
tuple[str, ...] | list[str]
|
| 487 |
+
) = DEFAULT_HF_SPACES_MODEL_NAMESPACES,
|
| 488 |
proxy_timeout: float = float(DEFAULT_PROXY_TIMEOUT),
|
| 489 |
path2_read_timeout: float = DEFAULT_PATH2_READ_TIMEOUT,
|
| 490 |
path3_read_timeout: float = DEFAULT_PATH3_READ_TIMEOUT,
|
app.py
CHANGED
|
@@ -97,6 +97,7 @@ Developer note — explicit error handling
|
|
| 97 |
|
| 98 |
from __future__ import annotations
|
| 99 |
|
|
|
|
| 100 |
import json
|
| 101 |
import logging
|
| 102 |
import os
|
|
@@ -147,14 +148,76 @@ except ImportError:
|
|
| 147 |
)
|
| 148 |
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 151 |
# Logging
|
| 152 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 153 |
|
| 154 |
-
logging.
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
logger = logging.getLogger(__name__)
|
| 159 |
|
| 160 |
|
|
@@ -172,23 +235,22 @@ HF_TOKEN: str = os.environ.get("HF_TOKEN", "").strip()
|
|
| 172 |
HF_BASE: str = os.environ.get("HF_BASE", DEFAULT_HF_BASE).rstrip("/")
|
| 173 |
|
| 174 |
#: Fallback model when request body omits ``model``.
|
| 175 |
-
DEFAULT_MODEL = (
|
| 176 |
-
os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL).strip() or DEFAULT_MODEL
|
| 177 |
-
)
|
| 178 |
|
| 179 |
#: Path 2 destination URL — the custom ai-model HF Space.
|
| 180 |
-
HF_SPACES_MODEL_URL: str = (
|
| 181 |
-
|
| 182 |
-
)
|
| 183 |
|
| 184 |
#: Parsed model owner namespaces routed to HF_SPACES_MODEL_URL (Path 2).
|
| 185 |
_raw_namespaces: str = os.environ.get(
|
| 186 |
"HF_SPACES_MODEL_NAMESPACES",
|
| 187 |
",".join(DEFAULT_HF_SPACES_MODEL_NAMESPACES),
|
| 188 |
)
|
| 189 |
-
HF_SPACES_MODEL_NAMESPACES: tuple[str, ...] =
|
| 190 |
-
ns.strip() for ns in _raw_namespaces.split(",") if ns.strip()
|
| 191 |
-
|
|
|
|
| 192 |
|
| 193 |
#: Maximum accepted request body size (bytes).
|
| 194 |
MAX_BODY_BYTES: int = _safe_int(
|
|
@@ -198,10 +260,12 @@ MAX_BODY_BYTES: int = _safe_int(
|
|
| 198 |
|
| 199 |
# ── Per-path read timeouts ────────────────────────────────────────────────────
|
| 200 |
#: Path 1 (BACKEND_URL) read timeout in seconds.
|
| 201 |
-
_proxy_timeout_secs: float = float(
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
| 205 |
|
| 206 |
#: Path 2 (ai-model Space, CPU inference) read timeout in seconds.
|
| 207 |
#: Default 600 s — covers 4-5 min CPU inference with 1 min headroom.
|
|
@@ -237,6 +301,22 @@ _allowed_origins: list[str] = (
|
|
| 237 |
else [o.strip() for o in _raw_origins.split(",") if o.strip()]
|
| 238 |
)
|
| 239 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 242 |
# Startup validation — fail fast with actionable messages
|
|
@@ -334,7 +414,10 @@ app.add_middleware(
|
|
| 334 |
CORSMiddleware,
|
| 335 |
allow_origins=_allowed_origins,
|
| 336 |
allow_methods=["GET", "POST", "OPTIONS"],
|
| 337 |
-
|
|
|
|
|
|
|
|
|
|
| 338 |
allow_credentials=False,
|
| 339 |
)
|
| 340 |
|
|
@@ -515,13 +598,15 @@ async def _forward(body: bytes) -> Response:
|
|
| 515 |
) as upstream:
|
| 516 |
if upstream.status_code != 200: # noqa: PLR2004
|
| 517 |
err_body = await upstream.aread()
|
| 518 |
-
error_payload = json.dumps(
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
"
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
|
|
|
|
|
|
| 525 |
yield f"data: {error_payload}\n\n".encode()
|
| 526 |
else:
|
| 527 |
async for chunk in upstream.aiter_bytes():
|
|
@@ -531,12 +616,14 @@ async def _forward(body: bytes) -> Response:
|
|
| 531 |
err_id = uuid.uuid4().hex
|
| 532 |
logger.warning(
|
| 533 |
"ReadTimeout after %.0f s on streaming request to %s [%s]",
|
| 534 |
-
read_timeout_s,
|
|
|
|
|
|
|
| 535 |
)
|
| 536 |
yield f'data: {{"id":"err-{err_id}","error":{{"status":504,"message":'
|
| 537 |
yield (
|
| 538 |
f'"Upstream timed out after {read_timeout_s:.0f} s. '
|
| 539 |
-
f
|
| 540 |
f'If using the ai-model Space, the model may still be loading."}}}}\n\n'
|
| 541 |
).encode()
|
| 542 |
|
|
@@ -555,7 +642,9 @@ async def _forward(body: bytes) -> Response:
|
|
| 555 |
err_id = uuid.uuid4().hex
|
| 556 |
logger.warning(
|
| 557 |
"RequestError on streaming request to %s: %s [%s]",
|
| 558 |
-
url,
|
|
|
|
|
|
|
| 559 |
)
|
| 560 |
yield (
|
| 561 |
f'data: {{"id":"err-{err_id}","error":{{"status":502,"message":'
|
|
@@ -567,7 +656,7 @@ async def _forward(body: bytes) -> Response:
|
|
| 567 |
status_code=200,
|
| 568 |
media_type="text/event-stream",
|
| 569 |
headers={
|
| 570 |
-
"Cache-Control":
|
| 571 |
"X-Accel-Buffering": "no",
|
| 572 |
},
|
| 573 |
)
|
|
@@ -580,13 +669,14 @@ async def _forward(body: bytes) -> Response:
|
|
| 580 |
except httpx.ReadTimeout:
|
| 581 |
logger.warning(
|
| 582 |
"ReadTimeout after %.0f s on non-streaming request to %s",
|
| 583 |
-
read_timeout_s,
|
|
|
|
| 584 |
)
|
| 585 |
return JSONResponse(
|
| 586 |
status_code=504,
|
| 587 |
content={
|
| 588 |
"error": {
|
| 589 |
-
"type":
|
| 590 |
"message": (
|
| 591 |
f"Upstream timed out after {read_timeout_s:.0f} s. "
|
| 592 |
"CPU inference on the ai-model Space can take 4-5 minutes. "
|
|
@@ -601,7 +691,7 @@ async def _forward(body: bytes) -> Response:
|
|
| 601 |
status_code=504,
|
| 602 |
content={
|
| 603 |
"error": {
|
| 604 |
-
"type":
|
| 605 |
"message": (
|
| 606 |
f"Connection timed out reaching {url}. "
|
| 607 |
"The HF Space may be cold-starting — retry in 30 seconds."
|
|
@@ -615,7 +705,7 @@ async def _forward(body: bytes) -> Response:
|
|
| 615 |
status_code=502,
|
| 616 |
content={
|
| 617 |
"error": {
|
| 618 |
-
"type":
|
| 619 |
"message": f"Failed to reach upstream: {type(exc).__name__}",
|
| 620 |
}
|
| 621 |
},
|
|
@@ -650,30 +740,32 @@ async def root() -> JSONResponse:
|
|
| 650 |
default 600 s). ``path3`` corresponds to the HF Serverless API (GPU,
|
| 651 |
default 120 s).
|
| 652 |
"""
|
| 653 |
-
return JSONResponse(
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
"
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
"
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
"
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
|
|
|
|
|
|
| 677 |
|
| 678 |
|
| 679 |
@app.get("/health")
|
|
@@ -746,3 +838,157 @@ async def chat_completions_alias(body: bytes = Depends(_validated_body)) -> Resp
|
|
| 746 |
to the bare Space URL without the path suffix.
|
| 747 |
"""
|
| 748 |
return await _forward(body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
from __future__ import annotations
|
| 99 |
|
| 100 |
+
import asyncio
|
| 101 |
import json
|
| 102 |
import logging
|
| 103 |
import os
|
|
|
|
| 148 |
)
|
| 149 |
|
| 150 |
|
| 151 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 152 |
+
# Helpers — placed before configuration so they are available at module scope
|
| 153 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 154 |
+
|
| 155 |
+
def _client_ip(request: Request) -> str:
|
| 156 |
+
"""Extract the real client IP from the request.
|
| 157 |
+
|
| 158 |
+
Parameters
|
| 159 |
+
----------
|
| 160 |
+
request : fastapi.Request
|
| 161 |
+
Incoming HTTP request.
|
| 162 |
+
|
| 163 |
+
Returns
|
| 164 |
+
-------
|
| 165 |
+
str
|
| 166 |
+
Best-effort client IP string; ``"unknown"`` when unavailable.
|
| 167 |
+
|
| 168 |
+
Notes
|
| 169 |
+
-----
|
| 170 |
+
Developer: HF Spaces sits behind a proxy so ``request.client.host``
|
| 171 |
+
is the proxy IP, not the user IP. ``X-Forwarded-For`` is the correct
|
| 172 |
+
source — take the FIRST value only (leftmost = original client;
|
| 173 |
+
rightmost values can be spoofed by intermediaries).
|
| 174 |
+
"""
|
| 175 |
+
xff = request.headers.get("x-forwarded-for", "")
|
| 176 |
+
if xff:
|
| 177 |
+
return xff.split(",")[0].strip()
|
| 178 |
+
if request.client:
|
| 179 |
+
return request.client.host or "unknown"
|
| 180 |
+
return "unknown"
|
| 181 |
+
|
| 182 |
+
|
| 183 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 184 |
# Logging
|
| 185 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 186 |
|
| 187 |
+
class _StructuredFormatter(logging.Formatter):
|
| 188 |
+
"""Emit one JSON object per log record to stdout.
|
| 189 |
+
|
| 190 |
+
Parameters
|
| 191 |
+
----------
|
| 192 |
+
*args, **kwargs
|
| 193 |
+
Forwarded to :class:`logging.Formatter`.
|
| 194 |
+
|
| 195 |
+
Notes
|
| 196 |
+
-----
|
| 197 |
+
Developer: JSON format is required for machine-parseable log ingestion
|
| 198 |
+
(HF Spaces log export, Datadog, etc.). Text-format lines require regex
|
| 199 |
+
in log queries; JSON fields are natively queryable.
|
| 200 |
+
|
| 201 |
+
The ``exc_info`` key is omitted entirely when no exception is attached
|
| 202 |
+
so log consumers do not need to handle a null field on every record.
|
| 203 |
+
"""
|
| 204 |
+
|
| 205 |
+
def format(self, record: logging.LogRecord) -> str: # noqa: A003
|
| 206 |
+
payload: dict = {
|
| 207 |
+
"ts": self.formatTime(record, datefmt="%Y-%m-%dT%H:%M:%S"),
|
| 208 |
+
"level": record.levelname,
|
| 209 |
+
"logger": record.name,
|
| 210 |
+
"event": record.getMessage(),
|
| 211 |
+
}
|
| 212 |
+
if record.exc_info:
|
| 213 |
+
payload["exc_info"] = self.formatException(record.exc_info)
|
| 214 |
+
return json.dumps(payload, ensure_ascii=False)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
_handler = logging.StreamHandler()
|
| 218 |
+
_handler.setFormatter(_StructuredFormatter())
|
| 219 |
+
logging.root.handlers = [_handler]
|
| 220 |
+
logging.root.setLevel(logging.INFO)
|
| 221 |
logger = logging.getLogger(__name__)
|
| 222 |
|
| 223 |
|
|
|
|
| 235 |
HF_BASE: str = os.environ.get("HF_BASE", DEFAULT_HF_BASE).rstrip("/")
|
| 236 |
|
| 237 |
#: Fallback model when request body omits ``model``.
|
| 238 |
+
DEFAULT_MODEL = os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL).strip() or DEFAULT_MODEL
|
|
|
|
|
|
|
| 239 |
|
| 240 |
#: Path 2 destination URL — the custom ai-model HF Space.
|
| 241 |
+
HF_SPACES_MODEL_URL: str = os.environ.get(
|
| 242 |
+
"HF_SPACES_MODEL_URL", DEFAULT_HF_SPACES_MODEL_URL
|
| 243 |
+
).strip()
|
| 244 |
|
| 245 |
#: Parsed model owner namespaces routed to HF_SPACES_MODEL_URL (Path 2).
|
| 246 |
_raw_namespaces: str = os.environ.get(
|
| 247 |
"HF_SPACES_MODEL_NAMESPACES",
|
| 248 |
",".join(DEFAULT_HF_SPACES_MODEL_NAMESPACES),
|
| 249 |
)
|
| 250 |
+
HF_SPACES_MODEL_NAMESPACES: tuple[str, ...] = (
|
| 251 |
+
tuple(ns.strip() for ns in _raw_namespaces.split(",") if ns.strip())
|
| 252 |
+
or DEFAULT_HF_SPACES_MODEL_NAMESPACES
|
| 253 |
+
)
|
| 254 |
|
| 255 |
#: Maximum accepted request body size (bytes).
|
| 256 |
MAX_BODY_BYTES: int = _safe_int(
|
|
|
|
| 260 |
|
| 261 |
# ── Per-path read timeouts ────────────────────────────────────────────────────
|
| 262 |
#: Path 1 (BACKEND_URL) read timeout in seconds.
|
| 263 |
+
_proxy_timeout_secs: float = float(
|
| 264 |
+
_safe_int(
|
| 265 |
+
os.environ.get("PROXY_TIMEOUT"),
|
| 266 |
+
DEFAULT_PROXY_TIMEOUT,
|
| 267 |
+
)
|
| 268 |
+
)
|
| 269 |
|
| 270 |
#: Path 2 (ai-model Space, CPU inference) read timeout in seconds.
|
| 271 |
#: Default 600 s — covers 4-5 min CPU inference with 1 min headroom.
|
|
|
|
| 301 |
else [o.strip() for o in _raw_origins.split(",") if o.strip()]
|
| 302 |
)
|
| 303 |
|
| 304 |
+
#: HuggingFace Dataset repo for training contributions.
|
| 305 |
+
#: Must be set if POST /v1/contribute is expected to succeed.
|
| 306 |
+
TRAINING_DATASET_REPO: str = os.environ.get("TRAINING_DATASET_REPO", "").strip()
|
| 307 |
+
|
| 308 |
+
#: Current consent version string. Must match the JS constant _TRAINING_CONSENT_VERSION.
|
| 309 |
+
#: Increment when the consent UI text changes materially.
|
| 310 |
+
TRAINING_CONSENT_VERSION: str = "v1.0"
|
| 311 |
+
|
| 312 |
+
#: Maximum records per contribution POST.
|
| 313 |
+
MAX_CONTRIBUTION_RECORDS: int = 100
|
| 314 |
+
|
| 315 |
+
#: In-memory per-IP rate-limit store for contribution endpoint.
|
| 316 |
+
#: Keys: IP string. Values: (count, window_start_timestamp).
|
| 317 |
+
_contrib_rl: dict[str, tuple[int, float]] = {}
|
| 318 |
+
_contrib_rl_lock = asyncio.Lock()
|
| 319 |
+
|
| 320 |
|
| 321 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 322 |
# Startup validation — fail fast with actionable messages
|
|
|
|
| 414 |
CORSMiddleware,
|
| 415 |
allow_origins=_allowed_origins,
|
| 416 |
allow_methods=["GET", "POST", "OPTIONS"],
|
| 417 |
+
# Authorization added for write endpoints (POST /v1/feedback, POST /v1/contribute)
|
| 418 |
+
# that validate a Bearer token. Without this the browser preflight rejects
|
| 419 |
+
# requests containing Authorization headers before the handler runs.
|
| 420 |
+
allow_headers=["Content-Type", "Authorization"],
|
| 421 |
allow_credentials=False,
|
| 422 |
)
|
| 423 |
|
|
|
|
| 598 |
) as upstream:
|
| 599 |
if upstream.status_code != 200: # noqa: PLR2004
|
| 600 |
err_body = await upstream.aread()
|
| 601 |
+
error_payload = json.dumps(
|
| 602 |
+
{
|
| 603 |
+
"id": f"err-{uuid.uuid4().hex}",
|
| 604 |
+
"error": {
|
| 605 |
+
"status": upstream.status_code,
|
| 606 |
+
"message": err_body.decode(errors="replace")[:500],
|
| 607 |
+
},
|
| 608 |
+
}
|
| 609 |
+
)
|
| 610 |
yield f"data: {error_payload}\n\n".encode()
|
| 611 |
else:
|
| 612 |
async for chunk in upstream.aiter_bytes():
|
|
|
|
| 616 |
err_id = uuid.uuid4().hex
|
| 617 |
logger.warning(
|
| 618 |
"ReadTimeout after %.0f s on streaming request to %s [%s]",
|
| 619 |
+
read_timeout_s,
|
| 620 |
+
url,
|
| 621 |
+
err_id,
|
| 622 |
)
|
| 623 |
yield f'data: {{"id":"err-{err_id}","error":{{"status":504,"message":'
|
| 624 |
yield (
|
| 625 |
f'"Upstream timed out after {read_timeout_s:.0f} s. '
|
| 626 |
+
f"CPU inference can take 4-5 minutes. "
|
| 627 |
f'If using the ai-model Space, the model may still be loading."}}}}\n\n'
|
| 628 |
).encode()
|
| 629 |
|
|
|
|
| 642 |
err_id = uuid.uuid4().hex
|
| 643 |
logger.warning(
|
| 644 |
"RequestError on streaming request to %s: %s [%s]",
|
| 645 |
+
url,
|
| 646 |
+
exc,
|
| 647 |
+
err_id,
|
| 648 |
)
|
| 649 |
yield (
|
| 650 |
f'data: {{"id":"err-{err_id}","error":{{"status":502,"message":'
|
|
|
|
| 656 |
status_code=200,
|
| 657 |
media_type="text/event-stream",
|
| 658 |
headers={
|
| 659 |
+
"Cache-Control": "no-cache",
|
| 660 |
"X-Accel-Buffering": "no",
|
| 661 |
},
|
| 662 |
)
|
|
|
|
| 669 |
except httpx.ReadTimeout:
|
| 670 |
logger.warning(
|
| 671 |
"ReadTimeout after %.0f s on non-streaming request to %s",
|
| 672 |
+
read_timeout_s,
|
| 673 |
+
url,
|
| 674 |
)
|
| 675 |
return JSONResponse(
|
| 676 |
status_code=504,
|
| 677 |
content={
|
| 678 |
"error": {
|
| 679 |
+
"type": "timeout_error",
|
| 680 |
"message": (
|
| 681 |
f"Upstream timed out after {read_timeout_s:.0f} s. "
|
| 682 |
"CPU inference on the ai-model Space can take 4-5 minutes. "
|
|
|
|
| 691 |
status_code=504,
|
| 692 |
content={
|
| 693 |
"error": {
|
| 694 |
+
"type": "timeout_error",
|
| 695 |
"message": (
|
| 696 |
f"Connection timed out reaching {url}. "
|
| 697 |
"The HF Space may be cold-starting — retry in 30 seconds."
|
|
|
|
| 705 |
status_code=502,
|
| 706 |
content={
|
| 707 |
"error": {
|
| 708 |
+
"type": "upstream_error",
|
| 709 |
"message": f"Failed to reach upstream: {type(exc).__name__}",
|
| 710 |
}
|
| 711 |
},
|
|
|
|
| 740 |
default 600 s). ``path3`` corresponds to the HF Serverless API (GPU,
|
| 741 |
default 120 s).
|
| 742 |
"""
|
| 743 |
+
return JSONResponse(
|
| 744 |
+
{
|
| 745 |
+
"status": "ok",
|
| 746 |
+
"service": f"sphinx-ai-assistant proxy v{PROXY_VERSION}",
|
| 747 |
+
"routing": {
|
| 748 |
+
"path_1_backend_url": BACKEND_URL or None,
|
| 749 |
+
"path_2_model_space_url": HF_SPACES_MODEL_URL or None,
|
| 750 |
+
"path_2_namespaces": list(HF_SPACES_MODEL_NAMESPACES),
|
| 751 |
+
"path_3_hf_api_base": HF_BASE,
|
| 752 |
+
"path_3_hf_token_set": bool(HF_TOKEN),
|
| 753 |
+
},
|
| 754 |
+
"timeouts": {
|
| 755 |
+
"path1_s": _proxy_timeout_secs,
|
| 756 |
+
"path2_s": _path2_timeout_secs,
|
| 757 |
+
"path3_s": _path3_timeout_secs,
|
| 758 |
+
"connect_s": _connect_timeout_secs,
|
| 759 |
+
"write_s": _write_timeout_secs,
|
| 760 |
+
},
|
| 761 |
+
"cors_origins": _allowed_origins,
|
| 762 |
+
"endpoints": {
|
| 763 |
+
"chat": "POST /v1/chat/completions (primary)",
|
| 764 |
+
"alias": "POST / (path-agnostic alias)",
|
| 765 |
+
"health": "GET /health (liveness probe)",
|
| 766 |
+
},
|
| 767 |
+
}
|
| 768 |
+
)
|
| 769 |
|
| 770 |
|
| 771 |
@app.get("/health")
|
|
|
|
| 838 |
to the bare Space URL without the path suffix.
|
| 839 |
"""
|
| 840 |
return await _forward(body)
|
| 841 |
+
|
| 842 |
+
|
| 843 |
+
@app.post("/v1/contribute")
|
| 844 |
+
async def contribute(request: Request) -> JSONResponse:
|
| 845 |
+
"""Accept a training data contribution from the AI assistant browser widget.
|
| 846 |
+
|
| 847 |
+
Parameters
|
| 848 |
+
----------
|
| 849 |
+
request : fastapi.Request
|
| 850 |
+
HTTP request. Body must be JSON conforming to the contribution schema.
|
| 851 |
+
|
| 852 |
+
Returns
|
| 853 |
+
-------
|
| 854 |
+
fastapi.responses.JSONResponse
|
| 855 |
+
``{"contributed": true, "rows": N}`` on success.
|
| 856 |
+
|
| 857 |
+
Raises
|
| 858 |
+
------
|
| 859 |
+
fastapi.HTTPException
|
| 860 |
+
422 when consent is absent/false, schemaVersion is unsupported, or
|
| 861 |
+
records exceed the maximum allowed count.
|
| 862 |
+
429 when the IP rate limit is exceeded.
|
| 863 |
+
503 when the HF Dataset push fails.
|
| 864 |
+
|
| 865 |
+
Notes
|
| 866 |
+
-----
|
| 867 |
+
Developer: ``consentFlag`` and ``consentVersion`` are checked before any
|
| 868 |
+
other validation. A missing or mismatched consent version is a hard
|
| 869 |
+
rejection — the UI must always send the current version string so that
|
| 870 |
+
old cached pages cannot submit records that would silently bypass a
|
| 871 |
+
consent-text update.
|
| 872 |
+
|
| 873 |
+
Developer: The in-memory rate-limit store (``_contrib_rl``) is per-process
|
| 874 |
+
only. HF Spaces may run multiple replicas. The limit (5 per hour) is
|
| 875 |
+
intentionally loose; the primary defence is the GDPR consent gate.
|
| 876 |
+
|
| 877 |
+
Developer: ``huggingface_hub.HfApi.commit`` is called synchronously inside
|
| 878 |
+
an async handler. This blocks the event loop for the duration of the HTTP
|
| 879 |
+
round-trip to HF (~200 ms on a warm connection). For the current traffic
|
| 880 |
+
level this is acceptable; if throughput grows, wrap in
|
| 881 |
+
``asyncio.get_event_loop().run_in_executor(None, ...)`` instead.
|
| 882 |
+
"""
|
| 883 |
+
import time as _time
|
| 884 |
+
|
| 885 |
+
# Body size guard
|
| 886 |
+
raw = await request.body()
|
| 887 |
+
if len(raw) > DEFAULT_MAX_BODY_BYTES:
|
| 888 |
+
raise HTTPException(status_code=413, detail="Payload too large.")
|
| 889 |
+
try:
|
| 890 |
+
payload = json.loads(raw)
|
| 891 |
+
except json.JSONDecodeError as exc:
|
| 892 |
+
raise HTTPException(status_code=400, detail=f"Invalid JSON: {exc}") from exc
|
| 893 |
+
|
| 894 |
+
# Consent guard — GDPR Article 7: explicit consent required
|
| 895 |
+
if not payload.get("consentFlag"):
|
| 896 |
+
raise HTTPException(
|
| 897 |
+
status_code=422,
|
| 898 |
+
detail="consentFlag must be true. Contribution requires explicit user consent.",
|
| 899 |
+
)
|
| 900 |
+
if payload.get("consentVersion") != TRAINING_CONSENT_VERSION:
|
| 901 |
+
raise HTTPException(
|
| 902 |
+
status_code=422,
|
| 903 |
+
detail=(
|
| 904 |
+
f"consentVersion {payload.get('consentVersion')!r} is not current. "
|
| 905 |
+
f"Expected {TRAINING_CONSENT_VERSION!r}. Reload the page and try again."
|
| 906 |
+
),
|
| 907 |
+
)
|
| 908 |
+
|
| 909 |
+
# Schema version guard
|
| 910 |
+
supported_versions: frozenset[int] = frozenset({1})
|
| 911 |
+
if payload.get("schemaVersion") not in supported_versions:
|
| 912 |
+
raise HTTPException(
|
| 913 |
+
status_code=422,
|
| 914 |
+
detail=(
|
| 915 |
+
f"Unsupported schemaVersion {payload.get('schemaVersion')!r}. "
|
| 916 |
+
f"Supported: {sorted(supported_versions)}"
|
| 917 |
+
),
|
| 918 |
+
)
|
| 919 |
+
|
| 920 |
+
records = payload.get("records", [])
|
| 921 |
+
if not isinstance(records, list):
|
| 922 |
+
raise HTTPException(status_code=422, detail="records must be a list.")
|
| 923 |
+
if len(records) > MAX_CONTRIBUTION_RECORDS:
|
| 924 |
+
raise HTTPException(
|
| 925 |
+
status_code=422,
|
| 926 |
+
detail=f"Too many records. Maximum {MAX_CONTRIBUTION_RECORDS} per request.",
|
| 927 |
+
)
|
| 928 |
+
|
| 929 |
+
# Per-IP rate limit: 5 contributions per hour
|
| 930 |
+
client_ip = _client_ip(request)
|
| 931 |
+
async with _contrib_rl_lock:
|
| 932 |
+
now = _time.time()
|
| 933 |
+
count, window_start = _contrib_rl.get(client_ip, (0, now))
|
| 934 |
+
if now - window_start > 3600:
|
| 935 |
+
count, window_start = 0, now
|
| 936 |
+
count += 1
|
| 937 |
+
_contrib_rl[client_ip] = (count, window_start)
|
| 938 |
+
if count > 5:
|
| 939 |
+
logger.warning(json.dumps({"event": "contribute.ratelimit", "ip": client_ip}))
|
| 940 |
+
raise HTTPException(
|
| 941 |
+
status_code=429,
|
| 942 |
+
detail="Rate limit exceeded. Maximum 5 contributions per hour.",
|
| 943 |
+
headers={"Retry-After": "3600"},
|
| 944 |
+
)
|
| 945 |
+
|
| 946 |
+
if not TRAINING_DATASET_REPO:
|
| 947 |
+
raise HTTPException(
|
| 948 |
+
status_code=503,
|
| 949 |
+
detail="Training endpoint not configured (TRAINING_DATASET_REPO not set).",
|
| 950 |
+
)
|
| 951 |
+
if not HF_TOKEN:
|
| 952 |
+
raise HTTPException(status_code=503, detail="HF_TOKEN not set.")
|
| 953 |
+
|
| 954 |
+
# Push to HF Dataset
|
| 955 |
+
from huggingface_hub import CommitOperationAdd, HfApi
|
| 956 |
+
|
| 957 |
+
api = HfApi(token=HF_TOKEN)
|
| 958 |
+
rows_jsonl = "\n".join(
|
| 959 |
+
json.dumps(
|
| 960 |
+
{
|
| 961 |
+
**rec,
|
| 962 |
+
"_sessionId": payload.get("sessionId", ""),
|
| 963 |
+
"_page": payload.get("page", ""),
|
| 964 |
+
"_model": payload.get("model"),
|
| 965 |
+
"_consentVersion": payload.get("consentVersion"),
|
| 966 |
+
"_ts": int(_time.time() * 1000),
|
| 967 |
+
},
|
| 968 |
+
ensure_ascii=False,
|
| 969 |
+
)
|
| 970 |
+
for rec in records
|
| 971 |
+
if isinstance(rec, dict)
|
| 972 |
+
)
|
| 973 |
+
filename = f"contributions/{int(_time.time() * 1000)}.jsonl"
|
| 974 |
+
try:
|
| 975 |
+
api.commit(
|
| 976 |
+
repo_id=TRAINING_DATASET_REPO,
|
| 977 |
+
repo_type="dataset",
|
| 978 |
+
operations=[
|
| 979 |
+
CommitOperationAdd(
|
| 980 |
+
path_in_repo=filename,
|
| 981 |
+
path_or_fileobj=rows_jsonl.encode(),
|
| 982 |
+
)
|
| 983 |
+
],
|
| 984 |
+
commit_message=f"Add {len(records)} feedback record(s)",
|
| 985 |
+
)
|
| 986 |
+
except Exception as exc:
|
| 987 |
+
logger.error(json.dumps({"event": "contribute.hf_fail", "error": str(exc)}))
|
| 988 |
+
raise HTTPException(
|
| 989 |
+
status_code=503,
|
| 990 |
+
detail="Failed to store contribution. Try again later.",
|
| 991 |
+
) from exc
|
| 992 |
+
|
| 993 |
+
logger.info(json.dumps({"event": "contribute.write", "rows": len(records), "ip": client_ip}))
|
| 994 |
+
return JSONResponse({"contributed": True, "rows": len(records)})
|
requirements.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# scikit-plots/ai · requirements.txt v3.
|
| 2 |
#
|
| 3 |
# Pin with ~= (compatible release) to allow patch upgrades while preventing
|
| 4 |
# breaking minor/major version changes across HF Space restarts.
|
|
@@ -11,3 +11,7 @@
|
|
| 11 |
fastapi~=0.111.0
|
| 12 |
uvicorn[standard]~=0.29.0
|
| 13 |
httpx~=0.27.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# scikit-plots/ai · requirements.txt v3.2.0
|
| 2 |
#
|
| 3 |
# Pin with ~= (compatible release) to allow patch upgrades while preventing
|
| 4 |
# breaking minor/major version changes across HF Space restarts.
|
|
|
|
| 11 |
fastapi~=0.111.0
|
| 12 |
uvicorn[standard]~=0.29.0
|
| 13 |
httpx~=0.27.0
|
| 14 |
+
# Required for POST /v1/contribute: push Q&A training records to a HF Dataset.
|
| 15 |
+
# HF_TOKEN (Space secret) is used server-side; no token exposed to the browser.
|
| 16 |
+
# Upper bound omitted — huggingface_hub follows semver and is stable across minors.
|
| 17 |
+
huggingface_hub~=0.23.0
|