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,397 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Long-context stress test for a vLLM/OpenAI-compatible endpoint.
4
+
5
+ What it does:
6
+ 1. Generates a long synthetic corpus with random "needle" facts embedded throughout.
7
+ 2. Uses the Qwen tokenizer chat template to size the prompt near a target token budget.
8
+ 3. Sends retrieval and arithmetic questions to the model with requests.
9
+ 4. Checks the returned answers and prints PASS/FAIL.
10
+
11
+ Typical usage:
12
+
13
+ python stress_test_long_context.py \
14
+ --base-url http://127.0.0.1:18000/v1 \
15
+ --model qwen3.5-122b-a10b-fp8-262k \
16
+ --max-context 262144 \
17
+ --reserved-output 512
18
+
19
+ If your API key is in generated/.env as VLLM_BACKEND_API_KEY, it will be auto-detected.
20
+ Otherwise pass --api-key explicitly.
21
+
22
+ Requirements:
23
+ pip install requests transformers
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import os
31
+ import random
32
+ import re
33
+ import string
34
+ import sys
35
+ import uuid
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ import requests
41
+ from transformers import AutoTokenizer
42
+
43
+
44
+ @dataclass
45
+ class Fact:
46
+ marker: str
47
+ secret_value: int
48
+ secret_city: str
49
+ secret_word: str
50
+
51
+
52
+ def load_api_key(cli_value: str | None) -> str | None:
53
+ if cli_value:
54
+ return cli_value
55
+ env_key = os.environ.get('VLLM_BACKEND_API_KEY') or os.environ.get(
56
+ 'OPENAI_API_KEY'
57
+ )
58
+ if env_key:
59
+ return env_key
60
+ env_path = Path('generated/.env')
61
+ if env_path.exists():
62
+ text = env_path.read_text(encoding='utf-8')
63
+ for line in text.splitlines():
64
+ if line.startswith('VLLM_BACKEND_API_KEY='):
65
+ return line.split('=', 1)[1].strip()
66
+ return None
67
+
68
+
69
+ def strip_think_tags(text: str) -> str:
70
+ # Remove <think>...</think> blocks if present.
71
+ return re.sub(r'<think>.*?</think>', '', text, flags=re.S).strip()
72
+
73
+
74
+ def normalize_answer(text: str) -> str:
75
+ text = strip_think_tags(text)
76
+ text = text.strip()
77
+ # Remove wrapping code fences or quotes.
78
+ text = text.strip('`').strip()
79
+ text = text.strip('"').strip("'").strip()
80
+ return text
81
+
82
+
83
+ def random_word(min_len: int = 4, max_len: int = 10) -> str:
84
+ return ''.join(
85
+ random.choices(
86
+ string.ascii_lowercase, k=random.randint(min_len, max_len)
87
+ )
88
+ )
89
+
90
+
91
+ def filler_paragraph(num_words: int = 120) -> str:
92
+ return ' '.join(random_word() for _ in range(num_words))
93
+
94
+
95
+ def build_facts(n: int) -> list[Fact]:
96
+ cities = [
97
+ 'Lima',
98
+ 'Oslo',
99
+ 'Accra',
100
+ 'Perth',
101
+ 'Quito',
102
+ 'Hobart',
103
+ 'Dakar',
104
+ 'Tallinn',
105
+ 'Salta',
106
+ 'Cork',
107
+ 'Uppsala',
108
+ 'Medellin',
109
+ 'LuangPrabang',
110
+ ]
111
+ facts: list[Fact] = []
112
+ for _ in range(n):
113
+ marker = str(uuid.uuid4())[:8].upper()
114
+ facts.append(
115
+ Fact(
116
+ marker=marker,
117
+ secret_value=random.randint(100000, 999999),
118
+ secret_city=random.choice(cities),
119
+ secret_word=random_word(7, 12).upper(),
120
+ )
121
+ )
122
+ return facts
123
+
124
+
125
+ def build_corpus(target_sections: int, facts: list[Fact]) -> str:
126
+ """
127
+ Build a long boring corpus with fact blocks inserted at spread-out positions.
128
+ """
129
+ sections = [
130
+ f'SECTION {i}\n{filler_paragraph()}\n' for i in range(target_sections)
131
+ ]
132
+ # Spread facts through the document.
133
+ positions = [
134
+ int(i * target_sections / max(len(facts), 1)) for i in range(len(facts))
135
+ ]
136
+ positions = [min(target_sections - 1, max(0, p)) for p in positions]
137
+
138
+ for pos, fact in zip(positions, facts):
139
+ sections[pos] += (
140
+ f'\nMARKER {fact.marker}\n'
141
+ f'SECRET_VALUE {fact.secret_value}\n'
142
+ f'SECRET_CITY {fact.secret_city}\n'
143
+ f'SECRET_WORD {fact.secret_word}\n'
144
+ f'END_MARKER {fact.marker}\n'
145
+ )
146
+
147
+ return '\n'.join(sections)
148
+
149
+
150
+ def chat_token_count(tokenizer: Any, user_content: str) -> int:
151
+ ids = tokenizer.apply_chat_template(
152
+ [{'role': 'user', 'content': user_content}],
153
+ tokenize=True,
154
+ add_generation_prompt=True,
155
+ )
156
+ return len(ids)
157
+
158
+
159
+ def fit_corpus_to_budget(
160
+ tokenizer: Any,
161
+ base_instruction: str,
162
+ corpus_template: str,
163
+ target_prompt_tokens: int,
164
+ ) -> str:
165
+ """
166
+ Binary-search the number of repeated filler sections until the whole prompt
167
+ fits just under target_prompt_tokens.
168
+ """
169
+ lo, hi = 1, 4000
170
+ best = corpus_template
171
+
172
+ while lo <= hi:
173
+ mid = (lo + hi) // 2
174
+ test_corpus = corpus_template.format(
175
+ extra_sections=('\nEXTRA\n' + filler_paragraph() + '\n') * mid
176
+ )
177
+ user_content = (
178
+ f'{base_instruction}\n\nBEGIN CORPUS\n{test_corpus}\nEND CORPUS'
179
+ )
180
+ n = chat_token_count(tokenizer, user_content)
181
+ if n <= target_prompt_tokens:
182
+ best = test_corpus
183
+ lo = mid + 1
184
+ else:
185
+ hi = mid - 1
186
+
187
+ return best
188
+
189
+
190
+ def make_questions(facts: list[Fact]) -> list[dict[str, Any]]:
191
+ """
192
+ Build a small suite of retrieval and aggregation questions.
193
+ """
194
+ qs: list[dict[str, Any]] = []
195
+
196
+ # Exact value retrieval
197
+ f0 = facts[0]
198
+ qs.append(
199
+ {
200
+ 'kind': 'value',
201
+ 'question': (
202
+ f'Using only the provided corpus, what is the SECRET_VALUE for marker {f0.marker}? '
203
+ 'Reply with only the integer.'
204
+ ),
205
+ 'expected': str(f0.secret_value),
206
+ }
207
+ )
208
+
209
+ # Exact city retrieval
210
+ f1 = facts[len(facts) // 2]
211
+ qs.append(
212
+ {
213
+ 'kind': 'city',
214
+ 'question': (
215
+ f'Using only the provided corpus, what is the SECRET_CITY for marker {f1.marker}? '
216
+ 'Reply with only the city name.'
217
+ ),
218
+ 'expected': f1.secret_city,
219
+ }
220
+ )
221
+
222
+ # Exact word retrieval from later in the corpus
223
+ f2 = facts[-1]
224
+ qs.append(
225
+ {
226
+ 'kind': 'word',
227
+ 'question': (
228
+ f'Using only the provided corpus, what is the SECRET_WORD for marker {f2.marker}? '
229
+ 'Reply with only the word.'
230
+ ),
231
+ 'expected': f2.secret_word,
232
+ }
233
+ )
234
+
235
+ # Arithmetic over multiple markers
236
+ picks = [facts[1], facts[len(facts) // 3], facts[-2]]
237
+ total = sum(f.secret_value for f in picks)
238
+ markers = ', '.join(f.marker for f in picks)
239
+ qs.append(
240
+ {
241
+ 'kind': 'sum',
242
+ 'question': (
243
+ f'Using only the provided corpus, add the SECRET_VALUE values for markers {markers}. '
244
+ 'Reply with only the integer sum.'
245
+ ),
246
+ 'expected': str(total),
247
+ }
248
+ )
249
+
250
+ return qs
251
+
252
+
253
+ def call_chat_completion(
254
+ session: requests.Session,
255
+ base_url: str,
256
+ api_key: str | None,
257
+ model: str,
258
+ prompt: str,
259
+ question: str,
260
+ max_tokens: int,
261
+ temperature: float,
262
+ ) -> str:
263
+ url = base_url.rstrip('/') + '/chat/completions'
264
+ payload: dict[str, Any] = {
265
+ 'model': model,
266
+ 'messages': [
267
+ {
268
+ 'role': 'system',
269
+ 'content': (
270
+ 'Answer using only the provided corpus. '
271
+ 'If the requested value is not present, reply with NOT_FOUND.'
272
+ ),
273
+ },
274
+ {
275
+ 'role': 'user',
276
+ 'content': f'{prompt}\n\nQUESTION:\n{question}',
277
+ },
278
+ ],
279
+ 'max_tokens': max_tokens,
280
+ 'temperature': temperature,
281
+ }
282
+ headers = {'Content-Type': 'application/json'}
283
+ if api_key:
284
+ headers['Authorization'] = f'Bearer {api_key}'
285
+
286
+ response = session.post(
287
+ url, headers=headers, data=json.dumps(payload), timeout=3600
288
+ )
289
+ response.raise_for_status()
290
+ data = response.json()
291
+ return data['choices'][0]['message']['content']
292
+
293
+
294
+ def main() -> int:
295
+ parser = argparse.ArgumentParser()
296
+ parser.add_argument('--base-url', default='http://127.0.0.1:18000/v1')
297
+ parser.add_argument('--model', required=True)
298
+ parser.add_argument(
299
+ '--tokenizer', default=None, help='Defaults to --model if omitted.'
300
+ )
301
+ parser.add_argument('--api-key', default=None)
302
+ parser.add_argument('--max-context', type=int, required=True)
303
+ parser.add_argument('--reserved-output', type=int, default=512)
304
+ parser.add_argument('--temperature', type=float, default=0.0)
305
+ parser.add_argument('--num-facts', type=int, default=10)
306
+ parser.add_argument('--seed', type=int, default=0)
307
+ parser.add_argument('--save-corpus', default='haystack_generated.txt')
308
+ parser.add_argument('--save-answers', default='answer_key_generated.json')
309
+ args = parser.parse_args()
310
+
311
+ random.seed(args.seed)
312
+ api_key = load_api_key(args.api_key)
313
+ tokenizer_name = args.tokenizer or args.model
314
+
315
+ print(f'Loading tokenizer: {tokenizer_name}', file=sys.stderr)
316
+ tokenizer = AutoTokenizer.from_pretrained(tokenizer_name)
317
+
318
+ facts = build_facts(args.num_facts)
319
+
320
+ # Base corpus with distributed facts. We will append extra filler during sizing.
321
+ corpus_without_extra = build_corpus(target_sections=1800, facts=facts)
322
+ corpus_template = corpus_without_extra + '\n{extra_sections}'
323
+
324
+ base_instruction = (
325
+ 'You will be given a long corpus delimited by BEGIN CORPUS and END CORPUS. '
326
+ 'Use only that corpus to answer the later question.'
327
+ )
328
+
329
+ # Keep a little slack so the specific question still fits.
330
+ target_prompt_tokens = args.max_context - args.reserved_output - 1024
331
+ fitted_corpus = fit_corpus_to_budget(
332
+ tokenizer=tokenizer,
333
+ base_instruction=base_instruction,
334
+ corpus_template=corpus_template,
335
+ target_prompt_tokens=target_prompt_tokens,
336
+ )
337
+
338
+ # Save generated materials for inspection.
339
+ Path(args.save_corpus).write_text(fitted_corpus, encoding='utf-8')
340
+ Path(args.save_answers).write_text(
341
+ json.dumps([fact.__dict__ for fact in facts], indent=2),
342
+ encoding='utf-8',
343
+ )
344
+
345
+ prompt = f'{base_instruction}\n\nBEGIN CORPUS\n{fitted_corpus}\nEND CORPUS'
346
+ prompt_tokens = chat_token_count(tokenizer, prompt)
347
+ print(f'Prompt tokens: {prompt_tokens}')
348
+ print(f'Reserved output tokens: {args.reserved_output}')
349
+ print(f'Total budget target: {args.max_context}')
350
+ print(f'Saved corpus to: {args.save_corpus}')
351
+ print(f'Saved answer key to: {args.save_answers}')
352
+
353
+ questions = make_questions(facts)
354
+ session = requests.Session()
355
+
356
+ passed = 0
357
+ for i, q in enumerate(questions, start=1):
358
+ print(f'\n=== Test {i}: {q["kind"]} ===')
359
+ print('Question:', q['question'])
360
+ try:
361
+ raw = call_chat_completion(
362
+ session=session,
363
+ base_url=args.base_url,
364
+ api_key=api_key,
365
+ model=args.model,
366
+ prompt=prompt,
367
+ question=q['question'],
368
+ max_tokens=args.reserved_output,
369
+ temperature=args.temperature,
370
+ )
371
+ except Exception as ex:
372
+ print(f'FAIL: request error: {ex}')
373
+ continue
374
+
375
+ got = normalize_answer(raw)
376
+ expected = str(q['expected']).strip()
377
+
378
+ # Gentle normalization for exact-answer tasks.
379
+ if q['kind'] in {'value', 'sum'}:
380
+ m = re.search(r'-?\d+', got)
381
+ got_cmp = m.group(0) if m else got
382
+ else:
383
+ got_cmp = got.splitlines()[0].strip()
384
+
385
+ ok = got_cmp == expected
386
+ print('Expected:', expected)
387
+ print('Got:', got_cmp)
388
+ print('Raw output:', raw[:1000].replace('\n', '\\n'))
389
+ print('Result:', 'PASS' if ok else 'FAIL')
390
+ passed += int(ok)
391
+
392
+ print(f'\nPassed {passed}/{len(questions)} tests.')
393
+ return 0 if passed == len(questions) else 1
394
+
395
+
396
+ if __name__ == '__main__':
397
+ raise SystemExit(main())
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import subprocess
5
+ from typing import Any
6
+
7
+
8
+ def _run(cmd: list[str]) -> str:
9
+ try:
10
+ out = subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL)
11
+ except Exception:
12
+ return ''
13
+ return out
14
+
15
+
16
+ def simulate_inventory(spec: str) -> dict[str, Any]:
17
+ """Build a fake inventory from a spec string like '4x96' (4 GPUs × 96 GiB)."""
18
+ try:
19
+ count_str, gib_str = spec.lower().split('x', 1)
20
+ gpu_count = int(count_str)
21
+ memory_gib = float(gib_str)
22
+ except (ValueError, AttributeError):
23
+ raise ValueError(
24
+ f'Invalid --simulate-hardware spec {spec!r}. Expected format: NxM (e.g. 4x96, 2x80).'
25
+ )
26
+ memory_mib = int(memory_gib * 1024)
27
+ gpus = [
28
+ {
29
+ 'index': i,
30
+ 'uuid': f'GPU-simulated-{i:04d}',
31
+ 'name': f'Simulated GPU ({memory_gib:.0f}GiB)',
32
+ 'memory_mib': memory_mib,
33
+ 'memory_gib': memory_gib,
34
+ 'display_active': False,
35
+ }
36
+ for i in range(gpu_count)
37
+ ]
38
+ return {'gpu_count': gpu_count, 'gpus': gpus}
39
+
40
+
41
+ def detect_inventory() -> dict[str, Any]:
42
+ query = _run(
43
+ [
44
+ 'nvidia-smi',
45
+ '--query-gpu=index,uuid,name,memory.total,display_active',
46
+ '--format=csv,noheader,nounits',
47
+ ]
48
+ )
49
+ gpus: list[dict[str, Any]] = []
50
+ if query:
51
+ reader = csv.reader(line for line in query.splitlines() if line.strip())
52
+ for row in reader:
53
+ if len(row) < 5:
54
+ continue
55
+ idx, uuid, name, mem, display_active = [x.strip() for x in row[:5]]
56
+ gpus.append(
57
+ {
58
+ 'index': int(idx),
59
+ 'uuid': uuid,
60
+ 'name': name,
61
+ 'memory_mib': int(float(mem)),
62
+ 'memory_gib': round(int(float(mem)) / 1024, 2),
63
+ 'display_active': display_active.lower()
64
+ in {'enabled', 'active', 'on', 'true'},
65
+ }
66
+ )
67
+ return {
68
+ 'gpu_count': len(gpus),
69
+ 'gpus': gpus,
70
+ }
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from .config import KUBEAI_GENERATED_SUBDIR, normalized_output
8
+
9
+
10
+ class CommandError(RuntimeError):
11
+ pass
12
+
13
+
14
+ def run(cmd: list[str]) -> None:
15
+ proc = subprocess.run(cmd)
16
+ if proc.returncode != 0:
17
+ raise CommandError(
18
+ f'Command failed with exit code {proc.returncode}: {" ".join(cmd)}'
19
+ )
20
+
21
+
22
+ def deploy_rendered_artifacts(deployment: dict) -> None:
23
+ cluster = deployment.get('cluster', {})
24
+ namespace = cluster.get('namespace', 'kubeai')
25
+ release_name = cluster.get('kubeai_release_name', 'kubeai')
26
+ chart = cluster.get('kubeai_chart', 'kubeai/kubeai')
27
+ output_root = Path(
28
+ normalized_output(deployment.get('output'))['generated_dir']
29
+ )
30
+ generated = output_root / KUBEAI_GENERATED_SUBDIR
31
+ values_file = generated / 'kubeai-values.yaml'
32
+ namespace_file = generated / 'namespace.yaml'
33
+ models_file = generated / 'models.yaml'
34
+ ingress_file = generated / 'ingress.yaml'
35
+
36
+ run(['kubectl', 'apply', '-f', str(namespace_file)])
37
+ run(
38
+ [
39
+ 'helm',
40
+ 'repo',
41
+ 'add',
42
+ 'kubeai',
43
+ 'https://www.kubeai.org',
44
+ '--force-update',
45
+ ]
46
+ )
47
+ run(['helm', 'repo', 'update'])
48
+
49
+ helm_cmd = [
50
+ 'helm',
51
+ 'upgrade',
52
+ '--install',
53
+ release_name,
54
+ chart,
55
+ '-n',
56
+ namespace,
57
+ '--create-namespace',
58
+ '-f',
59
+ str(values_file),
60
+ '--wait',
61
+ ]
62
+ hf_token = os.environ.get('HF_TOKEN', '').strip()
63
+ if hf_token:
64
+ helm_cmd.extend(['--set', f'secrets.huggingface.token={hf_token}'])
65
+ run(helm_cmd)
66
+
67
+ run(['kubectl', 'apply', '-f', str(models_file)])
68
+ if ingress_file.exists():
69
+ run(['kubectl', 'apply', '-f', str(ingress_file)])
70
+
71
+
72
+ def print_status(namespace: str) -> None:
73
+ run(['kubectl', '-n', namespace, 'get', 'pods'])
74
+ run(['kubectl', '-n', namespace, 'get', 'svc'])
75
+ run(['kubectl', '-n', namespace, 'get', 'ingress'])
76
+ run(['kubectl', '-n', namespace, 'get', 'models'])
infer_stack/paths.py ADDED
@@ -0,0 +1,87 @@
1
+ """CWD-independent locations for the infer-stack config and runtime data.
2
+
3
+ The CLI used to anchor every path on ``Path.cwd()``, which meant invoking
4
+ ``infer-stack`` from a different directory silently changed where config
5
+ was read from, where rendered artifacts landed, and where bind-mount
6
+ state lived. This module replaces that with two stable roots:
7
+
8
+ * ``config_root()`` — where ``config.yaml`` / ``models.yaml`` /
9
+ ``kubeai-values.local.yaml`` live. Defaults to
10
+ ``ub.Path.appdir('infer_stack', type='config')`` (``~/.config/infer_stack``
11
+ on Linux, respecting ``XDG_CONFIG_HOME``).
12
+ * ``data_root()`` — where ``generated/`` (rendered artifacts) and
13
+ ``state/`` (hf-cache, postgres volumes, runtime bind-mounts) default
14
+ to. ``ub.Path.appdir('infer_stack', type='data')``
15
+ (``~/.local/share/infer_stack`` on Linux, respecting
16
+ ``XDG_DATA_HOME``). Uses ``data`` and not ``cache`` because the stack
17
+ hosts persistent state — postgres databases, Open WebUI chat history,
18
+ and user accounts — that would be silently lost if treated as
19
+ regenerable cache by a system cleanup tool.
20
+
21
+ Both can be overridden by env vars (``INFER_STACK_CONFIG_DIR`` /
22
+ ``INFER_STACK_DATA_DIR``) or by the CLI flags ``--config-dir`` /
23
+ ``--data-dir``. The CLI flags translate into process-wide overrides via
24
+ ``set_config_root`` / ``set_data_root``.
25
+
26
+ ``--data-dir`` is the single knob for "put everything I generate in one
27
+ place": at ``setup`` time it is baked into the absolute ``state.*`` and
28
+ ``output.generated_dir`` paths written to ``config.yaml``. For a bespoke
29
+ split layout, edit those fields in ``config.yaml`` directly.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import os
35
+ from pathlib import Path
36
+
37
+ import ubelt as ub
38
+
39
+
40
+ CONFIG_DIR_ENV = 'INFER_STACK_CONFIG_DIR'
41
+ DATA_DIR_ENV = 'INFER_STACK_DATA_DIR'
42
+
43
+ _config_root_override: Path | None = None
44
+ _data_root_override: Path | None = None
45
+
46
+
47
+ def _default_config_root() -> Path:
48
+ return Path(ub.Path.appdir('infer_stack', type='config'))
49
+
50
+
51
+ def _default_data_root() -> Path:
52
+ return Path(ub.Path.appdir('infer_stack', type='data'))
53
+
54
+
55
+ def config_root() -> Path:
56
+ if _config_root_override is not None:
57
+ return _config_root_override
58
+ env = os.environ.get(CONFIG_DIR_ENV)
59
+ if env:
60
+ return Path(env).expanduser()
61
+ return _default_config_root()
62
+
63
+
64
+ def data_root() -> Path:
65
+ if _data_root_override is not None:
66
+ return _data_root_override
67
+ env = os.environ.get(DATA_DIR_ENV)
68
+ if env:
69
+ return Path(env).expanduser()
70
+ return _default_data_root()
71
+
72
+
73
+ def set_config_root(path: Path | str | None) -> None:
74
+ """Override ``config_root()`` for the lifetime of this process.
75
+
76
+ Pass ``None`` to clear the override and fall back to env var / default.
77
+ """
78
+ global _config_root_override
79
+ _config_root_override = (
80
+ Path(path).expanduser() if path is not None else None
81
+ )
82
+
83
+
84
+ def set_data_root(path: Path | str | None) -> None:
85
+ """Override ``data_root()`` for the lifetime of this process."""
86
+ global _data_root_override
87
+ _data_root_override = Path(path).expanduser() if path is not None else None
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def vllm_args(service: dict[str, Any]) -> list[str]:
7
+ args = [
8
+ f'--served-model-name={service["served_model_name"]}',
9
+ f'--tensor-parallel-size={service["tensor_parallel_size"]}',
10
+ f'--data-parallel-size={service["data_parallel_size"]}',
11
+ f'--max-model-len={service["max_model_len"]}',
12
+ f'--gpu-memory-utilization={service["gpu_memory_utilization"]}',
13
+ f'--max-num-batched-tokens={service["max_num_batched_tokens"]}',
14
+ f'--max-num-seqs={service["max_num_seqs"]}',
15
+ '--disable-log-requests',
16
+ ]
17
+ if service.get('enable_prefix_caching'):
18
+ args.append('--enable-prefix-caching')
19
+ if service.get('enable_auto_tool_choice'):
20
+ args.append('--enable-auto-tool-choice')
21
+ if service.get('tool_call_parser'):
22
+ args.append(f'--tool-call-parser={service["tool_call_parser"]}')
23
+ args.extend(service.get('extra_args', []))
24
+ return args
25
+
26
+
27
+ def default_base_url(
28
+ deployment: dict[str, Any], *, explicit: str | None = None
29
+ ) -> str:
30
+ if explicit:
31
+ return explicit.rstrip('/')
32
+ access = deployment.get('access', {}).get('default') or {}
33
+ if access.get('base_url'):
34
+ return str(access['base_url']).rstrip('/')
35
+ backend = deployment.get('backend', 'compose')
36
+ if backend == 'kubeai':
37
+ ingress = deployment.get('cluster', {}).get('ingress', {}) or {}
38
+ host = ingress.get('host', '')
39
+ if ingress.get('enabled') and host:
40
+ return f'http://{host}/openai/v1'
41
+ return 'http://127.0.0.1:8000/openai/v1'
42
+ if (deployment.get('gateways', {}).get('litellm') or {}).get('enabled'):
43
+ return f'http://127.0.0.1:{deployment.get("ports", {}).get("litellm", 14042)}/v1'
44
+ if (deployment.get('providers', {}).get('ollama') or {}).get('enabled'):
45
+ return f'http://127.0.0.1:{deployment.get("ports", {}).get("ollama", 11434)}'
46
+ return f'http://127.0.0.1:{deployment.get("ports", {}).get("litellm", 14042)}/v1'
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from .backends import render_compose_artifacts, render_kubeai_artifacts
4
+
5
+
6
+ def render_from_lock(lock_data: dict, *, assume_yes: bool = True) -> None:
7
+ """Render backend artifacts.
8
+
9
+ ``assume_yes`` defaults to True so programmatic callers and tests
10
+ are unaffected. CLI entry points pass ``assume_yes=False`` to surface
11
+ the per-file diff confirmation prompt.
12
+ """
13
+ backend = str(
14
+ lock_data.get('deployment', {}).get('backend', 'compose')
15
+ ).lower()
16
+ if backend == 'kubeai':
17
+ render_kubeai_artifacts(lock_data, assume_yes=assume_yes)
18
+ return
19
+ render_compose_artifacts(lock_data, assume_yes=assume_yes)