Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/TERMINAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ strata monitor --connection work
strata logs --connection work --source runtime --follow --output jsonl
```

`config show` reports the source of each effective setting and whether the referenced credential is present, without printing its value or connecting to the server. An explicit `--url` or `--connection` wins over `STRATA_CONNECTION`, then `STRATA_URL`, then the local default. `--credential-env` overrides the selected reference. Direct URL use retains the legacy `STRATA_API_KEY` default; new saved connections default to `STRATA_TELEMETRY_KEY` so a read-only key can be used. Remote roots require HTTPS, and a saved proxy prefix accepts only simple path segments. The source CLI and portable monitor bundle use the same owner-private connection files in `${XDG_CONFIG_HOME:-~/.config}/strata/connections` or `%APPDATA%/Strata/connections`; `STRATA_CONNECTION_DIR` can select an absolute directory. On POSIX, the directory and records must be owner-only. Windows ACL qualification remains open.
`config show` reports the source of each effective setting and whether the referenced credential is present, without printing its value or connecting to the server. An explicit `--url` or `--connection` wins over `STRATA_CONNECTION`, then `STRATA_URL`, then the local default. `--credential-env` overrides the selected reference. Direct URL use retains the legacy `STRATA_API_KEY` default; new saved connections default to `STRATA_TELEMETRY_KEY` so a read-only key can be used. Remote roots require HTTPS. HTTP is allowed for loopback and numeric addresses reported on this computer's own network interfaces; those local requests bypass system proxies. An address on another LAN computer still requires HTTPS. A saved proxy prefix accepts only simple path segments. The source CLI and portable monitor bundle use the same owner-private connection files in `${XDG_CONFIG_HOME:-~/.config}/strata/connections` or `%APPDATA%/Strata/connections`; `STRATA_CONNECTION_DIR` can select an absolute directory. On POSIX, the directory and records must be owner-only. Windows ACL qualification remains open.

Operator profiles are separate from setup's run config. Save one from an installed config, preview edits, and use its name for serving:

Expand Down
8 changes: 8 additions & 0 deletions docs/TERMINAL_LAUNCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ strata launch --preset "IQ3_XXS 128K" # a saved
strata launch --family qwen --model iq3_xxs --context 128k --parallel 4 --vision off --tune quick \
--access lan --port 8080 --save-preset "Office" --no-tui
strata launch --model-path /data/models/IQ3_XXS --no-tui # files you already have
strata launch --family qwen --model iq3_s --host 192.168.1.20 --port 8080 --context 128k --vision gpu --no-tui
strata launch --preset Office --set prefill=65536 --set STRATA_PIN_BY_NEED=1 --no-tui
strata launch --family qwen --model q2_0 --context 64k --restart --no-tui
strata launch --list-presets
Expand All @@ -101,6 +102,7 @@ strata launch --show-key
| `--set KEY=VALUE` | An advanced engine setting or `STRATA_*` variable (repeatable). Unknown names get a warning. |
| `--pool-cores LIST` | Experimental: pin CPU workers to these logical processors, for example `2-14:2` ([DETAILS.md](DETAILS.md)). |
| `--access local\|lan\|cloudflare\|tailscale`, `--port N` | Who can reach the server. |
| `--host ADDRESS` | Bind to an IPv4 address this computer owns, or `localhost`, instead of the access default. |
| `--api-key KEY`, `--api-key-env NAME`, `--new-key`, `--no-api-key` | The key clients must send. |
| `--save-preset NAME`, `--list-presets`, `--delete-preset NAME` | Presets. |
| `--no-tui` | No screens: serve, print the address and return. |
Expand All @@ -115,6 +117,12 @@ strata launch --show-key
Presets are small files in `launch-presets/` beside the profiles folder. A run uses the shared profile `launch-<name>` and the shared
tuning presets (`strata presets`), so the app sees them too.

`--host` takes an address without `http://` or a port. Without `--access`, a loopback address selects local access and another
address selects LAN access, keeping the existing API-key behavior. The address is saved with `--save-preset`; `--host` beside
`--preset` overrides it for that run. Choosing `--access` without `--host` clears a saved bind override. With neither, the access
mode's existing bind address is unchanged. Cloudflare needs a bind reachable at `127.0.0.1` (`127.0.0.1`, `localhost` or
`0.0.0.0`), and Tailscale needs `0.0.0.0`; incompatible overrides are rejected before starting.

**Launching while a model is serving.** The same choices restart nothing and print the running addresses. Different choices
(another context, pictures, access, or another model on the same port) make the screens ask once, "Restart the model with these
settings? Answers in progress stop." With `--no-tui` the command refuses with one line naming the running model, its port and settings. Add
Expand Down
30 changes: 28 additions & 2 deletions strata_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import ipaddress
import json
import math
import re
Expand Down Expand Up @@ -42,6 +43,28 @@ def __init__(self, message: str, exit_code: int = 3, *, http_status: int | None
self.http_status = http_status


def _local_address(host: str) -> bool:
"""Permit HTTP to an explicitly bound interface on this computer, never an arbitrary LAN peer."""
if host == "localhost":
return True
try:
address = ipaddress.ip_address(host)
except ValueError:
return False
if address.is_loopback:
return True
if address.is_unspecified or address.is_multicast:
return False
try:
import psutil
except ImportError:
return False
try:
return any(info.address == host for infos in psutil.net_if_addrs().values() for info in infos)
except (OSError, psutil.Error):
return False


def server_url(raw: str) -> str:
"""Require a server root and prevent credential/path ambiguity."""
if not isinstance(raw, str):
Expand All @@ -56,7 +79,7 @@ def server_url(raw: str) -> str:
except ValueError as e:
raise ValueError("invalid server port") from e
host = u.hostname.lower()
local = host in ("localhost", "127.0.0.1", "::1")
local = _local_address(host)
if u.scheme != "https" and not local:
raise ValueError("remote monitoring requires HTTPS or a local SSH tunnel")
authority = f"[{host}]" if ":" in host else host
Expand Down Expand Up @@ -105,7 +128,10 @@ def get(self, path: str) -> dict[str, Any]:
if self.credential:
headers["Authorization"] = "Bearer " + self.credential
req = urllib.request.Request(self.base_url + path, headers=headers)
opener = urllib.request.build_opener(_NoRedirect)
handlers = [_NoRedirect]
if _local_address(urllib.parse.urlsplit(self.root).hostname or ""):
handlers.append(urllib.request.ProxyHandler({}))
opener = urllib.request.build_opener(*handlers)
try:
with opener.open(req, timeout=self.timeout) as response:
content_type = response.headers.get("Content-Type", "")
Expand Down
12 changes: 12 additions & 0 deletions strata_cli/launch/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ def host_for(kind: str) -> str:
return "127.0.0.1" if kind in ("local", "cloudflare") else "0.0.0.0"


def bind_host(s: LaunchSettings) -> str:
return s.host if s.host is not None else host_for(s.access)


def client_url(host: str, port: int) -> str:
"""A wildcard is a bind address, never a destination for clients."""
return f"http://{'127.0.0.1' if host == '0.0.0.0' else host}:{port}"


def lan_ips() -> list[str]:
"""This computer's private IPv4 addresses, the one used for the default route first."""
found: list[str] = []
Expand Down Expand Up @@ -284,6 +293,9 @@ def missing_helper(kind: str) -> str | None:

def urls_for(s: LaunchSettings, *, tunnel_url: str | None = None) -> list[str]:
"""The addresses a client can use for these choices (the first is the main one)."""
if s.host is not None and s.host != "0.0.0.0":
base = [client_url(s.host, s.port)]
return [tunnel_url] + base if s.access == "cloudflare" and tunnel_url else base
base = [f"http://127.0.0.1:{s.port}"]
if s.access == "lan":
base += [f"http://{ip}:{s.port}" for ip in lan_ips()[:1]]
Expand Down
4 changes: 2 additions & 2 deletions strata_cli/launch/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def warnings(self) -> list[str]:
out.append(f"{r.title}: {r.words}.")
if self.computer.other_vendor_note:
out.append(self.computer.other_vendor_note)
missing = acc.missing_helper(s.access)
missing = None if s.host is not None and s.access == "lan" else acc.missing_helper(s.access)
if missing:
out.append(missing)
return out
Expand Down Expand Up @@ -420,7 +420,7 @@ def run_monitor(info: dict, *, keep_default: bool = True, color: str = "auto") -
"""Open the monitor for a running launch. q asks whether to keep serving or stop."""
from strata_cli.client import Endpoint
from strata_cli.launch.monitor_ext import LaunchMonitor
local = next((u for u in info.get("urls", []) if "127.0.0.1" in u), None) or f"http://127.0.0.1:{info.get('port', S.DEFAULT_PORT)}"
local = next((u for u in info.get("urls", []) if "127.0.0.1" in u), None) or info.get("url") or f"http://127.0.0.1:{info.get('port', S.DEFAULT_PORT)}"
endpoint = Endpoint(local, info.get("key"), 3.0, "")
app = LaunchMonitor(endpoint, None, info=info, color=color)
app.run()
Expand Down
29 changes: 24 additions & 5 deletions strata_cli/launch/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ def _port(value: str) -> int:
return p


def _host(value: str) -> str:
try:
return S.parse_host(value)
except ValueError as e:
raise argparse.ArgumentTypeError(str(e)) from None


def _parallel(value: str) -> int:
try:
p = int(value)
Expand Down Expand Up @@ -127,6 +134,9 @@ def add_parsers(sp: argparse._SubParsersAction) -> list[argparse.ArgumentParser]
a.add_argument("--access", type=_choice("a way to share Strata", S.ACCESS_KINDS), metavar="local|lan|cloudflare|tailscale",
help="local: this computer only; lan: your network; cloudflare: a quick tunnel; tailscale: your tailnet")
a.add_argument("--port", type=_port, metavar="N", help=f"port (default {S.DEFAULT_PORT})")
a.add_argument("--host", type=_host, metavar="ADDRESS",
help="bind to this IPv4 address or localhost instead of the access default; without --access, "
"loopback selects local and any other address selects lan")
a.add_argument("--api-key", metavar="KEY", help="the key clients must send (made for you when you share beyond this computer)")
a.add_argument("--api-key-env", metavar="NAME", help="read the key from this environment variable")
a.add_argument("--new-key", action="store_true", help="make a new API key")
Expand Down Expand Up @@ -265,6 +275,12 @@ def settings_from_args(a: argparse.Namespace, catalog=None) -> tuple[S.LaunchSet
s.tune_allow_small = True
if a.access:
s.access = a.access
if a.host is not None:
s.host = a.host
if not a.access and s.access in ("local", "lan"):
s.access = "local" if S.loopback_host(s.host) else "lan"
elif a.access:
s.host = None # choosing another access mode resets a saved bind override
if a.port:
s.port = a.port
if a.api_key:
Expand Down Expand Up @@ -357,7 +373,7 @@ def apply_defaults(s: S.LaunchSettings, catalog) -> tuple[S.LaunchSettings, list
s.speed = None
deepseek = bool(catalog.strata.multi_family(s.family))
notices += S.check_context(s, max_fit_tokens=None, deepseek=deepseek, pack_limit=catalog.pack_limit(s.family, s.model) if deepseek else None)
if s.access != "local" and not s.api_key and not s.no_key:
if S.shared_access(s) and not s.api_key and not s.no_key:
from strata_cli.launch import access
kept = access.kept_key(s.name or f"{s.model or 'model'} {S.context_label(s.context)}")
s.api_key = kept or access.new_key()
Expand Down Expand Up @@ -615,8 +631,10 @@ def running_launches() -> list[dict]:
except Exception: # noqa: BLE001
ov = {}
port = ov.get("port", S.DEFAULT_PORT)
from strata_cli.launch import access
host = ov.get("host", "127.0.0.1")
out.append({"name": p["id"][7:], "profile": p["id"], "state": st.get("state"), "port": port,
"url": f"http://127.0.0.1:{port}", "host": ov.get("host", "127.0.0.1"), "config": prof.get("source_config")})
"url": access.client_url(host, port), "host": host, "config": prof.get("source_config")})
return out


Expand All @@ -632,7 +650,7 @@ def _cfg_context(cfg: dict) -> int | None:

def launch_info(name: str | None = None) -> dict | None:
"""Address, key and model name of a launch that is running (or the named preset), for `connect` and the monitor's Connect view."""
from strata_cli.launch import store
from strata_cli.launch import access, store
running = running_launches()
pick = None
if name:
Expand All @@ -650,7 +668,8 @@ def launch_info(name: str | None = None) -> dict | None:
if pick is None and preset is None:
return None
port = (pick or {}).get("port") or (preset.port if preset else S.DEFAULT_PORT)
return {"url": (pick or {}).get("url") or f"http://127.0.0.1:{port}", "key": (cfg.get("api_key") if str(cfg.get("api_key") or "").strip().lower() != "none" else None) or (preset.api_key if preset else None),
fallback_host = access.bind_host(preset) if preset else "127.0.0.1"
return {"url": (pick or {}).get("url") or access.client_url(fallback_host, port), "key": (cfg.get("api_key") if str(cfg.get("api_key") or "").strip().lower() != "none" else None) or (preset.api_key if preset else None),
"model": cfg.get("model_name") or "strata", "context": _cfg_context(cfg), "running": bool(pick), "name": (pick or {}).get("name") or (preset.name if preset else None),
"port": port, "access": preset.access if preset else None}

Expand Down Expand Up @@ -750,7 +769,7 @@ def fail(text: str, code: int = 2) -> int:
s, notices = settings_from_args(a, catalog)
if not machine:
_interrupted_move_notice()
have_choices = bool(a.preset or a.model or a.model_path or a.context or a.vision or a.parallel or a.access or a.tune or a.drafting or getattr(a, "speed", None) or getattr(a, "full_quality", False))
have_choices = bool(a.preset or a.model or a.model_path or a.context or a.vision or a.parallel or a.access or a.host or a.tune or a.drafting or getattr(a, "speed", None) or getattr(a, "full_quality", False))
interactive = sys.stdin.isatty() and sys.stdout.isatty() and not a.no_tui and not machine and not a.plan
plain_questions = False
no_screens_package = None
Expand Down
8 changes: 4 additions & 4 deletions strata_cli/launch/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,11 +509,11 @@ def derive_config(base: dict, s: S.LaunchSettings, *, bool_flags: frozenset[str]
"""The private run config of this launch: the installed one plus the choices that are not setup's (host, port, key, cards, advanced)."""
from strata_control import tune_knobs
cfg = json.loads(json.dumps(base))
cfg["host"] = acc.host_for(s.access)
cfg["host"] = acc.bind_host(s)
cfg["port"] = int(s.port)
if s.api_key:
cfg["api_key"] = s.api_key
elif s.no_key and s.access != "local":
elif s.no_key and S.shared_access(s):
cfg["api_key"] = "none" # served without a key on purpose (the server warns once at start)
else:
cfg.pop("api_key", None)
Expand Down Expand Up @@ -1423,7 +1423,7 @@ def ensure_profile(self, config: Path) -> None:
from strata_control import profiles
name = self.profile
parallel = self.s.parallel if self.s.parallel else self.catalog_parallel()
sets = [f"host={acc.host_for(self.s.access)}", f"port={int(self.s.port)}"] + ([f"parallel={parallel}"] if parallel else [])
sets = [f"host={acc.bind_host(self.s)}", f"port={int(self.s.port)}"] + ([f"parallel={parallel}"] if parallel else [])
try:
current, _rev = profiles.load(name)
except profiles.ProfileError:
Expand Down Expand Up @@ -1850,7 +1850,7 @@ def report_drafting(self) -> None:
# ---- 6. access
def open_access(self) -> str | None:
s = self.s
missing = acc.missing_helper(s.access)
missing = None if s.host is not None and s.access == "lan" else acc.missing_helper(s.access)
tunnel = None
if s.access == "cloudflare":
if missing:
Expand Down
18 changes: 12 additions & 6 deletions strata_cli/launch/screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -1449,8 +1449,10 @@ def build(self) -> None:
def refresh_body(self) -> None:
s = self.ctx.settings
on = bool(s.api_key)
beyond = s.access != "local"
beyond = S.shared_access(s)
t = Text()
if s.host is not None:
t.append(f"\n Listen {s.host}\n")
t.append(f"\n Port [ {s.port} ] press p to change\n")
t.append(f" API key [ {'on' if on else 'off'} ] {acc.mask_key(s.api_key)} ", style=FG if on else WARN)
t.append(("(recommended for anything beyond this computer; t = no key)" if on else "(t = make a key)") if beyond else "(not needed on this computer)", style=MUTE)
Expand Down Expand Up @@ -1479,11 +1481,13 @@ def explain(self) -> Text:
def on_cursor(self) -> None:
kind = self.menu().current.key
s = self.ctx.settings
if kind != s.access:
s.host = None
s.access = kind
if kind != "local" and not s.api_key and not s.no_key:
if S.shared_access(s) and not s.api_key and not s.no_key:
s.api_key = acc.kept_key() or acc.new_key()
self.say("An API key was made for you (recommended beyond this computer). Press t if you want no key.")
elif kind != "local" and s.no_key:
elif S.shared_access(s) and s.no_key:
self.say(S.NO_KEY_PUBLIC if kind == "cloudflare" else S.NO_KEY_NETWORK, True)

def action_port(self) -> None:
Expand Down Expand Up @@ -1513,8 +1517,8 @@ def action_keytoggle(self) -> None:
s = self.ctx.settings
if s.api_key:
s.api_key = None
s.no_key = s.access != "local"
self.say("The API key is off." + ((" " + (S.NO_KEY_PUBLIC if s.access == "cloudflare" else S.NO_KEY_NETWORK)) if s.access != "local" else ""), s.access != "local")
s.no_key = S.shared_access(s)
self.say("The API key is off." + ((" " + (S.NO_KEY_PUBLIC if s.access == "cloudflare" else S.NO_KEY_NETWORK)) if S.shared_access(s) else ""), S.shared_access(s))
else:
s.api_key, s.no_key = acc.new_key(), False
self.say("The API key is on.")
Expand All @@ -1528,8 +1532,10 @@ def action_next(self) -> None:

def commit(self) -> None:
s = self.ctx.settings
if s.access != self.menu().current.key:
s.host = None
s.access = self.menu().current.key
if s.access != "local" and not s.api_key and not s.no_key:
if S.shared_access(s) and not s.api_key and not s.no_key:
s.api_key = acc.kept_key() or acc.new_key()

def action_back(self) -> None:
Expand Down
Loading
Loading