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.
Files changed (43) hide show
  1. modelmux_cli/__init__.py +8 -0
  2. modelmux_cli/__main__.py +7 -0
  3. modelmux_cli/_shared/providers/claude.json +34 -0
  4. modelmux_cli/_shared/providers/codex.json +35 -0
  5. modelmux_cli/_shared/release.json +4 -0
  6. modelmux_cli/_shared/schema/provider.schema.json +81 -0
  7. modelmux_cli/_shared/templates/compose-service.json +12 -0
  8. modelmux_cli/_shared/templates/config/curl.json +8 -0
  9. modelmux_cli/_shared/templates/config/env.json +8 -0
  10. modelmux_cli/_shared/templates/config/langchain.json +8 -0
  11. modelmux_cli/_shared/templates/config/litellm.json +8 -0
  12. modelmux_cli/_shared/templates/config/openai-python.json +8 -0
  13. modelmux_cli/clientconfig.py +87 -0
  14. modelmux_cli/commands/__init__.py +1 -0
  15. modelmux_cli/commands/basic.py +75 -0
  16. modelmux_cli/commands/config_cmd.py +65 -0
  17. modelmux_cli/commands/doctor.py +190 -0
  18. modelmux_cli/commands/login.py +190 -0
  19. modelmux_cli/commands/logout.py +53 -0
  20. modelmux_cli/commands/up.py +157 -0
  21. modelmux_cli/commands/upgrade.py +50 -0
  22. modelmux_cli/config.py +78 -0
  23. modelmux_cli/console.py +112 -0
  24. modelmux_cli/context.py +41 -0
  25. modelmux_cli/docker.py +340 -0
  26. modelmux_cli/errors.py +35 -0
  27. modelmux_cli/files.py +42 -0
  28. modelmux_cli/health.py +60 -0
  29. modelmux_cli/main.py +119 -0
  30. modelmux_cli/paths.py +51 -0
  31. modelmux_cli/ports.py +29 -0
  32. modelmux_cli/providers.py +168 -0
  33. modelmux_cli/py.typed +0 -0
  34. modelmux_cli/redact.py +35 -0
  35. modelmux_cli/release.py +40 -0
  36. modelmux_cli/secrets_store.py +71 -0
  37. modelmux_cli/stack.py +217 -0
  38. modelmux_cli/terminal.py +44 -0
  39. modelmux_cli/updates.py +50 -0
  40. modelmux_cli-0.1.0.dist-info/METADATA +40 -0
  41. modelmux_cli-0.1.0.dist-info/RECORD +43 -0
  42. modelmux_cli-0.1.0.dist-info/WHEEL +4 -0
  43. 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
@@ -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
@@ -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
@@ -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>