modelmux-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modelmux_cli/__init__.py +8 -0
- modelmux_cli/__main__.py +7 -0
- modelmux_cli/_shared/providers/claude.json +34 -0
- modelmux_cli/_shared/providers/codex.json +35 -0
- modelmux_cli/_shared/release.json +4 -0
- modelmux_cli/_shared/schema/provider.schema.json +81 -0
- modelmux_cli/_shared/templates/compose-service.json +12 -0
- modelmux_cli/_shared/templates/config/curl.json +8 -0
- modelmux_cli/_shared/templates/config/env.json +8 -0
- modelmux_cli/_shared/templates/config/langchain.json +8 -0
- modelmux_cli/_shared/templates/config/litellm.json +8 -0
- modelmux_cli/_shared/templates/config/openai-python.json +8 -0
- modelmux_cli/clientconfig.py +87 -0
- modelmux_cli/commands/__init__.py +1 -0
- modelmux_cli/commands/basic.py +75 -0
- modelmux_cli/commands/config_cmd.py +65 -0
- modelmux_cli/commands/doctor.py +190 -0
- modelmux_cli/commands/login.py +190 -0
- modelmux_cli/commands/logout.py +53 -0
- modelmux_cli/commands/up.py +157 -0
- modelmux_cli/commands/upgrade.py +50 -0
- modelmux_cli/config.py +78 -0
- modelmux_cli/console.py +112 -0
- modelmux_cli/context.py +41 -0
- modelmux_cli/docker.py +340 -0
- modelmux_cli/errors.py +35 -0
- modelmux_cli/files.py +42 -0
- modelmux_cli/health.py +60 -0
- modelmux_cli/main.py +119 -0
- modelmux_cli/paths.py +51 -0
- modelmux_cli/ports.py +29 -0
- modelmux_cli/providers.py +168 -0
- modelmux_cli/py.typed +0 -0
- modelmux_cli/redact.py +35 -0
- modelmux_cli/release.py +40 -0
- modelmux_cli/secrets_store.py +71 -0
- modelmux_cli/stack.py +217 -0
- modelmux_cli/terminal.py +44 -0
- modelmux_cli/updates.py +50 -0
- modelmux_cli-0.1.0.dist-info/METADATA +40 -0
- modelmux_cli-0.1.0.dist-info/RECORD +43 -0
- modelmux_cli-0.1.0.dist-info/WHEEL +4 -0
- modelmux_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Provider definitions, loaded from the shared JSON files.
|
|
2
|
+
|
|
3
|
+
The files are validated strictly here (no dependency needed); the test suite
|
|
4
|
+
additionally validates them against ``shared/schema/provider.schema.json``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from functools import cache
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from modelmux_cli.errors import CliError, UsageError
|
|
17
|
+
|
|
18
|
+
_IDENTIFIER = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
|
|
19
|
+
_VOLUME = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,62}$")
|
|
20
|
+
_ARG = re.compile(r"^[A-Za-z0-9._=/-]+$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class LoginMethod:
|
|
25
|
+
name: str
|
|
26
|
+
description: str
|
|
27
|
+
command: tuple[str, ...]
|
|
28
|
+
secret_stdin: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Provider:
|
|
33
|
+
name: str
|
|
34
|
+
display_name: str
|
|
35
|
+
driver: str
|
|
36
|
+
default_port: int
|
|
37
|
+
volume: str
|
|
38
|
+
home: str
|
|
39
|
+
example_model: str
|
|
40
|
+
login_methods: dict[str, LoginMethod]
|
|
41
|
+
default_login_method: str
|
|
42
|
+
link_pattern: re.Pattern[str]
|
|
43
|
+
login_timeout: int
|
|
44
|
+
status_command: tuple[str, ...]
|
|
45
|
+
logged_in_exit_code: int
|
|
46
|
+
logout_command: tuple[str, ...]
|
|
47
|
+
docs_url: str | None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def shared_dir() -> Path:
|
|
51
|
+
"""The bundled shared data, or the repository's /shared when run from source."""
|
|
52
|
+
here = Path(__file__).resolve().parent
|
|
53
|
+
bundled = here / "_shared"
|
|
54
|
+
if bundled.is_dir():
|
|
55
|
+
return bundled
|
|
56
|
+
return here.parents[3] / "shared" # cli/python/src/modelmux_cli -> repo root
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ProviderFileError(CliError):
|
|
60
|
+
def __init__(self, path: Path, problem: str) -> None:
|
|
61
|
+
super().__init__(
|
|
62
|
+
f"Provider definition {path.name} is invalid: {problem}",
|
|
63
|
+
hint="This is a packaging bug; please report it.",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _require(data: dict[str, Any], key: str, kind: type, path: Path) -> Any:
|
|
68
|
+
value = data.get(key)
|
|
69
|
+
# bool is a subclass of int; never accept true/false where a number is expected.
|
|
70
|
+
if not isinstance(value, kind) or (isinstance(value, bool) and kind is not bool):
|
|
71
|
+
raise ProviderFileError(path, f"'{key}' must be a {kind.__name__}")
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _command(value: Any, path: Path, where: str) -> tuple[str, ...]:
|
|
76
|
+
if (
|
|
77
|
+
not isinstance(value, list)
|
|
78
|
+
or not value
|
|
79
|
+
or not all(isinstance(a, str) and _ARG.fullmatch(a) for a in value)
|
|
80
|
+
):
|
|
81
|
+
raise ProviderFileError(path, f"'{where}' must be a non-empty list of plain arguments")
|
|
82
|
+
return tuple(value)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse(path: Path) -> Provider:
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
88
|
+
except (OSError, ValueError) as exc:
|
|
89
|
+
raise ProviderFileError(path, f"cannot be read ({type(exc).__name__})") from None
|
|
90
|
+
if not isinstance(data, dict) or data.get("schema_version") != 1:
|
|
91
|
+
raise ProviderFileError(path, "unsupported schema_version")
|
|
92
|
+
|
|
93
|
+
name = _require(data, "name", str, path)
|
|
94
|
+
if not _IDENTIFIER.fullmatch(name) or path.stem != name:
|
|
95
|
+
raise ProviderFileError(path, "'name' must be an identifier matching the file name")
|
|
96
|
+
volume = _require(data, "volume", str, path)
|
|
97
|
+
if not _VOLUME.fullmatch(volume):
|
|
98
|
+
raise ProviderFileError(path, "'volume' is not a valid volume name")
|
|
99
|
+
home = _require(data, "home", str, path)
|
|
100
|
+
if not home.startswith("/"):
|
|
101
|
+
raise ProviderFileError(path, "'home' must be an absolute container path")
|
|
102
|
+
port = _require(data, "default_port", int, path)
|
|
103
|
+
if not 1024 <= port <= 65535:
|
|
104
|
+
raise ProviderFileError(path, "'default_port' must be between 1024 and 65535")
|
|
105
|
+
|
|
106
|
+
login = _require(data, "login", dict, path)
|
|
107
|
+
methods: dict[str, LoginMethod] = {}
|
|
108
|
+
for method_name, method in _require(login, "methods", dict, path).items():
|
|
109
|
+
if not _IDENTIFIER.fullmatch(method_name) or not isinstance(method, dict):
|
|
110
|
+
raise ProviderFileError(path, f"invalid login method {method_name!r}")
|
|
111
|
+
secret = method.get("secret_stdin")
|
|
112
|
+
methods[method_name] = LoginMethod(
|
|
113
|
+
name=method_name,
|
|
114
|
+
description=_require(method, "description", str, path),
|
|
115
|
+
command=_command(method.get("command"), path, f"login.methods.{method_name}"),
|
|
116
|
+
secret_stdin=secret if isinstance(secret, str) else None,
|
|
117
|
+
)
|
|
118
|
+
default_method = _require(login, "default_method", str, path)
|
|
119
|
+
if default_method not in methods:
|
|
120
|
+
raise ProviderFileError(path, "'login.default_method' is not one of the methods")
|
|
121
|
+
try:
|
|
122
|
+
link_pattern = re.compile(_require(login, "link_pattern", str, path))
|
|
123
|
+
except re.error:
|
|
124
|
+
raise ProviderFileError(path, "'login.link_pattern' is not a valid regex") from None
|
|
125
|
+
|
|
126
|
+
status = _require(data, "status", dict, path)
|
|
127
|
+
logout = _require(data, "logout", dict, path)
|
|
128
|
+
docs_url = data.get("docs_url")
|
|
129
|
+
return Provider(
|
|
130
|
+
name=name,
|
|
131
|
+
display_name=_require(data, "display_name", str, path),
|
|
132
|
+
driver=_require(data, "driver", str, path),
|
|
133
|
+
default_port=port,
|
|
134
|
+
volume=volume,
|
|
135
|
+
home=home,
|
|
136
|
+
example_model=_require(data, "example_model", str, path),
|
|
137
|
+
login_methods=methods,
|
|
138
|
+
default_login_method=default_method,
|
|
139
|
+
link_pattern=link_pattern,
|
|
140
|
+
login_timeout=_require(login, "timeout_seconds", int, path),
|
|
141
|
+
status_command=_command(status.get("command"), path, "status.command"),
|
|
142
|
+
logged_in_exit_code=_require(status, "logged_in_exit_code", int, path),
|
|
143
|
+
logout_command=_command(logout.get("command"), path, "logout.command"),
|
|
144
|
+
docs_url=docs_url if isinstance(docs_url, str) else None,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@cache
|
|
149
|
+
def load_providers(directory: Path | None = None) -> dict[str, Provider]:
|
|
150
|
+
directory = directory or shared_dir() / "providers"
|
|
151
|
+
providers = {p.name: p for p in map(_parse, sorted(directory.glob("*.json")))}
|
|
152
|
+
if not providers:
|
|
153
|
+
raise CliError(
|
|
154
|
+
"No provider definitions found.", hint="This is a packaging bug; please report it."
|
|
155
|
+
)
|
|
156
|
+
volumes = [p.volume for p in providers.values()]
|
|
157
|
+
if len(volumes) != len(set(volumes)):
|
|
158
|
+
raise CliError("Two providers share a login volume.", hint="Please report this bug.")
|
|
159
|
+
return providers
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_provider(name: str) -> Provider:
|
|
163
|
+
providers = load_providers()
|
|
164
|
+
if name not in providers:
|
|
165
|
+
raise UsageError(
|
|
166
|
+
f"Unknown provider {name!r}.", hint=f"Choose one of: {', '.join(sorted(providers))}."
|
|
167
|
+
)
|
|
168
|
+
return providers[name]
|
modelmux_cli/py.typed
ADDED
|
File without changes
|
modelmux_cli/redact.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Mask secrets in anything the CLI prints (including --verbose output)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
MASK = "[REDACTED]"
|
|
9
|
+
|
|
10
|
+
_PATTERNS = (
|
|
11
|
+
(re.compile(r"(?i)\b(bearer)\s+[A-Za-z0-9._~+/=-]+"), r"\1 " + MASK),
|
|
12
|
+
(re.compile(r"\bsk-[A-Za-z0-9_-]{8,}"), MASK),
|
|
13
|
+
(re.compile(r"\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]*"), MASK),
|
|
14
|
+
# The server's key list: only values that look like keys, not "$(...)" hints.
|
|
15
|
+
(re.compile(r"\b(MODELMUX_API_KEYS=)[A-Za-z0-9_,-]{16,}"), r"\1" + MASK),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_secrets: set[str] = set()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register_secret(*values: str) -> None:
|
|
22
|
+
"""Mask these exact values everywhere from now on (e.g. the server API key)."""
|
|
23
|
+
_secrets.update(v for v in values if len(v) >= 8)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def registered() -> Iterable[str]:
|
|
27
|
+
return frozenset(_secrets)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def redact(text: str) -> str:
|
|
31
|
+
for secret in sorted(_secrets, key=len, reverse=True):
|
|
32
|
+
text = text.replace(secret, MASK)
|
|
33
|
+
for pattern, replacement in _PATTERNS:
|
|
34
|
+
text = pattern.sub(replacement, text)
|
|
35
|
+
return text
|
modelmux_cli/release.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The release this CLI belongs to, from ``shared/release.json``.
|
|
2
|
+
|
|
3
|
+
The release workflow writes the server image pinned by digest
|
|
4
|
+
(``ghcr.io/smit153/modelmux@sha256:...``) into that file before building, so
|
|
5
|
+
every CLI implementation (Python today, Node later) runs the same image. In a
|
|
6
|
+
development checkout ``image`` is null: pass ``--image`` instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from functools import cache
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from modelmux_cli.errors import CliError
|
|
16
|
+
from modelmux_cli.providers import shared_dir
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@cache
|
|
20
|
+
def release_data() -> dict[str, Any]:
|
|
21
|
+
path = shared_dir() / "release.json"
|
|
22
|
+
try:
|
|
23
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
24
|
+
except (OSError, ValueError):
|
|
25
|
+
raise CliError(
|
|
26
|
+
"The release data is missing or damaged.", hint="Please report this bug."
|
|
27
|
+
) from None
|
|
28
|
+
if not isinstance(data, dict) or not isinstance(data.get("version"), str):
|
|
29
|
+
raise CliError("The release data is invalid.", hint="Please report this bug.")
|
|
30
|
+
image = data.get("image")
|
|
31
|
+
if image is not None and not (isinstance(image, str) and "@sha256:" in image):
|
|
32
|
+
raise CliError(
|
|
33
|
+
"The pinned server image is not pinned by digest.", hint="Please report this bug."
|
|
34
|
+
)
|
|
35
|
+
return data
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def pinned_image() -> str | None:
|
|
39
|
+
image = release_data().get("image")
|
|
40
|
+
return image if isinstance(image, str) else None
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The API key clients use to call ModelMux (not a provider credential).
|
|
2
|
+
|
|
3
|
+
Generated once with ``secrets``, stored in ``secrets.env`` with mode 0600 in
|
|
4
|
+
the format compose reads as an env file. The value is registered for
|
|
5
|
+
redaction as soon as it is loaded, so it never appears in output unless the
|
|
6
|
+
user explicitly asks for it (``modelmux key show``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from modelmux_cli.errors import CliError
|
|
17
|
+
from modelmux_cli.files import tighten, write_private
|
|
18
|
+
from modelmux_cli.paths import ensure_private_dir
|
|
19
|
+
from modelmux_cli.redact import register_secret
|
|
20
|
+
|
|
21
|
+
SECRETS_FILE = "secrets.env"
|
|
22
|
+
VARIABLE = "MODELMUX_API_KEYS"
|
|
23
|
+
KEY_BYTES = 32 # token_urlsafe(32) -> 43 characters; the server requires >= 32
|
|
24
|
+
_LINE = re.compile(rf"^{VARIABLE}=([A-Za-z0-9_-]{{32,}})$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ApiKey:
|
|
29
|
+
value: str
|
|
30
|
+
path: Path
|
|
31
|
+
created: bool = False
|
|
32
|
+
permissions_fixed: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SecretsFileError(CliError):
|
|
36
|
+
def __init__(self, path: Path) -> None:
|
|
37
|
+
super().__init__(
|
|
38
|
+
"The saved ModelMux API key is unreadable or damaged.",
|
|
39
|
+
hint=f"Delete {path} and run 'modelmux up' to create a new key "
|
|
40
|
+
"(clients using the old key will need the new one).",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read_api_key(directory: Path) -> ApiKey | None:
|
|
45
|
+
path = directory / SECRETS_FILE
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return None
|
|
48
|
+
fixed = tighten(path)
|
|
49
|
+
try:
|
|
50
|
+
lines = [ln.strip() for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
|
51
|
+
except OSError:
|
|
52
|
+
raise SecretsFileError(path) from None
|
|
53
|
+
match = _LINE.fullmatch(lines[0]) if len(lines) == 1 else None
|
|
54
|
+
if match is None:
|
|
55
|
+
raise SecretsFileError(path)
|
|
56
|
+
value = match.group(1)
|
|
57
|
+
register_secret(value)
|
|
58
|
+
return ApiKey(value, path, permissions_fixed=fixed)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ensure_api_key(directory: Path) -> ApiKey:
|
|
62
|
+
"""Return the saved key, creating one on first use."""
|
|
63
|
+
existing = read_api_key(directory)
|
|
64
|
+
if existing is not None:
|
|
65
|
+
return existing
|
|
66
|
+
ensure_private_dir(directory)
|
|
67
|
+
value = secrets.token_urlsafe(KEY_BYTES)
|
|
68
|
+
register_secret(value)
|
|
69
|
+
path = directory / SECRETS_FILE
|
|
70
|
+
write_private(path, f"{VARIABLE}={value}\n")
|
|
71
|
+
return ApiKey(value, path, created=True)
|
modelmux_cli/stack.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""The ModelMux containers on this machine: image, compose file, login volumes.
|
|
2
|
+
|
|
3
|
+
All containers belong to the compose project ``modelmux``. Each provider has
|
|
4
|
+
one service (``modelmux-<provider>``) and one *external* login volume
|
|
5
|
+
(``modelmux_<provider>-home``) that only ``modelmux logout`` deletes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from modelmux_cli import release
|
|
17
|
+
from modelmux_cli.config import Config
|
|
18
|
+
from modelmux_cli.docker import PULL_TIMEOUT, Docker
|
|
19
|
+
from modelmux_cli.errors import CliError, DockerError, UsageError
|
|
20
|
+
from modelmux_cli.files import write_private
|
|
21
|
+
from modelmux_cli.providers import Provider, shared_dir
|
|
22
|
+
|
|
23
|
+
PROJECT = "modelmux"
|
|
24
|
+
COMPOSE_FILE = "compose.yaml" # JSON content; YAML is a superset of JSON
|
|
25
|
+
CONTAINER_PORT = 8000
|
|
26
|
+
# tmpfs mount spec inside helper containers (not a host path)
|
|
27
|
+
CONTAINER_TMP = "/tmp:rw,noexec,nosuid,nodev,size=64m" # noqa: S108
|
|
28
|
+
_IMAGE_REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,254}$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def volume_name(provider: Provider) -> str:
|
|
32
|
+
return f"{PROJECT}_{provider.volume}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def service_name(provider: Provider) -> str:
|
|
36
|
+
return f"{PROJECT}-{provider.name}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_image(config: Config, override: str | None = None) -> str:
|
|
40
|
+
"""The image to run: --image, then the saved override, then the pinned release image."""
|
|
41
|
+
image = override or config.image or release.pinned_image()
|
|
42
|
+
if image is None:
|
|
43
|
+
raise UsageError(
|
|
44
|
+
"This development build of modelmux has no pinned server image.",
|
|
45
|
+
hint="Pass one explicitly, e.g. --image modelmux:dev (it is remembered).",
|
|
46
|
+
)
|
|
47
|
+
if not _IMAGE_REF.fullmatch(image):
|
|
48
|
+
raise UsageError(f"{image!r} is not a valid image reference.")
|
|
49
|
+
return image
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def service_template() -> dict[str, Any]:
|
|
53
|
+
path = shared_dir() / "templates" / "compose-service.json"
|
|
54
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
if not isinstance(data, dict):
|
|
56
|
+
raise CliError("The compose template is invalid.", hint="Please report this bug.")
|
|
57
|
+
return data
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_compose(
|
|
61
|
+
providers: dict[str, Provider], config: Config, image: str, secrets_file: Path
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
template = service_template()
|
|
64
|
+
services: dict[str, Any] = {}
|
|
65
|
+
volumes: dict[str, Any] = {}
|
|
66
|
+
for name, provider in sorted(providers.items()):
|
|
67
|
+
services[service_name(provider)] = {
|
|
68
|
+
**template,
|
|
69
|
+
"image": image,
|
|
70
|
+
"environment": {"MODELMUX_DRIVER": provider.driver},
|
|
71
|
+
"env_file": [str(secrets_file)],
|
|
72
|
+
"volumes": [f"{provider.volume}:{provider.home}"],
|
|
73
|
+
"ports": [f"127.0.0.1:{config.port(name)}:{CONTAINER_PORT}"],
|
|
74
|
+
"networks": [PROJECT],
|
|
75
|
+
"labels": {**template.get("labels", {}), "io.modelmux.provider": name},
|
|
76
|
+
}
|
|
77
|
+
volumes[provider.volume] = {"external": True, "name": volume_name(provider)}
|
|
78
|
+
return {
|
|
79
|
+
"name": PROJECT,
|
|
80
|
+
"services": services,
|
|
81
|
+
"volumes": volumes,
|
|
82
|
+
"networks": {PROJECT: {"driver": "bridge"}},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_compose(directory: Path, compose: dict[str, Any]) -> Path:
|
|
87
|
+
path = directory / COMPOSE_FILE
|
|
88
|
+
header = "# Generated by modelmux. Do not edit: changes are overwritten by 'modelmux up'.\n"
|
|
89
|
+
write_private(path, header + json.dumps(compose, indent=2) + "\n")
|
|
90
|
+
return path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class ServiceState:
|
|
95
|
+
service: str
|
|
96
|
+
state: str # running, exited, restarting, created, ...
|
|
97
|
+
health: str # healthy, unhealthy, starting, or ""
|
|
98
|
+
exit_code: int | None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Stack:
|
|
102
|
+
def __init__(self, docker: Docker, home: Path) -> None:
|
|
103
|
+
self.docker = docker
|
|
104
|
+
self.home = home
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def compose_file(self) -> Path:
|
|
108
|
+
return self.home / COMPOSE_FILE
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------ image
|
|
111
|
+
|
|
112
|
+
def image_present(self, image: str) -> bool:
|
|
113
|
+
return self.docker.run("image", "inspect", image, check=False).ok
|
|
114
|
+
|
|
115
|
+
def ensure_image(self, image: str) -> bool:
|
|
116
|
+
"""Pull ``image`` if it is not present. Returns True if it was pulled."""
|
|
117
|
+
if self.image_present(image):
|
|
118
|
+
return False
|
|
119
|
+
try:
|
|
120
|
+
self.docker.run("pull", image, timeout=PULL_TIMEOUT)
|
|
121
|
+
except DockerError as exc:
|
|
122
|
+
if "/" in image.split("@", maxsplit=1)[0]:
|
|
123
|
+
raise
|
|
124
|
+
# A local-only name such as modelmux:dev.
|
|
125
|
+
raise DockerError(
|
|
126
|
+
f"The image {image} is not on this machine and could not be downloaded.",
|
|
127
|
+
hint=f"Build it first: docker build -f docker/Dockerfile -t {image} .",
|
|
128
|
+
) from exc
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------ volumes
|
|
132
|
+
|
|
133
|
+
def volume_exists(self, provider: Provider) -> bool:
|
|
134
|
+
return self.docker.run("volume", "inspect", volume_name(provider), check=False).ok
|
|
135
|
+
|
|
136
|
+
def ensure_volume(self, provider: Provider) -> None:
|
|
137
|
+
if not self.volume_exists(provider):
|
|
138
|
+
self.docker.run(
|
|
139
|
+
"volume", "create",
|
|
140
|
+
"--label", "io.modelmux.managed=true",
|
|
141
|
+
"--label", f"io.modelmux.provider={provider.name}",
|
|
142
|
+
volume_name(provider),
|
|
143
|
+
) # fmt: skip
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------ helper containers
|
|
146
|
+
|
|
147
|
+
def helper_args(
|
|
148
|
+
self,
|
|
149
|
+
provider: Provider,
|
|
150
|
+
image: str,
|
|
151
|
+
command: tuple[str, ...],
|
|
152
|
+
extra: tuple[str, ...] = (),
|
|
153
|
+
) -> list[str]:
|
|
154
|
+
"""``docker run`` arguments for a short-lived, hardened helper container.
|
|
155
|
+
|
|
156
|
+
``extra`` adds run options such as ``-it`` or ``--name``.
|
|
157
|
+
"""
|
|
158
|
+
return [
|
|
159
|
+
"run", "--rm", *extra,
|
|
160
|
+
"--read-only", "--tmpfs", CONTAINER_TMP,
|
|
161
|
+
"--cap-drop", "ALL", "--security-opt", "no-new-privileges:true",
|
|
162
|
+
"--user", "10001:10001",
|
|
163
|
+
"--label", "io.modelmux.managed=true",
|
|
164
|
+
"-v", f"{volume_name(provider)}:{provider.home}",
|
|
165
|
+
"-e", f"HOME={provider.home}",
|
|
166
|
+
"--entrypoint", command[0],
|
|
167
|
+
image, *command[1:],
|
|
168
|
+
] # fmt: skip
|
|
169
|
+
|
|
170
|
+
def logged_in(self, provider: Provider, image: str) -> bool:
|
|
171
|
+
if not self.volume_exists(provider):
|
|
172
|
+
return False
|
|
173
|
+
result = self.docker.run(
|
|
174
|
+
*self.helper_args(provider, image, provider.status_command), check=False, timeout=90
|
|
175
|
+
)
|
|
176
|
+
return result.returncode == provider.logged_in_exit_code
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------ compose
|
|
179
|
+
|
|
180
|
+
def compose(self, *args: str, **kwargs: Any) -> Any:
|
|
181
|
+
return self.docker.compose(PROJECT, self.compose_file, *args, **kwargs)
|
|
182
|
+
|
|
183
|
+
def states(self) -> dict[str, ServiceState]:
|
|
184
|
+
"""Current containers of the project, by service name. Empty if none."""
|
|
185
|
+
if not self.compose_file.exists():
|
|
186
|
+
return {}
|
|
187
|
+
result = self.compose("ps", "--all", "--format", "json", check=False)
|
|
188
|
+
if not result.ok:
|
|
189
|
+
return {}
|
|
190
|
+
return parse_ps(result.stdout)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parse_ps(output: str) -> dict[str, ServiceState]:
|
|
194
|
+
"""``docker compose ps --format json`` output: one object per line, or one array."""
|
|
195
|
+
text = output.strip()
|
|
196
|
+
if not text:
|
|
197
|
+
return {}
|
|
198
|
+
try:
|
|
199
|
+
items = (
|
|
200
|
+
json.loads(text)
|
|
201
|
+
if text.startswith("[")
|
|
202
|
+
else [json.loads(line) for line in text.splitlines() if line.strip()]
|
|
203
|
+
)
|
|
204
|
+
except ValueError:
|
|
205
|
+
return {}
|
|
206
|
+
states: dict[str, ServiceState] = {}
|
|
207
|
+
for item in items:
|
|
208
|
+
if not isinstance(item, dict) or not isinstance(item.get("Service"), str):
|
|
209
|
+
continue
|
|
210
|
+
code = item.get("ExitCode")
|
|
211
|
+
states[item["Service"]] = ServiceState(
|
|
212
|
+
service=item["Service"],
|
|
213
|
+
state=str(item.get("State", "")),
|
|
214
|
+
health=str(item.get("Health", "")),
|
|
215
|
+
exit_code=code if isinstance(code, int) else None,
|
|
216
|
+
)
|
|
217
|
+
return states
|
modelmux_cli/terminal.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Find the login link in a provider CLI's terminal output.
|
|
2
|
+
|
|
3
|
+
Output arrives in arbitrary chunks and contains escape sequences: colours
|
|
4
|
+
(CSI) and OSC 8 hyperlinks, which carry the URL a second time. Escapes are
|
|
5
|
+
removed from a rolling buffer, and a link is only reported once it is
|
|
6
|
+
complete, i.e. followed by whitespace, so a URL split across reads is never
|
|
7
|
+
opened half-finished.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
_OSC = re.compile(rb"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
|
15
|
+
_CSI = re.compile(rb"\x1b\[[0-9;?]*[ -/]*[@-~]")
|
|
16
|
+
_OTHER_ESC = re.compile(rb"\x1b[@-Z\\-_]")
|
|
17
|
+
_BUFFER_LIMIT = 64 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def strip_escapes(data: bytes) -> str:
|
|
21
|
+
data = _OSC.sub(b"", data)
|
|
22
|
+
data = _CSI.sub(b"", data)
|
|
23
|
+
data = _OTHER_ESC.sub(b"", data)
|
|
24
|
+
return data.decode("utf-8", "replace")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LinkScanner:
|
|
28
|
+
def __init__(self, pattern: re.Pattern[str]) -> None:
|
|
29
|
+
self.pattern = pattern
|
|
30
|
+
self._raw = b""
|
|
31
|
+
self.link: str | None = None
|
|
32
|
+
|
|
33
|
+
def feed(self, data: bytes) -> str | None:
|
|
34
|
+
"""Add output. Returns the link the first time a complete one is seen."""
|
|
35
|
+
if self.link is not None:
|
|
36
|
+
return None
|
|
37
|
+
self._raw = (self._raw + data)[-_BUFFER_LIMIT:]
|
|
38
|
+
text = strip_escapes(self._raw)
|
|
39
|
+
for match in self.pattern.finditer(text):
|
|
40
|
+
end = match.end()
|
|
41
|
+
if end < len(text) and text[end].isspace():
|
|
42
|
+
self.link = match.group(0)
|
|
43
|
+
return self.link
|
|
44
|
+
return None
|
modelmux_cli/updates.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Network checks used only by ``doctor`` and ``upgrade`` (never in the background).
|
|
2
|
+
|
|
3
|
+
Both honour the usual proxy variables (HTTPS_PROXY / NO_PROXY) via urllib.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
PYPI_URL = "https://pypi.org/pypi/modelmux-cli/json"
|
|
14
|
+
REGISTRY_URL = "https://ghcr.io/v2/"
|
|
15
|
+
UPGRADE_HINT = "pipx upgrade modelmux-cli (or: uvx --from modelmux-cli@latest modelmux)"
|
|
16
|
+
_RELEASE = re.compile(r"^\d+(\.\d+)*$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_version(value: str) -> tuple[int, ...] | None:
|
|
20
|
+
"""Plain releases only ("1.2.3"); pre-releases and local versions are ignored."""
|
|
21
|
+
return tuple(int(part) for part in value.split(".")) if _RELEASE.fullmatch(value) else None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def latest_version(timeout: float = 5.0) -> str | None:
|
|
25
|
+
"""The newest modelmux-cli on PyPI, or None if it cannot be checked."""
|
|
26
|
+
try:
|
|
27
|
+
with urllib.request.urlopen(PYPI_URL, timeout=timeout) as response:
|
|
28
|
+
data = json.loads(response.read())
|
|
29
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
30
|
+
return None
|
|
31
|
+
version = data.get("info", {}).get("version") if isinstance(data, dict) else None
|
|
32
|
+
return version if isinstance(version, str) else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def newer_available(current: str, latest: str | None) -> bool:
|
|
36
|
+
if latest is None:
|
|
37
|
+
return False
|
|
38
|
+
now, new = parse_version(current), parse_version(latest)
|
|
39
|
+
return now is not None and new is not None and new > now
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def registry_reachable(timeout: float = 5.0) -> bool:
|
|
43
|
+
"""Can we reach the image registry? Any HTTP answer (even 401) counts."""
|
|
44
|
+
try:
|
|
45
|
+
with urllib.request.urlopen(REGISTRY_URL, timeout=timeout):
|
|
46
|
+
return True
|
|
47
|
+
except urllib.error.HTTPError:
|
|
48
|
+
return True
|
|
49
|
+
except (urllib.error.URLError, OSError):
|
|
50
|
+
return False
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: modelmux-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Set up, log in to and run ModelMux: AI coding CLIs as an OpenAI-compatible API
|
|
5
|
+
Project-URL: Homepage, https://github.com/smit153/modelmux
|
|
6
|
+
Project-URL: Source, https://github.com/smit153/modelmux
|
|
7
|
+
Project-URL: Issues, https://github.com/smit153/modelmux/issues
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Operating System :: MacOS
|
|
11
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# modelmux-cli
|
|
19
|
+
|
|
20
|
+
Set up, log in to and run [ModelMux](https://github.com/smit153/modelmux):
|
|
21
|
+
AI coding CLIs (Claude Code, Codex) as a secure, OpenAI-compatible API on your
|
|
22
|
+
own machine.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pipx install modelmux-cli # or: uvx --from modelmux-cli modelmux
|
|
26
|
+
modelmux up # set up Docker containers, start logged-in providers
|
|
27
|
+
modelmux login claude # guided login (opens your browser), then a real test
|
|
28
|
+
modelmux config litellm # ready-to-paste config for LiteLLM, OpenAI SDK, LangChain...
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Other commands: `status`, `logs`, `doctor`, `upgrade`, `logout`, `key show`, `down`.
|
|
32
|
+
|
|
33
|
+
- Requires Docker with Compose v2 (Docker Desktop on macOS and Windows) and
|
|
34
|
+
Python 3.10+. No other dependencies.
|
|
35
|
+
- Each CLI release runs the server image built in the same release, pinned by
|
|
36
|
+
digest.
|
|
37
|
+
- Logins live in per-provider Docker volumes; the API key is generated
|
|
38
|
+
locally and stored with owner-only permissions.
|
|
39
|
+
|
|
40
|
+
Full documentation: <https://github.com/smit153/modelmux/blob/main/docs/CLI.md>
|