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
modelmux_cli/docker.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""The ONLY module that runs other programs: the ``docker`` CLI.
|
|
2
|
+
|
|
3
|
+
- Commands are argument lists, never shell strings.
|
|
4
|
+
- Every call has a timeout.
|
|
5
|
+
- Docker's raw output is shown only with --verbose (and redacted there);
|
|
6
|
+
failures become ``DockerError``s with a plain message and a next step.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shlex
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from modelmux_cli.console import Console
|
|
24
|
+
from modelmux_cli.errors import DockerError
|
|
25
|
+
|
|
26
|
+
DEFAULT_TIMEOUT = 60.0
|
|
27
|
+
PULL_TIMEOUT = 1800.0
|
|
28
|
+
|
|
29
|
+
Runner = Callable[..., "subprocess.CompletedProcess[str]"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Result:
|
|
34
|
+
args: tuple[str, ...]
|
|
35
|
+
returncode: int
|
|
36
|
+
stdout: str
|
|
37
|
+
stderr: str
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def ok(self) -> bool:
|
|
41
|
+
return self.returncode == 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _start_docker_hint(platform: str) -> str:
|
|
45
|
+
if platform in ("darwin", "win32"):
|
|
46
|
+
return "Start Docker Desktop and wait until it says it is running, then try again."
|
|
47
|
+
return "Start the Docker service (for example: sudo systemctl start docker), then try again."
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _install_hint(platform: str) -> str:
|
|
51
|
+
if platform in ("darwin", "win32"):
|
|
52
|
+
return "Install Docker Desktop: https://docs.docker.com/desktop/"
|
|
53
|
+
return "Install Docker Engine: https://docs.docker.com/engine/install/"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# (pattern in Docker's stderr, message, hint factory)
|
|
57
|
+
_CLASSIFIERS: tuple[tuple[re.Pattern[str], str, Callable[[str], str]], ...] = (
|
|
58
|
+
(
|
|
59
|
+
re.compile(r"permission denied while trying to connect", re.I),
|
|
60
|
+
"Your user is not allowed to use Docker.",
|
|
61
|
+
lambda _p: (
|
|
62
|
+
"Add yourself to the docker group (sudo usermod -aG docker $USER), "
|
|
63
|
+
"then log out and back in."
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
(
|
|
67
|
+
re.compile(
|
|
68
|
+
r"cannot connect to the docker daemon|is the docker daemon running|error during "
|
|
69
|
+
r"connect|docker_engine.*(cannot find|not found)|failed to connect to the docker API",
|
|
70
|
+
re.I,
|
|
71
|
+
),
|
|
72
|
+
"Docker is installed but not running.",
|
|
73
|
+
_start_docker_hint,
|
|
74
|
+
),
|
|
75
|
+
(
|
|
76
|
+
re.compile(
|
|
77
|
+
r"'compose' is not a docker command|unknown command.*compose|unknown shorthand"
|
|
78
|
+
r" flag: 'f' in -f",
|
|
79
|
+
re.I,
|
|
80
|
+
),
|
|
81
|
+
"Docker Compose v2 is not available.",
|
|
82
|
+
lambda _p: "Install the Docker Compose plugin: https://docs.docker.com/compose/install/",
|
|
83
|
+
),
|
|
84
|
+
(
|
|
85
|
+
re.compile(r"port is already allocated|address already in use|bind: .*in use", re.I),
|
|
86
|
+
"A port ModelMux needs is already in use.",
|
|
87
|
+
lambda _p: "Run 'modelmux doctor' to see which port, or choose another with --port.",
|
|
88
|
+
),
|
|
89
|
+
(
|
|
90
|
+
re.compile(
|
|
91
|
+
r"manifest unknown|manifest for .* not found|not found: manifest|"
|
|
92
|
+
r"failed to resolve reference .*: not found",
|
|
93
|
+
re.I,
|
|
94
|
+
),
|
|
95
|
+
"The ModelMux image for this version was not found.",
|
|
96
|
+
lambda _p: (
|
|
97
|
+
"Check for a newer modelmux-cli (pipx upgrade modelmux-cli), or run 'modelmux doctor'."
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
(
|
|
101
|
+
# GHCR answers a bare "denied" for images that do not exist or are private.
|
|
102
|
+
re.compile(r"pull access denied|unauthorized|error from registry: denied|denied: ", re.I),
|
|
103
|
+
"The image registry refused access (the image may not exist or may be private).",
|
|
104
|
+
lambda _p: (
|
|
105
|
+
"Check that you can reach ghcr.io and are not logged in with expired "
|
|
106
|
+
"credentials (docker logout ghcr.io)."
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
(
|
|
110
|
+
re.compile(
|
|
111
|
+
r"tls handshake timeout|i/o timeout|no such host|dial tcp|network is unreachable|"
|
|
112
|
+
r"proxyconnect|connection refused|temporary failure in name resolution",
|
|
113
|
+
re.I,
|
|
114
|
+
),
|
|
115
|
+
"Docker could not reach the internet.",
|
|
116
|
+
lambda _p: (
|
|
117
|
+
"Check your connection or proxy (HTTPS_PROXY, and Docker Desktop's proxy "
|
|
118
|
+
"settings), then try again."
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
(
|
|
122
|
+
re.compile(r"no space left on device", re.I),
|
|
123
|
+
"Docker has run out of disk space.",
|
|
124
|
+
lambda _p: "Free space with 'docker system prune' (it removes unused images), then retry.",
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def classify(stderr: str, command: str, platform: str | None = None) -> DockerError:
|
|
130
|
+
platform = platform or sys.platform
|
|
131
|
+
for pattern, message, hint in _CLASSIFIERS:
|
|
132
|
+
if pattern.search(stderr):
|
|
133
|
+
return DockerError(message, hint=hint(platform))
|
|
134
|
+
return DockerError(
|
|
135
|
+
f"Docker command failed: docker {command}.",
|
|
136
|
+
hint="Run again with --verbose to see Docker's output.",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@contextlib.contextmanager
|
|
141
|
+
def _raw_terminal(in_fd: int, master: int) -> Iterator[None]:
|
|
142
|
+
"""Give the pseudo-terminal our window size and put our terminal in raw mode."""
|
|
143
|
+
if sys.platform == "win32":
|
|
144
|
+
raise OSError("pseudo-terminals need Linux or macOS")
|
|
145
|
+
if not os.isatty(in_fd):
|
|
146
|
+
yield
|
|
147
|
+
return
|
|
148
|
+
import fcntl # noqa: PLC0415 - POSIX only
|
|
149
|
+
import termios # noqa: PLC0415
|
|
150
|
+
import tty # noqa: PLC0415
|
|
151
|
+
|
|
152
|
+
with contextlib.suppress(OSError):
|
|
153
|
+
size = fcntl.ioctl(in_fd, termios.TIOCGWINSZ, b"\0" * 8)
|
|
154
|
+
fcntl.ioctl(master, termios.TIOCSWINSZ, size)
|
|
155
|
+
saved = termios.tcgetattr(in_fd)
|
|
156
|
+
tty.setraw(in_fd)
|
|
157
|
+
try:
|
|
158
|
+
yield
|
|
159
|
+
finally:
|
|
160
|
+
termios.tcsetattr(in_fd, termios.TCSADRAIN, saved)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _relay(
|
|
164
|
+
master: int,
|
|
165
|
+
in_fd: int,
|
|
166
|
+
out_fd: int,
|
|
167
|
+
on_output: Callable[[bytes], bytes | None],
|
|
168
|
+
timeout: float,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Copy keystrokes to the program and its output to us until it closes."""
|
|
171
|
+
import select # noqa: PLC0415
|
|
172
|
+
import time # noqa: PLC0415
|
|
173
|
+
|
|
174
|
+
deadline = time.monotonic() + timeout
|
|
175
|
+
watch = [master, in_fd]
|
|
176
|
+
while True:
|
|
177
|
+
remaining = deadline - time.monotonic()
|
|
178
|
+
if remaining <= 0:
|
|
179
|
+
raise TimeoutError
|
|
180
|
+
ready, _, _ = select.select(watch, [], [], min(remaining, 0.5))
|
|
181
|
+
if master in ready:
|
|
182
|
+
try:
|
|
183
|
+
data = os.read(master, 4096)
|
|
184
|
+
except OSError: # EIO: the program closed its terminal
|
|
185
|
+
return
|
|
186
|
+
if not data:
|
|
187
|
+
return
|
|
188
|
+
os.write(out_fd, data)
|
|
189
|
+
if extra := on_output(data):
|
|
190
|
+
os.write(out_fd, extra)
|
|
191
|
+
if in_fd in ready:
|
|
192
|
+
typed = os.read(in_fd, 1024)
|
|
193
|
+
if typed:
|
|
194
|
+
os.write(master, typed)
|
|
195
|
+
else:
|
|
196
|
+
watch = [master] # our stdin closed; keep relaying output
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Docker:
|
|
200
|
+
def __init__(
|
|
201
|
+
self,
|
|
202
|
+
console: Console,
|
|
203
|
+
*,
|
|
204
|
+
runner: Runner | None = None,
|
|
205
|
+
which: Callable[[str], str | None] = shutil.which,
|
|
206
|
+
platform: str | None = None,
|
|
207
|
+
) -> None:
|
|
208
|
+
self.console = console
|
|
209
|
+
self._runner: Runner = runner or subprocess.run
|
|
210
|
+
self._which = which
|
|
211
|
+
self.platform = platform or sys.platform
|
|
212
|
+
self._binary: str | None = None
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def binary(self) -> str:
|
|
216
|
+
if self._binary is None:
|
|
217
|
+
found = self._which("docker")
|
|
218
|
+
if found is None:
|
|
219
|
+
raise DockerError("Docker is not installed.", hint=_install_hint(self.platform))
|
|
220
|
+
self._binary = found
|
|
221
|
+
return self._binary
|
|
222
|
+
|
|
223
|
+
def _argv(self, args: Sequence[str]) -> list[str]:
|
|
224
|
+
for arg in args:
|
|
225
|
+
if not isinstance(arg, str) or "\x00" in arg:
|
|
226
|
+
raise DockerError("Internal error: invalid docker argument.")
|
|
227
|
+
return [self.binary, *args]
|
|
228
|
+
|
|
229
|
+
def run(
|
|
230
|
+
self,
|
|
231
|
+
*args: str,
|
|
232
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
233
|
+
input_text: str | None = None,
|
|
234
|
+
check: bool = True,
|
|
235
|
+
) -> Result:
|
|
236
|
+
"""Run ``docker <args>`` and capture its output."""
|
|
237
|
+
argv = self._argv(args)
|
|
238
|
+
self.console.detail("$ docker " + shlex.join(args))
|
|
239
|
+
kwargs: dict[str, Any] = {
|
|
240
|
+
"capture_output": True,
|
|
241
|
+
"text": True,
|
|
242
|
+
"encoding": "utf-8",
|
|
243
|
+
"errors": "replace",
|
|
244
|
+
"timeout": timeout,
|
|
245
|
+
"check": False,
|
|
246
|
+
}
|
|
247
|
+
if input_text is not None:
|
|
248
|
+
kwargs["input"] = input_text
|
|
249
|
+
else:
|
|
250
|
+
kwargs["stdin"] = subprocess.DEVNULL
|
|
251
|
+
try:
|
|
252
|
+
proc = self._runner(argv, **kwargs)
|
|
253
|
+
except FileNotFoundError:
|
|
254
|
+
raise DockerError(
|
|
255
|
+
"Docker is not installed.", hint=_install_hint(self.platform)
|
|
256
|
+
) from None
|
|
257
|
+
except PermissionError:
|
|
258
|
+
raise DockerError(
|
|
259
|
+
"The docker command cannot be executed.", hint=f"Check permissions of {argv[0]}."
|
|
260
|
+
) from None
|
|
261
|
+
except subprocess.TimeoutExpired:
|
|
262
|
+
raise DockerError(
|
|
263
|
+
f"Docker did not finish 'docker {args[0] if args else ''}' in time.",
|
|
264
|
+
hint="Docker may be busy or stuck; check Docker is healthy and try again.",
|
|
265
|
+
) from None
|
|
266
|
+
result = Result(tuple(args), proc.returncode, proc.stdout or "", proc.stderr or "")
|
|
267
|
+
if result.stdout.strip():
|
|
268
|
+
self.console.detail(result.stdout.rstrip())
|
|
269
|
+
if result.stderr.strip():
|
|
270
|
+
self.console.detail(result.stderr.rstrip())
|
|
271
|
+
if check and not result.ok:
|
|
272
|
+
raise classify(result.stderr, args[0] if args else "", self.platform)
|
|
273
|
+
return result
|
|
274
|
+
|
|
275
|
+
def passthrough(self, *args: str, timeout: float | None = None) -> int:
|
|
276
|
+
"""Run ``docker <args>`` attached to this terminal (logs -f, interactive login).
|
|
277
|
+
|
|
278
|
+
Output is not captured, so it is the user's own Docker output.
|
|
279
|
+
"""
|
|
280
|
+
argv = self._argv(args)
|
|
281
|
+
self.console.detail("$ docker " + shlex.join(args))
|
|
282
|
+
try:
|
|
283
|
+
return self._runner(argv, timeout=timeout, check=False).returncode
|
|
284
|
+
except FileNotFoundError:
|
|
285
|
+
raise DockerError(
|
|
286
|
+
"Docker is not installed.", hint=_install_hint(self.platform)
|
|
287
|
+
) from None
|
|
288
|
+
except subprocess.TimeoutExpired:
|
|
289
|
+
raise DockerError("Docker did not finish in time.", hint="Try again.") from None
|
|
290
|
+
|
|
291
|
+
def run_pty(
|
|
292
|
+
self,
|
|
293
|
+
*args: str,
|
|
294
|
+
on_output: Callable[[bytes], bytes | None],
|
|
295
|
+
timeout: float,
|
|
296
|
+
stdin_fd: int | None = None,
|
|
297
|
+
stdout_fd: int | None = None,
|
|
298
|
+
) -> int:
|
|
299
|
+
"""POSIX: run ``docker <args>`` on a new pseudo-terminal and relay it.
|
|
300
|
+
|
|
301
|
+
Keystrokes go straight from our terminal to the program, and its
|
|
302
|
+
output straight to our terminal; neither is stored. ``on_output`` sees
|
|
303
|
+
each output chunk and may return extra bytes to display (for example
|
|
304
|
+
"opened your browser"). Raises ``TimeoutError`` after ``timeout``.
|
|
305
|
+
"""
|
|
306
|
+
if sys.platform == "win32":
|
|
307
|
+
raise OSError("pseudo-terminals need Linux or macOS")
|
|
308
|
+
import pty # noqa: PLC0415 - POSIX only
|
|
309
|
+
import signal # noqa: PLC0415
|
|
310
|
+
|
|
311
|
+
argv = self._argv(args)
|
|
312
|
+
self.console.detail("$ docker " + shlex.join(args))
|
|
313
|
+
in_fd = sys.stdin.fileno() if stdin_fd is None else stdin_fd
|
|
314
|
+
out_fd = sys.stdout.fileno() if stdout_fd is None else stdout_fd
|
|
315
|
+
pid, master = pty.fork()
|
|
316
|
+
if pid == 0: # pragma: no cover - child process
|
|
317
|
+
try:
|
|
318
|
+
os.execv(argv[0], argv)
|
|
319
|
+
finally:
|
|
320
|
+
os._exit(127)
|
|
321
|
+
try:
|
|
322
|
+
with _raw_terminal(in_fd, master):
|
|
323
|
+
_relay(master, in_fd, out_fd, on_output, timeout)
|
|
324
|
+
_, status = os.waitpid(pid, 0)
|
|
325
|
+
return os.waitstatus_to_exitcode(status)
|
|
326
|
+
finally:
|
|
327
|
+
with contextlib.suppress(ChildProcessError, ProcessLookupError, OSError):
|
|
328
|
+
if os.waitpid(pid, os.WNOHANG) == (0, 0):
|
|
329
|
+
os.kill(pid, signal.SIGTERM)
|
|
330
|
+
os.waitpid(pid, 0)
|
|
331
|
+
os.close(master)
|
|
332
|
+
|
|
333
|
+
def compose(self, project: str, file: Path, *args: str, **kwargs: Any) -> Result:
|
|
334
|
+
return self.run("compose", "-p", project, "-f", str(file), *args, **kwargs)
|
|
335
|
+
|
|
336
|
+
def check_available(self) -> str:
|
|
337
|
+
"""Docker is installed, running, and has Compose v2. Returns the server version."""
|
|
338
|
+
server = self.run("version", "--format", "{{.Server.Version}}").stdout.strip()
|
|
339
|
+
self.run("compose", "version", "--short")
|
|
340
|
+
return server
|
modelmux_cli/errors.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Errors the CLI reports to the user.
|
|
2
|
+
|
|
3
|
+
Every failure is a ``CliError``: a plain message saying what went wrong, an
|
|
4
|
+
optional hint saying what to do next, and an exit code. ``main`` is the only
|
|
5
|
+
place that prints them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
EXIT_OK = 0
|
|
11
|
+
EXIT_FAILURE = 1
|
|
12
|
+
EXIT_USAGE = 2
|
|
13
|
+
EXIT_DOCKER = 3
|
|
14
|
+
EXIT_INTERRUPTED = 130
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CliError(Exception):
|
|
18
|
+
exit_code = EXIT_FAILURE
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, *, hint: str | None = None) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.message = message
|
|
23
|
+
self.hint = hint
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UsageError(CliError):
|
|
27
|
+
"""The command was used incorrectly."""
|
|
28
|
+
|
|
29
|
+
exit_code = EXIT_USAGE
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DockerError(CliError):
|
|
33
|
+
"""Docker is missing, stopped, or a Docker command failed."""
|
|
34
|
+
|
|
35
|
+
exit_code = EXIT_DOCKER
|
modelmux_cli/files.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Small, safe file writes: atomic, and private (0600) from the first byte."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
PRIVATE_MODE = 0o600
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def write_private(path: Path, data: str) -> None:
|
|
14
|
+
"""Atomically replace ``path`` with ``data``, created with mode 0600.
|
|
15
|
+
|
|
16
|
+
The temporary file is created with 0600 (never readable by others, even
|
|
17
|
+
briefly) in the same directory, then renamed over the target.
|
|
18
|
+
"""
|
|
19
|
+
tmp = path.with_name(f".{path.name}.{secrets.token_hex(6)}.tmp")
|
|
20
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
|
21
|
+
fd = os.open(tmp, flags, PRIVATE_MODE)
|
|
22
|
+
try:
|
|
23
|
+
with os.fdopen(fd, "wb") as fh:
|
|
24
|
+
fh.write(data.encode("utf-8"))
|
|
25
|
+
fh.flush()
|
|
26
|
+
os.fsync(fh.fileno())
|
|
27
|
+
os.replace(tmp, path)
|
|
28
|
+
except BaseException:
|
|
29
|
+
with contextlib.suppress(OSError):
|
|
30
|
+
tmp.unlink()
|
|
31
|
+
raise
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def tighten(path: Path) -> bool:
|
|
35
|
+
"""Make an existing file 0600 on POSIX. Returns True if it had to change."""
|
|
36
|
+
if os.name != "posix":
|
|
37
|
+
return False
|
|
38
|
+
mode = path.stat().st_mode & 0o777
|
|
39
|
+
if mode != PRIVATE_MODE:
|
|
40
|
+
path.chmod(PRIVATE_MODE)
|
|
41
|
+
return True
|
|
42
|
+
return False
|
modelmux_cli/health.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Talk to a running ModelMux server over HTTP (localhost only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
LOCALHOST = "127.0.0.1"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def base_url(port: int) -> str:
|
|
16
|
+
return f"http://{LOCALHOST}:{port}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_json(
|
|
20
|
+
port: int, path: str, *, timeout: float = 3.0, api_key: str | None = None
|
|
21
|
+
) -> tuple[int, Any]:
|
|
22
|
+
"""GET ``path`` on the local server. Returns (status, body); status 0 if unreachable."""
|
|
23
|
+
request = urllib.request.Request(base_url(port) + path) # noqa: S310 - fixed http://127.0.0.1
|
|
24
|
+
if api_key is not None:
|
|
25
|
+
request.add_header("Authorization", f"Bearer {api_key}")
|
|
26
|
+
try:
|
|
27
|
+
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
|
28
|
+
return response.status, json.loads(response.read() or b"null")
|
|
29
|
+
except urllib.error.HTTPError as exc:
|
|
30
|
+
try:
|
|
31
|
+
body = json.loads(exc.read() or b"null")
|
|
32
|
+
except ValueError:
|
|
33
|
+
body = None
|
|
34
|
+
return exc.code, body
|
|
35
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
36
|
+
return 0, None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ready(port: int) -> bool:
|
|
40
|
+
status, body = get_json(port, "/health/ready")
|
|
41
|
+
return status == 200 and isinstance(body, dict) and body.get("status") == "ready"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def wait_until(
|
|
45
|
+
check: Callable[[], bool],
|
|
46
|
+
*,
|
|
47
|
+
timeout: float,
|
|
48
|
+
interval: float = 1.0,
|
|
49
|
+
should_stop: Callable[[], bool] = lambda: False,
|
|
50
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
51
|
+
clock: Callable[[], float] = time.monotonic,
|
|
52
|
+
) -> bool:
|
|
53
|
+
"""Poll ``check`` until it is true (True), ``should_stop`` is true or time runs out (False)."""
|
|
54
|
+
deadline = clock() + timeout
|
|
55
|
+
while True:
|
|
56
|
+
if check():
|
|
57
|
+
return True
|
|
58
|
+
if should_stop() or clock() >= deadline:
|
|
59
|
+
return False
|
|
60
|
+
sleep(interval)
|
modelmux_cli/main.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""``modelmux``: set up, log in to and run ModelMux.
|
|
2
|
+
|
|
3
|
+
This module only parses arguments and reports errors. Each command lives in
|
|
4
|
+
``modelmux_cli.commands``; the commands that are not built yet say so.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
import traceback
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
|
|
14
|
+
from modelmux_cli import __version__
|
|
15
|
+
from modelmux_cli.clientconfig import TARGETS
|
|
16
|
+
from modelmux_cli.commands import basic, config_cmd
|
|
17
|
+
from modelmux_cli.commands import doctor as doctor_command
|
|
18
|
+
from modelmux_cli.commands import login as login_command
|
|
19
|
+
from modelmux_cli.commands import logout as logout_command
|
|
20
|
+
from modelmux_cli.commands import up as up_command
|
|
21
|
+
from modelmux_cli.commands import upgrade as upgrade_command
|
|
22
|
+
from modelmux_cli.console import Console
|
|
23
|
+
from modelmux_cli.errors import EXIT_FAILURE, EXIT_INTERRUPTED, CliError
|
|
24
|
+
from modelmux_cli.providers import load_providers
|
|
25
|
+
|
|
26
|
+
Handler = Callable[[argparse.Namespace, Console], int]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
30
|
+
providers = sorted(load_providers())
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="modelmux",
|
|
33
|
+
description="Run AI coding CLIs as a secure, OpenAI-compatible API.",
|
|
34
|
+
epilog="Start with: modelmux up, then modelmux login <provider>.",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("--version", action="version", version=f"modelmux {__version__}")
|
|
37
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="show details and commands")
|
|
38
|
+
parser.add_argument("--no-color", action="store_true", help="disable coloured output")
|
|
39
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>", required=True)
|
|
40
|
+
|
|
41
|
+
up = sub.add_parser("up", help="start ModelMux (logged-in providers)")
|
|
42
|
+
up.add_argument(
|
|
43
|
+
"providers", nargs="*", metavar="provider", help=f"any of: {', '.join(providers)}"
|
|
44
|
+
)
|
|
45
|
+
up.add_argument("--image", metavar="REF", help="server image to run (remembered)")
|
|
46
|
+
up.add_argument(
|
|
47
|
+
"--port", action="append", metavar="PROVIDER=PORT", help="local port (remembered)"
|
|
48
|
+
)
|
|
49
|
+
up.set_defaults(handler=up_command.run)
|
|
50
|
+
|
|
51
|
+
down = sub.add_parser("down", help="stop ModelMux (logins are kept)")
|
|
52
|
+
down.set_defaults(handler=basic.down)
|
|
53
|
+
|
|
54
|
+
logs = sub.add_parser("logs", help="show server logs")
|
|
55
|
+
logs.add_argument("provider", nargs="?", choices=providers)
|
|
56
|
+
logs.add_argument("-f", "--follow", action="store_true", help="keep streaming new lines")
|
|
57
|
+
logs.add_argument("--tail", type=int, default=100, metavar="N", help="lines to show (100)")
|
|
58
|
+
logs.set_defaults(handler=basic.logs)
|
|
59
|
+
|
|
60
|
+
status = sub.add_parser("status", help="what is running, logged in and healthy")
|
|
61
|
+
status.set_defaults(handler=basic.status)
|
|
62
|
+
|
|
63
|
+
login = sub.add_parser("login", help="log a provider in (guided)")
|
|
64
|
+
login.add_argument("provider", choices=providers)
|
|
65
|
+
login.add_argument("--method", help="login method (see the provider's options)")
|
|
66
|
+
login.add_argument("--no-browser", action="store_true", help="do not open a browser")
|
|
67
|
+
login.add_argument("--raw", action="store_true", help="show the provider's raw output")
|
|
68
|
+
login.add_argument("--force", action="store_true", help="log in again even if logged in")
|
|
69
|
+
login.set_defaults(handler=login_command.run)
|
|
70
|
+
|
|
71
|
+
logout = sub.add_parser("logout", help="remove a provider's saved login")
|
|
72
|
+
logout.add_argument("provider", choices=providers)
|
|
73
|
+
logout.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
|
|
74
|
+
logout.set_defaults(handler=logout_command.run)
|
|
75
|
+
|
|
76
|
+
config = sub.add_parser("config", help="print ready-to-paste client config")
|
|
77
|
+
config.add_argument("target", choices=TARGETS)
|
|
78
|
+
config.add_argument("--provider", choices=providers)
|
|
79
|
+
config.add_argument("--reveal-key", action="store_true", help="include the real API key")
|
|
80
|
+
config.set_defaults(handler=config_cmd.config)
|
|
81
|
+
|
|
82
|
+
doctor = sub.add_parser("doctor", help="diagnose common problems")
|
|
83
|
+
doctor.set_defaults(handler=doctor_command.run)
|
|
84
|
+
|
|
85
|
+
upgrade = sub.add_parser("upgrade", help="run the image matching this CLI version")
|
|
86
|
+
upgrade.add_argument("--image", metavar="REF", help="use a different image (advanced)")
|
|
87
|
+
upgrade.set_defaults(handler=upgrade_command.run)
|
|
88
|
+
|
|
89
|
+
key = sub.add_parser("key", help="the API key clients use to call ModelMux")
|
|
90
|
+
key_sub = key.add_subparsers(dest="key_command", metavar="<action>", required=True)
|
|
91
|
+
key_show = key_sub.add_parser("show", help="print the API key")
|
|
92
|
+
key_show.set_defaults(handler=config_cmd.key_show)
|
|
93
|
+
return parser
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
97
|
+
raw = list(sys.argv[1:] if argv is None else argv)
|
|
98
|
+
# Until arguments are parsed, honour --verbose from the raw command line.
|
|
99
|
+
console = Console(verbose="-v" in raw or "--verbose" in raw)
|
|
100
|
+
try:
|
|
101
|
+
parser = build_parser()
|
|
102
|
+
args = parser.parse_args(raw)
|
|
103
|
+
console = Console(verbose=args.verbose, color=False if args.no_color else None)
|
|
104
|
+
handler: Handler = args.handler
|
|
105
|
+
return handler(args, console)
|
|
106
|
+
except CliError as exc:
|
|
107
|
+
console.error(exc.message, exc.hint)
|
|
108
|
+
return exc.exit_code
|
|
109
|
+
except KeyboardInterrupt:
|
|
110
|
+
console.error("Cancelled.")
|
|
111
|
+
return EXIT_INTERRUPTED
|
|
112
|
+
except Exception:
|
|
113
|
+
console.error(
|
|
114
|
+
"Something unexpected went wrong.",
|
|
115
|
+
"Run again with --verbose for details, and please report it if it keeps happening.",
|
|
116
|
+
)
|
|
117
|
+
if console.verbose:
|
|
118
|
+
console.detail(traceback.format_exc())
|
|
119
|
+
return EXIT_FAILURE
|
modelmux_cli/paths.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Where the CLI keeps its own files.
|
|
2
|
+
|
|
3
|
+
| OS | Directory |
|
|
4
|
+
|---------|----------------------------------------------------|
|
|
5
|
+
| Linux | ``$XDG_CONFIG_HOME/modelmux`` (``~/.config/modelmux``) |
|
|
6
|
+
| macOS | ``~/Library/Application Support/modelmux`` |
|
|
7
|
+
| Windows | ``%APPDATA%\\modelmux`` |
|
|
8
|
+
|
|
9
|
+
``MODELMUX_CLI_HOME`` overrides it (tests, portable setups). The directory
|
|
10
|
+
holds only small files: ``config.json``, ``secrets.env`` (mode 0600) and the
|
|
11
|
+
generated ``compose.yaml``. Logins never live here: they stay in Docker
|
|
12
|
+
volumes.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from pathlib import Path, PurePosixPath
|
|
21
|
+
|
|
22
|
+
APP_NAME = "modelmux"
|
|
23
|
+
OVERRIDE_ENV = "MODELMUX_CLI_HOME"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def config_dir(
|
|
27
|
+
env: Mapping[str, str] | None = None,
|
|
28
|
+
platform: str | None = None,
|
|
29
|
+
home: Path | None = None,
|
|
30
|
+
) -> Path:
|
|
31
|
+
env = os.environ if env is None else env
|
|
32
|
+
platform = platform or sys.platform
|
|
33
|
+
if override := env.get(OVERRIDE_ENV):
|
|
34
|
+
return Path(override).expanduser()
|
|
35
|
+
home = home or Path.home()
|
|
36
|
+
if platform == "win32":
|
|
37
|
+
base = Path(env["APPDATA"]) if env.get("APPDATA") else home / "AppData" / "Roaming"
|
|
38
|
+
elif platform == "darwin":
|
|
39
|
+
base = home / "Library" / "Application Support"
|
|
40
|
+
else:
|
|
41
|
+
xdg = env.get("XDG_CONFIG_HOME")
|
|
42
|
+
base = Path(xdg) if xdg and PurePosixPath(xdg).is_absolute() else home / ".config"
|
|
43
|
+
return base / APP_NAME
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ensure_private_dir(path: Path) -> Path:
|
|
47
|
+
"""Create ``path`` if needed; on POSIX make it accessible to the owner only."""
|
|
48
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
if os.name == "posix":
|
|
50
|
+
path.chmod(0o700)
|
|
51
|
+
return path
|
modelmux_cli/ports.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Is a local port free? (Checked with a socket bind: no extra processes.)"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from modelmux_cli.health import LOCALHOST
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def port_free(port: int, host: str = LOCALHOST) -> bool:
|
|
12
|
+
"""True unless another program is listening on ``host:port``.
|
|
13
|
+
|
|
14
|
+
Ports in TIME_WAIT (for example right after ``modelmux down``) count as
|
|
15
|
+
free, as they do for Docker: on POSIX ``SO_REUSEADDR`` ignores TIME_WAIT
|
|
16
|
+
but still fails against a live listener. On Windows ``SO_REUSEADDR`` would
|
|
17
|
+
allow stealing a port, so ``SO_EXCLUSIVEADDRUSE`` is used instead.
|
|
18
|
+
"""
|
|
19
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
20
|
+
if sys.platform == "win32":
|
|
21
|
+
exclusive = getattr(socket, "SO_EXCLUSIVEADDRUSE") # noqa: B009 - Windows-only name
|
|
22
|
+
sock.setsockopt(socket.SOL_SOCKET, exclusive, 1)
|
|
23
|
+
else:
|
|
24
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
25
|
+
try:
|
|
26
|
+
sock.bind((host, port))
|
|
27
|
+
except OSError:
|
|
28
|
+
return False
|
|
29
|
+
return True
|