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