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,8 @@
1
+ """modelmux-cli: set up, log in to and run ModelMux."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("modelmux-cli")
7
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
8
+ __version__ = "0.0.0"
@@ -0,0 +1,7 @@
1
+ """``python -m modelmux_cli``."""
2
+
3
+ import sys
4
+
5
+ from modelmux_cli.main import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "../schema/provider.schema.json",
3
+ "schema_version": 1,
4
+ "name": "claude",
5
+ "display_name": "Claude Code",
6
+ "driver": "claude",
7
+ "default_port": 8101,
8
+ "volume": "claude-home",
9
+ "home": "/home/modelmux/driver-home",
10
+ "example_model": "sonnet",
11
+ "login": {
12
+ "default_method": "browser",
13
+ "methods": {
14
+ "browser": {
15
+ "description": "Claude subscription: open a link, sign in, paste the code back",
16
+ "command": ["claude", "auth", "login"]
17
+ },
18
+ "console": {
19
+ "description": "Anthropic Console (API billing): open a link, sign in, paste the code back",
20
+ "command": ["claude", "auth", "login", "--console"]
21
+ }
22
+ },
23
+ "link_pattern": "https://\\S+",
24
+ "timeout_seconds": 600
25
+ },
26
+ "status": {
27
+ "command": ["claude", "auth", "status"],
28
+ "logged_in_exit_code": 0
29
+ },
30
+ "logout": {
31
+ "command": ["claude", "auth", "logout"]
32
+ },
33
+ "docs_url": "https://docs.anthropic.com/en/docs/claude-code"
34
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "../schema/provider.schema.json",
3
+ "schema_version": 1,
4
+ "name": "codex",
5
+ "display_name": "OpenAI Codex",
6
+ "driver": "codex",
7
+ "default_port": 8102,
8
+ "volume": "codex-home",
9
+ "home": "/home/modelmux/driver-home",
10
+ "example_model": "gpt-6.1-sol",
11
+ "login": {
12
+ "default_method": "device",
13
+ "methods": {
14
+ "device": {
15
+ "description": "ChatGPT account: open a link and enter the one-time code shown",
16
+ "command": ["codex", "login", "--device-auth"]
17
+ },
18
+ "api-key": {
19
+ "description": "OpenAI API key, read from your terminal without echo",
20
+ "command": ["codex", "login", "--with-api-key"],
21
+ "secret_stdin": "OpenAI API key"
22
+ }
23
+ },
24
+ "link_pattern": "https://\\S+",
25
+ "timeout_seconds": 900
26
+ },
27
+ "status": {
28
+ "command": ["codex", "login", "status"],
29
+ "logged_in_exit_code": 0
30
+ },
31
+ "logout": {
32
+ "command": ["codex", "logout"]
33
+ },
34
+ "docs_url": "https://developers.openai.com/codex/cli"
35
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "version": "0.1.0",
3
+ "image": "ghcr.io/smit153/modelmux@sha256:1edfd02f98bf739c874fa0db043bdbd46d907ac41d2bc30fb7f5b8b311381b72"
4
+ }
@@ -0,0 +1,81 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/smit153/modelmux/shared/schema/provider.schema.json",
4
+ "title": "ModelMux provider definition",
5
+ "description": "How the ModelMux CLI runs, logs in and checks one provider. Language-neutral: every CLI implementation reads these files.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "schema_version", "name", "display_name", "driver", "default_port", "volume", "home",
10
+ "example_model", "login", "status", "logout"
11
+ ],
12
+ "properties": {
13
+ "$schema": {"type": "string"},
14
+ "schema_version": {"const": 1},
15
+ "name": {"$ref": "#/$defs/identifier", "description": "Used on the command line: modelmux login <name>"},
16
+ "display_name": {"type": "string", "minLength": 1, "maxLength": 64},
17
+ "driver": {"$ref": "#/$defs/identifier", "description": "The server's MODELMUX_DRIVER value"},
18
+ "default_port": {"type": "integer", "minimum": 1024, "maximum": 65535},
19
+ "volume": {
20
+ "type": "string",
21
+ "pattern": "^[a-z0-9][a-z0-9_.-]{0,62}$",
22
+ "description": "Login volume name, unique per provider (prefixed with the compose project name)"
23
+ },
24
+ "home": {"type": "string", "pattern": "^/", "description": "HOME inside the container; the volume is mounted here"},
25
+ "example_model": {"type": "string", "minLength": 1, "maxLength": 64},
26
+ "login": {
27
+ "type": "object",
28
+ "additionalProperties": false,
29
+ "required": ["default_method", "methods", "link_pattern", "timeout_seconds"],
30
+ "properties": {
31
+ "default_method": {"$ref": "#/$defs/identifier"},
32
+ "methods": {
33
+ "type": "object",
34
+ "minProperties": 1,
35
+ "propertyNames": {"$ref": "#/$defs/identifier"},
36
+ "additionalProperties": {
37
+ "type": "object",
38
+ "additionalProperties": false,
39
+ "required": ["description", "command"],
40
+ "properties": {
41
+ "description": {"type": "string", "minLength": 1},
42
+ "command": {"$ref": "#/$defs/command"},
43
+ "secret_stdin": {
44
+ "type": "string",
45
+ "minLength": 1,
46
+ "description": "If set, prompt for this secret without echo and pass it on stdin"
47
+ }
48
+ }
49
+ }
50
+ },
51
+ "link_pattern": {"type": "string", "minLength": 1, "description": "Regex that finds the login link in the CLI's output"},
52
+ "timeout_seconds": {"type": "integer", "minimum": 30, "maximum": 3600}
53
+ }
54
+ },
55
+ "status": {
56
+ "type": "object",
57
+ "additionalProperties": false,
58
+ "required": ["command", "logged_in_exit_code"],
59
+ "properties": {
60
+ "command": {"$ref": "#/$defs/command"},
61
+ "logged_in_exit_code": {"type": "integer"}
62
+ }
63
+ },
64
+ "logout": {
65
+ "type": "object",
66
+ "additionalProperties": false,
67
+ "required": ["command"],
68
+ "properties": {"command": {"$ref": "#/$defs/command"}}
69
+ },
70
+ "docs_url": {"type": "string", "pattern": "^https://"}
71
+ },
72
+ "$defs": {
73
+ "identifier": {"type": "string", "pattern": "^[a-z][a-z0-9-]{0,31}$"},
74
+ "command": {
75
+ "type": "array",
76
+ "minItems": 1,
77
+ "description": "argv run inside a helper container; the first item is the entrypoint. Never a shell string.",
78
+ "items": {"type": "string", "pattern": "^[A-Za-z0-9._=/-]+$"}
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "read_only": true,
3
+ "tmpfs": ["/tmp:rw,noexec,nosuid,nodev,size=512m"],
4
+ "cap_drop": ["ALL"],
5
+ "security_opt": ["no-new-privileges:true"],
6
+ "user": "10001:10001",
7
+ "pids_limit": 256,
8
+ "mem_limit": "2g",
9
+ "cpus": 2,
10
+ "restart": "on-failure:3",
11
+ "labels": {"io.modelmux.managed": "true"}
12
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "description": "curl request",
3
+ "per": "provider",
4
+ "key_style": "shell",
5
+ "header": "",
6
+ "entry": "# {{display_name}}\ncurl {{base_url}}/chat/completions \\\n -H \"Authorization: Bearer {{key}}\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\": \"{{model}}\", \"messages\": [{\"role\": \"user\", \"content\": \"Hello!\"}]}'\n\n",
7
+ "footer": ""
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "description": "Environment variables (.env)",
3
+ "per": "provider",
4
+ "key_style": "env",
5
+ "header": "MODELMUX_API_KEY={{key}}\n",
6
+ "entry": "MODELMUX_{{PROVIDER}}_BASE_URL={{base_url}}\nMODELMUX_{{PROVIDER}}_MODEL={{model}}\n",
7
+ "footer": ""
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "description": "LangChain ChatOpenAI",
3
+ "per": "provider",
4
+ "key_style": "python",
5
+ "header": "import os\n\nfrom langchain_openai import ChatOpenAI\n",
6
+ "entry": "\n# {{display_name}}\n{{provider_var}} = ChatOpenAI(\n model=\"{{model}}\",\n base_url=\"{{base_url}}\",\n api_key={{key}},\n use_responses_api=False, # ModelMux implements Chat Completions\n)\nprint({{provider_var}}.invoke(\"Hello!\").content)\n",
7
+ "footer": ""
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "description": "LiteLLM proxy config.yaml (model_list)",
3
+ "per": "model",
4
+ "key_style": "litellm",
5
+ "header": "model_list:\n",
6
+ "entry": " - model_name: {{provider}}-{{model}}\n litellm_params:\n model: openai/{{model}}\n api_base: {{base_url}}\n api_key: {{key}}\n",
7
+ "footer": ""
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "description": "OpenAI Python SDK",
3
+ "per": "provider",
4
+ "key_style": "python",
5
+ "header": "import os\n\nfrom openai import OpenAI\n",
6
+ "entry": "\n# {{display_name}}\n{{provider_var}} = OpenAI(base_url=\"{{base_url}}\", api_key={{key}})\nreply = {{provider_var}}.chat.completions.create(\n model=\"{{model}}\",\n messages=[{\"role\": \"user\", \"content\": \"Hello!\"}],\n)\nprint(reply.choices[0].message.content)\n",
7
+ "footer": ""
8
+ }
@@ -0,0 +1,87 @@
1
+ """Render client config snippets from ``shared/templates/config/<target>.json``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from modelmux_cli.errors import CliError
11
+ from modelmux_cli.providers import shared_dir
12
+
13
+ KEY_ENV = "MODELMUX_API_KEY"
14
+ TARGETS = ("litellm", "openai-python", "langchain", "curl", "env")
15
+
16
+ # How each target refers to the key: (by reference, when revealed).
17
+ _KEY_STYLES: dict[str, tuple[str, Any]] = {
18
+ "litellm": (f"os.environ/{KEY_ENV}", lambda key: key),
19
+ "python": (f'os.environ["{KEY_ENV}"]', json.dumps),
20
+ "shell": (f"${KEY_ENV}", lambda key: key),
21
+ "env": ("", lambda key: key),
22
+ }
23
+ _PLACEHOLDER = re.compile(r"\{\{(\w+)\}\}")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Template:
28
+ name: str
29
+ description: str
30
+ per: str
31
+ key_style: str
32
+ header: str
33
+ entry: str
34
+ footer: str
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Target:
39
+ provider: str
40
+ display_name: str
41
+ base_url: str
42
+ models: tuple[str, ...]
43
+
44
+
45
+ def load_template(name: str) -> Template:
46
+ path = shared_dir() / "templates" / "config" / f"{name}.json"
47
+ try:
48
+ data = json.loads(path.read_text(encoding="utf-8"))
49
+ template = Template(name=name, **{k: data[k] for k in (
50
+ "description", "per", "key_style", "header", "entry", "footer")}) # fmt: skip
51
+ except (OSError, ValueError, KeyError, TypeError):
52
+ raise CliError(f"The {name!r} config template is missing or invalid.",
53
+ hint="Please report this bug.") from None # fmt: skip
54
+ if template.per not in {"provider", "model"} or template.key_style not in _KEY_STYLES:
55
+ raise CliError(f"The {name!r} config template is invalid.", hint="Please report this bug.")
56
+ return template
57
+
58
+
59
+ def _fill(text: str, values: dict[str, str]) -> str:
60
+ def replace(match: re.Match[str]) -> str:
61
+ if match.group(1) not in values:
62
+ raise CliError(f"Unknown placeholder {match.group(0)} in a config template.",
63
+ hint="Please report this bug.") # fmt: skip
64
+ return values[match.group(1)]
65
+
66
+ return _PLACEHOLDER.sub(replace, text)
67
+
68
+
69
+ def render(template: Template, targets: list[Target], api_key: str | None) -> str:
70
+ """``api_key`` None means: refer to $MODELMUX_API_KEY instead of the value."""
71
+ reference, literal = _KEY_STYLES[template.key_style]
72
+ key = reference if api_key is None else literal(api_key)
73
+ parts = [_fill(template.header, {"key": key})]
74
+ for target in targets:
75
+ models = target.models if template.per == "model" else target.models[:1]
76
+ for model in models:
77
+ parts.append(_fill(template.entry, {
78
+ "provider": target.provider,
79
+ "PROVIDER": target.provider.upper().replace("-", "_"),
80
+ "provider_var": target.provider.replace("-", "_"),
81
+ "display_name": target.display_name,
82
+ "model": model,
83
+ "base_url": target.base_url,
84
+ "key": key,
85
+ })) # fmt: skip
86
+ parts.append(_fill(template.footer, {"key": key}))
87
+ return "".join(parts).rstrip("\n") + "\n"
@@ -0,0 +1 @@
1
+ """One module per command. Each exposes ``run(args, console) -> exit code``."""
@@ -0,0 +1,75 @@
1
+ """``modelmux down``, ``logs`` and ``status``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from modelmux_cli import health
8
+ from modelmux_cli.console import Console
9
+ from modelmux_cli.context import make_context
10
+ from modelmux_cli.errors import CliError
11
+ from modelmux_cli.stack import resolve_image, service_name
12
+
13
+ NOT_SET_UP = "ModelMux is not set up on this machine yet."
14
+
15
+
16
+ def down(args: argparse.Namespace, console: Console) -> int:
17
+ ctx = make_context(console)
18
+ if not ctx.stack.compose_file.exists():
19
+ console.success("ModelMux is not running.")
20
+ return 0
21
+ ctx.stack.compose("down", timeout=120)
22
+ console.success("Stopped ModelMux. Your logins are kept.")
23
+ return 0
24
+
25
+
26
+ def logs(args: argparse.Namespace, console: Console) -> int:
27
+ ctx = make_context(console)
28
+ stack = ctx.stack
29
+ if not stack.compose_file.exists():
30
+ raise CliError(NOT_SET_UP, hint="Start it with: modelmux up")
31
+ services = [service_name(ctx.providers[args.provider])] if args.provider else []
32
+ follow = ["--follow"] if args.follow else []
33
+ return ctx.docker.passthrough(
34
+ "compose", "-p", "modelmux", "-f", str(stack.compose_file),
35
+ "logs", "--no-log-prefix" if args.provider else "--timestamps",
36
+ "--tail", str(max(args.tail, 0)), *follow, *services,
37
+ ) # fmt: skip
38
+
39
+
40
+ def status(args: argparse.Namespace, console: Console) -> int:
41
+ ctx = make_context(console)
42
+ ctx.docker.check_available()
43
+ stack = ctx.stack
44
+ states = stack.states()
45
+ try:
46
+ image: str | None = resolve_image(ctx.config)
47
+ except CliError:
48
+ image = None
49
+
50
+ rows = [("PROVIDER", "CONTAINER", "LOGIN", "HEALTH", "URL")]
51
+ for name, provider in sorted(ctx.providers.items()):
52
+ state = states.get(service_name(provider))
53
+ port = ctx.config.port(name)
54
+ container = state.state if state else "not started"
55
+ login = "?" if image is None else ("yes" if stack.logged_in(provider, image) else "no")
56
+ if state and state.state == "running":
57
+ healthy = "ready" if health.ready(port) else "starting"
58
+ else:
59
+ healthy = "-"
60
+ url = f"{health.base_url(port)}/v1" if healthy == "ready" else "-"
61
+ rows.append((name, container, login, healthy, url))
62
+
63
+ widths = [max(len(row[i]) for row in rows) for i in range(len(rows[0]))]
64
+ for row in rows:
65
+ console.print(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)).rstrip())
66
+
67
+ if image is None:
68
+ console.step("No server image chosen yet: run 'modelmux up' first.")
69
+ for name, provider in sorted(ctx.providers.items()):
70
+ state = states.get(service_name(provider))
71
+ if state and state.state in {"exited", "dead"}:
72
+ console.step(
73
+ f"{provider.display_name} has stopped. If its login expired: modelmux login {name}"
74
+ )
75
+ return 0
@@ -0,0 +1,65 @@
1
+ """``modelmux key show`` and ``modelmux config <target>``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from modelmux_cli import health
9
+ from modelmux_cli.clientconfig import KEY_ENV, Target, load_template, render
10
+ from modelmux_cli.console import Console
11
+ from modelmux_cli.context import make_context
12
+ from modelmux_cli.errors import CliError
13
+ from modelmux_cli.providers import get_provider
14
+ from modelmux_cli.secrets_store import ApiKey, read_api_key
15
+
16
+
17
+ def _key(home: Path) -> ApiKey:
18
+ key = read_api_key(home)
19
+ if key is None:
20
+ raise CliError("ModelMux is not set up yet, so there is no API key.",
21
+ hint="Run: modelmux up") # fmt: skip
22
+ return key
23
+
24
+
25
+ def key_show(args: argparse.Namespace, console: Console) -> int:
26
+ ctx = make_context(console)
27
+ key = _key(ctx.home)
28
+ console.reveal(key.value)
29
+ console.note("Treat this like a password. Clients send it as: Authorization: Bearer <key>")
30
+ return 0
31
+
32
+
33
+ def models_for(port: int, api_key: str) -> list[str]:
34
+ status, body = health.get_json(port, "/v1/models", api_key=api_key)
35
+ if status != 200 or not isinstance(body, dict) or not isinstance(body.get("data"), list):
36
+ return []
37
+ return [m["id"] for m in body["data"] if isinstance(m, dict) and isinstance(m.get("id"), str)]
38
+
39
+
40
+ def config(args: argparse.Namespace, console: Console) -> int:
41
+ ctx = make_context(console)
42
+ template = load_template(args.target)
43
+ key = _key(ctx.home)
44
+ names = [args.provider] if args.provider else sorted(ctx.providers)
45
+ targets = []
46
+ for name in names:
47
+ provider = get_provider(name)
48
+ port = ctx.config.port(name)
49
+ models = models_for(port, key.value)
50
+ if not models:
51
+ models = [provider.example_model]
52
+ console.note(f"{provider.display_name} is not running; using its example model "
53
+ f"'{provider.example_model}'.") # fmt: skip
54
+ targets.append(Target(name, provider.display_name, f"{health.base_url(port)}/v1",
55
+ tuple(models))) # fmt: skip
56
+ text = render(template, targets, key.value if args.reveal_key else None)
57
+ if args.reveal_key:
58
+ console.reveal(text.rstrip("\n"))
59
+ else:
60
+ console.print(text.rstrip("\n"))
61
+ if template.key_style != "env":
62
+ console.note(f"Set {KEY_ENV} first: export {KEY_ENV}=$(modelmux key show)")
63
+ else:
64
+ console.note("Add the key yourself (modelmux key show), or use --reveal-key.")
65
+ return 0
@@ -0,0 +1,190 @@
1
+ """``modelmux doctor``: diagnose common problems, each with a suggested fix."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import stat
9
+ from dataclasses import dataclass, field
10
+
11
+ from modelmux_cli import __version__, health, updates
12
+ from modelmux_cli.console import Console
13
+ from modelmux_cli.context import Context, make_context
14
+ from modelmux_cli.errors import CliError, DockerError
15
+ from modelmux_cli.ports import port_free
16
+ from modelmux_cli.providers import Provider
17
+ from modelmux_cli.secrets_store import SECRETS_FILE, read_api_key
18
+ from modelmux_cli.stack import resolve_image, service_name
19
+
20
+
21
+ @dataclass
22
+ class Report:
23
+ console: Console
24
+ failures: int = 0
25
+ warnings: int = 0
26
+ lines: list[str] = field(default_factory=list)
27
+
28
+ def ok(self, text: str) -> None:
29
+ self.console.success(text)
30
+
31
+ def warn(self, text: str, hint: str | None = None) -> None:
32
+ self.warnings += 1
33
+ self.console.warn(text + (f"\n {hint}" if hint else ""))
34
+
35
+ def fail(self, text: str, hint: str | None = None) -> None:
36
+ self.failures += 1
37
+ self.console.error(text, hint)
38
+
39
+
40
+ def startup_failure_reason(ctx: Context, provider: Provider) -> str | None:
41
+ """The server's own reason for refusing to start, from its recent logs."""
42
+ result = ctx.stack.compose(
43
+ "logs", "--no-log-prefix", "--tail", "200", service_name(provider), check=False
44
+ )
45
+ reason = None
46
+ for line in result.stdout.splitlines():
47
+ try:
48
+ entry = json.loads(line)
49
+ except ValueError:
50
+ continue
51
+ if isinstance(entry, dict) and entry.get("event") == "probe_failed":
52
+ value = entry.get("reason")
53
+ reason = value if isinstance(value, str) else reason
54
+ return reason
55
+
56
+
57
+ def check_docker(ctx: Context, report: Report) -> bool:
58
+ try:
59
+ version = ctx.docker.check_available()
60
+ except DockerError as exc:
61
+ report.fail(exc.message, exc.hint)
62
+ return False
63
+ report.ok(f"Docker {version} is running, with Compose v2")
64
+ return True
65
+
66
+
67
+ def check_network_and_version(report: Report) -> None:
68
+ if updates.registry_reachable():
69
+ report.ok("The image registry (ghcr.io) is reachable")
70
+ else:
71
+ report.warn(
72
+ "Cannot reach ghcr.io (needed to download the server image)",
73
+ "Check your connection or proxy (HTTPS_PROXY, and Docker's proxy settings).",
74
+ )
75
+ latest = updates.latest_version()
76
+ if latest is None:
77
+ report.warn("Could not check PyPI for a newer modelmux-cli")
78
+ elif updates.newer_available(__version__, latest):
79
+ report.warn(f"modelmux-cli {latest} is available (you have {__version__})",
80
+ updates.UPGRADE_HINT) # fmt: skip
81
+ else:
82
+ report.ok(f"modelmux-cli {__version__} is the latest version")
83
+
84
+
85
+ def check_files(ctx: Context, report: Report) -> bool:
86
+ key_path = ctx.home / SECRETS_FILE
87
+ if not key_path.exists():
88
+ report.fail("ModelMux is not set up on this machine yet", "Run: modelmux up")
89
+ return False
90
+ if os.name == "posix":
91
+ for path, mode in ((ctx.home, 0o700), (key_path, 0o600)):
92
+ if stat.S_IMODE(path.stat().st_mode) != mode:
93
+ report.fail(
94
+ f"{path} is readable by other users", f"Fix it: chmod {mode:o} '{path}'"
95
+ )
96
+ return True
97
+ report.ok(f"Settings and API key are in {ctx.home}")
98
+ return True
99
+
100
+
101
+ def port_conflict(ctx: Context, provider: Provider, report: Report) -> bool:
102
+ port = ctx.config.port(provider.name)
103
+ if port_free(port):
104
+ return False
105
+ report.fail(
106
+ f"{provider.display_name}: port {port} is used by another program",
107
+ f"Choose another: modelmux up --port {provider.name}=<port>",
108
+ )
109
+ return True
110
+
111
+
112
+ def stopped_hint(provider: Provider, reason: str | None) -> str:
113
+ if reason and "auth" in reason:
114
+ return f"The login has expired or was revoked: modelmux login {provider.name} --force"
115
+ if reason and any(word in reason for word in ("version", "flag", "feature", "config key")):
116
+ return "The CLI inside the image is not supported: modelmux upgrade"
117
+ return f"See its logs: modelmux logs {provider.name}"
118
+
119
+
120
+ def check_running(ctx: Context, provider: Provider, api_key: str, report: Report) -> None:
121
+ name, display = provider.name, provider.display_name
122
+ port = ctx.config.port(name)
123
+ if not health.ready(port):
124
+ report.warn(f"{display}: running but not ready yet", f"Watch it: modelmux logs {name} -f")
125
+ return
126
+ status, _ = health.get_json(port, "/v1/models", api_key=api_key)
127
+ if status == 200:
128
+ report.ok(f"{display}: logged in, running and answering at {health.base_url(port)}/v1")
129
+ elif status == 401:
130
+ report.fail(
131
+ f"{display}: the server does not accept the saved API key",
132
+ "Recreate it with the current key: modelmux up",
133
+ )
134
+ else:
135
+ report.fail(
136
+ f"{display}: the API did not answer correctly (HTTP {status})",
137
+ f"See its logs: modelmux logs {name}",
138
+ )
139
+
140
+
141
+ def check_provider(
142
+ ctx: Context, provider: Provider, image: str, api_key: str, report: Report
143
+ ) -> None:
144
+ display = provider.display_name
145
+ if not ctx.stack.logged_in(provider, image):
146
+ report.warn(f"{display}: not logged in", f"Run: modelmux login {provider.name}")
147
+ port_conflict(ctx, provider, report)
148
+ return
149
+ state = ctx.stack.states().get(service_name(provider))
150
+ if state is None:
151
+ if not port_conflict(ctx, provider, report):
152
+ report.warn(f"{display}: logged in but not running", "Start it: modelmux up")
153
+ return
154
+ if state.state != "running":
155
+ reason = startup_failure_reason(ctx, provider)
156
+ detail = f" ({reason})" if reason else ""
157
+ report.fail(f"{display}: the server stopped{detail}", stopped_hint(provider, reason))
158
+ return
159
+ check_running(ctx, provider, api_key, report)
160
+
161
+
162
+ def run(args: argparse.Namespace, console: Console) -> int:
163
+ ctx = make_context(console)
164
+ report = Report(console)
165
+ docker_ok = check_docker(ctx, report)
166
+ check_network_and_version(report)
167
+ set_up = check_files(ctx, report)
168
+ if docker_ok and set_up:
169
+ try:
170
+ image = resolve_image(ctx.config)
171
+ except CliError as exc:
172
+ report.fail(exc.message, exc.hint)
173
+ else:
174
+ if ctx.stack.image_present(image):
175
+ report.ok(f"Server image {image} is present")
176
+ else:
177
+ report.warn(f"Server image {image} is not downloaded yet", "Run: modelmux up")
178
+ key = read_api_key(ctx.home)
179
+ for provider in sorted(ctx.providers.values(), key=lambda p: p.name):
180
+ check_provider(ctx, provider, image, key.value if key else "", report)
181
+
182
+ console.print()
183
+ if report.failures:
184
+ console.error(f"{report.failures} problem(s) found.")
185
+ return 1
186
+ if report.warnings:
187
+ console.success(f"No problems, {report.warnings} note(s).")
188
+ else:
189
+ console.success("Everything looks good.")
190
+ return 0