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.
- kctl_litellm/__init__.py +3 -0
- kctl_litellm/__main__.py +5 -0
- kctl_litellm/cli.py +126 -0
- kctl_litellm/commands/__init__.py +1 -0
- kctl_litellm/commands/budgets_cmd.py +116 -0
- kctl_litellm/commands/config_cmd.py +225 -0
- kctl_litellm/commands/health_cmd.py +135 -0
- kctl_litellm/commands/keys_cmd.py +234 -0
- kctl_litellm/commands/logs_cmd.py +104 -0
- kctl_litellm/commands/models_cmd.py +131 -0
- kctl_litellm/commands/spend_cmd.py +122 -0
- kctl_litellm/commands/teams_cmd.py +161 -0
- kctl_litellm/core/__init__.py +1 -0
- kctl_litellm/core/callbacks.py +26 -0
- kctl_litellm/core/client.py +244 -0
- kctl_litellm/core/config.py +156 -0
- kctl_litellm/core/exceptions.py +51 -0
- kctl_litellm-0.2.0.dist-info/METADATA +16 -0
- kctl_litellm-0.2.0.dist-info/RECORD +21 -0
- kctl_litellm-0.2.0.dist-info/WHEEL +4 -0
- kctl_litellm-0.2.0.dist-info/entry_points.txt +2 -0
kctl_litellm/__init__.py
ADDED
kctl_litellm/__main__.py
ADDED
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}")
|