modelmux-cli 0.1.0__tar.gz
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-0.1.0/.gitignore +29 -0
- modelmux_cli-0.1.0/PKG-INFO +40 -0
- modelmux_cli-0.1.0/README.md +23 -0
- modelmux_cli-0.1.0/hatch_build.py +33 -0
- modelmux_cli-0.1.0/pyproject.toml +88 -0
- modelmux_cli-0.1.0/shared/providers/claude.json +34 -0
- modelmux_cli-0.1.0/shared/providers/codex.json +35 -0
- modelmux_cli-0.1.0/shared/release.json +4 -0
- modelmux_cli-0.1.0/shared/schema/provider.schema.json +81 -0
- modelmux_cli-0.1.0/shared/templates/compose-service.json +12 -0
- modelmux_cli-0.1.0/shared/templates/config/curl.json +8 -0
- modelmux_cli-0.1.0/shared/templates/config/env.json +8 -0
- modelmux_cli-0.1.0/shared/templates/config/langchain.json +8 -0
- modelmux_cli-0.1.0/shared/templates/config/litellm.json +8 -0
- modelmux_cli-0.1.0/shared/templates/config/openai-python.json +8 -0
- modelmux_cli-0.1.0/src/modelmux_cli/__init__.py +8 -0
- modelmux_cli-0.1.0/src/modelmux_cli/__main__.py +7 -0
- modelmux_cli-0.1.0/src/modelmux_cli/clientconfig.py +87 -0
- modelmux_cli-0.1.0/src/modelmux_cli/commands/__init__.py +1 -0
- modelmux_cli-0.1.0/src/modelmux_cli/commands/basic.py +75 -0
- modelmux_cli-0.1.0/src/modelmux_cli/commands/config_cmd.py +65 -0
- modelmux_cli-0.1.0/src/modelmux_cli/commands/doctor.py +190 -0
- modelmux_cli-0.1.0/src/modelmux_cli/commands/login.py +190 -0
- modelmux_cli-0.1.0/src/modelmux_cli/commands/logout.py +53 -0
- modelmux_cli-0.1.0/src/modelmux_cli/commands/up.py +157 -0
- modelmux_cli-0.1.0/src/modelmux_cli/commands/upgrade.py +50 -0
- modelmux_cli-0.1.0/src/modelmux_cli/config.py +78 -0
- modelmux_cli-0.1.0/src/modelmux_cli/console.py +112 -0
- modelmux_cli-0.1.0/src/modelmux_cli/context.py +41 -0
- modelmux_cli-0.1.0/src/modelmux_cli/docker.py +340 -0
- modelmux_cli-0.1.0/src/modelmux_cli/errors.py +35 -0
- modelmux_cli-0.1.0/src/modelmux_cli/files.py +42 -0
- modelmux_cli-0.1.0/src/modelmux_cli/health.py +60 -0
- modelmux_cli-0.1.0/src/modelmux_cli/main.py +119 -0
- modelmux_cli-0.1.0/src/modelmux_cli/paths.py +51 -0
- modelmux_cli-0.1.0/src/modelmux_cli/ports.py +29 -0
- modelmux_cli-0.1.0/src/modelmux_cli/providers.py +168 -0
- modelmux_cli-0.1.0/src/modelmux_cli/py.typed +0 -0
- modelmux_cli-0.1.0/src/modelmux_cli/redact.py +35 -0
- modelmux_cli-0.1.0/src/modelmux_cli/release.py +40 -0
- modelmux_cli-0.1.0/src/modelmux_cli/secrets_store.py +71 -0
- modelmux_cli-0.1.0/src/modelmux_cli/stack.py +217 -0
- modelmux_cli-0.1.0/src/modelmux_cli/terminal.py +44 -0
- modelmux_cli-0.1.0/src/modelmux_cli/updates.py +50 -0
- modelmux_cli-0.1.0/tests/__init__.py +0 -0
- modelmux_cli-0.1.0/tests/conftest.py +121 -0
- modelmux_cli-0.1.0/tests/fixtures/login/README.md +12 -0
- modelmux_cli-0.1.0/tests/fixtures/login/claude_browser.raw +3 -0
- modelmux_cli-0.1.0/tests/fixtures/login/codex_device.raw +14 -0
- modelmux_cli-0.1.0/tests/test_commands.py +254 -0
- modelmux_cli-0.1.0/tests/test_config_cmd.py +166 -0
- modelmux_cli-0.1.0/tests/test_console.py +118 -0
- modelmux_cli-0.1.0/tests/test_docker.py +185 -0
- modelmux_cli-0.1.0/tests/test_doctor_upgrade.py +263 -0
- modelmux_cli-0.1.0/tests/test_login.py +336 -0
- modelmux_cli-0.1.0/tests/test_main.py +101 -0
- modelmux_cli-0.1.0/tests/test_providers.py +146 -0
- modelmux_cli-0.1.0/tests/test_release.py +64 -0
- modelmux_cli-0.1.0/tests/test_source_scan.py +55 -0
- modelmux_cli-0.1.0/tests/test_stack.py +190 -0
- modelmux_cli-0.1.0/tests/test_storage.py +184 -0
- modelmux_cli-0.1.0/tests/test_terminal.py +142 -0
- modelmux_cli-0.1.0/uv.lock +952 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.venv/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
|
|
9
|
+
# Tooling
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.mypy_cache/
|
|
12
|
+
.ruff_cache/
|
|
13
|
+
.coverage
|
|
14
|
+
coverage.xml
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# Local config and secrets
|
|
18
|
+
.env
|
|
19
|
+
.env.*
|
|
20
|
+
!.env.example
|
|
21
|
+
*.env
|
|
22
|
+
secrets/
|
|
23
|
+
*.pem
|
|
24
|
+
*.key
|
|
25
|
+
|
|
26
|
+
# Editors / OS
|
|
27
|
+
.vscode/
|
|
28
|
+
.idea/
|
|
29
|
+
.DS_Store
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: modelmux-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Set up, log in to and run ModelMux: AI coding CLIs as an OpenAI-compatible API
|
|
5
|
+
Project-URL: Homepage, https://github.com/smit153/modelmux
|
|
6
|
+
Project-URL: Source, https://github.com/smit153/modelmux
|
|
7
|
+
Project-URL: Issues, https://github.com/smit153/modelmux/issues
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Operating System :: MacOS
|
|
11
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# modelmux-cli
|
|
19
|
+
|
|
20
|
+
Set up, log in to and run [ModelMux](https://github.com/smit153/modelmux):
|
|
21
|
+
AI coding CLIs (Claude Code, Codex) as a secure, OpenAI-compatible API on your
|
|
22
|
+
own machine.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pipx install modelmux-cli # or: uvx --from modelmux-cli modelmux
|
|
26
|
+
modelmux up # set up Docker containers, start logged-in providers
|
|
27
|
+
modelmux login claude # guided login (opens your browser), then a real test
|
|
28
|
+
modelmux config litellm # ready-to-paste config for LiteLLM, OpenAI SDK, LangChain...
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Other commands: `status`, `logs`, `doctor`, `upgrade`, `logout`, `key show`, `down`.
|
|
32
|
+
|
|
33
|
+
- Requires Docker with Compose v2 (Docker Desktop on macOS and Windows) and
|
|
34
|
+
Python 3.10+. No other dependencies.
|
|
35
|
+
- Each CLI release runs the server image built in the same release, pinned by
|
|
36
|
+
digest.
|
|
37
|
+
- Logins live in per-provider Docker volumes; the API key is generated
|
|
38
|
+
locally and stored with owner-only permissions.
|
|
39
|
+
|
|
40
|
+
Full documentation: <https://github.com/smit153/modelmux/blob/main/docs/CLI.md>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# modelmux-cli
|
|
2
|
+
|
|
3
|
+
Set up, log in to and run [ModelMux](https://github.com/smit153/modelmux):
|
|
4
|
+
AI coding CLIs (Claude Code, Codex) as a secure, OpenAI-compatible API on your
|
|
5
|
+
own machine.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pipx install modelmux-cli # or: uvx --from modelmux-cli modelmux
|
|
9
|
+
modelmux up # set up Docker containers, start logged-in providers
|
|
10
|
+
modelmux login claude # guided login (opens your browser), then a real test
|
|
11
|
+
modelmux config litellm # ready-to-paste config for LiteLLM, OpenAI SDK, LangChain...
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Other commands: `status`, `logs`, `doctor`, `upgrade`, `logout`, `key show`, `down`.
|
|
15
|
+
|
|
16
|
+
- Requires Docker with Compose v2 (Docker Desktop on macOS and Windows) and
|
|
17
|
+
Python 3.10+. No other dependencies.
|
|
18
|
+
- Each CLI release runs the server image built in the same release, pinned by
|
|
19
|
+
digest.
|
|
20
|
+
- Logins live in per-provider Docker volumes; the API key is generated
|
|
21
|
+
locally and stored with owner-only permissions.
|
|
22
|
+
|
|
23
|
+
Full documentation: <https://github.com/smit153/modelmux/blob/main/docs/CLI.md>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Bundle the repository's language-neutral /shared data into the package.
|
|
2
|
+
|
|
3
|
+
From the repository, the data is at ../../shared. Inside a source
|
|
4
|
+
distribution it was copied to ./shared, so wheels built from the sdist (as
|
|
5
|
+
pip and uv do) still get it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
14
|
+
|
|
15
|
+
PARTS = ("providers", "schema", "templates")
|
|
16
|
+
FILES = ("release.json",)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SharedDataHook(BuildHookInterface): # type: ignore[type-arg]
|
|
20
|
+
PLUGIN_NAME = "custom"
|
|
21
|
+
|
|
22
|
+
def initialize(self, version: str, build_data: dict[str, Any]) -> None:
|
|
23
|
+
root = Path(self.root)
|
|
24
|
+
for candidate in (root.parent.parent / "shared", root / "shared"):
|
|
25
|
+
if all((candidate / part).is_dir() for part in PARTS) and all(
|
|
26
|
+
(candidate / name).is_file() for name in FILES
|
|
27
|
+
):
|
|
28
|
+
break
|
|
29
|
+
else:
|
|
30
|
+
raise RuntimeError("modelmux shared data (providers, schema) not found")
|
|
31
|
+
prefix = "modelmux_cli/_shared" if self.target_name == "wheel" else "shared"
|
|
32
|
+
for part in (*PARTS, *FILES):
|
|
33
|
+
build_data["force_include"][str(candidate / part)] = f"{prefix}/{part}"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "modelmux-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Set up, log in to and run ModelMux: AI coding CLIs as an OpenAI-compatible API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
# Deliberately no runtime dependencies: this tool drives Docker on the user's
|
|
9
|
+
# machine, so it stays small and easy to audit.
|
|
10
|
+
dependencies = []
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Operating System :: MacOS",
|
|
14
|
+
"Operating System :: Microsoft :: Windows",
|
|
15
|
+
"Operating System :: POSIX :: Linux",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Software Development",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/smit153/modelmux"
|
|
22
|
+
Source = "https://github.com/smit153/modelmux"
|
|
23
|
+
Issues = "https://github.com/smit153/modelmux/issues"
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
modelmux = "modelmux_cli.main:main"
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"ruff>=0.6",
|
|
31
|
+
"mypy>=1.11",
|
|
32
|
+
"pytest>=8.3",
|
|
33
|
+
"pytest-cov>=5.0",
|
|
34
|
+
"jsonschema>=4.23",
|
|
35
|
+
"types-jsonschema>=4.23",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling>=1.25"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/modelmux_cli"]
|
|
44
|
+
|
|
45
|
+
# The language-neutral data in /shared is bundled by hatch_build.py, both
|
|
46
|
+
# from the repository and when a wheel is built from the sdist.
|
|
47
|
+
[tool.hatch.build.hooks.custom]
|
|
48
|
+
path = "hatch_build.py"
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------- ruff
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
target-version = "py310"
|
|
53
|
+
line-length = 100
|
|
54
|
+
src = ["src", "tests"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
select = [
|
|
58
|
+
"E", "W", "F", "I", "B", "UP", "S", "N", "SIM", "RUF", "PL", "TID", "PT", "C4", "RET",
|
|
59
|
+
]
|
|
60
|
+
ignore = ["PLR0913", "PLR2004"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint.pylint]
|
|
63
|
+
max-returns = 10
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint.flake8-tidy-imports.banned-api]
|
|
66
|
+
"subprocess".msg = "Run commands only via modelmux_cli.docker."
|
|
67
|
+
"os.system".msg = "No shells. Run commands only via modelmux_cli.docker."
|
|
68
|
+
"os.popen".msg = "No shells. Run commands only via modelmux_cli.docker."
|
|
69
|
+
"pty".msg = "Run commands only via modelmux_cli.docker."
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint.per-file-ignores]
|
|
72
|
+
"src/modelmux_cli/docker.py" = ["TID251", "S603", "S606"]
|
|
73
|
+
"tests/**" = ["S101", "S105", "S106", "S108", "TID251", "S603", "S607", "PLR0917"]
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------- mypy
|
|
76
|
+
[tool.mypy]
|
|
77
|
+
python_version = "3.10"
|
|
78
|
+
strict = true
|
|
79
|
+
files = ["src"]
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------- pytest
|
|
82
|
+
[tool.pytest.ini_options]
|
|
83
|
+
testpaths = ["tests"]
|
|
84
|
+
addopts = "-ra --strict-markers"
|
|
85
|
+
|
|
86
|
+
[tool.coverage.run]
|
|
87
|
+
source = ["modelmux_cli"]
|
|
88
|
+
branch = true
|
|
@@ -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,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,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
|