llmperfscope 0.1.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.
- llmperfscope/__init__.py +6 -0
- llmperfscope/__main__.py +5 -0
- llmperfscope/adapters.py +396 -0
- llmperfscope/analyze.py +598 -0
- llmperfscope/api.py +202 -0
- llmperfscope/capabilities.py +83 -0
- llmperfscope/cli.py +502 -0
- llmperfscope/compare.py +232 -0
- llmperfscope/container.py +79 -0
- llmperfscope/cpuprof.py +238 -0
- llmperfscope/diagnose.py +525 -0
- llmperfscope/driver.py +361 -0
- llmperfscope/figures.py +1096 -0
- llmperfscope/gpu.py +277 -0
- llmperfscope/html.py +320 -0
- llmperfscope/machine.py +313 -0
- llmperfscope/markers.py +107 -0
- llmperfscope/native/cpuprof.c +274 -0
- llmperfscope/nsys.py +287 -0
- llmperfscope/procfs.py +165 -0
- llmperfscope/report.py +264 -0
- llmperfscope/runner.py +567 -0
- llmperfscope/sampler.py +237 -0
- llmperfscope/share.py +67 -0
- llmperfscope/system.py +223 -0
- llmperfscope-0.1.0.dist-info/METADATA +207 -0
- llmperfscope-0.1.0.dist-info/RECORD +31 -0
- llmperfscope-0.1.0.dist-info/WHEEL +5 -0
- llmperfscope-0.1.0.dist-info/entry_points.txt +2 -0
- llmperfscope-0.1.0.dist-info/licenses/LICENSE +190 -0
- llmperfscope-0.1.0.dist-info/top_level.txt +1 -0
llmperfscope/__init__.py
ADDED
llmperfscope/__main__.py
ADDED
llmperfscope/adapters.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""What llmperfscope knows about specific engines. The core works without any of this; an adapter only adds finer phases,
|
|
2
|
+
exact throughput and server defaults. A new engine needs one small class here, or just `--mark 'regex=phase'`.
|
|
3
|
+
|
|
4
|
+
Canonical phase groups, so reports line up across engines:
|
|
5
|
+
startup, load, setup, warmup, prefill, decode, serve, teardown
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
|
15
|
+
WRAPPERS = {"env", "taskset", "numactl", "timeout", "nice", "ionice", "chrt", "flock", "stdbuf", "setsid", "nohup", "sudo"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Adapter:
|
|
19
|
+
name = "generic"
|
|
20
|
+
# Defaults for `llmperfscope serve`: paths on the server, signal to stop it, extra request fields.
|
|
21
|
+
server = None
|
|
22
|
+
|
|
23
|
+
def __init__(self, cmd, marks=()):
|
|
24
|
+
self.cmd = cmd
|
|
25
|
+
self.marks = [(re.compile(regex), phase, group) for regex, phase, group in marks]
|
|
26
|
+
self.report = {}
|
|
27
|
+
|
|
28
|
+
def prepare(self, cmd, env):
|
|
29
|
+
"""Last chance to change the command and environment. Everything changed is stored in the report."""
|
|
30
|
+
return cmd, env
|
|
31
|
+
|
|
32
|
+
def on_line(self, stream, t, text, emit):
|
|
33
|
+
"""A line of output arrived at CLOCK_MONOTONIC `t`. emit(phase, group=None, t=None, tokens=None, **meta)."""
|
|
34
|
+
for regex, phase, group in self.marks:
|
|
35
|
+
if regex.search(text):
|
|
36
|
+
emit(phase, group=group, t=t)
|
|
37
|
+
|
|
38
|
+
def finalize(self, phases, stdout_path, stderr_path):
|
|
39
|
+
"""After exit: parse results, fill in tokens per phase. Returns a dict merged into the report."""
|
|
40
|
+
return {}
|
|
41
|
+
|
|
42
|
+
def model_path(self):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def target_index(self):
|
|
46
|
+
"""Index of the real engine in a command like `taskset -c 1 numactl ... engine ...`, for tools inserted before it."""
|
|
47
|
+
return _skip_wrappers(self.cmd)
|
|
48
|
+
|
|
49
|
+
def has_progress_lines(self):
|
|
50
|
+
"""True when silence on stdout/stderr really means nothing is happening (the stall watchdog relies on it)."""
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _skip_wrappers(cmd):
|
|
55
|
+
"""Best effort. Options of wrappers are skipped by looking for the first word that is an executable or a known name."""
|
|
56
|
+
i = 0
|
|
57
|
+
while i < len(cmd):
|
|
58
|
+
base = os.path.basename(cmd[i])
|
|
59
|
+
if base in WRAPPERS or cmd[i].startswith("-") or "=" in cmd[i] or _looks_like_wrapper_value(cmd, i):
|
|
60
|
+
i += 1
|
|
61
|
+
continue
|
|
62
|
+
return i
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _looks_like_wrapper_value(cmd, i):
|
|
67
|
+
"""The value of a wrapper option such as `taskset -c 0-3` or `timeout 600`."""
|
|
68
|
+
if i == 0:
|
|
69
|
+
return False
|
|
70
|
+
previous = cmd[i - 1]
|
|
71
|
+
if previous in ("-c", "-n", "-p", "-k") and os.path.basename(cmd[i]) not in ("sh", "bash"):
|
|
72
|
+
return True
|
|
73
|
+
return re.fullmatch(r"[0-9][0-9smhd.,-]*", cmd[i]) is not None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _find(cmd, *flags):
|
|
77
|
+
for i, token in enumerate(cmd):
|
|
78
|
+
if token in flags and i + 1 < len(cmd):
|
|
79
|
+
return cmd[i + 1]
|
|
80
|
+
for flag in flags:
|
|
81
|
+
if flag.startswith("--") and token.startswith(flag + "="):
|
|
82
|
+
return token.split("=", 1)[1]
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class LlamaBench(Adapter):
|
|
87
|
+
"""llama-bench: `--progress` gives every run's start on stderr, `-o json` gives exact per-rep times on stdout."""
|
|
88
|
+
|
|
89
|
+
name = "llama.cpp:llama-bench"
|
|
90
|
+
|
|
91
|
+
def __init__(self, cmd, marks=()):
|
|
92
|
+
super().__init__(cmd, marks)
|
|
93
|
+
self.first = True
|
|
94
|
+
self.n_runs = 0
|
|
95
|
+
|
|
96
|
+
def prepare(self, cmd, env):
|
|
97
|
+
cmd = list(cmd)
|
|
98
|
+
if "--progress" not in cmd:
|
|
99
|
+
cmd.append("--progress")
|
|
100
|
+
for flag in ("-o", "--output"):
|
|
101
|
+
if flag in cmd:
|
|
102
|
+
cmd[cmd.index(flag) + 1] = "json"
|
|
103
|
+
break
|
|
104
|
+
else:
|
|
105
|
+
cmd += ["-o", "json"]
|
|
106
|
+
return cmd, env
|
|
107
|
+
|
|
108
|
+
def model_path(self):
|
|
109
|
+
return _find(self.cmd, "-m", "--model")
|
|
110
|
+
|
|
111
|
+
def has_progress_lines(self):
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
def on_line(self, stream, t, text, emit):
|
|
115
|
+
super().on_line(stream, t, text, emit)
|
|
116
|
+
if stream == "stdout":
|
|
117
|
+
if text.lstrip().startswith('"samples_ts"'):
|
|
118
|
+
emit("post_test", group="teardown", t=t)
|
|
119
|
+
return
|
|
120
|
+
m = re.search(r"benchmark (\d+)/(\d+): (.*)", text)
|
|
121
|
+
if not m:
|
|
122
|
+
return
|
|
123
|
+
test, what = int(m.group(1)), m.group(3).strip()
|
|
124
|
+
if what == "starting":
|
|
125
|
+
emit("load" if self.first else "setup", t=t, test=test)
|
|
126
|
+
self.first = False
|
|
127
|
+
elif what == "warmup prompt run":
|
|
128
|
+
emit("warmup_prefill", group="warmup", t=t, test=test)
|
|
129
|
+
elif what == "warmup generation run":
|
|
130
|
+
emit("warmup_decode", group="warmup", t=t, test=test)
|
|
131
|
+
elif what.startswith("depth run"):
|
|
132
|
+
emit("depth", group="setup", t=t, test=test)
|
|
133
|
+
elif what.startswith("prompt run"):
|
|
134
|
+
emit("prefill", t=t, test=test, rep=int(re.search(r"(\d+)/", what).group(1)))
|
|
135
|
+
elif what.startswith("generation run"):
|
|
136
|
+
emit("decode", t=t, test=test, rep=int(re.search(r"(\d+)/", what).group(1)))
|
|
137
|
+
|
|
138
|
+
def finalize(self, phases, stdout_path, stderr_path):
|
|
139
|
+
# Tools wrapping the engine can print around its output, so look for the array instead of assuming the whole file is one.
|
|
140
|
+
try:
|
|
141
|
+
with open(stdout_path, encoding="utf-8", errors="replace") as f:
|
|
142
|
+
text = f.read()
|
|
143
|
+
start = re.search(r"^\[\s*$", text, re.M)
|
|
144
|
+
tests, _ = json.JSONDecoder().raw_decode(text, start.start())
|
|
145
|
+
except (OSError, ValueError, AttributeError):
|
|
146
|
+
return {"notes": ["llama-bench JSON output was not readable (the run may have been cut short)"]}
|
|
147
|
+
summary = []
|
|
148
|
+
prefill, decode = [], []
|
|
149
|
+
for number, test in enumerate(tests, start=1):
|
|
150
|
+
n_prompt, n_gen = test.get("n_prompt", 0), test.get("n_gen", 0)
|
|
151
|
+
kind = "decode" if n_gen and not n_prompt else "prefill" if n_prompt and not n_gen else "mixed"
|
|
152
|
+
entry = {
|
|
153
|
+
"test": number,
|
|
154
|
+
"kind": kind,
|
|
155
|
+
"n_prompt": n_prompt,
|
|
156
|
+
"n_gen": n_gen,
|
|
157
|
+
"n_depth": test.get("n_depth", 0),
|
|
158
|
+
"tokens_per_s": test.get("avg_ts"),
|
|
159
|
+
"stddev": test.get("stddev_ts"),
|
|
160
|
+
"samples_tps": test.get("samples_ts"),
|
|
161
|
+
"samples_ns": test.get("samples_ns"),
|
|
162
|
+
}
|
|
163
|
+
summary.append(entry)
|
|
164
|
+
if kind == "decode":
|
|
165
|
+
decode.append(entry)
|
|
166
|
+
elif kind == "prefill":
|
|
167
|
+
prefill.append(entry)
|
|
168
|
+
for phase in phases:
|
|
169
|
+
if phase.get("meta", {}).get("test") == number:
|
|
170
|
+
if phase["group"] == "prefill":
|
|
171
|
+
phase["tokens"] = n_prompt
|
|
172
|
+
elif phase["group"] == "decode":
|
|
173
|
+
phase["tokens"] = n_gen
|
|
174
|
+
elif phase["name"] == "warmup_decode":
|
|
175
|
+
phase["tokens"] = 0
|
|
176
|
+
info = tests[0] if tests else {}
|
|
177
|
+
out = {
|
|
178
|
+
"tests": summary,
|
|
179
|
+
"engine": {k: info.get(k) for k in ("build_commit", "build_number", "cpu_info", "gpu_info", "backends",
|
|
180
|
+
"model_type", "model_size", "model_n_params", "n_batch", "n_ubatch",
|
|
181
|
+
"n_threads", "n_gpu_layers", "split_mode") if k in info},
|
|
182
|
+
}
|
|
183
|
+
throughput = {}
|
|
184
|
+
if prefill:
|
|
185
|
+
throughput["prefill"] = _merge_tests(prefill)
|
|
186
|
+
if decode:
|
|
187
|
+
throughput["decode"] = _merge_tests(decode)
|
|
188
|
+
out["throughput"] = throughput
|
|
189
|
+
out["consistency"] = _phase_vs_samples(phases, summary)
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _merge_tests(tests):
|
|
194
|
+
if len(tests) == 1:
|
|
195
|
+
test = tests[0]
|
|
196
|
+
return {"tokens_per_s": test["tokens_per_s"], "stddev": test["stddev"], "samples": test["samples_tps"],
|
|
197
|
+
"tokens_per_run": test["n_prompt"] or test["n_gen"]}
|
|
198
|
+
return {"tests": [{"tokens_per_run": t["n_prompt"] or t["n_gen"], "tokens_per_s": t["tokens_per_s"],
|
|
199
|
+
"stddev": t["stddev"]} for t in tests],
|
|
200
|
+
"tokens_per_s": tests[0]["tokens_per_s"]}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _phase_vs_samples(phases, tests):
|
|
204
|
+
"""Self-check: the time llmperfscope saw for each run against what the engine timed itself. Far from 1 means the phase
|
|
205
|
+
boundaries cannot be trusted."""
|
|
206
|
+
wall = sampled = 0.0
|
|
207
|
+
for test in tests:
|
|
208
|
+
if test["kind"] == "mixed" or not test["samples_ns"]:
|
|
209
|
+
continue
|
|
210
|
+
group = "decode" if test["kind"] == "decode" else "prefill"
|
|
211
|
+
runs = [p for p in phases if p["group"] == group and p.get("meta", {}).get("test") == test["test"]]
|
|
212
|
+
wall += sum(p["t_end"] - p["t"] for p in runs if "t_end" in p)
|
|
213
|
+
sampled += sum(test["samples_ns"][:len(runs)])
|
|
214
|
+
return {"phase_wall_over_engine_time": round(wall / sampled, 4)} if sampled and wall else {}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# Sub-phases of model loading and serving, from llama.cpp's own log lines. The log lines are not an API: when one of
|
|
218
|
+
# them changes only the sub-phase is lost, because readiness comes from /health.
|
|
219
|
+
_SERVER_LINES = [
|
|
220
|
+
(re.compile(r"loading model '"), "load", None),
|
|
221
|
+
(re.compile(r"llama_context: constructing llama_context|constructing llama_context"), "setup", None),
|
|
222
|
+
(re.compile(r"initializing slots"), "setup_slots", "setup"),
|
|
223
|
+
(re.compile(r"cleaning up before exit"), "teardown", None),
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class _LlamaCommon(Adapter):
|
|
228
|
+
def model_path(self):
|
|
229
|
+
return _find(self.cmd, "-m", "--model")
|
|
230
|
+
|
|
231
|
+
def prepare(self, cmd, env):
|
|
232
|
+
# JSONL logs are the only form of the memory breakdown visible without trace verbosity, which would slow decode.
|
|
233
|
+
env = dict(env)
|
|
234
|
+
env.setdefault("LLAMA_ARG_LOG_JSONL", "1")
|
|
235
|
+
return cmd, env
|
|
236
|
+
|
|
237
|
+
def on_line(self, stream, t, text, emit):
|
|
238
|
+
super().on_line(stream, t, text, emit)
|
|
239
|
+
message = text
|
|
240
|
+
if text.startswith("{"):
|
|
241
|
+
try:
|
|
242
|
+
record = json.loads(text)
|
|
243
|
+
except ValueError:
|
|
244
|
+
record = None
|
|
245
|
+
if isinstance(record, dict):
|
|
246
|
+
if record.get("type") == "fit_memory_breakdown":
|
|
247
|
+
self.report["memory_breakdown"] = _breakdown_from_json(record)
|
|
248
|
+
return
|
|
249
|
+
message = str(record.get("msg", ""))
|
|
250
|
+
message = ANSI.sub("", message)
|
|
251
|
+
table_row = parse_breakdown_line(message)
|
|
252
|
+
if table_row:
|
|
253
|
+
self.report.setdefault("memory_breakdown", []).append(table_row)
|
|
254
|
+
return
|
|
255
|
+
for regex, phase, group in _SERVER_LINES:
|
|
256
|
+
if regex.search(message):
|
|
257
|
+
emit(phase, group=group, t=t)
|
|
258
|
+
|
|
259
|
+
def finalize(self, phases, stdout_path, stderr_path):
|
|
260
|
+
return dict(self.report)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class LlamaServer(_LlamaCommon):
|
|
264
|
+
name = "llama.cpp:llama-server"
|
|
265
|
+
server = {"ready_path": "/health", "metrics_path": "/metrics", "base_path": "/v1", "stop_signal": "TERM",
|
|
266
|
+
"extra_body": {"cache_prompt": False}}
|
|
267
|
+
|
|
268
|
+
def prepare(self, cmd, env):
|
|
269
|
+
cmd, env = super().prepare(cmd, env)
|
|
270
|
+
if "--metrics" not in cmd:
|
|
271
|
+
cmd = list(cmd) + ["--metrics"]
|
|
272
|
+
return cmd, env
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class LlamaPerplexity(_LlamaCommon):
|
|
276
|
+
name = "llama.cpp:llama-perplexity"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class LlamaGeneric(_LlamaCommon):
|
|
280
|
+
name = "llama.cpp"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _breakdown_from_json(record):
|
|
284
|
+
data = record.get("data", record)
|
|
285
|
+
rows = data.get("rows", []) if isinstance(data, dict) else []
|
|
286
|
+
return [{k: row.get(k) for k in ("kind", "name", "total", "free", "self", "model", "context", "compute", "unaccounted")
|
|
287
|
+
if k in row} for row in rows]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
_BREAKDOWN_GPU = re.compile(
|
|
291
|
+
r"\|\s*-\s*(?P<name>.+?)\s*\|\s*(?P<total>\d+)\s*=\s*(?P<free>\d+)\s*\+\s*\(\s*(?P<self>\d+)\s*=\s*(?P<model>\d+)\s*\+\s*"
|
|
292
|
+
r"(?P<context>\d+)\s*\+\s*(?P<compute>\d+)\s*\)\s*\+\s*(?P<unaccounted>\d+)\s*\|")
|
|
293
|
+
_BREAKDOWN_OTHER = re.compile(
|
|
294
|
+
r"\|\s*-\s*(?P<name>.+?)\s*\|\s*(?P<self>\d+)\s*=\s*(?P<model>\d+)\s*\+\s*(?P<context>\d+)\s*\+\s*(?P<compute>\d+)\s*\|")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def parse_breakdown_line(text):
|
|
298
|
+
"""One row of common_memory_breakdown_print's table."""
|
|
299
|
+
m = _BREAKDOWN_GPU.search(text)
|
|
300
|
+
if m:
|
|
301
|
+
row = {k: (int(v) if v.isdigit() else v) for k, v in m.groupdict().items()}
|
|
302
|
+
row["kind"] = "device"
|
|
303
|
+
return row
|
|
304
|
+
m = _BREAKDOWN_OTHER.search(text)
|
|
305
|
+
if m:
|
|
306
|
+
row = {k: (int(v) if v.isdigit() else v) for k, v in m.groupdict().items()}
|
|
307
|
+
row["kind"] = "host" if row["name"].lower().startswith("host") else "buffer_type"
|
|
308
|
+
return row
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# Engines recognised from outside only: they get readiness, requests and server metrics, phases from markers.
|
|
313
|
+
SERVER_PRESETS = {
|
|
314
|
+
"openai": {"ready_path": "/health", "metrics_path": None, "base_path": "/v1", "stop_signal": "TERM", "extra_body": {},
|
|
315
|
+
"port": 8000},
|
|
316
|
+
"llama-server": {**LlamaServer.server, "port": 8080},
|
|
317
|
+
"vllm": {"ready_path": "/health", "metrics_path": "/metrics", "base_path": "/v1", "stop_signal": "TERM", "extra_body": {},
|
|
318
|
+
"port": 8000},
|
|
319
|
+
"sglang": {"ready_path": "/health", "metrics_path": "/metrics", "base_path": "/v1", "stop_signal": "TERM", "extra_body": {},
|
|
320
|
+
"port": 30000, "extra_args": ["--enable-metrics"]},
|
|
321
|
+
"tgi": {"ready_path": "/health", "metrics_path": "/metrics", "base_path": "/v1", "stop_signal": "TERM", "extra_body": {},
|
|
322
|
+
"port": 3000},
|
|
323
|
+
"ollama": {"ready_path": "/api/version", "metrics_path": None, "base_path": "/v1", "stop_signal": "TERM", "extra_body": {},
|
|
324
|
+
"port": 11434},
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def select(name, cmd, marks=()):
|
|
329
|
+
"""`name` is "auto" or an adapter name."""
|
|
330
|
+
words = [os.path.basename(token) for token in cmd]
|
|
331
|
+
if name in ("auto", "llama-bench") and "llama-bench" in words:
|
|
332
|
+
return LlamaBench(cmd, marks)
|
|
333
|
+
if name in ("auto", "llama-server") and "llama-server" in words:
|
|
334
|
+
return LlamaServer(cmd, marks)
|
|
335
|
+
if name in ("auto", "llama-perplexity") and "llama-perplexity" in words:
|
|
336
|
+
return LlamaPerplexity(cmd, marks)
|
|
337
|
+
if name in ("auto", "llama.cpp") and any(w.startswith("llama-") for w in words):
|
|
338
|
+
return LlamaGeneric(cmd, marks)
|
|
339
|
+
return Adapter(cmd, marks)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def preset_for(cmd):
|
|
343
|
+
"""Server defaults guessed from the command line, container images included."""
|
|
344
|
+
words = [os.path.basename(token).lower() for token in cmd]
|
|
345
|
+
joined = " ".join(token.lower() for token in cmd)
|
|
346
|
+
if "llama-server" in words or "llama.cpp" in joined:
|
|
347
|
+
return "llama-server"
|
|
348
|
+
if "vllm" in joined:
|
|
349
|
+
return "vllm"
|
|
350
|
+
if "sglang" in joined:
|
|
351
|
+
return "sglang"
|
|
352
|
+
if "text-generation-launcher" in words or "text-generation-inference" in joined:
|
|
353
|
+
return "tgi"
|
|
354
|
+
if "ollama" in joined:
|
|
355
|
+
return "ollama"
|
|
356
|
+
return "openai"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def docker_index(cmd):
|
|
360
|
+
"""Index of `docker` in a `docker run ...` command, or None."""
|
|
361
|
+
i = _skip_wrappers(cmd)
|
|
362
|
+
if i + 1 < len(cmd) and os.path.basename(cmd[i]) in ("docker", "podman") and cmd[i + 1] == "run":
|
|
363
|
+
return i
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def guess_url(cmd, preset_name):
|
|
368
|
+
"""Where a server will listen, so nobody has to say it twice. A container's published port wins over the flags inside."""
|
|
369
|
+
port = None
|
|
370
|
+
host = "127.0.0.1"
|
|
371
|
+
index = docker_index(cmd)
|
|
372
|
+
if index is not None:
|
|
373
|
+
for i, token in enumerate(cmd[index:], start=index):
|
|
374
|
+
value = None
|
|
375
|
+
if token in ("-p", "--publish") and i + 1 < len(cmd):
|
|
376
|
+
value = cmd[i + 1]
|
|
377
|
+
elif token.startswith("--publish="):
|
|
378
|
+
value = token.split("=", 1)[1]
|
|
379
|
+
if value:
|
|
380
|
+
parts = value.split("/")[0].split(":")
|
|
381
|
+
if len(parts) >= 2 and parts[-2].isdigit():
|
|
382
|
+
port = int(parts[-2])
|
|
383
|
+
break
|
|
384
|
+
if port is None:
|
|
385
|
+
for i, token in enumerate(cmd):
|
|
386
|
+
if token == "--port" and i + 1 < len(cmd) and cmd[i + 1].isdigit():
|
|
387
|
+
port = int(cmd[i + 1])
|
|
388
|
+
elif token.startswith("--port=") and token.split("=", 1)[1].isdigit():
|
|
389
|
+
port = int(token.split("=", 1)[1])
|
|
390
|
+
if port is None and preset_name == "ollama" and ":" in os.environ.get("OLLAMA_HOST", ""):
|
|
391
|
+
tail = os.environ["OLLAMA_HOST"].rsplit(":", 1)[1]
|
|
392
|
+
if tail.isdigit():
|
|
393
|
+
port = int(tail)
|
|
394
|
+
if port is None:
|
|
395
|
+
port = SERVER_PRESETS[preset_name]["port"]
|
|
396
|
+
return f"http://{host}:{port}"
|