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
infer_stack/contracts.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .config import CONFIG_FILE, initial_config, load_yaml
|
|
7
|
+
from .hardware import simulate_inventory
|
|
8
|
+
from .paths import config_root
|
|
9
|
+
from .resolver import resolve
|
|
10
|
+
from .profile_runtime import default_base_url
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _default_access(
|
|
14
|
+
service: dict[str, Any], deployment: dict[str, Any]
|
|
15
|
+
) -> dict[str, Any]:
|
|
16
|
+
backend = str(deployment.get('backend', 'compose')).lower()
|
|
17
|
+
if backend == 'kubeai':
|
|
18
|
+
return {
|
|
19
|
+
'name': 'kubeai-frontdoor',
|
|
20
|
+
'kind': 'openai-compatible',
|
|
21
|
+
'base_url': default_base_url(deployment),
|
|
22
|
+
'request_model_name': service['profile_public_name'],
|
|
23
|
+
'auth_env_name': 'KUBEAI_OPENAI_API_KEY',
|
|
24
|
+
'auth_placeholder': 'EMPTY',
|
|
25
|
+
'auth_required': False,
|
|
26
|
+
'notes': [
|
|
27
|
+
'Use the KubeAI OpenAI-compatible front door for routed requests.'
|
|
28
|
+
],
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
'name': 'compose-router',
|
|
32
|
+
'kind': 'openai-compatible',
|
|
33
|
+
'base_url': default_base_url(deployment),
|
|
34
|
+
'request_model_name': service['served_model_name'],
|
|
35
|
+
'auth_env_name': 'LITELLM_MASTER_KEY',
|
|
36
|
+
'auth_placeholder': 'SET_LITELLM_MASTER_KEY_IN_ENV',
|
|
37
|
+
'auth_required': True,
|
|
38
|
+
'notes': ['Use the LiteLLM router front door for routed requests.'],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _additional_accesses(
|
|
43
|
+
service: dict[str, Any], deployment: dict[str, Any]
|
|
44
|
+
) -> list[dict[str, Any]]:
|
|
45
|
+
benchmark_transport = deepcopy(service.get('benchmark_transport', {}))
|
|
46
|
+
if not benchmark_transport:
|
|
47
|
+
return []
|
|
48
|
+
default = _default_access(service, deployment)
|
|
49
|
+
default_auth_env = (
|
|
50
|
+
'VLLM_API_KEY'
|
|
51
|
+
if benchmark_transport.get('kind') == 'vllm-direct'
|
|
52
|
+
else default['auth_env_name']
|
|
53
|
+
)
|
|
54
|
+
default_auth_placeholder = (
|
|
55
|
+
'EMPTY'
|
|
56
|
+
if benchmark_transport.get('kind') == 'vllm-direct'
|
|
57
|
+
else default['auth_placeholder']
|
|
58
|
+
)
|
|
59
|
+
access = {
|
|
60
|
+
'name': benchmark_transport.get('name')
|
|
61
|
+
or benchmark_transport.get('kind')
|
|
62
|
+
or 'compatibility-access',
|
|
63
|
+
'kind': benchmark_transport.get('kind') or default['kind'],
|
|
64
|
+
'base_url': benchmark_transport.get('base_url') or default['base_url'],
|
|
65
|
+
'request_model_name': benchmark_transport.get('request_model_name')
|
|
66
|
+
or service.get('hf_model_id')
|
|
67
|
+
or service['served_model_name'],
|
|
68
|
+
'auth_env_name': benchmark_transport.get('api_key_env')
|
|
69
|
+
or default_auth_env,
|
|
70
|
+
'auth_placeholder': benchmark_transport.get('api_key_placeholder')
|
|
71
|
+
or default_auth_placeholder,
|
|
72
|
+
'auth_required': bool(
|
|
73
|
+
benchmark_transport.get(
|
|
74
|
+
'api_key_required', default_auth_placeholder != 'EMPTY'
|
|
75
|
+
)
|
|
76
|
+
),
|
|
77
|
+
'notes': [
|
|
78
|
+
'Optional compatibility access hint retained for external integrations.'
|
|
79
|
+
],
|
|
80
|
+
}
|
|
81
|
+
dedupe_key = (
|
|
82
|
+
access['kind'],
|
|
83
|
+
access['base_url'],
|
|
84
|
+
access['request_model_name'],
|
|
85
|
+
)
|
|
86
|
+
default_key = (
|
|
87
|
+
default['kind'],
|
|
88
|
+
default['base_url'],
|
|
89
|
+
default['request_model_name'],
|
|
90
|
+
)
|
|
91
|
+
if dedupe_key == default_key:
|
|
92
|
+
return []
|
|
93
|
+
return [access]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_profile_contract(deployment: dict[str, Any]) -> dict[str, Any]:
|
|
97
|
+
services = []
|
|
98
|
+
for service in deployment.get('services', []):
|
|
99
|
+
services.append(
|
|
100
|
+
{
|
|
101
|
+
'profile_name': service['profile_name'],
|
|
102
|
+
'public_name': service['profile_public_name'],
|
|
103
|
+
'service_name': service['service_name'],
|
|
104
|
+
'kubernetes_name': service['kubernetes_name'],
|
|
105
|
+
'model': {
|
|
106
|
+
'model_ref': service['model_ref'],
|
|
107
|
+
'hf_model_id': service['hf_model_id'],
|
|
108
|
+
'logical_model_name': service['logical_model_name'],
|
|
109
|
+
'served_model_name': service['served_model_name'],
|
|
110
|
+
'served_aliases': deepcopy(
|
|
111
|
+
service.get('served_aliases', [])
|
|
112
|
+
),
|
|
113
|
+
'tokenizer_name': service['tokenizer_name'],
|
|
114
|
+
'modalities': deepcopy(service.get('modalities', [])),
|
|
115
|
+
},
|
|
116
|
+
'protocol': {
|
|
117
|
+
'mode': service['protocol_mode'],
|
|
118
|
+
'engine': service['engine'],
|
|
119
|
+
'features': deepcopy(service.get('features', [])),
|
|
120
|
+
},
|
|
121
|
+
'access': {
|
|
122
|
+
'default': _default_access(service, deployment),
|
|
123
|
+
'additional': _additional_accesses(service, deployment),
|
|
124
|
+
},
|
|
125
|
+
'runtime': {
|
|
126
|
+
'resource_profile': service['resource_profile'],
|
|
127
|
+
'priority_class_name': service.get('priority_class_name'),
|
|
128
|
+
'min_replicas': service['min_replicas'],
|
|
129
|
+
'max_replicas': service['max_replicas'],
|
|
130
|
+
'tensor_parallel_size': service['tensor_parallel_size'],
|
|
131
|
+
'data_parallel_size': service['data_parallel_size'],
|
|
132
|
+
'max_model_len': service['max_model_len'],
|
|
133
|
+
'gpu_memory_utilization': service['gpu_memory_utilization'],
|
|
134
|
+
'max_num_batched_tokens': service['max_num_batched_tokens'],
|
|
135
|
+
'max_num_seqs': service['max_num_seqs'],
|
|
136
|
+
'enable_prefix_caching': service['enable_prefix_caching'],
|
|
137
|
+
'extra_args': deepcopy(service.get('extra_args', [])),
|
|
138
|
+
},
|
|
139
|
+
'placement': {
|
|
140
|
+
'gpu_indices': deepcopy(service.get('gpu_indices', [])),
|
|
141
|
+
'placement': deepcopy(service.get('placement', {})),
|
|
142
|
+
'topology': deepcopy(service.get('topology', {})),
|
|
143
|
+
},
|
|
144
|
+
'notes': deepcopy(service.get('notes', [])),
|
|
145
|
+
'caveats': deepcopy(service.get('audit_notes', [])),
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
return {
|
|
149
|
+
'schema_version': 1,
|
|
150
|
+
'kind': 'serving-profile-contract',
|
|
151
|
+
'backend': deployment['backend'],
|
|
152
|
+
'profile': {
|
|
153
|
+
'name': deployment['serving_profile']['name'],
|
|
154
|
+
'public_name': deployment['serving_profile']['public_name'],
|
|
155
|
+
'description': deployment['serving_profile'].get('description', ''),
|
|
156
|
+
'kubernetes_name': deployment['serving_profile'].get(
|
|
157
|
+
'kubernetes_name', ''
|
|
158
|
+
),
|
|
159
|
+
'service_name': deployment['serving_profile'].get(
|
|
160
|
+
'service_name', ''
|
|
161
|
+
),
|
|
162
|
+
'protocol_mode': deployment['serving_profile'].get(
|
|
163
|
+
'protocol_mode', ''
|
|
164
|
+
),
|
|
165
|
+
'engine': deployment['serving_profile'].get('engine', ''),
|
|
166
|
+
'resource_profile': deployment['serving_profile'].get(
|
|
167
|
+
'resource_profile', ''
|
|
168
|
+
),
|
|
169
|
+
'notes': deepcopy(deployment['serving_profile'].get('notes', [])),
|
|
170
|
+
'caveats': deepcopy(
|
|
171
|
+
deployment['serving_profile'].get('audit_notes', [])
|
|
172
|
+
),
|
|
173
|
+
},
|
|
174
|
+
'router': {
|
|
175
|
+
'type': deployment.get('router', {}).get('type', ''),
|
|
176
|
+
'aliases': deepcopy(
|
|
177
|
+
deployment.get('router', {}).get('aliases', {})
|
|
178
|
+
),
|
|
179
|
+
},
|
|
180
|
+
'services': services,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def describe_profile_contract(
|
|
185
|
+
config: dict[str, Any],
|
|
186
|
+
*,
|
|
187
|
+
resolve_fn,
|
|
188
|
+
profile_name: str | None = None,
|
|
189
|
+
inventory: dict[str, Any] | None = None,
|
|
190
|
+
) -> dict[str, Any]:
|
|
191
|
+
deployment = resolve_fn(
|
|
192
|
+
config, inventory=inventory, profile_name=profile_name
|
|
193
|
+
)
|
|
194
|
+
return build_profile_contract(deployment)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def load_profile_contract(
|
|
198
|
+
profile_name: str,
|
|
199
|
+
*,
|
|
200
|
+
backend: str | None = None,
|
|
201
|
+
simulate_hardware_spec: str | None = None,
|
|
202
|
+
) -> dict[str, Any]:
|
|
203
|
+
config_path = config_root() / CONFIG_FILE
|
|
204
|
+
if config_path.exists():
|
|
205
|
+
config = load_yaml(config_path)
|
|
206
|
+
else:
|
|
207
|
+
config = initial_config()
|
|
208
|
+
config.setdefault('catalog', {})
|
|
209
|
+
config['catalog']['builtin_models'] = True
|
|
210
|
+
config['catalog']['builtin_profiles'] = True
|
|
211
|
+
if backend is not None:
|
|
212
|
+
config['backend'] = backend
|
|
213
|
+
inventory = (
|
|
214
|
+
simulate_inventory(simulate_hardware_spec)
|
|
215
|
+
if simulate_hardware_spec
|
|
216
|
+
else None
|
|
217
|
+
)
|
|
218
|
+
return describe_profile_contract(
|
|
219
|
+
config,
|
|
220
|
+
resolve_fn=resolve,
|
|
221
|
+
profile_name=profile_name,
|
|
222
|
+
inventory=inventory,
|
|
223
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Render-time diff confirmation.
|
|
2
|
+
|
|
3
|
+
Builds a unified diff of every file the renderer is about to overwrite, prints
|
|
4
|
+
each one as its own panel using Rich syntax-highlighting, and asks the user to
|
|
5
|
+
accept the changes interactively unless ``assume_yes=True`` is passed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from difflib import unified_diff
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Mapping
|
|
13
|
+
|
|
14
|
+
from rich.console import Console, Group
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.prompt import Confirm
|
|
17
|
+
from rich.syntax import Syntax
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read(path: Path) -> str:
|
|
22
|
+
if not path.exists():
|
|
23
|
+
return ''
|
|
24
|
+
return path.read_text(encoding='utf-8')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _diff(old: str, new: str, label: Path) -> str:
|
|
28
|
+
return ''.join(
|
|
29
|
+
unified_diff(
|
|
30
|
+
old.splitlines(keepends=True),
|
|
31
|
+
new.splitlines(keepends=True),
|
|
32
|
+
fromfile=f'a/{label}',
|
|
33
|
+
tofile=f'b/{label}',
|
|
34
|
+
n=3,
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def confirm_writes(
|
|
40
|
+
planned: Mapping[Path, str],
|
|
41
|
+
*,
|
|
42
|
+
assume_yes: bool,
|
|
43
|
+
console: Console | None = None,
|
|
44
|
+
title: str = 'Pending render changes',
|
|
45
|
+
) -> bool:
|
|
46
|
+
"""Display a per-file diff and prompt for acceptance.
|
|
47
|
+
|
|
48
|
+
Returns True if the writes should proceed, False if the user declined.
|
|
49
|
+
A planned write whose new content matches the existing file is shown as
|
|
50
|
+
"no changes" and never blocks acceptance on its own.
|
|
51
|
+
"""
|
|
52
|
+
console = console or Console()
|
|
53
|
+
|
|
54
|
+
panels: list[Panel] = []
|
|
55
|
+
any_changes = False
|
|
56
|
+
new_files: list[Path] = []
|
|
57
|
+
modified_files: list[Path] = []
|
|
58
|
+
for path, new_content in planned.items():
|
|
59
|
+
old = _read(path)
|
|
60
|
+
if old == new_content:
|
|
61
|
+
panels.append(
|
|
62
|
+
Panel(
|
|
63
|
+
Text('(no changes)', style='dim'),
|
|
64
|
+
title=str(path),
|
|
65
|
+
border_style='green',
|
|
66
|
+
title_align='left',
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
continue
|
|
70
|
+
any_changes = True
|
|
71
|
+
diff_text = _diff(old, new_content, path)
|
|
72
|
+
if not diff_text:
|
|
73
|
+
# New file — synthesize a diff against an empty original.
|
|
74
|
+
diff_text = _diff('', new_content, path)
|
|
75
|
+
if not old:
|
|
76
|
+
new_files.append(path)
|
|
77
|
+
border = 'yellow'
|
|
78
|
+
tag = '[bold yellow]NEW[/bold yellow]'
|
|
79
|
+
else:
|
|
80
|
+
modified_files.append(path)
|
|
81
|
+
border = 'cyan'
|
|
82
|
+
tag = '[bold cyan]MOD[/bold cyan]'
|
|
83
|
+
body = Syntax(
|
|
84
|
+
diff_text,
|
|
85
|
+
'diff',
|
|
86
|
+
theme='ansi_dark',
|
|
87
|
+
background_color='default',
|
|
88
|
+
word_wrap=False,
|
|
89
|
+
line_numbers=False,
|
|
90
|
+
)
|
|
91
|
+
panels.append(
|
|
92
|
+
Panel(
|
|
93
|
+
body,
|
|
94
|
+
title=f'{tag} {path}',
|
|
95
|
+
border_style=border,
|
|
96
|
+
title_align='left',
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
console.rule(f'[bold]{title}[/bold]')
|
|
101
|
+
console.print(Group(*panels))
|
|
102
|
+
|
|
103
|
+
if not any_changes:
|
|
104
|
+
console.print('[green]No changes to apply.[/green]')
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
summary = (
|
|
108
|
+
f'[cyan]{len(modified_files)} modified[/cyan], '
|
|
109
|
+
f'[yellow]{len(new_files)} new[/yellow]'
|
|
110
|
+
)
|
|
111
|
+
console.print(f'Summary: {summary}')
|
|
112
|
+
|
|
113
|
+
if assume_yes:
|
|
114
|
+
console.print('[green]--yes provided; applying changes.[/green]')
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
return Confirm.ask('Apply these changes?', default=False, console=console)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import json
|
|
5
|
+
import socket
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DockerCommandError(RuntimeError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PortInUseError(RuntimeError):
|
|
15
|
+
"""One or more host ports the stack needs are already bound."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, busy: list[tuple[str, int, str]]):
|
|
18
|
+
self.busy = busy
|
|
19
|
+
lines = ['Cannot start stack: required host ports are already bound.']
|
|
20
|
+
for service, port, host in busy:
|
|
21
|
+
lines.append('')
|
|
22
|
+
lines.append(
|
|
23
|
+
f' {service}: {host}:{port} is already in use. Find the owner with:'
|
|
24
|
+
)
|
|
25
|
+
lines.append(f" ss -tlnp 'sport = :{port}'")
|
|
26
|
+
lines.append(f' sudo lsof -nP -iTCP:{port} -sTCP:LISTEN')
|
|
27
|
+
lines.append(f' docker ps --filter publish={port}')
|
|
28
|
+
lines.append('')
|
|
29
|
+
lines.append(
|
|
30
|
+
'If the conflict is a leftover container from this stack, run '
|
|
31
|
+
'`infer-stack down` (or `docker stop <name> && docker rm <name>`).'
|
|
32
|
+
)
|
|
33
|
+
lines.append(
|
|
34
|
+
'If a non-stack process owns the port, either stop that process or '
|
|
35
|
+
'pick different ports: `infer-stack setup --litellm-port N '
|
|
36
|
+
'--open-webui-port M`, then `infer-stack render --yes`.'
|
|
37
|
+
)
|
|
38
|
+
super().__init__('\n'.join(lines))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def our_published_ports(
|
|
42
|
+
compose_cmd: str, compose_file: Path, env_file: Path
|
|
43
|
+
) -> set[int]:
|
|
44
|
+
"""Return host ports currently published by our own compose project.
|
|
45
|
+
|
|
46
|
+
Used by the pre-flight check to skip ports that are "in use" only because
|
|
47
|
+
one of our containers is already publishing them. Requires
|
|
48
|
+
``docker compose ps --format json`` (compose v2.6+); on older versions or
|
|
49
|
+
when the command otherwise fails this returns an empty set, falling back
|
|
50
|
+
to the strict check.
|
|
51
|
+
"""
|
|
52
|
+
if not compose_file.exists():
|
|
53
|
+
return set()
|
|
54
|
+
cmd = compose_cmd.split() + [
|
|
55
|
+
'-f',
|
|
56
|
+
str(compose_file),
|
|
57
|
+
'--env-file',
|
|
58
|
+
str(env_file),
|
|
59
|
+
'ps',
|
|
60
|
+
'--format',
|
|
61
|
+
'json',
|
|
62
|
+
]
|
|
63
|
+
try:
|
|
64
|
+
proc = subprocess.run(
|
|
65
|
+
cmd, capture_output=True, text=True, check=False, timeout=10
|
|
66
|
+
)
|
|
67
|
+
except (subprocess.SubprocessError, OSError):
|
|
68
|
+
return set()
|
|
69
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
70
|
+
return set()
|
|
71
|
+
ports: set[int] = set()
|
|
72
|
+
# `docker compose ps --format json` emits either one JSON object per line
|
|
73
|
+
# (newer versions) or a single JSON array (older versions). Accept both.
|
|
74
|
+
text = proc.stdout.strip()
|
|
75
|
+
parsed_any = False
|
|
76
|
+
if text.startswith('['):
|
|
77
|
+
try:
|
|
78
|
+
for entry in json.loads(text):
|
|
79
|
+
_collect_published_ports(entry, ports)
|
|
80
|
+
parsed_any = True
|
|
81
|
+
except json.JSONDecodeError:
|
|
82
|
+
return set()
|
|
83
|
+
if not parsed_any:
|
|
84
|
+
for line in text.splitlines():
|
|
85
|
+
line = line.strip()
|
|
86
|
+
if not line:
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
entry = json.loads(line)
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
continue
|
|
92
|
+
_collect_published_ports(entry, ports)
|
|
93
|
+
return ports
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _collect_published_ports(entry: dict, out: set[int]) -> None:
|
|
97
|
+
for pub in entry.get('Publishers') or []:
|
|
98
|
+
port = pub.get('PublishedPort')
|
|
99
|
+
if isinstance(port, int) and port > 0:
|
|
100
|
+
out.add(port)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def check_ports_available(ports: list[tuple[str, int, str]]) -> None:
|
|
104
|
+
"""Pre-flight check: try to bind each (service, port, host) tuple.
|
|
105
|
+
|
|
106
|
+
``host`` is the interface the rendered compose file binds the publication
|
|
107
|
+
on (e.g. ``"0.0.0.0"`` for litellm / open-webui, ``"127.0.0.1"`` for vllm
|
|
108
|
+
services). Binding on ``0.0.0.0`` collides with any listener on any
|
|
109
|
+
interface for that port (including IPv6 ``[::]`` due to
|
|
110
|
+
IPv4-mapped-IPv6), which is the most common cause of the
|
|
111
|
+
``failed to bind port`` errors users hit at ``compose up`` time.
|
|
112
|
+
|
|
113
|
+
Raises ``PortInUseError`` listing every conflicting service so the user
|
|
114
|
+
sees the full picture in one shot, not one failure at a time.
|
|
115
|
+
"""
|
|
116
|
+
busy: list[tuple[str, int, str]] = []
|
|
117
|
+
for service, port, host in ports:
|
|
118
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
119
|
+
try:
|
|
120
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
121
|
+
try:
|
|
122
|
+
sock.bind((host, port))
|
|
123
|
+
except OSError as ex:
|
|
124
|
+
if ex.errno in (errno.EADDRINUSE, errno.EACCES):
|
|
125
|
+
busy.append((service, port, host))
|
|
126
|
+
else:
|
|
127
|
+
raise
|
|
128
|
+
finally:
|
|
129
|
+
sock.close()
|
|
130
|
+
if busy:
|
|
131
|
+
raise PortInUseError(busy)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _cmd(
|
|
135
|
+
compose_cmd: str, compose_file: Path, env_file: Path, *args: str
|
|
136
|
+
) -> list[str]:
|
|
137
|
+
return compose_cmd.split() + [
|
|
138
|
+
'-f',
|
|
139
|
+
str(compose_file),
|
|
140
|
+
'--env-file',
|
|
141
|
+
str(env_file),
|
|
142
|
+
*args,
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run(cmd: list[str]) -> None:
|
|
147
|
+
proc = subprocess.run(cmd)
|
|
148
|
+
if proc.returncode != 0:
|
|
149
|
+
raise DockerCommandError(
|
|
150
|
+
f'Command failed with exit code {proc.returncode}: {" ".join(cmd)}'
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def compose_up(
|
|
155
|
+
compose_cmd: str,
|
|
156
|
+
compose_file: Path,
|
|
157
|
+
env_file: Path,
|
|
158
|
+
*,
|
|
159
|
+
detach: bool = False,
|
|
160
|
+
remove_orphans: bool = True,
|
|
161
|
+
force_recreate: bool = False,
|
|
162
|
+
services: list[str] | None = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
args = ['up']
|
|
165
|
+
if detach:
|
|
166
|
+
args.append('-d')
|
|
167
|
+
if remove_orphans:
|
|
168
|
+
args.append('--remove-orphans')
|
|
169
|
+
if force_recreate:
|
|
170
|
+
args.append('--force-recreate')
|
|
171
|
+
if services:
|
|
172
|
+
args.extend(services)
|
|
173
|
+
run(_cmd(compose_cmd, compose_file, env_file, *args))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def compose_down(compose_cmd: str, compose_file: Path, env_file: Path) -> None:
|
|
177
|
+
"""Stop and remove services. Never removes named volumes."""
|
|
178
|
+
run(_cmd(compose_cmd, compose_file, env_file, 'down', '--remove-orphans'))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def docker_rm_dirs(dirs: list[Path], docker_cmd: str = 'docker') -> None:
|
|
182
|
+
"""Delete host directories that may be root-owned (written by Docker containers).
|
|
183
|
+
|
|
184
|
+
Groups paths by parent directory and removes them from inside a temporary
|
|
185
|
+
Alpine container so that permission errors from user-space rm are avoided.
|
|
186
|
+
"""
|
|
187
|
+
from collections import defaultdict
|
|
188
|
+
|
|
189
|
+
by_parent: dict[Path, list[str]] = defaultdict(list)
|
|
190
|
+
for d in dirs:
|
|
191
|
+
if d.exists():
|
|
192
|
+
by_parent[d.parent].append(d.name)
|
|
193
|
+
|
|
194
|
+
for parent, names in by_parent.items():
|
|
195
|
+
targets = ' '.join(f'/mnt/{name}' for name in names)
|
|
196
|
+
cmd = [
|
|
197
|
+
docker_cmd,
|
|
198
|
+
'run',
|
|
199
|
+
'--rm',
|
|
200
|
+
'-v',
|
|
201
|
+
f'{parent}:/mnt',
|
|
202
|
+
'alpine',
|
|
203
|
+
'sh',
|
|
204
|
+
'-c',
|
|
205
|
+
f'rm -rf {targets}',
|
|
206
|
+
]
|
|
207
|
+
run(cmd)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def compose_recreate_router(
|
|
211
|
+
compose_cmd: str,
|
|
212
|
+
compose_file: Path,
|
|
213
|
+
env_file: Path,
|
|
214
|
+
*,
|
|
215
|
+
detach: bool = True,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Recreate the LiteLLM router container in place.
|
|
218
|
+
|
|
219
|
+
Forces LiteLLM to reload its config from the rendered YAML. Open WebUI is
|
|
220
|
+
deliberately *not* recreated: it re-fetches ``/v1/models`` from LiteLLM
|
|
221
|
+
on user actions, so a brief stale-cache window is fine, while
|
|
222
|
+
force-recreating would log every user out of the chat UI.
|
|
223
|
+
"""
|
|
224
|
+
args = ['up']
|
|
225
|
+
if detach:
|
|
226
|
+
args.append('-d')
|
|
227
|
+
args.extend(
|
|
228
|
+
['--remove-orphans', '--force-recreate', '--no-deps', 'litellm']
|
|
229
|
+
)
|
|
230
|
+
run(_cmd(compose_cmd, compose_file, env_file, *args))
|
infer_stack/env_utils.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_env_file(path: Path) -> dict[str, str]:
|
|
8
|
+
"""Return a flat dict of KEY=value pairs from a .env file.
|
|
9
|
+
|
|
10
|
+
Comments, blank lines, and malformed lines (no '=') are skipped. Both
|
|
11
|
+
managed and unknown keys are returned together.
|
|
12
|
+
"""
|
|
13
|
+
if not path.exists():
|
|
14
|
+
return {}
|
|
15
|
+
data: dict[str, str] = {}
|
|
16
|
+
for line in path.read_text(encoding='utf-8').splitlines():
|
|
17
|
+
stripped = line.strip()
|
|
18
|
+
if not stripped or stripped.startswith('#') or '=' not in stripped:
|
|
19
|
+
continue
|
|
20
|
+
k, v = stripped.split('=', 1)
|
|
21
|
+
data[k.strip()] = v
|
|
22
|
+
return data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ensure_secret(
|
|
26
|
+
env: dict[str, str], key: str, length: int = 32, prefix: str = ''
|
|
27
|
+
) -> str:
|
|
28
|
+
"""Return ``env[key]`` if present (and matches ``prefix``), else a fresh secret.
|
|
29
|
+
|
|
30
|
+
The ``prefix`` requirement is checked against any existing value: if the
|
|
31
|
+
stored value does not start with ``prefix``, a new secret is generated.
|
|
32
|
+
This matters for keys whose downstream consumer enforces a format
|
|
33
|
+
(e.g. LiteLLM rejects auth tokens that don't start with ``sk-``).
|
|
34
|
+
"""
|
|
35
|
+
value = env.get(key, '').strip()
|
|
36
|
+
if value and (not prefix or value.startswith(prefix)):
|
|
37
|
+
return value
|
|
38
|
+
return prefix + secrets.token_urlsafe(length)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_env_lines(text: str) -> list[tuple[str, str | None, str]]:
|
|
42
|
+
"""Tokenize a .env file into ordered records.
|
|
43
|
+
|
|
44
|
+
Each record is a tuple ``(kind, key, raw_line)`` where ``kind`` is one of
|
|
45
|
+
``"kv"`` (a syntactically valid ``KEY=VALUE`` line) or ``"other"`` (a blank
|
|
46
|
+
line, comment, or malformed line). For ``"kv"`` records, ``key`` is the
|
|
47
|
+
variable name; otherwise ``key`` is None. ``raw_line`` is the verbatim
|
|
48
|
+
line (without trailing newline) to preserve formatting on rewrite.
|
|
49
|
+
"""
|
|
50
|
+
records: list[tuple[str, str | None, str]] = []
|
|
51
|
+
for line in text.splitlines():
|
|
52
|
+
stripped = line.strip()
|
|
53
|
+
if not stripped or stripped.startswith('#') or '=' not in stripped:
|
|
54
|
+
records.append(('other', None, line))
|
|
55
|
+
continue
|
|
56
|
+
k, _ = stripped.split('=', 1)
|
|
57
|
+
key = k.strip()
|
|
58
|
+
if not key or any(c.isspace() for c in key):
|
|
59
|
+
records.append(('other', None, line))
|
|
60
|
+
continue
|
|
61
|
+
records.append(('kv', key, line))
|
|
62
|
+
return records
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def write_env_file(path: Path, values: dict[str, str]) -> None:
|
|
66
|
+
"""Write the .env file non-destructively.
|
|
67
|
+
|
|
68
|
+
- Existing unknown ``KEY=value`` pairs are preserved verbatim.
|
|
69
|
+
- Comments and blank lines are preserved in their original positions.
|
|
70
|
+
- Keys present in ``values`` are updated in place (preserving line order).
|
|
71
|
+
- Keys in ``values`` not yet present in the file are appended at the end.
|
|
72
|
+
"""
|
|
73
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
|
|
75
|
+
existing_text = path.read_text(encoding='utf-8') if path.exists() else ''
|
|
76
|
+
records = _parse_env_lines(existing_text)
|
|
77
|
+
|
|
78
|
+
seen: set[str] = set()
|
|
79
|
+
out_lines: list[str] = []
|
|
80
|
+
for kind, key, raw in records:
|
|
81
|
+
if kind == 'kv' and key in values:
|
|
82
|
+
out_lines.append(f'{key}={values[key]}')
|
|
83
|
+
seen.add(key)
|
|
84
|
+
else:
|
|
85
|
+
out_lines.append(raw)
|
|
86
|
+
|
|
87
|
+
for key, value in values.items():
|
|
88
|
+
if key in seen:
|
|
89
|
+
continue
|
|
90
|
+
out_lines.append(f'{key}={value}')
|
|
91
|
+
|
|
92
|
+
text = '\n'.join(out_lines)
|
|
93
|
+
if text and not text.endswith('\n'):
|
|
94
|
+
text += '\n'
|
|
95
|
+
|
|
96
|
+
print(f'Write .env to {path}')
|
|
97
|
+
path.write_text(text, encoding='utf-8')
|