superwise-sentinel-cli 0.2.3__tar.gz → 0.3.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.3 → superwise_sentinel_cli-0.3.1}/CHANGELOG.md +19 -0
  2. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/PKG-INFO +2 -1
  3. superwise_sentinel_cli-0.3.1/build.env +1 -0
  4. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/poetry.lock +14 -2
  5. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/pyproject.toml +2 -1
  6. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/src/superwise_sentinel_cli/cli.py +145 -17
  7. superwise_sentinel_cli-0.3.1/src/superwise_sentinel_cli/codex_profile.py +85 -0
  8. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/src/superwise_sentinel_cli/config.py +4 -0
  9. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/src/superwise_sentinel_cli/controller.py +52 -2
  10. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/src/superwise_sentinel_cli/docker_manager.py +8 -0
  11. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/src/superwise_sentinel_cli/exceptions.py +4 -0
  12. superwise_sentinel_cli-0.2.3/build.env +0 -1
  13. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/.gitignore +0 -0
  14. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/.gitlab-ci.yml +0 -0
  15. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/.pre-commit-config.yaml +0 -0
  16. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/.releaserc.json +0 -0
  17. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/CLAUDE.md +0 -0
  18. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/README.md +0 -0
  19. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/poetry.toml +0 -0
  20. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/scripts/semantic-release-cli-publish.sh +0 -0
  21. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/src/superwise_sentinel_cli/__init__.py +0 -0
  22. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/src/superwise_sentinel_cli/auth.py +0 -0
  23. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/src/superwise_sentinel_cli/shell_profile.py +0 -0
  24. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/tests/__init__.py +0 -0
  25. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/tests/test_auth.py +0 -0
  26. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/tests/test_controller.py +0 -0
  27. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/tests/test_docker_manager.py +0 -0
  28. {superwise_sentinel_cli-0.2.3 → superwise_sentinel_cli-0.3.1}/tests/test_shell_profile.py +0 -0
@@ -1,3 +1,22 @@
1
+ ## [0.3.0](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/compare/0.2.3...0.3.0) (2026-09-16)
2
+
3
+
4
+ ### <!-- 1. -->:rocket: New Features
5
+
6
+ * configure codex ([16acd79](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/commit/16acd797fa82669ec3d195fbee8c50e1b4ab0055))
7
+
8
+
9
+ ### <!-- 2. -->:bug: Bug Fixes
10
+
11
+ * configure sentinel-cli request timeout ([a2597b7](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/commit/a2597b742136bb5b05baa046e2a13340b75577cc))
12
+
13
+ ## [0.2.3](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/compare/0.2.2...0.2.3) (2026-08-25)
14
+
15
+
16
+ ### <!-- 2. -->:bug: Bug Fixes
17
+
18
+ * Windows PowerShell profile ([ac70dd7](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/commit/ac70dd79a40fe3509bea4d546e1516df59c46012))
19
+
1
20
  ## [0.2.2](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/compare/0.2.1...0.2.2) (2026-08-10)
2
21
 
3
22
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: superwise-sentinel-cli
3
- Version: 0.2.3
3
+ Version: 0.3.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
@@ -8,3 +8,4 @@ Requires-Dist: docker>=7.0
8
8
  Requires-Dist: pydantic-settings>=2.3
9
9
  Requires-Dist: pydantic>=2.0
10
10
  Requires-Dist: requests>=2.28
11
+ Requires-Dist: tomlkit>=0.12
@@ -0,0 +1 @@
1
+ RELEASED_VERSION=0.3.1
@@ -1,4 +1,4 @@
1
- # This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
1
+ # This file is automatically @generated by Poetry 2.4.2 and should not be changed by hand.
2
2
 
3
3
  [[package]]
4
4
  name = "annotated-types"
@@ -658,6 +658,18 @@ urllib3 = ">=1.26,<3"
658
658
  socks = ["PySocks (>=1.5.6,!=1.5.7)"]
659
659
  use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
660
660
 
661
+ [[package]]
662
+ name = "tomlkit"
663
+ version = "0.15.1"
664
+ description = "Style preserving TOML library"
665
+ optional = false
666
+ python-versions = ">=3.9"
667
+ groups = ["main"]
668
+ files = [
669
+ {file = "tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304"},
670
+ {file = "tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97"},
671
+ ]
672
+
661
673
  [[package]]
662
674
  name = "typing-extensions"
663
675
  version = "4.15.0"
@@ -706,4 +718,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
706
718
  [metadata]
707
719
  lock-version = "2.1"
708
720
  python-versions = ">=3.11, <4.0"
709
- content-hash = "b2291011b34a3b81b2f9f32bc8d7fcc77aeeff74dc5ffd66b6bc16161dc97663"
721
+ content-hash = "751107b15b6abe0656b8c15addf5e196359a9362417eefe9915131cfb47c60b2"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "superwise-sentinel-cli"
7
- version = "0.2.3"
7
+ version = "0.3.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 = [
@@ -13,6 +13,7 @@ dependencies = [
13
13
  "pydantic>=2.0",
14
14
  "pydantic-settings>=2.3",
15
15
  "requests>=2.28",
16
+ "tomlkit>=0.12",
16
17
  ]
17
18
 
18
19
  [project.scripts]
@@ -11,22 +11,31 @@ from superwise_sentinel_cli.auth import device_authorize
11
11
  from superwise_sentinel_cli.auth import device_poll
12
12
  from superwise_sentinel_cli.config import AppConfig
13
13
  from superwise_sentinel_cli.config import CustomProvider
14
+ from superwise_sentinel_cli.config import VALID_APPS
14
15
  from superwise_sentinel_cli.config import VALID_PROVIDERS
16
+ from superwise_sentinel_cli.controller import check_gateway_health
17
+ from superwise_sentinel_cli.controller import check_invalid_apps
15
18
  from superwise_sentinel_cli.controller import check_invalid_providers
16
19
  from superwise_sentinel_cli.controller import clear_env_vars
17
20
  from superwise_sentinel_cli.controller import get_activation_url
18
21
  from superwise_sentinel_cli.controller import get_proxy_status
19
22
  from superwise_sentinel_cli.controller import get_running_gateway
20
23
  from superwise_sentinel_cli.controller import get_sentinel_url
24
+ from superwise_sentinel_cli.controller import is_docker_running
21
25
  from superwise_sentinel_cli.controller import needs_auth
26
+ from superwise_sentinel_cli.controller import parse_apps
22
27
  from superwise_sentinel_cli.controller import parse_providers
23
28
  from superwise_sentinel_cli.controller import provision_gateway
29
+ from superwise_sentinel_cli.controller import remove_codex_config
30
+ from superwise_sentinel_cli.controller import save_apps
24
31
  from superwise_sentinel_cli.controller import save_gateway_url
25
32
  from superwise_sentinel_cli.controller import save_providers
33
+ from superwise_sentinel_cli.controller import save_timeout
26
34
  from superwise_sentinel_cli.controller import stop_gateway_container
27
35
  from superwise_sentinel_cli.controller import update_client_id_and_secret
28
36
  from superwise_sentinel_cli.controller import update_env_vars
29
37
  from superwise_sentinel_cli.controller import update_gateway_image
38
+ from superwise_sentinel_cli.controller import write_codex_config
30
39
  from superwise_sentinel_cli.exceptions import AuthenticationError
31
40
  from superwise_sentinel_cli.exceptions import BaseCLIException
32
41
 
@@ -65,6 +74,20 @@ def _apply_providers(cfg: AppConfig, spec: str) -> None:
65
74
  save_providers(cfg, providers)
66
75
 
67
76
 
77
+ def _apply_apps(cfg: AppConfig, spec: str) -> None:
78
+ apps = parse_apps(spec.split())
79
+ invalid = check_invalid_apps(apps)
80
+ if invalid:
81
+ raise click.ClickException(f"Unknown app(s): {', '.join(invalid)}. Valid choices: {', '.join(VALID_APPS)}")
82
+ save_apps(cfg, apps)
83
+
84
+
85
+ def _apply_timeout(cfg: AppConfig, timeout_seconds: int) -> None:
86
+ if timeout_seconds <= 0:
87
+ raise click.ClickException(f"Timeout must be a positive number of seconds, got: {timeout_seconds}")
88
+ save_timeout(cfg, timeout_seconds)
89
+
90
+
68
91
  @click.group()
69
92
  @click.version_option(prog_name="superwise-sentinel-cli", help="Show the version and exit.")
70
93
  def main() -> None:
@@ -84,11 +107,22 @@ def main() -> None:
84
107
  @click.pass_context
85
108
  def quickstart_flow(ctx, new_sentinel: bool) -> None:
86
109
  """Configure your first sentinel proxy."""
87
- if new_sentinel or not click.confirm("Do you want to use an existing gateway?"):
110
+ click.echo(
111
+ """Welcome to SUPERWISE Sentinel!
112
+ The Sentinel proxy routes your local LLM traffic through a secure gateway, enabling guardrails and optimizations.
113
+ Select your gateway setup:
114
+ A) Provide the URL for an existing gateway.
115
+ B) Create a new gateway locally. Docker needs to be installed and running on this machine."""
116
+ )
117
+ if new_sentinel or not click.confirm("Do you have a URL for an existing gateway?"):
88
118
  ctx.invoke(start_gateway, new_sentinel=new_sentinel)
89
119
  else:
90
120
  ctx.invoke(config_url)
121
+ gateway_text = click.style("gateway", fg="green")
122
+ proxy_text = click.style("proxy", fg="yellow")
123
+ click.echo(f"\nWith the {gateway_text} ready, let's customize our {proxy_text} traffic routing.")
91
124
  ctx.invoke(config_providers)
125
+ ctx.invoke(config_apps)
92
126
  ctx.invoke(start_proxy)
93
127
  click.echo(
94
128
  "Sentinel proxy is ready to intercept traffic! Any new session you open will be protected by the Sentinel"
@@ -172,11 +206,25 @@ def gateway() -> None:
172
206
  @click.option(
173
207
  "--force", is_flag=True, default=False, help="If a local gateway is already running, stop it and start a new one."
174
208
  )
209
+ @click.option(
210
+ "--timeout",
211
+ "timeout_seconds",
212
+ type=int,
213
+ default=None,
214
+ help="Set the upstream request timeout in seconds for this gateway.",
215
+ )
175
216
  @click.pass_context
176
217
  def start_gateway(
177
- ctx, sentinel_id: uuid.UUID | None, new_sentinel: bool, custom_providers: str | None, force: bool
218
+ ctx,
219
+ sentinel_id: uuid.UUID | None,
220
+ new_sentinel: bool,
221
+ custom_providers: str | None,
222
+ force: bool,
223
+ timeout_seconds: int | None,
178
224
  ) -> None:
179
- """Bootstrap & run a local Sentinel gateway container."""
225
+ """Bootstrap & run a local Sentinel gateway container. Accessible only from this machine."""
226
+ if not is_docker_running():
227
+ raise click.ClickException("Docker is not responsive. Is it running?")
180
228
  cfg = AppConfig.load()
181
229
 
182
230
  if custom_providers:
@@ -185,6 +233,9 @@ def start_gateway(
185
233
  except Exception as exc:
186
234
  raise click.ClickException(f"Invalid --custom-providers: {exc}") from exc
187
235
 
236
+ if timeout_seconds is not None:
237
+ _apply_timeout(cfg, timeout_seconds)
238
+
188
239
  if force:
189
240
  stop_gateway_container()
190
241
  else:
@@ -222,11 +273,16 @@ def start_gateway(
222
273
 
223
274
  if created_name:
224
275
  click.echo(f"New sentinel '{created_name}' created. Sentinel ID: {cfg.sentinel_id}.")
276
+ local_notice = click.style("This gateway is local, and accessible only from this machine.", fg="cyan")
225
277
  click.echo(
226
- f"Gateway running on {url} with sentinel ID: {cfg.sentinel_id}. Configuration updated to match."
278
+ f"Gateway created and running on {url}!"
227
279
  f"\nTo view Sentinel configuration and metrics, please visit {get_sentinel_url(cfg)}"
280
+ f"\n{local_notice}"
228
281
  )
229
- click.echo("Want to test it out? Run 'sentinel test --text \"Your text here\"' to see it in action.")
282
+ if timeout_seconds is not None:
283
+ click.echo(f"Timeout set to {timeout_seconds} seconds.")
284
+ test_command = click.style('sentinel test --text "Your text here"', italic=True)
285
+ click.echo(f"\nWant to test it out? Run '{test_command}' to see it in action.")
230
286
 
231
287
 
232
288
  @gateway.command("stop")
@@ -257,7 +313,12 @@ def update_gateway() -> None:
257
313
  click.echo("Image is up to date.")
258
314
 
259
315
 
260
- @main.group("config", invoke_without_command=True)
316
+ @main.group()
317
+ def proxy() -> None:
318
+ """Toggle traffic interception."""
319
+
320
+
321
+ @proxy.group("config", invoke_without_command=True)
261
322
  @click.option("--url", "url_value", default=None, help="Set the gateway URL non-interactively.")
262
323
  @click.option(
263
324
  "--providers",
@@ -265,16 +326,23 @@ def update_gateway() -> None:
265
326
  default=None,
266
327
  help="Set providers non-interactively (space-separated, e.g. \"openai anthropic\", or 'all').",
267
328
  )
329
+ @click.option(
330
+ "--apps",
331
+ "apps_value",
332
+ default=None,
333
+ help='Set which apps Sentinel auto-configures, non-interactively (space-separated, e.g. "codex"; '
334
+ '"" disables auto-configuration).',
335
+ )
268
336
  @click.pass_context
269
- def config(ctx, url_value: str | None, providers_value: str | None) -> None:
270
- """Configure gateway URL and LLM providers.
337
+ def config(ctx, url_value: str | None, providers_value: str | None, apps_value: str | None) -> None:
338
+ """Configure gateway URL, LLM providers, and auto-configured apps.
271
339
 
272
- Run with --url/--providers for a non-interactive one-liner, e.g.
273
- 'sentinel config --url https://gateway.example.com --providers "openai anthropic"'.
340
+ Run with --url/--providers/--apps for a non-interactive one-liner, e.g.
341
+ 'sentinel config --url https://gateway.example.com --providers "openai anthropic" --apps codex'.
274
342
  """
275
343
  if ctx.invoked_subcommand is not None:
276
344
  return
277
- if url_value is None and providers_value is None:
345
+ if url_value is None and providers_value is None and apps_value is None:
278
346
  click.echo(ctx.get_help())
279
347
  return
280
348
  cfg = AppConfig.load()
@@ -282,6 +350,8 @@ def config(ctx, url_value: str | None, providers_value: str | None) -> None:
282
350
  _apply_url(cfg, url_value)
283
351
  if providers_value is not None:
284
352
  _apply_providers(cfg, providers_value)
353
+ if apps_value is not None:
354
+ _apply_apps(cfg, apps_value)
285
355
  click.echo(f"Configuration updated.")
286
356
 
287
357
 
@@ -327,9 +397,41 @@ def config_providers(set_value: str | None) -> None:
327
397
  save_providers(cfg, providers)
328
398
 
329
399
 
330
- @main.group()
331
- def proxy() -> None:
332
- """Toggle traffic interception."""
400
+ @config.command("apps")
401
+ @click.option(
402
+ "--set",
403
+ "set_value",
404
+ default=None,
405
+ help='Set which apps Sentinel auto-configures, non-interactively (space-separated, e.g. "codex"; '
406
+ '"" disables auto-configuration).',
407
+ )
408
+ def config_apps(set_value: str | None) -> None:
409
+ """Set which apps Sentinel auto-configures to use the gateway.
410
+
411
+ Supported apps: codex
412
+
413
+ Some applications do not rely on env vars for sentinel configuration,
414
+ Auto-configuring an app means Sentinel edits that app's own config file to point it at the gateway,
415
+ instead of relying only on env vars. Use 'all' to select every supported app, or pass an empty
416
+ value ("") to disable auto-configuration entirely and manage each app's config yourself.
417
+ """
418
+ cfg = AppConfig.load()
419
+ if set_value is not None:
420
+ _apply_apps(cfg, set_value)
421
+ click.echo(f"Apps to auto-configure updated: {', '.join(cfg.apps) or '(none, auto-configuration disabled)'}")
422
+ return
423
+ while True:
424
+ raw = click.prompt(
425
+ "Choose the apps you want Sentinel to auto-configure (space separated, 'all', or empty to disable)",
426
+ default=" ".join(cfg.apps),
427
+ show_default=True,
428
+ )
429
+ try:
430
+ _apply_apps(cfg, raw)
431
+ except click.ClickException as exc:
432
+ echo_error(str(exc))
433
+ continue
434
+ break
333
435
 
334
436
 
335
437
  @proxy.command("on")
@@ -344,11 +446,34 @@ def start_proxy(ctx) -> None:
344
446
  if not cfg.providers:
345
447
  ctx.invoke(config_providers)
346
448
 
347
- pairs = update_env_vars(AppConfig.load())
449
+ if not check_gateway_health(cfg.gateway_url):
450
+ click.echo(
451
+ f"Gateway at {cfg.gateway_url} did not respond in a timely manner."
452
+ f" Please verify it is healthy and try again."
453
+ )
454
+ return
455
+
456
+ cfg = AppConfig.load()
457
+ pairs = update_env_vars(cfg)
348
458
 
349
459
  for var, u in pairs:
350
460
  click.echo(f" {var}={u}", err=True)
351
461
 
462
+ if write_codex_config(cfg):
463
+ click.echo(" Codex CLI config (~/.codex/config.toml) updated to route through the gateway.", err=True)
464
+ elif "openai" not in cfg.providers:
465
+ click.echo(
466
+ " Codex CLI config unchanged: 'openai' isn't in your configured providers. "
467
+ "Run 'sentinel config providers --set \"openai ...\"' to include it.",
468
+ err=True,
469
+ )
470
+ else:
471
+ click.echo(
472
+ " Codex CLI config unchanged: 'codex' isn't in your configured apps. "
473
+ "Run 'sentinel config apps --set codex' to enable auto-configuration.",
474
+ err=True,
475
+ )
476
+
352
477
 
353
478
  @proxy.command("off")
354
479
  def stop_proxy() -> None:
@@ -357,16 +482,19 @@ def stop_proxy() -> None:
357
482
  if removed:
358
483
  click.echo(f"Proxy off: {', '.join(removed)}", err=True)
359
484
 
485
+ if remove_codex_config():
486
+ click.echo(" Codex CLI config (~/.codex/config.toml) reverted.", err=True)
487
+
360
488
 
361
489
  @proxy.command("status")
362
490
  def proxy_status() -> None:
363
491
  """Show whether traffic interception is currently active."""
364
- providers, stale = get_proxy_status()
492
+ providers, apps, stale = get_proxy_status()
365
493
  if not providers:
366
494
  click.echo("Proxy is off.")
367
495
  return
368
496
 
369
- click.echo(f"Proxy is on, covering: {', '.join(providers)}")
497
+ click.echo(f"Proxy is on,\n covering providers: {', '.join(providers)}, \n covering apps: {', '.join(apps)}")
370
498
 
371
499
  if stale:
372
500
  click.echo(
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import tomlkit
7
+ from superwise_sentinel_cli.exceptions import CodexConfigError
8
+ from tomlkit.exceptions import TOMLKitError
9
+
10
+ _SENTINEL_PROVIDER_NAME = "sentinel"
11
+
12
+
13
+ def _config_path() -> Path:
14
+ codex_home = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
15
+ return codex_home / "config.toml"
16
+
17
+
18
+ def _load_doc(path: Path) -> tomlkit.TOMLDocument:
19
+ if not path.exists():
20
+ return tomlkit.document()
21
+ try:
22
+ text = path.read_text()
23
+ except OSError as exc:
24
+ raise CodexConfigError(f"Could not read Codex config at {path}: {exc}") from exc
25
+ try:
26
+ return tomlkit.parse(text)
27
+ except TOMLKitError as exc:
28
+ raise CodexConfigError(f"Could not parse Codex config at {path}: {exc}") from exc
29
+
30
+
31
+ def _save_doc(path: Path, doc: tomlkit.TOMLDocument) -> None:
32
+ try:
33
+ path.write_text(tomlkit.dumps(doc))
34
+ except OSError as exc:
35
+ raise CodexConfigError(f"Could not write Codex config at {path}: {exc}") from exc
36
+
37
+
38
+ def write_codex_config(base_url: str) -> None:
39
+ path = _config_path()
40
+ try:
41
+ path.parent.mkdir(parents=True, exist_ok=True)
42
+ except OSError as exc:
43
+ raise CodexConfigError(f"Could not create Codex config directory {path.parent}: {exc}") from exc
44
+ doc = _load_doc(path)
45
+
46
+ model_providers = doc.setdefault("model_providers", tomlkit.table())
47
+ model_providers[_SENTINEL_PROVIDER_NAME] = {
48
+ "name": _SENTINEL_PROVIDER_NAME,
49
+ "base_url": f"{base_url.rstrip('/')}/openai/v1",
50
+ "wire_api": "responses",
51
+ "requires_openai_auth": True,
52
+ "supports_websockets": False,
53
+ }
54
+ doc["model_provider"] = _SENTINEL_PROVIDER_NAME
55
+
56
+ _save_doc(path, doc)
57
+
58
+
59
+ def is_codex_configured() -> bool:
60
+ path = _config_path()
61
+ if not path.exists():
62
+ return False
63
+ doc = _load_doc(path)
64
+ return doc.get("model_provider") == _SENTINEL_PROVIDER_NAME
65
+
66
+
67
+ def remove_codex_config() -> bool:
68
+ path = _config_path()
69
+ if not path.exists():
70
+ return False
71
+ doc = _load_doc(path)
72
+ changed = False
73
+
74
+ model_providers = doc.get("model_providers")
75
+ if model_providers is not None and _SENTINEL_PROVIDER_NAME in model_providers:
76
+ del model_providers[_SENTINEL_PROVIDER_NAME]
77
+ changed = True
78
+
79
+ if doc.get("model_provider") == _SENTINEL_PROVIDER_NAME:
80
+ del doc["model_provider"]
81
+ changed = True
82
+
83
+ if changed:
84
+ _save_doc(path, doc)
85
+ return changed
@@ -37,6 +37,8 @@ _PROVIDER_CONFIG: dict[str, tuple[str, str]] = {
37
37
 
38
38
  VALID_PROVIDERS = list(_PROVIDER_CONFIG.keys())
39
39
 
40
+ VALID_APPS = ["codex"]
41
+
40
42
 
41
43
  class CustomProvider(BaseModel):
42
44
  provider_name: str
@@ -111,9 +113,11 @@ class AppConfig(BaseSettings):
111
113
  superwise_client_secret: str | None = None
112
114
  sentinel_id: str | None = None
113
115
  providers: list[str] = VALID_PROVIDERS
116
+ apps: list[str] = VALID_APPS
114
117
  sw_api_host: str = "https://api.superwise.ai"
115
118
  sw_auth_host: str = "https://authentication.superwise.ai"
116
119
  custom_providers: list[CustomProvider] = Field(default_factory=list)
120
+ upstream_timeout_seconds: int = 1800
117
121
 
118
122
  FRONTEGG_APP_CLIENT_ID: str = "c9f24fc8-051d-4ba4-84df-15cf7298435d"
119
123
  CONTAINER_IMAGE: str = "us-central1-docker.pkg.dev/admina33d6818/sentinel-public/sentinel"
@@ -2,14 +2,20 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ import time
5
6
 
6
7
  import requests
7
8
  from superwise_sentinel_cli.auth import authenticate
9
+ from superwise_sentinel_cli.codex_profile import is_codex_configured
10
+ from superwise_sentinel_cli.codex_profile import remove_codex_config
11
+ from superwise_sentinel_cli.codex_profile import write_codex_config as _write_codex_config
8
12
  from superwise_sentinel_cli.config import _PROVIDER_CONFIG
9
13
  from superwise_sentinel_cli.config import AppConfig
14
+ from superwise_sentinel_cli.config import VALID_APPS
10
15
  from superwise_sentinel_cli.config import VALID_PROVIDERS
11
16
  from superwise_sentinel_cli.docker_manager import ensure_container_running
12
17
  from superwise_sentinel_cli.docker_manager import get_running_gateway_info
18
+ from superwise_sentinel_cli.docker_manager import is_docker_running
13
19
  from superwise_sentinel_cli.docker_manager import pull_latest_image
14
20
  from superwise_sentinel_cli.docker_manager import stop_container
15
21
  from superwise_sentinel_cli.exceptions import AuthenticationError
@@ -27,12 +33,22 @@ def check_invalid_providers(providers: list[str]) -> list[str]:
27
33
  return [p for p in providers if p not in VALID_PROVIDERS]
28
34
 
29
35
 
36
+ def check_invalid_apps(apps: list[str]) -> list[str]:
37
+ return [a for a in apps if a not in VALID_APPS]
38
+
39
+
30
40
  def parse_providers(providers: list[str]) -> list[str]:
31
41
  if providers == ["all"]:
32
42
  return list(VALID_PROVIDERS)
33
43
  return providers
34
44
 
35
45
 
46
+ def parse_apps(apps: list[str]) -> list[str]:
47
+ if apps == ["all"]:
48
+ return list(VALID_APPS)
49
+ return apps
50
+
51
+
36
52
  def needs_auth(cfg: AppConfig) -> bool:
37
53
  return not cfg.superwise_client_secret or not cfg.superwise_client_id
38
54
 
@@ -86,6 +102,7 @@ def get_or_create_local_endpoint(cfg: AppConfig) -> str:
86
102
  env["SW_AUTH_HOST"] = cfg.sw_auth_host
87
103
  if cfg.custom_providers:
88
104
  env["CUSTOM_PROVIDERS"] = json.dumps([p.model_dump() for p in cfg.custom_providers])
105
+ env["UPSTREAM_TIMEOUT_SECONDS"] = str(cfg.upstream_timeout_seconds)
89
106
  return ensure_container_running(environment=env).base_url
90
107
 
91
108
 
@@ -116,6 +133,16 @@ def save_providers(cfg: AppConfig, providers: list[str]) -> None:
116
133
  cfg.save()
117
134
 
118
135
 
136
+ def save_apps(cfg: AppConfig, apps: list[str]) -> None:
137
+ cfg.apps = apps
138
+ cfg.save()
139
+
140
+
141
+ def save_timeout(cfg: AppConfig, timeout_seconds: int) -> None:
142
+ cfg.upstream_timeout_seconds = timeout_seconds
143
+ cfg.save()
144
+
145
+
119
146
  def get_running_gateway() -> tuple[str, str | None] | None:
120
147
  info = get_running_gateway_info()
121
148
  if info is None:
@@ -131,14 +158,37 @@ def update_env_vars(cfg: AppConfig) -> list[tuple[str, str]]:
131
158
  return pairs
132
159
 
133
160
 
161
+ def check_gateway_health(gateway_url: str, retries: int = 3, delay_seconds: float = 1.0) -> bool:
162
+ url = gateway_url.rstrip("/") + "/healthz"
163
+ for attempt in range(retries):
164
+ try:
165
+ resp = requests.get(url, timeout=5)
166
+ if resp.ok:
167
+ return True
168
+ except requests.RequestException:
169
+ pass
170
+ if attempt < retries - 1:
171
+ time.sleep(delay_seconds)
172
+ return False
173
+
174
+
175
+ def write_codex_config(cfg: AppConfig) -> bool:
176
+ if "openai" not in cfg.providers or "codex" not in cfg.apps:
177
+ return False
178
+ _write_codex_config(cfg.gateway_url)
179
+ return True
180
+
181
+
134
182
  _ENV_VAR_TO_PROVIDER = {env_var: provider for provider, (env_var, _) in _PROVIDER_CONFIG.items()}
183
+ _APP_CONFIGURED_CHECKS = {"codex": is_codex_configured}
135
184
 
136
185
 
137
- def get_proxy_status() -> tuple[list[str], bool]:
186
+ def get_proxy_status() -> tuple[list[str], list[str], bool]:
138
187
  pairs = read_env_vars()
139
188
  providers = [_ENV_VAR_TO_PROVIDER.get(var, var) for var, _ in pairs]
140
189
  stale = any(os.environ.get(var) != url for var, url in pairs)
141
- return providers, stale
190
+ apps = [app for app, is_configured in _APP_CONFIGURED_CHECKS.items() if is_configured()]
191
+ return providers, apps, stale
142
192
 
143
193
 
144
194
  def get_sentinel_url(cfg: AppConfig) -> str:
@@ -35,6 +35,14 @@ def _docker_client() -> docker.DockerClient:
35
35
  raise DockerUnavailableError() from exc
36
36
 
37
37
 
38
+ def is_docker_running() -> bool:
39
+ try:
40
+ _docker_client()
41
+ return True
42
+ except DockerUnavailableError:
43
+ return False
44
+
45
+
38
46
  def _running_container(client: docker.DockerClient) -> Container | None:
39
47
  matches = client.containers.list(filters={"name": CONTAINER_NAME, "status": "running"})
40
48
  return matches[0] if matches else None
@@ -19,3 +19,7 @@ class DockerError(BaseCLIException):
19
19
  class DockerUnavailableError(DockerError):
20
20
  def __init__(self) -> None:
21
21
  super().__init__("Cannot connect to Docker daemon — is Docker running?")
22
+
23
+
24
+ class CodexConfigError(BaseCLIException):
25
+ """Raised when the Codex config file cannot be read, parsed, or written."""
@@ -1 +0,0 @@
1
- RELEASED_VERSION=0.2.3