modelmux-cli 0.1.0__tar.gz → 0.3.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 → modelmux_cli-0.3.0}/PKG-INFO +1 -1
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/pyproject.toml +1 -1
- modelmux_cli-0.3.0/shared/release.json +4 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/commands/up.py +53 -19
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/release.py +1 -1
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/stack.py +10 -3
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/test_commands.py +51 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/test_stack.py +6 -2
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/uv.lock +1 -1
- modelmux_cli-0.1.0/shared/release.json +0 -4
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/.gitignore +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/README.md +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/hatch_build.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/shared/providers/claude.json +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/shared/providers/codex.json +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/shared/schema/provider.schema.json +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/shared/templates/compose-service.json +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/shared/templates/config/curl.json +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/shared/templates/config/env.json +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/shared/templates/config/langchain.json +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/shared/templates/config/litellm.json +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/shared/templates/config/openai-python.json +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/__init__.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/__main__.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/clientconfig.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/commands/__init__.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/commands/basic.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/commands/config_cmd.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/commands/doctor.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/commands/login.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/commands/logout.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/commands/upgrade.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/config.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/console.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/context.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/docker.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/errors.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/files.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/health.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/main.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/paths.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/ports.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/providers.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/py.typed +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/redact.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/secrets_store.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/terminal.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/src/modelmux_cli/updates.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/__init__.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/conftest.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/fixtures/login/README.md +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/fixtures/login/claude_browser.raw +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/fixtures/login/codex_device.raw +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/test_config_cmd.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/test_console.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/test_docker.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/test_doctor_upgrade.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/test_login.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/test_main.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/test_providers.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/test_release.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/test_source_scan.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/test_storage.py +0 -0
- {modelmux_cli-0.1.0 → modelmux_cli-0.3.0}/tests/test_terminal.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: modelmux-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Set up, log in to and run ModelMux: AI coding CLIs as an OpenAI-compatible API
|
|
5
5
|
Project-URL: Homepage, https://github.com/smit153/modelmux
|
|
6
6
|
Project-URL: Source, https://github.com/smit153/modelmux
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
7
|
|
|
7
8
|
from modelmux_cli import health
|
|
8
9
|
from modelmux_cli.config import save_config
|
|
@@ -16,7 +17,7 @@ from modelmux_cli.secrets_store import ensure_api_key
|
|
|
16
17
|
from modelmux_cli.stack import build_compose, resolve_image, service_name, write_compose
|
|
17
18
|
|
|
18
19
|
READY_TIMEOUT = 240.0
|
|
19
|
-
POLL_INTERVAL =
|
|
20
|
+
POLL_INTERVAL = 0.5
|
|
20
21
|
COMPOSE_UP_TIMEOUT = 300.0
|
|
21
22
|
|
|
22
23
|
|
|
@@ -79,8 +80,11 @@ def start_and_wait(ctx: Context, provider: Provider) -> None:
|
|
|
79
80
|
console.success(f"{provider.display_name} is running at {health.base_url(port)}/v1")
|
|
80
81
|
|
|
81
82
|
|
|
82
|
-
def prepare(ctx: Context, image: str) ->
|
|
83
|
-
"""Docker, API key, compose file, image and login volumes: all idempotent.
|
|
83
|
+
def prepare(ctx: Context, image: str) -> bool:
|
|
84
|
+
"""Docker, API key, compose file, image and login volumes: all idempotent.
|
|
85
|
+
|
|
86
|
+
Returns True if the compose file changed (so running containers must be
|
|
87
|
+
recreated to pick it up)."""
|
|
84
88
|
console, stack = ctx.console, ctx.stack
|
|
85
89
|
console.step("Checking Docker...")
|
|
86
90
|
ctx.docker.check_available()
|
|
@@ -88,12 +92,45 @@ def prepare(ctx: Context, image: str) -> None:
|
|
|
88
92
|
key = ensure_api_key(ctx.home)
|
|
89
93
|
if key.created:
|
|
90
94
|
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))
|
|
95
|
+
changed = write_compose(ctx.home, build_compose(ctx.providers, ctx.config, image, key.path))
|
|
92
96
|
if not stack.image_present(image):
|
|
93
97
|
console.step(f"Downloading the ModelMux server image {image} (first time only, ~1.4 GB)...")
|
|
94
98
|
stack.ensure_image(image)
|
|
95
99
|
for provider in ctx.providers.values():
|
|
96
100
|
stack.ensure_volume(provider)
|
|
101
|
+
return changed or key.created
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def running_and_ready(ctx: Context, providers: list[Provider]) -> set[str]:
|
|
105
|
+
"""Providers whose container runs and answers /health/ready. Their login
|
|
106
|
+
is proven (the server's startup check made a real request), so no helper
|
|
107
|
+
container is needed to check it again."""
|
|
108
|
+
states = ctx.stack.states()
|
|
109
|
+
return {
|
|
110
|
+
p.name for p in providers
|
|
111
|
+
if (s := states.get(service_name(p))) is not None and s.state == "running"
|
|
112
|
+
and health.ready(ctx.config.port(p.name))
|
|
113
|
+
} # fmt: skip
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def check_logins(ctx: Context, providers: list[Provider], image: str) -> dict[str, bool]:
|
|
117
|
+
"""Logged in or not, per provider: running ones count as logged in, the
|
|
118
|
+
rest are checked in parallel helper containers."""
|
|
119
|
+
ready = running_and_ready(ctx, providers)
|
|
120
|
+
unknown = [p for p in providers if p.name not in ready]
|
|
121
|
+
with ThreadPoolExecutor(max_workers=max(1, len(unknown))) as pool:
|
|
122
|
+
answers = list(pool.map(lambda p: ctx.stack.logged_in(p, image), unknown))
|
|
123
|
+
return {p.name: True for p in providers if p.name in ready} | {
|
|
124
|
+
p.name: answer for p, answer in zip(unknown, answers, strict=True)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def wait_all(ctx: Context, providers: list[Provider]) -> None:
|
|
129
|
+
"""Wait for every provider at once; report the first failure afterwards."""
|
|
130
|
+
with ThreadPoolExecutor(max_workers=max(1, len(providers))) as pool:
|
|
131
|
+
futures = [pool.submit(start_and_wait, ctx, p) for p in providers]
|
|
132
|
+
for future in futures:
|
|
133
|
+
future.result() # re-raises the provider's CliError
|
|
97
134
|
|
|
98
135
|
|
|
99
136
|
def check_ports(ctx: Context, providers: list[Provider]) -> None:
|
|
@@ -119,11 +156,10 @@ def run(args: argparse.Namespace, console: Console) -> int:
|
|
|
119
156
|
hint=f"Choose from: {', '.join(sorted(providers))}.",
|
|
120
157
|
)
|
|
121
158
|
image = apply_settings(ctx, args)
|
|
122
|
-
prepare(ctx, image)
|
|
123
|
-
|
|
159
|
+
compose_changed = prepare(ctx, image)
|
|
124
160
|
targets = [providers[name] for name in (args.providers or sorted(providers))]
|
|
125
161
|
console.step("Checking logins...")
|
|
126
|
-
logged_in =
|
|
162
|
+
logged_in = check_logins(ctx, targets, image)
|
|
127
163
|
missing = [p for p in targets if not logged_in[p.name]]
|
|
128
164
|
if args.providers and missing:
|
|
129
165
|
raise CliError(
|
|
@@ -135,22 +171,20 @@ def run(args: argparse.Namespace, console: Console) -> int:
|
|
|
135
171
|
console.success("ModelMux is set up. No provider is logged in yet.")
|
|
136
172
|
console.step(f"Next: modelmux login {targets[0].name}")
|
|
137
173
|
return 0
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
ctx.stack.compose("up", "-d", *(service_name(p) for p in to_start), timeout=COMPOSE_UP_TIMEOUT)
|
|
174
|
+
ready = set() if compose_changed else running_and_ready(ctx, to_start)
|
|
175
|
+
waiting = [p for p in to_start if p.name not in ready]
|
|
176
|
+
if waiting:
|
|
177
|
+
check_ports(ctx, waiting)
|
|
178
|
+
ctx.stack.compose(
|
|
179
|
+
"up", "-d", *(service_name(p) for p in to_start), timeout=COMPOSE_UP_TIMEOUT
|
|
180
|
+
)
|
|
146
181
|
for provider in to_start:
|
|
147
|
-
|
|
148
|
-
|
|
182
|
+
if provider.name in ready:
|
|
183
|
+
port = ctx.config.port(provider.name)
|
|
149
184
|
console.success(
|
|
150
185
|
f"{provider.display_name} is already running at {health.base_url(port)}/v1"
|
|
151
186
|
)
|
|
152
|
-
|
|
153
|
-
start_and_wait(ctx, provider)
|
|
187
|
+
wait_all(ctx, waiting)
|
|
154
188
|
for provider in missing:
|
|
155
189
|
console.step(f"{provider.display_name} is not logged in: modelmux login {provider.name}")
|
|
156
190
|
console.step("Configure your tools: modelmux config litellm (or openai-python, langchain)")
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The release workflow writes the server image pinned by digest
|
|
4
4
|
(``ghcr.io/smit153/modelmux@sha256:...``) into that file before building, so
|
|
5
|
-
every CLI implementation (Python
|
|
5
|
+
every CLI implementation (Python and Node) runs the same image. In a
|
|
6
6
|
development checkout ``image`` is null: pass ``--image`` instead.
|
|
7
7
|
"""
|
|
8
8
|
|
|
@@ -83,11 +83,18 @@ def build_compose(
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
|
|
86
|
-
def write_compose(directory: Path, compose: dict[str, Any]) ->
|
|
86
|
+
def write_compose(directory: Path, compose: dict[str, Any]) -> bool:
|
|
87
|
+
"""Write the compose file. Returns True if its content changed."""
|
|
87
88
|
path = directory / COMPOSE_FILE
|
|
88
89
|
header = "# Generated by modelmux. Do not edit: changes are overwritten by 'modelmux up'.\n"
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
content = header + json.dumps(compose, indent=2) + "\n"
|
|
91
|
+
try:
|
|
92
|
+
if path.read_text(encoding="utf-8") == content:
|
|
93
|
+
return False
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
write_private(path, content)
|
|
97
|
+
return True
|
|
91
98
|
|
|
92
99
|
|
|
93
100
|
@dataclass(frozen=True)
|
|
@@ -3,11 +3,14 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import stat
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
|
|
8
10
|
import pytest
|
|
9
11
|
|
|
10
12
|
from modelmux_cli import health, release
|
|
13
|
+
from modelmux_cli import stack as stack_module
|
|
11
14
|
from modelmux_cli.commands import up as up_module
|
|
12
15
|
from modelmux_cli.main import main
|
|
13
16
|
from tests.conftest import FakeDocker
|
|
@@ -97,6 +100,54 @@ def test_up_is_idempotent(capsys: pytest.CaptureFixture[str], fake_docker: FakeD
|
|
|
97
100
|
assert "Created an API key" not in out
|
|
98
101
|
|
|
99
102
|
|
|
103
|
+
def test_up_running_provider_skips_login_check_and_compose(
|
|
104
|
+
capsys: pytest.CaptureFixture[str], fake_docker: FakeDocker
|
|
105
|
+
) -> None:
|
|
106
|
+
# Second `up` with nothing changed: no helper container, no compose up.
|
|
107
|
+
logged_in(fake_docker, "claude")
|
|
108
|
+
cli(capsys, "up", "claude")
|
|
109
|
+
fake_docker.calls.clear()
|
|
110
|
+
fake_docker.when("compose", "ps", returns=(0, RUNNING, ""))
|
|
111
|
+
code, out = cli(capsys, "up", "claude")
|
|
112
|
+
assert code == 0
|
|
113
|
+
assert "already running" in out
|
|
114
|
+
assert not [c for c in fake_docker.calls if c[:2] == ("run", "--rm")]
|
|
115
|
+
assert not fake_docker.called("compose", "up")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_up_changed_settings_recreate_running_provider(
|
|
119
|
+
capsys: pytest.CaptureFixture[str], fake_docker: FakeDocker
|
|
120
|
+
) -> None:
|
|
121
|
+
logged_in(fake_docker, "claude")
|
|
122
|
+
cli(capsys, "up", "claude")
|
|
123
|
+
fake_docker.when("compose", "ps", returns=(0, RUNNING, ""))
|
|
124
|
+
code, out = cli(capsys, "up", "claude", "--port", "claude=9101")
|
|
125
|
+
assert code == 0
|
|
126
|
+
assert fake_docker.called("compose", "up")
|
|
127
|
+
assert "running at http://127.0.0.1:9101/v1" in out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_up_checks_logins_in_parallel(
|
|
131
|
+
capsys: pytest.CaptureFixture[str], fake_docker: FakeDocker, monkeypatch: pytest.MonkeyPatch
|
|
132
|
+
) -> None:
|
|
133
|
+
active, peak, lock = 0, 0, threading.Lock()
|
|
134
|
+
|
|
135
|
+
def slow_check(self: object, provider: object, image: str) -> bool:
|
|
136
|
+
nonlocal active, peak
|
|
137
|
+
with lock:
|
|
138
|
+
active += 1
|
|
139
|
+
peak = max(peak, active)
|
|
140
|
+
time.sleep(0.05)
|
|
141
|
+
with lock:
|
|
142
|
+
active -= 1
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
monkeypatch.setattr(stack_module.Stack, "logged_in", slow_check)
|
|
146
|
+
code, _ = cli(capsys, "up")
|
|
147
|
+
assert code == 0
|
|
148
|
+
assert peak == 2 # claude and codex checked at the same time
|
|
149
|
+
|
|
150
|
+
|
|
100
151
|
def test_up_explicit_provider_not_logged_in(
|
|
101
152
|
capsys: pytest.CaptureFixture[str], fake_docker: FakeDocker
|
|
102
153
|
) -> None:
|
|
@@ -87,11 +87,15 @@ def test_compose_is_hardened_and_isolated() -> None:
|
|
|
87
87
|
|
|
88
88
|
|
|
89
89
|
def test_write_compose(tmp_path: Path) -> None:
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
compose = build_compose(PROVIDERS, Config(), "img:1", SECRETS)
|
|
91
|
+
assert write_compose(tmp_path, compose) is True
|
|
92
|
+
text = (tmp_path / "compose.yaml").read_text()
|
|
92
93
|
assert text.startswith("# Generated by modelmux")
|
|
93
94
|
body = json.loads(text.split("\n", 1)[1])
|
|
94
95
|
assert set(body["services"]) == {"modelmux-claude", "modelmux-codex"}
|
|
96
|
+
# Same content again: nothing to apply, so running containers can stay.
|
|
97
|
+
assert write_compose(tmp_path, compose) is False
|
|
98
|
+
assert write_compose(tmp_path, build_compose(PROVIDERS, Config(), "img:2", SECRETS)) is True
|
|
95
99
|
|
|
96
100
|
|
|
97
101
|
def test_names() -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|