vlmparse 0.1.7__py3-none-any.whl → 0.1.9__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.
- vlmparse/build_doc.py +20 -19
- vlmparse/cli.py +439 -270
- vlmparse/clients/chandra.py +176 -60
- vlmparse/clients/deepseekocr.py +193 -12
- vlmparse/clients/docling.py +0 -1
- vlmparse/clients/dotsocr.py +34 -31
- vlmparse/clients/glmocr.py +243 -0
- vlmparse/clients/granite_docling.py +9 -36
- vlmparse/clients/hunyuanocr.py +5 -1
- vlmparse/clients/lightonocr.py +23 -1
- vlmparse/clients/mineru.py +0 -1
- vlmparse/clients/mistral_converter.py +85 -0
- vlmparse/clients/nanonetocr.py +5 -1
- vlmparse/clients/olmocr.py +6 -2
- vlmparse/clients/openai_converter.py +95 -60
- vlmparse/clients/paddleocrvl.py +195 -40
- vlmparse/converter.py +51 -11
- vlmparse/converter_with_server.py +92 -19
- vlmparse/registries.py +107 -89
- vlmparse/servers/base_server.py +127 -0
- vlmparse/servers/docker_compose_deployment.py +489 -0
- vlmparse/servers/docker_compose_server.py +39 -0
- vlmparse/servers/docker_run_deployment.py +226 -0
- vlmparse/servers/docker_server.py +17 -109
- vlmparse/servers/model_identity.py +48 -0
- vlmparse/servers/server_registry.py +42 -0
- vlmparse/servers/utils.py +83 -219
- vlmparse/st_viewer/st_viewer.py +1 -1
- vlmparse/utils.py +15 -2
- {vlmparse-0.1.7.dist-info → vlmparse-0.1.9.dist-info}/METADATA +13 -3
- vlmparse-0.1.9.dist-info/RECORD +44 -0
- {vlmparse-0.1.7.dist-info → vlmparse-0.1.9.dist-info}/WHEEL +1 -1
- vlmparse-0.1.7.dist-info/RECORD +0 -36
- {vlmparse-0.1.7.dist-info → vlmparse-0.1.9.dist-info}/entry_points.txt +0 -0
- {vlmparse-0.1.7.dist-info → vlmparse-0.1.9.dist-info}/licenses/LICENSE +0 -0
- {vlmparse-0.1.7.dist-info → vlmparse-0.1.9.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import docker
|
|
13
|
+
from loguru import logger
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .base_server import BaseServerConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _sanitize_compose_project_name(name: str, fallback: str = "vlmparse") -> str:
|
|
20
|
+
"""Return a Docker Compose-compatible project name.
|
|
21
|
+
|
|
22
|
+
Compose requires only lowercase letters, numbers, hyphens, and underscores,
|
|
23
|
+
and the name must start with a letter or number.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
if not name:
|
|
27
|
+
return fallback
|
|
28
|
+
|
|
29
|
+
sanitized = re.sub(r"[^a-z0-9_-]+", "-", name.lower())
|
|
30
|
+
sanitized = re.sub(r"^[^a-z0-9]+", "", sanitized)
|
|
31
|
+
sanitized = sanitized.strip("-_")
|
|
32
|
+
|
|
33
|
+
return sanitized or fallback
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_compose_override_yaml(config: "BaseServerConfig") -> str | None:
|
|
37
|
+
services_overrides: dict[str, dict] = {}
|
|
38
|
+
|
|
39
|
+
service_names = config.compose_services or [config.server_service]
|
|
40
|
+
|
|
41
|
+
# Labels for model/uri inference (match docker_server behavior)
|
|
42
|
+
uri = f"http://localhost:{config.docker_port}{config.get_base_url_suffix()}"
|
|
43
|
+
if config.gpu_device_ids is None:
|
|
44
|
+
gpu_label = "0"
|
|
45
|
+
elif len(config.gpu_device_ids) == 0 or (
|
|
46
|
+
len(config.gpu_device_ids) == 1 and config.gpu_device_ids[0] == ""
|
|
47
|
+
):
|
|
48
|
+
gpu_label = "cpu"
|
|
49
|
+
else:
|
|
50
|
+
gpu_label = ",".join(config.gpu_device_ids)
|
|
51
|
+
if config.server_service:
|
|
52
|
+
services_overrides.setdefault(config.server_service, {}).setdefault(
|
|
53
|
+
"labels", {}
|
|
54
|
+
).update(
|
|
55
|
+
{
|
|
56
|
+
"vlmparse_model_name": config.model_name,
|
|
57
|
+
"vlmparse_uri": uri,
|
|
58
|
+
"vlmparse_gpus": gpu_label,
|
|
59
|
+
"vlmparse_deployment": "docker_compose",
|
|
60
|
+
"vlmparse_compose_project": str(config.compose_project_name or ""),
|
|
61
|
+
"vlmparse_compose_file": str(config.compose_file or ""),
|
|
62
|
+
"vlmparse_compose_server_service": str(config.server_service or ""),
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Port override for the server service
|
|
67
|
+
if config.server_service:
|
|
68
|
+
services_overrides.setdefault(config.server_service, {})["ports"] = [
|
|
69
|
+
f"{config.docker_port}:{config.container_port}"
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# Environment overrides
|
|
73
|
+
environment = config.get_environment()
|
|
74
|
+
if environment:
|
|
75
|
+
env_targets = (
|
|
76
|
+
config.environment_services
|
|
77
|
+
if config.environment_services is not None
|
|
78
|
+
else [config.server_service]
|
|
79
|
+
)
|
|
80
|
+
for service in env_targets:
|
|
81
|
+
services_overrides.setdefault(service, {})["environment"] = environment
|
|
82
|
+
|
|
83
|
+
# GPU overrides
|
|
84
|
+
if config.gpu_device_ids is not None:
|
|
85
|
+
gpu_targets = (
|
|
86
|
+
config.gpu_service_names
|
|
87
|
+
if config.gpu_service_names is not None
|
|
88
|
+
else service_names
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if len(config.gpu_device_ids) == 0 or (
|
|
92
|
+
len(config.gpu_device_ids) == 1 and config.gpu_device_ids[0] == ""
|
|
93
|
+
):
|
|
94
|
+
devices_value = []
|
|
95
|
+
else:
|
|
96
|
+
devices_value = [
|
|
97
|
+
{
|
|
98
|
+
"driver": "nvidia",
|
|
99
|
+
"device_ids": config.gpu_device_ids,
|
|
100
|
+
"capabilities": ["gpu"],
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
for service in gpu_targets:
|
|
105
|
+
services_overrides.setdefault(service, {}).setdefault("deploy", {})
|
|
106
|
+
services_overrides[service]["deploy"].setdefault("resources", {})
|
|
107
|
+
services_overrides[service]["deploy"]["resources"].setdefault(
|
|
108
|
+
"reservations", {}
|
|
109
|
+
)
|
|
110
|
+
services_overrides[service]["deploy"]["resources"]["reservations"][
|
|
111
|
+
"devices"
|
|
112
|
+
] = devices_value
|
|
113
|
+
|
|
114
|
+
if not services_overrides:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
# Manual YAML rendering for the limited structure we need.
|
|
118
|
+
lines = ["services:"]
|
|
119
|
+
for service, overrides in services_overrides.items():
|
|
120
|
+
lines.append(f" {service}:")
|
|
121
|
+
|
|
122
|
+
if "ports" in overrides:
|
|
123
|
+
# Compose override files normally merge/append list fields (like ports).
|
|
124
|
+
# Use !override so we replace the base compose list instead of adding
|
|
125
|
+
# extra published ports.
|
|
126
|
+
lines.append(" ports: !override")
|
|
127
|
+
for port in overrides["ports"]:
|
|
128
|
+
lines.append(f' - "{port}"')
|
|
129
|
+
|
|
130
|
+
if "environment" in overrides:
|
|
131
|
+
lines.append(" environment:")
|
|
132
|
+
for key, value in overrides["environment"].items():
|
|
133
|
+
lines.append(f' {key}: "{value}"')
|
|
134
|
+
|
|
135
|
+
if "labels" in overrides:
|
|
136
|
+
lines.append(" labels:")
|
|
137
|
+
for key, value in overrides["labels"].items():
|
|
138
|
+
lines.append(f' {key}: "{value}"')
|
|
139
|
+
|
|
140
|
+
if "deploy" in overrides:
|
|
141
|
+
lines.append(" deploy:")
|
|
142
|
+
lines.append(" resources:")
|
|
143
|
+
lines.append(" reservations:")
|
|
144
|
+
# Same story as ports: ensure we replace the base list.
|
|
145
|
+
lines.append(" devices: !override")
|
|
146
|
+
devices = overrides["deploy"]["resources"]["reservations"].get("devices")
|
|
147
|
+
if not devices:
|
|
148
|
+
lines.append(" []")
|
|
149
|
+
else:
|
|
150
|
+
for device in devices:
|
|
151
|
+
lines.append(" - driver: nvidia")
|
|
152
|
+
lines.append(" device_ids:")
|
|
153
|
+
for device_id in device.get("device_ids", []):
|
|
154
|
+
lines.append(f' - "{device_id}"')
|
|
155
|
+
lines.append(" capabilities:")
|
|
156
|
+
for cap in device.get("capabilities", []):
|
|
157
|
+
lines.append(f" - {cap}")
|
|
158
|
+
|
|
159
|
+
return "\n".join(lines) + "\n"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _get_compose_container(
|
|
163
|
+
client: docker.DockerClient, project_name: str, service: str
|
|
164
|
+
):
|
|
165
|
+
containers = client.containers.list(
|
|
166
|
+
all=True,
|
|
167
|
+
filters={
|
|
168
|
+
"label": [
|
|
169
|
+
f"com.docker.compose.project={project_name}",
|
|
170
|
+
f"com.docker.compose.service={service}",
|
|
171
|
+
]
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
if not containers:
|
|
175
|
+
return None
|
|
176
|
+
return containers[0]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _start_compose_logs_stream(
|
|
180
|
+
compose_cmd: list[str],
|
|
181
|
+
*,
|
|
182
|
+
env: dict | None,
|
|
183
|
+
model_name: str,
|
|
184
|
+
services: list[str] | None = None,
|
|
185
|
+
tail: int = 200,
|
|
186
|
+
) -> tuple[subprocess.Popen[str], threading.Event, threading.Thread] | None:
|
|
187
|
+
"""Stream `docker compose logs -f` output into loguru.
|
|
188
|
+
|
|
189
|
+
This is useful for compose stacks where the actual failure happens in a
|
|
190
|
+
dependency service (db, redis, etc). Output is forwarded line-by-line so it
|
|
191
|
+
shows up similarly to the single-container `docker_server` startup logs.
|
|
192
|
+
|
|
193
|
+
Returns a (process, stop_event, thread) triple, or None if the stream can't
|
|
194
|
+
be started.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
cmd = list(compose_cmd) + [
|
|
198
|
+
"logs",
|
|
199
|
+
"--no-color",
|
|
200
|
+
"--follow",
|
|
201
|
+
"--tail",
|
|
202
|
+
str(tail),
|
|
203
|
+
]
|
|
204
|
+
if services:
|
|
205
|
+
cmd.extend([s for s in services if s])
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
proc: subprocess.Popen[str] = subprocess.Popen(
|
|
209
|
+
cmd,
|
|
210
|
+
stdout=subprocess.PIPE,
|
|
211
|
+
stderr=subprocess.STDOUT,
|
|
212
|
+
text=True,
|
|
213
|
+
env=env,
|
|
214
|
+
bufsize=1,
|
|
215
|
+
)
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.debug(f"Failed to start docker compose log stream: {e}")
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
stop_event = threading.Event()
|
|
221
|
+
|
|
222
|
+
def _pump_logs() -> None:
|
|
223
|
+
try:
|
|
224
|
+
if proc.stdout is None:
|
|
225
|
+
return
|
|
226
|
+
for line in proc.stdout:
|
|
227
|
+
if stop_event.is_set():
|
|
228
|
+
break
|
|
229
|
+
line = line.rstrip("\n")
|
|
230
|
+
if line.strip():
|
|
231
|
+
# Compose already prefixes with service name; keep a stable
|
|
232
|
+
# model prefix so users can correlate when multiplexed.
|
|
233
|
+
logger.info(f"[{model_name}] {line}")
|
|
234
|
+
except Exception as e:
|
|
235
|
+
logger.debug(f"Compose log stream stopped: {e}")
|
|
236
|
+
finally:
|
|
237
|
+
try:
|
|
238
|
+
if proc.stdout is not None:
|
|
239
|
+
proc.stdout.close()
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
thread = threading.Thread(target=_pump_logs, name="compose-logs", daemon=True)
|
|
244
|
+
thread.start()
|
|
245
|
+
return proc, stop_event, thread
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _stop_compose_logs_stream(
|
|
249
|
+
stream: tuple[subprocess.Popen[str], threading.Event, threading.Thread] | None,
|
|
250
|
+
*,
|
|
251
|
+
timeout: float = 2.0,
|
|
252
|
+
) -> None:
|
|
253
|
+
if stream is None:
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
proc, stop_event, thread = stream
|
|
257
|
+
stop_event.set()
|
|
258
|
+
try:
|
|
259
|
+
proc.terminate()
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
try:
|
|
263
|
+
proc.wait(timeout=timeout)
|
|
264
|
+
except Exception:
|
|
265
|
+
try:
|
|
266
|
+
proc.kill()
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
thread.join(timeout=timeout)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@contextmanager
|
|
273
|
+
def docker_compose_server(
|
|
274
|
+
config: "BaseServerConfig",
|
|
275
|
+
timeout: int = 1000,
|
|
276
|
+
cleanup: bool = True,
|
|
277
|
+
):
|
|
278
|
+
"""Generic context manager for Docker Compose server deployment.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
config: DockerComposeServerConfig
|
|
282
|
+
timeout: Timeout in seconds to wait for server to be ready
|
|
283
|
+
cleanup: If True, stop and remove containers on exit. If False, leave running
|
|
284
|
+
|
|
285
|
+
Yields:
|
|
286
|
+
tuple: (base_url, container) - The base URL of the server and the container object
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
compose_file = Path(config.compose_file).expanduser().resolve()
|
|
290
|
+
if not compose_file.exists():
|
|
291
|
+
raise FileNotFoundError(f"Compose file not found at {compose_file}")
|
|
292
|
+
|
|
293
|
+
project_name = _sanitize_compose_project_name(
|
|
294
|
+
config.compose_project_name
|
|
295
|
+
or f"vlmparse-{config.model_name.replace('/', '-')}-{getpass.getuser()}"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Persist resolved name so it can be attached as a label via the override YAML.
|
|
299
|
+
# This allows `vlmparse stop` to bring down the full compose stack.
|
|
300
|
+
config.compose_project_name = project_name
|
|
301
|
+
|
|
302
|
+
base_cmd = [
|
|
303
|
+
"docker",
|
|
304
|
+
"compose",
|
|
305
|
+
"-f",
|
|
306
|
+
str(compose_file),
|
|
307
|
+
"--project-name",
|
|
308
|
+
project_name,
|
|
309
|
+
]
|
|
310
|
+
|
|
311
|
+
client = docker.from_env()
|
|
312
|
+
container = None
|
|
313
|
+
|
|
314
|
+
override_content = _build_compose_override_yaml(config)
|
|
315
|
+
compose_env = config.get_compose_env()
|
|
316
|
+
env = None
|
|
317
|
+
if compose_env:
|
|
318
|
+
env = os.environ.copy()
|
|
319
|
+
env.update(compose_env)
|
|
320
|
+
|
|
321
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
322
|
+
override_file = None
|
|
323
|
+
if override_content:
|
|
324
|
+
override_file = Path(temp_dir) / "compose.override.yaml"
|
|
325
|
+
override_file.write_text(override_content)
|
|
326
|
+
|
|
327
|
+
compose_cmd = list(base_cmd)
|
|
328
|
+
if override_file is not None:
|
|
329
|
+
compose_cmd.extend(["-f", str(override_file)])
|
|
330
|
+
|
|
331
|
+
logs_stream = None
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
logger.info(
|
|
335
|
+
f"Starting Docker Compose for {config.model_name} on port {config.docker_port}"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
subprocess.run(
|
|
340
|
+
compose_cmd + ["up", "-d"],
|
|
341
|
+
check=True,
|
|
342
|
+
capture_output=True,
|
|
343
|
+
text=True,
|
|
344
|
+
env=env,
|
|
345
|
+
)
|
|
346
|
+
except subprocess.CalledProcessError as e:
|
|
347
|
+
if e.stdout:
|
|
348
|
+
logger.error(
|
|
349
|
+
f"Docker Compose stdout for {config.model_name}:\n{e.stdout}"
|
|
350
|
+
)
|
|
351
|
+
if e.stderr:
|
|
352
|
+
logger.error(
|
|
353
|
+
f"Docker Compose stderr for {config.model_name}:\n{e.stderr}"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Best-effort diagnostics: container status and last logs.
|
|
357
|
+
# This is especially helpful when dependencies crash quickly (e.g. exit 137).
|
|
358
|
+
try:
|
|
359
|
+
ps_res = subprocess.run(
|
|
360
|
+
compose_cmd + ["ps"],
|
|
361
|
+
check=False,
|
|
362
|
+
capture_output=True,
|
|
363
|
+
text=True,
|
|
364
|
+
env=env,
|
|
365
|
+
)
|
|
366
|
+
if ps_res.stdout:
|
|
367
|
+
logger.error(
|
|
368
|
+
f"Docker Compose ps for {config.model_name}:\n{ps_res.stdout}"
|
|
369
|
+
)
|
|
370
|
+
if ps_res.stderr:
|
|
371
|
+
logger.error(
|
|
372
|
+
f"Docker Compose ps stderr for {config.model_name}:\n{ps_res.stderr}"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
logs_res = subprocess.run(
|
|
376
|
+
compose_cmd + ["logs", "--no-color", "--tail", "200"],
|
|
377
|
+
check=False,
|
|
378
|
+
capture_output=True,
|
|
379
|
+
text=True,
|
|
380
|
+
env=env,
|
|
381
|
+
)
|
|
382
|
+
if logs_res.stdout:
|
|
383
|
+
logger.error(
|
|
384
|
+
f"Docker Compose logs (tail=200) for {config.model_name}:\n{logs_res.stdout}"
|
|
385
|
+
)
|
|
386
|
+
if logs_res.stderr:
|
|
387
|
+
logger.error(
|
|
388
|
+
f"Docker Compose logs stderr for {config.model_name}:\n{logs_res.stderr}"
|
|
389
|
+
)
|
|
390
|
+
except Exception as diag_error:
|
|
391
|
+
logger.warning(
|
|
392
|
+
f"Failed to collect docker compose diagnostics: {diag_error}"
|
|
393
|
+
)
|
|
394
|
+
raise
|
|
395
|
+
|
|
396
|
+
logger.info("Compose stack started, waiting for server to be ready...")
|
|
397
|
+
|
|
398
|
+
# Stream logs for the whole compose stack (or requested services).
|
|
399
|
+
# This mirrors the visibility you get with `docker_server` while
|
|
400
|
+
# being much more useful for multi-service compose deployments.
|
|
401
|
+
service_names = (
|
|
402
|
+
config.compose_services
|
|
403
|
+
if config.compose_services
|
|
404
|
+
else ([config.server_service] if config.server_service else None)
|
|
405
|
+
)
|
|
406
|
+
logs_stream = _start_compose_logs_stream(
|
|
407
|
+
compose_cmd,
|
|
408
|
+
env=env,
|
|
409
|
+
model_name=config.model_name,
|
|
410
|
+
services=service_names,
|
|
411
|
+
tail=200,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
start_time = time.time()
|
|
415
|
+
server_ready = False
|
|
416
|
+
last_log_position = 0
|
|
417
|
+
|
|
418
|
+
while time.time() - start_time < timeout:
|
|
419
|
+
container = _get_compose_container(
|
|
420
|
+
client, project_name, config.server_service
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
if container is None:
|
|
424
|
+
time.sleep(2)
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
container.reload()
|
|
429
|
+
except docker.errors.NotFound as e:
|
|
430
|
+
logger.error("Container stopped unexpectedly during startup")
|
|
431
|
+
raise RuntimeError(
|
|
432
|
+
"Container crashed during initialization. Check Docker logs for details."
|
|
433
|
+
) from e
|
|
434
|
+
|
|
435
|
+
if container.status == "running":
|
|
436
|
+
all_logs = container.logs().decode("utf-8")
|
|
437
|
+
# Keep the old log-position tracker for readiness checks.
|
|
438
|
+
# Actual log visibility is handled by the compose log stream.
|
|
439
|
+
if len(all_logs) > last_log_position:
|
|
440
|
+
last_log_position = len(all_logs)
|
|
441
|
+
|
|
442
|
+
for indicator in config.server_ready_indicators:
|
|
443
|
+
if indicator in all_logs:
|
|
444
|
+
server_ready = True
|
|
445
|
+
break
|
|
446
|
+
|
|
447
|
+
if not server_ready:
|
|
448
|
+
health = container.attrs.get("State", {}).get("Health", {})
|
|
449
|
+
if health.get("Status") == "healthy":
|
|
450
|
+
server_ready = True
|
|
451
|
+
|
|
452
|
+
if server_ready:
|
|
453
|
+
logger.info(
|
|
454
|
+
f"Server ready indicator found for service '{config.server_service}'"
|
|
455
|
+
)
|
|
456
|
+
break
|
|
457
|
+
|
|
458
|
+
time.sleep(2)
|
|
459
|
+
|
|
460
|
+
if not server_ready:
|
|
461
|
+
raise TimeoutError(
|
|
462
|
+
f"Server did not become ready within {timeout} seconds"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
base_url = (
|
|
466
|
+
f"http://localhost:{config.docker_port}{config.get_base_url_suffix()}"
|
|
467
|
+
)
|
|
468
|
+
logger.info(f"{config.model_name} server ready at {base_url}")
|
|
469
|
+
|
|
470
|
+
# Deployment phase is over: stop log streaming so we don't
|
|
471
|
+
# continuously forward runtime logs (caller can still fetch logs
|
|
472
|
+
# explicitly via Docker / CLI if needed).
|
|
473
|
+
_stop_compose_logs_stream(logs_stream)
|
|
474
|
+
logs_stream = None
|
|
475
|
+
|
|
476
|
+
yield base_url, container
|
|
477
|
+
|
|
478
|
+
finally:
|
|
479
|
+
_stop_compose_logs_stream(logs_stream)
|
|
480
|
+
if cleanup:
|
|
481
|
+
logger.info("Stopping Docker Compose stack")
|
|
482
|
+
subprocess.run(
|
|
483
|
+
compose_cmd + ["down"],
|
|
484
|
+
check=False,
|
|
485
|
+
capture_output=True,
|
|
486
|
+
text=True,
|
|
487
|
+
env=env,
|
|
488
|
+
)
|
|
489
|
+
logger.info("Compose stack stopped")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from pydantic import Field
|
|
2
|
+
|
|
3
|
+
from .base_server import BaseServer, BaseServerConfig
|
|
4
|
+
from .docker_compose_deployment import docker_compose_server
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DockerComposeServerConfig(BaseServerConfig):
|
|
8
|
+
"""Configuration for deploying a Docker Compose server.
|
|
9
|
+
|
|
10
|
+
Inherits from BaseServerConfig which provides common server configuration.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
compose_file: str
|
|
14
|
+
server_service: str
|
|
15
|
+
compose_services: list[str] = Field(default_factory=list)
|
|
16
|
+
compose_project_name: str | None = None
|
|
17
|
+
compose_env: dict[str, str] = Field(default_factory=dict)
|
|
18
|
+
gpu_service_names: list[str] | None = None
|
|
19
|
+
environment_services: list[str] | None = None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def client_config(self):
|
|
23
|
+
"""Override in subclasses to return appropriate client config."""
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
|
|
26
|
+
def get_server(self, auto_stop: bool = True):
|
|
27
|
+
return ComposeServer(config=self, auto_stop=auto_stop)
|
|
28
|
+
|
|
29
|
+
def get_compose_env(self) -> dict | None:
|
|
30
|
+
"""Environment variables for docker compose command substitution."""
|
|
31
|
+
return self.compose_env if self.compose_env else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ComposeServer(BaseServer):
|
|
35
|
+
"""Manages Docker Compose server lifecycle with start/stop methods."""
|
|
36
|
+
|
|
37
|
+
def _create_server_context(self):
|
|
38
|
+
"""Create the Docker Compose server context."""
|
|
39
|
+
return docker_compose_server(config=self.config, cleanup=self.auto_stop)
|