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,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _default_model_for_deployment(
|
|
8
|
+
deployment: dict[str, Any], explicit: str | None = None
|
|
9
|
+
) -> str | None:
|
|
10
|
+
"""Pick a reasonable model name for readiness/smoke probes."""
|
|
11
|
+
if explicit:
|
|
12
|
+
return str(explicit)
|
|
13
|
+
litellm_routes = (
|
|
14
|
+
(deployment.get('gateways', {}) or {}).get('litellm', {}) or {}
|
|
15
|
+
).get('routes', {}) or {}
|
|
16
|
+
if litellm_routes:
|
|
17
|
+
return str(next(iter(litellm_routes)))
|
|
18
|
+
vllm_runtimes = (
|
|
19
|
+
(deployment.get('providers', {}) or {}).get('vllm', {}) or {}
|
|
20
|
+
).get('runtimes', {}) or {}
|
|
21
|
+
if vllm_runtimes:
|
|
22
|
+
first = next(iter(vllm_runtimes.values()))
|
|
23
|
+
return (
|
|
24
|
+
str(
|
|
25
|
+
first.get('served_model_name')
|
|
26
|
+
or first.get('logical_model_name')
|
|
27
|
+
or first.get('runtime_name')
|
|
28
|
+
or ''
|
|
29
|
+
)
|
|
30
|
+
or None
|
|
31
|
+
)
|
|
32
|
+
ollama_routes = (
|
|
33
|
+
(deployment.get('providers', {}) or {}).get('ollama', {}) or {}
|
|
34
|
+
).get('routes', {}) or {}
|
|
35
|
+
if ollama_routes:
|
|
36
|
+
first = next(iter(ollama_routes.values()))
|
|
37
|
+
return (
|
|
38
|
+
str(first.get('upstream_model') or first.get('model_ref') or '')
|
|
39
|
+
or None
|
|
40
|
+
)
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_smoke_protocol_from_deployment(
|
|
45
|
+
deployment: dict[str, Any], model_name: str | None
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Resolve chat vs completions from schema-v5 routes/runtimes."""
|
|
48
|
+
if model_name:
|
|
49
|
+
routes = (
|
|
50
|
+
(deployment.get('gateways', {}) or {}).get('litellm', {}) or {}
|
|
51
|
+
).get('routes', {}) or {}
|
|
52
|
+
route = routes.get(model_name)
|
|
53
|
+
if route:
|
|
54
|
+
return str(route.get('protocol_mode') or 'chat')
|
|
55
|
+
vllm_runtimes = (
|
|
56
|
+
(deployment.get('providers', {}) or {}).get('vllm', {}) or {}
|
|
57
|
+
).get('runtimes', {}) or {}
|
|
58
|
+
for rt in vllm_runtimes.values():
|
|
59
|
+
aliases = set(rt.get('served_aliases') or [])
|
|
60
|
+
aliases.add(str(rt.get('served_model_name') or ''))
|
|
61
|
+
if model_name in aliases:
|
|
62
|
+
return str(rt.get('protocol_mode') or 'chat')
|
|
63
|
+
return 'chat'
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ready_openai_probe(
|
|
67
|
+
*,
|
|
68
|
+
base_url: str,
|
|
69
|
+
headers: dict[str, str],
|
|
70
|
+
model: str | None,
|
|
71
|
+
protocol: str,
|
|
72
|
+
prompt: str,
|
|
73
|
+
max_tokens: int,
|
|
74
|
+
require_generation: bool,
|
|
75
|
+
) -> tuple[bool, str]:
|
|
76
|
+
"""Probe an OpenAI-compatible surface once without exiting."""
|
|
77
|
+
try:
|
|
78
|
+
models_resp = requests.get(
|
|
79
|
+
f'{base_url}/models', headers=headers, timeout=10
|
|
80
|
+
)
|
|
81
|
+
except requests.exceptions.RequestException as ex:
|
|
82
|
+
return False, f'/models not reachable yet: {ex}'
|
|
83
|
+
if models_resp.status_code >= 400:
|
|
84
|
+
body = (models_resp.text or '').strip()
|
|
85
|
+
return (
|
|
86
|
+
False,
|
|
87
|
+
f'/models returned HTTP {models_resp.status_code}: {body[:300]}',
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
models_doc = models_resp.json()
|
|
91
|
+
except ValueError:
|
|
92
|
+
return False, '/models returned non-JSON response'
|
|
93
|
+
models = models_doc.get('data') or []
|
|
94
|
+
model_name = model or (models[0].get('id') if models else None)
|
|
95
|
+
if not model_name:
|
|
96
|
+
return False, '/models is reachable but no models are advertised'
|
|
97
|
+
if not require_generation:
|
|
98
|
+
return True, f'/models is ready; selected model {model_name}'
|
|
99
|
+
if protocol == 'completions':
|
|
100
|
+
payload = {
|
|
101
|
+
'model': model_name,
|
|
102
|
+
'prompt': prompt,
|
|
103
|
+
'max_tokens': max_tokens,
|
|
104
|
+
}
|
|
105
|
+
endpoint = f'{base_url}/completions'
|
|
106
|
+
else:
|
|
107
|
+
payload = {
|
|
108
|
+
'model': model_name,
|
|
109
|
+
'messages': [{'role': 'user', 'content': prompt}],
|
|
110
|
+
'max_tokens': max_tokens,
|
|
111
|
+
}
|
|
112
|
+
endpoint = f'{base_url}/chat/completions'
|
|
113
|
+
try:
|
|
114
|
+
resp = requests.post(
|
|
115
|
+
endpoint, headers=headers, json=payload, timeout=45
|
|
116
|
+
)
|
|
117
|
+
except requests.exceptions.RequestException as ex:
|
|
118
|
+
return False, f'{endpoint} not serving yet: {ex}'
|
|
119
|
+
if resp.status_code >= 400:
|
|
120
|
+
body = (resp.text or '').strip()
|
|
121
|
+
return (
|
|
122
|
+
False,
|
|
123
|
+
f'{endpoint} returned HTTP {resp.status_code}: {body[:300]}',
|
|
124
|
+
)
|
|
125
|
+
return True, f'{model_name} served a {protocol} probe'
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _ready_ollama_probe(
|
|
129
|
+
*,
|
|
130
|
+
base_url: str,
|
|
131
|
+
model: str | None,
|
|
132
|
+
prompt: str,
|
|
133
|
+
max_tokens: int,
|
|
134
|
+
require_generation: bool,
|
|
135
|
+
) -> tuple[bool, str]:
|
|
136
|
+
"""Probe an Ollama-native surface once without exiting."""
|
|
137
|
+
try:
|
|
138
|
+
tags_resp = requests.get(f'{base_url}/api/tags', timeout=10)
|
|
139
|
+
except requests.exceptions.RequestException as ex:
|
|
140
|
+
return False, f'/api/tags not reachable yet: {ex}'
|
|
141
|
+
if tags_resp.status_code >= 400:
|
|
142
|
+
body = (tags_resp.text or '').strip()
|
|
143
|
+
return (
|
|
144
|
+
False,
|
|
145
|
+
f'/api/tags returned HTTP {tags_resp.status_code}: {body[:300]}',
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
tags_doc = tags_resp.json()
|
|
149
|
+
except ValueError:
|
|
150
|
+
return False, '/api/tags returned non-JSON response'
|
|
151
|
+
models = tags_doc.get('models') or []
|
|
152
|
+
model_name = model or (models[0].get('name') if models else None)
|
|
153
|
+
if not model_name:
|
|
154
|
+
if require_generation:
|
|
155
|
+
return (
|
|
156
|
+
False,
|
|
157
|
+
'Ollama is reachable but no model is installed; run `infer-stack ollama-pull <tag>`',
|
|
158
|
+
)
|
|
159
|
+
return True, 'Ollama API is reachable'
|
|
160
|
+
if not require_generation:
|
|
161
|
+
return True, f'Ollama API is reachable; selected model {model_name}'
|
|
162
|
+
payload = {
|
|
163
|
+
'model': model_name,
|
|
164
|
+
'messages': [{'role': 'user', 'content': prompt}],
|
|
165
|
+
'stream': False,
|
|
166
|
+
'options': {'num_predict': max_tokens},
|
|
167
|
+
}
|
|
168
|
+
try:
|
|
169
|
+
resp = requests.post(f'{base_url}/api/chat', json=payload, timeout=45)
|
|
170
|
+
except requests.exceptions.RequestException as ex:
|
|
171
|
+
return False, f'/api/chat not serving yet: {ex}'
|
|
172
|
+
if resp.status_code >= 400:
|
|
173
|
+
body = (resp.text or '').strip()
|
|
174
|
+
return (
|
|
175
|
+
False,
|
|
176
|
+
f'/api/chat returned HTTP {resp.status_code}: {body[:300]}',
|
|
177
|
+
)
|
|
178
|
+
return True, f'{model_name} served an Ollama chat probe'
|
infer_stack/config.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from .catalog import (
|
|
11
|
+
normalize_ollama_models,
|
|
12
|
+
normalize_stack_profiles,
|
|
13
|
+
normalize_vllm_models,
|
|
14
|
+
)
|
|
15
|
+
from .paths import config_root, data_root
|
|
16
|
+
|
|
17
|
+
CONFIG_FILE = Path('config.yaml')
|
|
18
|
+
MODELS_FILE = Path('models.yaml')
|
|
19
|
+
|
|
20
|
+
# Filenames/sub-paths inside whatever the resolved generated directory is.
|
|
21
|
+
# The directory itself defaults to ``<data-dir>/generated`` and is relocated
|
|
22
|
+
# by pointing ``--data-dir`` / ``INFER_STACK_DATA_DIR`` at a new root at setup
|
|
23
|
+
# time (or by editing ``output.generated_dir`` in config.yaml directly).
|
|
24
|
+
GENERATED_DIR_NAME = 'generated'
|
|
25
|
+
PLAN_FILENAME = 'plan.yaml'
|
|
26
|
+
KUBEAI_GENERATED_SUBDIR = 'kubeai'
|
|
27
|
+
KUBEAI_VALUES_FILENAME = 'kubeai-values.yaml'
|
|
28
|
+
KUBEAI_LOCAL_VALUES_FILENAME = 'kubeai-values.local.yaml'
|
|
29
|
+
|
|
30
|
+
PINNED_IMAGES = {
|
|
31
|
+
'postgres': 'postgres:16.8',
|
|
32
|
+
'open_webui': 'ghcr.io/open-webui/open-webui:v0.8.6',
|
|
33
|
+
'litellm': 'ghcr.io/berriai/litellm:v1.82.3-stable',
|
|
34
|
+
'vllm': 'vllm/vllm-openai:v0.19.1',
|
|
35
|
+
'ollama': 'ollama/ollama:latest',
|
|
36
|
+
'nginx': 'nginx:1.29.7-alpine',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
DEFAULT_PORTS = {
|
|
40
|
+
'litellm': 14042,
|
|
41
|
+
'open_webui': 13000,
|
|
42
|
+
'postgres': 15432,
|
|
43
|
+
'ollama': 11434,
|
|
44
|
+
'reverse_proxy_http': 80,
|
|
45
|
+
'reverse_proxy_https': 443,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _default_storage_root() -> Path:
|
|
50
|
+
"""Default parent for ``state.*`` paths (hf-cache, postgres volumes, etc.)."""
|
|
51
|
+
return data_root()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def default_state_paths() -> dict[str, str]:
|
|
55
|
+
storage_root = _default_storage_root()
|
|
56
|
+
return {
|
|
57
|
+
'hf_cache': str(storage_root / 'hf-cache'),
|
|
58
|
+
'vllm_cache': str(storage_root / 'vllm-cache'),
|
|
59
|
+
'torch_cache': str(storage_root / 'torch-cache'),
|
|
60
|
+
'triton_cache': str(storage_root / 'triton-cache'),
|
|
61
|
+
'cuda_cache': str(storage_root / 'cuda-cache'),
|
|
62
|
+
'open_webui': str(storage_root / 'open-webui'),
|
|
63
|
+
'postgres_open_webui': str(storage_root / 'postgres-open-webui'),
|
|
64
|
+
'postgres_litellm': str(storage_root / 'postgres-litellm'),
|
|
65
|
+
'ollama': str(storage_root / 'ollama'),
|
|
66
|
+
'runtime': str(storage_root / 'runtime'),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _default_generated_dir() -> Path:
|
|
71
|
+
return data_root() / GENERATED_DIR_NAME
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def default_output_config() -> dict[str, str]:
|
|
75
|
+
return {'generated_dir': str(_default_generated_dir())}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def normalized_output(output_cfg: dict[str, Any] | None) -> dict[str, str]:
|
|
79
|
+
"""Resolve the output section to absolute paths.
|
|
80
|
+
|
|
81
|
+
Relative ``generated_dir`` values are anchored on ``data_root()`` so
|
|
82
|
+
that a config that says ``generated_dir: generated`` lands at
|
|
83
|
+
``<data_root>/generated`` regardless of where ``infer-stack`` is
|
|
84
|
+
invoked from.
|
|
85
|
+
"""
|
|
86
|
+
normalized = deepcopy(default_output_config())
|
|
87
|
+
raw = (output_cfg or {}).get('generated_dir')
|
|
88
|
+
candidate = Path(raw) if raw else Path(normalized['generated_dir'])
|
|
89
|
+
if not candidate.is_absolute():
|
|
90
|
+
candidate = data_root() / candidate
|
|
91
|
+
normalized['generated_dir'] = str(candidate)
|
|
92
|
+
return normalized
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def generated_dir_for_config(cfg: dict[str, Any]) -> Path:
|
|
96
|
+
return Path(normalized_output(cfg.get('output', {}))['generated_dir'])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def plan_path_for_config(cfg: dict[str, Any]) -> Path:
|
|
100
|
+
return generated_dir_for_config(cfg) / PLAN_FILENAME
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def kubeai_generated_dir_for_config(cfg: dict[str, Any]) -> Path:
|
|
104
|
+
return generated_dir_for_config(cfg) / KUBEAI_GENERATED_SUBDIR
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def kubeai_values_path_for_config(cfg: dict[str, Any]) -> Path:
|
|
108
|
+
return kubeai_generated_dir_for_config(cfg) / KUBEAI_VALUES_FILENAME
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def default_cluster_config() -> dict[str, Any]:
|
|
112
|
+
return {
|
|
113
|
+
'namespace': 'kubeai',
|
|
114
|
+
'kubeai_release_name': 'kubeai',
|
|
115
|
+
'kubeai_chart': 'kubeai/kubeai',
|
|
116
|
+
'service_name': 'kubeai',
|
|
117
|
+
'ingress': {
|
|
118
|
+
'enabled': False,
|
|
119
|
+
'class_name': 'traefik',
|
|
120
|
+
'host': '',
|
|
121
|
+
'path_prefix': '/',
|
|
122
|
+
'tls_secret_name': '',
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def default_resource_profiles() -> dict[str, Any]:
|
|
128
|
+
return {
|
|
129
|
+
'gpu-single-default': {
|
|
130
|
+
'limits': {'nvidia.com/gpu': 1},
|
|
131
|
+
'requests': {'nvidia.com/gpu': 1},
|
|
132
|
+
},
|
|
133
|
+
'gpu-tp2-balanced': {
|
|
134
|
+
'limits': {'nvidia.com/gpu': 2},
|
|
135
|
+
'requests': {'nvidia.com/gpu': 2},
|
|
136
|
+
},
|
|
137
|
+
'gpu-tp2-maxctx': {
|
|
138
|
+
'limits': {'nvidia.com/gpu': 2},
|
|
139
|
+
'requests': {'nvidia.com/gpu': 2},
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def kubeai_local_values_path() -> Path:
|
|
145
|
+
"""Location of the user-editable ``kubeai-values.local.yaml``."""
|
|
146
|
+
return config_root() / KUBEAI_LOCAL_VALUES_FILENAME
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def resource_profiles_to_kubeai_values(
|
|
150
|
+
resource_profiles: dict[str, Any] | None,
|
|
151
|
+
) -> dict[str, Any]:
|
|
152
|
+
values: dict[str, Any] = {'resourceProfiles': {}}
|
|
153
|
+
for name, spec in (resource_profiles or {}).items():
|
|
154
|
+
item: dict[str, Any] = {}
|
|
155
|
+
if spec.get('node_selector'):
|
|
156
|
+
item['nodeSelector'] = deepcopy(spec['node_selector'])
|
|
157
|
+
if spec.get('requests'):
|
|
158
|
+
item['requests'] = deepcopy(spec['requests'])
|
|
159
|
+
if spec.get('limits'):
|
|
160
|
+
item['limits'] = deepcopy(spec['limits'])
|
|
161
|
+
if spec.get('tolerations'):
|
|
162
|
+
item['tolerations'] = deepcopy(spec['tolerations'])
|
|
163
|
+
if spec.get('runtime_class_name'):
|
|
164
|
+
item['runtimeClassName'] = spec['runtime_class_name']
|
|
165
|
+
if spec.get('scheduler_name'):
|
|
166
|
+
item['schedulerName'] = spec['scheduler_name']
|
|
167
|
+
if spec.get('image_name'):
|
|
168
|
+
item['imageName'] = spec['image_name']
|
|
169
|
+
values['resourceProfiles'][name] = item
|
|
170
|
+
return values
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def kubeai_values_to_resource_profiles(
|
|
174
|
+
values_doc: dict[str, Any] | None,
|
|
175
|
+
) -> dict[str, Any]:
|
|
176
|
+
profiles: dict[str, Any] = {}
|
|
177
|
+
for name, spec in (
|
|
178
|
+
(values_doc or {}).get('resourceProfiles', {}) or {}
|
|
179
|
+
).items():
|
|
180
|
+
profiles[name] = deepcopy(spec)
|
|
181
|
+
return profiles
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def load_kubeai_resource_profiles() -> tuple[
|
|
185
|
+
dict[str, Any], dict[str, Any], Path
|
|
186
|
+
]:
|
|
187
|
+
path = kubeai_local_values_path()
|
|
188
|
+
if not path.exists():
|
|
189
|
+
return {}, {}, path
|
|
190
|
+
values_doc = load_yaml(path)
|
|
191
|
+
return kubeai_values_to_resource_profiles(values_doc), values_doc, path
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def save_kubeai_resource_profiles(values_doc: dict[str, Any]) -> Path:
|
|
195
|
+
path = kubeai_local_values_path()
|
|
196
|
+
save_yaml(path, values_doc)
|
|
197
|
+
return path
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def normalized_state(state: dict[str, Any] | None) -> dict[str, str]:
|
|
201
|
+
"""Resolve ``state.*`` to absolute paths.
|
|
202
|
+
|
|
203
|
+
Relative values are anchored on ``data_root()`` so that bind-mount
|
|
204
|
+
locations don't depend on where ``infer-stack`` was invoked from.
|
|
205
|
+
"""
|
|
206
|
+
normalized = deepcopy(default_state_paths())
|
|
207
|
+
anchor = data_root()
|
|
208
|
+
for key, value in (state or {}).items():
|
|
209
|
+
if value in (None, ''):
|
|
210
|
+
continue
|
|
211
|
+
p = Path(value)
|
|
212
|
+
if not p.is_absolute():
|
|
213
|
+
p = anchor / p
|
|
214
|
+
normalized[key] = str(p)
|
|
215
|
+
return normalized
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def normalized_cluster(config: dict[str, Any] | None) -> dict[str, Any]:
|
|
219
|
+
return deep_merge(default_cluster_config(), config or {})
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def load_yaml(path: Path) -> dict[str, Any]:
|
|
223
|
+
return yaml.safe_load(path.read_text(encoding='utf-8')) or {}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def save_yaml(path: Path, data: dict[str, Any]) -> None:
|
|
227
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding='utf-8')
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _load_template_yaml(name: str) -> dict[str, Any]:
|
|
232
|
+
text = (
|
|
233
|
+
files('infer_stack')
|
|
234
|
+
.joinpath(f'templates/{name}')
|
|
235
|
+
.read_text(encoding='utf-8')
|
|
236
|
+
)
|
|
237
|
+
return yaml.safe_load(text) or {}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def builtin_vllm_models_catalog() -> dict[str, Any]:
|
|
241
|
+
return _load_template_yaml('default-vllm-models.yaml')
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def builtin_ollama_models_catalog() -> dict[str, Any]:
|
|
245
|
+
return _load_template_yaml('default-ollama-models.yaml')
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def builtin_profiles_catalog() -> dict[str, Any]:
|
|
249
|
+
return _load_template_yaml('default-profiles.yaml')
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# Backwards-compatible helper name for callers that only know about vLLM models.
|
|
253
|
+
def builtin_models_catalog() -> dict[str, Any]:
|
|
254
|
+
return builtin_vllm_models_catalog()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def deep_merge(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
|
|
258
|
+
out = deepcopy(a)
|
|
259
|
+
for k, v in (b or {}).items():
|
|
260
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
261
|
+
out[k] = deep_merge(out[k], v)
|
|
262
|
+
else:
|
|
263
|
+
out[k] = deepcopy(v)
|
|
264
|
+
return out
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def merged_catalogs(config: dict[str, Any]) -> dict[str, Any]:
|
|
268
|
+
catalog_cfg = config.get('catalog', {})
|
|
269
|
+
built_vllm = (
|
|
270
|
+
builtin_vllm_models_catalog()
|
|
271
|
+
if catalog_cfg.get('builtin_models', True)
|
|
272
|
+
else {}
|
|
273
|
+
)
|
|
274
|
+
built_ollama = (
|
|
275
|
+
builtin_ollama_models_catalog()
|
|
276
|
+
if catalog_cfg.get('builtin_models', True)
|
|
277
|
+
else {}
|
|
278
|
+
)
|
|
279
|
+
built_profiles = (
|
|
280
|
+
builtin_profiles_catalog()
|
|
281
|
+
if catalog_cfg.get('builtin_profiles', True)
|
|
282
|
+
else {}
|
|
283
|
+
)
|
|
284
|
+
raw_user_models = catalog_cfg.get('user_models_file', str(MODELS_FILE))
|
|
285
|
+
user_models_path = Path(raw_user_models)
|
|
286
|
+
if not user_models_path.is_absolute():
|
|
287
|
+
user_models_path = config_root() / user_models_path
|
|
288
|
+
user = load_yaml(user_models_path) if user_models_path.exists() else {}
|
|
289
|
+
|
|
290
|
+
# User files may use the new provider-specific keys or the old generic
|
|
291
|
+
# `models` key, which is interpreted as vLLM models.
|
|
292
|
+
vllm_models = deep_merge(
|
|
293
|
+
built_vllm.get('vllm_models', built_vllm.get('models', {})),
|
|
294
|
+
user.get('vllm_models', user.get('models', {})),
|
|
295
|
+
)
|
|
296
|
+
ollama_models = deep_merge(
|
|
297
|
+
built_ollama.get('ollama_models', {}), user.get('ollama_models', {})
|
|
298
|
+
)
|
|
299
|
+
profiles = deep_merge(
|
|
300
|
+
built_profiles.get('profiles', {}), user.get('profiles', {})
|
|
301
|
+
)
|
|
302
|
+
profiles = deep_merge(profiles, config.get('profiles', {}))
|
|
303
|
+
return {
|
|
304
|
+
'vllm_models': vllm_models,
|
|
305
|
+
'ollama_models': ollama_models,
|
|
306
|
+
'profiles': profiles,
|
|
307
|
+
# Compatibility view.
|
|
308
|
+
'models': vllm_models,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def normalized_catalogs(config: dict[str, Any]) -> dict[str, Any]:
|
|
313
|
+
catalogs = merged_catalogs(config)
|
|
314
|
+
vllm_models = normalize_vllm_models(catalogs.get('vllm_models', {}))
|
|
315
|
+
ollama_models = normalize_ollama_models(catalogs.get('ollama_models', {}))
|
|
316
|
+
profiles = normalize_stack_profiles(
|
|
317
|
+
catalogs.get('profiles', {}), vllm_models, ollama_models
|
|
318
|
+
)
|
|
319
|
+
return {
|
|
320
|
+
'vllm_models': vllm_models,
|
|
321
|
+
'ollama_models': ollama_models,
|
|
322
|
+
'models': vllm_models,
|
|
323
|
+
'profiles': profiles,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def initial_config() -> dict[str, Any]:
|
|
328
|
+
# SmolLM2-135M is tiny (~270 MB) and runs on a single small GPU, so it is a
|
|
329
|
+
# safe out-of-the-box default regardless of detected hardware.
|
|
330
|
+
return {
|
|
331
|
+
'name': 'local-llm-stack',
|
|
332
|
+
'backend': 'compose',
|
|
333
|
+
'active_profile': 'smollm2-135m-single',
|
|
334
|
+
'catalog': {
|
|
335
|
+
'builtin_models': True,
|
|
336
|
+
'builtin_profiles': True,
|
|
337
|
+
'user_models_file': str(MODELS_FILE),
|
|
338
|
+
},
|
|
339
|
+
'policy': {
|
|
340
|
+
'require_fit_validation': True,
|
|
341
|
+
'reserve_display_gpu': 'auto',
|
|
342
|
+
'forbid_reserved_gpu_use': False,
|
|
343
|
+
'require_homogeneous_multi_gpu_groups': True,
|
|
344
|
+
'minimum_vram_headroom_gib': 2,
|
|
345
|
+
'allow_unsupported_render': False,
|
|
346
|
+
},
|
|
347
|
+
'runtime': {
|
|
348
|
+
'compose_cmd': 'docker compose',
|
|
349
|
+
'target_inventory': 'auto',
|
|
350
|
+
},
|
|
351
|
+
'providers': {
|
|
352
|
+
'ollama': {'enabled': 'auto'},
|
|
353
|
+
'vllm': {'enabled': 'auto'},
|
|
354
|
+
},
|
|
355
|
+
'gateways': {
|
|
356
|
+
'litellm': {'enabled': 'auto'},
|
|
357
|
+
},
|
|
358
|
+
'frontends': {
|
|
359
|
+
'open_webui': {'enabled': 'auto'},
|
|
360
|
+
'reverse_proxy': {'enabled': False},
|
|
361
|
+
},
|
|
362
|
+
'ollama': {
|
|
363
|
+
'publish_port': False,
|
|
364
|
+
'host': '0.0.0.0:11434',
|
|
365
|
+
'keep_alive': '2m',
|
|
366
|
+
'context_length': 4096,
|
|
367
|
+
'num_parallel': 1,
|
|
368
|
+
'max_loaded_models': 1,
|
|
369
|
+
'max_queue': 8,
|
|
370
|
+
'gpu_indices': 'auto',
|
|
371
|
+
'extra_env': {},
|
|
372
|
+
'env_file': [],
|
|
373
|
+
'extra_volumes': [],
|
|
374
|
+
'extra_hosts': [],
|
|
375
|
+
'labels': {},
|
|
376
|
+
},
|
|
377
|
+
'ports': deepcopy(DEFAULT_PORTS),
|
|
378
|
+
'images': deepcopy(PINNED_IMAGES),
|
|
379
|
+
'state': default_state_paths(),
|
|
380
|
+
'output': default_output_config(),
|
|
381
|
+
'open_webui': {
|
|
382
|
+
'auth': False,
|
|
383
|
+
'provider': 'auto',
|
|
384
|
+
'publish_port': True,
|
|
385
|
+
'extra_env': {},
|
|
386
|
+
'env_file': [],
|
|
387
|
+
'extra_volumes': [],
|
|
388
|
+
'extra_hosts': [],
|
|
389
|
+
'labels': {},
|
|
390
|
+
'ldap': {'enabled': False},
|
|
391
|
+
},
|
|
392
|
+
'reverse_proxy': {
|
|
393
|
+
'enabled': False,
|
|
394
|
+
'image': '',
|
|
395
|
+
'container_name': 'reverse-proxy',
|
|
396
|
+
'target': 'open_webui',
|
|
397
|
+
'server_name': 'localhost',
|
|
398
|
+
'publish_http': True,
|
|
399
|
+
'publish_https': True,
|
|
400
|
+
'http_port': None,
|
|
401
|
+
'https_port': None,
|
|
402
|
+
'http_bind_host': '',
|
|
403
|
+
'https_bind_host': '',
|
|
404
|
+
'force_https': True,
|
|
405
|
+
'client_max_body_size': '1G',
|
|
406
|
+
'proxy_connect_timeout': '60s',
|
|
407
|
+
'proxy_read_timeout': '600s',
|
|
408
|
+
'proxy_send_timeout': '600s',
|
|
409
|
+
'proxy_buffer_size': '128k',
|
|
410
|
+
'proxy_buffers': '4 256k',
|
|
411
|
+
'proxy_busy_buffers_size': '256k',
|
|
412
|
+
'proxy_buffering': None,
|
|
413
|
+
'proxy_cache': None,
|
|
414
|
+
'resolver': [],
|
|
415
|
+
'resolver_timeout': '5s',
|
|
416
|
+
'hsts': {
|
|
417
|
+
'enabled': True,
|
|
418
|
+
'max_age': 63072000,
|
|
419
|
+
'include_subdomains': True,
|
|
420
|
+
'preload': False,
|
|
421
|
+
},
|
|
422
|
+
'ssl': {
|
|
423
|
+
'enabled': True,
|
|
424
|
+
'certificate': '',
|
|
425
|
+
'certificate_key': '',
|
|
426
|
+
'dhparam': '',
|
|
427
|
+
'certificate_container_path': '/etc/ssl/certs/infer-stack-site.crt',
|
|
428
|
+
'certificate_key_container_path': '/etc/ssl/private/infer-stack-site.key',
|
|
429
|
+
'dhparam_container_path': '/etc/ssl/certs/dhparam.pem',
|
|
430
|
+
'protocols': 'TLSv1.2 TLSv1.3',
|
|
431
|
+
'ciphers': 'ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384',
|
|
432
|
+
'prefer_server_ciphers': True,
|
|
433
|
+
'session_cache': 'shared:SSL:10m',
|
|
434
|
+
'ecdh_curve': 'secp384r1',
|
|
435
|
+
'session_tickets': False,
|
|
436
|
+
'stapling': True,
|
|
437
|
+
'stapling_verify': True,
|
|
438
|
+
},
|
|
439
|
+
'config_path': '',
|
|
440
|
+
'extra_config': '',
|
|
441
|
+
'extra_env': {},
|
|
442
|
+
'env_file': [],
|
|
443
|
+
'extra_volumes': [],
|
|
444
|
+
'extra_hosts': [],
|
|
445
|
+
'labels': {},
|
|
446
|
+
},
|
|
447
|
+
'cluster': default_cluster_config(),
|
|
448
|
+
'resource_profiles': default_resource_profiles(),
|
|
449
|
+
'profiles': {},
|
|
450
|
+
}
|