new tasks, devnet topup, cli, new model support
This commit is contained in:
@@ -159,6 +159,30 @@ CURATED_MODELS: list[ModelPreset] = [
|
||||
]
|
||||
|
||||
|
||||
def layers_from_config(cfg) -> int | None:
|
||||
"""Extract the transformer layer count from a HuggingFace config object.
|
||||
|
||||
Composite configs (vision-language, some MoE) nest the decoder settings in
|
||||
``text_config`` — e.g. Qwen3.5-MoE has no top-level ``num_hidden_layers``.
|
||||
"""
|
||||
candidates = [cfg]
|
||||
get_text_config = getattr(cfg, "get_text_config", None)
|
||||
if callable(get_text_config):
|
||||
try:
|
||||
candidates.append(get_text_config())
|
||||
except Exception:
|
||||
pass
|
||||
nested = getattr(cfg, "text_config", None)
|
||||
if nested is not None:
|
||||
candidates.append(nested)
|
||||
for candidate in candidates:
|
||||
for attr in ("num_hidden_layers", "num_layers", "n_layer", "n_layers"):
|
||||
value = getattr(candidate, attr, None)
|
||||
if value is not None:
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
|
||||
def detect_num_layers(hf_repo: str) -> int | None:
|
||||
"""Return num_hidden_layers from HuggingFace config.json (downloads ~1 KB only)."""
|
||||
# Check curated list first (no network call)
|
||||
@@ -168,7 +192,7 @@ def detect_num_layers(hf_repo: str) -> int | None:
|
||||
try:
|
||||
from transformers import AutoConfig # type: ignore[import]
|
||||
cfg = AutoConfig.from_pretrained(hf_repo)
|
||||
return int(cfg.num_hidden_layers)
|
||||
return layers_from_config(cfg)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -195,6 +219,8 @@ def model_metadata_for(
|
||||
hf_repo,
|
||||
cache_dir=str(cache_dir) if cache_dir is not None else None,
|
||||
)
|
||||
# Composite configs (VLM/MoE) nest decoder fields in text_config.
|
||||
text_cfg = getattr(cfg, "text_config", None) or cfg
|
||||
for attr, key in (
|
||||
("model_type", "architecture"),
|
||||
("num_hidden_layers", "num_layers"),
|
||||
@@ -204,8 +230,14 @@ def model_metadata_for(
|
||||
("max_position_embeddings", "context_length"),
|
||||
):
|
||||
value = getattr(cfg, attr, None)
|
||||
if value is None:
|
||||
value = getattr(text_cfg, attr, None)
|
||||
if value is not None:
|
||||
metadata[key] = value
|
||||
if "num_layers" not in metadata:
|
||||
layers = layers_from_config(cfg)
|
||||
if layers is not None:
|
||||
metadata["num_layers"] = layers
|
||||
except Exception:
|
||||
pass
|
||||
return metadata
|
||||
|
||||
@@ -5,14 +5,18 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_CONCURRENCY = 8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RelayBridgeInfo:
|
||||
@@ -32,8 +36,23 @@ def _make_envelope(topic: str, payload: dict, peer_id: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _max_concurrency_from_env() -> int:
|
||||
try:
|
||||
value = int(os.environ.get("MESHNET_RELAY_CONCURRENCY", DEFAULT_MAX_CONCURRENCY))
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_MAX_CONCURRENCY
|
||||
return max(1, value)
|
||||
|
||||
|
||||
class RelayHttpBridge:
|
||||
"""Connect outbound to a relay and proxy relay HTTP requests to localhost."""
|
||||
"""Connect outbound to a relay and proxy relay HTTP requests to localhost.
|
||||
|
||||
Requests are dispatched on a bounded worker pool (US-037) so a node that is
|
||||
the head of one route can still serve per-token ``/forward`` hops of another.
|
||||
Streaming responses (``text/event-stream``) are forwarded as multiple chunk
|
||||
frames sharing one ``request_id`` (US-036); everything else stays a single
|
||||
frame for backward compatibility.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -42,15 +61,20 @@ class RelayHttpBridge:
|
||||
local_base_url: str,
|
||||
advertised_addr: str,
|
||||
reconnect_interval: float = 3.0,
|
||||
max_concurrency: int | None = None,
|
||||
) -> None:
|
||||
self.relay_url = relay_url.rstrip("/")
|
||||
self.peer_id = peer_id
|
||||
self.local_base_url = local_base_url.rstrip("/")
|
||||
self.advertised_addr = advertised_addr
|
||||
self.reconnect_interval = reconnect_interval
|
||||
self.max_concurrency = max(1, max_concurrency) if max_concurrency else _max_concurrency_from_env()
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._connected = threading.Event()
|
||||
self._executor: ThreadPoolExecutor | None = None
|
||||
self._send_lock = threading.Lock()
|
||||
self._ws = None
|
||||
|
||||
@property
|
||||
def relay_addr(self) -> str:
|
||||
@@ -61,6 +85,9 @@ class RelayHttpBridge:
|
||||
|
||||
def start(self) -> RelayBridgeInfo:
|
||||
self._running = True
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=self.max_concurrency, thread_name_prefix="relay-http-worker"
|
||||
)
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name="relay-http-bridge")
|
||||
self._thread.start()
|
||||
return RelayBridgeInfo(peer_id=self.peer_id, relay_addr=self.relay_addr)
|
||||
@@ -72,6 +99,8 @@ class RelayHttpBridge:
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3.0)
|
||||
if self._executor is not None:
|
||||
self._executor.shutdown(wait=False)
|
||||
|
||||
def _run(self) -> None:
|
||||
import websockets.sync.client as wsc # type: ignore[import]
|
||||
@@ -79,6 +108,7 @@ class RelayHttpBridge:
|
||||
while self._running:
|
||||
try:
|
||||
with wsc.connect(self.relay_url, open_timeout=5) as ws:
|
||||
self._ws = ws
|
||||
self._connected.set()
|
||||
ws.send(json.dumps(_make_envelope(
|
||||
"peer-register",
|
||||
@@ -99,19 +129,36 @@ class RelayHttpBridge:
|
||||
payload = envelope.get("payload", {})
|
||||
if payload.get("target_peer") not in {None, self.peer_id}:
|
||||
continue
|
||||
response = self._handle_request(payload)
|
||||
ws.send(json.dumps(_make_envelope(
|
||||
"relay-http-response",
|
||||
response,
|
||||
self.peer_id,
|
||||
)))
|
||||
if self._executor is None:
|
||||
break
|
||||
self._executor.submit(self._process_request, payload)
|
||||
except Exception as exc:
|
||||
self._connected.clear()
|
||||
self._ws = None
|
||||
if self._running:
|
||||
log.debug("relay bridge disconnected: %s", exc)
|
||||
time.sleep(self.reconnect_interval)
|
||||
self._ws = None
|
||||
|
||||
def _handle_request(self, payload: dict) -> dict:
|
||||
def _send_response_frame(self, payload: dict) -> bool:
|
||||
"""Send one relay-http-response frame; False if the socket is gone.
|
||||
|
||||
The lock is held per frame so concurrent workers interleave whole
|
||||
frames on the shared WebSocket, never torn ones.
|
||||
"""
|
||||
ws = self._ws
|
||||
if ws is None:
|
||||
return False
|
||||
message = json.dumps(_make_envelope("relay-http-response", payload, self.peer_id))
|
||||
try:
|
||||
with self._send_lock:
|
||||
ws.send(message)
|
||||
return True
|
||||
except Exception as exc:
|
||||
log.debug("relay bridge send failed (request orphaned): %s", exc)
|
||||
return False
|
||||
|
||||
def _process_request(self, payload: dict) -> None:
|
||||
request_id = str(payload.get("request_id") or "")
|
||||
method = str(payload.get("method") or "POST").upper()
|
||||
path = str(payload.get("path") or "/")
|
||||
@@ -130,10 +177,14 @@ class RelayHttpBridge:
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=300.0) as resp:
|
||||
resp_bytes = resp.read()
|
||||
resp_headers = dict(resp.headers)
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
if "text/event-stream" in content_type:
|
||||
self._stream_response(request_id, resp, resp_headers)
|
||||
return
|
||||
resp_bytes = resp.read()
|
||||
# Forward all X-Meshnet-* headers so the caller can reconstruct the activation.
|
||||
is_binary = "octet-stream" in resp.headers.get("Content-Type", "")
|
||||
is_binary = "octet-stream" in content_type
|
||||
result: dict = {
|
||||
"request_id": request_id,
|
||||
"status": resp.status,
|
||||
@@ -143,21 +194,66 @@ class RelayHttpBridge:
|
||||
result["body_base64"] = base64.b64encode(resp_bytes).decode()
|
||||
else:
|
||||
result["body"] = resp_bytes.decode(errors="replace")
|
||||
return result
|
||||
self._send_response_frame(result)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return {
|
||||
self._send_response_frame({
|
||||
"request_id": request_id,
|
||||
"status": exc.code,
|
||||
"headers": {"Content-Type": exc.headers.get("Content-Type", "application/json")},
|
||||
"body": exc.read().decode(errors="replace"),
|
||||
}
|
||||
})
|
||||
except Exception as exc:
|
||||
return {
|
||||
self._send_response_frame({
|
||||
"request_id": request_id,
|
||||
"status": 503,
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"body": json.dumps({"error": f"relay bridge local request failed: {exc}"}),
|
||||
}
|
||||
})
|
||||
|
||||
def _stream_response(self, request_id: str, resp, resp_headers: dict) -> None:
|
||||
"""Forward an SSE response as chunk frames, one per complete SSE event.
|
||||
|
||||
Frame order: header frame (status + headers), chunk frames, done frame.
|
||||
A receiver that sees no ``stream`` key treats the frame as a complete
|
||||
legacy response, so non-streaming peers are unaffected.
|
||||
"""
|
||||
sent = self._send_response_frame({
|
||||
"request_id": request_id,
|
||||
"status": resp.status,
|
||||
"headers": resp_headers,
|
||||
"stream": True,
|
||||
"done": False,
|
||||
})
|
||||
if not sent:
|
||||
return
|
||||
event_lines: list[str] = []
|
||||
for raw_line in resp:
|
||||
line = raw_line.decode(errors="replace")
|
||||
event_lines.append(line)
|
||||
if line.strip():
|
||||
continue
|
||||
# Blank line terminates one SSE event — flush it as a frame.
|
||||
if not self._send_response_frame({
|
||||
"request_id": request_id,
|
||||
"stream": True,
|
||||
"chunk": "".join(event_lines),
|
||||
"done": False,
|
||||
}):
|
||||
return
|
||||
event_lines = []
|
||||
if event_lines:
|
||||
if not self._send_response_frame({
|
||||
"request_id": request_id,
|
||||
"stream": True,
|
||||
"chunk": "".join(event_lines),
|
||||
"done": False,
|
||||
}):
|
||||
return
|
||||
self._send_response_frame({
|
||||
"request_id": request_id,
|
||||
"stream": True,
|
||||
"done": True,
|
||||
})
|
||||
|
||||
|
||||
def peer_id_from_wallet(wallet_address: str) -> str:
|
||||
|
||||
@@ -734,11 +734,20 @@ def _detect_num_layers(model_id: str, cache_dir: Path | None = None) -> int | No
|
||||
"""Fetch num_hidden_layers from HuggingFace model config (downloads ~1 KB config.json only)."""
|
||||
try:
|
||||
from transformers import AutoConfig # type: ignore[import]
|
||||
|
||||
from .model_catalog import layers_from_config
|
||||
|
||||
cfg = AutoConfig.from_pretrained(
|
||||
model_id,
|
||||
cache_dir=str(cache_dir) if cache_dir is not None else None,
|
||||
)
|
||||
return int(cfg.num_hidden_layers)
|
||||
layers = layers_from_config(cfg)
|
||||
if layers is None:
|
||||
print(
|
||||
f" Warning: no layer count in {model_id} config "
|
||||
"(checked top level and text_config)", flush=True,
|
||||
)
|
||||
return layers
|
||||
except Exception as exc:
|
||||
print(f" Warning: could not read model config from HF: {exc}", flush=True)
|
||||
return None
|
||||
|
||||
@@ -57,7 +57,9 @@ class RelayServer:
|
||||
self._ready = threading.Event()
|
||||
self._running = False
|
||||
self._stop_event: asyncio.Event | None = None
|
||||
self._pending_rpc: dict[str, asyncio.Future] = {}
|
||||
# request_id → queue of relay-http-response frames (US-036: a streamed
|
||||
# response is a sequence of frames; a frame without "stream" is terminal).
|
||||
self._pending_rpc: dict[str, asyncio.Queue] = {}
|
||||
|
||||
@property
|
||||
def registry(self) -> PeerRegistry:
|
||||
@@ -172,9 +174,9 @@ class RelayServer:
|
||||
if topic == "relay-http-response":
|
||||
payload = envelope.get("payload", {})
|
||||
request_id = payload.get("request_id")
|
||||
fut = self._pending_rpc.pop(request_id, None)
|
||||
if fut is not None and not fut.done():
|
||||
fut.set_result(payload)
|
||||
queue = self._pending_rpc.get(request_id)
|
||||
if queue is not None:
|
||||
queue.put_nowait(payload)
|
||||
continue
|
||||
|
||||
# Fan out to all other registered peers
|
||||
@@ -240,8 +242,12 @@ class RelayServer:
|
||||
request_id = str(payload.get("request_id") or uuid.uuid4())
|
||||
payload["request_id"] = request_id
|
||||
payload["target_peer"] = target_peer_id
|
||||
fut = self._loop.create_future() if self._loop is not None else asyncio.get_running_loop().create_future()
|
||||
self._pending_rpc[request_id] = fut
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
self._pending_rpc[request_id] = queue
|
||||
overall_timeout = 310.0
|
||||
idle_timeout = 120.0
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + overall_timeout
|
||||
try:
|
||||
await target.ws.send(json.dumps({
|
||||
"topic": "relay-http-request",
|
||||
@@ -249,8 +255,19 @@ class RelayServer:
|
||||
"from_peer": "relay",
|
||||
"payload": payload,
|
||||
}))
|
||||
response = await asyncio.wait_for(fut, timeout=310.0)
|
||||
await ws_requester.send(json.dumps(response))
|
||||
# Forward frames until a terminal one: streamed responses (US-036)
|
||||
# end with {"stream": true, "done": true}; a frame without "stream"
|
||||
# is a complete legacy single response.
|
||||
while True:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
raise asyncio.TimeoutError
|
||||
frame = await asyncio.wait_for(
|
||||
queue.get(), timeout=min(idle_timeout, remaining)
|
||||
)
|
||||
await ws_requester.send(json.dumps(frame))
|
||||
if not frame.get("stream") or frame.get("done"):
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
await ws_requester.send(json.dumps({
|
||||
"request_id": request_id,
|
||||
|
||||
@@ -176,6 +176,14 @@ class AccountStore:
|
||||
with self._lock:
|
||||
return api_key in self._revoked_keys
|
||||
|
||||
def is_active_key(self, api_key: str) -> bool:
|
||||
with self._lock:
|
||||
return api_key in self._active_keys
|
||||
|
||||
def owner_of_key(self, api_key: str) -> str | None:
|
||||
with self._lock:
|
||||
return self._active_keys.get(api_key)
|
||||
|
||||
# ---- views ----
|
||||
|
||||
def _public_view(self, record: dict) -> dict:
|
||||
|
||||
@@ -87,6 +87,29 @@ class BillingLedger:
|
||||
def has_funds(self, api_key: str) -> bool:
|
||||
return self.ensure_client(api_key) > 0.0
|
||||
|
||||
def grant_caller_credit(self, api_key: str, account_id: str, amount: float) -> bool:
|
||||
"""Grant the one-time Caller Credit for an account (US-039).
|
||||
|
||||
The event id is derived from the account, not the key, so the grant is
|
||||
idempotent per account — across retries, additional keys, and hive
|
||||
gossip replication alike. Returns True only when credit was applied.
|
||||
"""
|
||||
if amount <= 0:
|
||||
return False
|
||||
event_id = f"caller-credit-{account_id}"
|
||||
with self._lock:
|
||||
if event_id in self._seen_event_ids:
|
||||
return False
|
||||
self._apply_locked({
|
||||
"id": event_id,
|
||||
"type": "credit",
|
||||
"api_key": api_key,
|
||||
"amount": amount,
|
||||
"ts": time.time(),
|
||||
"note": "caller-credit",
|
||||
})
|
||||
return True
|
||||
|
||||
def credit_client(self, api_key: str, amount: float, *, note: str = "deposit") -> float:
|
||||
if amount <= 0:
|
||||
raise ValueError("credit amount must be positive")
|
||||
|
||||
@@ -7,7 +7,12 @@ import time
|
||||
from .accounts import DEFAULT_ACCOUNTS_DB_PATH
|
||||
from .billing import DEFAULT_BILLING_DB_PATH
|
||||
from .hf_pricing import DEFAULT_HF_PRICING_LOG_DB_PATH
|
||||
from .server import TrackerServer, derive_relay_url_from_public_tracker_url
|
||||
from .server import (
|
||||
DEFAULT_CALLER_CREDIT_USDT,
|
||||
DEFAULT_DEVNET_TOPUP_USDT,
|
||||
TrackerServer,
|
||||
derive_relay_url_from_public_tracker_url,
|
||||
)
|
||||
|
||||
DEFAULT_REGISTRY_DB_PATH = "meshnet_registry.sqlite3"
|
||||
|
||||
@@ -66,6 +71,28 @@ def main() -> None:
|
||||
"token bound would cost more than this many USDT"
|
||||
),
|
||||
)
|
||||
common.add_argument(
|
||||
"--starting-credit",
|
||||
type=float,
|
||||
default=DEFAULT_CALLER_CREDIT_USDT,
|
||||
metavar="USDT",
|
||||
help=(
|
||||
"One-time Caller Credit granted when an account creates its first "
|
||||
f"API key (default: {DEFAULT_CALLER_CREDIT_USDT}; set 0 to require "
|
||||
"deposits before inference)"
|
||||
),
|
||||
)
|
||||
common.add_argument(
|
||||
"--devnet-topup",
|
||||
type=float,
|
||||
default=DEFAULT_DEVNET_TOPUP_USDT,
|
||||
metavar="USDT",
|
||||
help=(
|
||||
"Dashboard devnet top-up faucet: each click credits this many USDT "
|
||||
f"to one of the account's keys (default: {DEFAULT_DEVNET_TOPUP_USDT}; "
|
||||
"MUST be 0 on mainnet deployments)"
|
||||
),
|
||||
)
|
||||
common.add_argument(
|
||||
"--registry-db",
|
||||
default=DEFAULT_REGISTRY_DB_PATH,
|
||||
@@ -231,6 +258,8 @@ def main() -> None:
|
||||
enable_billing=not args.no_billing,
|
||||
billing_db=None if args.no_billing else args.billing_db,
|
||||
max_charge_per_request=args.max_charge_per_request,
|
||||
starting_credit=args.starting_credit,
|
||||
devnet_topup_amount=args.devnet_topup,
|
||||
contracts=contracts,
|
||||
accounts_db=None if args.no_accounts else args.accounts_db,
|
||||
treasury=treasury,
|
||||
|
||||
@@ -297,6 +297,12 @@ async function revokeKey(key) {
|
||||
await renderAccountPanel();
|
||||
}
|
||||
|
||||
async function topupKey(key) {
|
||||
const r = await apiCall("/v1/account/topup", "POST", { api_key: key });
|
||||
if (!r.ok) alert(r.data.error || "top-up failed");
|
||||
await renderAccountPanel();
|
||||
}
|
||||
|
||||
async function renderAccountPanel() {
|
||||
const r = await apiCall("/v1/account");
|
||||
if (r.status === 404) { // accounts disabled on this tracker
|
||||
@@ -305,7 +311,7 @@ async function renderAccountPanel() {
|
||||
return;
|
||||
}
|
||||
if (!r.ok) { setSession(null); renderAuthForms(); return; }
|
||||
const { account, api_keys, balances, total_balance, usage } = r.data;
|
||||
const { account, api_keys, balances, total_balance, usage, topup_amount } = r.data;
|
||||
const who = account.email || account.wallet || account.account_id;
|
||||
let html =
|
||||
`<div><b>${esc(who)}</b> <span class="pill">${esc(account.role)}</span> ` +
|
||||
@@ -319,6 +325,9 @@ async function renderAccountPanel() {
|
||||
for (const key of api_keys) {
|
||||
html += `<div class="keybox">${esc(key)}` +
|
||||
` <span class="dim">(${usdt(balances[key] ?? 0)} USDT)</span>` +
|
||||
(topup_amount > 0
|
||||
? ` <button class="small" onclick="topupKey('${esc(key)}')">+${usdt(topup_amount)} (devnet)</button>`
|
||||
: "") +
|
||||
` <button class="small" onclick="revokeKey('${esc(key)}')">revoke</button></div>`;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -102,6 +102,11 @@ DEFAULT_VRAM_BYTES = 8 * 1024 * 1024 * 1024
|
||||
DEFAULT_RAM_BYTES = 16 * 1024 * 1024 * 1024
|
||||
DEFAULT_QUANTIZATIONS = ["bfloat16"]
|
||||
DEFAULT_BENCHMARK_TOKENS_PER_SEC = 1.0
|
||||
# US-039/US-040 — single source of truth for the credit defaults (referenced by
|
||||
# TrackerServer, _TrackerHTTPServer, and the CLI). Alpha runs devnet-friendly;
|
||||
# flip both to 0 before any deployment holding a mainnet treasury.
|
||||
DEFAULT_CALLER_CREDIT_USDT = 1.0
|
||||
DEFAULT_DEVNET_TOPUP_USDT = 1.0
|
||||
|
||||
|
||||
def _model_aliases(model: str | None) -> set[str]:
|
||||
@@ -909,18 +914,29 @@ def _coverage_gaps(coverage: list[dict]) -> list[tuple[int, int]]:
|
||||
]
|
||||
|
||||
|
||||
def _relay_http_request(
|
||||
def _relay_http_request_frames(
|
||||
relay_addr: str,
|
||||
path: str,
|
||||
body: bytes,
|
||||
headers: dict[str, str],
|
||||
timeout: float = 310.0,
|
||||
) -> dict | None:
|
||||
"""Send an HTTP-shaped request through a relay RPC WebSocket."""
|
||||
idle_timeout: float = 120.0,
|
||||
):
|
||||
"""Send an HTTP-shaped request through a relay RPC WebSocket, yielding
|
||||
response frames until a terminal one (US-036).
|
||||
|
||||
A frame with ``stream: true`` is part of a chunked SSE response ending with
|
||||
``done: true``; a frame without ``stream`` is a complete single response.
|
||||
Yields nothing when the relay is unreachable; stops silently on idle or
|
||||
overall timeout (the caller bills whatever was observed).
|
||||
"""
|
||||
try:
|
||||
import websockets.sync.client as wsc # type: ignore[import]
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
except Exception:
|
||||
return
|
||||
request_id = str(uuid.uuid4())
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
with wsc.connect(relay_addr, open_timeout=10, close_timeout=5) as ws:
|
||||
ws.send(json.dumps({
|
||||
"request_id": request_id,
|
||||
@@ -929,13 +945,63 @@ def _relay_http_request(
|
||||
"headers": headers,
|
||||
"body": body.decode(errors="replace"),
|
||||
}))
|
||||
raw = ws.recv(timeout=timeout)
|
||||
response = json.loads(raw)
|
||||
if response.get("request_id") not in {None, request_id}:
|
||||
return None
|
||||
return response
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return
|
||||
raw = ws.recv(timeout=min(idle_timeout, remaining))
|
||||
frame = json.loads(raw)
|
||||
if frame.get("request_id") not in {None, request_id}:
|
||||
continue
|
||||
yield frame
|
||||
if not frame.get("stream") or frame.get("done"):
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _relay_http_request(
|
||||
relay_addr: str,
|
||||
path: str,
|
||||
body: bytes,
|
||||
headers: dict[str, str],
|
||||
timeout: float = 310.0,
|
||||
) -> dict | None:
|
||||
"""Send an HTTP-shaped request through a relay and buffer the response.
|
||||
|
||||
Streamed frame sequences are collapsed into one response dict whose body is
|
||||
the concatenated SSE text — used by non-chat callers and kept for
|
||||
backward compatibility; the chat proxy streams frames directly.
|
||||
"""
|
||||
frames = _relay_http_request_frames(relay_addr, path, body, headers, timeout=timeout)
|
||||
first = next(frames, None)
|
||||
if first is None:
|
||||
return None
|
||||
if not first.get("stream"):
|
||||
return first
|
||||
chunks = [first.get("chunk") or ""]
|
||||
for frame in frames:
|
||||
chunks.append(frame.get("chunk") or "")
|
||||
return {
|
||||
"request_id": first.get("request_id"),
|
||||
"status": first.get("status", 200),
|
||||
"headers": first.get("headers") or {},
|
||||
"body": "".join(chunks),
|
||||
}
|
||||
|
||||
|
||||
def _stream_line_tokens(line: bytes) -> tuple[int, int | None]:
|
||||
"""Token accounting for one SSE line: (observed delta, reported total or None)."""
|
||||
if not line.startswith(b"data:"):
|
||||
return 0, None
|
||||
payload = line[5:].strip()
|
||||
if not payload or payload == b"[DONE]":
|
||||
return 0, None
|
||||
try:
|
||||
chunk_payload = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return 1, None
|
||||
return _observed_stream_tokens(chunk_payload), _usage_total_tokens(chunk_payload)
|
||||
|
||||
|
||||
def _find_pinned_route(
|
||||
@@ -1507,6 +1573,8 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
validator_service_token: str | None = None,
|
||||
hive_secret: str | None = None,
|
||||
max_charge_per_request: float | None = None,
|
||||
starting_credit: float = DEFAULT_CALLER_CREDIT_USDT,
|
||||
devnet_topup_amount: float = DEFAULT_DEVNET_TOPUP_USDT,
|
||||
toploc_calibration: "ToplocCalibrationStore | None" = None,
|
||||
toploc_reference_node_url: str | None = None,
|
||||
toploc_calibration_gate_min_hardware_profiles: int = 1,
|
||||
@@ -1533,6 +1601,8 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
self.validator_service_token = validator_service_token
|
||||
self.hive_secret = hive_secret
|
||||
self.max_charge_per_request = max_charge_per_request
|
||||
self.starting_credit = starting_credit
|
||||
self.devnet_topup_amount = devnet_topup_amount
|
||||
self.toploc_calibration: ToplocCalibrationStore | None = toploc_calibration
|
||||
self.toploc_reference_node_url = (
|
||||
toploc_reference_node_url.rstrip("/") if toploc_reference_node_url else None
|
||||
@@ -1672,6 +1742,9 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
if self.path == "/v1/account/keys/revoke":
|
||||
self._handle_account_key_revoke()
|
||||
return
|
||||
if self.path == "/v1/account/topup":
|
||||
self._handle_account_topup()
|
||||
return
|
||||
if self.path == "/v1/accounts/gossip":
|
||||
self._handle_accounts_gossip()
|
||||
return
|
||||
@@ -2008,6 +2081,15 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
"code": "invalid_api_key",
|
||||
}})
|
||||
return
|
||||
# US-039: with accounts enabled, only real account keys may spend —
|
||||
# arbitrary bearer strings must never become billable clients.
|
||||
if server.accounts is not None and not server.accounts.is_active_key(api_key):
|
||||
self._send_json(401, {"error": {
|
||||
"message": "unknown API key: create one at /dashboard (register, then + new key)",
|
||||
"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",
|
||||
@@ -2181,17 +2263,26 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
flush=True,
|
||||
)
|
||||
started = time.monotonic()
|
||||
relayed = _relay_http_request(
|
||||
frames = _relay_http_request_frames(
|
||||
node.relay_addr,
|
||||
path="/v1/chat/completions",
|
||||
body=raw_body,
|
||||
headers=relay_headers,
|
||||
)
|
||||
elapsed = time.monotonic() - started
|
||||
if relayed is not None:
|
||||
self._send_relayed_response(relayed)
|
||||
if int(relayed.get("status", 503)) < 400:
|
||||
body_text = relayed.get("body") or ""
|
||||
first = next(frames, None)
|
||||
if first is not None and first.get("stream"):
|
||||
# Streamed response (US-036): forward SSE chunks as they arrive
|
||||
# and run the same token accounting as the direct stream path.
|
||||
self._stream_relayed_frames(
|
||||
first, frames, started,
|
||||
model, route_model, route_nodes, api_key, node_work,
|
||||
)
|
||||
return
|
||||
if first is not None:
|
||||
elapsed = time.monotonic() - started
|
||||
self._send_relayed_response(first)
|
||||
if int(first.get("status", 503)) < 400:
|
||||
body_text = first.get("body") or ""
|
||||
try:
|
||||
tokens = _billable_non_stream_tokens(json.loads(body_text), body)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
@@ -2254,18 +2345,10 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
break
|
||||
self.wfile.write(line)
|
||||
self.wfile.flush()
|
||||
if line.startswith(b"data:"):
|
||||
payload = line[5:].strip()
|
||||
if payload and payload != b"[DONE]":
|
||||
try:
|
||||
chunk_payload = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
observed_stream_tokens += 1
|
||||
continue
|
||||
observed_stream_tokens += _observed_stream_tokens(chunk_payload)
|
||||
found = _usage_total_tokens(chunk_payload)
|
||||
if found is not None:
|
||||
reported_stream_tokens = found
|
||||
observed, reported = _stream_line_tokens(line)
|
||||
observed_stream_tokens += observed
|
||||
if reported is not None:
|
||||
reported_stream_tokens = reported
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
elapsed = time.monotonic() - started
|
||||
@@ -2410,6 +2493,49 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
except Exception as exc:
|
||||
print(f"[tracker] billing failed for model={model!r}: {exc}", flush=True)
|
||||
|
||||
def _stream_relayed_frames(
|
||||
self,
|
||||
first: dict,
|
||||
frames,
|
||||
started: float,
|
||||
model: str,
|
||||
route_model: str,
|
||||
route_nodes: list,
|
||||
api_key: str | None,
|
||||
node_work: list,
|
||||
) -> None:
|
||||
"""Forward a streamed relay response (US-036) to the client as SSE,
|
||||
billing with the same accounting as the direct stream path."""
|
||||
headers = first.get("headers") if isinstance(first.get("headers"), dict) else {}
|
||||
self.send_response(int(first.get("status", 200)))
|
||||
self.send_header("Content-Type", headers.get("Content-Type", "text/event-stream; charset=utf-8"))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
reported_stream_tokens: int | None = None
|
||||
observed_stream_tokens = 0
|
||||
client_gone = False
|
||||
for frame in itertools.chain([first], frames):
|
||||
chunk = frame.get("chunk") or ""
|
||||
if not chunk:
|
||||
continue
|
||||
data = chunk.encode()
|
||||
if not client_gone:
|
||||
try:
|
||||
self.wfile.write(data)
|
||||
self.wfile.flush()
|
||||
except BrokenPipeError:
|
||||
# Keep draining frames — the nodes did the work; bill it.
|
||||
client_gone = True
|
||||
for line in data.splitlines():
|
||||
observed, reported = _stream_line_tokens(line)
|
||||
observed_stream_tokens += observed
|
||||
if reported is not None:
|
||||
reported_stream_tokens = reported
|
||||
elapsed = time.monotonic() - started
|
||||
observed_tokens = _billable_stream_tokens(observed_stream_tokens, reported_stream_tokens)
|
||||
self._record_observed_throughput(model, route_model, observed_tokens, elapsed, route_nodes)
|
||||
self._bill_completed(api_key, model, observed_tokens, node_work)
|
||||
|
||||
def _send_relayed_response(self, response: dict) -> None:
|
||||
status = int(response.get("status", 503))
|
||||
headers = response.get("headers") if isinstance(response.get("headers"), dict) else {}
|
||||
@@ -2909,6 +3035,12 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
api_key = accounts.create_api_key(account["account_id"])
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if server.billing is not None:
|
||||
# US-039: registration creates the account's first key, so Caller
|
||||
# Credit lands here; the account-derived event id keeps later
|
||||
# grants (e.g. via /v1/account/keys) no-ops.
|
||||
server.billing.grant_caller_credit(
|
||||
api_key, account["account_id"], server.starting_credit
|
||||
)
|
||||
server.billing.ensure_client(api_key)
|
||||
print(
|
||||
f"[tracker] account registered: {account.get('email') or account.get('wallet')} "
|
||||
@@ -2959,6 +3091,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
"balances": balances,
|
||||
"total_balance": sum(balances.values()),
|
||||
"usage": usage,
|
||||
"topup_amount": server.devnet_topup_amount if server.billing is not None else 0.0,
|
||||
})
|
||||
|
||||
def _handle_account_key_create(self):
|
||||
@@ -2971,9 +3104,48 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
return
|
||||
api_key = accounts.create_api_key(account["account_id"])
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
credited = False
|
||||
if server.billing is not None:
|
||||
# US-039: Caller Credit lands with the account's first key; the
|
||||
# account-derived event id keeps it once-per-account forever.
|
||||
credited = server.billing.grant_caller_credit(
|
||||
api_key, account["account_id"], server.starting_credit
|
||||
)
|
||||
server.billing.ensure_client(api_key)
|
||||
self._send_json(200, {"api_key": api_key})
|
||||
self._send_json(200, {"api_key": api_key, "caller_credit_granted": credited})
|
||||
|
||||
def _handle_account_topup(self):
|
||||
"""Devnet faucet (US-040): credit the configured amount to one of the
|
||||
logged-in account's keys. 404 unless the operator enabled it."""
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
accounts = self._require_accounts()
|
||||
if accounts is None:
|
||||
return
|
||||
if server.billing is None or server.devnet_topup_amount <= 0:
|
||||
self._send_json(404, {"error": "top-up is not enabled on this tracker"})
|
||||
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 accounts.owner_of_key(api_key) != account["account_id"]:
|
||||
self._send_json(403, {"error": "key not found on this account"})
|
||||
return
|
||||
balance = server.billing.credit_client(
|
||||
api_key, server.devnet_topup_amount, note="devnet-topup"
|
||||
)
|
||||
self._send_json(200, {
|
||||
"api_key": api_key,
|
||||
"credited": server.devnet_topup_amount,
|
||||
"balance": balance,
|
||||
})
|
||||
|
||||
def _handle_account_key_revoke(self):
|
||||
accounts = self._require_accounts()
|
||||
@@ -3851,6 +4023,8 @@ class TrackerServer:
|
||||
validator_service_token: str | None = None,
|
||||
hive_secret: str | None = None,
|
||||
max_charge_per_request: float | None = None,
|
||||
starting_credit: float = DEFAULT_CALLER_CREDIT_USDT,
|
||||
devnet_topup_amount: float = DEFAULT_DEVNET_TOPUP_USDT,
|
||||
toploc_calibration: ToplocCalibrationStore | None = None,
|
||||
toploc_calibration_db: str | None = None,
|
||||
toploc_reference_node_url: str | None = None,
|
||||
@@ -3923,6 +4097,8 @@ class TrackerServer:
|
||||
if max_charge_per_request is not None and max_charge_per_request <= 0.0:
|
||||
raise ValueError("max_charge_per_request must be positive")
|
||||
self._max_charge_per_request = max_charge_per_request
|
||||
self._starting_credit = max(0.0, starting_credit)
|
||||
self._devnet_topup_amount = max(0.0, devnet_topup_amount)
|
||||
self._validator_service_token = (
|
||||
validator_service_token
|
||||
if validator_service_token is not None
|
||||
@@ -3976,6 +4152,8 @@ class TrackerServer:
|
||||
validator_service_token=self._validator_service_token,
|
||||
hive_secret=self._hive_secret,
|
||||
max_charge_per_request=self._max_charge_per_request,
|
||||
starting_credit=self._starting_credit,
|
||||
devnet_topup_amount=self._devnet_topup_amount,
|
||||
toploc_calibration=self._toploc_calibration,
|
||||
toploc_reference_node_url=self._toploc_reference_node_url,
|
||||
toploc_calibration_gate_min_hardware_profiles=self._toploc_calibration_gate_min_hardware_profiles,
|
||||
|
||||
Reference in New Issue
Block a user