infer-stack 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- infer_stack/__init__.py +2 -0
- infer_stack/backends/__init__.py +7 -0
- infer_stack/backends/compose_renderer.py +243 -0
- infer_stack/backends/kubeai_renderer.py +202 -0
- infer_stack/benchmark.py +38 -0
- infer_stack/catalog.py +438 -0
- infer_stack/cli/__init__.py +169 -0
- infer_stack/cli/__main__.py +4 -0
- infer_stack/cli/commands_profile.py +467 -0
- infer_stack/cli/commands_runtime.py +719 -0
- infer_stack/cli/commands_smoke.py +691 -0
- infer_stack/cli/compose.py +755 -0
- infer_stack/cli/context.py +471 -0
- infer_stack/cli/options.py +134 -0
- infer_stack/cli/probes.py +178 -0
- infer_stack/config.py +450 -0
- infer_stack/contracts.py +223 -0
- infer_stack/diff_prompt.py +117 -0
- infer_stack/docker_utils.py +230 -0
- infer_stack/env_utils.py +97 -0
- infer_stack/experimental/model_catalog_discover.py +1155 -0
- infer_stack/experimental/model_memory_estimator.py +1264 -0
- infer_stack/experimental/stress_test_long_context.py +397 -0
- infer_stack/hardware.py +70 -0
- infer_stack/kubeai_ops.py +76 -0
- infer_stack/paths.py +87 -0
- infer_stack/profile_runtime.py +46 -0
- infer_stack/renderer.py +19 -0
- infer_stack/resolver.py +1092 -0
- infer_stack/templates/default-models.yaml +674 -0
- infer_stack/templates/default-ollama-models.yaml +31 -0
- infer_stack/templates/default-profiles.yaml +1731 -0
- infer_stack/templates/default-vllm-models.yaml +714 -0
- infer_stack/templates/docker-compose.yml.j2 +430 -0
- infer_stack/templates/litellm_config.yaml.j2 +44 -0
- infer_stack/templates/nginx.conf.j2 +84 -0
- infer_stack/tuning.py +3 -0
- infer_stack/validator.py +314 -0
- infer_stack/verification.py +46 -0
- infer_stack-0.6.0.dist-info/METADATA +1034 -0
- infer_stack-0.6.0.dist-info/RECORD +44 -0
- infer_stack-0.6.0.dist-info/WHEEL +5 -0
- infer_stack-0.6.0.dist-info/entry_points.txt +2 -0
- infer_stack-0.6.0.dist-info/top_level.txt +1 -0
infer_stack/__init__.py
ADDED
|
@@ -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()
|
infer_stack/benchmark.py
ADDED
|
@@ -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}
|