| """The request gate — authenticate, balance-check, and burn. |
| |
| Every /v1/* request flows through here: |
| 1. resolve the Bearer token → (user_id | admin | None) |
| 2. if a user: check balance > 0, else 402 |
| 3. after the upstream responds: compute cost from usage → spend from cache |
| + record usage + ledger entry (async, best-effort, never blocks the response) |
| |
| The master key bypasses billing entirely (admin tier). |
| """ |
| from __future__ import annotations |
|
|
| import hashlib |
| import logging |
| import re |
| from typing import Optional |
|
|
| from . import cache, pricing, registry, user_store |
|
|
| logger = logging.getLogger("router.gate") |
|
|
| |
| _ADDR_RE = re.compile(r"^0x[0-9a-f]{40}$") |
|
|
|
|
| def is_enabled() -> bool: |
| """True if the billing system is fully configured (gist store + wallet). |
| |
| Used throughout to gracefully degrade to unmetered mode when billing |
| secrets aren't set (e.g. local dev, or a Space without GIST_TOKEN). |
| """ |
| from .. import gist_client, wallet |
| return gist_client.is_configured() and wallet.is_configured() |
|
|
|
|
| def _sha256_hex(s: str) -> str: |
| """sha256 hex digest of a string (how api keys are stored/compared).""" |
| return hashlib.sha256(s.encode()).hexdigest() |
|
|
|
|
| def _normalize_addr(token: str) -> Optional[str]: |
| """If `token` is an ETH-address key, return the lowercased address, else None. |
| Accepts either `sk-0x…` (our key format) or a bare `0x…` address. |
| """ |
| t = token.strip() |
| if t[:3].lower() == "sk-": |
| t = t[3:] |
| t = t.strip().lower() |
| return t if _ADDR_RE.match(t) else None |
|
|
|
|
| def resolve(authorization: Optional[str], master_key: str) -> tuple[Optional[str], bool]: |
| """Resolve a Bearer header to (user_id | None, is_admin). |
| |
| Returns (None, True) for the master key, (user_id, False) for a valid user |
| credential, (None, False) for an invalid/missing token. |
| |
| A user credential is their ETH deposit address (as `sk-0x…` or bare `0x…`). |
| Legacy `dr_u_…` keys still resolve via the sha256 index (backward compat). |
| """ |
| if not authorization or not authorization.startswith("Bearer "): |
| return None, False |
| token = authorization[7:].strip() |
| if not token: |
| return None, False |
| if master_key and token == master_key: |
| return None, True |
| addr = _normalize_addr(token) |
| if addr: |
| uid = registry.find_user_by_address(addr) |
| if uid: |
| return uid, False |
| key_hash = _sha256_hex(token) |
| user_id = registry.find_user_by_key_hash(key_hash) |
| return user_id, False |
|
|
|
|
| async def check_balance(user_id: Optional[str]) -> bool: |
| """True if the caller may proceed: admin (None) or a user with credits.""" |
| if user_id is None: |
| return True |
| return await cache.get_balance(user_id) > 0 |
|
|
|
|
| async def burn(user_id: str, models: dict, model_name: str, tok_in: int, tok_out: int) -> int: |
| """Charge a user for a request: spend credits + log usage + ledger. Returns credits charged. |
| |
| `free:` models cost 0 credits (pricing returns 0), but we STILL record the |
| usage event + ledger entry so the dashboard reflects free-tier activity. |
| """ |
| cost = pricing.cost_credits(models, model_name, tok_in, tok_out) |
| _touch_active(user_id) |
| if cost > 0: |
| cache.spend(user_id, cost) |
| gid = cache.gist_id_for(user_id) |
| if gid: |
| try: |
| await user_store.record_usage(gid, model_name, tok_in, tok_out, cost) |
| await user_store.append_ledger(gid, { |
| "id": f"b_{tok_out}_{cost}", |
| "type": "burn", |
| "model": model_name, |
| "tok_in": tok_in, |
| "tok_out": tok_out, |
| "cost": cost, |
| "ts": registry._now(), |
| }) |
| except Exception as e: |
| logger.warning("usage log failed for %s: %s", user_id, e) |
| return cost |
|
|
|
|
| def _touch_active(user_id: str) -> None: |
| """Stamp the current epoch on the user's in-memory registry record (for the sweeper).""" |
| import time |
| rec = registry.find_user(user_id) |
| if rec: |
| rec["last_active_epoch"] = time.time() |
|
|
|
|
| def extract_usage(payload: dict) -> tuple[int, int]: |
| """Pull (prompt_tokens, completion_tokens) from an OpenAI-shaped response.""" |
| u = payload.get("usage") or {} |
| return int(u.get("prompt_tokens", 0)), int(u.get("completion_tokens", 0)) |
|
|