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.
Files changed (47) hide show
  1. inference_aiops/__init__.py +9 -0
  2. inference_aiops/cli/__init__.py +9 -0
  3. inference_aiops/cli/_common.py +78 -0
  4. inference_aiops/cli/_root.py +57 -0
  5. inference_aiops/cli/doctor.py +21 -0
  6. inference_aiops/cli/init.py +97 -0
  7. inference_aiops/cli/metrics.py +45 -0
  8. inference_aiops/cli/overview.py +16 -0
  9. inference_aiops/cli/secret.py +105 -0
  10. inference_aiops/cli/serve.py +90 -0
  11. inference_aiops/config.py +140 -0
  12. inference_aiops/connection.py +215 -0
  13. inference_aiops/doctor.py +84 -0
  14. inference_aiops/governance/__init__.py +40 -0
  15. inference_aiops/governance/audit.py +377 -0
  16. inference_aiops/governance/budget.py +225 -0
  17. inference_aiops/governance/decorators.py +474 -0
  18. inference_aiops/governance/paths.py +23 -0
  19. inference_aiops/governance/patterns.py +378 -0
  20. inference_aiops/governance/policy.py +411 -0
  21. inference_aiops/governance/sanitize.py +39 -0
  22. inference_aiops/governance/undo.py +218 -0
  23. inference_aiops/ops/__init__.py +1 -0
  24. inference_aiops/ops/_util.py +60 -0
  25. inference_aiops/ops/cost.py +52 -0
  26. inference_aiops/ops/deploy.py +75 -0
  27. inference_aiops/ops/metrics.py +152 -0
  28. inference_aiops/ops/models.py +88 -0
  29. inference_aiops/ops/overview.py +38 -0
  30. inference_aiops/ops/ray_cluster.py +154 -0
  31. inference_aiops/ops/serve.py +154 -0
  32. inference_aiops/secretstore.py +302 -0
  33. inference_aiops-0.1.0.dist-info/METADATA +129 -0
  34. inference_aiops-0.1.0.dist-info/RECORD +47 -0
  35. inference_aiops-0.1.0.dist-info/WHEEL +4 -0
  36. inference_aiops-0.1.0.dist-info/entry_points.txt +3 -0
  37. inference_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
  38. mcp_server/__init__.py +1 -0
  39. mcp_server/_shared.py +101 -0
  40. mcp_server/server.py +35 -0
  41. mcp_server/tools/__init__.py +1 -0
  42. mcp_server/tools/cost.py +27 -0
  43. mcp_server/tools/deploy.py +110 -0
  44. mcp_server/tools/metrics.py +70 -0
  45. mcp_server/tools/models.py +101 -0
  46. mcp_server/tools/ray_cluster.py +104 -0
  47. 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,9 @@
1
+ """CLI package for inference-aiops.
2
+
3
+ Re-exports ``app`` so the pyproject entry point
4
+ ``inference-aiops = "inference_aiops.cli:app"`` works unchanged.
5
+ """
6
+
7
+ from inference_aiops.cli._root import app
8
+
9
+ __all__ = ["app"]
@@ -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)