Files
archy/docker/routstr/archy-start.py
T
ssmithxandClaude Opus 5.5 12b160b8fb feat(routstr): sell models from Local Llama or an AI server on the LAN
"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>
2026-10-25 23:52:28 +00:00

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()