Spaces:
Running on Zero
Running on Zero
Invite codes: /invite_check and /decide_invite run invited Emberwick players on the owner's ZeroGPU quota, with daily caps
Browse files
app.py
CHANGED
|
@@ -7,6 +7,7 @@ The same request body as TypeSafe's /v1/systemone works in the API tab.
|
|
| 7 |
import html
|
| 8 |
import json
|
| 9 |
import os
|
|
|
|
| 10 |
|
| 11 |
import gradio as gr
|
| 12 |
import spaces
|
|
@@ -19,6 +20,8 @@ from jevalt.backends.hf import HFBackend
|
|
| 19 |
from jevalt.engine import DecisionEngine
|
| 20 |
from jevalt.format import compile_request
|
| 21 |
|
|
|
|
|
|
|
| 22 |
BASE = os.environ.get("JEVALT_BASE", "internlm/Intern-Decision-4B")
|
| 23 |
# {"en": "owner/Deem-4B/lora", ...}: a model repo plus the subfolder that holds its LoRA adapter.
|
| 24 |
ADAPTERS = json.loads(os.environ.get("JEVALT_ADAPTERS", "{}"))
|
|
@@ -194,6 +197,9 @@ TEXT = {
|
|
| 194 |
"need_situation": "Write the situation first.", "need_question": "Write a question, or list some options.",
|
| 195 |
"two_options": "Give at least two options, or none for a yes/no answer.",
|
| 196 |
"bad_json": "The request is not valid JSON:", "need_state": "The request needs a `state`.",
|
|
|
|
|
|
|
|
|
|
| 197 |
"quota": "The free GPU time on Hugging Face is used up for this visit. Signed-in accounts get more each day: "
|
| 198 |
"sign in at huggingface.co and use the Space there, or try again later."},
|
| 199 |
"tr": {"eyebrow": "Açık ağırlıklar · Apache-2.0", "tag": "Ne kadar emin olduğunu bilen kararlar.",
|
|
@@ -222,6 +228,9 @@ TEXT = {
|
|
| 222 |
"need_situation": "Önce durumu yazın.", "need_question": "Bir soru yazın ya da birkaç seçenek ekleyin.",
|
| 223 |
"two_options": "En az iki seçenek yazın ya da evet/hayır sorusu için boş bırakın.",
|
| 224 |
"bad_json": "İstek geçerli bir JSON değil:", "need_state": "İstekte bir `state` alanı olmalı.",
|
|
|
|
|
|
|
|
|
|
| 225 |
"quota": "Bu ziyaret için Hugging Face'in ücretsiz GPU süresi doldu. Giriş yapan hesaplar her gün daha fazla süre alır: "
|
| 226 |
"huggingface.co'da giriş yapıp Space'i orada kullanın ya da daha sonra tekrar deneyin."},
|
| 227 |
"de": {"eyebrow": "Offene Gewichte · Apache-2.0", "tag": "Entscheidungen, die wissen, wie sicher sie sind.",
|
|
@@ -250,6 +259,9 @@ TEXT = {
|
|
| 250 |
"need_situation": "Bitte zuerst die Situation beschreiben.", "need_question": "Schreiben Sie eine Frage oder nennen Sie einige Optionen.",
|
| 251 |
"two_options": "Mindestens zwei Optionen angeben, oder keine für eine Ja/Nein-Antwort.",
|
| 252 |
"bad_json": "Die Anfrage ist kein gültiges JSON:", "need_state": "Die Anfrage braucht einen `state`.",
|
|
|
|
|
|
|
|
|
|
| 253 |
"quota": "Die kostenlose GPU-Zeit von Hugging Face ist für diesen Besuch aufgebraucht. Angemeldete Konten bekommen jeden Tag mehr: "
|
| 254 |
"Melden Sie sich auf huggingface.co an und nutzen Sie den Space dort, oder versuchen Sie es später erneut."},
|
| 255 |
}
|
|
@@ -269,20 +281,26 @@ def _predict(request: dict, lang: str) -> dict:
|
|
| 269 |
return engine.predict({**request, "language": request.get("language", lang)})
|
| 270 |
|
| 271 |
|
| 272 |
-
def
|
| 273 |
-
"""
|
| 274 |
t = TEXT.get(lang, TEXT["en"])
|
| 275 |
try:
|
| 276 |
request = json.loads(request_json)
|
| 277 |
except json.JSONDecodeError as err:
|
| 278 |
raise gr.Error(f"{t['bad_json']} {err}") from None
|
| 279 |
-
# Checked on the CPU first, so a malformed request never takes GPU time from the visitor's quota.
|
| 280 |
try:
|
| 281 |
if not isinstance(request, dict) or "state" not in request:
|
| 282 |
raise ValueError(t["need_state"])
|
| 283 |
compile_request(request, abstain=bool(request.get("abstain")), language=lang)
|
| 284 |
except ValueError as err:
|
| 285 |
raise gr.Error(str(err)) from None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
try:
|
| 287 |
return _predict(request, lang)
|
| 288 |
except Exception as err:
|
|
@@ -291,6 +309,57 @@ def decide(request_json: str, lang: str = "en") -> dict:
|
|
| 291 |
raise
|
| 292 |
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
def parse_options(text: str) -> list[tuple[str, str]]:
|
| 295 |
"""One option per line, or a single line separated by commas. Text after ": " describes the option."""
|
| 296 |
lines = [line.strip().lstrip("-•*").strip() for line in (text or "").splitlines()]
|
|
@@ -602,5 +671,7 @@ with gr.Blocks(title="JevAlt") as demo:
|
|
| 602 |
go.click(run_form, form, [view, raw, request], api_name=False)
|
| 603 |
go_json.click(run_json, [request, model_choice], [view, raw], api_name=False)
|
| 604 |
gr.api(decide, api_name="decide")
|
|
|
|
|
|
|
| 605 |
|
| 606 |
demo.queue(default_concurrency_limit=4).launch(theme=theme, css=CSS)
|
|
|
|
| 7 |
import html
|
| 8 |
import json
|
| 9 |
import os
|
| 10 |
+
import threading
|
| 11 |
|
| 12 |
import gradio as gr
|
| 13 |
import spaces
|
|
|
|
| 20 |
from jevalt.engine import DecisionEngine
|
| 21 |
from jevalt.format import compile_request
|
| 22 |
|
| 23 |
+
import invite
|
| 24 |
+
|
| 25 |
BASE = os.environ.get("JEVALT_BASE", "internlm/Intern-Decision-4B")
|
| 26 |
# {"en": "owner/Deem-4B/lora", ...}: a model repo plus the subfolder that holds its LoRA adapter.
|
| 27 |
ADAPTERS = json.loads(os.environ.get("JEVALT_ADAPTERS", "{}"))
|
|
|
|
| 197 |
"need_situation": "Write the situation first.", "need_question": "Write a question, or list some options.",
|
| 198 |
"two_options": "Give at least two options, or none for a yes/no answer.",
|
| 199 |
"bad_json": "The request is not valid JSON:", "need_state": "The request needs a `state`.",
|
| 200 |
+
"invite_bad": "This invite code is not valid.", "too_long": "The request is too long.",
|
| 201 |
+
"invite_spent": "The GPU time for invites is used up for today. Please try again tomorrow.",
|
| 202 |
+
"invite_down": "The model did not answer. Please try again in a minute.",
|
| 203 |
"quota": "The free GPU time on Hugging Face is used up for this visit. Signed-in accounts get more each day: "
|
| 204 |
"sign in at huggingface.co and use the Space there, or try again later."},
|
| 205 |
"tr": {"eyebrow": "Açık ağırlıklar · Apache-2.0", "tag": "Ne kadar emin olduğunu bilen kararlar.",
|
|
|
|
| 228 |
"need_situation": "Önce durumu yazın.", "need_question": "Bir soru yazın ya da birkaç seçenek ekleyin.",
|
| 229 |
"two_options": "En az iki seçenek yazın ya da evet/hayır sorusu için boş bırakın.",
|
| 230 |
"bad_json": "İstek geçerli bir JSON değil:", "need_state": "İstekte bir `state` alanı olmalı.",
|
| 231 |
+
"invite_bad": "Bu davet kodu geçerli değil.", "too_long": "İstek çok uzun.",
|
| 232 |
+
"invite_spent": "Davetler için bugünkü GPU süresi doldu. Lütfen yarın tekrar deneyin.",
|
| 233 |
+
"invite_down": "Model cevap vermedi. Lütfen bir dakika sonra tekrar deneyin.",
|
| 234 |
"quota": "Bu ziyaret için Hugging Face'in ücretsiz GPU süresi doldu. Giriş yapan hesaplar her gün daha fazla süre alır: "
|
| 235 |
"huggingface.co'da giriş yapıp Space'i orada kullanın ya da daha sonra tekrar deneyin."},
|
| 236 |
"de": {"eyebrow": "Offene Gewichte · Apache-2.0", "tag": "Entscheidungen, die wissen, wie sicher sie sind.",
|
|
|
|
| 259 |
"need_situation": "Bitte zuerst die Situation beschreiben.", "need_question": "Schreiben Sie eine Frage oder nennen Sie einige Optionen.",
|
| 260 |
"two_options": "Mindestens zwei Optionen angeben, oder keine für eine Ja/Nein-Antwort.",
|
| 261 |
"bad_json": "Die Anfrage ist kein gültiges JSON:", "need_state": "Die Anfrage braucht einen `state`.",
|
| 262 |
+
"invite_bad": "Dieser Einladungscode ist ungültig.", "too_long": "Die Anfrage ist zu lang.",
|
| 263 |
+
"invite_spent": "Die GPU-Zeit für Einladungen ist für heute aufgebraucht. Bitte versuchen Sie es morgen wieder.",
|
| 264 |
+
"invite_down": "Das Modell hat nicht geantwortet. Bitte versuchen Sie es in einer Minute erneut.",
|
| 265 |
"quota": "Die kostenlose GPU-Zeit von Hugging Face ist für diesen Besuch aufgebraucht. Angemeldete Konten bekommen jeden Tag mehr: "
|
| 266 |
"Melden Sie sich auf huggingface.co an und nutzen Sie den Space dort, oder versuchen Sie es später erneut."},
|
| 267 |
}
|
|
|
|
| 281 |
return engine.predict({**request, "language": request.get("language", lang)})
|
| 282 |
|
| 283 |
|
| 284 |
+
def parse_request(request_json: str, lang: str) -> dict:
|
| 285 |
+
"""Checked on the CPU first, so a malformed request never takes GPU time from anyone's quota."""
|
| 286 |
t = TEXT.get(lang, TEXT["en"])
|
| 287 |
try:
|
| 288 |
request = json.loads(request_json)
|
| 289 |
except json.JSONDecodeError as err:
|
| 290 |
raise gr.Error(f"{t['bad_json']} {err}") from None
|
|
|
|
| 291 |
try:
|
| 292 |
if not isinstance(request, dict) or "state" not in request:
|
| 293 |
raise ValueError(t["need_state"])
|
| 294 |
compile_request(request, abstain=bool(request.get("abstain")), language=lang)
|
| 295 |
except ValueError as err:
|
| 296 |
raise gr.Error(str(err)) from None
|
| 297 |
+
return request
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def decide(request_json: str, lang: str = "en") -> dict:
|
| 301 |
+
"""Answer a Jev-style request. Body: {"state": ..., "questions": {...}, "reasoning": "off|on|auto", "abstain": false}."""
|
| 302 |
+
t = TEXT.get(lang, TEXT["en"])
|
| 303 |
+
request = parse_request(request_json, lang)
|
| 304 |
try:
|
| 305 |
return _predict(request, lang)
|
| 306 |
except Exception as err:
|
|
|
|
| 309 |
raise
|
| 310 |
|
| 311 |
|
| 312 |
+
# Invites (Emberwick): the code and the owner's token come from the Space secrets INVITE_CODES and INVITE_HF_TOKEN.
|
| 313 |
+
# An invited call goes back into this Space's /decide signed in as the owner, so ZeroGPU bills the owner's account.
|
| 314 |
+
INVITES = invite.Invites(os.environ.get("INVITE_CODES", ""), int(os.environ.get("INVITE_DAILY_UNITS", "4000")),
|
| 315 |
+
int(os.environ.get("INVITE_IP_DAILY_UNITS", "800")))
|
| 316 |
+
INVITE_TOKEN = os.environ.get("INVITE_HF_TOKEN", "")
|
| 317 |
+
_owner = None
|
| 318 |
+
_owner_lock = threading.Lock()
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def owner_client():
|
| 322 |
+
global _owner
|
| 323 |
+
with _owner_lock:
|
| 324 |
+
if _owner is None:
|
| 325 |
+
from gradio_client import Client
|
| 326 |
+
_owner = Client(os.environ.get("SPACE_ID", "mertkayacs/JevAlt"), token=INVITE_TOKEN, verbose=False)
|
| 327 |
+
return _owner
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def client_ip(request: gr.Request | None) -> str:
|
| 331 |
+
if request is None:
|
| 332 |
+
return ""
|
| 333 |
+
forwarded = request.headers.get("x-forwarded-for", "")
|
| 334 |
+
return forwarded.split(",")[0].strip() or getattr(request.client, "host", "") or ""
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def invite_check(code: str, request: gr.Request) -> dict:
|
| 338 |
+
"""Is this an invite code? Wrong guesses are counted per address."""
|
| 339 |
+
return {"ok": bool(INVITE_TOKEN) and INVITES.valid(code, client_ip(request))}
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def decide_invite(request_json: str, lang: str, code: str, request: gr.Request) -> dict:
|
| 343 |
+
"""/decide for invited players: the GPU time goes to the owner's ZeroGPU quota, then prepaid credits."""
|
| 344 |
+
global _owner
|
| 345 |
+
t = TEXT.get(lang, TEXT["en"])
|
| 346 |
+
ip = client_ip(request)
|
| 347 |
+
if not INVITE_TOKEN or not INVITES.valid(code, ip):
|
| 348 |
+
raise gr.Error(t["invite_bad"])
|
| 349 |
+
if len(request_json) > 20000:
|
| 350 |
+
raise gr.Error(t["too_long"])
|
| 351 |
+
parsed = parse_request(request_json, lang)
|
| 352 |
+
# A reasoning trace keeps the GPU several seconds instead of a fraction of one.
|
| 353 |
+
if not INVITES.charge(ip, 8 if parsed.get("reasoning") in ("on", "auto") else 1):
|
| 354 |
+
raise gr.Error(t["invite_spent"])
|
| 355 |
+
try:
|
| 356 |
+
return owner_client().predict(request_json, lang, api_name="/decide")
|
| 357 |
+
except Exception as err: # noqa: BLE001
|
| 358 |
+
with _owner_lock:
|
| 359 |
+
_owner = None
|
| 360 |
+
raise gr.Error(t["invite_spent"] if "quota" in str(err).lower() else t["invite_down"]) from None
|
| 361 |
+
|
| 362 |
+
|
| 363 |
def parse_options(text: str) -> list[tuple[str, str]]:
|
| 364 |
"""One option per line, or a single line separated by commas. Text after ": " describes the option."""
|
| 365 |
lines = [line.strip().lstrip("-•*").strip() for line in (text or "").splitlines()]
|
|
|
|
| 671 |
go.click(run_form, form, [view, raw, request], api_name=False)
|
| 672 |
go_json.click(run_json, [request, model_choice], [view, raw], api_name=False)
|
| 673 |
gr.api(decide, api_name="decide")
|
| 674 |
+
gr.api(invite_check, api_name="invite_check")
|
| 675 |
+
gr.api(decide_invite, api_name="decide_invite")
|
| 676 |
|
| 677 |
demo.queue(default_concurrency_limit=4).launch(theme=theme, css=CSS)
|
invite.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Invite codes for the Emberwick game. A player with a code gets live decisions billed to the Space owner's Hugging Face
|
| 2 |
+
account (PRO ZeroGPU quota first, then prepaid credits) instead of their own small anonymous quota. Daily caps keep
|
| 3 |
+
that spend bounded; the counters live in memory and start again each UTC day or when the Space restarts."""
|
| 4 |
+
|
| 5 |
+
import hmac
|
| 6 |
+
import threading
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def norm(code: str) -> bytes:
|
| 11 |
+
"""Case, spaces and punctuation do not matter: "Emberwick invite" and "EMBERWICK-INVITE" are the same code."""
|
| 12 |
+
return "".join(ch for ch in str(code or "").lower() if ch.isalnum()).encode()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def utc_day() -> str:
|
| 16 |
+
return datetime.now(timezone.utc).date().isoformat()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Invites:
|
| 20 |
+
def __init__(self, codes: str, daily_units: int, ip_daily_units: int, ip_daily_misses: int = 30, today=utc_day):
|
| 21 |
+
self.codes = [c for c in (norm(x) for x in codes.split(",")) if c]
|
| 22 |
+
self.daily_units, self.ip_daily_units, self.ip_daily_misses = daily_units, ip_daily_units, ip_daily_misses
|
| 23 |
+
self._today = today
|
| 24 |
+
self._lock = threading.Lock()
|
| 25 |
+
self._day, self._units, self._ip_units, self._misses = "", 0, {}, {}
|
| 26 |
+
|
| 27 |
+
def _roll(self) -> None:
|
| 28 |
+
if (day := self._today()) != self._day:
|
| 29 |
+
self._day, self._units, self._ip_units, self._misses = day, 0, {}, {}
|
| 30 |
+
|
| 31 |
+
def valid(self, code: str, ip: str) -> bool:
|
| 32 |
+
"""True for a known code. Wrong codes are counted per address; past the limit every check fails for the day."""
|
| 33 |
+
given = norm(code)
|
| 34 |
+
ok = bool(given) and any(hmac.compare_digest(given, c) for c in self.codes)
|
| 35 |
+
with self._lock:
|
| 36 |
+
self._roll()
|
| 37 |
+
if self._misses.get(ip, 0) >= self.ip_daily_misses:
|
| 38 |
+
return False
|
| 39 |
+
if not ok:
|
| 40 |
+
self._misses[ip] = self._misses.get(ip, 0) + 1
|
| 41 |
+
return ok
|
| 42 |
+
|
| 43 |
+
def charge(self, ip: str, units: int) -> bool:
|
| 44 |
+
"""Book `units` for today. False when the Space-wide or the per-address cap would be passed."""
|
| 45 |
+
with self._lock:
|
| 46 |
+
self._roll()
|
| 47 |
+
if self._units + units > self.daily_units or self._ip_units.get(ip, 0) + units > self.ip_daily_units:
|
| 48 |
+
return False
|
| 49 |
+
self._units += units
|
| 50 |
+
self._ip_units[ip] = self._ip_units.get(ip, 0) + units
|
| 51 |
+
return True
|