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.
- infer_stack/__init__.py +2 -0
- infer_stack/backends/__init__.py +7 -0
- infer_stack/backends/compose_renderer.py +243 -0
- infer_stack/backends/kubeai_renderer.py +202 -0
- infer_stack/benchmark.py +38 -0
- infer_stack/catalog.py +438 -0
- infer_stack/cli/__init__.py +169 -0
- infer_stack/cli/__main__.py +4 -0
- infer_stack/cli/commands_profile.py +467 -0
- infer_stack/cli/commands_runtime.py +719 -0
- infer_stack/cli/commands_smoke.py +691 -0
- infer_stack/cli/compose.py +755 -0
- infer_stack/cli/context.py +471 -0
- infer_stack/cli/options.py +134 -0
- infer_stack/cli/probes.py +178 -0
- infer_stack/config.py +450 -0
- infer_stack/contracts.py +223 -0
- infer_stack/diff_prompt.py +117 -0
- infer_stack/docker_utils.py +230 -0
- infer_stack/env_utils.py +97 -0
- infer_stack/experimental/model_catalog_discover.py +1155 -0
- infer_stack/experimental/model_memory_estimator.py +1264 -0
- infer_stack/experimental/stress_test_long_context.py +397 -0
- infer_stack/hardware.py +70 -0
- infer_stack/kubeai_ops.py +76 -0
- infer_stack/paths.py +87 -0
- infer_stack/profile_runtime.py +46 -0
- infer_stack/renderer.py +19 -0
- infer_stack/resolver.py +1092 -0
- infer_stack/templates/default-models.yaml +674 -0
- infer_stack/templates/default-ollama-models.yaml +31 -0
- infer_stack/templates/default-profiles.yaml +1731 -0
- infer_stack/templates/default-vllm-models.yaml +714 -0
- infer_stack/templates/docker-compose.yml.j2 +430 -0
- infer_stack/templates/litellm_config.yaml.j2 +44 -0
- infer_stack/templates/nginx.conf.j2 +84 -0
- infer_stack/tuning.py +3 -0
- infer_stack/validator.py +314 -0
- infer_stack/verification.py +46 -0
- infer_stack-0.6.0.dist-info/METADATA +1034 -0
- infer_stack-0.6.0.dist-info/RECORD +44 -0
- infer_stack-0.6.0.dist-info/WHEEL +5 -0
- infer_stack-0.6.0.dist-info/entry_points.txt +2 -0
- 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
|