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,190 @@
|
|
|
1
|
+
"""``modelmux login <provider>``: guided login, then a real test.
|
|
2
|
+
|
|
3
|
+
The provider's own login command runs in a short-lived, hardened helper
|
|
4
|
+
container with only that provider's login volume. On Linux and macOS it runs
|
|
5
|
+
on a pseudo-terminal so the login link can be spotted and opened in the
|
|
6
|
+
browser while the user still types or pastes codes directly into the CLI.
|
|
7
|
+
On Windows (or with ``--raw``) the user's console is attached directly.
|
|
8
|
+
|
|
9
|
+
Keystrokes, pasted codes and API keys are never stored or logged.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import contextlib
|
|
16
|
+
import getpass
|
|
17
|
+
import os
|
|
18
|
+
import secrets
|
|
19
|
+
import signal
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import webbrowser
|
|
23
|
+
from collections.abc import Callable, Iterator
|
|
24
|
+
|
|
25
|
+
from modelmux_cli import health
|
|
26
|
+
from modelmux_cli.commands import up as up_module
|
|
27
|
+
from modelmux_cli.console import Console
|
|
28
|
+
from modelmux_cli.context import Context, make_context
|
|
29
|
+
from modelmux_cli.errors import CliError, DockerError, UsageError
|
|
30
|
+
from modelmux_cli.providers import LoginMethod, Provider, get_provider
|
|
31
|
+
from modelmux_cli.redact import register_secret
|
|
32
|
+
from modelmux_cli.stack import resolve_image, service_name
|
|
33
|
+
from modelmux_cli.terminal import LinkScanner
|
|
34
|
+
|
|
35
|
+
SECRET_LOGIN_TIMEOUT = 120.0
|
|
36
|
+
|
|
37
|
+
# Replaceable in tests.
|
|
38
|
+
open_browser: Callable[[str], bool] = webbrowser.open
|
|
39
|
+
read_secret: Callable[[str], str] = getpass.getpass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def use_pty(args: argparse.Namespace) -> bool:
|
|
43
|
+
return os.name == "posix" and not args.raw
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def choose_method(provider: Provider, requested: str | None) -> LoginMethod:
|
|
47
|
+
name = requested or provider.default_login_method
|
|
48
|
+
if name not in provider.login_methods:
|
|
49
|
+
options = ", ".join(f"{m.name} ({m.description})" for m in provider.login_methods.values())
|
|
50
|
+
raise UsageError(
|
|
51
|
+
f"{provider.display_name} has no login method {name!r}.", hint=f"Choose: {options}"
|
|
52
|
+
)
|
|
53
|
+
return provider.login_methods[name]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ensure_running_and_tested(ctx: Context, provider: Provider) -> None:
|
|
57
|
+
"""(Re)start the provider and wait for /health/ready: the server's startup
|
|
58
|
+
check makes one tiny real request, so ready means the login works."""
|
|
59
|
+
port = ctx.config.port(provider.name)
|
|
60
|
+
state = ctx.stack.states().get(service_name(provider))
|
|
61
|
+
if state is not None and state.state == "running" and health.ready(port):
|
|
62
|
+
ctx.console.success(f"{provider.display_name} is running at {health.base_url(port)}/v1")
|
|
63
|
+
else:
|
|
64
|
+
up_module.check_ports(ctx, [provider])
|
|
65
|
+
ctx.stack.compose(
|
|
66
|
+
"up", "-d", "--force-recreate", service_name(provider),
|
|
67
|
+
timeout=up_module.COMPOSE_UP_TIMEOUT,
|
|
68
|
+
) # fmt: skip
|
|
69
|
+
up_module.start_and_wait(ctx, provider)
|
|
70
|
+
ctx.console.success(f"{provider.display_name} is logged in and tested.")
|
|
71
|
+
ctx.console.step("Configure your tools: modelmux config litellm (or openai-python, langchain)")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _link_handler(
|
|
75
|
+
provider: Provider, console: Console, no_browser: bool
|
|
76
|
+
) -> Callable[[bytes], bytes | None]:
|
|
77
|
+
scanner = LinkScanner(provider.link_pattern)
|
|
78
|
+
|
|
79
|
+
def on_output(data: bytes) -> bytes | None:
|
|
80
|
+
link = scanner.feed(data)
|
|
81
|
+
if link is None:
|
|
82
|
+
return None
|
|
83
|
+
if no_browser:
|
|
84
|
+
return b"\r\n-> Open the link above in your browser.\r\n"
|
|
85
|
+
opened = False
|
|
86
|
+
try:
|
|
87
|
+
opened = open_browser(link)
|
|
88
|
+
except (webbrowser.Error, OSError): # no usable browser here
|
|
89
|
+
opened = False
|
|
90
|
+
if opened:
|
|
91
|
+
return b"\r\n-> Opened your browser. If it did not open, use the link above.\r\n"
|
|
92
|
+
return b"\r\n-> Could not open a browser here: open the link above yourself.\r\n"
|
|
93
|
+
|
|
94
|
+
return on_output
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@contextlib.contextmanager
|
|
98
|
+
def cancel_on_signals() -> Iterator[None]:
|
|
99
|
+
"""Treat SIGTERM and SIGHUP (terminal closed) like Ctrl+C while logging in,
|
|
100
|
+
so the helper container is always removed and the user sees "Cancelled."."""
|
|
101
|
+
if sys.platform == "win32" or threading.current_thread() is not threading.main_thread():
|
|
102
|
+
yield
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
def interrupt(_signum: int, _frame: object) -> None:
|
|
106
|
+
raise KeyboardInterrupt
|
|
107
|
+
|
|
108
|
+
previous = {sig: signal.signal(sig, interrupt) for sig in (signal.SIGTERM, signal.SIGHUP)}
|
|
109
|
+
try:
|
|
110
|
+
yield
|
|
111
|
+
finally:
|
|
112
|
+
for sig, handler in previous.items():
|
|
113
|
+
signal.signal(sig, handler)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _interactive_login(
|
|
117
|
+
ctx: Context, provider: Provider, method: LoginMethod, image: str, args: argparse.Namespace
|
|
118
|
+
) -> int:
|
|
119
|
+
name = f"modelmux-login-{provider.name}-{secrets.token_hex(4)}"
|
|
120
|
+
run_args = ctx.stack.helper_args(provider, image, method.command, ("-it", "--name", name))
|
|
121
|
+
try:
|
|
122
|
+
with cancel_on_signals():
|
|
123
|
+
if use_pty(args):
|
|
124
|
+
return ctx.docker.run_pty(
|
|
125
|
+
*run_args,
|
|
126
|
+
on_output=_link_handler(provider, ctx.console, args.no_browser),
|
|
127
|
+
timeout=provider.login_timeout,
|
|
128
|
+
)
|
|
129
|
+
ctx.console.step(
|
|
130
|
+
"Follow the instructions below; open the link they show in your browser."
|
|
131
|
+
)
|
|
132
|
+
return ctx.docker.passthrough(*run_args, timeout=provider.login_timeout)
|
|
133
|
+
except (TimeoutError, DockerError) as exc:
|
|
134
|
+
_remove(ctx, name)
|
|
135
|
+
if isinstance(exc, DockerError) and "in time" not in exc.message:
|
|
136
|
+
raise
|
|
137
|
+
raise CliError(
|
|
138
|
+
"The login timed out.", hint=f"Start again: modelmux login {provider.name}"
|
|
139
|
+
) from None
|
|
140
|
+
except KeyboardInterrupt:
|
|
141
|
+
_remove(ctx, name)
|
|
142
|
+
raise
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _secret_login(ctx: Context, provider: Provider, method: LoginMethod, image: str) -> int:
|
|
146
|
+
prompt = method.secret_stdin or "Secret"
|
|
147
|
+
secret = read_secret(f"{prompt} (input is hidden): ").strip()
|
|
148
|
+
if not secret:
|
|
149
|
+
raise UsageError(f"No {prompt} entered.")
|
|
150
|
+
register_secret(secret)
|
|
151
|
+
run_args = ctx.stack.helper_args(provider, image, method.command, ("-i",))
|
|
152
|
+
return ctx.docker.run(
|
|
153
|
+
*run_args, input_text=secret + "\n", check=False, timeout=SECRET_LOGIN_TIMEOUT
|
|
154
|
+
).returncode
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _remove(ctx: Context, container: str) -> None:
|
|
158
|
+
ctx.docker.run("rm", "-f", container, check=False)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def run(args: argparse.Namespace, console: Console) -> int:
|
|
162
|
+
ctx = make_context(console)
|
|
163
|
+
provider = get_provider(args.provider)
|
|
164
|
+
method = choose_method(provider, args.method)
|
|
165
|
+
image = resolve_image(ctx.config)
|
|
166
|
+
up_module.prepare(ctx, image)
|
|
167
|
+
stack = ctx.stack
|
|
168
|
+
|
|
169
|
+
if not args.force and stack.logged_in(provider, image):
|
|
170
|
+
console.success(
|
|
171
|
+
f"{provider.display_name} is already logged in (use --force to log in again)."
|
|
172
|
+
)
|
|
173
|
+
ensure_running_and_tested(ctx, provider)
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
console.step(f"Logging in to {provider.display_name}: {method.description}.")
|
|
177
|
+
if method.secret_stdin:
|
|
178
|
+
_secret_login(ctx, provider, method, image)
|
|
179
|
+
else:
|
|
180
|
+
_interactive_login(ctx, provider, method, image, args)
|
|
181
|
+
console.print()
|
|
182
|
+
if not stack.logged_in(provider, image):
|
|
183
|
+
raise CliError(
|
|
184
|
+
f"The {provider.display_name} login did not complete.",
|
|
185
|
+
hint=f"Try again: modelmux login {provider.name} (add --raw to see only the "
|
|
186
|
+
"provider's own output).",
|
|
187
|
+
)
|
|
188
|
+
console.success(f"Logged in to {provider.display_name}.")
|
|
189
|
+
ensure_running_and_tested(ctx, provider)
|
|
190
|
+
return 0
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""``modelmux logout <provider>``: stop it and delete its saved login."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
from modelmux_cli.console import Console
|
|
10
|
+
from modelmux_cli.context import make_context
|
|
11
|
+
from modelmux_cli.errors import CliError, UsageError
|
|
12
|
+
from modelmux_cli.providers import get_provider
|
|
13
|
+
from modelmux_cli.stack import resolve_image, service_name, volume_name
|
|
14
|
+
|
|
15
|
+
# Replaceable in tests.
|
|
16
|
+
ask: Callable[[str], str] = input
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def confirm(question: str) -> bool:
|
|
20
|
+
if not sys.stdin.isatty():
|
|
21
|
+
raise UsageError(
|
|
22
|
+
"Logout needs confirmation.", hint="Add --yes to confirm when not in a terminal."
|
|
23
|
+
)
|
|
24
|
+
return ask(f"{question} [y/N] ").strip().lower() in {"y", "yes"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run(args: argparse.Namespace, console: Console) -> int:
|
|
28
|
+
ctx = make_context(console)
|
|
29
|
+
provider = get_provider(args.provider)
|
|
30
|
+
stack = ctx.stack
|
|
31
|
+
if not stack.volume_exists(provider):
|
|
32
|
+
console.success(f"{provider.display_name} is not logged in; nothing to remove.")
|
|
33
|
+
return 0
|
|
34
|
+
if not args.yes and not confirm(f"Remove the saved {provider.display_name} login?"):
|
|
35
|
+
console.step("Nothing changed.")
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
if stack.compose_file.exists():
|
|
39
|
+
stack.compose("rm", "--stop", "--force", service_name(provider), check=False)
|
|
40
|
+
try:
|
|
41
|
+
image: str | None = resolve_image(ctx.config)
|
|
42
|
+
except CliError:
|
|
43
|
+
image = None
|
|
44
|
+
if image is not None:
|
|
45
|
+
# Best effort: lets the provider revoke the token, not just forget it.
|
|
46
|
+
result = ctx.docker.run(
|
|
47
|
+
*stack.helper_args(provider, image, provider.logout_command), check=False, timeout=60
|
|
48
|
+
)
|
|
49
|
+
if not result.ok:
|
|
50
|
+
console.detail("The provider's own logout did not succeed; removing the login anyway.")
|
|
51
|
+
ctx.docker.run("volume", "rm", volume_name(provider))
|
|
52
|
+
console.success(f"Logged out of {provider.display_name} and removed its saved login.")
|
|
53
|
+
return 0
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""``modelmux up``: prepare everything and start the logged-in providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from modelmux_cli import health
|
|
8
|
+
from modelmux_cli.config import save_config
|
|
9
|
+
from modelmux_cli.console import Console
|
|
10
|
+
from modelmux_cli.context import Context, make_context
|
|
11
|
+
from modelmux_cli.errors import CliError, UsageError
|
|
12
|
+
from modelmux_cli.paths import ensure_private_dir
|
|
13
|
+
from modelmux_cli.ports import port_free
|
|
14
|
+
from modelmux_cli.providers import Provider
|
|
15
|
+
from modelmux_cli.secrets_store import ensure_api_key
|
|
16
|
+
from modelmux_cli.stack import build_compose, resolve_image, service_name, write_compose
|
|
17
|
+
|
|
18
|
+
READY_TIMEOUT = 240.0
|
|
19
|
+
POLL_INTERVAL = 2.0
|
|
20
|
+
COMPOSE_UP_TIMEOUT = 300.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_port_overrides(values: list[str], known: set[str]) -> dict[str, int]:
|
|
24
|
+
ports: dict[str, int] = {}
|
|
25
|
+
for value in values:
|
|
26
|
+
name, sep, raw = value.partition("=")
|
|
27
|
+
if not sep or name not in known or not raw.isdigit() or not 1024 <= int(raw) <= 65535:
|
|
28
|
+
raise UsageError(
|
|
29
|
+
f"Invalid --port {value!r}.",
|
|
30
|
+
hint="Use --port <provider>=<1024-65535>, e.g. claude=9101.",
|
|
31
|
+
)
|
|
32
|
+
ports[name] = int(raw)
|
|
33
|
+
return ports
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def apply_settings(ctx: Context, args: argparse.Namespace) -> str:
|
|
37
|
+
"""Validate and remember --image / --port; return the image to run."""
|
|
38
|
+
config = ctx.config
|
|
39
|
+
image = resolve_image(config, args.image)
|
|
40
|
+
ports = parse_port_overrides(args.port or [], set(ctx.providers))
|
|
41
|
+
changed = bool(ports) or (args.image is not None and args.image != config.image)
|
|
42
|
+
config.ports.update(ports)
|
|
43
|
+
if args.image is not None:
|
|
44
|
+
config.image = args.image
|
|
45
|
+
used = [config.port(name) for name in ctx.providers]
|
|
46
|
+
if len(used) != len(set(used)):
|
|
47
|
+
raise UsageError(
|
|
48
|
+
"Two providers are set to the same port.", hint="Choose different --port values."
|
|
49
|
+
)
|
|
50
|
+
if changed:
|
|
51
|
+
save_config(ctx.home, config)
|
|
52
|
+
return image
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def start_and_wait(ctx: Context, provider: Provider) -> None:
|
|
56
|
+
console, stack = ctx.console, ctx.stack
|
|
57
|
+
service = service_name(provider)
|
|
58
|
+
port = ctx.config.port(provider.name)
|
|
59
|
+
|
|
60
|
+
def stopped() -> bool:
|
|
61
|
+
state = stack.states().get(service)
|
|
62
|
+
return state is not None and state.state in {"exited", "dead"}
|
|
63
|
+
|
|
64
|
+
console.step(f"Starting {provider.display_name} (it checks its login, about 10-30 s)...")
|
|
65
|
+
if not health.wait_until(
|
|
66
|
+
lambda: health.ready(port), timeout=READY_TIMEOUT, interval=POLL_INTERVAL,
|
|
67
|
+
should_stop=stopped,
|
|
68
|
+
): # fmt: skip
|
|
69
|
+
if stopped():
|
|
70
|
+
raise CliError(
|
|
71
|
+
f"{provider.display_name} stopped while starting.",
|
|
72
|
+
hint=f"Its login may have expired: modelmux login {provider.name}. "
|
|
73
|
+
f"Details: modelmux logs {provider.name}",
|
|
74
|
+
)
|
|
75
|
+
raise CliError(
|
|
76
|
+
f"{provider.display_name} did not become ready in time.",
|
|
77
|
+
hint=f"See what it is doing: modelmux logs {provider.name}",
|
|
78
|
+
)
|
|
79
|
+
console.success(f"{provider.display_name} is running at {health.base_url(port)}/v1")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def prepare(ctx: Context, image: str) -> None:
|
|
83
|
+
"""Docker, API key, compose file, image and login volumes: all idempotent."""
|
|
84
|
+
console, stack = ctx.console, ctx.stack
|
|
85
|
+
console.step("Checking Docker...")
|
|
86
|
+
ctx.docker.check_available()
|
|
87
|
+
ensure_private_dir(ctx.home)
|
|
88
|
+
key = ensure_api_key(ctx.home)
|
|
89
|
+
if key.created:
|
|
90
|
+
console.success("Created an API key for your clients (see it with: modelmux key show).")
|
|
91
|
+
write_compose(ctx.home, build_compose(ctx.providers, ctx.config, image, key.path))
|
|
92
|
+
if not stack.image_present(image):
|
|
93
|
+
console.step(f"Downloading the ModelMux server image {image} (first time only, ~1.4 GB)...")
|
|
94
|
+
stack.ensure_image(image)
|
|
95
|
+
for provider in ctx.providers.values():
|
|
96
|
+
stack.ensure_volume(provider)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def check_ports(ctx: Context, providers: list[Provider]) -> None:
|
|
100
|
+
states = ctx.stack.states()
|
|
101
|
+
for provider in providers:
|
|
102
|
+
running = states.get(service_name(provider))
|
|
103
|
+
port = ctx.config.port(provider.name)
|
|
104
|
+
if (running is None or running.state != "running") and not port_free(port):
|
|
105
|
+
raise CliError(
|
|
106
|
+
f"Port {port} for {provider.display_name} is already in use by another program.",
|
|
107
|
+
hint=f"Pick another port: modelmux up --port {provider.name}=<port>, "
|
|
108
|
+
"or run 'modelmux doctor'.",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def run(args: argparse.Namespace, console: Console) -> int:
|
|
113
|
+
ctx = make_context(console)
|
|
114
|
+
providers = ctx.providers
|
|
115
|
+
unknown = [name for name in args.providers if name not in providers]
|
|
116
|
+
if unknown:
|
|
117
|
+
raise UsageError(
|
|
118
|
+
f"Unknown provider {unknown[0]!r}.",
|
|
119
|
+
hint=f"Choose from: {', '.join(sorted(providers))}.",
|
|
120
|
+
)
|
|
121
|
+
image = apply_settings(ctx, args)
|
|
122
|
+
prepare(ctx, image)
|
|
123
|
+
|
|
124
|
+
targets = [providers[name] for name in (args.providers or sorted(providers))]
|
|
125
|
+
console.step("Checking logins...")
|
|
126
|
+
logged_in = {p.name: ctx.stack.logged_in(p, image) for p in targets}
|
|
127
|
+
missing = [p for p in targets if not logged_in[p.name]]
|
|
128
|
+
if args.providers and missing:
|
|
129
|
+
raise CliError(
|
|
130
|
+
f"{missing[0].display_name} is not logged in yet.",
|
|
131
|
+
hint=f"Log in first: modelmux login {missing[0].name}",
|
|
132
|
+
)
|
|
133
|
+
to_start = [p for p in targets if logged_in[p.name]]
|
|
134
|
+
if not to_start:
|
|
135
|
+
console.success("ModelMux is set up. No provider is logged in yet.")
|
|
136
|
+
console.step(f"Next: modelmux login {targets[0].name}")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
check_ports(ctx, to_start)
|
|
140
|
+
states = ctx.stack.states()
|
|
141
|
+
was_running = {
|
|
142
|
+
p.name for p in to_start
|
|
143
|
+
if (s := states.get(service_name(p))) is not None and s.state == "running"
|
|
144
|
+
} # fmt: skip
|
|
145
|
+
ctx.stack.compose("up", "-d", *(service_name(p) for p in to_start), timeout=COMPOSE_UP_TIMEOUT)
|
|
146
|
+
for provider in to_start:
|
|
147
|
+
port = ctx.config.port(provider.name)
|
|
148
|
+
if provider.name in was_running and health.ready(port):
|
|
149
|
+
console.success(
|
|
150
|
+
f"{provider.display_name} is already running at {health.base_url(port)}/v1"
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
start_and_wait(ctx, provider)
|
|
154
|
+
for provider in missing:
|
|
155
|
+
console.step(f"{provider.display_name} is not logged in: modelmux login {provider.name}")
|
|
156
|
+
console.step("Configure your tools: modelmux config litellm (or openai-python, langchain)")
|
|
157
|
+
return 0
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""``modelmux upgrade``: run the server image that matches this CLI, keeping logins.
|
|
2
|
+
|
|
3
|
+
The image version follows the CLI version: a newer server comes with a newer
|
|
4
|
+
modelmux-cli. This command makes sure the image pinned for the installed CLI
|
|
5
|
+
(or an explicit ``--image``) is present, recreates the providers that were
|
|
6
|
+
running, and says when a newer CLI is available.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
|
|
13
|
+
from modelmux_cli import __version__, updates
|
|
14
|
+
from modelmux_cli.commands import up as up_module
|
|
15
|
+
from modelmux_cli.config import save_config
|
|
16
|
+
from modelmux_cli.console import Console
|
|
17
|
+
from modelmux_cli.context import make_context
|
|
18
|
+
from modelmux_cli.stack import resolve_image, service_name
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(args: argparse.Namespace, console: Console) -> int:
|
|
22
|
+
ctx = make_context(console)
|
|
23
|
+
image = resolve_image(ctx.config, args.image)
|
|
24
|
+
if args.image is not None and args.image != ctx.config.image:
|
|
25
|
+
ctx.config.image = args.image
|
|
26
|
+
save_config(ctx.home, ctx.config)
|
|
27
|
+
|
|
28
|
+
states = ctx.stack.states() if ctx.stack.compose_file.exists() else {}
|
|
29
|
+
up_module.prepare(ctx, image) # downloads the image if needed, rewrites compose.yaml
|
|
30
|
+
console.success(f"Server image: {image}")
|
|
31
|
+
|
|
32
|
+
running = [
|
|
33
|
+
p for p in sorted(ctx.providers.values(), key=lambda p: p.name)
|
|
34
|
+
if (s := states.get(service_name(p))) is not None and s.state == "running"
|
|
35
|
+
] # fmt: skip
|
|
36
|
+
if running:
|
|
37
|
+
ctx.stack.compose(
|
|
38
|
+
"up", "-d", *(service_name(p) for p in running), timeout=up_module.COMPOSE_UP_TIMEOUT
|
|
39
|
+
)
|
|
40
|
+
for provider in running:
|
|
41
|
+
up_module.start_and_wait(ctx, provider)
|
|
42
|
+
console.success("Upgraded. Logins were kept.")
|
|
43
|
+
else:
|
|
44
|
+
console.success("Nothing was running; the next 'modelmux up' uses this image.")
|
|
45
|
+
|
|
46
|
+
latest = updates.latest_version()
|
|
47
|
+
if updates.newer_available(__version__, latest):
|
|
48
|
+
console.step(f"modelmux-cli {latest} is available (you have {__version__}). "
|
|
49
|
+
f"It comes with a newer server: {updates.UPGRADE_HINT}") # fmt: skip
|
|
50
|
+
return 0
|
modelmux_cli/config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The CLI's settings (``config.json``): ports, and an optional image override.
|
|
2
|
+
|
|
3
|
+
Everything has a default, so the file only exists once something is changed
|
|
4
|
+
or ``modelmux up`` runs. A damaged file is reported, never silently replaced.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from modelmux_cli.errors import CliError
|
|
15
|
+
from modelmux_cli.files import write_private
|
|
16
|
+
from modelmux_cli.paths import ensure_private_dir
|
|
17
|
+
from modelmux_cli.providers import load_providers
|
|
18
|
+
|
|
19
|
+
CONFIG_FILE = "config.json"
|
|
20
|
+
CONFIG_VERSION = 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Config:
|
|
25
|
+
ports: dict[str, int] = field(default_factory=dict)
|
|
26
|
+
image: str | None = None # explicit override; None means "the image pinned for this CLI"
|
|
27
|
+
|
|
28
|
+
def port(self, provider: str) -> int:
|
|
29
|
+
if provider in self.ports:
|
|
30
|
+
return self.ports[provider]
|
|
31
|
+
return load_providers()[provider].default_port
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConfigFileError(CliError):
|
|
35
|
+
def __init__(self, path: Path, problem: str) -> None:
|
|
36
|
+
super().__init__(
|
|
37
|
+
f"Your ModelMux settings file is damaged ({problem}).",
|
|
38
|
+
hint=f"Fix or delete {path} and run the command again.",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse(path: Path, data: Any) -> Config:
|
|
43
|
+
if not isinstance(data, dict) or data.get("version") != CONFIG_VERSION:
|
|
44
|
+
raise ConfigFileError(path, "unknown format version")
|
|
45
|
+
ports = data.get("ports", {})
|
|
46
|
+
if not isinstance(ports, dict):
|
|
47
|
+
raise ConfigFileError(path, "'ports' must be an object")
|
|
48
|
+
known = load_providers()
|
|
49
|
+
for name, port in ports.items():
|
|
50
|
+
if name not in known:
|
|
51
|
+
raise ConfigFileError(path, f"unknown provider {name!r} in 'ports'")
|
|
52
|
+
if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535:
|
|
53
|
+
raise ConfigFileError(path, f"invalid port for {name!r}")
|
|
54
|
+
image = data.get("image")
|
|
55
|
+
if image is not None and not isinstance(image, str):
|
|
56
|
+
raise ConfigFileError(path, "'image' must be a string")
|
|
57
|
+
return Config(ports=dict(ports), image=image)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_config(directory: Path) -> Config:
|
|
61
|
+
path = directory / CONFIG_FILE
|
|
62
|
+
if not path.exists():
|
|
63
|
+
return Config()
|
|
64
|
+
try:
|
|
65
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
66
|
+
except (OSError, ValueError):
|
|
67
|
+
raise ConfigFileError(path, "not valid JSON") from None
|
|
68
|
+
return _parse(path, data)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def save_config(directory: Path, config: Config) -> Path:
|
|
72
|
+
ensure_private_dir(directory)
|
|
73
|
+
path = directory / CONFIG_FILE
|
|
74
|
+
data: dict[str, Any] = {"version": CONFIG_VERSION, "ports": dict(sorted(config.ports.items()))}
|
|
75
|
+
if config.image is not None:
|
|
76
|
+
data["image"] = config.image
|
|
77
|
+
write_private(path, json.dumps(data, indent=2) + "\n")
|
|
78
|
+
return path
|
modelmux_cli/console.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Terminal output: short, friendly lines with ✓ / ✗ markers.
|
|
2
|
+
|
|
3
|
+
Colour is used only on a real terminal, never when ``NO_COLOR`` is set or
|
|
4
|
+
``TERM=dumb``. Symbols fall back to ASCII when the terminal encoding cannot
|
|
5
|
+
show them (older Windows consoles).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from typing import TextIO
|
|
13
|
+
|
|
14
|
+
from modelmux_cli.redact import redact
|
|
15
|
+
|
|
16
|
+
_RESET = "\033[0m"
|
|
17
|
+
_STYLES = {"ok": "\033[32m", "error": "\033[31m", "warn": "\033[33m", "dim": "\033[2m",
|
|
18
|
+
"bold": "\033[1m"} # fmt: skip
|
|
19
|
+
_SYMBOLS = {"ok": ("✓", "OK"), "error": ("✗", "X"), "warn": ("!", "!"), "step": ("→", ">")}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _enable_windows_ansi() -> bool:
|
|
23
|
+
"""Turn on ANSI escape handling in the Windows console (Windows 10+)."""
|
|
24
|
+
if sys.platform != "win32":
|
|
25
|
+
return True
|
|
26
|
+
try:
|
|
27
|
+
import ctypes # noqa: PLC0415 - Windows only
|
|
28
|
+
|
|
29
|
+
kernel32 = ctypes.windll.kernel32
|
|
30
|
+
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
|
|
31
|
+
mode = ctypes.c_uint32()
|
|
32
|
+
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
33
|
+
return False
|
|
34
|
+
return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004))
|
|
35
|
+
except (AttributeError, OSError):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _can_encode(stream: TextIO, text: str) -> bool:
|
|
40
|
+
try:
|
|
41
|
+
text.encode(stream.encoding or "ascii")
|
|
42
|
+
except (UnicodeEncodeError, LookupError):
|
|
43
|
+
return False
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Console:
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
out: TextIO | None = None,
|
|
51
|
+
err: TextIO | None = None,
|
|
52
|
+
*,
|
|
53
|
+
verbose: bool = False,
|
|
54
|
+
color: bool | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.out = out or sys.stdout
|
|
57
|
+
self.err = err or sys.stderr
|
|
58
|
+
self.verbose = verbose
|
|
59
|
+
if color is None:
|
|
60
|
+
color = (
|
|
61
|
+
self.out.isatty()
|
|
62
|
+
and "NO_COLOR" not in os.environ
|
|
63
|
+
and os.environ.get("TERM") != "dumb"
|
|
64
|
+
and _enable_windows_ansi()
|
|
65
|
+
)
|
|
66
|
+
self.color = color
|
|
67
|
+
self.unicode = _can_encode(self.out, "✓✗→")
|
|
68
|
+
|
|
69
|
+
def _style(self, kind: str, text: str) -> str:
|
|
70
|
+
return f"{_STYLES[kind]}{text}{_RESET}" if self.color else text
|
|
71
|
+
|
|
72
|
+
def _symbol(self, kind: str) -> str:
|
|
73
|
+
fancy, plain = _SYMBOLS[kind]
|
|
74
|
+
return fancy if self.unicode else plain
|
|
75
|
+
|
|
76
|
+
def _line(self, stream: TextIO, text: str) -> None:
|
|
77
|
+
stream.write(redact(text) + "\n")
|
|
78
|
+
stream.flush()
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------ public API
|
|
81
|
+
|
|
82
|
+
def print(self, text: str = "") -> None:
|
|
83
|
+
"""Plain output (command results, config snippets)."""
|
|
84
|
+
self._line(self.out, text)
|
|
85
|
+
|
|
86
|
+
def success(self, text: str) -> None:
|
|
87
|
+
self._line(self.out, f"{self._style('ok', self._symbol('ok'))} {text}")
|
|
88
|
+
|
|
89
|
+
def step(self, text: str) -> None:
|
|
90
|
+
self._line(self.out, f"{self._style('dim', self._symbol('step'))} {text}")
|
|
91
|
+
|
|
92
|
+
def warn(self, text: str) -> None:
|
|
93
|
+
self._line(self.err, f"{self._style('warn', self._symbol('warn'))} {text}")
|
|
94
|
+
|
|
95
|
+
def error(self, text: str, hint: str | None = None) -> None:
|
|
96
|
+
self._line(self.err, f"{self._style('error', self._symbol('error'))} {text}")
|
|
97
|
+
if hint:
|
|
98
|
+
self._line(self.err, f" {self._style('dim', hint)}")
|
|
99
|
+
|
|
100
|
+
def note(self, text: str) -> None:
|
|
101
|
+
"""A hint on stderr, so stdout stays clean for piping (``config > file``)."""
|
|
102
|
+
self._line(self.err, f"{self._style('dim', self._symbol('step'))} {text}")
|
|
103
|
+
|
|
104
|
+
def reveal(self, text: str) -> None:
|
|
105
|
+
"""Print WITHOUT redaction. Only for an explicit user request to see a secret."""
|
|
106
|
+
self.out.write(text + "\n")
|
|
107
|
+
self.out.flush()
|
|
108
|
+
|
|
109
|
+
def detail(self, text: str) -> None:
|
|
110
|
+
"""Only shown with --verbose."""
|
|
111
|
+
if self.verbose:
|
|
112
|
+
self._line(self.err, self._style("dim", f" {text}"))
|
modelmux_cli/context.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Everything a command needs, built in one place (and replaceable in tests)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from modelmux_cli.config import Config, load_config
|
|
10
|
+
from modelmux_cli.console import Console
|
|
11
|
+
from modelmux_cli.docker import Docker
|
|
12
|
+
from modelmux_cli.paths import config_dir
|
|
13
|
+
from modelmux_cli.providers import Provider, load_providers
|
|
14
|
+
from modelmux_cli.stack import Stack
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def make_docker(console: Console) -> Docker:
|
|
18
|
+
return Docker(console)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Context:
|
|
23
|
+
console: Console
|
|
24
|
+
docker: Docker
|
|
25
|
+
home: Path = field(default_factory=config_dir)
|
|
26
|
+
|
|
27
|
+
@cached_property
|
|
28
|
+
def config(self) -> Config:
|
|
29
|
+
return load_config(self.home)
|
|
30
|
+
|
|
31
|
+
@cached_property
|
|
32
|
+
def providers(self) -> dict[str, Provider]:
|
|
33
|
+
return load_providers()
|
|
34
|
+
|
|
35
|
+
@cached_property
|
|
36
|
+
def stack(self) -> Stack:
|
|
37
|
+
return Stack(self.docker, self.home)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def make_context(console: Console) -> Context:
|
|
41
|
+
return Context(console, make_docker(console))
|