superwise-sentinel-cli 0.2.0__tar.gz → 0.2.1__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 (28) hide show
  1. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/CLAUDE.md +3 -3
  2. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/PKG-INFO +1 -1
  3. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/README.md +10 -1
  4. superwise_sentinel_cli-0.2.1/build.env +1 -0
  5. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/pyproject.toml +1 -1
  6. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/cli.py +98 -15
  7. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/config.py +1 -1
  8. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/controller.py +6 -5
  9. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/docker_manager.py +33 -23
  10. superwise_sentinel_cli-0.2.1/src/superwise_sentinel_cli/exceptions.py +21 -0
  11. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/tests/test_controller.py +2 -2
  12. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/tests/test_docker_manager.py +9 -7
  13. superwise_sentinel_cli-0.2.0/build.env +0 -1
  14. superwise_sentinel_cli-0.2.0/src/superwise_sentinel_cli/exceptions.py +0 -12
  15. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/.gitignore +0 -0
  16. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/.gitlab-ci.yml +0 -0
  17. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/.pre-commit-config.yaml +0 -0
  18. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/.releaserc.json +0 -0
  19. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/CHANGELOG.md +0 -0
  20. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/poetry.lock +0 -0
  21. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/poetry.toml +0 -0
  22. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/scripts/semantic-release-cli-publish.sh +0 -0
  23. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/__init__.py +0 -0
  24. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/auth.py +0 -0
  25. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/shell_profile.py +0 -0
  26. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/tests/__init__.py +0 -0
  27. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/tests/test_auth.py +0 -0
  28. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/tests/test_shell_profile.py +0 -0
@@ -40,11 +40,11 @@ Run manually: `pre-commit run --all-files`
40
40
 
41
41
  All source lives under `src/superwise_sentinel_cli/`:
42
42
 
43
- - **`cli.py`** — Click command tree: root group `main`, with subgroups `gateway` (start/stop) and `proxy` (on/off), plus top-level commands `auth` and `config`. Imports from `auth`, `runner`, `shell_profile`, and `config`.
43
+ - **`cli.py`** — Click command tree: root group `main`, with subgroups `gateway` (start/stop/update) and `proxy` (on/off), plus top-level commands `auth` and `config`. Imports from `auth`, `controller`, `shell_profile`, and `config`.
44
44
  - **`config.py`** — `AppConfig` (pydantic-settings). Settings priority: env vars (`SENTINEL_*`) > `~/.config/sentinel/config.json` > field defaults. Also owns `_PROVIDER_CONFIG` (provider → env var + URL path mapping) and `VALID_PROVIDERS`. `AppConfig.load()` / `.save()` are the only intended access points.
45
45
  - **`auth.py`** — `authenticate()` hits the Superwise auth API and caches the token at `~/.config/sentinel/token`. Skips the network call on subsequent invocations unless `force=True`.
46
- - **`runner.py`** — `get_or_create_local_endpoint()` starts the Docker container and returns its base URL. `resolve_env_vars()` maps provider names to `(env_var, full_url)` pairs using `_PROVIDER_CONFIG` from `config.py`.
47
- - **`docker_manager.py`** — Manages the `sentinel-local` Docker container (image: `us-central1-docker.pkg.dev/admina33d6818/docker/platform/sentinel:<tag>`). `ensure_container_running()` pulls, starts, and returns a `ContainerEndpoint`; `stop_container()` stops and removes it. Falls back to `linux/amd64` when native platform image is unavailable.
46
+ - **`controller.py`** — Business logic layer between `cli.py` and `docker_manager.py`/`shell_profile.py`. `get_or_create_local_endpoint()` starts the Docker container and returns its base URL. `resolve_env_vars()` maps provider names to `(env_var, full_url)` pairs using `_PROVIDER_CONFIG` from `config.py`. `update_gateway_image` is an alias for `docker_manager.pull_latest_image` — it only refreshes the local image, it does not touch a running container.
47
+ - **`docker_manager.py`** — Manages the `sentinel-local` Docker container (image from `AppConfig.CONTAINER_IMAGE`). `ensure_container_running()` pulls (if not cached), starts, and returns a `ContainerEndpoint`; `stop_container()` stops and removes it; `pull_latest_image()` force-pulls the image regardless of local cache, for use by `gateway update`. Falls back to `linux/amd64` when native platform image is unavailable.
48
48
  - **`shell_profile.py`** — `ShellContext` abstraction with `_UnixShellContext` (bash/zsh, auto-detected) and `_WindowsShellContext` (PowerShell). `ensure_shell_integration()` / `remove_shell_integration()` patch the shell profile. `write_env_vars()` / `remove_env_vars()` manage `~/.config/sentinel/protected_vars.json` and a sourced env file. On macOS, also calls `launchctl setenv/unsetenv` for GUI app visibility. The module-level `SHELL` constant holds the current platform context.
49
49
 
50
50
  ## Config & state files
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superwise-sentinel-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Sentinel is an LLM proxy that intercepts traffic, enabling you to monitor, secure, and optimize your AI usage. This CLI tool helps you quickly set up traffic interception on this machine
5
5
  Requires-Python: <4.0,>=3.11
6
6
  Requires-Dist: click>=8.1
@@ -28,13 +28,22 @@ Authenticate with Superwise (prompts for Client ID and Client Secret). Pass `--a
28
28
  |---|---|
29
29
  | `sentinel gateway start` | Pull and start the local Docker gateway container |
30
30
  | `sentinel gateway stop` | Stop and remove the container |
31
+ | `sentinel gateway update` | Pull the latest gateway image, without touching the running container |
31
32
 
32
- Pass `--sentinel-id <uuid>` to `start` to use an existing sentinel rather than creating a new one.
33
+ Pass `--sentinel-id <uuid>` to `start` to use an existing sentinel rather than creating a new one. After `gateway update`, run `sentinel gateway start --force` to switch the running gateway over to the newly pulled image.
33
34
 
34
35
  ### `sentinel config`
35
36
 
36
37
  Configure the gateway URL and which LLM providers to intercept. Valid providers: `anthropic`, `openai`, `google`. Use `all` to select every provider.
37
38
 
39
+ Run interactively via `sentinel config url` / `sentinel config providers`, or set both non-interactively in one line:
40
+
41
+ ```bash
42
+ sentinel config --url "https://gateway.example.com" --providers "openai anthropic"
43
+ ```
44
+
45
+ Each subcommand also accepts `--set` for setting just that one value non-interactively, e.g. `sentinel config url --set "https://gateway.example.com"` or `sentinel config providers --set all`.
46
+
38
47
  ### `sentinel proxy`
39
48
 
40
49
  | Command | Description |
@@ -0,0 +1 @@
1
+ RELEASED_VERSION=0.2.1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "superwise-sentinel-cli"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Sentinel is an LLM proxy that intercepts traffic, enabling you to monitor, secure, and optimize your AI usage. This CLI tool helps you quickly set up traffic interception on this machine"
9
9
  requires-python = ">=3.11, <4.0"
10
10
  dependencies = [
@@ -25,7 +25,9 @@ from superwise_sentinel_cli.controller import save_providers
25
25
  from superwise_sentinel_cli.controller import stop_gateway_container
26
26
  from superwise_sentinel_cli.controller import update_client_id_and_secret
27
27
  from superwise_sentinel_cli.controller import update_env_vars
28
+ from superwise_sentinel_cli.controller import update_gateway_image
28
29
  from superwise_sentinel_cli.exceptions import AuthenticationError
30
+ from superwise_sentinel_cli.exceptions import BaseCLIException
29
31
 
30
32
  _SECRET_PLACEHOLDER = "********"
31
33
 
@@ -48,6 +50,20 @@ def echo_error(message: str) -> None:
48
50
  click.secho(message, fg="red", err=True)
49
51
 
50
52
 
53
+ def _apply_url(cfg: AppConfig, url: str) -> None:
54
+ save_gateway_url(cfg, url)
55
+
56
+
57
+ def _apply_providers(cfg: AppConfig, spec: str) -> None:
58
+ providers = parse_providers(spec.split())
59
+ invalid = check_invalid_providers(providers)
60
+ if invalid:
61
+ raise click.ClickException(
62
+ f"Unknown provider(s): {', '.join(invalid)}. Valid choices: {', '.join(VALID_PROVIDERS)}"
63
+ )
64
+ save_providers(cfg, providers)
65
+
66
+
51
67
  @click.group()
52
68
  @click.version_option(prog_name="superwise-sentinel-cli", help="Show the version and exit.")
53
69
  def main() -> None:
@@ -152,8 +168,13 @@ def gateway() -> None:
152
168
  default=None,
153
169
  help='JSON array of custom LLM providers, e.g. \'[{"provider_name":"myllm","base_url":"http://host:4000"}]\'',
154
170
  )
171
+ @click.option(
172
+ "--force", is_flag=True, default=False, help="If a local gateway is already running, stop it and start a new one."
173
+ )
155
174
  @click.pass_context
156
- def start_gateway(ctx, sentinel_id: uuid.UUID | None, new_sentinel: bool, custom_providers: str | None) -> None:
175
+ def start_gateway(
176
+ ctx, sentinel_id: uuid.UUID | None, new_sentinel: bool, custom_providers: str | None, force: bool
177
+ ) -> None:
157
178
  """Bootstrap & run a local Sentinel gateway container."""
158
179
  cfg = AppConfig.load()
159
180
 
@@ -163,15 +184,19 @@ def start_gateway(ctx, sentinel_id: uuid.UUID | None, new_sentinel: bool, custom
163
184
  except Exception as exc:
164
185
  raise click.ClickException(f"Invalid --custom-providers: {exc}") from exc
165
186
 
166
- running = get_running_gateway()
167
- if running is not None:
168
- base_url, running_sentinel_id = running
169
- click.echo(
170
- f"A local gateway is already running at {base_url} " f"(sentinel ID: {running_sentinel_id or 'unknown'})."
171
- )
172
- if not click.confirm("Stop it and start a new one?"):
173
- return
187
+ if force:
174
188
  stop_gateway_container()
189
+ else:
190
+ running = get_running_gateway()
191
+ if running is not None:
192
+ base_url, running_sentinel_id = running
193
+ click.echo(
194
+ f"A local gateway is already running at {base_url} "
195
+ f"(sentinel ID: {running_sentinel_id or 'unknown'})."
196
+ )
197
+ if not click.confirm("Stop it and start a new one?"):
198
+ return
199
+ stop_gateway_container()
175
200
 
176
201
  if not sentinel_id and (new_sentinel or not cfg.sentinel_id):
177
202
  click.echo("Creating a new sentinel.")
@@ -211,23 +236,81 @@ def stop_gateway() -> None:
211
236
  click.echo(f"Gateway removed. Sentinel configuration remains available at {get_sentinel_url(cfg)}.")
212
237
 
213
238
 
214
- @main.group("config")
215
- def config() -> None:
216
- """Configure gateway URL and LLM providers."""
239
+ @gateway.command("update")
240
+ def update_gateway() -> None:
241
+ """Pull the latest local gateway image."""
242
+ try:
243
+ result = update_gateway_image()
244
+ except BaseCLIException:
245
+ raise
246
+ except Exception:
247
+ raise click.ClickException("Failed to pull the latest sentinel image.")
248
+ if result.updated:
249
+ if get_running_gateway() is not None:
250
+ click.echo(
251
+ "Sentinel image updated. Run 'sentinel gateway start --force' to restart the running gateway using the new image."
252
+ )
253
+ else:
254
+ click.echo("Sentinel image updated.")
255
+ else:
256
+ click.echo("Image is up to date.")
257
+
258
+
259
+ @main.group("config", invoke_without_command=True)
260
+ @click.option("--url", "url_value", default=None, help="Set the gateway URL non-interactively.")
261
+ @click.option(
262
+ "--providers",
263
+ "providers_value",
264
+ default=None,
265
+ help="Set providers non-interactively (space-separated, e.g. \"openai anthropic\", or 'all').",
266
+ )
267
+ @click.pass_context
268
+ def config(ctx, url_value: str | None, providers_value: str | None) -> None:
269
+ """Configure gateway URL and LLM providers.
270
+
271
+ Run with --url/--providers for a non-interactive one-liner, e.g.
272
+ 'sentinel config --url https://gateway.example.com --providers "openai anthropic"'.
273
+ """
274
+ if ctx.invoked_subcommand is not None:
275
+ return
276
+ if url_value is None and providers_value is None:
277
+ click.echo(ctx.get_help())
278
+ return
279
+ cfg = AppConfig.load()
280
+ if url_value is not None:
281
+ _apply_url(cfg, url_value)
282
+ if providers_value is not None:
283
+ _apply_providers(cfg, providers_value)
284
+ click.echo(f"Configuration updated.")
217
285
 
218
286
 
219
287
  @config.command("url")
220
- def config_url() -> None:
288
+ @click.option("--set", "set_value", default=None, help="Set the gateway URL non-interactively.")
289
+ def config_url(set_value: str | None) -> None:
221
290
  """Set the gateway URL."""
222
291
  cfg = AppConfig.load()
292
+ if set_value is not None:
293
+ _apply_url(cfg, set_value)
294
+ click.echo(f"Gateway URL updated.")
295
+ return
223
296
  url = click.prompt("Gateway url", default=cfg.gateway_url)
224
- save_gateway_url(cfg, url)
297
+ _apply_url(cfg, url)
225
298
 
226
299
 
227
300
  @config.command("providers")
228
- def config_providers() -> None:
301
+ @click.option(
302
+ "--set",
303
+ "set_value",
304
+ default=None,
305
+ help="Set providers non-interactively (space-separated, e.g. \"openai anthropic\", or 'all').",
306
+ )
307
+ def config_providers(set_value: str | None) -> None:
229
308
  """Set the LLM providers to intercept. Use 'all' to select all supported providers."""
230
309
  cfg = AppConfig.load()
310
+ if set_value is not None:
311
+ _apply_providers(cfg, set_value)
312
+ click.echo(f"Providers updated.")
313
+ return
231
314
  while True:
232
315
  providers = click.prompt(
233
316
  "Choose the providers you want to route to the configured gateway (space separated)",
@@ -31,7 +31,7 @@ CONFIG_FILE = CONFIG_DIR / "config.json"
31
31
 
32
32
  _PROVIDER_CONFIG: dict[str, tuple[str, str]] = {
33
33
  "anthropic": ("ANTHROPIC_BASE_URL", "anthropic"),
34
- "openai": ("OPENAI_BASE_URL", "openai"),
34
+ "openai": ("OPENAI_BASE_URL", "openai/v1"),
35
35
  "google": ("GOOGLE_GEMINI_BASE_URL", "gemini"),
36
36
  }
37
37
 
@@ -9,12 +9,18 @@ from superwise_sentinel_cli.config import AppConfig
9
9
  from superwise_sentinel_cli.config import VALID_PROVIDERS
10
10
  from superwise_sentinel_cli.docker_manager import ensure_container_running
11
11
  from superwise_sentinel_cli.docker_manager import get_running_gateway_info
12
+ from superwise_sentinel_cli.docker_manager import pull_latest_image
12
13
  from superwise_sentinel_cli.docker_manager import stop_container
13
14
  from superwise_sentinel_cli.exceptions import AuthenticationError
14
15
  from superwise_sentinel_cli.shell_profile import remove_env_vars
15
16
  from superwise_sentinel_cli.shell_profile import write_env_vars
16
17
 
17
18
 
19
+ stop_gateway_container = stop_container
20
+ update_gateway_image = pull_latest_image
21
+ clear_env_vars = remove_env_vars
22
+
23
+
18
24
  def check_invalid_providers(providers: list[str]) -> list[str]:
19
25
  return [p for p in providers if p not in VALID_PROVIDERS]
20
26
 
@@ -116,11 +122,6 @@ def get_running_gateway() -> tuple[str, str | None] | None:
116
122
  return endpoint.base_url, sentinel_id
117
123
 
118
124
 
119
- stop_gateway_container = stop_container
120
-
121
- clear_env_vars = remove_env_vars
122
-
123
-
124
125
  def update_env_vars(cfg: AppConfig) -> list[tuple[str, str]]:
125
126
  pairs = resolve_env_vars(cfg.providers, cfg.gateway_url)
126
127
  remove_env_vars()
@@ -7,6 +7,8 @@ import click
7
7
  import docker.errors
8
8
  from docker.models.containers import Container
9
9
  from superwise_sentinel_cli.config import AppConfig
10
+ from superwise_sentinel_cli.exceptions import DockerError
11
+ from superwise_sentinel_cli.exceptions import DockerUnavailableError
10
12
 
11
13
  cfg = AppConfig()
12
14
  CONTAINER_IMAGE = cfg.CONTAINER_IMAGE
@@ -30,7 +32,7 @@ def _docker_client() -> docker.DockerClient:
30
32
  client.ping()
31
33
  return client
32
34
  except docker.errors.DockerException as exc:
33
- raise RuntimeError("Cannot connect to Docker daemon — is Docker running?") from exc
35
+ raise DockerUnavailableError() from exc
34
36
 
35
37
 
36
38
  def _running_container(client: docker.DockerClient) -> Container | None:
@@ -59,7 +61,7 @@ def _endpoint_from_container(container: Container) -> ContainerEndpoint:
59
61
  def get_running_gateway_info() -> tuple[ContainerEndpoint, str | None] | None:
60
62
  try:
61
63
  client = _docker_client()
62
- except RuntimeError:
64
+ except DockerUnavailableError:
63
65
  return None
64
66
  container = _running_container(client)
65
67
  if container is None:
@@ -68,14 +70,11 @@ def get_running_gateway_info() -> tuple[ContainerEndpoint, str | None] | None:
68
70
 
69
71
 
70
72
  def stop_container() -> None:
71
- try:
72
- client = _docker_client()
73
- matches = client.containers.list(all=True, filters={"name": CONTAINER_NAME})
74
- for container in matches:
75
- container.stop(timeout=5)
76
- container.remove(force=True)
77
- except RuntimeError as exc:
78
- raise click.ClickException(str(exc)) from exc
73
+ client = _docker_client()
74
+ matches = client.containers.list(all=True, filters={"name": CONTAINER_NAME})
75
+ for container in matches:
76
+ container.stop(timeout=5)
77
+ container.remove(force=True)
79
78
 
80
79
 
81
80
  def _docker_pull(platform: str | None = None) -> subprocess.CompletedProcess:
@@ -83,30 +82,41 @@ def _docker_pull(platform: str | None = None) -> subprocess.CompletedProcess:
83
82
  if platform:
84
83
  cmd += ["--platform", platform]
85
84
  cmd.append(CONTAINER_IMAGE)
86
- return subprocess.run(cmd, stderr=subprocess.PIPE, text=True)
85
+ return subprocess.run(cmd, capture_output=True, text=True)
87
86
 
88
87
 
89
- def _pull_image_if_needed() -> str | None:
90
- client = _docker_client()
91
- try:
92
- client.images.get(CONTAINER_IMAGE)
93
- return None
94
- except docker.errors.ImageNotFound:
95
- pass
88
+ @dataclass(frozen=True)
89
+ class PullResult:
90
+ platform: str | None
91
+ updated: bool
92
+
93
+
94
+ def pull_latest_image() -> PullResult:
95
+ _docker_client()
96
96
 
97
97
  click.echo(f"Pulling sentinel image...")
98
98
  native = _docker_pull()
99
99
  if native.returncode == 0:
100
- return None
100
+ return PullResult(None, "Status: Downloaded newer image" in native.stdout)
101
101
 
102
102
  if "no matching manifest" in native.stderr:
103
103
  click.echo("Native platform image not available, falling back to linux/amd64...")
104
104
  fallback = _docker_pull("linux/amd64")
105
105
  if fallback.returncode == 0:
106
- return "linux/amd64"
107
- raise RuntimeError(f"Failed to pull image {CONTAINER_IMAGE}:\n{fallback.stderr}") from None
106
+ return PullResult("linux/amd64", "Status: Downloaded newer image" in fallback.stdout)
107
+ raise DockerError(f"Failed to pull image {CONTAINER_IMAGE}:\n{fallback.stderr}") from None
108
+
109
+ raise DockerError(f"Failed to pull image {CONTAINER_IMAGE}:\n{native.stderr}") from None
108
110
 
109
- raise RuntimeError(f"Failed to pull image {CONTAINER_IMAGE}:\n{native.stderr}") from None
111
+
112
+ def _pull_image_if_needed() -> str | None:
113
+ client = _docker_client()
114
+ try:
115
+ client.images.get(CONTAINER_IMAGE)
116
+ return None
117
+ except docker.errors.ImageNotFound:
118
+ pass
119
+ return pull_latest_image().platform
110
120
 
111
121
 
112
122
  def ensure_container_running(environment: dict[str, str] | None = None) -> ContainerEndpoint:
@@ -138,6 +148,6 @@ def ensure_container_running(environment: dict[str, str] | None = None) -> Conta
138
148
  remove=False,
139
149
  )
140
150
  except docker.errors.APIError as exc:
141
- raise RuntimeError(f"Failed to start sentinel container: {exc.explanation}") from exc
151
+ raise DockerError(f"Failed to start sentinel container: {exc.explanation}") from exc
142
152
 
143
153
  return _endpoint_from_container(container)
@@ -0,0 +1,21 @@
1
+ import sys
2
+
3
+ import click
4
+
5
+
6
+ class BaseCLIException(click.ClickException):
7
+ def show(self, file=None):
8
+ click.secho(f"Error: {self.message}", fg="red", file=file or sys.stderr)
9
+
10
+
11
+ class AuthenticationError(BaseCLIException):
12
+ pass
13
+
14
+
15
+ class DockerError(BaseCLIException):
16
+ """Base exception for docker-related failures."""
17
+
18
+
19
+ class DockerUnavailableError(DockerError):
20
+ def __init__(self) -> None:
21
+ super().__init__("Cannot connect to Docker daemon — is Docker running?")
@@ -264,13 +264,13 @@ def test_create_sentinel_propagates_http_error(mock_auth, mock_post):
264
264
 
265
265
 
266
266
  def test_resolve_env_vars_returns_correct_pair():
267
- assert resolve_env_vars(["openai"], "http://proxy") == [("OPENAI_BASE_URL", "http://proxy/openai")]
267
+ assert resolve_env_vars(["openai"], "http://proxy") == [("OPENAI_BASE_URL", "http://proxy/openai/v1")]
268
268
 
269
269
 
270
270
  def test_resolve_env_vars_handles_multiple_providers():
271
271
  pairs = resolve_env_vars(["openai", "anthropic"], "http://proxy")
272
272
 
273
- assert ("OPENAI_BASE_URL", "http://proxy/openai") in pairs
273
+ assert ("OPENAI_BASE_URL", "http://proxy/openai/v1") in pairs
274
274
  assert ("ANTHROPIC_BASE_URL", "http://proxy/anthropic") in pairs
275
275
 
276
276
 
@@ -11,6 +11,8 @@ from superwise_sentinel_cli.docker_manager import CONTAINER_PORT
11
11
  from superwise_sentinel_cli.docker_manager import ensure_container_running
12
12
  from superwise_sentinel_cli.docker_manager import get_running_gateway_info
13
13
  from superwise_sentinel_cli.docker_manager import stop_container
14
+ from superwise_sentinel_cli.exceptions import DockerError
15
+ from superwise_sentinel_cli.exceptions import DockerUnavailableError
14
16
 
15
17
 
16
18
  @patch("superwise_sentinel_cli.docker_manager.docker.from_env")
@@ -25,12 +27,12 @@ def test_docker_client_returns_client_when_docker_running(mock_from_env):
25
27
 
26
28
 
27
29
  @patch("superwise_sentinel_cli.docker_manager.docker.from_env", side_effect=docker.errors.DockerException("down"))
28
- def test_docker_client_raises_runtime_error_when_docker_unavailable(mock_from_env):
29
- with pytest.raises(RuntimeError, match="Cannot connect to Docker daemon"):
30
+ def test_docker_client_raises_docker_error_when_docker_unavailable(mock_from_env):
31
+ with pytest.raises(DockerError, match="Cannot connect to Docker daemon"):
30
32
  _docker_client()
31
33
 
32
34
 
33
- @patch("superwise_sentinel_cli.docker_manager._docker_client", side_effect=RuntimeError("no docker"))
35
+ @patch("superwise_sentinel_cli.docker_manager._docker_client", side_effect=DockerUnavailableError())
34
36
  def test_get_running_gateway_info_returns_none_when_docker_unavailable(mock_client):
35
37
  assert get_running_gateway_info() is None
36
38
 
@@ -90,7 +92,7 @@ def test_stop_container_noop_when_no_containers(mock_client):
90
92
  stop_container()
91
93
 
92
94
 
93
- @patch("superwise_sentinel_cli.docker_manager._docker_client", side_effect=RuntimeError("daemon down"))
95
+ @patch("superwise_sentinel_cli.docker_manager._docker_client", side_effect=DockerUnavailableError())
94
96
  def test_stop_container_raises_click_exception_when_docker_unavailable(mock_client):
95
97
  with pytest.raises(click.ClickException):
96
98
  stop_container()
@@ -202,7 +204,7 @@ def test_ensure_container_running_raises_on_api_error(mock_client, mock_running,
202
204
  client.containers.list.return_value = []
203
205
  client.containers.run.side_effect = docker.errors.APIError("start failed")
204
206
 
205
- with pytest.raises(RuntimeError, match="Failed to start sentinel container"):
207
+ with pytest.raises(DockerError, match="Failed to start sentinel container"):
206
208
  ensure_container_running()
207
209
 
208
210
 
@@ -243,7 +245,7 @@ def test_pull_image_if_needed_raises_when_amd64_fallback_fails(mock_client, mock
243
245
  MagicMock(returncode=1, stderr="amd64 also failed"),
244
246
  ]
245
247
 
246
- with pytest.raises(RuntimeError, match="Failed to pull image"):
248
+ with pytest.raises(DockerError, match="Failed to pull image"):
247
249
  _pull_image_if_needed()
248
250
 
249
251
 
@@ -253,5 +255,5 @@ def test_pull_image_if_needed_raises_on_non_manifest_pull_error(mock_client, moc
253
255
  mock_client.return_value.images.get.side_effect = docker.errors.ImageNotFound("not found")
254
256
  mock_pull.return_value = MagicMock(returncode=1, stderr="network timeout")
255
257
 
256
- with pytest.raises(RuntimeError, match="Failed to pull image"):
258
+ with pytest.raises(DockerError, match="Failed to pull image"):
257
259
  _pull_image_if_needed()
@@ -1 +0,0 @@
1
- RELEASED_VERSION=0.2.0
@@ -1,12 +0,0 @@
1
- import sys
2
-
3
- import click
4
-
5
-
6
- class BaseCLIException(click.ClickException):
7
- def show(self, file=None):
8
- click.secho(f"Error: {self.message}", fg="red", file=file or sys.stderr)
9
-
10
-
11
- class AuthenticationError(BaseCLIException):
12
- pass