infer-stack 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- infer_stack/__init__.py +2 -0
- infer_stack/backends/__init__.py +7 -0
- infer_stack/backends/compose_renderer.py +243 -0
- infer_stack/backends/kubeai_renderer.py +202 -0
- infer_stack/benchmark.py +38 -0
- infer_stack/catalog.py +438 -0
- infer_stack/cli/__init__.py +169 -0
- infer_stack/cli/__main__.py +4 -0
- infer_stack/cli/commands_profile.py +467 -0
- infer_stack/cli/commands_runtime.py +719 -0
- infer_stack/cli/commands_smoke.py +691 -0
- infer_stack/cli/compose.py +755 -0
- infer_stack/cli/context.py +471 -0
- infer_stack/cli/options.py +134 -0
- infer_stack/cli/probes.py +178 -0
- infer_stack/config.py +450 -0
- infer_stack/contracts.py +223 -0
- infer_stack/diff_prompt.py +117 -0
- infer_stack/docker_utils.py +230 -0
- infer_stack/env_utils.py +97 -0
- infer_stack/experimental/model_catalog_discover.py +1155 -0
- infer_stack/experimental/model_memory_estimator.py +1264 -0
- infer_stack/experimental/stress_test_long_context.py +397 -0
- infer_stack/hardware.py +70 -0
- infer_stack/kubeai_ops.py +76 -0
- infer_stack/paths.py +87 -0
- infer_stack/profile_runtime.py +46 -0
- infer_stack/renderer.py +19 -0
- infer_stack/resolver.py +1092 -0
- infer_stack/templates/default-models.yaml +674 -0
- infer_stack/templates/default-ollama-models.yaml +31 -0
- infer_stack/templates/default-profiles.yaml +1731 -0
- infer_stack/templates/default-vllm-models.yaml +714 -0
- infer_stack/templates/docker-compose.yml.j2 +430 -0
- infer_stack/templates/litellm_config.yaml.j2 +44 -0
- infer_stack/templates/nginx.conf.j2 +84 -0
- infer_stack/tuning.py +3 -0
- infer_stack/validator.py +314 -0
- infer_stack/verification.py +46 -0
- infer_stack-0.6.0.dist-info/METADATA +1034 -0
- infer_stack-0.6.0.dist-info/RECORD +44 -0
- infer_stack-0.6.0.dist-info/WHEEL +5 -0
- infer_stack-0.6.0.dist-info/entry_points.txt +2 -0
- infer_stack-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..benchmark import run_benchmark
|
|
4
|
+
from ..env_utils import parse_env_file
|
|
5
|
+
from ..kubeai_ops import print_status as kubeai_print_status
|
|
6
|
+
from ..paths import config_root
|
|
7
|
+
from ..profile_runtime import default_base_url
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
import json
|
|
11
|
+
import requests
|
|
12
|
+
import scriptconfig as scfg
|
|
13
|
+
|
|
14
|
+
from .context import (
|
|
15
|
+
_apply_path_overrides,
|
|
16
|
+
_as_mapping,
|
|
17
|
+
backend_name,
|
|
18
|
+
build_plan,
|
|
19
|
+
config_for_runtime,
|
|
20
|
+
effective_allow_unsupported,
|
|
21
|
+
effective_inventory,
|
|
22
|
+
plan_path,
|
|
23
|
+
runtime_env_path,
|
|
24
|
+
)
|
|
25
|
+
from .probes import (
|
|
26
|
+
_default_model_for_deployment,
|
|
27
|
+
_ready_ollama_probe,
|
|
28
|
+
_ready_openai_probe,
|
|
29
|
+
_resolve_smoke_protocol_from_deployment,
|
|
30
|
+
)
|
|
31
|
+
from .compose import (
|
|
32
|
+
_explain_readiness_message,
|
|
33
|
+
_print_compose_diagnostics,
|
|
34
|
+
_print_gateway_diagnostics,
|
|
35
|
+
)
|
|
36
|
+
from .options import (
|
|
37
|
+
_AllowUnsupportedMixin,
|
|
38
|
+
_BackendOverrideMixin,
|
|
39
|
+
_ClusterOverridesMixin,
|
|
40
|
+
_ComposeOverrideMixin,
|
|
41
|
+
_PathOverridesMixin,
|
|
42
|
+
_PortOverridesMixin,
|
|
43
|
+
_ProfileOverrideMixin,
|
|
44
|
+
_SimulateHardwareMixin,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Smoke-test / benchmark commands
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _wait_until_ready(
|
|
53
|
+
cfg: dict[str, Any],
|
|
54
|
+
config: Any,
|
|
55
|
+
*,
|
|
56
|
+
model: str | None = None,
|
|
57
|
+
timeout: float = 600.0,
|
|
58
|
+
interval: float = 5.0,
|
|
59
|
+
prompt: str = 'Reply with ready.',
|
|
60
|
+
max_tokens: int = 1,
|
|
61
|
+
require_generation: bool = True,
|
|
62
|
+
quiet: bool = False,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Wait until the active profile can serve a real request.
|
|
65
|
+
|
|
66
|
+
Docker Compose health only tells us that a process/container passed its
|
|
67
|
+
healthcheck. For vLLM, the API can exist before the model path is fully
|
|
68
|
+
ready through LiteLLM. This probes the user-facing access surface and, by
|
|
69
|
+
default, requires a tiny generation/completion to succeed.
|
|
70
|
+
"""
|
|
71
|
+
import time
|
|
72
|
+
|
|
73
|
+
plan = _smoke_plan(cfg, config)
|
|
74
|
+
deployment = plan.get('deployment', {})
|
|
75
|
+
access = deployment.get('access', {}).get('default', {}) or {}
|
|
76
|
+
access_kind = str(access.get('kind') or 'openai-compatible')
|
|
77
|
+
base_url = _infer_default_base_url(cfg, config, deployment=deployment)
|
|
78
|
+
model_name = _default_model_for_deployment(deployment, explicit=model)
|
|
79
|
+
deadline = time.monotonic() + float(timeout)
|
|
80
|
+
last_message = 'not probed yet'
|
|
81
|
+
attempt = 0
|
|
82
|
+
|
|
83
|
+
env = (
|
|
84
|
+
parse_env_file(runtime_env_path(cfg))
|
|
85
|
+
if backend_name(cfg) == 'compose'
|
|
86
|
+
else {}
|
|
87
|
+
)
|
|
88
|
+
headers = {'Content-Type': 'application/json'}
|
|
89
|
+
if access_kind != 'ollama-native':
|
|
90
|
+
auth_env_name = str(access.get('auth_env_name') or 'LITELLM_MASTER_KEY')
|
|
91
|
+
api_key = (
|
|
92
|
+
getattr(config, 'api_key', None)
|
|
93
|
+
or env.get(auth_env_name, '')
|
|
94
|
+
or env.get('LITELLM_MASTER_KEY', '')
|
|
95
|
+
)
|
|
96
|
+
if api_key:
|
|
97
|
+
headers['Authorization'] = f'Bearer {api_key}'
|
|
98
|
+
|
|
99
|
+
protocol = _resolve_smoke_protocol_from_deployment(deployment, model_name)
|
|
100
|
+
while True:
|
|
101
|
+
attempt += 1
|
|
102
|
+
if access_kind == 'ollama-native':
|
|
103
|
+
ok, message = _ready_ollama_probe(
|
|
104
|
+
base_url=base_url,
|
|
105
|
+
model=model_name,
|
|
106
|
+
prompt=prompt,
|
|
107
|
+
max_tokens=max_tokens,
|
|
108
|
+
require_generation=require_generation,
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
ok, message = _ready_openai_probe(
|
|
112
|
+
base_url=base_url,
|
|
113
|
+
headers=headers,
|
|
114
|
+
model=model_name,
|
|
115
|
+
protocol=protocol,
|
|
116
|
+
prompt=prompt,
|
|
117
|
+
max_tokens=max_tokens,
|
|
118
|
+
require_generation=require_generation,
|
|
119
|
+
)
|
|
120
|
+
last_message = message
|
|
121
|
+
if ok:
|
|
122
|
+
if not quiet:
|
|
123
|
+
print(f'Ready: {message}')
|
|
124
|
+
return message
|
|
125
|
+
now = time.monotonic()
|
|
126
|
+
if now >= deadline:
|
|
127
|
+
raise SystemExit(
|
|
128
|
+
'Timed out waiting for the active stack to serve requests.\n'
|
|
129
|
+
f'Last probe: {_explain_readiness_message(last_message)}\n'
|
|
130
|
+
'Useful diagnostics:\n'
|
|
131
|
+
' infer-stack diagnose --logs --tail 80\n'
|
|
132
|
+
' infer-stack ps\n'
|
|
133
|
+
' infer-stack logs vllm-* litellm open-webui'
|
|
134
|
+
)
|
|
135
|
+
if not quiet and (attempt == 1 or attempt % 6 == 0):
|
|
136
|
+
print(
|
|
137
|
+
f'Waiting for readiness: {_explain_readiness_message(last_message)}'
|
|
138
|
+
)
|
|
139
|
+
time.sleep(float(interval))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _smoke_plan(cfg: dict[str, Any], config: Any) -> dict[str, Any]:
|
|
143
|
+
overrides = _as_mapping(config)
|
|
144
|
+
return build_plan(
|
|
145
|
+
cfg,
|
|
146
|
+
profile_name=overrides.get('profile'),
|
|
147
|
+
allow_unsupported=effective_allow_unsupported(config, cfg),
|
|
148
|
+
inventory=effective_inventory(config),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _infer_default_base_url(
|
|
153
|
+
cfg: dict[str, Any], config: Any, deployment: dict[str, Any] | None = None
|
|
154
|
+
) -> str:
|
|
155
|
+
explicit = _as_mapping(config).get('base_url')
|
|
156
|
+
if explicit:
|
|
157
|
+
return str(explicit).rstrip('/')
|
|
158
|
+
if deployment is None:
|
|
159
|
+
try:
|
|
160
|
+
deployment = _smoke_plan(cfg, config).get('deployment', {})
|
|
161
|
+
except Exception:
|
|
162
|
+
deployment = {
|
|
163
|
+
'backend': backend_name(cfg),
|
|
164
|
+
'cluster': cfg.get('cluster', {}),
|
|
165
|
+
'ports': cfg.get('ports', {}),
|
|
166
|
+
}
|
|
167
|
+
return default_base_url(deployment)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _smoke_request(
|
|
171
|
+
method: str,
|
|
172
|
+
url: str,
|
|
173
|
+
*,
|
|
174
|
+
headers: dict[str, str] | None = None,
|
|
175
|
+
json_body: Any = None,
|
|
176
|
+
timeout: float = 30,
|
|
177
|
+
retries: int = 1,
|
|
178
|
+
retry_delay: float = 2.0,
|
|
179
|
+
) -> requests.Response:
|
|
180
|
+
"""Wrapper around ``requests.{get,post}`` that emits actionable errors.
|
|
181
|
+
|
|
182
|
+
The smoke test runs against a stack that may be (a) not listening yet,
|
|
183
|
+
(b) listening but with an unhealthy upstream that resets connections, or
|
|
184
|
+
(c) returning HTTP errors during model load. Retry transient startup
|
|
185
|
+
failures so ``switch --apply && smoke-test`` is usable immediately after a
|
|
186
|
+
provider container was recreated.
|
|
187
|
+
"""
|
|
188
|
+
last_timeout: requests.exceptions.Timeout | None = None
|
|
189
|
+
last_conn: requests.exceptions.ConnectionError | None = None
|
|
190
|
+
for attempt in range(1, max(1, retries) + 1):
|
|
191
|
+
try:
|
|
192
|
+
if method.upper() == 'GET':
|
|
193
|
+
resp = requests.get(url, headers=headers, timeout=timeout)
|
|
194
|
+
else:
|
|
195
|
+
resp = requests.post(
|
|
196
|
+
url, headers=headers, json=json_body, timeout=timeout
|
|
197
|
+
)
|
|
198
|
+
break
|
|
199
|
+
except requests.exceptions.Timeout as ex:
|
|
200
|
+
last_timeout = ex
|
|
201
|
+
if attempt < retries:
|
|
202
|
+
import time
|
|
203
|
+
|
|
204
|
+
time.sleep(retry_delay)
|
|
205
|
+
continue
|
|
206
|
+
raise SystemExit(
|
|
207
|
+
f'Request to {url} timed out after {timeout}s.\n'
|
|
208
|
+
'The model may still be loading, or the server is overloaded.\n'
|
|
209
|
+
' infer-stack logs vllm-*'
|
|
210
|
+
) from ex
|
|
211
|
+
except requests.exceptions.ConnectionError as ex:
|
|
212
|
+
last_conn = ex
|
|
213
|
+
if attempt < retries:
|
|
214
|
+
import time
|
|
215
|
+
|
|
216
|
+
time.sleep(retry_delay)
|
|
217
|
+
continue
|
|
218
|
+
# Two distinct sub-cases inside ConnectionError that warrant different
|
|
219
|
+
# remediation: (a) nothing listening on the port, (b) something is
|
|
220
|
+
# listening but it closed the connection without responding (typical
|
|
221
|
+
# of LiteLLM up but a depended-on vLLM container still loading the
|
|
222
|
+
# model and failing the dependency health-check chain).
|
|
223
|
+
cause = ex.args[0] if ex.args else ex
|
|
224
|
+
cause_str = str(cause)
|
|
225
|
+
if (
|
|
226
|
+
'RemoteDisconnected' in cause_str
|
|
227
|
+
or 'Connection aborted' in cause_str
|
|
228
|
+
):
|
|
229
|
+
raise SystemExit(
|
|
230
|
+
f'Connection to {url} was closed before a response arrived.\n'
|
|
231
|
+
'The router is listening but an upstream service is not ready yet.\n'
|
|
232
|
+
'Check container status and logs:\n'
|
|
233
|
+
' infer-stack ps\n'
|
|
234
|
+
' infer-stack logs vllm-*'
|
|
235
|
+
) from ex
|
|
236
|
+
if (
|
|
237
|
+
'Connection refused' in cause_str
|
|
238
|
+
or 'Failed to establish a new connection' in cause_str
|
|
239
|
+
):
|
|
240
|
+
raise SystemExit(
|
|
241
|
+
f'Could not connect to {url}: nothing is listening yet.\n'
|
|
242
|
+
'If you just ran `infer-stack up`, give the router a few seconds.\n'
|
|
243
|
+
' infer-stack ps # confirm the litellm container is running\n'
|
|
244
|
+
' infer-stack logs litellm # check for startup errors'
|
|
245
|
+
) from ex
|
|
246
|
+
raise SystemExit(
|
|
247
|
+
f'Connection error reaching {url}: {cause_str}'
|
|
248
|
+
) from ex
|
|
249
|
+
else: # pragma: no cover - defensive; loop exits via break or raise
|
|
250
|
+
if last_timeout is not None:
|
|
251
|
+
raise last_timeout
|
|
252
|
+
if last_conn is not None:
|
|
253
|
+
raise last_conn
|
|
254
|
+
raise RuntimeError('smoke request failed without an exception')
|
|
255
|
+
status = getattr(resp, 'status_code', 200)
|
|
256
|
+
if status >= 400:
|
|
257
|
+
body = getattr(resp, 'text', '') or ''
|
|
258
|
+
body = body.strip()
|
|
259
|
+
if len(body) > 500:
|
|
260
|
+
body = body[:500] + '... [truncated]'
|
|
261
|
+
reason = getattr(resp, 'reason', '') or ''
|
|
262
|
+
if status in (401, 403):
|
|
263
|
+
raise SystemExit(
|
|
264
|
+
f'{status} {reason} from {url}.\n'
|
|
265
|
+
"The auth key didn't match what the running container expects.\n"
|
|
266
|
+
'If you re-rendered after the container started, the key in .env '
|
|
267
|
+
'may have changed. Restart with:\n'
|
|
268
|
+
' infer-stack down && infer-stack up -d\n'
|
|
269
|
+
f'Response: {body}'
|
|
270
|
+
)
|
|
271
|
+
if status == 503:
|
|
272
|
+
raise SystemExit(
|
|
273
|
+
f'{status} {reason} from {url}.\n'
|
|
274
|
+
'An upstream service is unavailable (commonly the vLLM engine is still loading).\n'
|
|
275
|
+
' infer-stack logs vllm-*\n'
|
|
276
|
+
f'Response: {body}'
|
|
277
|
+
)
|
|
278
|
+
raise SystemExit(
|
|
279
|
+
f'HTTP {status} {reason} from {url}.\nResponse: {body}'
|
|
280
|
+
)
|
|
281
|
+
return resp
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _resolve_smoke_test_protocol(
|
|
285
|
+
cfg: dict[str, Any],
|
|
286
|
+
config: Any,
|
|
287
|
+
model_name: str,
|
|
288
|
+
) -> str:
|
|
289
|
+
"""Pick the OpenAI route for smoke-test based on protocol resolution order.
|
|
290
|
+
|
|
291
|
+
1. ``--protocol`` CLI override (``chat`` or ``completions``).
|
|
292
|
+
2. Resolved deployment: if the requested model maps to a service whose
|
|
293
|
+
protocol_mode is known, use that.
|
|
294
|
+
3. Active profile's primary service protocol_mode.
|
|
295
|
+
4. Fallback: ``chat``.
|
|
296
|
+
"""
|
|
297
|
+
overrides = _as_mapping(config)
|
|
298
|
+
explicit = overrides.get('protocol')
|
|
299
|
+
if explicit:
|
|
300
|
+
return str(explicit)
|
|
301
|
+
try:
|
|
302
|
+
plan = build_plan(
|
|
303
|
+
cfg,
|
|
304
|
+
profile_name=overrides.get('profile'),
|
|
305
|
+
allow_unsupported=effective_allow_unsupported(config, cfg),
|
|
306
|
+
inventory=effective_inventory(config),
|
|
307
|
+
)
|
|
308
|
+
except Exception:
|
|
309
|
+
return 'chat'
|
|
310
|
+
deployment = plan.get('deployment', {})
|
|
311
|
+
return _resolve_smoke_protocol_from_deployment(deployment, model_name)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _ollama_smoke_test(
|
|
315
|
+
base_url: str,
|
|
316
|
+
*,
|
|
317
|
+
model: str | None,
|
|
318
|
+
prompt: str,
|
|
319
|
+
max_tokens: int,
|
|
320
|
+
skip_chat: bool,
|
|
321
|
+
) -> int:
|
|
322
|
+
"""Smoke-test an Ollama-native endpoint without requiring LiteLLM."""
|
|
323
|
+
tags_resp = _smoke_request(
|
|
324
|
+
'GET', f'{base_url}/api/tags', timeout=30, retries=12, retry_delay=5
|
|
325
|
+
)
|
|
326
|
+
tags_doc = tags_resp.json()
|
|
327
|
+
print(json.dumps(tags_doc, indent=2))
|
|
328
|
+
if skip_chat:
|
|
329
|
+
return 0
|
|
330
|
+
models = tags_doc.get('models') or []
|
|
331
|
+
model_name = model or (models[0].get('name') if models else None)
|
|
332
|
+
if not model_name:
|
|
333
|
+
raise SystemExit(
|
|
334
|
+
'Ollama is reachable, but no models are installed in its model store.\n'
|
|
335
|
+
'Pull one through the CLI wrapper, for example:\n'
|
|
336
|
+
' infer-stack ollama-pull smollm2:135m\n'
|
|
337
|
+
'Then rerun:\n'
|
|
338
|
+
' infer-stack smoke-test --model smollm2:135m'
|
|
339
|
+
)
|
|
340
|
+
payload = {
|
|
341
|
+
'model': model_name,
|
|
342
|
+
'messages': [{'role': 'user', 'content': prompt}],
|
|
343
|
+
'stream': False,
|
|
344
|
+
'options': {'num_predict': max_tokens},
|
|
345
|
+
}
|
|
346
|
+
resp = _smoke_request(
|
|
347
|
+
'POST',
|
|
348
|
+
f'{base_url}/api/chat',
|
|
349
|
+
json_body=payload,
|
|
350
|
+
timeout=120,
|
|
351
|
+
retries=3,
|
|
352
|
+
retry_delay=5,
|
|
353
|
+
)
|
|
354
|
+
print(json.dumps(resp.json(), indent=2))
|
|
355
|
+
return 0
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class DiagnoseCLI(
|
|
359
|
+
_PathOverridesMixin,
|
|
360
|
+
_ProfileOverrideMixin,
|
|
361
|
+
_BackendOverrideMixin,
|
|
362
|
+
_PortOverridesMixin,
|
|
363
|
+
_ClusterOverridesMixin,
|
|
364
|
+
_AllowUnsupportedMixin,
|
|
365
|
+
_SimulateHardwareMixin,
|
|
366
|
+
):
|
|
367
|
+
"""Print targeted diagnostics for the active rendered stack.
|
|
368
|
+
|
|
369
|
+
This command is intentionally more specific than ``ps`` or ``logs``. It
|
|
370
|
+
prints the resolved provider/gateway/frontend graph, rendered compose
|
|
371
|
+
service state, LiteLLM route probes, direct provider probes, and optional
|
|
372
|
+
recent logs. It helps distinguish these cases:
|
|
373
|
+
|
|
374
|
+
* LiteLLM container is actually absent/down.
|
|
375
|
+
* LiteLLM is running but its upstream vLLM process is still booting.
|
|
376
|
+
* Open WebUI is polling a provider that is not present in the active
|
|
377
|
+
profile.
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
__command__ = 'diagnose'
|
|
381
|
+
|
|
382
|
+
model = scfg.Value(
|
|
383
|
+
None,
|
|
384
|
+
type=str,
|
|
385
|
+
help='Model/alias to use for optional generation diagnostics.',
|
|
386
|
+
)
|
|
387
|
+
logs = scfg.Value(
|
|
388
|
+
False,
|
|
389
|
+
isflag=True,
|
|
390
|
+
help='Include recent logs for litellm/open-webui/vllm/ollama services.',
|
|
391
|
+
)
|
|
392
|
+
tail = scfg.Value(
|
|
393
|
+
80, type=int, help='Number of log lines per service when --logs is set.'
|
|
394
|
+
)
|
|
395
|
+
generation = scfg.Value(
|
|
396
|
+
False,
|
|
397
|
+
isflag=True,
|
|
398
|
+
help='Also run a tiny generation probe through the active access surface.',
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
@classmethod
|
|
402
|
+
def main(cls, argv=True, **kwargs):
|
|
403
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
404
|
+
_apply_path_overrides(config)
|
|
405
|
+
cfg = config_for_runtime(config)
|
|
406
|
+
plan = build_plan(
|
|
407
|
+
cfg,
|
|
408
|
+
profile_name=_as_mapping(config).get('profile'),
|
|
409
|
+
allow_unsupported=effective_allow_unsupported(config, cfg),
|
|
410
|
+
inventory=effective_inventory(config),
|
|
411
|
+
)
|
|
412
|
+
deployment = plan.get('deployment', {}) or {}
|
|
413
|
+
print(
|
|
414
|
+
f'active_profile: {deployment.get("source", {}).get("active_profile") or cfg.get("active_profile")}'
|
|
415
|
+
)
|
|
416
|
+
print(f'backend: {deployment.get("backend") or backend_name(cfg)}')
|
|
417
|
+
print(f'plan: {plan_path(cfg)}')
|
|
418
|
+
access = (deployment.get('access', {}) or {}).get('default', {}) or {}
|
|
419
|
+
if access:
|
|
420
|
+
print('default access:')
|
|
421
|
+
print(f' kind: {access.get("kind")}')
|
|
422
|
+
print(f' base_url: {access.get("base_url")}')
|
|
423
|
+
if access.get('auth_env_name'):
|
|
424
|
+
print(f' auth_env_name: {access.get("auth_env_name")}')
|
|
425
|
+
|
|
426
|
+
providers = deployment.get('providers', {}) or {}
|
|
427
|
+
gateways = deployment.get('gateways', {}) or {}
|
|
428
|
+
frontends = deployment.get('frontends', {}) or {}
|
|
429
|
+
print('\nresolved graph:')
|
|
430
|
+
print(
|
|
431
|
+
f' providers: {", ".join(k for k, v in providers.items() if (v or {}).get("enabled") or (v or {}).get("runtimes")) or "none"}'
|
|
432
|
+
)
|
|
433
|
+
print(
|
|
434
|
+
f' gateways: {", ".join(k for k, v in gateways.items() if (v or {}).get("enabled")) or "none"}'
|
|
435
|
+
)
|
|
436
|
+
print(
|
|
437
|
+
f' frontends: {", ".join(k for k, v in frontends.items() if (v or {}).get("enabled")) or "none"}'
|
|
438
|
+
)
|
|
439
|
+
litellm_routes = (gateways.get('litellm') or {}).get('routes') or {}
|
|
440
|
+
if litellm_routes:
|
|
441
|
+
print('\nLiteLLM routes:')
|
|
442
|
+
for alias, route in litellm_routes.items():
|
|
443
|
+
print(
|
|
444
|
+
f' {alias}: provider={route.get("provider")} '
|
|
445
|
+
f'runtime={route.get("runtime", "-")} upstream={route.get("upstream_model", route.get("model", "-"))} '
|
|
446
|
+
f'protocol={route.get("protocol_mode", "chat")}'
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
if backend_name(cfg) == 'compose':
|
|
450
|
+
_print_compose_diagnostics(
|
|
451
|
+
cfg, tail=int(config.tail) if config.logs else 0
|
|
452
|
+
)
|
|
453
|
+
_print_gateway_diagnostics(
|
|
454
|
+
cfg,
|
|
455
|
+
deployment,
|
|
456
|
+
model=config.model,
|
|
457
|
+
require_generation=bool(config.generation),
|
|
458
|
+
)
|
|
459
|
+
else:
|
|
460
|
+
namespace = cfg.get('cluster', {}).get('namespace', 'kubeai')
|
|
461
|
+
kubeai_print_status(namespace)
|
|
462
|
+
return 0
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class WaitReadyCLI(
|
|
466
|
+
_PathOverridesMixin,
|
|
467
|
+
_ProfileOverrideMixin,
|
|
468
|
+
_BackendOverrideMixin,
|
|
469
|
+
_PortOverridesMixin,
|
|
470
|
+
_ClusterOverridesMixin,
|
|
471
|
+
_AllowUnsupportedMixin,
|
|
472
|
+
_SimulateHardwareMixin,
|
|
473
|
+
):
|
|
474
|
+
"""Wait until the active profile can serve a real request.
|
|
475
|
+
|
|
476
|
+
This is stronger than Docker Compose health. It probes the same access
|
|
477
|
+
surface users will hit (LiteLLM, direct Ollama, or direct vLLM) and, by
|
|
478
|
+
default, requires a tiny generation/completion to succeed.
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
__command__ = 'wait-ready'
|
|
482
|
+
|
|
483
|
+
base_url = scfg.Value(
|
|
484
|
+
None, type=str, help='Override the resolved base URL.'
|
|
485
|
+
)
|
|
486
|
+
api_key = scfg.Value(
|
|
487
|
+
None,
|
|
488
|
+
type=str,
|
|
489
|
+
help='Override the auth key for OpenAI-compatible surfaces.',
|
|
490
|
+
)
|
|
491
|
+
model = scfg.Value(
|
|
492
|
+
None,
|
|
493
|
+
type=str,
|
|
494
|
+
help='Model/alias to probe. Defaults to the first active route/runtime.',
|
|
495
|
+
)
|
|
496
|
+
prompt = scfg.Value('Reply with ready.', type=str)
|
|
497
|
+
max_tokens = scfg.Value(1, type=int)
|
|
498
|
+
timeout = scfg.Value(600, type=float, help='Maximum seconds to wait.')
|
|
499
|
+
interval = scfg.Value(5, type=float, help='Seconds between probes.')
|
|
500
|
+
skip_generation = scfg.Value(
|
|
501
|
+
False,
|
|
502
|
+
isflag=True,
|
|
503
|
+
help='Only wait for the API model listing/tag endpoint, not generation.',
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
@classmethod
|
|
507
|
+
def main(cls, argv=True, **kwargs):
|
|
508
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
509
|
+
_apply_path_overrides(config)
|
|
510
|
+
cfg = config_for_runtime(config)
|
|
511
|
+
_wait_until_ready(
|
|
512
|
+
cfg,
|
|
513
|
+
config,
|
|
514
|
+
model=config.model,
|
|
515
|
+
timeout=float(config.timeout),
|
|
516
|
+
interval=float(config.interval),
|
|
517
|
+
prompt=config.prompt,
|
|
518
|
+
max_tokens=int(config.max_tokens),
|
|
519
|
+
require_generation=not bool(config.skip_generation),
|
|
520
|
+
quiet=False,
|
|
521
|
+
)
|
|
522
|
+
return 0
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
class SmokeTestCLI(
|
|
526
|
+
_PathOverridesMixin,
|
|
527
|
+
_ProfileOverrideMixin,
|
|
528
|
+
_BackendOverrideMixin,
|
|
529
|
+
_PortOverridesMixin,
|
|
530
|
+
_ClusterOverridesMixin,
|
|
531
|
+
_AllowUnsupportedMixin,
|
|
532
|
+
_SimulateHardwareMixin,
|
|
533
|
+
):
|
|
534
|
+
"""Probe the running router with a single chat/completions request."""
|
|
535
|
+
|
|
536
|
+
__command__ = 'smoke-test'
|
|
537
|
+
|
|
538
|
+
base_url = scfg.Value(None, type=str)
|
|
539
|
+
api_key = scfg.Value(None, type=str)
|
|
540
|
+
model = scfg.Value(None, type=str)
|
|
541
|
+
prompt = scfg.Value('Say hello in one sentence.', type=str)
|
|
542
|
+
max_tokens = scfg.Value(128, type=int)
|
|
543
|
+
skip_chat = scfg.Value(False, isflag=True)
|
|
544
|
+
no_wait = scfg.Value(
|
|
545
|
+
False,
|
|
546
|
+
isflag=True,
|
|
547
|
+
help='Do not wait for the active access surface to serve a real request before the smoke request.',
|
|
548
|
+
)
|
|
549
|
+
wait_timeout = scfg.Value(
|
|
550
|
+
600,
|
|
551
|
+
type=float,
|
|
552
|
+
help='Seconds to wait for readiness before the smoke request.',
|
|
553
|
+
)
|
|
554
|
+
wait_interval = scfg.Value(
|
|
555
|
+
5, type=float, help='Seconds between readiness probes.'
|
|
556
|
+
)
|
|
557
|
+
protocol = scfg.Value(
|
|
558
|
+
None,
|
|
559
|
+
choices=['chat', 'completions'],
|
|
560
|
+
help="Force the smoke-test endpoint. Defaults to the resolved profile's protocol_mode.",
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
@classmethod
|
|
564
|
+
def main(cls, argv=True, **kwargs):
|
|
565
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
566
|
+
_apply_path_overrides(config)
|
|
567
|
+
cfg = config_for_runtime(config)
|
|
568
|
+
plan = _smoke_plan(cfg, config)
|
|
569
|
+
deployment = plan.get('deployment', {})
|
|
570
|
+
access = deployment.get('access', {}).get('default', {}) or {}
|
|
571
|
+
env = (
|
|
572
|
+
parse_env_file(runtime_env_path(cfg))
|
|
573
|
+
if backend_name(cfg) == 'compose'
|
|
574
|
+
else {}
|
|
575
|
+
)
|
|
576
|
+
base_url = _infer_default_base_url(cfg, config, deployment=deployment)
|
|
577
|
+
|
|
578
|
+
if not bool(config.no_wait):
|
|
579
|
+
_wait_until_ready(
|
|
580
|
+
cfg,
|
|
581
|
+
config,
|
|
582
|
+
model=config.model,
|
|
583
|
+
timeout=float(config.wait_timeout),
|
|
584
|
+
interval=float(config.wait_interval),
|
|
585
|
+
prompt=config.prompt,
|
|
586
|
+
max_tokens=1,
|
|
587
|
+
require_generation=not bool(config.skip_chat),
|
|
588
|
+
quiet=True,
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
access_kind = str(access.get('kind') or 'openai-compatible')
|
|
592
|
+
explicit_base_url = bool(_as_mapping(config).get('base_url'))
|
|
593
|
+
if access_kind == 'ollama-native' and not explicit_base_url:
|
|
594
|
+
return _ollama_smoke_test(
|
|
595
|
+
base_url,
|
|
596
|
+
model=config.model,
|
|
597
|
+
prompt=config.prompt,
|
|
598
|
+
max_tokens=int(config.max_tokens),
|
|
599
|
+
skip_chat=bool(config.skip_chat),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
headers = {'Content-Type': 'application/json'}
|
|
603
|
+
auth_env_name = str(access.get('auth_env_name') or 'LITELLM_MASTER_KEY')
|
|
604
|
+
api_key = (
|
|
605
|
+
config.api_key
|
|
606
|
+
or env.get(auth_env_name, '')
|
|
607
|
+
or env.get('LITELLM_MASTER_KEY', '')
|
|
608
|
+
)
|
|
609
|
+
if api_key:
|
|
610
|
+
headers['Authorization'] = f'Bearer {api_key}'
|
|
611
|
+
|
|
612
|
+
models_resp = _smoke_request(
|
|
613
|
+
'GET',
|
|
614
|
+
f'{base_url}/models',
|
|
615
|
+
headers=headers,
|
|
616
|
+
timeout=30,
|
|
617
|
+
retries=12,
|
|
618
|
+
retry_delay=5,
|
|
619
|
+
)
|
|
620
|
+
models = models_resp.json().get('data', [])
|
|
621
|
+
print(json.dumps(models_resp.json(), indent=2))
|
|
622
|
+
if config.skip_chat:
|
|
623
|
+
return 0
|
|
624
|
+
if not models:
|
|
625
|
+
raise SystemExit('No models returned from /models')
|
|
626
|
+
model_name = config.model or models[0]['id']
|
|
627
|
+
protocol = _resolve_smoke_test_protocol(cfg, config, model_name)
|
|
628
|
+
if protocol == 'completions':
|
|
629
|
+
payload = {
|
|
630
|
+
'model': model_name,
|
|
631
|
+
'prompt': config.prompt,
|
|
632
|
+
'max_tokens': config.max_tokens,
|
|
633
|
+
}
|
|
634
|
+
endpoint = f'{base_url}/completions'
|
|
635
|
+
else:
|
|
636
|
+
payload = {
|
|
637
|
+
'model': model_name,
|
|
638
|
+
'messages': [{'role': 'user', 'content': config.prompt}],
|
|
639
|
+
'max_tokens': config.max_tokens,
|
|
640
|
+
}
|
|
641
|
+
endpoint = f'{base_url}/chat/completions'
|
|
642
|
+
resp = _smoke_request(
|
|
643
|
+
'POST',
|
|
644
|
+
endpoint,
|
|
645
|
+
headers=headers,
|
|
646
|
+
json_body=payload,
|
|
647
|
+
timeout=120,
|
|
648
|
+
retries=3,
|
|
649
|
+
retry_delay=5,
|
|
650
|
+
)
|
|
651
|
+
print(json.dumps(resp.json(), indent=2))
|
|
652
|
+
return 0
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
class BenchmarkCLI(
|
|
656
|
+
_PathOverridesMixin,
|
|
657
|
+
_BackendOverrideMixin,
|
|
658
|
+
_ComposeOverrideMixin,
|
|
659
|
+
_PortOverridesMixin,
|
|
660
|
+
):
|
|
661
|
+
"""Run benchmark_prompts.json against the router."""
|
|
662
|
+
|
|
663
|
+
model = scfg.Value(None, type=str, required=True)
|
|
664
|
+
base_url = scfg.Value(None, type=str)
|
|
665
|
+
api_key = scfg.Value(None, type=str)
|
|
666
|
+
|
|
667
|
+
@classmethod
|
|
668
|
+
def main(cls, argv=True, **kwargs):
|
|
669
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
670
|
+
_apply_path_overrides(config)
|
|
671
|
+
# benchmark_prompts.json is a user-supplied fixture. Look for it
|
|
672
|
+
# first in the config dir, then fall back to CWD so an ad-hoc
|
|
673
|
+
# invocation from a checkout still picks up a sibling file.
|
|
674
|
+
prompts_path = config_root() / 'benchmark_prompts.json'
|
|
675
|
+
if not prompts_path.exists():
|
|
676
|
+
prompts_path = Path.cwd() / 'benchmark_prompts.json'
|
|
677
|
+
if not prompts_path.exists():
|
|
678
|
+
raise SystemExit(
|
|
679
|
+
f'benchmark_prompts.json not found at {config_root() / "benchmark_prompts.json"} '
|
|
680
|
+
f'or {Path.cwd() / "benchmark_prompts.json"}'
|
|
681
|
+
)
|
|
682
|
+
prompts = json.loads(prompts_path.read_text(encoding='utf-8'))
|
|
683
|
+
cfg = config_for_runtime(config)
|
|
684
|
+
env = parse_env_file(runtime_env_path(cfg))
|
|
685
|
+
base_url = (
|
|
686
|
+
config.base_url or f'http://127.0.0.1:{cfg["ports"]["litellm"]}/v1'
|
|
687
|
+
)
|
|
688
|
+
api_key = config.api_key or env.get('LITELLM_MASTER_KEY', '')
|
|
689
|
+
data = run_benchmark(base_url, api_key, config.model, prompts)
|
|
690
|
+
print(json.dumps(data, indent=2))
|
|
691
|
+
return 0
|