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,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
+ }