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,2 @@
1
+ __all__ = []
2
+ __version__ = '0.6.0'
@@ -0,0 +1,7 @@
1
+ from .compose_renderer import render_compose_artifacts
2
+ from .kubeai_renderer import render_kubeai_artifacts
3
+
4
+ __all__ = [
5
+ 'render_compose_artifacts',
6
+ 'render_kubeai_artifacts',
7
+ ]
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from importlib.resources import files
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+ from jinja2 import BaseLoader, Environment
9
+
10
+ from ..config import normalized_output, normalized_state, DEFAULT_PORTS
11
+ from ..diff_prompt import confirm_writes
12
+ from ..env_utils import ensure_secret, parse_env_file, write_env_file
13
+
14
+
15
+ def _template(name: str) -> str:
16
+ return (
17
+ files('infer_stack')
18
+ .joinpath(f'templates/{name}')
19
+ .read_text(encoding='utf-8')
20
+ )
21
+
22
+
23
+ def _compose_quote(value: object) -> str:
24
+ """Quote scalars for Compose YAML while preserving env interpolation text."""
25
+
26
+ return json.dumps('' if value is None else str(value))
27
+
28
+
29
+ def _compose_gpus(value: object, indent: int = 4) -> str:
30
+ """Render the ``gpus:`` service key for either form Compose accepts.
31
+
32
+ Compose understands both a scalar (``all``, ``-1``, or a device count) and
33
+ a structured list of device-request mappings. Scalars render inline and
34
+ quoted; lists/dicts are emitted as indented block YAML so the structured
35
+ "GPU settings" escape hatch round-trips faithfully instead of collapsing to
36
+ a quoted Python repr.
37
+ """
38
+
39
+ pad = ' ' * indent
40
+ if value is None or value == '':
41
+ return ''
42
+ if isinstance(value, bool):
43
+ value = 'true' if value else 'false'
44
+ if isinstance(value, (str, int, float)):
45
+ scalar = (
46
+ _compose_quote(value)
47
+ if isinstance(value, str)
48
+ else json.dumps(value)
49
+ )
50
+ return f'{pad}gpus: {scalar}'
51
+ dumped = yaml.safe_dump(
52
+ {'gpus': value}, default_flow_style=False, sort_keys=False
53
+ ).rstrip('\n')
54
+ return '\n'.join(pad + line for line in dumped.splitlines())
55
+
56
+
57
+ def render_compose_artifacts(
58
+ lock_data: dict, *, assume_yes: bool = True
59
+ ) -> None:
60
+ """Render component-aware Compose artifacts for the resolved stack."""
61
+ deployment = dict(lock_data.get('deployment', {}))
62
+ deployment['state'] = normalized_state(deployment.get('state', {}))
63
+ deployment['output'] = normalized_output(deployment.get('output'))
64
+ generated = Path(deployment['output']['generated_dir'])
65
+ generated.mkdir(parents=True, exist_ok=True)
66
+ runtime_dir = Path(deployment['state']['runtime'])
67
+ runtime_dir.mkdir(parents=True, exist_ok=True)
68
+
69
+ env_path = generated / '.env'
70
+ existing = parse_env_file(env_path)
71
+ env_values: dict[str, str] = {}
72
+
73
+ frontends = deployment.get('frontends', {}) or {}
74
+ gateways = deployment.get('gateways', {}) or {}
75
+ providers = deployment.get('providers', {}) or {}
76
+
77
+ if (frontends.get('open_webui') or {}).get('enabled'):
78
+ env_values.update(
79
+ {
80
+ 'OPENWEBUI_POSTGRES_DB': existing.get(
81
+ 'OPENWEBUI_POSTGRES_DB', 'openwebui'
82
+ ),
83
+ 'OPENWEBUI_POSTGRES_USER': existing.get(
84
+ 'OPENWEBUI_POSTGRES_USER', 'openwebui'
85
+ ),
86
+ 'OPENWEBUI_POSTGRES_PASSWORD': ensure_secret(
87
+ existing, 'OPENWEBUI_POSTGRES_PASSWORD'
88
+ ),
89
+ 'WEBUI_SECRET_KEY': ensure_secret(existing, 'WEBUI_SECRET_KEY'),
90
+ }
91
+ )
92
+ ldap_defaults = (
93
+ (frontends.get('open_webui') or {}).get('ldap') or {}
94
+ ).get('env_defaults') or {}
95
+ for key, default in ldap_defaults.items():
96
+ env_values[key] = existing.get(key, str(default))
97
+
98
+ if (gateways.get('litellm') or {}).get('enabled'):
99
+ env_values.update(
100
+ {
101
+ 'LITELLM_POSTGRES_DB': existing.get(
102
+ 'LITELLM_POSTGRES_DB', 'litellm'
103
+ ),
104
+ 'LITELLM_POSTGRES_USER': existing.get(
105
+ 'LITELLM_POSTGRES_USER', 'litellm'
106
+ ),
107
+ 'LITELLM_POSTGRES_PASSWORD': ensure_secret(
108
+ existing, 'LITELLM_POSTGRES_PASSWORD'
109
+ ),
110
+ 'LITELLM_MASTER_KEY': ensure_secret(
111
+ existing, 'LITELLM_MASTER_KEY', prefix='sk-'
112
+ ),
113
+ }
114
+ )
115
+
116
+ if (providers.get('vllm') or {}).get('enabled'):
117
+ env_values.update(
118
+ {
119
+ 'VLLM_BACKEND_API_KEY': ensure_secret(
120
+ existing, 'VLLM_BACKEND_API_KEY'
121
+ ),
122
+ 'HF_TOKEN': existing.get('HF_TOKEN', ''),
123
+ }
124
+ )
125
+
126
+ # Ports: expose configured host ports via environment variables so
127
+ # docker-compose can reference them and we persist them into `.env`.
128
+ ports = deployment.get('ports', {}) or {}
129
+
130
+ # LiteLLM port
131
+ if (gateways.get('litellm') or {}).get('enabled'):
132
+ litellm_port = ports.get('litellm') or DEFAULT_PORTS.get(
133
+ 'litellm', 14042
134
+ )
135
+ env_values['INFER_STACK_LITELLM_PORT'] = existing.get(
136
+ 'INFER_STACK_LITELLM_PORT', str(litellm_port)
137
+ )
138
+
139
+ # Open WebUI port
140
+ if (frontends.get('open_webui') or {}).get('enabled'):
141
+ open_webui_port = ports.get('open_webui') or DEFAULT_PORTS.get(
142
+ 'open_webui', 13000
143
+ )
144
+ env_values['INFER_STACK_OPEN_WEBUI_PORT'] = existing.get(
145
+ 'INFER_STACK_OPEN_WEBUI_PORT', str(open_webui_port)
146
+ )
147
+
148
+ # Reverse proxy ports
149
+ reverse_proxy = frontends.get('reverse_proxy') or {}
150
+ if reverse_proxy.get('enabled'):
151
+ http_port = (
152
+ reverse_proxy.get('http_port')
153
+ or ports.get('reverse_proxy_http')
154
+ or DEFAULT_PORTS.get('reverse_proxy_http', 80)
155
+ )
156
+ https_port = (
157
+ reverse_proxy.get('https_port')
158
+ or ports.get('reverse_proxy_https')
159
+ or DEFAULT_PORTS.get('reverse_proxy_https', 443)
160
+ )
161
+ env_values['INFER_STACK_REVERSE_PROXY_HTTP_PORT'] = existing.get(
162
+ 'INFER_STACK_REVERSE_PROXY_HTTP_PORT', str(http_port)
163
+ )
164
+ env_values['INFER_STACK_REVERSE_PROXY_HTTPS_PORT'] = existing.get(
165
+ 'INFER_STACK_REVERSE_PROXY_HTTPS_PORT', str(https_port)
166
+ )
167
+
168
+ # Ollama port (if publish enabled)
169
+ if (providers.get('ollama') or {}).get('enabled'):
170
+ # Ollama host_port is resolved in the deployment; fall back to DEFAULT_PORTS
171
+ ollama_port = (
172
+ (providers.get('ollama') or {}).get('host_port')
173
+ or ports.get('ollama')
174
+ or DEFAULT_PORTS.get('ollama', 11434)
175
+ )
176
+ env_values['INFER_STACK_OLLAMA_PORT'] = existing.get(
177
+ 'INFER_STACK_OLLAMA_PORT', str(ollama_port)
178
+ )
179
+
180
+ # vLLM runtimes: enumerate and export per-runtime host ports (index-based)
181
+ vllm_runtimes = (providers.get('vllm') or {}).get('runtimes', {}) or {}
182
+ for idx, (name, svc) in enumerate(vllm_runtimes.items()):
183
+ host_port = svc.get('host_port') or ports.get('vllm') or (18000 + idx)
184
+ env_name = f'INFER_STACK_VLLM_{idx}_PORT'
185
+ env_values[env_name] = existing.get(env_name, str(host_port))
186
+
187
+ # Preserve unknown/user-supplied keys, but let managed keys above win.
188
+ for key, value in existing.items():
189
+ env_values.setdefault(key, value)
190
+
191
+ env = Environment(
192
+ loader=BaseLoader(),
193
+ autoescape=False,
194
+ trim_blocks=True,
195
+ lstrip_blocks=True,
196
+ )
197
+ env.filters['compose_quote'] = _compose_quote
198
+ env.filters['compose_gpus'] = _compose_gpus
199
+ normalized_lock = dict(lock_data)
200
+ normalized_lock['deployment'] = deployment
201
+
202
+ reverse_proxy = (deployment.get('frontends') or {}).get(
203
+ 'reverse_proxy'
204
+ ) or {}
205
+ if reverse_proxy.get('enabled'):
206
+ if reverse_proxy.get('config_path'):
207
+ reverse_proxy['nginx_config_path'] = reverse_proxy['config_path']
208
+ else:
209
+ reverse_proxy['nginx_config_path'] = str(runtime_dir / 'nginx.conf')
210
+ deployment['frontends']['reverse_proxy'] = reverse_proxy
211
+
212
+ ctx = {'lock': normalized_lock}
213
+ compose = (
214
+ env.from_string(_template('docker-compose.yml.j2')).render(**ctx) + '\n'
215
+ )
216
+
217
+ compose_fpath = generated / 'docker-compose.yml'
218
+ planned: dict[Path, str] = {compose_fpath: compose}
219
+
220
+ lite_llm_config_fpath = runtime_dir / 'litellm_config.yaml'
221
+ if (gateways.get('litellm') or {}).get('enabled'):
222
+ litellm_cfg = (
223
+ env.from_string(_template('litellm_config.yaml.j2')).render(**ctx)
224
+ + '\n'
225
+ )
226
+ planned[lite_llm_config_fpath] = litellm_cfg
227
+
228
+ if reverse_proxy.get('enabled') and not reverse_proxy.get('config_path'):
229
+ nginx_ctx = {'rp': reverse_proxy}
230
+ planned[Path(reverse_proxy['nginx_config_path'])] = (
231
+ env.from_string(_template('nginx.conf.j2')).render(**nginx_ctx)
232
+ + '\n'
233
+ )
234
+
235
+ if not confirm_writes(
236
+ planned, assume_yes=assume_yes, title='Pending compose render'
237
+ ):
238
+ raise SystemExit('Aborted by user; no files were written.')
239
+
240
+ write_env_file(env_path, env_values)
241
+ for path, text in planned.items():
242
+ path.parent.mkdir(parents=True, exist_ok=True)
243
+ path.write_text(text, encoding='utf-8')
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+ from ..config import KUBEAI_GENERATED_SUBDIR, normalized_output
9
+ from ..diff_prompt import confirm_writes
10
+ from ..profile_runtime import vllm_args
11
+
12
+
13
+ def _resource_profile_values(plan: dict[str, Any]) -> dict[str, Any]:
14
+ values_doc = plan.get('deployment', {}).get('resource_profiles_values', {})
15
+ return values_doc or {'resourceProfiles': {}}
16
+
17
+
18
+ def _kubeai_resource_profile(service: dict[str, Any]) -> str:
19
+ profile = str(service.get('resource_profile', ''))
20
+ if not profile or ':' in profile:
21
+ return profile
22
+ gpu_count = max(
23
+ 1,
24
+ len(service.get('gpu_indices', [])),
25
+ int(service.get('tensor_parallel_size', 1) or 1),
26
+ )
27
+ return f'{profile}:{gpu_count}'
28
+
29
+
30
+ def _kubeai_args(service: dict[str, Any]) -> list[str]:
31
+ kubeai_service = dict(service)
32
+ kubeai_service['served_model_name'] = service['profile_public_name']
33
+ return vllm_args(kubeai_service)
34
+
35
+
36
+ def _model_doc(service: dict[str, Any]) -> dict[str, Any]:
37
+ doc = {
38
+ 'apiVersion': 'kubeai.org/v1',
39
+ 'kind': 'Model',
40
+ 'metadata': {
41
+ 'name': service['kubernetes_name'],
42
+ 'annotations': {
43
+ 'infer-stack/profile-name': service['profile_name'],
44
+ 'infer-stack/public-name': service['profile_public_name'],
45
+ 'infer-stack/logical-model-name': service['logical_model_name'],
46
+ 'infer-stack/protocol-mode': service['protocol_mode'],
47
+ },
48
+ },
49
+ 'spec': {
50
+ 'features': service.get('features', ['TextGeneration']),
51
+ 'url': service['model_url'],
52
+ 'engine': service.get('engine', 'VLLM'),
53
+ 'resourceProfile': _kubeai_resource_profile(service),
54
+ 'minReplicas': int(service.get('min_replicas', 0)),
55
+ 'maxReplicas': int(service.get('max_replicas', 1)),
56
+ 'args': _kubeai_args(service),
57
+ },
58
+ }
59
+ if service.get('priority_class_name'):
60
+ doc['spec']['priorityClassName'] = service['priority_class_name']
61
+ return doc
62
+
63
+
64
+ def render_kubeai_artifacts(
65
+ lock_data: dict, *, assume_yes: bool = True
66
+ ) -> None:
67
+ """Render the KubeAI backend artifacts.
68
+
69
+ When ``assume_yes`` is False, all rendered YAML files are diffed against
70
+ their on-disk versions and the user is prompted via Rich before any file
71
+ is written.
72
+ """
73
+ deployment = lock_data.get('deployment', {})
74
+ cluster = deployment.get('cluster', {})
75
+ namespace = cluster.get('namespace', 'kubeai')
76
+ output_root = Path(
77
+ normalized_output(deployment.get('output'))['generated_dir']
78
+ )
79
+ generated = output_root / KUBEAI_GENERATED_SUBDIR
80
+ generated.mkdir(parents=True, exist_ok=True)
81
+
82
+ namespace_doc = {
83
+ 'apiVersion': 'v1',
84
+ 'kind': 'Namespace',
85
+ 'metadata': {'name': namespace},
86
+ }
87
+ namespace_text = yaml.safe_dump(namespace_doc, sort_keys=False)
88
+
89
+ values_doc = _resource_profile_values(lock_data)
90
+ values_text = yaml.safe_dump(values_doc, sort_keys=False)
91
+
92
+ model_docs = [
93
+ _model_doc(service)
94
+ for service in (
95
+ deployment.get('providers', {}).get('vllm', {}).get('runtimes', {})
96
+ or {}
97
+ ).values()
98
+ ]
99
+ model_text = '---\n'.join(
100
+ yaml.safe_dump(doc, sort_keys=False) for doc in model_docs
101
+ )
102
+
103
+ ingress = cluster.get('ingress', {}) or {}
104
+ ingress_path = generated / 'ingress.yaml'
105
+ ingress_text: str | None = None
106
+ if ingress.get('enabled'):
107
+ path_prefix = ingress.get('path_prefix', '/') or '/'
108
+ ingress_doc: dict[str, Any] = {
109
+ 'apiVersion': 'networking.k8s.io/v1',
110
+ 'kind': 'Ingress',
111
+ 'metadata': {
112
+ 'name': cluster.get('service_name', 'kubeai'),
113
+ 'namespace': namespace,
114
+ },
115
+ 'spec': {
116
+ 'ingressClassName': ingress.get('class_name', 'traefik'),
117
+ 'rules': [
118
+ {
119
+ 'http': {
120
+ 'paths': [
121
+ {
122
+ 'path': path_prefix,
123
+ 'pathType': 'Prefix',
124
+ 'backend': {
125
+ 'service': {
126
+ 'name': cluster.get(
127
+ 'service_name', 'kubeai'
128
+ ),
129
+ 'port': {'number': 80},
130
+ }
131
+ },
132
+ }
133
+ ]
134
+ }
135
+ }
136
+ ],
137
+ },
138
+ }
139
+ if ingress.get('host'):
140
+ ingress_doc['spec']['rules'][0]['host'] = ingress['host']
141
+ if ingress.get('tls_secret_name') and ingress.get('host'):
142
+ ingress_doc['spec']['tls'] = [
143
+ {
144
+ 'hosts': [ingress['host']],
145
+ 'secretName': ingress['tls_secret_name'],
146
+ }
147
+ ]
148
+ ingress_text = yaml.safe_dump(ingress_doc, sort_keys=False)
149
+
150
+ readme = f"""# Generated KubeAI artifacts
151
+
152
+ Namespace: `{namespace}`
153
+ Release: `{cluster.get('kubeai_release_name', 'kubeai')}`
154
+ Chart: `{cluster.get('kubeai_chart', 'kubeai/kubeai')}`
155
+
156
+ Files:
157
+ - `namespace.yaml`: namespace to apply before the chart and models
158
+ - `kubeai-values.yaml`: custom resource profiles for the KubeAI chart
159
+ - `models.yaml`: KubeAI `Model` objects derived intentionally from the selected serving profile(s)
160
+ - `ingress.yaml`: optional ingress for one stable hostname
161
+
162
+ Typical flow:
163
+
164
+ ```bash
165
+ kubectl apply -f {generated}/namespace.yaml
166
+ helm repo add kubeai https://www.kubeai.org --force-update
167
+ helm repo update
168
+ helm upgrade --install {cluster.get('kubeai_release_name', 'kubeai')} {cluster.get('kubeai_chart', 'kubeai/kubeai')} \
169
+ -n {namespace} --create-namespace \
170
+ -f {generated}/kubeai-values.yaml \
171
+ --wait
172
+ kubectl apply -f {generated}/models.yaml
173
+ ```
174
+ """
175
+
176
+ namespace_path = generated / 'namespace.yaml'
177
+ values_path = generated / 'kubeai-values.yaml'
178
+ models_path = generated / 'models.yaml'
179
+ readme_path = generated / 'README.md'
180
+
181
+ planned: dict[Path, str] = {
182
+ namespace_path: namespace_text,
183
+ values_path: values_text,
184
+ models_path: model_text,
185
+ readme_path: readme,
186
+ }
187
+ if ingress_text is not None:
188
+ planned[ingress_path] = ingress_text
189
+
190
+ if not confirm_writes(
191
+ planned, assume_yes=assume_yes, title='Pending KubeAI render'
192
+ ):
193
+ raise SystemExit('Aborted by user; no files were written.')
194
+
195
+ namespace_path.write_text(namespace_text, encoding='utf-8')
196
+ values_path.write_text(values_text, encoding='utf-8')
197
+ models_path.write_text(model_text, encoding='utf-8')
198
+ readme_path.write_text(readme, encoding='utf-8')
199
+ if ingress_text is not None:
200
+ ingress_path.write_text(ingress_text, encoding='utf-8')
201
+ elif ingress_path.exists():
202
+ ingress_path.unlink()
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ import requests
7
+
8
+
9
+ def run_benchmark(
10
+ base_url: str, api_key: str, model: str, prompts: list[str]
11
+ ) -> dict[str, Any]:
12
+ headers = {'Content-Type': 'application/json'}
13
+ if api_key:
14
+ headers['Authorization'] = f'Bearer {api_key}'
15
+ results = []
16
+ for prompt in prompts:
17
+ payload = {
18
+ 'model': model,
19
+ 'messages': [{'role': 'user', 'content': prompt}],
20
+ 'max_tokens': 128,
21
+ }
22
+ t0 = time.time()
23
+ resp = requests.post(
24
+ f'{base_url.rstrip("/")}/chat/completions',
25
+ headers=headers,
26
+ json=payload,
27
+ timeout=300,
28
+ )
29
+ dt = time.time() - t0
30
+ results.append(
31
+ {
32
+ 'prompt': prompt,
33
+ 'status_code': resp.status_code,
34
+ 'elapsed_s': dt,
35
+ 'ok': resp.ok,
36
+ }
37
+ )
38
+ return {'results': results}