File size: 4,846 Bytes
1a2024f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f70bb6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
"""App-layer API key for the THOX Space fleet.

Why app-layer and not Hugging Face's edge gate
----------------------------------------------
Flipping a Space to private makes HF answer **404** to an unauthenticated caller, which
reads as "the app is broken" rather than "you are not signed in", and the edge gate eats
the `Authorization` header of anything proxying through it. Checking the key in the app
keeps the Space public — so `/health`, the Gradio UI and the model card stay reachable —
while `/v1/*` behaves the way every OpenAI client already expects: `Authorization: Bearer
<key>`, and a real `401` with a `WWW-Authenticate` header when it is wrong.

Fail-closed
-----------
If `THOX_API_KEY` is unset the endpoint refuses everything with 503 rather than serving
openly. An unset secret is a deployment mistake, and the failure mode of guessing wrong
here is a public, uncapped inference endpoint — so the safe direction is down.

`/health` deliberately stays open: liveness has to be checkable without a credential.
It must therefore never echo the key back — see `redact()`.
"""

from __future__ import annotations

import hmac
import os

from fastapi.responses import JSONResponse

ENV_VAR = "THOX_API_KEY"


def configured_key() -> str:
    return (os.environ.get(ENV_VAR) or "").strip()


def is_configured() -> bool:
    return bool(configured_key())


def redact(text: str) -> str:
    """Strip the key out of anything that might reach a log or an open endpoint."""
    key = configured_key()
    if key and key in text:
        return text.replace(key, "***redacted***")
    return text


def _deny(message: str, code: str, status: int = 401) -> JSONResponse:
    return JSONResponse(
        status_code=status,
        headers={"WWW-Authenticate": "Bearer"},
        content={"error": {"message": message, "type": "invalid_request_error",
                           "param": None, "code": code}},
    )


def presented_key(request) -> str:
    """Read the key the caller sent. `Authorization: Bearer` first, `X-API-Key` second."""
    auth = request.headers.get("authorization") or ""
    if auth[:7].lower() == "bearer ":
        return auth[7:].strip()
    return (request.headers.get("x-api-key") or "").strip()


def check(request):
    """Return None when the caller may proceed, or the JSONResponse to send back."""
    expected = configured_key()
    if not expected:
        return _deny(
            f"This endpoint is not configured: {ENV_VAR} is unset on the Space. "
            "Set it as a Space secret.",
            "no_api_key_configured",
            503,
        )
    presented = presented_key(request)
    if not presented:
        return _deny(
            "Missing API key. Send 'Authorization: Bearer <key>'.",
            "missing_api_key",
        )
    # Constant time: a short-circuiting == leaks the length of the matching prefix.
    if not hmac.compare_digest(presented, expected):
        return _deny("Incorrect API key provided.", "invalid_api_key")
    return None


# --------------------------------------------------------------------------------------
# OpenAI-shaped validation errors
# --------------------------------------------------------------------------------------
def openai_validation_handler(request, exc):
    """FastAPI answers a bad body with `422 {"detail": [...]}`; OpenAI uses
    `400 {"error": {...}}`. A client that reads `error.message` sees nothing in the
    former, so translate."""
    try:
        first = exc.errors()[0]
        where = ".".join(str(p) for p in first.get("loc", ()) if p != "body")
        message = f"{first.get('msg', 'Invalid request')}" + (f" (at '{where}')" if where else "")
        param = where or None
    except Exception:  # noqa: BLE001
        message, param = "Invalid request body.", None
    return JSONResponse(
        status_code=400,
        content={"error": {"message": message, "type": "invalid_request_error",
                           "param": param, "code": None}},
    )


def install(app) -> None:
    """Attach the handler to the app that will actually serve the request.

    This has to be Gradio's app, not the FastAPI instance the routes were declared on:
    Starlette resolves exception handlers against the app whose middleware stack is
    running, and the routes are grafted onto Gradio's router. Clearing
    `middleware_stack` forces a rebuild so a handler added after `launch()` is picked up
    -- adding to the dict alone would be silently ignored, because the stack that closed
    over the old handler map is already built.
    """
    from fastapi.exceptions import RequestValidationError

    app.add_exception_handler(RequestValidationError, openai_validation_handler)
    app.middleware_stack = None
    print("[thox] installed OpenAI-shaped validation error handler", flush=True)