"What you sell", in Settings → System → AI over the mesh, lists the inference servers Routstr forwards to and the models offered from each. This node's Local Llama is listed by default. "Add a server" takes the address of any OpenAI-compatible server on the network (llama.cpp, vLLM, Ollama, NIM; a DGX Spark, say) and an optional API key, checks it answers with a model list, and adds it. For each model the operator chooses whether to sell it, the name buyers see, the context and answer limit, and a price in US dollars per million tokens; the screen previews the sats a buyer sends up front. Backend (`routstr_seller.rs`, `routstr.selling.*` RPCs, operator only): drives Routstr's admin API; Routstr's database stays the one record of servers, models and prices. Addresses must be http(s) and are refused if they point at this node, its app network, or link-local addresses. The context offered is capped at what the server reports, and the local model's context stays fixed to llama-server's. A model's public ID may be sold from one server only. Routstr does not hand API keys back, so the node keeps its own copy (0600) to re-read a server's model list; keys are never returned or logged. archy-start keeps the operator's name and answer limit for the local model and re-states only its context. Image 0.5.0-archy3. Requires UI/UX UAT approval from the maintainer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
156 lines
5.5 KiB
Python
156 lines
5.5 KiB
Python
"""Start Routstr, then make the node's choices win over its saved settings.
|
|
|
|
Routstr seeds settings from the environment on first start only; afterwards
|
|
its database copy wins. The node wallet owns the mints and payout address
|
|
(CASHU_MINTS, RECEIVE_LN_ADDRESS, rendered by the orchestrator from the node
|
|
wallet), so they are re-applied through the admin API on every start. The model
|
|
entry's context is re-stated on every start, so buyers see the context size
|
|
llama-server really has. Its name, answer limit, price and on/off choice belong
|
|
to the operator (Settings, "What you sell"): they are set at install defaults
|
|
only when there is no entry yet, and priced only while unpriced (Routstr refuses
|
|
to serve an unpriced model).
|
|
|
|
Failures here are logged and never stop Routstr: unconfigured, it serves no
|
|
paid inference rather than the wrong mint.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
API = "http://127.0.0.1:8000"
|
|
UVICORN = [
|
|
"/opt/routstr/.venv/bin/python", "-m", "uvicorn", "routstr:fastapi_app",
|
|
"--host", "0.0.0.0", "--port", "8000", "--no-access-log",
|
|
]
|
|
|
|
|
|
def log(message):
|
|
print(f"archy-start: {message}", file=sys.stderr, flush=True)
|
|
|
|
|
|
def call(method, path, body=None, token=None):
|
|
headers = {"Content-Type": "application/json"}
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
request = urllib.request.Request(API + path, data, headers, method=method)
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
return json.loads(response.read() or b"null")
|
|
|
|
|
|
def wait_ready(child, deadline):
|
|
while time.monotonic() < deadline and child.poll() is None:
|
|
try:
|
|
call("GET", "/v1/info")
|
|
return True
|
|
except (urllib.error.URLError, OSError, ValueError):
|
|
time.sleep(2)
|
|
return False
|
|
|
|
|
|
def sync_settings(token):
|
|
update = {}
|
|
mints = [m.strip() for m in os.environ.get("CASHU_MINTS", "").split(",") if m.strip()]
|
|
if mints:
|
|
update["cashu_mints"] = mints
|
|
update["primary_mint"] = mints[0]
|
|
address = os.environ.get("RECEIVE_LN_ADDRESS", "").strip()
|
|
if address:
|
|
update["receive_ln_address"] = address
|
|
if update:
|
|
call("PATCH", "/admin/api/settings", update, token)
|
|
log(f"applied node wallet settings: {sorted(update)}")
|
|
|
|
|
|
def priced(model):
|
|
pricing = model.get("pricing") or {}
|
|
return any(float(pricing.get(k) or 0) > 0 for k in ("prompt", "completion", "request"))
|
|
|
|
|
|
def sync_model(token):
|
|
model_id = os.environ.get("ARCHY_MODEL_ID", "").strip()
|
|
if not model_id:
|
|
return
|
|
path = f"/admin/api/upstream-providers/custom/models/{model_id}"
|
|
try:
|
|
existing = call("GET", path, token=token)
|
|
except urllib.error.HTTPError as error:
|
|
if error.code != 404:
|
|
raise
|
|
existing = None
|
|
keep = existing if existing and priced(existing) else None
|
|
pricing = keep["pricing"] if keep else {
|
|
"prompt": os.environ["ARCHY_DEFAULT_PROMPT_USD_PER_TOKEN"],
|
|
"completion": os.environ["ARCHY_DEFAULT_COMPLETION_USD_PER_TOKEN"],
|
|
"request": "0", "image": "0", "web_search": "0", "internal_reasoning": "0",
|
|
}
|
|
context = int(os.environ.get("ARCHY_MODEL_CONTEXT", "4096"))
|
|
max_completion = int(os.environ.get("ARCHY_MODEL_MAX_COMPLETION", "1024"))
|
|
name = os.environ.get("ARCHY_MODEL_NAME", model_id)
|
|
if existing:
|
|
name = existing.get("name") or name
|
|
kept = (existing.get("top_provider") or {}).get("max_completion_tokens")
|
|
if isinstance(kept, int) and kept > 0:
|
|
max_completion = kept
|
|
max_completion = min(max_completion, context)
|
|
call("POST", "/admin/api/upstream-providers/custom/models", {
|
|
"id": model_id,
|
|
"name": name,
|
|
"description": "Local llama-server model",
|
|
"created": 0,
|
|
"context_length": context,
|
|
"architecture": {
|
|
"modality": "text->text", "input_modalities": ["text"],
|
|
"output_modalities": ["text"], "tokenizer": "Other", "instruct_type": None,
|
|
},
|
|
"pricing": pricing,
|
|
"top_provider": {
|
|
"context_length": context, "max_completion_tokens": max_completion,
|
|
"is_moderated": False,
|
|
},
|
|
"enabled": keep.get("enabled", True) if keep else True,
|
|
}, token)
|
|
log(f"{model_id}: context {context}, " + ("operator price kept" if keep else "priced at install defaults"))
|
|
|
|
|
|
def configure(child):
|
|
if not wait_ready(child, time.monotonic() + 300):
|
|
log("Routstr did not become ready; node settings not applied")
|
|
return
|
|
try:
|
|
password = os.environ["ADMIN_PASSWORD"]
|
|
token = call("POST", "/admin/api/login", {"password": password})["token"]
|
|
sync_settings(token)
|
|
sync_model(token)
|
|
except Exception as error: # never take Routstr down over configuration
|
|
log(f"configuration failed ({type(error).__name__}): {error}")
|
|
|
|
|
|
def main():
|
|
child = subprocess.Popen(UVICORN)
|
|
stopping = []
|
|
|
|
def forward(signum, _frame):
|
|
stopping.append(signum)
|
|
child.send_signal(signum)
|
|
|
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
signal.signal(sig, forward)
|
|
configure(child)
|
|
code = child.wait()
|
|
# uvicorn re-raises the stop signal after a graceful shutdown; a stop we
|
|
# were asked for is a clean exit, not a crash for the orchestrator.
|
|
if code < 0 and -code in stopping:
|
|
code = 0
|
|
sys.exit(code if code >= 0 else 128 - code)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|