LucasUn commited on
Commit
aefa501
·
verified ·
1 Parent(s): 45b9309

Add secure Rokid gateway

Browse files
Files changed (2) hide show
  1. README.md +25 -4
  2. app.py +171 -0
README.md CHANGED
@@ -1,13 +1,34 @@
1
  ---
2
  title: ASQ Rokid Gateway
3
- emoji: 💻
4
  colorFrom: green
5
  colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.28.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: ASQ Rokid Gateway
3
+ emoji: 🥽
4
  colorFrom: green
5
  colorTo: gray
6
  sdk: gradio
7
+ sdk_version: 5.49.1
 
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # ASQ Rokid Gateway
13
+
14
+ Secure gateway between the Rokid AIUI application and the private English ASQ Space.
15
+
16
+ Required Hugging Face Space secrets:
17
+
18
+ - `ACCESS_CODE`: exactly six digits.
19
+ - `SESSION_SECRET`: a long random value used to sign short-lived sessions.
20
+ - `HF_TOKEN`: a read token that can access the private target Space.
21
+
22
+ Optional variables:
23
+
24
+ - `TARGET_SPACE_ID`: defaults to `ASQ2/ASQ`.
25
+ - `TARGET_API_NAME`: defaults to `/chat_flow`.
26
+ - `SESSION_TTL_SECONDS`: defaults to 14,400 seconds.
27
+
28
+ Public endpoints:
29
+
30
+ - `GET /health`
31
+ - `POST /auth` with `{"code":"123456"}`
32
+ - `POST /suggest` with `{"transcript":"..."}` and `Authorization: Bearer <token>`
33
+
34
+ No Hugging Face token or access code is embedded in the Rokid application or committed to this repository.
app.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import base64
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import os
7
+ import secrets
8
+ import time
9
+ from typing import Any
10
+
11
+ import gradio as gr
12
+ import uvicorn
13
+ from fastapi import FastAPI, Header, HTTPException
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from gradio_client import Client
16
+ from pydantic import BaseModel, Field
17
+
18
+
19
+ ACCESS_CODE = os.getenv("ACCESS_CODE", "")
20
+ SESSION_SECRET = os.getenv("SESSION_SECRET", "")
21
+ HF_TOKEN = os.getenv("HF_TOKEN", "")
22
+ TARGET_SPACE_ID = os.getenv("TARGET_SPACE_ID", "ASQ2/ASQ")
23
+ TARGET_API_NAME = os.getenv("TARGET_API_NAME", "/chat_flow")
24
+ SESSION_TTL_SECONDS = int(os.getenv("SESSION_TTL_SECONDS", "14400"))
25
+
26
+
27
+ class AuthRequest(BaseModel):
28
+ code: str = Field(min_length=6, max_length=6, pattern=r"^\d{6}$")
29
+
30
+
31
+ class SuggestRequest(BaseModel):
32
+ transcript: str = Field(min_length=1, max_length=4000)
33
+
34
+
35
+ sessions: dict[str, dict[str, Any]] = {}
36
+
37
+
38
+ def _b64encode(data: bytes) -> str:
39
+ return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
40
+
41
+
42
+ def _b64decode(data: str) -> bytes:
43
+ return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
44
+
45
+
46
+ def _sign(payload: str) -> str:
47
+ return _b64encode(
48
+ hmac.new(SESSION_SECRET.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).digest()
49
+ )
50
+
51
+
52
+ def create_session_token() -> tuple[str, int, str]:
53
+ session_id = secrets.token_urlsafe(18)
54
+ expires_at = int(time.time()) + SESSION_TTL_SECONDS
55
+ payload = _b64encode(
56
+ json.dumps({"sid": session_id, "exp": expires_at}, separators=(",", ":")).encode("utf-8")
57
+ )
58
+ return f"{payload}.{_sign(payload)}", expires_at, session_id
59
+
60
+
61
+ def verify_session_token(token: str) -> dict[str, Any]:
62
+ if not SESSION_SECRET:
63
+ raise HTTPException(status_code=503, detail="Gateway is not configured")
64
+ try:
65
+ payload, signature = token.split(".", 1)
66
+ if not hmac.compare_digest(signature, _sign(payload)):
67
+ raise ValueError("bad signature")
68
+ data = json.loads(_b64decode(payload))
69
+ if int(data["exp"]) <= int(time.time()):
70
+ raise ValueError("expired")
71
+ session = sessions.get(data["sid"])
72
+ if not session:
73
+ raise ValueError("unknown session")
74
+ return session
75
+ except (ValueError, KeyError, json.JSONDecodeError):
76
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
77
+
78
+
79
+ def extract_question(result: Any) -> tuple[str, list[dict[str, Any]], int]:
80
+ if not isinstance(result, (list, tuple)) or len(result) < 4:
81
+ raise RuntimeError("Unexpected response from the ASQ Space")
82
+ history = result[0]
83
+ turns = int(result[3])
84
+ if not isinstance(history, list) or not history:
85
+ raise RuntimeError("ASQ returned no conversation history")
86
+ last = history[-1]
87
+ if isinstance(last, dict):
88
+ question = str(last.get("content", "")).strip()
89
+ elif isinstance(last, (list, tuple)) and len(last) >= 2:
90
+ question = str(last[1]).strip()
91
+ else:
92
+ question = ""
93
+ if not question:
94
+ raise RuntimeError("ASQ returned an empty question")
95
+ return question, history, turns
96
+
97
+
98
+ def call_asq(transcript: str, session: dict[str, Any]) -> tuple[str, list[dict[str, Any]], int]:
99
+ if not HF_TOKEN:
100
+ raise RuntimeError("HF_TOKEN is not configured")
101
+ client = Client(TARGET_SPACE_ID, token=HF_TOKEN, verbose=False)
102
+ result = client.predict(
103
+ transcript,
104
+ session["history"],
105
+ "",
106
+ "",
107
+ session["turns"],
108
+ api_name=TARGET_API_NAME,
109
+ )
110
+ return extract_question(result)
111
+
112
+
113
+ api = FastAPI(title="ASQ Rokid Gateway", version="0.1.0")
114
+ api.add_middleware(
115
+ CORSMiddleware,
116
+ allow_origins=["*"],
117
+ allow_methods=["GET", "POST", "OPTIONS"],
118
+ allow_headers=["Authorization", "Content-Type"],
119
+ )
120
+
121
+
122
+ @api.get("/health")
123
+ def health() -> dict[str, Any]:
124
+ return {
125
+ "ok": True,
126
+ "configured": bool(ACCESS_CODE and SESSION_SECRET and HF_TOKEN),
127
+ "target": TARGET_SPACE_ID,
128
+ }
129
+
130
+
131
+ @api.post("/auth")
132
+ def authenticate(request: AuthRequest) -> dict[str, Any]:
133
+ if not ACCESS_CODE or not SESSION_SECRET:
134
+ raise HTTPException(status_code=503, detail="Gateway is not configured")
135
+ if not hmac.compare_digest(request.code, ACCESS_CODE):
136
+ raise HTTPException(status_code=401, detail="Incorrect code")
137
+ token, expires_at, session_id = create_session_token()
138
+ sessions[session_id] = {"history": [], "turns": 0, "expires_at": expires_at}
139
+ return {"token": token, "expiresIn": SESSION_TTL_SECONDS}
140
+
141
+
142
+ @api.post("/suggest")
143
+ async def suggest(
144
+ request: SuggestRequest,
145
+ authorization: str | None = Header(default=None),
146
+ ) -> dict[str, str]:
147
+ if not authorization or not authorization.startswith("Bearer "):
148
+ raise HTTPException(status_code=401, detail="Missing session token")
149
+ session = verify_session_token(authorization[7:])
150
+ try:
151
+ question, history, turns = await asyncio.to_thread(call_asq, request.transcript.strip(), session)
152
+ except Exception as error:
153
+ raise HTTPException(status_code=502, detail=f"ASQ inference failed: {error}")
154
+ session["history"] = history
155
+ session["turns"] = turns
156
+ return {"question": question}
157
+
158
+
159
+ with gr.Blocks(title="ASQ Rokid Gateway") as status_page:
160
+ gr.Markdown(
161
+ "# ASQ Rokid Gateway\n"
162
+ "This service provides the authenticated API used by the Rokid glasses application.\n\n"
163
+ "Use `GET /health` to check configuration. The `/auth` and `/suggest` endpoints require the six-digit access flow."
164
+ )
165
+
166
+
167
+ api = gr.mount_gradio_app(api, status_page, path="/ui")
168
+
169
+
170
+ if __name__ == "__main__":
171
+ uvicorn.run(api, host="0.0.0.0", port=7860)