modelmux-cli 0.1.0__tar.gz → 0.2.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.
Files changed (64) hide show
  1. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/PKG-INFO +1 -1
  2. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/pyproject.toml +1 -1
  3. modelmux_cli-0.2.0/shared/release.json +4 -0
  4. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/commands/up.py +53 -19
  5. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/stack.py +10 -3
  6. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/test_commands.py +51 -0
  7. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/test_stack.py +6 -2
  8. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/uv.lock +1 -1
  9. modelmux_cli-0.1.0/shared/release.json +0 -4
  10. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/.gitignore +0 -0
  11. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/README.md +0 -0
  12. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/hatch_build.py +0 -0
  13. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/shared/providers/claude.json +0 -0
  14. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/shared/providers/codex.json +0 -0
  15. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/shared/schema/provider.schema.json +0 -0
  16. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/shared/templates/compose-service.json +0 -0
  17. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/shared/templates/config/curl.json +0 -0
  18. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/shared/templates/config/env.json +0 -0
  19. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/shared/templates/config/langchain.json +0 -0
  20. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/shared/templates/config/litellm.json +0 -0
  21. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/shared/templates/config/openai-python.json +0 -0
  22. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/__init__.py +0 -0
  23. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/__main__.py +0 -0
  24. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/clientconfig.py +0 -0
  25. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/commands/__init__.py +0 -0
  26. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/commands/basic.py +0 -0
  27. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/commands/config_cmd.py +0 -0
  28. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/commands/doctor.py +0 -0
  29. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/commands/login.py +0 -0
  30. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/commands/logout.py +0 -0
  31. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/commands/upgrade.py +0 -0
  32. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/config.py +0 -0
  33. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/console.py +0 -0
  34. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/context.py +0 -0
  35. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/docker.py +0 -0
  36. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/errors.py +0 -0
  37. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/files.py +0 -0
  38. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/health.py +0 -0
  39. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/main.py +0 -0
  40. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/paths.py +0 -0
  41. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/ports.py +0 -0
  42. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/providers.py +0 -0
  43. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/py.typed +0 -0
  44. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/redact.py +0 -0
  45. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/release.py +0 -0
  46. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/secrets_store.py +0 -0
  47. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/terminal.py +0 -0
  48. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/src/modelmux_cli/updates.py +0 -0
  49. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/__init__.py +0 -0
  50. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/conftest.py +0 -0
  51. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/fixtures/login/README.md +0 -0
  52. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/fixtures/login/claude_browser.raw +0 -0
  53. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/fixtures/login/codex_device.raw +0 -0
  54. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/test_config_cmd.py +0 -0
  55. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/test_console.py +0 -0
  56. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/test_docker.py +0 -0
  57. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/test_doctor_upgrade.py +0 -0
  58. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/test_login.py +0 -0
  59. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/test_main.py +0 -0
  60. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/test_providers.py +0 -0
  61. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/test_release.py +0 -0
  62. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/test_source_scan.py +0 -0
  63. {modelmux_cli-0.1.0 → modelmux_cli-0.2.0}/tests/test_storage.py +0 -0
  64. {modelmux_cli-0.1.0 → modelmux_cli-0.2.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.1.0
3
+ Version: 0.2.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "modelmux-cli"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Set up, log in to and run ModelMux: AI coding CLIs as an OpenAI-compatible API"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -0,0 +1,4 @@
1
+ {
2
+ "version": "0.2.0",
3
+ "image": "ghcr.io/smit153/modelmux@sha256:80cb7830fdbcb8076ecfb11eec78bd26e7909c4852072a68c1dfc5fe79c7fad3"
4
+ }
@@ -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 = 2.0
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) -> None:
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 = {p.name: ctx.stack.logged_in(p, image) for p in targets}
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
- check_ports(ctx, to_start)
140
- states = ctx.stack.states()
141
- was_running = {
142
- p.name for p in to_start
143
- if (s := states.get(service_name(p))) is not None and s.state == "running"
144
- } # fmt: skip
145
- ctx.stack.compose("up", "-d", *(service_name(p) for p in to_start), timeout=COMPOSE_UP_TIMEOUT)
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
- port = ctx.config.port(provider.name)
148
- if provider.name in was_running and health.ready(port):
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
- else:
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)")
@@ -83,11 +83,18 @@ def build_compose(
83
83
  }
84
84
 
85
85
 
86
- def write_compose(directory: Path, compose: dict[str, Any]) -> Path:
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
- write_private(path, header + json.dumps(compose, indent=2) + "\n")
90
- return path
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
- path = write_compose(tmp_path, build_compose(PROVIDERS, Config(), "img:1", SECRETS))
91
- text = path.read_text()
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:
@@ -403,7 +403,7 @@ wheels = [
403
403
 
404
404
  [[package]]
405
405
  name = "modelmux-cli"
406
- version = "0.1.0"
406
+ version = "0.2.0"
407
407
  source = { editable = "." }
408
408
 
409
409
  [package.dev-dependencies]
@@ -1,4 +0,0 @@
1
- {
2
- "version": "0.1.0",
3
- "image": "ghcr.io/smit153/modelmux@sha256:1edfd02f98bf739c874fa0db043bdbd46d907ac41d2bc30fb7f5b8b311381b72"
4
- }
File without changes
File without changes