Coverage for src/lilbee/cli/placement.py: 100%
83 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
1"""`lilbee placement` sub-app: inspect, preview, and set GPU placement."""
3from __future__ import annotations
5import sys
6from collections.abc import Callable
7from pathlib import Path
9import typer
10from rich.table import Table
11from rich.text import Text
13from lilbee.app.placement import (
14 PlacementView,
15 get_placement,
16 preview_placement,
17 set_placement,
18)
19from lilbee.cli import theme
20from lilbee.cli.app import apply_overrides, console, data_dir_option, global_option
21from lilbee.cli.helpers import json_output
22from lilbee.core.config import cfg
23from lilbee.providers.base import ProviderError
24from lilbee.providers.fleet.placement_spec import PlacementError, PlacementSpec
25from lilbee.runtime.console import styled
27_PLACEMENT_ERRORS = (PlacementError, ProviderError, OSError)
29placement_app = typer.Typer(
30 name="placement",
31 help="Inspect and override multi-GPU model placement.",
32 no_args_is_help=True,
33)
35_GIB = 1024**3
38def _read_spec(spec: str | None) -> PlacementSpec | None:
39 """Parse a spec from a file path or stdin ('-'); return None when omitted."""
40 if spec is None:
41 return None
42 if spec == "-":
43 raw = sys.stdin.read()
44 elif spec.lstrip().startswith("{"):
45 raw = spec # inline JSON rather than a file path
46 else:
47 raw = Path(spec).read_text(encoding="utf-8")
48 return PlacementSpec.from_json(raw)
51def _guard(action: Callable[[], PlacementView]) -> None:
52 """Run a placement action and render it, turning known failures into a clean exit."""
53 try:
54 view = action()
55 except _PLACEMENT_ERRORS as exc:
56 if cfg.json_mode:
57 json_output({"error": str(exc)})
58 else:
59 console.print(Text(str(exc), style=theme.ERROR), soft_wrap=True)
60 raise typer.Exit(code=1) from exc
61 if cfg.json_mode:
62 # The same canonical shape the HTTP and MCP surfaces return.
63 from lilbee.server.models import PlacementResponse
65 json_output(PlacementResponse.from_view(view).model_dump(mode="json"))
66 else:
67 _render_view(view)
70def _render_view(view: PlacementView) -> None:
71 """Print a Rich table of GPU rows plus per-role and unplaceable lines."""
72 title = "Placement (manual)" if view.manual else "Placement (auto)"
73 table = Table(title=title)
74 table.add_column("GPU")
75 table.add_column("Name")
76 table.add_column("Free / Total")
77 table.add_column("Roles")
79 placed: dict[int, list[str]] = {g.index: [] for g in view.gpus}
80 for role_view in view.roles:
81 for idx in role_view.devices:
82 placed.setdefault(idx, []).append(role_view.role.value)
84 for g in view.gpus:
85 free_gib = g.free_bytes / _GIB
86 total_gib = g.total_bytes / _GIB
87 table.add_row(
88 g.label,
89 g.name or "(unnamed)",
90 f"{free_gib:.0f} / {total_gib:.0f} GiB",
91 Text(", ".join(placed.get(g.index, [])) or "-"),
92 )
93 console.print(table)
95 for role_view in view.roles:
96 split_info = f" split={list(role_view.tensor_split)}" if role_view.tensor_split else ""
97 console.print(
98 Text(
99 f" {role_view.role.value}: devices={list(role_view.devices)}"
100 f" replicas={role_view.replicas}{split_info} {role_view.model}"
101 ),
102 soft_wrap=True,
103 )
105 if view.co_tenants:
106 names = ", ".join(role.value for role in view.co_tenants)
107 console.print(f" {names}: share memory, one loaded at a time", style=theme.MUTED)
109 for role in view.unplaceable:
110 console.print(f" {role.value}: does not fit, no server", style=theme.ERROR)
112 for skipped in view.skipped_not_installed:
113 console.print(
114 Text.assemble(
115 (f" {skipped.role.value}: ", theme.WARNING),
116 skipped.model,
117 (" not downloaded, pull it to place it", theme.WARNING),
118 ),
119 soft_wrap=True,
120 )
122 if view.rejected_spec_json:
123 console.print(
124 styled(
125 " ",
126 (
127 "a saved placement does not fit this hardware and is being "
128 "ignored; run 'lilbee placement clear' or set a new one",
129 theme.WARNING,
130 ),
131 )
132 )
135@placement_app.command("show")
136def show(
137 data_dir: Path | None = data_dir_option,
138 use_global: bool = global_option,
139) -> None:
140 """Show the current effective placement."""
141 apply_overrides(data_dir=data_dir, use_global=use_global)
142 _guard(get_placement)
145@placement_app.command("preview")
146def preview(
147 spec: str | None = typer.Option(
148 None, "--spec", help="Spec JSON file, or - for stdin; omit for auto."
149 ),
150 data_dir: Path | None = data_dir_option,
151 use_global: bool = global_option,
152) -> None:
153 """Preview what a spec (or auto) would place, without applying it."""
154 apply_overrides(data_dir=data_dir, use_global=use_global)
155 _guard(lambda: preview_placement(_read_spec(spec)))
158@placement_app.command("set")
159def set_cmd(
160 spec: str = typer.Option(..., "--spec", help="Spec JSON file, or - for stdin."),
161 data_dir: Path | None = data_dir_option,
162 use_global: bool = global_option,
163) -> None:
164 """Validate, persist, and apply a manual placement spec."""
165 apply_overrides(data_dir=data_dir, use_global=use_global)
166 _guard(lambda: set_placement(_read_spec(spec)))
169@placement_app.command("clear")
170def clear(
171 data_dir: Path | None = data_dir_option,
172 use_global: bool = global_option,
173) -> None:
174 """Clear the manual placement and return to automatic placement."""
175 apply_overrides(data_dir=data_dir, use_global=use_global)
176 _guard(lambda: set_placement(None))