kctl-litellm 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """Kodemeio LiteLLM CLI."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m kctl_litellm."""
2
+
3
+ from kctl_litellm.cli import _run
4
+
5
+ _run()
kctl_litellm/cli.py ADDED
@@ -0,0 +1,126 @@
1
+ """Main CLI entry point for kctl-litellm."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from kctl_lib import handle_cli_error
9
+
10
+ from kctl_litellm import __version__
11
+ from kctl_litellm.commands.budgets_cmd import app as budgets_app
12
+ from kctl_litellm.commands.config_cmd import app as config_app
13
+ from kctl_litellm.commands.health_cmd import app as health_app
14
+ from kctl_litellm.commands.keys_cmd import app as keys_app
15
+ from kctl_litellm.commands.logs_cmd import app as logs_app
16
+ from kctl_litellm.commands.models_cmd import app as models_app
17
+ from kctl_litellm.commands.spend_cmd import app as spend_app
18
+ from kctl_litellm.commands.teams_cmd import app as teams_app
19
+ from kctl_litellm.core.callbacks import AppContext
20
+ from kctl_litellm.core.exceptions import LiteLLMError
21
+ from kctl_lib.self_update import notify_if_outdated
22
+
23
+
24
+ def version_callback(value: bool) -> None:
25
+ if value:
26
+ typer.echo(f"kctl-litellm {__version__}")
27
+ raise typer.Exit()
28
+
29
+
30
+ app = typer.Typer(
31
+ name="kctl-litellm",
32
+ help="Kodemeio LiteLLM CLI - manage LiteLLM proxy instances.",
33
+ no_args_is_help=True,
34
+ rich_markup_mode="rich",
35
+ pretty_exceptions_enable=False,
36
+ )
37
+
38
+
39
+ @app.callback()
40
+ def main(
41
+ ctx: typer.Context,
42
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
43
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
44
+ profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
45
+ url: Annotated[str | None, typer.Option("--url", help="LiteLLM URL override")] = None,
46
+ version: Annotated[
47
+ bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
48
+ ] = False,
49
+ ) -> None:
50
+ """Kodemeio LiteLLM CLI."""
51
+ ctx.ensure_object(dict)
52
+ ctx.obj = AppContext(
53
+ json_mode=json_output,
54
+ quiet=quiet,
55
+ profile=profile,
56
+ url_override=url,
57
+ )
58
+ notify_if_outdated(ctx.obj.output, "kctl-litellm", __version__)
59
+
60
+
61
+ _P_ADMIN = "Admin & Config"
62
+ app.add_typer(config_app, name="config", rich_help_panel=_P_ADMIN)
63
+
64
+ _P_SERVICES = "Services"
65
+ app.add_typer(health_app, name="health", rich_help_panel=_P_SERVICES)
66
+ app.add_typer(models_app, name="models", rich_help_panel=_P_SERVICES)
67
+
68
+ _P_KEYS = "Key Management"
69
+ app.add_typer(keys_app, name="keys", rich_help_panel=_P_KEYS)
70
+
71
+ _P_TEAMS = "Teams & Budgets"
72
+ app.add_typer(teams_app, name="teams", rich_help_panel=_P_TEAMS)
73
+ app.add_typer(budgets_app, name="budgets", rich_help_panel=_P_TEAMS)
74
+
75
+ _P_USAGE = "Usage & Spend"
76
+ app.add_typer(spend_app, name="spend", rich_help_panel=_P_USAGE)
77
+ app.add_typer(logs_app, name="logs", rich_help_panel=_P_USAGE)
78
+
79
+
80
+ @app.command("self-update")
81
+ def self_update_cmd(ctx: typer.Context) -> None:
82
+ """Check for updates and upgrade kctl-litellm."""
83
+ actx = ctx.obj
84
+ out = actx.output
85
+ from kctl_lib.self_update import check_update
86
+ from kctl_lib.self_update import update as do_update
87
+
88
+ latest = check_update("kctl-litellm", __version__)
89
+ if latest:
90
+ out.info(f"Updating to {latest}...")
91
+ do_update("kctl-litellm")
92
+ out.success(f"Updated to {latest}")
93
+ else:
94
+ out.success("Already up to date")
95
+
96
+
97
+ @app.command()
98
+ def completions(
99
+ shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
100
+ install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
101
+ ) -> None:
102
+ """Generate or install shell completions."""
103
+ from kctl_lib.completions import get_completion_script, install_completions
104
+
105
+ if install:
106
+ path = install_completions("kctl-litellm", shell)
107
+ if path:
108
+ typer.echo(f"Completions installed to {path}")
109
+ else:
110
+ typer.echo(f"Could not install completions for {shell}", err=True)
111
+ raise typer.Exit(code=1)
112
+ else:
113
+ script = get_completion_script("kctl-litellm", shell)
114
+ typer.echo(script)
115
+
116
+
117
+ def _run() -> None:
118
+ """Entry point with error handling."""
119
+ try:
120
+ app()
121
+ except LiteLLMError as e:
122
+ handle_cli_error(e)
123
+
124
+
125
+ if __name__ == "__main__":
126
+ _run()
@@ -0,0 +1 @@
1
+ """CLI command modules for kctl-litellm."""
@@ -0,0 +1,116 @@
1
+ """Budget management commands for kctl-litellm.
2
+
3
+ Create and list LiteLLM budgets.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ from rich import print as rprint
12
+ from rich.table import Table
13
+
14
+ from kctl_litellm.core.callbacks import AppContext
15
+ from kctl_litellm.core.client import LiteLLMClient
16
+ from kctl_litellm.core.exceptions import LiteLLMError
17
+
18
+ app = typer.Typer(help="Manage LiteLLM budgets.")
19
+
20
+
21
+ def _get_client(actx: AppContext) -> LiteLLMClient:
22
+ cfg = actx.config
23
+ return LiteLLMClient(base_url=cfg.url, master_key=cfg.master_key)
24
+
25
+
26
+ @app.command()
27
+ def create(
28
+ ctx: typer.Context,
29
+ budget_id: Annotated[str | None, typer.Option("--budget-id", help="Budget identifier.")] = None,
30
+ max_budget: Annotated[float, typer.Option("--max-budget", help="Maximum budget in USD.")] = 100.0,
31
+ soft_budget: Annotated[
32
+ float | None, typer.Option("--soft-budget", help="Soft budget limit (warning threshold).")
33
+ ] = None,
34
+ max_parallel_requests: Annotated[
35
+ int | None, typer.Option("--max-parallel-requests", help="Max parallel requests.")
36
+ ] = None,
37
+ tpm_limit: Annotated[int | None, typer.Option("--tpm-limit", help="Tokens per minute limit.")] = None,
38
+ rpm_limit: Annotated[int | None, typer.Option("--rpm-limit", help="Requests per minute limit.")] = None,
39
+ ) -> None:
40
+ """Create a new budget.
41
+
42
+ Example: kctl-litellm budgets create --budget-id dev-budget --max-budget 200
43
+ """
44
+ actx: AppContext = ctx.obj
45
+ out = actx.output
46
+
47
+ kwargs: dict = {"max_budget": max_budget}
48
+ if budget_id:
49
+ kwargs["budget_id"] = budget_id
50
+ if soft_budget is not None:
51
+ kwargs["soft_budget"] = soft_budget
52
+ if max_parallel_requests is not None:
53
+ kwargs["max_parallel_requests"] = max_parallel_requests
54
+ if tpm_limit is not None:
55
+ kwargs["tpm_limit"] = tpm_limit
56
+ if rpm_limit is not None:
57
+ kwargs["rpm_limit"] = rpm_limit
58
+
59
+ try:
60
+ client = _get_client(actx)
61
+ result = client.create_budget(**kwargs)
62
+ client.close()
63
+ except LiteLLMError as exc:
64
+ out.error(str(exc))
65
+ raise typer.Exit(1) from exc
66
+
67
+ out.success("Budget created")
68
+ out.kv("Budget ID", result.get("budget_id", "[dim]auto-generated[/dim]"))
69
+ out.kv("Max Budget", f"${result.get('max_budget', max_budget)}")
70
+ if result.get("soft_budget") is not None:
71
+ out.kv("Soft Budget", f"${result['soft_budget']}")
72
+ if result.get("tpm_limit"):
73
+ out.kv("TPM Limit", str(result["tpm_limit"]))
74
+ if result.get("rpm_limit"):
75
+ out.kv("RPM Limit", str(result["rpm_limit"]))
76
+
77
+
78
+ @app.command("list")
79
+ def list_(ctx: typer.Context) -> None:
80
+ """List all budgets."""
81
+ actx: AppContext = ctx.obj
82
+ out = actx.output
83
+
84
+ try:
85
+ client = _get_client(actx)
86
+ budgets = client.list_budgets()
87
+ client.close()
88
+ except LiteLLMError as exc:
89
+ out.error(str(exc))
90
+ raise typer.Exit(1) from exc
91
+
92
+ if not budgets:
93
+ out.warn("No budgets found.")
94
+ return
95
+
96
+ table = Table(title="Budgets", show_header=True, header_style="bold cyan")
97
+ table.add_column("Budget ID", style="cyan")
98
+ table.add_column("Max Budget", justify="right")
99
+ table.add_column("Soft Budget", justify="right")
100
+ table.add_column("TPM Limit", justify="right")
101
+ table.add_column("RPM Limit", justify="right")
102
+ table.add_column("Max Parallel", justify="right")
103
+
104
+ for b in budgets:
105
+ if not isinstance(b, dict):
106
+ continue
107
+ budget_id = b.get("budget_id", "unknown")
108
+ max_b = f"${b['max_budget']:.2f}" if b.get("max_budget") is not None else "[dim]-[/dim]"
109
+ soft_b = f"${b['soft_budget']:.2f}" if b.get("soft_budget") is not None else "[dim]-[/dim]"
110
+ tpm = str(b.get("tpm_limit", "")) or "[dim]-[/dim]"
111
+ rpm = str(b.get("rpm_limit", "")) or "[dim]-[/dim]"
112
+ max_par = str(b.get("max_parallel_requests", "")) or "[dim]-[/dim]"
113
+ table.add_row(budget_id, max_b, soft_b, tpm, rpm, max_par)
114
+
115
+ rprint(table)
116
+ out.info(f"Total budgets: {len(budgets)}")
@@ -0,0 +1,225 @@
1
+ """Configuration management commands for kctl-litellm.
2
+
3
+ Manage profiles and LiteLLM connection settings.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from typing import Annotated
10
+
11
+ import typer
12
+ from rich import print as rprint
13
+ from rich.table import Table
14
+
15
+ from kctl_litellm.core.callbacks import AppContext
16
+ from kctl_litellm.core.config import (
17
+ SERVICE_KEY,
18
+ ServiceConfig,
19
+ get_all_services_in_profile,
20
+ get_default_profile,
21
+ get_profile_names,
22
+ get_service_config,
23
+ remove_profile,
24
+ resolve_active_profile_name,
25
+ set_default_profile,
26
+ set_service_config,
27
+ )
28
+
29
+ app = typer.Typer(help="Manage CLI configuration and profiles.")
30
+
31
+
32
+ def _mask_secret(secret: str) -> str:
33
+ """Mask a secret, showing only last 8 chars."""
34
+ if not secret:
35
+ return "[dim]not set[/dim]"
36
+ if len(secret) <= 8:
37
+ return "****"
38
+ return f"{'*' * (len(secret) - 8)}{secret[-8:]}"
39
+
40
+
41
+ @app.command()
42
+ def profiles(ctx: typer.Context) -> None:
43
+ """List all profiles with their LiteLLM configuration."""
44
+ actx: AppContext = ctx.obj
45
+ out = actx.output
46
+
47
+ profile_names = get_profile_names()
48
+ if not profile_names:
49
+ out.warn("No profiles configured.")
50
+ return
51
+
52
+ default = get_default_profile()
53
+ active = resolve_active_profile_name(actx.profile)
54
+
55
+ table = Table(title="Profiles", show_header=True, header_style="bold cyan")
56
+ table.add_column("Name", style="cyan")
57
+ table.add_column("URL")
58
+ table.add_column("Default")
59
+
60
+ for pname in profile_names:
61
+ svc = get_service_config(pname)
62
+ is_default = pname == default
63
+ is_active = pname == active
64
+ default_marker = "[green]yes[/green]" if is_default else ""
65
+ if is_active and not is_default:
66
+ default_marker = "[yellow]active[/yellow]"
67
+ elif is_active and is_default:
68
+ default_marker = "[green]yes (active)[/green]"
69
+ table.add_row(pname, svc.url or "[dim]-[/dim]", default_marker)
70
+
71
+ rprint(table)
72
+
73
+
74
+ @app.command()
75
+ def show(
76
+ ctx: typer.Context,
77
+ name: Annotated[str | None, typer.Argument(help="Profile name (default: active profile)")] = None,
78
+ ) -> None:
79
+ """Show current profile config (secrets masked)."""
80
+ actx: AppContext = ctx.obj
81
+ out = actx.output
82
+
83
+ profile_name = name or resolve_active_profile_name(actx.profile)
84
+ svc = get_service_config(profile_name)
85
+
86
+ out.kv("Profile", profile_name)
87
+ out.kv("Service", SERVICE_KEY)
88
+ out.kv("URL", svc.url or "[dim]not set[/dim]")
89
+ out.kv("Master Key", _mask_secret(svc.master_key))
90
+ out.kv("Salt Key", _mask_secret(svc.salt_key))
91
+ out.kv("DB URL", _mask_secret(svc.db_url))
92
+ out.kv("SSH Host", svc.ssh_host or "[dim]not set[/dim]")
93
+ out.kv("SSH Port", str(svc.ssh_port))
94
+ out.kv("SSH User", svc.ssh_user)
95
+ out.kv("SSH Key", svc.ssh_key)
96
+ out.kv("Container Name", svc.container_name or "[dim]not set[/dim]")
97
+
98
+
99
+ @app.command()
100
+ def current(ctx: typer.Context) -> None:
101
+ """Show the active profile name."""
102
+ actx: AppContext = ctx.obj
103
+ out = actx.output
104
+
105
+ active = resolve_active_profile_name(actx.profile)
106
+ default = get_default_profile()
107
+
108
+ source = "config default"
109
+ if actx.profile:
110
+ source = "--profile flag"
111
+ elif os.environ.get("KCTL_LITELLM_PROFILE"):
112
+ source = "KCTL_LITELLM_PROFILE env var"
113
+
114
+ out.kv("Active Profile", active)
115
+ out.kv("Default Profile", default)
116
+ out.kv("Source", source)
117
+
118
+
119
+ @app.command()
120
+ def add(
121
+ ctx: typer.Context,
122
+ name: Annotated[str, typer.Argument(help="Profile name (e.g. production, staging)")],
123
+ url: Annotated[str | None, typer.Option("--url", help="LiteLLM proxy URL.")] = None,
124
+ master_key: Annotated[str | None, typer.Option("--master-key", help="Master key for API access.")] = None,
125
+ salt_key: Annotated[str | None, typer.Option("--salt-key", help="Salt key for hashing.")] = None,
126
+ db_url: Annotated[str | None, typer.Option("--db-url", help="Database connection URL.")] = None,
127
+ ssh_host: Annotated[str | None, typer.Option("--ssh-host", help="SSH host (public IP).")] = None,
128
+ ssh_user: Annotated[str, typer.Option("--ssh-user", help="SSH username.")] = "root",
129
+ container_name: Annotated[str | None, typer.Option("--container-name", help="Docker container name.")] = None,
130
+ set_default: Annotated[bool, typer.Option("--default", help="Set as default profile.")] = False,
131
+ ) -> None:
132
+ """Add or update a LiteLLM profile.
133
+
134
+ Example: kctl-litellm config add production --url https://litellm.kodeme.io --master-key sk-...
135
+ """
136
+ actx: AppContext = ctx.obj
137
+ out = actx.output
138
+
139
+ existing = get_service_config(name)
140
+ if existing.url:
141
+ if not typer.confirm(f"Profile '{name}' already has {SERVICE_KEY} config ({existing.url}). Overwrite?"):
142
+ raise typer.Exit(0)
143
+
144
+ svc = ServiceConfig(
145
+ url=url or "",
146
+ master_key=master_key or "",
147
+ salt_key=salt_key or "",
148
+ db_url=db_url or "",
149
+ ssh_host=ssh_host or "",
150
+ ssh_user=ssh_user,
151
+ container_name=container_name or "",
152
+ )
153
+
154
+ set_service_config(name, svc)
155
+
156
+ if set_default or len(get_profile_names()) == 1:
157
+ set_default_profile(name)
158
+
159
+ out.success(f"Profile '{name}' -> {SERVICE_KEY} configured")
160
+ out.kv("URL", svc.url or "[dim]not set[/dim]")
161
+ if svc.ssh_host:
162
+ out.kv("SSH", f"{ssh_user}@{svc.ssh_host}")
163
+ if get_default_profile() == name:
164
+ out.info("Set as default profile")
165
+
166
+
167
+ @app.command("remove")
168
+ def remove_(
169
+ ctx: typer.Context,
170
+ name: Annotated[str, typer.Argument(help="Profile name to remove")],
171
+ force: Annotated[bool, typer.Option("--force", help="Skip confirmation")] = False,
172
+ ) -> None:
173
+ """Remove a profile."""
174
+ actx: AppContext = ctx.obj
175
+ out = actx.output
176
+
177
+ profile_names = get_profile_names()
178
+ if name not in profile_names:
179
+ out.error(f"Profile '{name}' not found")
180
+ out.info(f"Available: {', '.join(profile_names)}")
181
+ raise typer.Exit(1)
182
+
183
+ if not force:
184
+ services = get_all_services_in_profile(name)
185
+ svc_list = ", ".join(services.keys()) if services else "empty"
186
+ if not typer.confirm(f"Remove entire profile '{name}' (services: {svc_list})?"):
187
+ raise typer.Exit(0)
188
+
189
+ remove_profile(name)
190
+ out.success(f"Profile '{name}' removed")
191
+
192
+ new_default = get_default_profile()
193
+ if new_default and new_default != name:
194
+ out.info(f"Default is now: {new_default}")
195
+
196
+
197
+ @app.command()
198
+ def use(
199
+ ctx: typer.Context,
200
+ name: Annotated[str, typer.Argument(help="Profile name to switch to")],
201
+ ) -> None:
202
+ """Set the default profile.
203
+
204
+ Example: kctl-litellm config use production
205
+ """
206
+ actx: AppContext = ctx.obj
207
+ out = actx.output
208
+
209
+ profile_names = get_profile_names()
210
+ if name not in profile_names:
211
+ out.error(f"Profile '{name}' not found")
212
+ out.info(f"Available: {', '.join(profile_names)}")
213
+ raise typer.Exit(1)
214
+
215
+ old_default = get_default_profile()
216
+ set_default_profile(name)
217
+
218
+ svc = get_service_config(name)
219
+ if svc.url:
220
+ out.success(f"Switched to '{name}' ({svc.url})")
221
+ else:
222
+ out.warn(f"Switched to '{name}' -- no {SERVICE_KEY} config in this profile")
223
+
224
+ if old_default and old_default != name:
225
+ out.info(f"Previous default: {old_default}")
@@ -0,0 +1,135 @@
1
+ """Health check commands for kctl-litellm.
2
+
3
+ Check LiteLLM proxy service health via HTTP.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import time
9
+
10
+ import httpx
11
+ import typer
12
+ from rich import print as rprint
13
+ from rich.table import Table
14
+
15
+ from kctl_litellm.core.callbacks import AppContext
16
+ from kctl_litellm.core.client import LiteLLMClient
17
+ from kctl_litellm.core.exceptions import LiteLLMError
18
+
19
+ app = typer.Typer(help="Check LiteLLM proxy service health.")
20
+
21
+
22
+ def _get_client(actx: AppContext) -> LiteLLMClient:
23
+ cfg = actx.config
24
+ return LiteLLMClient(base_url=cfg.url, master_key=cfg.master_key)
25
+
26
+
27
+ @app.command()
28
+ def check(ctx: typer.Context) -> None:
29
+ """Full health check via /health endpoint.
30
+
31
+ Shows healthy and unhealthy models in a table.
32
+ """
33
+ actx: AppContext = ctx.obj
34
+ out = actx.output
35
+
36
+ try:
37
+ client = _get_client(actx)
38
+ result = client.health_check()
39
+ client.close()
40
+ except LiteLLMError as exc:
41
+ out.error(str(exc))
42
+ raise typer.Exit(1) from exc
43
+
44
+ # Parse healthy/unhealthy models from the response
45
+ healthy = result.get("healthy_endpoints", [])
46
+ unhealthy = result.get("unhealthy_endpoints", [])
47
+
48
+ table = Table(title="LiteLLM Health Check", show_header=True, header_style="bold cyan")
49
+ table.add_column("Model", style="cyan")
50
+ table.add_column("Status")
51
+ table.add_column("Details")
52
+
53
+ for endpoint in healthy:
54
+ model = endpoint.get("model", "unknown") if isinstance(endpoint, dict) else str(endpoint)
55
+ details = endpoint.get("api_base", "") if isinstance(endpoint, dict) else ""
56
+ table.add_row(model, "[green]healthy[/green]", details)
57
+
58
+ for endpoint in unhealthy:
59
+ model = endpoint.get("model", "unknown") if isinstance(endpoint, dict) else str(endpoint)
60
+ error = endpoint.get("error", "") if isinstance(endpoint, dict) else ""
61
+ table.add_row(model, "[red]unhealthy[/red]", str(error)[:80])
62
+
63
+ if not healthy and not unhealthy:
64
+ # Simple status display if no model-level data
65
+ status = result.get("status", "unknown")
66
+ if status == "healthy":
67
+ out.success("LiteLLM proxy is healthy")
68
+ else:
69
+ out.warn(f"LiteLLM proxy status: {status}")
70
+ return
71
+
72
+ rprint(table)
73
+ out.info(f"Healthy: {len(healthy)}, Unhealthy: {len(unhealthy)}")
74
+
75
+
76
+ @app.command()
77
+ def ping(ctx: typer.Context) -> None:
78
+ """Quick connectivity check (GET /, show status code and response time)."""
79
+ actx: AppContext = ctx.obj
80
+ out = actx.output
81
+
82
+ cfg = actx.config
83
+ if not cfg.url:
84
+ out.error("No LiteLLM URL configured. Run: kctl-litellm config add <name> --url <url>")
85
+ raise typer.Exit(1)
86
+
87
+ base_url = cfg.url.rstrip("/")
88
+ start = time.monotonic()
89
+ try:
90
+ response = httpx.get(f"{base_url}/", timeout=10.0)
91
+ elapsed = (time.monotonic() - start) * 1000
92
+ out.kv("URL", base_url)
93
+ out.kv("Status", str(response.status_code))
94
+ out.kv("Response time", f"{elapsed:.1f}ms")
95
+ except httpx.ConnectError as exc:
96
+ out.error(f"Connection failed: {exc}")
97
+ raise typer.Exit(1) from exc
98
+ except httpx.TimeoutException as exc:
99
+ out.error(f"Timeout: {exc}")
100
+ raise typer.Exit(1) from exc
101
+
102
+
103
+ @app.command()
104
+ def liveliness(ctx: typer.Context) -> None:
105
+ """Quick liveness check via /health/liveliness (no auth required)."""
106
+ actx: AppContext = ctx.obj
107
+ out = actx.output
108
+
109
+ cfg = actx.config
110
+ if not cfg.url:
111
+ out.error("No LiteLLM URL configured. Run: kctl-litellm config add <name> --url <url>")
112
+ raise typer.Exit(1)
113
+
114
+ base_url = cfg.url.rstrip("/")
115
+ start = time.monotonic()
116
+ try:
117
+ response = httpx.get(f"{base_url}/health/liveliness", timeout=10.0)
118
+ result = response.json()
119
+ elapsed = (time.monotonic() - start) * 1000
120
+ except httpx.ConnectError as exc:
121
+ out.error(f"Connection failed: {exc}")
122
+ raise typer.Exit(1) from exc
123
+ except httpx.TimeoutException as exc:
124
+ out.error(f"Timeout: {exc}")
125
+ raise typer.Exit(1) from exc
126
+
127
+ status = result.get("status", "unknown") if isinstance(result, dict) else str(result)
128
+ out.kv("URL", cfg.url)
129
+ out.kv("Status", status)
130
+ out.kv("Response time", f"{elapsed:.1f}ms")
131
+
132
+ if status == "alive":
133
+ out.success("LiteLLM proxy is alive")
134
+ else:
135
+ out.warn(f"Unexpected status: {status}")