inference-aiops 0.1.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.
- inference_aiops/__init__.py +9 -0
- inference_aiops/cli/__init__.py +9 -0
- inference_aiops/cli/_common.py +78 -0
- inference_aiops/cli/_root.py +57 -0
- inference_aiops/cli/doctor.py +21 -0
- inference_aiops/cli/init.py +97 -0
- inference_aiops/cli/metrics.py +45 -0
- inference_aiops/cli/overview.py +16 -0
- inference_aiops/cli/secret.py +105 -0
- inference_aiops/cli/serve.py +90 -0
- inference_aiops/config.py +140 -0
- inference_aiops/connection.py +215 -0
- inference_aiops/doctor.py +84 -0
- inference_aiops/governance/__init__.py +40 -0
- inference_aiops/governance/audit.py +377 -0
- inference_aiops/governance/budget.py +225 -0
- inference_aiops/governance/decorators.py +474 -0
- inference_aiops/governance/paths.py +23 -0
- inference_aiops/governance/patterns.py +378 -0
- inference_aiops/governance/policy.py +411 -0
- inference_aiops/governance/sanitize.py +39 -0
- inference_aiops/governance/undo.py +218 -0
- inference_aiops/ops/__init__.py +1 -0
- inference_aiops/ops/_util.py +60 -0
- inference_aiops/ops/cost.py +52 -0
- inference_aiops/ops/deploy.py +75 -0
- inference_aiops/ops/metrics.py +152 -0
- inference_aiops/ops/models.py +88 -0
- inference_aiops/ops/overview.py +38 -0
- inference_aiops/ops/ray_cluster.py +154 -0
- inference_aiops/ops/serve.py +154 -0
- inference_aiops/secretstore.py +302 -0
- inference_aiops-0.1.0.dist-info/METADATA +129 -0
- inference_aiops-0.1.0.dist-info/RECORD +47 -0
- inference_aiops-0.1.0.dist-info/WHEEL +4 -0
- inference_aiops-0.1.0.dist-info/entry_points.txt +3 -0
- inference_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
- mcp_server/__init__.py +1 -0
- mcp_server/_shared.py +101 -0
- mcp_server/server.py +35 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/cost.py +27 -0
- mcp_server/tools/deploy.py +110 -0
- mcp_server/tools/metrics.py +70 -0
- mcp_server/tools/models.py +101 -0
- mcp_server/tools/ray_cluster.py +104 -0
- mcp_server/tools/serve.py +216 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""inference-aiops — governed Inference SCALE operations for AI agents.
|
|
2
|
+
|
|
3
|
+
Standalone and self-contained: the governance harness (audit, token budget,
|
|
4
|
+
undo-token recording, graduated risk tiers, prompt-injection sanitize) is
|
|
5
|
+
bundled under ``inference_aiops.governance`` — this package has no external
|
|
6
|
+
skill-family dependency. Preview: not yet full-coverage.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Shared helpers for inference-aiops CLI sub-modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
# ─── Shared Option types ───────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
TargetOption = Annotated[
|
|
18
|
+
str | None, typer.Option("--target", "-t", help="Target name from config")
|
|
19
|
+
]
|
|
20
|
+
DryRunOption = Annotated[
|
|
21
|
+
bool, typer.Option("--dry-run", help="Print the API call without executing")
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cli_error_types() -> tuple[type[BaseException], ...]:
|
|
26
|
+
"""Exceptions translated to a one-line teaching error instead of a traceback."""
|
|
27
|
+
from inference_aiops.connection import InferenceApiError
|
|
28
|
+
|
|
29
|
+
return (InferenceApiError, KeyError, OSError, ValueError)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cli_errors(fn: Callable) -> Callable:
|
|
33
|
+
"""Translate known exceptions into one red line + exit code 1."""
|
|
34
|
+
|
|
35
|
+
@functools.wraps(fn)
|
|
36
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
37
|
+
try:
|
|
38
|
+
return fn(*args, **kwargs)
|
|
39
|
+
except (typer.Exit, typer.Abort):
|
|
40
|
+
raise
|
|
41
|
+
except _cli_error_types() as e:
|
|
42
|
+
message = str(e)
|
|
43
|
+
if isinstance(e, KeyError):
|
|
44
|
+
message = f"Missing required key or environment variable: {message}"
|
|
45
|
+
console.print(f"[red]Error: {message}[/]")
|
|
46
|
+
raise typer.Exit(1) from e
|
|
47
|
+
|
|
48
|
+
return wrapper
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_connection(target: str | None, config_path: Path | None = None):
|
|
52
|
+
"""Return a (conn, config) tuple for the given target."""
|
|
53
|
+
from inference_aiops.config import load_config
|
|
54
|
+
from inference_aiops.connection import ConnectionManager
|
|
55
|
+
|
|
56
|
+
cfg = load_config(config_path)
|
|
57
|
+
mgr = ConnectionManager(cfg)
|
|
58
|
+
return mgr.connect(target), cfg
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = None) -> None:
|
|
62
|
+
"""Print a dry-run preview of the API call that would be made."""
|
|
63
|
+
console.print("\n[bold magenta][DRY-RUN] No changes will be made.[/]")
|
|
64
|
+
console.print(f"[magenta] Operation: {operation}[/]")
|
|
65
|
+
console.print(f"[magenta] API Call: {api_call}[/]")
|
|
66
|
+
for k, v in (parameters or {}).items():
|
|
67
|
+
console.print(f"[magenta] Param: {k} = {v}[/]")
|
|
68
|
+
console.print("[magenta] Run without --dry-run to execute.[/]\n")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def double_confirm(action: str, resource: str) -> None:
|
|
72
|
+
"""Require two confirmations for a destructive operation."""
|
|
73
|
+
console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
|
|
74
|
+
typer.confirm(f"Confirm 1/2: {action} '{resource}'?", abort=True)
|
|
75
|
+
typer.confirm(
|
|
76
|
+
f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
|
|
77
|
+
abort=True,
|
|
78
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Top-level Typer app: assembles sub-apps and top-level commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from inference_aiops.cli._common import cli_errors
|
|
8
|
+
from inference_aiops.cli.doctor import doctor_cmd
|
|
9
|
+
from inference_aiops.cli.init import init_cmd
|
|
10
|
+
from inference_aiops.cli.metrics import metrics_app
|
|
11
|
+
from inference_aiops.cli.overview import overview_cmd
|
|
12
|
+
from inference_aiops.cli.secret import secret_app
|
|
13
|
+
from inference_aiops.cli.serve import serve_app
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="inference-aiops",
|
|
17
|
+
help="Governed AI-ops for GPU inference (vLLM + Ray Serve): metrics/RCA, "
|
|
18
|
+
"scaling, drain, models, jobs.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app.add_typer(serve_app, name="serve")
|
|
23
|
+
app.add_typer(metrics_app, name="metrics")
|
|
24
|
+
app.add_typer(secret_app, name="secret")
|
|
25
|
+
app.command("init")(init_cmd)
|
|
26
|
+
app.command("overview")(overview_cmd)
|
|
27
|
+
app.command("doctor")(doctor_cmd)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("mcp")
|
|
31
|
+
@cli_errors
|
|
32
|
+
def mcp_cmd() -> None:
|
|
33
|
+
"""Start the MCP server (stdio transport).
|
|
34
|
+
|
|
35
|
+
Single-command entry point for MCP clients (does not go through uvx/PyPI
|
|
36
|
+
resolution at launch):
|
|
37
|
+
inference-aiops mcp
|
|
38
|
+
"""
|
|
39
|
+
import sys
|
|
40
|
+
|
|
41
|
+
if sys.version_info < (3, 11):
|
|
42
|
+
typer.echo(
|
|
43
|
+
f"ERROR: inference-aiops requires Python >= 3.11 "
|
|
44
|
+
f"(got {sys.version_info.major}.{sys.version_info.minor}).\n"
|
|
45
|
+
f"Fix: uv python install 3.12 && "
|
|
46
|
+
f"uv tool install --python 3.12 --force inference-aiops",
|
|
47
|
+
err=True,
|
|
48
|
+
)
|
|
49
|
+
raise typer.Exit(2)
|
|
50
|
+
|
|
51
|
+
from mcp_server.server import main as _mcp_main
|
|
52
|
+
|
|
53
|
+
_mcp_main()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
app()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Doctor top-level command: environment and connectivity check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from inference_aiops.cli._common import cli_errors
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@cli_errors
|
|
13
|
+
def doctor_cmd(
|
|
14
|
+
skip_auth: Annotated[
|
|
15
|
+
bool, typer.Option("--skip-auth", help="Skip connectivity check (faster)")
|
|
16
|
+
] = False,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Check environment, config, secrets, and connectivity."""
|
|
19
|
+
from inference_aiops.doctor import run_doctor
|
|
20
|
+
|
|
21
|
+
raise typer.Exit(run_doctor(skip_auth=skip_auth))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""``inference-aiops init`` — a friendly, interactive onboarding wizard.
|
|
2
|
+
|
|
3
|
+
Walks a new user through connecting their first Inference SCALE target: collects
|
|
4
|
+
the non-secret connection details into ``config.yaml`` and the API key into the
|
|
5
|
+
*encrypted* store (never plaintext on disk). Designed to be run on a terminal;
|
|
6
|
+
everything it needs is prompted with sensible defaults.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import getpass
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from inference_aiops.cli._common import cli_errors, console
|
|
17
|
+
from inference_aiops.config import CONFIG_DIR, CONFIG_FILE, DEFAULT_RAY_PORT, DEFAULT_VLLM_PORT
|
|
18
|
+
from inference_aiops.secretstore import SecretStore, resolve_master_password
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_existing_targets() -> list[dict]:
|
|
22
|
+
if not CONFIG_FILE.exists():
|
|
23
|
+
return []
|
|
24
|
+
raw = yaml.safe_load(CONFIG_FILE.read_text("utf-8")) or {}
|
|
25
|
+
return list(raw.get("targets", []))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _write_targets(targets: list[dict]) -> None:
|
|
29
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
try:
|
|
31
|
+
CONFIG_DIR.chmod(0o700)
|
|
32
|
+
except OSError:
|
|
33
|
+
pass
|
|
34
|
+
CONFIG_FILE.write_text(yaml.safe_dump({"targets": targets}, sort_keys=False), "utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@cli_errors
|
|
38
|
+
def init_cmd() -> None:
|
|
39
|
+
"""Interactively set up your first Inference connection."""
|
|
40
|
+
console.print("[bold cyan]Inference AIops — setup wizard[/]")
|
|
41
|
+
console.print(
|
|
42
|
+
"This collects Ray dashboard + vLLM connection details (saved to "
|
|
43
|
+
"config.yaml). A bearer token is optional (many inference stacks run "
|
|
44
|
+
"open); if given it is saved [bold]encrypted[/] to secrets.enc.\n"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
console.print("[bold]Step 1 — master password[/]")
|
|
48
|
+
console.print(
|
|
49
|
+
"[dim]Encrypts secrets.enc. You'll set it via the "
|
|
50
|
+
"INFERENCE_AIOPS_MASTER_PASSWORD env var for non-interactive/MCP use.[/]"
|
|
51
|
+
)
|
|
52
|
+
password = resolve_master_password(confirm_if_new=True)
|
|
53
|
+
store = SecretStore.unlock(password)
|
|
54
|
+
|
|
55
|
+
targets = _load_existing_targets()
|
|
56
|
+
existing_names = {t.get("name") for t in targets}
|
|
57
|
+
|
|
58
|
+
while True:
|
|
59
|
+
console.print("\n[bold]Step 2 — add an inference target[/]")
|
|
60
|
+
name = typer.prompt("Target name (e.g. prod)").strip()
|
|
61
|
+
if name in existing_names:
|
|
62
|
+
if not typer.confirm(f"'{name}' already exists — overwrite?", default=False):
|
|
63
|
+
continue
|
|
64
|
+
targets = [t for t in targets if t.get("name") != name]
|
|
65
|
+
|
|
66
|
+
host = typer.prompt("Host (IP or FQDN, shared by Ray + vLLM)").strip()
|
|
67
|
+
ray_port = typer.prompt("Ray dashboard port", default=DEFAULT_RAY_PORT, type=int)
|
|
68
|
+
vllm_port = typer.prompt("vLLM port", default=DEFAULT_VLLM_PORT, type=int)
|
|
69
|
+
|
|
70
|
+
if typer.confirm("Does the API require a bearer token?", default=False):
|
|
71
|
+
secret = getpass.getpass(f"Token for '{name}' (hidden): ")
|
|
72
|
+
store = store.set(name, secret)
|
|
73
|
+
|
|
74
|
+
entry = {
|
|
75
|
+
"name": name,
|
|
76
|
+
"host": host,
|
|
77
|
+
"ray_port": ray_port,
|
|
78
|
+
"vllm_port": vllm_port,
|
|
79
|
+
"scheme": "http",
|
|
80
|
+
}
|
|
81
|
+
targets.append(entry)
|
|
82
|
+
existing_names.add(name)
|
|
83
|
+
_write_targets(targets)
|
|
84
|
+
console.print(f"[green]✓ Saved target '{name}'.[/]")
|
|
85
|
+
|
|
86
|
+
if not typer.confirm("\nAdd another target?", default=False):
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
console.print(f"\n[green]✓ Setup complete.[/] Config: {CONFIG_FILE}")
|
|
90
|
+
console.print(
|
|
91
|
+
"[dim]Tip: export INFERENCE_AIOPS_MASTER_PASSWORD=... in your shell profile "
|
|
92
|
+
"so the MCP server and CLI can unlock secrets non-interactively.[/]"
|
|
93
|
+
)
|
|
94
|
+
if typer.confirm("Run a connectivity check now (inference-aiops doctor)?", default=True):
|
|
95
|
+
from inference_aiops.doctor import run_doctor
|
|
96
|
+
|
|
97
|
+
raise typer.Exit(run_doctor())
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""``inference-aiops metrics`` — vLLM metric reads + latency/util RCA."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from inference_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
10
|
+
|
|
11
|
+
metrics_app = typer.Typer(
|
|
12
|
+
name="metrics",
|
|
13
|
+
help="vLLM request metrics, queue depth, KV cache, and latency/util RCA.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@metrics_app.command("requests")
|
|
19
|
+
@cli_errors
|
|
20
|
+
def metrics_requests(target: TargetOption = None) -> None:
|
|
21
|
+
"""TTFT / TPOT / e2e latency + generation-token totals."""
|
|
22
|
+
from inference_aiops.ops import metrics as ops
|
|
23
|
+
|
|
24
|
+
conn, _ = get_connection(target)
|
|
25
|
+
console.print_json(json.dumps(ops.get_request_metrics(conn)))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@metrics_app.command("queue")
|
|
29
|
+
@cli_errors
|
|
30
|
+
def metrics_queue(target: TargetOption = None) -> None:
|
|
31
|
+
"""Running vs waiting requests (backpressure signal)."""
|
|
32
|
+
from inference_aiops.ops import metrics as ops
|
|
33
|
+
|
|
34
|
+
conn, _ = get_connection(target)
|
|
35
|
+
console.print_json(json.dumps(ops.get_queue_depth(conn)))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@metrics_app.command("diagnose")
|
|
39
|
+
@cli_errors
|
|
40
|
+
def metrics_diagnose(target: TargetOption = None) -> None:
|
|
41
|
+
"""RCA: rank the probable cause of a latency spike + the knob to turn."""
|
|
42
|
+
from inference_aiops.ops import metrics as ops
|
|
43
|
+
|
|
44
|
+
conn, _ = get_connection(target)
|
|
45
|
+
console.print_json(json.dumps(ops.diagnose_latency_spike(conn)))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""``inference-aiops overview`` — one-shot fleet health."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from inference_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@cli_errors
|
|
11
|
+
def overview_cmd(target: TargetOption = None) -> None:
|
|
12
|
+
"""One-shot stack summary: deployments, total replicas, queue backpressure."""
|
|
13
|
+
from inference_aiops.ops import overview as ops
|
|
14
|
+
|
|
15
|
+
conn, _ = get_connection(target)
|
|
16
|
+
console.print_json(json.dumps(ops.fleet_overview(conn)))
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""``inference-aiops secret`` — manage the encrypted credential store.
|
|
2
|
+
|
|
3
|
+
Secrets (Inference API keys) are stored in ``~/.inference-aiops/secrets.enc``
|
|
4
|
+
(Fernet, key derived from a master password via scrypt). Nothing here ever
|
|
5
|
+
prints a secret value.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import getpass
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from inference_aiops.cli._common import cli_errors, console
|
|
16
|
+
from inference_aiops.config import SECRET_ENV_PREFIX, SECRET_ENV_SUFFIX
|
|
17
|
+
from inference_aiops.secretstore import (
|
|
18
|
+
SECRETS_FILE,
|
|
19
|
+
SecretStore,
|
|
20
|
+
check_permissions,
|
|
21
|
+
migrate_legacy_env,
|
|
22
|
+
resolve_master_password,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
secret_app = typer.Typer(
|
|
26
|
+
name="secret",
|
|
27
|
+
help="Manage the encrypted credential store (secrets.enc).",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
NameArg = Annotated[str, typer.Argument(help="Target name the API key belongs to")]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@secret_app.command("set")
|
|
35
|
+
@cli_errors
|
|
36
|
+
def secret_set(
|
|
37
|
+
name: NameArg,
|
|
38
|
+
value: Annotated[
|
|
39
|
+
str | None,
|
|
40
|
+
typer.Option("--value", help="API key value (omit to be prompted, hidden)"),
|
|
41
|
+
] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Store (or replace) the API key for a target — value is read hidden."""
|
|
44
|
+
password = resolve_master_password(confirm_if_new=True)
|
|
45
|
+
if value is None:
|
|
46
|
+
value = getpass.getpass(f"API key for '{name}' (hidden): ")
|
|
47
|
+
store = SecretStore.unlock(password)
|
|
48
|
+
store.set(name, value)
|
|
49
|
+
console.print(f"[green]✓ Stored encrypted API key for '{name}' in {SECRETS_FILE}[/]")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@secret_app.command("list")
|
|
53
|
+
@cli_errors
|
|
54
|
+
def secret_list() -> None:
|
|
55
|
+
"""List target names that have a stored API key (values never shown)."""
|
|
56
|
+
store = SecretStore.unlock()
|
|
57
|
+
names = store.names()
|
|
58
|
+
if not names:
|
|
59
|
+
console.print(
|
|
60
|
+
"[yellow]No secrets stored yet. Add one: inference-aiops secret set <name>[/]"
|
|
61
|
+
)
|
|
62
|
+
return
|
|
63
|
+
console.print("[bold]Stored secrets:[/]")
|
|
64
|
+
for n in names:
|
|
65
|
+
console.print(f" • {n}")
|
|
66
|
+
warning = check_permissions()
|
|
67
|
+
if warning:
|
|
68
|
+
console.print(f"[yellow]! {warning}[/]")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@secret_app.command("rm")
|
|
72
|
+
@cli_errors
|
|
73
|
+
def secret_rm(name: NameArg) -> None:
|
|
74
|
+
"""Delete a stored API key."""
|
|
75
|
+
store = SecretStore.unlock()
|
|
76
|
+
store.delete(name)
|
|
77
|
+
console.print(f"[green]✓ Deleted API key for '{name}'[/]")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@secret_app.command("migrate")
|
|
81
|
+
@cli_errors
|
|
82
|
+
def secret_migrate() -> None:
|
|
83
|
+
"""Import API keys from a legacy plaintext .env into the encrypted store."""
|
|
84
|
+
password = resolve_master_password(confirm_if_new=True)
|
|
85
|
+
imported = migrate_legacy_env(SECRET_ENV_PREFIX, SECRET_ENV_SUFFIX, password)
|
|
86
|
+
if not imported:
|
|
87
|
+
console.print("[yellow]Nothing to migrate (no legacy .env secrets found).[/]")
|
|
88
|
+
return
|
|
89
|
+
console.print(f"[green]✓ Imported {len(imported)} secret(s): {', '.join(imported)}[/]")
|
|
90
|
+
console.print("[dim]The old .env was renamed to .env.migrated — delete it once verified.[/]")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@secret_app.command("rotate-password")
|
|
94
|
+
@cli_errors
|
|
95
|
+
def secret_rotate_password() -> None:
|
|
96
|
+
"""Re-encrypt the whole store under a new master password."""
|
|
97
|
+
console.print("[bold]Unlock with the current master password:[/]")
|
|
98
|
+
store = SecretStore.unlock()
|
|
99
|
+
new_pw = getpass.getpass("New master password: ")
|
|
100
|
+
confirm = getpass.getpass("Confirm new master password: ")
|
|
101
|
+
if new_pw != confirm:
|
|
102
|
+
console.print("[red]Passwords did not match. Aborted.[/]")
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
store.with_password(new_pw)
|
|
105
|
+
console.print("[green]✓ Master password rotated. Update INFERENCE_AIOPS_MASTER_PASSWORD.[/]")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""``inference-aiops serve`` — Ray Serve reads + guarded scaling writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from inference_aiops.cli._common import (
|
|
11
|
+
DryRunOption,
|
|
12
|
+
TargetOption,
|
|
13
|
+
cli_errors,
|
|
14
|
+
console,
|
|
15
|
+
double_confirm,
|
|
16
|
+
dry_run_print,
|
|
17
|
+
get_connection,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
serve_app = typer.Typer(
|
|
21
|
+
name="serve",
|
|
22
|
+
help="Ray Serve: list/status, scale, scale-to-zero, drain.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
AppArg = Annotated[str, typer.Argument(help="Serve application name")]
|
|
27
|
+
DepArg = Annotated[str, typer.Argument(help="Deployment name")]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@serve_app.command("list")
|
|
31
|
+
@cli_errors
|
|
32
|
+
def serve_list(target: TargetOption = None) -> None:
|
|
33
|
+
"""List Ray Serve deployments."""
|
|
34
|
+
from inference_aiops.ops import serve as ops
|
|
35
|
+
|
|
36
|
+
conn, _ = get_connection(target)
|
|
37
|
+
console.print_json(json.dumps(ops.list_serve_deployments(conn)))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@serve_app.command("status")
|
|
41
|
+
@cli_errors
|
|
42
|
+
def serve_status(application: AppArg, deployment: DepArg, target: TargetOption = None) -> None:
|
|
43
|
+
"""Show one deployment's status + replica count."""
|
|
44
|
+
from inference_aiops.ops import serve as ops
|
|
45
|
+
|
|
46
|
+
conn, _ = get_connection(target)
|
|
47
|
+
console.print_json(json.dumps(ops.get_deployment_status(conn, application, deployment)))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@serve_app.command("scale")
|
|
51
|
+
@cli_errors
|
|
52
|
+
def serve_scale(
|
|
53
|
+
application: AppArg,
|
|
54
|
+
deployment: DepArg,
|
|
55
|
+
num_replicas: Annotated[int, typer.Argument(help="Target replica count")],
|
|
56
|
+
target: TargetOption = None,
|
|
57
|
+
dry_run: DryRunOption = False,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Scale a deployment to a target replica count."""
|
|
60
|
+
from inference_aiops.ops import serve as ops
|
|
61
|
+
|
|
62
|
+
if dry_run:
|
|
63
|
+
dry_run_print(operation="scale_replicas",
|
|
64
|
+
api_call=f"PUT /api/serve/applications/{application}/deployments/"
|
|
65
|
+
f"{deployment}",
|
|
66
|
+
parameters={"num_replicas": num_replicas})
|
|
67
|
+
return
|
|
68
|
+
conn, _ = get_connection(target)
|
|
69
|
+
result = ops.scale_replicas_up(conn, application, deployment, num_replicas)
|
|
70
|
+
console.print_json(json.dumps(result))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@serve_app.command("scale-to-zero")
|
|
74
|
+
@cli_errors
|
|
75
|
+
def serve_scale_zero(
|
|
76
|
+
application: AppArg, deployment: DepArg,
|
|
77
|
+
target: TargetOption = None, dry_run: DryRunOption = False,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Park a deployment at 0 replicas (dry-run + double confirm)."""
|
|
80
|
+
from inference_aiops.ops import serve as ops
|
|
81
|
+
|
|
82
|
+
if dry_run:
|
|
83
|
+
dry_run_print(operation="scale_to_zero",
|
|
84
|
+
api_call=f"PUT /api/serve/applications/{application}/deployments/"
|
|
85
|
+
f"{deployment}",
|
|
86
|
+
parameters={"num_replicas": 0})
|
|
87
|
+
return
|
|
88
|
+
double_confirm("scale to zero", f"{application}/{deployment}")
|
|
89
|
+
conn, _ = get_connection(target)
|
|
90
|
+
console.print_json(json.dumps(ops.scale_to_zero(conn, application, deployment)))
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Configuration management for Inference AIops.
|
|
2
|
+
|
|
3
|
+
Loads GPU inference-cluster connection targets from a YAML config file. A
|
|
4
|
+
"target" is one inference stack reached over two HTTP endpoints:
|
|
5
|
+
|
|
6
|
+
* a **Ray dashboard** (Ray Serve + Jobs API, default port ``8265``), and
|
|
7
|
+
* a **vLLM** OpenAI-compatible + Prometheus ``/metrics`` server (default port
|
|
8
|
+
``8000``).
|
|
9
|
+
|
|
10
|
+
Inference endpoints frequently run with **no auth** on a trusted network, so the
|
|
11
|
+
bearer **token is optional**: if none is stored, requests are sent unauthenticated.
|
|
12
|
+
When a token *is* used it is NEVER stored in the config file or in plaintext on
|
|
13
|
+
disk — it lives in the encrypted store ``~/.inference-aiops/secrets.enc`` (see
|
|
14
|
+
:mod:`inference_aiops.secretstore`), with a legacy env var
|
|
15
|
+
(``INFERENCE_<TARGET>_TOKEN``) honoured as a fallback.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
from inference_aiops.secretstore import SecretStoreError, get_secret, has_store
|
|
28
|
+
|
|
29
|
+
CONFIG_DIR = Path.home() / ".inference-aiops"
|
|
30
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
31
|
+
ENV_FILE = CONFIG_DIR / ".env"
|
|
32
|
+
|
|
33
|
+
DEFAULT_RAY_PORT = 8265
|
|
34
|
+
DEFAULT_VLLM_PORT = 8000
|
|
35
|
+
|
|
36
|
+
# Legacy env-var prefix/suffix; also used by the migration helper.
|
|
37
|
+
SECRET_ENV_PREFIX = "INFERENCE_" # nosec B105 — env-var name, not a secret
|
|
38
|
+
SECRET_ENV_SUFFIX = "_TOKEN" # nosec B105 — env-var name, not a secret
|
|
39
|
+
|
|
40
|
+
_log = logging.getLogger("inference-aiops.config")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _secret_env_key(name: str) -> str:
|
|
44
|
+
"""Legacy per-target token env var name, e.g. INFERENCE_PROD_TOKEN."""
|
|
45
|
+
return f"{SECRET_ENV_PREFIX}{name.upper().replace('-', '_')}{SECRET_ENV_SUFFIX}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_secret(name: str) -> str:
|
|
49
|
+
"""Return a target's bearer token, or "" when none is configured (auth optional)."""
|
|
50
|
+
if has_store():
|
|
51
|
+
try:
|
|
52
|
+
return get_secret(name)
|
|
53
|
+
except SecretStoreError:
|
|
54
|
+
pass # fall through to legacy env var / no-auth
|
|
55
|
+
legacy = os.environ.get(_secret_env_key(name))
|
|
56
|
+
if legacy:
|
|
57
|
+
_log.warning(
|
|
58
|
+
"Using plaintext env var %s. Migrate to the encrypted store with "
|
|
59
|
+
"'inference-aiops secret migrate'.",
|
|
60
|
+
_secret_env_key(name),
|
|
61
|
+
)
|
|
62
|
+
return legacy
|
|
63
|
+
return "" # no token → unauthenticated (common for on-prem inference)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class TargetConfig:
|
|
68
|
+
"""A connection target for one GPU inference stack (Ray dashboard + vLLM).
|
|
69
|
+
|
|
70
|
+
``host`` is shared by both services; ``ray_port`` (8265) reaches the Ray
|
|
71
|
+
Serve/Jobs dashboard API and ``vllm_port`` (8000) the vLLM OpenAI +
|
|
72
|
+
``/metrics`` server. ``token`` is optional (empty = unauthenticated).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
name: str
|
|
76
|
+
host: str
|
|
77
|
+
ray_port: int = DEFAULT_RAY_PORT
|
|
78
|
+
vllm_port: int = DEFAULT_VLLM_PORT
|
|
79
|
+
scheme: str = "http"
|
|
80
|
+
verify_ssl: bool = False
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def token(self) -> str:
|
|
84
|
+
return _resolve_secret(self.name)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def ray_url(self) -> str:
|
|
88
|
+
return f"{self.scheme}://{self.host}:{self.ray_port}"
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def vllm_url(self) -> str:
|
|
92
|
+
return f"{self.scheme}://{self.host}:{self.vllm_port}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class AppConfig:
|
|
97
|
+
"""Top-level application config."""
|
|
98
|
+
|
|
99
|
+
targets: tuple[TargetConfig, ...] = ()
|
|
100
|
+
|
|
101
|
+
def get_target(self, name: str) -> TargetConfig:
|
|
102
|
+
for t in self.targets:
|
|
103
|
+
if t.name == name:
|
|
104
|
+
return t
|
|
105
|
+
available = ", ".join(t.name for t in self.targets) or "(none)"
|
|
106
|
+
raise KeyError(f"Target '{name}' not found. Available: {available}")
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def default_target(self) -> TargetConfig:
|
|
110
|
+
if not self.targets:
|
|
111
|
+
raise ValueError("No targets configured. Check config.yaml")
|
|
112
|
+
return self.targets[0]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_config(config_path: Path | None = None) -> AppConfig:
|
|
116
|
+
"""Load config from YAML; any bearer token comes from the encrypted store."""
|
|
117
|
+
path = config_path or CONFIG_FILE
|
|
118
|
+
if not path.exists():
|
|
119
|
+
raise FileNotFoundError(
|
|
120
|
+
f"Config file not found: {path}\n"
|
|
121
|
+
f"Run 'inference-aiops init' to set up an inference target, or create "
|
|
122
|
+
f"{CONFIG_FILE} with a 'targets' list."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
with open(path) as f:
|
|
126
|
+
raw = yaml.safe_load(f) or {}
|
|
127
|
+
|
|
128
|
+
targets = tuple(
|
|
129
|
+
TargetConfig(
|
|
130
|
+
name=t["name"],
|
|
131
|
+
host=t["host"],
|
|
132
|
+
ray_port=t.get("ray_port", DEFAULT_RAY_PORT),
|
|
133
|
+
vllm_port=t.get("vllm_port", DEFAULT_VLLM_PORT),
|
|
134
|
+
scheme=t.get("scheme", "http"),
|
|
135
|
+
verify_ssl=t.get("verify_ssl", False),
|
|
136
|
+
)
|
|
137
|
+
for t in raw.get("targets", [])
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return AppConfig(targets=targets)
|