mertkayacs commited on
Commit
3b44ebf
·
verified ·
1 Parent(s): b1ce5f7

Invite codes: /invite_check and /decide_invite run invited Emberwick players on the owner's ZeroGPU quota, with daily caps

Browse files
Files changed (2) hide show
  1. app.py +74 -3
  2. invite.py +51 -0
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 decide(request_json: str, lang: str = "en") -> dict:
273
- """Answer a Jev-style request. Body: {"state": ..., "questions": {...}, "reasoning": "off|on|auto", "abstain": false}."""
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