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,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())
|
infer_stack/hardware.py
ADDED
|
@@ -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'
|
infer_stack/renderer.py
ADDED
|
@@ -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)
|