infer-stack 0.6.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 (44) hide show
  1. infer_stack/__init__.py +2 -0
  2. infer_stack/backends/__init__.py +7 -0
  3. infer_stack/backends/compose_renderer.py +243 -0
  4. infer_stack/backends/kubeai_renderer.py +202 -0
  5. infer_stack/benchmark.py +38 -0
  6. infer_stack/catalog.py +438 -0
  7. infer_stack/cli/__init__.py +169 -0
  8. infer_stack/cli/__main__.py +4 -0
  9. infer_stack/cli/commands_profile.py +467 -0
  10. infer_stack/cli/commands_runtime.py +719 -0
  11. infer_stack/cli/commands_smoke.py +691 -0
  12. infer_stack/cli/compose.py +755 -0
  13. infer_stack/cli/context.py +471 -0
  14. infer_stack/cli/options.py +134 -0
  15. infer_stack/cli/probes.py +178 -0
  16. infer_stack/config.py +450 -0
  17. infer_stack/contracts.py +223 -0
  18. infer_stack/diff_prompt.py +117 -0
  19. infer_stack/docker_utils.py +230 -0
  20. infer_stack/env_utils.py +97 -0
  21. infer_stack/experimental/model_catalog_discover.py +1155 -0
  22. infer_stack/experimental/model_memory_estimator.py +1264 -0
  23. infer_stack/experimental/stress_test_long_context.py +397 -0
  24. infer_stack/hardware.py +70 -0
  25. infer_stack/kubeai_ops.py +76 -0
  26. infer_stack/paths.py +87 -0
  27. infer_stack/profile_runtime.py +46 -0
  28. infer_stack/renderer.py +19 -0
  29. infer_stack/resolver.py +1092 -0
  30. infer_stack/templates/default-models.yaml +674 -0
  31. infer_stack/templates/default-ollama-models.yaml +31 -0
  32. infer_stack/templates/default-profiles.yaml +1731 -0
  33. infer_stack/templates/default-vllm-models.yaml +714 -0
  34. infer_stack/templates/docker-compose.yml.j2 +430 -0
  35. infer_stack/templates/litellm_config.yaml.j2 +44 -0
  36. infer_stack/templates/nginx.conf.j2 +84 -0
  37. infer_stack/tuning.py +3 -0
  38. infer_stack/validator.py +314 -0
  39. infer_stack/verification.py +46 -0
  40. infer_stack-0.6.0.dist-info/METADATA +1034 -0
  41. infer_stack-0.6.0.dist-info/RECORD +44 -0
  42. infer_stack-0.6.0.dist-info/WHEEL +5 -0
  43. infer_stack-0.6.0.dist-info/entry_points.txt +2 -0
  44. infer_stack-0.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,471 @@
1
+ from __future__ import annotations
2
+
3
+ from ..config import CONFIG_FILE
4
+ from ..config import MODELS_FILE
5
+ from ..config import deep_merge
6
+ from ..config import default_output_config
7
+ from ..config import generated_dir_for_config
8
+ from ..config import initial_config
9
+ from ..config import kubeai_generated_dir_for_config
10
+ from ..config import kubeai_local_values_path
11
+ from ..config import load_yaml
12
+ from ..config import plan_path_for_config
13
+ from ..config import save_yaml
14
+ from ..hardware import detect_inventory
15
+ from ..hardware import simulate_inventory
16
+ from ..paths import CONFIG_DIR_ENV
17
+ from ..paths import config_root
18
+ from ..paths import data_root
19
+ from ..paths import set_config_root
20
+ from ..paths import set_data_root
21
+ from ..resolver import resolve
22
+ from ..validator import validate_resolved
23
+ from copy import deepcopy
24
+ from pathlib import Path
25
+ from typing import Any
26
+ import os
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Path / config helpers
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ def config_path() -> Path:
34
+ return config_root() / CONFIG_FILE
35
+
36
+
37
+ def models_path() -> Path:
38
+ return config_root() / MODELS_FILE
39
+
40
+
41
+ def generated_dir(cfg: dict[str, Any] | None = None) -> Path:
42
+ cfg = cfg if cfg is not None else _safe_load_config()
43
+ return generated_dir_for_config(cfg)
44
+
45
+
46
+ def kubeai_generated_dir(cfg: dict[str, Any] | None = None) -> Path:
47
+ cfg = cfg if cfg is not None else _safe_load_config()
48
+ return kubeai_generated_dir_for_config(cfg)
49
+
50
+
51
+ def plan_path(cfg: dict[str, Any] | None = None) -> Path:
52
+ cfg = cfg if cfg is not None else _safe_load_config()
53
+ return plan_path_for_config(cfg)
54
+
55
+
56
+ def _hydrate_config_defaults(cfg: dict[str, Any] | None) -> dict[str, Any]:
57
+ """Merge persisted config on top of current defaults.
58
+
59
+ The stack schema is still moving quickly. Users may already have a
60
+ config.yaml that predates a newly introduced component such as Ollama.
61
+ Loading through this helper keeps those configs valid by filling in new
62
+ default images, ports, provider toggles, state paths, and frontend/gateway
63
+ defaults without rewriting the user's file.
64
+ """
65
+ return deep_merge(initial_config(), cfg or {})
66
+
67
+
68
+ def _safe_load_config() -> dict[str, Any]:
69
+ """Load config.yaml if present; otherwise return defaults."""
70
+ path = config_path()
71
+ if path.exists():
72
+ return _hydrate_config_defaults(load_yaml(path))
73
+ return initial_config()
74
+
75
+
76
+ def load_config() -> dict[str, Any]:
77
+ path = config_path()
78
+ if not path.exists():
79
+ raise SystemExit(
80
+ f'No config.yaml found at {path}. Run '
81
+ '`infer-stack setup --backend compose --profile qwen2-5-7b-instruct-turbo-default` first, '
82
+ f'or point ${CONFIG_DIR_ENV} / --config-dir at an existing config.'
83
+ )
84
+ return _hydrate_config_defaults(load_yaml(path))
85
+
86
+
87
+ def runtime_dir_for_config(cfg: dict[str, Any]) -> Path:
88
+ state = cfg.get('state', {})
89
+ runtime = state.get('runtime')
90
+ if not runtime:
91
+ return data_root() / 'runtime'
92
+ p = Path(runtime)
93
+ if p.is_absolute():
94
+ return p
95
+ return data_root() / p
96
+
97
+
98
+ def runtime_env_path(cfg: dict[str, Any]) -> Path:
99
+ return generated_dir(cfg) / '.env'
100
+
101
+
102
+ def runtime_litellm_config_path(cfg: dict[str, Any]) -> Path:
103
+ return runtime_dir_for_config(cfg) / 'litellm_config.yaml'
104
+
105
+
106
+ def backend_name(cfg: dict[str, Any]) -> str:
107
+ return str(cfg.get('backend', 'compose')).lower()
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Env-var / override resolution
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ def _env_text(name: str) -> str | None:
116
+ value = os.environ.get(name)
117
+ if value is None:
118
+ return None
119
+ text = value.strip()
120
+ return text or None
121
+
122
+
123
+ def _env_bool(name: str) -> bool | None:
124
+ value = _env_text(name)
125
+ if value is None:
126
+ return None
127
+ lowered = value.lower()
128
+ if lowered in {'1', 'true', 'yes', 'on', 'enabled'}:
129
+ return True
130
+ if lowered in {'0', 'false', 'no', 'off', 'disabled'}:
131
+ return False
132
+ raise SystemExit(f'Invalid boolean value for {name}: {value!r}')
133
+
134
+
135
+ def _env_int(name: str) -> int | None:
136
+ value = _env_text(name)
137
+ if value is None:
138
+ return None
139
+ try:
140
+ return int(value)
141
+ except ValueError as ex:
142
+ raise SystemExit(f'Invalid integer value for {name}: {value!r}') from ex
143
+
144
+
145
+ def _as_mapping(args: Any) -> dict[str, Any]:
146
+ """Coerce a CLI args object into a plain dict.
147
+
148
+ Works for ``None``, ``argparse.Namespace``, and ``scfg.DataConfig``
149
+ instances. Used to side-step name clashes between user-declared fields
150
+ and ``DataConfig`` builtins (e.g. ``namespace`` is a property on the
151
+ base class; ``getattr(cfg, 'namespace')`` returns the property, not the
152
+ field value, while ``asdict()['namespace']`` returns the field value).
153
+ """
154
+ if args is None:
155
+ return {}
156
+ if hasattr(args, 'asdict'):
157
+ return dict(args.asdict())
158
+ if hasattr(args, '__dict__'):
159
+ return dict(vars(args))
160
+ return dict(args)
161
+
162
+
163
+ def _arg_or_env(
164
+ args_dict: dict[str, Any], attr: str, env_name: str, *, caster=None
165
+ ):
166
+ """Look up ``attr`` in the args dict, falling back to env var ``env_name``."""
167
+ value = args_dict.get(attr)
168
+ if value is not None:
169
+ return value
170
+ env_value = _env_text(env_name)
171
+ if env_value is None:
172
+ return None
173
+ if caster is None:
174
+ return env_value
175
+ try:
176
+ return caster(env_value)
177
+ except ValueError as ex:
178
+ raise SystemExit(f'Invalid value for {env_name}: {env_value!r}') from ex
179
+
180
+
181
+ def apply_config_overrides(
182
+ cfg: dict[str, Any], args: Any | None
183
+ ) -> dict[str, Any]:
184
+ """Merge runtime overrides (CLI args + env vars) on top of ``cfg``.
185
+
186
+ ``args`` may be an ``argparse.Namespace``, a ``scfg.DataConfig`` instance,
187
+ or any mapping; it is coerced to a plain dict via ``_as_mapping``.
188
+ """
189
+ if args is None:
190
+ return deepcopy(cfg)
191
+ overrides = _as_mapping(args)
192
+ out = deepcopy(cfg)
193
+ out.setdefault('runtime', {})
194
+ out.setdefault('ports', {})
195
+ out.setdefault('state', {})
196
+ out.setdefault('output', {})
197
+ out.setdefault('cluster', {})
198
+ out['cluster'].setdefault('ingress', {})
199
+
200
+ backend = _arg_or_env(overrides, 'backend', 'INFER_STACK_BACKEND')
201
+ if backend:
202
+ out['backend'] = backend
203
+
204
+ profile = _arg_or_env(overrides, 'profile', 'INFER_STACK_PROFILE')
205
+ if profile:
206
+ out['active_profile'] = profile
207
+
208
+ compose_cmd = _arg_or_env(
209
+ overrides, 'compose_cmd', 'INFER_STACK_COMPOSE_CMD'
210
+ )
211
+ if compose_cmd:
212
+ out['runtime']['compose_cmd'] = compose_cmd
213
+
214
+ litellm_port = overrides.get('litellm_port')
215
+ if litellm_port is None:
216
+ litellm_port = _env_int('INFER_STACK_LITELLM_PORT')
217
+ if litellm_port is not None:
218
+ out['ports']['litellm'] = litellm_port
219
+
220
+ open_webui_port = overrides.get('open_webui_port')
221
+ if open_webui_port is None:
222
+ open_webui_port = _env_int('INFER_STACK_OPEN_WEBUI_PORT')
223
+ if open_webui_port is not None:
224
+ out['ports']['open_webui'] = open_webui_port
225
+
226
+ postgres_port = overrides.get('postgres_port')
227
+ if postgres_port is None:
228
+ postgres_port = _env_int('INFER_STACK_POSTGRES_PORT')
229
+ if postgres_port is not None:
230
+ out['ports']['postgres'] = postgres_port
231
+
232
+ # All rendered artifacts and bind-mount state live under a single root
233
+ # (``--data-dir`` / ``INFER_STACK_DATA_DIR``), which is baked into the
234
+ # absolute ``state.*`` and ``output.generated_dir`` paths at setup time.
235
+ # Granular per-knob path overrides were removed in favour of that one root;
236
+ # edit ``state.*`` / ``output.generated_dir`` in config.yaml directly for
237
+ # bespoke split layouts.
238
+ if not out['output'].get('generated_dir'):
239
+ out['output']['generated_dir'] = default_output_config()[
240
+ 'generated_dir'
241
+ ]
242
+
243
+ namespace = _arg_or_env(overrides, 'namespace', 'INFER_STACK_NAMESPACE')
244
+ if namespace:
245
+ out['cluster']['namespace'] = namespace
246
+
247
+ ingress_host = _arg_or_env(
248
+ overrides, 'ingress_host', 'INFER_STACK_INGRESS_HOST'
249
+ )
250
+ if ingress_host:
251
+ out['cluster']['ingress']['host'] = ingress_host
252
+
253
+ ingress_enabled = overrides.get('ingress_enabled')
254
+ if ingress_enabled is None:
255
+ ingress_enabled = _env_bool('INFER_STACK_INGRESS_ENABLED')
256
+ if ingress_enabled is not None:
257
+ out['cluster']['ingress']['enabled'] = bool(ingress_enabled)
258
+
259
+ return out
260
+
261
+
262
+ _OVERRIDE_ATTRS = (
263
+ 'profile',
264
+ 'backend',
265
+ 'compose_cmd',
266
+ 'litellm_port',
267
+ 'open_webui_port',
268
+ 'postgres_port',
269
+ 'namespace',
270
+ 'ingress_host',
271
+ 'ingress_enabled',
272
+ 'simulate_hardware',
273
+ 'allowed_gpus',
274
+ )
275
+
276
+ _OVERRIDE_ENVS = (
277
+ 'INFER_STACK_BACKEND',
278
+ 'INFER_STACK_PROFILE',
279
+ 'INFER_STACK_COMPOSE_CMD',
280
+ 'INFER_STACK_LITELLM_PORT',
281
+ 'INFER_STACK_OPEN_WEBUI_PORT',
282
+ 'INFER_STACK_POSTGRES_PORT',
283
+ 'INFER_STACK_NAMESPACE',
284
+ 'INFER_STACK_INGRESS_HOST',
285
+ 'INFER_STACK_INGRESS_ENABLED',
286
+ 'INFER_STACK_ALLOWED_GPUS',
287
+ )
288
+
289
+
290
+ def has_runtime_overrides(args: Any | None) -> bool:
291
+ if args is None:
292
+ return False
293
+ overrides = _as_mapping(args)
294
+ if any(overrides.get(attr) is not None for attr in _OVERRIDE_ATTRS):
295
+ return True
296
+ return any(_env_text(name) is not None for name in _OVERRIDE_ENVS)
297
+
298
+
299
+ def effective_allow_unsupported(args: Any | None, cfg: dict[str, Any]) -> bool:
300
+ overrides = _as_mapping(args)
301
+ arg_value = bool(overrides.get('allow_unsupported'))
302
+ policy_value = bool(
303
+ cfg.get('policy', {}).get('allow_unsupported_render', False)
304
+ )
305
+ return arg_value or policy_value
306
+
307
+
308
+ def _parse_allowed_gpus(raw: Any) -> list[int] | None:
309
+ """Parse a comma-separated list of GPU indices, or ``None`` if unset.
310
+
311
+ Accepts ints (when the value comes from ``data=`` kwargs in the
312
+ programmatic API), as well as strings of the form ``"1"`` or ``"1,3"``.
313
+ """
314
+ if raw is None or raw == '':
315
+ return None
316
+ if isinstance(raw, (list, tuple)):
317
+ items = list(raw)
318
+ else:
319
+ items = [x.strip() for x in str(raw).split(',') if x.strip()]
320
+ try:
321
+ return [int(x) for x in items]
322
+ except (TypeError, ValueError) as ex:
323
+ raise SystemExit(
324
+ f'Invalid --allowed-gpus value {raw!r}: expected a comma-separated '
325
+ f"list of integer GPU indices (e.g. '1' or '1,3'). {ex}"
326
+ )
327
+
328
+
329
+ def _filter_inventory_to_allowed(
330
+ inventory: dict[str, Any], allowed: list[int] | None
331
+ ) -> dict[str, Any]:
332
+ """Return a new inventory containing only the GPUs whose ``index`` is in ``allowed``.
333
+
334
+ Real indices are preserved — there is no renumbering — so a profile
335
+ that says ``placement.gpu_indices: [1, 3]`` still pins to physical
336
+ GPUs 1 and 3 after filtering.
337
+ """
338
+ if not allowed:
339
+ return inventory
340
+ allowed_set = set(allowed)
341
+ filtered = [
342
+ g for g in inventory.get('gpus', []) if g.get('index') in allowed_set
343
+ ]
344
+ return {'gpu_count': len(filtered), 'gpus': filtered}
345
+
346
+
347
+ def effective_inventory(args: Any | None) -> dict[str, Any] | None:
348
+ """Build the inventory the resolver should see, honoring CLI / env overrides.
349
+
350
+ Returns ``None`` when nothing is constraining the inventory, so the
351
+ resolver falls back to ``detect_inventory()`` at plan time.
352
+ """
353
+ overrides = _as_mapping(args)
354
+ spec = overrides.get('simulate_hardware')
355
+ allowed = _parse_allowed_gpus(
356
+ overrides.get('allowed_gpus') or _env_text('INFER_STACK_ALLOWED_GPUS')
357
+ )
358
+ if not spec and allowed is None:
359
+ return None
360
+ base = simulate_inventory(spec) if spec else detect_inventory()
361
+ return _filter_inventory_to_allowed(base, allowed)
362
+
363
+
364
+ def config_for_runtime(
365
+ args: Any | None, *, allow_missing: bool = False
366
+ ) -> dict[str, Any]:
367
+ if config_path().exists():
368
+ cfg = load_config()
369
+ elif allow_missing:
370
+ cfg = initial_config()
371
+ else:
372
+ raise SystemExit(
373
+ f'No config.yaml found at {config_path()}. Run '
374
+ '`infer-stack setup --backend compose --profile qwen2-5-7b-instruct-turbo-default` first, '
375
+ f'or point ${CONFIG_DIR_ENV} / --config-dir at an existing config.'
376
+ )
377
+ return apply_config_overrides(cfg, args)
378
+
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # Plan helpers
382
+ # ---------------------------------------------------------------------------
383
+
384
+
385
+ def build_plan(
386
+ cfg: dict[str, Any],
387
+ *,
388
+ profile_name: str | None = None,
389
+ allow_unsupported: bool = False,
390
+ inventory: dict[str, Any] | None = None,
391
+ ) -> dict[str, Any]:
392
+ resolved = resolve(cfg, inventory=inventory, profile_name=profile_name)
393
+ report = validate_resolved(resolved)
394
+ return {
395
+ 'schema_version': 1,
396
+ 'allow_unsupported': bool(allow_unsupported),
397
+ 'validated': report,
398
+ 'deployment': resolved,
399
+ }
400
+
401
+
402
+ def save_plan(plan: dict[str, Any], cfg: dict[str, Any] | None = None) -> Path:
403
+ path = plan_path(cfg)
404
+ save_yaml(path, plan)
405
+ return path
406
+
407
+
408
+ def ensure_renderable(plan: dict[str, Any]) -> None:
409
+ validated = plan.get('validated', {}) or {}
410
+ if validated.get('errors') and not plan.get('allow_unsupported', False):
411
+ raise SystemExit(
412
+ 'Refusing to render because the resolved plan contains validation errors. '
413
+ 'Use `--allow-unsupported` to override.'
414
+ )
415
+
416
+
417
+ def render_is_stale(cfg: dict[str, Any] | None = None) -> bool:
418
+ cfg = load_config() if cfg is None else cfg
419
+ cfg_path = config_path()
420
+ current_plan = plan_path(cfg)
421
+ backend = backend_name(cfg)
422
+
423
+ if backend == 'kubeai':
424
+ kubeai_root = kubeai_generated_dir(cfg)
425
+ required_outputs = [
426
+ current_plan,
427
+ kubeai_root / 'namespace.yaml',
428
+ kubeai_root / 'kubeai-values.yaml',
429
+ kubeai_root / 'models.yaml',
430
+ ]
431
+ else:
432
+ required_outputs = [
433
+ current_plan,
434
+ generated_dir(cfg) / 'docker-compose.yml',
435
+ runtime_env_path(cfg),
436
+ ]
437
+ # litellm_config.yaml is optional now; direct Ollama/raw-server profiles
438
+ # intentionally do not render it.
439
+
440
+ if any(not p.exists() for p in required_outputs):
441
+ return True
442
+
443
+ if cfg_path.exists():
444
+ oldest_generated = min(p.stat().st_mtime for p in required_outputs)
445
+ if cfg_path.stat().st_mtime > oldest_generated:
446
+ return True
447
+ if backend == 'kubeai':
448
+ local_values_path = kubeai_local_values_path()
449
+ if (
450
+ local_values_path.exists()
451
+ and local_values_path.stat().st_mtime > oldest_generated
452
+ ):
453
+ return True
454
+
455
+ if any(
456
+ current_plan.stat().st_mtime > p.stat().st_mtime
457
+ for p in required_outputs
458
+ if p != current_plan
459
+ ):
460
+ return True
461
+ return False
462
+
463
+
464
+ # ---------------------------------------------------------------------------
465
+ def _apply_path_overrides(config: Any) -> None:
466
+ """Honour ``--config-dir`` / ``--data-dir`` from a parsed subcommand config."""
467
+ overrides = _as_mapping(config)
468
+ if overrides.get('config_dir'):
469
+ set_config_root(overrides['config_dir'])
470
+ if overrides.get('data_dir'):
471
+ set_data_root(overrides['data_dir'])
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from ..paths import CONFIG_DIR_ENV
4
+ from ..paths import DATA_DIR_ENV
5
+ import scriptconfig as scfg
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # DataConfig mixins for common override flags
9
+ # ---------------------------------------------------------------------------
10
+
11
+
12
+ class _PathOverridesMixin(scfg.DataConfig):
13
+ """Adds global ``--config-dir`` / ``--data-dir`` to a subcommand."""
14
+
15
+ config_dir = scfg.Value(
16
+ None,
17
+ type=str,
18
+ help=(
19
+ f'Directory containing config.yaml / models.yaml. Defaults to '
20
+ f'~/.config/infer_stack (XDG_CONFIG_HOME) or ${CONFIG_DIR_ENV} when set.'
21
+ ),
22
+ )
23
+ data_dir = scfg.Value(
24
+ None,
25
+ type=str,
26
+ help=(
27
+ f'Directory for rendered artifacts and bind-mount state. Defaults to '
28
+ f'~/.local/share/infer_stack (XDG_DATA_HOME) or ${DATA_DIR_ENV} when set.'
29
+ ),
30
+ )
31
+
32
+
33
+ class _BackendOverrideMixin(scfg.DataConfig):
34
+ backend = scfg.Value(
35
+ None, choices=['compose', 'kubeai'], help='Active backend override.'
36
+ )
37
+
38
+
39
+ class _ComposeOverrideMixin(scfg.DataConfig):
40
+ compose_cmd = scfg.Value(
41
+ None,
42
+ type=str,
43
+ help="Docker compose command override (e.g. 'podman compose').",
44
+ )
45
+
46
+
47
+ class _ProfileOverrideMixin(scfg.DataConfig):
48
+ profile = scfg.Value(
49
+ None,
50
+ type=str,
51
+ help='Active profile override (sets config.active_profile).',
52
+ )
53
+
54
+
55
+ class _PortOverridesMixin(scfg.DataConfig):
56
+ litellm_port = scfg.Value(None, type=int)
57
+ open_webui_port = scfg.Value(None, type=int)
58
+ postgres_port = scfg.Value(None, type=int)
59
+
60
+
61
+ class _ClusterOverridesMixin(scfg.DataConfig):
62
+ namespace = scfg.Value(
63
+ None, type=str, help='Kubernetes namespace for kubeai deployments.'
64
+ )
65
+ ingress_host = scfg.Value(
66
+ None, type=str, help='Ingress host (kubeai only).'
67
+ )
68
+ ingress_enabled = scfg.Value(
69
+ None,
70
+ isflag=True,
71
+ alias=['ingress'],
72
+ help='Enable cluster ingress (kubeai only); use --no-ingress to disable.',
73
+ )
74
+
75
+
76
+ class _AllowUnsupportedMixin(scfg.DataConfig):
77
+ allow_unsupported = scfg.Value(
78
+ False,
79
+ isflag=True,
80
+ help='Allow validation errors when planning/rendering.',
81
+ )
82
+
83
+
84
+ class _SimulateHardwareMixin(scfg.DataConfig):
85
+ simulate_hardware = scfg.Value(
86
+ None,
87
+ type=str,
88
+ help='Simulate N GPUs with M GiB each (e.g. 4x96, 2x80). Useful for planning on smaller machines.',
89
+ )
90
+
91
+
92
+ class _AllowedGpusMixin(scfg.DataConfig):
93
+ allowed_gpus = scfg.Value(
94
+ None,
95
+ type=str,
96
+ help=(
97
+ 'Restrict placement to a comma-separated list of GPU indices '
98
+ "(e.g. '1' or '1,3'). Real indices are preserved — the rendered "
99
+ 'compose stack pins device_ids to exactly those GPUs. May also '
100
+ 'be set via INFER_STACK_ALLOWED_GPUS. Useful for integration '
101
+ 'tests on machines where some GPUs are tied up.'
102
+ ),
103
+ )
104
+
105
+
106
+ class _PlanOverridesCLI(
107
+ _PathOverridesMixin,
108
+ _ProfileOverrideMixin,
109
+ _BackendOverrideMixin,
110
+ _ComposeOverrideMixin,
111
+ _PortOverridesMixin,
112
+ _ClusterOverridesMixin,
113
+ _AllowUnsupportedMixin,
114
+ _SimulateHardwareMixin,
115
+ _AllowedGpusMixin,
116
+ ):
117
+ """Standard set of overrides for any command that builds a plan."""
118
+
119
+ pass
120
+
121
+
122
+ class _SwitchPathOverridesCLI(
123
+ _PathOverridesMixin,
124
+ _BackendOverrideMixin,
125
+ _ComposeOverrideMixin,
126
+ _PortOverridesMixin,
127
+ _ClusterOverridesMixin,
128
+ _AllowUnsupportedMixin,
129
+ _SimulateHardwareMixin,
130
+ _AllowedGpusMixin,
131
+ ):
132
+ """Overrides for commands that take a positional ``profile`` (no --profile)."""
133
+
134
+ pass