skills update; USER ACCOUNT system! Alpha!
This commit is contained in:
@@ -36,7 +36,8 @@ import uuid
|
||||
from importlib.resources import files
|
||||
from typing import Any
|
||||
|
||||
from .billing import BillingLedger
|
||||
from .accounts import DEFAULT_ACCOUNTS_DB_PATH, AccountStore
|
||||
from .billing import DEFAULT_BILLING_DB_PATH, BillingLedger
|
||||
from .gossip import NodeGossip
|
||||
from .raft import RaftNode
|
||||
|
||||
@@ -1261,6 +1262,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
gossip: "NodeGossip | None" = None,
|
||||
stats: "_StatsCollector | None" = None,
|
||||
billing: "BillingLedger | None" = None,
|
||||
accounts: "AccountStore | None" = None,
|
||||
benchmark_results_path: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(addr, handler)
|
||||
@@ -1275,6 +1277,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
self.gossip = gossip
|
||||
self.stats: _StatsCollector | None = stats
|
||||
self.billing: BillingLedger | None = billing
|
||||
self.accounts: AccountStore | None = accounts
|
||||
self.benchmark_results_path = benchmark_results_path or os.path.join(
|
||||
os.getcwd(), "benchmark_results.json"
|
||||
)
|
||||
@@ -1337,6 +1340,24 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
if self.path == "/v1/billing/forfeit":
|
||||
self._handle_billing_forfeit()
|
||||
return
|
||||
if self.path == "/v1/auth/register":
|
||||
self._handle_auth_register()
|
||||
return
|
||||
if self.path == "/v1/auth/login":
|
||||
self._handle_auth_login()
|
||||
return
|
||||
if self.path == "/v1/auth/logout":
|
||||
self._handle_auth_logout()
|
||||
return
|
||||
if self.path == "/v1/account/keys":
|
||||
self._handle_account_key_create()
|
||||
return
|
||||
if self.path == "/v1/account/keys/revoke":
|
||||
self._handle_account_key_revoke()
|
||||
return
|
||||
if self.path == "/v1/accounts/gossip":
|
||||
self._handle_accounts_gossip()
|
||||
return
|
||||
if self.path == "/v1/benchmark/hop-penalty":
|
||||
self._handle_benchmark_hop_penalty()
|
||||
return
|
||||
@@ -1382,6 +1403,10 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
self._handle_billing_summary()
|
||||
elif parsed.path == "/v1/billing/settlements":
|
||||
self._handle_billing_settlements()
|
||||
elif parsed.path == "/v1/account":
|
||||
self._handle_account_me()
|
||||
elif parsed.path == "/v1/admin/accounts":
|
||||
self._handle_admin_accounts()
|
||||
elif parsed.path == "/v1/benchmark/results":
|
||||
self._handle_benchmark_results()
|
||||
elif parsed.path == "/v1/registry/wallets":
|
||||
@@ -1649,6 +1674,13 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
"code": "missing_api_key",
|
||||
}})
|
||||
return
|
||||
if server.accounts is not None and server.accounts.is_key_revoked(api_key):
|
||||
self._send_json(401, {"error": {
|
||||
"message": "API key has been revoked",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}})
|
||||
return
|
||||
if not server.billing.has_funds(api_key):
|
||||
self._send_json(402, {"error": {
|
||||
"message": "insufficient balance: deposit USDT to continue",
|
||||
@@ -2431,6 +2463,165 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
)
|
||||
self._send_json(200, {"forfeited": event["amount"], **strike_state})
|
||||
|
||||
# ---- user accounts (registration, login, API keys) ----
|
||||
|
||||
def _session_account(self) -> dict | None:
|
||||
"""Resolve the session token in the Authorization header, or None."""
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if server.accounts is None:
|
||||
return None
|
||||
return server.accounts.session_account(_api_key_from_headers(self.headers))
|
||||
|
||||
def _require_accounts(self) -> "AccountStore | None":
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if server.accounts is None:
|
||||
self._send_json(404, {"error": "accounts are not enabled on this tracker"})
|
||||
return None
|
||||
return server.accounts
|
||||
|
||||
def _handle_auth_register(self):
|
||||
accounts = self._require_accounts()
|
||||
if accounts is None:
|
||||
return
|
||||
body = self._read_json_body()
|
||||
if body is None:
|
||||
return
|
||||
try:
|
||||
account = accounts.register(
|
||||
email=body.get("email"),
|
||||
wallet=body.get("wallet"),
|
||||
password=str(body.get("password") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
token = accounts.create_session(account["account_id"])
|
||||
api_key = accounts.create_api_key(account["account_id"])
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if server.billing is not None:
|
||||
server.billing.ensure_client(api_key) # grant Caller Credit up front
|
||||
print(
|
||||
f"[tracker] account registered: {account.get('email') or account.get('wallet')} "
|
||||
f"role={account['role']}", flush=True,
|
||||
)
|
||||
self._send_json(200, {"account": account, "session_token": token, "api_key": api_key})
|
||||
|
||||
def _handle_auth_login(self):
|
||||
accounts = self._require_accounts()
|
||||
if accounts is None:
|
||||
return
|
||||
body = self._read_json_body()
|
||||
if body is None:
|
||||
return
|
||||
identifier = body.get("identifier") or body.get("email") or body.get("wallet") or ""
|
||||
account = accounts.verify_login(str(identifier), str(body.get("password") or ""))
|
||||
if account is None:
|
||||
self._send_json(401, {"error": "invalid credentials"})
|
||||
return
|
||||
token = accounts.create_session(account["account_id"])
|
||||
self._send_json(200, {"account": account, "session_token": token})
|
||||
|
||||
def _handle_auth_logout(self):
|
||||
accounts = self._require_accounts()
|
||||
if accounts is None:
|
||||
return
|
||||
accounts.destroy_session(_api_key_from_headers(self.headers))
|
||||
self._send_json(200, {"ok": True})
|
||||
|
||||
def _handle_account_me(self):
|
||||
"""Balance, usage, and API keys for the logged-in account."""
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if self._require_accounts() is None:
|
||||
return
|
||||
account = self._session_account()
|
||||
if account is None:
|
||||
self._send_json(401, {"error": "login required"})
|
||||
return
|
||||
keys = server.accounts.keys_for(account["account_id"]) # type: ignore[union-attr]
|
||||
balances = {}
|
||||
usage: dict = {"requests": 0, "total_tokens": 0, "total_cost": 0.0, "recent": []}
|
||||
if server.billing is not None:
|
||||
balances = {key: server.billing.get_client_balance(key) for key in keys}
|
||||
usage = server.billing.usage_for(keys)
|
||||
self._send_json(200, {
|
||||
"account": account,
|
||||
"api_keys": keys,
|
||||
"balances": balances,
|
||||
"total_balance": sum(balances.values()),
|
||||
"usage": usage,
|
||||
})
|
||||
|
||||
def _handle_account_key_create(self):
|
||||
accounts = self._require_accounts()
|
||||
if accounts is None:
|
||||
return
|
||||
account = self._session_account()
|
||||
if account is None:
|
||||
self._send_json(401, {"error": "login required"})
|
||||
return
|
||||
api_key = accounts.create_api_key(account["account_id"])
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if server.billing is not None:
|
||||
server.billing.ensure_client(api_key)
|
||||
self._send_json(200, {"api_key": api_key})
|
||||
|
||||
def _handle_account_key_revoke(self):
|
||||
accounts = self._require_accounts()
|
||||
if accounts is None:
|
||||
return
|
||||
account = self._session_account()
|
||||
if account is None:
|
||||
self._send_json(401, {"error": "login required"})
|
||||
return
|
||||
body = self._read_json_body()
|
||||
if body is None:
|
||||
return
|
||||
api_key = body.get("api_key")
|
||||
if not api_key or not isinstance(api_key, str):
|
||||
self._send_json(400, {"error": "api_key is required"})
|
||||
return
|
||||
if not accounts.revoke_api_key(account["account_id"], api_key):
|
||||
self._send_json(404, {"error": "key not found on this account"})
|
||||
return
|
||||
self._send_json(200, {"revoked": api_key})
|
||||
|
||||
def _handle_admin_accounts(self):
|
||||
"""Admin-only: all accounts with their keys and balances."""
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
accounts = self._require_accounts()
|
||||
if accounts is None:
|
||||
return
|
||||
account = self._session_account()
|
||||
if account is None:
|
||||
self._send_json(401, {"error": "login required"})
|
||||
return
|
||||
if account["role"] != "admin":
|
||||
self._send_json(403, {"error": "admin role required"})
|
||||
return
|
||||
listing = accounts.list_accounts()
|
||||
if server.billing is not None:
|
||||
for entry in listing:
|
||||
entry["balances"] = {
|
||||
key: server.billing.get_client_balance(key)
|
||||
for key in entry.get("api_keys", [])
|
||||
}
|
||||
self._send_json(200, {"accounts": listing})
|
||||
|
||||
def _handle_accounts_gossip(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
body = self._read_json_body()
|
||||
if body is None:
|
||||
return
|
||||
if server.accounts is None:
|
||||
self._send_json(200, {"applied": 0})
|
||||
return
|
||||
events = body.get("events")
|
||||
if not isinstance(events, list):
|
||||
self._send_json(400, {"error": "events must be a list"})
|
||||
return
|
||||
applied = server.accounts.apply_events([e for e in events if isinstance(e, dict)])
|
||||
self._send_json(200, {"applied": applied})
|
||||
|
||||
def _handle_wallet_register(self):
|
||||
"""Bind the caller's client wallet pubkey to their API key (US-032).
|
||||
|
||||
@@ -2996,7 +3187,10 @@ class TrackerServer:
|
||||
stats_db: str | None = None,
|
||||
relay_url: str | None = None,
|
||||
billing: BillingLedger | None = None,
|
||||
enable_billing: bool = False,
|
||||
billing_db: str | None = None,
|
||||
accounts: AccountStore | None = None,
|
||||
accounts_db: str | None = None,
|
||||
benchmark_results_path: str | None = None,
|
||||
treasury: Any | None = None,
|
||||
deposit_poll_interval: float = 15.0,
|
||||
@@ -3028,15 +3222,27 @@ class TrackerServer:
|
||||
self._stats: _StatsCollector | None = _StatsCollector(db_path=stats_db) if stats_db else _StatsCollector()
|
||||
self._stats_stop = threading.Event()
|
||||
self._stats_thread: threading.Thread | None = None
|
||||
if billing is None and billing_db:
|
||||
preset_prices = {
|
||||
name: float(preset["price_per_1k_tokens"])
|
||||
for name, preset in self._model_presets.items()
|
||||
if isinstance(preset, dict) and "price_per_1k_tokens" in preset
|
||||
}
|
||||
billing = BillingLedger(db_path=billing_db, prices=preset_prices)
|
||||
if billing is None:
|
||||
db_path = billing_db
|
||||
if db_path is None and enable_billing:
|
||||
db_path = DEFAULT_BILLING_DB_PATH
|
||||
if db_path:
|
||||
preset_prices = {
|
||||
name: float(preset["price_per_1k_tokens"])
|
||||
for name, preset in self._model_presets.items()
|
||||
if isinstance(preset, dict) and "price_per_1k_tokens" in preset
|
||||
}
|
||||
billing = BillingLedger(db_path=db_path, prices=preset_prices)
|
||||
self._billing: BillingLedger | None = billing
|
||||
self._billing_gossip_cursor = 0
|
||||
if accounts is None:
|
||||
accounts_path = accounts_db
|
||||
if accounts_path is None and enable_billing:
|
||||
accounts_path = DEFAULT_ACCOUNTS_DB_PATH
|
||||
if accounts_path:
|
||||
accounts = AccountStore(db_path=accounts_path)
|
||||
self._accounts: AccountStore | None = accounts
|
||||
self._accounts_gossip_cursor = 0
|
||||
self._benchmark_results_path = benchmark_results_path
|
||||
self._treasury = treasury
|
||||
self._deposit_poll_interval = deposit_poll_interval
|
||||
@@ -3074,6 +3280,7 @@ class TrackerServer:
|
||||
relay_url=effective_relay_url,
|
||||
stats=self._stats,
|
||||
billing=self._billing,
|
||||
accounts=self._accounts,
|
||||
benchmark_results_path=self._benchmark_results_path,
|
||||
)
|
||||
self.port = self._server.server_address[1]
|
||||
@@ -3250,6 +3457,8 @@ class TrackerServer:
|
||||
self._stats.save_to_db()
|
||||
if self._billing is not None:
|
||||
self._billing.save_to_db()
|
||||
if self._accounts is not None:
|
||||
self._accounts.save_to_db()
|
||||
if self._stats is not None and self._cluster_peers:
|
||||
self_url = self._cluster_self_url or f"http://{self._host}:{self.port}"
|
||||
local_rpms = self._stats.get_local_rpms()
|
||||
@@ -3288,6 +3497,25 @@ class TrackerServer:
|
||||
# via event-id dedupe on the receiving side).
|
||||
if delivered_all:
|
||||
self._billing_gossip_cursor = cursor
|
||||
if self._accounts is not None and self._cluster_peers:
|
||||
events, cursor = self._accounts.events_since(self._accounts_gossip_cursor)
|
||||
if events:
|
||||
delivered_all = True
|
||||
body = json.dumps({"events": events}).encode()
|
||||
for peer in self._cluster_peers:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{peer}/v1/accounts/gossip",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=2.0) as r:
|
||||
r.read()
|
||||
except Exception:
|
||||
delivered_all = False
|
||||
if delivered_all:
|
||||
self._accounts_gossip_cursor = cursor
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._raft is not None:
|
||||
@@ -3304,6 +3532,8 @@ class TrackerServer:
|
||||
self._stats.save_to_db()
|
||||
if self._billing is not None:
|
||||
self._billing.save_to_db()
|
||||
if self._accounts is not None:
|
||||
self._accounts.save_to_db()
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
if self._thread is not None:
|
||||
|
||||
Reference in New Issue
Block a user