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/__init__.py
ADDED
|
@@ -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"
|
modelmux_cli/__main__.py
ADDED
|
@@ -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,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": "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
|