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,1092 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+ from .catalog import (
7
+ canonical_profile_name,
8
+ normalize_ollama_models,
9
+ normalize_stack_profiles,
10
+ normalize_vllm_models,
11
+ sanitize_name,
12
+ )
13
+ from .config import (
14
+ DEFAULT_PORTS,
15
+ PINNED_IMAGES,
16
+ deep_merge,
17
+ load_kubeai_resource_profiles,
18
+ merged_catalogs,
19
+ normalized_cluster,
20
+ normalized_output,
21
+ normalized_state,
22
+ resource_profiles_to_kubeai_values,
23
+ )
24
+ from .hardware import detect_inventory
25
+
26
+
27
+ def _available_gpu_indices(
28
+ inventory: dict[str, Any], reserve_display_gpu: str | bool | None
29
+ ) -> list[int]:
30
+ gpus = deepcopy(inventory.get('gpus', []))
31
+ if reserve_display_gpu == 'auto':
32
+ return [g['index'] for g in gpus if not g.get('display_active')]
33
+ if reserve_display_gpu is True:
34
+ return [g['index'] for g in gpus if not g.get('display_active')]
35
+ return [g['index'] for g in gpus]
36
+
37
+
38
+ def _first_fit(
39
+ available: list[int], count: int
40
+ ) -> tuple[list[int], str | None]:
41
+ if len(available) < count:
42
+ return available[
43
+ :
44
+ ], f'need {count} GPUs but only {len(available)} available'
45
+ return available[:count], None
46
+
47
+
48
+ def _runtime_value(
49
+ runtime: dict[str, Any], model: dict[str, Any], key: str, default: Any
50
+ ) -> Any:
51
+ runtime_cfg = runtime.get('runtime', {}) or {}
52
+ if key in runtime_cfg:
53
+ return runtime_cfg[key]
54
+ if key in runtime:
55
+ return runtime[key]
56
+ return model.get('defaults', {}).get(key, default)
57
+
58
+
59
+ def _enabled_value(value: Any, default: bool = False) -> bool:
60
+ if value == 'auto':
61
+ return default
62
+ if value is None:
63
+ return default
64
+ if isinstance(value, str):
65
+ return value.lower() in {'1', 'true', 'yes', 'on', 'enabled'}
66
+ return bool(value)
67
+
68
+
69
+ def _as_string_map(value: Any) -> dict[str, str]:
70
+ """Normalize user-supplied environment/label maps without dropping values.
71
+
72
+ Compose service override hooks are intentionally permissive. Keeping the
73
+ values as strings lets users pass literal values, numeric-looking settings,
74
+ booleans, and ``${ENV_VAR}`` substitutions through to Docker Compose.
75
+ """
76
+
77
+ if not value:
78
+ return {}
79
+ if not isinstance(value, dict):
80
+ raise TypeError(
81
+ f'expected a mapping, got {type(value).__name__}: {value!r}'
82
+ )
83
+ return {str(k): '' if v is None else str(v) for k, v in value.items()}
84
+
85
+
86
+ def _as_string_list(value: Any) -> list[str]:
87
+ if value in (None, ''):
88
+ return []
89
+ if isinstance(value, str):
90
+ return [value]
91
+ return [str(v) for v in value]
92
+
93
+
94
+ def _bool_string(value: Any, default: bool = False) -> str:
95
+ return 'true' if _enabled_value(value, default=default) else 'false'
96
+
97
+
98
+ def _service_override_fields(raw: dict[str, Any]) -> dict[str, Any]:
99
+ """Common opt-in escape hatches for rendered Compose services."""
100
+
101
+ return {
102
+ 'extra_env': _as_string_map(
103
+ raw.get('extra_env', raw.get('environment', {})) or {}
104
+ ),
105
+ 'env_file': _as_string_list(raw.get('env_file', [])),
106
+ 'extra_volumes': _as_string_list(
107
+ raw.get('extra_volumes', raw.get('volumes', []))
108
+ ),
109
+ 'extra_hosts': _as_string_list(raw.get('extra_hosts', [])),
110
+ 'labels': _as_string_map(raw.get('labels', {})),
111
+ 'additional_ports': _as_string_list(raw.get('additional_ports', [])),
112
+ 'gpus': raw.get('gpus'),
113
+ }
114
+
115
+
116
+ def _resolve_gpu_indices(
117
+ *,
118
+ name: str,
119
+ placement: dict[str, Any],
120
+ topology: dict[str, Any],
121
+ preferred_gpu_count: int,
122
+ available: list[int],
123
+ ) -> tuple[list[int], str | None]:
124
+ strategy = placement.get('strategy', 'first_fit')
125
+ if strategy in {'exact', 'multi_gpu', 'single_gpu'}:
126
+ gpu_indices = list(placement.get('gpu_indices', []))
127
+ if not gpu_indices:
128
+ return (
129
+ [],
130
+ f'{name} uses {strategy} placement but no gpu_indices were provided',
131
+ )
132
+ return gpu_indices, None
133
+ gpu_count = int(
134
+ placement.get(
135
+ 'gpu_count',
136
+ topology.get('tensor_parallel_size', preferred_gpu_count) or 1,
137
+ )
138
+ )
139
+ return _first_fit(available, gpu_count)
140
+
141
+
142
+ def _merge(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
143
+ out = deepcopy(a or {})
144
+ for k, v in (b or {}).items():
145
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
146
+ out[k] = _merge(out[k], v)
147
+ else:
148
+ out[k] = deepcopy(v)
149
+ return out
150
+
151
+
152
+ def _resolve_vllm_runtime(
153
+ *,
154
+ profile: dict[str, Any],
155
+ runtime_name: str,
156
+ runtime: dict[str, Any],
157
+ models: dict[str, Any],
158
+ inventory: dict[str, Any],
159
+ policy: dict[str, Any],
160
+ used: set[int],
161
+ backend: str,
162
+ ) -> dict[str, Any]:
163
+ model_key = runtime.get('model') or runtime.get('base_model')
164
+ if not model_key and runtime.get('hf_model_id'):
165
+ model_key = sanitize_name(runtime['hf_model_id'])
166
+ models = dict(models)
167
+ models[model_key] = {
168
+ 'key': model_key,
169
+ 'hf_model_id': runtime['hf_model_id'],
170
+ 'url': f'hf://{runtime["hf_model_id"]}',
171
+ 'served_model_name': runtime.get('served_model_name') or model_key,
172
+ 'logical_model_name': runtime.get('logical_model_name')
173
+ or model_key,
174
+ 'tokenizer_name': runtime.get('tokenizer_name') or model_key,
175
+ 'supported_protocols': ['chat', 'completions'],
176
+ 'modalities': ['text'],
177
+ 'features': ['TextGeneration'],
178
+ 'defaults': {},
179
+ 'preferred_gpu_count': 1,
180
+ 'min_vram_gib_per_replica': 0,
181
+ 'resource_profile': '',
182
+ 'notes': [],
183
+ 'caveats': [],
184
+ }
185
+ if model_key not in models:
186
+ raise KeyError(f'Unknown vLLM model: {model_key}')
187
+ model = deepcopy(models[model_key])
188
+ placement = deepcopy(runtime.get('placement', {}))
189
+ topology = deepcopy(runtime.get('topology', {}))
190
+ available = [
191
+ i
192
+ for i in _available_gpu_indices(
193
+ inventory, policy.get('reserve_display_gpu', 'auto')
194
+ )
195
+ if i not in used
196
+ ]
197
+ if 'tp' in topology and 'tensor_parallel_size' not in topology:
198
+ topology['tensor_parallel_size'] = topology['tp']
199
+ if 'dp' in topology and 'data_parallel_size' not in topology:
200
+ topology['data_parallel_size'] = topology['dp']
201
+ gpu_indices, placement_error = _resolve_gpu_indices(
202
+ name=f'vLLM runtime {runtime_name}',
203
+ placement=placement,
204
+ topology=topology,
205
+ preferred_gpu_count=int(model.get('preferred_gpu_count', 1) or 1),
206
+ available=available,
207
+ )
208
+ if backend == 'compose':
209
+ used.update(gpu_indices)
210
+ tp = int(
211
+ topology.get(
212
+ 'tensor_parallel_size',
213
+ max(1, len(gpu_indices) or placement.get('gpu_count', 1)),
214
+ )
215
+ )
216
+ dp = int(topology.get('data_parallel_size', 1))
217
+
218
+ tool_calling = _merge(
219
+ model.get('tool_calling', {}), runtime.get('tool_calling', {})
220
+ )
221
+ tool_call_parser = tool_calling.get('parser')
222
+ tool_calling_on = bool(
223
+ tool_calling.get('enabled', tool_calling.get('auto', False))
224
+ )
225
+ enable_auto_tool_choice = bool(tool_calling_on and tool_call_parser)
226
+
227
+ reasoning = _merge(model.get('reasoning', {}), runtime.get('reasoning', {}))
228
+ reasoning_enabled = bool(reasoning.get('enabled', False))
229
+ reasoning_parser = reasoning.get('parser') if reasoning_enabled else None
230
+ reasoning_expose_to_openwebui = bool(
231
+ reasoning.get('expose_to_openwebui', reasoning_enabled)
232
+ )
233
+
234
+ chat_compat = _merge(
235
+ model.get('chat_compat', {}), runtime.get('chat_compat', {})
236
+ )
237
+ chat_compat_enabled = bool(chat_compat.get('enabled', False))
238
+ chat_compat_strategy = (
239
+ str(chat_compat.get('strategy', 'flat_messages'))
240
+ if chat_compat_enabled
241
+ else None
242
+ )
243
+
244
+ hf_model_id = runtime.get('hf_model_id', model.get('hf_model_id', ''))
245
+ served_model_name = (
246
+ runtime.get('served_model_name')
247
+ or model.get('served_model_name')
248
+ or runtime_name
249
+ )
250
+ logical_model_name = (
251
+ runtime.get('logical_model_name')
252
+ or model.get('logical_model_name')
253
+ or served_model_name
254
+ )
255
+ public_name = runtime.get('public_name') or runtime_name
256
+ protocol_mode = (
257
+ runtime.get('protocol_mode') or runtime.get('protocol') or 'chat'
258
+ )
259
+ model_url = (
260
+ runtime.get('url')
261
+ or model.get('url')
262
+ or (f'hf://{hf_model_id}' if hf_model_id else '')
263
+ )
264
+
265
+ return {
266
+ 'provider': 'vllm',
267
+ 'runtime_name': runtime_name,
268
+ 'service_name': sanitize_name(runtime_name),
269
+ 'compose_service_name': f'vllm-{sanitize_name(runtime_name)}',
270
+ 'container_name': f'vllm-{sanitize_name(runtime_name)}',
271
+ 'profile_name': profile['name'],
272
+ 'profile_public_name': public_name,
273
+ 'kubernetes_name': sanitize_name(public_name),
274
+ 'model_ref': model_key,
275
+ 'hf_model_id': hf_model_id,
276
+ 'model_url': model_url,
277
+ 'logical_model_name': logical_model_name,
278
+ 'served_model_name': served_model_name,
279
+ 'served_aliases': [],
280
+ 'protocol_mode': protocol_mode,
281
+ 'supported_protocols': list(
282
+ model.get('supported_protocols', ['chat', 'completions'])
283
+ ),
284
+ 'modalities': model.get('modalities', ['text']),
285
+ 'features': deepcopy(model.get('features', ['TextGeneration'])),
286
+ 'engine': 'VLLM',
287
+ 'memory_class_gib': model.get('memory_class_gib'),
288
+ 'min_vram_gib_per_replica': model.get('min_vram_gib_per_replica', 0),
289
+ 'context_window': model.get('context_window'),
290
+ 'tokenizer_name': runtime.get(
291
+ 'tokenizer_name', model.get('tokenizer_name', logical_model_name)
292
+ ),
293
+ 'notes': deepcopy(model.get('notes', []))
294
+ + deepcopy(runtime.get('notes', [])),
295
+ 'audit_notes': deepcopy(runtime.get('audit_notes', []))
296
+ + deepcopy(model.get('caveats', [])),
297
+ 'tags': deepcopy(runtime.get('tags', [])),
298
+ 'gpu_indices': gpu_indices,
299
+ 'tensor_parallel_size': tp,
300
+ 'data_parallel_size': dp,
301
+ 'resource_profile': runtime.get(
302
+ 'resource_profile', model.get('resource_profile', '')
303
+ ),
304
+ 'min_replicas': int(
305
+ runtime.get(
306
+ 'min_replicas', model.get('defaults', {}).get('min_replicas', 0)
307
+ )
308
+ ),
309
+ 'max_replicas': int(
310
+ runtime.get(
311
+ 'max_replicas', model.get('defaults', {}).get('max_replicas', 1)
312
+ )
313
+ ),
314
+ 'priority_class_name': runtime.get(
315
+ 'priority_class_name', model.get('priority_class_name')
316
+ ),
317
+ 'max_model_len': int(
318
+ _runtime_value(runtime, model, 'max_model_len', 32768)
319
+ ),
320
+ 'gpu_memory_utilization': float(
321
+ _runtime_value(runtime, model, 'gpu_memory_utilization', 0.9)
322
+ ),
323
+ 'enable_prefix_caching': bool(
324
+ _runtime_value(runtime, model, 'enable_prefix_caching', True)
325
+ ),
326
+ 'max_num_batched_tokens': int(
327
+ _runtime_value(runtime, model, 'max_num_batched_tokens', 8192)
328
+ ),
329
+ 'max_num_seqs': int(_runtime_value(runtime, model, 'max_num_seqs', 16)),
330
+ 'thinking_history_policy': model.get(
331
+ 'thinking_history_policy', 'keep_final_only'
332
+ ),
333
+ 'placement': placement,
334
+ 'topology': topology,
335
+ 'placement_error': placement_error,
336
+ 'enable_auto_tool_choice': enable_auto_tool_choice,
337
+ 'tool_call_parser': tool_call_parser,
338
+ 'extra_args': deepcopy(
339
+ runtime.get(
340
+ 'extra_args', model.get('defaults', {}).get('extra_args', [])
341
+ )
342
+ ),
343
+ 'reasoning_enabled': reasoning_enabled,
344
+ 'reasoning_parser': reasoning_parser,
345
+ 'reasoning_expose_to_openwebui': reasoning_expose_to_openwebui,
346
+ 'chat_compat_enabled': chat_compat_enabled,
347
+ 'chat_compat_strategy': chat_compat_strategy,
348
+ 'benchmark_transport': deepcopy(
349
+ runtime.get('benchmark_transport', runtime.get('transport', {}))
350
+ ),
351
+ 'publish_port': bool(runtime.get('publish_port', False)),
352
+ 'host_port': runtime.get('host_port'),
353
+ **_service_override_fields(runtime),
354
+ }
355
+
356
+
357
+ def _resolve_ollama_model_tag(
358
+ model_ref: str, ollama_models: dict[str, Any]
359
+ ) -> tuple[str, dict[str, Any]]:
360
+ if model_ref in ollama_models:
361
+ model = deepcopy(ollama_models[model_ref])
362
+ return model['tag'], model
363
+ return str(model_ref), {
364
+ 'key': model_ref,
365
+ 'tag': str(model_ref),
366
+ 'served_model_name': sanitize_name(str(model_ref)),
367
+ 'defaults': {},
368
+ }
369
+
370
+
371
+ def _collect_ollama_needed(profile: dict[str, Any]) -> bool:
372
+ p = profile.get('providers', {}).get('ollama', {}) or {}
373
+ if _enabled_value(p.get('enabled'), default=False):
374
+ return True
375
+ for route in (profile.get('routes', {}) or {}).values():
376
+ if str(route.get('provider', '')).lower() == 'ollama':
377
+ return True
378
+ return False
379
+
380
+
381
+ def _resolve_ollama_provider(
382
+ profile: dict[str, Any],
383
+ config: dict[str, Any],
384
+ inventory: dict[str, Any],
385
+ policy: dict[str, Any],
386
+ ) -> dict[str, Any]:
387
+ profile_ollama = deepcopy(
388
+ profile.get('providers', {}).get('ollama', {}) or {}
389
+ )
390
+ # Historical configs used the top-level ``ollama`` section for provider
391
+ # details. Newer configs may keep those details under
392
+ # ``providers.ollama``. Support both so users can opt into advanced
393
+ # settings without having to rewrite old config files.
394
+ config_ollama = deepcopy(config.get('ollama', {}) or {})
395
+ config_provider_ollama = deepcopy(
396
+ (config.get('providers', {}) or {}).get('ollama', {}) or {}
397
+ )
398
+ merged = _merge(
399
+ _merge(config_ollama, config_provider_ollama), profile_ollama
400
+ )
401
+ enabled = _enabled_value(
402
+ merged.get('enabled'), default=_collect_ollama_needed(profile)
403
+ )
404
+ if not enabled:
405
+ return {'enabled': False, 'routes': {}}
406
+
407
+ gpu_indices = merged.get('gpu_indices', 'auto')
408
+ placement_error = None
409
+ if gpu_indices == 'auto' or gpu_indices is None:
410
+ available = _available_gpu_indices(
411
+ inventory, policy.get('reserve_display_gpu', 'auto')
412
+ )
413
+ gpu_indices = available
414
+ else:
415
+ gpu_indices = list(gpu_indices)
416
+ return {
417
+ 'enabled': True,
418
+ 'service_name': 'ollama',
419
+ 'base_url': 'http://ollama:11434',
420
+ 'host_port': int(config.get('ports', {}).get('ollama', 11434)),
421
+ 'gpu_indices': gpu_indices,
422
+ 'publish_port': bool(merged.get('publish_port', False)),
423
+ 'host': str(merged.get('host', '0.0.0.0:11434')),
424
+ 'keep_alive': str(merged.get('keep_alive', '2m')),
425
+ 'context_length': int(merged.get('context_length', 4096)),
426
+ 'num_parallel': int(merged.get('num_parallel', 1)),
427
+ 'max_loaded_models': int(merged.get('max_loaded_models', 1)),
428
+ 'max_queue': int(merged.get('max_queue', 8)),
429
+ 'placement_error': placement_error,
430
+ 'routes': {},
431
+ **_service_override_fields(merged),
432
+ }
433
+
434
+
435
+ def _resolve_routes(
436
+ profile: dict[str, Any],
437
+ vllm_runtimes: dict[str, Any],
438
+ ollama_models: dict[str, Any],
439
+ ) -> dict[str, Any]:
440
+ routes: dict[str, Any] = {}
441
+ for alias, raw in (profile.get('routes', {}) or {}).items():
442
+ route = deepcopy(raw) or {}
443
+ provider = str(route.get('provider', 'vllm')).lower()
444
+ aliases = route.get('aliases') or [alias]
445
+ if isinstance(aliases, str):
446
+ aliases = [aliases]
447
+ aliases = [str(a) for a in aliases]
448
+ if alias not in aliases:
449
+ aliases.insert(0, alias)
450
+ for public_alias in aliases:
451
+ if provider == 'vllm':
452
+ runtime_name = (
453
+ route.get('runtime')
454
+ or route.get('service')
455
+ or route.get('target')
456
+ )
457
+ if runtime_name is None and len(vllm_runtimes) == 1:
458
+ runtime_name = next(iter(vllm_runtimes))
459
+ if runtime_name not in vllm_runtimes:
460
+ raise KeyError(
461
+ f'route {alias!r} references unknown vLLM runtime {runtime_name!r}'
462
+ )
463
+ rt = vllm_runtimes[runtime_name]
464
+ routes[public_alias] = {
465
+ 'alias': public_alias,
466
+ 'provider': 'vllm',
467
+ 'runtime': runtime_name,
468
+ 'service_name': rt['service_name'],
469
+ 'upstream_service_name': rt.get(
470
+ 'compose_service_name', rt['service_name']
471
+ ),
472
+ 'served_model_name': rt['served_model_name'],
473
+ 'protocol_mode': rt['protocol_mode'],
474
+ 'max_model_len': rt['max_model_len'],
475
+ 'chat_compat_enabled': rt.get('chat_compat_enabled', False),
476
+ 'chat_compat_strategy': rt.get('chat_compat_strategy'),
477
+ }
478
+ elif provider == 'ollama':
479
+ model_ref = (
480
+ route.get('model') or route.get('tag') or public_alias
481
+ )
482
+ tag, model = _resolve_ollama_model_tag(
483
+ str(model_ref), ollama_models
484
+ )
485
+ defaults = deepcopy(model.get('defaults', {}))
486
+ defaults.update(deepcopy(route.get('options', {})))
487
+ max_model_len = int(
488
+ route.get('max_model_len')
489
+ or defaults.get('num_ctx')
490
+ or model.get('context_window')
491
+ or 4096
492
+ )
493
+ routes[public_alias] = {
494
+ 'alias': public_alias,
495
+ 'provider': 'ollama',
496
+ 'upstream_model': tag,
497
+ 'model_ref': str(model_ref),
498
+ 'protocol_mode': 'chat',
499
+ 'max_model_len': max_model_len,
500
+ 'options': defaults,
501
+ }
502
+ else:
503
+ raise KeyError(
504
+ f'route {alias!r} uses unknown provider {provider!r}'
505
+ )
506
+ return routes
507
+
508
+
509
+ def _resolve_litellm(
510
+ profile: dict[str, Any],
511
+ routes: dict[str, Any],
512
+ providers: dict[str, Any],
513
+ backend: str,
514
+ config: dict[str, Any],
515
+ ) -> dict[str, Any]:
516
+ if backend == 'kubeai':
517
+ return {'enabled': False, 'base_url': '', 'routes': {}}
518
+ cfg = deepcopy(config.get('litellm', {}) or {})
519
+ cfg = _merge(
520
+ cfg, (config.get('gateways', {}) or {}).get('litellm', {}) or {}
521
+ )
522
+ cfg = _merge(cfg, profile.get('gateways', {}).get('litellm', {}) or {})
523
+ route_providers = {route.get('provider') for route in routes.values()}
524
+ needs_gateway = bool(routes) and (backend == 'compose')
525
+ if len(route_providers) > 1:
526
+ needs_gateway = True
527
+ enabled = _enabled_value(cfg.get('enabled'), default=needs_gateway)
528
+ return {
529
+ 'enabled': bool(enabled),
530
+ 'base_url': 'http://litellm:4000/v1' if enabled else '',
531
+ 'routes': routes if enabled else {},
532
+ **_service_override_fields(cfg),
533
+ }
534
+
535
+
536
+ def _resolve_open_webui_ldap(raw: dict[str, Any]) -> dict[str, Any]:
537
+ ldap = deepcopy(raw.get('ldap', {}) or {})
538
+ enabled = _enabled_value(ldap.get('enabled'), default=False)
539
+ if not enabled:
540
+ return {'enabled': False, 'env': {}, 'env_defaults': {}}
541
+
542
+ # Defaults are intentionally expressed through .env placeholders so LDAP
543
+ # credentials and site-local directory settings can be edited after render
544
+ # without changing generated Compose YAML.
545
+ env = {
546
+ 'ENABLE_LDAP': _bool_string(True),
547
+ 'LDAP_SERVER_LABEL': str(ldap.get('server_label', 'OpenLDAP')),
548
+ 'LDAP_SERVER_HOST': str(ldap.get('server_host', '${LDAP_HOST}')),
549
+ 'LDAP_SERVER_PORT': str(ldap.get('server_port', '${LDAP_PORT}')),
550
+ 'LDAP_USE_TLS': str(ldap.get('use_tls', '${LDAP_USE_TLS}')),
551
+ 'LDAP_VALIDATE_CERT': str(
552
+ ldap.get(
553
+ 'validate_cert',
554
+ ldap.get('validate_certs', '${LDAP_VALIDATE_CERTS}'),
555
+ )
556
+ ),
557
+ 'LDAP_APP_DN': str(ldap.get('app_dn', '${LDAP_BASEDN}')),
558
+ 'LDAP_APP_PASSWORD': str(ldap.get('app_password', '${LDAP_PASSWD}')),
559
+ 'LDAP_SEARCH_BASE': str(ldap.get('search_base', '${LDAP_SEARCH_BASE}')),
560
+ 'LDAP_ATTRIBUTE_FOR_USERNAME': str(
561
+ ldap.get('attribute_for_username', '${LDAP_ATTRIBUTE_FOR_USERNAME}')
562
+ ),
563
+ 'LDAP_ATTRIBUTE_FOR_MAIL': str(ldap.get('attribute_for_mail', 'mail')),
564
+ 'LDAP_SEARCH_FILTER': str(
565
+ ldap.get('search_filter', '${LDAP_SEARCH_FILTER}')
566
+ ),
567
+ }
568
+ env.update(_as_string_map(ldap.get('extra_env', {})))
569
+ env_defaults = {
570
+ 'LDAP_HOST': str(ldap.get('env_defaults', {}).get('LDAP_HOST', '')),
571
+ 'LDAP_PORT': str(ldap.get('env_defaults', {}).get('LDAP_PORT', '636')),
572
+ 'LDAP_USE_TLS': str(
573
+ ldap.get('env_defaults', {}).get('LDAP_USE_TLS', 'true')
574
+ ),
575
+ 'LDAP_VALIDATE_CERTS': str(
576
+ ldap.get('env_defaults', {}).get('LDAP_VALIDATE_CERTS', 'true')
577
+ ),
578
+ 'LDAP_BASEDN': str(ldap.get('env_defaults', {}).get('LDAP_BASEDN', '')),
579
+ 'LDAP_PASSWD': str(ldap.get('env_defaults', {}).get('LDAP_PASSWD', '')),
580
+ 'LDAP_SEARCH_BASE': str(
581
+ ldap.get('env_defaults', {}).get('LDAP_SEARCH_BASE', '')
582
+ ),
583
+ 'LDAP_ATTRIBUTE_FOR_USERNAME': str(
584
+ ldap.get('env_defaults', {}).get(
585
+ 'LDAP_ATTRIBUTE_FOR_USERNAME', 'uid'
586
+ )
587
+ ),
588
+ 'LDAP_SEARCH_FILTER': str(
589
+ ldap.get('env_defaults', {}).get('LDAP_SEARCH_FILTER', '')
590
+ ),
591
+ }
592
+ return {'enabled': True, 'env': env, 'env_defaults': env_defaults}
593
+
594
+
595
+ def _resolve_open_webui(
596
+ profile: dict[str, Any],
597
+ gateways: dict[str, Any],
598
+ providers: dict[str, Any],
599
+ backend: str,
600
+ config: dict[str, Any],
601
+ ) -> dict[str, Any]:
602
+ if backend == 'kubeai':
603
+ return {
604
+ 'enabled': False,
605
+ 'auth': False,
606
+ 'provider': 'none',
607
+ 'publish_port': False,
608
+ }
609
+ raw = deepcopy(config.get('open_webui', {}) or {})
610
+ raw = _merge(
611
+ raw, (config.get('frontends', {}) or {}).get('open_webui', {}) or {}
612
+ )
613
+ raw = _merge(raw, profile.get('frontends', {}).get('open_webui', {}) or {})
614
+ default_enabled = backend == 'compose' and (
615
+ gateways.get('litellm', {}).get('enabled')
616
+ or providers.get('ollama', {}).get('enabled')
617
+ )
618
+ enabled = _enabled_value(raw.get('enabled'), default=bool(default_enabled))
619
+ provider = str(raw.get('provider', 'auto'))
620
+ if provider == 'auto':
621
+ if gateways.get('litellm', {}).get('enabled'):
622
+ provider = 'litellm'
623
+ elif providers.get('ollama', {}).get('enabled'):
624
+ provider = 'ollama'
625
+ else:
626
+ provider = 'none'
627
+ ldap = _resolve_open_webui_ldap(raw)
628
+ return {
629
+ 'enabled': enabled,
630
+ 'auth': bool(raw.get('auth', False)),
631
+ 'provider': provider,
632
+ 'publish_port': _enabled_value(raw.get('publish_port'), default=True),
633
+ 'webui_url': str(raw.get('webui_url', raw.get('WEBUI_URL', ''))),
634
+ 'cors_allow_origin': str(
635
+ raw.get('cors_allow_origin', raw.get('CORS_ALLOW_ORIGIN', ''))
636
+ ),
637
+ 'ldap': ldap,
638
+ **_service_override_fields(raw),
639
+ }
640
+
641
+
642
+ def _resolve_reverse_proxy(
643
+ profile: dict[str, Any], backend: str, config: dict[str, Any]
644
+ ) -> dict[str, Any]:
645
+ if backend == 'kubeai':
646
+ return {'enabled': False}
647
+
648
+ raw = deepcopy(config.get('reverse_proxy', {}) or {})
649
+ raw = _merge(
650
+ raw, (config.get('frontends', {}) or {}).get('reverse_proxy', {}) or {}
651
+ )
652
+ raw = _merge(
653
+ raw, profile.get('frontends', {}).get('reverse_proxy', {}) or {}
654
+ )
655
+ enabled = _enabled_value(raw.get('enabled'), default=False)
656
+ if not enabled:
657
+ return {'enabled': False}
658
+
659
+ ssl = deepcopy(raw.get('ssl', {}) or {})
660
+ hsts = deepcopy(raw.get('hsts', {}) or {})
661
+ ssl_enabled = _enabled_value(ssl.get('enabled'), default=True)
662
+ target = str(raw.get('target', 'open_webui'))
663
+ if target in {'open_webui', 'open-webui'}:
664
+ target_service = 'open-webui'
665
+ target_port = 8080
666
+ target_scheme = 'http'
667
+ depends_on = ['open-webui']
668
+ elif target == 'litellm':
669
+ target_service = 'litellm'
670
+ target_port = 4000
671
+ target_scheme = 'http'
672
+ depends_on = ['litellm']
673
+ elif target == 'ollama':
674
+ target_service = 'ollama'
675
+ target_port = 11434
676
+ target_scheme = 'http'
677
+ depends_on = ['ollama']
678
+ else:
679
+ target_service = str(raw.get('target_service', target))
680
+ target_port = int(raw.get('target_port', 8080))
681
+ target_scheme = str(raw.get('target_scheme', 'http'))
682
+ depends_on = _as_string_list(raw.get('depends_on', []))
683
+
684
+ service_overrides = _service_override_fields(raw)
685
+ ports = config.get('ports', {}) or {}
686
+ return {
687
+ 'enabled': True,
688
+ 'image': str(
689
+ raw.get('image')
690
+ or config.get('images', {}).get('nginx')
691
+ or PINNED_IMAGES.get('nginx', 'nginx:alpine')
692
+ ),
693
+ 'service_name': str(raw.get('service_name', 'reverse-proxy')),
694
+ 'container_name': str(raw.get('container_name', 'reverse-proxy')),
695
+ 'target': target,
696
+ 'target_service': target_service,
697
+ 'target_port': target_port,
698
+ 'target_scheme': target_scheme,
699
+ 'depends_on': depends_on,
700
+ 'server_name': str(raw.get('server_name', 'localhost')),
701
+ 'publish_http': _enabled_value(raw.get('publish_http'), default=True),
702
+ # Publishing :443 is only meaningful when there is a TLS server block to
703
+ # listen on it, so gate it on ssl regardless of how publish_https was
704
+ # defaulted (the top-level config template ships it as true).
705
+ 'publish_https': _enabled_value(raw.get('publish_https'), default=True)
706
+ and ssl_enabled,
707
+ 'http_port': int(
708
+ raw.get('http_port') or ports.get('reverse_proxy_http') or 80
709
+ ),
710
+ 'https_port': int(
711
+ raw.get('https_port') or ports.get('reverse_proxy_https') or 443
712
+ ),
713
+ 'http_bind_host': str(raw.get('http_bind_host', '')),
714
+ 'https_bind_host': str(raw.get('https_bind_host', '')),
715
+ 'force_https': _enabled_value(
716
+ raw.get('force_https'), default=ssl_enabled
717
+ ),
718
+ 'client_max_body_size': str(raw.get('client_max_body_size', '1G')),
719
+ 'proxy_connect_timeout': str(raw.get('proxy_connect_timeout', '60s')),
720
+ 'proxy_read_timeout': str(raw.get('proxy_read_timeout', '600s')),
721
+ 'proxy_send_timeout': str(raw.get('proxy_send_timeout', '600s')),
722
+ 'proxy_buffer_size': str(raw.get('proxy_buffer_size', '128k')),
723
+ 'proxy_buffers': str(raw.get('proxy_buffers', '4 256k')),
724
+ 'proxy_busy_buffers_size': str(
725
+ raw.get('proxy_busy_buffers_size', '256k')
726
+ ),
727
+ 'proxy_buffering': raw.get('proxy_buffering'),
728
+ 'proxy_cache': raw.get('proxy_cache'),
729
+ 'resolver': _as_string_list(raw.get('resolver', [])),
730
+ 'resolver_timeout': str(raw.get('resolver_timeout', '5s')),
731
+ 'hsts': {
732
+ 'enabled': _enabled_value(hsts.get('enabled'), default=True),
733
+ 'max_age': int(hsts.get('max_age', 63072000)),
734
+ 'include_subdomains': _enabled_value(
735
+ hsts.get('include_subdomains'), default=True
736
+ ),
737
+ 'preload': _enabled_value(hsts.get('preload'), default=False),
738
+ },
739
+ 'ssl': {
740
+ 'enabled': ssl_enabled,
741
+ 'certificate': str(ssl.get('certificate', '')),
742
+ 'certificate_key': str(ssl.get('certificate_key', '')),
743
+ 'dhparam': str(ssl.get('dhparam', '')),
744
+ 'certificate_container_path': str(
745
+ ssl.get(
746
+ 'certificate_container_path',
747
+ '/etc/ssl/certs/infer-stack-site.crt',
748
+ )
749
+ ),
750
+ 'certificate_key_container_path': str(
751
+ ssl.get(
752
+ 'certificate_key_container_path',
753
+ '/etc/ssl/private/infer-stack-site.key',
754
+ )
755
+ ),
756
+ 'dhparam_container_path': str(
757
+ ssl.get('dhparam_container_path', '/etc/ssl/certs/dhparam.pem')
758
+ ),
759
+ 'protocols': str(ssl.get('protocols', 'TLSv1.2 TLSv1.3')),
760
+ 'ciphers': str(
761
+ ssl.get(
762
+ 'ciphers',
763
+ 'ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384',
764
+ )
765
+ ),
766
+ 'prefer_server_ciphers': _enabled_value(
767
+ ssl.get('prefer_server_ciphers'), default=True
768
+ ),
769
+ 'session_cache': str(ssl.get('session_cache', 'shared:SSL:10m')),
770
+ 'ecdh_curve': str(ssl.get('ecdh_curve', 'secp384r1')),
771
+ 'session_tickets': _enabled_value(
772
+ ssl.get('session_tickets'), default=False
773
+ ),
774
+ 'stapling': _enabled_value(ssl.get('stapling'), default=True),
775
+ 'stapling_verify': _enabled_value(
776
+ ssl.get('stapling_verify'), default=True
777
+ ),
778
+ },
779
+ 'config_path': str(raw.get('config_path', '')),
780
+ 'extra_config': str(raw.get('extra_config', '')),
781
+ **service_overrides,
782
+ }
783
+
784
+
785
+ def _serving_profile(
786
+ profile: dict[str, Any],
787
+ vllm_runtimes: dict[str, Any],
788
+ routes: dict[str, Any],
789
+ ) -> dict[str, Any]:
790
+ first_rt = next(iter(vllm_runtimes.values()), {}) if vllm_runtimes else {}
791
+ first_route = next(iter(routes.values()), {}) if routes else {}
792
+ public = (
793
+ first_route.get('alias')
794
+ or first_rt.get('profile_public_name')
795
+ or profile['name']
796
+ )
797
+ return {
798
+ 'name': profile['name'],
799
+ 'public_name': public,
800
+ 'kind': profile.get('kind', 'stack'),
801
+ 'description': profile.get('description', ''),
802
+ 'base_model': first_rt.get(
803
+ 'model_ref', first_route.get('model_ref', '')
804
+ ),
805
+ 'logical_model_name': first_rt.get(
806
+ 'logical_model_name', first_route.get('alias', '')
807
+ ),
808
+ 'served_model_name': first_rt.get(
809
+ 'served_model_name', first_route.get('upstream_model', '')
810
+ ),
811
+ 'served_aliases': list(routes.keys()),
812
+ 'protocol_mode': first_rt.get(
813
+ 'protocol_mode', first_route.get('protocol_mode', 'chat')
814
+ ),
815
+ 'engine': 'mixed'
816
+ if vllm_runtimes
817
+ and any(r.get('provider') == 'ollama' for r in routes.values())
818
+ else ('VLLM' if vllm_runtimes else 'OLLAMA'),
819
+ 'resource_profile': first_rt.get('resource_profile', ''),
820
+ 'service_name': first_rt.get('service_name', ''),
821
+ 'kubernetes_name': first_rt.get(
822
+ 'kubernetes_name', sanitize_name(profile['name'])
823
+ ),
824
+ 'tags': deepcopy(profile.get('tags', [])),
825
+ 'audit_notes': deepcopy(profile.get('audit_notes', [])),
826
+ 'notes': deepcopy(profile.get('notes', [])),
827
+ 'benchmark_transport': {},
828
+ }
829
+
830
+
831
+ def _resolve_access(deployment: dict[str, Any]) -> dict[str, Any]:
832
+ ports = deployment.get('ports', {})
833
+ access: dict[str, Any] = {}
834
+ litellm = deployment['gateways']['litellm']
835
+ ollama = deployment['providers']['ollama']
836
+ vllm_runtimes = deployment['providers']['vllm'].get('runtimes', {})
837
+ frontends = deployment.get('frontends', {}) or {}
838
+ open_webui = frontends.get('open_webui', {}) or {}
839
+ reverse_proxy = frontends.get('reverse_proxy', {}) or {}
840
+ if deployment.get('backend') == 'kubeai':
841
+ ingress = deployment.get('cluster', {}).get('ingress', {}) or {}
842
+ base = (
843
+ f'http://{ingress["host"]}/openai/v1'
844
+ if ingress.get('enabled') and ingress.get('host')
845
+ else 'http://127.0.0.1:8000/openai/v1'
846
+ )
847
+ access['default'] = {
848
+ 'kind': 'openai-compatible',
849
+ 'base_url': base,
850
+ 'auth_env_name': 'KUBEAI_OPENAI_API_KEY',
851
+ }
852
+ elif litellm.get('enabled'):
853
+ access['default'] = {
854
+ 'kind': 'openai-compatible',
855
+ 'base_url': f'http://127.0.0.1:{ports.get("litellm", 14042)}/v1',
856
+ 'auth_env_name': 'LITELLM_MASTER_KEY',
857
+ }
858
+ elif ollama.get('enabled'):
859
+ access['default'] = {
860
+ 'kind': 'ollama-native',
861
+ 'base_url': f'http://127.0.0.1:{ports.get("ollama", 11434)}',
862
+ 'auth_required': False,
863
+ }
864
+ access['ollama_openai'] = {
865
+ 'kind': 'openai-compatible',
866
+ 'base_url': f'http://127.0.0.1:{ports.get("ollama", 11434)}/v1',
867
+ 'auth_required': False,
868
+ }
869
+ elif vllm_runtimes:
870
+ name, rt = next(iter(vllm_runtimes.items()))
871
+ port = rt.get('host_port') or 18000
872
+ access['default'] = {
873
+ 'kind': 'openai-compatible',
874
+ 'base_url': f'http://127.0.0.1:{port}/v1',
875
+ 'auth_env_name': 'VLLM_BACKEND_API_KEY',
876
+ }
877
+ else:
878
+ access['default'] = {'kind': 'none', 'base_url': ''}
879
+ if open_webui.get('enabled') and open_webui.get('publish_port'):
880
+ access['open_webui'] = {
881
+ 'kind': 'web-ui',
882
+ 'base_url': f'http://127.0.0.1:{ports.get("open_webui", 13000)}',
883
+ 'auth_required': bool(open_webui.get('auth')),
884
+ }
885
+ if reverse_proxy.get('enabled'):
886
+ scheme = (
887
+ 'https'
888
+ if reverse_proxy.get('ssl', {}).get('enabled', True)
889
+ and reverse_proxy.get('publish_https', True)
890
+ else 'http'
891
+ )
892
+ host = reverse_proxy.get('server_name') or '127.0.0.1'
893
+ port = (
894
+ reverse_proxy.get('https_port')
895
+ if scheme == 'https'
896
+ else reverse_proxy.get('http_port')
897
+ )
898
+ default_port = 443 if scheme == 'https' else 80
899
+ port_suffix = (
900
+ '' if int(port or default_port) == default_port else f':{port}'
901
+ )
902
+ access['reverse_proxy'] = {
903
+ 'kind': 'web-ui',
904
+ 'base_url': f'{scheme}://{host}{port_suffix}',
905
+ 'auth_required': bool(open_webui.get('auth')),
906
+ }
907
+ for idx, (name, rt) in enumerate(vllm_runtimes.items()):
908
+ port = rt.get('host_port') or (18000 + idx)
909
+ access[f'vllm_{name}'] = {
910
+ 'kind': 'openai-compatible',
911
+ 'base_url': f'http://127.0.0.1:{port}/v1',
912
+ 'auth_env_name': 'VLLM_BACKEND_API_KEY',
913
+ }
914
+ return access
915
+
916
+
917
+ def resolve(
918
+ config: dict[str, Any],
919
+ inventory: dict[str, Any] | None = None,
920
+ profile_name: str | None = None,
921
+ ) -> dict[str, Any]:
922
+ raw_catalogs = merged_catalogs(config)
923
+ vllm_models = normalize_vllm_models(
924
+ raw_catalogs.get('vllm_models', raw_catalogs.get('models', {}))
925
+ )
926
+ ollama_models = normalize_ollama_models(
927
+ raw_catalogs.get('ollama_models', {})
928
+ )
929
+ profiles = normalize_stack_profiles(
930
+ raw_catalogs.get('profiles', {}), vllm_models, ollama_models
931
+ )
932
+ inventory = (
933
+ deepcopy(inventory) if inventory is not None else detect_inventory()
934
+ )
935
+ effective_profile_name = canonical_profile_name(
936
+ profile_name or config.get('active_profile')
937
+ )
938
+ if effective_profile_name not in profiles:
939
+ raise KeyError(f'Unknown profile: {effective_profile_name}')
940
+ profile = deepcopy(profiles[effective_profile_name])
941
+ if profile.get('kind') == 'invalid-profile':
942
+ raise KeyError(
943
+ f'Profile {effective_profile_name!r} is invalid: {profile.get("catalog_error", "unknown error")}'
944
+ )
945
+
946
+ backend = str(config.get('backend', 'compose')).lower()
947
+ merged_policy = _merge(config.get('policy', {}), profile.get('policy', {}))
948
+ images = deep_merge(PINNED_IMAGES, config.get('images', {}) or {})
949
+ ports = deep_merge(DEFAULT_PORTS, config.get('ports', {}) or {})
950
+ state = normalized_state(config.get('state', {}))
951
+ output = normalized_output(config.get('output', {}))
952
+
953
+ used: set[int] = set()
954
+ vllm_cfg = deepcopy(profile.get('providers', {}).get('vllm', {}) or {})
955
+ runtime_cfgs = deepcopy(vllm_cfg.get('runtimes', {}) or {})
956
+ vllm_runtimes: dict[str, Any] = {}
957
+ for runtime_name, runtime in runtime_cfgs.items():
958
+ resolved_rt = _resolve_vllm_runtime(
959
+ profile=profile,
960
+ runtime_name=str(runtime_name),
961
+ runtime=runtime,
962
+ models=vllm_models,
963
+ inventory=inventory,
964
+ policy=merged_policy,
965
+ used=used,
966
+ backend=backend,
967
+ )
968
+ vllm_runtimes[str(runtime_name)] = resolved_rt
969
+
970
+ ollama_provider = _resolve_ollama_provider(
971
+ profile, config, inventory, merged_policy
972
+ )
973
+ routes = _resolve_routes(profile, vllm_runtimes, ollama_models)
974
+ for alias, route in routes.items():
975
+ if (
976
+ route.get('provider') == 'vllm'
977
+ and route.get('runtime') in vllm_runtimes
978
+ ):
979
+ aliases = vllm_runtimes[route['runtime']].setdefault(
980
+ 'served_aliases', []
981
+ )
982
+ if alias not in aliases:
983
+ aliases.append(alias)
984
+ ollama_routes = {
985
+ k: v for k, v in routes.items() if v.get('provider') == 'ollama'
986
+ }
987
+ if ollama_routes and not ollama_provider.get('enabled'):
988
+ ollama_provider = _resolve_ollama_provider(
989
+ {
990
+ **profile,
991
+ 'providers': {
992
+ **profile.get('providers', {}),
993
+ 'ollama': {'enabled': True},
994
+ },
995
+ },
996
+ config,
997
+ inventory,
998
+ merged_policy,
999
+ )
1000
+ ollama_provider['routes'] = ollama_routes
1001
+
1002
+ providers = {
1003
+ 'ollama': ollama_provider,
1004
+ 'vllm': {'enabled': bool(vllm_runtimes), 'runtimes': vllm_runtimes},
1005
+ }
1006
+ litellm = _resolve_litellm(profile, routes, providers, backend, config)
1007
+ gateways = {'litellm': litellm}
1008
+ frontends = {
1009
+ 'open_webui': _resolve_open_webui(
1010
+ profile, gateways, providers, backend, config
1011
+ )
1012
+ }
1013
+ frontends['reverse_proxy'] = _resolve_reverse_proxy(
1014
+ profile, backend, config
1015
+ )
1016
+ serving_profile = _serving_profile(profile, vllm_runtimes, routes)
1017
+
1018
+ if backend == 'kubeai':
1019
+ resource_profiles, resource_profiles_values, resource_profiles_path = (
1020
+ load_kubeai_resource_profiles()
1021
+ )
1022
+ if resource_profiles:
1023
+ resource_profile_source = str(resource_profiles_path)
1024
+ else:
1025
+ resource_profiles = deepcopy(config.get('resource_profiles', {}))
1026
+ resource_profiles_values = deepcopy(
1027
+ {
1028
+ 'resourceProfiles': resource_profiles_to_kubeai_values(
1029
+ resource_profiles
1030
+ )['resourceProfiles']
1031
+ }
1032
+ )
1033
+ resource_profile_source = 'config.yaml.resource_profiles'
1034
+ else:
1035
+ resource_profiles = deepcopy(config.get('resource_profiles', {}))
1036
+ resource_profiles_values = deepcopy(
1037
+ {
1038
+ 'resourceProfiles': resource_profiles_to_kubeai_values(
1039
+ resource_profiles
1040
+ )['resourceProfiles']
1041
+ }
1042
+ )
1043
+ resource_profile_source = 'config.yaml.resource_profiles'
1044
+
1045
+ # Compatibility aliases. New templates should use providers/gateways/frontends.
1046
+ services = list(vllm_runtimes.values())
1047
+ router_aliases = {
1048
+ alias: route.get('service_name', route.get('upstream_model', ''))
1049
+ for alias, route in routes.items()
1050
+ }
1051
+
1052
+ deployment = {
1053
+ 'schema_version': 5,
1054
+ 'source': {
1055
+ 'config_file': 'config.yaml',
1056
+ 'active_profile': effective_profile_name,
1057
+ },
1058
+ 'backend': backend,
1059
+ 'images': images,
1060
+ 'ports': ports,
1061
+ 'policy': merged_policy,
1062
+ 'vllm': {
1063
+ 'enable_responses_api_store': bool(
1064
+ profile.get('vllm', {}).get('enable_responses_api_store', False)
1065
+ ),
1066
+ 'logging_level': str(
1067
+ profile.get('vllm', {}).get('logging_level', 'INFO')
1068
+ ),
1069
+ },
1070
+ 'state': state,
1071
+ 'output': output,
1072
+ 'cluster': normalized_cluster(config.get('cluster', {})),
1073
+ 'resource_profiles': resource_profiles,
1074
+ 'resource_profiles_values': resource_profiles_values,
1075
+ 'resource_profiles_source': resource_profile_source,
1076
+ 'inventory': inventory,
1077
+ 'providers': providers,
1078
+ 'gateways': gateways,
1079
+ 'frontends': frontends,
1080
+ 'access': {},
1081
+ 'profile': serving_profile,
1082
+ 'serving_profile': deepcopy(serving_profile),
1083
+ 'services': services,
1084
+ 'router': {
1085
+ 'enabled': litellm.get('enabled'),
1086
+ 'type': 'litellm' if litellm.get('enabled') else 'none',
1087
+ 'aliases': router_aliases,
1088
+ },
1089
+ 'open_webui': frontends['open_webui'],
1090
+ }
1091
+ deployment['access'] = _resolve_access(deployment)
1092
+ return deployment